/ clis / doubao / utils.test.js
utils.test.js
  1  import { describe, expect, it, vi } from 'vitest';
  2  import { CommandExecutionError } from '@jackwener/opencli/errors';
  3  import {
  4      __test__,
  5      collectDoubaoTranscriptAdditions,
  6      mergeTranscriptSnapshots,
  7      parseDoubaoConversationId,
  8      sendDoubaoMessage,
  9      waitForDoubaoResponse,
 10  } from './utils.js';
 11  
 12  function createPageMock() {
 13      return {
 14          goto: vi.fn().mockResolvedValue(undefined),
 15          evaluate: vi.fn(),
 16          getCookies: vi.fn().mockResolvedValue([]),
 17          snapshot: vi.fn().mockResolvedValue(undefined),
 18          click: vi.fn().mockResolvedValue(undefined),
 19          typeText: vi.fn().mockResolvedValue(undefined),
 20          pressKey: vi.fn().mockResolvedValue(undefined),
 21          scrollTo: vi.fn().mockResolvedValue(undefined),
 22          getFormState: vi.fn().mockResolvedValue({}),
 23          wait: vi.fn().mockResolvedValue(undefined),
 24          tabs: vi.fn().mockResolvedValue([]),
 25          selectTab: vi.fn().mockResolvedValue(undefined),
 26          networkRequests: vi.fn().mockResolvedValue([]),
 27          consoleMessages: vi.fn().mockResolvedValue([]),
 28          scroll: vi.fn().mockResolvedValue(undefined),
 29          autoScroll: vi.fn().mockResolvedValue(undefined),
 30          installInterceptor: vi.fn().mockResolvedValue(undefined),
 31          getInterceptedRequests: vi.fn().mockResolvedValue([]),
 32          waitForCapture: vi.fn().mockResolvedValue(undefined),
 33          screenshot: vi.fn().mockResolvedValue(''),
 34          nativeType: vi.fn().mockResolvedValue(undefined),
 35          nativeKeyPress: vi.fn().mockResolvedValue(undefined),
 36      };
 37  }
 38  
 39  describe('parseDoubaoConversationId', () => {
 40      it('extracts the numeric id from a full conversation URL', () => {
 41          expect(parseDoubaoConversationId('https://www.doubao.com/chat/1234567890123')).toBe('1234567890123');
 42      });
 43      it('keeps a raw id unchanged', () => {
 44          expect(parseDoubaoConversationId('1234567890123')).toBe('1234567890123');
 45      });
 46  });
 47  describe('doubao send strategy', () => {
 48      it('prefers native CDP text insertion and button submission when a send button is available', async () => {
 49          const page = createPageMock();
 50          const evaluate = vi.mocked(page.evaluate);
 51          const nativeType = vi.mocked(page.nativeType);
 52          const nativeKeyPress = vi.mocked(page.nativeKeyPress);
 53          evaluate
 54              .mockResolvedValueOnce('https://www.doubao.com/chat')
 55              .mockResolvedValueOnce({ ok: true })
 56              .mockResolvedValueOnce({ hasText: true, text: '你好' })
 57              .mockResolvedValueOnce({ hasText: true, text: '你好' })
 58              .mockResolvedValueOnce(true)
 59              .mockResolvedValueOnce({ detected: false });
 60          const result = await sendDoubaoMessage(page, '你好');
 61          expect(nativeType).toHaveBeenCalledWith('你好');
 62          expect(nativeKeyPress).not.toHaveBeenCalled();
 63          expect(result).toBe('button');
 64      });
 65      it('falls back to DOM insertion when native insertion does not update the composer', async () => {
 66          const page = createPageMock();
 67          const evaluate = vi.mocked(page.evaluate);
 68          const nativeType = vi.mocked(page.nativeType);
 69          evaluate
 70              .mockResolvedValueOnce('https://www.doubao.com/chat')
 71              .mockResolvedValueOnce({ ok: true })
 72              .mockResolvedValueOnce({ hasText: false, text: '' })
 73              .mockResolvedValueOnce({ hasText: false, text: '' })
 74              .mockResolvedValueOnce({ hasText: true, text: '你好' })
 75              .mockResolvedValueOnce(true)
 76              .mockResolvedValueOnce({ detected: false });
 77          const result = await sendDoubaoMessage(page, '你好');
 78          expect(nativeType).toHaveBeenCalledWith('你好');
 79          expect(evaluate).toHaveBeenCalledTimes(7);
 80          expect(result).toBe('button');
 81      });
 82      it('falls back to DOM insertion when native insertion text does not match the requested prompt', async () => {
 83          const page = createPageMock();
 84          const evaluate = vi.mocked(page.evaluate);
 85          evaluate
 86              .mockResolvedValueOnce('https://www.doubao.com/chat')
 87              .mockResolvedValueOnce({ ok: true })
 88              .mockResolvedValueOnce({ hasText: true, text: '你' })
 89              .mockResolvedValueOnce({ hasText: true, text: '你好' })
 90              .mockResolvedValueOnce(true)
 91              .mockResolvedValueOnce({ detected: false });
 92          const result = await sendDoubaoMessage(page, '你好');
 93          expect(result).toBe('button');
 94      });
 95      it('falls back to native Enter when no clickable submit button is found', async () => {
 96          const page = createPageMock();
 97          const evaluate = vi.mocked(page.evaluate);
 98          const nativeKeyPress = vi.mocked(page.nativeKeyPress);
 99          evaluate
100              .mockResolvedValueOnce('https://www.doubao.com/chat')
101              .mockResolvedValueOnce({ ok: true })
102              .mockResolvedValueOnce({ hasText: true, text: '你好' })
103              .mockResolvedValueOnce({ hasText: true, text: '你好' })
104              .mockResolvedValueOnce(false)
105              .mockResolvedValueOnce({ detected: false });
106          const result = await sendDoubaoMessage(page, '你好');
107          expect(nativeKeyPress).toHaveBeenCalledWith('Enter');
108          expect(result).toBe('enter');
109      });
110      it('does not throw verification errors just because the prompt mentions verification terms', async () => {
111          const page = createPageMock();
112          const evaluate = vi.mocked(page.evaluate);
113          evaluate
114              .mockResolvedValueOnce('https://www.doubao.com/chat')
115              .mockResolvedValueOnce({ ok: true })
116              .mockResolvedValueOnce({ hasText: true, text: '请解释 CAPTCHA verification 是什么' })
117              .mockResolvedValueOnce({ hasText: true, text: '请解释 CAPTCHA verification 是什么' })
118              .mockResolvedValueOnce(true)
119              .mockResolvedValueOnce({ detected: false, reason: '' });
120          await expect(sendDoubaoMessage(page, '请解释 CAPTCHA verification 是什么')).resolves.toBe('button');
121      });
122      it('does not throw verification errors for ordinary chinese prompts mentioning security terms', async () => {
123          const page = createPageMock();
124          const evaluate = vi.mocked(page.evaluate);
125          evaluate
126              .mockResolvedValueOnce('https://www.doubao.com/chat')
127              .mockResolvedValueOnce({ ok: true })
128              .mockResolvedValueOnce({ hasText: true, text: '请解释人机验证和完成安全验证的区别' })
129              .mockResolvedValueOnce({ hasText: true, text: '请解释人机验证和完成安全验证的区别' })
130              .mockResolvedValueOnce(true)
131              .mockResolvedValueOnce({ detected: false, reason: '' });
132          await expect(sendDoubaoMessage(page, '请解释人机验证和完成安全验证的区别')).resolves.toBe('button');
133      });
134      it('throws a command error when Doubao shows a verification challenge after submit', async () => {
135          const page = createPageMock();
136          const evaluate = vi.mocked(page.evaluate);
137          evaluate
138              .mockResolvedValueOnce('https://www.doubao.com/chat')
139              .mockResolvedValueOnce({ ok: true })
140              .mockResolvedValueOnce({ hasText: true, text: '你好' })
141              .mockResolvedValueOnce({ hasText: true, text: '你好' })
142              .mockResolvedValueOnce(true)
143              .mockResolvedValueOnce({ detected: true, reason: '请完成安全验证' });
144          await expect(sendDoubaoMessage(page, '你好')).rejects.toBeInstanceOf(CommandExecutionError);
145      });
146  });
147  describe('collectDoubaoTranscriptAdditions', () => {
148      it('ignores landing-page capability chips that are not assistant content', () => {
149          const before = ['older'];
150          const current = [
151              'older',
152              '测试一下,只回复OK快速视频生成深入研究图像生成帮我写作音乐生成更多',
153              '测试一下,只回复OK',
154          ];
155          expect(collectDoubaoTranscriptAdditions(before, current, '测试一下,只回复OK')).toBe('');
156      });
157      it('filters prompt-contaminated chip lines for arbitrary prompts', () => {
158          const before = ['older'];
159          const current = [
160              'older',
161              '你好快速视频生成深入研究图像生成帮我写作音乐生成更多',
162          ];
163          expect(collectDoubaoTranscriptAdditions(before, current, '你好')).toBe('');
164      });
165      it('filters whitespace-normalized multiline prompt echoes and prompt-plus-chip artifacts', () => {
166          const before = ['older'];
167          const prompt = '第一行\n第二行';
168          expect(collectDoubaoTranscriptAdditions(before, ['older', '第一行 第二行'], prompt)).toBe('');
169          expect(collectDoubaoTranscriptAdditions(before, ['older', '第一行 第二行快速视频生成深入研究图像生成帮我写作音乐生成更多'], prompt)).toBe('');
170      });
171      it('keeps legitimate replies that discuss Doubao features', () => {
172          const before = ['older'];
173          const current = [
174              'older',
175              '图像生成和音乐生成目前都支持,但适用场景不同。',
176          ];
177          expect(collectDoubaoTranscriptAdditions(before, current, 'irrelevant prompt')).toBe('图像生成和音乐生成目前都支持,但适用场景不同。');
178      });
179      it('keeps an exact chip string when it is the assistant reply rather than prompt contamination', () => {
180          const before = ['older'];
181          const current = [
182              'older',
183              '快速视频生成深入研究图像生成帮我写作音乐生成更多',
184          ];
185          expect(collectDoubaoTranscriptAdditions(before, current, '测试一下,只回复OK')).toBe('快速视频生成深入研究图像生成帮我写作音乐生成更多');
186      });
187      it('filters combined sidebar chrome that appears as a new transcript line', () => {
188          const before = ['older'];
189          const current = [
190              'older',
191              'AI 创作云盘更多历史对话',
192          ];
193          expect(collectDoubaoTranscriptAdditions(before, current, '测试一下,只回复OK')).toBe('');
194      });
195      it('filters transcript lines that only differ because the prompt was appended to existing page chrome', () => {
196          const before = [
197              '有什么我能帮你的吗?资讯:韩国三大运营商允许超流量用基本数据服务快速视频生成深入研究图像生成帮我写作音乐生成更多',
198          ];
199          const current = [
200              '有什么我能帮你的吗?资讯:韩国三大运营商允许超流量用基本数据服务快速视频生成深入研究图像生成帮我写作音乐生成更多测试一下,只回复OK',
201          ];
202          expect(collectDoubaoTranscriptAdditions(before, current, '测试一下,只回复OK', (value) => value.replace('测试一下,只回复OK', '').trim())).toBe('');
203      });
204      it('treats only the exact landing-page chip string as UI noise', () => {
205          expect(__test__.clickSendButtonScript()).not.toContain('document,');
206          expect(__test__.clickSendButtonScript()).toContain('bestScore >= 200');
207          expect(__test__.clickSendButtonScript()).not.toContain("|| !!button.closest('.chat-input-button')");
208          expect(__test__.clickSendButtonScript()).toContain("button.getAttribute('type') === 'submit') score += 1200");
209          expect(__test__.composerStateScript()).toContain("(composer.innerText || '').trim() || (composer.textContent || '').trim()");
210          expect(__test__.detectDoubaoVerificationScript()).not.toContain('document.body?.innerText');
211          expect(__test__.detectDoubaoVerificationScript()).not.toContain('[class*=\"verify\"]');
212          expect(__test__.detectDoubaoVerificationScript()).not.toContain('[class*=\"captcha\"]');
213          expect(__test__.detectDoubaoVerificationScript()).not.toContain('document.body?.children');
214      });
215  });
216  describe('waitForDoubaoResponse', () => {
217      it('allows transcript fallback on local chat urls when new transcript lines appear', async () => {
218          const page = createPageMock();
219          const evaluate = vi.mocked(page.evaluate);
220          const wait = vi.mocked(page.wait);
221          evaluate
222              .mockResolvedValueOnce({ detected: false })
223              .mockResolvedValueOnce('https://www.doubao.com/chat/local_123')
224              .mockResolvedValueOnce([])
225              .mockResolvedValueOnce('https://www.doubao.com/chat/local_123')
226              .mockResolvedValueOnce(['older', '真正的回答']);
227          const result = await waitForDoubaoResponse(page, ['older'], [], '测试一下,只回复OK', 2);
228          expect(wait).toHaveBeenCalled();
229          expect(result).toBe('真正的回答');
230      });
231      it('does not suppress assistant turns that happen to match landing-page chip text', async () => {
232          const page = createPageMock();
233          const evaluate = vi.mocked(page.evaluate);
234          evaluate
235              .mockResolvedValueOnce({ detected: false })
236              .mockResolvedValueOnce('https://www.doubao.com/chat')
237              .mockResolvedValueOnce([
238              { Role: 'Assistant', Text: '快速视频生成深入研究图像生成帮我写作音乐生成更多' },
239          ]);
240          const result = await waitForDoubaoResponse(page, [], [], '测试一下,只回复OK', 2);
241          expect(result).toBe('快速视频生成深入研究图像生成帮我写作音乐生成更多');
242      });
243      it('raises a command error when a verification challenge appears during polling', async () => {
244          const page = createPageMock();
245          const evaluate = vi.mocked(page.evaluate);
246          evaluate.mockResolvedValueOnce({ detected: true, reason: '请完成安全验证' });
247          await expect(waitForDoubaoResponse(page, [], [], '你好', 2)).rejects.toBeInstanceOf(CommandExecutionError);
248      });
249  });
250  describe('mergeTranscriptSnapshots', () => {
251      it('extends the transcript when the next snapshot overlaps with the tail', () => {
252          const merged = mergeTranscriptSnapshots('Alice 00:00\nHello team\nBob 00:05\nHi', 'Bob 00:05\nHi\nAlice 00:10\nNext topic');
253          expect(merged).toBe('Alice 00:00\nHello team\nBob 00:05\nHi\nAlice 00:10\nNext topic');
254      });
255      it('does not duplicate a snapshot that is already contained in the transcript', () => {
256          const merged = mergeTranscriptSnapshots('Alice 00:00\nHello team\nBob 00:05\nHi', 'Bob 00:05\nHi');
257          expect(merged).toBe('Alice 00:00\nHello team\nBob 00:05\nHi');
258      });
259      it('keeps both windows when a virtualized panel returns adjacent chunks without full history', () => {
260          const merged = mergeTranscriptSnapshots('Alice 00:00\nHello team\nBob 00:05\nHi', 'Alice 00:10\nNext topic\nBob 00:15\nAction items');
261          expect(merged).toBe('Alice 00:00\nHello team\nBob 00:05\nHi\nAlice 00:10\nNext topic\nBob 00:15\nAction items');
262      });
263  });