@jackwener/opencli 0.6.1 → 0.6.3

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.
Files changed (46) hide show
  1. package/.github/actions/setup-chrome/action.yml +26 -0
  2. package/.github/workflows/ci.yml +59 -3
  3. package/.github/workflows/e2e-headed.yml +37 -0
  4. package/README.md +20 -1
  5. package/README.zh-CN.md +1 -1
  6. package/TESTING.md +233 -0
  7. package/dist/bilibili.js +2 -2
  8. package/dist/browser.d.ts +1 -1
  9. package/dist/browser.js +12 -3
  10. package/dist/browser.test.js +56 -16
  11. package/dist/cli-manifest.json +39 -0
  12. package/dist/clis/boss/detail.d.ts +1 -0
  13. package/dist/clis/boss/detail.js +104 -0
  14. package/dist/clis/boss/search.js +2 -1
  15. package/dist/interceptor.test.d.ts +4 -0
  16. package/dist/interceptor.test.js +81 -0
  17. package/dist/output.test.d.ts +3 -0
  18. package/dist/output.test.js +60 -0
  19. package/dist/pipeline/executor.js +0 -6
  20. package/dist/pipeline/executor.test.d.ts +4 -0
  21. package/dist/pipeline/executor.test.js +145 -0
  22. package/dist/pipeline/steps/fetch.js +4 -3
  23. package/dist/registry.d.ts +2 -2
  24. package/package.json +4 -4
  25. package/src/bilibili.ts +2 -2
  26. package/src/browser.test.ts +54 -16
  27. package/src/browser.ts +11 -3
  28. package/src/clis/boss/detail.ts +115 -0
  29. package/src/clis/boss/search.ts +2 -1
  30. package/src/clis/twitter/notifications.ts +1 -1
  31. package/src/engine.ts +2 -2
  32. package/src/interceptor.test.ts +94 -0
  33. package/src/output.test.ts +69 -4
  34. package/src/pipeline/executor.test.ts +161 -0
  35. package/src/pipeline/executor.ts +0 -5
  36. package/src/pipeline/steps/fetch.ts +4 -3
  37. package/src/registry.ts +2 -2
  38. package/tests/e2e/browser-auth.test.ts +90 -0
  39. package/tests/e2e/browser-public.test.ts +169 -0
  40. package/tests/e2e/helpers.ts +63 -0
  41. package/tests/e2e/management.test.ts +106 -0
  42. package/tests/e2e/output-formats.test.ts +48 -0
  43. package/tests/e2e/public-commands.test.ts +56 -0
  44. package/tests/smoke/api-health.test.ts +72 -0
  45. package/tsconfig.json +1 -0
  46. package/vitest.config.ts +1 -1
@@ -0,0 +1,104 @@
1
+ /**
2
+ * BOSS直聘 job detail — fetch full job posting details via browser cookie API.
3
+ *
4
+ * Uses securityId from search results to call the detail API.
5
+ * Returns: job description, skills, welfare, boss info, company info, address.
6
+ */
7
+ import { cli, Strategy } from '../../registry.js';
8
+ cli({
9
+ site: 'boss',
10
+ name: 'detail',
11
+ description: 'BOSS直聘查看职位详情',
12
+ domain: 'www.zhipin.com',
13
+ strategy: Strategy.COOKIE,
14
+ browser: true,
15
+ args: [
16
+ { name: 'security_id', required: true, help: 'Security ID from search results (securityId field)' },
17
+ ],
18
+ columns: [
19
+ 'name', 'salary', 'experience', 'degree', 'city', 'district',
20
+ 'description', 'skills', 'welfare',
21
+ 'boss_name', 'boss_title', 'active_time',
22
+ 'company', 'industry', 'scale', 'stage',
23
+ 'address', 'url',
24
+ ],
25
+ func: async (page, kwargs) => {
26
+ if (!page)
27
+ throw new Error('Browser page required');
28
+ const securityId = kwargs.security_id;
29
+ // Navigate to zhipin.com first to establish cookie context (referrer + cookies)
30
+ await page.goto('https://www.zhipin.com/web/geek/job');
31
+ await page.wait({ time: 1 });
32
+ const targetUrl = `https://www.zhipin.com/wapi/zpgeek/job/detail.json?securityId=${encodeURIComponent(securityId)}`;
33
+ if (process.env.OPENCLI_VERBOSE || process.env.DEBUG?.includes('opencli')) {
34
+ console.error(`[opencli:boss] Fetching job detail...`);
35
+ }
36
+ const evaluateScript = `
37
+ async () => {
38
+ return new Promise((resolve, reject) => {
39
+ const xhr = new window.XMLHttpRequest();
40
+ xhr.open('GET', ${JSON.stringify(targetUrl)}, true);
41
+ xhr.withCredentials = true;
42
+ xhr.timeout = 15000;
43
+ xhr.setRequestHeader('Accept', 'application/json, text/plain, */*');
44
+ xhr.onload = () => {
45
+ if (xhr.status >= 200 && xhr.status < 300) {
46
+ try {
47
+ resolve(JSON.parse(xhr.responseText));
48
+ } catch (e) {
49
+ reject(new Error('Failed to parse JSON. Raw (200 chars): ' + xhr.responseText.substring(0, 200)));
50
+ }
51
+ } else {
52
+ reject(new Error('XHR HTTP Status: ' + xhr.status));
53
+ }
54
+ };
55
+ xhr.onerror = () => reject(new Error('XHR Network Error'));
56
+ xhr.ontimeout = () => reject(new Error('XHR Timeout'));
57
+ xhr.send();
58
+ });
59
+ }
60
+ `;
61
+ let data;
62
+ try {
63
+ data = await page.evaluate(evaluateScript);
64
+ }
65
+ catch (e) {
66
+ throw new Error('API evaluate failed: ' + e.message);
67
+ }
68
+ if (data.code !== 0) {
69
+ if (data.code === 37) {
70
+ throw new Error('Cookie 已过期!请在当前 Chrome 浏览器中重新登录 BOSS 直聘。');
71
+ }
72
+ throw new Error(`BOSS API error: ${data.message || 'Unknown'} (code=${data.code})`);
73
+ }
74
+ const zpData = data.zpData || {};
75
+ const jobInfo = zpData.jobInfo || {};
76
+ const bossInfo = zpData.bossInfo || {};
77
+ const brandComInfo = zpData.brandComInfo || {};
78
+ if (!jobInfo.jobName) {
79
+ throw new Error('该职位信息不存在或已下架');
80
+ }
81
+ return [{
82
+ name: jobInfo.jobName || '',
83
+ salary: jobInfo.salaryDesc || '',
84
+ experience: jobInfo.experienceName || '',
85
+ degree: jobInfo.degreeName || '',
86
+ city: jobInfo.locationName || '',
87
+ district: [jobInfo.areaDistrict, jobInfo.businessDistrict].filter(Boolean).join('·'),
88
+ description: jobInfo.postDescription || '',
89
+ skills: (jobInfo.showSkills || []).join(', '),
90
+ welfare: (brandComInfo.labels || []).join(', '),
91
+ boss_name: bossInfo.name || '',
92
+ boss_title: bossInfo.title || '',
93
+ active_time: bossInfo.activeTimeDesc || '',
94
+ company: brandComInfo.brandName || bossInfo.brandName || '',
95
+ industry: brandComInfo.industryName || '',
96
+ scale: brandComInfo.scaleName || '',
97
+ stage: brandComInfo.stageName || '',
98
+ address: jobInfo.address || '',
99
+ url: jobInfo.encryptId
100
+ ? 'https://www.zhipin.com/job_detail/' + jobInfo.encryptId + '.html'
101
+ : '',
102
+ }];
103
+ },
104
+ });
@@ -78,7 +78,7 @@ cli({
78
78
  { name: 'page', type: 'int', default: 1, help: 'Page number' },
79
79
  { name: 'limit', type: 'int', default: 15, help: 'Number of results' },
80
80
  ],
81
- columns: ['name', 'salary', 'company', 'area', 'experience', 'degree', 'skills', 'boss', 'url'],
81
+ columns: ['name', 'salary', 'company', 'area', 'experience', 'degree', 'skills', 'boss', 'security_id', 'url'],
82
82
  func: async (page, kwargs) => {
83
83
  if (!page)
84
84
  throw new Error('Browser page required');
@@ -180,6 +180,7 @@ cli({
180
180
  degree: j.jobDegree,
181
181
  skills: (j.skills || []).join(','),
182
182
  boss: j.bossName + ' · ' + j.bossTitle,
183
+ security_id: j.securityId || '',
183
184
  url: 'https://www.zhipin.com/job_detail/' + j.encryptJobId + '.html',
184
185
  });
185
186
  addedInBatch++;
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Tests for interceptor.ts: JavaScript code generators for XHR/Fetch interception.
3
+ */
4
+ export {};
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Tests for interceptor.ts: JavaScript code generators for XHR/Fetch interception.
3
+ */
4
+ import { describe, it, expect } from 'vitest';
5
+ import { generateInterceptorJs, generateReadInterceptedJs, generateTapInterceptorJs } from './interceptor.js';
6
+ describe('generateInterceptorJs', () => {
7
+ it('generates valid JavaScript function source', () => {
8
+ const js = generateInterceptorJs('"api/search"');
9
+ expect(js).toContain('window.fetch');
10
+ expect(js).toContain('XMLHttpRequest');
11
+ expect(js).toContain('"api/search"');
12
+ // Should be a function expression wrapping
13
+ expect(js.trim()).toMatch(/^\(\)\s*=>/);
14
+ });
15
+ it('uses default array name and patch guard', () => {
16
+ const js = generateInterceptorJs('"test"');
17
+ expect(js).toContain('__opencli_intercepted');
18
+ expect(js).toContain('__opencli_interceptor_patched');
19
+ });
20
+ it('uses custom array name and patch guard', () => {
21
+ const js = generateInterceptorJs('"test"', {
22
+ arrayName: '__my_data',
23
+ patchGuard: '__my_guard',
24
+ });
25
+ expect(js).toContain('__my_data');
26
+ expect(js).toContain('__my_guard');
27
+ expect(js).not.toContain('__opencli_intercepted');
28
+ });
29
+ it('includes fetch clone and json parsing', () => {
30
+ const js = generateInterceptorJs('"api"');
31
+ expect(js).toContain('response.clone()');
32
+ expect(js).toContain('clone.json()');
33
+ });
34
+ it('includes XHR open and send patching', () => {
35
+ const js = generateInterceptorJs('"api"');
36
+ expect(js).toContain('XMLHttpRequest.prototype');
37
+ expect(js).toContain('__origOpen');
38
+ expect(js).toContain('__origSend');
39
+ });
40
+ });
41
+ describe('generateReadInterceptedJs', () => {
42
+ it('generates valid JavaScript to read and clear data', () => {
43
+ const js = generateReadInterceptedJs();
44
+ expect(js).toContain('__opencli_intercepted');
45
+ // Should clear the array after reading
46
+ expect(js).toContain('= []');
47
+ });
48
+ it('uses custom array name', () => {
49
+ const js = generateReadInterceptedJs('__custom_arr');
50
+ expect(js).toContain('__custom_arr');
51
+ expect(js).not.toContain('__opencli_intercepted');
52
+ });
53
+ });
54
+ describe('generateTapInterceptorJs', () => {
55
+ it('returns all required fields', () => {
56
+ const tap = generateTapInterceptorJs('"api/data"');
57
+ expect(tap.setupVar).toBeDefined();
58
+ expect(tap.capturedVar).toBe('captured');
59
+ expect(tap.promiseVar).toBe('capturePromise');
60
+ expect(tap.resolveVar).toBe('captureResolve');
61
+ expect(tap.fetchPatch).toBeDefined();
62
+ expect(tap.xhrPatch).toBeDefined();
63
+ expect(tap.restorePatch).toBeDefined();
64
+ });
65
+ it('contains the capture pattern in setup', () => {
66
+ const tap = generateTapInterceptorJs('"my-pattern"');
67
+ expect(tap.setupVar).toContain('"my-pattern"');
68
+ });
69
+ it('restores original fetch and XHR in restorePatch', () => {
70
+ const tap = generateTapInterceptorJs('"test"');
71
+ expect(tap.restorePatch).toContain('origFetch');
72
+ expect(tap.restorePatch).toContain('origXhrOpen');
73
+ expect(tap.restorePatch).toContain('origXhrSend');
74
+ });
75
+ it('uses first-match capture (only first response)', () => {
76
+ const tap = generateTapInterceptorJs('"test"');
77
+ // Both fetch and xhr patches should check !captured before storing
78
+ expect(tap.fetchPatch).toContain('!captured');
79
+ expect(tap.xhrPatch).toContain('!captured');
80
+ });
81
+ });
@@ -1 +1,4 @@
1
+ /**
2
+ * Tests for output.ts: render function format coverage.
3
+ */
1
4
  export {};
@@ -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,4 @@
1
+ /**
2
+ * Tests for pipeline/executor.ts: pipeline execution with mock page.
3
+ */
4
+ export {};
@@ -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 escapedUrl = finalUrl.replace(/"/g, '\\"');
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("${escapedUrl}", {
41
- method: "${method}", headers: ${headersJs}, credentials: "include"
41
+ const resp = await fetch(${urlJs}, {
42
+ method: ${methodJs}, headers: ${headersJs}, credentials: "include"
42
43
  });
43
44
  return await resp.json();
44
45
  }
@@ -26,7 +26,7 @@ export interface CliCommand {
26
26
  browser?: boolean;
27
27
  args: Arg[];
28
28
  columns?: string[];
29
- func?: (page: IPage | null, kwargs: Record<string, any>, debug?: boolean) => Promise<any>;
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 | null, kwargs: Record<string, any>, debug?: boolean) => Promise<any>;
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.1",
3
+ "version": "0.6.3",
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 || true",
20
- "clean-yaml": "find dist/clis -name '*.yaml' -o -name '*.yml' 2>/dev/null | xargs rm -f",
21
- "copy-yaml": "find src/clis -name '*.yaml' -o -name '*.yml' | while read f; do d=\"dist/${f#src/}\"; mkdir -p \"$(dirname \"$d\")\"; cp \"$f\" \"$d\"; done",
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 escapedUrl = url.replace(/"/g, '\\"');
87
+ const urlJs = JSON.stringify(url);
88
88
  return page.evaluate(`
89
89
  async () => {
90
- const res = await fetch("${escapedUrl}", { credentials: "include" });
90
+ const res = await fetch(${urlJs}, { credentials: "include" });
91
91
  return await res.json();
92
92
  }
93
93
  `);
@@ -49,23 +49,61 @@ describe('browser helpers', () => {
49
49
  expect(__test__.appendLimited('12345', '67890', 8)).toBe('34567890');
50
50
  });
51
51
 
52
- it('builds Playwright MCP args with kebab-case executable path', () => {
53
- expect(__test__.buildMcpArgs({
54
- mcpPath: '/tmp/cli.js',
55
- executablePath: '/mnt/c/Program Files/Google/Chrome/Application/chrome.exe',
56
- })).toEqual([
57
- '/tmp/cli.js',
58
- '--extension',
59
- '--executable-path',
60
- '/mnt/c/Program Files/Google/Chrome/Application/chrome.exe',
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
- expect(__test__.buildMcpArgs({
64
- mcpPath: '/tmp/cli.js',
65
- })).toEqual([
66
- '/tmp/cli.js',
67
- '--extension',
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', amount: number = 500): Promise<void> {
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] Extension token: ${extensionToken ? `configured (fingerprint ${tokenFingerprint})` : 'missing'}`);
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, '--extension'];
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
  }