@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,115 @@
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
+ import type { IPage } from '../../types.js';
9
+
10
+ cli({
11
+ site: 'boss',
12
+ name: 'detail',
13
+ description: 'BOSS直聘查看职位详情',
14
+ domain: 'www.zhipin.com',
15
+ strategy: Strategy.COOKIE,
16
+
17
+ browser: true,
18
+ args: [
19
+ { name: 'security_id', required: true, help: 'Security ID from search results (securityId field)' },
20
+ ],
21
+ columns: [
22
+ 'name', 'salary', 'experience', 'degree', 'city', 'district',
23
+ 'description', 'skills', 'welfare',
24
+ 'boss_name', 'boss_title', 'active_time',
25
+ 'company', 'industry', 'scale', 'stage',
26
+ 'address', 'url',
27
+ ],
28
+ func: async (page: IPage | null, kwargs) => {
29
+ if (!page) throw new Error('Browser page required');
30
+
31
+ const securityId = kwargs.security_id;
32
+
33
+ // Navigate to zhipin.com first to establish cookie context (referrer + cookies)
34
+ await page.goto('https://www.zhipin.com/web/geek/job');
35
+ await page.wait({ time: 1 });
36
+
37
+ const targetUrl = `https://www.zhipin.com/wapi/zpgeek/job/detail.json?securityId=${encodeURIComponent(securityId)}`;
38
+
39
+ if (process.env.OPENCLI_VERBOSE || process.env.DEBUG?.includes('opencli')) {
40
+ console.error(`[opencli:boss] Fetching job detail...`);
41
+ }
42
+
43
+ const evaluateScript = `
44
+ async () => {
45
+ return new Promise((resolve, reject) => {
46
+ const xhr = new window.XMLHttpRequest();
47
+ xhr.open('GET', ${JSON.stringify(targetUrl)}, true);
48
+ xhr.withCredentials = true;
49
+ xhr.timeout = 15000;
50
+ xhr.setRequestHeader('Accept', 'application/json, text/plain, */*');
51
+ xhr.onload = () => {
52
+ if (xhr.status >= 200 && xhr.status < 300) {
53
+ try {
54
+ resolve(JSON.parse(xhr.responseText));
55
+ } catch (e) {
56
+ reject(new Error('Failed to parse JSON. Raw (200 chars): ' + xhr.responseText.substring(0, 200)));
57
+ }
58
+ } else {
59
+ reject(new Error('XHR HTTP Status: ' + xhr.status));
60
+ }
61
+ };
62
+ xhr.onerror = () => reject(new Error('XHR Network Error'));
63
+ xhr.ontimeout = () => reject(new Error('XHR Timeout'));
64
+ xhr.send();
65
+ });
66
+ }
67
+ `;
68
+
69
+ let data: any;
70
+ try {
71
+ data = await page.evaluate(evaluateScript);
72
+ } catch (e: any) {
73
+ throw new Error('API evaluate failed: ' + e.message);
74
+ }
75
+
76
+ if (data.code !== 0) {
77
+ if (data.code === 37) {
78
+ throw new Error('Cookie 已过期!请在当前 Chrome 浏览器中重新登录 BOSS 直聘。');
79
+ }
80
+ throw new Error(`BOSS API error: ${data.message || 'Unknown'} (code=${data.code})`);
81
+ }
82
+
83
+ const zpData = data.zpData || {};
84
+ const jobInfo = zpData.jobInfo || {};
85
+ const bossInfo = zpData.bossInfo || {};
86
+ const brandComInfo = zpData.brandComInfo || {};
87
+
88
+ if (!jobInfo.jobName) {
89
+ throw new Error('该职位信息不存在或已下架');
90
+ }
91
+
92
+ return [{
93
+ name: jobInfo.jobName || '',
94
+ salary: jobInfo.salaryDesc || '',
95
+ experience: jobInfo.experienceName || '',
96
+ degree: jobInfo.degreeName || '',
97
+ city: jobInfo.locationName || '',
98
+ district: [jobInfo.areaDistrict, jobInfo.businessDistrict].filter(Boolean).join('·'),
99
+ description: jobInfo.postDescription || '',
100
+ skills: (jobInfo.showSkills || []).join(', '),
101
+ welfare: (brandComInfo.labels || []).join(', '),
102
+ boss_name: bossInfo.name || '',
103
+ boss_title: bossInfo.title || '',
104
+ active_time: bossInfo.activeTimeDesc || '',
105
+ company: brandComInfo.brandName || bossInfo.brandName || '',
106
+ industry: brandComInfo.industryName || '',
107
+ scale: brandComInfo.scaleName || '',
108
+ stage: brandComInfo.stageName || '',
109
+ address: jobInfo.address || '',
110
+ url: jobInfo.encryptId
111
+ ? 'https://www.zhipin.com/job_detail/' + jobInfo.encryptId + '.html'
112
+ : '',
113
+ }];
114
+ },
115
+ });
@@ -81,7 +81,7 @@ cli({
81
81
  { name: 'page', type: 'int', default: 1, help: 'Page number' },
82
82
  { name: 'limit', type: 'int', default: 15, help: 'Number of results' },
83
83
  ],
84
- columns: ['name', 'salary', 'company', 'area', 'experience', 'degree', 'skills', 'boss', 'url'],
84
+ columns: ['name', 'salary', 'company', 'area', 'experience', 'degree', 'skills', 'boss', 'security_id', 'url'],
85
85
  func: async (page: IPage | null, kwargs) => {
86
86
  if (!page) throw new Error('Browser page required');
87
87
 
@@ -191,6 +191,7 @@ cli({
191
191
  degree: j.jobDegree,
192
192
  skills: (j.skills || []).join(','),
193
193
  boss: j.bossName + ' · ' + j.bossTitle,
194
+ security_id: j.securityId || '',
194
195
  url: 'https://www.zhipin.com/job_detail/' + j.encryptJobId + '.html',
195
196
  });
196
197
  addedInBatch++;
@@ -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, kwargs, debug);
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, kwargs, debug);
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
+ });
@@ -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 YAML output', () => {
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
- render([{ title: 'Hello', rank: 1 }], { fmt: 'yaml' });
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
  });
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Tests for pipeline/executor.ts: pipeline execution with mock page.
3
+ */
4
+
5
+ import { describe, it, expect, vi } from 'vitest';
6
+ import { executePipeline } from './index.js';
7
+ import type { IPage } from '../types.js';
8
+
9
+ /** Create a minimal mock page for testing */
10
+ function createMockPage(overrides: Partial<IPage> = {}): IPage {
11
+ return {
12
+ goto: vi.fn(),
13
+ evaluate: vi.fn().mockResolvedValue(null),
14
+ snapshot: vi.fn().mockResolvedValue(''),
15
+ click: vi.fn(),
16
+ typeText: vi.fn(),
17
+ pressKey: vi.fn(),
18
+ wait: vi.fn(),
19
+ tabs: vi.fn().mockResolvedValue([]),
20
+ closeTab: vi.fn(),
21
+ newTab: vi.fn(),
22
+ selectTab: vi.fn(),
23
+ networkRequests: vi.fn().mockResolvedValue([]),
24
+ consoleMessages: vi.fn().mockResolvedValue(''),
25
+ scroll: vi.fn(),
26
+ autoScroll: vi.fn(),
27
+ installInterceptor: vi.fn(),
28
+ getInterceptedRequests: vi.fn().mockResolvedValue([]),
29
+ ...overrides,
30
+ };
31
+ }
32
+
33
+ describe('executePipeline', () => {
34
+ it('returns null for empty pipeline', async () => {
35
+ const result = await executePipeline(null, []);
36
+ expect(result).toBeNull();
37
+ });
38
+
39
+ it('skips null/invalid steps', async () => {
40
+ const result = await executePipeline(null, [null, undefined, 42] as any);
41
+ expect(result).toBeNull();
42
+ });
43
+
44
+ it('executes navigate step', async () => {
45
+ const page = createMockPage();
46
+ await executePipeline(page, [
47
+ { navigate: 'https://example.com' },
48
+ ]);
49
+ expect(page.goto).toHaveBeenCalledWith('https://example.com');
50
+ });
51
+
52
+ it('executes evaluate + select pipeline', async () => {
53
+ const page = createMockPage({
54
+ evaluate: vi.fn().mockResolvedValue({ data: { list: [{ name: 'a' }, { name: 'b' }] } }),
55
+ });
56
+ const result = await executePipeline(page, [
57
+ { evaluate: '() => ({ data: { list: [{name: "a"}, {name: "b"}] } })' },
58
+ { select: 'data.list' },
59
+ ]);
60
+ expect(result).toEqual([{ name: 'a' }, { name: 'b' }]);
61
+ });
62
+
63
+ it('executes map step to transform items', async () => {
64
+ const page = createMockPage({
65
+ evaluate: vi.fn().mockResolvedValue([
66
+ { title: 'Hello', count: 10 },
67
+ { title: 'World', count: 20 },
68
+ ]),
69
+ });
70
+ const result = await executePipeline(page, [
71
+ { evaluate: 'test' },
72
+ { map: { name: '${{ item.title }}', score: '${{ item.count }}' } },
73
+ ]);
74
+ expect(result).toEqual([
75
+ { name: 'Hello', score: 10 },
76
+ { name: 'World', score: 20 },
77
+ ]);
78
+ });
79
+
80
+ it('executes limit step', async () => {
81
+ const page = createMockPage({
82
+ evaluate: vi.fn().mockResolvedValue([1, 2, 3, 4, 5]),
83
+ });
84
+ const result = await executePipeline(page, [
85
+ { evaluate: 'test' },
86
+ { limit: '3' },
87
+ ]);
88
+ expect(result).toEqual([1, 2, 3]);
89
+ });
90
+
91
+ it('executes sort step', async () => {
92
+ const page = createMockPage({
93
+ evaluate: vi.fn().mockResolvedValue([{ n: 3 }, { n: 1 }, { n: 2 }]),
94
+ });
95
+ const result = await executePipeline(page, [
96
+ { evaluate: 'test' },
97
+ { sort: { by: 'n', order: 'asc' } },
98
+ ]);
99
+ expect(result).toEqual([{ n: 1 }, { n: 2 }, { n: 3 }]);
100
+ });
101
+
102
+ it('executes sort step with desc order', async () => {
103
+ const page = createMockPage({
104
+ evaluate: vi.fn().mockResolvedValue([{ n: 1 }, { n: 3 }, { n: 2 }]),
105
+ });
106
+ const result = await executePipeline(page, [
107
+ { evaluate: 'test' },
108
+ { sort: { by: 'n', order: 'desc' } },
109
+ ]);
110
+ expect(result).toEqual([{ n: 3 }, { n: 2 }, { n: 1 }]);
111
+ });
112
+
113
+ it('executes wait step with number', async () => {
114
+ const page = createMockPage();
115
+ await executePipeline(page, [
116
+ { wait: 2 },
117
+ ]);
118
+ expect(page.wait).toHaveBeenCalledWith(2);
119
+ });
120
+
121
+ it('handles unknown steps gracefully in debug mode', async () => {
122
+ const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
123
+ await executePipeline(null, [
124
+ { unknownStep: 'test' },
125
+ ], { debug: true });
126
+ expect(stderr).toHaveBeenCalledWith(expect.stringContaining('Unknown step'));
127
+ stderr.mockRestore();
128
+ });
129
+
130
+ it('passes args through template rendering', async () => {
131
+ const page = createMockPage({
132
+ evaluate: vi.fn().mockResolvedValue([1, 2, 3, 4, 5]),
133
+ });
134
+ const result = await executePipeline(page, [
135
+ { evaluate: 'test' },
136
+ { limit: '${{ args.count }}' },
137
+ ], { args: { count: 2 } });
138
+ expect(result).toEqual([1, 2]);
139
+ });
140
+
141
+ it('click step calls page.click', async () => {
142
+ const page = createMockPage();
143
+ await executePipeline(page, [
144
+ { click: '@5' },
145
+ ]);
146
+ expect(page.click).toHaveBeenCalledWith('5');
147
+ });
148
+
149
+ it('navigate preserves existing data through pipeline', async () => {
150
+ const page = createMockPage({
151
+ evaluate: vi.fn().mockResolvedValue([{ a: 1 }]),
152
+ });
153
+ const result = await executePipeline(page, [
154
+ { evaluate: 'test' },
155
+ { navigate: 'https://example.com' },
156
+ ]);
157
+ // navigate should preserve existing data
158
+ expect(result).toEqual([{ a: 1 }]);
159
+ expect(page.goto).toHaveBeenCalledWith('https://example.com');
160
+ });
161
+ });
@@ -60,11 +60,6 @@ export async function executePipeline(
60
60
  if (debug) process.stderr.write(` ${chalk.yellow('⚠')} Unknown step: ${op}\n`);
61
61
  }
62
62
 
63
- // Detect error objects returned by steps (e.g. tap store not found)
64
- if (data && typeof data === 'object' && !Array.isArray(data) && data.error) {
65
- process.stderr.write(` ${chalk.yellow('⚠')} ${chalk.yellow(op)}: ${data.error}\n`);
66
- if (data.hint) process.stderr.write(` ${chalk.dim('💡')} ${chalk.dim(data.hint)}\n`);
67
- }
68
63
  if (debug) debugStepResult(op, data);
69
64
  }
70
65
  }
@@ -45,11 +45,12 @@ async function fetchSingle(
45
45
  }
46
46
 
47
47
  const headersJs = JSON.stringify(renderedHeaders);
48
- const escapedUrl = finalUrl.replace(/"/g, '\\"');
48
+ const urlJs = JSON.stringify(finalUrl);
49
+ const methodJs = JSON.stringify(method.toUpperCase());
49
50
  return page.evaluate(`
50
51
  async () => {
51
- const resp = await fetch("${escapedUrl}", {
52
- method: "${method}", headers: ${headersJs}, credentials: "include"
52
+ const resp = await fetch(${urlJs}, {
53
+ method: ${methodJs}, headers: ${headersJs}, credentials: "include"
53
54
  });
54
55
  return await resp.json();
55
56
  }
package/src/registry.ts CHANGED
@@ -30,7 +30,7 @@ export interface CliCommand {
30
30
  browser?: boolean;
31
31
  args: Arg[];
32
32
  columns?: string[];
33
- func?: (page: IPage | null, kwargs: Record<string, any>, debug?: boolean) => Promise<any>;
33
+ func?: (page: IPage, kwargs: Record<string, any>, debug?: boolean) => Promise<any>;
34
34
  pipeline?: any[];
35
35
  timeoutSeconds?: number;
36
36
  source?: string;
@@ -50,7 +50,7 @@ export interface CliOptions {
50
50
  browser?: boolean;
51
51
  args?: Arg[];
52
52
  columns?: string[];
53
- func?: (page: IPage | null, kwargs: Record<string, any>, debug?: boolean) => Promise<any>;
53
+ func?: (page: IPage, kwargs: Record<string, any>, debug?: boolean) => Promise<any>;
54
54
  pipeline?: any[];
55
55
  timeoutSeconds?: number;
56
56
  }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * E2E tests for login-required browser commands.
3
+ * These commands REQUIRE authentication (cookie/session).
4
+ * In CI (headless, no login), they should fail gracefully — NOT crash.
5
+ *
6
+ * These tests verify the error handling path, not the data extraction.
7
+ */
8
+
9
+ import { describe, it, expect } from 'vitest';
10
+ import { runCli } from './helpers.js';
11
+
12
+ /**
13
+ * Verify a login-required command fails gracefully (no crash, no hang).
14
+ * Acceptable outcomes: exit code 1 with error message, OR timeout handled.
15
+ */
16
+ async function expectGracefulAuthFailure(args: string[], label: string) {
17
+ const { stdout, stderr, code } = await runCli(args, { timeout: 60_000 });
18
+ // Should either fail with exit code 1 (error message) or succeed with empty data
19
+ // The key assertion: it should NOT hang forever or crash with unhandled exception
20
+ if (code !== 0) {
21
+ // Verify stderr has a meaningful error, not an unhandled crash
22
+ const output = stderr + stdout;
23
+ expect(output.length).toBeGreaterThan(0);
24
+ }
25
+ // If it somehow succeeds (e.g., partial public data), that's fine too
26
+ }
27
+
28
+ describe('login-required commands — graceful failure', () => {
29
+
30
+ // ── bilibili (requires cookie session) ──
31
+ it('bilibili me fails gracefully without login', async () => {
32
+ await expectGracefulAuthFailure(['bilibili', 'me', '-f', 'json'], 'bilibili me');
33
+ }, 60_000);
34
+
35
+ it('bilibili dynamic fails gracefully without login', async () => {
36
+ await expectGracefulAuthFailure(['bilibili', 'dynamic', '--limit', '3', '-f', 'json'], 'bilibili dynamic');
37
+ }, 60_000);
38
+
39
+ it('bilibili favorite fails gracefully without login', async () => {
40
+ await expectGracefulAuthFailure(['bilibili', 'favorite', '--limit', '3', '-f', 'json'], 'bilibili favorite');
41
+ }, 60_000);
42
+
43
+ it('bilibili history fails gracefully without login', async () => {
44
+ await expectGracefulAuthFailure(['bilibili', 'history', '--limit', '3', '-f', 'json'], 'bilibili history');
45
+ }, 60_000);
46
+
47
+ it('bilibili following fails gracefully without login', async () => {
48
+ await expectGracefulAuthFailure(['bilibili', 'following', '--limit', '3', '-f', 'json'], 'bilibili following');
49
+ }, 60_000);
50
+
51
+ // ── twitter (requires login) ──
52
+ it('twitter bookmarks fails gracefully without login', async () => {
53
+ await expectGracefulAuthFailure(['twitter', 'bookmarks', '--limit', '3', '-f', 'json'], 'twitter bookmarks');
54
+ }, 60_000);
55
+
56
+ it('twitter timeline fails gracefully without login', async () => {
57
+ await expectGracefulAuthFailure(['twitter', 'timeline', '--limit', '3', '-f', 'json'], 'twitter timeline');
58
+ }, 60_000);
59
+
60
+ it('twitter notifications fails gracefully without login', async () => {
61
+ await expectGracefulAuthFailure(['twitter', 'notifications', '--limit', '3', '-f', 'json'], 'twitter notifications');
62
+ }, 60_000);
63
+
64
+ // ── v2ex (requires login) ──
65
+ it('v2ex me fails gracefully without login', async () => {
66
+ await expectGracefulAuthFailure(['v2ex', 'me', '-f', 'json'], 'v2ex me');
67
+ }, 60_000);
68
+
69
+ it('v2ex notifications fails gracefully without login', async () => {
70
+ await expectGracefulAuthFailure(['v2ex', 'notifications', '--limit', '3', '-f', 'json'], 'v2ex notifications');
71
+ }, 60_000);
72
+
73
+ // ── xueqiu (requires login) ──
74
+ it('xueqiu feed fails gracefully without login', async () => {
75
+ await expectGracefulAuthFailure(['xueqiu', 'feed', '--limit', '3', '-f', 'json'], 'xueqiu feed');
76
+ }, 60_000);
77
+
78
+ it('xueqiu watchlist fails gracefully without login', async () => {
79
+ await expectGracefulAuthFailure(['xueqiu', 'watchlist', '-f', 'json'], 'xueqiu watchlist');
80
+ }, 60_000);
81
+
82
+ // ── xiaohongshu (requires login) ──
83
+ it('xiaohongshu feed fails gracefully without login', async () => {
84
+ await expectGracefulAuthFailure(['xiaohongshu', 'feed', '--limit', '3', '-f', 'json'], 'xiaohongshu feed');
85
+ }, 60_000);
86
+
87
+ it('xiaohongshu notifications fails gracefully without login', async () => {
88
+ await expectGracefulAuthFailure(['xiaohongshu', 'notifications', '--limit', '3', '-f', 'json'], 'xiaohongshu notifications');
89
+ }, 60_000);
90
+ });