@jackwener/opencli 0.6.1 → 0.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/actions/setup-chrome/action.yml +26 -0
- package/.github/workflows/ci.yml +59 -3
- package/.github/workflows/e2e-headed.yml +37 -0
- package/README.md +19 -0
- package/TESTING.md +233 -0
- package/dist/bilibili.js +2 -2
- package/dist/browser.d.ts +1 -1
- package/dist/browser.js +12 -3
- package/dist/browser.test.js +56 -16
- package/dist/interceptor.test.d.ts +4 -0
- package/dist/interceptor.test.js +81 -0
- package/dist/output.test.d.ts +3 -0
- package/dist/output.test.js +60 -0
- package/dist/pipeline/executor.js +0 -6
- package/dist/pipeline/executor.test.d.ts +4 -0
- package/dist/pipeline/executor.test.js +145 -0
- package/dist/pipeline/steps/fetch.js +4 -3
- package/dist/registry.d.ts +2 -2
- package/package.json +4 -4
- package/src/bilibili.ts +2 -2
- package/src/browser.test.ts +54 -16
- package/src/browser.ts +11 -3
- package/src/clis/twitter/notifications.ts +1 -1
- package/src/engine.ts +2 -2
- package/src/interceptor.test.ts +94 -0
- package/src/output.test.ts +69 -4
- package/src/pipeline/executor.test.ts +161 -0
- package/src/pipeline/executor.ts +0 -5
- package/src/pipeline/steps/fetch.ts +4 -3
- package/src/registry.ts +2 -2
- package/tests/e2e/browser-auth.test.ts +90 -0
- package/tests/e2e/browser-public.test.ts +169 -0
- package/tests/e2e/helpers.ts +63 -0
- package/tests/e2e/management.test.ts +106 -0
- package/tests/e2e/output-formats.test.ts +48 -0
- package/tests/e2e/public-commands.test.ts +56 -0
- package/tests/smoke/api-health.test.ts +72 -0
- package/tsconfig.json +1 -0
- package/vitest.config.ts +1 -1
package/dist/output.test.js
CHANGED
|
@@ -1,9 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for output.ts: render function format coverage.
|
|
3
|
+
*/
|
|
1
4
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
2
5
|
import { render } from './output.js';
|
|
3
6
|
afterEach(() => {
|
|
4
7
|
vi.restoreAllMocks();
|
|
5
8
|
});
|
|
6
9
|
describe('render', () => {
|
|
10
|
+
it('renders JSON output', () => {
|
|
11
|
+
const log = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
12
|
+
render([{ title: 'Hello', rank: 1 }], { fmt: 'json' });
|
|
13
|
+
expect(log).toHaveBeenCalledOnce();
|
|
14
|
+
const output = log.mock.calls[0]?.[0];
|
|
15
|
+
const parsed = JSON.parse(output);
|
|
16
|
+
expect(parsed).toEqual([{ title: 'Hello', rank: 1 }]);
|
|
17
|
+
});
|
|
18
|
+
it('renders Markdown table output', () => {
|
|
19
|
+
const log = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
20
|
+
render([{ name: 'Alice', score: 100 }], { fmt: 'md', columns: ['name', 'score'] });
|
|
21
|
+
const calls = log.mock.calls.map(c => c[0]);
|
|
22
|
+
expect(calls[0]).toContain('| name | score |');
|
|
23
|
+
expect(calls[1]).toContain('| --- | --- |');
|
|
24
|
+
expect(calls[2]).toContain('| Alice | 100 |');
|
|
25
|
+
});
|
|
26
|
+
it('renders CSV output with proper quoting', () => {
|
|
27
|
+
const log = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
28
|
+
render([{ name: 'Alice, Bob', value: 'say "hi"' }], { fmt: 'csv' });
|
|
29
|
+
const calls = log.mock.calls.map(c => c[0]);
|
|
30
|
+
// Header
|
|
31
|
+
expect(calls[0]).toBe('name,value');
|
|
32
|
+
// Values with commas/quotes are quoted
|
|
33
|
+
expect(calls[1]).toContain('"Alice, Bob"');
|
|
34
|
+
expect(calls[1]).toContain('"say ""hi"""');
|
|
35
|
+
});
|
|
36
|
+
it('handles null and undefined data', () => {
|
|
37
|
+
const log = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
38
|
+
render(null, { fmt: 'json' });
|
|
39
|
+
expect(log).toHaveBeenCalledWith(null);
|
|
40
|
+
});
|
|
41
|
+
it('renders single object as single-row table', () => {
|
|
42
|
+
const log = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
43
|
+
render({ title: 'Test' }, { fmt: 'json' });
|
|
44
|
+
const output = log.mock.calls[0]?.[0];
|
|
45
|
+
const parsed = JSON.parse(output);
|
|
46
|
+
expect(parsed).toEqual({ title: 'Test' });
|
|
47
|
+
});
|
|
48
|
+
it('handles empty array gracefully', () => {
|
|
49
|
+
const log = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
50
|
+
render([], { fmt: 'table' });
|
|
51
|
+
// Should show "(no data)" for empty arrays
|
|
52
|
+
expect(log).toHaveBeenCalled();
|
|
53
|
+
});
|
|
54
|
+
it('uses custom columns for CSV', () => {
|
|
55
|
+
const log = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
56
|
+
render([{ a: 1, b: 2, c: 3 }], { fmt: 'csv', columns: ['a', 'c'] });
|
|
57
|
+
const calls = log.mock.calls.map(c => c[0]);
|
|
58
|
+
expect(calls[0]).toBe('a,c');
|
|
59
|
+
expect(calls[1]).toBe('1,3');
|
|
60
|
+
});
|
|
7
61
|
it('renders YAML output', () => {
|
|
8
62
|
const log = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
9
63
|
render([{ title: 'Hello', rank: 1 }], { fmt: 'yaml' });
|
|
@@ -17,4 +71,10 @@ describe('render', () => {
|
|
|
17
71
|
expect(log).toHaveBeenCalledOnce();
|
|
18
72
|
expect(log.mock.calls[0]?.[0]).toContain('title: Hello');
|
|
19
73
|
});
|
|
74
|
+
it('handles null values in CSV cells', () => {
|
|
75
|
+
const log = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
76
|
+
render([{ name: 'test', value: null }], { fmt: 'csv' });
|
|
77
|
+
const calls = log.mock.calls.map(c => c[0]);
|
|
78
|
+
expect(calls[1]).toBe('test,');
|
|
79
|
+
});
|
|
20
80
|
});
|
|
@@ -45,12 +45,6 @@ export async function executePipeline(page, pipeline, ctx = {}) {
|
|
|
45
45
|
if (debug)
|
|
46
46
|
process.stderr.write(` ${chalk.yellow('⚠')} Unknown step: ${op}\n`);
|
|
47
47
|
}
|
|
48
|
-
// Detect error objects returned by steps (e.g. tap store not found)
|
|
49
|
-
if (data && typeof data === 'object' && !Array.isArray(data) && data.error) {
|
|
50
|
-
process.stderr.write(` ${chalk.yellow('⚠')} ${chalk.yellow(op)}: ${data.error}\n`);
|
|
51
|
-
if (data.hint)
|
|
52
|
-
process.stderr.write(` ${chalk.dim('💡')} ${chalk.dim(data.hint)}\n`);
|
|
53
|
-
}
|
|
54
48
|
if (debug)
|
|
55
49
|
debugStepResult(op, data);
|
|
56
50
|
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for pipeline/executor.ts: pipeline execution with mock page.
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
5
|
+
import { executePipeline } from './index.js';
|
|
6
|
+
/** Create a minimal mock page for testing */
|
|
7
|
+
function createMockPage(overrides = {}) {
|
|
8
|
+
return {
|
|
9
|
+
goto: vi.fn(),
|
|
10
|
+
evaluate: vi.fn().mockResolvedValue(null),
|
|
11
|
+
snapshot: vi.fn().mockResolvedValue(''),
|
|
12
|
+
click: vi.fn(),
|
|
13
|
+
typeText: vi.fn(),
|
|
14
|
+
pressKey: vi.fn(),
|
|
15
|
+
wait: vi.fn(),
|
|
16
|
+
tabs: vi.fn().mockResolvedValue([]),
|
|
17
|
+
closeTab: vi.fn(),
|
|
18
|
+
newTab: vi.fn(),
|
|
19
|
+
selectTab: vi.fn(),
|
|
20
|
+
networkRequests: vi.fn().mockResolvedValue([]),
|
|
21
|
+
consoleMessages: vi.fn().mockResolvedValue(''),
|
|
22
|
+
scroll: vi.fn(),
|
|
23
|
+
autoScroll: vi.fn(),
|
|
24
|
+
installInterceptor: vi.fn(),
|
|
25
|
+
getInterceptedRequests: vi.fn().mockResolvedValue([]),
|
|
26
|
+
...overrides,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
describe('executePipeline', () => {
|
|
30
|
+
it('returns null for empty pipeline', async () => {
|
|
31
|
+
const result = await executePipeline(null, []);
|
|
32
|
+
expect(result).toBeNull();
|
|
33
|
+
});
|
|
34
|
+
it('skips null/invalid steps', async () => {
|
|
35
|
+
const result = await executePipeline(null, [null, undefined, 42]);
|
|
36
|
+
expect(result).toBeNull();
|
|
37
|
+
});
|
|
38
|
+
it('executes navigate step', async () => {
|
|
39
|
+
const page = createMockPage();
|
|
40
|
+
await executePipeline(page, [
|
|
41
|
+
{ navigate: 'https://example.com' },
|
|
42
|
+
]);
|
|
43
|
+
expect(page.goto).toHaveBeenCalledWith('https://example.com');
|
|
44
|
+
});
|
|
45
|
+
it('executes evaluate + select pipeline', async () => {
|
|
46
|
+
const page = createMockPage({
|
|
47
|
+
evaluate: vi.fn().mockResolvedValue({ data: { list: [{ name: 'a' }, { name: 'b' }] } }),
|
|
48
|
+
});
|
|
49
|
+
const result = await executePipeline(page, [
|
|
50
|
+
{ evaluate: '() => ({ data: { list: [{name: "a"}, {name: "b"}] } })' },
|
|
51
|
+
{ select: 'data.list' },
|
|
52
|
+
]);
|
|
53
|
+
expect(result).toEqual([{ name: 'a' }, { name: 'b' }]);
|
|
54
|
+
});
|
|
55
|
+
it('executes map step to transform items', async () => {
|
|
56
|
+
const page = createMockPage({
|
|
57
|
+
evaluate: vi.fn().mockResolvedValue([
|
|
58
|
+
{ title: 'Hello', count: 10 },
|
|
59
|
+
{ title: 'World', count: 20 },
|
|
60
|
+
]),
|
|
61
|
+
});
|
|
62
|
+
const result = await executePipeline(page, [
|
|
63
|
+
{ evaluate: 'test' },
|
|
64
|
+
{ map: { name: '${{ item.title }}', score: '${{ item.count }}' } },
|
|
65
|
+
]);
|
|
66
|
+
expect(result).toEqual([
|
|
67
|
+
{ name: 'Hello', score: 10 },
|
|
68
|
+
{ name: 'World', score: 20 },
|
|
69
|
+
]);
|
|
70
|
+
});
|
|
71
|
+
it('executes limit step', async () => {
|
|
72
|
+
const page = createMockPage({
|
|
73
|
+
evaluate: vi.fn().mockResolvedValue([1, 2, 3, 4, 5]),
|
|
74
|
+
});
|
|
75
|
+
const result = await executePipeline(page, [
|
|
76
|
+
{ evaluate: 'test' },
|
|
77
|
+
{ limit: '3' },
|
|
78
|
+
]);
|
|
79
|
+
expect(result).toEqual([1, 2, 3]);
|
|
80
|
+
});
|
|
81
|
+
it('executes sort step', async () => {
|
|
82
|
+
const page = createMockPage({
|
|
83
|
+
evaluate: vi.fn().mockResolvedValue([{ n: 3 }, { n: 1 }, { n: 2 }]),
|
|
84
|
+
});
|
|
85
|
+
const result = await executePipeline(page, [
|
|
86
|
+
{ evaluate: 'test' },
|
|
87
|
+
{ sort: { by: 'n', order: 'asc' } },
|
|
88
|
+
]);
|
|
89
|
+
expect(result).toEqual([{ n: 1 }, { n: 2 }, { n: 3 }]);
|
|
90
|
+
});
|
|
91
|
+
it('executes sort step with desc order', async () => {
|
|
92
|
+
const page = createMockPage({
|
|
93
|
+
evaluate: vi.fn().mockResolvedValue([{ n: 1 }, { n: 3 }, { n: 2 }]),
|
|
94
|
+
});
|
|
95
|
+
const result = await executePipeline(page, [
|
|
96
|
+
{ evaluate: 'test' },
|
|
97
|
+
{ sort: { by: 'n', order: 'desc' } },
|
|
98
|
+
]);
|
|
99
|
+
expect(result).toEqual([{ n: 3 }, { n: 2 }, { n: 1 }]);
|
|
100
|
+
});
|
|
101
|
+
it('executes wait step with number', async () => {
|
|
102
|
+
const page = createMockPage();
|
|
103
|
+
await executePipeline(page, [
|
|
104
|
+
{ wait: 2 },
|
|
105
|
+
]);
|
|
106
|
+
expect(page.wait).toHaveBeenCalledWith(2);
|
|
107
|
+
});
|
|
108
|
+
it('handles unknown steps gracefully in debug mode', async () => {
|
|
109
|
+
const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
|
110
|
+
await executePipeline(null, [
|
|
111
|
+
{ unknownStep: 'test' },
|
|
112
|
+
], { debug: true });
|
|
113
|
+
expect(stderr).toHaveBeenCalledWith(expect.stringContaining('Unknown step'));
|
|
114
|
+
stderr.mockRestore();
|
|
115
|
+
});
|
|
116
|
+
it('passes args through template rendering', async () => {
|
|
117
|
+
const page = createMockPage({
|
|
118
|
+
evaluate: vi.fn().mockResolvedValue([1, 2, 3, 4, 5]),
|
|
119
|
+
});
|
|
120
|
+
const result = await executePipeline(page, [
|
|
121
|
+
{ evaluate: 'test' },
|
|
122
|
+
{ limit: '${{ args.count }}' },
|
|
123
|
+
], { args: { count: 2 } });
|
|
124
|
+
expect(result).toEqual([1, 2]);
|
|
125
|
+
});
|
|
126
|
+
it('click step calls page.click', async () => {
|
|
127
|
+
const page = createMockPage();
|
|
128
|
+
await executePipeline(page, [
|
|
129
|
+
{ click: '@5' },
|
|
130
|
+
]);
|
|
131
|
+
expect(page.click).toHaveBeenCalledWith('5');
|
|
132
|
+
});
|
|
133
|
+
it('navigate preserves existing data through pipeline', async () => {
|
|
134
|
+
const page = createMockPage({
|
|
135
|
+
evaluate: vi.fn().mockResolvedValue([{ a: 1 }]),
|
|
136
|
+
});
|
|
137
|
+
const result = await executePipeline(page, [
|
|
138
|
+
{ evaluate: 'test' },
|
|
139
|
+
{ navigate: 'https://example.com' },
|
|
140
|
+
]);
|
|
141
|
+
// navigate should preserve existing data
|
|
142
|
+
expect(result).toEqual([{ a: 1 }]);
|
|
143
|
+
expect(page.goto).toHaveBeenCalledWith('https://example.com');
|
|
144
|
+
});
|
|
145
|
+
});
|
|
@@ -34,11 +34,12 @@ async function fetchSingle(page, url, method, queryParams, headers, args, data)
|
|
|
34
34
|
return resp.json();
|
|
35
35
|
}
|
|
36
36
|
const headersJs = JSON.stringify(renderedHeaders);
|
|
37
|
-
const
|
|
37
|
+
const urlJs = JSON.stringify(finalUrl);
|
|
38
|
+
const methodJs = JSON.stringify(method.toUpperCase());
|
|
38
39
|
return page.evaluate(`
|
|
39
40
|
async () => {
|
|
40
|
-
const resp = await fetch(
|
|
41
|
-
method:
|
|
41
|
+
const resp = await fetch(${urlJs}, {
|
|
42
|
+
method: ${methodJs}, headers: ${headersJs}, credentials: "include"
|
|
42
43
|
});
|
|
43
44
|
return await resp.json();
|
|
44
45
|
}
|
package/dist/registry.d.ts
CHANGED
|
@@ -26,7 +26,7 @@ export interface CliCommand {
|
|
|
26
26
|
browser?: boolean;
|
|
27
27
|
args: Arg[];
|
|
28
28
|
columns?: string[];
|
|
29
|
-
func?: (page: IPage
|
|
29
|
+
func?: (page: IPage, kwargs: Record<string, any>, debug?: boolean) => Promise<any>;
|
|
30
30
|
pipeline?: any[];
|
|
31
31
|
timeoutSeconds?: number;
|
|
32
32
|
source?: string;
|
|
@@ -45,7 +45,7 @@ export interface CliOptions {
|
|
|
45
45
|
browser?: boolean;
|
|
46
46
|
args?: Arg[];
|
|
47
47
|
columns?: string[];
|
|
48
|
-
func?: (page: IPage
|
|
48
|
+
func?: (page: IPage, kwargs: Record<string, any>, debug?: boolean) => Promise<any>;
|
|
49
49
|
pipeline?: any[];
|
|
50
50
|
timeoutSeconds?: number;
|
|
51
51
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jackwener/opencli",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.2",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
@@ -16,9 +16,9 @@
|
|
|
16
16
|
"scripts": {
|
|
17
17
|
"dev": "tsx src/main.ts",
|
|
18
18
|
"build": "tsc && npm run clean-yaml && npm run copy-yaml && npm run build-manifest",
|
|
19
|
-
"build-manifest": "node dist/build-manifest.js
|
|
20
|
-
"clean-yaml": "
|
|
21
|
-
"copy-yaml": "
|
|
19
|
+
"build-manifest": "node dist/build-manifest.js",
|
|
20
|
+
"clean-yaml": "node -e \"const{readdirSync:r,rmSync:d,existsSync:e,statSync:s}=require('fs'),p=require('path');function w(dir){if(!e(dir))return;for(const f of r(dir)){const fp=p.join(dir,f);s(fp).isDirectory()?w(fp):/\\.ya?ml$/.test(f)&&d(fp)}}w('dist/clis')\"",
|
|
21
|
+
"copy-yaml": "node -e \"const{readdirSync:r,copyFileSync:c,mkdirSync:m,existsSync:e,statSync:s}=require('fs'),p=require('path');function w(src,dst){if(!e(src))return;for(const f of r(src)){const sp=p.join(src,f),dp=p.join(dst,f);s(sp).isDirectory()?w(sp,dp):/\\.ya?ml$/.test(f)&&(m(p.dirname(dp),{recursive:!0}),c(sp,dp))}}w('src/clis','dist/clis')\"",
|
|
22
22
|
"start": "node dist/main.js",
|
|
23
23
|
"typecheck": "tsc --noEmit",
|
|
24
24
|
"lint": "tsc --noEmit",
|
package/src/bilibili.ts
CHANGED
|
@@ -84,10 +84,10 @@ export async function apiGet(
|
|
|
84
84
|
}
|
|
85
85
|
|
|
86
86
|
export async function fetchJson(page: IPage, url: string): Promise<any> {
|
|
87
|
-
const
|
|
87
|
+
const urlJs = JSON.stringify(url);
|
|
88
88
|
return page.evaluate(`
|
|
89
89
|
async () => {
|
|
90
|
-
const res = await fetch(
|
|
90
|
+
const res = await fetch(${urlJs}, { credentials: "include" });
|
|
91
91
|
return await res.json();
|
|
92
92
|
}
|
|
93
93
|
`);
|
package/src/browser.test.ts
CHANGED
|
@@ -49,23 +49,61 @@ describe('browser helpers', () => {
|
|
|
49
49
|
expect(__test__.appendLimited('12345', '67890', 8)).toBe('34567890');
|
|
50
50
|
});
|
|
51
51
|
|
|
52
|
-
it('builds
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
52
|
+
it('builds extension MCP args in local mode (no CI)', () => {
|
|
53
|
+
const savedCI = process.env.CI;
|
|
54
|
+
delete process.env.CI;
|
|
55
|
+
try {
|
|
56
|
+
expect(__test__.buildMcpArgs({
|
|
57
|
+
mcpPath: '/tmp/cli.js',
|
|
58
|
+
executablePath: '/mnt/c/Program Files/Google/Chrome/Application/chrome.exe',
|
|
59
|
+
})).toEqual([
|
|
60
|
+
'/tmp/cli.js',
|
|
61
|
+
'--extension',
|
|
62
|
+
'--executable-path',
|
|
63
|
+
'/mnt/c/Program Files/Google/Chrome/Application/chrome.exe',
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
expect(__test__.buildMcpArgs({
|
|
67
|
+
mcpPath: '/tmp/cli.js',
|
|
68
|
+
})).toEqual([
|
|
69
|
+
'/tmp/cli.js',
|
|
70
|
+
'--extension',
|
|
71
|
+
]);
|
|
72
|
+
} finally {
|
|
73
|
+
if (savedCI !== undefined) {
|
|
74
|
+
process.env.CI = savedCI;
|
|
75
|
+
} else {
|
|
76
|
+
delete process.env.CI;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
});
|
|
62
80
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
81
|
+
it('builds standalone MCP args in CI mode', () => {
|
|
82
|
+
const savedCI = process.env.CI;
|
|
83
|
+
process.env.CI = 'true';
|
|
84
|
+
try {
|
|
85
|
+
// CI mode: no --extension — browser launches in standalone headed mode
|
|
86
|
+
expect(__test__.buildMcpArgs({
|
|
87
|
+
mcpPath: '/tmp/cli.js',
|
|
88
|
+
})).toEqual([
|
|
89
|
+
'/tmp/cli.js',
|
|
90
|
+
]);
|
|
91
|
+
|
|
92
|
+
expect(__test__.buildMcpArgs({
|
|
93
|
+
mcpPath: '/tmp/cli.js',
|
|
94
|
+
executablePath: '/usr/bin/chromium',
|
|
95
|
+
})).toEqual([
|
|
96
|
+
'/tmp/cli.js',
|
|
97
|
+
'--executable-path',
|
|
98
|
+
'/usr/bin/chromium',
|
|
99
|
+
]);
|
|
100
|
+
} finally {
|
|
101
|
+
if (savedCI !== undefined) {
|
|
102
|
+
process.env.CI = savedCI;
|
|
103
|
+
} else {
|
|
104
|
+
delete process.env.CI;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
69
107
|
});
|
|
70
108
|
|
|
71
109
|
it('times out slow promises', async () => {
|
package/src/browser.ts
CHANGED
|
@@ -214,7 +214,7 @@ export class Page implements IPage {
|
|
|
214
214
|
return this.call('tools/call', { name: 'browser_console_messages', arguments: { level } });
|
|
215
215
|
}
|
|
216
216
|
|
|
217
|
-
async scroll(direction: string = 'down',
|
|
217
|
+
async scroll(direction: string = 'down', _amount: number = 500): Promise<void> {
|
|
218
218
|
await this.call('tools/call', { name: 'browser_press_key', arguments: { key: direction === 'down' ? 'PageDown' : 'PageUp' } });
|
|
219
219
|
}
|
|
220
220
|
|
|
@@ -349,6 +349,7 @@ export class PlaywrightMCP {
|
|
|
349
349
|
return new Promise<Page>((resolve, reject) => {
|
|
350
350
|
const isDebug = process.env.DEBUG?.includes('opencli:mcp');
|
|
351
351
|
const debugLog = (msg: string) => isDebug && console.error(`[opencli:mcp] ${msg}`);
|
|
352
|
+
const useExtension = !!process.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN;
|
|
352
353
|
const extensionToken = process.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN;
|
|
353
354
|
const tokenFingerprint = getTokenFingerprint(extensionToken);
|
|
354
355
|
let stderrBuffer = '';
|
|
@@ -392,7 +393,8 @@ export class PlaywrightMCP {
|
|
|
392
393
|
executablePath: process.env.OPENCLI_BROWSER_EXECUTABLE_PATH,
|
|
393
394
|
});
|
|
394
395
|
if (process.env.OPENCLI_VERBOSE) {
|
|
395
|
-
console.error(`[opencli]
|
|
396
|
+
console.error(`[opencli] Mode: ${useExtension ? 'extension' : 'standalone'}`);
|
|
397
|
+
if (useExtension) console.error(`[opencli] Extension token: fingerprint ${tokenFingerprint}`);
|
|
396
398
|
}
|
|
397
399
|
debugLog(`Spawning node ${mcpArgs.join(' ')}`);
|
|
398
400
|
|
|
@@ -610,7 +612,13 @@ function appendLimited(current: string, chunk: string, limit: number): string {
|
|
|
610
612
|
}
|
|
611
613
|
|
|
612
614
|
function buildMcpArgs(input: { mcpPath: string; executablePath?: string | null }): string[] {
|
|
613
|
-
const args = [input.mcpPath
|
|
615
|
+
const args = [input.mcpPath];
|
|
616
|
+
if (!process.env.CI) {
|
|
617
|
+
// Local: always connect to user's running Chrome via MCP Bridge extension
|
|
618
|
+
args.push('--extension');
|
|
619
|
+
}
|
|
620
|
+
// CI: standalone mode — @playwright/mcp launches its own browser (headed by default).
|
|
621
|
+
// xvfb provides a virtual display for headed mode in GitHub Actions.
|
|
614
622
|
if (input.executablePath) {
|
|
615
623
|
args.push('--executable-path', input.executablePath);
|
|
616
624
|
}
|
|
@@ -30,7 +30,7 @@ cli({
|
|
|
30
30
|
let results: any[] = [];
|
|
31
31
|
for (const req of requests) {
|
|
32
32
|
try {
|
|
33
|
-
let instructions = [];
|
|
33
|
+
let instructions: any[] = [];
|
|
34
34
|
if (req.data?.data?.viewer?.timeline_response?.timeline?.instructions) {
|
|
35
35
|
instructions = req.data.data.viewer.timeline_response.timeline.instructions;
|
|
36
36
|
} else if (req.data?.data?.viewer_v2?.user_results?.result?.notification_timeline?.timeline?.instructions) {
|
package/src/engine.ts
CHANGED
|
@@ -185,7 +185,7 @@ export async function executeCommand(
|
|
|
185
185
|
const { getRegistry, fullName } = await import('./registry.js');
|
|
186
186
|
const updated = getRegistry().get(fullName(cmd));
|
|
187
187
|
if (updated && updated.func) {
|
|
188
|
-
return updated.func(page
|
|
188
|
+
return updated.func(page!, kwargs, debug);
|
|
189
189
|
}
|
|
190
190
|
if (updated && updated.pipeline) {
|
|
191
191
|
return executePipeline(page, updated.pipeline, { args: kwargs, debug });
|
|
@@ -193,7 +193,7 @@ export async function executeCommand(
|
|
|
193
193
|
}
|
|
194
194
|
|
|
195
195
|
if (cmd.func) {
|
|
196
|
-
return cmd.func(page
|
|
196
|
+
return cmd.func(page!, kwargs, debug);
|
|
197
197
|
}
|
|
198
198
|
if (cmd.pipeline) {
|
|
199
199
|
return executePipeline(page, cmd.pipeline, { args: kwargs, debug });
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for interceptor.ts: JavaScript code generators for XHR/Fetch interception.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect } from 'vitest';
|
|
6
|
+
import { generateInterceptorJs, generateReadInterceptedJs, generateTapInterceptorJs } from './interceptor.js';
|
|
7
|
+
|
|
8
|
+
describe('generateInterceptorJs', () => {
|
|
9
|
+
it('generates valid JavaScript function source', () => {
|
|
10
|
+
const js = generateInterceptorJs('"api/search"');
|
|
11
|
+
expect(js).toContain('window.fetch');
|
|
12
|
+
expect(js).toContain('XMLHttpRequest');
|
|
13
|
+
expect(js).toContain('"api/search"');
|
|
14
|
+
// Should be a function expression wrapping
|
|
15
|
+
expect(js.trim()).toMatch(/^\(\)\s*=>/);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('uses default array name and patch guard', () => {
|
|
19
|
+
const js = generateInterceptorJs('"test"');
|
|
20
|
+
expect(js).toContain('__opencli_intercepted');
|
|
21
|
+
expect(js).toContain('__opencli_interceptor_patched');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('uses custom array name and patch guard', () => {
|
|
25
|
+
const js = generateInterceptorJs('"test"', {
|
|
26
|
+
arrayName: '__my_data',
|
|
27
|
+
patchGuard: '__my_guard',
|
|
28
|
+
});
|
|
29
|
+
expect(js).toContain('__my_data');
|
|
30
|
+
expect(js).toContain('__my_guard');
|
|
31
|
+
expect(js).not.toContain('__opencli_intercepted');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('includes fetch clone and json parsing', () => {
|
|
35
|
+
const js = generateInterceptorJs('"api"');
|
|
36
|
+
expect(js).toContain('response.clone()');
|
|
37
|
+
expect(js).toContain('clone.json()');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('includes XHR open and send patching', () => {
|
|
41
|
+
const js = generateInterceptorJs('"api"');
|
|
42
|
+
expect(js).toContain('XMLHttpRequest.prototype');
|
|
43
|
+
expect(js).toContain('__origOpen');
|
|
44
|
+
expect(js).toContain('__origSend');
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('generateReadInterceptedJs', () => {
|
|
49
|
+
it('generates valid JavaScript to read and clear data', () => {
|
|
50
|
+
const js = generateReadInterceptedJs();
|
|
51
|
+
expect(js).toContain('__opencli_intercepted');
|
|
52
|
+
// Should clear the array after reading
|
|
53
|
+
expect(js).toContain('= []');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('uses custom array name', () => {
|
|
57
|
+
const js = generateReadInterceptedJs('__custom_arr');
|
|
58
|
+
expect(js).toContain('__custom_arr');
|
|
59
|
+
expect(js).not.toContain('__opencli_intercepted');
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('generateTapInterceptorJs', () => {
|
|
64
|
+
it('returns all required fields', () => {
|
|
65
|
+
const tap = generateTapInterceptorJs('"api/data"');
|
|
66
|
+
|
|
67
|
+
expect(tap.setupVar).toBeDefined();
|
|
68
|
+
expect(tap.capturedVar).toBe('captured');
|
|
69
|
+
expect(tap.promiseVar).toBe('capturePromise');
|
|
70
|
+
expect(tap.resolveVar).toBe('captureResolve');
|
|
71
|
+
expect(tap.fetchPatch).toBeDefined();
|
|
72
|
+
expect(tap.xhrPatch).toBeDefined();
|
|
73
|
+
expect(tap.restorePatch).toBeDefined();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('contains the capture pattern in setup', () => {
|
|
77
|
+
const tap = generateTapInterceptorJs('"my-pattern"');
|
|
78
|
+
expect(tap.setupVar).toContain('"my-pattern"');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('restores original fetch and XHR in restorePatch', () => {
|
|
82
|
+
const tap = generateTapInterceptorJs('"test"');
|
|
83
|
+
expect(tap.restorePatch).toContain('origFetch');
|
|
84
|
+
expect(tap.restorePatch).toContain('origXhrOpen');
|
|
85
|
+
expect(tap.restorePatch).toContain('origXhrSend');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('uses first-match capture (only first response)', () => {
|
|
89
|
+
const tap = generateTapInterceptorJs('"test"');
|
|
90
|
+
// Both fetch and xhr patches should check !captured before storing
|
|
91
|
+
expect(tap.fetchPatch).toContain('!captured');
|
|
92
|
+
expect(tap.xhrPatch).toContain('!captured');
|
|
93
|
+
});
|
|
94
|
+
});
|
package/src/output.test.ts
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for output.ts: render function format coverage.
|
|
3
|
+
*/
|
|
4
|
+
|
|
1
5
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
2
6
|
import { render } from './output.js';
|
|
3
7
|
|
|
@@ -6,11 +10,67 @@ afterEach(() => {
|
|
|
6
10
|
});
|
|
7
11
|
|
|
8
12
|
describe('render', () => {
|
|
9
|
-
it('renders
|
|
13
|
+
it('renders JSON output', () => {
|
|
14
|
+
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
15
|
+
render([{ title: 'Hello', rank: 1 }], { fmt: 'json' });
|
|
16
|
+
expect(log).toHaveBeenCalledOnce();
|
|
17
|
+
const output = log.mock.calls[0]?.[0];
|
|
18
|
+
const parsed = JSON.parse(output);
|
|
19
|
+
expect(parsed).toEqual([{ title: 'Hello', rank: 1 }]);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('renders Markdown table output', () => {
|
|
10
23
|
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
24
|
+
render([{ name: 'Alice', score: 100 }], { fmt: 'md', columns: ['name', 'score'] });
|
|
25
|
+
const calls = log.mock.calls.map(c => c[0]);
|
|
26
|
+
expect(calls[0]).toContain('| name | score |');
|
|
27
|
+
expect(calls[1]).toContain('| --- | --- |');
|
|
28
|
+
expect(calls[2]).toContain('| Alice | 100 |');
|
|
29
|
+
});
|
|
11
30
|
|
|
12
|
-
|
|
31
|
+
it('renders CSV output with proper quoting', () => {
|
|
32
|
+
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
33
|
+
render([{ name: 'Alice, Bob', value: 'say "hi"' }], { fmt: 'csv' });
|
|
34
|
+
const calls = log.mock.calls.map(c => c[0]);
|
|
35
|
+
// Header
|
|
36
|
+
expect(calls[0]).toBe('name,value');
|
|
37
|
+
// Values with commas/quotes are quoted
|
|
38
|
+
expect(calls[1]).toContain('"Alice, Bob"');
|
|
39
|
+
expect(calls[1]).toContain('"say ""hi"""');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('handles null and undefined data', () => {
|
|
43
|
+
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
44
|
+
render(null, { fmt: 'json' });
|
|
45
|
+
expect(log).toHaveBeenCalledWith(null);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('renders single object as single-row table', () => {
|
|
49
|
+
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
50
|
+
render({ title: 'Test' }, { fmt: 'json' });
|
|
51
|
+
const output = log.mock.calls[0]?.[0];
|
|
52
|
+
const parsed = JSON.parse(output);
|
|
53
|
+
expect(parsed).toEqual({ title: 'Test' });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('handles empty array gracefully', () => {
|
|
57
|
+
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
58
|
+
render([], { fmt: 'table' });
|
|
59
|
+
// Should show "(no data)" for empty arrays
|
|
60
|
+
expect(log).toHaveBeenCalled();
|
|
61
|
+
});
|
|
13
62
|
|
|
63
|
+
it('uses custom columns for CSV', () => {
|
|
64
|
+
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
65
|
+
render([{ a: 1, b: 2, c: 3 }], { fmt: 'csv', columns: ['a', 'c'] });
|
|
66
|
+
const calls = log.mock.calls.map(c => c[0]);
|
|
67
|
+
expect(calls[0]).toBe('a,c');
|
|
68
|
+
expect(calls[1]).toBe('1,3');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('renders YAML output', () => {
|
|
72
|
+
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
73
|
+
render([{ title: 'Hello', rank: 1 }], { fmt: 'yaml' });
|
|
14
74
|
expect(log).toHaveBeenCalledOnce();
|
|
15
75
|
expect(log.mock.calls[0]?.[0]).toContain('- title: Hello');
|
|
16
76
|
expect(log.mock.calls[0]?.[0]).toContain('rank: 1');
|
|
@@ -18,10 +78,15 @@ describe('render', () => {
|
|
|
18
78
|
|
|
19
79
|
it('renders yml alias as YAML output', () => {
|
|
20
80
|
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
21
|
-
|
|
22
81
|
render({ title: 'Hello' }, { fmt: 'yml' });
|
|
23
|
-
|
|
24
82
|
expect(log).toHaveBeenCalledOnce();
|
|
25
83
|
expect(log.mock.calls[0]?.[0]).toContain('title: Hello');
|
|
26
84
|
});
|
|
85
|
+
|
|
86
|
+
it('handles null values in CSV cells', () => {
|
|
87
|
+
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
88
|
+
render([{ name: 'test', value: null }], { fmt: 'csv' });
|
|
89
|
+
const calls = log.mock.calls.map(c => c[0]);
|
|
90
|
+
expect(calls[1]).toBe('test,');
|
|
91
|
+
});
|
|
27
92
|
});
|