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 });