import { describe, expect, it, vi } from 'vitest'; import { __test__, waitForGeminiResponse, waitForGeminiSubmission } from './utils.js'; function snapshot(overrides = {}) { return { turns: [], transcriptLines: [], composerHasText: false, isGenerating: false, structuredTurnsTrusted: true, ...overrides, }; } function createPageMock() { return { goto: vi.fn().mockResolvedValue(undefined), evaluate: vi.fn(), getCookies: vi.fn().mockResolvedValue([]), snapshot: vi.fn().mockResolvedValue(undefined), click: vi.fn().mockResolvedValue(undefined), typeText: vi.fn().mockResolvedValue(undefined), pressKey: vi.fn().mockResolvedValue(undefined), scrollTo: vi.fn().mockResolvedValue(undefined), getFormState: vi.fn().mockResolvedValue({}), wait: vi.fn().mockResolvedValue(undefined), tabs: vi.fn().mockResolvedValue([]), selectTab: vi.fn().mockResolvedValue(undefined), networkRequests: vi.fn().mockResolvedValue([]), consoleMessages: vi.fn().mockResolvedValue([]), scroll: vi.fn().mockResolvedValue(undefined), autoScroll: vi.fn().mockResolvedValue(undefined), installInterceptor: vi.fn().mockResolvedValue(undefined), getInterceptedRequests: vi.fn().mockResolvedValue([]), waitForCapture: vi.fn().mockResolvedValue(undefined), screenshot: vi.fn().mockResolvedValue(''), nativeType: vi.fn().mockResolvedValue(undefined), nativeKeyPress: vi.fn().mockResolvedValue(undefined), }; } describe('Gemini snapshot diff helpers', () => { it('reports appended trusted turns when the current snapshot extends the baseline', () => { const before = snapshot({ turns: [{ Role: 'Assistant', Text: '旧回答' }], }); const current = snapshot({ turns: [ { Role: 'Assistant', Text: '旧回答' }, { Role: 'User', Text: '请只回复:OK' }, ], }); expect(__test__.diffTrustedStructuredTurns(before, current)).toEqual({ appendedTurns: [{ Role: 'User', Text: '请只回复:OK' }], hasTrustedAppend: true, hasNewUserTurn: true, hasNewAssistantTurn: false, }); }); it('treats restored structured turns as untrusted when the pre-send snapshot had no trustworthy turns', () => { const before = snapshot({ turns: [], transcriptLines: ['旧问题', '旧回答'], structuredTurnsTrusted: false, }); const current = snapshot({ turns: [ { Role: 'User', Text: '旧问题' }, { Role: 'Assistant', Text: '旧回答' }, ], transcriptLines: ['旧问题', '旧回答'], structuredTurnsTrusted: true, }); expect(__test__.diffTrustedStructuredTurns(before, current)).toEqual({ appendedTurns: [], hasTrustedAppend: false, hasNewUserTurn: false, hasNewAssistantTurn: false, }); }); it('keeps transcript delta lines raw for later conservative fallback checks', () => { const before = snapshot({ transcriptLines: ['baseline'], }); const current = snapshot({ transcriptLines: ['baseline', '关于“请只回复:OK”,这里是解释。'], structuredTurnsTrusted: false, }); expect(__test__.diffTranscriptLines(before, current)).toEqual([ '关于“请只回复:OK”,这里是解释。', ]); }); }); describe('Gemini submission state', () => { it('confirms submission from a trusted appended user turn', async () => { const page = createPageMock(); const evaluate = vi.mocked(page.evaluate); evaluate .mockResolvedValueOnce('https://gemini.google.com/app') .mockResolvedValueOnce({ turns: [ { Role: 'Assistant', Text: '旧回答' }, { Role: 'User', Text: '请只回复:OK' }, ], transcriptLines: ['baseline', '请只回复:OK'], composerHasText: false, isGenerating: true, structuredTurnsTrusted: true, }); const result = await waitForGeminiSubmission(page, snapshot({ turns: [{ Role: 'Assistant', Text: '旧回答' }], transcriptLines: ['baseline'], composerHasText: true, structuredTurnsTrusted: true, }), 4); expect(result).toEqual({ snapshot: { turns: [ { Role: 'Assistant', Text: '旧回答' }, { Role: 'User', Text: '请只回复:OK' }, ], transcriptLines: ['baseline', '请只回复:OK'], composerHasText: false, isGenerating: true, structuredTurnsTrusted: true, }, preSendAssistantCount: 1, userAnchorTurn: { Role: 'User', Text: '请只回复:OK' }, reason: 'user_turn', }); }); it('confirms submission from composer cleared plus generating even when transcript has not changed yet', async () => { const page = createPageMock(); const evaluate = vi.mocked(page.evaluate); evaluate .mockResolvedValueOnce('https://gemini.google.com/app') .mockResolvedValueOnce({ turns: [], transcriptLines: ['baseline'], composerHasText: false, isGenerating: true, structuredTurnsTrusted: false, }); const result = await waitForGeminiSubmission(page, snapshot({ transcriptLines: ['baseline'], composerHasText: true, structuredTurnsTrusted: false, }), 2); expect(result).toEqual({ snapshot: { turns: [], transcriptLines: ['baseline'], composerHasText: false, isGenerating: true, structuredTurnsTrusted: false, }, preSendAssistantCount: 0, userAnchorTurn: null, reason: 'composer_generating', }); }); it('confirms submission from generating state even when the pre-send baseline composer was empty', async () => { const page = createPageMock(); const evaluate = vi.mocked(page.evaluate); evaluate .mockResolvedValueOnce('https://gemini.google.com/app') .mockResolvedValueOnce({ turns: [ { Role: 'User', Text: '你说\n\n请只回复:DBG2' }, { Role: 'User', Text: '请只回复:DBG2' }, ], transcriptLines: ['baseline', '请只回复:DBG2'], composerHasText: false, isGenerating: true, structuredTurnsTrusted: true, }); const result = await waitForGeminiSubmission(page, snapshot({ turns: [{ Role: 'Assistant', Text: '需要我为你做些什么?' }], transcriptLines: ['baseline'], composerHasText: false, structuredTurnsTrusted: true, }), 2); expect(result).toEqual({ snapshot: { turns: [ { Role: 'User', Text: '你说\n\n请只回复:DBG2' }, { Role: 'User', Text: '请只回复:DBG2' }, ], transcriptLines: ['baseline', '请只回复:DBG2'], composerHasText: false, isGenerating: true, structuredTurnsTrusted: true, }, preSendAssistantCount: 1, userAnchorTurn: { Role: 'User', Text: '请只回复:DBG2' }, reason: 'composer_generating', }); }); it('confirms submission from composer cleared plus transcript growth when generation state is unavailable', async () => { const page = createPageMock(); const evaluate = vi.mocked(page.evaluate); // This transcript delta may be only a prompt echo. It is allowed to confirm // submission only because the composer has already cleared, and it must never // be reused later as reply ownership evidence. evaluate .mockResolvedValueOnce('https://gemini.google.com/app') .mockResolvedValueOnce({ turns: [], transcriptLines: ['baseline', '请只回复:OK'], composerHasText: false, isGenerating: false, structuredTurnsTrusted: false, }); const result = await waitForGeminiSubmission(page, snapshot({ transcriptLines: ['baseline'], composerHasText: true, structuredTurnsTrusted: false, }), 2); expect(result).toEqual({ snapshot: { turns: [], transcriptLines: ['baseline', '请只回复:OK'], composerHasText: false, isGenerating: false, structuredTurnsTrusted: false, }, preSendAssistantCount: 0, userAnchorTurn: null, reason: 'composer_transcript', }); }); it('does not confirm submission when old structured turns only reappear after an untrusted pre-send snapshot', async () => { const page = createPageMock(); const evaluate = vi.mocked(page.evaluate); evaluate .mockResolvedValueOnce('https://gemini.google.com/app') .mockResolvedValueOnce({ turns: [ { Role: 'User', Text: '旧问题' }, { Role: 'Assistant', Text: '旧回答' }, ], transcriptLines: ['旧问题', '旧回答'], composerHasText: false, isGenerating: false, structuredTurnsTrusted: true, }) .mockResolvedValueOnce('https://gemini.google.com/app') .mockResolvedValueOnce({ turns: [ { Role: 'User', Text: '旧问题' }, { Role: 'Assistant', Text: '旧回答' }, ], transcriptLines: ['旧问题', '旧回答'], composerHasText: false, isGenerating: false, structuredTurnsTrusted: true, }); const result = await waitForGeminiSubmission(page, snapshot({ turns: [], transcriptLines: ['旧问题', '旧回答'], composerHasText: true, structuredTurnsTrusted: false, }), 2); expect(result).toBeNull(); }); it('does not confirm submission from transcript growth alone when the composer never clears', async () => { const page = createPageMock(); const evaluate = vi.mocked(page.evaluate); evaluate .mockResolvedValueOnce('https://gemini.google.com/app') .mockResolvedValueOnce({ turns: [], transcriptLines: ['baseline', '请只回复:OK'], composerHasText: true, isGenerating: false, structuredTurnsTrusted: false, }) .mockResolvedValueOnce('https://gemini.google.com/app') .mockResolvedValueOnce({ turns: [], transcriptLines: ['baseline', '请只回复:OK'], composerHasText: true, isGenerating: false, structuredTurnsTrusted: false, }); const result = await waitForGeminiSubmission(page, snapshot({ transcriptLines: ['baseline'], composerHasText: true, structuredTurnsTrusted: false, }), 2); expect(result).toBeNull(); }); it('does not confirm transcript-only submission while url remains /app root', async () => { const page = createPageMock(); const evaluate = vi.mocked(page.evaluate); evaluate .mockResolvedValueOnce('https://gemini.google.com/app') .mockResolvedValueOnce({ url: 'https://gemini.google.com/app', turns: [], transcriptLines: ['baseline', 'prompt'], composerHasText: false, isGenerating: false, structuredTurnsTrusted: false, }) .mockResolvedValueOnce('https://gemini.google.com/app') .mockResolvedValueOnce({ url: 'https://gemini.google.com/app', turns: [], transcriptLines: ['baseline', 'prompt'], composerHasText: false, isGenerating: false, structuredTurnsTrusted: false, }); const result = await waitForGeminiSubmission(page, snapshot({ url: 'https://gemini.google.com/app', transcriptLines: ['baseline'], composerHasText: true, structuredTurnsTrusted: false, }), 2); expect(result).toBeNull(); }); it('keeps polling past ten seconds when the overall timeout budget still allows submission confirmation', async () => { const page = createPageMock(); const evaluate = vi.mocked(page.evaluate); for (let index = 0; index < 10; index += 1) { evaluate .mockResolvedValueOnce('https://gemini.google.com/app') .mockResolvedValueOnce({ turns: [], transcriptLines: ['baseline'], composerHasText: true, isGenerating: false, structuredTurnsTrusted: false, }); } evaluate .mockResolvedValueOnce('https://gemini.google.com/app') .mockResolvedValueOnce({ turns: [], transcriptLines: ['baseline', '请只回复:OK'], composerHasText: false, isGenerating: false, structuredTurnsTrusted: false, }); const result = await waitForGeminiSubmission(page, snapshot({ transcriptLines: ['baseline'], composerHasText: true, structuredTurnsTrusted: false, }), 12); expect(result?.reason).toBe('composer_transcript'); }); }); describe('Gemini reply state', () => { it('does not reuse an older identical reply when the submission baseline has no structured user anchor', async () => { const page = createPageMock(); const evaluate = vi.mocked(page.evaluate); evaluate .mockResolvedValueOnce('https://gemini.google.com/app') .mockResolvedValueOnce({ turns: [{ Role: 'Assistant', Text: 'OK' }], transcriptLines: ['baseline', 'OK'], composerHasText: false, isGenerating: false, structuredTurnsTrusted: true, }) .mockResolvedValueOnce('https://gemini.google.com/app') .mockResolvedValueOnce({ turns: [ { Role: 'Assistant', Text: 'OK' }, { Role: 'Assistant', Text: 'OK' }, ], transcriptLines: ['baseline', 'OK'], composerHasText: false, isGenerating: false, structuredTurnsTrusted: true, }) .mockResolvedValueOnce('https://gemini.google.com/app') .mockResolvedValueOnce({ turns: [ { Role: 'Assistant', Text: 'OK' }, { Role: 'Assistant', Text: 'OK' }, ], transcriptLines: ['baseline', 'OK'], composerHasText: false, isGenerating: false, structuredTurnsTrusted: true, }); const result = await waitForGeminiResponse(page, { snapshot: snapshot({ turns: [{ Role: 'Assistant', Text: 'OK' }], transcriptLines: ['baseline', 'OK'], composerHasText: false, isGenerating: true, structuredTurnsTrusted: true, }), preSendAssistantCount: 1, userAnchorTurn: null, reason: 'composer_generating', }, '请只回复:OK', 6); expect(result).toBe('OK'); }); it('does not treat prepended older history as the current round reply when reply ownership has no user anchor', async () => { const page = createPageMock(); const evaluate = vi.mocked(page.evaluate); evaluate .mockResolvedValueOnce('https://gemini.google.com/app') .mockResolvedValueOnce({ turns: [ { Role: 'Assistant', Text: '更早的问题' }, { Role: 'Assistant', Text: '旧回答' }, ], transcriptLines: ['baseline'], composerHasText: false, isGenerating: false, structuredTurnsTrusted: true, }) .mockResolvedValueOnce('https://gemini.google.com/app') .mockResolvedValueOnce({ turns: [ { Role: 'Assistant', Text: '更早的问题' }, { Role: 'Assistant', Text: '旧回答' }, ], transcriptLines: ['baseline'], composerHasText: false, isGenerating: false, structuredTurnsTrusted: true, }) .mockResolvedValueOnce('https://gemini.google.com/app') .mockResolvedValueOnce({ turns: [ { Role: 'Assistant', Text: '更早的问题' }, { Role: 'Assistant', Text: '旧回答' }, ], transcriptLines: ['baseline'], composerHasText: false, isGenerating: false, structuredTurnsTrusted: true, }); const result = await waitForGeminiResponse(page, { snapshot: snapshot({ turns: [{ Role: 'Assistant', Text: '旧回答' }], transcriptLines: ['baseline'], composerHasText: false, isGenerating: true, structuredTurnsTrusted: true, }), preSendAssistantCount: 1, userAnchorTurn: null, reason: 'composer_generating', }, '请只回复:OK', 6); expect(result).toBe(''); }); it('accepts a reply when the submission snapshot contains only the current round user turns and later appends a new assistant', async () => { const page = createPageMock(); const evaluate = vi.mocked(page.evaluate); evaluate .mockResolvedValueOnce('https://gemini.google.com/app') .mockResolvedValueOnce({ turns: [ { Role: 'User', Text: '你说\n\n请只回复:DBGREG' }, { Role: 'User', Text: '请只回复:DBGREG' }, { Role: 'Assistant', Text: 'DBGREG' }, ], transcriptLines: ['baseline'], composerHasText: false, isGenerating: false, structuredTurnsTrusted: true, }) .mockResolvedValueOnce('https://gemini.google.com/app') .mockResolvedValueOnce({ turns: [ { Role: 'User', Text: '你说\n\n请只回复:DBGREG' }, { Role: 'User', Text: '请只回复:DBGREG' }, { Role: 'Assistant', Text: 'DBGREG' }, ], transcriptLines: ['baseline'], composerHasText: false, isGenerating: false, structuredTurnsTrusted: true, }); const result = await waitForGeminiResponse(page, { snapshot: snapshot({ turns: [ { Role: 'User', Text: '你说\n\n请只回复:DBGREG' }, { Role: 'User', Text: '请只回复:DBGREG' }, ], transcriptLines: ['baseline'], composerHasText: false, isGenerating: true, structuredTurnsTrusted: true, }), preSendAssistantCount: 1, userAnchorTurn: { Role: 'User', Text: '请只回复:DBGREG' }, reason: 'composer_generating', }, '请只回复:DBGREG', 6); expect(result).toBe('DBGREG'); }); it('does not trust an assistant-only submission snapshot without a stable post-submission owner', async () => { const page = createPageMock(); const evaluate = vi.mocked(page.evaluate); evaluate .mockResolvedValueOnce('https://gemini.google.com/app') .mockResolvedValueOnce({ turns: [{ Role: 'Assistant', Text: '完整回答' }], transcriptLines: ['baseline'], composerHasText: false, isGenerating: false, structuredTurnsTrusted: true, }) .mockResolvedValueOnce('https://gemini.google.com/app') .mockResolvedValueOnce({ turns: [{ Role: 'Assistant', Text: '完整回答' }], transcriptLines: ['baseline'], composerHasText: false, isGenerating: false, structuredTurnsTrusted: true, }); const result = await waitForGeminiResponse(page, { snapshot: snapshot({ turns: [{ Role: 'Assistant', Text: '半截回答' }], transcriptLines: ['baseline'], composerHasText: false, isGenerating: true, structuredTurnsTrusted: true, }), preSendAssistantCount: 0, userAnchorTurn: null, reason: 'composer_generating', }, '请解释', 4); expect(result).toBe(''); }); it('accepts an assistant reply that appears after a structured user anchor only after it stabilizes and generation stops', async () => { const page = createPageMock(); const evaluate = vi.mocked(page.evaluate); evaluate .mockResolvedValueOnce('https://gemini.google.com/app') .mockResolvedValueOnce({ turns: [ { Role: 'Assistant', Text: '旧回答' }, { Role: 'User', Text: '请解释' }, { Role: 'Assistant', Text: '半截回答' }, ], transcriptLines: ['baseline'], composerHasText: false, isGenerating: true, structuredTurnsTrusted: true, }) .mockResolvedValueOnce('https://gemini.google.com/app') .mockResolvedValueOnce({ turns: [ { Role: 'Assistant', Text: '旧回答' }, { Role: 'User', Text: '请解释' }, { Role: 'Assistant', Text: '完整回答' }, ], transcriptLines: ['baseline'], composerHasText: false, isGenerating: true, structuredTurnsTrusted: true, }) .mockResolvedValueOnce('https://gemini.google.com/app') .mockResolvedValueOnce({ turns: [ { Role: 'Assistant', Text: '旧回答' }, { Role: 'User', Text: '请解释' }, { Role: 'Assistant', Text: '完整回答' }, ], transcriptLines: ['baseline'], composerHasText: false, isGenerating: false, structuredTurnsTrusted: true, }); const result = await waitForGeminiResponse(page, { snapshot: snapshot({ turns: [ { Role: 'Assistant', Text: '旧回答' }, { Role: 'User', Text: '请解释' }, ], transcriptLines: ['baseline'], composerHasText: false, isGenerating: true, structuredTurnsTrusted: true, }), preSendAssistantCount: 1, userAnchorTurn: { Role: 'User', Text: '请解释' }, reason: 'user_turn', }, '请解释', 6); expect(result).toBe('完整回答'); }); it('uses transcript fallback only after two identical post-submission deltas and after generation stops', async () => { const page = createPageMock(); const evaluate = vi.mocked(page.evaluate); evaluate .mockResolvedValueOnce('https://gemini.google.com/app') .mockResolvedValueOnce({ turns: [{ Role: 'User', Text: '请只回复:OK' }], transcriptLines: ['baseline', 'OK'], composerHasText: false, isGenerating: true, structuredTurnsTrusted: true, }) .mockResolvedValueOnce('https://gemini.google.com/app') .mockResolvedValueOnce({ turns: [{ Role: 'User', Text: '请只回复:OK' }], transcriptLines: ['baseline', 'OK'], composerHasText: false, isGenerating: false, structuredTurnsTrusted: true, }) .mockResolvedValueOnce('https://gemini.google.com/app') .mockResolvedValueOnce({ turns: [{ Role: 'User', Text: '请只回复:OK' }], transcriptLines: ['baseline', 'OK'], composerHasText: false, isGenerating: false, structuredTurnsTrusted: true, }); const result = await waitForGeminiResponse(page, { snapshot: snapshot({ turns: [{ Role: 'User', Text: '请只回复:OK' }], transcriptLines: ['baseline'], composerHasText: false, isGenerating: true, structuredTurnsTrusted: true, }), preSendAssistantCount: 0, userAnchorTurn: { Role: 'User', Text: '请只回复:OK' }, reason: 'user_turn', }, '请只回复:OK', 6); expect(result).toBe('OK'); }); it('ignores transcript lines that appeared before submission confirmation and only accepts post-submission transcript deltas', async () => { const page = createPageMock(); const evaluate = vi.mocked(page.evaluate); evaluate .mockResolvedValueOnce('https://gemini.google.com/app') .mockResolvedValueOnce({ turns: [{ Role: 'User', Text: '请只回复:OK' }], transcriptLines: ['baseline', '早到的提示词回声', 'OK'], composerHasText: false, isGenerating: false, structuredTurnsTrusted: true, }) .mockResolvedValueOnce('https://gemini.google.com/app') .mockResolvedValueOnce({ turns: [{ Role: 'User', Text: '请只回复:OK' }], transcriptLines: ['baseline', '早到的提示词回声', 'OK'], composerHasText: false, isGenerating: false, structuredTurnsTrusted: true, }) .mockResolvedValueOnce('https://gemini.google.com/app') .mockResolvedValueOnce({ turns: [{ Role: 'User', Text: '请只回复:OK' }], transcriptLines: ['baseline', '早到的提示词回声', 'OK'], composerHasText: false, isGenerating: false, structuredTurnsTrusted: true, }); const result = await waitForGeminiResponse(page, { snapshot: snapshot({ turns: [{ Role: 'User', Text: '请只回复:OK' }], transcriptLines: ['baseline', '早到的提示词回声'], composerHasText: false, isGenerating: true, structuredTurnsTrusted: true, }), preSendAssistantCount: 0, userAnchorTurn: { Role: 'User', Text: '请只回复:OK' }, reason: 'composer_transcript', }, '请只回复:OK', 6); expect(result).toBe('OK'); }); });