@jackwener/opencli 1.7.5 → 1.7.7

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 (121) hide show
  1. package/README.md +22 -10
  2. package/README.zh-CN.md +18 -9
  3. package/cli-manifest.json +401 -11
  4. package/clis/51job/company.js +125 -0
  5. package/clis/51job/detail.js +108 -0
  6. package/clis/51job/hot.js +55 -0
  7. package/clis/51job/search.js +79 -0
  8. package/clis/51job/utils.js +302 -0
  9. package/clis/51job/utils.test.js +69 -0
  10. package/clis/bilibili/video.js +68 -0
  11. package/clis/bilibili/video.test.js +132 -0
  12. package/clis/chatgpt/image.js +1 -1
  13. package/clis/deepseek/ask.js +37 -11
  14. package/clis/deepseek/ask.test.js +165 -0
  15. package/clis/deepseek/utils.js +192 -24
  16. package/clis/deepseek/utils.test.js +145 -0
  17. package/clis/gemini/image.js +1 -1
  18. package/clis/instagram/download.js +1 -1
  19. package/clis/jianyu/search.js +139 -3
  20. package/clis/jianyu/search.test.js +25 -0
  21. package/clis/jianyu/shared/procurement-detail.js +15 -0
  22. package/clis/jianyu/shared/procurement-detail.test.js +12 -0
  23. package/clis/twitter/likes.js +3 -2
  24. package/clis/twitter/search.js +4 -2
  25. package/clis/twitter/search.test.js +4 -0
  26. package/clis/twitter/shared.js +35 -2
  27. package/clis/twitter/shared.test.js +96 -0
  28. package/clis/twitter/thread.js +3 -1
  29. package/clis/twitter/timeline.js +3 -2
  30. package/clis/twitter/tweets.js +219 -0
  31. package/clis/twitter/tweets.test.js +125 -0
  32. package/clis/web/read.js +25 -5
  33. package/clis/web/read.test.js +76 -0
  34. package/clis/weread/ai-outline.js +170 -0
  35. package/clis/weread/ai-outline.test.js +83 -0
  36. package/clis/weread/book.js +57 -44
  37. package/clis/weread/commands.test.js +24 -0
  38. package/clis/xiaoyuzhou/podcast-episodes.js +2 -2
  39. package/clis/xiaoyuzhou/podcast-episodes.test.js +78 -0
  40. package/clis/youtube/channel.js +35 -0
  41. package/dist/src/browser/analyze.d.ts +103 -0
  42. package/dist/src/browser/analyze.js +230 -0
  43. package/dist/src/browser/analyze.test.d.ts +1 -0
  44. package/dist/src/browser/analyze.test.js +164 -0
  45. package/dist/src/browser/article-extract.d.ts +57 -0
  46. package/dist/src/browser/article-extract.e2e.test.d.ts +1 -0
  47. package/dist/src/browser/article-extract.e2e.test.js +105 -0
  48. package/dist/src/browser/article-extract.js +169 -0
  49. package/dist/src/browser/article-extract.test.d.ts +1 -0
  50. package/dist/src/browser/article-extract.test.js +94 -0
  51. package/dist/src/browser/base-page.d.ts +13 -3
  52. package/dist/src/browser/base-page.js +35 -25
  53. package/dist/src/browser/cdp.d.ts +1 -0
  54. package/dist/src/browser/cdp.js +23 -5
  55. package/dist/src/browser/compound.d.ts +59 -0
  56. package/dist/src/browser/compound.js +112 -0
  57. package/dist/src/browser/compound.test.d.ts +1 -0
  58. package/dist/src/browser/compound.test.js +175 -0
  59. package/dist/src/browser/dom-snapshot.d.ts +7 -0
  60. package/dist/src/browser/dom-snapshot.js +76 -3
  61. package/dist/src/browser/dom-snapshot.test.js +65 -0
  62. package/dist/src/browser/extract.d.ts +69 -0
  63. package/dist/src/browser/extract.js +132 -0
  64. package/dist/src/browser/extract.test.d.ts +1 -0
  65. package/dist/src/browser/extract.test.js +129 -0
  66. package/dist/src/browser/find.d.ts +76 -0
  67. package/dist/src/browser/find.js +179 -0
  68. package/dist/src/browser/find.test.d.ts +1 -0
  69. package/dist/src/browser/find.test.js +120 -0
  70. package/dist/src/browser/html-tree.d.ts +75 -0
  71. package/dist/src/browser/html-tree.js +112 -0
  72. package/dist/src/browser/html-tree.test.d.ts +1 -0
  73. package/dist/src/browser/html-tree.test.js +181 -0
  74. package/dist/src/browser/network-cache.d.ts +48 -0
  75. package/dist/src/browser/network-cache.js +66 -0
  76. package/dist/src/browser/network-cache.test.d.ts +1 -0
  77. package/dist/src/browser/network-cache.test.js +58 -0
  78. package/dist/src/browser/network-key.d.ts +22 -0
  79. package/dist/src/browser/network-key.js +66 -0
  80. package/dist/src/browser/network-key.test.d.ts +1 -0
  81. package/dist/src/browser/network-key.test.js +49 -0
  82. package/dist/src/browser/shape-filter.d.ts +52 -0
  83. package/dist/src/browser/shape-filter.js +101 -0
  84. package/dist/src/browser/shape-filter.test.d.ts +1 -0
  85. package/dist/src/browser/shape-filter.test.js +101 -0
  86. package/dist/src/browser/shape.d.ts +23 -0
  87. package/dist/src/browser/shape.js +95 -0
  88. package/dist/src/browser/shape.test.d.ts +1 -0
  89. package/dist/src/browser/shape.test.js +82 -0
  90. package/dist/src/browser/target-errors.d.ts +14 -1
  91. package/dist/src/browser/target-errors.js +13 -0
  92. package/dist/src/browser/target-errors.test.js +39 -6
  93. package/dist/src/browser/target-resolver.d.ts +57 -10
  94. package/dist/src/browser/target-resolver.js +195 -75
  95. package/dist/src/browser/target-resolver.test.js +80 -5
  96. package/dist/src/browser/verify-fixture.d.ts +59 -0
  97. package/dist/src/browser/verify-fixture.js +213 -0
  98. package/dist/src/browser/verify-fixture.test.d.ts +1 -0
  99. package/dist/src/browser/verify-fixture.test.js +161 -0
  100. package/dist/src/cli.d.ts +32 -0
  101. package/dist/src/cli.js +936 -141
  102. package/dist/src/cli.test.js +1051 -1
  103. package/dist/src/daemon.d.ts +3 -2
  104. package/dist/src/daemon.js +16 -4
  105. package/dist/src/daemon.test.d.ts +1 -0
  106. package/dist/src/daemon.test.js +19 -0
  107. package/dist/src/download/article-download.d.ts +12 -0
  108. package/dist/src/download/article-download.js +141 -17
  109. package/dist/src/download/article-download.test.js +196 -0
  110. package/dist/src/download/index.js +73 -86
  111. package/dist/src/errors.js +4 -2
  112. package/dist/src/errors.test.js +13 -0
  113. package/dist/src/execution.js +7 -2
  114. package/dist/src/execution.test.js +54 -0
  115. package/dist/src/launcher.d.ts +1 -1
  116. package/dist/src/launcher.js +3 -3
  117. package/dist/src/main.js +16 -0
  118. package/dist/src/output.js +1 -1
  119. package/dist/src/output.test.js +6 -0
  120. package/dist/src/types.d.ts +18 -3
  121. package/package.json +5 -1
@@ -0,0 +1,68 @@
1
+ import { cli, Strategy } from '@jackwener/opencli/registry';
2
+ import { CommandExecutionError } from '@jackwener/opencli/errors';
3
+ import { apiGet, resolveBvid } from './utils.js';
4
+
5
+ cli({
6
+ site: 'bilibili',
7
+ name: 'video',
8
+ description: 'Get Bilibili video metadata (title, author, duration, stats, etc.)',
9
+ strategy: Strategy.COOKIE,
10
+ args: [
11
+ { name: 'bvid', required: true, positional: true, help: 'BV ID, video URL, or b23.tv short link' },
12
+ ],
13
+ columns: ['field', 'value'],
14
+ func: async (page, kwargs) => {
15
+ if (!page) {
16
+ throw new CommandExecutionError('Browser session required for bilibili video');
17
+ }
18
+
19
+ // Resolve BV ID from three advertised input forms:
20
+ // 1. Bare "BV..." id
21
+ // 2. Full bilibili.com/video/<BV>... URL (with or without query string / www / m.)
22
+ // 3. b23.tv short link (delegated to resolveBvid)
23
+ // resolveBvid() alone handles (1) and (3) but not (2), so we pre-extract
24
+ // from bilibili URLs before falling through.
25
+ const input = String(kwargs.bvid ?? '').trim();
26
+ const bilibiliUrlMatch = input.match(/bilibili\.com\/(?:video|bangumi\/play)\/(BV[A-Za-z0-9]+)/i);
27
+ const bvid = bilibiliUrlMatch ? bilibiliUrlMatch[1] : await resolveBvid(input);
28
+
29
+ // Navigate to video page first so subsequent api call shares a primed session.
30
+ await page.goto(`https://www.bilibili.com/video/${bvid}/`);
31
+
32
+ const payload = await apiGet(page, '/x/web-interface/view', {
33
+ params: { bvid },
34
+ });
35
+ if (payload.code !== 0) {
36
+ throw new CommandExecutionError(`Bilibili view API failed: ${payload.message} (${payload.code})`);
37
+ }
38
+
39
+ const d = payload.data || {};
40
+ const stat = d.stat || {};
41
+ const owner = d.owner || {};
42
+
43
+ const pubDate = d.pubdate ? new Date(d.pubdate * 1000).toISOString().slice(0, 16).replace('T', ' ') : '';
44
+ const dur = d.duration || 0;
45
+ const mm = Math.floor(dur / 60);
46
+ const ss = dur % 60;
47
+
48
+ return [
49
+ { field: 'bvid', value: d.bvid ?? '' },
50
+ { field: 'aid', value: String(d.aid ?? '') },
51
+ { field: 'title', value: d.title ?? '' },
52
+ { field: 'author', value: owner.name ? `${owner.name} (mid: ${owner.mid})` : '' },
53
+ { field: 'category', value: d.tname_v2 || d.tname || '' },
54
+ { field: 'publish_time', value: pubDate },
55
+ { field: 'duration', value: dur ? `${mm}m${ss}s (${dur}s)` : '' },
56
+ { field: 'view', value: String(stat.view ?? '') },
57
+ { field: 'danmaku', value: String(stat.danmaku ?? '') },
58
+ { field: 'reply', value: String(stat.reply ?? '') },
59
+ { field: 'like', value: String(stat.like ?? '') },
60
+ { field: 'coin', value: String(stat.coin ?? '') },
61
+ { field: 'favorite', value: String(stat.favorite ?? '') },
62
+ { field: 'share', value: String(stat.share ?? '') },
63
+ { field: 'parts', value: String(d.videos ?? 1) },
64
+ { field: 'thumbnail', value: d.pic ?? '' },
65
+ { field: 'description', value: d.desc ?? '' },
66
+ ];
67
+ },
68
+ });
@@ -0,0 +1,132 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { CommandExecutionError } from '@jackwener/opencli/errors';
3
+
4
+ const { mockApiGet } = vi.hoisted(() => ({
5
+ mockApiGet: vi.fn(),
6
+ }));
7
+
8
+ vi.mock('./utils.js', async (importOriginal) => ({
9
+ ...(await importOriginal()),
10
+ apiGet: mockApiGet,
11
+ }));
12
+
13
+ import { getRegistry } from '@jackwener/opencli/registry';
14
+ import './video.js';
15
+
16
+ describe('bilibili video', () => {
17
+ const command = getRegistry().get('bilibili/video');
18
+ const page = {
19
+ goto: vi.fn().mockResolvedValue(undefined),
20
+ evaluate: vi.fn(),
21
+ };
22
+
23
+ beforeEach(() => {
24
+ mockApiGet.mockReset();
25
+ page.goto.mockClear();
26
+ page.evaluate.mockReset();
27
+ });
28
+
29
+ it('returns a field/value table of video metadata on success', async () => {
30
+ mockApiGet.mockResolvedValueOnce({
31
+ code: 0,
32
+ data: {
33
+ bvid: 'BV1xx411c7mD',
34
+ aid: 12345678,
35
+ title: '三层结构笔记法',
36
+ tname: '教程',
37
+ pubdate: 1775053078, // 2026-04-01 14:17:58 UTC
38
+ duration: 434,
39
+ videos: 1,
40
+ pic: 'https://i1.hdslb.com/some.jpg',
41
+ desc: 'Obsidian 教程',
42
+ owner: { mid: 507578555, name: 'IOI科技' },
43
+ stat: { view: 6128, danmaku: 0, reply: 21, like: 162, coin: 48, favorite: 564, share: 26 },
44
+ },
45
+ });
46
+
47
+ const rows = await command.func(page, { bvid: 'BV1xx411c7mD' });
48
+
49
+ // Every row has { field, value }
50
+ expect(Array.isArray(rows)).toBe(true);
51
+ for (const row of rows) {
52
+ expect(row).toHaveProperty('field');
53
+ expect(row).toHaveProperty('value');
54
+ }
55
+
56
+ const byField = Object.fromEntries(rows.map((r) => [r.field, r.value]));
57
+ expect(byField.bvid).toBe('BV1xx411c7mD');
58
+ expect(byField.title).toBe('三层结构笔记法');
59
+ expect(byField.author).toBe('IOI科技 (mid: 507578555)');
60
+ expect(byField.duration).toBe('7m14s (434s)');
61
+ expect(byField.view).toBe('6128');
62
+ expect(byField.like).toBe('162');
63
+
64
+ // Navigation primes the session
65
+ expect(page.goto).toHaveBeenCalledWith('https://www.bilibili.com/video/BV1xx411c7mD/');
66
+ // API called without signing
67
+ expect(mockApiGet).toHaveBeenCalledWith(page, '/x/web-interface/view', { params: { bvid: 'BV1xx411c7mD' } });
68
+ });
69
+
70
+ it('throws CommandExecutionError when bilibili view API returns non-zero code', async () => {
71
+ mockApiGet.mockResolvedValueOnce({
72
+ code: -404,
73
+ message: '啥都木有',
74
+ data: null,
75
+ });
76
+
77
+ await expect(command.func(page, { bvid: 'BV1xx411c7mD' })).rejects.toSatisfy(
78
+ (err) => err instanceof CommandExecutionError && /啥都木有|-404/.test(err.message),
79
+ );
80
+ });
81
+
82
+ it('extracts BV ID from full bilibili.com URL input', async () => {
83
+ mockApiGet.mockResolvedValueOnce({
84
+ code: 0,
85
+ data: { bvid: 'BV1xx411c7mD', stat: {}, owner: {}, desc: '' },
86
+ });
87
+
88
+ await command.func(page, { bvid: 'https://www.bilibili.com/video/BV1xx411c7mD/' });
89
+
90
+ expect(page.goto).toHaveBeenCalledWith('https://www.bilibili.com/video/BV1xx411c7mD/');
91
+ expect(mockApiGet).toHaveBeenCalledWith(page, '/x/web-interface/view', { params: { bvid: 'BV1xx411c7mD' } });
92
+ });
93
+
94
+ it('extracts BV ID from bilibili URL with trailing query string', async () => {
95
+ mockApiGet.mockResolvedValueOnce({
96
+ code: 0,
97
+ data: { bvid: 'BV1Je9EBnEha', stat: {}, owner: {}, desc: '' },
98
+ });
99
+
100
+ await command.func(page, {
101
+ bvid: 'https://www.bilibili.com/video/BV1Je9EBnEha/?spm_id_from=333.1007&vd_source=abc',
102
+ });
103
+
104
+ expect(mockApiGet).toHaveBeenCalledWith(page, '/x/web-interface/view', { params: { bvid: 'BV1Je9EBnEha' } });
105
+ });
106
+
107
+ it('extracts BV ID from m.bilibili.com mobile URL', async () => {
108
+ mockApiGet.mockResolvedValueOnce({
109
+ code: 0,
110
+ data: { bvid: 'BV1xx411c7mD', stat: {}, owner: {}, desc: '' },
111
+ });
112
+
113
+ await command.func(page, { bvid: 'https://m.bilibili.com/video/BV1xx411c7mD' });
114
+
115
+ expect(mockApiGet).toHaveBeenCalledWith(page, '/x/web-interface/view', { params: { bvid: 'BV1xx411c7mD' } });
116
+ });
117
+
118
+ it('returns full description without truncation or whitespace collapse', async () => {
119
+ const longDesc = '第一行描述\n\n第二段,有多个空格 和换行\n\n' + 'x'.repeat(500);
120
+ mockApiGet.mockResolvedValueOnce({
121
+ code: 0,
122
+ data: { bvid: 'BV1xx411c7mD', stat: {}, owner: {}, desc: longDesc },
123
+ });
124
+
125
+ const rows = await command.func(page, { bvid: 'BV1xx411c7mD' });
126
+ const byField = Object.fromEntries(rows.map((r) => [r.field, r.value]));
127
+ // JSON/YAML consumers must receive the complete description verbatim,
128
+ // including original whitespace and length > 200 chars.
129
+ expect(byField.description).toBe(longDesc);
130
+ expect(byField.description.length).toBeGreaterThan(200);
131
+ });
132
+ });
@@ -41,7 +41,7 @@ export const imageCommand = cli({
41
41
  timeoutSeconds: 240,
42
42
  args: [
43
43
  { name: 'prompt', positional: true, required: true, help: 'Image prompt to send to ChatGPT' },
44
- { name: 'op', default: path.join(os.homedir(), 'Pictures', 'chatgpt'), help: 'Output directory' },
44
+ { name: 'op', default: '~/Pictures/chatgpt', help: 'Output directory' },
45
45
  { name: 'sd', type: 'boolean', default: false, help: 'Skip download shorthand; only show ChatGPT link' },
46
46
  ],
47
47
  columns: ['status', 'file', 'link'],
@@ -2,7 +2,7 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
2
2
  import { CommandExecutionError } from '@jackwener/opencli/errors';
3
3
  import {
4
4
  DEEPSEEK_DOMAIN, DEEPSEEK_URL, ensureOnDeepSeek, selectModel, setFeature,
5
- sendMessage, getBubbleCount, waitForResponse, parseBoolFlag, withRetry,
5
+ sendMessage, sendWithFile, getBubbleCount, waitForResponse, parseBoolFlag, withRetry,
6
6
  } from './utils.js';
7
7
 
8
8
  export const askCommand = cli({
@@ -21,8 +21,9 @@ export const askCommand = cli({
21
21
  { name: 'model', default: 'instant', choices: ['instant', 'expert'], help: 'Model to use: instant or expert' },
22
22
  { name: 'think', type: 'boolean', default: false, help: 'Enable DeepThink mode' },
23
23
  { name: 'search', type: 'boolean', default: false, help: 'Enable web search' },
24
+ { name: 'file', help: 'Attach a file (PDF, image, text) with the prompt' },
24
25
  ],
25
- columns: ['response'],
26
+ // columns omitted: derived from row keys so non-think output shows only 'response'
26
27
 
27
28
  func: async (page, kwargs) => {
28
29
  const prompt = kwargs.prompt;
@@ -44,19 +45,41 @@ export const askCommand = cli({
44
45
  if (!modelResult?.ok) {
45
46
  throw new CommandExecutionError(`Could not switch to ${wantModel} model`);
46
47
  }
47
- if (modelResult.toggled) await page.wait(0.5);
48
+ if (modelResult?.toggled) await page.wait(0.5);
48
49
 
49
50
  const thinkResult = await withRetry(() => setFeature(page, 'DeepThink', wantThink));
50
- if (!thinkResult?.ok) {
51
- throw new CommandExecutionError('Could not toggle DeepThink');
51
+ if (!thinkResult?.ok && wantThink) {
52
+ throw new CommandExecutionError('Could not enable DeepThink');
52
53
  }
53
54
 
54
55
  const searchResult = await withRetry(() => setFeature(page, 'Search', wantSearch));
55
- if (!searchResult?.ok) {
56
- throw new CommandExecutionError('Could not toggle Search');
56
+ if (!searchResult?.ok && wantSearch) {
57
+ throw new CommandExecutionError('Could not enable Search');
57
58
  }
58
59
 
59
- if (thinkResult.toggled || searchResult.toggled) await page.wait(0.5);
60
+ if (thinkResult?.toggled || searchResult?.toggled) await page.wait(0.5);
61
+
62
+ if (kwargs.file) {
63
+ const baseline = await withRetry(() => getBubbleCount(page));
64
+ try {
65
+ const fileResult = await sendWithFile(page, kwargs.file, prompt);
66
+ if (fileResult && !fileResult.ok) {
67
+ throw new CommandExecutionError(fileResult.reason || 'Failed to attach file');
68
+ }
69
+ } catch (err) {
70
+ // SPA navigates after send; "Promise was collected" means send succeeded
71
+ if (!String(err?.message || err).includes('Promise was collected')) throw err;
72
+ }
73
+ await page.wait(3);
74
+ const result = await waitForResponse(page, baseline, prompt, timeoutMs, wantThink);
75
+ if (!result) {
76
+ return [{ response: `[NO RESPONSE] No reply within ${kwargs.timeout}s.` }];
77
+ }
78
+ if (wantThink && typeof result === 'object' && result.response !== undefined) {
79
+ return [result];
80
+ }
81
+ return [{ response: result }];
82
+ }
60
83
 
61
84
  const baseline = await withRetry(() => getBubbleCount(page));
62
85
  const sendResult = await withRetry(() => sendMessage(page, prompt));
@@ -64,11 +87,14 @@ export const askCommand = cli({
64
87
  throw new CommandExecutionError(sendResult?.reason || 'Failed to send message');
65
88
  }
66
89
 
67
- const response = await waitForResponse(page, baseline, prompt, timeoutMs);
68
- if (!response) {
90
+ const result = await waitForResponse(page, baseline, prompt, timeoutMs, wantThink);
91
+ if (!result) {
69
92
  return [{ response: `[NO RESPONSE] No reply within ${kwargs.timeout}s.` }];
70
93
  }
71
94
 
72
- return [{ response }];
95
+ if (wantThink && typeof result === 'object' && result.response !== undefined) {
96
+ return [result];
97
+ }
98
+ return [{ response: result }];
73
99
  },
74
100
  });
@@ -0,0 +1,165 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { CommandExecutionError } from '@jackwener/opencli/errors';
3
+
4
+ const {
5
+ mockEnsureOnDeepSeek,
6
+ mockSelectModel,
7
+ mockSetFeature,
8
+ mockSendMessage,
9
+ mockSendWithFile,
10
+ mockGetBubbleCount,
11
+ mockWaitForResponse,
12
+ mockParseBoolFlag,
13
+ mockWithRetry,
14
+ } = vi.hoisted(() => ({
15
+ mockEnsureOnDeepSeek: vi.fn(),
16
+ mockSelectModel: vi.fn(),
17
+ mockSetFeature: vi.fn(),
18
+ mockSendMessage: vi.fn(),
19
+ mockSendWithFile: vi.fn(),
20
+ mockGetBubbleCount: vi.fn(),
21
+ mockWaitForResponse: vi.fn(),
22
+ mockParseBoolFlag: vi.fn((v) => v === true || v === 'true'),
23
+ mockWithRetry: vi.fn(async (fn) => fn()),
24
+ }));
25
+
26
+ vi.mock('./utils.js', () => ({
27
+ DEEPSEEK_DOMAIN: 'chat.deepseek.com',
28
+ DEEPSEEK_URL: 'https://chat.deepseek.com/',
29
+ ensureOnDeepSeek: mockEnsureOnDeepSeek,
30
+ selectModel: mockSelectModel,
31
+ setFeature: mockSetFeature,
32
+ sendMessage: mockSendMessage,
33
+ sendWithFile: mockSendWithFile,
34
+ getBubbleCount: mockGetBubbleCount,
35
+ waitForResponse: mockWaitForResponse,
36
+ parseBoolFlag: mockParseBoolFlag,
37
+ withRetry: mockWithRetry,
38
+ }));
39
+
40
+ import { askCommand } from './ask.js';
41
+
42
+ describe('deepseek ask --file', () => {
43
+ const page = {
44
+ wait: vi.fn().mockResolvedValue(undefined),
45
+ goto: vi.fn().mockResolvedValue(undefined),
46
+ };
47
+
48
+ beforeEach(() => {
49
+ vi.clearAllMocks();
50
+ mockEnsureOnDeepSeek.mockResolvedValue(undefined);
51
+ mockSelectModel.mockResolvedValue({ ok: true, toggled: false });
52
+ mockSetFeature.mockResolvedValue({ ok: true, toggled: false });
53
+ mockSendWithFile.mockResolvedValue({ ok: true });
54
+ mockGetBubbleCount.mockResolvedValue(7);
55
+ mockWaitForResponse.mockResolvedValue('new reply');
56
+ });
57
+
58
+ it('captures the existing baseline before sending a file prompt', async () => {
59
+ const rows = await askCommand.func(page, {
60
+ prompt: 'summarize this',
61
+ timeout: 120,
62
+ file: './report.pdf',
63
+ new: false,
64
+ model: 'instant',
65
+ think: false,
66
+ search: false,
67
+ });
68
+
69
+ expect(rows).toEqual([{ response: 'new reply' }]);
70
+ expect(mockGetBubbleCount).toHaveBeenCalledTimes(1);
71
+ expect(mockSendWithFile).toHaveBeenCalledWith(page, './report.pdf', 'summarize this');
72
+ expect(mockWaitForResponse).toHaveBeenCalledWith(page, 7, 'summarize this', 120000, false);
73
+ });
74
+
75
+ it('still fails when explicit instant model selection cannot be verified', async () => {
76
+ mockSelectModel.mockResolvedValue({ ok: false });
77
+
78
+ await expect(askCommand.func(page, {
79
+ prompt: 'summarize this',
80
+ timeout: 120,
81
+ new: false,
82
+ model: 'instant',
83
+ think: false,
84
+ search: false,
85
+ })).rejects.toThrow(new CommandExecutionError('Could not switch to instant model'));
86
+ });
87
+ });
88
+
89
+ describe('deepseek ask --think', () => {
90
+ const page = {
91
+ wait: vi.fn().mockResolvedValue(undefined),
92
+ goto: vi.fn().mockResolvedValue(undefined),
93
+ };
94
+
95
+ beforeEach(() => {
96
+ vi.clearAllMocks();
97
+ mockEnsureOnDeepSeek.mockResolvedValue(undefined);
98
+ mockSelectModel.mockResolvedValue({ ok: true, toggled: false });
99
+ mockSetFeature.mockResolvedValue({ ok: true, toggled: false });
100
+ mockSendMessage.mockResolvedValue({ ok: true });
101
+ mockGetBubbleCount.mockResolvedValue(5);
102
+ });
103
+
104
+ it('returns separate thinking and response fields when --think is enabled', async () => {
105
+ mockWaitForResponse.mockResolvedValue({
106
+ response: 'The answer is 42.',
107
+ thinking: 'Let me analyze this...',
108
+ thinking_time: '2.5',
109
+ });
110
+
111
+ const rows = await askCommand.func(page, {
112
+ prompt: 'what is the answer?',
113
+ timeout: 120,
114
+ new: false,
115
+ model: 'instant',
116
+ think: true,
117
+ search: false,
118
+ });
119
+
120
+ expect(rows).toEqual([{
121
+ response: 'The answer is 42.',
122
+ thinking: 'Let me analyze this...',
123
+ thinking_time: '2.5',
124
+ }]);
125
+ expect(mockWaitForResponse).toHaveBeenCalledWith(page, 5, 'what is the answer?', 120000, true);
126
+ });
127
+
128
+ it('returns plain response when --think is disabled', async () => {
129
+ mockWaitForResponse.mockResolvedValue('The answer is 42.');
130
+
131
+ const rows = await askCommand.func(page, {
132
+ prompt: 'what is the answer?',
133
+ timeout: 120,
134
+ new: false,
135
+ model: 'instant',
136
+ think: false,
137
+ search: false,
138
+ });
139
+
140
+ expect(rows).toEqual([{ response: 'The answer is 42.' }]);
141
+ expect(mockWaitForResponse).toHaveBeenCalledWith(page, 5, 'what is the answer?', 120000, false);
142
+ });
143
+
144
+ it('does not declare static columns (derived from row keys)', () => {
145
+ // columns should be undefined so the renderer infers from row keys,
146
+ // avoiding empty trailing columns on non-think output.
147
+ expect(askCommand.columns).toBeUndefined();
148
+ });
149
+
150
+ it('non-think rows only contain response key', async () => {
151
+ mockWaitForResponse.mockResolvedValue('Plain answer.');
152
+
153
+ const rows = await askCommand.func(page, {
154
+ prompt: 'hello',
155
+ timeout: 120,
156
+ new: false,
157
+ model: 'instant',
158
+ think: false,
159
+ search: false,
160
+ });
161
+
162
+ // Row keys drive rendered columns; no thinking/thinking_time present.
163
+ expect(Object.keys(rows[0])).toEqual(['response']);
164
+ });
165
+ });