Files
jackwener__opencli/clis/gemini/ask.test.js
jakevin 12d88e4b23 refactor(runtime): unify command timeout into a single --timeout arg (#1364)
* refactor(runtime): unify command timeout into a single --timeout arg

Drop the cli-level `timeoutSeconds` build-time ceiling field. A command
now opts into runtime-enforced timeouts purely by declaring an arg named
`timeout`; the user-facing `--timeout` value (its default or override)
is the single authoritative knob, used both by the adapter polling loop
and by the runtime ceiling (with a 30s padding for return + closeWindow
+ trace export).

Behavior:
- Browser commands without a `--timeout` arg fall back to
  OPENCLI_BROWSER_COMMAND_TIMEOUT (default 60s, unchanged).
- Non-browser commands without a `--timeout` arg now run unbounded
  rather than against the previously implicit `timeoutSeconds` cap.
  Affected commands keep their old caps via newly added `--timeout` args.
- LLM adapters (gemini/claude/deepseek/doubao/qwen/yuanbao ask) keep
  their current `--timeout` defaults; the runtime ceiling is now strictly
  more generous (userTimeout + 30s vs. the previous 180s cap), so
  `--timeout 600` actually buys 600s of polling rather than dying at 180s.

Closes the design discussion that started from PR #1227, which proposed
a per-site `OPENCLI_GEMINI_ASK_TIMEOUT` env var to work around the same
underlying mismatch.

* fix(timeout): wire --timeout arg into chatgpt/gemini image adapter polling

codex-coder review on PR #1364 caught that the new --timeout arg I added
to chatgpt/image and gemini/image only drove the runtime ceiling — the
adapter still hardcoded `const timeout = 120`, so users passing
--timeout 240/600 saw runtime allow 270s/630s but the adapter stop
polling at 120s. That recreated the same single-knob mismatch this PR
was meant to delete.

Also add the browser-path runWithTimeout assertion codex-coder flagged
as missing: a browser command with --timeout default=5 must call
runWithTimeout with timeout: 35; a browser command without --timeout
arg must fall back to DEFAULT_BROWSER_COMMAND_TIMEOUT.

Image adapters now read kwargs.timeout and reject non-positive-integer
values with ArgumentError (no silent fallback). chatgpt/image.test.js
updated to pass an explicit timeout when calling .func directly (the
test bypasses arg coercion).

* fix(runtime): reject invalid timeout ceilings

* fix(timeout): normalize timeout args to integer values

* fix(timeout): preserve remaining command ceilings

* fix(runtime): validate timeout before browser setup
2026-05-06 23:30:03 +08:00

101 lines
4.4 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 { beforeEach, describe, expect, it, vi } from 'vitest';
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 mocks = vi.hoisted(() => ({
readGeminiSnapshot: 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,
readGeminiSnapshot: mocks.readGeminiSnapshot,
sendGeminiMessage: mocks.sendGeminiMessage,
startNewGeminiChat: mocks.startNewGeminiChat,
waitForGeminiSubmission: mocks.waitForGeminiSubmission,
waitForGeminiResponse: mocks.waitForGeminiResponse,
};
});
import { askCommand } from './ask.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('gemini ask orchestration', () => {
beforeEach(() => {
vi.clearAllMocks();
});
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.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.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);
});
});