Files
jackwener__opencli/clis/gemini/reply-state.test.js
jakevin d2974a9ff6 refactor(adapters): convert adapter layer from TypeScript to JavaScript (#928)
* refactor(adapters): convert adapter layer from TypeScript to JavaScript

Core framework stays TypeScript; adapter layer moves to JS-first.
Adapters are essentially "executable config + browser scripts" that
barely use TS features — this simplifies the build/distribution pipeline
by removing the dist/clis/ intermediate compilation step.

Changes:
- Convert all 753 adapter files in clis/ from .ts to .js
- Update tsconfig to exclude clis/ from compilation
- Simplify build-manifest to scan clis/*.js directly (no dist/clis/)
- Update discovery, main, fetch-adapters to load JS adapters from clis/
- Update generate-verified to output .js artifacts
- Update package.json files field: dist/clis/ → clis/
- Fix all test files for the .ts → .js transition

* fix(main): use findPackageRoot for BUILTIN_CLIS path

The previous relative path (../../clis from __dirname) only worked for
dist/src/main.js but broke dev mode (tsx src/main.ts) where __dirname
is <repo>/src — resolving to /clis instead of <repo>/clis.

Use findPackageRoot() which works for both dev and prod paths.
2026-04-10 14:52:18 +08:00

672 lines
27 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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');
});
});