Files
jackwener__opencli/clis/gemini/utils.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

199 lines
10 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__, collectGeminiTranscriptAdditions, pickGeminiDeepResearchExportUrl, sanitizeGeminiResponseText, sendGeminiMessage, } from './utils.js';
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('sanitizeGeminiResponseText', () => {
it('strips a prompt echo only when it appears as a prefixed block', () => {
const prompt = 'Reply with the word opencli';
const value = `Reply with the word opencli\n\nopencli`;
expect(sanitizeGeminiResponseText(value, prompt)).toBe('opencli');
});
it('does not strip prompt text that appears later in a legitimate answer', () => {
const prompt = 'opencli';
const value = 'You asked about opencli, and opencli is the right keyword here.';
expect(sanitizeGeminiResponseText(value, prompt)).toBe(value);
});
it('removes known Gemini footer noise', () => {
const value = 'Answer body\nGemini can make mistakes.\nGoogle Terms';
expect(sanitizeGeminiResponseText(value, '')).toBe('Answer body');
});
});
describe('collectGeminiTranscriptAdditions', () => {
it('joins multiple new transcript lines instead of keeping only the last line', () => {
const before = ['Older answer'];
const current = ['Older answer', 'First new line', 'Second new line'];
expect(collectGeminiTranscriptAdditions(before, current, '')).toBe('First new line\nSecond new line');
});
it('filters prompt echoes out of transcript additions', () => {
const prompt = 'Tell me a haiku';
const before = ['Previous'];
const current = ['Previous', 'Tell me a haiku', 'Tell me a haiku\n\nSoft spring rain arrives'];
expect(collectGeminiTranscriptAdditions(before, current, prompt)).toBe('Soft spring rain arrives');
});
it('keeps a reply line that quotes the prompt inside the answer body', () => {
const prompt = '请只回复:OK';
const before = ['baseline'];
const current = ['baseline', '关于“请只回复:OK”,这里是解释。'];
expect(collectGeminiTranscriptAdditions(before, current, prompt)).toBe('关于“请只回复:OK”,这里是解释。');
});
});
describe('gemini send strategy', () => {
it('includes structural composer selectors instead of relying only on english aria labels', () => {
expect(__test__.GEMINI_COMPOSER_SELECTORS).toContain('.ql-editor[contenteditable="true"]');
expect(__test__.GEMINI_COMPOSER_SELECTORS).toContain('.ql-editor[role="textbox"]');
});
it('prefers native text insertion before submitting the composer', async () => {
const page = createPageMock();
const evaluate = vi.mocked(page.evaluate);
const nativeType = vi.mocked(page.nativeType);
const nativeKeyPress = vi.mocked(page.nativeKeyPress);
evaluate
.mockResolvedValueOnce('https://gemini.google.com/app')
.mockResolvedValueOnce({ ok: true })
.mockResolvedValueOnce({ hasText: true })
.mockResolvedValueOnce('button');
const result = await sendGeminiMessage(page, '你好');
expect(nativeType).toHaveBeenCalledWith('你好');
expect(nativeKeyPress).not.toHaveBeenCalled();
expect(result).toBe('button');
});
it('falls back when native insertion does not update the composer', async () => {
const page = createPageMock();
const evaluate = vi.mocked(page.evaluate);
const nativeType = vi.mocked(page.nativeType);
const nativeKeyPress = vi.mocked(page.nativeKeyPress);
evaluate
.mockResolvedValueOnce('https://gemini.google.com/app')
.mockResolvedValueOnce({ ok: true })
.mockResolvedValueOnce({ hasText: false })
.mockResolvedValueOnce({ hasText: true })
.mockResolvedValueOnce('enter');
const result = await sendGeminiMessage(page, '你好');
expect(nativeType).toHaveBeenCalledWith('你好');
expect(nativeKeyPress).toHaveBeenCalledWith('Enter');
expect(evaluate).toHaveBeenCalledTimes(5);
expect(result).toBe('enter');
});
it('falls back when native insertion throws', async () => {
const page = createPageMock();
const evaluate = vi.mocked(page.evaluate);
const nativeType = vi.mocked(page.nativeType);
nativeType.mockRejectedValueOnce(new Error('Unknown action: cdp'));
evaluate
.mockResolvedValueOnce('https://gemini.google.com/app')
.mockResolvedValueOnce({ ok: true })
.mockResolvedValueOnce({ hasText: true })
.mockResolvedValueOnce('button');
const result = await sendGeminiMessage(page, '你好');
expect(nativeType).toHaveBeenCalledWith('你好');
expect(result).toBe('button');
});
it('retries composer preparation until a slow-loading composer appears', async () => {
const page = createPageMock();
const evaluate = vi.mocked(page.evaluate);
const wait = vi.mocked(page.wait);
evaluate
.mockResolvedValueOnce('https://gemini.google.com/app')
.mockResolvedValueOnce({ ok: false, reason: 'Could not find Gemini composer' })
.mockResolvedValueOnce({ ok: false, reason: 'Could not find Gemini composer' })
.mockResolvedValueOnce({ ok: true })
.mockResolvedValueOnce({ hasText: true })
.mockResolvedValueOnce('button');
const result = await sendGeminiMessage(page, '你好');
expect(result).toBe('button');
expect(wait.mock.calls.filter(([value]) => value === 1)).toHaveLength(3);
});
it('keeps retrying until a composer that appears on the fourth attempt is ready', async () => {
const page = createPageMock();
const evaluate = vi.mocked(page.evaluate);
const wait = vi.mocked(page.wait);
evaluate
.mockResolvedValueOnce('https://gemini.google.com/app')
.mockResolvedValueOnce({ ok: false, reason: 'Could not find Gemini composer' })
.mockResolvedValueOnce({ ok: false, reason: 'Could not find Gemini composer' })
.mockResolvedValueOnce({ ok: false, reason: 'Could not find Gemini composer' })
.mockResolvedValueOnce({ ok: true })
.mockResolvedValueOnce({ hasText: true })
.mockResolvedValueOnce('button');
const result = await sendGeminiMessage(page, '你好');
expect(result).toBe('button');
expect(wait.mock.calls.filter(([value]) => value === 1)).toHaveLength(4);
});
it('avoids innerHTML in the fallback insertion path for trusted types pages', () => {
expect(__test__.insertComposerTextFallbackScript('你好')).not.toContain('innerHTML');
expect(__test__.insertComposerTextFallbackScript('你好')).toContain('replaceChildren');
});
it('keeps a button submit path in the generated submit script', () => {
expect(__test__.submitComposerScript()).toContain('.click()');
});
it('supports localized new chat labels in the generated new-chat script', () => {
expect(__test__.clickNewChatScript()).toContain('发起新对话');
});
});
describe('gemini turn normalization', () => {
it('collapses only adjacent duplicate turns so identical replies across rounds remain visible', () => {
const turns = [
{ Role: 'User', Text: '你说\n\n请只回复:OK' },
{ Role: 'User', Text: '请只回复:OK' },
{ Role: 'Assistant', Text: 'OK' },
{ Role: 'Assistant', Text: 'OK' },
{ Role: 'User', Text: '你说\n\n请只回复:OK' },
{ Role: 'User', Text: '请只回复:OK' },
{ Role: 'Assistant', Text: 'OK' },
{ Role: 'Assistant', Text: 'OK' },
];
expect(__test__.collapseAdjacentGeminiTurns(turns)).toEqual([
{ Role: 'User', Text: '你说\n\n请只回复:OK' },
{ Role: 'User', Text: '请只回复:OK' },
{ Role: 'Assistant', Text: 'OK' },
{ Role: 'User', Text: '你说\n\n请只回复:OK' },
{ Role: 'User', Text: '请只回复:OK' },
{ Role: 'Assistant', Text: 'OK' },
]);
});
});
describe('pickGeminiDeepResearchExportUrl', () => {
it('prefers docs.google.com document url over sheets and noise endpoints', () => {
const picked = pickGeminiDeepResearchExportUrl([
'xhr::https://gemini.google.com/_/BardChatUi/data/batchexecute?rpcids=ESY5D',
'performance::https://docs.google.com/spreadsheets/d/1abc/edit',
'open::https://docs.google.com/document/d/1docid/edit',
], 'https://gemini.google.com/app/abc');
expect(picked).toEqual({
url: 'https://docs.google.com/document/d/1docid/edit',
source: 'window-open',
});
});
it('returns none when only non-export telemetry urls are present', () => {
const picked = pickGeminiDeepResearchExportUrl([
'fetch::https://gemini.google.com/_/BardChatUi/cspreport',
'performance::https://www.google-analytics.com/g/collect?v=2',
], 'https://gemini.google.com/app/abc');
expect(picked).toEqual({ url: '', source: 'none' });
});
});