/ clis / gemini / utils.test.js
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  });