mirror of
https://github.com/jackwener/OpenCLI.git
synced 2026-09-14 18:25:42 +08:00
12d88e4b23
* 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
101 lines
4.4 KiB
JavaScript
101 lines
4.4 KiB
JavaScript
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);
|
||
});
|
||
});
|