Files
wehub-resource-sync 9b395f5cc3
E2E Headed Chrome / e2e-headed (macos-15) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (windows-latest) (push) Has been cancelled
CI / build (macos-latest) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
CI / unit-test (push) Has been cancelled
CI / bun-test (push) Has been cancelled
CI / adapter-test (push) Has been cancelled
CI / smoke-test (macos-latest) (push) Has been cancelled
CI / smoke-test (ubuntu-latest) (push) Has been cancelled
Security Audit / audit (push) Has been cancelled
Build Chrome Extension / build (push) Has been cancelled
Trigger Website Rebuild (Docs Updated) / dispatch (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:39:48 +08:00

646 lines
25 KiB
JavaScript

import { beforeEach, describe, expect, it, vi } from 'vitest';
import { JSDOM } from 'jsdom';
import { CommandExecutionError } from '@jackwener/opencli/errors';
const mocks = vi.hoisted(() => ({
ensureGeminiPage: vi.fn(),
}));
vi.mock('./utils.js', async () => {
const actual = await vi.importActual('./utils.js');
return {
...actual,
ensureGeminiPage: mocks.ensureGeminiPage,
};
});
import { modelsCommand, __test__ } from './models.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),
};
}
const FIXTURE_MODEL_ROWS = [
{ model: '2.5-flash', thinkingValues: ['standard', 'extended'] },
{ model: '2.5-flash-lite', thinkingValues: ['standard'] },
{ model: '2.5-pro', thinkingValues: ['standard', 'extended'] },
{ model: '2.5-flash-thinking', thinkingValues: [] },
];
describe('gemini models', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('returns model rows with model and thinkingValues columns', async () => {
const page = createPageMock();
mocks.ensureGeminiPage.mockResolvedValue(undefined);
vi.mocked(page.evaluate).mockResolvedValueOnce({ ok: true }); // picker click
vi.mocked(page.evaluate).mockResolvedValueOnce(FIXTURE_MODEL_ROWS); // menu read
const rows = await modelsCommand.func(page);
expect(rows).toEqual(FIXTURE_MODEL_ROWS);
expect(rows[0]).toHaveProperty('model');
expect(rows[0]).toHaveProperty('thinkingValues');
expect(Array.isArray(rows[0].thinkingValues)).toBe(true);
});
it('calls ensureGeminiPage before discovery', async () => {
const page = createPageMock();
mocks.ensureGeminiPage.mockResolvedValue(undefined);
vi.mocked(page.evaluate).mockResolvedValueOnce({ ok: true });
vi.mocked(page.evaluate).mockResolvedValueOnce(FIXTURE_MODEL_ROWS);
await modelsCommand.func(page);
expect(mocks.ensureGeminiPage).toHaveBeenCalledWith(page);
expect(mocks.ensureGeminiPage).toHaveBeenCalledBefore(page.evaluate);
});
it('is read-only: does not start a new chat, send a message, or select a model', async () => {
const page = createPageMock();
mocks.ensureGeminiPage.mockResolvedValue(undefined);
vi.mocked(page.evaluate).mockResolvedValueOnce({ ok: true });
vi.mocked(page.evaluate).mockResolvedValueOnce(FIXTURE_MODEL_ROWS);
await modelsCommand.func(page);
// ensureGeminiPage always gets called, but no other stateful utils.
expect(mocks.ensureGeminiPage).toHaveBeenCalledTimes(1);
// evaluate is called 3 times: picker click, model read, close menu.
expect(page.evaluate).toHaveBeenCalledTimes(3);
});
it('unwraps Browser Bridge envelope { session, data } for picker and model rows', async () => {
const page = createPageMock();
mocks.ensureGeminiPage.mockResolvedValue(undefined);
vi.mocked(page.evaluate).mockResolvedValueOnce({ session: 'site:gemini', data: { ok: true } });
vi.mocked(page.evaluate).mockResolvedValueOnce({
session: 'site:gemini',
data: FIXTURE_MODEL_ROWS,
});
const rows = await modelsCommand.func(page);
expect(rows).toEqual(FIXTURE_MODEL_ROWS);
});
it('throws CommandExecutionError when the model picker cannot be opened', async () => {
const page = createPageMock();
mocks.ensureGeminiPage.mockResolvedValue(undefined);
vi.mocked(page.evaluate).mockResolvedValueOnce({ ok: false, reason: 'Gemini model picker button was not found' });
await expect(modelsCommand.func(page)).rejects.toThrow(/model picker/i);
});
it('throws CommandExecutionError when evaluate returns a non-array result', async () => {
const page = createPageMock();
mocks.ensureGeminiPage.mockResolvedValue(undefined);
vi.mocked(page.evaluate).mockResolvedValueOnce({ ok: true });
vi.mocked(page.evaluate).mockResolvedValueOnce({ ok: false });
await expect(modelsCommand.func(page)).rejects.toBeInstanceOf(CommandExecutionError);
});
it('throws CommandExecutionError when a row is missing the model field', async () => {
const page = createPageMock();
mocks.ensureGeminiPage.mockResolvedValue(undefined);
vi.mocked(page.evaluate).mockResolvedValueOnce({ ok: true });
vi.mocked(page.evaluate).mockResolvedValueOnce([
{ thinkingValues: ['standard'] },
]);
await expect(modelsCommand.func(page)).rejects.toBeInstanceOf(CommandExecutionError);
});
it('throws CommandExecutionError when a row has a non-array thinkingValues field', async () => {
const page = createPageMock();
mocks.ensureGeminiPage.mockResolvedValue(undefined);
vi.mocked(page.evaluate).mockResolvedValueOnce({ ok: true });
vi.mocked(page.evaluate).mockResolvedValueOnce([
{ model: '2.5-flash', thinkingValues: 'standard' },
]);
await expect(modelsCommand.func(page)).rejects.toBeInstanceOf(CommandExecutionError);
});
it('accepts rows with empty thinkingValues array', async () => {
const page = createPageMock();
mocks.ensureGeminiPage.mockResolvedValue(undefined);
vi.mocked(page.evaluate).mockResolvedValueOnce({ ok: true });
vi.mocked(page.evaluate).mockResolvedValueOnce([
{ model: '2.5-flash', thinkingValues: [] },
{ model: '2.5-pro', thinkingValues: [] },
]);
const rows = await modelsCommand.func(page);
expect(rows).toHaveLength(2);
expect(rows[0].thinkingValues).toEqual([]);
expect(rows[1].thinkingValues).toEqual([]);
});
it('keeps model values as strings and thinkingValues as string arrays', async () => {
const page = createPageMock();
mocks.ensureGeminiPage.mockResolvedValue(undefined);
vi.mocked(page.evaluate).mockResolvedValueOnce({ ok: true });
vi.mocked(page.evaluate).mockResolvedValueOnce([
{ model: '2.5-flash-lite', thinkingValues: ['standard'] },
{ model: '2.5-pro', thinkingValues: ['standard', 'extended'] },
]);
const rows = await modelsCommand.func(page);
for (const row of rows) {
expect(typeof row.model).toBe('string');
expect(Array.isArray(row.thinkingValues)).toBe(true);
for (const tv of row.thinkingValues) {
expect(typeof tv).toBe('string');
}
}
});
});
describe('gemini models command registration', () => {
it('is registered with site=gemini and name=models', () => {
expect(modelsCommand.site).toBe('gemini');
expect(modelsCommand.name).toBe('models');
});
it('is access=read (not write)', () => {
expect(modelsCommand.access).toBe('read');
});
it('declares model and thinkingValues as its output columns', () => {
expect(modelsCommand.columns).toEqual(['model', 'thinkingValues']);
});
it('has no CLI arguments', () => {
expect(modelsCommand.args).toEqual([]);
});
it('is a browser command', () => {
expect(modelsCommand.browser).toBe(true);
});
it('targets gemini.google.com', () => {
expect(modelsCommand.domain).toBe('gemini.google.com');
});
});
// ── DOM fixture tests (jsdom) ──────────────────────────────────────────────
// These tests run the actual discoverModelsScript() against pre-built DOM
// fixtures that simulate the Gemini web UI model picker and menu.
/**
* Create a JSDOM instance wired up for the discovery script.
*
* - All elements get a realistic getBoundingClientRect so isVisible() passes.
* - getComputedStyle is patched to honour inline style changes (e.g. display
* toggling when the picker menu opens).
* - Click handlers can be attached to elements before the script runs.
*/
function createGeminiDom(htmlFixture) {
const dom = new JSDOM(`<!doctype html><body>${htmlFixture}</body>`, {
pretendToBeVisual: true,
runScripts: 'outside-only',
});
const { window } = dom;
const { document } = window;
// Make every element appear to have non-zero bounding rect so isVisible
// does not reject elements merely because jsdom returns all-zero rects.
Object.defineProperty(window.HTMLElement.prototype, 'getBoundingClientRect', {
configurable: true,
value() {
return { width: 100, height: 24, top: 0, left: 0, right: 100, bottom: 24 };
},
});
return { window, document };
}
/**
* Normalize element text to a single-line, whitespace-compacted label
* suitable for use as a spy key.
*/
function normalizeLabel(el) {
const text = (el.textContent || '').replace(/\s+/g, ' ').trim();
const aria = (el.getAttribute('aria-label') || '').replace(/\s+/g, ' ').trim();
return (text || aria || '(anonymous)').slice(0, 80);
}
/**
* Run the discovery script in a jsdom window and return the result.
*/
function evalDiscoverScript(window) {
const scriptText = __test__.discoverModelsScript();
return window.eval(scriptText);
}
/**
* Build a click-spy instrumented window. Returns a `spy` object where each
* key is a normalized label of a clickable element and the value is the
* number of times that element received a click event.
*/
function instrumentClickSpy(window) {
const { document } = window;
const spy = {};
function record(label) {
spy[label] = (spy[label] || 0) + 1;
}
// Track clicks on all interactive elements.
document.querySelectorAll('button, [role="button"], [role="menuitem"], [role="option"], [role="menuitemradio"]').forEach((el) => {
const label = normalizeLabel(el);
el.addEventListener('click', () => record(label));
});
// Track body clicks (menu dismiss).
let bodyClickCount = 0;
document.body.addEventListener('click', () => {
bodyClickCount++;
});
// Patch body.click so we can read the count after script returns.
const originalBodyClick = document.body.click.bind(document.body);
document.body.click = function () {
bodyClickCount++;
return originalBodyClick();
};
// Attach the count to spy via a getter.
Object.defineProperty(spy, '(body)', {
get() { return bodyClickCount; },
enumerable: true,
configurable: true,
});
return spy;
}
describe('discoverModelsScript — DOM fixture extraction', () => {
it('extracts model rows without inferring global thinking controls as per-model support', () => {
const { window, document } = createGeminiDom(`
<header>
<button id="model-picker">2.5 Flash</button>
<button>New chat</button>
</header>
<div id="model-menu" role="menu" style="display: none;">
<div role="menuitem">2.5 Flash</div>
<div role="menuitem">2.5 Flash Lite</div>
<div role="menuitem">2.5 Pro</div>
<div role="menuitem">2.5 Flash Thinking</div>
<!-- Thinking levels as separate menu items (Gemini Web pattern) -->
<div role="menuitem">Standard</div>
<div role="menuitem">Extended</div>
</div>
`);
// Click handler opens the menu (simulating React render on click).
document.getElementById('model-picker').addEventListener('click', () => {
document.getElementById('model-menu').style.display = 'block';
});
const result = evalDiscoverScript(window);
expect(Array.isArray(result)).toBe(true);
expect(result).toHaveLength(4);
// Thinking menu items are global/current-selection controls, not a
// reliable per-model capability matrix, so they are not copied to rows.
const models = new Map(result.map((r) => [r.model, r.thinkingValues]));
expect(models.get('2.5-flash')).toEqual([]);
expect(models.get('2.5-flash-lite')).toEqual([]);
expect(models.get('2.5-pro')).toEqual([]);
expect(models.has('2.5-flash-thinking')).toBe(true);
expect(models.get('2.5-flash-thinking')).toEqual([]);
});
it('extracts models from menu items with role=option inside role=listbox', () => {
const { window, document } = createGeminiDom(`
<header>
<button id="model-picker">2.5 Pro</button>
</header>
<div id="menu" role="listbox" style="display: none;">
<div role="option">2.5 Flash</div>
<div role="option">2.5 Pro</div>
<div role="option">2.5 Flash Lite</div>
</div>
`);
document.getElementById('model-picker').addEventListener('click', () => {
document.getElementById('menu').style.display = 'block';
});
const result = evalDiscoverScript(window);
expect(Array.isArray(result)).toBe(true);
expect(result).toHaveLength(3);
const modelIds = result.map((r) => r.model).sort();
expect(modelIds).toEqual(['2.5-flash', '2.5-flash-lite', '2.5-pro']);
});
it('returns canonical model ids matching acceptance criteria examples (3.1-flash-lite, 3.5-flash, 3.1-pro)', () => {
const { window, document } = createGeminiDom(`
<button id="picker">3.1 Flash</button>
<div id="menu" role="menu" style="display: none;">
<div role="menuitem">3.1 Flash Lite</div>
<div role="menuitem">3.5 Flash</div>
<div role="menuitem">3.1 Pro</div>
</div>
`);
document.getElementById('picker').addEventListener('click', () => {
document.getElementById('menu').style.display = 'block';
});
const result = evalDiscoverScript(window);
const modelIds = result.map((r) => r.model).sort();
expect(modelIds).toEqual(['3.1-flash-lite', '3.1-pro', '3.5-flash']);
});
it('does not infer separate thinking-level menu items as per-model support', () => {
const { window, document } = createGeminiDom(`
<button id="picker">2.5 Flash</button>
<div id="menu" role="menu" style="display: none;">
<div role="menuitem">2.5 Flash</div>
<div role="menuitem">2.5 Pro</div>
<!-- Thinking levels as separate items (real Gemini Web pattern) -->
<div role="menuitem">Standard</div>
<div role="menuitem">Extended</div>
</div>
`);
document.getElementById('picker').addEventListener('click', () => {
document.getElementById('menu').style.display = 'block';
});
const result = evalDiscoverScript(window);
expect(result).toHaveLength(2);
const models = new Map(result.map((r) => [r.model, r.thinkingValues]));
expect(models.get('2.5-flash')).toEqual([]);
expect(models.get('2.5-pro')).toEqual([]);
});
it('returns empty array when model picker is absent', () => {
const { window, document } = createGeminiDom(`
<header>
<button>Settings</button>
<button>Help</button>
</header>
<div role="menu">
<div role="menuitem">Theme</div>
</div>
`);
const result = evalDiscoverScript(window);
expect(result).toEqual([]);
});
it('returns empty array when menu items contain only thinking options (no model entries)', () => {
const { window, document } = createGeminiDom(`
<button id="picker">2.5 Flash</button>
<div id="menu" role="menu" style="display: none;">
<div role="menuitem">Standard</div>
<div role="menuitem">Extended</div>
</div>
`);
document.getElementById('picker').addEventListener('click', () => {
document.getElementById('menu').style.display = 'block';
});
const result = evalDiscoverScript(window);
expect(result).toEqual([]);
});
it('deduplicates model entries with the same canonical id', () => {
const { window, document } = createGeminiDom(`
<button id="picker">2.5 Flash</button>
<div id="menu" role="menu" style="display: none;">
<div role="menuitem">2.5 Flash</div>
<div role="menuitem">2.5 Flash</div>
<div role="menuitem">2.5 Pro</div>
</div>
`);
document.getElementById('picker').addEventListener('click', () => {
document.getElementById('menu').style.display = 'block';
});
const result = evalDiscoverScript(window);
expect(result).toHaveLength(2);
const modelIds = result.map((r) => r.model).sort();
expect(modelIds).toEqual(['2.5-flash', '2.5-pro']);
});
});
describe('discoverModelsScript — read-only verification (DOM fixture)', () => {
it('only clicks the model picker and document.body, never model items', () => {
const { window, document } = createGeminiDom(`
<button id="model-picker">2.5 Flash</button>
<button id="new-chat-btn">New chat</button>
<div id="model-menu" role="menu" style="display: none;">
<div role="menuitem" id="model-flash">2.5 Flash</div>
<div role="menuitem" id="model-pro">2.5 Pro</div>
<!-- Thinking items as separate menu items -->
<div role="menuitem" id="think-standard">Standard</div>
<div role="menuitem" id="think-extended">Extended</div>
</div>
<div id="composer">
<div contenteditable="true">Enter a prompt</div>
<button id="send-btn" aria-label="Send">Send</button>
</div>
`);
// Show menu on click.
document.getElementById('model-picker').addEventListener('click', () => {
document.getElementById('model-menu').style.display = 'block';
});
const spy = instrumentClickSpy(window);
const result = evalDiscoverScript(window);
// The script must have found models (not an empty result).
expect(Array.isArray(result)).toBe(true);
expect(result.length).toBeGreaterThan(0);
// The picker button is clicked to open the menu. That's expected.
expect(spy['2.5 Flash']).toBeGreaterThanOrEqual(1);
// The script clicks document.body to close the menu. That's allowed.
expect(spy['(body)']).toBeGreaterThanOrEqual(1);
// BUT it must NOT click any model menu item, new-chat button, or send button.
expect(spy['2.5 Flash'] || 0).toBeGreaterThanOrEqual(1); // picker only
expect(spy['2.5 Pro'] || 0).toBe(0);
expect(spy['New chat'] || 0).toBe(0);
expect(spy['Send'] || 0).toBe(0);
// Thinking items as separate menuitems — should NOT be clicked.
expect(spy['Standard'] || 0).toBe(0);
expect(spy['Extended'] || 0).toBe(0);
});
it('does not click thinking controls (separate menu items without toggle)', () => {
const { window, document } = createGeminiDom(`
<button id="picker">2.5 Flash</button>
<div id="menu" role="menu" style="display: none;">
<div role="menuitem">2.5 Flash</div>
<div role="menuitem">2.5 Pro</div>
<!-- Thinking levels as separate menu items (no toggle present) -->
<div role="menuitem" id="think-standard">Standard</div>
<div role="menuitem" id="think-extended">Extended</div>
</div>
`);
document.getElementById('picker').addEventListener('click', () => {
document.getElementById('menu').style.display = 'block';
});
const spy = instrumentClickSpy(window);
const result = evalDiscoverScript(window);
expect(Array.isArray(result)).toBe(true);
expect(result.length).toBeGreaterThan(0);
// Model picker click is allowed.
expect(spy['2.5 Flash']).toBeGreaterThanOrEqual(1);
// Body click (menu dismiss) is allowed.
expect(spy['(body)']).toBeGreaterThanOrEqual(1);
// Thinking items must NOT receive clicks (no 思考等级 toggle).
expect(spy['Standard'] || 0).toBe(0);
expect(spy['Extended'] || 0).toBe(0);
});
it('does not click a new-chat button', () => {
const { window, document } = createGeminiDom(`
<button id="picker">2.5 Flash</button>
<button id="new-chat" aria-label="New chat">New chat</button>
<div id="menu" role="menu" style="display: none;">
<div role="menuitem">2.5 Flash</div>
</div>
`);
document.getElementById('picker').addEventListener('click', () => {
document.getElementById('menu').style.display = 'block';
});
const spy = instrumentClickSpy(window);
const result = evalDiscoverScript(window);
expect(Array.isArray(result)).toBe(true);
expect(spy['New chat'] || 0).toBe(0);
expect(spy['2.5 Flash']).toBeGreaterThanOrEqual(1);
});
it('does not click a send/submit button', () => {
const { window, document } = createGeminiDom(`
<button id="picker">2.5 Flash</button>
<div id="menu" role="menu" style="display: none;">
<div role="menuitem">2.5 Flash</div>
</div>
<div>
<div contenteditable="true"></div>
<button id="send" aria-label="Send">Send</button>
<button id="submit">Submit</button>
</div>
`);
document.getElementById('picker').addEventListener('click', () => {
document.getElementById('menu').style.display = 'block';
});
const spy = instrumentClickSpy(window);
const result = evalDiscoverScript(window);
expect(Array.isArray(result)).toBe(true);
expect(spy['Send'] || 0).toBe(0);
expect(spy['Submit'] || 0).toBe(0);
});
it('only clicks the picker and body — no other elements receive clicks', () => {
const { window, document } = createGeminiDom(`
<button id="picker">3.1 Flash</button>
<button>New chat</button>
<button>Settings</button>
<div id="menu" role="menu" style="display: none;">
<div role="menuitem">3.1 Flash Lite</div>
<div role="menuitem">3.5 Flash</div>
<div role="menuitem">3.1 Pro</div>
<!-- Thinking items as separate menu items (no toggle) -->
<div role="menuitem">Standard</div>
<div role="menuitem">Extended</div>
</div>
<div>
<button id="send" aria-label="Send message">Send</button>
</div>
`);
document.getElementById('picker').addEventListener('click', () => {
document.getElementById('menu').style.display = 'block';
});
const spy = instrumentClickSpy(window);
const result = evalDiscoverScript(window);
expect(Array.isArray(result)).toBe(true);
expect(result.length).toBeGreaterThan(0);
// The only elements that may receive clicks:
// - The model picker ("3.1 Flash") — to open the menu.
// - document.body — to close the menu.
for (const [label, count] of Object.entries(spy)) {
if (label === '3.1 Flash' || label === '(body)') {
expect(count).toBeGreaterThanOrEqual(1);
} else {
expect(count).toBe(0);
}
}
// Explicitly verify the dangerous controls.
expect(spy['New chat'] || 0).toBe(0);
expect(spy['Settings'] || 0).toBe(0);
// Menu items should not receive clicks.
expect(spy['3.1 Flash Lite'] || 0).toBe(0);
expect(spy['3.5 Flash'] || 0).toBe(0);
expect(spy['3.1 Pro'] || 0).toBe(0);
// Thinking controls should not receive clicks.
expect(spy['Standard'] || 0).toBe(0);
expect(spy['Extended'] || 0).toBe(0);
// Send button should not receive clicks.
expect(spy['Send message'] || 0).toBe(0);
});
});