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
1262 lines
55 KiB
JavaScript
1262 lines
55 KiB
JavaScript
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
|
|
const baseline = {
|
|
turns: [{ Role: 'Assistant', Text: '旧回答' }],
|
|
transcriptLines: ['baseline'],
|
|
composerHasText: true,
|
|
isGenerating: false,
|
|
structuredTurnsTrusted: true,
|
|
};
|
|
const submission = {
|
|
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',
|
|
};
|
|
|
|
const FIXTURE_MODELS = [
|
|
{ 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: [] },
|
|
];
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
ensureGeminiPage: vi.fn(),
|
|
getCurrentGeminiModel: vi.fn(),
|
|
readGeminiSnapshot: vi.fn(),
|
|
selectGeminiModel: vi.fn(),
|
|
selectGeminiThinking: vi.fn(),
|
|
sendGeminiMessage: vi.fn(),
|
|
startNewGeminiChat: vi.fn(),
|
|
waitForGeminiSubmission: vi.fn(),
|
|
waitForGeminiResponse: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('./utils.js', async () => {
|
|
const actual = await vi.importActual('./utils.js');
|
|
return {
|
|
...actual,
|
|
ensureGeminiPage: mocks.ensureGeminiPage,
|
|
getCurrentGeminiModel: mocks.getCurrentGeminiModel,
|
|
readGeminiSnapshot: mocks.readGeminiSnapshot,
|
|
selectGeminiModel: mocks.selectGeminiModel,
|
|
selectGeminiThinking: mocks.selectGeminiThinking,
|
|
sendGeminiMessage: mocks.sendGeminiMessage,
|
|
startNewGeminiChat: mocks.startNewGeminiChat,
|
|
waitForGeminiSubmission: mocks.waitForGeminiSubmission,
|
|
waitForGeminiResponse: mocks.waitForGeminiResponse,
|
|
};
|
|
});
|
|
|
|
// Mock models.js — stub the split discovery script functions.
|
|
const modelsMock = vi.hoisted(() => ({
|
|
pickModelPickerScript: vi.fn().mockReturnValue('__PICKER_SCRIPT__'),
|
|
readMenuModelsScript: vi.fn().mockReturnValue('__READ_MODELS_SCRIPT__'),
|
|
clickThinkingToggleScript: vi.fn().mockReturnValue('__TOGGLE_SCRIPT__'),
|
|
extractThinkingScript: vi.fn().mockReturnValue('__EXTRACT_THINKING_SCRIPT__'),
|
|
}));
|
|
vi.mock('./models.js', () => ({
|
|
pickModelPickerScript: modelsMock.pickModelPickerScript,
|
|
readMenuModelsScript: modelsMock.readMenuModelsScript,
|
|
clickThinkingToggleScript: modelsMock.clickThinkingToggleScript,
|
|
extractThinkingScript: modelsMock.extractThinkingScript,
|
|
__test__: {
|
|
pickModelPickerScript: modelsMock.pickModelPickerScript,
|
|
readMenuModelsScript: modelsMock.readMenuModelsScript,
|
|
clickThinkingToggleScript: modelsMock.clickThinkingToggleScript,
|
|
extractThinkingScript: modelsMock.extractThinkingScript,
|
|
},
|
|
}));
|
|
|
|
import { askCommand, __test__ } from './ask.js';
|
|
|
|
const { validateAskModelValue } = __test__;
|
|
|
|
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),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Set up page.evaluate mocks for the multi-step discovery flow used by ask.
|
|
*
|
|
* The ask command opens the picker (evaluate → { ok }), waits, reads
|
|
* models (evaluate → models[]), then closes the menu (evaluate → anything).
|
|
*/
|
|
function setupDiscoveryMocks(page, models = FIXTURE_MODELS) {
|
|
vi.mocked(page.evaluate).mockResolvedValueOnce({ ok: true }); // picker click
|
|
vi.mocked(page.evaluate).mockResolvedValueOnce(models); // read models
|
|
vi.mocked(page.evaluate).mockResolvedValueOnce(undefined); // close menu
|
|
}
|
|
|
|
function setupDiscoveryWithoutThinking(page, models = FIXTURE_MODELS) {
|
|
vi.mocked(page.evaluate).mockResolvedValueOnce({ ok: true });
|
|
vi.mocked(page.evaluate).mockResolvedValueOnce(models);
|
|
vi.mocked(page.evaluate).mockResolvedValueOnce(undefined);
|
|
}
|
|
|
|
function setupFailedDiscovery(page, reason = 'Model picker button not found') {
|
|
vi.mocked(page.evaluate).mockResolvedValueOnce({ ok: false, reason });
|
|
}
|
|
|
|
function setupDiscoveryNonArray(page) {
|
|
vi.mocked(page.evaluate).mockResolvedValueOnce({ ok: true });
|
|
vi.mocked(page.evaluate).mockResolvedValueOnce({ not: 'an array' });
|
|
vi.mocked(page.evaluate).mockResolvedValueOnce(undefined);
|
|
}
|
|
|
|
function setupDiscoveryEmpty(page) {
|
|
vi.mocked(page.evaluate).mockResolvedValueOnce({ ok: true });
|
|
vi.mocked(page.evaluate).mockResolvedValueOnce([]);
|
|
vi.mocked(page.evaluate).mockResolvedValueOnce(undefined);
|
|
}
|
|
|
|
// ── Command registration ─────────────────────────────────────────────────
|
|
|
|
describe('ask command registration', () => {
|
|
it('is registered with site=gemini and name=ask', () => {
|
|
expect(askCommand.site).toBe('gemini');
|
|
expect(askCommand.name).toBe('ask');
|
|
});
|
|
|
|
it('is access=write', () => {
|
|
expect(askCommand.access).toBe('write');
|
|
});
|
|
|
|
it('declares response as its output column', () => {
|
|
expect(askCommand.columns).toEqual(['response']);
|
|
});
|
|
|
|
it('includes --model as an optional string argument', () => {
|
|
const modelArg = askCommand.args.find((a) => a.name === 'model');
|
|
expect(modelArg).toBeDefined();
|
|
expect(modelArg.required).toBe(false);
|
|
expect(modelArg.type).toBe('string');
|
|
});
|
|
|
|
it('includes --thinking as an optional argument', () => {
|
|
const thinkingArg = askCommand.args.find((a) => a.name === 'thinking');
|
|
expect(thinkingArg).toBeDefined();
|
|
expect(thinkingArg.required).toBe(false);
|
|
});
|
|
|
|
it('still includes prompt, timeout, and new args', () => {
|
|
const names = askCommand.args.map((a) => a.name);
|
|
expect(names).toContain('prompt');
|
|
expect(names).toContain('timeout');
|
|
expect(names).toContain('new');
|
|
expect(names).toContain('model');
|
|
expect(names).toContain('thinking');
|
|
});
|
|
});
|
|
|
|
// ── validateAskModelValue ────────────────────────────────────────────────
|
|
|
|
describe('validateAskModelValue', () => {
|
|
it('accepts canonical model ids', () => {
|
|
expect(() => validateAskModelValue('2.5-flash')).not.toThrow();
|
|
expect(() => validateAskModelValue('2.5-flash-lite')).not.toThrow();
|
|
expect(() => validateAskModelValue('2.5-pro')).not.toThrow();
|
|
expect(() => validateAskModelValue('2.5-flash-thinking')).not.toThrow();
|
|
expect(() => validateAskModelValue('3.1-flash-lite')).not.toThrow();
|
|
expect(() => validateAskModelValue('3.5-flash')).not.toThrow();
|
|
expect(() => validateAskModelValue('3.1-pro')).not.toThrow();
|
|
});
|
|
|
|
it('rejects short aliases without version numbers', () => {
|
|
const msg = 'is not accepted';
|
|
|
|
expect(() => validateAskModelValue('pro')).toThrow(ArgumentError);
|
|
expect(() => validateAskModelValue('pro')).toThrow(msg);
|
|
expect(() => validateAskModelValue('flash')).toThrow(ArgumentError);
|
|
expect(() => validateAskModelValue('flash')).toThrow(msg);
|
|
expect(() => validateAskModelValue('flash-lite')).toThrow(ArgumentError);
|
|
expect(() => validateAskModelValue('flash-lite')).toThrow(msg);
|
|
expect(() => validateAskModelValue('lite')).toThrow(ArgumentError);
|
|
expect(() => validateAskModelValue('thinking')).toThrow(ArgumentError);
|
|
});
|
|
|
|
it('rejects empty string', () => {
|
|
expect(() => validateAskModelValue('')).toThrow(ArgumentError);
|
|
});
|
|
|
|
it('rejects values missing a variant after version', () => {
|
|
expect(() => validateAskModelValue('2.5')).toThrow(ArgumentError);
|
|
});
|
|
|
|
it('rejects inverted format (variant first)', () => {
|
|
expect(() => validateAskModelValue('flash-2.5')).toThrow(ArgumentError);
|
|
});
|
|
|
|
it('mentions opencli gemini models in error messages', () => {
|
|
try { validateAskModelValue('pro'); } catch (e) {
|
|
expect(e.message).toContain('opencli gemini models');
|
|
}
|
|
try { validateAskModelValue('2.5'); } catch (e) {
|
|
expect(e.message).toContain('opencli gemini models');
|
|
}
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Helper: set up the two evaluate calls needed for thinking validation:
|
|
* 1. discoverModelsScript (via page.evaluate)
|
|
* 2. getCurrentGeminiModel (mocked separately)
|
|
*/
|
|
function setupDiscoveryAndModel(page, discoveredModels, currentModel) {
|
|
// Multi-step discovery: picker click → read models → close.
|
|
vi.mocked(page.evaluate).mockResolvedValueOnce({ ok: true });
|
|
vi.mocked(page.evaluate).mockResolvedValueOnce(discoveredModels);
|
|
vi.mocked(page.evaluate).mockResolvedValueOnce(undefined); // close
|
|
mocks.getCurrentGeminiModel.mockResolvedValueOnce(currentModel);
|
|
}
|
|
|
|
/**
|
|
* Helper: set up the full happy-path mocks after thinking validation.
|
|
*/
|
|
function setupHappyPath() {
|
|
mocks.selectGeminiThinking.mockResolvedValueOnce('Selected');
|
|
mocks.readGeminiSnapshot.mockResolvedValueOnce(baseline);
|
|
mocks.sendGeminiMessage.mockResolvedValueOnce('button');
|
|
mocks.waitForGeminiSubmission.mockResolvedValueOnce(submission);
|
|
mocks.waitForGeminiResponse.mockResolvedValueOnce('OK');
|
|
}
|
|
|
|
// ── gemini ask orchestration ─────────────────────────────────────────────
|
|
|
|
describe('gemini ask orchestration', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
// ── Baseline behavior (no --model) ───────────────────────────────────
|
|
|
|
it('captures baseline, sends, waits for confirmed submission, then waits with the remaining timeout', async () => {
|
|
vi.spyOn(Date, 'now')
|
|
.mockReturnValueOnce(0)
|
|
.mockReturnValueOnce(2000);
|
|
const page = createPageMock();
|
|
mocks.ensureGeminiPage.mockResolvedValue(undefined);
|
|
mocks.readGeminiSnapshot.mockResolvedValueOnce(baseline);
|
|
mocks.sendGeminiMessage.mockResolvedValueOnce('button');
|
|
mocks.waitForGeminiSubmission.mockResolvedValueOnce(submission);
|
|
mocks.waitForGeminiResponse.mockResolvedValueOnce('OK');
|
|
|
|
const result = await askCommand.func(page, { prompt: '请只回复:OK', timeout: 20, new: 'false' });
|
|
|
|
expect(mocks.readGeminiSnapshot).toHaveBeenCalledWith(page);
|
|
expect(mocks.waitForGeminiSubmission).toHaveBeenCalledWith(page, baseline, 20);
|
|
expect(mocks.waitForGeminiResponse).toHaveBeenCalledWith(page, submission, '请只回复:OK', 18);
|
|
expect(result).toEqual([{ response: '💬 OK' }]);
|
|
});
|
|
|
|
it('does not spend extra response wait time after submission has already consumed the full timeout budget', async () => {
|
|
vi.spyOn(Date, 'now')
|
|
.mockReturnValueOnce(0)
|
|
.mockReturnValueOnce(20000);
|
|
const page = createPageMock();
|
|
mocks.ensureGeminiPage.mockResolvedValue(undefined);
|
|
mocks.readGeminiSnapshot.mockResolvedValueOnce(baseline);
|
|
mocks.sendGeminiMessage.mockResolvedValueOnce('button');
|
|
mocks.waitForGeminiSubmission.mockResolvedValueOnce(submission);
|
|
mocks.waitForGeminiResponse.mockResolvedValueOnce('');
|
|
|
|
await askCommand.func(page, { prompt: '请只回复:OK', timeout: 20, new: 'false' });
|
|
|
|
expect(mocks.waitForGeminiResponse).toHaveBeenCalledWith(page, submission, '请只回复:OK', 0);
|
|
});
|
|
|
|
// ── Omitted --model (no model change) ────────────────────────────────
|
|
|
|
it('does not call ensureGeminiPage or selectGeminiModel when --model is omitted', async () => {
|
|
const page = createPageMock();
|
|
mocks.ensureGeminiPage.mockResolvedValue(undefined);
|
|
mocks.readGeminiSnapshot.mockResolvedValueOnce(baseline);
|
|
mocks.sendGeminiMessage.mockResolvedValueOnce('button');
|
|
mocks.waitForGeminiSubmission.mockResolvedValueOnce(submission);
|
|
mocks.waitForGeminiResponse.mockResolvedValueOnce('OK');
|
|
|
|
await askCommand.func(page, { prompt: 'hello', timeout: 10, new: 'false' });
|
|
|
|
// ensureGeminiPage may be called internally by readGeminiSnapshot/sendGeminiMessage
|
|
// but selectGeminiModel must NOT be called when --model is absent.
|
|
expect(mocks.selectGeminiModel).not.toHaveBeenCalled();
|
|
// page.evaluate (for model discovery) must NOT be called.
|
|
expect(page.evaluate).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not call selectGeminiModel when --model is undefined', async () => {
|
|
const page = createPageMock();
|
|
mocks.ensureGeminiPage.mockResolvedValue(undefined);
|
|
mocks.readGeminiSnapshot.mockResolvedValueOnce(baseline);
|
|
mocks.sendGeminiMessage.mockResolvedValueOnce('button');
|
|
mocks.waitForGeminiSubmission.mockResolvedValueOnce(submission);
|
|
mocks.waitForGeminiResponse.mockResolvedValueOnce('OK');
|
|
|
|
await askCommand.func(page, { prompt: 'hello', timeout: 10, new: 'false' });
|
|
|
|
expect(mocks.selectGeminiModel).not.toHaveBeenCalled();
|
|
});
|
|
|
|
// ── Valid model selection ────────────────────────────────────────────
|
|
|
|
it('selects the model before readGeminiSnapshot when --model is provided', async () => {
|
|
const page = createPageMock();
|
|
mocks.ensureGeminiPage.mockResolvedValue(undefined);
|
|
setupDiscoveryWithoutThinking(page);
|
|
mocks.selectGeminiModel.mockResolvedValueOnce(undefined);
|
|
mocks.readGeminiSnapshot.mockResolvedValueOnce(baseline);
|
|
mocks.sendGeminiMessage.mockResolvedValueOnce('button');
|
|
mocks.waitForGeminiSubmission.mockResolvedValueOnce(submission);
|
|
mocks.waitForGeminiResponse.mockResolvedValueOnce('OK');
|
|
|
|
await askCommand.func(page, { prompt: 'hello', model: '2.5-flash', timeout: 10, new: 'false' });
|
|
|
|
// Model discovery and selection happen first.
|
|
expect(mocks.ensureGeminiPage).toHaveBeenCalledWith(page);
|
|
expect(mocks.ensureGeminiPage).toHaveBeenCalledBefore(page.evaluate);
|
|
expect(page.evaluate).toHaveBeenCalledTimes(3); // picker click, read models, close
|
|
expect(mocks.selectGeminiModel).toHaveBeenCalledWith(page, '2.5-flash');
|
|
|
|
// Model selection happens before readGeminiSnapshot.
|
|
expect(mocks.selectGeminiModel).toHaveBeenCalled();
|
|
expect(mocks.readGeminiSnapshot).toHaveBeenCalled();
|
|
// Verify ordering: selectGeminiModel called before readGeminiSnapshot
|
|
const selectCallOrder = mocks.selectGeminiModel.mock.invocationCallOrder[0];
|
|
const snapshotCallOrder = mocks.readGeminiSnapshot.mock.invocationCallOrder[0];
|
|
expect(selectCallOrder).toBeLessThan(snapshotCallOrder);
|
|
});
|
|
|
|
it('selects the model before sending the prompt', async () => {
|
|
const page = createPageMock();
|
|
mocks.ensureGeminiPage.mockResolvedValue(undefined);
|
|
setupDiscoveryWithoutThinking(page);
|
|
mocks.selectGeminiModel.mockResolvedValueOnce(undefined);
|
|
mocks.readGeminiSnapshot.mockResolvedValueOnce(baseline);
|
|
mocks.sendGeminiMessage.mockResolvedValueOnce('button');
|
|
mocks.waitForGeminiSubmission.mockResolvedValueOnce(submission);
|
|
mocks.waitForGeminiResponse.mockResolvedValueOnce('OK');
|
|
|
|
await askCommand.func(page, { prompt: 'hello', model: '2.5-flash', timeout: 10, new: 'false' });
|
|
|
|
const selectCallOrder = mocks.selectGeminiModel.mock.invocationCallOrder[0];
|
|
const sendCallOrder = mocks.sendGeminiMessage.mock.invocationCallOrder[0];
|
|
expect(selectCallOrder).toBeLessThan(sendCallOrder);
|
|
});
|
|
|
|
it('selects 2.5-pro model successfully', async () => {
|
|
const page = createPageMock();
|
|
mocks.ensureGeminiPage.mockResolvedValue(undefined);
|
|
setupDiscoveryWithoutThinking(page);
|
|
mocks.selectGeminiModel.mockResolvedValueOnce(undefined);
|
|
mocks.readGeminiSnapshot.mockResolvedValueOnce(baseline);
|
|
mocks.sendGeminiMessage.mockResolvedValueOnce('button');
|
|
mocks.waitForGeminiSubmission.mockResolvedValueOnce(submission);
|
|
mocks.waitForGeminiResponse.mockResolvedValueOnce('OK');
|
|
|
|
const result = await askCommand.func(page, { prompt: 'hello', model: '2.5-pro', timeout: 10, new: 'false' });
|
|
|
|
expect(mocks.selectGeminiModel).toHaveBeenCalledWith(page, '2.5-pro');
|
|
expect(result).toEqual([{ response: '💬 OK' }]);
|
|
});
|
|
|
|
it('selects 2.5-flash-lite model successfully', async () => {
|
|
const page = createPageMock();
|
|
mocks.ensureGeminiPage.mockResolvedValue(undefined);
|
|
setupDiscoveryWithoutThinking(page);
|
|
mocks.selectGeminiModel.mockResolvedValueOnce(undefined);
|
|
mocks.readGeminiSnapshot.mockResolvedValueOnce(baseline);
|
|
mocks.sendGeminiMessage.mockResolvedValueOnce('button');
|
|
mocks.waitForGeminiSubmission.mockResolvedValueOnce(submission);
|
|
mocks.waitForGeminiResponse.mockResolvedValueOnce('OK');
|
|
|
|
const result = await askCommand.func(page, { prompt: 'hello', model: '2.5-flash-lite', timeout: 10, new: 'false' });
|
|
|
|
expect(mocks.selectGeminiModel).toHaveBeenCalledWith(page, '2.5-flash-lite');
|
|
expect(result).toEqual([{ response: '💬 OK' }]);
|
|
});
|
|
|
|
// ── Alias rejection ──────────────────────────────────────────────────
|
|
|
|
it('throws ArgumentError for alias "pro" before any model discovery', async () => {
|
|
const page = createPageMock();
|
|
mocks.ensureGeminiPage.mockResolvedValue(undefined);
|
|
|
|
await expect(
|
|
askCommand.func(page, { prompt: 'hello', model: 'pro', timeout: 10, new: 'false' })
|
|
).rejects.toBeInstanceOf(ArgumentError);
|
|
|
|
// Must not call page.evaluate (no discovery attempted).
|
|
expect(page.evaluate).not.toHaveBeenCalled();
|
|
expect(mocks.selectGeminiModel).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('throws ArgumentError for alias "flash" before any model discovery', async () => {
|
|
const page = createPageMock();
|
|
|
|
await expect(
|
|
askCommand.func(page, { prompt: 'hello', model: 'flash', timeout: 10, new: 'false' })
|
|
).rejects.toBeInstanceOf(ArgumentError);
|
|
|
|
expect(page.evaluate).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('throws ArgumentError for alias "flash-lite" before any model discovery', async () => {
|
|
const page = createPageMock();
|
|
|
|
await expect(
|
|
askCommand.func(page, { prompt: 'hello', model: 'flash-lite', timeout: 10, new: 'false' })
|
|
).rejects.toBeInstanceOf(ArgumentError);
|
|
|
|
expect(page.evaluate).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('rejects aliases with error message mentioning canonical ids and opencli gemini models', async () => {
|
|
const page = createPageMock();
|
|
|
|
try {
|
|
await askCommand.func(page, { prompt: 'hello', model: 'pro', timeout: 10, new: 'false' });
|
|
expect.fail('Expected ArgumentError');
|
|
} catch (e) {
|
|
expect(e).toBeInstanceOf(ArgumentError);
|
|
expect(e.message).toContain('not accepted');
|
|
expect(e.message).toContain('opencli gemini models');
|
|
}
|
|
});
|
|
|
|
// ── Invalid / unavailable model ──────────────────────────────────────
|
|
|
|
it('throws ArgumentError for a model id not in the discovered list', async () => {
|
|
const page = createPageMock();
|
|
mocks.ensureGeminiPage.mockResolvedValue(undefined);
|
|
setupDiscoveryWithoutThinking(page);
|
|
|
|
await expect(
|
|
askCommand.func(page, { prompt: 'hello', model: '99.9-nonexistent', timeout: 10, new: 'false' })
|
|
).rejects.toBeInstanceOf(ArgumentError);
|
|
|
|
// Discovery was attempted.
|
|
expect(page.evaluate).toHaveBeenCalledTimes(3);
|
|
// But model was not selected.
|
|
expect(mocks.selectGeminiModel).not.toHaveBeenCalled();
|
|
// And no snapshot or send happened.
|
|
expect(mocks.readGeminiSnapshot).not.toHaveBeenCalled();
|
|
expect(mocks.sendGeminiMessage).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('includes available models and suggests opencli gemini models in invalid-model error', async () => {
|
|
const page = createPageMock();
|
|
mocks.ensureGeminiPage.mockResolvedValue(undefined);
|
|
setupDiscoveryWithoutThinking(page);
|
|
|
|
try {
|
|
await askCommand.func(page, { prompt: 'hello', model: '99.9-nonexistent', timeout: 10, new: 'false' });
|
|
expect.fail('Expected ArgumentError');
|
|
} catch (e) {
|
|
expect(e).toBeInstanceOf(ArgumentError);
|
|
expect(e.message).toContain('99.9-nonexistent');
|
|
expect(e.message).toContain('Available models');
|
|
expect(e.message).toContain('2.5-flash');
|
|
expect(e.message).toContain('2.5-pro');
|
|
expect(e.message).toContain('opencli gemini models');
|
|
}
|
|
});
|
|
|
|
it('throws when model discovery returns non-array data', async () => {
|
|
const page = createPageMock();
|
|
mocks.ensureGeminiPage.mockResolvedValue(undefined);
|
|
setupDiscoveryNonArray(page);
|
|
|
|
await expect(
|
|
askCommand.func(page, { prompt: 'hello', model: '2.5-flash', timeout: 10, new: 'false' })
|
|
).rejects.toBeInstanceOf(CommandExecutionError);
|
|
});
|
|
|
|
it('typed-fails malformed Browser Bridge envelopes during model discovery', async () => {
|
|
const page = createPageMock();
|
|
mocks.ensureGeminiPage.mockResolvedValue(undefined);
|
|
vi.mocked(page.evaluate).mockResolvedValueOnce({ ok: true });
|
|
vi.mocked(page.evaluate).mockResolvedValueOnce({ session: 'site:gemini' });
|
|
|
|
await expect(
|
|
askCommand.func(page, { prompt: 'hello', model: '2.5-flash', timeout: 10, new: 'false' })
|
|
).rejects.toBeInstanceOf(CommandExecutionError);
|
|
|
|
expect(mocks.selectGeminiModel).not.toHaveBeenCalled();
|
|
expect(mocks.readGeminiSnapshot).not.toHaveBeenCalled();
|
|
expect(mocks.sendGeminiMessage).not.toHaveBeenCalled();
|
|
});
|
|
|
|
// ── Browser Bridge envelope unwrap ───────────────────────────────────
|
|
|
|
it('unwraps Browser Bridge envelope { session, data } for model discovery', async () => {
|
|
const page = createPageMock();
|
|
mocks.ensureGeminiPage.mockResolvedValue(undefined);
|
|
// The envelope is returned by readMenuModelsScript.
|
|
vi.mocked(page.evaluate).mockResolvedValueOnce({ ok: true }); // picker click
|
|
vi.mocked(page.evaluate).mockResolvedValueOnce({
|
|
session: 'site:gemini',
|
|
data: FIXTURE_MODELS,
|
|
}); // read models
|
|
vi.mocked(page.evaluate).mockResolvedValueOnce(false); // toggle
|
|
vi.mocked(page.evaluate).mockResolvedValueOnce(undefined); // close
|
|
mocks.selectGeminiModel.mockResolvedValueOnce(undefined);
|
|
mocks.readGeminiSnapshot.mockResolvedValueOnce(baseline);
|
|
mocks.sendGeminiMessage.mockResolvedValueOnce('button');
|
|
mocks.waitForGeminiSubmission.mockResolvedValueOnce(submission);
|
|
mocks.waitForGeminiResponse.mockResolvedValueOnce('OK');
|
|
|
|
const result = await askCommand.func(page, { prompt: 'hello', model: '2.5-flash', timeout: 10, new: 'false' });
|
|
|
|
expect(mocks.selectGeminiModel).toHaveBeenCalledWith(page, '2.5-flash');
|
|
expect(result).toEqual([{ response: '💬 OK' }]);
|
|
});
|
|
|
|
it('handles empty model discovery result gracefully', async () => {
|
|
const page = createPageMock();
|
|
mocks.ensureGeminiPage.mockResolvedValue(undefined);
|
|
setupDiscoveryEmpty(page);
|
|
|
|
await expect(
|
|
askCommand.func(page, { prompt: 'hello', model: '2.5-flash', timeout: 10, new: 'false' })
|
|
).rejects.toBeInstanceOf(CommandExecutionError);
|
|
});
|
|
|
|
// ── Ordering and state ───────────────────────────────────────────────
|
|
|
|
it('does not restore previous model after completion', async () => {
|
|
// The ask command selects the model and does not revert it.
|
|
// This test verifies that selectGeminiModel is only called once
|
|
// (for selection) and never called again (for restoration).
|
|
const page = createPageMock();
|
|
mocks.ensureGeminiPage.mockResolvedValue(undefined);
|
|
setupDiscoveryWithoutThinking(page);
|
|
mocks.selectGeminiModel.mockResolvedValueOnce(undefined);
|
|
mocks.readGeminiSnapshot.mockResolvedValueOnce(baseline);
|
|
mocks.sendGeminiMessage.mockResolvedValueOnce('button');
|
|
mocks.waitForGeminiSubmission.mockResolvedValueOnce(submission);
|
|
mocks.waitForGeminiResponse.mockResolvedValueOnce('OK');
|
|
|
|
await askCommand.func(page, { prompt: 'hello', model: '2.5-flash', timeout: 10, new: 'false' });
|
|
|
|
// selectGeminiModel is called exactly once (for selection, not restoration).
|
|
expect(mocks.selectGeminiModel).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('does not mutate other Gemini commands — only ask accepts --model', () => {
|
|
// Other Gemini commands (image, deep research, etc.) are in separate
|
|
// files and do not import model selection. This test confirms ask
|
|
// is the only command that declares --model within gemini.
|
|
const modelArg = askCommand.args.find((a) => a.name === 'model');
|
|
expect(modelArg).toBeDefined();
|
|
// The model arg is on ask only; other commands are unchanged.
|
|
});
|
|
|
|
// ── Model selection + --new interaction ──────────────────────────────
|
|
|
|
it('starts new chat, then selects model when both --model and --new=true', async () => {
|
|
const page = createPageMock();
|
|
mocks.ensureGeminiPage.mockResolvedValue(undefined);
|
|
mocks.startNewGeminiChat.mockResolvedValueOnce('clicked');
|
|
setupDiscoveryWithoutThinking(page);
|
|
mocks.selectGeminiModel.mockResolvedValueOnce(undefined);
|
|
mocks.readGeminiSnapshot.mockResolvedValueOnce(baseline);
|
|
mocks.sendGeminiMessage.mockResolvedValueOnce('button');
|
|
mocks.waitForGeminiSubmission.mockResolvedValueOnce(submission);
|
|
mocks.waitForGeminiResponse.mockResolvedValueOnce('OK');
|
|
|
|
await askCommand.func(page, { prompt: 'hello', model: '2.5-flash', timeout: 10, new: 'true' });
|
|
|
|
// New chat happens first, then model selection.
|
|
const newChatCallOrder = mocks.startNewGeminiChat.mock.invocationCallOrder[0];
|
|
const selectCallOrder = mocks.selectGeminiModel.mock.invocationCallOrder[0];
|
|
expect(newChatCallOrder).toBeLessThan(selectCallOrder);
|
|
});
|
|
});
|
|
|
|
describe('gemini ask thinking selection', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
// ── Valid thinking selection (current model matched) ─────────────
|
|
it('selects standard thinking when current model supports it', async () => {
|
|
vi.spyOn(Date, 'now')
|
|
.mockReturnValueOnce(0)
|
|
.mockReturnValueOnce(2000);
|
|
const page = createPageMock();
|
|
setupDiscoveryAndModel(page,
|
|
[{ model: '2.5-flash', thinkingValues: ['standard', 'extended'] }],
|
|
'2.5-flash',
|
|
);
|
|
setupHappyPath();
|
|
|
|
const result = await askCommand.func(page, {
|
|
prompt: '请只回复:OK', timeout: 20, new: 'false', thinking: 'standard',
|
|
});
|
|
|
|
expect(mocks.selectGeminiThinking).toHaveBeenCalledWith(page, 'standard');
|
|
const selectCall = mocks.selectGeminiThinking.mock.invocationCallOrder[0];
|
|
const snapshotCall = mocks.readGeminiSnapshot.mock.invocationCallOrder[0];
|
|
expect(selectCall).toBeLessThan(snapshotCall);
|
|
expect(result).toEqual([{ response: '💬 OK' }]);
|
|
});
|
|
|
|
it('selects extended thinking when current model supports it', async () => {
|
|
vi.spyOn(Date, 'now')
|
|
.mockReturnValueOnce(0)
|
|
.mockReturnValueOnce(2000);
|
|
const page = createPageMock();
|
|
setupDiscoveryAndModel(page,
|
|
[{ model: '2.5-pro', thinkingValues: ['standard', 'extended'] }],
|
|
'2.5-pro',
|
|
);
|
|
setupHappyPath();
|
|
|
|
const result = await askCommand.func(page, {
|
|
prompt: 'test', timeout: 20, new: 'false', thinking: 'extended',
|
|
});
|
|
|
|
expect(mocks.selectGeminiThinking).toHaveBeenCalledWith(page, 'extended');
|
|
expect(result[0].response).toContain('OK');
|
|
});
|
|
|
|
it('selects thinking with --new true: new chat before thinking before snapshot', async () => {
|
|
vi.spyOn(Date, 'now')
|
|
.mockReturnValueOnce(0)
|
|
.mockReturnValueOnce(2000);
|
|
const page = createPageMock();
|
|
setupDiscoveryAndModel(page,
|
|
[{ model: '2.5-flash', thinkingValues: ['standard', 'extended'] }],
|
|
'2.5-flash',
|
|
);
|
|
mocks.startNewGeminiChat.mockResolvedValueOnce('navigate');
|
|
setupHappyPath();
|
|
|
|
await askCommand.func(page, {
|
|
prompt: 'test', timeout: 20, new: 'true', thinking: 'standard',
|
|
});
|
|
|
|
const newChatCall = mocks.startNewGeminiChat.mock.invocationCallOrder[0];
|
|
const thinkingCall = mocks.selectGeminiThinking.mock.invocationCallOrder[0];
|
|
const snapshotCall = mocks.readGeminiSnapshot.mock.invocationCallOrder[0];
|
|
expect(newChatCall).toBeLessThan(thinkingCall);
|
|
expect(thinkingCall).toBeLessThan(snapshotCall);
|
|
});
|
|
|
|
// ── Invalid thinking value (not standard or extended) ────────────
|
|
it('rejects invalid thinking value with ArgumentError', async () => {
|
|
const page = createPageMock();
|
|
let err;
|
|
try {
|
|
await askCommand.func(page, {
|
|
prompt: 'test', timeout: 20, thinking: 'invalid',
|
|
});
|
|
} catch (e) { err = e; }
|
|
expect(err).toBeInstanceOf(ArgumentError);
|
|
expect(err.message).toContain("--thinking must be 'standard' or 'extended'");
|
|
expect(mocks.selectGeminiThinking).not.toHaveBeenCalled();
|
|
expect(mocks.readGeminiSnapshot).not.toHaveBeenCalled();
|
|
expect(mocks.sendGeminiMessage).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('rejects empty thinking value', async () => {
|
|
const page = createPageMock();
|
|
let err;
|
|
try {
|
|
await askCommand.func(page, { prompt: 'test', timeout: 20, thinking: '' });
|
|
} catch (e) { err = e; }
|
|
expect(err).toBeInstanceOf(ArgumentError);
|
|
expect(err.message).toContain("--thinking must be 'standard' or 'extended'");
|
|
});
|
|
|
|
// ── Current-model-scoped validation ──────────────────────────────
|
|
it('rejects thinking not available for the current model (cross-model mismatch)', async () => {
|
|
const page = createPageMock();
|
|
// Two models: 2.5-flash supports 'standard' only, 2.5-pro supports extended.
|
|
// Current model is 2.5-flash, but user requests 'extended'.
|
|
setupDiscoveryAndModel(page,
|
|
[
|
|
{ model: '2.5-flash', thinkingValues: ['standard'] },
|
|
{ model: '2.5-pro', thinkingValues: ['standard', 'extended'] },
|
|
],
|
|
'2.5-flash',
|
|
);
|
|
|
|
let err;
|
|
try {
|
|
await askCommand.func(page, {
|
|
prompt: 'test', timeout: 20, thinking: 'extended',
|
|
});
|
|
} catch (e) { err = e; }
|
|
expect(err).toBeInstanceOf(ArgumentError);
|
|
expect(err.message).toContain("not available for the current model");
|
|
expect(err.message).toContain("2.5-flash");
|
|
expect(err.hint).toContain("standard");
|
|
// Must NOT mention 'extended' as available (it's not on this model).
|
|
expect(err.hint).not.toContain("extended");
|
|
|
|
expect(mocks.selectGeminiThinking).not.toHaveBeenCalled();
|
|
expect(mocks.readGeminiSnapshot).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('attempts thinking selection when current model has no reliable per-model thinking data', async () => {
|
|
const page = createPageMock();
|
|
setupDiscoveryAndModel(page,
|
|
[{ model: '2.5-flash-lite', thinkingValues: [] }],
|
|
'2.5-flash-lite',
|
|
);
|
|
mocks.selectGeminiThinking.mockResolvedValueOnce('');
|
|
|
|
let err;
|
|
try {
|
|
await askCommand.func(page, {
|
|
prompt: 'test', timeout: 20, thinking: 'standard',
|
|
});
|
|
} catch (e) { err = e; }
|
|
expect(err).toBeInstanceOf(ArgumentError);
|
|
expect(err.message).toContain("Could not select thinking level");
|
|
|
|
expect(mocks.selectGeminiThinking).toHaveBeenCalledWith(page, 'standard');
|
|
});
|
|
|
|
// ── Union fallback when current model unknown ────────────────────
|
|
it('falls back to union validation when current model is empty', async () => {
|
|
const page = createPageMock();
|
|
setupDiscoveryAndModel(page,
|
|
[{ model: '2.5-flash', thinkingValues: ['standard'] }],
|
|
'', // current model unknown → union fallback
|
|
);
|
|
|
|
let err;
|
|
try {
|
|
await askCommand.func(page, {
|
|
prompt: 'test', timeout: 20, thinking: 'extended',
|
|
});
|
|
} catch (e) { err = e; }
|
|
expect(err).toBeInstanceOf(ArgumentError);
|
|
expect(err.message).toContain("not currently available");
|
|
expect(err.hint).toContain("standard");
|
|
});
|
|
|
|
it('falls back to union when current model not found in discovered set', async () => {
|
|
const page = createPageMock();
|
|
setupDiscoveryAndModel(page,
|
|
[{ model: '2.5-flash', thinkingValues: ['standard'] }],
|
|
'3.0-unknown-model', // not in discovered set
|
|
);
|
|
|
|
let err;
|
|
try {
|
|
await askCommand.func(page, {
|
|
prompt: 'test', timeout: 20, thinking: 'extended',
|
|
});
|
|
} catch (e) { err = e; }
|
|
expect(err).toBeInstanceOf(ArgumentError);
|
|
expect(err.message).toContain("not currently available");
|
|
});
|
|
|
|
// ── Graceful degradation when discovery returns empty ────────────
|
|
it('proceeds when discovery returns empty', async () => {
|
|
vi.spyOn(Date, 'now')
|
|
.mockReturnValueOnce(0)
|
|
.mockReturnValueOnce(2000);
|
|
const page = createPageMock();
|
|
setupDiscoveryAndModel(page, [], '');
|
|
setupHappyPath();
|
|
|
|
const result = await askCommand.func(page, {
|
|
prompt: 'test', timeout: 20, thinking: 'standard',
|
|
});
|
|
expect(mocks.selectGeminiThinking).toHaveBeenCalledWith(page, 'standard');
|
|
expect(result[0].response).toContain('OK');
|
|
});
|
|
|
|
it('does not reject empty thinkingValues as unsupported because they mean unknown support', async () => {
|
|
vi.spyOn(Date, 'now')
|
|
.mockReturnValueOnce(0)
|
|
.mockReturnValueOnce(2000);
|
|
const page = createPageMock();
|
|
setupDiscoveryAndModel(page,
|
|
[{ model: '2.5-flash', thinkingValues: [] }],
|
|
'2.5-flash',
|
|
);
|
|
setupHappyPath();
|
|
|
|
const result = await askCommand.func(page, {
|
|
prompt: 'test', timeout: 20, thinking: 'extended',
|
|
});
|
|
|
|
expect(mocks.selectGeminiThinking).toHaveBeenCalledWith(page, 'extended');
|
|
expect(result[0].response).toContain('OK');
|
|
});
|
|
|
|
// ── Failed thinking selection ────────────────────────────────────
|
|
it('throws when selectGeminiThinking returns empty after successful validation', async () => {
|
|
const page = createPageMock();
|
|
setupDiscoveryAndModel(page,
|
|
[{ model: '2.5-flash', thinkingValues: ['standard', 'extended'] }],
|
|
'2.5-flash',
|
|
);
|
|
mocks.selectGeminiThinking.mockResolvedValueOnce(''); // failed selection
|
|
|
|
let err;
|
|
try {
|
|
await askCommand.func(page, {
|
|
prompt: 'test', timeout: 20, thinking: 'standard',
|
|
});
|
|
} catch (e) { err = e; }
|
|
expect(err).toBeInstanceOf(ArgumentError);
|
|
expect(err.message).toContain("Could not select thinking level");
|
|
|
|
// Must not proceed to snapshot or send.
|
|
expect(mocks.readGeminiSnapshot).not.toHaveBeenCalled();
|
|
expect(mocks.sendGeminiMessage).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('failed selection hint includes model-specific info when current model known', async () => {
|
|
const page = createPageMock();
|
|
setupDiscoveryAndModel(page,
|
|
[{ model: '2.5-flash', thinkingValues: ['standard', 'extended'] }],
|
|
'2.5-flash',
|
|
);
|
|
mocks.selectGeminiThinking.mockResolvedValueOnce('');
|
|
|
|
let err;
|
|
try {
|
|
await askCommand.func(page, {
|
|
prompt: 'test', timeout: 20, thinking: 'extended',
|
|
});
|
|
} catch (e) { err = e; }
|
|
expect(err).toBeInstanceOf(ArgumentError);
|
|
expect(err.hint).toContain("2.5-flash");
|
|
expect(err.hint).toContain("standard");
|
|
expect(err.hint).toContain("extended");
|
|
expect(err.hint).toContain("gemini models");
|
|
});
|
|
|
|
// ── Omitted thinking ─────────────────────────────────────────────
|
|
it('omitted --thinking does not call selectGeminiThinking', async () => {
|
|
vi.spyOn(Date, 'now')
|
|
.mockReturnValueOnce(0)
|
|
.mockReturnValueOnce(2000);
|
|
const page = createPageMock();
|
|
mocks.readGeminiSnapshot.mockResolvedValueOnce(baseline);
|
|
mocks.sendGeminiMessage.mockResolvedValueOnce('button');
|
|
mocks.waitForGeminiSubmission.mockResolvedValueOnce(submission);
|
|
mocks.waitForGeminiResponse.mockResolvedValueOnce('OK');
|
|
|
|
await askCommand.func(page, { prompt: 'test', timeout: 20, new: 'false' });
|
|
expect(mocks.selectGeminiThinking).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('null thinking does not call selectGeminiThinking', async () => {
|
|
vi.spyOn(Date, 'now')
|
|
.mockReturnValueOnce(0)
|
|
.mockReturnValueOnce(2000);
|
|
const page = createPageMock();
|
|
mocks.readGeminiSnapshot.mockResolvedValueOnce(baseline);
|
|
mocks.sendGeminiMessage.mockResolvedValueOnce('button');
|
|
mocks.waitForGeminiSubmission.mockResolvedValueOnce(submission);
|
|
mocks.waitForGeminiResponse.mockResolvedValueOnce('OK');
|
|
|
|
await askCommand.func(page, { prompt: 'test', timeout: 20, thinking: null });
|
|
expect(mocks.selectGeminiThinking).not.toHaveBeenCalled();
|
|
});
|
|
|
|
// ── Case insensitivity ───────────────────────────────────────────
|
|
it('accepts uppercase thinking value', async () => {
|
|
vi.spyOn(Date, 'now')
|
|
.mockReturnValueOnce(0)
|
|
.mockReturnValueOnce(2000);
|
|
const page = createPageMock();
|
|
setupDiscoveryAndModel(page,
|
|
[{ model: '2.5-flash', thinkingValues: ['standard', 'extended'] }],
|
|
'2.5-flash',
|
|
);
|
|
setupHappyPath();
|
|
|
|
const result = await askCommand.func(page, {
|
|
prompt: 'test', timeout: 20, thinking: 'STANDARD',
|
|
});
|
|
expect(mocks.selectGeminiThinking).toHaveBeenCalledWith(page, 'standard');
|
|
expect(result[0].response).toContain('OK');
|
|
});
|
|
|
|
it('accepts mixed-case thinking value', async () => {
|
|
vi.spyOn(Date, 'now')
|
|
.mockReturnValueOnce(0)
|
|
.mockReturnValueOnce(2000);
|
|
const page = createPageMock();
|
|
setupDiscoveryAndModel(page,
|
|
[{ model: '2.5-flash', thinkingValues: ['standard', 'extended'] }],
|
|
'2.5-flash',
|
|
);
|
|
setupHappyPath();
|
|
|
|
const result = await askCommand.func(page, {
|
|
prompt: 'test', timeout: 20, thinking: 'ExTeNdEd',
|
|
});
|
|
expect(mocks.selectGeminiThinking).toHaveBeenCalledWith(page, 'extended');
|
|
expect(result[0].response).toContain('OK');
|
|
});
|
|
|
|
// ── Does not restore thinking after completion ───────────────────
|
|
it('does not call selectGeminiThinking again after send', async () => {
|
|
vi.spyOn(Date, 'now')
|
|
.mockReturnValueOnce(0)
|
|
.mockReturnValueOnce(2000);
|
|
const page = createPageMock();
|
|
setupDiscoveryAndModel(page,
|
|
[{ model: '2.5-flash', thinkingValues: ['standard', 'extended'] }],
|
|
'2.5-flash',
|
|
);
|
|
setupHappyPath();
|
|
|
|
await askCommand.func(page, {
|
|
prompt: 'test', timeout: 20, thinking: 'standard',
|
|
});
|
|
expect(mocks.selectGeminiThinking).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
// ── --model + --thinking integration ───────────────────────────
|
|
it('scopes thinking validation to the selected model when --model is supplied', async () => {
|
|
vi.spyOn(Date, 'now')
|
|
.mockReturnValueOnce(0)
|
|
.mockReturnValueOnce(2000);
|
|
const page = createPageMock();
|
|
mocks.ensureGeminiPage.mockResolvedValue(undefined);
|
|
setupDiscoveryMocks(page);
|
|
mocks.selectGeminiModel.mockResolvedValueOnce(undefined);
|
|
// getCurrentGeminiModel should NOT be called because --model overrides it
|
|
mocks.getCurrentGeminiModel.mockResolvedValue('2.5-flash');
|
|
setupHappyPath();
|
|
|
|
const result = await askCommand.func(page, {
|
|
prompt: 'test', timeout: 20, new: 'false',
|
|
model: '2.5-pro', thinking: 'extended',
|
|
});
|
|
|
|
// Thinking validated against selected model (2.5-pro), not current (2.5-flash).
|
|
expect(mocks.selectGeminiThinking).toHaveBeenCalledWith(page, 'extended');
|
|
// getCurrentGeminiModel should NOT have been called when --model is supplied.
|
|
expect(mocks.getCurrentGeminiModel).not.toHaveBeenCalled();
|
|
expect(result[0].response).toContain('OK');
|
|
});
|
|
|
|
it('rejects thinking not available for the selected model (--model + --thinking mismatch)', async () => {
|
|
const page = createPageMock();
|
|
mocks.ensureGeminiPage.mockResolvedValue(undefined);
|
|
// Multi-step discovery WITHOUT thinking extraction so per-model
|
|
// thinkingValues from the discovery data are preserved.
|
|
setupDiscoveryWithoutThinking(page, [
|
|
{ model: '2.5-flash', thinkingValues: ['standard'] },
|
|
{ model: '2.5-pro', thinkingValues: ['standard', 'extended'] },
|
|
]);
|
|
mocks.selectGeminiModel.mockResolvedValueOnce(undefined);
|
|
|
|
let err;
|
|
try {
|
|
await askCommand.func(page, {
|
|
prompt: 'test', timeout: 20,
|
|
model: '2.5-flash', thinking: 'extended',
|
|
});
|
|
} catch (e) { err = e; }
|
|
expect(err).toBeInstanceOf(ArgumentError);
|
|
expect(err.message).toContain("not available for the selected model");
|
|
expect(err.message).toContain("2.5-flash");
|
|
expect(err.hint).toContain("standard");
|
|
expect(err.hint).not.toContain("extended");
|
|
|
|
expect(mocks.selectGeminiThinking).not.toHaveBeenCalled();
|
|
expect(mocks.readGeminiSnapshot).not.toHaveBeenCalled();
|
|
});
|
|
|
|
|
|
it('does not treat global/current thinking controls as selected-model support when rows have no per-model data', async () => {
|
|
vi.spyOn(Date, 'now')
|
|
.mockReturnValueOnce(0)
|
|
.mockReturnValueOnce(2000);
|
|
const page = createPageMock();
|
|
mocks.ensureGeminiPage.mockResolvedValue(undefined);
|
|
setupDiscoveryWithoutThinking(page, [
|
|
{ model: '2.5-flash', thinkingValues: [] },
|
|
{ model: '2.5-pro', thinkingValues: [] },
|
|
]);
|
|
mocks.selectGeminiModel.mockResolvedValueOnce(undefined);
|
|
setupHappyPath();
|
|
|
|
const result = await askCommand.func(page, {
|
|
prompt: 'test', timeout: 20,
|
|
model: '2.5-flash', thinking: 'extended',
|
|
});
|
|
|
|
expect(mocks.selectGeminiThinking).toHaveBeenCalledWith(page, 'extended');
|
|
expect(result[0].response).toContain('OK');
|
|
});
|
|
|
|
it('unwraps Browser Bridge envelope for thinking discovery before scoped validation', async () => {
|
|
const page = createPageMock();
|
|
mocks.ensureGeminiPage.mockResolvedValue(undefined);
|
|
// Multi-step discovery: picker click → envelope-wrapped read → close.
|
|
vi.mocked(page.evaluate).mockResolvedValueOnce({ ok: true });
|
|
vi.mocked(page.evaluate).mockResolvedValueOnce({
|
|
session: 'site:gemini',
|
|
data: [
|
|
{ model: '2.5-flash', thinkingValues: ['standard'] },
|
|
{ model: '2.5-pro', thinkingValues: ['standard', 'extended'] },
|
|
],
|
|
});
|
|
vi.mocked(page.evaluate).mockResolvedValueOnce(undefined); // close
|
|
mocks.selectGeminiModel.mockResolvedValueOnce(undefined);
|
|
|
|
let err;
|
|
try {
|
|
await askCommand.func(page, {
|
|
prompt: 'test', timeout: 20,
|
|
model: '2.5-flash', thinking: 'extended',
|
|
});
|
|
} catch (e) { err = e; }
|
|
expect(err).toBeInstanceOf(ArgumentError);
|
|
expect(err.message).toContain('not available for the selected model');
|
|
expect(err.hint).toContain('standard');
|
|
expect(err.hint).not.toContain('extended');
|
|
|
|
expect(mocks.selectGeminiThinking).not.toHaveBeenCalled();
|
|
expect(mocks.readGeminiSnapshot).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('uses current model for scoping when --model is omitted (unchanged behavior)', async () => {
|
|
const page = createPageMock();
|
|
setupDiscoveryAndModel(page,
|
|
[{ model: '2.5-flash', thinkingValues: ['standard', 'extended'] }],
|
|
'2.5-flash',
|
|
);
|
|
setupHappyPath();
|
|
|
|
const result = await askCommand.func(page, {
|
|
prompt: 'test', timeout: 20, new: 'false', thinking: 'standard',
|
|
});
|
|
|
|
expect(mocks.getCurrentGeminiModel).toHaveBeenCalled();
|
|
expect(mocks.selectGeminiThinking).toHaveBeenCalledWith(page, 'standard');
|
|
expect(result[0].response).toContain('OK');
|
|
});
|
|
|
|
// ── Error message suggests gemini models ────────────────────────
|
|
it('error hint suggests gemini models for invalid values', async () => {
|
|
const page = createPageMock();
|
|
let err;
|
|
try {
|
|
await askCommand.func(page, { prompt: 'test', timeout: 20, thinking: 'bad-value' });
|
|
} catch (e) { err = e; }
|
|
expect(err).toBeInstanceOf(ArgumentError);
|
|
expect(err.hint).toContain('gemini models');
|
|
});
|
|
|
|
it('invalid thinking value caught by first check does not trigger discovery', async () => {
|
|
const page = createPageMock();
|
|
try {
|
|
await askCommand.func(page, { prompt: 'test', timeout: 20, thinking: 'bad' });
|
|
} catch (_) {}
|
|
// First check should catch it; neither discovery nor current-model lookup runs.
|
|
expect(page.evaluate).not.toHaveBeenCalled();
|
|
expect(mocks.getCurrentGeminiModel).not.toHaveBeenCalled();
|
|
});
|
|
|
|
// ── Combined --model + --thinking + --new true ordering ─────────
|
|
|
|
it('discovers models once when both --model and --thinking are supplied', async () => {
|
|
vi.spyOn(Date, 'now')
|
|
.mockReturnValueOnce(0)
|
|
.mockReturnValueOnce(2000);
|
|
const page = createPageMock();
|
|
mocks.ensureGeminiPage.mockResolvedValue(undefined);
|
|
setupDiscoveryMocks(page);
|
|
mocks.selectGeminiModel.mockResolvedValueOnce(undefined);
|
|
setupHappyPath();
|
|
|
|
await askCommand.func(page, {
|
|
prompt: 'test', timeout: 20, new: 'false',
|
|
model: '2.5-flash', thinking: 'standard',
|
|
});
|
|
|
|
// page.evaluate is called 3 times (picker, read, close).
|
|
expect(page.evaluate).toHaveBeenCalledTimes(3);
|
|
});
|
|
|
|
it('applies model and thinking before snapshot when both flags are supplied (no --new)', async () => {
|
|
vi.spyOn(Date, 'now')
|
|
.mockReturnValueOnce(0)
|
|
.mockReturnValueOnce(2000);
|
|
const page = createPageMock();
|
|
mocks.ensureGeminiPage.mockResolvedValue(undefined);
|
|
setupDiscoveryMocks(page);
|
|
mocks.selectGeminiModel.mockResolvedValueOnce(undefined);
|
|
setupHappyPath();
|
|
|
|
await askCommand.func(page, {
|
|
prompt: 'test', timeout: 20, new: 'false',
|
|
model: '2.5-flash', thinking: 'standard',
|
|
});
|
|
|
|
// Model selection before thinking selection.
|
|
const modelCall = mocks.selectGeminiModel.mock.invocationCallOrder[0];
|
|
const thinkingCall = mocks.selectGeminiThinking.mock.invocationCallOrder[0];
|
|
expect(modelCall).toBeLessThan(thinkingCall);
|
|
|
|
// Thinking selection before snapshot.
|
|
const snapshotCall = mocks.readGeminiSnapshot.mock.invocationCallOrder[0];
|
|
expect(thinkingCall).toBeLessThan(snapshotCall);
|
|
});
|
|
|
|
it('applies --new true, model, and thinking in correct order', async () => {
|
|
vi.spyOn(Date, 'now')
|
|
.mockReturnValueOnce(0)
|
|
.mockReturnValueOnce(2000);
|
|
const page = createPageMock();
|
|
mocks.ensureGeminiPage.mockResolvedValue(undefined);
|
|
mocks.startNewGeminiChat.mockResolvedValueOnce('clicked');
|
|
setupDiscoveryMocks(page);
|
|
mocks.selectGeminiModel.mockResolvedValueOnce(undefined);
|
|
setupHappyPath();
|
|
|
|
await askCommand.func(page, {
|
|
prompt: 'test', timeout: 20,
|
|
new: 'true', model: '2.5-flash', thinking: 'standard',
|
|
});
|
|
|
|
// Order: new chat → model selection → thinking selection → snapshot.
|
|
const newCall = mocks.startNewGeminiChat.mock.invocationCallOrder[0];
|
|
const modelCall = mocks.selectGeminiModel.mock.invocationCallOrder[0];
|
|
const thinkingCall = mocks.selectGeminiThinking.mock.invocationCallOrder[0];
|
|
const snapshotCall = mocks.readGeminiSnapshot.mock.invocationCallOrder[0];
|
|
expect(newCall).toBeLessThan(modelCall);
|
|
expect(modelCall).toBeLessThan(thinkingCall);
|
|
expect(thinkingCall).toBeLessThan(snapshotCall);
|
|
});
|
|
|
|
it('stops before snapshot and send when combined validation fails (invalid model)', async () => {
|
|
const page = createPageMock();
|
|
mocks.ensureGeminiPage.mockResolvedValue(undefined);
|
|
setupDiscoveryMocks(page);
|
|
|
|
let err;
|
|
try {
|
|
await askCommand.func(page, {
|
|
prompt: 'test', timeout: 20,
|
|
model: '99.9-nonexistent', thinking: 'standard',
|
|
});
|
|
} catch (e) { err = e; }
|
|
|
|
expect(err).toBeInstanceOf(ArgumentError);
|
|
expect(err.message).toContain('Unknown model');
|
|
// Must not proceed to snapshot, thinking selection, or send.
|
|
expect(mocks.selectGeminiModel).not.toHaveBeenCalled();
|
|
expect(mocks.selectGeminiThinking).not.toHaveBeenCalled();
|
|
expect(mocks.readGeminiSnapshot).not.toHaveBeenCalled();
|
|
expect(mocks.sendGeminiMessage).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('stops before snapshot and send when combined validation fails (thinking unavailable)', async () => {
|
|
const page = createPageMock();
|
|
mocks.ensureGeminiPage.mockResolvedValue(undefined);
|
|
setupDiscoveryWithoutThinking(page, [
|
|
{ model: '2.5-flash-lite', thinkingValues: ['extended'] },
|
|
{ model: '2.5-pro', thinkingValues: ['standard', 'extended'] },
|
|
]);
|
|
mocks.selectGeminiModel.mockResolvedValueOnce(undefined);
|
|
|
|
let err;
|
|
try {
|
|
await askCommand.func(page, {
|
|
prompt: 'test', timeout: 20,
|
|
model: '2.5-flash-lite', thinking: 'standard',
|
|
});
|
|
} catch (e) { err = e; }
|
|
|
|
expect(err).toBeInstanceOf(ArgumentError);
|
|
expect(err.message).toContain('not available for the selected model');
|
|
// Must not proceed to snapshot or send.
|
|
expect(mocks.selectGeminiThinking).not.toHaveBeenCalled();
|
|
expect(mocks.readGeminiSnapshot).not.toHaveBeenCalled();
|
|
expect(mocks.sendGeminiMessage).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not change response extraction when model and thinking are selected', async () => {
|
|
vi.spyOn(Date, 'now')
|
|
.mockReturnValueOnce(0)
|
|
.mockReturnValueOnce(2000);
|
|
const page = createPageMock();
|
|
mocks.ensureGeminiPage.mockResolvedValue(undefined);
|
|
setupDiscoveryMocks(page);
|
|
mocks.selectGeminiModel.mockResolvedValueOnce(undefined);
|
|
setupHappyPath();
|
|
|
|
const result = await askCommand.func(page, {
|
|
prompt: '请只回复:OK', timeout: 20, new: 'false',
|
|
model: '2.5-flash', thinking: 'standard',
|
|
});
|
|
|
|
// Response extraction is unchanged: same prefix, same wait logic.
|
|
expect(mocks.readGeminiSnapshot).toHaveBeenCalledWith(page);
|
|
expect(mocks.waitForGeminiSubmission).toHaveBeenCalledWith(page, baseline, 20);
|
|
expect(mocks.waitForGeminiResponse).toHaveBeenCalledWith(page, submission, '请只回复:OK', 18);
|
|
expect(result).toEqual([{ response: '💬 OK' }]);
|
|
});
|
|
|
|
it('does not restore model or thinking after completion (combined flags)', async () => {
|
|
vi.spyOn(Date, 'now')
|
|
.mockReturnValueOnce(0)
|
|
.mockReturnValueOnce(2000);
|
|
const page = createPageMock();
|
|
mocks.ensureGeminiPage.mockResolvedValue(undefined);
|
|
setupDiscoveryMocks(page);
|
|
mocks.selectGeminiModel.mockResolvedValueOnce(undefined);
|
|
setupHappyPath();
|
|
|
|
await askCommand.func(page, {
|
|
prompt: 'test', timeout: 20, new: 'false',
|
|
model: '2.5-flash', thinking: 'standard',
|
|
});
|
|
|
|
// Both selectGeminiModel and selectGeminiThinking are called exactly once
|
|
// (for selection, not for restoration).
|
|
expect(mocks.selectGeminiModel).toHaveBeenCalledTimes(1);
|
|
expect(mocks.selectGeminiThinking).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|