Files
jackwener__opencli/clis/twitter/post.test.ts
jakevin 4fe9a73ebc refactor: migrate adapter imports to package exports (#795)
* refactor: migrate adapter imports to package exports

Replace all relative imports (../../src/registry.js, ../../browser/cdp.js, etc.)
with package exports (@jackwener/opencli/registry, @jackwener/opencli/errors, etc.)
across all 484 adapter files.

This decouples adapter import resolution from directory structure:
- User CLIs in ~/.opencli/clis/ resolve via node_modules symlink
- Internal adapters resolve via Node.js self-referencing
- No more shim files needed for import resolution

Changes:
- package.json: add sub-path exports for all public modules
- clis/**: replace relative imports with @jackwener/opencli/...
- discovery.ts: simplify ensureUserCliCompatShims to symlink-only
- registry-api.ts: export CommandArgs type
- Remove root-level shim directories (browser/, download/, pipeline/)
- Remove shim entries from tsconfig.json include and package.json files

* test: add regression tests for package exports

Prevents regressions like #788/#791 by:
1. Scanning all adapter files for forbidden relative imports
   (../../src/, ../../browser/, etc.) — fails if any remain
2. Verifying every package.json export maps to an existing source file

18 new test cases.

* fix: use junction on Windows + broaden test patterns

- discovery.ts: use 'junction' symlink type on Windows (no admin required)
- package-exports.test.ts: generalize forbidden patterns to catch any
  depth of ../ traversal (not just ../../ and ../../../)

* fix: update stale vi.mock/importActual paths in adapter tests

Test files still used old relative paths for vi.mock() and
vi.importActual() calls. Updated 5 test files to use package exports.
Also broadened regression test patterns to catch mock/importActual paths.

* fix: use rm instead of unlink for symlink cleanup, add warn on failure

Addresses review feedback from Astro-Han:
- rm() handles both symlinks and stale directories (unlink fails on dirs)
- Log a warning when symlink creation fails instead of silent catch

* docs: update import examples to use package exports

Update all documentation, contributing guides, and skills to use
@jackwener/opencli/registry instead of ../../src/registry.js.

Without this, users following the docs would write adapters with
broken imports since the old shim files are no longer created.
2026-04-05 16:02:17 +08:00

158 lines
5.2 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest';
import { getRegistry } from '@jackwener/opencli/registry';
import './post.js';
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>();
return {
...actual,
statSync: vi.fn((p: string, _opts?: any) => {
if (String(p).includes('missing')) return undefined;
return { isFile: () => true };
}),
};
});
vi.mock('node:path', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:path')>();
return {
...actual,
resolve: vi.fn((p: string) => `/abs/${p}`),
extname: vi.fn((p: string) => {
const m = p.match(/\.[^.]+$/);
return m ? m[0] : '';
}),
};
});
function makePage(overrides: Record<string, any> = {}) {
return {
goto: vi.fn().mockResolvedValue(undefined),
wait: vi.fn().mockResolvedValue(undefined),
evaluate: vi.fn().mockResolvedValue({ ok: true }),
setFileInput: vi.fn().mockResolvedValue(undefined),
...overrides,
};
}
describe('twitter post command', () => {
const getCommand = () => getRegistry().get('twitter/post');
it('posts text-only tweet successfully', async () => {
const command = getCommand();
const page = makePage({
evaluate: vi.fn()
.mockResolvedValueOnce({ ok: true })
.mockResolvedValueOnce({ ok: true, message: 'Tweet posted successfully.' }),
});
const result = await command!.func!(page as any, { text: 'hello world' });
expect(result).toEqual([{ status: 'success', message: 'Tweet posted successfully.', text: 'hello world' }]);
expect(page.goto).toHaveBeenCalledWith('https://x.com/compose/tweet');
});
it('returns failed when text area not found', async () => {
const command = getCommand();
const page = makePage({
evaluate: vi.fn()
.mockResolvedValueOnce({ ok: false, message: 'Could not find the tweet composer text area.' }),
});
const result = await command!.func!(page as any, { text: 'hello' });
expect(result).toEqual([{ status: 'failed', message: 'Could not find the tweet composer text area.', text: 'hello' }]);
});
it('throws when more than 4 images', async () => {
const command = getCommand();
const page = makePage();
await expect(
command!.func!(page as any, { text: 'hi', images: 'a.png,b.png,c.png,d.png,e.png' }),
).rejects.toThrow('Too many images: 5 (max 4)');
});
it('throws when image file does not exist', async () => {
const command = getCommand();
const page = makePage();
await expect(
command!.func!(page as any, { text: 'hi', images: 'missing.png' }),
).rejects.toThrow('Not a valid file');
});
it('throws on unsupported image format', async () => {
const command = getCommand();
const page = makePage();
await expect(
command!.func!(page as any, { text: 'hi', images: 'photo.bmp' }),
).rejects.toThrow('Unsupported image format');
});
it('throws when page.setFileInput is not available', async () => {
const command = getCommand();
const page = makePage({
evaluate: vi.fn().mockResolvedValueOnce({ ok: true }),
setFileInput: undefined,
});
await expect(
command!.func!(page as any, { text: 'hi', images: 'a.png' }),
).rejects.toThrow('Browser extension does not support file upload');
});
it('posts with images when upload completes', async () => {
const command = getCommand();
const page = makePage({
evaluate: vi.fn()
.mockResolvedValueOnce({ ok: true }) // type text
.mockResolvedValueOnce(true) // upload polling returns true
.mockResolvedValueOnce({ ok: true, message: 'Tweet posted successfully.' }), // click post
});
const result = await command!.func!(page as any, { text: 'with images', images: 'a.png,b.png' });
expect(result).toEqual([{ status: 'success', message: 'Tweet posted successfully.', text: 'with images' }]);
expect(page.setFileInput).toHaveBeenCalled();
const uploadScript = page.evaluate.mock.calls[1][0] as string;
expect(uploadScript).toContain('[data-testid="attachments"]');
expect(uploadScript).toContain('[role="group"]');
});
it('returns failed when image upload times out', async () => {
const command = getCommand();
const page = makePage({
evaluate: vi.fn()
.mockResolvedValueOnce({ ok: true })
.mockResolvedValueOnce(false),
});
const result = await command!.func!(page as any, { text: 'timeout', images: 'a.png' });
expect(result).toEqual([{ status: 'failed', message: 'Image upload timed out (30s).', text: 'timeout' }]);
});
it('validates images before navigating to compose page', async () => {
const command = getCommand();
const page = makePage();
await expect(
command!.func!(page as any, { text: 'hi', images: 'missing.png' }),
).rejects.toThrow('Not a valid file');
// Should NOT have navigated since validation happens first
expect(page.goto).not.toHaveBeenCalled();
});
it('throws when no browser session', async () => {
const command = getCommand();
await expect(
command!.func!(null as any, { text: 'hi' }),
).rejects.toThrow('Browser session required for twitter post');
});
});