utils.test.js
1 import { describe, expect, it, vi } from 'vitest'; 2 import { __test__, collectGeminiTranscriptAdditions, pickGeminiDeepResearchExportUrl, sanitizeGeminiResponseText, sendGeminiMessage, } from './utils.js'; 3 function createPageMock() { 4 return { 5 goto: vi.fn().mockResolvedValue(undefined), 6 evaluate: vi.fn(), 7 getCookies: vi.fn().mockResolvedValue([]), 8 snapshot: vi.fn().mockResolvedValue(undefined), 9 click: vi.fn().mockResolvedValue(undefined), 10 typeText: vi.fn().mockResolvedValue(undefined), 11 pressKey: vi.fn().mockResolvedValue(undefined), 12 scrollTo: vi.fn().mockResolvedValue(undefined), 13 getFormState: vi.fn().mockResolvedValue({}), 14 wait: vi.fn().mockResolvedValue(undefined), 15 tabs: vi.fn().mockResolvedValue([]), 16 selectTab: vi.fn().mockResolvedValue(undefined), 17 networkRequests: vi.fn().mockResolvedValue([]), 18 consoleMessages: vi.fn().mockResolvedValue([]), 19 scroll: vi.fn().mockResolvedValue(undefined), 20 autoScroll: vi.fn().mockResolvedValue(undefined), 21 installInterceptor: vi.fn().mockResolvedValue(undefined), 22 getInterceptedRequests: vi.fn().mockResolvedValue([]), 23 waitForCapture: vi.fn().mockResolvedValue(undefined), 24 screenshot: vi.fn().mockResolvedValue(''), 25 nativeType: vi.fn().mockResolvedValue(undefined), 26 nativeKeyPress: vi.fn().mockResolvedValue(undefined), 27 }; 28 } 29 describe('sanitizeGeminiResponseText', () => { 30 it('strips a prompt echo only when it appears as a prefixed block', () => { 31 const prompt = 'Reply with the word opencli'; 32 const value = `Reply with the word opencli\n\nopencli`; 33 expect(sanitizeGeminiResponseText(value, prompt)).toBe('opencli'); 34 }); 35 it('does not strip prompt text that appears later in a legitimate answer', () => { 36 const prompt = 'opencli'; 37 const value = 'You asked about opencli, and opencli is the right keyword here.'; 38 expect(sanitizeGeminiResponseText(value, prompt)).toBe(value); 39 }); 40 it('removes known Gemini footer noise', () => { 41 const value = 'Answer body\nGemini can make mistakes.\nGoogle Terms'; 42 expect(sanitizeGeminiResponseText(value, '')).toBe('Answer body'); 43 }); 44 }); 45 describe('collectGeminiTranscriptAdditions', () => { 46 it('joins multiple new transcript lines instead of keeping only the last line', () => { 47 const before = ['Older answer']; 48 const current = ['Older answer', 'First new line', 'Second new line']; 49 expect(collectGeminiTranscriptAdditions(before, current, '')).toBe('First new line\nSecond new line'); 50 }); 51 it('filters prompt echoes out of transcript additions', () => { 52 const prompt = 'Tell me a haiku'; 53 const before = ['Previous']; 54 const current = ['Previous', 'Tell me a haiku', 'Tell me a haiku\n\nSoft spring rain arrives']; 55 expect(collectGeminiTranscriptAdditions(before, current, prompt)).toBe('Soft spring rain arrives'); 56 }); 57 it('keeps a reply line that quotes the prompt inside the answer body', () => { 58 const prompt = '请只回复:OK'; 59 const before = ['baseline']; 60 const current = ['baseline', '关于“请只回复:OK”,这里是解释。']; 61 expect(collectGeminiTranscriptAdditions(before, current, prompt)).toBe('关于“请只回复:OK”,这里是解释。'); 62 }); 63 }); 64 describe('gemini send strategy', () => { 65 it('includes structural composer selectors instead of relying only on english aria labels', () => { 66 expect(__test__.GEMINI_COMPOSER_SELECTORS).toContain('.ql-editor[contenteditable="true"]'); 67 expect(__test__.GEMINI_COMPOSER_SELECTORS).toContain('.ql-editor[role="textbox"]'); 68 }); 69 it('prefers native text insertion before submitting the composer', async () => { 70 const page = createPageMock(); 71 const evaluate = vi.mocked(page.evaluate); 72 const nativeType = vi.mocked(page.nativeType); 73 const nativeKeyPress = vi.mocked(page.nativeKeyPress); 74 evaluate 75 .mockResolvedValueOnce('https://gemini.google.com/app') 76 .mockResolvedValueOnce({ ok: true }) 77 .mockResolvedValueOnce({ hasText: true }) 78 .mockResolvedValueOnce('button'); 79 const result = await sendGeminiMessage(page, '你好'); 80 expect(nativeType).toHaveBeenCalledWith('你好'); 81 expect(nativeKeyPress).not.toHaveBeenCalled(); 82 expect(result).toBe('button'); 83 }); 84 it('falls back when native insertion does not update the composer', async () => { 85 const page = createPageMock(); 86 const evaluate = vi.mocked(page.evaluate); 87 const nativeType = vi.mocked(page.nativeType); 88 const nativeKeyPress = vi.mocked(page.nativeKeyPress); 89 evaluate 90 .mockResolvedValueOnce('https://gemini.google.com/app') 91 .mockResolvedValueOnce({ ok: true }) 92 .mockResolvedValueOnce({ hasText: false }) 93 .mockResolvedValueOnce({ hasText: true }) 94 .mockResolvedValueOnce('enter'); 95 const result = await sendGeminiMessage(page, '你好'); 96 expect(nativeType).toHaveBeenCalledWith('你好'); 97 expect(nativeKeyPress).toHaveBeenCalledWith('Enter'); 98 expect(evaluate).toHaveBeenCalledTimes(5); 99 expect(result).toBe('enter'); 100 }); 101 it('falls back when native insertion throws', async () => { 102 const page = createPageMock(); 103 const evaluate = vi.mocked(page.evaluate); 104 const nativeType = vi.mocked(page.nativeType); 105 nativeType.mockRejectedValueOnce(new Error('Unknown action: cdp')); 106 evaluate 107 .mockResolvedValueOnce('https://gemini.google.com/app') 108 .mockResolvedValueOnce({ ok: true }) 109 .mockResolvedValueOnce({ hasText: true }) 110 .mockResolvedValueOnce('button'); 111 const result = await sendGeminiMessage(page, '你好'); 112 expect(nativeType).toHaveBeenCalledWith('你好'); 113 expect(result).toBe('button'); 114 }); 115 it('retries composer preparation until a slow-loading composer appears', async () => { 116 const page = createPageMock(); 117 const evaluate = vi.mocked(page.evaluate); 118 const wait = vi.mocked(page.wait); 119 evaluate 120 .mockResolvedValueOnce('https://gemini.google.com/app') 121 .mockResolvedValueOnce({ ok: false, reason: 'Could not find Gemini composer' }) 122 .mockResolvedValueOnce({ ok: false, reason: 'Could not find Gemini composer' }) 123 .mockResolvedValueOnce({ ok: true }) 124 .mockResolvedValueOnce({ hasText: true }) 125 .mockResolvedValueOnce('button'); 126 const result = await sendGeminiMessage(page, '你好'); 127 expect(result).toBe('button'); 128 expect(wait.mock.calls.filter(([value]) => value === 1)).toHaveLength(3); 129 }); 130 it('keeps retrying until a composer that appears on the fourth attempt is ready', async () => { 131 const page = createPageMock(); 132 const evaluate = vi.mocked(page.evaluate); 133 const wait = vi.mocked(page.wait); 134 evaluate 135 .mockResolvedValueOnce('https://gemini.google.com/app') 136 .mockResolvedValueOnce({ ok: false, reason: 'Could not find Gemini composer' }) 137 .mockResolvedValueOnce({ ok: false, reason: 'Could not find Gemini composer' }) 138 .mockResolvedValueOnce({ ok: false, reason: 'Could not find Gemini composer' }) 139 .mockResolvedValueOnce({ ok: true }) 140 .mockResolvedValueOnce({ hasText: true }) 141 .mockResolvedValueOnce('button'); 142 const result = await sendGeminiMessage(page, '你好'); 143 expect(result).toBe('button'); 144 expect(wait.mock.calls.filter(([value]) => value === 1)).toHaveLength(4); 145 }); 146 it('avoids innerHTML in the fallback insertion path for trusted types pages', () => { 147 expect(__test__.insertComposerTextFallbackScript('你好')).not.toContain('innerHTML'); 148 expect(__test__.insertComposerTextFallbackScript('你好')).toContain('replaceChildren'); 149 }); 150 it('keeps a button submit path in the generated submit script', () => { 151 expect(__test__.submitComposerScript()).toContain('.click()'); 152 }); 153 it('supports localized new chat labels in the generated new-chat script', () => { 154 expect(__test__.clickNewChatScript()).toContain('发起新对话'); 155 }); 156 }); 157 describe('gemini turn normalization', () => { 158 it('collapses only adjacent duplicate turns so identical replies across rounds remain visible', () => { 159 const turns = [ 160 { Role: 'User', Text: '你说\n\n请只回复:OK' }, 161 { Role: 'User', Text: '请只回复:OK' }, 162 { Role: 'Assistant', Text: 'OK' }, 163 { Role: 'Assistant', Text: 'OK' }, 164 { Role: 'User', Text: '你说\n\n请只回复:OK' }, 165 { Role: 'User', Text: '请只回复:OK' }, 166 { Role: 'Assistant', Text: 'OK' }, 167 { Role: 'Assistant', Text: 'OK' }, 168 ]; 169 expect(__test__.collapseAdjacentGeminiTurns(turns)).toEqual([ 170 { Role: 'User', Text: '你说\n\n请只回复:OK' }, 171 { Role: 'User', Text: '请只回复:OK' }, 172 { Role: 'Assistant', Text: 'OK' }, 173 { Role: 'User', Text: '你说\n\n请只回复:OK' }, 174 { Role: 'User', Text: '请只回复:OK' }, 175 { Role: 'Assistant', Text: 'OK' }, 176 ]); 177 }); 178 }); 179 describe('pickGeminiDeepResearchExportUrl', () => { 180 it('prefers docs.google.com document url over sheets and noise endpoints', () => { 181 const picked = pickGeminiDeepResearchExportUrl([ 182 'xhr::https://gemini.google.com/_/BardChatUi/data/batchexecute?rpcids=ESY5D', 183 'performance::https://docs.google.com/spreadsheets/d/1abc/edit', 184 'open::https://docs.google.com/document/d/1docid/edit', 185 ], 'https://gemini.google.com/app/abc'); 186 expect(picked).toEqual({ 187 url: 'https://docs.google.com/document/d/1docid/edit', 188 source: 'window-open', 189 }); 190 }); 191 it('returns none when only non-export telemetry urls are present', () => { 192 const picked = pickGeminiDeepResearchExportUrl([ 193 'fetch::https://gemini.google.com/_/BardChatUi/cspreport', 194 'performance::https://www.google-analytics.com/g/collect?v=2', 195 ], 'https://gemini.google.com/app/abc'); 196 expect(picked).toEqual({ url: '', source: 'none' }); 197 }); 198 });