From 52396b2da2efdcf4be9f6185c3edbd1b81a22c2a Mon Sep 17 00:00:00 2001 From: jakevin Date: Wed, 1 Jul 2026 00:59:03 +0800 Subject: [PATCH 01/27] chore(browser): extract network interceptor script (#2042) --- src/browser/network-interceptor.ts | 11 +++++++++++ src/cli.ts | 13 +------------ 2 files changed, 12 insertions(+), 12 deletions(-) create mode 100644 src/browser/network-interceptor.ts diff --git a/src/browser/network-interceptor.ts b/src/browser/network-interceptor.ts new file mode 100644 index 000000000..1b3f786c8 --- /dev/null +++ b/src/browser/network-interceptor.ts @@ -0,0 +1,11 @@ +/** + * Injected page-side network interceptor. + * + * Used when the session-level capture channel (CDP/extension) is unavailable. + * It captures fetch/XHR response bodies while matching the CDP path's + * truncation contract: bodies above the per-entry cap are stored as a string + * prefix with `bodyTruncated: true` and `bodyFullSize` set. + * + * Keep this script dependency-free; it executes in the target page context. + */ +export const NETWORK_INTERCEPTOR_JS = `(function(){if(window.__opencli_net)return;window.__opencli_net=[];var M=200,B=1048576,F=window.fetch;function capture(url,method,status,text,ct){if(window.__opencli_net.length>=M)return;var full=text?text.length:0,trunc=full>B,stored=trunc?text.slice(0,B):text,body=null;if(stored){if(trunc){body=stored}else{try{body=JSON.parse(stored)}catch(e){body=stored}}}var e={url:url,method:method||'GET',status:status,size:full,ct:ct,body:body,timestamp:Date.now()};if(trunc){e.bodyTruncated=true;e.bodyFullSize=full}window.__opencli_net.push(e)}window.fetch=async function(){var r=await F.apply(this,arguments);try{var ct=r.headers.get('content-type')||'';if(ct.includes('json')||ct.includes('text')){var c=r.clone(),t=await c.text();capture(r.url||(arguments[0]&&arguments[0].url)||String(arguments[0]),(arguments[1]&&arguments[1].method)||'GET',r.status,t,ct)}}catch(e){}return r};var X=XMLHttpRequest.prototype,O=X.open,S=X.send;X.open=function(m,u){this._om=m;this._ou=u;return O.apply(this,arguments)};X.send=function(){var x=this;x.addEventListener('load',function(){try{var ct=x.getResponseHeader('content-type')||'';if(ct.includes('json')||ct.includes('text')){capture(x._ou,x._om||'GET',x.status,x.responseText||'',ct)}}catch(e){}});return S.apply(this,arguments)}})()`; diff --git a/src/cli.ts b/src/cli.ts index 801146af0..5e89145b4 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -27,6 +27,7 @@ import { buildFindJs, buildSemanticFindJs, isFindError, type FindResult, type Fi import { inferShape } from './browser/shape.js'; import { assignKeys } from './browser/network-key.js'; import { DEFAULT_TTL_MS, findEntry, loadNetworkCache, saveNetworkCache, type CachedNetworkEntry } from './browser/network-cache.js'; +import { NETWORK_INTERCEPTOR_JS } from './browser/network-interceptor.js'; import { parseFilter, shapeMatchesFilter } from './browser/shape-filter.js'; import { buildHtmlTreeJs, type HtmlTreeResult } from './browser/html-tree.js'; import { buildExtractHtmlJs, runExtractFromHtml } from './browser/extract.js'; @@ -1154,18 +1155,6 @@ Examples: // ── Navigation ── - /** - * Network interceptor JS — injected on every open/navigate to capture - * fetch/XHR bodies when the session-level capture channel (CDP/extension) - * isn't available. Keeps parity with the CDP path's truncation contract: - * when a body exceeds the per-entry cap, we keep a string prefix and set - * `bodyTruncated: true` + `bodyFullSize: ` so `browser - * network` can propagate a visible signal to the agent instead of - * silently dropping the body. Per-entry cap is 1 MiB and the ring is - * capped at 200 entries, bounding worst-case in-page memory. - */ - const NETWORK_INTERCEPTOR_JS = `(function(){if(window.__opencli_net)return;window.__opencli_net=[];var M=200,B=1048576,F=window.fetch;function capture(url,method,status,text,ct){if(window.__opencli_net.length>=M)return;var full=text?text.length:0,trunc=full>B,stored=trunc?text.slice(0,B):text,body=null;if(stored){if(trunc){body=stored}else{try{body=JSON.parse(stored)}catch(e){body=stored}}}var e={url:url,method:method||'GET',status:status,size:full,ct:ct,body:body,timestamp:Date.now()};if(trunc){e.bodyTruncated=true;e.bodyFullSize=full}window.__opencli_net.push(e)}window.fetch=async function(){var r=await F.apply(this,arguments);try{var ct=r.headers.get('content-type')||'';if(ct.includes('json')||ct.includes('text')){var c=r.clone(),t=await c.text();capture(r.url||(arguments[0]&&arguments[0].url)||String(arguments[0]),(arguments[1]&&arguments[1].method)||'GET',r.status,t,ct)}}catch(e){}return r};var X=XMLHttpRequest.prototype,O=X.open,S=X.send;X.open=function(m,u){this._om=m;this._ou=u;return O.apply(this,arguments)};X.send=function(){var x=this;x.addEventListener('load',function(){try{var ct=x.getResponseHeader('content-type')||'';if(ct.includes('json')||ct.includes('text')){capture(x._ou,x._om||'GET',x.status,x.responseText||'',ct)}}catch(e){}});return S.apply(this,arguments)}})()`; - addBrowserTabOption(browser.command('open').argument('').description('Open URL in the browser session')) .action(browserAction(async (page, url, opts) => { // Start session-level capture before navigation (catches initial requests) From 01022d9c094af057915b15617c68f8da7a701eca Mon Sep 17 00:00:00 2001 From: jakevin Date: Wed, 1 Jul 2026 00:59:18 +0800 Subject: [PATCH 02/27] chore(daemon): read status through transport layer (#2040) --- src/browser/daemon-version.ts | 2 +- src/cli.ts | 3 ++- src/commands/daemon.test.ts | 2 +- src/commands/daemon.ts | 2 +- src/doctor.test.ts | 2 +- src/doctor.ts | 4 ++-- 6 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/browser/daemon-version.ts b/src/browser/daemon-version.ts index 1c4bbb13d..0d941d306 100644 --- a/src/browser/daemon-version.ts +++ b/src/browser/daemon-version.ts @@ -1,4 +1,4 @@ -import type { DaemonStatus } from './daemon-client.js'; +import type { DaemonStatus } from './daemon-transport.js'; export function isDaemonStale(status: Pick | null | undefined, cliVersion?: string): boolean { if (!status || !cliVersion) return false; diff --git a/src/cli.ts b/src/cli.ts index 5e89145b4..e7e67f796 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -35,7 +35,8 @@ import { analyzeSite, type PageSignals } from './browser/analyze.js'; import { registerAuthCommands } from './commands/auth.js'; import { daemonRestart, daemonStatus, daemonStop } from './commands/daemon.js'; import { log } from './logger.js'; -import { bindTab, BrowserCommandError, fetchDaemonStatus, sendCommand } from './browser/daemon-client.js'; +import { bindTab, BrowserCommandError, sendCommand } from './browser/daemon-client.js'; +import { fetchDaemonStatus } from './browser/daemon-transport.js'; import { aliasForContextId, loadProfileConfig, renameProfile, resolveProfileContextId, setDefaultProfile } from './browser/profile.js'; import { formatDaemonVersion, isDaemonStale } from './browser/daemon-version.js'; import { DEFAULT_BROWSER_CONNECT_TIMEOUT } from './browser/config.js'; diff --git a/src/commands/daemon.test.ts b/src/commands/daemon.test.ts index a32f73544..c37206631 100644 --- a/src/commands/daemon.test.ts +++ b/src/commands/daemon.test.ts @@ -10,7 +10,7 @@ const { restartDaemonMock: vi.fn(), })); -vi.mock('../browser/daemon-client.js', () => ({ +vi.mock('../browser/daemon-transport.js', () => ({ fetchDaemonStatus: fetchDaemonStatusMock, requestDaemonShutdown: requestDaemonShutdownMock, })); diff --git a/src/commands/daemon.ts b/src/commands/daemon.ts index 127a71152..e03fd61a7 100644 --- a/src/commands/daemon.ts +++ b/src/commands/daemon.ts @@ -5,7 +5,7 @@ * opencli daemon restart — graceful shutdown, then start a fresh daemon */ -import { fetchDaemonStatus, requestDaemonShutdown } from '../browser/daemon-client.js'; +import { fetchDaemonStatus, requestDaemonShutdown } from '../browser/daemon-transport.js'; import { restartDaemon } from '../browser/daemon-lifecycle.js'; import { formatDuration } from '../download/progress.js'; import { log } from '../logger.js'; diff --git a/src/doctor.test.ts b/src/doctor.test.ts index f56701f02..0660ddf4d 100644 --- a/src/doctor.test.ts +++ b/src/doctor.test.ts @@ -7,7 +7,7 @@ const { mockGetDaemonHealth, mockConnect, mockClose, mockFindShadowedUserAdapter mockFindShadowedUserAdapters: vi.fn(), })); -vi.mock('./browser/daemon-client.js', () => ({ +vi.mock('./browser/daemon-transport.js', () => ({ getDaemonHealth: mockGetDaemonHealth, })); diff --git a/src/doctor.ts b/src/doctor.ts index fb1d3d60e..c851e03b0 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -6,11 +6,11 @@ import { DEFAULT_DAEMON_PORT } from './constants.js'; import { BrowserBridge } from './browser/index.js'; -import { getDaemonHealth } from './browser/daemon-client.js'; +import { getDaemonHealth } from './browser/daemon-transport.js'; import { getErrorMessage } from './errors.js'; import { getRuntimeLabel } from './runtime-detect.js'; import { getCachedLatestExtensionVersion } from './update-check.js'; -import type { BrowserProfileStatus } from './browser/daemon-client.js'; +import type { BrowserProfileStatus } from './browser/daemon-transport.js'; import { aliasForContextId, loadProfileConfig } from './browser/profile.js'; import { formatDaemonVersion, isDaemonStale, staleDaemonIssue } from './browser/daemon-version.js'; import { findShadowedUserAdapters, formatAdapterShadowIssue, type AdapterShadow } from './adapter-shadow.js'; From b3695a2468e1e518adb43509350c6ce31c92009e Mon Sep 17 00:00:00 2001 From: jakevin Date: Wed, 1 Jul 2026 00:59:34 +0800 Subject: [PATCH 03/27] chore(browser): share bind command handling (#2043) --- src/cli.ts | 102 +++++++++++++++++++++++++---------------------------- 1 file changed, 48 insertions(+), 54 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index e7e67f796..87a374905 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -981,6 +981,22 @@ Examples: }, null, 2)); } + function emitBrowserCommandErrorEnvelope(err: BrowserCommandError): void { + if (!err.code) return; + console.log(JSON.stringify({ + error: { + code: err.code, + message: err.message, + ...(err.hint ? { hint: err.hint } : {}), + }, + }, null, 2)); + } + + function logBrowserCommandError(err: BrowserCommandError): void { + log.error(err.message); + if (err.hint) log.error(`Hint: ${err.hint}`); + } + /** Wrap browser actions with error handling and optional --json output */ function browserAction(fn: (page: Awaited>, ...args: Args) => Promise) { return async (...args: Args) => { @@ -1000,17 +1016,10 @@ Examples: } else if (err instanceof BrowserCommandError) { if (isJavaScriptDialogMessage(err.message)) { emitJavaScriptDialogError(err.message); - } else if (err.code) { - console.log(JSON.stringify({ - error: { - code: err.code, - message: err.message, - ...(err.hint ? { hint: err.hint } : {}), - }, - }, null, 2)); + } else { + emitBrowserCommandErrorEnvelope(err); } - log.error(err.message); - if (err.hint) log.error(`Hint: ${err.hint}`); + logBrowserCommandError(err); } else if (err instanceof TargetError) { // Agent-facing structured envelope on stdout + short human line on stderr. emitTargetError(err); @@ -1032,63 +1041,48 @@ Examples: }; } - browser.command('bind') - .description('Bind the current Chrome tab/window to the browser session named by ') - .action(async (optsOrCommand, maybeCommand?: Command) => { + type BrowserSessionCommandContext = { + session: string; + contextId?: string; + }; + + function browserSessionCommandAction(fn: (ctx: BrowserSessionCommandContext) => Promise) { + return async (optsOrCommand: unknown, maybeCommand?: Command) => { const command = optsOrCommand instanceof Command ? optsOrCommand : maybeCommand; const session = getBrowserSession(command); + const contextId = getBrowserContextId(command); try { const { BrowserBridge } = await import('./browser/index.js'); const bridge = new BrowserBridge(); - const contextId = getBrowserContextId(command); await bridge.connect({ timeout: DEFAULT_BROWSER_CONNECT_TIMEOUT, session, surface: 'browser', ...(contextId && { contextId }) }); - const data = await bindTab(session, { ...(contextId && { contextId }) }); - saveBrowserTargetState(undefined, getBrowserScope(session, contextId)); - console.log(JSON.stringify({ session, ...((data && typeof data === 'object') ? data as Record : { data }) }, null, 2)); + await fn({ session, contextId }); } catch (err) { - if (err instanceof BrowserCommandError && err.code) { - console.log(JSON.stringify({ - error: { - code: err.code, - message: err.message, - ...(err.hint ? { hint: err.hint } : {}), - }, - }, null, 2)); + if (err instanceof BrowserCommandError) { + emitBrowserCommandErrorEnvelope(err); + logBrowserCommandError(err); + } else { + log.error(err instanceof Error ? err.message : String(err)); } - log.error(err instanceof Error ? err.message : String(err)); - if (err instanceof BrowserCommandError && err.hint) log.error(`Hint: ${err.hint}`); process.exitCode = EXIT_CODES.GENERIC_ERROR; } - }); + }; + } + + browser.command('bind') + .description('Bind the current Chrome tab/window to the browser session named by ') + .action(browserSessionCommandAction(async ({ session, contextId }) => { + const data = await bindTab(session, { ...(contextId && { contextId }) }); + saveBrowserTargetState(undefined, getBrowserScope(session, contextId)); + console.log(JSON.stringify({ session, ...((data && typeof data === 'object') ? data as Record : { data }) }, null, 2)); + })); browser.command('unbind') .description('Detach the bound browser session named by without closing the user tab/window') - .action(async (optsOrCommand, maybeCommand?: Command) => { - const command = optsOrCommand instanceof Command ? optsOrCommand : maybeCommand; - const session = getBrowserSession(command); - try { - const { BrowserBridge } = await import('./browser/index.js'); - const bridge = new BrowserBridge(); - const contextId = getBrowserContextId(command); - await bridge.connect({ timeout: DEFAULT_BROWSER_CONNECT_TIMEOUT, session, surface: 'browser', ...(contextId && { contextId }) }); - await sendCommand('close-window', { session, surface: 'browser', ...(contextId && { contextId }) }); - saveBrowserTargetState(undefined, getBrowserScope(session, contextId)); - console.log(JSON.stringify({ unbound: true, session }, null, 2)); - } catch (err) { - if (err instanceof BrowserCommandError && err.code) { - console.log(JSON.stringify({ - error: { - code: err.code, - message: err.message, - ...(err.hint ? { hint: err.hint } : {}), - }, - }, null, 2)); - } - log.error(err instanceof Error ? err.message : String(err)); - if (err instanceof BrowserCommandError && err.hint) log.error(`Hint: ${err.hint}`); - process.exitCode = EXIT_CODES.GENERIC_ERROR; - } - }); + .action(browserSessionCommandAction(async ({ session, contextId }) => { + await sendCommand('close-window', { session, surface: 'browser', ...(contextId && { contextId }) }); + saveBrowserTargetState(undefined, getBrowserScope(session, contextId)); + console.log(JSON.stringify({ unbound: true, session }, null, 2)); + })); const browserTab = browser .command('tab') From 5d2e87ad169b06026e817c220ba429f0897cdfe5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E5=AE=B6=E5=90=8D?= Date: Wed, 1 Jul 2026 01:05:06 +0800 Subject: [PATCH 04/27] test(slock): cover trimmed task status filters (#2041) --- clis/slock/task-list-server.test.js | 3 ++- clis/slock/task-list.test.js | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/clis/slock/task-list-server.test.js b/clis/slock/task-list-server.test.js index 3e9a5ceba..433dba867 100644 --- a/clis/slock/task-list-server.test.js +++ b/clis/slock/task-list-server.test.js @@ -28,10 +28,11 @@ describe('slock task-list-server', () => { it('passes ?status= through when --status is set', async () => { const page = makePage({ kind: 'ok', rows: [] }); - await command.func(page, { status: 'todo' }); + await command.func(page, { status: ' todo ' }); const snippet = page.evaluate.mock.calls[0][0]; expect(snippet).toContain('?status='); expect(snippet).toContain('"todo"'); + expect(snippet).not.toContain('" todo "'); }); it('rejects an invalid --status before navigation', async () => { diff --git a/clis/slock/task-list.test.js b/clis/slock/task-list.test.js index fe97a961a..e65933396 100644 --- a/clis/slock/task-list.test.js +++ b/clis/slock/task-list.test.js @@ -24,10 +24,11 @@ describe('slock task-list', () => { it('passes ?status= through when --status is set (server-side filter)', async () => { const page = makePage({ kind: 'ok', rows: [] }); - await command.func(page, { channel: '11111111-1111-1111-1111-111111111111', status: 'in_review' }); + await command.func(page, { channel: '11111111-1111-1111-1111-111111111111', status: ' in_review ' }); const snippet = page.evaluate.mock.calls[0][0]; expect(snippet).toContain('?status='); expect(snippet).toContain('"in_review"'); + expect(snippet).not.toContain('" in_review "'); }); it('rejects an invalid --status before navigation', async () => { From 244ec45278e4fc008ad3c8fa4a8ec7b036a1e0c5 Mon Sep 17 00:00:00 2001 From: jakevin Date: Wed, 1 Jul 2026 01:22:06 +0800 Subject: [PATCH 05/27] fix(core): stop silently swallowing pipeline context + daemon WS errors (#1979) Two unrelated try/catch blocks were eating errors with no observable signal, both reachable from production paths: 1) `src/pipeline/template.ts:215` `sanitizeContext` (the JSON round-trip that severs prototype chains before handing pipeline context to the VM sandbox) caught any `JSON.stringify` failure and returned `{}`. The most common cause is a BigInt anywhere in `data` / `args` / `item` / `root` (e.g. GraphQL 64-bit IDs). After collapse, every template expression referencing that branch resolved to `undefined`, producing silent column-drops downstream with no warning. Fix: - Add a JSON.stringify replacer that coerces BigInt to string, so the common BigInt-in-context case survives the sandbox copy. - For everything else (circular references, Symbol, etc.), the fallback is still `{}` but now log.warn so the failure shows up in `~/.opencli` logs and doctor output instead of silently producing blank rows. 2) `src/daemon.ts:445` the WS message handler from the extension caught `JSON.parse` failures and ran the `// Ignore malformed messages` comment. A malformed message presents downstream as a generic command timeout (`pending` never resolves), so the actual protocol drift / version skew between daemon and extension never surfaced in the log. Fix: log.warn the parse error plus the first 200 chars of the offending frame so the root cause is visible during triage. Both changes are observability-only: no successful path changes behavior; only previously-silent failure paths get a log line, plus BigInt now serializes to a string instead of nuking its containing branch. Tests: - `src/pipeline/template.test.ts`: two new cases covering the BigInt preservation path (forces the VM sandbox via `String(args.id)`, not the resolvePath fast path) and the circular-ref no-crash invariant. - daemon WS handler change is log-only; existing daemon tests cover the message dispatch path. --- src/daemon.ts | 11 +++++++++-- src/pipeline/template.test.ts | 24 ++++++++++++++++++++++++ src/pipeline/template.ts | 14 ++++++++++++-- 3 files changed, 45 insertions(+), 4 deletions(-) diff --git a/src/daemon.ts b/src/daemon.ts index 77349490b..778823852 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -446,8 +446,15 @@ wss.on('connection', (ws: WebSocket) => { pending.delete(msg.id); p.resolve(msg); } - } catch { - // Ignore malformed messages + } catch (err) { + // Malformed message from the extension. Surface so protocol drift / + // version skew between daemon and extension shows up in the log + // instead of presenting as a generic command timeout downstream. + const sample = data.toString().slice(0, 200); + log.warn( + `[daemon] Ignoring malformed WS message from extension: ` + + `${err instanceof Error ? err.message : String(err)} (first 200 chars: ${JSON.stringify(sample)})`, + ); } }); diff --git a/src/pipeline/template.test.ts b/src/pipeline/template.test.ts index 6253994c8..fe7172229 100644 --- a/src/pipeline/template.test.ts +++ b/src/pipeline/template.test.ts @@ -178,3 +178,27 @@ describe('normalizeEvaluateSource', () => { expect(normalizeEvaluateSource('')).toBe('() => undefined'); }); }); + +describe('sanitizeContext (via VM sandbox path)', () => { + it('coerces BigInt fields to string instead of silently dropping the branch', () => { + // Previously a BigInt anywhere in args would make JSON.stringify throw and + // sanitizeContext returned {}, so any reference to args.id in the JS + // sandbox would resolve to undefined. We now serialize BigInt as string + // so the value survives the sandbox copy. + const id = BigInt('9007199254740993'); // > Number.MAX_SAFE_INTEGER + const ctx = { args: { id } }; + // Use an expression that forces evalJsExpr (not the resolvePath fast path) + // so it goes through sanitizeContext + VM sandbox. + expect(evalExpr('String(args.id)', ctx)).toBe('9007199254740993'); + }); + + it('does not crash on circular references (returns {} after warning)', () => { + // Circular refs still can't be safely round-tripped; verify the + // fallback is the empty-object path, not a crash. + const args: Record = { keep: 'me' }; + args.self = args; + const ctx = { args }; + // The expression must not throw even if the args object has a cycle. + expect(() => evalExpr('args.keep || "fallback"', ctx)).not.toThrow(); + }); +}); diff --git a/src/pipeline/template.ts b/src/pipeline/template.ts index fb7ed66c5..7e1ebfc5d 100644 --- a/src/pipeline/template.ts +++ b/src/pipeline/template.ts @@ -13,6 +13,7 @@ export interface RenderContext { } import { isRecord } from '../utils.js'; +import { log } from '../logger.js'; export function render(template: unknown, ctx: RenderContext): unknown { if (typeof template !== 'string') return template; @@ -209,10 +210,19 @@ function sanitizeContext(obj: unknown): unknown { const cached = _sanitizeCache.get(objRef); if (cached !== undefined) return JSON.parse(cached); try { - const jsonStr = JSON.stringify(obj); + // BigInt is non-serializable by default but is the most common cause of + // sanitizeContext failures (e.g. GraphQL 64-bit IDs). Coerce to string + // so callers see the value instead of a silent {}. + const jsonStr = JSON.stringify(obj, (_key, value) => + typeof value === 'bigint' ? value.toString() : value, + ); _sanitizeCache.set(objRef, jsonStr); return JSON.parse(jsonStr); - } catch { + } catch (err) { + log.warn( + `[pipeline/template] sanitizeContext failed: ${err instanceof Error ? err.message : String(err)}. ` + + `Returning {} for this branch. Likely cause: circular reference, Symbol, or other non-serializable value in pipeline context.`, + ); return {}; } } From d174f724ecafd73041bd05a6ee8cdbc793690990 Mon Sep 17 00:00:00 2001 From: iynewz Date: Wed, 1 Jul 2026 01:25:09 +0800 Subject: [PATCH 06/27] feat(adapter): add Mercury reimbursement helpers (#2052) * Add Mercury reimbursement helpers * fix(mercury): fail closed reimbursement drafts * fix(mercury): harden reimbursement draft safety checks --------- Co-authored-by: jackwener --- cli-manifest.json | 194 ++++++++++++++++++ clis/mercury/check-login.js | 29 +++ clis/mercury/mercury.test.js | 288 ++++++++++++++++++++++++++ clis/mercury/reimbursement-draft.js | 130 ++++++++++++ clis/mercury/reimbursement-plan.js | 38 ++++ clis/mercury/utils.js | 305 ++++++++++++++++++++++++++++ docs/.vitepress/config.mts | 1 + docs/adapters/browser/mercury.md | 167 +++++++++++++++ 8 files changed, 1152 insertions(+) create mode 100644 clis/mercury/check-login.js create mode 100644 clis/mercury/mercury.test.js create mode 100644 clis/mercury/reimbursement-draft.js create mode 100644 clis/mercury/reimbursement-plan.js create mode 100644 clis/mercury/utils.js create mode 100644 docs/adapters/browser/mercury.md diff --git a/cli-manifest.json b/cli-manifest.json index 71db7b246..065fd14a4 100644 --- a/cli-manifest.json +++ b/cli-manifest.json @@ -23087,6 +23087,200 @@ "sourceFile": "medium/user.js", "navigateBefore": "https://medium.com" }, + { + "site": "mercury", + "name": "check-login", + "description": "Open Mercury reimbursements and report whether the active browser profile is logged in", + "access": "read", + "example": "opencli --profile mercury check-login -f json", + "domain": "app.mercury.com", + "strategy": "ui", + "browser": true, + "args": [], + "columns": [ + "status", + "loggedIn", + "url", + "hasSubmitExpense", + "hasReimbursements", + "title" + ], + "type": "js", + "modulePath": "mercury/check-login.js", + "sourceFile": "mercury/check-login.js", + "navigateBefore": false, + "siteSession": "persistent", + "defaultWindowMode": "foreground" + }, + { + "site": "mercury", + "name": "reimbursement-draft", + "description": "Create a Mercury reimbursement draft from a local receipt, correct OCR fields, and stop at Review", + "access": "write", + "example": "opencli --profile mercury reimbursement-draft --receipt /tmp/receipt.png --amount 140.00 --currency CNY --date 2026-06-26 --merchant \"Example Merchant\" --category \"Marketing & Advertising\" --notes \"Example business purpose.\" -f json", + "domain": "app.mercury.com", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "receipt", + "type": "str", + "required": true, + "help": "Local receipt/proof file path" + }, + { + "name": "amount", + "type": "str", + "required": true, + "help": "Original-currency amount, e.g. 140.00" + }, + { + "name": "currency", + "type": "str", + "default": "CNY", + "required": false, + "help": "Original currency code" + }, + { + "name": "date", + "type": "str", + "required": true, + "help": "Expense date as YYYY-MM-DD" + }, + { + "name": "merchant", + "type": "str", + "required": true, + "help": "Merchant shown on the reimbursement" + }, + { + "name": "category", + "type": "str", + "default": "Marketing & Advertising", + "required": false, + "help": "Mercury expense category" + }, + { + "name": "notes", + "type": "str", + "required": true, + "help": "Business purpose / reimbursement notes" + }, + { + "name": "ocr-wait-seconds", + "type": "str", + "default": "8", + "required": false, + "help": "Seconds to wait after receipt upload before correcting OCR-overwritten fields" + }, + { + "name": "close-after-review", + "type": "boolean", + "default": false, + "required": false, + "help": "Close the Review dialog after verification; final Submit is still never clicked" + } + ], + "columns": [ + "status", + "url", + "receipt", + "uploaded", + "fieldsTouched", + "reviewReady", + "submitBlocked", + "warnings" + ], + "type": "js", + "modulePath": "mercury/reimbursement-draft.js", + "sourceFile": "mercury/reimbursement-draft.js", + "navigateBefore": false, + "siteSession": "persistent", + "defaultWindowMode": "foreground" + }, + { + "site": "mercury", + "name": "reimbursement-plan", + "description": "Validate Mercury reimbursement inputs and print the draft plan without opening a browser", + "access": "read", + "example": "opencli mercury reimbursement-plan --receipt /tmp/receipt.png --amount 140.00 --currency CNY --date 2026-06-26 --merchant \"Example Merchant\" --category \"Marketing & Advertising\" --notes \"Example business purpose.\" -f json", + "strategy": "local", + "browser": false, + "args": [ + { + "name": "receipt", + "type": "str", + "required": true, + "help": "Local receipt/proof file path" + }, + { + "name": "amount", + "type": "str", + "required": true, + "help": "Original-currency amount, e.g. 140.00" + }, + { + "name": "currency", + "type": "str", + "default": "CNY", + "required": false, + "help": "Original currency code" + }, + { + "name": "date", + "type": "str", + "required": true, + "help": "Expense date as YYYY-MM-DD" + }, + { + "name": "merchant", + "type": "str", + "required": true, + "help": "Merchant shown on the reimbursement" + }, + { + "name": "category", + "type": "str", + "default": "Marketing & Advertising", + "required": false, + "help": "Mercury expense category" + }, + { + "name": "notes", + "type": "str", + "required": true, + "help": "Business purpose / reimbursement notes" + }, + { + "name": "ocr-wait-seconds", + "type": "str", + "default": "8", + "required": false, + "help": "Seconds the draft command waits after receipt upload before correcting OCR fields" + }, + { + "name": "close-after-review", + "type": "boolean", + "default": false, + "required": false, + "help": "For draft command: close the Review dialog after verification" + } + ], + "columns": [ + "status", + "receipt", + "amount", + "currency", + "date", + "merchant", + "category", + "notes", + "safety" + ], + "type": "js", + "modulePath": "mercury/reimbursement-plan.js", + "sourceFile": "mercury/reimbursement-plan.js" + }, { "site": "mubu", "name": "doc", diff --git a/clis/mercury/check-login.js b/clis/mercury/check-login.js new file mode 100644 index 000000000..5d5e856e4 --- /dev/null +++ b/clis/mercury/check-login.js @@ -0,0 +1,29 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { inspectMercury } from './utils.js'; + +cli({ + site: 'mercury', + name: 'check-login', + description: 'Open Mercury reimbursements and report whether the active browser profile is logged in', + access: 'read', + example: 'opencli --profile mercury check-login -f json', + domain: 'app.mercury.com', + strategy: Strategy.UI, + browser: true, + siteSession: 'persistent', + defaultWindowMode: 'foreground', + navigateBefore: false, + args: [], + columns: ['status', 'loggedIn', 'url', 'hasSubmitExpense', 'hasReimbursements', 'title'], + func: async (page) => { + const state = await inspectMercury(page); + return [{ + status: state.loggedIn ? 'ready' : 'needs_login', + loggedIn: state.loggedIn, + url: state.url, + hasSubmitExpense: state.hasSubmitExpense, + hasReimbursements: state.hasReimbursements, + title: state.title, + }]; + }, +}); diff --git a/clis/mercury/mercury.test.js b/clis/mercury/mercury.test.js new file mode 100644 index 000000000..f4c6d18ef --- /dev/null +++ b/clis/mercury/mercury.test.js @@ -0,0 +1,288 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors'; +import { getRegistry } from '@jackwener/opencli/registry'; +import { normalizeReimbursementInput } from './utils.js'; +import './check-login.js'; +import './reimbursement-draft.js'; +import './reimbursement-plan.js'; + +let tmpDir; +let receiptPath; + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-mercury-test-')); + receiptPath = path.join(tmpDir, 'receipt.png'); + fs.writeFileSync(receiptPath, 'receipt'); +}); + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +function validArgs(overrides = {}) { + return { + receipt: receiptPath, + amount: '140.00', + currency: 'CNY', + date: '2026-06-26', + merchant: 'Example Merchant', + category: 'Marketing & Advertising', + notes: 'Example business purpose.', + ...overrides, + }; +} + +function createPageMock(evaluateResults, overrides = {}) { + return { + goto: vi.fn().mockResolvedValue(undefined), + wait: vi.fn().mockResolvedValue(undefined), + evaluate: vi.fn() + .mockImplementation(() => { + if (!evaluateResults.length) throw new Error('unexpected evaluate call'); + return evaluateResults.shift(); + }), + uploadFiles: vi.fn().mockResolvedValue({ + uploaded: true, + files: 1, + file_names: ['receipt.png'], + target: '[data-testid="expense-attachment-upload"]', + matches_n: 1, + match_level: 'exact', + }), + setFileInput: vi.fn().mockResolvedValue(undefined), + ...overrides, + }; +} + +function loggedInState(overrides = {}) { + return { + url: 'https://app.mercury.com/expenses/my-expenses', + loggedIn: true, + hasSubmitExpense: true, + hasReimbursements: true, + title: 'Mercury', + ...overrides, + }; +} + +function createSurface(overrides = {}) { + return { + url: 'https://app.mercury.com/expenses/my-expenses', + title: 'Mercury', + hasFinalSubmit: false, + hasForm: false, + bodyPreview: 'My Expenses Submit expense', + ...overrides, + }; +} + +function clickResult(clicked = true) { + return { clicked, blocked: false, text: clicked ? 'clicked' : '' }; +} + +function fillResult(touched = {}) { + return { + touched: { + amount: true, + currency: true, + date: true, + merchant: true, + category: true, + notes: true, + ...touched, + }, + }; +} + +function reviewState(overrides = {}) { + return { + url: 'https://app.mercury.com/expenses/review', + title: 'Mercury', + hasReview: true, + hasSubmitExpenseButton: true, + bodyPreview: 'Review Submit expense', + ...overrides, + }; +} + +describe('mercury reimbursement input validation', () => { + it('normalizes valid input and keeps receipt absolute internally', () => { + const input = normalizeReimbursementInput(validArgs()); + expect(input).toMatchObject({ + receipt: receiptPath, + amount: '140.00', + currency: 'CNY', + date: '2026-06-26', + merchant: 'Example Merchant', + category: 'Marketing & Advertising', + notes: 'Example business purpose.', + ocrWaitSeconds: 8, + closeAfterReview: false, + }); + }); + + it('rejects malformed local-only arguments before browser work', () => { + expect(() => normalizeReimbursementInput(validArgs({ amount: '0' }))).toThrow(ArgumentError); + expect(() => normalizeReimbursementInput(validArgs({ amount: '1.999' }))).toThrow(ArgumentError); + expect(() => normalizeReimbursementInput(validArgs({ currency: 'US' }))).toThrow(ArgumentError); + expect(() => normalizeReimbursementInput(validArgs({ date: '2026-02-30' }))).toThrow(ArgumentError); + expect(() => normalizeReimbursementInput(validArgs({ 'ocr-wait-seconds': 'abc' }))).toThrow(ArgumentError); + expect(() => normalizeReimbursementInput(validArgs({ receipt: path.join(tmpDir, 'missing.png') }))).toThrow(ArgumentError); + }); +}); + +describe('mercury reimbursement-plan', () => { + it('validates locally and returns a deterministic basename-only plan', async () => { + const command = getRegistry().get('mercury/reimbursement-plan'); + const rows = await command.func(validArgs()); + expect(rows).toEqual([expect.objectContaining({ + status: 'ready', + receipt: 'receipt.png', + amount: '140.00', + currency: 'CNY', + safety: expect.stringContaining('never clicks final Submit expense'), + })]); + }); +}); + +describe('mercury reimbursement-draft', () => { + const command = getRegistry().get('mercury/reimbursement-draft'); + + it('throws AuthRequiredError instead of returning a fake draft when logged out', async () => { + const page = createPageMock([loggedInState({ loggedIn: false, url: 'https://app.mercury.com/login' })]); + await expect(command.func(page, validArgs())).rejects.toBeInstanceOf(AuthRequiredError); + expect(page.uploadFiles).not.toHaveBeenCalled(); + }); + + it('typed-fails malformed login state payloads', async () => { + const page = createPageMock([{ url: 'https://app.mercury.com/expenses/my-expenses', title: 'Mercury' }]); + await expect(command.func(page, validArgs())).rejects.toBeInstanceOf(CommandExecutionError); + expect(page.uploadFiles).not.toHaveBeenCalled(); + }); + + it('refuses to click when an existing review/submit surface is already open', async () => { + const page = createPageMock([ + loggedInState(), + createSurface({ hasFinalSubmit: true, hasForm: true, bodyPreview: 'Review Submit expense amount merchant' }), + ]); + await expect(command.func(page, validArgs())).rejects.toBeInstanceOf(CommandExecutionError); + expect(page.uploadFiles).not.toHaveBeenCalled(); + }); + + it('typed-fails if the create click probe sees a submit/review surface', async () => { + const page = createPageMock([ + loggedInState(), + createSurface(), + { clicked: false, blocked: true, text: 'Submit expense' }, + ]); + await expect(command.func(page, validArgs())).rejects.toBeInstanceOf(CommandExecutionError); + expect(page.uploadFiles).not.toHaveBeenCalled(); + }); + + it('typed-fails malformed create expense surface payloads', async () => { + const page = createPageMock([ + loggedInState(), + { url: 'https://app.mercury.com/expenses/my-expenses', title: 'Mercury', hasForm: false }, + ]); + await expect(command.func(page, validArgs())).rejects.toBeInstanceOf(CommandExecutionError); + expect(page.uploadFiles).not.toHaveBeenCalled(); + }); + + it('typed-fails upload confirmation drift', async () => { + const page = createPageMock([ + loggedInState(), + createSurface(), + clickResult(true), + ], { + uploadFiles: vi.fn().mockResolvedValue({ uploaded: false, files: 0 }), + }); + await expect(command.func(page, validArgs())).rejects.toBeInstanceOf(CommandExecutionError); + }); + + it('typed-fails upload confirmation for the wrong file name', async () => { + const page = createPageMock([ + loggedInState(), + createSurface(), + clickResult(true), + ], { + uploadFiles: vi.fn().mockResolvedValue({ uploaded: true, files: 1, file_names: ['other.png'] }), + }); + await expect(command.func(page, validArgs())).rejects.toBeInstanceOf(CommandExecutionError); + }); + + it('typed-fails upload confirmation for the wrong input target', async () => { + const page = createPageMock([ + loggedInState(), + createSurface(), + clickResult(true), + ], { + uploadFiles: vi.fn().mockResolvedValue({ + uploaded: true, + files: 1, + file_names: ['receipt.png'], + target: '[data-testid="wrong"]', + matches_n: 1, + }), + }); + await expect(command.func(page, validArgs())).rejects.toBeInstanceOf(CommandExecutionError); + }); + + it('typed-fails missed required field selectors before Review', async () => { + const page = createPageMock([ + loggedInState(), + createSurface(), + clickResult(true), + fillResult({ merchant: false }), + ]); + await expect(command.func(page, validArgs())).rejects.toThrow(/merchant/); + }); + + it('typed-fails if Mercury does not reach Review with final submit visible', async () => { + const page = createPageMock([ + loggedInState(), + createSurface(), + clickResult(true), + fillResult(), + clickResult(true), + reviewState({ hasReview: false, hasSubmitExpenseButton: false }), + ]); + await expect(command.func(page, validArgs())).rejects.toBeInstanceOf(CommandExecutionError); + }); + + it('typed-fails malformed review payloads', async () => { + const page = createPageMock([ + loggedInState(), + createSurface(), + clickResult(true), + fillResult(), + clickResult(true), + { url: 'https://app.mercury.com/expenses/review', title: 'Mercury', hasReview: true }, + ]); + await expect(command.func(page, validArgs())).rejects.toBeInstanceOf(CommandExecutionError); + }); + + it('returns review-ready summary without clicking final Submit expense', async () => { + const page = createPageMock([ + loggedInState(), + createSurface(), + clickResult(true), + fillResult(), + clickResult(true), + reviewState(), + ]); + const rows = await command.func(page, validArgs()); + expect(rows).toEqual([expect.objectContaining({ + status: 'review_ready', + receipt: 'receipt.png', + uploaded: true, + reviewReady: true, + submitBlocked: true, + warnings: 'final Submit expense was intentionally not clicked', + })]); + expect(page.uploadFiles).toHaveBeenCalledWith('[data-testid="expense-attachment-upload"]', [receiptPath]); + expect(page.evaluate).toHaveBeenCalledTimes(6); + }); +}); diff --git a/clis/mercury/reimbursement-draft.js b/clis/mercury/reimbursement-draft.js new file mode 100644 index 000000000..015d312a0 --- /dev/null +++ b/clis/mercury/reimbursement-draft.js @@ -0,0 +1,130 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { CommandExecutionError } from '@jackwener/opencli/errors'; +import { + MERCURY_EXPENSES_URL, + RECEIPT_INPUT_SELECTOR, + assertCreateExpenseSurface, + clickCreateExpenseButton, + assertLoggedIn, + fillReimbursementFields, + inspectMercury, + normalizeReimbursementInput, + receiptBasename, + reviewSnapshot, +} from './utils.js'; + +cli({ + site: 'mercury', + name: 'reimbursement-draft', + description: 'Create a Mercury reimbursement draft from a local receipt, correct OCR fields, and stop at Review', + access: 'write', + example: 'opencli --profile mercury reimbursement-draft --receipt /tmp/receipt.png --amount 140.00 --currency CNY --date 2026-06-26 --merchant "Example Merchant" --category "Marketing & Advertising" --notes "Example business purpose." -f json', + domain: 'app.mercury.com', + strategy: Strategy.UI, + browser: true, + siteSession: 'persistent', + defaultWindowMode: 'foreground', + navigateBefore: false, + args: [ + { name: 'receipt', required: true, help: 'Local receipt/proof file path' }, + { name: 'amount', required: true, help: 'Original-currency amount, e.g. 140.00' }, + { name: 'currency', default: 'CNY', help: 'Original currency code' }, + { name: 'date', required: true, help: 'Expense date as YYYY-MM-DD' }, + { name: 'merchant', required: true, help: 'Merchant shown on the reimbursement' }, + { name: 'category', default: 'Marketing & Advertising', help: 'Mercury expense category' }, + { name: 'notes', required: true, help: 'Business purpose / reimbursement notes' }, + { name: 'ocr-wait-seconds', default: '8', help: 'Seconds to wait after receipt upload before correcting OCR-overwritten fields' }, + { name: 'close-after-review', type: 'boolean', default: false, help: 'Close the Review dialog after verification; final Submit is still never clicked' }, + ], + columns: ['status', 'url', 'receipt', 'uploaded', 'fieldsTouched', 'reviewReady', 'submitBlocked', 'warnings'], + func: async (page, kwargs) => { + const input = normalizeReimbursementInput(kwargs); + const before = await inspectMercury(page); + assertLoggedIn(before); + + await page.goto(MERCURY_EXPENSES_URL, { waitUntil: 'load', settleMs: 1500 }); + await page.wait({ time: 1 }); + const createSurface = await assertCreateExpenseSurface(page); + if (createSurface.hasFinalSubmit && /review/i.test(createSurface.bodyPreview || '')) { + throw new CommandExecutionError('Mercury appears to have an existing expense review open; refusing to click a possible final Submit expense button'); + } + + const opened = await clickCreateExpenseButton(page); + if (!opened.clicked) { + throw new CommandExecutionError('Could not find the Mercury Submit expense or New expense button'); + } + + await page.wait({ time: 2 }); + + if (!page.uploadFiles) { + throw new CommandExecutionError('Mercury reimbursement-draft requires Browser Bridge uploadFiles support to verify the intended receipt input'); + } + const upload = await page.uploadFiles(RECEIPT_INPUT_SELECTOR, [input.receipt]); + if (!upload || upload.uploaded !== true || upload.files !== 1) { + throw new CommandExecutionError('Mercury receipt upload did not confirm exactly one uploaded file'); + } + if (upload.target !== RECEIPT_INPUT_SELECTOR || upload.matches_n !== 1) { + throw new CommandExecutionError('Mercury receipt upload did not confirm the intended receipt input'); + } + const uploadedNames = Array.isArray(upload.file_names) ? upload.file_names : []; + if (!uploadedNames.includes(receiptBasename(input.receipt))) { + throw new CommandExecutionError('Mercury receipt upload did not confirm the expected receipt file'); + } + + if (input.ocrWaitSeconds > 0) await page.wait({ time: input.ocrWaitSeconds }); + + const fields = await fillReimbursementFields(page, input); + await page.wait({ time: 1 }); + const expectedFields = ['amount', 'currency', 'date', 'merchant', 'category', 'notes']; + const missing = expectedFields.filter((key) => !fields.touched[key]); + if (missing.length > 0) { + throw new CommandExecutionError(`Mercury reimbursement form fields were not all filled: ${missing.join(', ')}`); + } + + const reviewClick = await page.evaluate(`(() => { + const norm = (s) => String(s || '').replace(/\\s+/g, ' ').trim().toLowerCase(); + const candidates = Array.from(document.querySelectorAll('button, a, [role="button"], [role="link"]')) + .filter((node) => { + const style = window.getComputedStyle(node); + return style.visibility !== 'hidden' && style.display !== 'none' && node.offsetParent !== null; + }); + const el = candidates.find((node) => norm(node.innerText || node.textContent || '') === 'review'); + if (!el) return { clicked: false }; + el.click(); + return { clicked: true, text: el.innerText || el.textContent || '' }; + })()`); + if (!reviewClick || typeof reviewClick !== 'object' || typeof reviewClick.clicked !== 'boolean') { + throw new CommandExecutionError('Mercury returned malformed Review click result'); + } + if (!reviewClick.clicked) { + throw new CommandExecutionError('Mercury Review button was not clicked; inspect the page for validation errors'); + } + await page.wait({ time: 2 }); + const review = await reviewSnapshot(page); + if (!review.hasReview || !review.hasSubmitExpenseButton) { + throw new CommandExecutionError('Mercury did not reach the Review step with the final Submit expense button visible'); + } + + if (input.closeAfterReview) { + await page.evaluate(`(() => { + const norm = (s) => String(s || '').replace(/\\s+/g, ' ').trim().toLowerCase(); + const el = Array.from(document.querySelectorAll('button, a, [role="button"], [role="link"]')) + .find((node) => norm(node.innerText || node.textContent || '') === 'close'); + el?.click(); + return { clicked: Boolean(el) }; + })()`); + await page.wait({ time: 1 }); + } + + return [{ + status: 'review_ready', + url: review.url, + receipt: receiptBasename(input.receipt), + uploaded: true, + fieldsTouched: JSON.stringify(fields.touched), + reviewReady: true, + submitBlocked: true, + warnings: 'final Submit expense was intentionally not clicked', + }]; + }, +}); diff --git a/clis/mercury/reimbursement-plan.js b/clis/mercury/reimbursement-plan.js new file mode 100644 index 000000000..bf6ab5750 --- /dev/null +++ b/clis/mercury/reimbursement-plan.js @@ -0,0 +1,38 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { normalizeReimbursementInput, receiptBasename } from './utils.js'; + +cli({ + site: 'mercury', + name: 'reimbursement-plan', + description: 'Validate Mercury reimbursement inputs and print the draft plan without opening a browser', + access: 'read', + example: 'opencli mercury reimbursement-plan --receipt /tmp/receipt.png --amount 140.00 --currency CNY --date 2026-06-26 --merchant "Example Merchant" --category "Marketing & Advertising" --notes "Example business purpose." -f json', + strategy: Strategy.LOCAL, + browser: false, + args: [ + { name: 'receipt', required: true, help: 'Local receipt/proof file path' }, + { name: 'amount', required: true, help: 'Original-currency amount, e.g. 140.00' }, + { name: 'currency', default: 'CNY', help: 'Original currency code' }, + { name: 'date', required: true, help: 'Expense date as YYYY-MM-DD' }, + { name: 'merchant', required: true, help: 'Merchant shown on the reimbursement' }, + { name: 'category', default: 'Marketing & Advertising', help: 'Mercury expense category' }, + { name: 'notes', required: true, help: 'Business purpose / reimbursement notes' }, + { name: 'ocr-wait-seconds', default: '8', help: 'Seconds the draft command waits after receipt upload before correcting OCR fields' }, + { name: 'close-after-review', type: 'boolean', default: false, help: 'For draft command: close the Review dialog after verification' }, + ], + columns: ['status', 'receipt', 'amount', 'currency', 'date', 'merchant', 'category', 'notes', 'safety'], + func: async (kwargs) => { + const input = normalizeReimbursementInput(kwargs); + return [{ + status: 'ready', + receipt: receiptBasename(input.receipt), + amount: input.amount, + currency: input.currency, + date: input.date, + merchant: input.merchant, + category: input.category, + notes: input.notes, + safety: 'reimbursement-draft stops at Mercury Review and never clicks final Submit expense', + }]; + }, +}); diff --git a/clis/mercury/utils.js b/clis/mercury/utils.js new file mode 100644 index 000000000..62f8e1c73 --- /dev/null +++ b/clis/mercury/utils.js @@ -0,0 +1,305 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors'; + +export const MERCURY_EXPENSES_URL = 'https://app.mercury.com/expenses/my-expenses'; +export const RECEIPT_INPUT_SELECTOR = '[data-testid="expense-attachment-upload"]'; + +export function requireString(kwargs, name) { + const value = kwargs[name]; + if (typeof value !== 'string' || value.trim() === '') { + throw new ArgumentError(`Missing required argument: ${name}`); + } + return value.trim(); +} + +export function optionalString(kwargs, name, fallback) { + const value = kwargs[name]; + if (typeof value !== 'string' || value.trim() === '') return fallback; + return value.trim(); +} + +export function optionalBoolean(kwargs, name, fallback = false) { + const value = kwargs[name]; + if (typeof value === 'boolean') return value; + if (typeof value === 'string') { + const normalized = value.trim().toLowerCase(); + if (['1', 'true', 'yes', 'y', 'on'].includes(normalized)) return true; + if (['0', 'false', 'no', 'n', 'off'].includes(normalized)) return false; + throw new ArgumentError(`Boolean argument ${name} must be true or false`); + } + return fallback; +} + +function parseReceiptPath(kwargs) { + const receipt = path.resolve(requireString(kwargs, 'receipt')); + let stat; + try { + stat = fs.statSync(receipt, { throwIfNoEntry: false }); + } + catch (error) { + throw new ArgumentError(`Receipt file cannot be read: ${receipt}`, error instanceof Error ? error.message : undefined); + } + if (!stat || !stat.isFile()) { + throw new ArgumentError(`Receipt file does not exist: ${receipt}`); + } + return receipt; +} + +function parseAmount(kwargs) { + const amount = requireString(kwargs, 'amount').replace(/,/g, ''); + if (!/^\d+(\.\d{1,2})?$/.test(amount) || Number(amount) <= 0) { + throw new ArgumentError(`Amount must be a positive number with up to two decimals: ${amount}`); + } + return amount; +} + +function parseCurrency(kwargs) { + const currency = optionalString(kwargs, 'currency', 'CNY').toUpperCase(); + if (!/^[A-Z]{3}$/.test(currency)) { + throw new ArgumentError(`Currency must be a three-letter ISO currency code: ${currency}`); + } + return currency; +} + +function parseDate(kwargs) { + const date = requireString(kwargs, 'date'); + const match = date.match(/^(\d{4})-(\d{2})-(\d{2})$/); + if (!match) { + throw new ArgumentError(`Date must be YYYY-MM-DD: ${date}`); + } + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + const parsed = new Date(Date.UTC(year, month - 1, day)); + if (parsed.getUTCFullYear() !== year || parsed.getUTCMonth() !== month - 1 || parsed.getUTCDate() !== day) { + throw new ArgumentError(`Date must be a real calendar date: ${date}`); + } + return date; +} + +function parseOcrWaitSeconds(kwargs) { + const raw = optionalString(kwargs, 'ocr-wait-seconds', '8'); + if (!/^\d+$/.test(raw)) { + throw new ArgumentError(`ocr-wait-seconds must be a non-negative integer: ${raw}`); + } + return Number(raw); +} + +export function normalizeReimbursementInput(kwargs) { + return { + receipt: parseReceiptPath(kwargs), + amount: parseAmount(kwargs), + currency: parseCurrency(kwargs), + date: parseDate(kwargs), + merchant: requireString(kwargs, 'merchant'), + category: optionalString(kwargs, 'category', 'Marketing & Advertising'), + notes: requireString(kwargs, 'notes'), + ocrWaitSeconds: parseOcrWaitSeconds(kwargs), + closeAfterReview: optionalBoolean(kwargs, 'close-after-review', false), + }; +} + +export function receiptBasename(receipt) { + return path.basename(receipt); +} + +export function assertObject(value, label) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new CommandExecutionError(`Mercury returned malformed ${label}`); + } + return value; +} + +export function assertMercuryState(value, label = 'page state') { + const state = assertObject(value, label); + if (typeof state.url !== 'string' || typeof state.title !== 'string') { + throw new CommandExecutionError(`Mercury returned malformed ${label}`); + } + return state; +} + +function assertBooleanFields(state, label, fields) { + for (const field of fields) { + if (typeof state[field] !== 'boolean') { + throw new CommandExecutionError(`Mercury returned malformed ${label}: ${field} must be boolean`); + } + } + return state; +} + +export async function inspectMercury(page) { + await page.goto(MERCURY_EXPENSES_URL, { waitUntil: 'load', settleMs: 1500 }); + await page.wait({ time: 1 }); + + const state = await page.evaluate(`(() => { + const text = document.body?.innerText || ''; + const url = location.href; + return { + url, + loggedIn: !/\\/login\\b/.test(url) && !/sign in|log in|password|passkey/i.test(text), + hasSubmitExpense: /Submit expense/i.test(text), + hasReimbursements: /Reimbursements|My Expenses|Submitted Expenses/i.test(text), + title: document.title + }; + })()`); + return assertBooleanFields(assertMercuryState(state, 'login state'), 'login state', ['loggedIn', 'hasSubmitExpense', 'hasReimbursements']); +} + +export async function clickText(page, labels) { + const result = await page.evaluate(`(() => { + const labels = ${JSON.stringify(labels)}; + const norm = (s) => String(s || '').replace(/\\s+/g, ' ').trim().toLowerCase(); + const wanted = labels.map(norm); + const candidates = Array.from(document.querySelectorAll('button, a, [role="button"], [role="link"]')) + .filter((node) => { + const style = window.getComputedStyle(node); + return style.visibility !== 'hidden' && style.display !== 'none' && node.offsetParent !== null; + }); + const el = candidates.find((node) => wanted.includes(norm(node.innerText || node.textContent || ''))); + if (!el) return { clicked: false, labels }; + el.click(); + return { clicked: true, text: el.innerText || el.textContent || '' }; + })()`); + const payload = assertObject(result, 'click result'); + if (typeof payload.clicked !== 'boolean') throw new CommandExecutionError('Mercury returned malformed click result'); + return payload; +} + +export async function clickCreateExpenseButton(page) { + const result = await page.evaluate(`(() => { + const norm = (s) => String(s || '').replace(/\\s+/g, ' ').trim().toLowerCase(); + const wanted = new Set(['submit expense', 'new expense']); + const candidates = Array.from(document.querySelectorAll('button, a, [role="button"], [role="link"]')) + .filter((node) => { + const style = window.getComputedStyle(node); + return style.visibility !== 'hidden' && style.display !== 'none' && node.offsetParent !== null; + }) + .filter((node) => wanted.has(norm(node.innerText || node.textContent || ''))); + const dangerous = candidates.find((node) => { + const container = node.closest('[role="dialog"], dialog, form, [aria-modal="true"]'); + const context = String(container?.innerText || container?.textContent || '').replace(/\\s+/g, ' ').trim(); + return Boolean(container) || /Review|receipt|amount|merchant|category|notes|expense date/i.test(context); + }); + if (dangerous) { + return { clicked: false, blocked: true, text: dangerous.innerText || dangerous.textContent || '' }; + } + const el = candidates[0]; + if (!el) return { clicked: false, blocked: false }; + el.click(); + return { clicked: true, blocked: false, text: el.innerText || el.textContent || '' }; + })()`); + const payload = assertObject(result, 'create expense click result'); + if (typeof payload.clicked !== 'boolean' || typeof payload.blocked !== 'boolean') { + throw new CommandExecutionError('Mercury returned malformed create expense click result'); + } + if (payload.blocked) { + throw new CommandExecutionError('Mercury is already showing a submit/review surface; refusing to click a possible final Submit expense button'); + } + return payload; +} + +export async function assertCreateExpenseSurface(page) { + const state = await page.evaluate(`(() => { + const text = document.body?.innerText || ''; + return { + url: location.href, + title: document.title, + hasFinalSubmit: /Submit expense/i.test(text) && /Review|receipt|amount|merchant|category|notes/i.test(text), + hasForm: /receipt|amount|merchant|category|notes|expense date/i.test(text), + bodyPreview: text.replace(/\\s+/g, ' ').trim().slice(0, 600) + }; + })()`); + const payload = assertBooleanFields(assertMercuryState(state, 'expense form state'), 'expense form state', ['hasFinalSubmit', 'hasForm']); + if (payload.hasFinalSubmit && !payload.hasForm) { + throw new CommandExecutionError('Mercury is showing a submit button without a recognizable expense form; refusing to click'); + } + return payload; +} + +export async function fillReimbursementFields(page, input) { + const result = await page.evaluate(`(() => { + const payload = ${JSON.stringify(input)}; + const setNativeValue = (el, value) => { + const proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype; + const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set; + setter?.call(el, value); + el.dispatchEvent(new Event('input', { bubbles: true })); + el.dispatchEvent(new Event('change', { bubbles: true })); + el.dispatchEvent(new Event('blur', { bubbles: true })); + }; + const visible = (nodes) => nodes.find((node) => { + const style = window.getComputedStyle(node); + return style.visibility !== 'hidden' && style.display !== 'none' && node.offsetParent !== null; + }); + const allInputs = Array.from(document.querySelectorAll('input, textarea')); + const bySelector = (selector) => visible(Array.from(document.querySelectorAll(selector))); + const byTextNear = (label) => { + const nodes = Array.from(document.querySelectorAll('label, div, span, p')); + for (const node of nodes) { + if (!label.test(node.innerText || node.textContent || '')) continue; + const box = node.closest('label, fieldset, div'); + const field = box?.querySelector('input, textarea'); + if (field) return field; + } + return undefined; + }; + const amount = bySelector('input[id^="amount"], input[name*="amount" i]') || byTextNear(/amount/i); + const merchant = bySelector('input[id^="merchant"], input[name*="merchant" i]') || byTextNear(/merchant/i); + const notes = visible(Array.from(document.querySelectorAll('textarea'))) || byTextNear(/notes|memo|purpose/i); + const date = + bySelector('input[type="date"], input[id*="date" i], input[name*="date" i]') || + byTextNear(/date|expense date/i) || + allInputs.find((field) => /yyyy|mm|dd|\\d{4}-\\d{2}-\\d{2}/i.test(field.getAttribute('placeholder') || '')); + const currency = + bySelector('input[id*="currency" i], input[name*="currency" i]') || + byTextNear(/currency/i) || + allInputs.find((field) => /currency/i.test(field.getAttribute('aria-label') || '')); + const category = + bySelector('input[id*="category" i], input[name*="category" i]') || + byTextNear(/category/i) || + allInputs.find((field) => + /category|select/i.test(\`\${field.getAttribute('placeholder') || ''} \${field.getAttribute('aria-label') || ''}\`) + ); + + const touched = {}; + if (amount) { setNativeValue(amount, payload.amount); touched.amount = true; } + if (currency) { setNativeValue(currency, payload.currency); touched.currency = true; } + if (date) { setNativeValue(date, payload.date); touched.date = true; } + if (merchant) { setNativeValue(merchant, payload.merchant); touched.merchant = true; } + if (notes) { setNativeValue(notes, payload.notes); touched.notes = true; } + if (category) { + category.focus(); + setNativeValue(category, payload.category); + category.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + category.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter', bubbles: true })); + touched.category = true; + } + return { touched }; + })()`); + const payload = assertObject(result, 'field fill result'); + if (!payload.touched || typeof payload.touched !== 'object' || Array.isArray(payload.touched)) { + throw new CommandExecutionError('Mercury returned malformed field fill result'); + } + return payload; +} + +export async function reviewSnapshot(page) { + const snapshot = await page.evaluate(`(() => { + const text = document.body?.innerText || ''; + return { + url: location.href, + title: document.title, + hasReview: /Review/i.test(text), + hasSubmitExpenseButton: /Submit expense/i.test(text), + bodyPreview: text.replace(/\\s+/g, ' ').trim().slice(0, 1200) + }; + })()`); + return assertBooleanFields(assertMercuryState(snapshot, 'review state'), 'review state', ['hasReview', 'hasSubmitExpenseButton']); +} + +export function assertLoggedIn(state) { + if (!state.loggedIn) { + throw new AuthRequiredError('app.mercury.com', 'Mercury reimbursements require a logged-in browser session'); + } +} diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index fa43e175e..18995df29 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -104,6 +104,7 @@ export default defineConfig({ { text: 'Instagram', link: '/adapters/browser/instagram' }, { text: 'JD.com', link: '/adapters/browser/jd' }, { text: 'Medium', link: '/adapters/browser/medium' }, + { text: 'Mercury', link: '/adapters/browser/mercury' }, { text: 'Mubu', link: '/adapters/browser/mubu' }, { text: 'TikTok', link: '/adapters/browser/tiktok' }, { text: 'Web (Generic)', link: '/adapters/browser/web' }, diff --git a/docs/adapters/browser/mercury.md b/docs/adapters/browser/mercury.md new file mode 100644 index 000000000..dd832e902 --- /dev/null +++ b/docs/adapters/browser/mercury.md @@ -0,0 +1,167 @@ +# Mercury + +**Mode**: 🔐 Browser · **Domain**: `app.mercury.com` + +Mercury reimbursements are authenticated browser UI flows. There is no public +API and no stable JSON endpoint for creating an expense, so the adapter drives +the visible Mercury reimbursements page through OpenCLI Browser Bridge. It +creates a **draft**, uploads a local receipt file, waits for Mercury OCR, then +re-applies the agent-provided fields because OCR can overwrite amount, currency, +date, or merchant. + +The write command is deliberately conservative: it stops at Mercury's Review +step and never clicks the final `Submit expense` button. A human or responsible +agent must inspect the Review page before any final submission. + +## Commands + +| Command | Description | +|---------|-------------| +| `opencli mercury reimbursement-plan` | Validate a reimbursement payload locally without opening Mercury | +| `opencli mercury check-login` | Open Mercury reimbursements and report whether the selected browser profile is logged in | +| `opencli mercury reimbursement-draft` | Create a reimbursement draft, attach the receipt, correct OCR-overwritten fields, and stop at Review | + +`reimbursement-plan` and `reimbursement-draft` take the same business payload: + +| Flag | Meaning | +|------|---------| +| `--receipt` | Absolute or relative path to a local receipt/proof file | +| `--amount` | Original-currency positive amount, e.g. `140.00` | +| `--currency` | Original currency code, default `CNY` | +| `--date` | Expense date as `YYYY-MM-DD` | +| `--merchant` | Merchant name to show in Mercury | +| `--category` | Mercury expense category, default `Marketing & Advertising` | +| `--notes` | Business purpose / reimbursement notes | +| `--ocr-wait-seconds` | Seconds to wait after receipt upload before reapplying fields, default `8` | +| `--close-after-review` | Close Review after verification; still never submits | + +## Agent Workflow + +```bash +# 1. Confirm the selected browser profile is logged into Mercury +opencli --profile mercury check-login -f json + +# 2. Validate the payload locally first +opencli mercury reimbursement-plan \ + --receipt /absolute/path/to/receipt.png \ + --amount 140.00 \ + --currency CNY \ + --date 2026-06-26 \ + --merchant "Example Merchant" \ + --category "Marketing & Advertising" \ + --notes "Example business purpose." \ + -f json + +# 3. Create the draft and stop at Review +opencli --profile mercury reimbursement-draft \ + --receipt /absolute/path/to/receipt.png \ + --amount 140.00 \ + --currency CNY \ + --date 2026-06-26 \ + --merchant "Example Merchant" \ + --category "Marketing & Advertising" \ + --notes "Example business purpose." \ + -f json +``` + +## Expected Results + +For `check-login`: + +- `status: "ready"` means the profile reached Mercury reimbursements. +- `status: "needs_login"` means Mercury redirected to login; sign into Mercury + in that Chrome/OpenCLI profile and rerun. + +For `reimbursement-plan`: + +- `status: "ready"` means the local receipt exists and amount/date formats are + valid. +- Missing receipt files, non-positive amount strings, malformed currency codes, + invalid calendar dates, or invalid wait/boolean flags fail before Mercury is + opened. +- Output uses the receipt basename, not the absolute local path. + +For `reimbursement-draft`: + +- `uploaded: true` +- `reviewReady: true` +- `submitBlocked: true` +- `warnings` includes `final Submit expense was intentionally not clicked` +- Mercury shows the Review step with the expected receipt, amount, currency, + date, merchant, category, and notes. + +If upload confirmation, required field correction, or the Review postcondition +fails, the command throws a typed error instead of returning a partial success +row. Keep the browser open and inspect Mercury for validation errors. + +## Testing + +Run repository-level checks: + +```bash +npm run dev -- validate mercury +npm run typecheck +npm run docs:build +``` + +Run a local input smoke test: + +```bash +npm run dev -- mercury reimbursement-plan \ + --receipt /tmp/example-receipt.png \ + --amount 1.00 \ + --currency USD \ + --date 2026-06-30 \ + --merchant "OpenCLI Test Merchant" \ + --category "Office Supplies & Equipment" \ + --notes "OpenCLI adapter smoke test; do not submit." \ + -f json +``` + +Run a real UI smoke test only in a test Mercury workspace/profile or with a +harmless test receipt: + +```bash +npm run dev -- --profile mercury reimbursement-draft \ + --receipt /absolute/path/to/test-receipt.png \ + --amount 1.00 \ + --currency USD \ + --date 2026-06-30 \ + --merchant "OpenCLI Test Merchant" \ + --category "Office Supplies & Equipment" \ + --notes "OpenCLI adapter smoke test; do not submit." \ + -f json +``` + +Pass condition: Mercury stops at Review and the returned row has +`submitBlocked: true`. Do not click final Submit during smoke tests. + +## Notes + +- **Login is required.** This adapter uses the selected OpenCLI browser profile; + it does not store Mercury credentials or run an OAuth/login flow. +- **Receipt upload** targets Mercury's attachment input: + `[data-testid="expense-attachment-upload"]`. If Mercury changes that selector, + upload can fail while the rest of the form remains visible. +- **OCR happens before correction.** Mercury OCR can misread currency (for + example `CNY` as `JPY`) or overwrite merchant/amount. The command uploads the + receipt first, waits, then re-fills fields from the CLI arguments. +- **Review is not submission.** `reimbursement-draft` never presses the final + `Submit expense` button. It prepares a draft for inspection. +- **Use original currency.** Enter the currency shown on the source receipt + when Mercury supports it, then verify Mercury's converted reimbursement amount + on the Review page. +- **Output is intentionally compact.** The returned row is a control-plane + status summary; Mercury remains the source of truth for final visual review. + +## Troubleshooting + +- `needs_login`: open Mercury in the same Chrome/OpenCLI profile, finish login, + then rerun `check-login`. +- Upload failure: verify the file exists locally and that Mercury still uses the + receipt input selector above. The command requires Browser Bridge + `uploadFiles` support so it can verify the intended file input. +- Review failure: inspect Mercury for validation errors; the command may have + uploaded the receipt and filled fields but failed to reach Review. +- Category did not commit: custom Mercury dropdowns can be sensitive to UI + changes. Retry with the exact category label visible in Mercury. From 9a4e11d3fae3bf2fee33013eb6d44d4c47de76f3 Mon Sep 17 00:00:00 2001 From: jakevin Date: Wed, 1 Jul 2026 01:26:25 +0800 Subject: [PATCH 07/27] fix(extension): honor persisted remaining idle lifetime on reconcile (#1980) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reconcileTargetLeaseRegistry computed each lease's remaining lifetime (stored.idleDeadlineAt - now) but used it only to decide expire-vs-keep; the keep branch called resetWindowIdleTimer(leaseKey), which always schedules a fresh FULL idle timeout, discarding the remaining time. Under MV3 service-worker churn (the SW is evicted/restarted routinely), a lease's idle deadline was refreshed to the full timeout on every restart, so an owned adapter tab/placeholder that should auto-release could linger far past its idle timeout — effectively indefinitely. Add an optional remainingMs override to resetWindowIdleTimer and pass the computed remaining from reconcile, clamped to [0, timeout]. Adds a regression test (5s-remaining lease must schedule a ~5s alarm, not 30s); reverse-validated. --- extension/dist/background.js | 4097 +++++++++++++++--------------- extension/src/background.test.ts | 43 + extension/src/background.ts | 20 +- 3 files changed, 2067 insertions(+), 2093 deletions(-) diff --git a/extension/dist/background.js b/extension/dist/background.js index 87aab01ff..31f893d1f 100644 --- a/extension/dist/background.js +++ b/extension/dist/background.js @@ -1,2324 +1,2245 @@ -//#region src/protocol.ts -/** Default daemon port */ -var DAEMON_PORT = 19825; -var DAEMON_HOST = "localhost"; -var DAEMON_WS_URL = `ws://${DAEMON_HOST}:${DAEMON_PORT}/ext`; -/** Lightweight health-check endpoint — probed before each WebSocket attempt. */ -var DAEMON_PING_URL = `http://${DAEMON_HOST}:${DAEMON_PORT}/ping`; -//#endregion -//#region src/cdp.ts -/** -* CDP execution via chrome.debugger API. -* -* chrome.debugger only needs the "debugger" permission — no host_permissions. -* It can attach to any http/https tab. Avoid chrome:// and chrome-extension:// -* tabs (resolveTabId in background.ts filters them). -*/ -var attached = /* @__PURE__ */ new Set(); -var tabFrameContexts = /* @__PURE__ */ new Map(); -var frameTargets = /* @__PURE__ */ new Map(); -var frameTargetKeys = /* @__PURE__ */ new Map(); -var frameTargetCleanupRegistered = false; -var CDP_RESPONSE_BODY_CAPTURE_LIMIT = 8 * 1024 * 1024; -var CDP_REQUEST_BODY_CAPTURE_LIMIT = 1 * 1024 * 1024; -var networkCaptures = /* @__PURE__ */ new Map(); -/** Check if a URL can be attached via CDP — only allow http(s) and blank pages. */ +const DAEMON_PORT = 19825; +const DAEMON_HOST = "localhost"; +const DAEMON_WS_URL = `ws://${DAEMON_HOST}:${DAEMON_PORT}/ext`; +const DAEMON_PING_URL = `http://${DAEMON_HOST}:${DAEMON_PORT}/ping`; + +const attached = /* @__PURE__ */ new Set(); +const tabFrameContexts = /* @__PURE__ */ new Map(); +const frameTargets = /* @__PURE__ */ new Map(); +const frameTargetKeys = /* @__PURE__ */ new Map(); +let frameTargetCleanupRegistered = false; +const CDP_RESPONSE_BODY_CAPTURE_LIMIT = 8 * 1024 * 1024; +const CDP_REQUEST_BODY_CAPTURE_LIMIT = 1 * 1024 * 1024; +const networkCaptures = /* @__PURE__ */ new Map(); function isDebuggableUrl$1(url) { - if (!url) return true; - return url.startsWith("http://") || url.startsWith("https://") || url === "about:blank" || url.startsWith("data:"); + if (!url) return true; + return url.startsWith("http://") || url.startsWith("https://") || url === "about:blank" || url.startsWith("data:"); } async function ensureAttached(tabId, aggressiveRetry = false) { - try { - const tab = await chrome.tabs.get(tabId); - if (!isDebuggableUrl$1(tab.url)) { - attached.delete(tabId); - throw new Error(`Cannot debug tab ${tabId}: URL is ${tab.url ?? "unknown"}`); - } - } catch (e) { - if (e instanceof Error && e.message.startsWith("Cannot debug tab")) throw e; - attached.delete(tabId); - throw new Error(`Tab ${tabId} no longer exists`); - } - if (attached.has(tabId)) try { - await chrome.debugger.sendCommand({ tabId }, "Runtime.evaluate", { - expression: "1", - returnByValue: true - }); - return; - } catch { - attached.delete(tabId); - } - const MAX_ATTACH_RETRIES = aggressiveRetry ? 5 : 2; - const RETRY_DELAY_MS = aggressiveRetry ? 1500 : 500; - let lastError = ""; - for (let attempt = 1; attempt <= MAX_ATTACH_RETRIES; attempt++) try { - try { - await chrome.debugger.detach({ tabId }); - } catch {} - await chrome.debugger.attach({ tabId }, "1.3"); - lastError = ""; - break; - } catch (e) { - lastError = e instanceof Error ? e.message : String(e); - if (attempt < MAX_ATTACH_RETRIES) { - console.warn(`[opencli] attach attempt ${attempt}/${MAX_ATTACH_RETRIES} failed: ${lastError}, retrying in ${RETRY_DELAY_MS}ms...`); - await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS)); - try { - const tab = await chrome.tabs.get(tabId); - if (!isDebuggableUrl$1(tab.url)) { - lastError = `Tab URL changed to ${tab.url} during retry`; - break; - } - } catch { - lastError = `Tab ${tabId} no longer exists`; - } - } - } - if (lastError) { - let finalUrl = "unknown"; - let finalWindowId = "unknown"; - try { - const tab = await chrome.tabs.get(tabId); - finalUrl = tab.url ?? "undefined"; - finalWindowId = String(tab.windowId); - } catch {} - console.warn(`[opencli] attach failed for tab ${tabId}: url=${finalUrl}, windowId=${finalWindowId}, error=${lastError}`); - const hint = lastError.includes("chrome-extension://") ? ". Tip: another Chrome extension may be interfering — try disabling other extensions" : ""; - throw new Error(`attach failed: ${lastError}${hint}`); - } - attached.add(tabId); - try { - await chrome.debugger.sendCommand({ tabId }, "Runtime.enable"); - } catch {} + try { + const tab = await chrome.tabs.get(tabId); + if (!isDebuggableUrl$1(tab.url)) { + attached.delete(tabId); + throw new Error(`Cannot debug tab ${tabId}: URL is ${tab.url ?? "unknown"}`); + } + } catch (e) { + if (e instanceof Error && e.message.startsWith("Cannot debug tab")) throw e; + attached.delete(tabId); + throw new Error(`Tab ${tabId} no longer exists`); + } + if (attached.has(tabId)) { + try { + await chrome.debugger.sendCommand({ tabId }, "Runtime.evaluate", { + expression: "1", + returnByValue: true + }); + return; + } catch { + attached.delete(tabId); + } + } + const MAX_ATTACH_RETRIES = aggressiveRetry ? 5 : 2; + const RETRY_DELAY_MS = aggressiveRetry ? 1500 : 500; + let lastError = ""; + for (let attempt = 1; attempt <= MAX_ATTACH_RETRIES; attempt++) { + try { + try { + await chrome.debugger.detach({ tabId }); + } catch { + } + await chrome.debugger.attach({ tabId }, "1.3"); + lastError = ""; + break; + } catch (e) { + lastError = e instanceof Error ? e.message : String(e); + if (attempt < MAX_ATTACH_RETRIES) { + console.warn(`[opencli] attach attempt ${attempt}/${MAX_ATTACH_RETRIES} failed: ${lastError}, retrying in ${RETRY_DELAY_MS}ms...`); + await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS)); + try { + const tab = await chrome.tabs.get(tabId); + if (!isDebuggableUrl$1(tab.url)) { + lastError = `Tab URL changed to ${tab.url} during retry`; + break; + } + } catch { + lastError = `Tab ${tabId} no longer exists`; + } + } + } + } + if (lastError) { + let finalUrl = "unknown"; + let finalWindowId = "unknown"; + try { + const tab = await chrome.tabs.get(tabId); + finalUrl = tab.url ?? "undefined"; + finalWindowId = String(tab.windowId); + } catch { + } + console.warn(`[opencli] attach failed for tab ${tabId}: url=${finalUrl}, windowId=${finalWindowId}, error=${lastError}`); + const hint = lastError.includes("chrome-extension://") ? ". Tip: another Chrome extension may be interfering — try disabling other extensions" : ""; + throw new Error(`attach failed: ${lastError}${hint}`); + } + attached.add(tabId); + try { + await chrome.debugger.sendCommand({ tabId }, "Runtime.enable"); + } catch { + } } async function evaluate(tabId, expression, aggressiveRetry = false) { - const MAX_EVAL_RETRIES = aggressiveRetry ? 3 : 2; - for (let attempt = 1; attempt <= MAX_EVAL_RETRIES; attempt++) try { - await ensureAttached(tabId, aggressiveRetry); - const result = await chrome.debugger.sendCommand({ tabId }, "Runtime.evaluate", { - expression, - returnByValue: true, - awaitPromise: true - }); - if (result.exceptionDetails) { - const errMsg = result.exceptionDetails.exception?.description || result.exceptionDetails.text || "Eval error"; - throw new Error(errMsg); - } - return result.result?.value; - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - const isNavigateError = msg.includes("Inspected target navigated") || msg.includes("Target closed"); - if ((isNavigateError || msg.includes("attach failed") || msg.includes("Debugger is not attached") || msg.includes("chrome-extension://")) && attempt < MAX_EVAL_RETRIES) { - attached.delete(tabId); - const retryMs = isNavigateError ? 200 : 500; - await new Promise((resolve) => setTimeout(resolve, retryMs)); - continue; - } - throw e; - } - throw new Error("evaluate: max retries exhausted"); -} -var evaluateAsync = evaluate; -/** -* Capture a screenshot via CDP Page.captureScreenshot. -* Returns base64-encoded image data. -*/ + const MAX_EVAL_RETRIES = aggressiveRetry ? 3 : 2; + for (let attempt = 1; attempt <= MAX_EVAL_RETRIES; attempt++) { + try { + await ensureAttached(tabId, aggressiveRetry); + const result = await chrome.debugger.sendCommand({ tabId }, "Runtime.evaluate", { + expression, + returnByValue: true, + awaitPromise: true + }); + if (result.exceptionDetails) { + const errMsg = result.exceptionDetails.exception?.description || result.exceptionDetails.text || "Eval error"; + throw new Error(errMsg); + } + return result.result?.value; + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + const isNavigateError = msg.includes("Inspected target navigated") || msg.includes("Target closed"); + const isAttachError = isNavigateError || msg.includes("attach failed") || msg.includes("Debugger is not attached") || msg.includes("chrome-extension://"); + if (isAttachError && attempt < MAX_EVAL_RETRIES) { + attached.delete(tabId); + const retryMs = isNavigateError ? 200 : 500; + await new Promise((resolve) => setTimeout(resolve, retryMs)); + continue; + } + throw e; + } + } + throw new Error("evaluate: max retries exhausted"); +} +const evaluateAsync = evaluate; async function screenshot(tabId, options = {}) { - await ensureAttached(tabId); - const format = options.format ?? "png"; - const fullPage = options.fullPage === true; - const overrideWidth = options.width && options.width > 0 ? Math.ceil(options.width) : void 0; - const overrideHeight = !fullPage && options.height && options.height > 0 ? Math.ceil(options.height) : void 0; - const needsOverride = fullPage || overrideWidth !== void 0 || overrideHeight !== void 0; - if (needsOverride) { - if (overrideWidth !== void 0 && fullPage) await chrome.debugger.sendCommand({ tabId }, "Emulation.setDeviceMetricsOverride", { - mobile: false, - width: overrideWidth, - height: 0, - deviceScaleFactor: 1 - }); - let finalWidth = overrideWidth ?? 0; - let finalHeight = overrideHeight ?? 0; - if (fullPage) { - const metrics = await chrome.debugger.sendCommand({ tabId }, "Page.getLayoutMetrics"); - const size = metrics.cssContentSize || metrics.contentSize; - if (size) { - if (finalWidth === 0) finalWidth = Math.ceil(size.width); - finalHeight = Math.ceil(size.height); - } - } - await chrome.debugger.sendCommand({ tabId }, "Emulation.setDeviceMetricsOverride", { - mobile: false, - width: finalWidth, - height: finalHeight, - deviceScaleFactor: 1 - }); - } - try { - const params = { format }; - if (format === "jpeg" && options.quality !== void 0) params.quality = Math.max(0, Math.min(100, options.quality)); - return (await chrome.debugger.sendCommand({ tabId }, "Page.captureScreenshot", params)).data; - } finally { - if (needsOverride) await chrome.debugger.sendCommand({ tabId }, "Emulation.clearDeviceMetricsOverride").catch(() => {}); - } -} -/** -* Set local file paths on a file input element via CDP DOM.setFileInputFiles. -* This bypasses the need to send large base64 payloads through the message channel — -* Chrome reads the files directly from the local filesystem. -* -* @param tabId - Target tab ID -* @param files - Array of absolute local file paths -* @param selector - CSS selector to find the file input (optional, defaults to first file input) -*/ + await ensureAttached(tabId); + const format = options.format ?? "png"; + const fullPage = options.fullPage === true; + const overrideWidth = options.width && options.width > 0 ? Math.ceil(options.width) : void 0; + const overrideHeight = !fullPage && options.height && options.height > 0 ? Math.ceil(options.height) : void 0; + const needsOverride = fullPage || overrideWidth !== void 0 || overrideHeight !== void 0; + if (needsOverride) { + if (overrideWidth !== void 0 && fullPage) { + await chrome.debugger.sendCommand({ tabId }, "Emulation.setDeviceMetricsOverride", { + mobile: false, + width: overrideWidth, + height: 0, + deviceScaleFactor: 1 + }); + } + let finalWidth = overrideWidth ?? 0; + let finalHeight = overrideHeight ?? 0; + if (fullPage) { + const metrics = await chrome.debugger.sendCommand({ tabId }, "Page.getLayoutMetrics"); + const size = metrics.cssContentSize || metrics.contentSize; + if (size) { + if (finalWidth === 0) finalWidth = Math.ceil(size.width); + finalHeight = Math.ceil(size.height); + } + } + await chrome.debugger.sendCommand({ tabId }, "Emulation.setDeviceMetricsOverride", { + mobile: false, + width: finalWidth, + height: finalHeight, + deviceScaleFactor: 1 + }); + } + try { + const params = { format }; + if (format === "jpeg" && options.quality !== void 0) { + params.quality = Math.max(0, Math.min(100, options.quality)); + } + const result = await chrome.debugger.sendCommand({ tabId }, "Page.captureScreenshot", params); + return result.data; + } finally { + if (needsOverride) { + await chrome.debugger.sendCommand({ tabId }, "Emulation.clearDeviceMetricsOverride").catch(() => { + }); + } + } +} async function setFileInputFiles(tabId, files, selector) { - await ensureAttached(tabId); - await chrome.debugger.sendCommand({ tabId }, "DOM.enable"); - const doc = await chrome.debugger.sendCommand({ tabId }, "DOM.getDocument"); - const query = selector || "input[type=\"file\"]"; - const result = await chrome.debugger.sendCommand({ tabId }, "DOM.querySelector", { - nodeId: doc.root.nodeId, - selector: query - }); - if (!result.nodeId) throw new Error(`No element found matching selector: ${query}`); - await chrome.debugger.sendCommand({ tabId }, "DOM.setFileInputFiles", { - files, - nodeId: result.nodeId - }); + await ensureAttached(tabId); + await chrome.debugger.sendCommand({ tabId }, "DOM.enable"); + const doc = await chrome.debugger.sendCommand({ tabId }, "DOM.getDocument"); + const query = selector || 'input[type="file"]'; + const result = await chrome.debugger.sendCommand({ tabId }, "DOM.querySelector", { + nodeId: doc.root.nodeId, + selector: query + }); + if (!result.nodeId) { + throw new Error(`No element found matching selector: ${query}`); + } + await chrome.debugger.sendCommand({ tabId }, "DOM.setFileInputFiles", { + files, + nodeId: result.nodeId + }); } function matchesDownloadPattern(item, pattern) { - if (!pattern) return true; - return [ - item.filename, - item.url, - item.finalUrl, - item.mime - ].filter(Boolean).join("\n").toLowerCase().includes(pattern.toLowerCase()); + if (!pattern) return true; + const haystack = [ + item.filename, + item.url, + item.finalUrl, + item.mime + ].filter(Boolean).join("\n").toLowerCase(); + return haystack.includes(pattern.toLowerCase()); } function downloadResult(item, startedAt) { - return { - downloaded: item.state === "complete", - id: item.id, - filename: item.filename, - url: item.url, - finalUrl: item.finalUrl, - mime: item.mime, - totalBytes: item.totalBytes, - state: item.state, - danger: item.danger, - error: item.error, - elapsedMs: Date.now() - startedAt - }; + return { + downloaded: item.state === "complete", + id: item.id, + filename: item.filename, + url: item.url, + finalUrl: item.finalUrl, + mime: item.mime, + totalBytes: item.totalBytes, + state: item.state, + danger: item.danger, + error: item.error, + elapsedMs: Date.now() - startedAt + }; } async function waitForDownload(pattern = "", timeoutMs = 3e4) { - const startedAt = Date.now(); - const timeout = Math.max(1, timeoutMs); - return await new Promise((resolve) => { - let done = false; - const inProgressIds = /* @__PURE__ */ new Set(); - const finish = (result) => { - if (done) return; - done = true; - clearTimeout(timer); - chrome.downloads.onCreated.removeListener(onCreated); - chrome.downloads.onChanged.removeListener(onChanged); - resolve(result); - }; - const inspectById = async (id) => { - const item = (await chrome.downloads.search({ id }))[0]; - if (!item || !matchesDownloadPattern(item, pattern)) return; - inProgressIds.add(id); - if (item.state === "complete" || item.state === "interrupted") finish(downloadResult(item, startedAt)); - }; - const onCreated = (item) => { - if (!matchesDownloadPattern(item, pattern)) return; - inProgressIds.add(item.id); - if (item.state === "complete" || item.state === "interrupted") finish(downloadResult(item, startedAt)); - }; - const onChanged = (delta) => { - if (!delta.id) return; - if (!inProgressIds.has(delta.id) && !delta.filename && !delta.url) return; - if (delta.filename?.current || delta.url?.current) { - inspectById(delta.id); - return; - } - if (delta.state?.current === "complete" || delta.state?.current === "interrupted") inspectById(delta.id); - }; - const timer = setTimeout(() => { - finish({ - downloaded: false, - state: "interrupted", - error: `No download matched "${pattern || "*"}" within ${timeout}ms`, - elapsedMs: Date.now() - startedAt - }); - }, timeout); - chrome.downloads.onCreated.addListener(onCreated); - chrome.downloads.onChanged.addListener(onChanged); - chrome.downloads.search({ - limit: 50, - orderBy: ["-startTime"], - startedAfter: new Date(startedAt - Math.max(timeout, 1e3)).toISOString() - }).then((recent) => { - if (done) return; - const completed = recent.find((item) => item.state === "complete" && matchesDownloadPattern(item, pattern)); - if (completed) { - finish(downloadResult(completed, startedAt)); - return; - } - for (const item of recent) if (item.state === "in_progress" && matchesDownloadPattern(item, pattern)) inProgressIds.add(item.id); - }).catch((err) => { - finish({ - downloaded: false, - state: "interrupted", - error: err instanceof Error ? err.message : String(err), - elapsedMs: Date.now() - startedAt - }); - }); - }); + const startedAt = Date.now(); + const timeout = Math.max(1, timeoutMs); + return await new Promise((resolve) => { + let done = false; + const inProgressIds = /* @__PURE__ */ new Set(); + const finish = (result) => { + if (done) return; + done = true; + clearTimeout(timer); + chrome.downloads.onCreated.removeListener(onCreated); + chrome.downloads.onChanged.removeListener(onChanged); + resolve(result); + }; + const inspectById = async (id) => { + const items = await chrome.downloads.search({ id }); + const item = items[0]; + if (!item || !matchesDownloadPattern(item, pattern)) return; + inProgressIds.add(id); + if (item.state === "complete" || item.state === "interrupted") finish(downloadResult(item, startedAt)); + }; + const onCreated = (item) => { + if (!matchesDownloadPattern(item, pattern)) return; + inProgressIds.add(item.id); + if (item.state === "complete" || item.state === "interrupted") finish(downloadResult(item, startedAt)); + }; + const onChanged = (delta) => { + if (!delta.id) return; + if (!inProgressIds.has(delta.id) && !delta.filename && !delta.url) return; + if (delta.filename?.current || delta.url?.current) { + void inspectById(delta.id); + return; + } + if (delta.state?.current === "complete" || delta.state?.current === "interrupted") { + void inspectById(delta.id); + } + }; + const timer = setTimeout(() => { + finish({ + downloaded: false, + state: "interrupted", + error: `No download matched "${pattern || "*"}" within ${timeout}ms`, + elapsedMs: Date.now() - startedAt + }); + }, timeout); + chrome.downloads.onCreated.addListener(onCreated); + chrome.downloads.onChanged.addListener(onChanged); + void chrome.downloads.search({ + limit: 50, + orderBy: ["-startTime"], + startedAfter: new Date(startedAt - Math.max(timeout, 1e3)).toISOString() + }).then((recent) => { + if (done) return; + const completed = recent.find((item) => item.state === "complete" && matchesDownloadPattern(item, pattern)); + if (completed) { + finish(downloadResult(completed, startedAt)); + return; + } + for (const item of recent) { + if (item.state === "in_progress" && matchesDownloadPattern(item, pattern)) inProgressIds.add(item.id); + } + }).catch((err) => { + finish({ + downloaded: false, + state: "interrupted", + error: err instanceof Error ? err.message : String(err), + elapsedMs: Date.now() - startedAt + }); + }); + }); } function frameTargetKey(tabId, frameId) { - return `${tabId}:${frameId}`; + return `${tabId}:${frameId}`; } function registerFrameTargetCleanup() { - if (frameTargetCleanupRegistered) return; - frameTargetCleanupRegistered = true; - chrome.debugger.onEvent.addListener((_source, method, params) => { - if (method === "Target.detachedFromTarget") clearFrameTarget(String(params?.targetId || "")); - }); + if (frameTargetCleanupRegistered) return; + frameTargetCleanupRegistered = true; + chrome.debugger.onEvent.addListener((_source, method, params) => { + if (method === "Target.detachedFromTarget") { + const targetId = String(params?.targetId || ""); + clearFrameTarget(targetId); + } + }); } function clearFrameTarget(targetId) { - if (!targetId) return; - const key = frameTargetKeys.get(targetId); - if (key) frameTargets.delete(key); - frameTargetKeys.delete(targetId); + if (!targetId) return; + const key = frameTargetKeys.get(targetId); + if (key) frameTargets.delete(key); + frameTargetKeys.delete(targetId); } async function ensureFrameTarget(tabId, frameId, aggressiveRetry = false, targetUrl) { - registerFrameTargetCleanup(); - await ensureAttached(tabId, aggressiveRetry); - const key = frameTargetKey(tabId, frameId); - const existing = frameTargets.get(key); - if (existing) return existing; - await chrome.debugger.sendCommand({ tabId }, "Target.setDiscoverTargets", { discover: true }).catch(() => {}); - await chrome.debugger.sendCommand({ tabId }, "Target.setAutoAttach", { - autoAttach: true, - waitForDebuggerOnStart: false, - flatten: true, - filter: [{ - type: "iframe", - exclude: false - }] - }).catch(() => {}); - const targetId = await resolveFrameTargetId(tabId, frameId, targetUrl); - try { - await chrome.debugger.attach({ targetId }, "1.3"); - } catch (err) { - if (!(err instanceof Error ? err.message : String(err)).includes("Another debugger is already attached")) throw err; - } - frameTargets.set(key, targetId); - frameTargetKeys.set(targetId, key); - return targetId; + registerFrameTargetCleanup(); + await ensureAttached(tabId, aggressiveRetry); + const key = frameTargetKey(tabId, frameId); + const existing = frameTargets.get(key); + if (existing) return existing; + await chrome.debugger.sendCommand({ tabId }, "Target.setDiscoverTargets", { discover: true }).catch(() => { + }); + await chrome.debugger.sendCommand({ tabId }, "Target.setAutoAttach", { + autoAttach: true, + waitForDebuggerOnStart: false, + flatten: true, + filter: [{ type: "iframe", exclude: false }] + }).catch(() => { + }); + const targetId = await resolveFrameTargetId(tabId, frameId, targetUrl); + try { + await chrome.debugger.attach({ targetId }, "1.3"); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (!message.includes("Another debugger is already attached")) throw err; + } + frameTargets.set(key, targetId); + frameTargetKeys.set(targetId, key); + return targetId; } async function resolveFrameTargetId(tabId, frameId, targetUrl) { - const targets = (await chrome.debugger.sendCommand({ tabId }, "Target.getTargets").catch(() => null))?.targetInfos ?? []; - const frameTarget = targets.find((candidate) => { - const candidateId = candidate.targetId || candidate.id; - return candidate.type === "iframe" && (candidateId === frameId || !!targetUrl && candidate.url === targetUrl); - }); - const targetId = frameTarget?.targetId || frameTarget?.id; - if (targetId) return targetId; - const candidates = targets.filter((target) => target.type === "iframe").map((target) => `${target.targetId || target.id || "?"} ${target.url || ""}`).join("; "); - throw new Error(`No iframe target found for frame ${frameId}${targetUrl ? ` (${targetUrl})` : ""}. Candidates: ${candidates || "none"}`); + const result = await chrome.debugger.sendCommand({ tabId }, "Target.getTargets").catch(() => null); + const targets = result?.targetInfos ?? []; + const frameTarget = targets.find((candidate) => { + const candidateId = candidate.targetId || candidate.id; + return candidate.type === "iframe" && (candidateId === frameId || !!targetUrl && candidate.url === targetUrl); + }); + const targetId = frameTarget?.targetId || frameTarget?.id; + if (targetId) return targetId; + const candidates = targets.filter((target) => target.type === "iframe").map((target) => `${target.targetId || target.id || "?"} ${target.url || ""}`).join("; "); + throw new Error(`No iframe target found for frame ${frameId}${targetUrl ? ` (${targetUrl})` : ""}. Candidates: ${candidates || "none"}`); } async function sendCommandInFrameTarget(tabId, frameId, method, params = {}, aggressiveRetry = false, _timeoutMs = 3e4, targetUrl) { - const target = { targetId: await ensureFrameTarget(tabId, frameId, aggressiveRetry, targetUrl) }; - return chrome.debugger.sendCommand(target, method, params); + const targetId = await ensureFrameTarget(tabId, frameId, aggressiveRetry, targetUrl); + const target = { targetId }; + return chrome.debugger.sendCommand(target, method, params); } async function insertText(tabId, text) { - await ensureAttached(tabId); - await chrome.debugger.sendCommand({ tabId }, "Input.insertText", { text }); + await ensureAttached(tabId); + await chrome.debugger.sendCommand({ tabId }, "Input.insertText", { text }); } function registerFrameTracking() { - registerFrameTargetCleanup(); - chrome.debugger.onEvent.addListener((source, method, params) => { - const tabId = source.tabId; - if (!tabId) return; - if (method === "Runtime.executionContextCreated") { - const context = params.context; - if (!context?.auxData?.frameId || context.auxData.isDefault !== true) return; - const frameId = context.auxData.frameId; - if (!tabFrameContexts.has(tabId)) tabFrameContexts.set(tabId, /* @__PURE__ */ new Map()); - tabFrameContexts.get(tabId).set(frameId, context.id); - } - if (method === "Runtime.executionContextDestroyed") { - const ctxId = params.executionContextId; - const contexts = tabFrameContexts.get(tabId); - if (contexts) { - for (const [fid, cid] of contexts) if (cid === ctxId) { - contexts.delete(fid); - break; - } - } - } - if (method === "Runtime.executionContextsCleared") tabFrameContexts.delete(tabId); - }); - chrome.tabs.onRemoved.addListener((tabId) => { - tabFrameContexts.delete(tabId); - }); + registerFrameTargetCleanup(); + chrome.debugger.onEvent.addListener((source, method, params) => { + const tabId = source.tabId; + if (!tabId) return; + if (method === "Runtime.executionContextCreated") { + const context = params.context; + if (!context?.auxData?.frameId || context.auxData.isDefault !== true) return; + const frameId = context.auxData.frameId; + if (!tabFrameContexts.has(tabId)) { + tabFrameContexts.set(tabId, /* @__PURE__ */ new Map()); + } + tabFrameContexts.get(tabId).set(frameId, context.id); + } + if (method === "Runtime.executionContextDestroyed") { + const ctxId = params.executionContextId; + const contexts = tabFrameContexts.get(tabId); + if (contexts) { + for (const [fid, cid] of contexts) { + if (cid === ctxId) { + contexts.delete(fid); + break; + } + } + } + } + if (method === "Runtime.executionContextsCleared") { + tabFrameContexts.delete(tabId); + } + }); + chrome.tabs.onRemoved.addListener((tabId) => { + tabFrameContexts.delete(tabId); + }); } async function getFrameTree(tabId) { - await ensureAttached(tabId); - return chrome.debugger.sendCommand({ tabId }, "Page.getFrameTree"); + await ensureAttached(tabId); + return chrome.debugger.sendCommand({ tabId }, "Page.getFrameTree"); } async function evaluateInFrame(tabId, expression, frameId, aggressiveRetry = false) { - await ensureAttached(tabId, aggressiveRetry); - await chrome.debugger.sendCommand({ tabId }, "Runtime.enable").catch(() => {}); - const contextId = tabFrameContexts.get(tabId)?.get(frameId); - if (contextId === void 0) { - await sendCommandInFrameTarget(tabId, frameId, "Runtime.enable", {}, aggressiveRetry).catch(() => void 0); - const result = await sendCommandInFrameTarget(tabId, frameId, "Runtime.evaluate", { - expression, - returnByValue: true, - awaitPromise: true - }, aggressiveRetry); - if (result.exceptionDetails) { - const errMsg = result.exceptionDetails.exception?.description || result.exceptionDetails.text || "Eval error"; - throw new Error(errMsg); - } - return result.result?.value; - } - const result = await chrome.debugger.sendCommand({ tabId }, "Runtime.evaluate", { - expression, - contextId, - returnByValue: true, - awaitPromise: true - }); - if (result.exceptionDetails) { - const errMsg = result.exceptionDetails.exception?.description || result.exceptionDetails.text || "Eval error"; - throw new Error(errMsg); - } - return result.result?.value; + await ensureAttached(tabId, aggressiveRetry); + await chrome.debugger.sendCommand({ tabId }, "Runtime.enable").catch(() => { + }); + const contexts = tabFrameContexts.get(tabId); + const contextId = contexts?.get(frameId); + if (contextId === void 0) { + await sendCommandInFrameTarget(tabId, frameId, "Runtime.enable", {}, aggressiveRetry).catch(() => void 0); + const result2 = await sendCommandInFrameTarget(tabId, frameId, "Runtime.evaluate", { + expression, + returnByValue: true, + awaitPromise: true + }, aggressiveRetry); + if (result2.exceptionDetails) { + const errMsg = result2.exceptionDetails.exception?.description || result2.exceptionDetails.text || "Eval error"; + throw new Error(errMsg); + } + return result2.result?.value; + } + const result = await chrome.debugger.sendCommand({ tabId }, "Runtime.evaluate", { + expression, + contextId, + returnByValue: true, + awaitPromise: true + }); + if (result.exceptionDetails) { + const errMsg = result.exceptionDetails.exception?.description || result.exceptionDetails.text || "Eval error"; + throw new Error(errMsg); + } + return result.result?.value; } function normalizeCapturePatterns(pattern) { - return String(pattern || "").split("|").map((part) => part.trim()).filter(Boolean); + return String(pattern || "").split("|").map((part) => part.trim()).filter(Boolean); } function shouldCaptureUrl(url, patterns) { - if (!url) return false; - if (!patterns.length) return true; - return patterns.some((pattern) => url.includes(pattern)); + if (!url) return false; + if (!patterns.length) return true; + return patterns.some((pattern) => url.includes(pattern)); } function normalizeHeaders(headers) { - if (!headers || typeof headers !== "object") return {}; - const out = {}; - for (const [key, value] of Object.entries(headers)) out[String(key)] = String(value); - return out; + if (!headers || typeof headers !== "object") return {}; + const out = {}; + for (const [key, value] of Object.entries(headers)) { + out[String(key)] = String(value); + } + return out; } function getOrCreateNetworkCaptureEntry(tabId, requestId, fallback) { - const state = networkCaptures.get(tabId); - if (!state) return null; - const existingIndex = state.requestToIndex.get(requestId); - if (existingIndex !== void 0) return state.entries[existingIndex] || null; - const url = fallback?.url || ""; - if (!shouldCaptureUrl(url, state.patterns)) return null; - const entry = { - kind: "cdp", - url, - method: fallback?.method || "GET", - requestHeaders: fallback?.requestHeaders || {}, - timestamp: Date.now() - }; - state.entries.push(entry); - state.requestToIndex.set(requestId, state.entries.length - 1); - return entry; + const state = networkCaptures.get(tabId); + if (!state) return null; + const existingIndex = state.requestToIndex.get(requestId); + if (existingIndex !== void 0) { + return state.entries[existingIndex] || null; + } + const url = fallback?.url || ""; + if (!shouldCaptureUrl(url, state.patterns)) return null; + const entry = { + kind: "cdp", + url, + method: fallback?.method || "GET", + requestHeaders: fallback?.requestHeaders || {}, + timestamp: Date.now() + }; + state.entries.push(entry); + state.requestToIndex.set(requestId, state.entries.length - 1); + return entry; } async function startNetworkCapture(tabId, pattern) { - await ensureAttached(tabId); - await chrome.debugger.sendCommand({ tabId }, "Network.enable"); - networkCaptures.set(tabId, { - patterns: normalizeCapturePatterns(pattern), - entries: [], - requestToIndex: /* @__PURE__ */ new Map() - }); + await ensureAttached(tabId); + await chrome.debugger.sendCommand({ tabId }, "Network.enable"); + networkCaptures.set(tabId, { + patterns: normalizeCapturePatterns(pattern), + entries: [], + requestToIndex: /* @__PURE__ */ new Map() + }); } async function readNetworkCapture(tabId) { - const state = networkCaptures.get(tabId); - if (!state) return []; - const entries = state.entries.slice(); - state.entries = []; - state.requestToIndex.clear(); - return entries; + const state = networkCaptures.get(tabId); + if (!state) return []; + const entries = state.entries.slice(); + state.entries = []; + state.requestToIndex.clear(); + return entries; } function hasActiveNetworkCapture(tabId) { - return networkCaptures.has(tabId); + return networkCaptures.has(tabId); } function clearFrameTargetsForTab(tabId) { - for (const [key, targetId] of [...frameTargets.entries()]) { - if (!key.startsWith(`${tabId}:`)) continue; - frameTargets.delete(key); - frameTargetKeys.delete(targetId); - chrome.debugger.detach({ targetId }).catch(() => {}); - } + for (const [key, targetId] of [...frameTargets.entries()]) { + if (!key.startsWith(`${tabId}:`)) continue; + frameTargets.delete(key); + frameTargetKeys.delete(targetId); + chrome.debugger.detach({ targetId }).catch(() => { + }); + } } async function detach(tabId) { - clearFrameTargetsForTab(tabId); - if (!attached.has(tabId)) return; - attached.delete(tabId); - networkCaptures.delete(tabId); - tabFrameContexts.delete(tabId); - try { - await chrome.debugger.detach({ tabId }); - } catch {} + clearFrameTargetsForTab(tabId); + if (!attached.has(tabId)) return; + attached.delete(tabId); + networkCaptures.delete(tabId); + tabFrameContexts.delete(tabId); + try { + await chrome.debugger.detach({ tabId }); + } catch { + } } function registerListeners() { - chrome.tabs.onRemoved.addListener((tabId) => { - attached.delete(tabId); - networkCaptures.delete(tabId); - tabFrameContexts.delete(tabId); - clearFrameTargetsForTab(tabId); - }); - chrome.debugger.onDetach.addListener((source) => { - if (source.tabId) { - attached.delete(source.tabId); - networkCaptures.delete(source.tabId); - tabFrameContexts.delete(source.tabId); - clearFrameTargetsForTab(source.tabId); - return; - } - if (source.targetId) clearFrameTarget(source.targetId); - }); - chrome.tabs.onUpdated.addListener(async (tabId, info) => { - if (info.url && !isDebuggableUrl$1(info.url)) await detach(tabId); - }); - chrome.debugger.onEvent.addListener(async (source, method, params) => { - const tabId = source.tabId; - if (!tabId) return; - const state = networkCaptures.get(tabId); - if (!state) return; - const eventParams = params; - if (method === "Network.requestWillBeSent") { - const requestId = String(eventParams?.requestId || ""); - const request = eventParams?.request; - const entry = getOrCreateNetworkCaptureEntry(tabId, requestId, { - url: request?.url, - method: request?.method, - requestHeaders: normalizeHeaders(request?.headers) - }); - if (!entry) return; - entry.requestBodyKind = request?.hasPostData ? "string" : "empty"; - { - const raw = String(request?.postData || ""); - const fullSize = raw.length; - const truncated = fullSize > CDP_REQUEST_BODY_CAPTURE_LIMIT; - entry.requestBodyPreview = truncated ? raw.slice(0, CDP_REQUEST_BODY_CAPTURE_LIMIT) : raw; - entry.requestBodyFullSize = fullSize; - entry.requestBodyTruncated = truncated; - } - try { - const postData = await chrome.debugger.sendCommand({ tabId }, "Network.getRequestPostData", { requestId }); - if (postData?.postData) { - const raw = postData.postData; - const fullSize = raw.length; - const truncated = fullSize > CDP_REQUEST_BODY_CAPTURE_LIMIT; - entry.requestBodyKind = "string"; - entry.requestBodyPreview = truncated ? raw.slice(0, CDP_REQUEST_BODY_CAPTURE_LIMIT) : raw; - entry.requestBodyFullSize = fullSize; - entry.requestBodyTruncated = truncated; - } - } catch {} - return; - } - if (method === "Network.responseReceived") { - const requestId = String(eventParams?.requestId || ""); - const response = eventParams?.response; - const entry = getOrCreateNetworkCaptureEntry(tabId, requestId, { url: response?.url }); - if (!entry) return; - entry.responseStatus = response?.status; - entry.responseContentType = response?.mimeType || ""; - entry.responseHeaders = normalizeHeaders(response?.headers); - return; - } - if (method === "Network.loadingFinished") { - const requestId = String(eventParams?.requestId || ""); - const stateEntryIndex = state.requestToIndex.get(requestId); - if (stateEntryIndex === void 0) return; - const entry = state.entries[stateEntryIndex]; - if (!entry) return; - try { - const body = await chrome.debugger.sendCommand({ tabId }, "Network.getResponseBody", { requestId }); - if (typeof body?.body === "string") { - const fullSize = body.body.length; - const truncated = fullSize > CDP_RESPONSE_BODY_CAPTURE_LIMIT; - const stored = truncated ? body.body.slice(0, CDP_RESPONSE_BODY_CAPTURE_LIMIT) : body.body; - entry.responsePreview = body.base64Encoded ? `base64:${stored}` : stored; - entry.responseBodyFullSize = fullSize; - entry.responseBodyTruncated = truncated; - } - } catch {} - } - }); -} -//#endregion -//#region src/identity.ts -/** -* Page identity mapping — targetId ↔ tabId. -* -* targetId is the cross-layer page identity (CDP target UUID). -* tabId is an internal Chrome Tabs API routing detail — never exposed outside the extension. -* -* Lifecycle: -* - Cache populated lazily via chrome.debugger.getTargets() -* - Evicted on tab close (chrome.tabs.onRemoved) -* - Miss triggers full refresh; refresh miss → hard error (no guessing) -*/ -var targetToTab = /* @__PURE__ */ new Map(); -var tabToTarget = /* @__PURE__ */ new Map(); -/** -* Resolve targetId for a given tabId. -* Returns cached value if available; on miss, refreshes from chrome.debugger.getTargets(). -* Throws if no targetId can be found (page may have been destroyed). -*/ + chrome.tabs.onRemoved.addListener((tabId) => { + attached.delete(tabId); + networkCaptures.delete(tabId); + tabFrameContexts.delete(tabId); + clearFrameTargetsForTab(tabId); + }); + chrome.debugger.onDetach.addListener((source) => { + if (source.tabId) { + attached.delete(source.tabId); + networkCaptures.delete(source.tabId); + tabFrameContexts.delete(source.tabId); + clearFrameTargetsForTab(source.tabId); + return; + } + if (source.targetId) clearFrameTarget(source.targetId); + }); + chrome.tabs.onUpdated.addListener(async (tabId, info) => { + if (info.url && !isDebuggableUrl$1(info.url)) { + await detach(tabId); + } + }); + chrome.debugger.onEvent.addListener(async (source, method, params) => { + const tabId = source.tabId; + if (!tabId) return; + const state = networkCaptures.get(tabId); + if (!state) return; + const eventParams = params; + if (method === "Network.requestWillBeSent") { + const requestId = String(eventParams?.requestId || ""); + const request = eventParams?.request; + const entry = getOrCreateNetworkCaptureEntry(tabId, requestId, { + url: request?.url, + method: request?.method, + requestHeaders: normalizeHeaders(request?.headers) + }); + if (!entry) return; + entry.requestBodyKind = request?.hasPostData ? "string" : "empty"; + { + const raw = String(request?.postData || ""); + const fullSize = raw.length; + const truncated = fullSize > CDP_REQUEST_BODY_CAPTURE_LIMIT; + entry.requestBodyPreview = truncated ? raw.slice(0, CDP_REQUEST_BODY_CAPTURE_LIMIT) : raw; + entry.requestBodyFullSize = fullSize; + entry.requestBodyTruncated = truncated; + } + try { + const postData = await chrome.debugger.sendCommand({ tabId }, "Network.getRequestPostData", { requestId }); + if (postData?.postData) { + const raw = postData.postData; + const fullSize = raw.length; + const truncated = fullSize > CDP_REQUEST_BODY_CAPTURE_LIMIT; + entry.requestBodyKind = "string"; + entry.requestBodyPreview = truncated ? raw.slice(0, CDP_REQUEST_BODY_CAPTURE_LIMIT) : raw; + entry.requestBodyFullSize = fullSize; + entry.requestBodyTruncated = truncated; + } + } catch { + } + return; + } + if (method === "Network.responseReceived") { + const requestId = String(eventParams?.requestId || ""); + const response = eventParams?.response; + const entry = getOrCreateNetworkCaptureEntry(tabId, requestId, { + url: response?.url + }); + if (!entry) return; + entry.responseStatus = response?.status; + entry.responseContentType = response?.mimeType || ""; + entry.responseHeaders = normalizeHeaders(response?.headers); + return; + } + if (method === "Network.loadingFinished") { + const requestId = String(eventParams?.requestId || ""); + const stateEntryIndex = state.requestToIndex.get(requestId); + if (stateEntryIndex === void 0) return; + const entry = state.entries[stateEntryIndex]; + if (!entry) return; + try { + const body = await chrome.debugger.sendCommand({ tabId }, "Network.getResponseBody", { requestId }); + if (typeof body?.body === "string") { + const fullSize = body.body.length; + const truncated = fullSize > CDP_RESPONSE_BODY_CAPTURE_LIMIT; + const stored = truncated ? body.body.slice(0, CDP_RESPONSE_BODY_CAPTURE_LIMIT) : body.body; + entry.responsePreview = body.base64Encoded ? `base64:${stored}` : stored; + entry.responseBodyFullSize = fullSize; + entry.responseBodyTruncated = truncated; + } + } catch { + } + } + }); +} + +const targetToTab = /* @__PURE__ */ new Map(); +const tabToTarget = /* @__PURE__ */ new Map(); async function resolveTargetId(tabId) { - const cached = tabToTarget.get(tabId); - if (cached) return cached; - await refreshMappings(); - const result = tabToTarget.get(tabId); - if (!result) throw new Error(`No targetId for tab ${tabId} — page may have been closed`); - return result; -} -/** -* Resolve tabId for a given targetId. -* Returns cached value if available; on miss, refreshes from chrome.debugger.getTargets(). -* Throws if no tabId can be found — never falls back to guessing. -*/ + const cached = tabToTarget.get(tabId); + if (cached) return cached; + await refreshMappings(); + const result = tabToTarget.get(tabId); + if (!result) throw new Error(`No targetId for tab ${tabId} — page may have been closed`); + return result; +} async function resolveTabId$1(targetId) { - const cached = targetToTab.get(targetId); - if (cached !== void 0) return cached; - await refreshMappings(); - const result = targetToTab.get(targetId); - if (result === void 0) throw new Error(`Page not found: ${targetId} — stale page identity`); - return result; -} -/** -* Remove mappings for a closed tab. -* Called from chrome.tabs.onRemoved listener. -*/ + const cached = targetToTab.get(targetId); + if (cached !== void 0) return cached; + await refreshMappings(); + const result = targetToTab.get(targetId); + if (result === void 0) throw new Error(`Page not found: ${targetId} — stale page identity`); + return result; +} function evictTab(tabId) { - const targetId = tabToTarget.get(tabId); - if (targetId) targetToTab.delete(targetId); - tabToTarget.delete(tabId); + const targetId = tabToTarget.get(tabId); + if (targetId) targetToTab.delete(targetId); + tabToTarget.delete(tabId); } -/** -* Full refresh of targetId ↔ tabId mappings from chrome.debugger.getTargets(). -*/ async function refreshMappings() { - const targets = await chrome.debugger.getTargets(); - targetToTab.clear(); - tabToTarget.clear(); - for (const t of targets) if (t.type === "page" && t.tabId !== void 0) { - targetToTab.set(t.id, t.tabId); - tabToTarget.set(t.tabId, t.id); - } -} -//#endregion -//#region src/background.ts -var ws = null; -var reconnectTimer = null; -var reconnectAttempts = 0; -var CONTEXT_ID_KEY = "opencli_context_id_v1"; -var currentContextId = "default"; -var contextIdPromise = null; -var connectInFlight = null; + const targets = await chrome.debugger.getTargets(); + targetToTab.clear(); + tabToTarget.clear(); + for (const t of targets) { + if (t.type === "page" && t.tabId !== void 0) { + targetToTab.set(t.id, t.tabId); + tabToTarget.set(t.tabId, t.id); + } + } +} + +let ws = null; +let reconnectTimer = null; +const CONTEXT_ID_KEY = "opencli_context_id_v1"; +let currentContextId = "default"; +let contextIdPromise = null; +let connectInFlight = null; async function getCurrentContextId() { - if (contextIdPromise) return contextIdPromise; - contextIdPromise = (async () => { - try { - const local = chrome.storage?.local; - if (!local) return currentContextId; - const existing = (await local.get(CONTEXT_ID_KEY))[CONTEXT_ID_KEY]; - if (typeof existing === "string" && existing.trim()) { - currentContextId = existing.trim(); - return currentContextId; - } - const generated = generateContextId(); - await local.set({ [CONTEXT_ID_KEY]: generated }); - currentContextId = generated; - return currentContextId; - } catch { - return currentContextId; - } - })(); - return contextIdPromise; + if (contextIdPromise) return contextIdPromise; + contextIdPromise = (async () => { + try { + const local = chrome.storage?.local; + if (!local) return currentContextId; + const raw = await local.get(CONTEXT_ID_KEY); + const existing = raw[CONTEXT_ID_KEY]; + if (typeof existing === "string" && existing.trim()) { + currentContextId = existing.trim(); + return currentContextId; + } + const generated = generateContextId(); + await local.set({ [CONTEXT_ID_KEY]: generated }); + currentContextId = generated; + return currentContextId; + } catch { + return currentContextId; + } + })(); + return contextIdPromise; } function generateContextId() { - const alphabet = "23456789abcdefghjkmnpqrstuvwxyz"; - const maxUnbiasedByte = Math.floor(256 / 31) * 31; - let id = ""; - while (id.length < 8) { - const bytes = new Uint8Array(8); - try { - crypto.getRandomValues(bytes); - } catch { - for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(Math.random() * 256); - } - for (const byte of bytes) { - if (byte >= maxUnbiasedByte) continue; - id += alphabet[byte % 31]; - if (id.length === 8) break; - } - } - return id; -} -var _origLog = console.log.bind(console); -var _origWarn = console.warn.bind(console); -var _origError = console.error.bind(console); + const alphabet = "23456789abcdefghjkmnpqrstuvwxyz"; + const maxUnbiasedByte = Math.floor(256 / alphabet.length) * alphabet.length; + let id = ""; + while (id.length < 8) { + const bytes = new Uint8Array(8); + try { + crypto.getRandomValues(bytes); + } catch { + for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(Math.random() * 256); + } + for (const byte of bytes) { + if (byte >= maxUnbiasedByte) continue; + id += alphabet[byte % alphabet.length]; + if (id.length === 8) break; + } + } + return id; +} +const _origLog = console.log.bind(console); +const _origWarn = console.warn.bind(console); +const _origError = console.error.bind(console); function forwardLog(level, args) { - try { - const msg = args.map((a) => typeof a === "string" ? a : JSON.stringify(a)).join(" "); - safeSend(ws, { - type: "log", - level, - msg, - ts: Date.now() - }); - } catch {} + try { + const msg = args.map((a) => typeof a === "string" ? a : JSON.stringify(a)).join(" "); + safeSend(ws, { type: "log", level, msg, ts: Date.now() }); + } catch { + } } function safeSend(socket, payload) { - if (!socket || socket.readyState !== WebSocket.OPEN) return false; - try { - socket.send(JSON.stringify(payload)); - return true; - } catch { - return false; - } + if (!socket || socket.readyState !== WebSocket.OPEN) return false; + try { + socket.send(JSON.stringify(payload)); + return true; + } catch { + return false; + } } console.log = (...args) => { - _origLog(...args); - forwardLog("info", args); + _origLog(...args); + forwardLog("info", args); }; console.warn = (...args) => { - _origWarn(...args); - forwardLog("warn", args); + _origWarn(...args); + forwardLog("warn", args); }; console.error = (...args) => { - _origError(...args); - forwardLog("error", args); + _origError(...args); + forwardLog("error", args); }; function isDaemonSocketActive(socket = ws) { - return socket?.readyState === WebSocket.OPEN || socket?.readyState === WebSocket.CONNECTING; -} -/** -* Probe the daemon via its /ping HTTP endpoint before attempting a WebSocket -* connection. fetch() failures are silently catchable; new WebSocket() is not -* — Chrome logs ERR_CONNECTION_REFUSED to the extension error page before any -* JS handler can intercept it. By keeping the probe inside connect() every -* call site remains unchanged and the guard can never be accidentally skipped. -*/ + return socket?.readyState === WebSocket.OPEN || socket?.readyState === WebSocket.CONNECTING; +} function connect() { - if (isDaemonSocketActive()) return Promise.resolve(); - if (connectInFlight) return connectInFlight; - connectInFlight = connectAttempt().finally(() => { - connectInFlight = null; - }); - return connectInFlight; + if (isDaemonSocketActive()) return Promise.resolve(); + if (connectInFlight) return connectInFlight; + connectInFlight = connectAttempt().finally(() => { + connectInFlight = null; + }); + return connectInFlight; } async function connectAttempt() { - if (isDaemonSocketActive()) return; - try { - if (!(await fetch(DAEMON_PING_URL, { signal: AbortSignal.timeout(1e3) })).ok) { - scheduleReconnect(); - return; - } - notifyDaemonReachable(); - } catch { - scheduleReconnect(); - return; - } - if (isDaemonSocketActive()) return; - let thisWs; - try { - const contextId = await getCurrentContextId(); - if (isDaemonSocketActive()) return; - thisWs = new WebSocket(DAEMON_WS_URL); - ws = thisWs; - currentContextId = contextId; - } catch { - scheduleReconnect(); - return; - } - thisWs.onopen = () => { - if (ws !== thisWs) return; - console.log("[opencli] Connected to daemon"); - reconnectAttempts = 0; - reconnectPhaseStartedAt = 0; - if (reconnectTimer) { - clearTimeout(reconnectTimer); - reconnectTimer = null; - } - reconnectTimerDelayMs = null; - safeSend(thisWs, { - type: "hello", - contextId: currentContextId, - version: chrome.runtime.getManifest().version, - compatRange: ">=1.7.0" - }); - }; - thisWs.onmessage = async (event) => { - if (ws !== thisWs) return; - try { - const result = await handleCommand(JSON.parse(event.data)); - if (ws !== thisWs) return; - safeSend(thisWs, result); - } catch (err) { - console.error("[opencli] Message handling error:", err); - } - }; - thisWs.onclose = () => { - if (ws !== thisWs) return; - console.log("[opencli] Disconnected from daemon"); - ws = null; - scheduleReconnect(); - }; - thisWs.onerror = () => { - thisWs.close(); - }; -} -/** -* Reconnect cadence is phased and never gives up while Chrome keeps the -* service worker alive: -* -* - fast phase: every 3s for 30s after a disconnect/failure; -* - slow phase: every 15s after the fast window expires; -* - durable wake path: chrome.alarms. Production Chrome currently enforces a -* 30s minimum alarm interval, so alarms wake the service worker after idle -* eviction while setTimeout provides the faster path only when the worker -* remains alive. -*/ -var RECONNECT_FAST_INTERVAL_MS = 3e3; -var RECONNECT_FAST_WINDOW_MS = 3e4; -var RECONNECT_SLOW_INTERVAL_MS = 15e3; -var reconnectPhaseStartedAt = 0; -var reconnectTimerDelayMs = null; + if (isDaemonSocketActive()) return; + try { + const res = await fetch(DAEMON_PING_URL, { signal: AbortSignal.timeout(1e3) }); + if (!res.ok) { + scheduleReconnect(); + return; + } + notifyDaemonReachable(); + } catch { + scheduleReconnect(); + return; + } + if (isDaemonSocketActive()) return; + let thisWs; + try { + const contextId = await getCurrentContextId(); + if (isDaemonSocketActive()) return; + thisWs = new WebSocket(DAEMON_WS_URL); + ws = thisWs; + currentContextId = contextId; + } catch { + scheduleReconnect(); + return; + } + thisWs.onopen = () => { + if (ws !== thisWs) return; + console.log("[opencli] Connected to daemon"); + reconnectPhaseStartedAt = 0; + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + reconnectTimerDelayMs = null; + safeSend(thisWs, { + type: "hello", + contextId: currentContextId, + version: chrome.runtime.getManifest().version, + compatRange: ">=1.7.0" + }); + }; + thisWs.onmessage = async (event) => { + if (ws !== thisWs) return; + try { + const command = JSON.parse(event.data); + const result = await handleCommand(command); + if (ws !== thisWs) return; + safeSend(thisWs, result); + } catch (err) { + console.error("[opencli] Message handling error:", err); + } + }; + thisWs.onclose = () => { + if (ws !== thisWs) return; + console.log("[opencli] Disconnected from daemon"); + ws = null; + scheduleReconnect(); + }; + thisWs.onerror = () => { + thisWs.close(); + }; +} +const RECONNECT_FAST_INTERVAL_MS = 3e3; +const RECONNECT_FAST_WINDOW_MS = 3e4; +const RECONNECT_SLOW_INTERVAL_MS = 15e3; +let reconnectPhaseStartedAt = 0; +let reconnectTimerDelayMs = null; function nextReconnectDelayMs() { - return Date.now() - reconnectPhaseStartedAt < RECONNECT_FAST_WINDOW_MS ? RECONNECT_FAST_INTERVAL_MS : RECONNECT_SLOW_INTERVAL_MS; + const sinceLoss = Date.now() - reconnectPhaseStartedAt; + return sinceLoss < RECONNECT_FAST_WINDOW_MS ? RECONNECT_FAST_INTERVAL_MS : RECONNECT_SLOW_INTERVAL_MS; } function scheduleReconnect(opts = {}) { - if (reconnectTimer) { - if (!opts.replaceExisting) return; - clearTimeout(reconnectTimer); - reconnectTimer = null; - reconnectTimerDelayMs = null; - } - reconnectAttempts++; - if (reconnectPhaseStartedAt === 0) reconnectPhaseStartedAt = Date.now(); - const delay = nextReconnectDelayMs(); - reconnectTimerDelayMs = delay; - reconnectTimer = setTimeout(() => { - reconnectTimer = null; - reconnectTimerDelayMs = null; - connect(); - }, delay); + if (reconnectTimer) { + if (!opts.replaceExisting) return; + clearTimeout(reconnectTimer); + reconnectTimer = null; + reconnectTimerDelayMs = null; + } + if (reconnectPhaseStartedAt === 0) { + reconnectPhaseStartedAt = Date.now(); + } + const delay = nextReconnectDelayMs(); + reconnectTimerDelayMs = delay; + reconnectTimer = setTimeout(() => { + reconnectTimer = null; + reconnectTimerDelayMs = null; + void connect(); + }, delay); } function notifyDaemonReachable() { - reconnectPhaseStartedAt = Date.now(); - if (reconnectTimer && reconnectTimerDelayMs !== RECONNECT_FAST_INTERVAL_MS) scheduleReconnect({ replaceExisting: true }); -} -var automationSessions = /* @__PURE__ */ new Map(); -var IDLE_TIMEOUT_DEFAULT = 3e4; -var IDLE_TIMEOUT_INTERACTIVE = 6e5; -var IDLE_TIMEOUT_NONE = -1; -var REGISTRY_KEY = "opencli_target_lease_registry_v2"; -var LEASE_IDLE_ALARM_PREFIX = "opencli:lease-idle:"; -var CONTAINER_TAB_GROUP_TITLE = { - interactive: "OpenCLI Browser", - automation: "OpenCLI Adapter" -}; -var OWNED_TAB_GROUP_COLOR = "orange"; -var leaseMutationQueue = Promise.resolve(); -var ownedContainers = { - interactive: { - windowId: null, - groupId: null, - promise: null, - groupPromise: null - }, - automation: { - windowId: null, - groupId: null, - promise: null, - groupPromise: null - } + reconnectPhaseStartedAt = Date.now(); + if (reconnectTimer && reconnectTimerDelayMs !== RECONNECT_FAST_INTERVAL_MS) { + scheduleReconnect({ replaceExisting: true }); + } +} +const automationSessions = /* @__PURE__ */ new Map(); +const IDLE_TIMEOUT_DEFAULT = 3e4; +const IDLE_TIMEOUT_INTERACTIVE = 6e5; +const IDLE_TIMEOUT_NONE = -1; +const REGISTRY_KEY = "opencli_target_lease_registry_v2"; +const LEASE_IDLE_ALARM_PREFIX = "opencli:lease-idle:"; +const CONTAINER_TAB_GROUP_TITLE = { + interactive: "OpenCLI Browser", + // Retained for registry/type compatibility. Adapter automation no longer + // creates or discovers a visible tab group. + automation: "OpenCLI Adapter" }; -var CommandFailure = class extends Error { - constructor(code, message, hint) { - super(message); - this.code = code; - this.hint = hint; - this.name = "CommandFailure"; - } +const OWNED_TAB_GROUP_COLOR = "orange"; +let leaseMutationQueue = Promise.resolve(); +const ownedContainers = { + interactive: { windowId: null, groupId: null, promise: null, groupPromise: null }, + automation: { windowId: null, groupId: null, promise: null, groupPromise: null } }; -/** Per-session custom timeout overrides set via command.idleTimeout */ -var sessionTimeoutOverrides = /* @__PURE__ */ new Map(); -var sessionWindowModeOverrides = /* @__PURE__ */ new Map(); -var sessionLifecycleOverrides = /* @__PURE__ */ new Map(); -var LEASE_KEY_SEPARATOR = "\0"; +class CommandFailure extends Error { + constructor(code, message, hint) { + super(message); + this.code = code; + this.hint = hint; + this.name = "CommandFailure"; + } +} +const sessionTimeoutOverrides = /* @__PURE__ */ new Map(); +const sessionWindowModeOverrides = /* @__PURE__ */ new Map(); +const sessionLifecycleOverrides = /* @__PURE__ */ new Map(); +const LEASE_KEY_SEPARATOR = "\0"; function getLeaseKey(session, surface) { - return `${surface}${LEASE_KEY_SEPARATOR}${encodeURIComponent(session)}`; + return `${surface}${LEASE_KEY_SEPARATOR}${encodeURIComponent(session)}`; } function getSessionName(session) { - const raw = session?.trim(); - if (!raw) throw new CommandFailure("session_required", "Browser session is required.", "Pass a browser session name, e.g. opencli browser ."); - return raw; + const raw = session?.trim(); + if (!raw) throw new CommandFailure( + "session_required", + "Browser session is required.", + "Pass a browser session name, e.g. opencli browser ." + ); + return raw; } function getCommandSurface(cmd) { - return cmd.surface === "adapter" ? "adapter" : "browser"; + return cmd.surface === "adapter" ? "adapter" : "browser"; } function getSurfaceFromKey(key) { - return key.split(LEASE_KEY_SEPARATOR, 1)[0] === "adapter" ? "adapter" : "browser"; + return key.split(LEASE_KEY_SEPARATOR, 1)[0] === "adapter" ? "adapter" : "browser"; } function getSessionFromKey(key) { - const idx = key.indexOf(LEASE_KEY_SEPARATOR); - if (idx === -1) return key; - try { - return decodeURIComponent(key.slice(idx + 1)); - } catch { - return key.slice(idx + 1); - } + const idx = key.indexOf(LEASE_KEY_SEPARATOR); + if (idx === -1) return key; + try { + return decodeURIComponent(key.slice(idx + 1)); + } catch { + return key.slice(idx + 1); + } } function getIdleTimeout(key) { - const session = automationSessions.get(key); - if (session?.kind === "bound") return IDLE_TIMEOUT_NONE; - if (getSurfaceFromKey(key) === "adapter" && (session?.lifecycle === "persistent" || sessionLifecycleOverrides.get(key) === "persistent")) return IDLE_TIMEOUT_NONE; - const override = sessionTimeoutOverrides.get(key); - if (override !== void 0) return override; - return getSurfaceFromKey(key) === "browser" ? IDLE_TIMEOUT_INTERACTIVE : IDLE_TIMEOUT_DEFAULT; + const session = automationSessions.get(key); + if (session?.kind === "bound") return IDLE_TIMEOUT_NONE; + const adapterPersistent = getSurfaceFromKey(key) === "adapter" && (session?.lifecycle === "persistent" || sessionLifecycleOverrides.get(key) === "persistent"); + if (adapterPersistent) return IDLE_TIMEOUT_NONE; + const override = sessionTimeoutOverrides.get(key); + if (override !== void 0) return override; + return getSurfaceFromKey(key) === "browser" ? IDLE_TIMEOUT_INTERACTIVE : IDLE_TIMEOUT_DEFAULT; } function getLeaseLifecycle(key, kind) { - if (kind === "bound") return "pinned"; - const override = sessionLifecycleOverrides.get(key); - if (override) return override; - return getSurfaceFromKey(key) === "browser" ? "persistent" : "ephemeral"; + if (kind === "bound") return "pinned"; + const override = sessionLifecycleOverrides.get(key); + if (override) return override; + return getSurfaceFromKey(key) === "browser" ? "persistent" : "ephemeral"; } function getOwnedWindowRole(key) { - return getSurfaceFromKey(key) === "browser" ? "interactive" : "automation"; + return getSurfaceFromKey(key) === "browser" ? "interactive" : "automation"; } function getWindowRole(key, ownership) { - return ownership === "borrowed" ? "borrowed-user" : getOwnedWindowRole(key); + return ownership === "borrowed" ? "borrowed-user" : getOwnedWindowRole(key); } function getWindowMode(key) { - return sessionWindowModeOverrides.get(key) ?? (getOwnedWindowRole(key) === "interactive" ? "foreground" : "background"); + return sessionWindowModeOverrides.get(key) ?? (getOwnedWindowRole(key) === "interactive" ? "foreground" : "background"); } function makeAlarmName(leaseKey) { - return `${LEASE_IDLE_ALARM_PREFIX}${encodeURIComponent(leaseKey)}`; + return `${LEASE_IDLE_ALARM_PREFIX}${encodeURIComponent(leaseKey)}`; } function leaseKeyFromAlarmName(name) { - if (!name.startsWith(LEASE_IDLE_ALARM_PREFIX)) return null; - try { - return decodeURIComponent(name.slice(19)); - } catch { - return null; - } + if (!name.startsWith(LEASE_IDLE_ALARM_PREFIX)) return null; + try { + return decodeURIComponent(name.slice(LEASE_IDLE_ALARM_PREFIX.length)); + } catch { + return null; + } } function withLeaseMutation(fn) { - const run = leaseMutationQueue.then(fn, fn); - leaseMutationQueue = run.then(() => void 0, () => void 0); - return run; + const run = leaseMutationQueue.then(fn, fn); + leaseMutationQueue = run.then(() => void 0, () => void 0); + return run; } function makeSession(key, session) { - const ownership = session.owned ? "owned" : "borrowed"; - return { - ...session, - contextId: currentContextId, - ownership, - lifecycle: getLeaseLifecycle(key, session.kind), - windowRole: getWindowRole(key, ownership) - }; + const ownership = session.owned ? "owned" : "borrowed"; + return { + ...session, + contextId: currentContextId, + ownership, + lifecycle: getLeaseLifecycle(key, session.kind), + windowRole: getWindowRole(key, ownership) + }; } function emptyRegistry() { - return { - version: 2, - contextId: currentContextId, - ownedContainers: { - interactive: { - windowId: ownedContainers.interactive.windowId, - groupId: ownedContainers.interactive.groupId - }, - automation: { - windowId: ownedContainers.automation.windowId, - groupId: null - } - }, - leases: {} - }; + return { + version: 2, + contextId: currentContextId, + ownedContainers: { + interactive: { + windowId: ownedContainers.interactive.windowId, + groupId: ownedContainers.interactive.groupId + }, + automation: { + windowId: ownedContainers.automation.windowId, + groupId: null + } + }, + leases: {} + }; } async function readRegistry() { - try { - const local = chrome.storage?.local; - if (!local) return emptyRegistry(); - const stored = (await local.get(REGISTRY_KEY))[REGISTRY_KEY]; - if (!stored || stored.version !== 2 || typeof stored.leases !== "object") return emptyRegistry(); - const storedContainers = stored.ownedContainers && typeof stored.ownedContainers === "object" ? stored.ownedContainers : emptyRegistry().ownedContainers; - return { - version: 2, - contextId: currentContextId, - ownedContainers: { - interactive: { - windowId: typeof storedContainers.interactive?.windowId === "number" ? storedContainers.interactive.windowId : null, - groupId: typeof storedContainers.interactive?.groupId === "number" ? storedContainers.interactive.groupId : null - }, - automation: { - windowId: typeof storedContainers.automation?.windowId === "number" ? storedContainers.automation.windowId : null, - groupId: null - } - }, - leases: stored.leases - }; - } catch { - return emptyRegistry(); - } + try { + const local = chrome.storage?.local; + if (!local) return emptyRegistry(); + const raw = await local.get(REGISTRY_KEY); + const stored = raw[REGISTRY_KEY]; + if (!stored || stored.version !== 2 || typeof stored.leases !== "object") return emptyRegistry(); + const storedContainers = stored.ownedContainers && typeof stored.ownedContainers === "object" ? stored.ownedContainers : emptyRegistry().ownedContainers; + return { + version: 2, + contextId: currentContextId, + ownedContainers: { + interactive: { + windowId: typeof storedContainers.interactive?.windowId === "number" ? storedContainers.interactive.windowId : null, + groupId: typeof storedContainers.interactive?.groupId === "number" ? storedContainers.interactive.groupId : null + }, + automation: { + windowId: typeof storedContainers.automation?.windowId === "number" ? storedContainers.automation.windowId : null, + groupId: null + } + }, + leases: stored.leases + }; + } catch { + return emptyRegistry(); + } } async function writeRegistry(registry) { - try { - await chrome.storage?.local?.set({ [REGISTRY_KEY]: registry }); - } catch {} + try { + await chrome.storage?.local?.set({ [REGISTRY_KEY]: registry }); + } catch { + } } async function persistRuntimeState() { - const leases = {}; - for (const [leaseKey, session] of automationSessions.entries()) leases[leaseKey] = { - session: session.session, - surface: session.surface, - kind: session.kind, - windowId: session.windowId, - owned: session.owned, - preferredTabId: session.preferredTabId, - contextId: session.contextId, - ownership: session.ownership, - lifecycle: session.lifecycle, - windowRole: session.windowRole, - idleDeadlineAt: session.idleDeadlineAt, - updatedAt: Date.now() - }; - await writeRegistry({ - version: 2, - contextId: currentContextId, - ownedContainers: { - interactive: { - windowId: ownedContainers.interactive.windowId, - groupId: ownedContainers.interactive.groupId - }, - automation: { - windowId: ownedContainers.automation.windowId, - groupId: null - } - }, - leases - }); + const leases = {}; + for (const [leaseKey, session] of automationSessions.entries()) { + leases[leaseKey] = { + session: session.session, + surface: session.surface, + kind: session.kind, + windowId: session.windowId, + owned: session.owned, + preferredTabId: session.preferredTabId, + contextId: session.contextId, + ownership: session.ownership, + lifecycle: session.lifecycle, + windowRole: session.windowRole, + idleDeadlineAt: session.idleDeadlineAt, + updatedAt: Date.now() + }; + } + await writeRegistry({ + version: 2, + contextId: currentContextId, + ownedContainers: { + interactive: { + windowId: ownedContainers.interactive.windowId, + groupId: ownedContainers.interactive.groupId + }, + automation: { + windowId: ownedContainers.automation.windowId, + groupId: null + } + }, + leases + }); } function scheduleIdleAlarm(leaseKey, timeout) { - const alarmName = makeAlarmName(leaseKey); - try { - if (timeout > 0) chrome.alarms?.create?.(alarmName, { when: Date.now() + timeout }); - else chrome.alarms?.clear?.(alarmName); - } catch {} + const alarmName = makeAlarmName(leaseKey); + try { + if (timeout > 0) { + chrome.alarms?.create?.(alarmName, { when: Date.now() + timeout }); + } else { + chrome.alarms?.clear?.(alarmName); + } + } catch { + } } async function safeDetach(tabId) { - try { - const detach$1 = detach; - if (typeof detach$1 === "function") await detach$1(tabId); - } catch {} + try { + const detach$1 = detach; + if (typeof detach$1 === "function") await detach$1(tabId); + } catch { + } } async function removeLeaseSession(leaseKey) { - const existing = automationSessions.get(leaseKey); - if (existing?.idleTimer) clearTimeout(existing.idleTimer); - automationSessions.delete(leaseKey); - sessionTimeoutOverrides.delete(leaseKey); - sessionWindowModeOverrides.delete(leaseKey); - sessionLifecycleOverrides.delete(leaseKey); - scheduleIdleAlarm(leaseKey, IDLE_TIMEOUT_NONE); - await persistRuntimeState(); -} -function resetWindowIdleTimer(leaseKey) { - const session = automationSessions.get(leaseKey); - if (!session) return; - if (session.idleTimer) clearTimeout(session.idleTimer); - const timeout = getIdleTimeout(leaseKey); - scheduleIdleAlarm(leaseKey, timeout); - if (timeout <= 0) { - session.idleTimer = null; - session.idleDeadlineAt = 0; - persistRuntimeState(); - return; - } - session.idleDeadlineAt = Date.now() + timeout; - persistRuntimeState(); - session.idleTimer = setTimeout(async () => { - await releaseLease(leaseKey, "idle timeout"); - }, timeout); + const existing = automationSessions.get(leaseKey); + if (existing?.idleTimer) clearTimeout(existing.idleTimer); + automationSessions.delete(leaseKey); + sessionTimeoutOverrides.delete(leaseKey); + sessionWindowModeOverrides.delete(leaseKey); + sessionLifecycleOverrides.delete(leaseKey); + scheduleIdleAlarm(leaseKey, IDLE_TIMEOUT_NONE); + await persistRuntimeState(); +} +function resetWindowIdleTimer(leaseKey, remainingMs) { + const session = automationSessions.get(leaseKey); + if (!session) return; + if (session.idleTimer) clearTimeout(session.idleTimer); + const timeout = getIdleTimeout(leaseKey); + if (timeout <= 0) { + scheduleIdleAlarm(leaseKey, timeout); + session.idleTimer = null; + session.idleDeadlineAt = 0; + void persistRuntimeState(); + return; + } + const interval = remainingMs === void 0 ? timeout : Math.max(0, Math.min(remainingMs, timeout)); + scheduleIdleAlarm(leaseKey, interval); + session.idleDeadlineAt = Date.now() + interval; + void persistRuntimeState(); + session.idleTimer = setTimeout(async () => { + await releaseLease(leaseKey, "idle timeout"); + }, interval); } function getOwnedContainerGroupTitles(role) { - return role === "automation" ? [] : [CONTAINER_TAB_GROUP_TITLE.interactive]; + return role === "automation" ? [] : [CONTAINER_TAB_GROUP_TITLE.interactive]; } async function focusOwnedWindowIfRequested(windowId, mode) { - if (mode !== "foreground") return; - const updateWindow = chrome.windows.update; - if (typeof updateWindow === "function") await updateWindow(windowId, { focused: true }).catch(() => {}); + if (mode !== "foreground") return; + const updateWindow = chrome.windows.update; + if (typeof updateWindow === "function") await updateWindow(windowId, { focused: true }).catch(() => { + }); } async function toOwnedContainerGroupCandidate(group) { - try { - const chromeWindow = await chrome.windows.get(group.windowId); - const reusableTabId = await findReusableOwnedContainerTab(group.windowId, group.id); - return { - id: group.id, - windowId: group.windowId, - title: group.title, - focused: !!chromeWindow.focused, - hasReusableTab: reusableTabId !== void 0 - }; - } catch { - return null; - } + try { + const chromeWindow = await chrome.windows.get(group.windowId); + const reusableTabId = await findReusableOwnedContainerTab(group.windowId, group.id); + return { + id: group.id, + windowId: group.windowId, + title: group.title, + focused: !!chromeWindow.focused, + hasReusableTab: reusableTabId !== void 0 + }; + } catch { + return null; + } } function selectOwnedContainerGroupCandidate(candidates) { - if (candidates.length === 0) return null; - return [...candidates].sort((a, b) => { - if (a.focused !== b.focused) return a.focused ? -1 : 1; - if (a.hasReusableTab !== b.hasReusableTab) return a.hasReusableTab ? -1 : 1; - if (a.windowId !== b.windowId) return a.windowId - b.windowId; - return a.id - b.id; - })[0]; + if (candidates.length === 0) return null; + return [...candidates].sort((a, b) => { + if (a.focused !== b.focused) return a.focused ? -1 : 1; + if (a.hasReusableTab !== b.hasReusableTab) return a.hasReusableTab ? -1 : 1; + if (a.windowId !== b.windowId) return a.windowId - b.windowId; + return a.id - b.id; + })[0]; } async function collectOwnedGroupCandidates(role) { - if (role === "automation") return []; - const container = ownedContainers[role]; - const groupsById = /* @__PURE__ */ new Map(); - if (container.groupId !== null) try { - const group = await chrome.tabGroups.get(container.groupId); - groupsById.set(group.id, group); - } catch { - container.groupId = null; - } - for (const title of getOwnedContainerGroupTitles(role)) { - const groups = await chrome.tabGroups.query({ title }); - for (const group of groups) groupsById.set(group.id, group); - } - for (const [leaseKey, session] of automationSessions.entries()) { - if (!session.owned || getOwnedWindowRole(leaseKey) !== role || session.preferredTabId === null) continue; - try { - const groupId = (await chrome.tabs.get(session.preferredTabId)).groupId; - if (typeof groupId !== "number" || groupId === chrome.tabGroups.TAB_GROUP_ID_NONE) continue; - const group = await chrome.tabGroups.get(groupId); - groupsById.set(group.id, group); - } catch {} - } - const ownedPreferredTabIds = /* @__PURE__ */ new Set(); - for (const [leaseKey, session] of automationSessions.entries()) { - if (!session.owned || getOwnedWindowRole(leaseKey) !== role || session.preferredTabId === null) continue; - ownedPreferredTabIds.add(session.preferredTabId); - } - if (ownedPreferredTabIds.size > 0) try { - const allGroups = await chrome.tabGroups.query({}); - for (const group of allGroups) { - if (group.title) continue; - if (groupsById.has(group.id)) continue; - if ((await chrome.tabs.query({ groupId: group.id })).some((tab) => tab.id !== void 0 && ownedPreferredTabIds.has(tab.id))) groupsById.set(group.id, group); - } - } catch {} - return (await Promise.all([...groupsById.values()].map(toOwnedContainerGroupCandidate))).filter((candidate) => candidate !== null); + if (role === "automation") return []; + const container = ownedContainers[role]; + const groupsById = /* @__PURE__ */ new Map(); + if (container.groupId !== null) { + try { + const group = await chrome.tabGroups.get(container.groupId); + groupsById.set(group.id, group); + } catch { + container.groupId = null; + } + } + for (const title of getOwnedContainerGroupTitles(role)) { + const groups = await chrome.tabGroups.query({ title }); + for (const group of groups) groupsById.set(group.id, group); + } + for (const [leaseKey, session] of automationSessions.entries()) { + if (!session.owned || getOwnedWindowRole(leaseKey) !== role || session.preferredTabId === null) continue; + try { + const tab = await chrome.tabs.get(session.preferredTabId); + const groupId = tab.groupId; + if (typeof groupId !== "number" || groupId === chrome.tabGroups.TAB_GROUP_ID_NONE) continue; + const group = await chrome.tabGroups.get(groupId); + groupsById.set(group.id, group); + } catch { + } + } + const ownedPreferredTabIds = /* @__PURE__ */ new Set(); + for (const [leaseKey, session] of automationSessions.entries()) { + if (!session.owned || getOwnedWindowRole(leaseKey) !== role || session.preferredTabId === null) continue; + ownedPreferredTabIds.add(session.preferredTabId); + } + if (ownedPreferredTabIds.size > 0) { + try { + const allGroups = await chrome.tabGroups.query({}); + for (const group of allGroups) { + if (group.title) continue; + if (groupsById.has(group.id)) continue; + const tabsInGroup = await chrome.tabs.query({ groupId: group.id }); + if (tabsInGroup.some((tab) => tab.id !== void 0 && ownedPreferredTabIds.has(tab.id))) { + groupsById.set(group.id, group); + } + } + } catch { + } + } + const candidates = await Promise.all([...groupsById.values()].map(toOwnedContainerGroupCandidate)); + return candidates.filter((candidate) => candidate !== null); } function updateOwnedSessionWindowForTabs(role, tabIds, windowId) { - const moved = new Set(tabIds); - for (const [leaseKey, session] of automationSessions.entries()) { - if (!session.owned || getOwnedWindowRole(leaseKey) !== role) continue; - if (session.preferredTabId !== null && moved.has(session.preferredTabId)) session.windowId = windowId; - } + const moved = new Set(tabIds); + for (const [leaseKey, session] of automationSessions.entries()) { + if (!session.owned || getOwnedWindowRole(leaseKey) !== role) continue; + if (session.preferredTabId !== null && moved.has(session.preferredTabId)) { + session.windowId = windowId; + } + } } async function ensureTabsInWindow(tabIds, windowId) { - const movedIds = []; - for (const tabId of tabIds) try { - if ((await chrome.tabs.get(tabId)).windowId !== windowId) { - await chrome.tabs.move(tabId, { - windowId, - index: -1 - }); - movedIds.push(tabId); - } - } catch {} - return movedIds; + const movedIds = []; + for (const tabId of tabIds) { + try { + const tab = await chrome.tabs.get(tabId); + if (tab.windowId !== windowId) { + await chrome.tabs.move(tabId, { windowId, index: -1 }); + movedIds.push(tabId); + } + } catch { + } + } + return movedIds; } async function ensureCanonicalGroupTitle(role, group) { - const canonicalTitle = CONTAINER_TAB_GROUP_TITLE[role]; - if (group.title === canonicalTitle) return group; - const updated = await chrome.tabGroups.update(group.id, { - title: canonicalTitle, - color: OWNED_TAB_GROUP_COLOR - }); - return { - id: updated.id, - windowId: updated.windowId, - title: updated.title - }; + const canonicalTitle = CONTAINER_TAB_GROUP_TITLE[role]; + if (group.title === canonicalTitle) return group; + const updated = await chrome.tabGroups.update(group.id, { + title: canonicalTitle, + color: OWNED_TAB_GROUP_COLOR + }); + return { id: updated.id, windowId: updated.windowId, title: updated.title }; } async function convergeOwnedGroupDuplicates(role, canonical, candidates) { - for (const duplicate of candidates) { - if (duplicate.id === canonical.id) continue; - const tabIds = (await chrome.tabs.query({ groupId: duplicate.id })).map((tab) => tab.id).filter((id) => id !== void 0); - if (tabIds.length === 0) continue; - await ensureTabsInWindow(tabIds, canonical.windowId); - await chrome.tabs.group({ - groupId: canonical.id, - tabIds - }); - updateOwnedSessionWindowForTabs(role, tabIds, canonical.windowId); - } - return canonical; + for (const duplicate of candidates) { + if (duplicate.id === canonical.id) continue; + const tabs = await chrome.tabs.query({ groupId: duplicate.id }); + const tabIds = tabs.map((tab) => tab.id).filter((id) => id !== void 0); + if (tabIds.length === 0) continue; + await ensureTabsInWindow(tabIds, canonical.windowId); + await chrome.tabs.group({ groupId: canonical.id, tabIds }); + updateOwnedSessionWindowForTabs(role, tabIds, canonical.windowId); + } + return canonical; } async function attachTabsToOwnedGroup(role, group, ids) { - if (ids.length === 0) return group; - await ensureTabsInWindow(ids, group.windowId); - const missing = (await Promise.all(ids.map((id) => chrome.tabs.get(id).catch(() => null)))).filter((tab) => tab !== null && tab.id !== void 0 && tab.groupId !== group.id).map((tab) => tab.id); - if (missing.length > 0) await chrome.tabs.group({ - groupId: group.id, - tabIds: missing - }); - updateOwnedSessionWindowForTabs(role, ids, group.windowId); - return group; + if (ids.length === 0) return group; + await ensureTabsInWindow(ids, group.windowId); + const tabs = await Promise.all(ids.map((id) => chrome.tabs.get(id).catch(() => null))); + const missing = tabs.filter((tab) => tab !== null && tab.id !== void 0 && tab.groupId !== group.id).map((tab) => tab.id); + if (missing.length > 0) await chrome.tabs.group({ groupId: group.id, tabIds: missing }); + updateOwnedSessionWindowForTabs(role, ids, group.windowId); + return group; } async function createOwnedGroup(role, windowId, ids) { - if (ids.length === 0) throw new Error(`Cannot create ${role} tab group without tabs`); - await ensureTabsInWindow(ids, windowId); - const groupId = await chrome.tabs.group({ - tabIds: ids, - createProperties: { windowId } - }); - ownedContainers[role].groupId = groupId; - ownedContainers[role].windowId = windowId; - await persistRuntimeState(); - const group = await chrome.tabGroups.update(groupId, { - color: OWNED_TAB_GROUP_COLOR, - title: CONTAINER_TAB_GROUP_TITLE[role], - collapsed: false - }); - updateOwnedSessionWindowForTabs(role, ids, group.windowId); - return { - id: group.id, - windowId: group.windowId, - title: group.title - }; + if (ids.length === 0) throw new Error(`Cannot create ${role} tab group without tabs`); + await ensureTabsInWindow(ids, windowId); + const groupId = await chrome.tabs.group({ tabIds: ids, createProperties: { windowId } }); + ownedContainers[role].groupId = groupId; + ownedContainers[role].windowId = windowId; + await persistRuntimeState(); + const group = await chrome.tabGroups.update(groupId, { + color: OWNED_TAB_GROUP_COLOR, + title: CONTAINER_TAB_GROUP_TITLE[role], + collapsed: false + }); + updateOwnedSessionWindowForTabs(role, ids, group.windowId); + return { id: group.id, windowId: group.windowId, title: group.title }; } async function ensureOwnedContainerGroup(role, fallbackWindowId, tabIds) { - if (role === "automation") return null; - const ids = [...new Set(tabIds.filter((id) => id !== void 0))]; - const container = ownedContainers[role]; - const trackedGroupPromise = (container.groupPromise ?? Promise.resolve(null)).catch(() => null).then(() => ensureOwnedContainerGroupUnlocked(role, fallbackWindowId, ids)).finally(() => { - if (container.groupPromise === trackedGroupPromise) container.groupPromise = null; - }); - container.groupPromise = trackedGroupPromise; - return trackedGroupPromise; + if (role === "automation") return null; + const ids = [...new Set(tabIds.filter((id) => id !== void 0))]; + const container = ownedContainers[role]; + const previousGroupPromise = container.groupPromise ?? Promise.resolve(null); + const nextGroupPromise = previousGroupPromise.catch(() => null).then(() => ensureOwnedContainerGroupUnlocked(role, fallbackWindowId, ids)); + const trackedGroupPromise = nextGroupPromise.finally(() => { + if (container.groupPromise === trackedGroupPromise) container.groupPromise = null; + }); + container.groupPromise = trackedGroupPromise; + return trackedGroupPromise; } async function ensureOwnedContainerGroupUnlocked(role, fallbackWindowId, ids) { - try { - const candidates = await collectOwnedGroupCandidates(role); - const selected = selectOwnedContainerGroupCandidate(candidates); - let canonical = selected ? { - id: selected.id, - windowId: selected.windowId, - title: selected.title - } : null; - if (canonical) { - canonical = await convergeOwnedGroupDuplicates(role, canonical, candidates); - canonical = await ensureCanonicalGroupTitle(role, canonical); - canonical = await attachTabsToOwnedGroup(role, canonical, ids); - } else if (fallbackWindowId !== null && ids.length > 0) canonical = await createOwnedGroup(role, fallbackWindowId, ids); - if (canonical) { - ownedContainers[role].windowId = canonical.windowId; - ownedContainers[role].groupId = canonical.id; - } else { - ownedContainers[role].groupId = null; - if (fallbackWindowId === null) ownedContainers[role].windowId = null; - } - return canonical; - } catch (err) { - console.warn(`[opencli] Failed to ensure ${role} tab group: ${err instanceof Error ? err.message : String(err)}`); - throw err; - } -} -/** -* Ensure the owned window for the requested role exists. -* -* First-principles model: -* - BrowserContext is the user's default Chrome profile. -* - Session identity maps to a TargetLease (usually a tab), not a window. -* - Browser commands and adapters use separate owned windows so foreground -* interactive work cannot drag background adapter automation into view. -*/ + try { + const candidates = await collectOwnedGroupCandidates(role); + const selected = selectOwnedContainerGroupCandidate(candidates); + let canonical = selected ? { id: selected.id, windowId: selected.windowId, title: selected.title } : null; + if (canonical) { + canonical = await convergeOwnedGroupDuplicates(role, canonical, candidates); + canonical = await ensureCanonicalGroupTitle(role, canonical); + canonical = await attachTabsToOwnedGroup(role, canonical, ids); + } else if (fallbackWindowId !== null && ids.length > 0) { + canonical = await createOwnedGroup(role, fallbackWindowId, ids); + } + if (canonical) { + ownedContainers[role].windowId = canonical.windowId; + ownedContainers[role].groupId = canonical.id; + } else { + ownedContainers[role].groupId = null; + if (fallbackWindowId === null) ownedContainers[role].windowId = null; + } + return canonical; + } catch (err) { + console.warn(`[opencli] Failed to ensure ${role} tab group: ${err instanceof Error ? err.message : String(err)}`); + throw err; + } +} async function ensureOwnedContainerWindow(role, initialUrl, mode = "background") { - const container = ownedContainers[role]; - if (container.promise) return container.promise; - container.promise = ensureOwnedContainerWindowUnlocked(role, initialUrl, mode).finally(() => { - container.promise = null; - }); - return container.promise; + const container = ownedContainers[role]; + if (container.promise) return container.promise; + container.promise = ensureOwnedContainerWindowUnlocked(role, initialUrl, mode).finally(() => { + container.promise = null; + }); + return container.promise; } async function ensureOwnedContainerWindowUnlocked(role, initialUrl, mode = "background") { - const container = ownedContainers[role]; - if (container.windowId !== null) try { - await chrome.windows.get(container.windowId); - const group = await ensureOwnedContainerGroup(role, container.windowId, []); - if (group) { - await focusOwnedWindowIfRequested(group.windowId, mode); - const initialTabId = await findReusableOwnedContainerTab(group.windowId, group.id); - return { - windowId: group.windowId, - initialTabId - }; - } - await focusOwnedWindowIfRequested(container.windowId, mode); - const initialTabId = await findReusableOwnedContainerTab(container.windowId, null); - const createdGroup = await ensureOwnedContainerGroup(role, container.windowId, [initialTabId]); - if (createdGroup) return { - windowId: createdGroup.windowId, - initialTabId - }; - return { - windowId: container.windowId, - initialTabId - }; - } catch { - container.windowId = null; - container.groupId = null; - } - const existingGroup = await ensureOwnedContainerGroup(role, null, []); - if (existingGroup) { - await focusOwnedWindowIfRequested(existingGroup.windowId, mode); - const initialTabId = await findReusableOwnedContainerTab(existingGroup.windowId, existingGroup.id); - await persistRuntimeState(); - return { - windowId: existingGroup.windowId, - initialTabId - }; - } - const startUrl = initialUrl && isSafeNavigationUrl(initialUrl) ? initialUrl : BLANK_PAGE; - const win = await chrome.windows.create({ - url: startUrl, - focused: mode === "foreground", - width: 1280, - height: 900, - type: "normal" - }); - container.windowId = win.id; - await persistRuntimeState(); - console.log(`[opencli] Created owned ${role} window ${container.windowId} (start=${startUrl})`); - const tabs = await chrome.tabs.query({ windowId: win.id }); - const initialTabId = tabs[0]?.id; - if (initialTabId) await new Promise((resolve) => { - const timeout = setTimeout(resolve, 500); - const listener = (tabId, info) => { - if (tabId === initialTabId && info.status === "complete") { - chrome.tabs.onUpdated.removeListener(listener); - clearTimeout(timeout); - resolve(); - } - }; - if (tabs[0].status === "complete") { - clearTimeout(timeout); - resolve(); - } else chrome.tabs.onUpdated.addListener(listener); - }); - const group = await ensureOwnedContainerGroup(role, container.windowId, [initialTabId]); - await persistRuntimeState(); - return { - windowId: group?.windowId ?? container.windowId, - initialTabId - }; + const container = ownedContainers[role]; + if (container.windowId !== null) { + try { + await chrome.windows.get(container.windowId); + const group2 = await ensureOwnedContainerGroup(role, container.windowId, []); + if (group2) { + await focusOwnedWindowIfRequested(group2.windowId, mode); + const initialTabId3 = await findReusableOwnedContainerTab(group2.windowId, group2.id); + return { + windowId: group2.windowId, + initialTabId: initialTabId3 + }; + } + await focusOwnedWindowIfRequested(container.windowId, mode); + const initialTabId2 = await findReusableOwnedContainerTab(container.windowId, null); + const createdGroup = await ensureOwnedContainerGroup(role, container.windowId, [initialTabId2]); + if (createdGroup) { + return { + windowId: createdGroup.windowId, + initialTabId: initialTabId2 + }; + } + return { + windowId: container.windowId, + initialTabId: initialTabId2 + }; + } catch { + container.windowId = null; + container.groupId = null; + } + } + const existingGroup = await ensureOwnedContainerGroup(role, null, []); + if (existingGroup) { + await focusOwnedWindowIfRequested(existingGroup.windowId, mode); + const initialTabId2 = await findReusableOwnedContainerTab(existingGroup.windowId, existingGroup.id); + await persistRuntimeState(); + return { + windowId: existingGroup.windowId, + initialTabId: initialTabId2 + }; + } + const startUrl = initialUrl && isSafeNavigationUrl(initialUrl) ? initialUrl : BLANK_PAGE; + const win = await chrome.windows.create({ + url: startUrl, + focused: mode === "foreground", + width: 1280, + height: 900, + type: "normal" + }); + container.windowId = win.id; + await persistRuntimeState(); + console.log(`[opencli] Created owned ${role} window ${container.windowId} (start=${startUrl})`); + const tabs = await chrome.tabs.query({ windowId: win.id }); + const initialTabId = tabs[0]?.id; + if (initialTabId) { + await new Promise((resolve) => { + const timeout = setTimeout(resolve, 500); + const listener = (tabId, info) => { + if (tabId === initialTabId && info.status === "complete") { + chrome.tabs.onUpdated.removeListener(listener); + clearTimeout(timeout); + resolve(); + } + }; + if (tabs[0].status === "complete") { + clearTimeout(timeout); + resolve(); + } else { + chrome.tabs.onUpdated.addListener(listener); + } + }); + } + const group = await ensureOwnedContainerGroup(role, container.windowId, [initialTabId]); + await persistRuntimeState(); + return { windowId: group?.windowId ?? container.windowId, initialTabId }; } async function findReusableOwnedContainerTab(windowId, ownedGroupId) { - try { - return (await chrome.tabs.query({ windowId })).find((tab) => tab.id !== void 0 && initialTabIsAvailable(tab.id) && isDebuggableUrl(tab.url) && (ownedGroupId === void 0 || ownedGroupId !== null && tab.groupId === ownedGroupId || !isSafeNavigationUrl(tab.url ?? "")))?.id; - } catch { - return; - } + try { + const tabs = await chrome.tabs.query({ windowId }); + const reusable = tabs.find( + (tab) => tab.id !== void 0 && initialTabIsAvailable(tab.id) && isDebuggableUrl(tab.url) && (ownedGroupId === void 0 || ownedGroupId !== null && tab.groupId === ownedGroupId || !isSafeNavigationUrl(tab.url ?? "")) + ); + return reusable?.id; + } catch { + return void 0; + } } function initialTabIsAvailable(tabId) { - if (tabId === void 0) return false; - for (const session of automationSessions.values()) if (session.owned && session.preferredTabId === tabId) return false; - return true; + if (tabId === void 0) return false; + for (const session of automationSessions.values()) { + if (session.owned && session.preferredTabId === tabId) return false; + } + return true; } async function createOwnedTabLease(leaseKey, initialUrl) { - return withLeaseMutation(() => createOwnedTabLeaseUnlocked(leaseKey, initialUrl)); + return withLeaseMutation(() => createOwnedTabLeaseUnlocked(leaseKey, initialUrl)); } async function createOwnedTabLeaseUnlocked(leaseKey, initialUrl) { - const targetUrl = initialUrl && isSafeNavigationUrl(initialUrl) ? initialUrl : BLANK_PAGE; - const role = getOwnedWindowRole(leaseKey); - const { windowId, initialTabId } = await ensureOwnedContainerWindow(role, targetUrl, getWindowMode(leaseKey)); - let tab; - if (initialTabIsAvailable(initialTabId)) { - tab = await chrome.tabs.get(initialTabId); - if (!isTargetUrl(tab.url, targetUrl)) { - tab = await chrome.tabs.update(initialTabId, { url: targetUrl }); - await new Promise((resolve) => setTimeout(resolve, 300)); - tab = await chrome.tabs.get(initialTabId); - } - } else tab = await chrome.tabs.create({ - windowId, - url: targetUrl, - active: true - }); - const tabId = tab.id; - if (!tabId) throw new Error("Failed to create tab lease in automation container"); - const sessionWindowId = (await ensureOwnedContainerGroup(role, windowId, [tabId]))?.windowId ?? tab.windowId; - if (tab.windowId !== sessionWindowId) tab = await chrome.tabs.get(tabId); - setLeaseSession(leaseKey, { - session: getSessionFromKey(leaseKey), - surface: getSurfaceFromKey(leaseKey), - kind: "owned", - windowId: sessionWindowId, - owned: true, - preferredTabId: tabId - }); - resetWindowIdleTimer(leaseKey); - return { - tabId, - tab - }; -} -/** Get or create the dedicated automation container window. -* This compatibility helper returns the shared owned container. Leases -* lease tabs inside it instead of owning separate windows. -*/ + const targetUrl = initialUrl && isSafeNavigationUrl(initialUrl) ? initialUrl : BLANK_PAGE; + const role = getOwnedWindowRole(leaseKey); + const { windowId, initialTabId } = await ensureOwnedContainerWindow(role, targetUrl, getWindowMode(leaseKey)); + let tab; + if (initialTabIsAvailable(initialTabId)) { + tab = await chrome.tabs.get(initialTabId); + if (!isTargetUrl(tab.url, targetUrl)) { + tab = await chrome.tabs.update(initialTabId, { url: targetUrl }); + await new Promise((resolve) => setTimeout(resolve, 300)); + tab = await chrome.tabs.get(initialTabId); + } + } else { + tab = await chrome.tabs.create({ windowId, url: targetUrl, active: true }); + } + const tabId = tab.id; + if (!tabId) throw new Error("Failed to create tab lease in automation container"); + const group = await ensureOwnedContainerGroup(role, windowId, [tabId]); + const sessionWindowId = group?.windowId ?? tab.windowId; + if (tab.windowId !== sessionWindowId) tab = await chrome.tabs.get(tabId); + setLeaseSession(leaseKey, { + session: getSessionFromKey(leaseKey), + surface: getSurfaceFromKey(leaseKey), + kind: "owned", + windowId: sessionWindowId, + owned: true, + preferredTabId: tabId + }); + resetWindowIdleTimer(leaseKey); + return { tabId, tab }; +} async function getAutomationWindow(leaseKey, initialUrl) { - const existing = automationSessions.get(leaseKey); - if (existing) { - if (!existing.owned) throw new CommandFailure("bound_window_operation_blocked", `Session "${existing.session}" is bound to a user tab and does not own an OpenCLI tab lease.`, "Use page commands on the bound tab, or unbind the session first."); - try { - const tabId = existing.preferredTabId; - if (tabId !== null) { - const tab = await chrome.tabs.get(tabId); - if (isDebuggableUrl(tab.url)) return tab.windowId; - } - await chrome.windows.get(existing.windowId); - return existing.windowId; - } catch { - await removeLeaseSession(leaseKey); - } - } - return (await ensureOwnedContainerWindow(getOwnedWindowRole(leaseKey), initialUrl, getWindowMode(leaseKey))).windowId; + const existing = automationSessions.get(leaseKey); + if (existing) { + if (!existing.owned) { + throw new CommandFailure( + "bound_window_operation_blocked", + `Session "${existing.session}" is bound to a user tab and does not own an OpenCLI tab lease.`, + "Use page commands on the bound tab, or unbind the session first." + ); + } + try { + const tabId = existing.preferredTabId; + if (tabId !== null) { + const tab = await chrome.tabs.get(tabId); + if (isDebuggableUrl(tab.url)) return tab.windowId; + } + await chrome.windows.get(existing.windowId); + return existing.windowId; + } catch { + await removeLeaseSession(leaseKey); + } + } + const role = getOwnedWindowRole(leaseKey); + return (await ensureOwnedContainerWindow(role, initialUrl, getWindowMode(leaseKey))).windowId; } chrome.windows.onRemoved.addListener(async (windowId) => { - for (const container of Object.values(ownedContainers)) if (container.windowId === windowId) { - container.windowId = null; - container.groupId = null; - } - for (const [leaseKey, session] of automationSessions.entries()) if (session.windowId === windowId) { - console.log(`[opencli] ${session.surface} container closed (session=${session.session})`); - if (session.idleTimer) clearTimeout(session.idleTimer); - automationSessions.delete(leaseKey); - sessionTimeoutOverrides.delete(leaseKey); - sessionWindowModeOverrides.delete(leaseKey); - sessionLifecycleOverrides.delete(leaseKey); - scheduleIdleAlarm(leaseKey, IDLE_TIMEOUT_NONE); - } - await persistRuntimeState(); + for (const container of Object.values(ownedContainers)) { + if (container.windowId === windowId) { + container.windowId = null; + container.groupId = null; + } + } + for (const [leaseKey, session] of automationSessions.entries()) { + if (session.windowId === windowId) { + console.log(`[opencli] ${session.surface} container closed (session=${session.session})`); + if (session.idleTimer) clearTimeout(session.idleTimer); + automationSessions.delete(leaseKey); + sessionTimeoutOverrides.delete(leaseKey); + sessionWindowModeOverrides.delete(leaseKey); + sessionLifecycleOverrides.delete(leaseKey); + scheduleIdleAlarm(leaseKey, IDLE_TIMEOUT_NONE); + } + } + await persistRuntimeState(); }); chrome.tabs.onRemoved.addListener(async (tabId) => { - evictTab(tabId); - for (const [leaseKey, session] of automationSessions.entries()) if (session.preferredTabId === tabId) { - if (session.idleTimer) clearTimeout(session.idleTimer); - automationSessions.delete(leaseKey); - sessionTimeoutOverrides.delete(leaseKey); - sessionWindowModeOverrides.delete(leaseKey); - sessionLifecycleOverrides.delete(leaseKey); - scheduleIdleAlarm(leaseKey, IDLE_TIMEOUT_NONE); - console.log(`[opencli] Session ${session.session} detached from tab ${tabId} (tab closed)`); - } - await persistRuntimeState(); + evictTab(tabId); + for (const [leaseKey, session] of automationSessions.entries()) { + if (session.preferredTabId === tabId) { + if (session.idleTimer) clearTimeout(session.idleTimer); + automationSessions.delete(leaseKey); + sessionTimeoutOverrides.delete(leaseKey); + sessionWindowModeOverrides.delete(leaseKey); + sessionLifecycleOverrides.delete(leaseKey); + scheduleIdleAlarm(leaseKey, IDLE_TIMEOUT_NONE); + console.log(`[opencli] Session ${session.session} detached from tab ${tabId} (tab closed)`); + } + } + await persistRuntimeState(); }); -var initialized = false; +let initialized = false; function initialize() { - if (initialized) return; - initialized = true; - chrome.alarms.create("keepalive", { periodInMinutes: .5 }); - registerListeners(); - try { - registerFrameTracking?.(); - } catch {} - (async () => { - await getCurrentContextId(); - await reconcileTargetLeaseRegistry(); - await connect(); - })(); - console.log("[opencli] OpenCLI extension initialized"); + if (initialized) return; + initialized = true; + chrome.alarms.create("keepalive", { periodInMinutes: 0.5 }); + registerListeners(); + try { + const registerFrameTracking$1 = registerFrameTracking; + registerFrameTracking$1?.(); + } catch { + } + void (async () => { + await getCurrentContextId(); + await reconcileTargetLeaseRegistry(); + await connect(); + })(); + console.log("[opencli] OpenCLI extension initialized"); } chrome.runtime.onInstalled.addListener(() => { - initialize(); + initialize(); }); chrome.runtime.onStartup.addListener(() => { - initialize(); + initialize(); }); initialize(); chrome.alarms.onAlarm.addListener(async (alarm) => { - if (alarm.name === "keepalive") connect(); - const leaseKey = leaseKeyFromAlarmName(alarm.name); - if (leaseKey) await releaseLease(leaseKey, "idle alarm"); + if (alarm.name === "keepalive") void connect(); + const leaseKey = leaseKeyFromAlarmName(alarm.name); + if (leaseKey) await releaseLease(leaseKey, "idle alarm"); }); chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { - if (msg?.type === "getStatus") { - (async () => { - const contextId = await getCurrentContextId(); - const connected = ws?.readyState === WebSocket.OPEN; - const extensionVersion = chrome.runtime.getManifest().version; - const daemonVersion = connected ? await fetchDaemonVersion() : null; - sendResponse({ - connected, - reconnecting: reconnectTimer !== null, - contextId, - extensionVersion, - daemonVersion - }); - })(); - return true; - } - return false; + if (msg?.type === "getStatus") { + void (async () => { + const contextId = await getCurrentContextId(); + const connected = ws?.readyState === WebSocket.OPEN; + const extensionVersion = chrome.runtime.getManifest().version; + const daemonVersion = connected ? await fetchDaemonVersion() : null; + sendResponse({ + connected, + reconnecting: reconnectTimer !== null, + contextId, + extensionVersion, + daemonVersion + }); + })(); + return true; + } + return false; }); -/** -* Best-effort fetch of the daemon's reported version for the popup status panel. -* Resolves to null on any failure — the popup degrades to showing connection -* state without the version label. -*/ async function fetchDaemonVersion() { - try { - const res = await fetch(`http://${DAEMON_HOST}:${DAEMON_PORT}/status`, { - method: "GET", - headers: { "X-OpenCLI": "1" }, - signal: AbortSignal.timeout(1500) - }); - if (!res.ok) return null; - const body = await res.json(); - return typeof body.daemonVersion === "string" ? body.daemonVersion : null; - } catch { - return null; - } + try { + const res = await fetch(`http://${DAEMON_HOST}:${DAEMON_PORT}/status`, { + method: "GET", + headers: { "X-OpenCLI": "1" }, + signal: AbortSignal.timeout(1500) + }); + if (!res.ok) return null; + const body = await res.json(); + return typeof body.daemonVersion === "string" ? body.daemonVersion : null; + } catch { + return null; + } } async function handleCommand(cmd) { - const session = getSessionName(cmd.session); - const surface = getCommandSurface(cmd); - const leaseKey = getLeaseKey(session, surface); - if (cmd.windowMode === "foreground" || cmd.windowMode === "background") sessionWindowModeOverrides.set(leaseKey, cmd.windowMode); - if (surface === "adapter" && (cmd.siteSession === "persistent" || cmd.siteSession === "ephemeral")) sessionLifecycleOverrides.set(leaseKey, cmd.siteSession); - if (cmd.idleTimeout != null && cmd.idleTimeout > 0) sessionTimeoutOverrides.set(leaseKey, cmd.idleTimeout * 1e3); - resetWindowIdleTimer(leaseKey); - try { - switch (cmd.action) { - case "exec": return await handleExec(cmd, leaseKey); - case "navigate": return await handleNavigate(cmd, leaseKey); - case "tabs": return await handleTabs(cmd, leaseKey); - case "cookies": return await handleCookies(cmd); - case "screenshot": return await handleScreenshot(cmd, leaseKey); - case "close-window": return await handleCloseWindow(cmd, leaseKey); - case "cdp": return await handleCdp(cmd, leaseKey); - case "set-file-input": return await handleSetFileInput(cmd, leaseKey); - case "insert-text": return await handleInsertText(cmd, leaseKey); - case "bind": return await handleBind(cmd, leaseKey); - case "network-capture-start": return await handleNetworkCaptureStart(cmd, leaseKey); - case "network-capture-read": return await handleNetworkCaptureRead(cmd, leaseKey); - case "wait-download": return await handleWaitDownload(cmd); - case "frames": return await handleFrames(cmd, leaseKey); - default: return { - id: cmd.id, - ok: false, - error: `Unknown action: ${cmd.action}` - }; - } - } catch (err) { - return { - id: cmd.id, - ok: false, - error: err instanceof Error ? err.message : String(err), - ...err instanceof CommandFailure ? { errorCode: err.code } : {}, - ...err instanceof CommandFailure && err.hint ? { errorHint: err.hint } : {} - }; - } -} -/** Internal blank page used when no user URL is provided. */ -var BLANK_PAGE = "about:blank"; -/** Check if a URL can be attached via CDP — only allow http(s) and blank pages. */ + const session = getSessionName(cmd.session); + const surface = getCommandSurface(cmd); + const leaseKey = getLeaseKey(session, surface); + if (cmd.windowMode === "foreground" || cmd.windowMode === "background") { + sessionWindowModeOverrides.set(leaseKey, cmd.windowMode); + } + if (surface === "adapter" && (cmd.siteSession === "persistent" || cmd.siteSession === "ephemeral")) { + sessionLifecycleOverrides.set(leaseKey, cmd.siteSession); + } + if (cmd.idleTimeout != null && cmd.idleTimeout > 0) { + sessionTimeoutOverrides.set(leaseKey, cmd.idleTimeout * 1e3); + } + resetWindowIdleTimer(leaseKey); + try { + switch (cmd.action) { + case "exec": + return await handleExec(cmd, leaseKey); + case "navigate": + return await handleNavigate(cmd, leaseKey); + case "tabs": + return await handleTabs(cmd, leaseKey); + case "cookies": + return await handleCookies(cmd); + case "screenshot": + return await handleScreenshot(cmd, leaseKey); + case "close-window": + return await handleCloseWindow(cmd, leaseKey); + case "cdp": + return await handleCdp(cmd, leaseKey); + case "set-file-input": + return await handleSetFileInput(cmd, leaseKey); + case "insert-text": + return await handleInsertText(cmd, leaseKey); + case "bind": + return await handleBind(cmd, leaseKey); + case "network-capture-start": + return await handleNetworkCaptureStart(cmd, leaseKey); + case "network-capture-read": + return await handleNetworkCaptureRead(cmd, leaseKey); + case "wait-download": + return await handleWaitDownload(cmd); + case "frames": + return await handleFrames(cmd, leaseKey); + default: + return { id: cmd.id, ok: false, error: `Unknown action: ${cmd.action}` }; + } + } catch (err) { + return { + id: cmd.id, + ok: false, + error: err instanceof Error ? err.message : String(err), + ...err instanceof CommandFailure ? { errorCode: err.code } : {}, + ...err instanceof CommandFailure && err.hint ? { errorHint: err.hint } : {} + }; + } +} +const BLANK_PAGE = "about:blank"; function isDebuggableUrl(url) { - if (!url) return true; - return url.startsWith("http://") || url.startsWith("https://") || url === "about:blank" || url.startsWith("data:"); + if (!url) return true; + return url.startsWith("http://") || url.startsWith("https://") || url === "about:blank" || url.startsWith("data:"); } -/** Check if a URL is safe for user-facing navigation (http/https only). */ function isSafeNavigationUrl(url) { - return url.startsWith("http://") || url.startsWith("https://"); + return url.startsWith("http://") || url.startsWith("https://"); } -/** Minimal URL normalization for same-page comparison: root slash + default port only. */ function normalizeUrlForComparison(url) { - if (!url) return ""; - try { - const parsed = new URL(url); - if (parsed.protocol === "https:" && parsed.port === "443" || parsed.protocol === "http:" && parsed.port === "80") parsed.port = ""; - const pathname = parsed.pathname === "/" ? "" : parsed.pathname; - return `${parsed.protocol}//${parsed.host}${pathname}${parsed.search}${parsed.hash}`; - } catch { - return url; - } + if (!url) return ""; + try { + const parsed = new URL(url); + if (parsed.protocol === "https:" && parsed.port === "443" || parsed.protocol === "http:" && parsed.port === "80") { + parsed.port = ""; + } + const pathname = parsed.pathname === "/" ? "" : parsed.pathname; + return `${parsed.protocol}//${parsed.host}${pathname}${parsed.search}${parsed.hash}`; + } catch { + return url; + } } function isTargetUrl(currentUrl, targetUrl) { - return normalizeUrlForComparison(currentUrl) === normalizeUrlForComparison(targetUrl); + return normalizeUrlForComparison(currentUrl) === normalizeUrlForComparison(targetUrl); } function getUrlOrigin(url) { - if (!url) return null; - try { - return new URL(url).origin; - } catch { - return null; - } + if (!url) return null; + try { + return new URL(url).origin; + } catch { + return null; + } } function enumerateCrossOriginFrames(tree) { - const frames = []; - function collect(node, accessibleOrigin) { - for (const child of node.childFrames || []) { - const frame = child.frame; - const frameUrl = frame.url || frame.unreachableUrl || ""; - const frameOrigin = getUrlOrigin(frameUrl); - if (accessibleOrigin && frameOrigin && frameOrigin === accessibleOrigin) { - collect(child, frameOrigin); - continue; - } - frames.push({ - index: frames.length, - frameId: frame.id, - url: frameUrl, - name: frame.name || "" - }); - } - } - const rootFrame = tree?.frameTree?.frame; - const rootUrl = rootFrame?.url || rootFrame?.unreachableUrl || ""; - collect(tree.frameTree, getUrlOrigin(rootUrl)); - return frames; + const frames = []; + function collect(node, accessibleOrigin) { + for (const child of node.childFrames || []) { + const frame = child.frame; + const frameUrl = frame.url || frame.unreachableUrl || ""; + const frameOrigin = getUrlOrigin(frameUrl); + if (accessibleOrigin && frameOrigin && frameOrigin === accessibleOrigin) { + collect(child, frameOrigin); + continue; + } + frames.push({ + index: frames.length, + frameId: frame.id, + url: frameUrl, + name: frame.name || "" + }); + } + } + const rootFrame = tree?.frameTree?.frame; + const rootUrl = rootFrame?.url || rootFrame?.unreachableUrl || ""; + collect(tree.frameTree, getUrlOrigin(rootUrl)); + return frames; } function setLeaseSession(leaseKey, session) { - const existing = automationSessions.get(leaseKey); - if (existing?.idleTimer) clearTimeout(existing.idleTimer); - const timeout = getIdleTimeout(leaseKey); - automationSessions.set(leaseKey, { - ...makeSession(leaseKey, session), - idleTimer: null, - idleDeadlineAt: timeout <= 0 ? 0 : Date.now() + timeout - }); - persistRuntimeState(); -} -/** -* Resolve tabId from command's page (targetId). -* Returns undefined if no page identity is provided. -*/ + const existing = automationSessions.get(leaseKey); + if (existing?.idleTimer) clearTimeout(existing.idleTimer); + const timeout = getIdleTimeout(leaseKey); + automationSessions.set(leaseKey, { + ...makeSession(leaseKey, session), + idleTimer: null, + idleDeadlineAt: timeout <= 0 ? 0 : Date.now() + timeout + }); + void persistRuntimeState(); +} async function resolveCommandTabId(cmd) { - if (cmd.page) return resolveTabId$1(cmd.page); + if (cmd.page) return resolveTabId$1(cmd.page); + return void 0; } -/** -* Resolve target tab for the session lease, returning both the tabId and -* the Tab object (when available) so callers can skip a redundant chrome.tabs.get(). -*/ async function resolveTab(tabId, leaseKey, initialUrl) { - const existingSession = automationSessions.get(leaseKey); - if (tabId !== void 0) try { - const tab = await chrome.tabs.get(tabId); - const session = existingSession; - const matchesSession = session ? session.preferredTabId !== null ? session.preferredTabId === tabId : tab.windowId === session.windowId : false; - if (isDebuggableUrl(tab.url) && matchesSession) return { - tabId, - tab - }; - if (session && !session.owned) throw new CommandFailure(matchesSession ? "bound_tab_not_debuggable" : "bound_tab_mismatch", matchesSession ? `Bound tab for session "${session.session}" is not debuggable (${tab.url ?? "unknown URL"}).` : `Target tab is not the tab bound to session "${session.session}".`, "Run \"opencli browser bind\" again on a debuggable http(s) tab."); - if (session && !matchesSession && session.preferredTabId === null && isDebuggableUrl(tab.url)) { - console.warn(`[opencli] Tab ${tabId} drifted to window ${tab.windowId}, moving back to ${session.windowId}`); - try { - await chrome.tabs.move(tabId, { - windowId: session.windowId, - index: -1 - }); - const moved = await chrome.tabs.get(tabId); - if (moved.windowId === session.windowId && isDebuggableUrl(moved.url)) return { - tabId, - tab: moved - }; - } catch (moveErr) { - console.warn(`[opencli] Failed to move tab back: ${moveErr}`); - } - } else if (!isDebuggableUrl(tab.url)) console.warn(`[opencli] Tab ${tabId} URL is not debuggable (${tab.url}), re-resolving`); - } catch (err) { - if (err instanceof CommandFailure) throw err; - if (existingSession && !existingSession.owned) { - automationSessions.delete(leaseKey); - throw new CommandFailure("bound_tab_gone", `Bound tab for session "${existingSession.session}" no longer exists.`, "Run \"opencli browser bind\" again, then retry the command."); - } - console.warn(`[opencli] Tab ${tabId} no longer exists, re-resolving`); - } - const existingPreferredTabId = existingSession?.preferredTabId ?? null; - if (existingSession && existingPreferredTabId !== null) { - const session = existingSession; - try { - const preferredTab = await chrome.tabs.get(existingPreferredTabId); - if (isDebuggableUrl(preferredTab.url)) return { - tabId: preferredTab.id, - tab: preferredTab - }; - if (!session.owned) throw new CommandFailure("bound_tab_not_debuggable", `Bound tab for session "${session.session}" is not debuggable (${preferredTab.url ?? "unknown URL"}).`, "Switch the tab to an http(s) page or run \"opencli browser bind\" on another tab."); - } catch (err) { - if (err instanceof CommandFailure) throw err; - await removeLeaseSession(leaseKey); - if (!session.owned) throw new CommandFailure("bound_tab_gone", `Bound tab for session "${session.session}" no longer exists.`, "Run \"opencli browser bind\" again, then retry the command."); - return createOwnedTabLease(leaseKey, initialUrl); - } - } - if (!existingSession || existingSession.owned && existingSession.preferredTabId === null) return createOwnedTabLease(leaseKey, initialUrl); - const windowId = await getAutomationWindow(leaseKey, initialUrl); - const role = getOwnedWindowRole(leaseKey); - const group = existingSession?.owned ? await ensureOwnedContainerGroup(role, windowId, []) : null; - const scopedWindowId = group?.windowId ?? windowId; - const reusableTabId = await findReusableOwnedContainerTab(scopedWindowId, existingSession?.owned ? group?.id ?? null : void 0); - if (reusableTabId !== void 0) return { - tabId: reusableTabId, - tab: await chrome.tabs.get(reusableTabId) - }; - const tabs = await chrome.tabs.query({ windowId: scopedWindowId }); - const reuseTab = existingSession?.owned ? void 0 : tabs.find((t) => t.id); - if (reuseTab?.id) { - await chrome.tabs.update(reuseTab.id, { url: BLANK_PAGE }); - await new Promise((resolve) => setTimeout(resolve, 300)); - try { - const updated = await chrome.tabs.get(reuseTab.id); - if (isDebuggableUrl(updated.url)) return { - tabId: reuseTab.id, - tab: updated - }; - console.warn(`[opencli] data: URI was intercepted (${updated.url}), creating fresh tab`); - } catch {} - } - const newTab = await chrome.tabs.create({ - windowId: scopedWindowId, - url: BLANK_PAGE, - active: true - }); - if (!newTab.id) throw new Error("Failed to create tab in automation container"); - await ensureOwnedContainerGroup(role, scopedWindowId, [newTab.id]); - return { - tabId: newTab.id, - tab: await chrome.tabs.get(newTab.id) - }; -} -/** Build a page-scoped success result with targetId resolved from tabId */ + const existingSession = automationSessions.get(leaseKey); + if (tabId !== void 0) { + try { + const tab = await chrome.tabs.get(tabId); + const session = existingSession; + const matchesSession = session ? session.preferredTabId !== null ? session.preferredTabId === tabId : tab.windowId === session.windowId : false; + if (isDebuggableUrl(tab.url) && matchesSession) return { tabId, tab }; + if (session && !session.owned) { + throw new CommandFailure( + matchesSession ? "bound_tab_not_debuggable" : "bound_tab_mismatch", + matchesSession ? `Bound tab for session "${session.session}" is not debuggable (${tab.url ?? "unknown URL"}).` : `Target tab is not the tab bound to session "${session.session}".`, + 'Run "opencli browser bind" again on a debuggable http(s) tab.' + ); + } + if (session && !matchesSession && session.preferredTabId === null && isDebuggableUrl(tab.url)) { + console.warn(`[opencli] Tab ${tabId} drifted to window ${tab.windowId}, moving back to ${session.windowId}`); + try { + await chrome.tabs.move(tabId, { windowId: session.windowId, index: -1 }); + const moved = await chrome.tabs.get(tabId); + if (moved.windowId === session.windowId && isDebuggableUrl(moved.url)) { + return { tabId, tab: moved }; + } + } catch (moveErr) { + console.warn(`[opencli] Failed to move tab back: ${moveErr}`); + } + } else if (!isDebuggableUrl(tab.url)) { + console.warn(`[opencli] Tab ${tabId} URL is not debuggable (${tab.url}), re-resolving`); + } + } catch (err) { + if (err instanceof CommandFailure) throw err; + if (existingSession && !existingSession.owned) { + automationSessions.delete(leaseKey); + throw new CommandFailure( + "bound_tab_gone", + `Bound tab for session "${existingSession.session}" no longer exists.`, + 'Run "opencli browser bind" again, then retry the command.' + ); + } + console.warn(`[opencli] Tab ${tabId} no longer exists, re-resolving`); + } + } + const existingPreferredTabId = existingSession?.preferredTabId ?? null; + if (existingSession && existingPreferredTabId !== null) { + const session = existingSession; + try { + const preferredTab = await chrome.tabs.get(existingPreferredTabId); + if (isDebuggableUrl(preferredTab.url)) return { tabId: preferredTab.id, tab: preferredTab }; + if (!session.owned) { + throw new CommandFailure( + "bound_tab_not_debuggable", + `Bound tab for session "${session.session}" is not debuggable (${preferredTab.url ?? "unknown URL"}).`, + 'Switch the tab to an http(s) page or run "opencli browser bind" on another tab.' + ); + } + } catch (err) { + if (err instanceof CommandFailure) throw err; + await removeLeaseSession(leaseKey); + if (!session.owned) { + throw new CommandFailure( + "bound_tab_gone", + `Bound tab for session "${session.session}" no longer exists.`, + 'Run "opencli browser bind" again, then retry the command.' + ); + } + return createOwnedTabLease(leaseKey, initialUrl); + } + } + if (!existingSession || existingSession.owned && existingSession.preferredTabId === null) { + return createOwnedTabLease(leaseKey, initialUrl); + } + const windowId = await getAutomationWindow(leaseKey, initialUrl); + const role = getOwnedWindowRole(leaseKey); + const group = existingSession?.owned ? await ensureOwnedContainerGroup(role, windowId, []) : null; + const scopedWindowId = group?.windowId ?? windowId; + const reusableTabId = await findReusableOwnedContainerTab(scopedWindowId, existingSession?.owned ? group?.id ?? null : void 0); + if (reusableTabId !== void 0) return { tabId: reusableTabId, tab: await chrome.tabs.get(reusableTabId) }; + const tabs = await chrome.tabs.query({ windowId: scopedWindowId }); + const reuseTab = existingSession?.owned ? void 0 : tabs.find((t) => t.id); + if (reuseTab?.id) { + await chrome.tabs.update(reuseTab.id, { url: BLANK_PAGE }); + await new Promise((resolve) => setTimeout(resolve, 300)); + try { + const updated = await chrome.tabs.get(reuseTab.id); + if (isDebuggableUrl(updated.url)) return { tabId: reuseTab.id, tab: updated }; + console.warn(`[opencli] data: URI was intercepted (${updated.url}), creating fresh tab`); + } catch { + } + } + const newTab = await chrome.tabs.create({ windowId: scopedWindowId, url: BLANK_PAGE, active: true }); + if (!newTab.id) throw new Error("Failed to create tab in automation container"); + await ensureOwnedContainerGroup(role, scopedWindowId, [newTab.id]); + return { tabId: newTab.id, tab: await chrome.tabs.get(newTab.id) }; +} async function pageScopedResult(id, tabId, data) { - return { - id, - ok: true, - data, - page: await resolveTargetId(tabId) - }; -} -/** Convenience wrapper returning just the tabId (used by most handlers) */ + const page = await resolveTargetId(tabId); + return { id, ok: true, data, page }; +} async function resolveTabId(tabId, leaseKey, initialUrl) { - return (await resolveTab(tabId, leaseKey, initialUrl)).tabId; + const resolved = await resolveTab(tabId, leaseKey, initialUrl); + return resolved.tabId; } async function listAutomationTabs(leaseKey) { - const session = automationSessions.get(leaseKey); - if (!session) return []; - if (session.preferredTabId !== null) try { - return [await chrome.tabs.get(session.preferredTabId)]; - } catch { - automationSessions.delete(leaseKey); - return []; - } - try { - return await chrome.tabs.query({ windowId: session.windowId }); - } catch { - automationSessions.delete(leaseKey); - return []; - } + const session = automationSessions.get(leaseKey); + if (!session) return []; + if (session.preferredTabId !== null) { + try { + return [await chrome.tabs.get(session.preferredTabId)]; + } catch { + automationSessions.delete(leaseKey); + return []; + } + } + try { + return await chrome.tabs.query({ windowId: session.windowId }); + } catch { + automationSessions.delete(leaseKey); + return []; + } } async function listAutomationWebTabs(leaseKey) { - return (await listAutomationTabs(leaseKey)).filter((tab) => isDebuggableUrl(tab.url)); + const tabs = await listAutomationTabs(leaseKey); + return tabs.filter((tab) => isDebuggableUrl(tab.url)); } async function handleExec(cmd, leaseKey) { - if (!cmd.code) return { - id: cmd.id, - ok: false, - error: "Missing code" - }; - const tabId = await resolveTabId(await resolveCommandTabId(cmd), leaseKey); - try { - const aggressive = getSurfaceFromKey(leaseKey) === "browser"; - if (cmd.frameIndex != null) { - const frames = enumerateCrossOriginFrames(await getFrameTree(tabId)); - if (cmd.frameIndex < 0 || cmd.frameIndex >= frames.length) return { - id: cmd.id, - ok: false, - error: `Frame index ${cmd.frameIndex} out of range (${frames.length} cross-origin frames available)` - }; - const data = await evaluateInFrame(tabId, cmd.code, frames[cmd.frameIndex].frameId, aggressive); - return pageScopedResult(cmd.id, tabId, data); - } - const data = await evaluateAsync(tabId, cmd.code, aggressive); - return pageScopedResult(cmd.id, tabId, data); - } catch (err) { - return { - id: cmd.id, - ok: false, - error: err instanceof Error ? err.message : String(err) - }; - } + if (!cmd.code) return { id: cmd.id, ok: false, error: "Missing code" }; + const cmdTabId = await resolveCommandTabId(cmd); + const tabId = await resolveTabId(cmdTabId, leaseKey); + try { + const aggressive = getSurfaceFromKey(leaseKey) === "browser"; + if (cmd.frameIndex != null) { + const tree = await getFrameTree(tabId); + const frames = enumerateCrossOriginFrames(tree); + if (cmd.frameIndex < 0 || cmd.frameIndex >= frames.length) { + return { id: cmd.id, ok: false, error: `Frame index ${cmd.frameIndex} out of range (${frames.length} cross-origin frames available)` }; + } + const data2 = await evaluateInFrame(tabId, cmd.code, frames[cmd.frameIndex].frameId, aggressive); + return pageScopedResult(cmd.id, tabId, data2); + } + const data = await evaluateAsync(tabId, cmd.code, aggressive); + return pageScopedResult(cmd.id, tabId, data); + } catch (err) { + return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; + } } async function handleFrames(cmd, leaseKey) { - const tabId = await resolveTabId(await resolveCommandTabId(cmd), leaseKey); - try { - const tree = await getFrameTree(tabId); - return { - id: cmd.id, - ok: true, - data: enumerateCrossOriginFrames(tree) - }; - } catch (err) { - return { - id: cmd.id, - ok: false, - error: err instanceof Error ? err.message : String(err) - }; - } + const cmdTabId = await resolveCommandTabId(cmd); + const tabId = await resolveTabId(cmdTabId, leaseKey); + try { + const tree = await getFrameTree(tabId); + return { id: cmd.id, ok: true, data: enumerateCrossOriginFrames(tree) }; + } catch (err) { + return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; + } } async function handleNavigate(cmd, leaseKey) { - if (!cmd.url) return { - id: cmd.id, - ok: false, - error: "Missing url" - }; - if (!isSafeNavigationUrl(cmd.url)) return { - id: cmd.id, - ok: false, - error: "Blocked URL scheme -- only http:// and https:// are allowed" - }; - const resolved = await resolveTab(await resolveCommandTabId(cmd), leaseKey, cmd.url); - const tabId = resolved.tabId; - const beforeTab = resolved.tab ?? await chrome.tabs.get(tabId); - const beforeNormalized = normalizeUrlForComparison(beforeTab.url); - const targetUrl = cmd.url; - if (beforeTab.status === "complete" && isTargetUrl(beforeTab.url, targetUrl)) return pageScopedResult(cmd.id, tabId, { - title: beforeTab.title, - url: beforeTab.url, - timedOut: false - }); - if (!hasActiveNetworkCapture(tabId)) await detach(tabId); - await chrome.tabs.update(tabId, { url: targetUrl }); - let timedOut = false; - await new Promise((resolve) => { - let settled = false; - let checkTimer = null; - let timeoutTimer = null; - const finish = () => { - if (settled) return; - settled = true; - chrome.tabs.onUpdated.removeListener(listener); - if (checkTimer) clearTimeout(checkTimer); - if (timeoutTimer) clearTimeout(timeoutTimer); - resolve(); - }; - const isNavigationDone = (url) => { - return isTargetUrl(url, targetUrl) || normalizeUrlForComparison(url) !== beforeNormalized; - }; - const listener = (id, info, tab) => { - if (id !== tabId) return; - if (info.status === "complete" && isNavigationDone(tab.url ?? info.url)) finish(); - }; - chrome.tabs.onUpdated.addListener(listener); - checkTimer = setTimeout(async () => { - try { - const currentTab = await chrome.tabs.get(tabId); - if (currentTab.status === "complete" && isNavigationDone(currentTab.url)) finish(); - } catch {} - }, 100); - timeoutTimer = setTimeout(() => { - timedOut = true; - console.warn(`[opencli] Navigate to ${targetUrl} timed out after 15s`); - finish(); - }, 15e3); - }); - let tab = await chrome.tabs.get(tabId); - const postNavigationSession = automationSessions.get(leaseKey); - if (postNavigationSession && tab.windowId !== postNavigationSession.windowId) { - console.warn(`[opencli] Tab ${tabId} drifted to window ${tab.windowId} during navigation, moving back to ${postNavigationSession.windowId}`); - try { - await chrome.tabs.move(tabId, { - windowId: postNavigationSession.windowId, - index: -1 - }); - tab = await chrome.tabs.get(tabId); - } catch (moveErr) { - console.warn(`[opencli] Failed to recover drifted tab: ${moveErr}`); - } - } - return pageScopedResult(cmd.id, tabId, { - title: tab.title, - url: tab.url, - timedOut - }); + if (!cmd.url) return { id: cmd.id, ok: false, error: "Missing url" }; + if (!isSafeNavigationUrl(cmd.url)) { + return { id: cmd.id, ok: false, error: "Blocked URL scheme -- only http:// and https:// are allowed" }; + } + const cmdTabId = await resolveCommandTabId(cmd); + const resolved = await resolveTab(cmdTabId, leaseKey, cmd.url); + const tabId = resolved.tabId; + const beforeTab = resolved.tab ?? await chrome.tabs.get(tabId); + const beforeNormalized = normalizeUrlForComparison(beforeTab.url); + const targetUrl = cmd.url; + if (beforeTab.status === "complete" && isTargetUrl(beforeTab.url, targetUrl)) { + return pageScopedResult(cmd.id, tabId, { title: beforeTab.title, url: beforeTab.url, timedOut: false }); + } + if (!hasActiveNetworkCapture(tabId)) { + await detach(tabId); + } + await chrome.tabs.update(tabId, { url: targetUrl }); + let timedOut = false; + await new Promise((resolve) => { + let settled = false; + let checkTimer = null; + let timeoutTimer = null; + const finish = () => { + if (settled) return; + settled = true; + chrome.tabs.onUpdated.removeListener(listener); + if (checkTimer) clearTimeout(checkTimer); + if (timeoutTimer) clearTimeout(timeoutTimer); + resolve(); + }; + const isNavigationDone = (url) => { + return isTargetUrl(url, targetUrl) || normalizeUrlForComparison(url) !== beforeNormalized; + }; + const listener = (id, info, tab2) => { + if (id !== tabId) return; + if (info.status === "complete" && isNavigationDone(tab2.url ?? info.url)) { + finish(); + } + }; + chrome.tabs.onUpdated.addListener(listener); + checkTimer = setTimeout(async () => { + try { + const currentTab = await chrome.tabs.get(tabId); + if (currentTab.status === "complete" && isNavigationDone(currentTab.url)) { + finish(); + } + } catch { + } + }, 100); + timeoutTimer = setTimeout(() => { + timedOut = true; + console.warn(`[opencli] Navigate to ${targetUrl} timed out after 15s`); + finish(); + }, 15e3); + }); + let tab = await chrome.tabs.get(tabId); + const postNavigationSession = automationSessions.get(leaseKey); + if (postNavigationSession && tab.windowId !== postNavigationSession.windowId) { + console.warn(`[opencli] Tab ${tabId} drifted to window ${tab.windowId} during navigation, moving back to ${postNavigationSession.windowId}`); + try { + await chrome.tabs.move(tabId, { windowId: postNavigationSession.windowId, index: -1 }); + tab = await chrome.tabs.get(tabId); + } catch (moveErr) { + console.warn(`[opencli] Failed to recover drifted tab: ${moveErr}`); + } + } + return pageScopedResult(cmd.id, tabId, { title: tab.title, url: tab.url, timedOut }); } async function handleTabs(cmd, leaseKey) { - const session = automationSessions.get(leaseKey); - if (session && !session.owned && cmd.op !== "list") return { - id: cmd.id, - ok: false, - errorCode: "bound_tab_mutation_blocked", - error: `Session "${session.session}" is bound to a user tab; tab new/select/close requires an owned OpenCLI session.`, - errorHint: "Unbind the session first, or use a different session for owned OpenCLI tabs." - }; - switch (cmd.op) { - case "list": { - const tabs = await listAutomationWebTabs(leaseKey); - const data = await Promise.all(tabs.map(async (t, i) => { - let page; - try { - page = t.id ? await resolveTargetId(t.id) : void 0; - } catch {} - return { - index: i, - page, - url: t.url, - title: t.title, - active: t.active - }; - })); - return { - id: cmd.id, - ok: true, - data - }; - } - case "new": { - if (cmd.url && !isSafeNavigationUrl(cmd.url)) return { - id: cmd.id, - ok: false, - error: "Blocked URL scheme -- only http:// and https:// are allowed" - }; - if (!automationSessions.has(leaseKey)) { - const created = await createOwnedTabLease(leaseKey, cmd.url); - return pageScopedResult(cmd.id, created.tabId, { url: created.tab?.url }); - } - const windowId = await getAutomationWindow(leaseKey); - let tab = await chrome.tabs.create({ - windowId, - url: cmd.url ?? BLANK_PAGE, - active: true - }); - const tabId = tab.id; - if (!tabId) return { - id: cmd.id, - ok: false, - error: "Failed to create tab" - }; - const sessionWindowId = (await ensureOwnedContainerGroup(getOwnedWindowRole(leaseKey), windowId, [tabId]))?.windowId ?? tab.windowId; - if (tab.windowId !== sessionWindowId) tab = await chrome.tabs.get(tabId); - setLeaseSession(leaseKey, { - session: getSessionFromKey(leaseKey), - surface: getSurfaceFromKey(leaseKey), - kind: "owned", - windowId: sessionWindowId, - owned: true, - preferredTabId: tabId - }); - resetWindowIdleTimer(leaseKey); - return pageScopedResult(cmd.id, tabId, { url: tab.url }); - } - case "close": { - if (cmd.index !== void 0) { - const target = (await listAutomationWebTabs(leaseKey))[cmd.index]; - if (!target?.id) return { - id: cmd.id, - ok: false, - error: `Tab index ${cmd.index} not found` - }; - const closedPage = await resolveTargetId(target.id).catch(() => void 0); - if (automationSessions.get(leaseKey)?.preferredTabId === target.id) await releaseLease(leaseKey, "tab close"); - else { - await safeDetach(target.id); - await chrome.tabs.remove(target.id); - } - return { - id: cmd.id, - ok: true, - data: { closed: closedPage } - }; - } - const tabId = await resolveTabId(await resolveCommandTabId(cmd), leaseKey); - const closedPage = await resolveTargetId(tabId).catch(() => void 0); - if (automationSessions.get(leaseKey)?.preferredTabId === tabId) await releaseLease(leaseKey, "tab close"); - else { - await safeDetach(tabId); - await chrome.tabs.remove(tabId); - } - return { - id: cmd.id, - ok: true, - data: { closed: closedPage } - }; - } - case "select": { - if (cmd.index === void 0 && cmd.page === void 0) return { - id: cmd.id, - ok: false, - error: "Missing index or page" - }; - const cmdTabId = await resolveCommandTabId(cmd); - if (cmdTabId !== void 0) { - const session = automationSessions.get(leaseKey); - let tab; - try { - tab = await chrome.tabs.get(cmdTabId); - } catch { - return { - id: cmd.id, - ok: false, - error: `Page no longer exists` - }; - } - if (!session || tab.windowId !== session.windowId) return { - id: cmd.id, - ok: false, - error: `Page is not in the automation container` - }; - await chrome.tabs.update(cmdTabId, { active: true }); - return pageScopedResult(cmd.id, cmdTabId, { selected: true }); - } - const target = (await listAutomationWebTabs(leaseKey))[cmd.index]; - if (!target?.id) return { - id: cmd.id, - ok: false, - error: `Tab index ${cmd.index} not found` - }; - await chrome.tabs.update(target.id, { active: true }); - return pageScopedResult(cmd.id, target.id, { selected: true }); - } - default: return { - id: cmd.id, - ok: false, - error: `Unknown tabs op: ${cmd.op}` - }; - } + const session = automationSessions.get(leaseKey); + if (session && !session.owned && cmd.op !== "list") { + return { + id: cmd.id, + ok: false, + errorCode: "bound_tab_mutation_blocked", + error: `Session "${session.session}" is bound to a user tab; tab new/select/close requires an owned OpenCLI session.`, + errorHint: "Unbind the session first, or use a different session for owned OpenCLI tabs." + }; + } + switch (cmd.op) { + case "list": { + const tabs = await listAutomationWebTabs(leaseKey); + const data = await Promise.all(tabs.map(async (t, i) => { + let page; + try { + page = t.id ? await resolveTargetId(t.id) : void 0; + } catch { + } + return { index: i, page, url: t.url, title: t.title, active: t.active }; + })); + return { id: cmd.id, ok: true, data }; + } + case "new": { + if (cmd.url && !isSafeNavigationUrl(cmd.url)) { + return { id: cmd.id, ok: false, error: "Blocked URL scheme -- only http:// and https:// are allowed" }; + } + if (!automationSessions.has(leaseKey)) { + const created = await createOwnedTabLease(leaseKey, cmd.url); + return pageScopedResult(cmd.id, created.tabId, { url: created.tab?.url }); + } + const windowId = await getAutomationWindow(leaseKey); + let tab = await chrome.tabs.create({ windowId, url: cmd.url ?? BLANK_PAGE, active: true }); + const tabId = tab.id; + if (!tabId) return { id: cmd.id, ok: false, error: "Failed to create tab" }; + const group = await ensureOwnedContainerGroup(getOwnedWindowRole(leaseKey), windowId, [tabId]); + const sessionWindowId = group?.windowId ?? tab.windowId; + if (tab.windowId !== sessionWindowId) tab = await chrome.tabs.get(tabId); + setLeaseSession(leaseKey, { + session: getSessionFromKey(leaseKey), + surface: getSurfaceFromKey(leaseKey), + kind: "owned", + windowId: sessionWindowId, + owned: true, + preferredTabId: tabId + }); + resetWindowIdleTimer(leaseKey); + return pageScopedResult(cmd.id, tabId, { url: tab.url }); + } + case "close": { + if (cmd.index !== void 0) { + const tabs = await listAutomationWebTabs(leaseKey); + const target = tabs[cmd.index]; + if (!target?.id) return { id: cmd.id, ok: false, error: `Tab index ${cmd.index} not found` }; + const closedPage2 = await resolveTargetId(target.id).catch(() => void 0); + const currentSession2 = automationSessions.get(leaseKey); + if (currentSession2?.preferredTabId === target.id) { + await releaseLease(leaseKey, "tab close"); + } else { + await safeDetach(target.id); + await chrome.tabs.remove(target.id); + } + return { id: cmd.id, ok: true, data: { closed: closedPage2 } }; + } + const cmdTabId = await resolveCommandTabId(cmd); + const tabId = await resolveTabId(cmdTabId, leaseKey); + const closedPage = await resolveTargetId(tabId).catch(() => void 0); + const currentSession = automationSessions.get(leaseKey); + if (currentSession?.preferredTabId === tabId) { + await releaseLease(leaseKey, "tab close"); + } else { + await safeDetach(tabId); + await chrome.tabs.remove(tabId); + } + return { id: cmd.id, ok: true, data: { closed: closedPage } }; + } + case "select": { + if (cmd.index === void 0 && cmd.page === void 0) + return { id: cmd.id, ok: false, error: "Missing index or page" }; + const cmdTabId = await resolveCommandTabId(cmd); + if (cmdTabId !== void 0) { + const session2 = automationSessions.get(leaseKey); + let tab; + try { + tab = await chrome.tabs.get(cmdTabId); + } catch { + return { id: cmd.id, ok: false, error: `Page no longer exists` }; + } + if (!session2 || tab.windowId !== session2.windowId) { + return { id: cmd.id, ok: false, error: `Page is not in the automation container` }; + } + await chrome.tabs.update(cmdTabId, { active: true }); + return pageScopedResult(cmd.id, cmdTabId, { selected: true }); + } + const tabs = await listAutomationWebTabs(leaseKey); + const target = tabs[cmd.index]; + if (!target?.id) return { id: cmd.id, ok: false, error: `Tab index ${cmd.index} not found` }; + await chrome.tabs.update(target.id, { active: true }); + return pageScopedResult(cmd.id, target.id, { selected: true }); + } + default: + return { id: cmd.id, ok: false, error: `Unknown tabs op: ${cmd.op}` }; + } } async function handleCookies(cmd) { - if (!cmd.domain && !cmd.url) return { - id: cmd.id, - ok: false, - error: "Cookie scope required: provide domain or url to avoid dumping all cookies" - }; - const details = {}; - if (cmd.domain) details.domain = cmd.domain; - if (cmd.url) details.url = cmd.url; - const data = (await chrome.cookies.getAll(details)).map((c) => ({ - name: c.name, - value: c.value, - domain: c.domain, - path: c.path, - secure: c.secure, - httpOnly: c.httpOnly, - expirationDate: c.expirationDate - })); - return { - id: cmd.id, - ok: true, - data - }; + if (!cmd.domain && !cmd.url) { + return { id: cmd.id, ok: false, error: "Cookie scope required: provide domain or url to avoid dumping all cookies" }; + } + const details = {}; + if (cmd.domain) details.domain = cmd.domain; + if (cmd.url) details.url = cmd.url; + const cookies = await chrome.cookies.getAll(details); + const data = cookies.map((c) => ({ + name: c.name, + value: c.value, + domain: c.domain, + path: c.path, + secure: c.secure, + httpOnly: c.httpOnly, + expirationDate: c.expirationDate + })); + return { id: cmd.id, ok: true, data }; } async function handleScreenshot(cmd, leaseKey) { - const tabId = await resolveTabId(await resolveCommandTabId(cmd), leaseKey); - try { - const data = await screenshot(tabId, { - format: cmd.format, - quality: cmd.quality, - fullPage: cmd.fullPage, - width: cmd.width, - height: cmd.height - }); - return pageScopedResult(cmd.id, tabId, data); - } catch (err) { - return { - id: cmd.id, - ok: false, - error: err instanceof Error ? err.message : String(err) - }; - } -} -/** CDP methods permitted via the 'cdp' passthrough action. */ -var CDP_ALLOWLIST = new Set([ - "Accessibility.enable", - "Accessibility.getFullAXTree", - "DOM.enable", - "DOM.getDocument", - "DOM.getBoxModel", - "DOM.getContentQuads", - "DOM.focus", - "DOM.querySelector", - "DOM.querySelectorAll", - "DOM.scrollIntoViewIfNeeded", - "DOMSnapshot.captureSnapshot", - "Input.dispatchMouseEvent", - "Input.dispatchKeyEvent", - "Input.insertText", - "Page.getLayoutMetrics", - "Page.captureScreenshot", - "Page.getFrameTree", - "Page.handleJavaScriptDialog", - "Runtime.enable", - "Emulation.setDeviceMetricsOverride", - "Emulation.clearDeviceMetricsOverride" + const cmdTabId = await resolveCommandTabId(cmd); + const tabId = await resolveTabId(cmdTabId, leaseKey); + try { + const data = await screenshot(tabId, { + format: cmd.format, + quality: cmd.quality, + fullPage: cmd.fullPage, + width: cmd.width, + height: cmd.height + }); + return pageScopedResult(cmd.id, tabId, data); + } catch (err) { + return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; + } +} +const CDP_ALLOWLIST = /* @__PURE__ */ new Set([ + // Agent DOM context + "Accessibility.enable", + "Accessibility.getFullAXTree", + "DOM.enable", + "DOM.getDocument", + "DOM.getBoxModel", + "DOM.getContentQuads", + "DOM.focus", + "DOM.querySelector", + "DOM.querySelectorAll", + "DOM.scrollIntoViewIfNeeded", + "DOMSnapshot.captureSnapshot", + // Native input events + "Input.dispatchMouseEvent", + "Input.dispatchKeyEvent", + "Input.insertText", + // Page metrics & screenshots + "Page.getLayoutMetrics", + "Page.captureScreenshot", + "Page.getFrameTree", + "Page.handleJavaScriptDialog", + // Runtime.enable needed for CDP attach setup (Runtime.evaluate goes through 'exec' action) + "Runtime.enable", + // Emulation (used by screenshot full-page) + "Emulation.setDeviceMetricsOverride", + "Emulation.clearDeviceMetricsOverride" ]); async function handleCdp(cmd, leaseKey) { - if (!cmd.cdpMethod) return { - id: cmd.id, - ok: false, - error: "Missing cdpMethod" - }; - if (!CDP_ALLOWLIST.has(cmd.cdpMethod)) return { - id: cmd.id, - ok: false, - error: `CDP method not permitted: ${cmd.cdpMethod}` - }; - const tabId = await resolveTabId(await resolveCommandTabId(cmd), leaseKey); - try { - const aggressive = getSurfaceFromKey(leaseKey) === "browser"; - await ensureAttached(tabId, aggressive); - const params = cmd.cdpParams ?? {}; - const routeFrameId = typeof params.frameId === "string" && params.sessionId === "target" ? params.frameId : void 0; - const routeTargetUrl = typeof params.targetUrl === "string" ? params.targetUrl : void 0; - const data = routeFrameId ? await sendCommandInFrameTarget(tabId, routeFrameId, cmd.cdpMethod, stripOpenCliFrameRoutingParams(params, true), aggressive, 3e4, routeTargetUrl) : await chrome.debugger.sendCommand({ tabId }, cmd.cdpMethod, stripOpenCliFrameRoutingParams(params, false)); - return pageScopedResult(cmd.id, tabId, data); - } catch (err) { - return { - id: cmd.id, - ok: false, - error: err instanceof Error ? err.message : String(err) - }; - } + if (!cmd.cdpMethod) return { id: cmd.id, ok: false, error: "Missing cdpMethod" }; + if (!CDP_ALLOWLIST.has(cmd.cdpMethod)) { + return { id: cmd.id, ok: false, error: `CDP method not permitted: ${cmd.cdpMethod}` }; + } + const cmdTabId = await resolveCommandTabId(cmd); + const tabId = await resolveTabId(cmdTabId, leaseKey); + try { + const aggressive = getSurfaceFromKey(leaseKey) === "browser"; + await ensureAttached(tabId, aggressive); + const params = cmd.cdpParams ?? {}; + const routeFrameId = typeof params.frameId === "string" && params.sessionId === "target" ? params.frameId : void 0; + const routeTargetUrl = typeof params.targetUrl === "string" ? params.targetUrl : void 0; + const data = routeFrameId ? await sendCommandInFrameTarget(tabId, routeFrameId, cmd.cdpMethod, stripOpenCliFrameRoutingParams(params, true), aggressive, 3e4, routeTargetUrl) : await chrome.debugger.sendCommand( + { tabId }, + cmd.cdpMethod, + stripOpenCliFrameRoutingParams(params, false) + ); + return pageScopedResult(cmd.id, tabId, data); + } catch (err) { + return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; + } } function stripOpenCliFrameRoutingParams(params, stripFrameId) { - const { sessionId, frameId, targetUrl, ...rest } = params; - if (!stripFrameId && frameId !== void 0) return { - ...rest, - frameId - }; - return rest; + const { sessionId, frameId, targetUrl, ...rest } = params; + if (!stripFrameId && frameId !== void 0) return { ...rest, frameId }; + return rest; } async function handleCloseWindow(cmd, leaseKey) { - const sessionName = automationSessions.get(leaseKey)?.session ?? getSessionFromKey(leaseKey); - await releaseLease(leaseKey, "explicit close"); - return { - id: cmd.id, - ok: true, - data: { - closed: true, - session: sessionName - } - }; + const sessionName = automationSessions.get(leaseKey)?.session ?? getSessionFromKey(leaseKey); + await releaseLease(leaseKey, "explicit close"); + return { id: cmd.id, ok: true, data: { closed: true, session: sessionName } }; } async function handleSetFileInput(cmd, leaseKey) { - if (!cmd.files || !Array.isArray(cmd.files) || cmd.files.length === 0) return { - id: cmd.id, - ok: false, - error: "Missing or empty files array" - }; - const tabId = await resolveTabId(await resolveCommandTabId(cmd), leaseKey); - try { - await setFileInputFiles(tabId, cmd.files, cmd.selector); - return pageScopedResult(cmd.id, tabId, { count: cmd.files.length }); - } catch (err) { - return { - id: cmd.id, - ok: false, - error: err instanceof Error ? err.message : String(err) - }; - } + if (!cmd.files || !Array.isArray(cmd.files) || cmd.files.length === 0) { + return { id: cmd.id, ok: false, error: "Missing or empty files array" }; + } + const cmdTabId = await resolveCommandTabId(cmd); + const tabId = await resolveTabId(cmdTabId, leaseKey); + try { + await setFileInputFiles(tabId, cmd.files, cmd.selector); + return pageScopedResult(cmd.id, tabId, { count: cmd.files.length }); + } catch (err) { + return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; + } } async function handleInsertText(cmd, leaseKey) { - if (typeof cmd.text !== "string") return { - id: cmd.id, - ok: false, - error: "Missing text payload" - }; - const tabId = await resolveTabId(await resolveCommandTabId(cmd), leaseKey); - try { - await insertText(tabId, cmd.text); - return pageScopedResult(cmd.id, tabId, { inserted: true }); - } catch (err) { - return { - id: cmd.id, - ok: false, - error: err instanceof Error ? err.message : String(err) - }; - } + if (typeof cmd.text !== "string") { + return { id: cmd.id, ok: false, error: "Missing text payload" }; + } + const cmdTabId = await resolveCommandTabId(cmd); + const tabId = await resolveTabId(cmdTabId, leaseKey); + try { + await insertText(tabId, cmd.text); + return pageScopedResult(cmd.id, tabId, { inserted: true }); + } catch (err) { + return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; + } } async function handleNetworkCaptureStart(cmd, leaseKey) { - const tabId = await resolveTabId(await resolveCommandTabId(cmd), leaseKey); - try { - await startNetworkCapture(tabId, cmd.pattern); - return pageScopedResult(cmd.id, tabId, { started: true }); - } catch (err) { - return { - id: cmd.id, - ok: false, - error: err instanceof Error ? err.message : String(err) - }; - } + const cmdTabId = await resolveCommandTabId(cmd); + const tabId = await resolveTabId(cmdTabId, leaseKey); + try { + await startNetworkCapture(tabId, cmd.pattern); + return pageScopedResult(cmd.id, tabId, { started: true }); + } catch (err) { + return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; + } } async function handleNetworkCaptureRead(cmd, leaseKey) { - const tabId = await resolveTabId(await resolveCommandTabId(cmd), leaseKey); - try { - const data = await readNetworkCapture(tabId); - return pageScopedResult(cmd.id, tabId, data); - } catch (err) { - return { - id: cmd.id, - ok: false, - error: err instanceof Error ? err.message : String(err) - }; - } + const cmdTabId = await resolveCommandTabId(cmd); + const tabId = await resolveTabId(cmdTabId, leaseKey); + try { + const data = await readNetworkCapture(tabId); + return pageScopedResult(cmd.id, tabId, data); + } catch (err) { + return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; + } } async function handleWaitDownload(cmd) { - try { - const data = await waitForDownload(cmd.pattern ?? "", cmd.timeoutMs ?? 3e4); - return { - id: cmd.id, - ok: true, - data - }; - } catch (err) { - return { - id: cmd.id, - ok: false, - error: err instanceof Error ? err.message : String(err) - }; - } + try { + const data = await waitForDownload(cmd.pattern ?? "", cmd.timeoutMs ?? 3e4); + return { id: cmd.id, ok: true, data }; + } catch (err) { + return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; + } } async function releaseLease(leaseKey, reason = "released") { - const session = automationSessions.get(leaseKey); - if (!session) { - sessionTimeoutOverrides.delete(leaseKey); - sessionWindowModeOverrides.delete(leaseKey); - sessionLifecycleOverrides.delete(leaseKey); - scheduleIdleAlarm(leaseKey, IDLE_TIMEOUT_NONE); - await persistRuntimeState(); - return; - } - if (session.idleTimer) clearTimeout(session.idleTimer); - scheduleIdleAlarm(leaseKey, IDLE_TIMEOUT_NONE); - if (session.owned) { - const tabId = session.preferredTabId; - if (tabId !== null) { - const hasOtherOwnedLease = [...automationSessions.entries()].some(([otherLease, otherSession]) => otherLease !== leaseKey && otherSession.owned && otherSession.windowId === session.windowId && otherSession.preferredTabId !== null); - await safeDetach(tabId); - evictTab(tabId); - if (hasOtherOwnedLease) { - await chrome.tabs.remove(tabId).catch(() => {}); - console.log(`[opencli] Released owned tab lease ${tabId} (session=${session.session}, surface=${session.surface}, ${reason})`); - } else try { - const tab = await chrome.tabs.update(tabId, { - url: BLANK_PAGE, - active: true - }); - const group = await ensureOwnedContainerGroup(getOwnedWindowRole(leaseKey), session.windowId, [tab.id ?? tabId]); - if (group) session.windowId = group.windowId; - console.log(`[opencli] Released owned tab lease ${tabId} as reusable placeholder (session=${session.session}, surface=${session.surface}, ${reason})`); - } catch { - await chrome.tabs.remove(tabId).catch(() => {}); - console.log(`[opencli] Released owned tab lease ${tabId} (session=${session.session}, surface=${session.surface}, ${reason})`); - } - } else console.log(`[opencli] Released legacy owned window lease ${session.windowId} without closing container (session=${session.session}, surface=${session.surface}, ${reason})`); - } else if (session.preferredTabId !== null) { - await safeDetach(session.preferredTabId); - console.log(`[opencli] Detached borrowed tab lease ${session.preferredTabId} (session=${session.session}, surface=${session.surface}, ${reason})`); - } - automationSessions.delete(leaseKey); - sessionTimeoutOverrides.delete(leaseKey); - sessionWindowModeOverrides.delete(leaseKey); - sessionLifecycleOverrides.delete(leaseKey); - await persistRuntimeState(); + const session = automationSessions.get(leaseKey); + if (!session) { + sessionTimeoutOverrides.delete(leaseKey); + sessionWindowModeOverrides.delete(leaseKey); + sessionLifecycleOverrides.delete(leaseKey); + scheduleIdleAlarm(leaseKey, IDLE_TIMEOUT_NONE); + await persistRuntimeState(); + return; + } + if (session.idleTimer) clearTimeout(session.idleTimer); + scheduleIdleAlarm(leaseKey, IDLE_TIMEOUT_NONE); + if (session.owned) { + const tabId = session.preferredTabId; + if (tabId !== null) { + const hasOtherOwnedLease = [...automationSessions.entries()].some( + ([otherLease, otherSession]) => otherLease !== leaseKey && otherSession.owned && otherSession.windowId === session.windowId && otherSession.preferredTabId !== null + ); + await safeDetach(tabId); + evictTab(tabId); + if (hasOtherOwnedLease) { + await chrome.tabs.remove(tabId).catch(() => { + }); + console.log(`[opencli] Released owned tab lease ${tabId} (session=${session.session}, surface=${session.surface}, ${reason})`); + } else { + try { + const tab = await chrome.tabs.update(tabId, { url: BLANK_PAGE, active: true }); + const group = await ensureOwnedContainerGroup(getOwnedWindowRole(leaseKey), session.windowId, [tab.id ?? tabId]); + if (group) session.windowId = group.windowId; + console.log(`[opencli] Released owned tab lease ${tabId} as reusable placeholder (session=${session.session}, surface=${session.surface}, ${reason})`); + } catch { + await chrome.tabs.remove(tabId).catch(() => { + }); + console.log(`[opencli] Released owned tab lease ${tabId} (session=${session.session}, surface=${session.surface}, ${reason})`); + } + } + } else { + console.log(`[opencli] Released legacy owned window lease ${session.windowId} without closing container (session=${session.session}, surface=${session.surface}, ${reason})`); + } + } else if (session.preferredTabId !== null) { + await safeDetach(session.preferredTabId); + console.log(`[opencli] Detached borrowed tab lease ${session.preferredTabId} (session=${session.session}, surface=${session.surface}, ${reason})`); + } + automationSessions.delete(leaseKey); + sessionTimeoutOverrides.delete(leaseKey); + sessionWindowModeOverrides.delete(leaseKey); + sessionLifecycleOverrides.delete(leaseKey); + await persistRuntimeState(); } async function reconcileTargetLeaseRegistry() { - const registry = await readRegistry(); - for (const role of Object.keys(ownedContainers)) { - ownedContainers[role].windowId = registry.ownedContainers[role]?.windowId ?? null; - ownedContainers[role].groupId = registry.ownedContainers[role]?.groupId ?? null; - const windowId = ownedContainers[role].windowId; - if (windowId !== null) try { - await chrome.windows.get(windowId); - } catch { - ownedContainers[role].windowId = null; - ownedContainers[role].groupId = null; - } - } - automationSessions.clear(); - for (const [leaseKey, stored] of Object.entries(registry.leases)) { - const tabId = stored.preferredTabId; - if (tabId === null) continue; - try { - const tab = await chrome.tabs.get(tabId); - if (!isDebuggableUrl(tab.url)) continue; - if (stored.lifecycle === "ephemeral" || stored.lifecycle === "persistent" || stored.lifecycle === "pinned") sessionLifecycleOverrides.set(leaseKey, stored.lifecycle); - const session = makeSession(leaseKey, { - session: typeof stored.session === "string" ? stored.session : getSessionFromKey(leaseKey), - surface: stored.surface === "adapter" ? "adapter" : getSurfaceFromKey(leaseKey), - kind: stored.kind === "bound" || stored.owned === false ? "bound" : "owned", - windowId: tab.windowId, - owned: stored.owned, - preferredTabId: tabId - }); - const timeout = getIdleTimeout(leaseKey); - automationSessions.set(leaseKey, { - ...session, - idleTimer: null, - idleDeadlineAt: stored.idleDeadlineAt - }); - if (session.owned) { - const role = getOwnedWindowRole(leaseKey); - if (ownedContainers[role].windowId === null) ownedContainers[role].windowId = tab.windowId; - const group = await ensureOwnedContainerGroup(role, tab.windowId, [tabId]); - if (group) { - const current = automationSessions.get(leaseKey); - if (current) current.windowId = group.windowId; - } - } - const remaining = stored.idleDeadlineAt > 0 ? stored.idleDeadlineAt - Date.now() : timeout; - if (timeout > 0) if (remaining <= 0) await releaseLease(leaseKey, "reconciled idle expiry"); - else resetWindowIdleTimer(leaseKey); - } catch {} - } - await persistRuntimeState(); + const registry = await readRegistry(); + for (const role of Object.keys(ownedContainers)) { + ownedContainers[role].windowId = registry.ownedContainers[role]?.windowId ?? null; + ownedContainers[role].groupId = registry.ownedContainers[role]?.groupId ?? null; + const windowId = ownedContainers[role].windowId; + if (windowId !== null) { + try { + await chrome.windows.get(windowId); + } catch { + ownedContainers[role].windowId = null; + ownedContainers[role].groupId = null; + } + } + } + automationSessions.clear(); + for (const [leaseKey, stored] of Object.entries(registry.leases)) { + const tabId = stored.preferredTabId; + if (tabId === null) continue; + try { + const tab = await chrome.tabs.get(tabId); + if (!isDebuggableUrl(tab.url)) continue; + if (stored.lifecycle === "ephemeral" || stored.lifecycle === "persistent" || stored.lifecycle === "pinned") { + sessionLifecycleOverrides.set(leaseKey, stored.lifecycle); + } + const session = makeSession(leaseKey, { + session: typeof stored.session === "string" ? stored.session : getSessionFromKey(leaseKey), + surface: stored.surface === "adapter" ? "adapter" : getSurfaceFromKey(leaseKey), + kind: stored.kind === "bound" || stored.owned === false ? "bound" : "owned", + windowId: tab.windowId, + owned: stored.owned, + preferredTabId: tabId + }); + const timeout = getIdleTimeout(leaseKey); + automationSessions.set(leaseKey, { + ...session, + idleTimer: null, + idleDeadlineAt: stored.idleDeadlineAt + }); + if (session.owned) { + const role = getOwnedWindowRole(leaseKey); + if (ownedContainers[role].windowId === null) ownedContainers[role].windowId = tab.windowId; + const group = await ensureOwnedContainerGroup(role, tab.windowId, [tabId]); + if (group) { + const current = automationSessions.get(leaseKey); + if (current) current.windowId = group.windowId; + } + } + const remaining = stored.idleDeadlineAt > 0 ? stored.idleDeadlineAt - Date.now() : timeout; + if (timeout > 0) { + if (remaining <= 0) { + await releaseLease(leaseKey, "reconciled idle expiry"); + } else { + resetWindowIdleTimer(leaseKey, remaining); + } + } + } catch { + } + } + await persistRuntimeState(); } async function handleBind(cmd, leaseKey) { - if (automationSessions.get(leaseKey)?.owned) await releaseLease(leaseKey, "rebind"); - const activeTabs = await chrome.tabs.query({ - active: true, - lastFocusedWindow: true - }); - const fallbackTabs = await chrome.tabs.query({ lastFocusedWindow: true }); - const boundTab = activeTabs.find((tab) => isDebuggableUrl(tab.url)) ?? fallbackTabs.find((tab) => isDebuggableUrl(tab.url)); - if (!boundTab?.id) return { - id: cmd.id, - ok: false, - errorCode: "bound_tab_not_found", - error: "No debuggable tab found in the current window", - errorHint: "Focus the target Chrome tab/window, then retry bind." - }; - const current = automationSessions.get(leaseKey); - if (current && !current.owned && current.preferredTabId !== null && current.preferredTabId !== boundTab.id) await detach(current.preferredTabId).catch(() => {}); - setLeaseSession(leaseKey, { - session: getSessionFromKey(leaseKey), - surface: getSurfaceFromKey(leaseKey), - kind: "bound", - windowId: boundTab.windowId, - owned: false, - preferredTabId: boundTab.id - }); - resetWindowIdleTimer(leaseKey); - console.log(`[opencli] Session ${getSessionFromKey(leaseKey)} explicitly bound to tab ${boundTab.id} (${boundTab.url})`); - return pageScopedResult(cmd.id, boundTab.id, { - url: boundTab.url, - title: boundTab.title, - session: getSessionFromKey(leaseKey) - }); -} -//#endregion + const existing = automationSessions.get(leaseKey); + if (existing?.owned) { + await releaseLease(leaseKey, "rebind"); + } + const activeTabs = await chrome.tabs.query({ active: true, lastFocusedWindow: true }); + const fallbackTabs = await chrome.tabs.query({ lastFocusedWindow: true }); + const boundTab = activeTabs.find((tab) => isDebuggableUrl(tab.url)) ?? fallbackTabs.find((tab) => isDebuggableUrl(tab.url)); + if (!boundTab?.id) { + return { + id: cmd.id, + ok: false, + errorCode: "bound_tab_not_found", + error: "No debuggable tab found in the current window", + errorHint: "Focus the target Chrome tab/window, then retry bind." + }; + } + const current = automationSessions.get(leaseKey); + if (current && !current.owned && current.preferredTabId !== null && current.preferredTabId !== boundTab.id) { + await detach(current.preferredTabId).catch(() => { + }); + } + setLeaseSession(leaseKey, { + session: getSessionFromKey(leaseKey), + surface: getSurfaceFromKey(leaseKey), + kind: "bound", + windowId: boundTab.windowId, + owned: false, + preferredTabId: boundTab.id + }); + resetWindowIdleTimer(leaseKey); + console.log(`[opencli] Session ${getSessionFromKey(leaseKey)} explicitly bound to tab ${boundTab.id} (${boundTab.url})`); + return pageScopedResult(cmd.id, boundTab.id, { + url: boundTab.url, + title: boundTab.title, + session: getSessionFromKey(leaseKey) + }); +} diff --git a/extension/src/background.test.ts b/extension/src/background.test.ts index 3acdcd573..3b190dc77 100644 --- a/extension/src/background.test.ts +++ b/extension/src/background.test.ts @@ -1055,6 +1055,49 @@ describe('background tab isolation', () => { expect(chrome.windows.remove).not.toHaveBeenCalled(); }); + it('honors the persisted remaining idle lifetime on reconcile instead of granting a fresh full timeout', async () => { + const { chrome } = createChromeMock(); + const now = Date.now(); + // 5s left of a 30s adapter idle timeout when the service worker restarts. + const deadline = now + 5_000; + vi.stubGlobal('chrome', chrome); + await chrome.storage.local.set({ + opencli_target_lease_registry_v2: { + version: 2, + contextId: 'user-default', + ownedContainers: { interactive: { windowId: null }, automation: { windowId: 1 } }, + leases: { + [adapterKey('twitter')]: { + windowId: 1, + owned: true, + preferredTabId: 1, + contextId: 'user-default', + ownership: 'owned', + lifecycle: 'ephemeral', + windowRole: 'automation', + idleDeadlineAt: deadline, + updatedAt: now, + }, + }, + }, + }); + + const mod = await import('./background'); + await mod.__test__.reconcileTargetLeaseRegistry(); + + const alarmName = `opencli:lease-idle:${encodeURIComponent(adapterKey('twitter'))}`; + const createCalls = chrome.alarms.create.mock.calls.filter((c: unknown[]) => c[0] === alarmName); + expect(createCalls.length).toBeGreaterThan(0); + const scheduledWhen = (createCalls.at(-1)![1] as { when: number }).when; + + // The alarm must fire after the ~5s remaining, NOT after a fresh 30s timeout. + // Without honoring `remaining`, the lease keeps getting a full 30s on every + // SW restart and can dodge idle expiry indefinitely. + expect(scheduledWhen).toBeLessThan(now + 15_000); + expect(scheduledWhen).toBeGreaterThan(now + 1_000); + expect(mod.__test__.getSession(adapterKey('twitter')).idleDeadlineAt).toBeLessThan(now + 15_000); + }); + it('releases owned leases from the idle alarm path', async () => { const { chrome } = createChromeMock(); vi.stubGlobal('chrome', chrome); diff --git a/extension/src/background.ts b/extension/src/background.ts index e984e5b92..87d65edcf 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -534,23 +534,31 @@ async function removeLeaseSession(leaseKey: string): Promise { await persistRuntimeState(); } -function resetWindowIdleTimer(leaseKey: string): void { +// `remainingMs` lets the caller honor an already-elapsed deadline (e.g. after a +// service-worker restart) instead of granting a fresh full timeout. When given, +// the timer/alarm fire after the clamped remaining lifetime; when omitted, a +// full idle timeout is started. +function resetWindowIdleTimer(leaseKey: string, remainingMs?: number): void { const session = automationSessions.get(leaseKey); if (!session) return; if (session.idleTimer) clearTimeout(session.idleTimer); const timeout = getIdleTimeout(leaseKey); - scheduleIdleAlarm(leaseKey, timeout); if (timeout <= 0) { + scheduleIdleAlarm(leaseKey, timeout); session.idleTimer = null; session.idleDeadlineAt = 0; void persistRuntimeState(); return; } - session.idleDeadlineAt = Date.now() + timeout; + const interval = remainingMs === undefined + ? timeout + : Math.max(0, Math.min(remainingMs, timeout)); + scheduleIdleAlarm(leaseKey, interval); + session.idleDeadlineAt = Date.now() + interval; void persistRuntimeState(); session.idleTimer = setTimeout(async () => { await releaseLease(leaseKey, 'idle timeout'); - }, timeout); + }, interval); } function getOwnedContainerGroupTitles(role: OwnedWindowRole): string[] { @@ -1993,7 +2001,9 @@ async function reconcileTargetLeaseRegistry(): Promise { if (remaining <= 0) { await releaseLease(leaseKey, 'reconciled idle expiry'); } else { - resetWindowIdleTimer(leaseKey); + // Honor the persisted remaining lifetime — not a fresh full timeout — + // so a lease cannot dodge idle expiry by riding repeated SW restarts. + resetWindowIdleTimer(leaseKey, remaining); } } } catch { From 1f8b09bd1cc037c2dfbdb130ad0adc7c7a3d4545 Mon Sep 17 00:00:00 2001 From: jakevin Date: Wed, 1 Jul 2026 01:31:25 +0800 Subject: [PATCH 08/27] fix(extension): preserve network capture across ensureAttached re-attach (#1978) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A forced detach inside ensureAttached's re-attach loop fires chrome.debugger.onDetach, whose handler deletes the tab's armed networkCaptures state; the detach also disables the CDP Network domain, and re-attach only re-issued Runtime.enable. So any non-navigate command that triggered a re-attach (a stale-attach health-check failure during SPA navigation, or third-party debugger interference) left network-capture-read returning [] even though requests fired — the recorded "0 captures" symptom. Snapshot the capture before the re-attach and, on success, re-enable the Network domain and restore the accumulated state (restored last so it wins over the onDetach handler's delete). Adds a regression test that fails without the restore. --- extension/dist/background.js | 8 ++++ extension/src/cdp.test.ts | 72 ++++++++++++++++++++++++++++++++++++ extension/src/cdp.ts | 24 ++++++++++++ 3 files changed, 104 insertions(+) diff --git a/extension/dist/background.js b/extension/dist/background.js index 31f893d1f..a41eb4bf8 100644 --- a/extension/dist/background.js +++ b/extension/dist/background.js @@ -41,6 +41,7 @@ async function ensureAttached(tabId, aggressiveRetry = false) { const MAX_ATTACH_RETRIES = aggressiveRetry ? 5 : 2; const RETRY_DELAY_MS = aggressiveRetry ? 1500 : 500; let lastError = ""; + const preservedNetworkCapture = networkCaptures.get(tabId); for (let attempt = 1; attempt <= MAX_ATTACH_RETRIES; attempt++) { try { try { @@ -85,6 +86,13 @@ async function ensureAttached(tabId, aggressiveRetry = false) { await chrome.debugger.sendCommand({ tabId }, "Runtime.enable"); } catch { } + if (preservedNetworkCapture) { + try { + await chrome.debugger.sendCommand({ tabId }, "Network.enable"); + networkCaptures.set(tabId, preservedNetworkCapture); + } catch { + } + } } async function evaluate(tabId, expression, aggressiveRetry = false) { const MAX_EVAL_RETRIES = aggressiveRetry ? 3 : 2; diff --git a/extension/src/cdp.test.ts b/extension/src/cdp.test.ts index 2eeb9a716..d71922e40 100644 --- a/extension/src/cdp.test.ts +++ b/extension/src/cdp.test.ts @@ -393,3 +393,75 @@ describe('cdp download waits', () => { }); }); }); + +describe('cdp network capture survives forced re-attach', () => { + beforeEach(() => { + vi.resetModules(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + function createReattachMock() { + const onDetachListeners: Array<(source: { tabId?: number }) => void> = []; + let failNextHealthCheck = false; + let networkEnableCount = 0; + const debuggerApi = { + attach: vi.fn(async () => {}), + detach: vi.fn(async ({ tabId }: { tabId?: number }) => { + // Chrome fires onDetach whenever the debugger detaches from a tab. + for (const fn of onDetachListeners) fn({ tabId }); + }), + sendCommand: vi.fn(async (_target: unknown, method: string, params?: any) => { + if (method === 'Runtime.evaluate' && params?.expression === '1') { + if (failNextHealthCheck) { + failNextHealthCheck = false; + throw new Error('Inspected target navigated or closed'); + } + return { result: { value: '1' } }; + } + if (method === 'Network.enable') { + networkEnableCount += 1; + return {}; + } + return {}; + }), + onDetach: { addListener: vi.fn((fn: (s: { tabId?: number }) => void) => { onDetachListeners.push(fn); }) }, + onEvent: { addListener: vi.fn() }, + }; + const tabs = { + get: vi.fn(async () => ({ id: 1, windowId: 1, url: 'https://x.com/home' })), + onRemoved: { addListener: vi.fn() }, + onUpdated: { addListener: vi.fn() }, + }; + return { + chrome: { tabs, debugger: debuggerApi, scripting: {}, runtime: { id: 'opencli-test' } }, + debuggerApi, + failNextHealthCheck: () => { failNextHealthCheck = true; }, + networkEnableCount: () => networkEnableCount, + }; + } + + it('preserves armed network capture and re-enables Network across a forced re-attach', async () => { + const mock = createReattachMock(); + vi.stubGlobal('chrome', mock.chrome); + + const mod = await import('./cdp'); + // Wire the onDetach handler that wipes networkCaptures on detach. + mod.registerListeners(); + + await mod.startNetworkCapture(1); + expect(mod.hasActiveNetworkCapture(1)).toBe(true); + const enablesAfterStart = mock.networkEnableCount(); + + // The next ensureAttached health-check throws, forcing a detach + re-attach. + // The detach fires onDetach (which deletes the capture) and disables the + // Network domain — the capture must be restored, not silently dropped. + mock.failNextHealthCheck(); + await mod.ensureAttached(1); + + expect(mod.hasActiveNetworkCapture(1)).toBe(true); + expect(mock.networkEnableCount()).toBeGreaterThan(enablesAfterStart); + }); +}); diff --git a/extension/src/cdp.ts b/extension/src/cdp.ts index b292f516b..493434e99 100644 --- a/extension/src/cdp.ts +++ b/extension/src/cdp.ts @@ -101,6 +101,15 @@ export async function ensureAttached(tabId: number, aggressiveRetry: boolean = f const RETRY_DELAY_MS = aggressiveRetry ? 1500 : 500; let lastError = ''; + // The forced detach below fires chrome.debugger.onDetach, whose handler wipes + // this tab's armed network-capture state; detaching also disables the CDP + // Network domain. Snapshot the capture so we can restore it after a successful + // re-attach instead of silently dropping in-flight capture — otherwise any + // non-navigate command that triggers a re-attach (a stale-attach health-check + // failure during SPA navigation or third-party debugger interference) leaves + // network-capture-read returning [] even though requests fired. + const preservedNetworkCapture = networkCaptures.get(tabId); + for (let attempt = 1; attempt <= MAX_ATTACH_RETRIES; attempt++) { try { // Force detach first to clear any stale state from other extensions @@ -153,6 +162,21 @@ export async function ensureAttached(tabId: number, aggressiveRetry: boolean = f } catch { // Some pages may not need explicit enable } + + // Restore network capture that the re-attach (detach + onDetach) tore down. + // The detach always disables the CDP Network domain, so re-enable it and put + // the accumulated capture state back unconditionally. Done last (after the + // awaits above) so it wins over the onDetach handler's delete, which fires + // while those awaits yield to the event loop. + if (preservedNetworkCapture) { + try { + await chrome.debugger.sendCommand({ tabId }, 'Network.enable'); + networkCaptures.set(tabId, preservedNetworkCapture); + } catch { + // Leave capture cleared rather than arm a half-attached Network domain; + // the next start-capture re-arms cleanly. + } + } } export async function evaluate(tabId: number, expression: string, aggressiveRetry: boolean = false): Promise { From 215a73dc562f70c2b301aaf1bc465ab0b50d775d Mon Sep 17 00:00:00 2001 From: lizkaiman <43192006+bailob@users.noreply.github.com> Date: Wed, 1 Jul 2026 01:33:43 +0800 Subject: [PATCH 09/27] feat(gemini): add model and thinking selection (#2044) * feat(gemini): add model and thinking selection Co-authored-by: multica-agent * fix(gemini): harden model selection contracts --------- Co-authored-by: coder-SOTA-hm Co-authored-by: multica-agent Co-authored-by: jackwener --- cli-manifest.json | 32 + clis/gemini/ask.js | 227 +++++- clis/gemini/ask.test.js | 1163 ++++++++++++++++++++++++++++++- clis/gemini/models.js | 498 +++++++++++++ clis/gemini/models.test.js | 645 +++++++++++++++++ clis/gemini/utils.js | 633 +++++++++++++++++ clis/gemini/utils.test.js | 228 +++++- docs/adapters/browser/gemini.md | 42 +- 8 files changed, 3463 insertions(+), 5 deletions(-) create mode 100644 clis/gemini/models.js create mode 100644 clis/gemini/models.test.js diff --git a/cli-manifest.json b/cli-manifest.json index 065fd14a4..76bf61a82 100644 --- a/cli-manifest.json +++ b/cli-manifest.json @@ -13662,6 +13662,12 @@ "positional": true, "help": "Prompt to send" }, + { + "name": "model", + "type": "string", + "required": false, + "help": "Gemini model to use (e.g. \"2.5-flash\"). Use \"opencli gemini models\" to list available values." + }, { "name": "timeout", "type": "int", @@ -13675,6 +13681,13 @@ "default": "false", "required": false, "help": "Start a new chat first (true/false, default: false)" + }, + { + "name": "thinking", + "type": "str", + "default": null, + "required": false, + "help": "Thinking level: standard or extended (omitted = leave unchanged)" } ], "columns": [ @@ -13930,6 +13943,25 @@ "siteSession": "persistent", "defaultWindowMode": "foreground" }, + { + "site": "gemini", + "name": "models", + "description": "List available Gemini models from the web UI", + "access": "read", + "domain": "gemini.google.com", + "strategy": "cookie", + "browser": true, + "args": [], + "columns": [ + "model", + "thinkingValues" + ], + "type": "js", + "modulePath": "gemini/models.js", + "sourceFile": "gemini/models.js", + "navigateBefore": false, + "siteSession": "persistent" + }, { "site": "gemini", "name": "new", diff --git a/clis/gemini/ask.js b/clis/gemini/ask.js index f87b8070e..14078a063 100644 --- a/clis/gemini/ask.js +++ b/clis/gemini/ask.js @@ -1,6 +1,18 @@ import { cli, Strategy } from '@jackwener/opencli/registry'; -import { ArgumentError } from '@jackwener/opencli/errors'; -import { GEMINI_DOMAIN, readGeminiSnapshot, sendGeminiMessage, startNewGeminiChat, waitForGeminiResponse, waitForGeminiSubmission } from './utils.js'; +import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors'; +import { + GEMINI_DOMAIN, + ensureGeminiPage, + getCurrentGeminiModel, + readGeminiSnapshot, + selectGeminiModel, + selectGeminiThinking, + sendGeminiMessage, + startNewGeminiChat, + waitForGeminiResponse, + waitForGeminiSubmission, +} from './utils.js'; +import { pickModelPickerScript, readMenuModelsScript } from './models.js'; function normalizeBooleanFlag(value) { if (typeof value === 'boolean') return value; @@ -8,6 +20,62 @@ function normalizeBooleanFlag(value) { return normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on'; } const NO_RESPONSE_PREFIX = '[NO RESPONSE]'; + +/** + * Validate a --model value for gemini ask. + * Throws ArgumentError for short aliases or invalid formats. + */ +function unwrapBrowserBridgeEnvelope(value) { + if (value && typeof value === 'object' && !Array.isArray(value) && 'session' in value) { + if ('data' in value) return value.data; + throw new CommandExecutionError('Gemini model discovery returned a malformed Browser Bridge envelope'); + } + return value; +} + +function requireDiscoveredModels(value) { + const unwrapped = unwrapBrowserBridgeEnvelope(value); + if (!Array.isArray(unwrapped)) { + throw new CommandExecutionError('Gemini model discovery returned a malformed result'); + } + for (const row of unwrapped) { + if (!row || typeof row.model !== 'string' || !Array.isArray(row.thinkingValues)) { + throw new CommandExecutionError('Gemini model discovery returned a malformed row'); + } + } + return unwrapped; +} + +function validateAskModelValue(value) { + if (!value) { + throw new ArgumentError( + '--model requires a canonical model id (e.g. "2.5-flash"). ' + + 'Use "opencli gemini models" to list available values.' + ); + } + // Reject short aliases like "pro", "flash", "flash-lite" that lack a version number. + if (!/\d+\.\d+/.test(value)) { + throw new ArgumentError( + '--model "' + value + '" is not accepted. ' + + 'Short aliases like "pro", "flash", or "flash-lite" are not supported. ' + + 'Use a canonical model id (e.g. "2.5-flash"). ' + + 'Use "opencli gemini models" to list available values.' + ); + } + // Must match canonical format: X.Y-variant + if (!/^\d+\.\d+-[a-z][a-z-]*$/.test(value)) { + throw new ArgumentError( + '--model "' + value + '" is not a valid canonical model id. ' + + 'Expected format: version-variant (e.g. "2.5-flash", "3.1-pro"). ' + + 'Use "opencli gemini models" to list available values.' + ); + } +} + +export const __test__ = { + validateAskModelValue, +}; + export const askCommand = cli({ site: 'gemini', name: 'ask', @@ -21,8 +89,10 @@ export const askCommand = cli({ defaultFormat: 'plain', args: [ { name: 'prompt', required: true, positional: true, help: 'Prompt to send' }, + { name: 'model', type: 'string', required: false, help: 'Gemini model to use (e.g. "2.5-flash"). Use "opencli gemini models" to list available values.' }, { name: 'timeout', type: 'int', required: false, help: 'Max seconds to wait (default: 60)', default: 60 }, { name: 'new', required: false, help: 'Start a new chat first (true/false, default: false)', default: 'false' }, + { name: 'thinking', required: false, help: 'Thinking level: standard or extended (omitted = leave unchanged)', default: null }, ], columns: ['response'], func: async (page, kwargs) => { @@ -31,9 +101,162 @@ export const askCommand = cli({ if (!Number.isInteger(timeout) || timeout < 1) { throw new ArgumentError('--timeout must be a positive integer (seconds)'); } + + // ── New chat (must happen before model/thinking selection) ────── const startFresh = normalizeBooleanFlag(kwargs.new); if (startFresh) await startNewGeminiChat(page); + + // ── Early validation: model format & thinking value ──────────── + const hasModel = kwargs.model !== undefined && kwargs.model !== null; + const hasThinking = kwargs.thinking != null; + + let modelValue = null; + if (hasModel) { + modelValue = String(kwargs.model).trim(); + validateAskModelValue(modelValue); + } + + if (hasThinking) { + const thinkingRaw = String(kwargs.thinking ?? '').trim(); + const thinkingValue = thinkingRaw.toLowerCase(); + if (thinkingValue !== 'standard' && thinkingValue !== 'extended') { + throw new ArgumentError( + `--thinking must be 'standard' or 'extended', got '${thinkingRaw}'`, + 'Run `opencli gemini models` to see available thinking levels.', + ); + } + } + + // ── Model and thinking discovery (shared by both model and + // thinking selection) ────────────────────────────────────── + let discoveredModels = null; + if (hasModel || hasThinking) { + await ensureGeminiPage(page); + + // Open the picker menu (click the model-picker button). + const pickerRaw = await page.evaluate(` + (() => { + ${pickModelPickerScript()} + const picker = findModelPicker(); + if (!picker) return { ok: false, reason: 'Gemini model picker button was not found' }; + try { picker.click(); } catch (_) { return { ok: false, reason: 'Failed to click Gemini model picker button' }; } + return { ok: true }; + })() + `); + const pickerResult = unwrapBrowserBridgeEnvelope(pickerRaw); + if (!pickerResult || typeof pickerResult !== 'object' || !pickerResult.ok) { + throw new CommandExecutionError( + pickerResult?.reason || 'Failed to open Gemini model picker for model discovery' + ); + } + + // Wait for React to render the menu. + await page.wait(1.0); + + // Read model entries from the open menu. + const raw = await page.evaluate(readMenuModelsScript()); + discoveredModels = requireDiscoveredModels(raw); + + // Close the menu. Thinking support is intentionally not copied from + // the currently-visible UI into every model row: Gemini exposes + // thinking controls for the current selection, not a reliable + // per-model capability matrix. + await page.evaluate(`(() => { try { document.body.click(); } catch (_) {} })()`); + } + + // ── Model selection ────────────────────────────────────────────── + if (hasModel) { + const availableModels = discoveredModels || []; + if (!Array.isArray(availableModels) || availableModels.length === 0) { + throw new CommandExecutionError( + 'Gemini model discovery returned no selectable models. Gemini Web may have changed its model selector UI.' + ); + } + const availableIds = availableModels.map((m) => m?.model).filter(Boolean); + if (!availableIds.includes(modelValue)) { + throw new ArgumentError( + 'Unknown model "' + modelValue + '". ' + + 'Available models: ' + availableIds.join(', ') + '. ' + + 'Use "opencli gemini models" to see available values.' + ); + } + await selectGeminiModel(page, modelValue); + } + + // ── Thinking validation and selection ────────────────────────── + if (hasThinking) { + // Reuse the pre-discovered models from the shared discovery call. + const discovered = discoveredModels || []; + + // When --model was supplied, scope thinking to the selected model; + // otherwise scope to the current model from the web UI. + let targetModelId; + if (hasModel) { + targetModelId = modelValue; + } else { + targetModelId = await getCurrentGeminiModel(page); + } + + // Build a map of model → thinkingValues for lookup. + const modelThinkingMap = new Map(); + const allThinking = new Set(); + for (const entry of discovered) { + if (entry && typeof entry.model === 'string' && Array.isArray(entry.thinkingValues)) { + const tvs = entry.thinkingValues.filter((tv) => typeof tv === 'string'); + if (tvs.length > 0) { + modelThinkingMap.set(entry.model, tvs); + for (const tv of tvs) allThinking.add(tv); + } + } + } + + // Resolve thinkingValue from early validation. + const thinkingValue = String(kwargs.thinking ?? '').trim().toLowerCase(); + + // Validate: if we can identify the target model, scope to its + // thinking values; otherwise fall back to the union across all models. + const targetModelThinking = + targetModelId && modelThinkingMap.has(targetModelId) + ? modelThinkingMap.get(targetModelId) + : null; + + if (targetModelThinking) { + // Scoped to the target model. + if (!targetModelThinking.includes(thinkingValue)) { + const availableForModel = targetModelThinking.sort().join(', '); + throw new ArgumentError( + `--thinking '${thinkingValue}' is not available for the ${hasModel ? 'selected' : 'current'} model ('${targetModelId}')`, + `Model '${targetModelId}' supports: ${availableForModel}. Run \`opencli gemini models\` for all models.`, + ); + } + } else if (allThinking.size > 0 && !allThinking.has(thinkingValue)) { + // Union fallback when the target model cannot be identified. + const availableList = [...allThinking].sort().join(', '); + throw new ArgumentError( + `--thinking '${thinkingValue}' is not currently available`, + `Available thinking values: ${availableList}. Run \`opencli gemini models\` for details.`, + ); + } + + // Select the requested thinking level before snapshot. + const selected = await selectGeminiThinking(page, thinkingValue); + if (!selected) { + // Build an informative hint from what we know. + const hintParts = []; + if (targetModelThinking && targetModelThinking.length > 0) { + hintParts.push(`Model '${targetModelId}' supports: ${targetModelThinking.sort().join(', ')}.`); + } else if (allThinking.size > 0) { + hintParts.push(`Available thinking values: ${[...allThinking].sort().join(', ')}.`); + } + hintParts.push('Run `opencli gemini models` for details.'); + throw new ArgumentError( + `Could not select thinking level '${thinkingValue}' in the Gemini web UI`, + hintParts.join(' '), + ); + } + } + const before = await readGeminiSnapshot(page); await sendGeminiMessage(page, prompt); const submissionStartedAt = Date.now(); diff --git a/clis/gemini/ask.test.js b/clis/gemini/ask.test.js index a22227a76..7ea634f6d 100644 --- a/clis/gemini/ask.test.js +++ b/clis/gemini/ask.test.js @@ -1,4 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors'; + const baseline = { turns: [{ Role: 'Assistant', Text: '旧回答' }], transcriptLines: ['baseline'], @@ -21,25 +23,66 @@ const submission = { 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, }; }); -import { askCommand } from './ask.js'; + +// 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), @@ -66,35 +109,1153 @@ function createPageMock() { 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); + }); }); diff --git a/clis/gemini/models.js b/clis/gemini/models.js new file mode 100644 index 000000000..061c38c58 --- /dev/null +++ b/clis/gemini/models.js @@ -0,0 +1,498 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { CommandExecutionError } from '@jackwener/opencli/errors'; +import { GEMINI_DOMAIN, ensureGeminiPage } from './utils.js'; + +function isObjectRecord(value) { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function unwrapBrowserBridgeEnvelope(value, context) { + if (isObjectRecord(value) && Object.prototype.hasOwnProperty.call(value, 'session')) { + if (Object.prototype.hasOwnProperty.call(value, 'data')) { + return value.data; + } + throw new CommandExecutionError(`${context} returned a malformed Browser Bridge envelope`); + } + return value; +} + +// ── Shared helpers (embedded in browser scripts) ────────────────────────── + +function sharedHelpers() { + return ` + const isVisible = (el) => { + if (!el) return false; + if (!(el instanceof HTMLElement) && !(el instanceof Element)) return false; + if (el.hidden || el.closest('[hidden]')) return false; + const ariaHidden = el.getAttribute('aria-hidden'); + if (ariaHidden && ariaHidden.toLowerCase() === 'true') return false; + if (el.closest('[aria-hidden="true"]')) return false; + const style = window.getComputedStyle(el); + if (style.display === 'none' || style.visibility === 'hidden') return false; + if (Number(style.opacity) === 0 || style.pointerEvents === 'none') return false; + const rect = el.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }; + + const normalize = (value) => String(value || '').replace(/\\s+/g, ' ').trim().toLowerCase(); + const VERSION_LABEL_RE = /\\d+\\.\\d+/; + `; +} + +function findModelPickerLogic() { + return ` + // Patterns for detecting model/mode selector buttons by aria-label. + const MODE_SELECTOR_PATTERNS = [ + /模式选择器/i, + /mode[\\s-]*selector/i, + /model[\\s-]*selector/i, + /model[\\s-]*picker/i, + /选择模型/i, + /select[\\s-]+model/i, + /choose[\\s-]+model/i, + /switch[\\s-]+model/i, + /change[\\s-]+model/i, + ]; + + const findModelPicker = () => { + const buttons = Array.from( + document.querySelectorAll('button, [role="button"]') + ).filter(isVisible); + + // Method 1: Detect model/mode selector via aria-label patterns. + for (const button of buttons) { + const aria = normalize(button.getAttribute('aria-label') || ''); + for (const pattern of MODE_SELECTOR_PATTERNS) { + if (pattern.test(aria)) return button; + } + } + + // Method 2: Detect buttons whose text contains a model-version pattern. + const versionCandidates = buttons.filter((b) => { + const text = normalize(b.textContent || '') || normalize(b.getAttribute('aria-label') || ''); + return VERSION_LABEL_RE.test(text) && text.length < 80; + }); + versionCandidates.sort((a, b) => { + const aRect = a.getBoundingClientRect(); + const bRect = b.getBoundingClientRect(); + return aRect.top - bRect.top || aRect.left - bRect.left; + }); + if (versionCandidates.length > 0) return versionCandidates[0]; + + // Method 3: Detect buttons showing a known model variant as their + // sole text (e.g. "Pro", "Flash", "Flash-Lite"). + const MODEL_VARIANT_RE = /^(?:gemini\\s+)?(flash|lite|pro|ultra|nano|flash-lite|flash[\\s-]*thinking)$/i; + const variantCandidates = buttons.filter((b) => { + const text = normalize(b.textContent || ''); + return MODEL_VARIANT_RE.test(text) && text.length < 30; + }); + variantCandidates.sort((a, b) => { + const aRect = a.getBoundingClientRect(); + const bRect = b.getBoundingClientRect(); + return aRect.top - bRect.top || aRect.left - bRect.left; + }); + if (variantCandidates.length > 0) return variantCandidates[0]; + + // Method 4: Fallback — look for any element with model-related attributes. + const attrEls = Array.from( + document.querySelectorAll('[data-model-selector], [aria-label*="model" i], [aria-label*="模式" i]') + ).filter(isVisible); + if (attrEls.length > 0) return attrEls[0]; + + return null; + }; + `; +} + +function modelParsingLogic() { + return ` + /** + * Best-effort extraction of a canonical model id from display text. + * Handles patterns like "3.1 flash-lite", "3.5 flash", "3.1 pro", + * "2.5-flash-thinking", "gemini 3.0 pro experimental", and entries with + * trailing Chinese descriptions (e.g. "3.1 Flash-Lite 极速回答"). + */ + const canonicalModelId = (raw) => { + const text = normalize(raw); + if (!text) return ''; + + // Remove leading decorative characters (e.g. checkmark icons). + const cleaned = text.replace(/^[^a-z0-9]+/i, '').trim(); + + // Known model-version patterns: "X.Y variant" or "X.Y-variant". + const versionRe = /^((?:gemini[\\s-]*)?(\\d+(?:\\.\\d+)?)(?:[\\s-]+(flash|pro|lite|ultra|nano|thinking|experimental))(?:[\\s-]*(flash|pro|lite|ultra|nano|thinking|experimental))?)/i; + const match = cleaned.match(versionRe); + if (match) { + const version = match[2]; + let variant = (match[3] || '').toLowerCase(); + const extra = (match[4] || '').toLowerCase(); + if (extra) variant = variant + '-' + extra; + return version + '-' + variant; + } + + // Fallback: if the text contains a version number and a known keyword. + const fallbackRe = /(\\d+(?:\\.\\d+)?)\\s*(flash|pro|lite|ultra|nano|thinking|experimental)/i; + const fallbackMatch = cleaned.match(fallbackRe); + if (fallbackMatch) { + return fallbackMatch[1] + '-' + fallbackMatch[2].toLowerCase(); + } + + // For experimental / custom models: use a cleaned version of the label. + if (/\\d+(?:\\.\\d+)?/.test(cleaned) && cleaned.length < 60) { + return cleaned.replace(/\\s+/g, '-').replace(/[^a-z0-9.-]/g, ''); + } + + return ''; + }; + `; +} + +function thinkingExtractionLogic() { + return ` + /** + * Extract thinking values from the full set of visible menu items. + * Gemini Web shows thinking levels as separate menu items + * (e.g. "标准 最适合回答大多数问题", "扩展 擅长解决复杂问题"). + * Returns a deduplicated, stable-order array. + */ + const extractAllThinkingValues = (menuItems) => { + const found = new Map(); + + const THINKING_PATTERNS = [ + { value: 'standard', patterns: [/\\bstandard\\b/i, /标准/i], priority: 1 }, + { value: 'extended', patterns: [/\\bextended\\b/i, /扩展/i], priority: 2 }, + ]; + + for (const item of menuItems) { + const itemText = normalize(item.textContent || ''); + const itemAria = normalize(item.getAttribute('aria-label') || ''); + const combined = itemText + ' ' + itemAria; + + // Skip model entries (contain a version like "3.1"). + if (/\\d+\\.\\d+/.test(combined)) continue; + // Skip the thinking-section header itself (e.g. "思考等级"). + if (/^思考等级|^thinking level|^thinking mode/i.test(combined.trim())) continue; + + for (const { value, patterns } of THINKING_PATTERNS) { + for (const re of patterns) { + if (re.test(combined)) { + found.set(value, true); + break; + } + } + } + } + + const result = []; + for (const { value } of THINKING_PATTERNS) { + if (found.has(value)) result.push(value); + } + return result; + }; + `; +} + +function menuEnumerationLogic() { + return ` + const MENU_SELECTORS = [ + '[role="menu"] [role="menuitem"]', + '[role="menu"] [role="menuitemradio"]', + '[role="listbox"] [role="option"]', + '[role="menu"] button', + '[role="listbox"] button', + '[role="menu"] li', + '[role="listbox"] li', + '[role="dialog"] [role="menuitem"]', + '[role="dialog"] [role="option"]', + '[aria-modal="true"] [role="menuitem"]', + '[aria-modal="true"] [role="option"]', + 'gem-menu-item', + 'GEM-MENU-ITEM', + '[role="menu"] gem-menu-item', + '[role="menu"] GEM-MENU-ITEM', + '[role="listbox"] gem-menu-item', + '[role="listbox"] GEM-MENU-ITEM', + '[role="menu"] :not(script):not(style)', + '[role="listbox"] :not(script):not(style)', + ]; + + const findVisibleMenuItems = () => { + let menuItems = []; + for (const sel of MENU_SELECTORS) { + const items = Array.from(document.querySelectorAll(sel)).filter(isVisible); + if (items.length >= 2) { menuItems = items; break; } + } + + if (menuItems.length === 0) { + const containers = Array.from( + document.querySelectorAll('[role="menu"], [role="listbox"], [role="dialog"], [aria-modal="true"]') + ).filter(isVisible); + for (const container of containers) { + const children = Array.from( + container.querySelectorAll('button, [role="button"], li, [role="menuitem"], [role="option"], gem-menu-item, GEM-MENU-ITEM, :not(script):not(style)') + ).filter(isVisible); + if (children.length >= 2) { menuItems = children; break; } + } + } + + return menuItems; + }; + `; +} + +function readMenuAndCloseLogic() { + return ` + const menuItems = findVisibleMenuItems(); + + // ── Parse models from menu items ────────────────────────────────── + const results = []; + const seen = new Set(); + + for (const item of menuItems) { + if (!item || (!(item instanceof HTMLElement) && !(item instanceof Element))) continue; + const itemText = (item.textContent || '').replace(/\\s+/g, ' ').trim(); + const modelId = canonicalModelId(itemText); + if (!modelId) continue; + if (seen.has(modelId)) continue; + seen.add(modelId); + + results.push({ model: modelId, thinkingValues: [] }); + } + + // Guard: only thinking options, no model entries → bail out. + const hasModelEntries = results.some((r) => { + return /\\d+\\.\\d+/.test(r.model) || /flash|pro|lite/i.test(r.model); + }); + if (!hasModelEntries) { + try { document.body.click(); } catch (_) {} + return []; + } + + // Do not copy the thinking controls visible for the current Gemini UI + // selection onto every model row. They are not a reliable per-model + // capability matrix, so rows only report thinkingValues when a future UI + // exposes them directly on that model entry. + + // ── Close the menu ──────────────────────────────────────────────── + try { document.body.click(); } catch (_) {} + + return results; + `; +} + +// ── Exported script builders ────────────────────────────────────────────── + +/** + * Complete discovery script that finds the model picker, opens it, + * reads model rows, and closes the menu. + * Used by gemini ask for model/thinking discovery. + * Uses .click() to trigger React event handlers on Gemini Web. + */ +export function discoverModelsScript() { + return ` + (() => { + ${sharedHelpers()} + ${findModelPickerLogic()} + ${modelParsingLogic()} + ${thinkingExtractionLogic()} + ${menuEnumerationLogic()} + + const picker = findModelPicker(); + if (!picker) return []; + + // Use native .click() — Gemini Web is a React app and synthetic + // event dispatch may not trigger React's event handlers. + try { picker.click(); } catch (_) { throw new Error('Failed to click Gemini model picker button'); } + + ${readMenuAndCloseLogic()} + })() + `; +} + +/** + * Script that only finds and clicks the model-picker button. + * Does NOT read the menu — only locates and clicks the picker. + */ +export function pickModelPickerScript() { + return `${sharedHelpers()}${findModelPickerLogic()}`; +} + +/** + * Script that reads model rows from an already-open Gemini Web + * model-picker menu, then closes the menu. + */ +export function readMenuScript() { + return ` + (() => { + ${sharedHelpers()} + ${modelParsingLogic()} + ${thinkingExtractionLogic()} + ${menuEnumerationLogic()} + ${readMenuAndCloseLogic()} + })() + `; +} + +/** + * Script that reads model entries from an open menu (no thinking extraction, + * no menu close). Returns {models: [...], hasThinkingToggle: bool}. + */ +export function readMenuModelsScript() { + return ` + (() => { + ${sharedHelpers()} + ${modelParsingLogic()} + ${menuEnumerationLogic()} + + const menuItems = findVisibleMenuItems(); + + const results = []; + const seen = new Set(); + + for (const item of menuItems) { + if (!item || (!(item instanceof HTMLElement) && !(item instanceof Element))) continue; + const itemText = (item.textContent || '').replace(/\\s+/g, ' ').trim(); + + const modelId = canonicalModelId(itemText); + if (!modelId) continue; + if (seen.has(modelId)) continue; + seen.add(modelId); + + results.push({ model: modelId, thinkingValues: [] }); + } + + const hasModelEntries = results.some((r) => { + return /\\d+\\.\\d+/.test(r.model) || /flash|pro|lite/i.test(r.model); + }); + if (!hasModelEntries) return []; + + return results; + })() + `; +} + +/** + * Script that clicks the "思考等级" / "Thinking level" toggle in the + * currently-open menu to expand thinking options. + * Returns true if the toggle was found and clicked. + */ +export function clickThinkingToggleScript() { + return ` + (() => { + const isVisible = (el) => { + if (!el) return false; + if (!(el instanceof HTMLElement) && !(el instanceof Element)) return false; + if (el.hidden || el.closest('[hidden]')) return false; + const style = window.getComputedStyle(el); + if (style.display === 'none' || style.visibility === 'hidden') return false; + const rect = el.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }; + + const menuItems = Array.from( + document.querySelectorAll('[role="menuitem"], gem-menu-item, GEM-MENU-ITEM') + ).filter(isVisible); + + const thinkingToggle = menuItems.find((item) => { + const text = (item.textContent || '').trim(); + return /思考等级|thinking level|thinking mode/i.test(text); + }); + + if (thinkingToggle) { + try { thinkingToggle.click(); } catch (_) { return false; } + return true; + } + return false; + })() + `; +} + +/** + * Script that extracts thinking values from the currently-open menu. + * Looks for menu items whose text matches known thinking-level strings. + */ +export function extractThinkingScript() { + return ` + (() => { + ${sharedHelpers()} + ${thinkingExtractionLogic()} + ${menuEnumerationLogic()} + + const menuItems = findVisibleMenuItems(); + return extractAllThinkingValues(menuItems); + })() + `; +} + +export const __test__ = { + discoverModelsScript, + pickModelPickerScript, + readMenuScript, + readMenuModelsScript, + clickThinkingToggleScript, + extractThinkingScript, +}; + +// ── Command registration ────────────────────────────────────────────────── + +export const modelsCommand = cli({ + site: 'gemini', + name: 'models', + access: 'read', + description: 'List available Gemini models from the web UI', + domain: GEMINI_DOMAIN, + strategy: Strategy.COOKIE, + browser: true, + siteSession: 'persistent', + navigateBefore: false, + args: [], + columns: ['model', 'thinkingValues'], + func: async (page) => { + await ensureGeminiPage(page); + + // Step 1: Open the picker menu (click the model-picker button). + const pickerRaw = await page.evaluate(` + (() => { + ${pickModelPickerScript()} + const picker = findModelPicker(); + if (!picker) return { ok: false, reason: 'Gemini model picker button was not found' }; + try { picker.click(); } catch (_) { return { ok: false, reason: 'Failed to click Gemini model picker button' }; } + return { ok: true }; + })() + `); + const pickerResult = unwrapBrowserBridgeEnvelope(pickerRaw, 'Gemini model picker'); + if (!pickerResult || !pickerResult.ok) { + throw new CommandExecutionError( + (pickerResult && pickerResult.reason) + ? `${pickerResult.reason}. Gemini Web may have changed its model selector UI.` + : 'Failed to open Gemini model picker. Gemini Web may have changed its model selector UI.' + ); + } + + // Step 2: Wait for React to render the menu. + await page.wait(1.0); + + // Step 3: Read model entries from the open menu. + const raw = await page.evaluate(readMenuModelsScript()); + const result = unwrapBrowserBridgeEnvelope(raw, 'Gemini models discovery'); + + if (!Array.isArray(result)) { + throw new CommandExecutionError('Gemini models discovery returned unexpected data'); + } + for (const row of result) { + if (!row || typeof row.model !== 'string' || !Array.isArray(row.thinkingValues)) { + throw new CommandExecutionError('Gemini models discovery returned a malformed row'); + } + } + + // Step 4: Close the menu. The current Gemini UI can show thinking + // controls for the active model, but that is not a reliable per-model + // support matrix, so the command does not copy those values to every + // model row. + + // Step 5: Close the menu. + await page.evaluate(`(() => { try { document.body.click(); } catch (_) {} })()`); + + return result; + }, +}); diff --git a/clis/gemini/models.test.js b/clis/gemini/models.test.js new file mode 100644 index 000000000..28fa11879 --- /dev/null +++ b/clis/gemini/models.test.js @@ -0,0 +1,645 @@ +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(`${htmlFixture}`, { + 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(` +
+ + +
+ + `); + + // 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(` +
+ +
+ + `); + + 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(` + + + `); + + 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(` + + + `); + + 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(` +
+ + +
+
+
Theme
+
+ `); + + 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(` + + + `); + + 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(` + + + `); + + 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(` + + + +
+
Enter a prompt
+ +
+ `); + + // 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(` + + + `); + + 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(` + + + + `); + + 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(` + + +
+
+ + +
+ `); + + 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(` + + + + +
+ +
+ `); + + 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); + }); +}); diff --git a/clis/gemini/utils.js b/clis/gemini/utils.js index b8173e106..56ad8986b 100644 --- a/clis/gemini/utils.js +++ b/clis/gemini/utils.js @@ -1842,6 +1842,468 @@ export async function exportGeminiDeepResearchReport(page, timeoutSeconds = 120) : await getCurrentGeminiUrl(page); return pickGeminiDeepResearchExportUrl(urls, currentUrl); } +// ── Thinking-level selection ───────────────────────────────────────── + +/** + * Browser evaluate script that opens the Gemini model-picker menu. + * Returns { ok: true } on success, { ok: false } on failure. + */ +function openModelPickerForThinkingScript() { + return ` + (() => { + const isVisible = (el) => { + if (!(el instanceof HTMLElement)) return false; + if (el.hidden || el.closest('[hidden]')) return false; + const ariaHidden = el.getAttribute('aria-hidden'); + if (ariaHidden && ariaHidden.toLowerCase() === 'true') return false; + if (el.closest('[aria-hidden="true"]')) return false; + const style = window.getComputedStyle(el); + if (style.display === 'none' || style.visibility === 'hidden') return false; + if (Number(style.opacity) === 0 || style.pointerEvents === 'none') return false; + const rect = el.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }; + + const normalize = (value) => String(value || '').replace(/\\s+/g, ' ').trim().toLowerCase(); + const VERSION_LABEL_RE = /\\d+\\.\\d+/; + + const MODE_SELECTOR_PATTERNS = [ + /模式选择器/i, + /mode[\\s-]*selector/i, + /model[\\s-]*selector/i, + /model[\\s-]*picker/i, + /选择模型/i, + /select[\\s-]+model/i, + /choose[\\s-]+model/i, + /switch[\\s-]+model/i, + /change[\\s-]+model/i, + ]; + + const MODEL_VARIANT_RE = /^(?:gemini\\s+)?(flash|lite|pro|ultra|nano|flash-lite|flash[\\s-]*thinking)$/i; + + const findModelPicker = () => { + const buttons = Array.from( + document.querySelectorAll('button, [role="button"]') + ).filter(isVisible); + + // Method 1: Detect model/mode selector via aria-label patterns. + for (const button of buttons) { + const aria = normalize(button.getAttribute('aria-label') || ''); + for (const pattern of MODE_SELECTOR_PATTERNS) { + if (pattern.test(aria)) return button; + } + } + + // Method 2: Detect buttons whose text contains a model-version pattern. + const versionCandidates = buttons.filter((b) => { + const text = normalize(b.textContent || '') || normalize(b.getAttribute('aria-label') || ''); + return VERSION_LABEL_RE.test(text) && text.length < 80; + }); + versionCandidates.sort((a, b) => { + const aRect = a.getBoundingClientRect(); + const bRect = b.getBoundingClientRect(); + return aRect.top - bRect.top || aRect.left - bRect.left; + }); + if (versionCandidates.length > 0) return versionCandidates[0]; + + // Method 3: Detect buttons showing a known model variant as their + // sole text (e.g. "Pro", "Flash", "Flash-Lite"). + const variantCandidates = buttons.filter((b) => { + const text = normalize(b.textContent || ''); + return MODEL_VARIANT_RE.test(text) && text.length < 30; + }); + variantCandidates.sort((a, b) => { + const aRect = a.getBoundingClientRect(); + const bRect = b.getBoundingClientRect(); + return aRect.top - bRect.top || aRect.left - bRect.left; + }); + if (variantCandidates.length > 0) return variantCandidates[0]; + + // Method 4: Fallback — look for any element with model-related attributes. + const attrEls = Array.from( + document.querySelectorAll('[data-model-selector], [aria-label*="model" i], [aria-label*="模式" i]') + ).filter(isVisible); + if (attrEls.length > 0) return attrEls[0]; + + return null; + }; + + const picker = findModelPicker(); + if (!picker) return { ok: false, reason: 'Model picker not found' }; + + try { + picker.click(); + return { ok: true }; + } catch (_) { + return { ok: false, reason: 'Failed to click model picker' }; + } + })() + `; +} + +/** + * Browser evaluate script that clicks the "思考等级" / "Thinking level" + * toggle inside an already-open model-picker menu to expand thinking options. + * Returns true if the toggle was found and clicked. + */ +function clickThinkingToggleInMenuScript() { + return ` + (() => { + const isVisible = (el) => { + if (!el) return false; + if (!(el instanceof HTMLElement) && !(el instanceof Element)) return false; + if (el.hidden || el.closest('[hidden]')) return false; + const style = window.getComputedStyle(el); + if (style.display === 'none' || style.visibility === 'hidden') return false; + const rect = el.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }; + + const MENU_SELECTORS = [ + '[role="menu"] [role="menuitem"]', + '[role="menu"] [role="menuitemradio"]', + '[role="listbox"] [role="option"]', + '[role="menu"] button', + '[role="listbox"] button', + '[role="menu"] li', + '[role="listbox"] li', + '[role="dialog"] [role="menuitem"]', + '[role="dialog"] [role="option"]', + '[aria-modal="true"] [role="menuitem"]', + '[aria-modal="true"] [role="option"]', + 'gem-menu-item', + 'GEM-MENU-ITEM', + ]; + + let menuItems = []; + for (const sel of MENU_SELECTORS) { + const items = Array.from(document.querySelectorAll(sel)).filter(isVisible); + if (items.length >= 2) { menuItems = items; break; } + } + + if (menuItems.length === 0) { + const containers = Array.from( + document.querySelectorAll('[role="menu"], [role="listbox"], [role="dialog"], [aria-modal="true"]') + ).filter(isVisible); + for (const container of containers) { + const children = Array.from( + container.querySelectorAll('button, [role="button"], li, [role="menuitem"], [role="option"], gem-menu-item, GEM-MENU-ITEM, :not(script):not(style)') + ).filter(isVisible); + if (children.length >= 2) { menuItems = children; break; } + } + } + + const thinkingToggle = menuItems.find((item) => { + const text = (item.textContent || '').replace(/\\s+/g, ' ').trim(); + return /思考等级|thinking level|thinking mode/i.test(text); + }); + + if (thinkingToggle) { + try { thinkingToggle.click(); } catch (_) { return false; } + return true; + } + return false; + })() + `; +} + +/** + * Browser evaluate script that selects a specific thinking level from an + * already-open model-picker menu (after the thinking section has been + * expanded). Closes the menu after selection (or on failure). + * + * @param {string} thinkingValue - 'standard' or 'extended' + * @returns a function string for page.evaluate() that returns the matched + * label text, or empty string on failure + */ +function selectThinkingInMenuScript(thinkingValue) { + const normalizedValue = String(thinkingValue || '').trim().toLowerCase(); + if (!normalizedValue) return '(() => "")'; + const labelMap = JSON.stringify({ + standard: ['standard', '标准', '標準'], + extended: ['extended', '扩展', '擴展', '拡張'], + }); + return ` + (() => { + const LABEL_MAP = ${labelMap}; + const candidateLabels = LABEL_MAP['${normalizedValue}'] || ['${normalizedValue}']; + + const isVisible = (el) => { + if (!(el instanceof HTMLElement)) return false; + if (el.hidden || el.closest('[hidden]')) return false; + const ariaHidden = el.getAttribute('aria-hidden'); + if (ariaHidden && ariaHidden.toLowerCase() === 'true') return false; + if (el.closest('[aria-hidden="true"]')) return false; + const style = window.getComputedStyle(el); + if (style.display === 'none' || style.visibility === 'hidden') return false; + if (Number(style.opacity) === 0 || style.pointerEvents === 'none') return false; + const rect = el.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }; + + const normalize = (value) => String(value || '').replace(/\\s+/g, ' ').trim().toLowerCase(); + + const textOf = (node) => [ + node?.textContent || '', + node instanceof HTMLElement ? (node.innerText || '') : '', + node?.getAttribute?.('aria-label') || '', + node?.getAttribute?.('title') || '', + node?.getAttribute?.('data-tooltip') || '', + ].join(' '); + + const matchesThinking = (node) => { + const combined = normalize(textOf(node)); + if (!combined) return false; + return candidateLabels.some((label) => combined.includes(label)); + }; + + // Search inside visible menu containers first. + const containers = Array.from( + document.querySelectorAll('[role="menu"], [role="listbox"], [role="dialog"], [aria-modal="true"]') + ).filter(isVisible); + + const SELECTORS = [ + 'button', '[role="button"]', '[role="menuitem"]', '[role="menuitemradio"]', + '[role="option"]', '[role="radio"]', '[role="switch"]', 'li', + ]; + + let candidates = []; + for (const container of containers) { + for (const sel of SELECTORS) { + const nodes = Array.from(container.querySelectorAll(sel)) + .filter((node) => isVisible(node) && matchesThinking(node)); + candidates.push(...nodes); + } + } + + // Fallback: search the entire page. + if (candidates.length === 0) { + for (const sel of SELECTORS) { + const nodes = Array.from(document.querySelectorAll(sel)) + .filter((node) => isVisible(node) && matchesThinking(node)); + candidates.push(...nodes); + } + } + + if (candidates.length === 0) { + try { document.body.click(); } catch (_) {} + return ''; + } + + // Prefer exact match over substring. + candidates.sort((a, b) => { + const aText = normalize(textOf(a)); + const bText = normalize(textOf(b)); + const aExact = candidateLabels.some((l) => aText === l); + const bExact = candidateLabels.some((l) => bText === l); + if (aExact && !bExact) return -1; + if (!aExact && bExact) return 1; + return 0; + }); + + const target = candidates[0]; + let matchedLabel = ''; + try { + target.click(); + matchedLabel = (target.textContent || '').trim(); + } catch (_) { + try { document.body.click(); } catch (_) {} + return ''; + } + + // Close the menu after successful selection. + try { document.body.click(); } catch (_) {} + + return matchedLabel; + })() + `; +} + +/** + * Select a Gemini thinking level in the web UI. + * + * Multi-step approach matching modelsCommand.func: + * 1. Open the model-picker menu. + * 2. Wait for React to render the menu. + * 3. Click the "思考等级"/"Thinking level" toggle to expand thinking options. + * 4. Wait for the expanded items to render. + * 5. Select the requested thinking level. + * 6. Close the menu. + * + * @param {object} page - Puppeteer page object + * @param {string} thinkingValue - 'standard' or 'extended' + * @returns {Promise} matched label text, or empty string on failure + */ +export async function selectGeminiThinking(page, thinkingValue) { + await ensureGeminiPage(page); + + // Step 1: Open the picker menu. + const pickerRaw = await page.evaluate(openModelPickerForThinkingScript()).catch(() => null); + const pickerResult = unwrapGeminiEvaluateResult(pickerRaw, 'Gemini thinking model picker'); + if (!pickerResult || !pickerResult.ok) return ''; + + // Step 2: Wait for React to render the menu. + await page.wait(0.8); + + // Step 3: Click the thinking toggle to expand thinking options. + const toggleRaw = await page.evaluate(clickThinkingToggleInMenuScript()).catch(() => false); + const toggleClicked = unwrapGeminiEvaluateResult(toggleRaw, 'Gemini thinking toggle'); + + if (toggleClicked) { + // Step 4: Wait for the expanded thinking items to render. + await page.wait(0.5); + } + + // Step 5: Select the requested thinking level (also closes the menu). + const matchedRaw = await page.evaluate(selectThinkingInMenuScript(thinkingValue)).catch(() => ''); + const matched = unwrapGeminiEvaluateResult(matchedRaw, 'Gemini thinking selection'); + + if (typeof matched === 'string' && matched) { + await page.wait(0.3); + return matched; + } + + return ''; +} + +// ── Current model detection ───────────────────────────────────────── + +/** + * Browser evaluate script that reads the currently selected model from the + * Gemini model-picker button and returns its canonical model ID. + * + * Uses the same canonicalModelId logic as discoverModelsScript so that + * the returned ID can be matched against discovered model entries. + * + * @returns a function string for page.evaluate() + */ +function getCurrentGeminiModelScript() { + return ` + (() => { + const isVisible = (el) => { + if (!(el instanceof HTMLElement)) return false; + if (el.hidden || el.closest('[hidden]')) return false; + const ariaHidden = el.getAttribute('aria-hidden'); + if (ariaHidden && ariaHidden.toLowerCase() === 'true') return false; + if (el.closest('[aria-hidden="true"]')) return false; + const style = window.getComputedStyle(el); + if (style.display === 'none' || style.visibility === 'hidden') return false; + if (Number(style.opacity) === 0 || style.pointerEvents === 'none') return false; + const rect = el.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }; + + const normalize = (value) => String(value || '').replace(/\\s+/g, ' ').trim().toLowerCase(); + + const VERSION_LABEL_RE = /\\d+\\.\\d+/; + + const MODE_SELECTOR_PATTERNS = [ + /模式选择器/i, + /mode[\\s-]*selector/i, + /model[\\s-]*selector/i, + /model[\\s-]*picker/i, + /选择模型/i, + /select[\\s-]+model/i, + /choose[\\s-]+model/i, + /switch[\\s-]+model/i, + /change[\\s-]+model/i, + ]; + + const MODEL_VARIANT_RE = /^(?:gemini\\s+)?(flash|lite|pro|ultra|nano|flash-lite|flash[\\s-]*thinking)$/i; + + const findModelPicker = () => { + const buttons = Array.from( + document.querySelectorAll('button, [role="button"]') + ).filter(isVisible); + + // Method 1: Detect model/mode selector via aria-label patterns. + for (const button of buttons) { + const aria = normalize(button.getAttribute('aria-label') || ''); + for (const pattern of MODE_SELECTOR_PATTERNS) { + if (pattern.test(aria)) return button; + } + } + + // Method 2: Detect buttons whose text contains a model-version pattern. + const versionCandidates = buttons.filter((b) => { + const text = normalize(b.textContent || '') || normalize(b.getAttribute('aria-label') || ''); + return VERSION_LABEL_RE.test(text) && text.length < 80; + }); + versionCandidates.sort((a, b) => { + const aRect = a.getBoundingClientRect(); + const bRect = b.getBoundingClientRect(); + return aRect.top - bRect.top || aRect.left - bRect.left; + }); + if (versionCandidates.length > 0) return versionCandidates[0]; + + // Method 3: Detect buttons showing a known model variant as their + // sole text (e.g. "Pro", "Flash", "Flash-Lite"). + const variantCandidates = buttons.filter((b) => { + const text = normalize(b.textContent || ''); + return MODEL_VARIANT_RE.test(text) && text.length < 30; + }); + variantCandidates.sort((a, b) => { + const aRect = a.getBoundingClientRect(); + const bRect = b.getBoundingClientRect(); + return aRect.top - bRect.top || aRect.left - bRect.left; + }); + if (variantCandidates.length > 0) return variantCandidates[0]; + + // Method 4: Fallback — look for any element with model-related attributes. + const attrEls = Array.from( + document.querySelectorAll('[data-model-selector], [aria-label*="model" i], [aria-label*="模式" i]') + ).filter(isVisible); + if (attrEls.length > 0) return attrEls[0]; + + return null; + }; + + const canonicalModelId = (raw) => { + const text = normalize(raw); + if (!text) return ''; + const cleaned = text.replace(/^[^a-z0-9]+/i, '').trim(); + const versionRe = /^((?:gemini[\\s-]*)?(\\d+(?:\\.\\d+)?)(?:[\\s-]+(flash|pro|lite|ultra|nano|thinking|experimental))(?:[\\s-]*(flash|pro|lite|ultra|nano|thinking|experimental))?)/i; + const match = cleaned.match(versionRe); + if (match) { + const version = match[2]; + let variant = (match[3] || '').toLowerCase(); + const extra = (match[4] || '').toLowerCase(); + if (extra) variant = variant + '-' + extra; + return version + '-' + variant; + } + const fallbackRe = /(\\d+(?:\\.\\d+)?)\\s*(flash|pro|lite|ultra|nano|thinking|experimental)/i; + const fallbackMatch = cleaned.match(fallbackRe); + if (fallbackMatch) { + return fallbackMatch[1] + '-' + fallbackMatch[2].toLowerCase(); + } + if (/\\d+(?:\\.\\d+)?/.test(cleaned) && cleaned.length < 60) { + return cleaned.replace(/\\s+/g, '-').replace(/[^a-z0-9.-]/g, ''); + } + return ''; + }; + + const picker = findModelPicker(); + if (!picker) return ''; + + const combined = normalize(picker.textContent || '') + ' ' + normalize(picker.getAttribute('aria-label') || ''); + return canonicalModelId(combined); + })() + `; +} + +/** + * Get the canonical ID of the currently selected Gemini model from the + * model-picker button in the web UI. + * + * @param {object} page - Puppeteer page object + * @returns {Promise} canonical model ID, or empty string on failure + */ +export async function getCurrentGeminiModel(page) { + await ensureGeminiPage(page); + const modelId = await page.evaluate(getCurrentGeminiModelScript()).catch(() => ''); + return typeof modelId === 'string' ? modelId : ''; +} + export const __test__ = { GEMINI_COMPOSER_SELECTORS, GEMINI_COMPOSER_MARKER_ATTR, @@ -1855,6 +2317,11 @@ export const __test__ = { expandGeminiRecentScript, submitComposerScript, insertComposerTextFallbackScript, + openModelPickerForThinkingScript, + clickThinkingToggleInMenuScript, + selectThinkingInMenuScript, + selectGeminiModelScript, + getCurrentGeminiModelScript, }; export async function getGeminiVisibleImageUrls(page) { await ensureGeminiPage(page); @@ -2054,3 +2521,169 @@ export async function waitForGeminiResponse(page, baseline, promptText, timeoutS } return ''; } + +/** + * Evaluate script that selects a specific Gemini model from the web UI + * model picker by canonical model id (e.g. "2.5-flash"). + * + * Returns { ok: true } on success, or { ok: false, reason: "..." } + * when the picker or model item could not be found / clicked. + */ +/** + * Browser evaluate script that finds and clicks a model entry in an + * already-open Gemini model-picker menu. Does NOT open the picker. + */ +function selectModelInMenuScript(modelId) { + return ` + (() => { + const targetModelId = ${JSON.stringify(modelId)}; + if (!targetModelId) return { ok: false, reason: 'No model id provided' }; + + const isVisible = (el) => { + if (!(el instanceof HTMLElement) && !(el instanceof Element)) return false; + if (el.hidden || el.closest('[hidden]')) return false; + const ariaHidden = el.getAttribute('aria-hidden'); + if (ariaHidden && ariaHidden.toLowerCase() === 'true') return false; + if (el.closest('[aria-hidden="true"]')) return false; + const style = window.getComputedStyle(el); + if (style.display === 'none' || style.visibility === 'hidden') return false; + if (Number(style.opacity) === 0 || style.pointerEvents === 'none') return false; + const rect = el.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }; + + const normalize = (value) => String(value || '').replace(/\\s+/g, ' ').trim().toLowerCase(); + + const canonicalModelId = (raw) => { + const text = normalize(raw); + if (!text) return ''; + const cleaned = text.replace(/^[^a-z0-9]+/i, '').trim(); + const versionRe = /^((?:gemini[\\s-]*)?(\\d+(?:\\.\\d+)?)(?:[\\s-]+(flash|pro|lite|ultra|nano|thinking|experimental))(?:[\\s-]*(flash|pro|lite|ultra|nano|thinking|experimental))?)/i; + const match = cleaned.match(versionRe); + if (match) { + const version = match[2]; + let variant = (match[3] || '').toLowerCase(); + const extra = (match[4] || '').toLowerCase(); + if (extra) variant = variant + '-' + extra; + return version + '-' + variant; + } + const fallbackRe = /(\\d+(?:\\.\\d+)?)\\s*(flash|pro|lite|ultra|nano|thinking|experimental)/i; + const fallbackMatch = cleaned.match(fallbackRe); + if (fallbackMatch) { + return fallbackMatch[1] + '-' + fallbackMatch[2].toLowerCase(); + } + if (/\\d+(?:\\.\\d+)?/.test(cleaned) && cleaned.length < 60) { + return cleaned.replace(/\\s+/g, '-').replace(/[^a-z0-9.-]/g, ''); + } + return ''; + }; + + const MENU_SELECTORS = [ + '[role="menu"] [role="menuitem"]', + '[role="menu"] [role="menuitemradio"]', + '[role="listbox"] [role="option"]', + '[role="menu"] button', + '[role="listbox"] button', + '[role="menu"] li', + '[role="listbox"] li', + '[role="dialog"] [role="menuitem"]', + '[role="dialog"] [role="option"]', + '[aria-modal="true"] [role="menuitem"]', + '[aria-modal="true"] [role="option"]', + 'gem-menu-item', + 'GEM-MENU-ITEM', + ]; + + let menuItems = []; + for (const sel of MENU_SELECTORS) { + const items = Array.from(document.querySelectorAll(sel)).filter(isVisible); + if (items.length >= 2) { menuItems = items; break; } + } + + if (menuItems.length === 0) { + const containers = Array.from( + document.querySelectorAll('[role="menu"], [role="listbox"], [role="dialog"], [aria-modal="true"]') + ).filter(isVisible); + for (const container of containers) { + const children = Array.from( + container.querySelectorAll('button, [role="button"], li, [role="menuitem"], [role="option"], gem-menu-item, GEM-MENU-ITEM, :not(script):not(style)') + ).filter(isVisible); + if (children.length >= 2) { menuItems = children; break; } + } + } + + let matched = null; + for (const item of menuItems) { + const id = canonicalModelId(item.textContent || ''); + if (id === targetModelId) { + matched = item; + break; + } + } + + if (!matched) { + try { document.body.click(); } catch (_) {} + return { ok: false, reason: 'Model "' + targetModelId + '" not found in picker menu' }; + } + + try { + matched.click(); + } catch (_) { + try { document.body.click(); } catch (_) {} + return { ok: false, reason: 'Failed to click model menu item' }; + } + + return { ok: true }; + })() + `; +} + +/** + * Legacy wrapper kept for test compatibility — delegates to + * selectModelInMenuScript. New callers should prefer the split + * openModelPickerForThinkingScript + selectModelInMenuScript + * pattern with an explicit page.wait between them. + */ +export function selectGeminiModelScript(modelId) { + return selectModelInMenuScript(modelId); +} + +/** + * Select a Gemini model by canonical id (e.g. "2.5-flash") in the + * web UI model picker. Opens the picker, waits for React, then clicks + * the matching entry. Throws CommandExecutionError on failure. + */ +export async function selectGeminiModel(page, modelId) { + await ensureGeminiPage(page); + + // Open the picker menu. + const pickerRaw = await page.evaluate(openModelPickerForThinkingScript()); + const pickerResult = unwrapGeminiEvaluateResult(pickerRaw, 'Gemini model picker'); + if (!pickerResult || !pickerResult.ok) { + throw new CommandExecutionError( + pickerResult?.reason || 'Failed to open model picker for model selection' + ); + } + + // Wait for React to render the menu. + await page.wait(0.8); + + // Find and click the matching menu item. + const raw = await page.evaluate(selectModelInMenuScript(modelId)); + const result = unwrapGeminiEvaluateResult(raw, 'Gemini model selection'); + if (!result || !result.ok) { + throw new CommandExecutionError( + result?.reason || 'Failed to select Gemini model "' + modelId + '"' + ); + } + + await page.wait(0.5); + const selectedModelId = await getCurrentGeminiModel(page); + if (selectedModelId !== modelId) { + throw new CommandExecutionError( + selectedModelId + ? `Gemini model selection read-back returned "${selectedModelId}", expected "${modelId}"` + : `Gemini model selection did not expose selected model "${modelId}" after click` + ); + } +} diff --git a/clis/gemini/utils.test.js b/clis/gemini/utils.test.js index 4767f7534..0b59140b5 100644 --- a/clis/gemini/utils.test.js +++ b/clis/gemini/utils.test.js @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; import { JSDOM } from 'jsdom'; import { CommandExecutionError } from '@jackwener/opencli/errors'; -import { __test__, collectGeminiTranscriptAdditions, getGeminiConversationList, getGeminiPageState, getGeminiVisibleTurns, pickGeminiDeepResearchExportUrl, readGeminiSnapshot, sanitizeGeminiResponseText, sendGeminiMessage, } from './utils.js'; +import { __test__, collectGeminiTranscriptAdditions, getGeminiConversationList, getGeminiPageState, getGeminiVisibleTurns, pickGeminiDeepResearchExportUrl, readGeminiSnapshot, sanitizeGeminiResponseText, selectGeminiModel, selectGeminiThinking, sendGeminiMessage, } from './utils.js'; function createPageMock() { return { goto: vi.fn().mockResolvedValue(undefined), @@ -390,3 +390,229 @@ describe('pickGeminiDeepResearchExportUrl', () => { expect(picked).toEqual({ url: '', source: 'none' }); }); }); + +// ── Model-picker detection DOM fixture tests ───────────────────────────── +// These tests verify that openModelPickerForThinkingScript, +// selectGeminiModelScript, and getCurrentGeminiModelScript share the same +// 4-method picker detection strategy as models.js (discoverModelsScript). + +function createPickerDom(htmlFixture) { + const dom = new JSDOM(`${htmlFixture}`, { + pretendToBeVisual: true, + runScripts: 'outside-only', + }); + const { window } = dom; + 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: window.document }; +} + +function evalScript(window, scriptFn) { + const scriptText = scriptFn(); + return window.eval(scriptText); +} + +describe('openModelPickerForThinkingScript — picker detection', () => { + it('Method 1: detects picker by aria-label "mode-selector"', () => { + const { window } = createPickerDom(` + + + `); + const result = evalScript(window, __test__.openModelPickerForThinkingScript); + expect(result).toEqual({ ok: true }); + }); + + it('Method 1: detects picker by aria-label "模式选择器"', () => { + const { window } = createPickerDom(` + + `); + const result = evalScript(window, __test__.openModelPickerForThinkingScript); + expect(result).toEqual({ ok: true }); + }); + + it('Method 1: detects picker by aria-label "model-picker"', () => { + const { window } = createPickerDom(` + + `); + const result = evalScript(window, __test__.openModelPickerForThinkingScript); + expect(result).toEqual({ ok: true }); + }); + + it('Method 1: detects picker by aria-label "选择模型"', () => { + const { window } = createPickerDom(` + + `); + const result = evalScript(window, __test__.openModelPickerForThinkingScript); + expect(result).toEqual({ ok: true }); + }); + + it('Method 2: detects picker by version-number text', () => { + const { window } = createPickerDom(` + + `); + const result = evalScript(window, __test__.openModelPickerForThinkingScript); + expect(result).toEqual({ ok: true }); + }); + + it('Method 3: detects picker by variant-only text "Flash"', () => { + const { window } = createPickerDom(` + + `); + const result = evalScript(window, __test__.openModelPickerForThinkingScript); + expect(result).toEqual({ ok: true }); + }); + + it('Method 3: detects picker by variant-only text "Pro"', () => { + const { window } = createPickerDom(` + + `); + const result = evalScript(window, __test__.openModelPickerForThinkingScript); + expect(result).toEqual({ ok: true }); + }); + + it('Method 3: detects picker by variant-only text "Lite"', () => { + const { window } = createPickerDom(` + + `); + const result = evalScript(window, __test__.openModelPickerForThinkingScript); + expect(result).toEqual({ ok: true }); + }); + + it('Method 3: detects picker by variant-only text "Flash-Lite"', () => { + const { window } = createPickerDom(` + + `); + const result = evalScript(window, __test__.openModelPickerForThinkingScript); + expect(result).toEqual({ ok: true }); + }); + + it('Method 4: detects picker by data-model-selector attribute', () => { + const { window } = createPickerDom(` + + `); + const result = evalScript(window, __test__.openModelPickerForThinkingScript); + expect(result).toEqual({ ok: true }); + }); + + it('returns { ok: false } when no picker is found', () => { + const { window } = createPickerDom(` + + + `); + const result = evalScript(window, __test__.openModelPickerForThinkingScript); + expect(result).toEqual({ ok: false, reason: 'Model picker not found' }); + }); + + it('Method 1 wins over Method 2 for ambiguous buttons', () => { + // A button with both a model-selector aria-label and a version-like + // text should still be found (Method 1 returns early). + const { window } = createPickerDom(` + + `); + const result = evalScript(window, __test__.openModelPickerForThinkingScript); + expect(result).toEqual({ ok: true }); + }); +}); + +describe('selectGeminiModelScript — picker detection parity', () => { + it('Method 1: detects picker by aria-label "mode-selector" and selects model', () => { + const { window } = createPickerDom(` + +
+ +
+ `); + // Show the menu so the script can find menu items (just test picker detection). + // The script will fail to find the model in the menu (shown), but we check + // the picker was found by the aria-label. + const result = evalScript(window, () => __test__.selectGeminiModelScript('2.5-flash')); + // The menu is visible but doesn't contain the right items — we just care + // that the picker was found (not the old "Model picker button not found" error). + expect(result.ok).toBe(false); + expect(result.reason).toContain('not found in picker menu'); + }); + + it('Method 3: detects picker by variant-only text "Flash" and attempts selection', () => { + const { window } = createPickerDom(` + +
+ +
+ `); + const result = evalScript(window, () => __test__.selectGeminiModelScript('2.5-flash')); + expect(result.ok).toBe(false); + expect(result.reason).toContain('not found in picker menu'); + }); +}); + +describe('getCurrentGeminiModelScript — picker detection parity', () => { + it('Method 1: detects picker by aria-label "模式选择器"', () => { + const { window } = createPickerDom(` + + `); + const result = window.eval(__test__.getCurrentGeminiModelScript()); + expect(result).toBe('2.5-flash'); + }); + + it('Method 3: detects picker by variant-only text "Flash"', () => { + const { window } = createPickerDom(` + + `); + const result = window.eval(__test__.getCurrentGeminiModelScript()); + expect(result).toBe(''); + }); + + it('Method 2: detects picker by version-number text and extracts model id', () => { + const { window } = createPickerDom(` + + `); + const result = window.eval(__test__.getCurrentGeminiModelScript()); + expect(result).toBe('2.5-pro'); + }); +}); + +describe('Gemini model/thinking selection Browser Bridge envelopes', () => { + it('unwraps envelopes while selecting a thinking level', async () => { + const page = createPageMock(); + vi.mocked(page.evaluate) + .mockResolvedValueOnce('https://gemini.google.com/app') + .mockResolvedValueOnce({ session: 'site:gemini', data: { ok: true } }) + .mockResolvedValueOnce({ session: 'site:gemini', data: true }) + .mockResolvedValueOnce({ session: 'site:gemini', data: 'Extended' }); + + const result = await selectGeminiThinking(page, 'extended'); + + expect(result).toBe('Extended'); + expect(page.evaluate).toHaveBeenCalledTimes(4); + }); + + it('unwraps envelopes while selecting a model', async () => { + const page = createPageMock(); + vi.mocked(page.evaluate) + .mockResolvedValueOnce('https://gemini.google.com/app') + .mockResolvedValueOnce({ session: 'site:gemini', data: { ok: true } }) + .mockResolvedValueOnce({ session: 'site:gemini', data: { ok: true } }) + .mockResolvedValueOnce('https://gemini.google.com/app') + .mockResolvedValueOnce('2.5-flash'); + + await expect(selectGeminiModel(page, '2.5-flash')).resolves.toBeUndefined(); + + expect(page.evaluate).toHaveBeenCalledTimes(5); + }); + + it('typed-fails when model selection read-back does not match the requested model', async () => { + const page = createPageMock(); + vi.mocked(page.evaluate) + .mockResolvedValueOnce('https://gemini.google.com/app') + .mockResolvedValueOnce({ session: 'site:gemini', data: { ok: true } }) + .mockResolvedValueOnce({ session: 'site:gemini', data: { ok: true } }) + .mockResolvedValueOnce('https://gemini.google.com/app') + .mockResolvedValueOnce('2.5-pro'); + + await expect(selectGeminiModel(page, '2.5-flash')).rejects.toBeInstanceOf(CommandExecutionError); + }); +}); diff --git a/docs/adapters/browser/gemini.md b/docs/adapters/browser/gemini.md index 1fbe42a15..fb07f85e0 100644 --- a/docs/adapters/browser/gemini.md +++ b/docs/adapters/browser/gemini.md @@ -7,8 +7,9 @@ | Command | Description | |---------|-------------| | `opencli gemini new` | Start a new Gemini web chat | -| `opencli gemini ask ` | Send a prompt and return only the assistant reply | +| `opencli gemini ask [--model ] [--thinking ]` | Send a prompt and return only the assistant reply | | `opencli gemini image ` | Generate images in Gemini and optionally save them locally | +| `opencli gemini models` | List available Gemini models | | `opencli gemini deep-research ` | Start a Gemini Deep Research run and confirm it | | `opencli gemini deep-research-result ` | Export Deep Research report URL from a Gemini conversation | | `opencli gemini status` | Check Gemini web page availability and login state | @@ -25,9 +26,24 @@ opencli gemini new # Ask Gemini and return minimal plain-text output opencli gemini ask "Reply with exactly: HELLO" +# Ask with a specific model selected +opencli gemini ask "Explain quantum computing in one sentence" --model 2.5-flash + # Ask in a new chat and wait longer opencli gemini ask "Summarize this design in 3 bullets" --new true --timeout 90 +# Ask with extended thinking +opencli gemini ask "Explain quantum computing" --thinking extended + +# Ask with standard thinking in a fresh chat +opencli gemini ask "Hello" --new true --thinking standard + +# Ask with a specific model and thinking level combined +opencli gemini ask "Explain quantum computing in one sentence" --model 2.5-pro --thinking extended + +# Ask in a new chat with a specific model and thinking level +opencli gemini ask "Summarize this design in 3 bullets" --new true --model 2.5-flash --thinking standard + # Generate an icon image with short flags opencli gemini image "Generate a tiny cyan moon icon" --rt 1:1 --st icon @@ -36,6 +52,12 @@ opencli gemini image "A watercolor sunset over a lake" --sd true # Save generated images to a custom directory opencli gemini image "A flat illustration of a robot" --op ~/tmp/gemini-images + +# List available models +opencli gemini models + +# List models as JSON for scripting +opencli gemini models -f json ``` ## Options @@ -45,8 +67,10 @@ opencli gemini image "A flat illustration of a robot" --op ~/tmp/gemini-images | Option | Description | |--------|-------------| | `prompt` | Prompt to send (required positional argument) | +| `--model` | Gemini model to use (e.g. `2.5-flash`, `2.5-pro`). Use `opencli gemini models` to list available values. | | `--timeout` | Max seconds to wait for a reply (default: `60`) | | `--new` | Start a new chat before sending (default: `false`) | +| `--thinking` | Thinking level: `standard` or `extended` (omitted = leave unchanged) | ### `image` @@ -58,8 +82,24 @@ opencli gemini image "A flat illustration of a robot" --op ~/tmp/gemini-images | `--op` | Output directory for downloaded images (default: `~/tmp/gemini-images`) | | `--sd` | Skip download and only print the Gemini page link | +### `models` + +| Column | Description | +|--------|-------------| +| `model` | Canonical model ID (e.g. `2.5-flash`, `2.5-pro`, `2.5-flash-lite`) | +| `thinkingValues` | Per-model thinking levels only when Gemini exposes them directly on the model entry; otherwise `[]`. The current Gemini UI usually exposes thinking controls for the active model, so this command does not infer support for every model. | + +- `models` discovers available models from the visible Gemini web UI model picker. +- The command is read-only: it does not select a model, change a thinking level, start a new chat, or submit a prompt. +- Model IDs match the canonical format used by later `gemini ask` model selection. +- Throws a command error when the model picker cannot be opened, which helps surface Gemini Web UI changes. Returns an empty list only when the picker opens but no model entries are available. + ## Behavior +- When `--new true` is combined with `--model` and/or `--thinking`, the new chat is created first, then the model and thinking level are selected, then the snapshot is read, and finally the prompt is submitted. +- `ask --model ` selects the requested model before reading the page state and sending the prompt. The selected model remains visible in the Gemini web UI after the command completes. Short aliases like `pro` or `flash` are rejected—use canonical model IDs from `opencli gemini models`. +- When `--model` is omitted, `ask` does not change the current model. +- All other Gemini commands (`image`, `deep-research`, etc.) are unaffected and do not accept `--model`. - `ask` uses plain minimal output and returns only the assistant response text prefixed with `💬`. - `image` also uses plain output and prints `status / file / link` instead of a table. - `image` always starts from a fresh Gemini chat before sending the prompt. From 0a90179322fb9443b3b2b154e0c9b83dd4a09cd0 Mon Sep 17 00:00:00 2001 From: jakevin Date: Wed, 1 Jul 2026 01:35:33 +0800 Subject: [PATCH 10/27] fix(extension): cdp network-capture + frame-eval robustness (#1984) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three independent CDP-layer correctness fixes: 1. Redirect wiped the captured POST body. On an HTTP 30x, CDP re-fires Network.requestWillBeSent with the SAME requestId (the prior hop in `redirectResponse`) for the redirect target — usually a GET with no postData. The handler overwrote the entry's request-body fields unconditionally, destroying the original POST body. Now the body is only populated on the initial send (guarded on `!redirectResponse`). 2. responseReceived created orphan entries. If readNetworkCapture() drained the entries (clearing requestToIndex) while a request was in flight, the later Network.responseReceived ran getOrCreate and made a new half-entry with a defaulted method ('GET') and no request data. Now it is lookup-only, mirroring loadingFinished. 3. evaluateInFrame had no retry on a stale cached context. A navigated/ reloaded frame invalidates its cached execution-context id, but the executionContextDestroyed event may not be processed yet, so Runtime.evaluate rejects with "Cannot find context with specified id". Now that rejection drops the stale id and falls through to the frame-target path (mirrors evaluate()'s re-resolution); genuine page errors still propagate. Tests: redirect-body preservation, orphan-entry prevention, and stale-context fallback — all reverse-validated. cdp suite 15/15, tsc clean, extension/dist rebuilt. --- extension/dist/background.js | 74 ++++++++++-------- extension/src/cdp.test.ts | 143 +++++++++++++++++++++++++++++++++++ extension/src/cdp.ts | 105 +++++++++++++++---------- 3 files changed, 250 insertions(+), 72 deletions(-) diff --git a/extension/dist/background.js b/extension/dist/background.js index a41eb4bf8..d5fc24a5d 100644 --- a/extension/dist/background.js +++ b/extension/dist/background.js @@ -395,25 +395,33 @@ async function evaluateInFrame(tabId, expression, frameId, aggressiveRetry = fal }); const contexts = tabFrameContexts.get(tabId); const contextId = contexts?.get(frameId); - if (contextId === void 0) { - await sendCommandInFrameTarget(tabId, frameId, "Runtime.enable", {}, aggressiveRetry).catch(() => void 0); - const result2 = await sendCommandInFrameTarget(tabId, frameId, "Runtime.evaluate", { - expression, - returnByValue: true, - awaitPromise: true - }, aggressiveRetry); - if (result2.exceptionDetails) { - const errMsg = result2.exceptionDetails.exception?.description || result2.exceptionDetails.text || "Eval error"; - throw new Error(errMsg); + if (contextId !== void 0) { + try { + const result2 = await chrome.debugger.sendCommand({ tabId }, "Runtime.evaluate", { + expression, + contextId, + returnByValue: true, + awaitPromise: true + }); + if (result2.exceptionDetails) { + const errMsg = result2.exceptionDetails.exception?.description || result2.exceptionDetails.text || "Eval error"; + throw new Error(errMsg); + } + return result2.result?.value; + } catch (err) { + const msg = String(err?.message || err); + if (!/Cannot find context|context with specified id|Execution context was destroyed/i.test(msg)) { + throw err; + } + contexts?.delete(frameId); } - return result2.result?.value; } - const result = await chrome.debugger.sendCommand({ tabId }, "Runtime.evaluate", { + await sendCommandInFrameTarget(tabId, frameId, "Runtime.enable", {}, aggressiveRetry).catch(() => void 0); + const result = await sendCommandInFrameTarget(tabId, frameId, "Runtime.evaluate", { expression, - contextId, returnByValue: true, awaitPromise: true - }); + }, aggressiveRetry); if (result.exceptionDetails) { const errMsg = result.exceptionDetails.exception?.description || result.exceptionDetails.text || "Eval error"; throw new Error(errMsg); @@ -533,36 +541,38 @@ function registerListeners() { requestHeaders: normalizeHeaders(request?.headers) }); if (!entry) return; - entry.requestBodyKind = request?.hasPostData ? "string" : "empty"; - { - const raw = String(request?.postData || ""); - const fullSize = raw.length; - const truncated = fullSize > CDP_REQUEST_BODY_CAPTURE_LIMIT; - entry.requestBodyPreview = truncated ? raw.slice(0, CDP_REQUEST_BODY_CAPTURE_LIMIT) : raw; - entry.requestBodyFullSize = fullSize; - entry.requestBodyTruncated = truncated; - } - try { - const postData = await chrome.debugger.sendCommand({ tabId }, "Network.getRequestPostData", { requestId }); - if (postData?.postData) { - const raw = postData.postData; + if (!eventParams?.redirectResponse) { + entry.requestBodyKind = request?.hasPostData ? "string" : "empty"; + { + const raw = String(request?.postData || ""); const fullSize = raw.length; const truncated = fullSize > CDP_REQUEST_BODY_CAPTURE_LIMIT; - entry.requestBodyKind = "string"; entry.requestBodyPreview = truncated ? raw.slice(0, CDP_REQUEST_BODY_CAPTURE_LIMIT) : raw; entry.requestBodyFullSize = fullSize; entry.requestBodyTruncated = truncated; } - } catch { + try { + const postData = await chrome.debugger.sendCommand({ tabId }, "Network.getRequestPostData", { requestId }); + if (postData?.postData) { + const raw = postData.postData; + const fullSize = raw.length; + const truncated = fullSize > CDP_REQUEST_BODY_CAPTURE_LIMIT; + entry.requestBodyKind = "string"; + entry.requestBodyPreview = truncated ? raw.slice(0, CDP_REQUEST_BODY_CAPTURE_LIMIT) : raw; + entry.requestBodyFullSize = fullSize; + entry.requestBodyTruncated = truncated; + } + } catch { + } } return; } if (method === "Network.responseReceived") { const requestId = String(eventParams?.requestId || ""); const response = eventParams?.response; - const entry = getOrCreateNetworkCaptureEntry(tabId, requestId, { - url: response?.url - }); + const stateEntryIndex = state.requestToIndex.get(requestId); + if (stateEntryIndex === void 0) return; + const entry = state.entries[stateEntryIndex]; if (!entry) return; entry.responseStatus = response?.status; entry.responseContentType = response?.mimeType || ""; diff --git a/extension/src/cdp.test.ts b/extension/src/cdp.test.ts index d71922e40..74360d872 100644 --- a/extension/src/cdp.test.ts +++ b/extension/src/cdp.test.ts @@ -465,3 +465,146 @@ describe('cdp network capture survives forced re-attach', () => { expect(mock.networkEnableCount()).toBeGreaterThan(enablesAfterStart); }); }); + +describe('cdp network capture correctness', () => { + beforeEach(() => { + vi.resetModules(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + function createNetworkMock() { + const onEventListeners = []; + const debuggerApi = { + attach: vi.fn(async () => {}), + detach: vi.fn(async () => {}), + sendCommand: vi.fn(async (_target, method, params) => { + if (method === 'Runtime.evaluate' && params?.expression === '1') return { result: { value: '1' } }; + if (method === 'Network.getRequestPostData') return {}; // no override; use inline postData + return {}; + }), + onDetach: { addListener: vi.fn() }, + onEvent: { addListener: vi.fn((fn) => { onEventListeners.push(fn); }) }, + }; + const tabs = { + get: vi.fn(async () => ({ id: 1, windowId: 1, url: 'https://x.com/home' })), + onRemoved: { addListener: vi.fn() }, + onUpdated: { addListener: vi.fn() }, + }; + const fire = async (method, params) => { + for (const fn of onEventListeners) await fn({ tabId: 1 }, method, params); + }; + return { + chrome: { tabs, debugger: debuggerApi, scripting: {}, runtime: { id: 'opencli-test' } }, + fire, + }; + } + + it('preserves the original POST body when a captured request follows a redirect', async () => { + const mock = createNetworkMock(); + vi.stubGlobal('chrome', mock.chrome); + const mod = await import('./cdp'); + mod.registerListeners(); + await mod.startNetworkCapture(1, 'api.example'); + + // Initial POST with a body. + await mock.fire('Network.requestWillBeSent', { + requestId: 'r1', + request: { url: 'https://api.example/login', method: 'POST', postData: 'user=a&pass=b', hasPostData: true }, + }); + // The 302 target re-fires with the SAME requestId, carried via redirectResponse, + // as a GET with no postData — this must NOT wipe the captured body. + await mock.fire('Network.requestWillBeSent', { + requestId: 'r1', + redirectResponse: { status: 302, url: 'https://api.example/login' }, + request: { url: 'https://api.example/home', method: 'GET' }, + }); + + const entries = await mod.readNetworkCapture(1); + expect(entries).toHaveLength(1); + expect(entries[0].requestBodyPreview).toBe('user=a&pass=b'); + expect(entries[0].requestBodyKind).toBe('string'); + }); + + it('does not create an orphan entry from a response after the request was drained', async () => { + const mock = createNetworkMock(); + vi.stubGlobal('chrome', mock.chrome); + const mod = await import('./cdp'); + mod.registerListeners(); + await mod.startNetworkCapture(1, 'api.example'); + + await mock.fire('Network.requestWillBeSent', { + requestId: 'r2', + request: { url: 'https://api.example/x', method: 'GET' }, + }); + // Read drains entries + clears requestToIndex while the request is in flight. + const first = await mod.readNetworkCapture(1); + expect(first).toHaveLength(1); + + // Late response for the drained request must not resurrect a half-entry. + await mock.fire('Network.responseReceived', { + requestId: 'r2', + response: { url: 'https://api.example/x', status: 200, mimeType: 'text/html' }, + }); + const second = await mod.readNetworkCapture(1); + expect(second).toEqual([]); + }); +}); + +describe('cdp evaluateInFrame stale context fallback', () => { + beforeEach(() => { + vi.resetModules(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('falls back to the frame target when the cached context id went stale', async () => { + const debuggerEventListeners = []; + const debuggerApi = { + attach: vi.fn(async () => {}), + detach: vi.fn(async () => {}), + sendCommand: vi.fn(async (target, method, params) => { + if (method === 'Runtime.enable') return {}; + // The cached context id is stale after the frame navigated: CDP rejects. + if (method === 'Runtime.evaluate' && params?.contextId === 99) { + throw new Error('Cannot find context with specified id'); + } + if (method === 'Target.setDiscoverTargets') return {}; + if (method === 'Target.setAutoAttach') return {}; + if (method === 'Target.getTargets') { + return { targetInfos: [{ targetId: 'stale-frame', type: 'iframe', url: 'https://frame.test' }] }; + } + if (target?.targetId === 'stale-frame' && method === 'Runtime.evaluate') { + return { result: { value: 'frame-ok' } }; + } + return {}; + }), + onDetach: { addListener: vi.fn() }, + onEvent: { addListener: vi.fn((fn) => { debuggerEventListeners.push(fn); }) }, + }; + const tabs = { + get: vi.fn(async () => ({ id: 1, windowId: 1, url: 'https://x.com/home' })), + onRemoved: { addListener: vi.fn() }, + onUpdated: { addListener: vi.fn() }, + }; + vi.stubGlobal('chrome', { tabs, debugger: debuggerApi, scripting: {}, runtime: { id: 'opencli-test' } }); + + const mod = await import('./cdp'); + mod.registerFrameTracking(); + // Cache a context id (99) for the frame, which then goes stale. + for (const fn of debuggerEventListeners) { + fn({ tabId: 1 }, 'Runtime.executionContextCreated', { + context: { id: 99, auxData: { frameId: 'stale-frame', isDefault: true } }, + }); + } + + const result = await mod.evaluateInFrame(1, 'document.title', 'stale-frame'); + + expect(result).toBe('frame-ok'); + expect(debuggerApi.attach).toHaveBeenCalledWith({ targetId: 'stale-frame' }, '1.3'); + }); +}); diff --git a/extension/src/cdp.ts b/extension/src/cdp.ts index 493434e99..cb72430fc 100644 --- a/extension/src/cdp.ts +++ b/extension/src/cdp.ts @@ -588,33 +588,46 @@ export async function evaluateInFrame( const contexts = tabFrameContexts.get(tabId); const contextId = contexts?.get(frameId); - if (contextId === undefined) { - await sendCommandInFrameTarget(tabId, frameId, 'Runtime.enable', {}, aggressiveRetry).catch(() => undefined); - const result = await sendCommandInFrameTarget(tabId, frameId, 'Runtime.evaluate', { - expression, - returnByValue: true, - awaitPromise: true, - }, aggressiveRetry) as { - result?: { type: string; value?: unknown; description?: string; subtype?: string }; - exceptionDetails?: { exception?: { description?: string }; text?: string }; - }; - - if (result.exceptionDetails) { - const errMsg = result.exceptionDetails.exception?.description - || result.exceptionDetails.text - || 'Eval error'; - throw new Error(errMsg); + if (contextId !== undefined) { + try { + const result = await chrome.debugger.sendCommand({ tabId }, 'Runtime.evaluate', { + expression, + contextId, + returnByValue: true, + awaitPromise: true, + }) as { + result?: { type: string; value?: unknown; description?: string; subtype?: string }; + exceptionDetails?: { exception?: { description?: string }; text?: string }; + }; + if (result.exceptionDetails) { + const errMsg = result.exceptionDetails.exception?.description + || result.exceptionDetails.text + || 'Eval error'; + throw new Error(errMsg); + } + return result.result?.value; + } catch (err) { + // A navigated/reloaded frame invalidates its cached context id, but the + // Runtime.executionContextDestroyed event may not have been processed + // yet — the cache still holds the stale id and Runtime.evaluate rejects + // with "Cannot find context with specified id". Drop the stale id and + // fall through to the frame-target path instead of failing (evaluate() + // likewise re-resolves on a dead context). Re-throw genuine page errors. + const msg = String((err as { message?: string })?.message || err); + if (!/Cannot find context|context with specified id|Execution context was destroyed/i.test(msg)) { + throw err; + } + contexts?.delete(frameId); } - - return result.result?.value; } - const result = await chrome.debugger.sendCommand({ tabId }, 'Runtime.evaluate', { + // No cached context, or the cached one went stale: resolve via the frame target. + await sendCommandInFrameTarget(tabId, frameId, 'Runtime.enable', {}, aggressiveRetry).catch(() => undefined); + const result = await sendCommandInFrameTarget(tabId, frameId, 'Runtime.evaluate', { expression, - contextId, returnByValue: true, awaitPromise: true, - }) as { + }, aggressiveRetry) as { result?: { type: string; value?: unknown; description?: string; subtype?: string }; exceptionDetails?: { exception?: { description?: string }; text?: string }; }; @@ -765,28 +778,35 @@ export function registerListeners(): void { requestHeaders: normalizeHeaders(request?.headers), }); if (!entry) return; - entry.requestBodyKind = request?.hasPostData ? 'string' : 'empty'; - { - const raw = String(request?.postData || ''); - const fullSize = raw.length; - const truncated = fullSize > CDP_REQUEST_BODY_CAPTURE_LIMIT; - entry.requestBodyPreview = truncated ? raw.slice(0, CDP_REQUEST_BODY_CAPTURE_LIMIT) : raw; - entry.requestBodyFullSize = fullSize; - entry.requestBodyTruncated = truncated; - } - try { - const postData = await chrome.debugger.sendCommand({ tabId }, 'Network.getRequestPostData', { requestId }) as { postData?: string }; - if (postData?.postData) { - const raw = postData.postData; + // On an HTTP 30x, CDP re-fires requestWillBeSent with the SAME requestId + // (the prior hop is carried in `redirectResponse`) for the redirect + // target — typically a GET with no postData. Overwriting the body here + // would wipe the original request's captured POST body, so only populate + // the body on the initial send. + if (!eventParams?.redirectResponse) { + entry.requestBodyKind = request?.hasPostData ? 'string' : 'empty'; + { + const raw = String(request?.postData || ''); const fullSize = raw.length; const truncated = fullSize > CDP_REQUEST_BODY_CAPTURE_LIMIT; - entry.requestBodyKind = 'string'; entry.requestBodyPreview = truncated ? raw.slice(0, CDP_REQUEST_BODY_CAPTURE_LIMIT) : raw; entry.requestBodyFullSize = fullSize; entry.requestBodyTruncated = truncated; } - } catch { - // Optional; some requests do not expose postData. + try { + const postData = await chrome.debugger.sendCommand({ tabId }, 'Network.getRequestPostData', { requestId }) as { postData?: string }; + if (postData?.postData) { + const raw = postData.postData; + const fullSize = raw.length; + const truncated = fullSize > CDP_REQUEST_BODY_CAPTURE_LIMIT; + entry.requestBodyKind = 'string'; + entry.requestBodyPreview = truncated ? raw.slice(0, CDP_REQUEST_BODY_CAPTURE_LIMIT) : raw; + entry.requestBodyFullSize = fullSize; + entry.requestBodyTruncated = truncated; + } + } catch { + // Optional; some requests do not expose postData. + } } return; } @@ -799,9 +819,14 @@ export function registerListeners(): void { status?: number; headers?: Record; } | undefined; - const entry = getOrCreateNetworkCaptureEntry(tabId, requestId, { - url: response?.url, - }); + // Lookup-only (like loadingFinished below): never create an entry from a + // response. If the matching requestWillBeSent was already drained by a + // readNetworkCapture() while the request was in flight, creating one here + // produces an orphan half-entry with a defaulted method ('GET') and no + // request data. + const stateEntryIndex = state.requestToIndex.get(requestId); + if (stateEntryIndex === undefined) return; + const entry = state.entries[stateEntryIndex]; if (!entry) return; entry.responseStatus = response?.status; entry.responseContentType = response?.mimeType || ''; From e7bdad4783ce8fca307f2d2c3153dc6ea425dedd Mon Sep 17 00:00:00 2001 From: Marvin <48939577+lavapapa@users.noreply.github.com> Date: Wed, 1 Jul 2026 02:01:22 +0800 Subject: [PATCH 11/27] Fix ChatGPT intelligence level selection (#2022) * Fix ChatGPT intelligence level selection * Document ChatGPT model adapter usage in browser skill * fix(chatgpt): verify model config selection * fix(chatgpt): classify model preference api drift --------- Co-authored-by: jackwener --- cli-manifest.json | 21 +++- clis/chatgpt/commands.test.js | 2 +- clis/chatgpt/model.js | 4 +- clis/chatgpt/model.test.js | 2 +- clis/chatgpt/utils.js | 186 ++++++++++++++++++++++++---- clis/chatgpt/utils.test.js | 206 +++++++++++++++++++++---------- docs/adapters/browser/chatgpt.md | 12 +- skills/opencli-browser/SKILL.md | 2 +- 8 files changed, 330 insertions(+), 105 deletions(-) diff --git a/cli-manifest.json b/cli-manifest.json index 76bf61a82..2a8b6befa 100644 --- a/cli-manifest.json +++ b/cli-manifest.json @@ -6429,7 +6429,7 @@ { "site": "chatgpt", "name": "model", - "description": "Switch ChatGPT web intelligence level (instant, medium, high, extra-high, pro; thinking aliases high)", + "description": "Switch ChatGPT web intelligence level (fast, balanced, advanced, very-high, pro)", "access": "write", "domain": "chatgpt.com", "strategy": "cookie", @@ -6440,14 +6440,29 @@ "type": "str", "required": true, "positional": true, - "help": "Intelligence level to switch to; thinking is a backward-compatible alias for high", + "help": "Intelligence level to switch to", "choices": [ + "fast", + "speed", "instant", + "极速", + "balanced", + "balance", "medium", + "均衡", + "advanced", "high", + "thinking", + "高级", + "very-high", + "ultra", + "xhigh", + "x-high", "extra-high", + "超高", "pro", - "thinking" + "professional", + "专业" ] }, { diff --git a/clis/chatgpt/commands.test.js b/clis/chatgpt/commands.test.js index 6332900e5..a768ec428 100644 --- a/clis/chatgpt/commands.test.js +++ b/clis/chatgpt/commands.test.js @@ -144,7 +144,7 @@ describe('chatgpt browser command registration', () => { name: 'model', positional: true, required: true, - choices: ['instant', 'medium', 'high', 'extra-high', 'pro', 'thinking'], + choices: expect.arrayContaining(['fast', 'speed', 'instant', 'balanced', 'balance', 'advanced', 'high', 'thinking', 'very-high', 'ultra', 'xhigh', 'x-high', 'pro', 'professional']), }), expect.objectContaining({ name: 'project', valueRequired: true }), ])); diff --git a/clis/chatgpt/model.js b/clis/chatgpt/model.js index d2d0469d1..a7ea10871 100644 --- a/clis/chatgpt/model.js +++ b/clis/chatgpt/model.js @@ -10,14 +10,14 @@ export const modelCommand = cli({ site: 'chatgpt', name: 'model', access: 'write', - description: 'Switch ChatGPT web intelligence level (instant, medium, high, extra-high, pro; thinking aliases high)', + description: 'Switch ChatGPT web intelligence level (fast, balanced, advanced, very-high, pro)', domain: CHATGPT_DOMAIN, strategy: Strategy.COOKIE, browser: true, siteSession: 'persistent', navigateBefore: false, args: [ - { name: 'model', required: true, positional: true, help: 'Intelligence level to switch to; thinking is a backward-compatible alias for high', choices: CHATGPT_MODEL_CHOICES }, + { name: 'model', required: true, positional: true, help: 'Intelligence level to switch to', choices: CHATGPT_MODEL_CHOICES }, { name: 'project', valueRequired: true, help: 'Open a ChatGPT project ID or /g/g-p- URL before switching intelligence level' }, ], columns: ['Status', 'Model'], diff --git a/clis/chatgpt/model.test.js b/clis/chatgpt/model.test.js index d0329673c..2c365d43f 100644 --- a/clis/chatgpt/model.test.js +++ b/clis/chatgpt/model.test.js @@ -7,7 +7,7 @@ const mocks = vi.hoisted(() => ({ vi.mock('./utils.js', () => ({ CHATGPT_DOMAIN: 'chatgpt.com', - CHATGPT_MODEL_CHOICES: ['instant', 'medium', 'high', 'extra-high', 'pro', 'thinking'], + CHATGPT_MODEL_CHOICES: ['fast', 'speed', 'instant', 'balanced', 'advanced', 'high', 'thinking', 'very-high', 'pro'], navigateToProject: mocks.navigateToProject, selectChatGPTModel: mocks.selectChatGPTModel, })); diff --git a/clis/chatgpt/utils.js b/clis/chatgpt/utils.js index 98eae91e3..062da56cc 100644 --- a/clis/chatgpt/utils.js +++ b/clis/chatgpt/utils.js @@ -10,49 +10,60 @@ export const CHATGPT_DOMAIN = 'chatgpt.com'; export const CHATGPT_URL = 'https://chatgpt.com'; const CHATGPT_MODEL_TARGETS = { - instant: { - label: 'Instant', - labels: ['Instant', '即时', '极速'], - optionLabels: ['Instant', '极速', '即时'], + fast: { + label: 'Fast', + labels: ['Fast', 'Speed', 'Instant', '极速', '即时'], + optionLabels: ['Fast', 'Speed', 'Instant', '极速', '即时'], testIds: ['model-switcher-gpt-5-5'], intelligenceOrder: 0, + aliases: ['speed', 'instant', '极速'], }, - medium: { - label: 'Medium', - labels: ['Medium', '均衡'], - optionLabels: ['Medium', '均衡'], + balanced: { + label: 'Balanced', + labels: ['Balanced', 'Balance', 'Medium', '均衡'], + optionLabels: ['Balanced', 'Balance', 'Medium', '均衡'], testIds: [], intelligenceOrder: 1, + aliases: ['balance', 'medium', '均衡'], }, - high: { - label: 'High', - labels: ['High', '高级', 'Thinking', '思考'], - optionLabels: ['High', '高级', 'Thinking', '思考'], + advanced: { + label: 'Advanced', + labels: ['Advanced', 'High', 'Thinking', '高级', '思考'], + optionLabels: ['Advanced', 'High', 'Thinking', '高级', '思考'], testIds: ['model-switcher-gpt-5-5-thinking'], intelligenceOrder: 2, + aliases: ['high', 'thinking', '高级'], + modelConfig: { modelSlug: 'gpt-5-5-thinking', effort: 'extended' }, }, - 'extra-high': { - label: 'Extra High', - labels: ['Extra High', '超高'], - optionLabels: ['Extra High', '超高'], + 'very-high': { + label: 'Very High', + labels: ['Very High', 'Extra High', 'Ultra', 'XHigh', 'X-High', '超高'], + optionLabels: ['Very High', 'Extra High', 'Ultra', 'XHigh', 'X-High', '超高'], testIds: [], intelligenceOrder: 3, + aliases: ['ultra', 'xhigh', 'x-high', 'extra-high', '超高'], }, pro: { label: 'Pro', - labels: ['Pro', '进阶专业', '专业'], - optionLabels: ['专业', 'Pro', '进阶专业'], + labels: ['Pro', 'Professional', '进阶专业', '专业'], + optionLabels: ['专业', 'Pro', 'Professional', '进阶专业'], testIds: ['model-switcher-gpt-5-5-pro'], intelligenceOrder: 4, + aliases: ['professional', '专业'], + modelConfig: { modelSlug: 'gpt-5-5-pro', effort: 'standard' }, }, }; -const CHATGPT_MODEL_ALIASES = { - thinking: 'high', -}; -export const CHATGPT_MODEL_CHOICES = [ - ...Object.keys(CHATGPT_MODEL_TARGETS), - ...Object.keys(CHATGPT_MODEL_ALIASES), -]; +const CHATGPT_MODEL_ALIASES = Object.fromEntries(Object.entries(CHATGPT_MODEL_TARGETS).flatMap(([key, target]) => [ + [key, key], + ...(target.aliases || []).map((alias) => [String(alias).toLowerCase(), key]), +])); +export const CHATGPT_MODEL_CHOICES = Object.keys(CHATGPT_MODEL_ALIASES); + +function debugChatGPTModel(message) { + if (process?.env?.OPENCLI_CHATGPT_MODEL_DEBUG) { + console.error(`[chatgpt/model] ${message}`); + } +} const CHATGPT_TOOL_OPTIONS = { 'deep-research': { label: 'Deep Research', labels: ['深度研究', 'Deep Research'] }, @@ -445,18 +456,143 @@ export async function getCurrentChatGPTModel(page) { })()`)), 'chatgpt current model'); } +async function buildChatGPTBackendHeaders(page, { includeAuthorization = false } = {}) { + if (typeof page.getCookies !== 'function') { + return { ok: false, status: 0, reason: 'missing-cookie-api' }; + } + const cookieLists = await Promise.all([ + page.getCookies({ url: CHATGPT_URL }).catch(() => []), + page.getCookies({ url: `${CHATGPT_URL}/api/auth/session` }).catch(() => []), + page.getCookies({ domain: CHATGPT_DOMAIN }).catch(() => []), + page.getCookies({ domain: `.${CHATGPT_DOMAIN}` }).catch(() => []), + page.getCookies().catch(() => []), + ]); + const cookiesByName = new Map(); + for (const cookie of cookieLists.flat()) { + if (!cookie?.name || typeof cookie.value !== 'string') continue; + if (!cookiesByName.has(cookie.name) || cookie.domain === CHATGPT_DOMAIN || cookie.domain === `.${CHATGPT_DOMAIN}`) { + cookiesByName.set(cookie.name, cookie); + } + } + const cookieHeader = Array.from(cookiesByName.values()) + .map((cookie) => `${cookie.name}=${cookie.value}`) + .join('; '); + if (!cookieHeader) return { ok: false, status: 0, reason: 'missing-cookies' }; + const headers = { + accept: 'application/json', + cookie: cookieHeader, + origin: CHATGPT_URL, + referer: `${CHATGPT_URL}/`, + 'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8', + 'sec-fetch-dest': 'empty', + 'sec-fetch-mode': 'cors', + 'sec-fetch-site': 'same-origin', + 'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36', + }; + if (!includeAuthorization) return { ok: true, status: 200, headers }; + + const sessionResponse = await fetch(`${CHATGPT_URL}/api/auth/session`, { + headers, + signal: AbortSignal.timeout(10000), + }); + if (!sessionResponse.ok) { + return { ok: false, status: sessionResponse.status, reason: 'session' }; + } + let session = null; + try { + session = await sessionResponse.json(); + } catch { + return { ok: false, status: sessionResponse.status, reason: 'session-json' }; + } + const accessToken = session?.accessToken; + if (!accessToken) return { ok: false, status: 0, reason: 'missing-access-token' }; + return { + ok: true, + status: 200, + headers: { + ...headers, + authorization: `Bearer ${accessToken}`, + }, + }; +} + +async function setChatGPTModelConfig(page, target) { + if (!target.modelConfig) return null; + const auth = await buildChatGPTBackendHeaders(page, { includeAuthorization: true }); + if (!auth.ok) return auth; + + const modelSlug = target.modelConfig.modelSlug; + const effort = target.modelConfig.effort; + const patchUrl = `${CHATGPT_URL}/backend-api/settings/user_last_used_model_config` + + `?model_slug=${encodeURIComponent(modelSlug)}` + + `&thinking_effort=${encodeURIComponent(effort)}`; + const response = await fetch(patchUrl, { + method: 'PATCH', + headers: auth.headers, + signal: AbortSignal.timeout(10000), + }); + let body = null; + try { body = await response.json(); } catch {} + if (!response.ok || body?.success !== true) { + return { ok: false, status: response.status, reason: 'patch', body }; + } + await page.evaluate(`(() => { + const value = encodeURIComponent(JSON.stringify({ model: ${JSON.stringify(modelSlug)}, effort: ${JSON.stringify(effort)} })); + for (const domain of ['; domain=.chatgpt.com', '; domain=chatgpt.com', '']) { + document.cookie = 'oai-last-model-config=; path=/' + domain + '; max-age=0; SameSite=Lax'; + } + document.cookie = 'oai-last-model-config=' + value + '; path=/; domain=.chatgpt.com; max-age=31536000; SameSite=Lax'; + document.cookie = 'oai-last-model-config=' + value + '; path=/; max-age=31536000; SameSite=Lax'; + if (window.location.pathname === '/new') window.location.reload(); + else window.location.assign('/new'); + return true; + })()`).catch(() => true); + return { ok: true, status: response.status, modelSlug, effort }; +} + export async function selectChatGPTModel(page, model) { const target = requireKnownChatGPTModel(model); + debugChatGPTModel(`target=${target.key}`); if (typeof page.nativeClick !== 'function') { throw new CommandExecutionError('ChatGPT model selection requires native browser click support.'); } await ensureOnChatGPT(page); + debugChatGPTModel('ensured chatgpt'); + const currentUrl = await currentChatGPTUrl(page).catch(() => ''); + debugChatGPTModel(`url=${currentUrl}`); + if (!currentUrl.startsWith(`${CHATGPT_URL}/new`)) { + await page.goto(`${CHATGPT_URL}/new`, { waitUntil: 'none' }); + await page.wait(2); + } await ensureChatGPTComposer(page, 'ChatGPT model selection requires a logged-in ChatGPT session with a visible composer.'); + debugChatGPTModel('composer ok'); const before = await getCurrentChatGPTModel(page); + debugChatGPTModel(`before=${before.model || 'none'}`); if (before.model === target.key) { return { Status: 'Already selected', Model: target.label }; } + const apiResult = await setChatGPTModelConfig(page, target); + debugChatGPTModel(`api=${apiResult ? JSON.stringify({ ok: apiResult.ok, status: apiResult.status, reason: apiResult.reason }) : 'none'}`); + if (apiResult) { + if (!apiResult.ok) { + if (apiResult.status === 401 || apiResult.status === 403) { + throw new AuthRequiredError(CHATGPT_DOMAIN, `ChatGPT model preference API rejected the current session while selecting ${target.label}.`); + } + debugChatGPTModel(`falling back to picker after api failure: ${apiResult.reason || 'unknown'}`); + } else { + debugChatGPTModel('config cookie set and reload scheduled'); + await page.wait(2); + await ensureChatGPTComposer(page, 'ChatGPT model selection requires a logged-in ChatGPT session with a visible composer.'); + const afterApi = await getCurrentChatGPTModel(page); + debugChatGPTModel(`after-api=${afterApi.model || 'none'}`); + if (afterApi.model === target.key) { + return { Status: 'Success', Model: target.label }; + } + debugChatGPTModel('api did not prove selection; falling back to visible picker'); + } + } + await page.wait(2); const menuButton = requireObjectEvaluateResult(unwrapEvaluateResult(await page.evaluate(`(() => { const isVisible = (el) => { diff --git a/clis/chatgpt/utils.test.js b/clis/chatgpt/utils.test.js index 8fb940a09..61ee643b7 100644 --- a/clis/chatgpt/utils.test.js +++ b/clis/chatgpt/utils.test.js @@ -49,6 +49,8 @@ function createDomEvaluatePage(html) { } return { dom, + goto: vi.fn().mockResolvedValue(undefined), + wait: vi.fn().mockResolvedValue(undefined), evaluate: vi.fn((script) => Promise.resolve(dom.window.eval(script))), }; } @@ -147,25 +149,132 @@ describe('chatgpt model selection validation', () => { it('clicks the model selector and verifies the selected postcondition', async () => { let objectCall = 0; const page = { + goto: vi.fn().mockResolvedValue(undefined), wait: vi.fn().mockResolvedValue(undefined), nativeClick: vi.fn().mockResolvedValue(undefined), evaluate: vi.fn((script) => { if (script === 'window.location.href') return Promise.resolve('https://chatgpt.com/c/demo'); objectCall += 1; if (objectCall === 1) return Promise.resolve({ isLoggedIn: true, hasLoginGate: false, hasComposer: true }); - if (objectCall === 2) return Promise.resolve({ model: 'instant', label: 'Instant' }); + if (objectCall === 2) return Promise.resolve({ model: 'balanced', label: 'Balanced' }); if (objectCall === 3) return Promise.resolve({ found: true, x: 10, y: 20 }); if (objectCall === 4) return Promise.resolve({ found: true, x: 30, y: 40 }); - if (objectCall === 5) return Promise.resolve({ model: 'pro', label: 'Pro' }); + if (objectCall === 5) return Promise.resolve({ model: 'fast', label: 'Fast' }); + return Promise.resolve({}); + }), + }; + + await expect(selectChatGPTModel(page, 'fast')).resolves.toEqual({ Status: 'Success', Model: 'Fast' }); + expect(page.nativeClick).toHaveBeenNthCalledWith(1, 10, 20); + expect(page.nativeClick).toHaveBeenNthCalledWith(2, 30, 40); + }); + + it('sets Advanced through the ChatGPT model config API when browser cookies are available', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(new Response(JSON.stringify({ accessToken: 'token' }), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ success: true }), { status: 200 })); + let objectCall = 0; + const page = { + goto: vi.fn().mockResolvedValue(undefined), + wait: vi.fn().mockResolvedValue(undefined), + nativeClick: vi.fn().mockResolvedValue(undefined), + getCookies: vi.fn().mockResolvedValue([{ name: '__Secure-next-auth.session-token', value: 'cookie', domain: '.chatgpt.com' }]), + evaluate: vi.fn((script) => { + if (script === 'window.location.href') return Promise.resolve('https://chatgpt.com/c/demo'); + if (String(script).includes('oai-last-model-config')) return Promise.resolve(true); + objectCall += 1; + if (objectCall === 1) return Promise.resolve({ isLoggedIn: true, hasLoginGate: false, hasComposer: true }); + if (objectCall === 2) return Promise.resolve({ model: 'balanced', label: 'Balanced' }); + if (objectCall === 3) return Promise.resolve({ isLoggedIn: true, hasLoginGate: false, hasComposer: true }); + if (objectCall === 4) return Promise.resolve({ model: 'advanced', label: 'Advanced' }); + return Promise.resolve({}); + }), + }; + + await expect(selectChatGPTModel(page, 'thinking')).resolves.toEqual({ Status: 'Success', Model: 'Advanced' }); + expect(fetchMock.mock.calls[1][0]).toContain('/backend-api/settings/user_last_used_model_config'); + expect(fetchMock.mock.calls[1][0]).toContain('model_slug=gpt-5-5-thinking'); + expect(fetchMock.mock.calls[1][0]).toContain('thinking_effort=extended'); + expect(page.nativeClick).not.toHaveBeenCalled(); + }); + + it('falls back to the visible picker when the model config API does not prove selection', async () => { + vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(new Response(JSON.stringify({ accessToken: 'token' }), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ success: true }), { status: 200 })); + let objectCall = 0; + const page = { + goto: vi.fn().mockResolvedValue(undefined), + wait: vi.fn().mockResolvedValue(undefined), + nativeClick: vi.fn().mockResolvedValue(undefined), + getCookies: vi.fn().mockResolvedValue([{ name: '__Secure-next-auth.session-token', value: 'cookie', domain: '.chatgpt.com' }]), + evaluate: vi.fn((script) => { + if (script === 'window.location.href') return Promise.resolve('https://chatgpt.com/c/demo'); + if (String(script).includes('oai-last-model-config')) return Promise.resolve(true); + objectCall += 1; + if (objectCall === 1) return Promise.resolve({ isLoggedIn: true, hasLoginGate: false, hasComposer: true }); + if (objectCall === 2) return Promise.resolve({ model: 'balanced', label: 'Balanced' }); + if (objectCall === 3) return Promise.resolve({ isLoggedIn: true, hasLoginGate: false, hasComposer: true }); + if (objectCall === 4) return Promise.resolve({ model: 'balanced', label: 'Balanced' }); + if (objectCall === 5) return Promise.resolve({ found: true, x: 10, y: 20 }); + if (objectCall === 6) return Promise.resolve({ found: true, x: 30, y: 40 }); + if (objectCall === 7) return Promise.resolve({ model: 'advanced', label: 'Advanced' }); return Promise.resolve({}); }), }; - await expect(selectChatGPTModel(page, 'pro')).resolves.toEqual({ Status: 'Success', Model: 'Pro' }); + await expect(selectChatGPTModel(page, 'advanced')).resolves.toEqual({ Status: 'Success', Model: 'Advanced' }); expect(page.nativeClick).toHaveBeenNthCalledWith(1, 10, 20); expect(page.nativeClick).toHaveBeenNthCalledWith(2, 30, 40); }); + it('falls back to the picker when the session API response is malformed', async () => { + vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(new Response('{', { status: 200 })); + let objectCall = 0; + const page = { + goto: vi.fn().mockResolvedValue(undefined), + wait: vi.fn().mockResolvedValue(undefined), + nativeClick: vi.fn().mockResolvedValue(undefined), + getCookies: vi.fn().mockResolvedValue([{ name: '__Secure-next-auth.session-token', value: 'cookie', domain: '.chatgpt.com' }]), + evaluate: vi.fn((script) => { + if (script === 'window.location.href') return Promise.resolve('https://chatgpt.com/c/demo'); + objectCall += 1; + if (objectCall === 1) return Promise.resolve({ isLoggedIn: true, hasLoginGate: false, hasComposer: true }); + if (objectCall === 2) return Promise.resolve({ model: 'balanced', label: 'Balanced' }); + if (objectCall === 3) return Promise.resolve({ found: true, x: 10, y: 20 }); + if (objectCall === 4) return Promise.resolve({ found: true, x: 30, y: 40 }); + if (objectCall === 5) return Promise.resolve({ model: 'advanced', label: 'Advanced' }); + return Promise.resolve({}); + }), + }; + + await expect(selectChatGPTModel(page, 'advanced')).resolves.toEqual({ Status: 'Success', Model: 'Advanced' }); + expect(page.nativeClick).toHaveBeenCalledTimes(2); + }); + + it('maps ChatGPT preference API auth rejection to AuthRequiredError', async () => { + vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(new Response(JSON.stringify({ accessToken: 'token' }), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ error: 'unauthorized' }), { status: 401 })); + let objectCall = 0; + const page = { + goto: vi.fn().mockResolvedValue(undefined), + wait: vi.fn().mockResolvedValue(undefined), + nativeClick: vi.fn().mockResolvedValue(undefined), + getCookies: vi.fn().mockResolvedValue([{ name: '__Secure-next-auth.session-token', value: 'cookie', domain: '.chatgpt.com' }]), + evaluate: vi.fn((script) => { + if (script === 'window.location.href') return Promise.resolve('https://chatgpt.com/c/demo'); + objectCall += 1; + if (objectCall === 1) return Promise.resolve({ isLoggedIn: true, hasLoginGate: false, hasComposer: true }); + if (objectCall === 2) return Promise.resolve({ model: 'balanced', label: 'Balanced' }); + return Promise.resolve({}); + }), + }; + + await expect(selectChatGPTModel(page, 'advanced')).rejects.toBeInstanceOf(AuthRequiredError); + }); + it('selects current Chinese intelligence options by exact visible menu text', async () => { const page = createDomEvaluatePage(`
@@ -185,11 +294,11 @@ describe('chatgpt model selection validation', () => { page.nativeClick = vi.fn().mockImplementation(async () => { clickCount += 1; if (clickCount === 2) { - page.evaluate(`document.querySelector('[data-testid="model-switcher-dropdown-button"]').textContent = 'GPT-5.5 高级'`); + page.evaluate(`document.querySelector('[data-testid="model-switcher-dropdown-button"]').textContent = 'GPT-5.5 超高'`); } }); - await expect(selectChatGPTModel(page, 'high')).resolves.toEqual({ Status: 'Success', Model: 'High' }); + await expect(selectChatGPTModel(page, 'very-high')).resolves.toEqual({ Status: 'Success', Model: 'Very High' }); expect(page.nativeClick).toHaveBeenCalledTimes(2); }); @@ -200,17 +309,16 @@ describe('chatgpt model selection validation', () => {
-
高级
+
极速
`); let clickCount = 0; - page.wait = vi.fn().mockResolvedValue(undefined); page.nativeClick = vi.fn().mockImplementation(async () => { clickCount += 1; if (clickCount === 2) { await page.evaluate(` const button = document.querySelector('[data-testid="model-switcher-dropdown-button"]'); - button.innerHTML = 'Mode raisonnement'; + button.innerHTML = 'Mode rapide'; `); for (const node of page.dom.window.document.querySelectorAll('[data-testid]')) { node.getBoundingClientRect = () => ({ width: 120, height: 36 }); @@ -219,7 +327,7 @@ describe('chatgpt model selection validation', () => { } }); - await expect(selectChatGPTModel(page, 'thinking')).resolves.toEqual({ Status: 'Success', Model: 'High' }); + await expect(selectChatGPTModel(page, 'instant')).resolves.toEqual({ Status: 'Success', Model: 'Fast' }); expect(page.nativeClick).toHaveBeenCalledTimes(2); }); @@ -240,7 +348,6 @@ describe('chatgpt model selection validation', () => { `); let clickCount = 0; - page.wait = vi.fn().mockResolvedValue(undefined); page.nativeClick = vi.fn().mockImplementation(async () => { clickCount += 1; if (clickCount === 2) { @@ -248,7 +355,7 @@ describe('chatgpt model selection validation', () => { } }); - await expect(selectChatGPTModel(page, 'extra-high')).resolves.toEqual({ Status: 'Success', Model: 'Extra High' }); + await expect(selectChatGPTModel(page, 'extra-high')).resolves.toEqual({ Status: 'Success', Model: 'Very High' }); expect(page.nativeClick).toHaveBeenCalledTimes(2); }); @@ -269,7 +376,6 @@ describe('chatgpt model selection validation', () => { `); let clickCount = 0; - page.wait = vi.fn().mockResolvedValue(undefined); page.nativeClick = vi.fn().mockImplementation(async () => { clickCount += 1; if (clickCount === 2) { @@ -277,11 +383,11 @@ describe('chatgpt model selection validation', () => { } }); - await expect(selectChatGPTModel(page, 'instant')).resolves.toEqual({ Status: 'Success', Model: 'Instant' }); + await expect(selectChatGPTModel(page, 'instant')).resolves.toEqual({ Status: 'Success', Model: 'Fast' }); expect(page.nativeClick).toHaveBeenCalledTimes(2); }); - it('selects High when the current precise level is Extra High', async () => { + it('selects Balanced when the current precise level is Extra High', async () => { const page = createDomEvaluatePage(`
@@ -298,44 +404,14 @@ describe('chatgpt model selection validation', () => { `); let clickCount = 0; - page.wait = vi.fn().mockResolvedValue(undefined); - page.nativeClick = vi.fn().mockImplementation(async () => { - clickCount += 1; - if (clickCount === 2) { - page.evaluate(`document.querySelector('[data-testid="model-switcher-dropdown-button"]').textContent = 'High'`); - } - }); - - await expect(selectChatGPTModel(page, 'high')).resolves.toEqual({ Status: 'Success', Model: 'High' }); - expect(page.nativeClick).toHaveBeenCalledTimes(2); - }); - - it('thinking alias selects the High intelligence level', async () => { - const page = createDomEvaluatePage(` - - -
-
-
-
-
Instant
-
Medium
-
High
-
Extra High
-
Pro
-
-
- `); - let clickCount = 0; - page.wait = vi.fn().mockResolvedValue(undefined); page.nativeClick = vi.fn().mockImplementation(async () => { clickCount += 1; if (clickCount === 2) { - page.evaluate(`document.querySelector('[data-testid="model-switcher-dropdown-button"]').textContent = 'High'`); + page.evaluate(`document.querySelector('[data-testid="model-switcher-dropdown-button"]').textContent = 'Medium'`); } }); - await expect(selectChatGPTModel(page, 'thinking')).resolves.toEqual({ Status: 'Success', Model: 'High' }); + await expect(selectChatGPTModel(page, 'balanced')).resolves.toEqual({ Status: 'Success', Model: 'Balanced' }); expect(page.nativeClick).toHaveBeenCalledTimes(2); }); @@ -356,7 +432,6 @@ describe('chatgpt model selection validation', () => { `); let clickCount = 0; - page.wait = vi.fn().mockResolvedValue(undefined); page.nativeClick = vi.fn().mockImplementation(async () => { clickCount += 1; if (clickCount === 2) { @@ -366,7 +441,7 @@ describe('chatgpt model selection validation', () => { } }); - await expect(selectChatGPTModel(page, 'extra-high')).resolves.toEqual({ Status: 'Success', Model: 'Extra High' }); + await expect(selectChatGPTModel(page, 'extra-high')).resolves.toEqual({ Status: 'Success', Model: 'Very High' }); expect(page.nativeClick).toHaveBeenCalledTimes(4); }); @@ -386,12 +461,11 @@ describe('chatgpt model selection validation', () => { `); - page.wait = vi.fn().mockResolvedValue(undefined); page.nativeClick = vi.fn().mockResolvedValue(undefined); await expect(selectChatGPTModel(page, 'extra-high')).rejects.toMatchObject({ code: 'COMMAND_EXEC', - message: expect.stringContaining('Could not click the ChatGPT Extra High model option'), + message: expect.stringContaining('Could not click the ChatGPT Very High model option'), }); }); @@ -410,35 +484,35 @@ describe('chatgpt model selection validation', () => { `); - page.wait = vi.fn().mockResolvedValue(undefined); page.nativeClick = vi.fn().mockResolvedValue(undefined); - await expect(selectChatGPTModel(page, 'pro')).rejects.toMatchObject({ + await expect(selectChatGPTModel(page, 'very-high')).rejects.toMatchObject({ code: 'COMMAND_EXEC', - message: expect.stringContaining('Could not click the ChatGPT Pro model option'), + message: expect.stringContaining('Could not click the ChatGPT Very High model option'), }); }); it('fails closed when the postcondition does not prove the requested model', async () => { let objectCall = 0; const page = { + goto: vi.fn().mockResolvedValue(undefined), wait: vi.fn().mockResolvedValue(undefined), nativeClick: vi.fn().mockResolvedValue(undefined), evaluate: vi.fn((script) => { if (script === 'window.location.href') return Promise.resolve('https://chatgpt.com/c/demo'); objectCall += 1; if (objectCall === 1) return Promise.resolve({ isLoggedIn: true, hasLoginGate: false, hasComposer: true }); - if (objectCall === 2) return Promise.resolve({ model: 'instant', label: 'Instant' }); + if (objectCall === 2) return Promise.resolve({ model: 'balanced', label: 'Balanced' }); if (objectCall === 3) return Promise.resolve({ found: true, x: 10, y: 20 }); if (objectCall === 4) return Promise.resolve({ found: true, x: 30, y: 40 }); - if (objectCall === 5) return Promise.resolve({ model: 'instant', label: 'Instant' }); + if (objectCall === 5) return Promise.resolve({ model: 'balanced', label: 'Balanced' }); return Promise.resolve({}); }), }; - await expect(selectChatGPTModel(page, 'pro')).rejects.toMatchObject({ + await expect(selectChatGPTModel(page, 'fast')).rejects.toMatchObject({ code: 'COMMAND_EXEC', - message: expect.stringContaining('did not switch to Pro'), + message: expect.stringContaining('did not switch to Fast'), }); }); }); @@ -674,16 +748,16 @@ describe('chatgpt generation state', () => { describe('chatgpt current model detection', () => { it.each([ - ['Instant', { model: 'instant', label: 'Instant' }], - ['Medium', { model: 'medium', label: 'Medium' }], - ['Thinking', { model: 'high', label: 'High' }], - ['High', { model: 'high', label: 'High' }], - ['Extra High', { model: 'extra-high', label: 'Extra High' }], + ['Instant', { model: 'fast', label: 'Fast' }], + ['Medium', { model: 'balanced', label: 'Balanced' }], + ['Thinking', { model: 'advanced', label: 'Advanced' }], + ['High', { model: 'advanced', label: 'Advanced' }], + ['Extra High', { model: 'very-high', label: 'Very High' }], ['Pro', { model: 'pro', label: 'Pro' }], - ['GPT-5.5 极速', { model: 'instant', label: 'Instant' }], - ['GPT-5.5 均衡', { model: 'medium', label: 'Medium' }], - ['智能水平 高级', { model: 'high', label: 'High' }], - ['GPT-5.5 超高', { model: 'extra-high', label: 'Extra High' }], + ['GPT-5.5 极速', { model: 'fast', label: 'Fast' }], + ['GPT-5.5 均衡', { model: 'balanced', label: 'Balanced' }], + ['智能水平 高级', { model: 'advanced', label: 'Advanced' }], + ['GPT-5.5 超高', { model: 'very-high', label: 'Very High' }], ['GPT-5.5 专业', { model: 'pro', label: 'Pro' }], ['进阶专业', { model: 'pro', label: 'Pro' }], ])('detects the visible %s model label', async (label, expected) => { diff --git a/docs/adapters/browser/chatgpt.md b/docs/adapters/browser/chatgpt.md index 1e957035c..a8e327a97 100644 --- a/docs/adapters/browser/chatgpt.md +++ b/docs/adapters/browser/chatgpt.md @@ -39,10 +39,10 @@ opencli chatgpt new opencli chatgpt image "a cyberpunk city at night" # Switch ChatGPT's intelligence level -opencli chatgpt model instant -opencli chatgpt model medium -opencli chatgpt model high -opencli chatgpt model extra-high +opencli chatgpt model fast +opencli chatgpt model balanced +opencli chatgpt model advanced +opencli chatgpt model very-high opencli chatgpt model pro # Upload a local image, ask ChatGPT to edit it, and save the result @@ -70,14 +70,14 @@ opencli chatgpt image "a tiny watercolor fox" --sd true | `--image` | Local image path to attach before prompting; comma-separated paths are supported | | `--op` | Output directory for downloaded images (default: `~/Pictures/chatgpt`) | | `--sd` | Skip download and only print the ChatGPT conversation link | -| `model` | ChatGPT intelligence level for `model`: `instant`, `medium`, `high`, `extra-high`, or `pro`; `thinking` is accepted as a backward-compatible alias for `high` | +| `model` | ChatGPT intelligence level for `model`: `fast`, `balanced`, `advanced`, `very-high`, or `pro`; aliases include `speed`/`instant`, `balance`/`medium`, `high`/`thinking`, `ultra`/`xhigh`/`x-high`/`extra-high`, `professional`, and the Chinese labels `极速`/`均衡`/`高级`/`超高`/`专业` | ## Behavior - ChatGPT web commands use persistent site sessions by default, so consecutive `ask` / `send` / `read` / `detail` commands continue in the same ChatGPT tab. Use `--site-session ephemeral` for one-shot isolated tabs. - `ask` waits for the first stable assistant response after sending. `send` submits only and returns immediately. - `history` reads visible `/c/` links from the ChatGPT sidebar; it does not use private backend APIs. -- `model` switches the visible ChatGPT web intelligence level. It recognizes the current English labels (`Instant`, `Medium`, `High`, `Extra High`, `Pro`) and Chinese labels (`极速`, `均衡`, `高级`, `超高`, `专业`). If labels are localized differently, it only falls back to option order after confirming the guarded five-option ChatGPT intelligence picker structure. +- `model` switches the visible ChatGPT web intelligence level. It recognizes the current English labels (`Fast`, `Balanced`, `Advanced`, `Very High`, `Pro`), legacy labels (`Instant`, `Medium`, `High`, `Extra High`), and Chinese labels (`极速`, `均衡`, `高级`, `超高`, `专业`). Advanced and Pro first try ChatGPT's authenticated backend model preference update, then the adapter falls back to the visible picker for UI-only levels. If labels are localized differently, it only falls back to option order after confirming the guarded five-option ChatGPT intelligence picker structure. - `image` opens a fresh `chatgpt.com/new` page before sending the image prompt. - When `--image` is provided, local images are uploaded first and the prompt is sent as an image edit request. - `image` output is plain `status / file / link`, not a markdown table. diff --git a/skills/opencli-browser/SKILL.md b/skills/opencli-browser/SKILL.md index db362d407..d83a68ff9 100644 --- a/skills/opencli-browser/SKILL.md +++ b/skills/opencli-browser/SKILL.md @@ -60,7 +60,7 @@ Bound sessions have no OpenCLI idle-close timer; the binding lasts until `unbind ## Critical rules 1. **Always inspect before you act.** Run `state` or `find` first. Never hard-code a ref or selector from memory across sessions — indices are per-snapshot. -2. **Prefer site adapters before raw browser driving.** If `opencli ` already covers the task, use that adapter command first (`opencli facebook notifications`, `opencli reddit read`, etc.). Use `opencli browser ...` only for gaps, debugging, or one-off UI flows the adapter does not expose. +2. **Prefer site adapters before raw browser driving.** If `opencli ` already covers the task, use that adapter command first (`opencli facebook notifications`, `opencli reddit read`, `opencli chatgpt model `, etc.). Use `opencli browser ...` only for gaps, debugging, or one-off UI flows the adapter does not expose. 3. **Prefer numeric ref over CSS once you have it.** Numeric refs survive mild DOM shifts because the CLI fingerprints each tagged element. A CSS selector written by hand will break the first time the site re-renders. 4. **Read `match_level` after every write.** `exact` = all good. `stable` = the element is the same but some soft attrs drifted — your action still applied. `reidentified` = the original ref was gone and the CLI found a unique replacement; double-check you hit the right element. 5. **Use the `compound` field for form controls.** Do not regex-guess a date format, do not `state` twice to get the full ``. From d6a701145414e21c5bdfa1d7f8b0f50af79dd886 Mon Sep 17 00:00:00 2001 From: Marvin <48939577+lavapapa@users.noreply.github.com> Date: Wed, 1 Jul 2026 02:12:06 +0800 Subject: [PATCH 12/27] Add ChatGPT Deep Research result extraction (#2023) * Add ChatGPT Deep Research result extraction * fix(chatgpt): bind deep research results to requested conversation * fix(chatgpt): preserve deep research payload failures --------- Co-authored-by: jackwener --- cli-manifest.json | 53 +++ clis/chatgpt/commands.test.js | 130 ++++++ clis/chatgpt/deep-research-result.js | 79 ++++ clis/chatgpt/utils.js | 656 +++++++++++++++++++++++++++ clis/chatgpt/utils.test.js | 125 +++++ docs/adapters/browser/chatgpt.md | 7 + docs/adapters/index.md | 2 +- skills/opencli-usage/SKILL.md | 2 + 8 files changed, 1053 insertions(+), 1 deletion(-) create mode 100644 clis/chatgpt/deep-research-result.js diff --git a/cli-manifest.json b/cli-manifest.json index 2a8b6befa..caaf33bfd 100644 --- a/cli-manifest.json +++ b/cli-manifest.json @@ -6246,6 +6246,59 @@ "navigateBefore": false, "siteSession": "persistent" }, + { + "site": "chatgpt", + "name": "deep-research-result", + "description": "Read a completed ChatGPT Deep Research report from the conversation payload", + "access": "read", + "domain": "chatgpt.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "id", + "type": "str", + "required": true, + "positional": true, + "help": "Conversation ID or full /c/ URL" + }, + { + "name": "wait", + "type": "boolean", + "default": false, + "required": false, + "help": "Wait until Deep Research completes or becomes extractable" + }, + { + "name": "timeout", + "type": "int", + "default": 120, + "required": false, + "help": "Max seconds to wait when --wait is true" + }, + { + "name": "stable", + "type": "int", + "default": 6, + "required": false, + "help": "Seconds the report text must remain unchanged when --wait is true" + } + ], + "columns": [ + "conversationId", + "status", + "report", + "sources", + "url", + "method", + "diagnostics" + ], + "type": "js", + "modulePath": "chatgpt/deep-research-result.js", + "sourceFile": "chatgpt/deep-research-result.js", + "navigateBefore": false, + "siteSession": "persistent" + }, { "site": "chatgpt", "name": "detail", diff --git a/clis/chatgpt/commands.test.js b/clis/chatgpt/commands.test.js index a768ec428..d8d295da8 100644 --- a/clis/chatgpt/commands.test.js +++ b/clis/chatgpt/commands.test.js @@ -2,12 +2,14 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { afterEach, describe, expect, it, vi } from 'vitest'; +import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors'; import { getRegistry } from '@jackwener/opencli/registry'; import './ask.js'; import './send.js'; import './read.js'; import './history.js'; import './detail.js'; +import './deep-research-result.js'; import './new.js'; import './status.js'; import './image.js'; @@ -50,6 +52,7 @@ describe('chatgpt browser command registration', () => { read: 'read', history: 'read', detail: 'read', + 'deep-research-result': 'read', new: 'read', status: 'read', image: 'write', @@ -119,6 +122,133 @@ describe('chatgpt browser command registration', () => { expect(detail.columns).toEqual(['Index', 'Role', 'Text', 'Generating', 'StableSeconds']); }); + it('registers deep research result command with wait options', () => { + const command = getRegistry().get('chatgpt/deep-research-result'); + expect(command.args).toEqual(expect.arrayContaining([ + expect.objectContaining({ name: 'id', positional: true, required: true }), + expect.objectContaining({ name: 'wait', type: 'boolean', default: false }), + expect.objectContaining({ name: 'timeout', type: 'int', default: 120 }), + expect.objectContaining({ name: 'stable', type: 'int', default: 6 }), + ])); + expect(command.columns).toEqual(['conversationId', 'status', 'report', 'sources', 'url', 'method', 'diagnostics']); + }); + + it('does not return a success row when no completed deep research report exists', async () => { + const command = getRegistry().get('chatgpt/deep-research-result'); + const page = { + goto: vi.fn().mockResolvedValue(undefined), + wait: vi.fn().mockResolvedValue(undefined), + startNetworkCapture: vi.fn().mockResolvedValue(true), + readNetworkCapture: vi.fn().mockResolvedValue([]), + getCookies: vi.fn().mockResolvedValue([]), + evaluate: vi.fn((script) => { + const s = String(script); + if (s === 'window.location.href') return Promise.resolve('https://chatgpt.com/'); + if (s.includes("fetch('/backend-api/conversation/requested123'")) { + return Promise.resolve({ + ok: true, + status: 200, + contentType: 'application/json', + text: JSON.stringify({ mapping: {} }), + }); + } + if (s.includes("document.querySelectorAll('iframe')")) { + return Promise.resolve({ + url: 'https://chatgpt.com/c/requested123', + title: 'ChatGPT', + iframes: [], + deepResearchIframe: null, + }); + } + if (s.includes('composerSelectors') && s.includes('hasComposer')) { + return Promise.resolve({ + url: 'https://chatgpt.com/c/requested123', + title: 'ChatGPT', + hasComposer: true, + isLoggedIn: true, + hasLoginGate: false, + }); + } + if (s.includes('Stop generating') || s.includes('Thinking')) return Promise.resolve(false); + return Promise.resolve(undefined); + }), + }; + + await expect(command.func(page, { id: 'requested123' })) + .rejects.toBeInstanceOf(EmptyResultError); + }); + + it('typed-fails malformed deep research source rows instead of falling back to empty success', async () => { + const command = getRegistry().get('chatgpt/deep-research-result'); + const report = `# Executive Summary\n\n${'Completed Deep Research report paragraph with enough detail to pass extraction heuristics. '.repeat(12)}\n\n## Sources`; + const payload = { + conversation_id: 'requested123', + mapping: { + report_node: { + message: { + metadata: { + chatgpt_sdk: { + widget_state: JSON.stringify({ + status: 'completed', + report_message: { + id: 'report-msg', + content: { parts: [report] }, + metadata: { + search_result_groups: [ + { entries: [{ title: 'Source without URL' }] }, + ], + }, + }, + }), + }, + }, + }, + }, + }, + }; + const page = { + goto: vi.fn().mockResolvedValue(undefined), + wait: vi.fn().mockResolvedValue(undefined), + startNetworkCapture: vi.fn().mockResolvedValue(true), + readNetworkCapture: vi.fn().mockResolvedValue([]), + getCookies: vi.fn().mockResolvedValue([]), + evaluate: vi.fn((script) => { + const s = String(script); + if (s === 'window.location.href') return Promise.resolve('https://chatgpt.com/'); + if (s.includes("fetch('/backend-api/conversation/requested123'")) { + return Promise.resolve({ + ok: true, + status: 200, + contentType: 'application/json', + text: JSON.stringify(payload), + }); + } + if (s.includes("document.querySelectorAll('iframe')")) { + return Promise.resolve({ + url: 'https://chatgpt.com/c/requested123', + title: 'ChatGPT', + iframes: [], + deepResearchIframe: null, + }); + } + if (s.includes('composerSelectors') && s.includes('hasComposer')) { + return Promise.resolve({ + url: 'https://chatgpt.com/c/requested123', + title: 'ChatGPT', + hasComposer: true, + isLoggedIn: true, + hasLoginGate: false, + }); + } + if (s.includes('Stop generating') || s.includes('Thinking')) return Promise.resolve(false); + return Promise.resolve(undefined); + }), + }; + + await expect(command.func(page, { id: 'requested123' })) + .rejects.toBeInstanceOf(CommandExecutionError); + }); + it('registers project routing on chat-starting commands', () => { for (const name of ['new', 'image', 'model']) { const cmd = getRegistry().get(`chatgpt/${name}`); diff --git a/clis/chatgpt/deep-research-result.js b/clis/chatgpt/deep-research-result.js new file mode 100644 index 000000000..a92ba8d82 --- /dev/null +++ b/clis/chatgpt/deep-research-result.js @@ -0,0 +1,79 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { EmptyResultError } from '@jackwener/opencli/errors'; +import { + CHATGPT_DOMAIN, + CHATGPT_URL, + currentChatGPTUrl, + ensureChatGPTLogin, + getChatGPTDeepResearchResult, + normalizeBooleanFlag, + parseChatGPTConversationId, + requireNonNegativeInt, + requirePositiveInt, + waitForChatGPTDeepResearchResult, +} from './utils.js'; + +export const deepResearchResultCommand = cli({ + site: 'chatgpt', + name: 'deep-research-result', + access: 'read', + description: 'Read a completed ChatGPT Deep Research report from the conversation payload', + domain: CHATGPT_DOMAIN, + strategy: Strategy.COOKIE, + browser: true, + siteSession: 'persistent', + navigateBefore: false, + args: [ + { name: 'id', positional: true, required: true, help: 'Conversation ID or full /c/ URL' }, + { name: 'wait', type: 'boolean', default: false, help: 'Wait until Deep Research completes or becomes extractable' }, + { name: 'timeout', type: 'int', default: 120, help: 'Max seconds to wait when --wait is true' }, + { name: 'stable', type: 'int', default: 6, help: 'Seconds the report text must remain unchanged when --wait is true' }, + ], + columns: ['conversationId', 'status', 'report', 'sources', 'url', 'method', 'diagnostics'], + func: async (page, kwargs) => { + const id = parseChatGPTConversationId(kwargs.id); + const shouldWait = normalizeBooleanFlag(kwargs.wait, false); + const timeout = requirePositiveInt( + Number(kwargs.timeout ?? 120), + 'chatgpt deep-research-result --timeout', + 'Example: opencli chatgpt deep-research-result --wait true --timeout 600', + ); + const stableSeconds = requireNonNegativeInt( + Number(kwargs.stable ?? 6), + 'chatgpt deep-research-result --stable', + 'Example: opencli chatgpt deep-research-result --wait true --stable 6', + ); + const targetUrl = `${CHATGPT_URL}/c/${id}`; + await page.readNetworkCapture?.().catch(() => []); + const currentUrl = await currentChatGPTUrl(page).catch(() => ''); + if (currentUrl.startsWith(targetUrl)) { + await page.goto(`${CHATGPT_URL}/?opencli_dr_result=${Date.now()}`, { waitUntil: 'none' }); + await page.wait(1); + } + await page.startNetworkCapture?.('/backend-api/conversation/').catch(() => false); + await page.goto(targetUrl, { waitUntil: 'none' }); + await page.wait(3); + await ensureChatGPTLogin(page, 'ChatGPT deep-research-result requires a logged-in ChatGPT session.'); + + const result = shouldWait + ? await waitForChatGPTDeepResearchResult(page, { conversationId: id, timeoutSeconds: timeout, stableSeconds }) + : await getChatGPTDeepResearchResult(page, { conversationId: id, useBridgeProbes: true }); + + if (result.status !== 'completed' || !result.report) { + throw new EmptyResultError( + 'chatgpt deep-research-result', + `No completed Deep Research report was found for conversation ${id}.`, + ); + } + + return [{ + conversationId: id, + status: result.status, + report: result.report || '', + sources: result.sources || [], + url: result.url || targetUrl, + method: result.method || '', + diagnostics: result.diagnostics || {}, + }]; + }, +}); diff --git a/clis/chatgpt/utils.js b/clis/chatgpt/utils.js index 062da56cc..1409f7864 100644 --- a/clis/chatgpt/utils.js +++ b/clis/chatgpt/utils.js @@ -1180,6 +1180,658 @@ export async function waitForChatGPTDetailRows(page, { wantMarkdown = false, tim ); } +function normalizeDeepResearchText(value) { + return String(value || '') + .replace(/\u00a0/g, ' ') + .replace(/[ \t]+\n/g, '\n') + .replace(/\n{3,}/g, '\n\n') + .trim(); +} + +function looksLikeDeepResearchReport(text) { + const normalized = normalizeDeepResearchText(text); + if (normalized.length < 500) return false; + return /(^|\n)\s*#{1,3}\s+\S|Sources|References|参考|来源|结论|建议|Executive Summary|摘要/i.test(normalized); +} + +function parseJsonMaybe(value) { + if (!value) return null; + if (typeof value === 'object') return value; + if (typeof value !== 'string') return null; + try { + return JSON.parse(value); + } catch { + return null; + } +} + +function extractDeepResearchSourcesFromReportMessage(reportMessage) { + const metadata = reportMessage?.metadata && typeof reportMessage.metadata === 'object' + ? reportMessage.metadata + : {}; + const references = Array.isArray(metadata.content_references) ? metadata.content_references : []; + const safeUrls = Array.isArray(metadata.safe_urls) ? metadata.safe_urls : []; + const groups = Array.isArray(metadata.search_result_groups) ? metadata.search_result_groups : []; + const byUrl = new Map(); + + const addSource = (source = {}, label = 'source') => { + if (!source || typeof source !== 'object') { + throw new CommandExecutionError(`Malformed ChatGPT Deep Research ${label}: expected object source row.`); + } + const rawUrl = String(source.url || source.href || source.safe_url || '').trim(); + const title = String(source.title || source.name || source.text || '').trim(); + if (!rawUrl) { + if (title || source.matched_text || source.metadata) { + throw new CommandExecutionError(`Malformed ChatGPT Deep Research ${label}: missing source URL.`); + } + return; + } + if (!/^https?:\/\//i.test(rawUrl)) { + throw new CommandExecutionError(`Malformed ChatGPT Deep Research ${label}: invalid source URL.`); + } + if (!byUrl.has(rawUrl)) { + byUrl.set(rawUrl, { title, url: rawUrl }); + } else if (title && !byUrl.get(rawUrl).title) { + byUrl.get(rawUrl).title = title; + } + }; + + for (const reference of references) { + const hasDirectSource = reference && typeof reference === 'object' + && (reference.url || reference.href || reference.safe_url || reference.title || reference.name || reference.text || reference.matched_text); + if (hasDirectSource) addSource(reference, 'content reference'); + if (reference?.matched_text) addSource({ title: reference.matched_text, url: reference.url }, 'matched content reference'); + if (reference?.metadata) addSource(reference.metadata, 'content reference metadata'); + } + for (const url of safeUrls) addSource(typeof url === 'string' ? { url } : url, 'safe URL'); + for (const group of groups) { + for (const entry of [ + ...(Array.isArray(group?.entries) ? group.entries : []), + ...(Array.isArray(group?.results) ? group.results : []), + ...(Array.isArray(group?.items) ? group.items : []), + ]) { + addSource(entry, 'search result'); + } + } + return [...byUrl.values()].slice(0, 200); +} + +function extractDeepResearchFromWidgetState(widgetState, source = 'conversation-widget-state') { + const state = parseJsonMaybe(widgetState); + if (!state || typeof state !== 'object') return null; + const reportMessage = state.report_message || state.reportMessage || null; + const parts = Array.isArray(reportMessage?.content?.parts) ? reportMessage.content.parts : []; + const report = normalizeDeepResearchText(parts.filter((part) => typeof part === 'string').join('\n\n')); + if (!looksLikeDeepResearchReport(report)) return null; + return { + status: 'completed', + report, + html: '', + method: source, + sources: extractDeepResearchSourcesFromReportMessage(reportMessage), + widgetStatus: String(state.status || ''), + reportMessageId: String(reportMessage?.id || ''), + reportLength: report.length, + }; +} + +function extractDeepResearchFromConversationPayload(payload, { expectedConversationId = '' } = {}) { + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + throw new CommandExecutionError('Malformed ChatGPT conversation payload for Deep Research extraction.'); + } + const payloadConversationId = String(payload.conversation_id || payload.conversationId || payload.id || '').trim(); + if (expectedConversationId && payloadConversationId && payloadConversationId !== expectedConversationId) { + throw new CommandExecutionError( + `ChatGPT conversation payload id mismatch: expected ${expectedConversationId}, got ${payloadConversationId}.`, + ); + } + const mapping = payload?.mapping && typeof payload.mapping === 'object' ? payload.mapping : {}; + if (!payload.mapping || typeof payload.mapping !== 'object' || Array.isArray(payload.mapping)) { + throw new CommandExecutionError('Malformed ChatGPT conversation payload for Deep Research extraction: missing mapping.'); + } + const candidates = []; + for (const [messageId, node] of Object.entries(mapping)) { + const message = node?.message || {}; + const sdk = message?.metadata?.chatgpt_sdk; + for (const widgetState of [ + sdk?.widget_state, + sdk?.widgetState, + message?.metadata?.widget_state, + message?.metadata?.widgetState, + ]) { + const extracted = extractDeepResearchFromWidgetState(widgetState); + if (extracted) { + candidates.push({ + ...extracted, + conversationMessageId: messageId, + }); + } + } + } + candidates.sort((a, b) => b.report.length - a.report.length); + return candidates[0] || null; +} + +function conversationIdFromBackendConversationUrl(url) { + const match = String(url || '').match(/\/backend-api\/conversation\/([^/?#]+)/); + return match?.[1] ? decodeURIComponent(match[1]) : ''; +} + +function extractDeepResearchFromNetworkEntries(entries, { expectedConversationId = '' } = {}) { + const candidates = []; + for (const entry of Array.isArray(entries) ? entries : []) { + const url = String(entry?.url || ''); + if (!/\/backend-api\/conversation\//.test(url)) continue; + const entryConversationId = conversationIdFromBackendConversationUrl(url); + if (expectedConversationId && entryConversationId !== expectedConversationId) continue; + const body = parseJsonMaybe(entry?.responsePreview) || parseJsonMaybe(entry?.body) || null; + if (!body) { + throw new CommandExecutionError(`Malformed ChatGPT conversation network payload for ${entryConversationId || 'unknown conversation'}.`); + } + const extracted = extractDeepResearchFromConversationPayload(body, { expectedConversationId }); + if (extracted) { + candidates.push({ + ...extracted, + method: 'network-conversation-widget-state', + networkUrl: url, + }); + } + } + candidates.sort((a, b) => b.report.length - a.report.length); + return candidates[0] || null; +} + +function conversationIdFromUrl(url) { + const match = String(url || '').match(/\/c\/([a-zA-Z0-9_-]+)/); + return match?.[1] || ''; +} + +async function buildChatGPTConversationHeaders(page, { includeAuthorization = false } = {}) { + if (typeof page.getCookies !== 'function') { + return { ok: false, status: 0, reason: 'missing-cookie-api' }; + } + const cookieLists = await Promise.all([ + page.getCookies({ url: CHATGPT_URL }).catch(() => []), + page.getCookies({ url: `${CHATGPT_URL}/api/auth/session` }).catch(() => []), + page.getCookies({ domain: CHATGPT_DOMAIN }).catch(() => []), + page.getCookies({ domain: `.${CHATGPT_DOMAIN}` }).catch(() => []), + page.getCookies().catch(() => []), + ]); + const cookiesByName = new Map(); + for (const cookie of cookieLists.flat()) { + if (!cookie?.name || typeof cookie.value !== 'string') continue; + if (!cookiesByName.has(cookie.name) || cookie.domain === CHATGPT_DOMAIN || cookie.domain === `.${CHATGPT_DOMAIN}`) { + cookiesByName.set(cookie.name, cookie); + } + } + const cookieHeader = Array.from(cookiesByName.values()) + .map((cookie) => `${cookie.name}=${cookie.value}`) + .join('; '); + if (!cookieHeader) return { ok: false, status: 0, reason: 'missing-cookies' }; + const headers = { + accept: 'application/json', + cookie: cookieHeader, + origin: CHATGPT_URL, + referer: `${CHATGPT_URL}/`, + 'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8', + 'sec-fetch-dest': 'empty', + 'sec-fetch-mode': 'cors', + 'sec-fetch-site': 'same-origin', + 'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36', + }; + if (!includeAuthorization) return { ok: true, status: 200, headers }; + + const sessionResponse = await fetch(`${CHATGPT_URL}/api/auth/session`, { + headers, + signal: AbortSignal.timeout(10000), + }); + if (!sessionResponse.ok) { + return { ok: false, status: sessionResponse.status, reason: 'session' }; + } + const session = await sessionResponse.json(); + const accessToken = session?.accessToken; + if (!accessToken) return { ok: false, status: 0, reason: 'missing-access-token' }; + return { + ok: true, + status: 200, + headers: { + ...headers, + authorization: `Bearer ${accessToken}`, + }, + }; +} + +async function fetchChatGPTConversationPayload(page, conversationId) { + if (!conversationId) return null; + const errors = []; + try { + const cookieAuth = await buildChatGPTConversationHeaders(page, { includeAuthorization: false }); + if (cookieAuth.ok) { + const response = await fetch(`${CHATGPT_URL}/backend-api/conversation/${conversationId}`, { + headers: { + ...cookieAuth.headers, + referer: `${CHATGPT_URL}/c/${conversationId}`, + }, + signal: AbortSignal.timeout(8000), + }); + const text = await response.text(); + if (response.ok) { + const payload = parseJsonMaybe(text); + if (payload) { + return { + payload, + status: response.status, + contentType: response.headers.get('content-type') || '', + transport: 'node-fetch-cookie', + }; + } + errors.push('node fetch returned non-json'); + } else if (response.status === 401 || response.status === 403) { + errors.push(`node cookie fetch status ${response.status}`); + const bearerAuth = await buildChatGPTConversationHeaders(page, { includeAuthorization: true }); + if (bearerAuth.ok) { + const bearerResponse = await fetch(`${CHATGPT_URL}/backend-api/conversation/${conversationId}`, { + headers: { + ...bearerAuth.headers, + referer: `${CHATGPT_URL}/c/${conversationId}`, + }, + signal: AbortSignal.timeout(8000), + }); + const bearerText = await bearerResponse.text(); + if (bearerResponse.ok) { + const payload = parseJsonMaybe(bearerText); + if (payload) { + return { + payload, + status: bearerResponse.status, + contentType: bearerResponse.headers.get('content-type') || '', + transport: 'node-fetch-bearer', + }; + } + errors.push('node bearer fetch returned non-json'); + } else { + errors.push(`node bearer fetch status ${bearerResponse.status}`); + } + } else { + errors.push(`node bearer auth ${bearerAuth.reason || bearerAuth.status || 'failed'}`); + } + } else { + errors.push(`node fetch status ${response.status}`); + } + } else { + errors.push(`node cookie auth ${cookieAuth.reason || cookieAuth.status || 'failed'}`); + } + } catch (error) { + errors.push(`node fetch ${String(error?.message || error)}`); + } + + const result = unwrapEvaluateResult(await withTimeout(page.evaluate(`(async () => { + const response = await fetch('/backend-api/conversation/${conversationId}', { + credentials: 'include', + headers: { accept: 'application/json' }, + }); + const text = await response.text(); + return { + ok: response.ok, + status: response.status, + contentType: response.headers.get('content-type') || '', + text, + }; + })()`), 8000, 'conversation fetch')); + if (!result?.ok) { + return { error: [...errors, `page fetch status ${result?.status || 0}`].join('; ') }; + } + const payload = parseJsonMaybe(result.text); + if (!payload) return { error: [...errors, 'page fetch returned non-json'].join('; ') }; + return { payload, status: result.status, contentType: result.contentType, transport: 'page-fetch' }; +} + +function collectAxText(tree) { + const nodes = Array.isArray(tree?.nodes) ? tree.nodes : []; + const pieces = []; + for (const node of nodes) { + const role = String(node?.role?.value || node?.role || ''); + if (/StaticText|InlineTextBox|heading|paragraph|link|button|text/i.test(role)) { + const value = String(node?.name?.value || node?.name || '').trim(); + if (value) pieces.push(value); + } + } + return normalizeDeepResearchText(pieces.join('\n')); +} + +function withTimeout(promise, ms, label) { + return Promise.race([ + promise, + new Promise((_, reject) => setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms)), + ]); +} + +export async function getChatGPTDeepResearchResult(page, { conversationId = '', useBridgeProbes = false } = {}) { + const iframeState = requireObjectEvaluateResult(unwrapEvaluateResult(await page.evaluate(`(() => { + const isVisible = (el) => { + if (!(el instanceof HTMLElement)) return false; + const style = window.getComputedStyle(el); + if (style.display === 'none' || style.visibility === 'hidden') return false; + const rect = el.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }; + const normalize = (value) => String(value || '').replace(/\\s+/g, ' ').trim(); + const iframes = Array.from(document.querySelectorAll('iframe')).map((frame, index) => { + const rect = frame.getBoundingClientRect(); + const title = frame.getAttribute('title') || ''; + const src = frame.getAttribute('src') || frame.src || ''; + const deepResearch = /deep-research|connector_openai_deep_research/i.test(title + ' ' + src); + let accessible = false; + let text = ''; + let html = ''; + let accessError = ''; + try { + const doc = frame.contentDocument || frame.contentWindow?.document; + accessible = !!doc; + text = normalize(doc?.body?.innerText || doc?.body?.textContent || ''); + html = String(doc?.body?.innerHTML || ''); + } catch (error) { + accessError = String(error?.name || error); + } + return { + index, + title, + src, + visible: isVisible(frame), + width: Math.round(rect.width), + height: Math.round(rect.height), + deepResearch, + accessible, + accessError, + text, + html, + }; + }); + const matched = iframes.find((frame) => frame.deepResearch) || null; + return { + url: window.location.href, + title: document.title, + iframes, + deepResearchIframe: matched, + }; + })()`)), 'chatgpt deep research iframe state'); + + const generating = await isGenerating(page).catch(() => false); + const iframe = iframeState.deepResearchIframe; + const currentConversationId = conversationIdFromUrl(iframeState.url); + if (conversationId) { + if (!currentConversationId) { + throw new CommandExecutionError( + `ChatGPT deep-research-result did not stay on requested conversation ${conversationId}.`, + ); + } + if (currentConversationId !== conversationId) { + throw new CommandExecutionError( + `ChatGPT deep-research-result conversation mismatch: expected ${conversationId}, current page is ${currentConversationId}.`, + ); + } + } + const diagnostics = { + iframeCount: Array.isArray(iframeState.iframes) ? iframeState.iframes.length : 0, + iframe: iframe ? { + index: iframe.index, + title: iframe.title, + src: iframe.src, + visible: iframe.visible, + width: iframe.width, + height: iframe.height, + accessible: iframe.accessible, + accessError: iframe.accessError || '', + } : null, + methodsTried: ['main-document-iframe'], + methodsSkipped: useBridgeProbes ? [] : ['browser-frames', 'cdp-accessibility', 'network-capture'], + }; + + if (useBridgeProbes && typeof page.readNetworkCapture === 'function') { + diagnostics.methodsTried.push('network-conversation-widget-state'); + try { + const entries = await withTimeout(page.readNetworkCapture(), 5000, 'network capture read'); + const relevantEntries = (Array.isArray(entries) ? entries : []) + .filter((entry) => /\/backend-api\/conversation\/|deep|research|oaiusercontent|ecosystem|widget/i.test(String(entry?.url || ''))); + diagnostics.networkEntries = relevantEntries + .slice(-20) + .map((entry) => ({ + url: entry.url, + status: entry.responseStatus ?? entry.status ?? 0, + contentType: entry.responseContentType ?? '', + preview: String(entry.responsePreview || '').slice(0, 500), + bodySize: Number(entry.responseBodyFullSize || 0) || undefined, + bodyTruncated: entry.responseBodyTruncated === true || undefined, + })); + const extracted = extractDeepResearchFromNetworkEntries(relevantEntries, { expectedConversationId: conversationId }); + if (extracted) { + diagnostics.networkConversation = { + foundReport: true, + reportLength: extracted.reportLength || extracted.report.length, + sourceCount: Array.isArray(extracted.sources) ? extracted.sources.length : 0, + }; + return { + status: 'completed', + report: extracted.report, + html: '', + url: iframeState.url, + method: extracted.method, + sources: extracted.sources || [], + diagnostics, + }; + } + } catch (error) { + if (error instanceof CommandExecutionError) throw error; + diagnostics.networkConversationError = String(error?.message || error); + } + } + + const fetchConversationId = conversationId || currentConversationId; + if (fetchConversationId) { + diagnostics.methodsTried.push('conversation-widget-state'); + try { + const conversation = await fetchChatGPTConversationPayload(page, fetchConversationId); + if (conversation?.error) { + diagnostics.conversationError = conversation.error; + } else { + const extracted = extractDeepResearchFromConversationPayload(conversation?.payload, { + expectedConversationId: fetchConversationId, + }); + diagnostics.conversation = { + status: conversation?.status || 0, + contentType: conversation?.contentType || '', + transport: conversation?.transport || '', + foundReport: !!extracted, + reportLength: extracted?.reportLength || 0, + widgetStatus: extracted?.widgetStatus || '', + sourceCount: Array.isArray(extracted?.sources) ? extracted.sources.length : 0, + }; + if (extracted) { + return { + status: 'completed', + report: extracted.report, + html: '', + url: iframeState.url, + method: extracted.method, + sources: extracted.sources || [], + diagnostics, + }; + } + } + } catch (error) { + if (error instanceof CommandExecutionError) throw error; + diagnostics.conversationError = String(error?.message || error); + } + } + + if (iframe?.text && looksLikeDeepResearchReport(iframe.text)) { + return { + status: 'completed', + report: normalizeDeepResearchText(iframe.text), + html: iframe.html || '', + url: iframeState.url, + method: 'same-origin-iframe-dom', + sources: [], + diagnostics, + }; + } + + if (!iframe) { + return { + status: generating ? 'running' : 'not_found', + report: '', + html: '', + url: iframeState.url, + method: 'main-document-dom', + sources: [], + diagnostics, + }; + } + + if (useBridgeProbes && typeof page.frames === 'function' && typeof page.evaluateInFrame === 'function') { + try { + const frames = await withTimeout(page.frames(), 3000, 'browser frames'); + diagnostics.frames = Array.isArray(frames) ? frames : []; + for (let index = 0; index < diagnostics.frames.length; index += 1) { + const frameInfo = diagnostics.frames[index]; + const frameText = unwrapEvaluateResult(await withTimeout( + page.evaluateInFrame('document.body?.innerText || document.body?.textContent || ""', index), + 3000, + 'frame eval', + )); + const text = normalizeDeepResearchText(frameText); + if ((/deep-research|connector_openai_deep_research/i.test(String(frameInfo?.url || '')) || looksLikeDeepResearchReport(text)) + && looksLikeDeepResearchReport(text)) { + return { + status: 'completed', + report: text, + html: '', + url: iframeState.url, + method: 'browser-frame-dom', + sources: [], + diagnostics, + }; + } + } + } catch (error) { + diagnostics.frameError = String(error?.message || error); + } + } + + if (useBridgeProbes && typeof page.cdp === 'function') { + try { + const frameTree = await withTimeout(page.cdp('Page.getFrameTree', {}), 5000, 'Page.getFrameTree'); + diagnostics.frameTree = frameTree; + const stack = [frameTree?.frameTree].filter(Boolean); + const frames = []; + while (stack.length) { + const node = stack.shift(); + const frame = node?.frame; + const url = String(frame?.url || frame?.unreachableUrl || ''); + if (frame?.id && /deep-research|connector_openai_deep_research|oaiusercontent/i.test(url)) { + frames.push({ frameId: frame.id, url }); + } + for (const child of node?.childFrames || []) stack.push(child); + } + diagnostics.cdpFrames = frames; + for (const frame of frames) { + const tree = await withTimeout(page.cdp('Accessibility.getFullAXTree', { + frameId: frame.frameId, + sessionId: 'target', + targetUrl: frame.url, + }), 5000, 'Accessibility.getFullAXTree').catch(() => null); + const text = collectAxText(tree); + if (looksLikeDeepResearchReport(text)) { + return { + status: 'completed', + report: text, + html: '', + url: iframeState.url, + method: 'cdp-accessibility-frame', + sources: [], + diagnostics, + }; + } + } + } catch (error) { + diagnostics.cdpError = String(error?.message || error); + } + } + + if (useBridgeProbes && typeof page.readNetworkCapture === 'function' && !diagnostics.networkEntries) { + try { + const entries = await withTimeout(page.readNetworkCapture(), 3000, 'network capture read'); + diagnostics.networkEntries = (Array.isArray(entries) ? entries : []) + .filter((entry) => /deep|research|oaiusercontent|ecosystem|widget/i.test(String(entry?.url || ''))) + .slice(-20) + .map((entry) => ({ + url: entry.url, + status: entry.responseStatus ?? entry.status ?? 0, + contentType: entry.responseContentType ?? '', + preview: String(entry.responsePreview || '').slice(0, 500), + })); + const candidate = diagnostics.networkEntries + .map((entry) => entry.preview) + .find((preview) => looksLikeDeepResearchReport(preview)); + if (candidate) { + return { + status: 'completed', + report: normalizeDeepResearchText(candidate), + html: '', + url: iframeState.url, + method: 'network-capture', + sources: [], + diagnostics, + }; + } + } catch (error) { + diagnostics.networkError = String(error?.message || error); + } + } + + return { + status: generating ? 'running' : 'unavailable', + report: '', + html: '', + url: iframeState.url, + method: 'cross-origin-iframe-detected', + sources: [], + diagnostics, + }; +} + +export async function waitForChatGPTDeepResearchResult(page, { conversationId = '', timeoutSeconds = 120, stableSeconds = 6 } = {}) { + const startTime = Date.now(); + let lastReport = ''; + let stableStartedAt = 0; + + while (Date.now() - startTime < timeoutSeconds * 1000) { + const result = await getChatGPTDeepResearchResult(page, { conversationId, useBridgeProbes: true }); + if (result.status === 'completed' && result.report) { + if (/conversation-widget-state/.test(result.method || '')) { + return { ...result, stableSeconds: 0 }; + } + if (result.report === lastReport) { + if (!stableStartedAt) stableStartedAt = Date.now(); + const elapsedSeconds = Math.floor((Date.now() - stableStartedAt) / 1000); + if (elapsedSeconds >= stableSeconds) { + return { ...result, stableSeconds: elapsedSeconds }; + } + } else { + lastReport = result.report; + stableStartedAt = Date.now(); + } + } + await page.wait(3); + } + + throw new TimeoutError( + 'chatgpt deep-research-result', + timeoutSeconds, + 'Deep Research did not complete or become extractable before timeout.', + ); +} + export function messageHtmlToMarkdown(html) { try { return htmlToMarkdown(html).trim(); @@ -2254,6 +2906,10 @@ export const __test__ = { isSameChatGPTConversation, parseChatGPTConversationId, parseChatGPTProjectId, + extractDeepResearchFromConversationPayload, + extractDeepResearchFromNetworkEntries, + extractDeepResearchFromWidgetState, + looksLikeDeepResearchReport, imageMimeFromPath, mimeFromFilePath, PROJECT_LINK_SELECTOR, diff --git a/clis/chatgpt/utils.test.js b/clis/chatgpt/utils.test.js index 61ee643b7..0432b635e 100644 --- a/clis/chatgpt/utils.test.js +++ b/clis/chatgpt/utils.test.js @@ -131,6 +131,131 @@ describe('chatgpt conversation navigation', () => { }); }); +function makeDeepResearchReport() { + return [ + '# Executive Summary', + '', + 'This completed Deep Research report is intentionally long enough to pass extraction heuristics.', + 'It summarizes findings, constraints, evidence, and recommendations from multiple public sources.', + 'The extraction path should read this markdown from metadata.chatgpt_sdk.widget_state.report_message.content.parts[0].', + 'Using the conversation payload avoids the cross-origin internal deep research iframe boundary.', + 'The report body includes repeated detail so the parser treats it as a real report, not a short UI preview.', + 'Findings show that reliable automation should prefer captured backend conversation JSON over iframe DOM access.', + 'Recommendations include returning diagnostics when no report is present and bounding source extraction.', + 'References and Sources are represented in metadata content references, safe URLs, and search result groups.', + 'Additional detail confirms that source de-duplication should key by URL and keep a readable title.', + 'This paragraph pads the fixture with realistic report text for the minimum-length guard.', + 'Another paragraph pads the fixture with realistic report text for the minimum-length guard.', + 'A final paragraph pads the fixture with realistic report text for the minimum-length guard.', + '', + '## Sources', + '', + '- Example source', + ].join('\n'); +} + +function makeDeepResearchPayload(report = makeDeepResearchReport(), { conversationId = '' } = {}) { + const payload = { + mapping: { + report_node: { + message: { + metadata: { + chatgpt_sdk: { + widget_state: JSON.stringify({ + status: 'completed', + report_message: { + id: 'report-msg', + content: { parts: [report] }, + metadata: { + content_references: [ + { title: 'Reference A', url: 'https://example.com/a' }, + { matched_text: 'Matched B', url: 'https://example.com/b' }, + ], + safe_urls: ['https://example.com/c'], + search_result_groups: [ + { entries: [{ title: 'Reference D', url: 'https://example.com/d' }] }, + ], + }, + }, + }), + }, + }, + }, + }, + }, + }; + if (conversationId) payload.conversation_id = conversationId; + return payload; +} + +describe('chatgpt deep research result extraction', () => { + it('extracts report markdown and sources from conversation widget_state', () => { + const result = __test__.extractDeepResearchFromConversationPayload(makeDeepResearchPayload()); + + expect(result).toMatchObject({ + status: 'completed', + method: 'conversation-widget-state', + reportMessageId: 'report-msg', + reportLength: expect.any(Number), + }); + expect(result.report).toContain('Executive Summary'); + expect(result.sources).toEqual(expect.arrayContaining([ + { title: 'Reference A', url: 'https://example.com/a' }, + { title: 'Matched B', url: 'https://example.com/b' }, + { title: '', url: 'https://example.com/c' }, + { title: 'Reference D', url: 'https://example.com/d' }, + ])); + }); + + it('extracts the requested report from captured conversation network entries', () => { + const shorterReport = `${makeDeepResearchReport()}\n\nshort`; + const longerReport = `${makeDeepResearchReport()}\n\nAdditional longer section.`; + const result = __test__.extractDeepResearchFromNetworkEntries([ + { url: 'https://chatgpt.com/backend-api/bootstrap', responsePreview: '{}' }, + { + url: 'https://chatgpt.com/backend-api/conversation/requested123', + responsePreview: JSON.stringify(makeDeepResearchPayload(shorterReport, { conversationId: 'requested123' })), + }, + { + url: 'https://chatgpt.com/backend-api/conversation/stale45678', + responsePreview: JSON.stringify(makeDeepResearchPayload(longerReport, { conversationId: 'stale45678' })), + }, + ], { expectedConversationId: 'requested123' }); + + expect(result.method).toBe('network-conversation-widget-state'); + expect(result.networkUrl).toContain('/conversation/requested123'); + expect(result.report).not.toContain('Additional longer section'); + }); + + it('typed-fails when the conversation payload id does not match the requested id', () => { + expect(() => __test__.extractDeepResearchFromConversationPayload( + makeDeepResearchPayload(makeDeepResearchReport(), { conversationId: 'stale45678' }), + { expectedConversationId: 'requested123' }, + )).toThrow(CommandExecutionError); + }); + + it('typed-fails malformed source rows instead of silently dropping them', () => { + const payload = makeDeepResearchPayload(); + const widget = JSON.parse(payload.mapping.report_node.message.metadata.chatgpt_sdk.widget_state); + widget.report_message.metadata.search_result_groups = [ + { entries: [{ title: 'Source without URL' }] }, + ]; + payload.mapping.report_node.message.metadata.chatgpt_sdk.widget_state = JSON.stringify(widget); + + expect(() => __test__.extractDeepResearchFromConversationPayload(payload)) + .toThrow(CommandExecutionError); + }); + + it('typed-fails malformed conversation payloads instead of treating them as empty reports', () => { + expect(() => __test__.extractDeepResearchFromConversationPayload({})) + .toThrow(CommandExecutionError); + }); + + it('ignores short widget previews that are not completed reports', () => { + expect(__test__.extractDeepResearchFromConversationPayload(makeDeepResearchPayload('short preview'))).toBeNull(); + }); +}); + describe('chatgpt model selection validation', () => { it('rejects unknown model names', async () => { await expect(selectChatGPTModel({ nativeClick: vi.fn() }, 'unknown')) diff --git a/docs/adapters/browser/chatgpt.md b/docs/adapters/browser/chatgpt.md index a8e327a97..3e24aed72 100644 --- a/docs/adapters/browser/chatgpt.md +++ b/docs/adapters/browser/chatgpt.md @@ -11,6 +11,7 @@ | `opencli chatgpt read` | Read the current conversation | | `opencli chatgpt history` | List visible conversation history links from the sidebar | | `opencli chatgpt detail ` | Open a conversation by `/c/` and read it | +| `opencli chatgpt deep-research-result ` | Read a completed Deep Research report from a conversation | | `opencli chatgpt new` | Start a new conversation | | `opencli chatgpt status` | Check page and login state | | `opencli chatgpt image ` | Generate images in ChatGPT web and optionally save them locally | @@ -32,6 +33,9 @@ opencli chatgpt read --markdown true opencli chatgpt history --limit 10 opencli chatgpt detail "https://chatgpt.com/c/" +# Extract a completed Deep Research report +opencli chatgpt deep-research-result "https://chatgpt.com/c/" --wait true --timeout 600 + # Start a fresh chat opencli chatgpt new @@ -64,6 +68,8 @@ opencli chatgpt image "a tiny watercolor fox" --sd true |--------|-------------| | `prompt` | Prompt to send (required for `ask`, `send`, and `image`) | | `--timeout` | Max seconds for `ask` to wait for a response (default: `120`) | +| `--wait` | For `deep-research-result`, wait until a Deep Research report completes or becomes extractable | +| `--stable` | For `deep-research-result`, seconds the report text must remain unchanged when waiting (default: `6`) | | `--new` | Start a new conversation before `ask` / `send` | | `--markdown` | Convert assistant message HTML to Markdown for `read` / `detail` | | `--limit` | Max visible history conversations to return (default: `20`) | @@ -77,6 +83,7 @@ opencli chatgpt image "a tiny watercolor fox" --sd true - ChatGPT web commands use persistent site sessions by default, so consecutive `ask` / `send` / `read` / `detail` commands continue in the same ChatGPT tab. Use `--site-session ephemeral` for one-shot isolated tabs. - `ask` waits for the first stable assistant response after sending. `send` submits only and returns immediately. - `history` reads visible `/c/` links from the ChatGPT sidebar; it does not use private backend APIs. +- `deep-research-result` opens the requested conversation and extracts completed Deep Research output from that conversation's `/backend-api/conversation/` payload, especially `metadata.chatgpt_sdk.widget_state.report_message`. It does not return a success row when no completed report is present. - `model` switches the visible ChatGPT web intelligence level. It recognizes the current English labels (`Fast`, `Balanced`, `Advanced`, `Very High`, `Pro`), legacy labels (`Instant`, `Medium`, `High`, `Extra High`), and Chinese labels (`极速`, `均衡`, `高级`, `超高`, `专业`). Advanced and Pro first try ChatGPT's authenticated backend model preference update, then the adapter falls back to the visible picker for UI-only levels. If labels are localized differently, it only falls back to option order after confirming the guarded five-option ChatGPT intelligence picker structure. - `image` opens a fresh `chatgpt.com/new` page before sending the image prompt. - When `--image` is provided, local images are uploaded first and the prompt is sent as an image edit request. diff --git a/docs/adapters/index.md b/docs/adapters/index.md index e25ee7458..0599b10d9 100644 --- a/docs/adapters/index.md +++ b/docs/adapters/index.md @@ -42,7 +42,7 @@ Run `opencli list` for the live registry. | **[chaoxing](./browser/chaoxing.md)** | `assignments` `exams` | 🔐 Browser | | **[grok](./browser/grok.md)** | `ask` `send` `read` `history` `detail` `export` `export-all` `new` `status` `image` | 🔐 Browser | | **[kimi](./browser/kimi.md)** | `ask` `send` `read` `history` `detail` `new` `status` `usage` `model` `mode` `copy-message` `storage-keys` | 🔐 Browser | -| **[chatgpt](./browser/chatgpt.md)** | `ask` `send` `read` `history` `detail` `new` `status` `image` | 🔐 Browser | +| **[chatgpt](./browser/chatgpt.md)** | `ask` `send` `read` `history` `detail` `deep-research-result` `new` `status` `image` `model` `project-list` `project-file-add` | 🔐 Browser | | **[gemini](./browser/gemini.md)** | `new` `ask` `image` `deep-research` `deep-research-result` | 🔐 Browser | | **[geogebra](./browser/geogebra.md)** | `eval` `add-point` `add-line` `add-circle` `add-polygon` `triangle` `hexagon` `list` `info` | 🌐 Browser | | **[claude](./browser/claude.md)** | `ask` `send` `new` `status` `read` `history` `detail` | 🔐 Browser | diff --git a/skills/opencli-usage/SKILL.md b/skills/opencli-usage/SKILL.md index e579c47a2..662e8781b 100644 --- a/skills/opencli-usage/SKILL.md +++ b/skills/opencli-usage/SKILL.md @@ -54,6 +54,8 @@ opencli --help # see positional args and command-specific flags Do not hard-code adapter lists — there are 100+ sites and the count moves every week. `opencli list -f json` is the source of truth; it emits one entry per command with `{site, name, aliases, description, strategy, browser, args, columns, ...}`. For an agent, that is always better than grepping a doc. +Before falling back to raw `opencli browser` commands on high-change authenticated sites, check whether a site adapter already exposes the workflow. For example, ChatGPT web has higher-level commands for conversation reads and Deep Research result extraction; discover the current surface with `opencli chatgpt --help` or `opencli list -f json`. + ## Universal flags (work on every adapter command) | flag | effect | From 23cf6e5239e3093b447fb636e8e76f2dc8a54081 Mon Sep 17 00:00:00 2001 From: jakevin Date: Fri, 3 Jul 2026 13:35:09 +0800 Subject: [PATCH 13/27] fix(browser): end-to-end command deadlines, safe transport retries, CDP timeouts (#2067) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(browser): end-to-end command deadlines, safe transport retries, CDP timeouts Three connectivity/stability fixes that share one root cause: the timeout and retry contracts between CLI, daemon, and extension were disconnected. 1. Plumb one command deadline through all three layers. The client HTTP request was hardcoded to 30s while the daemon default was 120s and no caller ever set body.timeout — every command slower than 30s died with an opaque client-side AbortError while still running in the browser. Now the transport computes an effective timeout (user --timeout via setDaemonCommandTimeoutSeconds, or timeoutMs + margin for extension-side waits like wait-download), sends it as body.timeout, and aborts the HTTP request only after the daemon's structured 408 should have arrived. The daemon timer now rejects with the command_result_unknown contract instead of a bare Error the client cannot classify. 2. Stop replaying possibly-dispatched commands on fetch TypeError. Any `TypeError: fetch failed` used to trigger ensure + resend with a fresh id, bypassing the daemon's duplicate-id guard — a daemon crash mid-click could double-submit a form. Only pre-connect failures (ECONNREFUSED and friends, checked via err.cause) are retried now; post-connect drops surface as command_result_unknown per the existing contract. 3. Give chrome.debugger commands a real deadline. The extension's CDP calls had none (sendCommandInFrameTarget declared _timeoutMs and never used it), so a page-blocking native dialog (alert/confirm/beforeunload) hung Runtime.evaluate forever and wedged every later command on the tab. All sendCommand calls now race a timer; exec/cdp commands derive their deadline from the transport's body.timeout, undercut by 5s so the more specific extension error beats the daemon's generic timer. * fix(browser): swallow post-timeout CDP rejections; short deadline for doctor probe Two issues found in self-review of the deadline work: - sendDebuggerCommand raced the command promise against a timer but left the losing command promise unobserved — if it rejected later (debugger detach on tab close long after the timeout fired) it surfaced as an unhandled rejection in the service worker. Swallow it on a side branch. - doctor's checkConnectivity probe inherited the default 120s transport deadline, so a daemon that accepts requests but never answers made doctor hang for 2 minutes before reporting FAIL. A health probe wants the opposite: shrink the per-command deadline to the probe budget (8s) and restore it afterwards. * fix(extension): honor derived CDP deadline in evaluateInFrame warm-up; pin deadline tests From adversarial review of the deadline work: - evaluateInFrame's Runtime.enable warm-up on the frame-target fallback path dropped the caller's derived deadline and fell back to the 60s default — a blocked iframe could burn the whole daemon budget in the warm-up alone, so the daemon's generic 408 always beat the extension's specific error on the cross-frame path. - Two untested links in the deadline chain are now pinned by regression tests: the client HTTP abort fires exactly at timeout*1000 + 10s (not before the daemon's structured 408 can arrive), and handleExec derives 115s from a 120s transport timeout (10s floor for tiny timeouts). --- extension/dist/background.js | 99 ++++++++++++++++--------- extension/src/background.test.ts | 50 ++++++++++++- extension/src/background.ts | 23 +++++- extension/src/cdp.test.ts | 46 ++++++++++++ extension/src/cdp.ts | 113 ++++++++++++++++++++-------- extension/src/protocol.ts | 6 ++ src/browser/daemon-client.test.ts | 113 +++++++++++++++++++++++++++- src/browser/daemon-client.ts | 118 +++++++++++++++++++++++++++--- src/daemon-utils.ts | 10 +++ src/daemon.test.ts | 11 +++ src/daemon.ts | 9 ++- src/doctor.test.ts | 10 ++- src/doctor.ts | 9 ++- src/execution.ts | 6 ++ 14 files changed, 532 insertions(+), 91 deletions(-) diff --git a/extension/dist/background.js b/extension/dist/background.js index d5fc24a5d..efe3cf3d0 100644 --- a/extension/dist/background.js +++ b/extension/dist/background.js @@ -11,6 +11,26 @@ let frameTargetCleanupRegistered = false; const CDP_RESPONSE_BODY_CAPTURE_LIMIT = 8 * 1024 * 1024; const CDP_REQUEST_BODY_CAPTURE_LIMIT = 1 * 1024 * 1024; const networkCaptures = /* @__PURE__ */ new Map(); +const CDP_COMMAND_TIMEOUT_MS = 6e4; +const CDP_PROBE_TIMEOUT_MS = 2e3; +async function sendDebuggerCommand(target, method, params, timeoutMs = CDP_COMMAND_TIMEOUT_MS) { + let timer; + const commandPromise = params === void 0 ? chrome.debugger.sendCommand(target, method) : chrome.debugger.sendCommand(target, method, params); + commandPromise.catch(() => { + }); + try { + return await Promise.race([ + commandPromise, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error( + `CDP command ${method} timed out after ${Math.round(timeoutMs / 1e3)}s — the page may be blocked by a native dialog (alert/confirm/print)` + )), timeoutMs); + }) + ]); + } finally { + if (timer !== void 0) clearTimeout(timer); + } +} function isDebuggableUrl$1(url) { if (!url) return true; return url.startsWith("http://") || url.startsWith("https://") || url === "about:blank" || url.startsWith("data:"); @@ -29,10 +49,10 @@ async function ensureAttached(tabId, aggressiveRetry = false) { } if (attached.has(tabId)) { try { - await chrome.debugger.sendCommand({ tabId }, "Runtime.evaluate", { + await sendDebuggerCommand({ tabId }, "Runtime.evaluate", { expression: "1", returnByValue: true - }); + }, CDP_PROBE_TIMEOUT_MS); return; } catch { attached.delete(tabId); @@ -83,27 +103,27 @@ async function ensureAttached(tabId, aggressiveRetry = false) { } attached.add(tabId); try { - await chrome.debugger.sendCommand({ tabId }, "Runtime.enable"); + await sendDebuggerCommand({ tabId }, "Runtime.enable"); } catch { } if (preservedNetworkCapture) { try { - await chrome.debugger.sendCommand({ tabId }, "Network.enable"); + await sendDebuggerCommand({ tabId }, "Network.enable"); networkCaptures.set(tabId, preservedNetworkCapture); } catch { } } } -async function evaluate(tabId, expression, aggressiveRetry = false) { +async function evaluate(tabId, expression, aggressiveRetry = false, timeoutMs = CDP_COMMAND_TIMEOUT_MS) { const MAX_EVAL_RETRIES = aggressiveRetry ? 3 : 2; for (let attempt = 1; attempt <= MAX_EVAL_RETRIES; attempt++) { try { await ensureAttached(tabId, aggressiveRetry); - const result = await chrome.debugger.sendCommand({ tabId }, "Runtime.evaluate", { + const result = await sendDebuggerCommand({ tabId }, "Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true - }); + }, timeoutMs); if (result.exceptionDetails) { const errMsg = result.exceptionDetails.exception?.description || result.exceptionDetails.text || "Eval error"; throw new Error(errMsg); @@ -134,7 +154,7 @@ async function screenshot(tabId, options = {}) { const needsOverride = fullPage || overrideWidth !== void 0 || overrideHeight !== void 0; if (needsOverride) { if (overrideWidth !== void 0 && fullPage) { - await chrome.debugger.sendCommand({ tabId }, "Emulation.setDeviceMetricsOverride", { + await sendDebuggerCommand({ tabId }, "Emulation.setDeviceMetricsOverride", { mobile: false, width: overrideWidth, height: 0, @@ -144,14 +164,14 @@ async function screenshot(tabId, options = {}) { let finalWidth = overrideWidth ?? 0; let finalHeight = overrideHeight ?? 0; if (fullPage) { - const metrics = await chrome.debugger.sendCommand({ tabId }, "Page.getLayoutMetrics"); + const metrics = await sendDebuggerCommand({ tabId }, "Page.getLayoutMetrics"); const size = metrics.cssContentSize || metrics.contentSize; if (size) { if (finalWidth === 0) finalWidth = Math.ceil(size.width); finalHeight = Math.ceil(size.height); } } - await chrome.debugger.sendCommand({ tabId }, "Emulation.setDeviceMetricsOverride", { + await sendDebuggerCommand({ tabId }, "Emulation.setDeviceMetricsOverride", { mobile: false, width: finalWidth, height: finalHeight, @@ -163,28 +183,28 @@ async function screenshot(tabId, options = {}) { if (format === "jpeg" && options.quality !== void 0) { params.quality = Math.max(0, Math.min(100, options.quality)); } - const result = await chrome.debugger.sendCommand({ tabId }, "Page.captureScreenshot", params); + const result = await sendDebuggerCommand({ tabId }, "Page.captureScreenshot", params); return result.data; } finally { if (needsOverride) { - await chrome.debugger.sendCommand({ tabId }, "Emulation.clearDeviceMetricsOverride").catch(() => { + await sendDebuggerCommand({ tabId }, "Emulation.clearDeviceMetricsOverride").catch(() => { }); } } } async function setFileInputFiles(tabId, files, selector) { await ensureAttached(tabId); - await chrome.debugger.sendCommand({ tabId }, "DOM.enable"); - const doc = await chrome.debugger.sendCommand({ tabId }, "DOM.getDocument"); + await sendDebuggerCommand({ tabId }, "DOM.enable"); + const doc = await sendDebuggerCommand({ tabId }, "DOM.getDocument"); const query = selector || 'input[type="file"]'; - const result = await chrome.debugger.sendCommand({ tabId }, "DOM.querySelector", { + const result = await sendDebuggerCommand({ tabId }, "DOM.querySelector", { nodeId: doc.root.nodeId, selector: query }); if (!result.nodeId) { throw new Error(`No element found matching selector: ${query}`); } - await chrome.debugger.sendCommand({ tabId }, "DOM.setFileInputFiles", { + await sendDebuggerCommand({ tabId }, "DOM.setFileInputFiles", { files, nodeId: result.nodeId }); @@ -310,9 +330,9 @@ async function ensureFrameTarget(tabId, frameId, aggressiveRetry = false, target const key = frameTargetKey(tabId, frameId); const existing = frameTargets.get(key); if (existing) return existing; - await chrome.debugger.sendCommand({ tabId }, "Target.setDiscoverTargets", { discover: true }).catch(() => { + await sendDebuggerCommand({ tabId }, "Target.setDiscoverTargets", { discover: true }).catch(() => { }); - await chrome.debugger.sendCommand({ tabId }, "Target.setAutoAttach", { + await sendDebuggerCommand({ tabId }, "Target.setAutoAttach", { autoAttach: true, waitForDebuggerOnStart: false, flatten: true, @@ -331,7 +351,7 @@ async function ensureFrameTarget(tabId, frameId, aggressiveRetry = false, target return targetId; } async function resolveFrameTargetId(tabId, frameId, targetUrl) { - const result = await chrome.debugger.sendCommand({ tabId }, "Target.getTargets").catch(() => null); + const result = await sendDebuggerCommand({ tabId }, "Target.getTargets").catch(() => null); const targets = result?.targetInfos ?? []; const frameTarget = targets.find((candidate) => { const candidateId = candidate.targetId || candidate.id; @@ -342,14 +362,14 @@ async function resolveFrameTargetId(tabId, frameId, targetUrl) { const candidates = targets.filter((target) => target.type === "iframe").map((target) => `${target.targetId || target.id || "?"} ${target.url || ""}`).join("; "); throw new Error(`No iframe target found for frame ${frameId}${targetUrl ? ` (${targetUrl})` : ""}. Candidates: ${candidates || "none"}`); } -async function sendCommandInFrameTarget(tabId, frameId, method, params = {}, aggressiveRetry = false, _timeoutMs = 3e4, targetUrl) { +async function sendCommandInFrameTarget(tabId, frameId, method, params = {}, aggressiveRetry = false, timeoutMs = CDP_COMMAND_TIMEOUT_MS, targetUrl) { const targetId = await ensureFrameTarget(tabId, frameId, aggressiveRetry, targetUrl); const target = { targetId }; - return chrome.debugger.sendCommand(target, method, params); + return sendDebuggerCommand(target, method, params, timeoutMs); } async function insertText(tabId, text) { await ensureAttached(tabId); - await chrome.debugger.sendCommand({ tabId }, "Input.insertText", { text }); + await sendDebuggerCommand({ tabId }, "Input.insertText", { text }); } function registerFrameTracking() { registerFrameTargetCleanup(); @@ -387,22 +407,22 @@ function registerFrameTracking() { } async function getFrameTree(tabId) { await ensureAttached(tabId); - return chrome.debugger.sendCommand({ tabId }, "Page.getFrameTree"); + return sendDebuggerCommand({ tabId }, "Page.getFrameTree"); } -async function evaluateInFrame(tabId, expression, frameId, aggressiveRetry = false) { +async function evaluateInFrame(tabId, expression, frameId, aggressiveRetry = false, timeoutMs = CDP_COMMAND_TIMEOUT_MS) { await ensureAttached(tabId, aggressiveRetry); - await chrome.debugger.sendCommand({ tabId }, "Runtime.enable").catch(() => { + await sendDebuggerCommand({ tabId }, "Runtime.enable").catch(() => { }); const contexts = tabFrameContexts.get(tabId); const contextId = contexts?.get(frameId); if (contextId !== void 0) { try { - const result2 = await chrome.debugger.sendCommand({ tabId }, "Runtime.evaluate", { + const result2 = await sendDebuggerCommand({ tabId }, "Runtime.evaluate", { expression, contextId, returnByValue: true, awaitPromise: true - }); + }, timeoutMs); if (result2.exceptionDetails) { const errMsg = result2.exceptionDetails.exception?.description || result2.exceptionDetails.text || "Eval error"; throw new Error(errMsg); @@ -416,12 +436,12 @@ async function evaluateInFrame(tabId, expression, frameId, aggressiveRetry = fal contexts?.delete(frameId); } } - await sendCommandInFrameTarget(tabId, frameId, "Runtime.enable", {}, aggressiveRetry).catch(() => void 0); + await sendCommandInFrameTarget(tabId, frameId, "Runtime.enable", {}, aggressiveRetry, timeoutMs).catch(() => void 0); const result = await sendCommandInFrameTarget(tabId, frameId, "Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true - }, aggressiveRetry); + }, aggressiveRetry, timeoutMs); if (result.exceptionDetails) { const errMsg = result.exceptionDetails.exception?.description || result.exceptionDetails.text || "Eval error"; throw new Error(errMsg); @@ -466,7 +486,7 @@ function getOrCreateNetworkCaptureEntry(tabId, requestId, fallback) { } async function startNetworkCapture(tabId, pattern) { await ensureAttached(tabId); - await chrome.debugger.sendCommand({ tabId }, "Network.enable"); + await sendDebuggerCommand({ tabId }, "Network.enable"); networkCaptures.set(tabId, { patterns: normalizeCapturePatterns(pattern), entries: [], @@ -552,7 +572,7 @@ function registerListeners() { entry.requestBodyTruncated = truncated; } try { - const postData = await chrome.debugger.sendCommand({ tabId }, "Network.getRequestPostData", { requestId }); + const postData = await sendDebuggerCommand({ tabId }, "Network.getRequestPostData", { requestId }); if (postData?.postData) { const raw = postData.postData; const fullSize = raw.length; @@ -586,7 +606,7 @@ function registerListeners() { const entry = state.entries[stateEntryIndex]; if (!entry) return; try { - const body = await chrome.debugger.sendCommand({ tabId }, "Network.getResponseBody", { requestId }); + const body = await sendDebuggerCommand({ tabId }, "Network.getResponseBody", { requestId }); if (typeof body?.body === "string") { const fullSize = body.body.length; const truncated = fullSize > CDP_RESPONSE_BODY_CAPTURE_LIMIT; @@ -1758,6 +1778,12 @@ async function listAutomationWebTabs(leaseKey) { const tabs = await listAutomationTabs(leaseKey); return tabs.filter((tab) => isDebuggableUrl(tab.url)); } +function commandCdpTimeoutMs(cmd) { + if (typeof cmd.timeout === "number" && cmd.timeout > 0) { + return Math.max(1e4, cmd.timeout * 1e3 - 5e3); + } + return void 0; +} async function handleExec(cmd, leaseKey) { if (!cmd.code) return { id: cmd.id, ok: false, error: "Missing code" }; const cmdTabId = await resolveCommandTabId(cmd); @@ -1770,10 +1796,10 @@ async function handleExec(cmd, leaseKey) { if (cmd.frameIndex < 0 || cmd.frameIndex >= frames.length) { return { id: cmd.id, ok: false, error: `Frame index ${cmd.frameIndex} out of range (${frames.length} cross-origin frames available)` }; } - const data2 = await evaluateInFrame(tabId, cmd.code, frames[cmd.frameIndex].frameId, aggressive); + const data2 = await evaluateInFrame(tabId, cmd.code, frames[cmd.frameIndex].frameId, aggressive, commandCdpTimeoutMs(cmd)); return pageScopedResult(cmd.id, tabId, data2); } - const data = await evaluateAsync(tabId, cmd.code, aggressive); + const data = await evaluateAsync(tabId, cmd.code, aggressive, commandCdpTimeoutMs(cmd)); return pageScopedResult(cmd.id, tabId, data); } catch (err) { return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; @@ -2039,10 +2065,11 @@ async function handleCdp(cmd, leaseKey) { const params = cmd.cdpParams ?? {}; const routeFrameId = typeof params.frameId === "string" && params.sessionId === "target" ? params.frameId : void 0; const routeTargetUrl = typeof params.targetUrl === "string" ? params.targetUrl : void 0; - const data = routeFrameId ? await sendCommandInFrameTarget(tabId, routeFrameId, cmd.cdpMethod, stripOpenCliFrameRoutingParams(params, true), aggressive, 3e4, routeTargetUrl) : await chrome.debugger.sendCommand( + const data = routeFrameId ? await sendCommandInFrameTarget(tabId, routeFrameId, cmd.cdpMethod, stripOpenCliFrameRoutingParams(params, true), aggressive, commandCdpTimeoutMs(cmd) ?? 3e4, routeTargetUrl) : await sendDebuggerCommand( { tabId }, cmd.cdpMethod, - stripOpenCliFrameRoutingParams(params, false) + stripOpenCliFrameRoutingParams(params, false), + commandCdpTimeoutMs(cmd) ); return pageScopedResult(cmd.id, tabId, data); } catch (err) { diff --git a/extension/src/background.test.ts b/extension/src/background.test.ts index 3b190dc77..f737af69a 100644 --- a/extension/src/background.test.ts +++ b/extension/src/background.test.ts @@ -552,7 +552,55 @@ describe('background tab isolation', () => { { index: 1, frameId: 'cross-origin-sibling', url: 'https://y.example/iframe', name: 'sibling-y' }, ]); expect(execResult.ok).toBe(true); - expect(evaluateInFrame).toHaveBeenCalledWith(1, 'document.title', 'cross-origin-nested', false); + // Fifth arg is the CDP deadline derived from cmd.timeout (undefined here — no timeout on the command). + expect(evaluateInFrame).toHaveBeenCalledWith(1, 'document.title', 'cross-origin-nested', false, undefined); + }); + + it('derives the CDP deadline from cmd.timeout for exec (timeout*1000 - 5s, floor 10s)', async () => { + const { chrome } = createChromeMock(); + vi.stubGlobal('chrome', chrome); + + const evaluateAsync = vi.fn(async () => 'main-result'); + vi.doMock('./cdp', () => ({ + registerListeners: vi.fn(), + registerFrameTracking: vi.fn(), + hasActiveNetworkCapture: vi.fn(() => false), + detach: vi.fn(async () => {}), + evaluateAsync, + evaluateInFrame: vi.fn(), + getFrameTree: vi.fn(), + screenshot: vi.fn(), + setFileInputFiles: vi.fn(), + insertText: vi.fn(), + startNetworkCapture: vi.fn(), + readNetworkCapture: vi.fn(async () => []), + ensureAttached: vi.fn(), + })); + + const mod = await import('./background'); + mod.__test__.setAutomationWindowId(adapterKey('twitter'), 1); + + // 120s transport timeout → 115s CDP deadline + await mod.__test__.handleCommand({ + id: 'exec-with-timeout', + action: 'exec', + code: '1', + session: 'twitter', + surface: 'adapter', + timeout: 120, + }); + expect(evaluateAsync).toHaveBeenLastCalledWith(1, '1', false, 115_000); + + // Tiny transport timeout → clamped to the 10s floor + await mod.__test__.handleCommand({ + id: 'exec-with-tiny-timeout', + action: 'exec', + code: '1', + session: 'twitter', + surface: 'adapter', + timeout: 8, + }); + expect(evaluateAsync).toHaveBeenLastCalledWith(1, '1', false, 10_000); }); it('creates new tabs inside the automation container', async () => { diff --git a/extension/src/background.ts b/extension/src/background.ts index 87d65edcf..30e634690 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -1490,6 +1490,20 @@ async function listAutomationWebTabs(leaseKey: string): Promise isDebuggableUrl(tab.url)); } +/** + * Derive the per-command CDP deadline from the daemon-side timeout (seconds) + * the CLI transport put on the command. Undercut it by 5s so this (more + * specific) error reaches the CLI before the daemon's generic timer fires. + * Returns undefined when the command carries no timeout — callers fall back + * to the executor's default deadline. + */ +function commandCdpTimeoutMs(cmd: Command): number | undefined { + if (typeof cmd.timeout === 'number' && cmd.timeout > 0) { + return Math.max(10_000, cmd.timeout * 1000 - 5_000); + } + return undefined; +} + async function handleExec(cmd: Command, leaseKey: string): Promise { if (!cmd.code) return { id: cmd.id, ok: false, error: 'Missing code' }; const cmdTabId = await resolveCommandTabId(cmd); @@ -1502,10 +1516,10 @@ async function handleExec(cmd: Command, leaseKey: string): Promise { if (cmd.frameIndex < 0 || cmd.frameIndex >= frames.length) { return { id: cmd.id, ok: false, error: `Frame index ${cmd.frameIndex} out of range (${frames.length} cross-origin frames available)` }; } - const data = await executor.evaluateInFrame(tabId, cmd.code, frames[cmd.frameIndex].frameId, aggressive); + const data = await executor.evaluateInFrame(tabId, cmd.code, frames[cmd.frameIndex].frameId, aggressive, commandCdpTimeoutMs(cmd)); return pageScopedResult(cmd.id, tabId, data); } - const data = await executor.evaluateAsync(tabId, cmd.code, aggressive); + const data = await executor.evaluateAsync(tabId, cmd.code, aggressive, commandCdpTimeoutMs(cmd)); return pageScopedResult(cmd.id, tabId, data); } catch (err) { return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; @@ -1809,11 +1823,12 @@ async function handleCdp(cmd: Command, leaseKey: string): Promise { : undefined; const routeTargetUrl = typeof params.targetUrl === 'string' ? params.targetUrl : undefined; const data = routeFrameId - ? await executor.sendCommandInFrameTarget(tabId, routeFrameId, cmd.cdpMethod, stripOpenCliFrameRoutingParams(params, true), aggressive, 30_000, routeTargetUrl) - : await chrome.debugger.sendCommand( + ? await executor.sendCommandInFrameTarget(tabId, routeFrameId, cmd.cdpMethod, stripOpenCliFrameRoutingParams(params, true), aggressive, commandCdpTimeoutMs(cmd) ?? 30_000, routeTargetUrl) + : await executor.sendDebuggerCommand( { tabId }, cmd.cdpMethod, stripOpenCliFrameRoutingParams(params, false), + commandCdpTimeoutMs(cmd), ); return pageScopedResult(cmd.id, tabId, data); } catch (err) { diff --git a/extension/src/cdp.test.ts b/extension/src/cdp.test.ts index 74360d872..3d96fd7ad 100644 --- a/extension/src/cdp.test.ts +++ b/extension/src/cdp.test.ts @@ -608,3 +608,49 @@ describe('cdp evaluateInFrame stale context fallback', () => { expect(debuggerApi.attach).toHaveBeenCalledWith({ targetId: 'stale-frame' }, '1.3'); }); }); + +describe('cdp command deadline', () => { + beforeEach(() => { + vi.resetModules(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + function createHangingChromeMock() { + const { chrome, debuggerApi } = createChromeMock(); + // A page-blocking native dialog (alert/confirm) makes Runtime.evaluate + // never resolve; every other command still works. + debuggerApi.sendCommand = vi.fn((_target: unknown, method: string) => { + if (method === 'Runtime.evaluate') return new Promise(() => {}); + return Promise.resolve({}); + }) as typeof debuggerApi.sendCommand; + return { chrome, debuggerApi }; + } + + it('evaluate rejects instead of hanging forever when Runtime.evaluate never resolves', async () => { + vi.useFakeTimers(); + const { chrome } = createHangingChromeMock(); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./cdp'); + const pending = mod.evaluate(1, 'alert("blocked")'); + const assertion = expect(pending).rejects.toThrow(/timed out after 60s/); + await vi.advanceTimersByTimeAsync(60_000); + await assertion; + }); + + it('evaluate honors a caller-supplied deadline from the command timeout', async () => { + vi.useFakeTimers(); + const { chrome } = createHangingChromeMock(); + vi.stubGlobal('chrome', chrome); + + const mod = await import('./cdp'); + const pending = mod.evaluate(1, 'alert("blocked")', false, 10_000); + const assertion = expect(pending).rejects.toThrow(/timed out after 10s/); + await vi.advanceTimersByTimeAsync(10_000); + await assertion; + }); +}); diff --git a/extension/src/cdp.ts b/extension/src/cdp.ts index cb72430fc..71a4649a0 100644 --- a/extension/src/cdp.ts +++ b/extension/src/cdp.ts @@ -58,6 +58,51 @@ export type DownloadWaitResult = { }; const networkCaptures = new Map(); + +/** + * Default deadline for a single chrome.debugger command. chrome.debugger has + * no timeout of its own: a page-blocking native dialog (alert/confirm/print/ + * beforeunload) makes Runtime.evaluate hang forever, wedging every later + * command on the tab. Long enough for legitimate in-page waits (default 30s + * plus headroom), short enough to fail before the daemon's 120s timer. + */ +const CDP_COMMAND_TIMEOUT_MS = 60_000; +/** Health-check probe deadline — a blocked probe should fail fast. */ +const CDP_PROBE_TIMEOUT_MS = 2_000; + +/** + * chrome.debugger.sendCommand with a deadline. The underlying command cannot + * be cancelled — this only unblocks the caller so the CLI gets an error + * instead of an infinite hang. + */ +export async function sendDebuggerCommand( + target: chrome.debugger.Debuggee, + method: string, + params?: Record, + timeoutMs: number = CDP_COMMAND_TIMEOUT_MS, +): Promise { + let timer: ReturnType | undefined; + const commandPromise = (params === undefined + ? chrome.debugger.sendCommand(target, method) + : chrome.debugger.sendCommand(target, method, params)) as Promise; + // If the timeout wins the race, the command promise may still reject much + // later (e.g. debugger detach on tab close) — swallow that on a side branch + // so it never surfaces as an unhandled rejection in the service worker. + commandPromise.catch(() => {}); + try { + return await Promise.race([ + commandPromise, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error( + `CDP command ${method} timed out after ${Math.round(timeoutMs / 1000)}s — the page may be blocked by a native dialog (alert/confirm/print)`, + )), timeoutMs); + }), + ]); + } finally { + if (timer !== undefined) clearTimeout(timer); + } +} + /** Check if a URL can be attached via CDP — only allow http(s) and blank pages. */ function isDebuggableUrl(url?: string): boolean { if (!url) return true; // empty/undefined = tab still loading, allow it @@ -83,9 +128,9 @@ export async function ensureAttached(tabId: number, aggressiveRetry: boolean = f if (attached.has(tabId)) { // Verify the debugger is still actually attached by sending a harmless command try { - await chrome.debugger.sendCommand({ tabId }, 'Runtime.evaluate', { + await sendDebuggerCommand({ tabId }, 'Runtime.evaluate', { expression: '1', returnByValue: true, - }); + }, CDP_PROBE_TIMEOUT_MS); return; // Still attached and working } catch { // Stale cache entry — need to re-attach @@ -158,7 +203,7 @@ export async function ensureAttached(tabId: number, aggressiveRetry: boolean = f attached.add(tabId); try { - await chrome.debugger.sendCommand({ tabId }, 'Runtime.enable'); + await sendDebuggerCommand({ tabId }, 'Runtime.enable'); } catch { // Some pages may not need explicit enable } @@ -170,7 +215,7 @@ export async function ensureAttached(tabId: number, aggressiveRetry: boolean = f // while those awaits yield to the event loop. if (preservedNetworkCapture) { try { - await chrome.debugger.sendCommand({ tabId }, 'Network.enable'); + await sendDebuggerCommand({ tabId }, 'Network.enable'); networkCaptures.set(tabId, preservedNetworkCapture); } catch { // Leave capture cleared rather than arm a half-attached Network domain; @@ -179,7 +224,12 @@ export async function ensureAttached(tabId: number, aggressiveRetry: boolean = f } } -export async function evaluate(tabId: number, expression: string, aggressiveRetry: boolean = false): Promise { +export async function evaluate( + tabId: number, + expression: string, + aggressiveRetry: boolean = false, + timeoutMs: number = CDP_COMMAND_TIMEOUT_MS, +): Promise { // Retry the entire evaluate (attach + command). // Normal: 2 retries. Browser: 3 retries (tolerates extension interference). const MAX_EVAL_RETRIES = aggressiveRetry ? 3 : 2; @@ -187,11 +237,11 @@ export async function evaluate(tabId: number, expression: string, aggressiveRetr try { await ensureAttached(tabId, aggressiveRetry); - const result = await chrome.debugger.sendCommand({ tabId }, 'Runtime.evaluate', { + const result = await sendDebuggerCommand({ tabId }, 'Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true, - }) as { + }, timeoutMs) as { result?: { type: string; value?: unknown; description?: string; subtype?: string }; exceptionDetails?: { exception?: { description?: string }; text?: string }; }; @@ -245,7 +295,7 @@ export async function screenshot( if (needsOverride) { // When width is set, apply it first so layout reflows before we read content size. if (overrideWidth !== undefined && fullPage) { - await chrome.debugger.sendCommand({ tabId }, 'Emulation.setDeviceMetricsOverride', { + await sendDebuggerCommand({ tabId }, 'Emulation.setDeviceMetricsOverride', { mobile: false, width: overrideWidth, height: 0, @@ -255,7 +305,7 @@ export async function screenshot( let finalWidth = overrideWidth ?? 0; let finalHeight = overrideHeight ?? 0; if (fullPage) { - const metrics = await chrome.debugger.sendCommand({ tabId }, 'Page.getLayoutMetrics') as { + const metrics = await sendDebuggerCommand({ tabId }, 'Page.getLayoutMetrics') as { contentSize?: { width: number; height: number }; cssContentSize?: { width: number; height: number }; }; @@ -265,7 +315,7 @@ export async function screenshot( finalHeight = Math.ceil(size.height); } } - await chrome.debugger.sendCommand({ tabId }, 'Emulation.setDeviceMetricsOverride', { + await sendDebuggerCommand({ tabId }, 'Emulation.setDeviceMetricsOverride', { mobile: false, width: finalWidth, height: finalHeight, @@ -279,14 +329,14 @@ export async function screenshot( params.quality = Math.max(0, Math.min(100, options.quality)); } - const result = await chrome.debugger.sendCommand({ tabId }, 'Page.captureScreenshot', params) as { + const result = await sendDebuggerCommand({ tabId }, 'Page.captureScreenshot', params) as { data: string; // base64-encoded }; return result.data; } finally { if (needsOverride) { - await chrome.debugger.sendCommand({ tabId }, 'Emulation.clearDeviceMetricsOverride').catch(() => {}); + await sendDebuggerCommand({ tabId }, 'Emulation.clearDeviceMetricsOverride').catch(() => {}); } } } @@ -308,16 +358,16 @@ export async function setFileInputFiles( await ensureAttached(tabId); // Enable DOM domain (required for DOM.querySelector and DOM.setFileInputFiles) - await chrome.debugger.sendCommand({ tabId }, 'DOM.enable'); + await sendDebuggerCommand({ tabId }, 'DOM.enable'); // Get the document root - const doc = await chrome.debugger.sendCommand({ tabId }, 'DOM.getDocument') as { + const doc = await sendDebuggerCommand({ tabId }, 'DOM.getDocument') as { root: { nodeId: number }; }; // Find the file input element const query = selector || 'input[type="file"]'; - const result = await chrome.debugger.sendCommand({ tabId }, 'DOM.querySelector', { + const result = await sendDebuggerCommand({ tabId }, 'DOM.querySelector', { nodeId: doc.root.nodeId, selector: query, }) as { nodeId: number }; @@ -327,7 +377,7 @@ export async function setFileInputFiles( } // Set files directly via CDP — Chrome reads from local filesystem - await chrome.debugger.sendCommand({ tabId }, 'DOM.setFileInputFiles', { + await sendDebuggerCommand({ tabId }, 'DOM.setFileInputFiles', { files, nodeId: result.nodeId, }); @@ -471,8 +521,8 @@ async function ensureFrameTarget( const existing = frameTargets.get(key); if (existing) return existing; - await chrome.debugger.sendCommand({ tabId }, 'Target.setDiscoverTargets', { discover: true }).catch(() => {}); - await chrome.debugger.sendCommand({ tabId }, 'Target.setAutoAttach', { + await sendDebuggerCommand({ tabId }, 'Target.setDiscoverTargets', { discover: true }).catch(() => {}); + await sendDebuggerCommand({ tabId }, 'Target.setAutoAttach', { autoAttach: true, waitForDebuggerOnStart: false, flatten: true, @@ -491,7 +541,7 @@ async function ensureFrameTarget( } async function resolveFrameTargetId(tabId: number, frameId: string, targetUrl?: string): Promise { - const result = await chrome.debugger.sendCommand({ tabId }, 'Target.getTargets').catch(() => null) as + const result = await sendDebuggerCommand({ tabId }, 'Target.getTargets').catch(() => null) as | { targetInfos?: Array<{ targetId?: string; id?: string; type?: string; url?: string }> } | null; const targets = result?.targetInfos ?? []; @@ -518,12 +568,12 @@ export async function sendCommandInFrameTarget( method: string, params: Record = {}, aggressiveRetry: boolean = false, - _timeoutMs: number = 30_000, + timeoutMs: number = CDP_COMMAND_TIMEOUT_MS, targetUrl?: string, ): Promise { const targetId = await ensureFrameTarget(tabId, frameId, aggressiveRetry, targetUrl); const target = { targetId } as chrome.debugger.Debuggee; - return chrome.debugger.sendCommand(target, method, params); + return sendDebuggerCommand(target, method, params, timeoutMs); } export async function insertText( @@ -531,7 +581,7 @@ export async function insertText( text: string, ): Promise { await ensureAttached(tabId); - await chrome.debugger.sendCommand({ tabId }, 'Input.insertText', { text }); + await sendDebuggerCommand({ tabId }, 'Input.insertText', { text }); } export function registerFrameTracking(): void { @@ -572,7 +622,7 @@ export function registerFrameTracking(): void { export async function getFrameTree(tabId: number): Promise { await ensureAttached(tabId); - return chrome.debugger.sendCommand({ tabId }, 'Page.getFrameTree'); + return sendDebuggerCommand({ tabId }, 'Page.getFrameTree'); } export async function evaluateInFrame( @@ -580,22 +630,23 @@ export async function evaluateInFrame( expression: string, frameId: string, aggressiveRetry: boolean = false, + timeoutMs: number = CDP_COMMAND_TIMEOUT_MS, ): Promise { await ensureAttached(tabId, aggressiveRetry); - await chrome.debugger.sendCommand({ tabId }, 'Runtime.enable').catch(() => {}); + await sendDebuggerCommand({ tabId }, 'Runtime.enable').catch(() => {}); const contexts = tabFrameContexts.get(tabId); const contextId = contexts?.get(frameId); if (contextId !== undefined) { try { - const result = await chrome.debugger.sendCommand({ tabId }, 'Runtime.evaluate', { + const result = await sendDebuggerCommand({ tabId }, 'Runtime.evaluate', { expression, contextId, returnByValue: true, awaitPromise: true, - }) as { + }, timeoutMs) as { result?: { type: string; value?: unknown; description?: string; subtype?: string }; exceptionDetails?: { exception?: { description?: string }; text?: string }; }; @@ -622,12 +673,12 @@ export async function evaluateInFrame( } // No cached context, or the cached one went stale: resolve via the frame target. - await sendCommandInFrameTarget(tabId, frameId, 'Runtime.enable', {}, aggressiveRetry).catch(() => undefined); + await sendCommandInFrameTarget(tabId, frameId, 'Runtime.enable', {}, aggressiveRetry, timeoutMs).catch(() => undefined); const result = await sendCommandInFrameTarget(tabId, frameId, 'Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true, - }, aggressiveRetry) as { + }, aggressiveRetry, timeoutMs) as { result?: { type: string; value?: unknown; description?: string; subtype?: string }; exceptionDetails?: { exception?: { description?: string }; text?: string }; }; @@ -694,7 +745,7 @@ export async function startNetworkCapture( pattern?: string, ): Promise { await ensureAttached(tabId); - await chrome.debugger.sendCommand({ tabId }, 'Network.enable'); + await sendDebuggerCommand({ tabId }, 'Network.enable'); networkCaptures.set(tabId, { patterns: normalizeCapturePatterns(pattern), entries: [], @@ -794,7 +845,7 @@ export function registerListeners(): void { entry.requestBodyTruncated = truncated; } try { - const postData = await chrome.debugger.sendCommand({ tabId }, 'Network.getRequestPostData', { requestId }) as { postData?: string }; + const postData = await sendDebuggerCommand({ tabId }, 'Network.getRequestPostData', { requestId }) as { postData?: string }; if (postData?.postData) { const raw = postData.postData; const fullSize = raw.length; @@ -841,7 +892,7 @@ export function registerListeners(): void { const entry = state.entries[stateEntryIndex]; if (!entry) return; try { - const body = await chrome.debugger.sendCommand({ tabId }, 'Network.getResponseBody', { requestId }) as { + const body = await sendDebuggerCommand({ tabId }, 'Network.getResponseBody', { requestId }) as { body?: string; base64Encoded?: boolean; }; diff --git a/extension/src/protocol.ts b/extension/src/protocol.ts index ba5acc79d..61f89876f 100644 --- a/extension/src/protocol.ts +++ b/extension/src/protocol.ts @@ -77,6 +77,12 @@ export interface Command { frameIndex?: number; /** Browser profile/context selected by the CLI. Used by the daemon for routing. */ contextId?: string; + /** + * Daemon-side command timeout in seconds, set by the CLI transport. The + * extension derives its CDP deadline from this so it fails just before the + * daemon timer and its (more specific) error wins. + */ + timeout?: number; } export interface Result { diff --git a/src/browser/daemon-client.test.ts b/src/browser/daemon-client.test.ts index 61060423f..6499b4080 100644 --- a/src/browser/daemon-client.test.ts +++ b/src/browser/daemon-client.test.ts @@ -6,6 +6,7 @@ import { getDaemonHealth, requestDaemonShutdown, sendCommand, + setDaemonCommandTimeoutSeconds, } from './daemon-client.js'; import * as daemonLifecycle from './daemon-lifecycle.js'; @@ -309,7 +310,7 @@ describe('daemon-client', () => { expect(ids[0]).not.toBe(ids[1]); }); - it('sendCommand runs full bridge ensure on a local TypeError before resending', async () => { + it('sendCommand runs full bridge ensure on a pre-connect TypeError (ECONNREFUSED) before resending', async () => { const ensureSpy = vi.spyOn(daemonLifecycle, 'ensureBrowserBridgeReady').mockResolvedValue({ health: { state: 'ready', @@ -325,9 +326,11 @@ describe('daemon-client', () => { }, spawnedProcess: null, }); + const refused = new TypeError('fetch failed'); + (refused as { cause?: unknown }).cause = Object.assign(new Error('connect ECONNREFUSED 127.0.0.1:19825'), { code: 'ECONNREFUSED' }); const fetchMock = vi.mocked(fetch); fetchMock - .mockRejectedValueOnce(new TypeError('fetch failed')) + .mockRejectedValueOnce(refused) .mockResolvedValueOnce({ ok: true, status: 200, @@ -340,6 +343,34 @@ describe('daemon-client', () => { expect(fetchMock).toHaveBeenCalledTimes(2); }); + it('sendCommand does NOT resend on a post-connect TypeError (ECONNRESET) — surfaces command_result_unknown', async () => { + const ensureSpy = vi.spyOn(daemonLifecycle, 'ensureBrowserBridgeReady'); + const reset = new TypeError('fetch failed'); + (reset as { cause?: unknown }).cause = Object.assign(new Error('read ECONNRESET'), { code: 'ECONNRESET' }); + vi.mocked(fetch).mockRejectedValueOnce(reset); + + await expect(sendCommand('navigate', { url: 'https://example.com' })).rejects.toMatchObject({ + name: 'BrowserCommandError', + code: 'command_result_unknown', + } satisfies Partial); + + expect(ensureSpy).not.toHaveBeenCalled(); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it('sendCommand does NOT resend on a bare TypeError with no pre-connect cause', async () => { + const ensureSpy = vi.spyOn(daemonLifecycle, 'ensureBrowserBridgeReady'); + vi.mocked(fetch).mockRejectedValueOnce(new TypeError('fetch failed')); + + await expect(sendCommand('exec', { code: 'window.__mutate = true' })).rejects.toMatchObject({ + name: 'BrowserCommandError', + code: 'command_result_unknown', + } satisfies Partial); + + expect(ensureSpy).not.toHaveBeenCalled(); + expect(fetch).toHaveBeenCalledTimes(1); + }); + it('sendCommand does NOT wait when the bridge reports profile_required', async () => { const ensureSpy = vi.spyOn(daemonLifecycle, 'ensureBrowserBridgeReady'); const fetchMock = vi.mocked(fetch); @@ -363,6 +394,84 @@ describe('daemon-client', () => { expect(fetchMock).toHaveBeenCalledTimes(1); }); + it('sendCommand plumbs the default command timeout into body.timeout', async () => { + const fetchMock = vi.mocked(fetch); + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => Promise.resolve({ id: 'server', ok: true, data: 1 }), + } as Response); + + await expect(sendCommand('exec', { code: '1' })).resolves.toBe(1); + + const body = JSON.parse(String(fetchMock.mock.calls[0][1]?.body)) as { timeout?: number }; + expect(body.timeout).toBe(120); + }); + + it('sendCommand extends body.timeout past an extension-side timeoutMs (wait-download)', async () => { + const fetchMock = vi.mocked(fetch); + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => Promise.resolve({ id: 'server', ok: true, data: { downloaded: true } }), + } as Response); + + await sendCommand('wait-download', { timeoutMs: 240_000 }); + + const body = JSON.parse(String(fetchMock.mock.calls[0][1]?.body)) as { timeout?: number }; + // 240s extension wait + 15s margin + expect(body.timeout).toBe(255); + }); + + it('setDaemonCommandTimeoutSeconds raises the transport deadline for the user --timeout', async () => { + const fetchMock = vi.mocked(fetch); + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => Promise.resolve({ id: 'server', ok: true, data: 1 }), + } as Response); + + setDaemonCommandTimeoutSeconds(300); + try { + await sendCommand('exec', { code: '1' }); + } finally { + setDaemonCommandTimeoutSeconds(null); + } + + const body = JSON.parse(String(fetchMock.mock.calls[0][1]?.body)) as { timeout?: number }; + expect(body.timeout).toBe(300); + }); + + it('client HTTP abort fires only after the daemon timer margin (timeout*1000 + 10s)', async () => { + vi.useFakeTimers(); + try { + const fetchMock = vi.mocked(fetch); + let aborted = false; + fetchMock.mockImplementationOnce((_url, init) => new Promise((_, reject) => { + init?.signal?.addEventListener('abort', () => { + aborted = true; + reject(Object.assign(new Error('This operation was aborted'), { name: 'AbortError' })); + }); + })); + + const pending = sendCommand('exec', { code: '1' }); + const assertion = expect(pending).rejects.toMatchObject({ + name: 'BrowserCommandError', + code: 'command_result_unknown', + } satisfies Partial); + + // Just before the margin the daemon still owns the deadline — no abort. + await vi.advanceTimersByTimeAsync(120_000 + 9_999); + expect(aborted).toBe(false); + + await vi.advanceTimersByTimeAsync(1); + expect(aborted).toBe(true); + await assertion; + } finally { + vi.useRealTimers(); + } + }); + it('sendCommand surfaces an AbortError as command_result_unknown without ensure or resend', async () => { const ensureSpy = vi.spyOn(daemonLifecycle, 'ensureBrowserBridgeReady'); const abortErr = new Error('The operation was aborted'); diff --git a/src/browser/daemon-client.ts b/src/browser/daemon-client.ts index 7633bcd30..28f2f6b96 100644 --- a/src/browser/daemon-client.ts +++ b/src/browser/daemon-client.ts @@ -6,6 +6,7 @@ import { sleep } from '../utils.js'; import { BrowserConnectError } from '../errors.js'; +import { COMMAND_RESULT_UNKNOWN_CODE, COMMAND_RESULT_UNKNOWN_HINT } from '../daemon-utils.js'; import { classifyBrowserError } from './errors.js'; import { resolveProfileContextId } from './profile.js'; import { DEFAULT_BROWSER_CONNECT_TIMEOUT } from './config.js'; @@ -27,6 +28,69 @@ function generateId(): string { return `cmd_${process.pid}_${Date.now()}_${++_idCounter}`; } +/** + * Transport-level deadlines share one source of truth: `body.timeout` (seconds). + * The daemon arms its per-command timer from it, the extension derives its CDP + * deadline from the same value, and the client HTTP abort fires only after the + * daemon's structured timeout response should have arrived — so failures + * surface innermost-first (extension < daemon < client) with a real error + * instead of an opaque client-side AbortError. + */ +const DEFAULT_COMMAND_TIMEOUT_SECONDS = 120; +/** Headroom past an extension-side operation's own timer (e.g. wait-download). */ +const EXTENSION_OP_TIMEOUT_MARGIN_MS = 15_000; +/** Client aborts only this long after the daemon timer should have fired. */ +const HTTP_TIMEOUT_MARGIN_MS = 10_000; + +let _userCommandTimeoutSeconds: number | null = null; + +/** + * Propagate the user's `--timeout` down to the transport layer. Without this + * the daemon/HTTP deadlines stay at their defaults and a long-running command + * gets aborted mid-flight even though the user explicitly allowed more time. + */ +export function setDaemonCommandTimeoutSeconds(seconds: number | null): void { + _userCommandTimeoutSeconds = typeof seconds === 'number' && seconds > 0 ? Math.ceil(seconds) : null; +} + +function effectiveCommandTimeoutSeconds(params: Omit): number { + const base = _userCommandTimeoutSeconds ?? DEFAULT_COMMAND_TIMEOUT_SECONDS; + if (typeof params.timeoutMs === 'number' && params.timeoutMs > 0) { + return Math.max(base, Math.ceil((params.timeoutMs + EXTENSION_OP_TIMEOUT_MARGIN_MS) / 1000)); + } + return base; +} + +/** + * undici surfaces network failures as `TypeError: fetch failed` with the real + * error in `.cause` (possibly an AggregateError). Only failures that happen + * before the request could reach the daemon are safe to auto-retry — a reset + * or hang-up after connect means the daemon may have already dispatched the + * command to the browser. + */ +const PRE_CONNECT_ERROR_CODES = new Set([ + 'ECONNREFUSED', + 'UND_ERR_CONNECT_TIMEOUT', + 'EHOSTUNREACH', + 'ENETUNREACH', + 'ENOTFOUND', +]); + +function isPreConnectFetchError(err: unknown): boolean { + const queue: unknown[] = [err]; + const seen = new Set(); + while (queue.length) { + const current = queue.pop(); + if (!current || typeof current !== 'object' || seen.has(current)) continue; + seen.add(current); + const { code, cause, errors } = current as { code?: unknown; cause?: unknown; errors?: unknown }; + if (typeof code === 'string' && PRE_CONNECT_ERROR_CODES.has(code)) return true; + if (cause) queue.push(cause); + if (Array.isArray(errors)) queue.push(...errors); + } + return false; +} + export interface DaemonCommand { id: string; action: 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'set-file-input' | 'insert-text' | 'bind' | 'network-capture-start' | 'network-capture-read' | 'wait-download' | 'cdp' | 'frames'; @@ -69,6 +133,12 @@ export interface DaemonCommand { frameIndex?: number; /** Browser profile/context to route the command to. */ contextId?: string; + /** + * Daemon-side command timeout in seconds. Set by the transport layer from + * the effective command deadline; the extension derives its CDP deadline + * from the same value. + */ + timeout?: number; } export interface DaemonResult { @@ -104,8 +174,12 @@ export { * Retry policy is explicit: * - pre-dispatch bridge/profile errors: run the full daemon/extension ensure * path, then resend with a fresh transport id; - * - local TypeError before dispatch: same full ensure path, because the daemon - * may be stopped/stale and needs spawn/replacement, not just polling; + * - fetch TypeError whose cause is a pre-connect failure (ECONNREFUSED etc.): + * same full ensure path, because the daemon may be stopped/stale and needs + * spawn/replacement — the request never reached it, so resending is safe; + * - fetch TypeError after connect (ECONNRESET / socket hang-up): NOT retried — + * the daemon may have already dispatched the command to the browser, so this + * surfaces as `command_result_unknown` instead of risking a double write; * - `command_result_unknown` and AbortError: never retry automatically. */ async function sendCommandRaw( @@ -125,13 +199,21 @@ async function sendCommandRaw( : undefined; const contextId = params.contextId ?? resolveProfileContextId(); const windowMode = params.windowMode ?? envWindowMode; - const command: DaemonCommand = { id, action, ...params, ...(contextId && { contextId }), ...(windowMode && { windowMode }) }; + const timeoutSeconds = effectiveCommandTimeoutSeconds(params); + const command: DaemonCommand = { + id, + action, + ...params, + timeout: timeoutSeconds, + ...(contextId && { contextId }), + ...(windowMode && { windowMode }), + }; try { const res = await requestDaemon('/command', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(command), - timeout: 30000, + timeout: timeoutSeconds * 1000 + HTTP_TIMEOUT_MARGIN_MS, }); const result = (await res.json()) as DaemonResult; @@ -179,14 +261,26 @@ async function sendCommandRaw( ); } - if (!dispatchRecoveryUsed && err instanceof TypeError) { - dispatchRecoveryUsed = true; - await ensureBrowserBridgeReady({ - timeoutSeconds: DEFAULT_BROWSER_CONNECT_TIMEOUT, - contextId, - verbose: false, - }); - continue; + if (err instanceof TypeError) { + if (!isPreConnectFetchError(err)) { + // Connection dropped after the request may have reached the daemon + // (ECONNRESET / socket hang-up) — the command may already be running + // in the browser, so resending would risk a double write. + throw new BrowserCommandError( + 'Connection to the daemon was lost mid-command; it may have already been applied.', + COMMAND_RESULT_UNKNOWN_CODE, + COMMAND_RESULT_UNKNOWN_HINT, + ); + } + if (!dispatchRecoveryUsed) { + dispatchRecoveryUsed = true; + await ensureBrowserBridgeReady({ + timeoutSeconds: DEFAULT_BROWSER_CONNECT_TIMEOUT, + contextId, + verbose: false, + }); + continue; + } } if (err instanceof Error) { diff --git a/src/daemon-utils.ts b/src/daemon-utils.ts index 93f177615..1837edbc8 100644 --- a/src/daemon-utils.ts +++ b/src/daemon-utils.ts @@ -35,6 +35,16 @@ export function buildExtensionDisconnectFailure(input: { return buildCommandDispatchFailure(input.contextId); } +export function buildCommandTimeoutFailure(action: string, timeoutMs: number): DaemonFailureContract { + return { + message: `Browser ${action} command timed out after ${Math.round(timeoutMs / 1000)}s; it may still complete in the browser.`, + errorCode: COMMAND_RESULT_UNKNOWN_CODE, + errorHint: COMMAND_RESULT_UNKNOWN_HINT, + status: 408, + countAsCommandResultUnknown: true, + }; +} + export function buildCommandDispatchFailure(contextId: string): DaemonFailureContract { return { message: `Browser profile "${contextId}" disconnected before command dispatch`, diff --git a/src/daemon.test.ts b/src/daemon.test.ts index cdac27bea..9272ed8cc 100644 --- a/src/daemon.test.ts +++ b/src/daemon.test.ts @@ -4,6 +4,7 @@ import { COMMAND_RESULT_UNKNOWN_CODE, COMMAND_RESULT_UNKNOWN_HINT, buildCommandDispatchFailure, + buildCommandTimeoutFailure, buildExtensionDisconnectFailure, commandResultUnknownMessage, getResponseCorsHeaders, @@ -73,4 +74,14 @@ describe('daemon command dispatch', () => { countAsCommandResultUnknown: false, }); }); + + it('classifies daemon-side command timeouts as command_result_unknown with a 408', () => { + expect(buildCommandTimeoutFailure('navigate', 120_000)).toEqual({ + message: 'Browser navigate command timed out after 120s; it may still complete in the browser.', + errorCode: 'command_result_unknown', + errorHint: COMMAND_RESULT_UNKNOWN_HINT, + status: 408, + countAsCommandResultUnknown: true, + }); + }); }); diff --git a/src/daemon.ts b/src/daemon.ts index 778823852..78134176a 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -30,6 +30,7 @@ import { DEFAULT_CONTEXT_ID } from './browser/profile.js'; import { recordExtensionVersion } from './update-check.js'; import { buildCommandDispatchFailure, + buildCommandTimeoutFailure, buildExtensionDisconnectFailure, getResponseCorsHeaders, } from './daemon-utils.js'; @@ -326,8 +327,14 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise } const result = await new Promise((resolve, reject) => { const timer = setTimeout(() => { + const entry = pending.get(body.id); pending.delete(body.id); - reject(new Error(`Command timeout (${timeoutMs / 1000}s)`)); + const failure = buildCommandTimeoutFailure(entry?.action ?? 'unknown', timeoutMs); + if (failure.countAsCommandResultUnknown && entry?.dispatched) { + commandResultUnknownCount++; + log.warn(`[daemon] Command timed out after dispatch (id=${body.id}, action=${entry.action}, timeout=${timeoutMs}ms)`); + } + reject(new DaemonCommandFailure(failure.message, failure.errorCode, failure.errorHint, failure.status)); }, timeoutMs); const entry = { contextId: route.connection!.contextId, diff --git a/src/doctor.test.ts b/src/doctor.test.ts index 0660ddf4d..67d336029 100644 --- a/src/doctor.test.ts +++ b/src/doctor.test.ts @@ -7,9 +7,13 @@ const { mockGetDaemonHealth, mockConnect, mockClose, mockFindShadowedUserAdapter mockFindShadowedUserAdapters: vi.fn(), })); -vi.mock('./browser/daemon-transport.js', () => ({ - getDaemonHealth: mockGetDaemonHealth, -})); +vi.mock('./browser/daemon-transport.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getDaemonHealth: mockGetDaemonHealth, + }; +}); vi.mock('./browser/index.js', () => ({ BrowserBridge: class { diff --git a/src/doctor.ts b/src/doctor.ts index c851e03b0..fdfc32dc2 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -6,6 +6,7 @@ import { DEFAULT_DAEMON_PORT } from './constants.js'; import { BrowserBridge } from './browser/index.js'; +import { setDaemonCommandTimeoutSeconds } from './browser/daemon-client.js'; import { getDaemonHealth } from './browser/daemon-transport.js'; import { getErrorMessage } from './errors.js'; import { getRuntimeLabel } from './runtime-detect.js'; @@ -79,10 +80,14 @@ export type DoctorReport = { */ export async function checkConnectivity(opts?: { timeout?: number }): Promise { const start = Date.now(); + const timeoutSeconds = opts?.timeout ?? DOCTOR_LIVE_TIMEOUT_SECONDS; + // This is a health probe: shrink the transport's per-command deadline so a + // hung daemon/extension fails the check in seconds, not the default 120s. + setDaemonCommandTimeoutSeconds(timeoutSeconds); try { const bridge = new BrowserBridge(); const page = await bridge.connect({ - timeout: opts?.timeout ?? DOCTOR_LIVE_TIMEOUT_SECONDS, + timeout: timeoutSeconds, session: DOCTOR_SESSION, surface: 'browser', }); @@ -96,6 +101,8 @@ export async function checkConnectivity(opts?: { timeout?: number }): Promise Date: Fri, 3 Jul 2026 14:25:38 +0800 Subject: [PATCH 14/27] =?UTF-8?q?refactor(transport):=20exactly-once=20com?= =?UTF-8?q?mand=20transport=20=E2=80=94=20journal,=20waiters,=20absolute?= =?UTF-8?q?=20deadlines=20(#2070)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(transport): exactly-once command transport — journal, waiters, absolute deadlines Rebuilds the CLI→daemon→extension command transport around one principle: exactly-once = at-least-once retry + an idempotent executor. This replaces the accumulated per-layer compensation (three client retry flags, cause-code walking, duplicate-id 409s, an inner extension retry loop, a phased reconnect state machine) with three small primitives: 1. Command journal (extension/src/journal.ts, chrome.storage.session). Every command id executes exactly once: duplicates attach to the in-flight promise, completed ids replay the recorded result, and ids whose worker died mid-execution report `command_lost` honestly. storage.session survives service-worker restarts and clears on browser exit — precisely the lifetime a retry cares about. 2. Stable ids + daemon waiters. Transport retries keep the SAME id; the daemon attaches duplicate ids to the pending command instead of 409ing. With the executor idempotent, every transport failure becomes safely retryable (gated on extension >= 1.0.22; legacy extensions keep the old conservative pre-connect-only retry). Semantic retries (attach_failed / tab_gone — failures BEFORE any page code ran) are the only place a new id is minted, once. 3. Absolute deadline (`deadlineAt`, epoch ms) instead of per-hop durations. Same machine, one clock: every layer computes remaining = deadlineAt - now, so daemon queueing and service-worker wake latency no longer silently shrink the innermost budget or invert the layering. Error classification now happens once, at the failure site: the extension tags results with machine-readable codes (attach_failed, tab_gone, target_navigated, detached_mid_command, cdp_timeout) and errors.ts prefers codes over the legacy message-pattern tables (kept only for old extensions). detached_mid_command / cdp_timeout are now correctly non-retryable — they die mid-execution, so a blind re-run could double-apply a write. Deletions and stability fixes riding the same contract: - extension evaluate()'s inner retry loop (the client owns semantic retries now); the fast/slow reconnect window + notifyDaemonReachable rescheduling (plain exponential backoff with jitter, reset on success); the three parallel session-override Maps (one record per lease). - WS application-level keepalive ({type:'ping'} every 20s): Chrome 116+ only extends the service worker's lifetime on WS *activity*, so an idle socket lived on a knife-edge between the 30s idle kill and the 30s keepalive alarm. - idle-lease release is deferred while a command is executing on the lease (refcount) — a 30s idle timer can no longer tear the tab down mid-command; completion re-arms the timer. - daemon shutdown flushes structured 503s to waiting clients before exiting instead of process.exit() killing the queued responses. - results are delivered on the freshest open socket after a reconnect instead of being dropped when the executing socket was superseded. * fix(transport): gate daemon_shutting_down resend on journal capability; bound ensure by deadline From adversarial review of the transport-v2 work: - daemon_shutting_down was resent with the same id regardless of the extension's journal capability. The daemon fires it for DISPATCHED commands too, so on a pre-journal extension the resend re-executes a write. The daemon now returns the pre-dispatch contract for commands that never reached the extension (safe to resend anywhere) and daemon_shutting_down only for dispatched ones; the client resends those only when the extension journals ids, else surfaces command_result_unknown. - ensureBridge's connect wait was a fixed 45s regardless of the command's remaining budget — repeated daemon failures could stretch a 30s --timeout command past two minutes. The wait is now clamped to the remaining deadline. --- extension/dist/background.js | 304 ++++++++++++++++++++---------- extension/manifest.json | 2 +- extension/package.json | 2 +- extension/src/background.test.ts | 61 +++--- extension/src/background.ts | 262 +++++++++++++++---------- extension/src/cdp.ts | 65 +++---- extension/src/journal.test.ts | 121 ++++++++++++ extension/src/journal.ts | 146 ++++++++++++++ extension/src/protocol.ts | 8 + src/browser/daemon-client.test.ts | 122 +++++++++--- src/browser/daemon-client.ts | 197 +++++++++++-------- src/browser/errors.ts | 25 +++ src/daemon.ts | 109 +++++++---- 13 files changed, 1023 insertions(+), 401 deletions(-) create mode 100644 extension/src/journal.test.ts create mode 100644 extension/src/journal.ts diff --git a/extension/dist/background.js b/extension/dist/background.js index efe3cf3d0..210f3a759 100644 --- a/extension/dist/background.js +++ b/extension/dist/background.js @@ -115,34 +115,25 @@ async function ensureAttached(tabId, aggressiveRetry = false) { } } async function evaluate(tabId, expression, aggressiveRetry = false, timeoutMs = CDP_COMMAND_TIMEOUT_MS) { - const MAX_EVAL_RETRIES = aggressiveRetry ? 3 : 2; - for (let attempt = 1; attempt <= MAX_EVAL_RETRIES; attempt++) { - try { - await ensureAttached(tabId, aggressiveRetry); - const result = await sendDebuggerCommand({ tabId }, "Runtime.evaluate", { - expression, - returnByValue: true, - awaitPromise: true - }, timeoutMs); - if (result.exceptionDetails) { - const errMsg = result.exceptionDetails.exception?.description || result.exceptionDetails.text || "Eval error"; - throw new Error(errMsg); - } - return result.result?.value; - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - const isNavigateError = msg.includes("Inspected target navigated") || msg.includes("Target closed"); - const isAttachError = isNavigateError || msg.includes("attach failed") || msg.includes("Debugger is not attached") || msg.includes("chrome-extension://"); - if (isAttachError && attempt < MAX_EVAL_RETRIES) { - attached.delete(tabId); - const retryMs = isNavigateError ? 200 : 500; - await new Promise((resolve) => setTimeout(resolve, retryMs)); - continue; - } - throw e; + try { + await ensureAttached(tabId, aggressiveRetry); + const result = await sendDebuggerCommand({ tabId }, "Runtime.evaluate", { + expression, + returnByValue: true, + awaitPromise: true + }, timeoutMs); + if (result.exceptionDetails) { + const errMsg = result.exceptionDetails.exception?.description || result.exceptionDetails.text || "Eval error"; + throw new Error(errMsg); + } + return result.result?.value; + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + if (msg.includes("Detached") || msg.includes("Debugger is not attached") || msg.includes("Target closed")) { + attached.delete(tabId); } + throw e; } - throw new Error("evaluate: max retries exhausted"); } const evaluateAsync = evaluate; async function screenshot(tabId, options = {}) { @@ -656,8 +647,97 @@ async function refreshMappings() { } } +const JOURNAL_KEY = "opencli_command_journal_v1"; +const JOURNAL_MAX_ENTRIES = 64; +const JOURNAL_RESULT_MAX_BYTES = 64 * 1024; +let cache = null; +let writeQueue = Promise.resolve(); +const inFlight = /* @__PURE__ */ new Map(); +async function load() { + if (cache) return cache; + try { + const stored = await chrome.storage.session.get(JOURNAL_KEY); + cache = stored?.[JOURNAL_KEY] ?? {}; + } catch { + cache = {}; + } + return cache; +} +function persist() { + const snapshot = cache; + if (!snapshot) return; + writeQueue = writeQueue.then(async () => { + try { + await chrome.storage.session.set({ [JOURNAL_KEY]: snapshot }); + } catch { + } + }); +} +function trim(journal) { + const ids = Object.keys(journal); + if (ids.length <= JOURNAL_MAX_ENTRIES) return; + ids.sort((a, b) => journal[a].ts - journal[b].ts); + for (const id of ids.slice(0, ids.length - JOURNAL_MAX_ENTRIES)) delete journal[id]; +} +function resultByteLength(result) { + try { + return JSON.stringify(result).length; + } catch { + return Number.POSITIVE_INFINITY; + } +} +const UNKNOWN_OUTCOME_HINT = "Inspect the browser/session state before retrying. Do not blindly re-run write commands such as navigate, click, type, or eval."; +async function executeWithJournal(cmd, execute) { + const id = cmd.id; + if (!id) return execute(cmd); + const running = inFlight.get(id); + if (running) return running; + const run = (async () => { + const journal = await load(); + const entry = journal[id]; + if (entry?.status === "done") { + if (entry.result) return entry.result; + return { + id, + ok: false, + errorCode: "result_evicted", + error: "Command already executed, but its result was too large to record for replay.", + errorHint: UNKNOWN_OUTCOME_HINT + }; + } + if (entry?.status === "started") { + return { + id, + ok: false, + errorCode: "command_lost", + error: "Command was interrupted mid-execution (extension or browser restarted); it may or may not have applied.", + errorHint: UNKNOWN_OUTCOME_HINT + }; + } + journal[id] = { status: "started", ts: Date.now() }; + trim(journal); + persist(); + let result; + try { + result = await execute(cmd); + } catch (err) { + result = { id, ok: false, error: err instanceof Error ? err.message : String(err) }; + } + journal[id] = resultByteLength(result) <= JOURNAL_RESULT_MAX_BYTES ? { status: "done", ts: Date.now(), result } : { status: "done", ts: Date.now() }; + persist(); + return result; + })(); + inFlight.set(id, run); + try { + return await run; + } finally { + inFlight.delete(id); + } +} + let ws = null; let reconnectTimer = null; +let reconnectAttempts = 0; const CONTEXT_ID_KEY = "opencli_context_id_v1"; let currentContextId = "default"; let contextIdPromise = null; @@ -753,7 +833,7 @@ async function connectAttempt() { scheduleReconnect(); return; } - notifyDaemonReachable(); + reconnectAttempts = 0; } catch { scheduleReconnect(); return; @@ -773,31 +853,32 @@ async function connectAttempt() { thisWs.onopen = () => { if (ws !== thisWs) return; console.log("[opencli] Connected to daemon"); - reconnectPhaseStartedAt = 0; + reconnectAttempts = 0; if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } - reconnectTimerDelayMs = null; safeSend(thisWs, { type: "hello", contextId: currentContextId, version: chrome.runtime.getManifest().version, compatRange: ">=1.7.0" }); + startWsKeepalive(thisWs); }; thisWs.onmessage = async (event) => { if (ws !== thisWs) return; try { const command = JSON.parse(event.data); - const result = await handleCommand(command); - if (ws !== thisWs) return; - safeSend(thisWs, result); + const result = await executeWithJournal(command, handleCommand); + const target = ws && ws.readyState === WebSocket.OPEN ? ws : thisWs; + safeSend(target, result); } catch (err) { console.error("[opencli] Message handling error:", err); } }; thisWs.onclose = () => { + stopWsKeepalive(thisWs); if (ws !== thisWs) return; console.log("[opencli] Disconnected from daemon"); ws = null; @@ -807,39 +888,41 @@ async function connectAttempt() { thisWs.close(); }; } -const RECONNECT_FAST_INTERVAL_MS = 3e3; -const RECONNECT_FAST_WINDOW_MS = 3e4; -const RECONNECT_SLOW_INTERVAL_MS = 15e3; -let reconnectPhaseStartedAt = 0; -let reconnectTimerDelayMs = null; +const WS_KEEPALIVE_INTERVAL_MS = 2e4; +let wsKeepaliveTimer = null; +let wsKeepaliveSocket = null; +function startWsKeepalive(socket) { + if (wsKeepaliveTimer) clearInterval(wsKeepaliveTimer); + wsKeepaliveSocket = socket; + wsKeepaliveTimer = setInterval(() => { + if (socket !== ws || socket.readyState !== WebSocket.OPEN) { + stopWsKeepalive(socket); + return; + } + safeSend(socket, { type: "ping", ts: Date.now() }); + }, WS_KEEPALIVE_INTERVAL_MS); +} +function stopWsKeepalive(socket) { + if (wsKeepaliveSocket !== socket) return; + if (wsKeepaliveTimer) clearInterval(wsKeepaliveTimer); + wsKeepaliveTimer = null; + wsKeepaliveSocket = null; +} +const RECONNECT_BASE_DELAY_MS = 1e3; +const RECONNECT_MAX_DELAY_MS = 15e3; function nextReconnectDelayMs() { - const sinceLoss = Date.now() - reconnectPhaseStartedAt; - return sinceLoss < RECONNECT_FAST_WINDOW_MS ? RECONNECT_FAST_INTERVAL_MS : RECONNECT_SLOW_INTERVAL_MS; + const exp = Math.min(RECONNECT_MAX_DELAY_MS, RECONNECT_BASE_DELAY_MS * 2 ** Math.min(reconnectAttempts, 6)); + return exp + Math.floor(Math.random() * 500); } -function scheduleReconnect(opts = {}) { - if (reconnectTimer) { - if (!opts.replaceExisting) return; - clearTimeout(reconnectTimer); - reconnectTimer = null; - reconnectTimerDelayMs = null; - } - if (reconnectPhaseStartedAt === 0) { - reconnectPhaseStartedAt = Date.now(); - } +function scheduleReconnect() { + if (reconnectTimer) return; const delay = nextReconnectDelayMs(); - reconnectTimerDelayMs = delay; + reconnectAttempts++; reconnectTimer = setTimeout(() => { reconnectTimer = null; - reconnectTimerDelayMs = null; void connect(); }, delay); } -function notifyDaemonReachable() { - reconnectPhaseStartedAt = Date.now(); - if (reconnectTimer && reconnectTimerDelayMs !== RECONNECT_FAST_INTERVAL_MS) { - scheduleReconnect({ replaceExisting: true }); - } -} const automationSessions = /* @__PURE__ */ new Map(); const IDLE_TIMEOUT_DEFAULT = 3e4; const IDLE_TIMEOUT_INTERACTIVE = 6e5; @@ -866,9 +949,11 @@ class CommandFailure extends Error { this.name = "CommandFailure"; } } -const sessionTimeoutOverrides = /* @__PURE__ */ new Map(); -const sessionWindowModeOverrides = /* @__PURE__ */ new Map(); -const sessionLifecycleOverrides = /* @__PURE__ */ new Map(); +const sessionOverrides = /* @__PURE__ */ new Map(); +function setSessionOverride(key, patch) { + sessionOverrides.set(key, { ...sessionOverrides.get(key), ...patch }); +} +const activeCommandCounts = /* @__PURE__ */ new Map(); const LEASE_KEY_SEPARATOR = "\0"; function getLeaseKey(session, surface) { return `${surface}${LEASE_KEY_SEPARATOR}${encodeURIComponent(session)}`; @@ -900,15 +985,15 @@ function getSessionFromKey(key) { function getIdleTimeout(key) { const session = automationSessions.get(key); if (session?.kind === "bound") return IDLE_TIMEOUT_NONE; - const adapterPersistent = getSurfaceFromKey(key) === "adapter" && (session?.lifecycle === "persistent" || sessionLifecycleOverrides.get(key) === "persistent"); + const overrides = sessionOverrides.get(key); + const adapterPersistent = getSurfaceFromKey(key) === "adapter" && (session?.lifecycle === "persistent" || overrides?.lifecycle === "persistent"); if (adapterPersistent) return IDLE_TIMEOUT_NONE; - const override = sessionTimeoutOverrides.get(key); - if (override !== void 0) return override; + if (overrides?.idleTimeoutMs !== void 0) return overrides.idleTimeoutMs; return getSurfaceFromKey(key) === "browser" ? IDLE_TIMEOUT_INTERACTIVE : IDLE_TIMEOUT_DEFAULT; } function getLeaseLifecycle(key, kind) { if (kind === "bound") return "pinned"; - const override = sessionLifecycleOverrides.get(key); + const override = sessionOverrides.get(key)?.lifecycle; if (override) return override; return getSurfaceFromKey(key) === "browser" ? "persistent" : "ephemeral"; } @@ -919,7 +1004,7 @@ function getWindowRole(key, ownership) { return ownership === "borrowed" ? "borrowed-user" : getOwnedWindowRole(key); } function getWindowMode(key) { - return sessionWindowModeOverrides.get(key) ?? (getOwnedWindowRole(key) === "interactive" ? "foreground" : "background"); + return sessionOverrides.get(key)?.windowMode ?? (getOwnedWindowRole(key) === "interactive" ? "foreground" : "background"); } function makeAlarmName(leaseKey) { return `${LEASE_IDLE_ALARM_PREFIX}${encodeURIComponent(leaseKey)}`; @@ -1053,9 +1138,7 @@ async function removeLeaseSession(leaseKey) { const existing = automationSessions.get(leaseKey); if (existing?.idleTimer) clearTimeout(existing.idleTimer); automationSessions.delete(leaseKey); - sessionTimeoutOverrides.delete(leaseKey); - sessionWindowModeOverrides.delete(leaseKey); - sessionLifecycleOverrides.delete(leaseKey); + sessionOverrides.delete(leaseKey); scheduleIdleAlarm(leaseKey, IDLE_TIMEOUT_NONE); await persistRuntimeState(); } @@ -1076,6 +1159,9 @@ function resetWindowIdleTimer(leaseKey, remainingMs) { session.idleDeadlineAt = Date.now() + interval; void persistRuntimeState(); session.idleTimer = setTimeout(async () => { + if ((activeCommandCounts.get(leaseKey) ?? 0) > 0) { + return; + } await releaseLease(leaseKey, "idle timeout"); }, interval); } @@ -1440,9 +1526,7 @@ chrome.windows.onRemoved.addListener(async (windowId) => { console.log(`[opencli] ${session.surface} container closed (session=${session.session})`); if (session.idleTimer) clearTimeout(session.idleTimer); automationSessions.delete(leaseKey); - sessionTimeoutOverrides.delete(leaseKey); - sessionWindowModeOverrides.delete(leaseKey); - sessionLifecycleOverrides.delete(leaseKey); + sessionOverrides.delete(leaseKey); scheduleIdleAlarm(leaseKey, IDLE_TIMEOUT_NONE); } } @@ -1454,9 +1538,7 @@ chrome.tabs.onRemoved.addListener(async (tabId) => { if (session.preferredTabId === tabId) { if (session.idleTimer) clearTimeout(session.idleTimer); automationSessions.delete(leaseKey); - sessionTimeoutOverrides.delete(leaseKey); - sessionWindowModeOverrides.delete(leaseKey); - sessionLifecycleOverrides.delete(leaseKey); + sessionOverrides.delete(leaseKey); scheduleIdleAlarm(leaseKey, IDLE_TIMEOUT_NONE); console.log(`[opencli] Session ${session.session} detached from tab ${tabId} (tab closed)`); } @@ -1491,7 +1573,12 @@ initialize(); chrome.alarms.onAlarm.addListener(async (alarm) => { if (alarm.name === "keepalive") void connect(); const leaseKey = leaseKeyFromAlarmName(alarm.name); - if (leaseKey) await releaseLease(leaseKey, "idle alarm"); + if (!leaseKey) return; + if ((activeCommandCounts.get(leaseKey) ?? 0) > 0) { + resetWindowIdleTimer(leaseKey); + return; + } + await releaseLease(leaseKey, "idle alarm"); }); chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { if (msg?.type === "getStatus") { @@ -1531,15 +1618,16 @@ async function handleCommand(cmd) { const surface = getCommandSurface(cmd); const leaseKey = getLeaseKey(session, surface); if (cmd.windowMode === "foreground" || cmd.windowMode === "background") { - sessionWindowModeOverrides.set(leaseKey, cmd.windowMode); + setSessionOverride(leaseKey, { windowMode: cmd.windowMode }); } if (surface === "adapter" && (cmd.siteSession === "persistent" || cmd.siteSession === "ephemeral")) { - sessionLifecycleOverrides.set(leaseKey, cmd.siteSession); + setSessionOverride(leaseKey, { lifecycle: cmd.siteSession }); } if (cmd.idleTimeout != null && cmd.idleTimeout > 0) { - sessionTimeoutOverrides.set(leaseKey, cmd.idleTimeout * 1e3); + setSessionOverride(leaseKey, { idleTimeoutMs: cmd.idleTimeout * 1e3 }); } resetWindowIdleTimer(leaseKey); + activeCommandCounts.set(leaseKey, (activeCommandCounts.get(leaseKey) ?? 0) + 1); try { switch (cmd.action) { case "exec": @@ -1574,13 +1662,12 @@ async function handleCommand(cmd) { return { id: cmd.id, ok: false, error: `Unknown action: ${cmd.action}` }; } } catch (err) { - return { - id: cmd.id, - ok: false, - error: err instanceof Error ? err.message : String(err), - ...err instanceof CommandFailure ? { errorCode: err.code } : {}, - ...err instanceof CommandFailure && err.hint ? { errorHint: err.hint } : {} - }; + return errorResult(cmd.id, err); + } finally { + const remaining = (activeCommandCounts.get(leaseKey) ?? 1) - 1; + if (remaining <= 0) activeCommandCounts.delete(leaseKey); + else activeCommandCounts.set(leaseKey, remaining); + resetWindowIdleTimer(leaseKey); } } const BLANK_PAGE = "about:blank"; @@ -1779,11 +1866,30 @@ async function listAutomationWebTabs(leaseKey) { return tabs.filter((tab) => isDebuggableUrl(tab.url)); } function commandCdpTimeoutMs(cmd) { + if (typeof cmd.deadlineAt === "number" && cmd.deadlineAt > 0) { + return Math.max(1e4, cmd.deadlineAt - Date.now() - 5e3); + } if (typeof cmd.timeout === "number" && cmd.timeout > 0) { return Math.max(1e4, cmd.timeout * 1e3 - 5e3); } return void 0; } +function classifyExtensionError(message) { + if (/Inspected target navigated|Target closed/.test(message)) return "target_navigated"; + if (/Detached while handling command/.test(message)) return "detached_mid_command"; + if (/CDP command .* timed out/.test(message)) return "cdp_timeout"; + if (/attach failed|Debugger is not attached/.test(message)) return "attach_failed"; + if (/No tab with id|no longer exists|No window with id/.test(message)) return "tab_gone"; + return void 0; +} +function errorResult(id, err) { + const message = err instanceof Error ? err.message : String(err); + if (err instanceof CommandFailure) { + return { id, ok: false, error: message, errorCode: err.code, ...err.hint ? { errorHint: err.hint } : {} }; + } + const errorCode = classifyExtensionError(message); + return { id, ok: false, error: message, ...errorCode ? { errorCode } : {} }; +} async function handleExec(cmd, leaseKey) { if (!cmd.code) return { id: cmd.id, ok: false, error: "Missing code" }; const cmdTabId = await resolveCommandTabId(cmd); @@ -1802,7 +1908,7 @@ async function handleExec(cmd, leaseKey) { const data = await evaluateAsync(tabId, cmd.code, aggressive, commandCdpTimeoutMs(cmd)); return pageScopedResult(cmd.id, tabId, data); } catch (err) { - return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; + return errorResult(cmd.id, err); } } async function handleFrames(cmd, leaseKey) { @@ -1812,7 +1918,7 @@ async function handleFrames(cmd, leaseKey) { const tree = await getFrameTree(tabId); return { id: cmd.id, ok: true, data: enumerateCrossOriginFrames(tree) }; } catch (err) { - return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; + return errorResult(cmd.id, err); } } async function handleNavigate(cmd, leaseKey) { @@ -2021,7 +2127,7 @@ async function handleScreenshot(cmd, leaseKey) { }); return pageScopedResult(cmd.id, tabId, data); } catch (err) { - return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; + return errorResult(cmd.id, err); } } const CDP_ALLOWLIST = /* @__PURE__ */ new Set([ @@ -2073,7 +2179,7 @@ async function handleCdp(cmd, leaseKey) { ); return pageScopedResult(cmd.id, tabId, data); } catch (err) { - return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; + return errorResult(cmd.id, err); } } function stripOpenCliFrameRoutingParams(params, stripFrameId) { @@ -2096,7 +2202,7 @@ async function handleSetFileInput(cmd, leaseKey) { await setFileInputFiles(tabId, cmd.files, cmd.selector); return pageScopedResult(cmd.id, tabId, { count: cmd.files.length }); } catch (err) { - return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; + return errorResult(cmd.id, err); } } async function handleInsertText(cmd, leaseKey) { @@ -2109,7 +2215,7 @@ async function handleInsertText(cmd, leaseKey) { await insertText(tabId, cmd.text); return pageScopedResult(cmd.id, tabId, { inserted: true }); } catch (err) { - return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; + return errorResult(cmd.id, err); } } async function handleNetworkCaptureStart(cmd, leaseKey) { @@ -2119,7 +2225,7 @@ async function handleNetworkCaptureStart(cmd, leaseKey) { await startNetworkCapture(tabId, cmd.pattern); return pageScopedResult(cmd.id, tabId, { started: true }); } catch (err) { - return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; + return errorResult(cmd.id, err); } } async function handleNetworkCaptureRead(cmd, leaseKey) { @@ -2129,7 +2235,7 @@ async function handleNetworkCaptureRead(cmd, leaseKey) { const data = await readNetworkCapture(tabId); return pageScopedResult(cmd.id, tabId, data); } catch (err) { - return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; + return errorResult(cmd.id, err); } } async function handleWaitDownload(cmd) { @@ -2137,15 +2243,13 @@ async function handleWaitDownload(cmd) { const data = await waitForDownload(cmd.pattern ?? "", cmd.timeoutMs ?? 3e4); return { id: cmd.id, ok: true, data }; } catch (err) { - return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; + return errorResult(cmd.id, err); } } async function releaseLease(leaseKey, reason = "released") { const session = automationSessions.get(leaseKey); if (!session) { - sessionTimeoutOverrides.delete(leaseKey); - sessionWindowModeOverrides.delete(leaseKey); - sessionLifecycleOverrides.delete(leaseKey); + sessionOverrides.delete(leaseKey); scheduleIdleAlarm(leaseKey, IDLE_TIMEOUT_NONE); await persistRuntimeState(); return; @@ -2184,9 +2288,7 @@ async function releaseLease(leaseKey, reason = "released") { console.log(`[opencli] Detached borrowed tab lease ${session.preferredTabId} (session=${session.session}, surface=${session.surface}, ${reason})`); } automationSessions.delete(leaseKey); - sessionTimeoutOverrides.delete(leaseKey); - sessionWindowModeOverrides.delete(leaseKey); - sessionLifecycleOverrides.delete(leaseKey); + sessionOverrides.delete(leaseKey); await persistRuntimeState(); } async function reconcileTargetLeaseRegistry() { @@ -2212,7 +2314,7 @@ async function reconcileTargetLeaseRegistry() { const tab = await chrome.tabs.get(tabId); if (!isDebuggableUrl(tab.url)) continue; if (stored.lifecycle === "ephemeral" || stored.lifecycle === "persistent" || stored.lifecycle === "pinned") { - sessionLifecycleOverrides.set(leaseKey, stored.lifecycle); + setSessionOverride(leaseKey, { lifecycle: stored.lifecycle }); } const session = makeSession(leaseKey, { session: typeof stored.session === "string" ? stored.session : getSessionFromKey(leaseKey), diff --git a/extension/manifest.json b/extension/manifest.json index 4b4298858..02db293cc 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "OpenCLI", - "version": "1.0.21", + "version": "1.0.22", "description": "Browser automation bridge for the OpenCLI CLI tool. Executes commands in Chrome tab leases via a local daemon.", "permissions": [ "debugger", diff --git a/extension/package.json b/extension/package.json index 6e71e3b3d..491a0ee65 100644 --- a/extension/package.json +++ b/extension/package.json @@ -1,6 +1,6 @@ { "name": "opencli-extension", - "version": "1.0.21", + "version": "1.0.22", "private": true, "opencli": { "compatRange": ">=1.7.0" diff --git a/extension/src/background.test.ts b/extension/src/background.test.ts index f737af69a..dd35f879a 100644 --- a/extension/src/background.test.ts +++ b/extension/src/background.test.ts @@ -821,30 +821,43 @@ describe('background tab isolation', () => { expect(chrome.alarms.create).toHaveBeenCalledWith('keepalive', { periodInMinutes: 0.5 }); }); - it('reschedules a pending slow reconnect timer to fast cadence after daemon ping succeeds', async () => { + it('reconnect delay backs off exponentially with a 15s cap and resets on success', async () => { const { chrome } = createChromeMock(); - vi.useFakeTimers(); - vi.setSystemTime(100_000); vi.stubGlobal('chrome', chrome); - const fetchMock = vi.fn() - .mockRejectedValueOnce(new Error('daemon down')) - .mockResolvedValue({ ok: true }); - vi.stubGlobal('fetch', fetchMock); + vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false }))); const mod = await import('./background'); - await vi.waitFor(() => { - expect(fetchMock).toHaveBeenCalledTimes(1); - }); + mod.__test__.resetReconnectState(); + + mod.__test__.setReconnectAttempts(0); + const first = mod.__test__.nextReconnectDelayMs(); + expect(first).toBeGreaterThanOrEqual(1_000); + expect(first).toBeLessThan(1_500); + mod.__test__.setReconnectAttempts(3); + const fourth = mod.__test__.nextReconnectDelayMs(); + expect(fourth).toBeGreaterThanOrEqual(8_000); + expect(fourth).toBeLessThan(8_500); + + mod.__test__.setReconnectAttempts(10); + const capped = mod.__test__.nextReconnectDelayMs(); + expect(capped).toBeGreaterThanOrEqual(15_000); + expect(capped).toBeLessThan(15_500); + }); + + it('a successful daemon ping resets the backoff before the WebSocket attempt', async () => { + const { chrome } = createChromeMock(); + vi.stubGlobal('chrome', chrome); + vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true }))); + + const mod = await import('./background'); mod.__test__.resetReconnectState(); - mod.__test__.setReconnectPhaseStartedAt(Date.now() - 31_000); - mod.__test__.scheduleReconnectForTest(); - expect(mod.__test__.getReconnectTimerDelay()).toBe(15_000); + mod.__test__.setReconnectAttempts(5); await mod.__test__.connectForTest(); - expect(MockWebSocket.instances).toHaveLength(1); - expect(mod.__test__.getReconnectTimerDelay()).toBe(3_000); + expect(MockWebSocket.instances.length).toBeGreaterThanOrEqual(1); + expect(mod.__test__.getReconnectAttempts()).toBe(0); }); it('ignores daemon commands delivered to a superseded WebSocket', async () => { @@ -1590,7 +1603,7 @@ describe('background tab isolation', () => { expect(mod.__test__.getSession(browserKey('default'))).toBeNull(); }); - it('clears sessionTimeoutOverrides on idle expiry', async () => { + it('clears session overrides on idle expiry', async () => { const { chrome } = createChromeMock(); vi.useFakeTimers(); vi.stubGlobal('chrome', chrome); @@ -1599,7 +1612,7 @@ describe('background tab isolation', () => { mod.__test__.setAutomationWindowId(browserKey('default'), 1); // Set a custom timeout override - mod.__test__.sessionTimeoutOverrides.set(browserKey('default'), 120_000); + mod.__test__.sessionOverrides.set(browserKey('default'), { idleTimeoutMs: 120_000 }); expect(mod.__test__.getIdleTimeout(browserKey('default'))).toBe(120_000); // Trigger idle timer with the custom timeout @@ -1607,19 +1620,19 @@ describe('background tab isolation', () => { await vi.advanceTimersByTimeAsync(120001); // Override should be cleaned up - expect(mod.__test__.sessionTimeoutOverrides.has(browserKey('default'))).toBe(false); + expect(mod.__test__.sessionOverrides.has(browserKey('default'))).toBe(false); expect(mod.__test__.getSession(browserKey('default'))).toBeNull(); // Should fall back to default interactive timeout expect(mod.__test__.getIdleTimeout(browserKey('default'))).toBe(600_000); }); - it('clears sessionTimeoutOverrides on explicit close', async () => { + it('clears session overrides on explicit close', async () => { const { chrome } = createChromeMock(); vi.stubGlobal('chrome', chrome); const mod = await import('./background'); mod.__test__.setAutomationWindowId(browserKey('default'), 1); - mod.__test__.sessionTimeoutOverrides.set(browserKey('default'), 300_000); + mod.__test__.sessionOverrides.set(browserKey('default'), { idleTimeoutMs: 300_000 }); const result = await mod.__test__.handleCommand({ id: 'close-1', @@ -1629,7 +1642,7 @@ describe('background tab isolation', () => { }); expect(result.ok).toBe(true); - expect(mod.__test__.sessionTimeoutOverrides.has(browserKey('default'))).toBe(false); + expect(mod.__test__.sessionOverrides.has(browserKey('default'))).toBe(false); }); it('applies idleTimeout from command to session override', async () => { @@ -1656,7 +1669,7 @@ describe('background tab isolation', () => { expect(mod.__test__.getIdleTimeout(browserKey('default'))).toBe(120_000); }); - it('clears sessionTimeoutOverrides when user manually closes the automation container', async () => { + it('clears session overrides when user manually closes the automation container', async () => { const { chrome } = createChromeMock(); vi.stubGlobal('chrome', chrome); @@ -1664,7 +1677,7 @@ describe('background tab isolation', () => { // Set up a session with window ID 42 and a custom timeout override mod.__test__.setAutomationWindowId(browserKey('default'), 42); - mod.__test__.sessionTimeoutOverrides.set(browserKey('default'), 180_000); + mod.__test__.sessionOverrides.set(browserKey('default'), { idleTimeoutMs: 180_000 }); expect(mod.__test__.getIdleTimeout(browserKey('default'))).toBe(180_000); // Simulate user closing the window — invoke the onRemoved listener @@ -1673,7 +1686,7 @@ describe('background tab isolation', () => { // Session and override should both be cleaned up expect(mod.__test__.getSession(browserKey('default'))).toBeNull(); - expect(mod.__test__.sessionTimeoutOverrides.has(browserKey('default'))).toBe(false); + expect(mod.__test__.sessionOverrides.has(browserKey('default'))).toBe(false); // Should fall back to default interactive timeout expect(mod.__test__.getIdleTimeout(browserKey('default'))).toBe(600_000); }); diff --git a/extension/src/background.ts b/extension/src/background.ts index 30e634690..ec4b05037 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -11,6 +11,7 @@ import type { Command, Result } from './protocol'; import { DAEMON_HOST, DAEMON_PORT, DAEMON_WS_URL, DAEMON_PING_URL } from './protocol'; import * as executor from './cdp'; import * as identity from './identity'; +import { executeWithJournal } from './journal'; let ws: WebSocket | null = null; let reconnectTimer: ReturnType | null = null; @@ -122,7 +123,8 @@ async function connectAttempt(): Promise { scheduleReconnect(); return; // unexpected response — not our daemon, but keep polling. } - notifyDaemonReachable(); + // Daemon is reachable — proceed straight to the WebSocket below. + reconnectAttempts = 0; } catch { scheduleReconnect(); return; // daemon not running — keep polling until the next daemon spawn. @@ -145,12 +147,10 @@ async function connectAttempt(): Promise { if (ws !== thisWs) return; console.log('[opencli] Connected to daemon'); reconnectAttempts = 0; // Reset on successful connection - reconnectPhaseStartedAt = 0; if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } - reconnectTimerDelayMs = null; // Send version + compatibility range so the daemon can report mismatches to the CLI safeSend(thisWs, { type: 'hello', @@ -158,21 +158,30 @@ async function connectAttempt(): Promise { version: chrome.runtime.getManifest().version, compatRange: __OPENCLI_COMPAT_RANGE__, }); + // Application-level keepalive. Chrome (116+) extends the service worker's + // lifetime on WebSocket ACTIVITY — an idle OPEN socket does not count, so + // without this the worker lives on a knife-edge between the 30s idle kill + // and the 30s keepalive alarm. The daemon ignores `ping` messages. + startWsKeepalive(thisWs); }; thisWs.onmessage = async (event) => { if (ws !== thisWs) return; try { const command = JSON.parse(event.data as string) as Command; - const result = await handleCommand(command); - if (ws !== thisWs) return; - safeSend(thisWs, result); + const result = await executeWithJournal(command, handleCommand); + // The socket may have been replaced while a long command ran. Deliver + // the result on the freshest open socket — the daemon correlates by id, + // and the journal replays it if this delivery is lost too. + const target = ws && ws.readyState === WebSocket.OPEN ? ws : thisWs; + safeSend(target, result); } catch (err) { console.error('[opencli] Message handling error:', err); } }; thisWs.onclose = () => { + stopWsKeepalive(thisWs); if (ws !== thisWs) return; console.log('[opencli] Disconnected from daemon'); ws = null; @@ -184,57 +193,57 @@ async function connectAttempt(): Promise { }; } +// ─── WebSocket keepalive ───────────────────────────────────────────── + +const WS_KEEPALIVE_INTERVAL_MS = 20_000; +let wsKeepaliveTimer: ReturnType | null = null; +let wsKeepaliveSocket: WebSocket | null = null; + +function startWsKeepalive(socket: WebSocket): void { + if (wsKeepaliveTimer) clearInterval(wsKeepaliveTimer); + wsKeepaliveSocket = socket; + wsKeepaliveTimer = setInterval(() => { + if (socket !== ws || socket.readyState !== WebSocket.OPEN) { + stopWsKeepalive(socket); + return; + } + safeSend(socket, { type: 'ping', ts: Date.now() }); + }, WS_KEEPALIVE_INTERVAL_MS); +} + +function stopWsKeepalive(socket: WebSocket): void { + if (wsKeepaliveSocket !== socket) return; + if (wsKeepaliveTimer) clearInterval(wsKeepaliveTimer); + wsKeepaliveTimer = null; + wsKeepaliveSocket = null; +} + /** - * Reconnect cadence is phased and never gives up while Chrome keeps the - * service worker alive: - * - * - fast phase: every 3s for 30s after a disconnect/failure; - * - slow phase: every 15s after the fast window expires; - * - durable wake path: chrome.alarms. Production Chrome currently enforces a - * 30s minimum alarm interval, so alarms wake the service worker after idle - * eviction while setTimeout provides the faster path only when the worker - * remains alive. + * Reconnect cadence: plain exponential backoff with jitter, never giving up + * while Chrome keeps the service worker alive. 1s → 2s → 4s → … capped at 15s + * (+0-500ms jitter); attempts reset on a successful WS open. The durable wake + * path is chrome.alarms: production Chrome enforces a ~30s minimum alarm + * interval, so alarms wake the worker after idle eviction while setTimeout + * provides the faster path only when the worker remains alive. */ -const RECONNECT_FAST_INTERVAL_MS = 3000; -const RECONNECT_FAST_WINDOW_MS = 30000; -const RECONNECT_SLOW_INTERVAL_MS = 15000; -let reconnectPhaseStartedAt = 0; -let reconnectTimerDelayMs: number | null = null; +const RECONNECT_BASE_DELAY_MS = 1000; +const RECONNECT_MAX_DELAY_MS = 15000; function nextReconnectDelayMs(): number { - const sinceLoss = Date.now() - reconnectPhaseStartedAt; - return sinceLoss < RECONNECT_FAST_WINDOW_MS - ? RECONNECT_FAST_INTERVAL_MS - : RECONNECT_SLOW_INTERVAL_MS; + const exp = Math.min(RECONNECT_MAX_DELAY_MS, RECONNECT_BASE_DELAY_MS * 2 ** Math.min(reconnectAttempts, 6)); + return exp + Math.floor(Math.random() * 500); } -function scheduleReconnect(opts: { replaceExisting?: boolean } = {}): void { - if (reconnectTimer) { - if (!opts.replaceExisting) return; - clearTimeout(reconnectTimer); - reconnectTimer = null; - reconnectTimerDelayMs = null; - } - reconnectAttempts++; - if (reconnectPhaseStartedAt === 0) { - reconnectPhaseStartedAt = Date.now(); - } +function scheduleReconnect(): void { + if (reconnectTimer) return; const delay = nextReconnectDelayMs(); - reconnectTimerDelayMs = delay; + reconnectAttempts++; reconnectTimer = setTimeout(() => { reconnectTimer = null; - reconnectTimerDelayMs = null; void connect(); }, delay); } -function notifyDaemonReachable(): void { - reconnectPhaseStartedAt = Date.now(); - if (reconnectTimer && reconnectTimerDelayMs !== RECONNECT_FAST_INTERVAL_MS) { - scheduleReconnect({ replaceExisting: true }); - } -} - // ─── Browser target leases ─────────────────────────────────────────── // A browser session owns or borrows a tab lease. Owned leases live in either // the interactive browser window or the background adapter window; bound leases @@ -308,10 +317,24 @@ class CommandFailure extends Error { } } -/** Per-session custom timeout overrides set via command.idleTimeout */ -const sessionTimeoutOverrides = new Map(); -const sessionWindowModeOverrides = new Map(); -const sessionLifecycleOverrides = new Map(); +/** + * Per-session overrides set via command fields (idleTimeout / windowMode / + * siteSession). One record per lease key — a single map so create/clear + * stay in lockstep. + */ +type SessionOverrides = { + idleTimeoutMs?: number; + windowMode?: WindowMode; + lifecycle?: LeaseLifecycle; +}; +const sessionOverrides = new Map(); + +function setSessionOverride(key: string, patch: SessionOverrides): void { + sessionOverrides.set(key, { ...sessionOverrides.get(key), ...patch }); +} + +/** Commands currently executing per lease — idle release is deferred while > 0. */ +const activeCommandCounts = new Map(); const LEASE_KEY_SEPARATOR = '\u0000'; function getLeaseKey(session: string, surface: BrowserSurface): string { @@ -349,17 +372,17 @@ function getSessionFromKey(key: string): string { function getIdleTimeout(key: string): number { const session = automationSessions.get(key); if (session?.kind === 'bound') return IDLE_TIMEOUT_NONE; + const overrides = sessionOverrides.get(key); const adapterPersistent = getSurfaceFromKey(key) === 'adapter' - && (session?.lifecycle === 'persistent' || sessionLifecycleOverrides.get(key) === 'persistent'); + && (session?.lifecycle === 'persistent' || overrides?.lifecycle === 'persistent'); if (adapterPersistent) return IDLE_TIMEOUT_NONE; - const override = sessionTimeoutOverrides.get(key); - if (override !== undefined) return override; + if (overrides?.idleTimeoutMs !== undefined) return overrides.idleTimeoutMs; return getSurfaceFromKey(key) === 'browser' ? IDLE_TIMEOUT_INTERACTIVE : IDLE_TIMEOUT_DEFAULT; } function getLeaseLifecycle(key: string, kind: LeaseKind): LeaseLifecycle { if (kind === 'bound') return 'pinned'; - const override = sessionLifecycleOverrides.get(key); + const override = sessionOverrides.get(key)?.lifecycle; if (override) return override; return getSurfaceFromKey(key) === 'browser' ? 'persistent' : 'ephemeral'; } @@ -373,7 +396,7 @@ function getWindowRole(key: string, ownership: LeaseOwnership): WindowRole { } function getWindowMode(key: string): WindowMode { - return sessionWindowModeOverrides.get(key) + return sessionOverrides.get(key)?.windowMode ?? (getOwnedWindowRole(key) === 'interactive' ? 'foreground' : 'background'); } @@ -527,9 +550,7 @@ async function removeLeaseSession(leaseKey: string): Promise { const existing = automationSessions.get(leaseKey); if (existing?.idleTimer) clearTimeout(existing.idleTimer); automationSessions.delete(leaseKey); - sessionTimeoutOverrides.delete(leaseKey); - sessionWindowModeOverrides.delete(leaseKey); - sessionLifecycleOverrides.delete(leaseKey); + sessionOverrides.delete(leaseKey); scheduleIdleAlarm(leaseKey, IDLE_TIMEOUT_NONE); await persistRuntimeState(); } @@ -557,6 +578,11 @@ function resetWindowIdleTimer(leaseKey: string, remainingMs?: number): void { session.idleDeadlineAt = Date.now() + interval; void persistRuntimeState(); session.idleTimer = setTimeout(async () => { + if ((activeCommandCounts.get(leaseKey) ?? 0) > 0) { + // A command is still executing on this lease — never tear the tab down + // from under it. Its completion re-arms the timer. + return; + } await releaseLease(leaseKey, 'idle timeout'); }, interval); } @@ -1061,9 +1087,7 @@ chrome.windows.onRemoved.addListener(async (windowId) => { console.log(`[opencli] ${session.surface} container closed (session=${session.session})`); if (session.idleTimer) clearTimeout(session.idleTimer); automationSessions.delete(leaseKey); - sessionTimeoutOverrides.delete(leaseKey); - sessionWindowModeOverrides.delete(leaseKey); - sessionLifecycleOverrides.delete(leaseKey); + sessionOverrides.delete(leaseKey); scheduleIdleAlarm(leaseKey, IDLE_TIMEOUT_NONE); } } @@ -1077,9 +1101,7 @@ chrome.tabs.onRemoved.addListener(async (tabId) => { if (session.preferredTabId === tabId) { if (session.idleTimer) clearTimeout(session.idleTimer); automationSessions.delete(leaseKey); - sessionTimeoutOverrides.delete(leaseKey); - sessionWindowModeOverrides.delete(leaseKey); - sessionLifecycleOverrides.delete(leaseKey); + sessionOverrides.delete(leaseKey); scheduleIdleAlarm(leaseKey, IDLE_TIMEOUT_NONE); console.log(`[opencli] Session ${session.session} detached from tab ${tabId} (tab closed)`); } @@ -1126,7 +1148,14 @@ initialize(); chrome.alarms.onAlarm.addListener(async (alarm) => { if (alarm.name === 'keepalive') void connect(); const leaseKey = leaseKeyFromAlarmName(alarm.name); - if (leaseKey) await releaseLease(leaseKey, 'idle alarm'); + if (!leaseKey) return; + if ((activeCommandCounts.get(leaseKey) ?? 0) > 0) { + // A command is mid-flight (the alarm can fire while a long command runs + // in a woken worker) — defer; command completion re-arms the idle timer. + resetWindowIdleTimer(leaseKey); + return; + } + await releaseLease(leaseKey, 'idle alarm'); }); // ─── Popup status API ─────────────────────────────────────────────── @@ -1178,17 +1207,21 @@ async function handleCommand(cmd: Command): Promise { const surface = getCommandSurface(cmd); const leaseKey = getLeaseKey(session, surface); if (cmd.windowMode === 'foreground' || cmd.windowMode === 'background') { - sessionWindowModeOverrides.set(leaseKey, cmd.windowMode); + setSessionOverride(leaseKey, { windowMode: cmd.windowMode }); } if (surface === 'adapter' && (cmd.siteSession === 'persistent' || cmd.siteSession === 'ephemeral')) { - sessionLifecycleOverrides.set(leaseKey, cmd.siteSession); + setSessionOverride(leaseKey, { lifecycle: cmd.siteSession }); } // Apply custom idle timeout if specified in the command if (cmd.idleTimeout != null && cmd.idleTimeout > 0) { - sessionTimeoutOverrides.set(leaseKey, cmd.idleTimeout * 1000); + setSessionOverride(leaseKey, { idleTimeoutMs: cmd.idleTimeout * 1000 }); } - // Reset idle timer on every command (window stays alive while active) + // Reset idle timer on every command (window stays alive while active). + // The in-flight refcount below additionally blocks idle release while a + // long command is still executing — otherwise a 30s idle timer could tear + // the tab down mid-command. resetWindowIdleTimer(leaseKey); + activeCommandCounts.set(leaseKey, (activeCommandCounts.get(leaseKey) ?? 0) + 1); try { switch (cmd.action) { case 'exec': @@ -1223,13 +1256,13 @@ async function handleCommand(cmd: Command): Promise { return { id: cmd.id, ok: false, error: `Unknown action: ${cmd.action}` }; } } catch (err) { - return { - id: cmd.id, - ok: false, - error: err instanceof Error ? err.message : String(err), - ...(err instanceof CommandFailure ? { errorCode: err.code } : {}), - ...(err instanceof CommandFailure && err.hint ? { errorHint: err.hint } : {}), - }; + return errorResult(cmd.id, err); + } finally { + const remaining = (activeCommandCounts.get(leaseKey) ?? 1) - 1; + if (remaining <= 0) activeCommandCounts.delete(leaseKey); + else activeCommandCounts.set(leaseKey, remaining); + // Grant a fresh idle window measured from command COMPLETION, not start. + resetWindowIdleTimer(leaseKey); } } @@ -1491,19 +1524,51 @@ async function listAutomationWebTabs(leaseKey: string): Promise 0) { + return Math.max(10_000, cmd.deadlineAt - Date.now() - 5_000); + } if (typeof cmd.timeout === 'number' && cmd.timeout > 0) { return Math.max(10_000, cmd.timeout * 1000 - 5_000); } return undefined; } +/** + * Map an executor error to a machine-readable code so the CLI can decide + * retry safety without regex-matching message text: + * - `attach_failed` / `tab_gone`: failed BEFORE any page code ran — a new + * logical attempt is safe; + * - `target_navigated`: the document changed under the command — the page + * layer decides whether to settle-retry; + * - `detached_mid_command` / `cdp_timeout`: died MID-execution — the outcome + * is unknown, a blind re-run could double-apply a write. + */ +function classifyExtensionError(message: string): string | undefined { + if (/Inspected target navigated|Target closed/.test(message)) return 'target_navigated'; + if (/Detached while handling command/.test(message)) return 'detached_mid_command'; + if (/CDP command .* timed out/.test(message)) return 'cdp_timeout'; + if (/attach failed|Debugger is not attached/.test(message)) return 'attach_failed'; + if (/No tab with id|no longer exists|No window with id/.test(message)) return 'tab_gone'; + return undefined; +} + +function errorResult(id: string, err: unknown): Result { + const message = err instanceof Error ? err.message : String(err); + if (err instanceof CommandFailure) { + return { id, ok: false, error: message, errorCode: err.code, ...(err.hint ? { errorHint: err.hint } : {}) }; + } + const errorCode = classifyExtensionError(message); + return { id, ok: false, error: message, ...(errorCode ? { errorCode } : {}) }; +} + async function handleExec(cmd: Command, leaseKey: string): Promise { if (!cmd.code) return { id: cmd.id, ok: false, error: 'Missing code' }; const cmdTabId = await resolveCommandTabId(cmd); @@ -1522,7 +1587,7 @@ async function handleExec(cmd: Command, leaseKey: string): Promise { const data = await executor.evaluateAsync(tabId, cmd.code, aggressive, commandCdpTimeoutMs(cmd)); return pageScopedResult(cmd.id, tabId, data); } catch (err) { - return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; + return errorResult(cmd.id, err); } } @@ -1533,7 +1598,7 @@ async function handleFrames(cmd: Command, leaseKey: string): Promise { const tree = await executor.getFrameTree(tabId); return { id: cmd.id, ok: true, data: enumerateCrossOriginFrames(tree) }; } catch (err) { - return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; + return errorResult(cmd.id, err); } } @@ -1773,7 +1838,7 @@ async function handleScreenshot(cmd: Command, leaseKey: string): Promise }); return pageScopedResult(cmd.id, tabId, data); } catch (err) { - return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; + return errorResult(cmd.id, err); } } @@ -1832,7 +1897,7 @@ async function handleCdp(cmd: Command, leaseKey: string): Promise { ); return pageScopedResult(cmd.id, tabId, data); } catch (err) { - return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; + return errorResult(cmd.id, err); } } @@ -1858,7 +1923,7 @@ async function handleSetFileInput(cmd: Command, leaseKey: string): Promise await executor.insertText(tabId, cmd.text); return pageScopedResult(cmd.id, tabId, { inserted: true }); } catch (err) { - return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; + return errorResult(cmd.id, err); } } @@ -1883,7 +1948,7 @@ async function handleNetworkCaptureStart(cmd: Command, leaseKey: string): Promis await executor.startNetworkCapture(tabId, cmd.pattern); return pageScopedResult(cmd.id, tabId, { started: true }); } catch (err) { - return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; + return errorResult(cmd.id, err); } } @@ -1894,7 +1959,7 @@ async function handleNetworkCaptureRead(cmd: Command, leaseKey: string): Promise const data = await executor.readNetworkCapture(tabId); return pageScopedResult(cmd.id, tabId, data); } catch (err) { - return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; + return errorResult(cmd.id, err); } } @@ -1903,16 +1968,14 @@ async function handleWaitDownload(cmd: Command): Promise { const data = await executor.waitForDownload(cmd.pattern ?? '', cmd.timeoutMs ?? 30000); return { id: cmd.id, ok: true, data }; } catch (err) { - return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) }; + return errorResult(cmd.id, err); } } async function releaseLease(leaseKey: string, reason: string = 'released'): Promise { const session = automationSessions.get(leaseKey); if (!session) { - sessionTimeoutOverrides.delete(leaseKey); - sessionWindowModeOverrides.delete(leaseKey); - sessionLifecycleOverrides.delete(leaseKey); + sessionOverrides.delete(leaseKey); scheduleIdleAlarm(leaseKey, IDLE_TIMEOUT_NONE); await persistRuntimeState(); return; @@ -1955,9 +2018,7 @@ async function releaseLease(leaseKey: string, reason: string = 'released'): Prom } automationSessions.delete(leaseKey); - sessionTimeoutOverrides.delete(leaseKey); - sessionWindowModeOverrides.delete(leaseKey); - sessionLifecycleOverrides.delete(leaseKey); + sessionOverrides.delete(leaseKey); await persistRuntimeState(); } @@ -1986,7 +2047,7 @@ async function reconcileTargetLeaseRegistry(): Promise { const tab = await chrome.tabs.get(tabId); if (!isDebuggableUrl(tab.url)) continue; if (stored.lifecycle === 'ephemeral' || stored.lifecycle === 'persistent' || stored.lifecycle === 'pinned') { - sessionLifecycleOverrides.set(leaseKey, stored.lifecycle); + setSessionOverride(leaseKey, { lifecycle: stored.lifecycle }); } const session = makeSession(leaseKey, { session: typeof stored.session === 'string' ? stored.session : getSessionFromKey(leaseKey), @@ -2084,20 +2145,21 @@ export const __test__ = { getCommandSurface, getIdleTimeout, getLeaseKey, - sessionTimeoutOverrides, + sessionOverrides, reconcileTargetLeaseRegistry, ensureOwnedContainerGroup, connectForTest: connect, scheduleReconnectForTest: () => scheduleReconnect(), - notifyDaemonReachableForTest: notifyDaemonReachable, - getReconnectTimerDelay: () => reconnectTimerDelayMs, - setReconnectPhaseStartedAt: (value: number) => { reconnectPhaseStartedAt = value; }, + getReconnectAttempts: () => reconnectAttempts, + setReconnectAttempts: (value: number) => { reconnectAttempts = value; }, + nextReconnectDelayMs, resetReconnectState: () => { if (reconnectTimer) clearTimeout(reconnectTimer); reconnectTimer = null; - reconnectTimerDelayMs = null; reconnectAttempts = 0; - reconnectPhaseStartedAt = 0; + if (wsKeepaliveTimer) clearInterval(wsKeepaliveTimer); + wsKeepaliveTimer = null; + wsKeepaliveSocket = null; connectInFlight = null; ws = null; }, diff --git a/extension/src/cdp.ts b/extension/src/cdp.ts index 71a4649a0..a32b10cb7 100644 --- a/extension/src/cdp.ts +++ b/extension/src/cdp.ts @@ -230,47 +230,38 @@ export async function evaluate( aggressiveRetry: boolean = false, timeoutMs: number = CDP_COMMAND_TIMEOUT_MS, ): Promise { - // Retry the entire evaluate (attach + command). - // Normal: 2 retries. Browser: 3 retries (tolerates extension interference). - const MAX_EVAL_RETRIES = aggressiveRetry ? 3 : 2; - for (let attempt = 1; attempt <= MAX_EVAL_RETRIES; attempt++) { - try { - await ensureAttached(tabId, aggressiveRetry); - - const result = await sendDebuggerCommand({ tabId }, 'Runtime.evaluate', { - expression, - returnByValue: true, - awaitPromise: true, - }, timeoutMs) as { - result?: { type: string; value?: unknown; description?: string; subtype?: string }; - exceptionDetails?: { exception?: { description?: string }; text?: string }; - }; + // No retry loop here: failures carry a machine-readable errorCode (see + // classifyExtensionError in background.ts) and the CLI decides whether a + // NEW logical attempt is safe. ensureAttached still does its own local + // attach retries; a debugger error mid-evaluate invalidates the attach + // cache so the next attempt re-attaches. + try { + await ensureAttached(tabId, aggressiveRetry); + + const result = await sendDebuggerCommand({ tabId }, 'Runtime.evaluate', { + expression, + returnByValue: true, + awaitPromise: true, + }, timeoutMs) as { + result?: { type: string; value?: unknown; description?: string; subtype?: string }; + exceptionDetails?: { exception?: { description?: string }; text?: string }; + }; - if (result.exceptionDetails) { - const errMsg = result.exceptionDetails.exception?.description - || result.exceptionDetails.text - || 'Eval error'; - throw new Error(errMsg); - } + if (result.exceptionDetails) { + const errMsg = result.exceptionDetails.exception?.description + || result.exceptionDetails.text + || 'Eval error'; + throw new Error(errMsg); + } - return result.result?.value; - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - // Only retry on attach/debugger errors, not on JS eval errors - const isNavigateError = msg.includes('Inspected target navigated') || msg.includes('Target closed'); - const isAttachError = isNavigateError || msg.includes('attach failed') || msg.includes('Debugger is not attached') - || msg.includes('chrome-extension://'); - if (isAttachError && attempt < MAX_EVAL_RETRIES) { - attached.delete(tabId); // Force re-attach on next attempt - // SPA navigations recover quickly; debugger detach needs longer - const retryMs = isNavigateError ? 200 : 500; - await new Promise(resolve => setTimeout(resolve, retryMs)); - continue; - } - throw e; + return result.result?.value; + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + if (msg.includes('Detached') || msg.includes('Debugger is not attached') || msg.includes('Target closed')) { + attached.delete(tabId); // Force re-attach on the next command } + throw e; } - throw new Error('evaluate: max retries exhausted'); } export const evaluateAsync = evaluate; diff --git a/extension/src/journal.test.ts b/extension/src/journal.test.ts new file mode 100644 index 000000000..4b654f1bc --- /dev/null +++ b/extension/src/journal.test.ts @@ -0,0 +1,121 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { Command, Result } from './protocol'; +import { executeWithJournal, __test__ } from './journal'; + +function makeCmd(id: string): Command { + return { id, action: 'exec', code: '1 + 1', session: 's', surface: 'browser' }; +} + +function makeSessionStorageMock() { + let store: Record = {}; + return { + get: vi.fn(async (key: string) => ({ [key]: store[key] })), + set: vi.fn(async (items: Record) => { store = { ...store, ...items }; }), + _dump: () => store, + _load: (items: Record) => { store = items; }, + }; +} + +describe('command journal', () => { + let sessionStorage: ReturnType; + + beforeEach(() => { + __test__.reset(); + sessionStorage = makeSessionStorageMock(); + vi.stubGlobal('chrome', { storage: { session: sessionStorage } }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('executes a fresh id once and replays the recorded result for duplicates', async () => { + const execute = vi.fn(async (cmd: Command): Promise => ({ id: cmd.id, ok: true, data: 42 })); + + const first = await executeWithJournal(makeCmd('cmd-1'), execute); + const replay = await executeWithJournal(makeCmd('cmd-1'), execute); + + expect(first).toEqual({ id: 'cmd-1', ok: true, data: 42 }); + expect(replay).toEqual(first); + expect(execute).toHaveBeenCalledTimes(1); + }); + + it('attaches concurrent duplicates to the in-flight execution', async () => { + let release: (r: Result) => void; + const gate = new Promise((resolve) => { release = resolve; }); + const execute = vi.fn(() => gate); + + const a = executeWithJournal(makeCmd('cmd-2'), execute); + const b = executeWithJournal(makeCmd('cmd-2'), execute); + release!({ id: 'cmd-2', ok: true, data: 'once' }); + + expect(await a).toEqual({ id: 'cmd-2', ok: true, data: 'once' }); + expect(await b).toEqual({ id: 'cmd-2', ok: true, data: 'once' }); + expect(execute).toHaveBeenCalledTimes(1); + }); + + it('reports command_lost when a started entry survives a worker restart', async () => { + // Simulate: previous worker persisted 'started', then died mid-execution. + sessionStorage._load({ + opencli_command_journal_v1: { 'cmd-3': { status: 'started', ts: Date.now() } }, + }); + + const execute = vi.fn(); + const result = await executeWithJournal(makeCmd('cmd-3'), execute); + + expect(result.ok).toBe(false); + expect(result.errorCode).toBe('command_lost'); + expect(execute).not.toHaveBeenCalled(); + }); + + it('replays a completed result persisted by a previous worker', async () => { + const recorded: Result = { id: 'cmd-4', ok: true, data: 'from-previous-worker' }; + sessionStorage._load({ + opencli_command_journal_v1: { 'cmd-4': { status: 'done', ts: Date.now(), result: recorded } }, + }); + + const execute = vi.fn(); + const result = await executeWithJournal(makeCmd('cmd-4'), execute); + + expect(result).toEqual(recorded); + expect(execute).not.toHaveBeenCalled(); + }); + + it('reports result_evicted for oversized results instead of re-executing', async () => { + const huge = 'x'.repeat(65 * 1024); + const execute = vi.fn(async (cmd: Command): Promise => ({ id: cmd.id, ok: true, data: huge })); + + const first = await executeWithJournal(makeCmd('cmd-5'), execute); + const replay = await executeWithJournal(makeCmd('cmd-5'), execute); + + expect(first.ok).toBe(true); + expect(replay.ok).toBe(false); + expect(replay.errorCode).toBe('result_evicted'); + expect(execute).toHaveBeenCalledTimes(1); + }); + + it('records a thrown execution as a done error result and replays it', async () => { + const execute = vi.fn(async () => { throw new Error('boom'); }); + + const first = await executeWithJournal(makeCmd('cmd-6'), execute); + const replay = await executeWithJournal(makeCmd('cmd-6'), execute); + + expect(first).toMatchObject({ id: 'cmd-6', ok: false, error: 'boom' }); + expect(replay).toEqual(first); + expect(execute).toHaveBeenCalledTimes(1); + }); + + it('degrades to in-memory when chrome.storage.session is unavailable', async () => { + vi.stubGlobal('chrome', {}); + __test__.reset(); + const execute = vi.fn(async (cmd: Command): Promise => ({ id: cmd.id, ok: true, data: 1 })); + + const first = await executeWithJournal(makeCmd('cmd-7'), execute); + const replay = await executeWithJournal(makeCmd('cmd-7'), execute); + + expect(first.ok).toBe(true); + expect(replay).toEqual(first); + expect(execute).toHaveBeenCalledTimes(1); + }); +}); diff --git a/extension/src/journal.ts b/extension/src/journal.ts new file mode 100644 index 000000000..b394b59aa --- /dev/null +++ b/extension/src/journal.ts @@ -0,0 +1,146 @@ +/** + * Command journal — the executor-side half of the transport's retry contract. + * + * The CLI retries a failed transport attempt with the SAME command id; this + * journal makes that retry safe by making execution idempotent per id: + * + * - a command currently executing attaches to the in-flight promise; + * - a completed command replays its recorded Result instead of re-executing; + * - a command that started but never finished (service worker died mid-way) + * reports `command_lost` instead of silently re-executing a write. + * + * Persisted in chrome.storage.session: survives service-worker restarts, + * cleared when the browser exits — exactly the lifetime of "results a retry + * might still ask for". When storage is unavailable (tests, very old Chrome) + * the journal degrades to in-memory only. + */ + +import type { Command, Result } from './protocol'; + +const JOURNAL_KEY = 'opencli_command_journal_v1'; +const JOURNAL_MAX_ENTRIES = 64; +/** Results larger than this are not recorded; a replayed id re-fails honestly. */ +const JOURNAL_RESULT_MAX_BYTES = 64 * 1024; + +type JournalEntry = + | { status: 'started'; ts: number } + | { status: 'done'; ts: number; result?: Result }; + +type JournalMap = Record; + +let cache: JournalMap | null = null; +let writeQueue: Promise = Promise.resolve(); +const inFlight = new Map>(); + +async function load(): Promise { + if (cache) return cache; + try { + const stored = await chrome.storage.session.get(JOURNAL_KEY); + cache = (stored?.[JOURNAL_KEY] as JournalMap | undefined) ?? {}; + } catch { + cache = {}; + } + return cache; +} + +function persist(): void { + const snapshot = cache; + if (!snapshot) return; + writeQueue = writeQueue.then(async () => { + try { + await chrome.storage.session.set({ [JOURNAL_KEY]: snapshot }); + } catch { + // Storage unavailable — journal stays in-memory for this worker's lifetime. + } + }); +} + +function trim(journal: JournalMap): void { + const ids = Object.keys(journal); + if (ids.length <= JOURNAL_MAX_ENTRIES) return; + ids.sort((a, b) => journal[a].ts - journal[b].ts); + for (const id of ids.slice(0, ids.length - JOURNAL_MAX_ENTRIES)) delete journal[id]; +} + +function resultByteLength(result: Result): number { + try { + return JSON.stringify(result).length; + } catch { + return Number.POSITIVE_INFINITY; + } +} + +const UNKNOWN_OUTCOME_HINT = + 'Inspect the browser/session state before retrying. Do not blindly re-run write commands such as navigate, click, type, or eval.'; + +/** + * Execute a command exactly once per id. Duplicate deliveries of the same id + * (transport retries after a dropped connection) replay the recorded outcome. + */ +export async function executeWithJournal( + cmd: Command, + execute: (cmd: Command) => Promise, +): Promise { + const id = cmd.id; + if (!id) return execute(cmd); + + const running = inFlight.get(id); + if (running) return running; + + // Register in-flight SYNCHRONOUSLY (before any await) so a duplicate + // arriving in the same tick attaches here instead of racing past the + // journal check and misreading our own 'started' marker as a lost command. + const run = (async (): Promise => { + const journal = await load(); + const entry = journal[id]; + if (entry?.status === 'done') { + if (entry.result) return entry.result; + return { + id, + ok: false, + errorCode: 'result_evicted', + error: 'Command already executed, but its result was too large to record for replay.', + errorHint: UNKNOWN_OUTCOME_HINT, + }; + } + if (entry?.status === 'started') { + return { + id, + ok: false, + errorCode: 'command_lost', + error: 'Command was interrupted mid-execution (extension or browser restarted); it may or may not have applied.', + errorHint: UNKNOWN_OUTCOME_HINT, + }; + } + + journal[id] = { status: 'started', ts: Date.now() }; + trim(journal); + persist(); + let result: Result; + try { + result = await execute(cmd); + } catch (err) { + result = { id, ok: false, error: err instanceof Error ? err.message : String(err) }; + } + journal[id] = resultByteLength(result) <= JOURNAL_RESULT_MAX_BYTES + ? { status: 'done', ts: Date.now(), result } + : { status: 'done', ts: Date.now() }; + persist(); + return result; + })(); + + inFlight.set(id, run); + try { + return await run; + } finally { + inFlight.delete(id); + } +} + +export const __test__ = { + reset(): void { + cache = null; + inFlight.clear(); + writeQueue = Promise.resolve(); + }, +}; diff --git a/extension/src/protocol.ts b/extension/src/protocol.ts index 61f89876f..240ed2fb8 100644 --- a/extension/src/protocol.ts +++ b/extension/src/protocol.ts @@ -81,8 +81,16 @@ export interface Command { * Daemon-side command timeout in seconds, set by the CLI transport. The * extension derives its CDP deadline from this so it fails just before the * daemon timer and its (more specific) error wins. + * Kept alongside `deadlineAt` for older daemons; new code prefers deadlineAt. */ timeout?: number; + /** + * Absolute command deadline (epoch ms), set by the CLI transport. All hops + * run on the same machine, so every layer derives its remaining budget as + * `deadlineAt - Date.now()` — queueing and service-worker wake latency are + * absorbed instead of silently shrinking the innermost budget. + */ + deadlineAt?: number; } export interface Result { diff --git a/src/browser/daemon-client.test.ts b/src/browser/daemon-client.test.ts index 6499b4080..ccde1797a 100644 --- a/src/browser/daemon-client.test.ts +++ b/src/browser/daemon-client.test.ts @@ -221,14 +221,13 @@ describe('daemon-client', () => { expect(body.windowMode).toBe('background'); }); - it('sendCommand retries with a new id when daemon reports a duplicate pending id', async () => { - vi.spyOn(Date, 'now').mockReturnValue(1_763_000_000_123); + it('sendCommand retries executor-transient errors ONCE with a NEW id (re-execution is a new logical attempt)', async () => { const fetchMock = vi.mocked(fetch); fetchMock .mockResolvedValueOnce({ ok: false, - status: 409, - json: () => Promise.resolve({ ok: false, error: 'Duplicate command id already pending; retry' }), + status: 200, + json: () => Promise.resolve({ id: 'server', ok: false, error: 'attach failed: interference', errorCode: 'attach_failed' }), } as Response) .mockResolvedValueOnce({ ok: true, @@ -246,6 +245,21 @@ describe('daemon-client', () => { expect(ids[0]).not.toBe(ids[1]); }); + it('sendCommand does NOT retry mid-execution failures (detached_mid_command) — outcome is unknown', async () => { + const fetchMock = vi.mocked(fetch); + fetchMock.mockResolvedValueOnce({ + ok: false, + status: 200, + json: () => Promise.resolve({ id: 'server', ok: false, error: 'Detached while handling command', errorCode: 'detached_mid_command' }), + } as Response); + + await expect(sendCommand('exec', { code: 'submit()' })).rejects.toMatchObject({ + name: 'BrowserCommandError', + code: 'detached_mid_command', + } satisfies Partial); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + it('sendCommand does not retry command_result_unknown even when the message looks transient', async () => { const fetchMock = vi.mocked(fetch); fetchMock.mockResolvedValue({ @@ -268,9 +282,8 @@ describe('daemon-client', () => { expect(fetchMock).toHaveBeenCalledTimes(1); }); - it('sendCommand runs full bridge ensure on a pre-dispatch failure, then resends with a fresh id', async () => { - vi.spyOn(Date, 'now').mockReturnValue(1_763_000_000_321); - const ensureSpy = vi.spyOn(daemonLifecycle, 'ensureBrowserBridgeReady').mockResolvedValue({ + function mockEnsureReady(extensionVersion?: string) { + return vi.spyOn(daemonLifecycle, 'ensureBrowserBridgeReady').mockResolvedValue({ health: { state: 'ready', status: { @@ -278,6 +291,7 @@ describe('daemon-client', () => { pid: 1, uptime: 1, extensionConnected: true, + ...(extensionVersion && { extensionVersion }), pending: 0, memoryMB: 0, port: 19825, @@ -285,6 +299,11 @@ describe('daemon-client', () => { }, spawnedProcess: null, }); + } + + it('sendCommand runs full bridge ensure on a pre-dispatch failure, then resends the SAME id', async () => { + vi.spyOn(Date, 'now').mockReturnValue(1_763_000_000_321); + const ensureSpy = mockEnsureReady('1.0.22'); const fetchMock = vi.mocked(fetch); fetchMock .mockResolvedValueOnce({ @@ -307,25 +326,12 @@ describe('daemon-client', () => { expect(ensureSpy).toHaveBeenCalledWith(expect.objectContaining({ contextId: 'work', verbose: false })); const ids = fetchMock.mock.calls.map(([, init]) => (JSON.parse(String(init?.body)) as { id: string }).id); expect(ids).toHaveLength(2); - expect(ids[0]).not.toBe(ids[1]); + // Transport retries keep the id stable so the executor's journal can dedupe. + expect(ids[0]).toBe(ids[1]); }); it('sendCommand runs full bridge ensure on a pre-connect TypeError (ECONNREFUSED) before resending', async () => { - const ensureSpy = vi.spyOn(daemonLifecycle, 'ensureBrowserBridgeReady').mockResolvedValue({ - health: { - state: 'ready', - status: { - ok: true, - pid: 1, - uptime: 1, - extensionConnected: true, - pending: 0, - memoryMB: 0, - port: 19825, - }, - }, - spawnedProcess: null, - }); + const ensureSpy = mockEnsureReady(); const refused = new TypeError('fetch failed'); (refused as { cause?: unknown }).cause = Object.assign(new Error('connect ECONNREFUSED 127.0.0.1:19825'), { code: 'ECONNREFUSED' }); const fetchMock = vi.mocked(fetch); @@ -343,8 +349,28 @@ describe('daemon-client', () => { expect(fetchMock).toHaveBeenCalledTimes(2); }); - it('sendCommand does NOT resend on a post-connect TypeError (ECONNRESET) — surfaces command_result_unknown', async () => { - const ensureSpy = vi.spyOn(daemonLifecycle, 'ensureBrowserBridgeReady'); + it('sendCommand retries a post-connect drop with the SAME id when the extension journals ids', async () => { + mockEnsureReady('1.0.22'); + const reset = new TypeError('fetch failed'); + (reset as { cause?: unknown }).cause = Object.assign(new Error('read ECONNRESET'), { code: 'ECONNRESET' }); + const fetchMock = vi.mocked(fetch); + fetchMock + .mockRejectedValueOnce(reset) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => Promise.resolve({ id: 'server', ok: true, data: 'replayed' }), + } as Response); + + await expect(sendCommand('navigate', { url: 'https://example.com' })).resolves.toBe('replayed'); + + const ids = fetchMock.mock.calls.map(([, init]) => (JSON.parse(String(init?.body)) as { id: string }).id); + expect(ids).toHaveLength(2); + expect(ids[0]).toBe(ids[1]); + }); + + it('sendCommand does NOT resend a post-connect drop when the extension predates the journal', async () => { + const ensureSpy = mockEnsureReady('1.0.21'); const reset = new TypeError('fetch failed'); (reset as { cause?: unknown }).cause = Object.assign(new Error('read ECONNRESET'), { code: 'ECONNRESET' }); vi.mocked(fetch).mockRejectedValueOnce(reset); @@ -354,12 +380,50 @@ describe('daemon-client', () => { code: 'command_result_unknown', } satisfies Partial); - expect(ensureSpy).not.toHaveBeenCalled(); + expect(ensureSpy).toHaveBeenCalledTimes(1); expect(fetch).toHaveBeenCalledTimes(1); }); - it('sendCommand does NOT resend on a bare TypeError with no pre-connect cause', async () => { - const ensureSpy = vi.spyOn(daemonLifecycle, 'ensureBrowserBridgeReady'); + it('sendCommand retries daemon_shutting_down with the same id when the extension journals ids', async () => { + mockEnsureReady('1.0.22'); + const fetchMock = vi.mocked(fetch); + fetchMock + .mockResolvedValueOnce({ + ok: false, + status: 503, + json: () => Promise.resolve({ ok: false, errorCode: 'daemon_shutting_down', error: 'Daemon shutting down before the command completed.' }), + } as Response) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => Promise.resolve({ id: 'server', ok: true, data: 'replayed' }), + } as Response); + + await expect(sendCommand('navigate', { url: 'https://example.com' })).resolves.toBe('replayed'); + + const ids = fetchMock.mock.calls.map(([, init]) => (JSON.parse(String(init?.body)) as { id: string }).id); + expect(ids).toHaveLength(2); + expect(ids[0]).toBe(ids[1]); + }); + + it('sendCommand does NOT resend daemon_shutting_down on a legacy extension — outcome unknown', async () => { + mockEnsureReady('1.0.21'); + const fetchMock = vi.mocked(fetch); + fetchMock.mockResolvedValueOnce({ + ok: false, + status: 503, + json: () => Promise.resolve({ ok: false, errorCode: 'daemon_shutting_down', error: 'Daemon shutting down before the command completed.' }), + } as Response); + + await expect(sendCommand('navigate', { url: 'https://example.com' })).rejects.toMatchObject({ + name: 'BrowserCommandError', + code: 'command_result_unknown', + } satisfies Partial); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('sendCommand does NOT resend a bare TypeError on a legacy extension', async () => { + const ensureSpy = mockEnsureReady('1.0.15'); vi.mocked(fetch).mockRejectedValueOnce(new TypeError('fetch failed')); await expect(sendCommand('exec', { code: 'window.__mutate = true' })).rejects.toMatchObject({ @@ -367,7 +431,7 @@ describe('daemon-client', () => { code: 'command_result_unknown', } satisfies Partial); - expect(ensureSpy).not.toHaveBeenCalled(); + expect(ensureSpy).toHaveBeenCalledTimes(1); expect(fetch).toHaveBeenCalledTimes(1); }); diff --git a/src/browser/daemon-client.ts b/src/browser/daemon-client.ts index 28f2f6b96..f01643603 100644 --- a/src/browser/daemon-client.ts +++ b/src/browser/daemon-client.ts @@ -61,6 +61,31 @@ function effectiveCommandTimeoutSeconds(params: Omit 0; + } + return true; +} + +/** Error codes meaning the executor's outcome is genuinely unknown — never auto-retry. */ +const UNKNOWN_OUTCOME_CODES = new Set(['command_result_unknown', 'command_lost', 'result_evicted']); + +/** Max transport attempts for one logical command (same id throughout). */ +const TRANSPORT_MAX_ATTEMPTS = 4; + /** * undici surfaces network failures as `TypeError: fetch failed` with the real * error in `.cause` (possibly an AggregateError). Only failures that happen @@ -135,10 +160,16 @@ export interface DaemonCommand { contextId?: string; /** * Daemon-side command timeout in seconds. Set by the transport layer from - * the effective command deadline; the extension derives its CDP deadline - * from the same value. + * the effective command deadline; kept for older daemons — new code prefers + * `deadlineAt`. */ timeout?: number; + /** + * Absolute command deadline (epoch ms). All hops run on one machine, so + * every layer derives its remaining budget as `deadlineAt - Date.now()`, + * absorbing queueing and service-worker wake latency. + */ + deadlineAt?: number; } export interface DaemonResult { @@ -171,40 +202,70 @@ export { /** * Internal: send a command to the daemon and return the raw `DaemonResult`. * - * Retry policy is explicit: - * - pre-dispatch bridge/profile errors: run the full daemon/extension ensure - * path, then resend with a fresh transport id; - * - fetch TypeError whose cause is a pre-connect failure (ECONNREFUSED etc.): - * same full ensure path, because the daemon may be stopped/stale and needs - * spawn/replacement — the request never reached it, so resending is safe; - * - fetch TypeError after connect (ECONNRESET / socket hang-up): NOT retried — - * the daemon may have already dispatched the command to the browser, so this - * surfaces as `command_result_unknown` instead of risking a double write; - * - `command_result_unknown` and AbortError: never retry automatically. + * There are exactly two retry classes, with different id semantics: + * + * TRANSPORT retries — the SAME command id, so the executor's journal replays + * an already-executed command instead of re-running it: + * - fetch failures (daemon down/replaced/crashed): run the ensure path (which + * also spawns the daemon and tells us the extension version), then resend. + * Requires a journaling extension unless the failure was pre-connect; + * - pre-dispatch bridge/profile errors and `daemon_shutting_down`; + * - a duplicate id landing on a still-pending command attaches to it in the + * daemon (no re-dispatch), so same-id resends never double-execute. + * + * SEMANTIC retry — ONE new logical attempt with a NEW id, only for executor + * errors that happened before any page code ran (`attach_failed`/`tab_gone`). + * `target_navigated` is the page layer's decision, not ours. + * + * Never retried: `command_result_unknown` / `command_lost` / `result_evicted` + * (the outcome is genuinely unknown) and client-side AbortError (the shared + * deadline is already exhausted). */ async function sendCommandRaw( action: DaemonCommand['action'], params: Omit, ): Promise { - const maxAttempts = 4; - let dispatchRecoveryUsed = false; - let duplicateIdRetryUsed = false; - let transientRetryUsed = false; - - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - const id = generateId(); - const rawWindowMode = process.env.OPENCLI_WINDOW; - const envWindowMode = rawWindowMode === 'foreground' || rawWindowMode === 'background' - ? rawWindowMode - : undefined; - const contextId = params.contextId ?? resolveProfileContextId(); - const windowMode = params.windowMode ?? envWindowMode; - const timeoutSeconds = effectiveCommandTimeoutSeconds(params); + const timeoutSeconds = effectiveCommandTimeoutSeconds(params); + const deadlineAt = Date.now() + timeoutSeconds * 1000; + const rawWindowMode = process.env.OPENCLI_WINDOW; + const envWindowMode = rawWindowMode === 'foreground' || rawWindowMode === 'background' + ? rawWindowMode + : undefined; + const contextId = params.contextId ?? resolveProfileContextId(); + const windowMode = params.windowMode ?? envWindowMode; + + let id = generateId(); + let ensureUsed = false; + let semanticRetryUsed = false; + let executorJournaled: boolean | null = null; + + const ensureBridge = async (): Promise => { + // Bound the connect wait by the command's remaining budget so repeated + // daemon failures cannot stretch the total wall time far past --timeout. + const remainingSeconds = Math.ceil((deadlineAt - Date.now()) / 1000); + const ready = await ensureBrowserBridgeReady({ + timeoutSeconds: Math.max(1, Math.min(DEFAULT_BROWSER_CONNECT_TIMEOUT, remainingSeconds)), + contextId, + verbose: false, + }); + executorJournaled = versionAtLeast(ready.health.status?.extensionVersion, MIN_JOURNAL_EXTENSION_VERSION); + }; + + for (let attempt = 1; attempt <= TRANSPORT_MAX_ATTEMPTS; attempt++) { + if (attempt > 1 && Date.now() >= deadlineAt) { + throw new BrowserCommandError( + 'Browser command deadline exhausted across transport retries.', + COMMAND_RESULT_UNKNOWN_CODE, + COMMAND_RESULT_UNKNOWN_HINT, + ); + } + const remainingMs = Math.max(1000, deadlineAt - Date.now()); const command: DaemonCommand = { id, action, ...params, timeout: timeoutSeconds, + deadlineAt, ...(contextId && { contextId }), ...(windowMode && { windowMode }), }; @@ -213,38 +274,42 @@ async function sendCommandRaw( method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(command), - timeout: timeoutSeconds * 1000 + HTTP_TIMEOUT_MARGIN_MS, + timeout: remainingMs + HTTP_TIMEOUT_MARGIN_MS, }); const result = (await res.json()) as DaemonResult; if (result.ok) return result; - if (result.errorCode === 'command_result_unknown') { + if (result.errorCode && UNKNOWN_OUTCOME_CODES.has(result.errorCode)) { throw new BrowserCommandError(result.error ?? 'Browser command result is unknown', result.errorCode, result.errorHint); } - if (!dispatchRecoveryUsed && isPreDispatchError(result.errorCode)) { - dispatchRecoveryUsed = true; - await ensureBrowserBridgeReady({ - timeoutSeconds: DEFAULT_BROWSER_CONNECT_TIMEOUT, - contextId, - verbose: false, - }); + if (isPreDispatchError(result.errorCode) && !ensureUsed) { + // Never dispatched — resending the same id is safe on any extension. + ensureUsed = true; + await ensureBridge(); continue; } - const isDuplicateCommandId = res.status === 409 - && !result.errorCode - && (result.error ?? '').includes('Duplicate command id'); - if (isDuplicateCommandId && !duplicateIdRetryUsed) { - duplicateIdRetryUsed = true; - continue; + if (result.errorCode === 'daemon_shutting_down' && !ensureUsed) { + // The command WAS dispatched and the daemon died before the result + // came back. Resending the same id is only safe when the extension + // journals ids; otherwise the outcome is genuinely unknown. + ensureUsed = true; + await ensureBridge(); + if (executorJournaled) continue; + throw new BrowserCommandError( + result.error ?? 'Daemon shut down mid-command; the command may have already been applied.', + COMMAND_RESULT_UNKNOWN_CODE, + COMMAND_RESULT_UNKNOWN_HINT, + ); } - const advice = classifyBrowserError(new Error(result.error ?? '')); - if (advice.retryable && !transientRetryUsed) { - transientRetryUsed = true; + const advice = classifyBrowserError(new BrowserCommandError(result.error ?? '', result.errorCode)); + if (advice.kind === 'extension-transient' && !semanticRetryUsed) { + semanticRetryUsed = true; + id = generateId(); await sleep(advice.delayMs); continue; } @@ -256,40 +321,24 @@ async function sendCommandRaw( if (err instanceof Error && err.name === 'AbortError') { throw new BrowserCommandError( 'Browser command timed out client-side; the page may still have applied it.', - 'command_result_unknown', - 'Inspect the page state before retrying. Idempotent reads are safe to retry; non-idempotent writes may have already happened.', + COMMAND_RESULT_UNKNOWN_CODE, + COMMAND_RESULT_UNKNOWN_HINT, ); } if (err instanceof TypeError) { - if (!isPreConnectFetchError(err)) { - // Connection dropped after the request may have reached the daemon - // (ECONNRESET / socket hang-up) — the command may already be running - // in the browser, so resending would risk a double write. - throw new BrowserCommandError( - 'Connection to the daemon was lost mid-command; it may have already been applied.', - COMMAND_RESULT_UNKNOWN_CODE, - COMMAND_RESULT_UNKNOWN_HINT, - ); - } - if (!dispatchRecoveryUsed) { - dispatchRecoveryUsed = true; - await ensureBrowserBridgeReady({ - timeoutSeconds: DEFAULT_BROWSER_CONNECT_TIMEOUT, - contextId, - verbose: false, - }); - continue; - } - } - - if (err instanceof Error) { - const advice = classifyBrowserError(err); - if (advice.retryable && !transientRetryUsed) { - transientRetryUsed = true; - await sleep(advice.delayMs); - continue; - } + // Transport failure — the request may or may not have reached the + // daemon. Bring the bridge back up (spawns a daemon if none is + // running) and learn whether the extension journals command ids. + await ensureBridge(); + // Same-id resend is safe when the request never connected, or when + // the executor dedupes ids. Otherwise the outcome is unknown. + if (executorJournaled || isPreConnectFetchError(err)) continue; + throw new BrowserCommandError( + 'Connection to the daemon was lost mid-command; it may have already been applied.', + COMMAND_RESULT_UNKNOWN_CODE, + COMMAND_RESULT_UNKNOWN_HINT, + ); } throw err; diff --git a/src/browser/errors.ts b/src/browser/errors.ts index 63dab603e..904e3d83c 100644 --- a/src/browser/errors.ts +++ b/src/browser/errors.ts @@ -63,12 +63,37 @@ function errorMessage(err: unknown): string { return err instanceof Error ? err.message : String(err); } +/** + * Machine-readable error codes set by the extension at the failure site. + * These are authoritative — the message-pattern tables below are only a + * fallback for extensions that predate error codes. + * + * `detached_mid_command` and `cdp_timeout` are deliberately non-retryable: + * both mean execution died MID-command, so a blind re-run could double-apply + * a write. (The legacy pattern table still retries "Detached while handling + * command" because old extensions cannot distinguish pre- from mid-execution.) + */ +const ERROR_CODE_ADVICE: Record = { + attach_failed: { kind: 'extension-transient', retryable: true, delayMs: 1500 }, + tab_gone: { kind: 'extension-transient', retryable: true, delayMs: 1500 }, + target_navigated: { kind: 'target-navigation', retryable: true, delayMs: 200 }, + detached_mid_command: { kind: 'non-retryable', retryable: false, delayMs: 0 }, + cdp_timeout: { kind: 'non-retryable', retryable: false, delayMs: 0 }, +}; + /** * Classify a browser error and return retry advice. * * Single source of truth for "is this error transient?" across all layers. + * Prefers the machine-readable `code` carried by BrowserCommandError; falls + * back to message patterns for legacy extensions. */ export function classifyBrowserError(err: unknown): RetryAdvice { + const code = err && typeof err === 'object' ? (err as { code?: unknown }).code : undefined; + if (typeof code === 'string' && ERROR_CODE_ADVICE[code]) { + return ERROR_CODE_ADVICE[code]; + } + const msg = errorMessage(err); // Extension/daemon transient errors — longer recovery time diff --git a/src/daemon.ts b/src/daemon.ts index 78134176a..4fc7eb486 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -52,14 +52,33 @@ type ExtensionProfileConnection = { }; const extensionProfiles = new Map(); -const pending = new Map void; + reject: (error: Error) => void; +}; +type PendingEntry = { contextId: string; action: string; dispatched: boolean; - resolve: (data: unknown) => void; - reject: (error: Error) => void; + /** + * All HTTP requests waiting on this command id. The first settler is the + * original request; transport retries with the same id attach here instead + * of re-dispatching, so a retry never re-executes a command that is still + * running. + */ + settlers: PendingSettler[]; timer: ReturnType; -}>(); +}; +const pending = new Map(); + +function settlePending(id: string, entry: PendingEntry, outcome: { data?: unknown; error?: Error }): void { + clearTimeout(entry.timer); + pending.delete(id); + for (const settler of entry.settlers) { + if (outcome.error) settler.reject(outcome.error); + else settler.resolve(outcome.data); + } +} let commandResultUnknownCount = 0; // Extension log ring buffer interface LogEntry { level: string; msg: string; ts: number; } @@ -148,7 +167,6 @@ function unregisterExtensionConnection(ws: WebSocket): void { extensionProfiles.delete(contextId); for (const [id, p] of pending) { if (p.contextId !== contextId) continue; - clearTimeout(p.timer); const failure = buildExtensionDisconnectFailure({ contextId, action: p.action, @@ -158,8 +176,7 @@ function unregisterExtensionConnection(ws: WebSocket): void { commandResultUnknownCount++; log.warn(`[daemon] Command result unknown after extension disconnect (id=${id}, action=${p.action}, context=${contextId})`); } - p.reject(new DaemonCommandFailure(failure.message, failure.errorCode, failure.errorHint, failure.status)); - pending.delete(id); + settlePending(id, p, { error: new DaemonCommandFailure(failure.message, failure.errorCode, failure.errorHint, failure.status) }); } } } @@ -314,43 +331,46 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise return; } - const timeoutMs = typeof body.timeout === 'number' && body.timeout > 0 - ? body.timeout * 1000 - : 120000; - if (pending.has(body.id)) { - jsonResponse(res, 409, { - id: body.id, - ok: false, - error: 'Duplicate command id already pending; retry', + // Absolute deadline wins over the legacy duration field: all hops share + // one wall clock, so remaining budget absorbs queueing/transit time. + const timeoutMs = typeof body.deadlineAt === 'number' && body.deadlineAt > 0 + ? Math.max(1000, body.deadlineAt - Date.now()) + : (typeof body.timeout === 'number' && body.timeout > 0 ? body.timeout * 1000 : 120000); + + // A transport retry of an in-flight command attaches to it instead of + // re-dispatching — the extension is already executing this id. + const existing = pending.get(body.id); + if (existing) { + const result = await new Promise((resolve, reject) => { + existing.settlers.push({ resolve, reject }); }); + jsonResponse(res, 200, result); return; } + const result = await new Promise((resolve, reject) => { const timer = setTimeout(() => { const entry = pending.get(body.id); - pending.delete(body.id); - const failure = buildCommandTimeoutFailure(entry?.action ?? 'unknown', timeoutMs); - if (failure.countAsCommandResultUnknown && entry?.dispatched) { + if (!entry) return; + const failure = buildCommandTimeoutFailure(entry.action, timeoutMs); + if (failure.countAsCommandResultUnknown && entry.dispatched) { commandResultUnknownCount++; log.warn(`[daemon] Command timed out after dispatch (id=${body.id}, action=${entry.action}, timeout=${timeoutMs}ms)`); } - reject(new DaemonCommandFailure(failure.message, failure.errorCode, failure.errorHint, failure.status)); + settlePending(body.id, entry, { error: new DaemonCommandFailure(failure.message, failure.errorCode, failure.errorHint, failure.status) }); }, timeoutMs); - const entry = { + const entry: PendingEntry = { contextId: route.connection!.contextId, action: typeof body.action === 'string' ? body.action : 'unknown', dispatched: false, - resolve, - reject, + settlers: [{ resolve, reject }], timer, }; pending.set(body.id, entry); const failBeforeDispatch = (err: unknown) => { if (pending.get(body.id) !== entry) return; const failure = buildCommandDispatchFailure(entry.contextId); - clearTimeout(timer); - pending.delete(body.id); - reject(new DaemonCommandFailure(failure.message, failure.errorCode, failure.errorHint, failure.status)); + settlePending(body.id, entry, { error: new DaemonCommandFailure(failure.message, failure.errorCode, failure.errorHint, failure.status) }); log.warn(`[daemon] Failed to dispatch command ${body.id}: ${err instanceof Error ? err.message : String(err)}`); }; try { @@ -446,12 +466,14 @@ wss.on('connection', (ws: WebSocket) => { return; } + // Application-level keepalive from the extension — WS traffic is what + // keeps the MV3 service worker alive; nothing to do here. + if (msg.type === 'ping') return; + // Handle command results const p = pending.get(msg.id); if (p) { - clearTimeout(p.timer); - pending.delete(msg.id); - p.resolve(msg); + settlePending(msg.id, p, { data: msg }); } } catch (err) { // Malformed message from the extension. Surface so protocol drift / @@ -494,15 +516,34 @@ httpServer.on('error', (err: NodeJS.ErrnoException) => { // Graceful shutdown function shutdown(): void { - // Reject all pending requests so CLI doesn't hang - for (const [, p] of pending) { - clearTimeout(p.timer); - p.reject(new Error('Daemon shutting down')); + // Reject all pending requests so the CLI gets a structured response it can + // act on instead of a socket hang-up it must treat as result-unknown. + // Not-yet-dispatched commands get the pre-dispatch contract (safe to resend + // anywhere); dispatched ones get `daemon_shutting_down`, which the client + // only resends when the extension journals ids. + for (const [id, p] of pending) { + const failure = p.dispatched + ? new DaemonCommandFailure( + 'Daemon shutting down before the command completed.', + 'daemon_shutting_down', + 'The daemon is being replaced; a journaling extension replays the command result on retry.', + 503, + ) + : (() => { + const contract = buildCommandDispatchFailure(p.contextId); + return new DaemonCommandFailure(contract.message, contract.errorCode, contract.errorHint, contract.status); + })(); + settlePending(id, p, { error: failure }); } pending.clear(); for (const profile of extensionProfiles.values()) profile.ws.close(); - httpServer.close(); - process.exit(EXIT_CODES.SUCCESS); + // Let the rejection responses flush before exiting — a synchronous + // process.exit() would kill the queued microtasks that write them. + httpServer.close(() => process.exit(EXIT_CODES.SUCCESS)); + setTimeout(() => { + httpServer.closeIdleConnections?.(); + setTimeout(() => process.exit(EXIT_CODES.SUCCESS), 500).unref(); + }, 100).unref(); } process.on('SIGTERM', shutdown); From 96cbeb4f65c9b385b83ae3f2ee972e348b18c3d4 Mon Sep 17 00:00:00 2001 From: Louie <147122252+yixin-1024@users.noreply.github.com> Date: Fri, 3 Jul 2026 20:21:47 +0800 Subject: [PATCH 15/27] docs: align adapter contribution example with the JavaScript adapter layer (#2017) The "Create a file like clis//.ts" example predates #928, which converted the entire adapter layer from TypeScript to JavaScript. The repo now ships 0 .ts and 1259 .js built-in adapters, so a contributor following the doc creates a file in the wrong format. Update the example to JavaScript (keeping a pointer to the still-supported TypeScript path), and fix the adapter test command, which pointed at src/ rather than the adapter's own clis/ test file. Co-authored-by: Claude Opus 4.8 (1M context) --- docs/developer/contributing.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/developer/contributing.md b/docs/developer/contributing.md index 845a1bbd5..449ea566a 100644 --- a/docs/developer/contributing.md +++ b/docs/developer/contributing.md @@ -33,11 +33,11 @@ Before you start: - Normalize expected adapter failures to `CliError` subclasses instead of raw `Error` whenever possible. Prefer `AuthRequiredError`, `EmptyResultError`, `CommandExecutionError`, `TimeoutError`, and `ArgumentError` so the top-level CLI can render better messages and hints. - If you add a new adapter or make a command newly discoverable, update the matching doc page and the user-facing indexes that expose it. -### TypeScript Adapter +### Create the Adapter -Create a file like `clis//.ts`: +Built-in adapters are authored in JavaScript. Create a file like `clis//.js`: -```typescript +```javascript import { cli, Strategy } from '@jackwener/opencli/registry'; import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors'; @@ -60,7 +60,7 @@ cli({ // ... browser automation logic if (!Array.isArray(data)) throw new CommandExecutionError('MySite returned an unexpected response'); if (!data.length) throw new EmptyResultError('mysite search', 'Try a different keyword'); - return data.slice(0, Number(limit)).map((item: any) => ({ + return data.slice(0, Number(limit)).map((item) => ({ title: item.title, url: item.url, date: item.created_at, @@ -69,6 +69,8 @@ cli({ }); ``` +> TypeScript adapters are also supported — see [TypeScript Adapter](./ts-adapter). + ### Validate Your Adapter ```bash @@ -104,7 +106,7 @@ chore: bump vitest to v4 ```bash npx tsc --noEmit # Type check npm run build # Ensure dist stays healthy - npx vitest run src/.test.ts + npx vitest run clis//.test.js # Your adapter's tests npm test # Broader local gate when shared runtime changes justify it ``` 4. Commit using conventional commit format From 928b1e548d3ea843a8b01c8c34a524a693601f5e Mon Sep 17 00:00:00 2001 From: Adong <67085180+CrazysCodes@users.noreply.github.com> Date: Fri, 3 Jul 2026 20:50:22 +0800 Subject: [PATCH 16/27] feat(hltv): add HLTV adapters (#2028) * feat(hltv): add HLTV adapters * fix(hltv): harden row identity contracts --------- Co-authored-by: Adong Co-authored-by: jackwener --- README.md | 1 + README.zh-CN.md | 1 + cli-manifest.json | 910 ++++++++++++++++++++++++++++ clis/hltv/event-matches.js | 90 +++ clis/hltv/hltv.test.js | 97 +++ clis/hltv/match-map.js | 21 + clis/hltv/match-series.js | 21 + clis/hltv/player-duel.js | 358 +++++++++++ clis/hltv/player-form.js | 121 ++++ clis/hltv/player-map-pool.js | 104 ++++ clis/hltv/player-matches.js | 29 + clis/hltv/player-summary.js | 103 ++++ clis/hltv/player-teammate-impact.js | 95 +++ clis/hltv/player-vs-team.js | 95 +++ clis/hltv/search.js | 107 ++++ clis/hltv/team-map-pool.js | 146 +++++ clis/hltv/team-matches.js | 30 + clis/hltv/utils.js | 867 ++++++++++++++++++++++++++ docs/.vitepress/config.mts | 1 + docs/adapters/browser/hltv.md | 87 +++ docs/adapters/index.md | 1 + 21 files changed, 3285 insertions(+) create mode 100644 clis/hltv/event-matches.js create mode 100644 clis/hltv/hltv.test.js create mode 100644 clis/hltv/match-map.js create mode 100644 clis/hltv/match-series.js create mode 100644 clis/hltv/player-duel.js create mode 100644 clis/hltv/player-form.js create mode 100644 clis/hltv/player-map-pool.js create mode 100644 clis/hltv/player-matches.js create mode 100644 clis/hltv/player-summary.js create mode 100644 clis/hltv/player-teammate-impact.js create mode 100644 clis/hltv/player-vs-team.js create mode 100644 clis/hltv/search.js create mode 100644 clis/hltv/team-map-pool.js create mode 100644 clis/hltv/team-matches.js create mode 100644 clis/hltv/utils.js create mode 100644 docs/adapters/browser/hltv.md diff --git a/README.md b/README.md index 1ac9cce9e..4f90cc6a6 100644 --- a/README.md +++ b/README.md @@ -188,6 +188,7 @@ When the site you need is not yet covered, use the `opencli-adapter-author` skil | **bilibili** | `hot` `search` `history` `feed` `ranking` `download` `comments` `dynamic` `favorite` `following` `follow` `unfollow` `me` `subtitle` `summary` `video` `user-videos` | | **zhihu** | `hot` `search` `question` `download` `follow` `like` `favorite` `comment` `answer` | | **hackernews** | `top` `new` `best` `ask` `show` `jobs` `search` `user` | +| **hltv** | `search` `player-summary` `player-matches` `player-form` `player-map-pool` `player-vs-team` `player-teammate-impact` `player-duel` `match-map` `match-series` `team-matches` `team-map-pool` `event-matches` | | **geogebra** | `eval` `add-point` `add-line` `add-circle` `add-polygon` `triangle` `hexagon` `list` `info` | | **linkedin** | `connect` `inbox` `job-detail` `jobs-preferences` `post-analytics` `posts` `profile-experience` `profile-projects` `profile-read` `profile-analytics` `safe-send` `search` `services-read` `sent-invitations` `thread-snapshot` `timeline` `salesnav-search` `salesnav-inbox` `salesnav-message` `salesnav-thread` | | **reddit** | `hot` `frontpage` `popular` `search` `subreddit` `read` `user` `user-posts` `user-comments` `upvote` `upvoted` `save` `saved` `comment` `subscribe` | diff --git a/README.zh-CN.md b/README.zh-CN.md index be43b947c..885acfdfd 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -176,6 +176,7 @@ Agent 在内部自动处理所有 `opencli browser` 命令——你只需用自 | **bilibili** | `hot` `search` `me` `favorite` `history` `feed` `subtitle` `summary` `video` `comments` `dynamic` `ranking` `following` `follow` `unfollow` `user-videos` `download` | | **zhihu** | `hot` `search` `question` `download` `follow` `like` `favorite` `comment` `answer` | | **hackernews** | `top` `new` `best` `ask` `show` `jobs` `search` `user` | +| **hltv** | `search` `player-summary` `player-matches` `player-form` `player-map-pool` `player-vs-team` `player-teammate-impact` `player-duel` `match-map` `match-series` `team-matches` `team-map-pool` `event-matches` | | **geogebra** | `eval` `add-point` `add-line` `add-circle` `add-polygon` `triangle` `hexagon` `list` `info` | | **linkedin** | `connect` `inbox` `job-detail` `jobs-preferences` `post-analytics` `posts` `profile-experience` `profile-projects` `profile-read` `profile-analytics` `safe-send` `search` `people-search` `services-read` `sent-invitations` `thread-snapshot` `timeline` `salesnav-search` `salesnav-inbox` `salesnav-message` `salesnav-thread` | | **reddit** | `hot` `frontpage` `popular` `search` `subreddit` `read` `user` `user-posts` `user-comments` `upvote` `save` `comment` `subscribe` `saved` `upvoted` | diff --git a/cli-manifest.json b/cli-manifest.json index caaf33bfd..58a0bb0d0 100644 --- a/cli-manifest.json +++ b/cli-manifest.json @@ -16274,6 +16274,916 @@ "navigateBefore": false, "siteSession": "persistent" }, + { + "site": "hltv", + "name": "event-matches", + "description": "Read visible HLTV stats match rows for a specific event", + "access": "read", + "example": "opencli hltv event-matches 8301 --limit 10 -f json", + "domain": "www.hltv.org", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "event", + "type": "string", + "required": true, + "positional": true, + "help": "Event id, /events/:id URL, or stats URL with event=" + }, + { + "name": "limit", + "type": "int", + "default": 100, + "required": false, + "help": "Rows to return from the visible stats matches table (max 100)" + } + ], + "columns": [ + "rank", + "date", + "eventId", + "team", + "opponent", + "score", + "map", + "matchStatsId", + "matchStatsUrl", + "details" + ], + "type": "js", + "modulePath": "hltv/event-matches.js", + "sourceFile": "hltv/event-matches.js", + "navigateBefore": false + }, + { + "site": "hltv", + "name": "match-map", + "description": "Read a single HLTV mapstats page and return all player rows for that map", + "access": "read", + "example": "opencli hltv match-map \"https://www.hltv.org/stats/matches/mapstatsid/231594/falcons-vs-natus-vincere\" -f json", + "domain": "www.hltv.org", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "match", + "type": "string", + "required": true, + "positional": true, + "help": "Full HLTV /stats/matches/mapstatsid/:id/:slug URL from player-matches" + } + ], + "columns": [ + "matchStatsId", + "playerId", + "playerName", + "team", + "kills", + "deaths", + "adr", + "kastPct", + "rating", + "opKd", + "details", + "url" + ], + "type": "js", + "modulePath": "hltv/match-map.js", + "sourceFile": "hltv/match-map.js", + "navigateBefore": false + }, + { + "site": "hltv", + "name": "match-series", + "description": "Expand an HLTV match or stats series into summary, map, and player rows", + "access": "read", + "example": "opencli hltv match-series https://www.hltv.org/stats/matches/126993/spirit-vs-falcons -f json", + "domain": "www.hltv.org", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "match", + "type": "string", + "required": true, + "positional": true, + "help": "HLTV match, stats series, or mapstats URL" + } + ], + "columns": [ + "rowType", + "date", + "event", + "map", + "team", + "opponent", + "score", + "matchStatsId", + "playerId", + "playerName", + "rating", + "details" + ], + "type": "js", + "modulePath": "hltv/match-series.js", + "sourceFile": "hltv/match-series.js", + "navigateBefore": false + }, + { + "site": "hltv", + "name": "player-duel", + "description": "Compare two HLTV players on shared maps, including direct kill matrix when available", + "access": "read", + "example": "opencli hltv player-duel 19230/m0nesy 3741/niko --match https://www.hltv.org/stats/matches/mapstatsid/231594/falcons-vs-natus-vincere -f json", + "domain": "www.hltv.org", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "playerA", + "type": "string", + "required": true, + "positional": true, + "help": "First player ref: 19230/m0nesy, player URL, or stats player URL" + }, + { + "name": "playerB", + "type": "string", + "required": true, + "positional": true, + "help": "Second player ref: 3741/niko, player URL, or stats player URL" + }, + { + "name": "match", + "type": "string", + "default": "", + "required": false, + "help": "Optional HLTV match, stats series, or mapstats URL" + }, + { + "name": "mode", + "type": "string", + "default": "lastEncounter", + "required": false, + "help": "lastEncounter / intersection / history. Ignored when --match is provided" + }, + { + "name": "period", + "type": "string", + "default": "all", + "required": false, + "help": "all / lastMonth / last3Months / last6Months / last12Months / YYYY / YYYY-MM-DD:YYYY-MM-DD" + }, + { + "name": "eventType", + "type": "string", + "default": "all", + "required": false, + "help": "all / majors / bigEvents / mvpEvents / lan / online" + }, + { + "name": "event", + "type": "string", + "default": "all", + "required": false, + "help": "all / event id / /events/:id URL / stats URL with event=" + }, + { + "name": "ranking", + "type": "string", + "default": "all", + "required": false, + "help": "all / top5 / top10 / top20 / top30 / top50" + }, + { + "name": "map", + "type": "string", + "default": "all", + "required": false, + "help": "all / ancient / anubis / dust2 / inferno / mirage / nuke / overpass / cache / cobblestone / season / train / tuscan / vertigo" + }, + { + "name": "version", + "type": "string", + "default": "both", + "required": false, + "help": "both / cs2 / csgo" + }, + { + "name": "offset", + "type": "int", + "default": 0, + "required": false, + "help": "Pagination offset for broad mode; must be a multiple of 100" + }, + { + "name": "recentMaps", + "type": "int", + "default": 100, + "required": false, + "help": "lastEncounter mode candidate maps from playerA to scan (max 500)" + }, + { + "name": "scanPages", + "type": "int", + "default": 1, + "required": false, + "help": "Broad mode pages to scan, 100 rows each (max 5)" + }, + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Maximum shared maps to compare (max 100)" + } + ], + "columns": [ + "rowType", + "scope", + "date", + "event", + "map", + "matchStatsId", + "playerAId", + "playerBId", + "ratingDiff", + "killDiff", + "directKillDiff", + "details" + ], + "type": "js", + "modulePath": "hltv/player-duel.js", + "sourceFile": "hltv/player-duel.js", + "navigateBefore": false + }, + { + "site": "hltv", + "name": "player-form", + "description": "Aggregate recent HLTV player maps into form summaries grouped by summary, map, and opponent", + "access": "read", + "example": "opencli hltv player-form --player 19230/m0nesy --limit 30 -f json", + "domain": "www.hltv.org", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "player", + "type": "string", + "default": "3741/niko", + "required": false, + "help": "Player ref: 3741/niko, player URL, or stats matches URL" + }, + { + "name": "period", + "type": "string", + "default": "all", + "required": false, + "help": "all / lastMonth / last3Months / last6Months / last12Months / YYYY / YYYY-MM-DD:YYYY-MM-DD" + }, + { + "name": "eventType", + "type": "string", + "default": "all", + "required": false, + "help": "all / majors / bigEvents / mvpEvents / lan / online" + }, + { + "name": "ranking", + "type": "string", + "default": "all", + "required": false, + "help": "all / top5 / top10 / top20 / top30 / top50" + }, + { + "name": "map", + "type": "string", + "default": "all", + "required": false, + "help": "all / ancient / anubis / dust2 / inferno / mirage / nuke / overpass / cache / cobblestone / season / train / tuscan / vertigo" + }, + { + "name": "version", + "type": "string", + "default": "both", + "required": false, + "help": "both / cs2 / csgo" + }, + { + "name": "offset", + "type": "int", + "default": 0, + "required": false, + "help": "Pagination offset; must be a multiple of 100" + }, + { + "name": "limit", + "type": "int", + "default": 30, + "required": false, + "help": "Recent maps to aggregate from the current page (max 100)" + } + ], + "columns": [ + "category", + "key", + "playerId", + "nickname", + "maps", + "avgRating", + "kdRatio", + "killsPerMap", + "plusMinusTotal", + "winMaps", + "lossMaps", + "details" + ], + "type": "js", + "modulePath": "hltv/player-form.js", + "sourceFile": "hltv/player-form.js", + "navigateBefore": false + }, + { + "site": "hltv", + "name": "player-map-pool", + "description": "Aggregate a player matches page into per-map performance buckets", + "access": "read", + "example": "opencli hltv player-map-pool --player 3741/niko --limit 50 -f json", + "domain": "www.hltv.org", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "player", + "type": "string", + "default": "3741/niko", + "required": false, + "help": "Player ref: 3741/niko, player URL, or stats player URL" + }, + { + "name": "period", + "type": "string", + "default": "all", + "required": false, + "help": "all / lastMonth / last3Months / last6Months / last12Months / YYYY / YYYY-MM-DD:YYYY-MM-DD" + }, + { + "name": "eventType", + "type": "string", + "default": "all", + "required": false, + "help": "all / majors / bigEvents / mvpEvents / lan / online" + }, + { + "name": "event", + "type": "string", + "default": "all", + "required": false, + "help": "all / event id / /events/:id URL / stats URL with event=" + }, + { + "name": "ranking", + "type": "string", + "default": "all", + "required": false, + "help": "all / top5 / top10 / top20 / top30 / top50" + }, + { + "name": "map", + "type": "string", + "default": "all", + "required": false, + "help": "all / ancient / anubis / dust2 / inferno / mirage / nuke / overpass / cache / cobblestone / season / train / tuscan / vertigo" + }, + { + "name": "version", + "type": "string", + "default": "both", + "required": false, + "help": "both / cs2 / csgo" + }, + { + "name": "offset", + "type": "int", + "default": 0, + "required": false, + "help": "Pagination offset; must be a multiple of 100" + }, + { + "name": "limit", + "type": "int", + "default": 100, + "required": false, + "help": "Recent maps to aggregate from the current page (max 100)" + } + ], + "columns": [ + "category", + "key", + "playerId", + "nickname", + "maps", + "avgRating", + "kdRatio", + "killsPerMap", + "plusMinusTotal", + "winMaps", + "lossMaps", + "details" + ], + "type": "js", + "modulePath": "hltv/player-map-pool.js", + "sourceFile": "hltv/player-map-pool.js", + "navigateBefore": false + }, + { + "site": "hltv", + "name": "player-matches", + "description": "Read HLTV player match history from the stats Matches tab", + "access": "read", + "example": "opencli hltv player-matches --player 3741/niko --limit 10 -f json", + "domain": "www.hltv.org", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "player", + "type": "string", + "default": "3741/niko", + "required": false, + "help": "Player ref: 3741/niko, player URL, or stats matches URL" + }, + { + "name": "period", + "type": "string", + "default": "all", + "required": false, + "help": "all / lastMonth / last3Months / last6Months / last12Months / YYYY / YYYY-MM-DD:YYYY-MM-DD" + }, + { + "name": "eventType", + "type": "string", + "default": "all", + "required": false, + "help": "all / majors / bigEvents / mvpEvents / lan / online" + }, + { + "name": "ranking", + "type": "string", + "default": "all", + "required": false, + "help": "all / top5 / top10 / top20 / top30 / top50" + }, + { + "name": "map", + "type": "string", + "default": "all", + "required": false, + "help": "all / ancient / anubis / dust2 / inferno / mirage / nuke / overpass / cache / cobblestone / season / train / tuscan / vertigo" + }, + { + "name": "version", + "type": "string", + "default": "both", + "required": false, + "help": "both / cs2 / csgo" + }, + { + "name": "offset", + "type": "int", + "default": 0, + "required": false, + "help": "Pagination offset; must be a multiple of 100" + }, + { + "name": "limit", + "type": "int", + "default": 100, + "required": false, + "help": "Rows to return from the current page (max 100)" + } + ], + "columns": [ + "rank", + "date", + "playerTeam", + "opponent", + "map", + "kills", + "deaths", + "plusMinus", + "rating", + "playerId", + "matchStatsId", + "details" + ], + "type": "js", + "modulePath": "hltv/player-matches.js", + "sourceFile": "hltv/player-matches.js", + "navigateBefore": false + }, + { + "site": "hltv", + "name": "player-summary", + "description": "Read an HLTV player summary page", + "access": "read", + "example": "opencli hltv player-summary --player 3741/niko -f json", + "domain": "www.hltv.org", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "player", + "type": "string", + "default": "3741/niko", + "required": false, + "help": "Player ref: 3741/niko or https://www.hltv.org/player/3741/niko" + } + ], + "columns": [ + "playerId", + "slug", + "nickname", + "fullName", + "age", + "teamName", + "prizeMoneyUsd", + "maps", + "rating", + "roles", + "completeStatsUrl", + "url" + ], + "type": "js", + "modulePath": "hltv/player-summary.js", + "sourceFile": "hltv/player-summary.js", + "navigateBefore": false + }, + { + "site": "hltv", + "name": "player-teammate-impact", + "description": "Compare playerA maps with and without playerB present in the scanned sample", + "access": "read", + "example": "opencli hltv player-teammate-impact 3741/niko 19230/m0nesy --limit 50 -f json", + "domain": "www.hltv.org", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "playerA", + "type": "string", + "required": true, + "positional": true, + "help": "Primary player ref" + }, + { + "name": "playerB", + "type": "string", + "required": true, + "positional": true, + "help": "Teammate player ref" + }, + { + "name": "period", + "type": "string", + "default": "all", + "required": false, + "help": "all / lastMonth / last3Months / last6Months / last12Months / YYYY / YYYY-MM-DD:YYYY-MM-DD" + }, + { + "name": "eventType", + "type": "string", + "default": "all", + "required": false, + "help": "all / majors / bigEvents / mvpEvents / lan / online" + }, + { + "name": "event", + "type": "string", + "default": "all", + "required": false, + "help": "all / event id / /events/:id URL / stats URL with event=" + }, + { + "name": "ranking", + "type": "string", + "default": "all", + "required": false, + "help": "all / top5 / top10 / top20 / top30 / top50" + }, + { + "name": "map", + "type": "string", + "default": "all", + "required": false, + "help": "all / ancient / anubis / dust2 / inferno / mirage / nuke / overpass / cache / cobblestone / season / train / tuscan / vertigo" + }, + { + "name": "version", + "type": "string", + "default": "both", + "required": false, + "help": "both / cs2 / csgo" + }, + { + "name": "offset", + "type": "int", + "default": 0, + "required": false, + "help": "Pagination offset; must be a multiple of 100" + }, + { + "name": "limit", + "type": "int", + "default": 100, + "required": false, + "help": "Rows to scan for each player from the current page (max 100)" + } + ], + "columns": [ + "category", + "key", + "playerId", + "maps", + "avgRating", + "kdRatio", + "killsPerMap", + "plusMinusTotal", + "winMaps", + "lossMaps", + "details" + ], + "type": "js", + "modulePath": "hltv/player-teammate-impact.js", + "sourceFile": "hltv/player-teammate-impact.js", + "navigateBefore": false + }, + { + "site": "hltv", + "name": "player-vs-team", + "description": "Filter a player matches page to maps played against a specific HLTV team", + "access": "read", + "example": "opencli hltv player-vs-team 7020/spirit --player 3741/niko --limit 20 -f json", + "domain": "www.hltv.org", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "player", + "type": "string", + "default": "3741/niko", + "required": false, + "help": "Player ref: 3741/niko, player URL, or stats player URL" + }, + { + "name": "team", + "type": "string", + "required": true, + "positional": true, + "help": "Team ref: 7020/spirit, team URL, or stats team URL" + }, + { + "name": "period", + "type": "string", + "default": "all", + "required": false, + "help": "all / lastMonth / last3Months / last6Months / last12Months / YYYY / YYYY-MM-DD:YYYY-MM-DD" + }, + { + "name": "eventType", + "type": "string", + "default": "all", + "required": false, + "help": "all / majors / bigEvents / mvpEvents / lan / online" + }, + { + "name": "event", + "type": "string", + "default": "all", + "required": false, + "help": "all / event id / /events/:id URL / stats URL with event=" + }, + { + "name": "ranking", + "type": "string", + "default": "all", + "required": false, + "help": "all / top5 / top10 / top20 / top30 / top50" + }, + { + "name": "map", + "type": "string", + "default": "all", + "required": false, + "help": "all / ancient / anubis / dust2 / inferno / mirage / nuke / overpass / cache / cobblestone / season / train / tuscan / vertigo" + }, + { + "name": "version", + "type": "string", + "default": "both", + "required": false, + "help": "both / cs2 / csgo" + }, + { + "name": "offset", + "type": "int", + "default": 0, + "required": false, + "help": "Pagination offset; must be a multiple of 100" + }, + { + "name": "limit", + "type": "int", + "default": 100, + "required": false, + "help": "Rows to scan from the current page (max 100)" + } + ], + "columns": [ + "rowType", + "rank", + "date", + "playerId", + "opponent", + "map", + "kills", + "deaths", + "rating", + "result", + "matchStatsId", + "details" + ], + "type": "js", + "modulePath": "hltv/player-vs-team.js", + "sourceFile": "hltv/player-vs-team.js", + "navigateBefore": false + }, + { + "site": "hltv", + "name": "search", + "description": "Search HLTV players, teams, events, and articles", + "access": "read", + "example": "opencli hltv search niko --limit 10 -f json", + "domain": "www.hltv.org", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "query", + "type": "string", + "required": true, + "positional": true, + "help": "Search keyword, e.g. niko" + }, + { + "name": "limit", + "type": "int", + "default": 10, + "required": false, + "help": "Maximum rows per result type (max 50)" + } + ], + "columns": [ + "rank", + "type", + "id", + "name", + "title", + "date", + "author", + "url" + ], + "type": "js", + "modulePath": "hltv/search.js", + "sourceFile": "hltv/search.js", + "navigateBefore": false + }, + { + "site": "hltv", + "name": "team-map-pool", + "description": "Read the visible HLTV team map-pool page with win rate, pick rate, and ban rate", + "access": "read", + "example": "opencli hltv team-map-pool 11283/falcons -f json", + "domain": "www.hltv.org", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "team", + "type": "string", + "required": true, + "positional": true, + "help": "Team ref: 11283/falcons, team URL, or stats team URL" + } + ], + "columns": [ + "category", + "key", + "teamId", + "team", + "maps", + "winMaps", + "lossMaps", + "winRatePct", + "avgRoundDiff", + "details" + ], + "type": "js", + "modulePath": "hltv/team-map-pool.js", + "sourceFile": "hltv/team-map-pool.js", + "navigateBefore": false + }, + { + "site": "hltv", + "name": "team-matches", + "description": "Read recent HLTV team map results from the stats team matches page", + "access": "read", + "example": "opencli hltv team-matches 6667/falcons --limit 10 -f json", + "domain": "www.hltv.org", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "team", + "type": "string", + "required": true, + "positional": true, + "help": "Team ref: 6667/falcons, team URL, or stats team URL" + }, + { + "name": "period", + "type": "string", + "default": "all", + "required": false, + "help": "all / lastMonth / last3Months / last6Months / last12Months / YYYY / YYYY-MM-DD:YYYY-MM-DD" + }, + { + "name": "eventType", + "type": "string", + "default": "all", + "required": false, + "help": "all / majors / bigEvents / mvpEvents / lan / online" + }, + { + "name": "event", + "type": "string", + "default": "all", + "required": false, + "help": "all / event id / /events/:id URL / stats URL with event=" + }, + { + "name": "ranking", + "type": "string", + "default": "all", + "required": false, + "help": "all / top5 / top10 / top20 / top30 / top50" + }, + { + "name": "map", + "type": "string", + "default": "all", + "required": false, + "help": "all / ancient / anubis / dust2 / inferno / mirage / nuke / overpass / cache / cobblestone / season / train / tuscan / vertigo" + }, + { + "name": "version", + "type": "string", + "default": "both", + "required": false, + "help": "both / cs2 / csgo" + }, + { + "name": "offset", + "type": "int", + "default": 0, + "required": false, + "help": "Pagination offset; must be a multiple of 100" + }, + { + "name": "limit", + "type": "int", + "default": 100, + "required": false, + "help": "Rows to return from the current page (max 100)" + } + ], + "columns": [ + "rank", + "date", + "team", + "opponent", + "map", + "result", + "teamId", + "matchStatsId", + "details" + ], + "type": "js", + "modulePath": "hltv/team-matches.js", + "sourceFile": "hltv/team-matches.js", + "navigateBefore": false + }, { "site": "homebrew", "name": "cask", diff --git a/clis/hltv/event-matches.js b/clis/hltv/event-matches.js new file mode 100644 index 000000000..619f0da01 --- /dev/null +++ b/clis/hltv/event-matches.js @@ -0,0 +1,90 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors'; +import { BASE, assertRequiredFields, extractIdFromUrl, gotoAndWait, normalizeLimit, parseEventRef, parseHltvDate, parseNumber } from './utils.js'; + +function buildEventStatsUrl(event) { + const eventId = parseEventRef(event); + if (!eventId) throw new ArgumentError('event is required and must be an event id or event URL'); + const pageUrl = new URL('/stats/matches', BASE); + pageUrl.searchParams.set('event', eventId); + const target = {}; + target.pageUrl = pageUrl; + target.eventId = eventId; + return target; +} + +cli({ + site: 'hltv', + name: 'event-matches', + description: 'Read visible HLTV stats match rows for a specific event', + access: 'read', + example: 'opencli hltv event-matches 8301 --limit 10 -f json', + domain: 'www.hltv.org', + strategy: Strategy.UI, + browser: true, + navigateBefore: false, + args: [ + { name: 'event', type: 'string', positional: true, required: true, help: 'Event id, /events/:id URL, or stats URL with event=' }, + { name: 'limit', type: 'int', default: 100, help: 'Rows to return from the visible stats matches table (max 100)' }, + ], + columns: ['rank', 'date', 'eventId', 'team', 'opponent', 'score', 'map', 'matchStatsId', 'matchStatsUrl', 'details'], + func: async (page, args) => { + const limit = normalizeLimit(args.limit, 100, 100); + const { pageUrl, eventId } = buildEventStatsUrl(args.event); + await gotoAndWait(page, pageUrl, '.stats-matches-table, table.stats-table', 'hltv event matches page'); + + const rawRows = await page.evaluate((payload) => { + const clean = (value) => String(value ?? '').replace(/\s+/g, ' ').trim(); + const parseScoreName = (value) => { + const raw = clean(value); + const match = raw.match(/^(.*?)\s*\((\d+)\)$/); + if (!match) return { _label: raw, _score: null }; + return { _label: match[1].trim(), _score: Number(match[2]) }; + }; + const out = []; + const rows = document.querySelectorAll('.stats-matches-table tr.group-1, .stats-matches-table tr.group-2, table.stats-table tr.group-1, table.stats-table tr.group-2'); + for (const tr of rows) { + if (out.length >= payload.limit) break; + const cells = [...tr.children]; + if (cells.length < 4) continue; + const link = tr.querySelector('a[href*="/stats/matches/mapstatsid/"], a[href*="/stats/matches/"]'); + const matchStatsUrl = link ? new URL(link.getAttribute('href'), payload.base).toString() : null; + const first = parseScoreName(cells[1]?.innerText); + const second = parseScoreName(cells[2]?.innerText); + const rawRow = { + rank: out.length + 1, + date: clean(cells[0]?.innerText), + team: first._label, + _teamScore: first._score, + opponent: second._label, + _opponentScore: second._score, + map: clean(cells[3]?.innerText), + matchStatsUrl, + _extra: cells.slice(4).map((cell) => clean(cell.innerText)).filter(Boolean).join(' | '), + }; + out.push(rawRow); + } + return out; + }, { base: BASE, limit }); + + if (!Array.isArray(rawRows)) throw new CommandExecutionError('hltv event-matches parser returned an unexpected shape'); + if (rawRows.length === 0) throw new EmptyResultError('hltv event-matches', `No stats match rows found for event ${eventId}`); + + return assertRequiredFields(rawRows.map((row) => ({ + rank: row.rank, + date: parseHltvDate(row.date), + eventId, + team: row.team, + opponent: row.opponent, + score: row._teamScore === null ? null : `${row._teamScore}-${row._opponentScore}`, + map: row.map, + matchStatsId: extractIdFromUrl(row.matchStatsUrl, 'matchStats'), + matchStatsUrl: row.matchStatsUrl, + details: { + teamScore: parseNumber(row._teamScore), + opponentScore: parseNumber(row._opponentScore), + extra: row._extra, + }, + })), 'hltv event-matches', ['rank', 'date', 'eventId', 'team', 'opponent', 'matchStatsId', 'matchStatsUrl']); + }, +}); diff --git a/clis/hltv/hltv.test.js b/clis/hltv/hltv.test.js new file mode 100644 index 000000000..01b613760 --- /dev/null +++ b/clis/hltv/hltv.test.js @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest'; +import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors'; +import { getRegistry } from '@jackwener/opencli/registry'; +import './event-matches.js'; +import './match-map.js'; +import './match-series.js'; +import './player-duel.js'; +import './player-form.js'; +import './player-map-pool.js'; +import './player-matches.js'; +import './player-summary.js'; +import './player-teammate-impact.js'; +import './player-vs-team.js'; +import './search.js'; +import './team-map-pool.js'; +import './team-matches.js'; +import { + absolutizeUrl, + assertRequiredFields, + extractIdFromUrl, + normalizeMatchUrl, + parseEventRef, + parsePlayerRef, + parseTeamRef, +} from './utils.js'; + +describe('hltv command registration', () => { + it('registers the read-only HLTV browser commands', () => { + const commands = [ + 'event-matches', + 'match-map', + 'match-series', + 'player-duel', + 'player-form', + 'player-map-pool', + 'player-matches', + 'player-summary', + 'player-teammate-impact', + 'player-vs-team', + 'search', + 'team-map-pool', + 'team-matches', + ]; + + for (const name of commands) { + const command = getRegistry().get(`hltv/${name}`); + expect(command, `hltv/${name}`).toBeDefined(); + expect(command.access).toBe('read'); + expect(command.browser).toBe(true); + expect(command.navigateBefore).toBe(false); + expect(command.domain).toBe('www.hltv.org'); + } + }); +}); + +describe('hltv argument normalization', () => { + it('accepts compact ids and first-party HLTV URLs', () => { + expect(parsePlayerRef('3741/niko')).toEqual({ playerId: '3741', slug: 'niko' }); + expect(parsePlayerRef('https://www.hltv.org/player/3741/niko')).toEqual({ playerId: '3741', slug: 'niko' }); + expect(parseTeamRef('6667/falcons')).toEqual({ teamId: '6667', slug: 'falcons' }); + expect(parseTeamRef('https://www.hltv.org/team/6667/falcons')).toEqual({ teamId: '6667', slug: 'falcons' }); + expect(parseEventRef('https://www.hltv.org/events/8301/demo')).toBe('8301'); + expect(normalizeMatchUrl('https://www.hltv.org/stats/matches/mapstatsid/231594/falcons-vs-natus-vincere').pathname) + .toBe('/stats/matches/mapstatsid/231594/falcons-vs-natus-vincere'); + }); + + it('rejects off-domain URLs before browser navigation', () => { + expect(() => parsePlayerRef('https://evil.test/player/3741/niko')).toThrow(ArgumentError); + expect(() => parseTeamRef('https://evil.test/team/6667/falcons')).toThrow(ArgumentError); + expect(() => parseEventRef('https://evil.test/stats/matches?event=8301')).toThrow(ArgumentError); + expect(() => normalizeMatchUrl('https://evil.test/stats/matches/mapstatsid/231594/demo')).toThrow(ArgumentError); + expect(() => parsePlayerRef('https://')).toThrow(ArgumentError); + }); + + it('requires numeric event ids in stats URLs', () => { + expect(() => parseEventRef('https://www.hltv.org/stats/matches?event=not-an-id')).toThrow(ArgumentError); + }); +}); + +describe('hltv row identity contract', () => { + it('extracts ids only from first-party HLTV URLs', () => { + expect(extractIdFromUrl('https://www.hltv.org/player/3741/niko', 'player')).toBe('3741'); + expect(extractIdFromUrl('https://evil.test/player/3741/niko', 'player')).toBeNull(); + expect(extractIdFromUrl('https://', 'player')).toBeNull(); + expect(() => absolutizeUrl('https://')).toThrow(CommandExecutionError); + }); + + it('typed-fails rows missing required round-trip identities', () => { + expect(() => assertRequiredFields([ + { rank: 1, type: 'player', id: null, url: 'https://www.hltv.org/player/3741/niko' }, + ], 'hltv search', ['rank', 'type', 'id', 'url'])).toThrow(CommandExecutionError); + + expect(() => assertRequiredFields([ + { rank: 1, type: 'player', id: '3741', url: 'https://www.hltv.org/player/3741/niko' }, + ], 'hltv search', ['rank', 'type', 'id', 'url'])).not.toThrow(); + }); +}); diff --git a/clis/hltv/match-map.js b/clis/hltv/match-map.js new file mode 100644 index 000000000..1214c3705 --- /dev/null +++ b/clis/hltv/match-map.js @@ -0,0 +1,21 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { readMatchMap } from './utils.js'; + +cli({ + site: 'hltv', + name: 'match-map', + description: 'Read a single HLTV mapstats page and return all player rows for that map', + access: 'read', + example: 'opencli hltv match-map "https://www.hltv.org/stats/matches/mapstatsid/231594/falcons-vs-natus-vincere" -f json', + domain: 'www.hltv.org', + strategy: Strategy.UI, + browser: true, + navigateBefore: false, + args: [ + { name: 'match', type: 'string', positional: true, required: true, help: 'Full HLTV /stats/matches/mapstatsid/:id/:slug URL from player-matches' }, + ], + columns: ['matchStatsId', 'playerId', 'playerName', 'team', 'kills', 'deaths', 'adr', 'kastPct', 'rating', 'opKd', 'details', 'url'], + func: async (page, args) => { + return readMatchMap(page, args.match); + }, +}); diff --git a/clis/hltv/match-series.js b/clis/hltv/match-series.js new file mode 100644 index 000000000..d8a4d0c4e --- /dev/null +++ b/clis/hltv/match-series.js @@ -0,0 +1,21 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { readMatchSeries } from './utils.js'; + +cli({ + site: 'hltv', + name: 'match-series', + description: 'Expand an HLTV match or stats series into summary, map, and player rows', + access: 'read', + example: 'opencli hltv match-series https://www.hltv.org/stats/matches/126993/spirit-vs-falcons -f json', + domain: 'www.hltv.org', + strategy: Strategy.UI, + browser: true, + navigateBefore: false, + args: [ + { name: 'match', type: 'string', positional: true, required: true, help: 'HLTV match, stats series, or mapstats URL' }, + ], + columns: ['rowType', 'date', 'event', 'map', 'team', 'opponent', 'score', 'matchStatsId', 'playerId', 'playerName', 'rating', 'details'], + func: async (page, args) => { + return readMatchSeries(page, args.match); + }, +}); diff --git a/clis/hltv/player-duel.js b/clis/hltv/player-duel.js new file mode 100644 index 000000000..1d1b7c199 --- /dev/null +++ b/clis/hltv/player-duel.js @@ -0,0 +1,358 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { ArgumentError, EmptyResultError } from '@jackwener/opencli/errors'; +import { + normalizeLimit, + normalizeOffset, + parsePlayerRef, + readMatchMap, + readPerformanceKillMatrix, + readPlayerMatches, + resolveMatchMapUrls, + resolveStatsSeriesUrlFromMap, +} from './utils.js'; + +function round(value, digits = 2) { + if (!Number.isFinite(value)) return null; + return Number(value.toFixed(digits)); +} + +function normalizeScanPages(value) { + const raw = value ?? 1; + const n = Number(raw); + if (!Number.isInteger(n) || n <= 0) throw new ArgumentError('scanPages must be a positive integer'); + if (n > 5) throw new ArgumentError('scanPages must be <= 5'); + return n; +} + +function normalizeRecentMaps(value) { + const raw = value ?? 100; + const n = Number(raw); + if (!Number.isInteger(n) || n <= 0) throw new ArgumentError('recentMaps must be a positive integer'); + if (n > 500) throw new ArgumentError('recentMaps must be <= 500'); + return n; +} + +function normalizeMode(value) { + const raw = String(value ?? 'lastEncounter'); + if (!['lastEncounter', 'intersection', 'history'].includes(raw)) { + throw new ArgumentError('mode must be one of: lastEncounter, intersection, history'); + } + return raw; +} + +function findPlayer(rows, playerId) { + return rows.find((row) => row.playerId === playerId) ?? null; +} + +function parseDirectDuel(matrix, playerAName, playerBName) { + const norm = (value) => String(value ?? '').trim().toLowerCase(); + const a = norm(playerAName); + const b = norm(playerBName); + const rowA = matrix.find((item) => norm(item.rowPlayer) === a && norm(item.colPlayer) === b); + if (rowA) { + return { playerAKillsPlayerB: rowA.rowKillsCol, playerBKillsPlayerA: rowA.colKillsRow }; + } + const rowB = matrix.find((item) => norm(item.rowPlayer) === b && norm(item.colPlayer) === a); + if (rowB) { + return { playerAKillsPlayerB: rowB.colKillsRow, playerBKillsPlayerA: rowB.rowKillsCol }; + } + return { playerAKillsPlayerB: null, playerBKillsPlayerA: null }; +} + +function toMapRow(scope, mapstatsUrl, playerA, playerB, direct) { + const directKillDiff = direct.playerAKillsPlayerB === null || direct.playerBKillsPlayerA === null + ? null + : direct.playerAKillsPlayerB - direct.playerBKillsPlayerA; + return { + rowType: 'map', + scope, + date: playerA.details?.dateTime?.slice(0, 10) ?? null, + event: playerA.details?.event ?? null, + map: playerA.details?.map ?? null, + matchStatsId: playerA.matchStatsId, + playerAId: playerA.playerId, + playerBId: playerB.playerId, + ratingDiff: playerA.rating === null || playerB.rating === null ? null : round(playerA.rating - playerB.rating), + killDiff: playerA.kills === null || playerB.kills === null ? null : playerA.kills - playerB.kills, + directKillDiff, + details: { + playerAName: playerA.playerName, + playerBName: playerB.playerName, + playerATeam: playerA.team, + playerBTeam: playerB.team, + playerAKills: playerA.kills, + playerBKills: playerB.kills, + playerADeaths: playerA.deaths, + playerBDeaths: playerB.deaths, + playerARating: playerA.rating, + playerBRating: playerB.rating, + playerAAdr: playerA.adr, + playerBAdr: playerB.adr, + playerAKastPct: playerA.kastPct, + playerBKastPct: playerB.kastPct, + playerAOpKd: playerA.opKd, + playerBOpKd: playerB.opKd, + playerAKillsPlayerB: direct.playerAKillsPlayerB, + playerBKillsPlayerA: direct.playerBKillsPlayerA, + teamScore: playerA.details?.teamScore ?? null, + opponentScore: playerA.details?.opponentScore ?? null, + matchStatsUrl: mapstatsUrl, + }, + }; +} + +function toSummaryRow(scope, rows, context = {}) { + const first = rows[0]; + const withDirect = rows.filter((row) => row.directKillDiff !== null); + const sum = (values) => values.reduce((acc, value) => acc + (Number.isFinite(value) ? value : 0), 0); + const avg = (values) => { + const valid = values.filter((value) => Number.isFinite(value)); + return valid.length ? round(sum(valid) / valid.length) : null; + }; + const details = rows.reduce((acc, row) => { + acc.playerAKills += row.details.playerAKills ?? 0; + acc.playerBKills += row.details.playerBKills ?? 0; + acc.playerADeaths += row.details.playerADeaths ?? 0; + acc.playerBDeaths += row.details.playerBDeaths ?? 0; + acc.playerAKillsPlayerB += row.details.playerAKillsPlayerB ?? 0; + acc.playerBKillsPlayerA += row.details.playerBKillsPlayerA ?? 0; + return acc; + }, { + playerAName: first?.details.playerAName ?? null, + playerBName: first?.details.playerBName ?? null, + playerAKills: 0, + playerBKills: 0, + playerADeaths: 0, + playerBDeaths: 0, + playerAKillsPlayerB: 0, + playerBKillsPlayerA: 0, + mapsWithDirectDuel: withDirect.length, + firstDate: rows.map((row) => row.date).filter(Boolean).sort()[0] ?? null, + lastDate: rows.map((row) => row.date).filter(Boolean).sort().at(-1) ?? null, + seriesUrl: context.seriesUrl ?? null, + matchedMapStats: context.matchedMapStatsId ?? null, + }); + if (withDirect.length === 0) { + details.playerAKillsPlayerB = null; + details.playerBKillsPlayerA = null; + } + + return { + rowType: 'summary', + scope, + date: details.lastDate, + event: first?.event ?? null, + map: 'all', + matchStatsId: 'all', + playerAId: first?.playerAId ?? null, + playerBId: first?.playerBId ?? null, + ratingDiff: avg(rows.map((row) => row.ratingDiff)), + killDiff: sum(rows.map((row) => row.killDiff)), + directKillDiff: withDirect.length ? sum(rows.map((row) => row.directKillDiff)) : null, + details, + }; +} + +async function collectCommonMapUrls(page, args, limit, scanPages) { + const offset = normalizeOffset(args.offset, 0); + const playerA = parsePlayerRef(args.playerA); + const playerB = parsePlayerRef(args.playerB); + const aRows = []; + const bRows = []; + + for (let i = 0; i < scanPages; i += 1) { + const pageOffset = offset + i * 100; + const commonArgs = { + period: args.period, + eventType: args.eventType, + event: args.event, + ranking: args.ranking, + map: args.map, + version: args.version, + offset: pageOffset, + }; + aRows.push(...await readPlayerMatches(page, { ...commonArgs, player: args.playerA }, 100)); + bRows.push(...await readPlayerMatches(page, { ...commonArgs, player: args.playerB }, 100)); + } + + const bByMatchStatsId = new Map(bRows.map((row) => [row.matchStatsId, row])); + const urls = []; + const seen = new Set(); + for (const aRow of aRows) { + if (urls.length >= limit) break; + if (!aRow.matchStatsId || seen.has(aRow.matchStatsId) || !bByMatchStatsId.has(aRow.matchStatsId)) continue; + seen.add(aRow.matchStatsId); + urls.push(aRow.details.matchStatsUrl); + } + if (urls.length === 0) { + throw new EmptyResultError('hltv player-duel', `No common mapstats rows found for ${playerA.playerId} and ${playerB.playerId}`); + } + return urls; +} + +async function collectHistorySeriesMapUrls(page, args, limit, scanPages) { + const commonMapUrls = await collectCommonMapUrls(page, args, limit, scanPages); + const seriesUrls = []; + const seenSeries = new Set(); + for (const mapUrl of commonMapUrls) { + const seriesUrl = await resolveStatsSeriesUrlFromMap(page, mapUrl); + if (seenSeries.has(seriesUrl)) continue; + seenSeries.add(seriesUrl); + seriesUrls.push(seriesUrl); + if (seriesUrls.length >= limit) break; + } + + const mapUrls = []; + const seenMaps = new Set(); + for (const seriesUrl of seriesUrls) { + const seriesMapUrls = await resolveMatchMapUrls(page, seriesUrl); + for (const mapUrl of seriesMapUrls) { + const id = new URL(mapUrl).pathname.match(/mapstatsid\/(\d+)\//)?.[1] ?? mapUrl; + if (seenMaps.has(id)) continue; + seenMaps.add(id); + mapUrls.push(mapUrl); + } + } + return { mapUrls, seriesUrls }; +} + +async function collectPlayerMatchesByRef(page, args, player, recentMaps) { + const rows = []; + let offset = normalizeOffset(args.offset, 0); + while (rows.length < recentMaps) { + const chunkLimit = Math.min(100, recentMaps - rows.length); + const chunk = await readPlayerMatches(page, { + player, + period: args.period, + eventType: args.eventType, + event: args.event, + ranking: args.ranking, + map: args.map, + version: args.version, + offset, + }, chunkLimit); + rows.push(...chunk); + if (chunk.length < chunkLimit) break; + offset += 100; + } + return rows.slice(0, recentMaps); +} + +async function findLastEncounterSeries(page, args, recentMaps) { + const playerA = parsePlayerRef(args.playerA); + const playerB = parsePlayerRef(args.playerB); + const candidates = await collectPlayerMatchesByRef(page, args, args.playerA, recentMaps); + const playerBMatches = await collectPlayerMatchesByRef(page, args, args.playerB, recentMaps); + const playerBMatchIds = new Set(playerBMatches.map((row) => row.matchStatsId).filter(Boolean)); + const seen = new Set(); + + for (const candidate of candidates) { + const mapUrl = candidate.details?.matchStatsUrl; + if (!mapUrl || seen.has(candidate.matchStatsId)) continue; + seen.add(candidate.matchStatsId); + if (!playerBMatchIds.has(candidate.matchStatsId)) continue; + + const mapRows = await readMatchMap(page, mapUrl); + if (!findPlayer(mapRows, playerB.playerId)) continue; + + const seriesUrl = await resolveStatsSeriesUrlFromMap(page, mapUrl); + const mapUrls = await resolveMatchMapUrls(page, seriesUrl); + return { + mapUrls, + matchedMapStatsId: candidate.matchStatsId, + seriesUrl, + }; + } + + throw new EmptyResultError( + 'hltv player-duel', + `No last encounter found for ${playerA.playerId} and ${playerB.playerId} in the latest ${recentMaps} maps from playerA`, + ); +} + +async function buildDuelRows(page, args, mapUrls, scope, limit, context = {}) { + const playerA = parsePlayerRef(args.playerA); + const playerB = parsePlayerRef(args.playerB); + const rows = []; + + for (const mapUrl of mapUrls.slice(0, limit)) { + const mapRows = await readMatchMap(page, mapUrl); + const playerARow = findPlayer(mapRows, playerA.playerId); + const playerBRow = findPlayer(mapRows, playerB.playerId); + if (!playerARow || !playerBRow) continue; + const matrix = playerARow.team === playerBRow.team ? [] : await readPerformanceKillMatrix(page, mapUrl); + const direct = parseDirectDuel(matrix, playerARow.playerName, playerBRow.playerName); + rows.push(toMapRow(scope, mapUrl, playerARow, playerBRow, direct)); + } + + if (rows.length === 0) { + throw new EmptyResultError('hltv player-duel', 'The selected players were not both present in the resolved mapstats pages'); + } + return [toSummaryRow(scope, rows, context), ...rows]; +} + +cli({ + site: 'hltv', + name: 'player-duel', + description: 'Compare two HLTV players on shared maps, including direct kill matrix when available', + access: 'read', + example: 'opencli hltv player-duel 19230/m0nesy 3741/niko --match https://www.hltv.org/stats/matches/mapstatsid/231594/falcons-vs-natus-vincere -f json', + domain: 'www.hltv.org', + strategy: Strategy.UI, + browser: true, + navigateBefore: false, + args: [ + { name: 'playerA', type: 'string', positional: true, required: true, help: 'First player ref: 19230/m0nesy, player URL, or stats player URL' }, + { name: 'playerB', type: 'string', positional: true, required: true, help: 'Second player ref: 3741/niko, player URL, or stats player URL' }, + { name: 'match', type: 'string', default: '', help: 'Optional HLTV match, stats series, or mapstats URL' }, + { name: 'mode', type: 'string', default: 'lastEncounter', help: 'lastEncounter / intersection / history. Ignored when --match is provided' }, + { name: 'period', type: 'string', default: 'all', help: 'all / lastMonth / last3Months / last6Months / last12Months / YYYY / YYYY-MM-DD:YYYY-MM-DD' }, + { name: 'eventType', type: 'string', default: 'all', help: 'all / majors / bigEvents / mvpEvents / lan / online' }, + { name: 'event', type: 'string', default: 'all', help: 'all / event id / /events/:id URL / stats URL with event=' }, + { name: 'ranking', type: 'string', default: 'all', help: 'all / top5 / top10 / top20 / top30 / top50' }, + { name: 'map', type: 'string', default: 'all', help: 'all / ancient / anubis / dust2 / inferno / mirage / nuke / overpass / cache / cobblestone / season / train / tuscan / vertigo' }, + { name: 'version', type: 'string', default: 'both', help: 'both / cs2 / csgo' }, + { name: 'offset', type: 'int', default: 0, help: 'Pagination offset for broad mode; must be a multiple of 100' }, + { name: 'recentMaps', type: 'int', default: 100, help: 'lastEncounter mode candidate maps from playerA to scan (max 500)' }, + { name: 'scanPages', type: 'int', default: 1, help: 'Broad mode pages to scan, 100 rows each (max 5)' }, + { name: 'limit', type: 'int', default: 20, help: 'Maximum shared maps to compare (max 100)' }, + ], + columns: ['rowType', 'scope', 'date', 'event', 'map', 'matchStatsId', 'playerAId', 'playerBId', 'ratingDiff', 'killDiff', 'directKillDiff', 'details'], + func: async (page, args) => { + const limit = normalizeLimit(args.limit, 20, 100); + const scanPages = normalizeScanPages(args.scanPages); + const recentMaps = normalizeRecentMaps(args.recentMaps); + const mode = normalizeMode(args.mode); + parsePlayerRef(args.playerA); + parsePlayerRef(args.playerB); + + const match = String(args.match ?? '').trim(); + let mapUrls; + let scope; + let context = {}; + if (match) { + mapUrls = await resolveMatchMapUrls(page, match); + scope = 'match'; + } else if (mode === 'intersection') { + mapUrls = await collectCommonMapUrls(page, args, limit, scanPages); + scope = 'filters'; + } else if (mode === 'history') { + const history = await collectHistorySeriesMapUrls(page, args, limit, scanPages); + mapUrls = history.mapUrls; + scope = 'history'; + context = { + seriesUrl: history.seriesUrls.join(','), + }; + } else { + const lastEncounter = await findLastEncounterSeries(page, args, recentMaps); + mapUrls = lastEncounter.mapUrls; + scope = 'lastEncounter'; + context = { + seriesUrl: lastEncounter.seriesUrl, + matchedMapStatsId: lastEncounter.matchedMapStatsId, + }; + } + const effectiveLimit = scope === 'lastEncounter' || scope === 'match' ? mapUrls.length : limit; + return buildDuelRows(page, args, mapUrls, scope, effectiveLimit, context); + }, +}); diff --git a/clis/hltv/player-form.js b/clis/hltv/player-form.js new file mode 100644 index 000000000..e71f8a308 --- /dev/null +++ b/clis/hltv/player-form.js @@ -0,0 +1,121 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { normalizeLimit, readPlayerMatches } from './utils.js'; + +function round(value, digits = 2) { + if (!Number.isFinite(value)) return null; + return Number(value.toFixed(digits)); +} + +function createBucket(category, key, seed) { + const bucket = {}; + bucket.category = category; + bucket.key = key; + bucket.playerId = seed.playerId; + bucket.nickname = seed.details?.nickname ?? null; + bucket.maps = 0; + bucket._kills = 0; + bucket._deaths = 0; + bucket.plusMinusTotal = 0; + bucket._ratingSum = 0; + bucket._ratingCount = 0; + bucket.winMaps = 0; + bucket.lossMaps = 0; + bucket._dates = []; + bucket._bestRating = null; + bucket._worstRating = null; + bucket._filters = seed.details?.filters ?? null; + return bucket; +} + +function addMatch(bucket, row) { + bucket.maps += 1; + bucket._kills += row.kills ?? 0; + bucket._deaths += row.deaths ?? 0; + bucket.plusMinusTotal += row.plusMinus ?? ((row.kills ?? 0) - (row.deaths ?? 0)); + if (Number.isFinite(row.rating)) { + bucket._ratingSum += row.rating; + bucket._ratingCount += 1; + bucket._bestRating = bucket._bestRating === null ? row.rating : Math.max(bucket._bestRating, row.rating); + bucket._worstRating = bucket._worstRating === null ? row.rating : Math.min(bucket._worstRating, row.rating); + } + if (row.details?.result === 'win') bucket.winMaps += 1; + if (row.details?.result === 'loss') bucket.lossMaps += 1; + if (row.date) bucket._dates.push(row.date); +} + +function finalizeBucket(bucket) { + const sortedDates = [...bucket._dates].sort(); + return { + category: bucket.category, + key: bucket.key, + playerId: bucket.playerId, + nickname: bucket.nickname, + maps: bucket.maps, + avgRating: bucket._ratingCount ? round(bucket._ratingSum / bucket._ratingCount) : null, + kdRatio: bucket._deaths > 0 ? round(bucket._kills / bucket._deaths) : null, + killsPerMap: bucket.maps ? round(bucket._kills / bucket.maps) : null, + plusMinusTotal: bucket.plusMinusTotal, + winMaps: bucket.winMaps, + lossMaps: bucket.lossMaps, + details: { + firstDate: sortedDates[0] ?? null, + lastDate: sortedDates[sortedDates.length - 1] ?? null, + deathsPerMap: bucket.maps ? round(bucket._deaths / bucket.maps) : null, + bestRating: bucket._bestRating, + worstRating: bucket._worstRating, + filters: bucket._filters, + }, + }; +} + +function buildBuckets(rows) { + const first = rows[0]; + const buckets = [createBucket('summary', 'all', first)]; + const byMap = new Map(); + const byOpponent = new Map(); + + for (const row of rows) { + addMatch(buckets[0], row); + + if (!byMap.has(row.map)) byMap.set(row.map, createBucket('map', row.map, first)); + addMatch(byMap.get(row.map), row); + + if (!byOpponent.has(row.opponent)) byOpponent.set(row.opponent, createBucket('opponent', row.opponent, first)); + addMatch(byOpponent.get(row.opponent), row); + } + + const sortBuckets = (items) => [...items].sort((a, b) => b.maps - a.maps || String(a.key).localeCompare(String(b.key))); + return [ + buckets[0], + ...sortBuckets(byMap.values()), + ...sortBuckets(byOpponent.values()), + ].map(finalizeBucket); +} + +cli({ + site: 'hltv', + name: 'player-form', + description: 'Aggregate recent HLTV player maps into form summaries grouped by summary, map, and opponent', + access: 'read', + example: 'opencli hltv player-form --player 19230/m0nesy --limit 30 -f json', + domain: 'www.hltv.org', + strategy: Strategy.UI, + browser: true, + navigateBefore: false, + args: [ + { name: 'player', type: 'string', default: '3741/niko', help: 'Player ref: 3741/niko, player URL, or stats matches URL' }, + { name: 'period', type: 'string', default: 'all', help: 'all / lastMonth / last3Months / last6Months / last12Months / YYYY / YYYY-MM-DD:YYYY-MM-DD' }, + { name: 'eventType', type: 'string', default: 'all', help: 'all / majors / bigEvents / mvpEvents / lan / online' }, + { name: 'ranking', type: 'string', default: 'all', help: 'all / top5 / top10 / top20 / top30 / top50' }, + { name: 'map', type: 'string', default: 'all', help: 'all / ancient / anubis / dust2 / inferno / mirage / nuke / overpass / cache / cobblestone / season / train / tuscan / vertigo' }, + { name: 'version', type: 'string', default: 'both', help: 'both / cs2 / csgo' }, + { name: 'offset', type: 'int', default: 0, help: 'Pagination offset; must be a multiple of 100' }, + { name: 'limit', type: 'int', default: 30, help: 'Recent maps to aggregate from the current page (max 100)' }, + ], + columns: ['category', 'key', 'playerId', 'nickname', 'maps', 'avgRating', 'kdRatio', 'killsPerMap', 'plusMinusTotal', 'winMaps', 'lossMaps', 'details'], + func: async (page, args) => { + const limit = normalizeLimit(args.limit, 30, 100); + const rows = await readPlayerMatches(page, args, limit); + return buildBuckets(rows); + }, +}); diff --git a/clis/hltv/player-map-pool.js b/clis/hltv/player-map-pool.js new file mode 100644 index 000000000..827b2dd03 --- /dev/null +++ b/clis/hltv/player-map-pool.js @@ -0,0 +1,104 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { normalizeLimit, readPlayerMatches } from './utils.js'; + +function round(value, digits = 2) { + if (!Number.isFinite(value)) return null; + return Number(value.toFixed(digits)); +} + +function bucket(category, key, seed) { + const target = {}; + target.category = category; + target.key = key; + target.playerId = seed.playerId; + target.nickname = seed.details?.nickname ?? null; + target.maps = 0; + target._wins = 0; + target._losses = 0; + target._kills = 0; + target._deaths = 0; + target.plusMinusTotal = 0; + target._ratingSum = 0; + target._ratingCount = 0; + target._dates = []; + target._filters = seed.details?.filters ?? null; + return target; +} + +function add(row, target) { + target.maps += 1; + target._kills += row.kills ?? 0; + target._deaths += row.deaths ?? 0; + target.plusMinusTotal += row.plusMinus ?? ((row.kills ?? 0) - (row.deaths ?? 0)); + if (row.details?.result === 'win') target._wins += 1; + if (row.details?.result === 'loss') target._losses += 1; + if (Number.isFinite(row.rating)) { + target._ratingSum += row.rating; + target._ratingCount += 1; + } + if (row.date) target._dates.push(row.date); +} + +function finish(target) { + const dates = [...target._dates].sort(); + return { + category: target.category, + key: target.key, + playerId: target.playerId, + nickname: target.nickname, + maps: target.maps, + avgRating: target._ratingCount ? round(target._ratingSum / target._ratingCount) : null, + kdRatio: target._deaths > 0 ? round(target._kills / target._deaths) : null, + killsPerMap: target.maps ? round(target._kills / target.maps) : null, + plusMinusTotal: target.plusMinusTotal, + winMaps: target._wins, + lossMaps: target._losses, + details: { + winRatePct: target.maps ? round((target._wins / target.maps) * 100) : null, + deathsPerMap: target.maps ? round(target._deaths / target.maps) : null, + firstDate: dates[0] ?? null, + lastDate: dates.at(-1) ?? null, + filters: target._filters, + }, + }; +} + +function aggregate(rows) { + const first = rows[0]; + const all = bucket('summary', 'all', first); + const maps = new Map(); + for (const row of rows) { + add(row, all); + if (!maps.has(row.map)) maps.set(row.map, bucket('map', row.map, first)); + add(row, maps.get(row.map)); + } + return [all, ...[...maps.values()].sort((a, b) => b.maps - a.maps || a.key.localeCompare(b.key))].map(finish); +} + +cli({ + site: 'hltv', + name: 'player-map-pool', + description: 'Aggregate a player matches page into per-map performance buckets', + access: 'read', + example: 'opencli hltv player-map-pool --player 3741/niko --limit 50 -f json', + domain: 'www.hltv.org', + strategy: Strategy.UI, + browser: true, + navigateBefore: false, + args: [ + { name: 'player', type: 'string', default: '3741/niko', help: 'Player ref: 3741/niko, player URL, or stats player URL' }, + { name: 'period', type: 'string', default: 'all', help: 'all / lastMonth / last3Months / last6Months / last12Months / YYYY / YYYY-MM-DD:YYYY-MM-DD' }, + { name: 'eventType', type: 'string', default: 'all', help: 'all / majors / bigEvents / mvpEvents / lan / online' }, + { name: 'event', type: 'string', default: 'all', help: 'all / event id / /events/:id URL / stats URL with event=' }, + { name: 'ranking', type: 'string', default: 'all', help: 'all / top5 / top10 / top20 / top30 / top50' }, + { name: 'map', type: 'string', default: 'all', help: 'all / ancient / anubis / dust2 / inferno / mirage / nuke / overpass / cache / cobblestone / season / train / tuscan / vertigo' }, + { name: 'version', type: 'string', default: 'both', help: 'both / cs2 / csgo' }, + { name: 'offset', type: 'int', default: 0, help: 'Pagination offset; must be a multiple of 100' }, + { name: 'limit', type: 'int', default: 100, help: 'Recent maps to aggregate from the current page (max 100)' }, + ], + columns: ['category', 'key', 'playerId', 'nickname', 'maps', 'avgRating', 'kdRatio', 'killsPerMap', 'plusMinusTotal', 'winMaps', 'lossMaps', 'details'], + func: async (page, args) => { + const limit = normalizeLimit(args.limit, 100, 100); + return aggregate(await readPlayerMatches(page, args, limit)); + }, +}); diff --git a/clis/hltv/player-matches.js b/clis/hltv/player-matches.js new file mode 100644 index 000000000..b9fea9cb4 --- /dev/null +++ b/clis/hltv/player-matches.js @@ -0,0 +1,29 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { normalizeLimit, readPlayerMatches } from './utils.js'; + +cli({ + site: 'hltv', + name: 'player-matches', + description: 'Read HLTV player match history from the stats Matches tab', + access: 'read', + example: 'opencli hltv player-matches --player 3741/niko --limit 10 -f json', + domain: 'www.hltv.org', + strategy: Strategy.UI, + browser: true, + navigateBefore: false, + args: [ + { name: 'player', type: 'string', default: '3741/niko', help: 'Player ref: 3741/niko, player URL, or stats matches URL' }, + { name: 'period', type: 'string', default: 'all', help: 'all / lastMonth / last3Months / last6Months / last12Months / YYYY / YYYY-MM-DD:YYYY-MM-DD' }, + { name: 'eventType', type: 'string', default: 'all', help: 'all / majors / bigEvents / mvpEvents / lan / online' }, + { name: 'ranking', type: 'string', default: 'all', help: 'all / top5 / top10 / top20 / top30 / top50' }, + { name: 'map', type: 'string', default: 'all', help: 'all / ancient / anubis / dust2 / inferno / mirage / nuke / overpass / cache / cobblestone / season / train / tuscan / vertigo' }, + { name: 'version', type: 'string', default: 'both', help: 'both / cs2 / csgo' }, + { name: 'offset', type: 'int', default: 0, help: 'Pagination offset; must be a multiple of 100' }, + { name: 'limit', type: 'int', default: 100, help: 'Rows to return from the current page (max 100)' }, + ], + columns: ['rank', 'date', 'playerTeam', 'opponent', 'map', 'kills', 'deaths', 'plusMinus', 'rating', 'playerId', 'matchStatsId', 'details'], + func: async (page, args) => { + const limit = normalizeLimit(args.limit, 100, 100); + return readPlayerMatches(page, args, limit); + }, +}); diff --git a/clis/hltv/player-summary.js b/clis/hltv/player-summary.js new file mode 100644 index 000000000..1c8c664d9 --- /dev/null +++ b/clis/hltv/player-summary.js @@ -0,0 +1,103 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { BASE, assertRequiredFields, buildPlayerUrl, gotoAndWait, parseMoneyUsd, parseNumber, parsePlayerRef } from './utils.js'; + +cli({ + site: 'hltv', + name: 'player-summary', + description: 'Read an HLTV player summary page', + access: 'read', + example: 'opencli hltv player-summary --player 3741/niko -f json', + domain: 'www.hltv.org', + strategy: Strategy.UI, + browser: true, + navigateBefore: false, + args: [ + { name: 'player', type: 'string', default: '3741/niko', help: 'Player ref: 3741/niko or https://www.hltv.org/player/3741/niko' }, + ], + columns: ['playerId', 'slug', 'nickname', 'fullName', 'age', 'teamName', 'prizeMoneyUsd', 'maps', 'rating', 'roles', 'completeStatsUrl', 'url'], + func: async (page, args) => { + const { playerId, slug } = parsePlayerRef(args.player); + const url = buildPlayerUrl(args.player); + + await gotoAndWait(page, url, '.playerProfile', 'hltv player summary page'); + + const rows = await page.evaluate((payload) => { + const clean = (value) => String(value ?? '').replace(/\s+/g, ' ').trim(); + const lines = document.body.innerText.split('\n').map(clean).filter(Boolean); + const numberFrom = (value) => { + const match = String(value ?? '').replace(/,/g, '').match(/-?\d+(?:\.\d+)?/); + return match ? Number(match[0]) : null; + }; + const nextAfter = (label) => { + const idx = lines.findIndex((line) => line === label); + return idx >= 0 ? lines[idx + 1] ?? null : null; + }; + const statAfter = (label) => { + const idx = lines.findIndex((line) => line === label); + if (idx < 0) return null; + for (let i = idx + 1; i < Math.min(lines.length, idx + 5); i += 1) { + const n = numberFrom(lines[i]); + if (n !== null) return n; + } + return null; + }; + + const nickname = clean(document.querySelector('.playerNickname')?.textContent) || payload.slug; + const fullName = clean(document.querySelector('.playerRealname')?.textContent).replace(/^[-\s]+/, '') || null; + const age = numberFrom(nextAfter('Age')); + const teamName = nextAfter('Current team'); + const prizeMoneyUsd = numberFrom(nextAfter('Prize money (?)')); + const completeLink = [...document.querySelectorAll('a[href]')].find((a) => /\/stats\/players\/\d+\//.test(a.getAttribute('href') || '')); + const statsHeadingIdx = lines.findIndex((line) => line === `${nickname} statistics`); + const statsPeriodLine = statsHeadingIdx >= 0 ? lines[statsHeadingIdx + 1] : null; + const statsPeriod = statsPeriodLine ? statsPeriodLine.replace(/^\(|\)$/g, '') : null; + const mapsMatch = statsPeriod ? statsPeriod.match(/([\d,]+)\s+maps/i) : null; + + return [{ + playerId: payload.playerId, + slug: payload.slug, + nickname, + fullName, + age, + teamName, + prizeMoneyUsd, + statsPeriod, + maps: mapsMatch ? numberFrom(mapsMatch[1]) : null, + rating: statAfter('Rating 3.0'), + firepower: statAfter('Firepower'), + entrying: statAfter('Entrying'), + trading: statAfter('Trading'), + opening: statAfter('Opening'), + clutching: statAfter('Clutching'), + sniping: statAfter('Sniping'), + utility: statAfter('Utility'), + completeStatsUrl: completeLink ? new URL(completeLink.getAttribute('href'), payload.base).toString() : null, + url: payload.url, + }]; + }, { base: BASE, playerId, slug, url: url.toString() }); + + return assertRequiredFields(rows.map((row) => ({ + playerId: row.playerId, + slug: row.slug, + nickname: row.nickname, + fullName: row.fullName, + age: parseNumber(row.age), + teamName: row.teamName, + prizeMoneyUsd: parseMoneyUsd(row.prizeMoneyUsd), + maps: parseNumber(row.maps), + rating: parseNumber(row.rating), + roles: { + statsPeriod: row.statsPeriod, + firepower: parseNumber(row.firepower), + entrying: parseNumber(row.entrying), + trading: parseNumber(row.trading), + opening: parseNumber(row.opening), + clutching: parseNumber(row.clutching), + sniping: parseNumber(row.sniping), + utility: parseNumber(row.utility), + }, + completeStatsUrl: row.completeStatsUrl, + url: row.url, + })), 'hltv player-summary', ['playerId', 'slug', 'nickname', 'completeStatsUrl', 'url']); + }, +}); diff --git a/clis/hltv/player-teammate-impact.js b/clis/hltv/player-teammate-impact.js new file mode 100644 index 000000000..21c4fef11 --- /dev/null +++ b/clis/hltv/player-teammate-impact.js @@ -0,0 +1,95 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { EmptyResultError } from '@jackwener/opencli/errors'; +import { normalizeLimit, parsePlayerRef, readPlayerMatches } from './utils.js'; + +function round(value, digits = 2) { + if (!Number.isFinite(value)) return null; + return Number(value.toFixed(digits)); +} + +function summarize(category, key, rows, player) { + const kills = rows.reduce((acc, row) => acc + (row.kills ?? 0), 0); + const deaths = rows.reduce((acc, row) => acc + (row.deaths ?? 0), 0); + const ratings = rows.map((row) => row.rating).filter(Number.isFinite); + const wins = rows.filter((row) => row.details?.result === 'win').length; + const dates = rows.map((row) => row.date).filter(Boolean).sort(); + return { + category, + key, + playerId: player.playerId, + maps: rows.length, + avgRating: ratings.length ? round(ratings.reduce((acc, value) => acc + value, 0) / ratings.length) : null, + kdRatio: deaths > 0 ? round(kills / deaths) : null, + killsPerMap: rows.length ? round(kills / rows.length) : null, + plusMinusTotal: rows.reduce((acc, row) => acc + (row.plusMinus ?? 0), 0), + winMaps: wins, + lossMaps: rows.length - wins, + details: { + nickname: rows[0]?.details?.nickname ?? player.slug, + firstDate: dates[0] ?? null, + lastDate: dates.at(-1) ?? null, + deathsPerMap: rows.length ? round(deaths / rows.length) : null, + matchStatsIds: rows.map((row) => row.matchStatsId).filter(Boolean).join(','), + }, + }; +} + +cli({ + site: 'hltv', + name: 'player-teammate-impact', + description: 'Compare playerA maps with and without playerB present in the scanned sample', + access: 'read', + example: 'opencli hltv player-teammate-impact 3741/niko 19230/m0nesy --limit 50 -f json', + domain: 'www.hltv.org', + strategy: Strategy.UI, + browser: true, + navigateBefore: false, + args: [ + { name: 'playerA', type: 'string', positional: true, required: true, help: 'Primary player ref' }, + { name: 'playerB', type: 'string', positional: true, required: true, help: 'Teammate player ref' }, + { name: 'period', type: 'string', default: 'all', help: 'all / lastMonth / last3Months / last6Months / last12Months / YYYY / YYYY-MM-DD:YYYY-MM-DD' }, + { name: 'eventType', type: 'string', default: 'all', help: 'all / majors / bigEvents / mvpEvents / lan / online' }, + { name: 'event', type: 'string', default: 'all', help: 'all / event id / /events/:id URL / stats URL with event=' }, + { name: 'ranking', type: 'string', default: 'all', help: 'all / top5 / top10 / top20 / top30 / top50' }, + { name: 'map', type: 'string', default: 'all', help: 'all / ancient / anubis / dust2 / inferno / mirage / nuke / overpass / cache / cobblestone / season / train / tuscan / vertigo' }, + { name: 'version', type: 'string', default: 'both', help: 'both / cs2 / csgo' }, + { name: 'offset', type: 'int', default: 0, help: 'Pagination offset; must be a multiple of 100' }, + { name: 'limit', type: 'int', default: 100, help: 'Rows to scan for each player from the current page (max 100)' }, + ], + columns: ['category', 'key', 'playerId', 'maps', 'avgRating', 'kdRatio', 'killsPerMap', 'plusMinusTotal', 'winMaps', 'lossMaps', 'details'], + func: async (page, args) => { + const limit = normalizeLimit(args.limit, 100, 100); + const playerA = parsePlayerRef(args.playerA); + const playerB = parsePlayerRef(args.playerB); + const commonArgs = { + period: args.period, + eventType: args.eventType, + event: args.event, + ranking: args.ranking, + map: args.map, + version: args.version, + offset: args.offset, + }; + const aRows = await readPlayerMatches(page, { ...commonArgs, player: args.playerA }, limit); + const bRows = await readPlayerMatches(page, { ...commonArgs, player: args.playerB }, limit); + const bIds = new Set(bRows.map((row) => row.matchStatsId).filter(Boolean)); + const withB = aRows.filter((row) => bIds.has(row.matchStatsId)); + const withoutB = aRows.filter((row) => !bIds.has(row.matchStatsId)); + if (withB.length === 0) { + throw new EmptyResultError('hltv player-teammate-impact', `No shared map rows found for ${playerA.playerId} and ${playerB.playerId}`); + } + const rows = [ + summarize('summary', 'withTeammate', withB, playerA), + summarize('summary', 'withoutTeammate', withoutB, playerA), + ]; + const byMap = new Map(); + for (const row of withB) { + if (!byMap.has(row.map)) byMap.set(row.map, []); + byMap.get(row.map).push(row); + } + for (const [mapName, mapRows] of [...byMap.entries()].sort((a, b) => b[1].length - a[1].length || a[0].localeCompare(b[0]))) { + rows.push(summarize('map', mapName, mapRows, playerA)); + } + return rows; + }, +}); diff --git a/clis/hltv/player-vs-team.js b/clis/hltv/player-vs-team.js new file mode 100644 index 000000000..53af54cf5 --- /dev/null +++ b/clis/hltv/player-vs-team.js @@ -0,0 +1,95 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { EmptyResultError } from '@jackwener/opencli/errors'; +import { normalizeLimit, parseTeamRef, readPlayerMatches } from './utils.js'; + +function round(value, digits = 2) { + if (!Number.isFinite(value)) return null; + return Number(value.toFixed(digits)); +} + +function norm(value) { + return String(value ?? '').toLowerCase().replace(/[^a-z0-9]+/g, ''); +} + +function summarize(rows, teamRef) { + const kills = rows.reduce((acc, row) => acc + (row.kills ?? 0), 0); + const deaths = rows.reduce((acc, row) => acc + (row.deaths ?? 0), 0); + const ratingRows = rows.filter((row) => Number.isFinite(row.rating)); + const wins = rows.filter((row) => row.details?.result === 'win').length; + const dates = rows.map((row) => row.date).filter(Boolean).sort(); + return { + rowType: 'summary', + rank: 0, + date: dates.at(-1) ?? null, + playerId: rows[0]?.playerId ?? null, + opponent: teamRef.slug, + map: 'all', + kills, + deaths, + rating: ratingRows.length ? round(ratingRows.reduce((acc, row) => acc + row.rating, 0) / ratingRows.length) : null, + result: `${wins}-${rows.length - wins}`, + matchStatsId: 'all', + details: { + maps: rows.length, + kdRatio: deaths > 0 ? round(kills / deaths) : null, + plusMinusTotal: rows.reduce((acc, row) => acc + (row.plusMinus ?? 0), 0), + firstDate: dates[0] ?? null, + lastDate: dates.at(-1) ?? null, + targetTeam: `${teamRef.teamId}/${teamRef.slug}`, + nickname: rows[0]?.details?.nickname ?? null, + }, + }; +} + +cli({ + site: 'hltv', + name: 'player-vs-team', + description: 'Filter a player matches page to maps played against a specific HLTV team', + access: 'read', + example: 'opencli hltv player-vs-team 7020/spirit --player 3741/niko --limit 20 -f json', + domain: 'www.hltv.org', + strategy: Strategy.UI, + browser: true, + navigateBefore: false, + args: [ + { name: 'player', type: 'string', default: '3741/niko', help: 'Player ref: 3741/niko, player URL, or stats player URL' }, + { name: 'team', type: 'string', positional: true, required: true, help: 'Team ref: 7020/spirit, team URL, or stats team URL' }, + { name: 'period', type: 'string', default: 'all', help: 'all / lastMonth / last3Months / last6Months / last12Months / YYYY / YYYY-MM-DD:YYYY-MM-DD' }, + { name: 'eventType', type: 'string', default: 'all', help: 'all / majors / bigEvents / mvpEvents / lan / online' }, + { name: 'event', type: 'string', default: 'all', help: 'all / event id / /events/:id URL / stats URL with event=' }, + { name: 'ranking', type: 'string', default: 'all', help: 'all / top5 / top10 / top20 / top30 / top50' }, + { name: 'map', type: 'string', default: 'all', help: 'all / ancient / anubis / dust2 / inferno / mirage / nuke / overpass / cache / cobblestone / season / train / tuscan / vertigo' }, + { name: 'version', type: 'string', default: 'both', help: 'both / cs2 / csgo' }, + { name: 'offset', type: 'int', default: 0, help: 'Pagination offset; must be a multiple of 100' }, + { name: 'limit', type: 'int', default: 100, help: 'Rows to scan from the current page (max 100)' }, + ], + columns: ['rowType', 'rank', 'date', 'playerId', 'opponent', 'map', 'kills', 'deaths', 'rating', 'result', 'matchStatsId', 'details'], + func: async (page, args) => { + const limit = normalizeLimit(args.limit, 100, 100); + const teamRef = parseTeamRef(args.team); + const wanted = norm(teamRef.slug); + const rows = (await readPlayerMatches(page, args, limit)).filter((row) => { + return norm(row.opponent).includes(wanted) || wanted.includes(norm(row.opponent)); + }); + if (rows.length === 0) { + throw new EmptyResultError('hltv player-vs-team', `No player match rows matched team ${teamRef.teamId}/${teamRef.slug}`); + } + return [ + summarize(rows, teamRef), + ...rows.map((row) => ({ + rowType: 'map', + rank: row.rank, + date: row.date, + playerId: row.playerId, + opponent: row.opponent, + map: row.map, + kills: row.kills, + deaths: row.deaths, + rating: row.rating, + result: row.details?.result ?? null, + matchStatsId: row.matchStatsId, + details: row.details, + })), + ]; + }, +}); diff --git a/clis/hltv/search.js b/clis/hltv/search.js new file mode 100644 index 000000000..9f549f48f --- /dev/null +++ b/clis/hltv/search.js @@ -0,0 +1,107 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { ArgumentError } from '@jackwener/opencli/errors'; +import { BASE, absolutizeUrl, assertRequiredFields, extractIdFromUrl, gotoAndWait, normalizeLimit } from './utils.js'; + +const RESULT_TYPES = ['player', 'team', 'event', 'article']; + +cli({ + site: 'hltv', + name: 'search', + description: 'Search HLTV players, teams, events, and articles', + access: 'read', + example: 'opencli hltv search niko --limit 10 -f json', + domain: 'www.hltv.org', + strategy: Strategy.UI, + browser: true, + navigateBefore: false, + args: [ + { name: 'query', type: 'string', positional: true, required: true, help: 'Search keyword, e.g. niko' }, + { name: 'limit', type: 'int', default: 10, help: 'Maximum rows per result type (max 50)' }, + ], + columns: ['rank', 'type', 'id', 'name', 'title', 'date', 'author', 'url'], + func: async (page, args) => { + const query = String(args.query ?? '').trim(); + if (!query) throw new ArgumentError('query is required'); + const limit = normalizeLimit(args.limit, 10, 50); + const url = new URL('/search', BASE); + url.searchParams.set('query', query); + + await gotoAndWait(page, url, 'table', 'hltv search page'); + + const rows = await page.evaluate((payload) => { + const clean = (value) => String(value ?? '').replace(/\s+/g, ' ').trim(); + const absolutize = (value) => (value ? new URL(value, payload.base).toString() : null); + const extractId = (value, kind) => { + if (!value) return null; + const path = new URL(value, payload.base).pathname; + const patterns = { + player: /^\/player\/(\d+)\//, + team: /^\/team\/(\d+)\//, + event: /^\/events\/(\d+)\//, + article: /^\/news\/(\d+)\//, + }; + const match = path.match(patterns[kind]); + return match ? match[1] : null; + }; + + const out = []; + for (const table of document.querySelectorAll('table')) { + const header = clean(table.querySelector('tr')?.innerText); + let type = null; + if (/^Player$/i.test(header)) type = 'player'; + if (/^Team$/i.test(header)) type = 'team'; + if (/^Event$/i.test(header)) type = 'event'; + if (/^Article\b/i.test(header)) type = 'article'; + if (!type) continue; + + let rank = 0; + for (const tr of [...table.querySelectorAll('tr')].slice(1)) { + if (rank >= payload.limit) break; + const cells = [...tr.children]; + const firstLink = cells[0]?.querySelector('a[href]'); + const itemUrl = absolutize(firstLink?.getAttribute('href')); + const firstText = clean(firstLink?.innerText || cells[0]?.innerText); + if (!firstText || !itemUrl) continue; + rank += 1; + + if (type === 'article') { + const authorLink = cells[2]?.querySelector('a[href]'); + out.push({ + rank, + type, + id: extractId(itemUrl, type), + name: null, + title: firstText, + date: clean(cells[1]?.innerText) || null, + author: clean(authorLink?.innerText || cells[2]?.innerText) || null, + url: itemUrl, + }); + } else { + out.push({ + rank, + type, + id: extractId(itemUrl, type), + name: firstText, + title: null, + date: null, + author: null, + url: itemUrl, + }); + } + } + } + return out.filter((row) => payload.types.includes(row.type)); + }, { base: BASE, limit, types: RESULT_TYPES }); + + return assertRequiredFields(rows.map((row) => ({ + rank: row.rank, + type: row.type, + id: row.id ?? extractIdFromUrl(row.url, row.type), + name: row.name, + title: row.title, + date: row.date, + author: row.author, + url: absolutizeUrl(row.url), + })), 'hltv search', ['rank', 'type', 'id', 'url']); + }, +}); diff --git a/clis/hltv/team-map-pool.js b/clis/hltv/team-map-pool.js new file mode 100644 index 000000000..3003ad6f1 --- /dev/null +++ b/clis/hltv/team-map-pool.js @@ -0,0 +1,146 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors'; +import { BASE, gotoAndWait, parseNumber, parseTeamRef } from './utils.js'; + +function round(value, digits = 2) { + if (!Number.isFinite(value)) return null; + return Number(value.toFixed(digits)); +} + +function splitRecord(value) { + const match = String(value ?? '').match(/(\d+)\s*\/\s*(\d+)\s*\/\s*(\d+)/); + return match ? { wins: Number(match[1]), draws: Number(match[2]), losses: Number(match[3]) } : { wins: null, draws: null, losses: null }; +} + +cli({ + site: 'hltv', + name: 'team-map-pool', + description: 'Read the visible HLTV team map-pool page with win rate, pick rate, and ban rate', + access: 'read', + example: 'opencli hltv team-map-pool 11283/falcons -f json', + domain: 'www.hltv.org', + strategy: Strategy.UI, + browser: true, + navigateBefore: false, + args: [ + { name: 'team', type: 'string', positional: true, required: true, help: 'Team ref: 11283/falcons, team URL, or stats team URL' }, + ], + columns: ['category', 'key', 'teamId', 'team', 'maps', 'winMaps', 'lossMaps', 'winRatePct', 'avgRoundDiff', 'details'], + func: async (page, args) => { + const { teamId, slug } = parseTeamRef(args.team); + await gotoAndWait(page, new URL(`/team/${teamId}/${slug}`, BASE), '.team-row, .profile-team-name, .teamName', 'hltv team profile warmup page'); + const url = new URL(`/stats/teams/maps/${teamId}/${slug}`, BASE); + await gotoAndWait(page, url, '.map-pool-map-holder.map-stats, .two-grid .stats-row', 'hltv team map-pool page'); + + const rawRows = await page.evaluate((payload) => { + const clean = (value) => String(value ?? '').replace(/\s+/g, ' ').trim(); + const readMetric = (root, label) => { + const row = [...root.querySelectorAll('.stats-row')].find((item) => clean(item.textContent).startsWith(label)); + return row ? clean(row.textContent).slice(label.length).trim() : null; + }; + const team = clean(document.querySelector('.context-item-name, .profile-team-name, .teamName')?.textContent) || payload.slug; + const byMap = new Map(); + for (const link of document.querySelectorAll('.map-pool-map-holder.map-stats')) { + const text = clean(link.textContent); + const match = text.match(/^(.+?)\s*-\s*([\d.]+)%$/); + if (!match) continue; + byMap.set(match[1].trim(), { + category: 'map', + key: match[1].trim(), + teamId: payload.teamId, + team, + record: null, + winRate: `${match[2]}%`, + totalRounds: null, + firstKillWinPct: null, + firstDeathWinPct: null, + pickPct: null, + banPct: null, + mapUrl: new URL(link.getAttribute('href'), payload.base).toString(), + }); + } + for (const grid of document.querySelectorAll('.two-grid')) { + const mapName = clean(grid.querySelector('.map-pool-map-holder')?.textContent); + if (!mapName) continue; + const record = readMetric(grid, 'Wins / draws / losses'); + const winRate = readMetric(grid, 'Win rate'); + const totalRounds = readMetric(grid, 'Total rounds'); + const firstKillWinPct = readMetric(grid, 'Round win-% after getting first kill'); + const firstDeathWinPct = readMetric(grid, 'Round win-% after receiving first death'); + const pickPct = readMetric(grid, 'Pick %'); + const banPct = readMetric(grid, 'Ban %'); + if (!record && !winRate) continue; + byMap.set(mapName, { + category: 'map', + key: mapName, + teamId: payload.teamId, + team, + record, + winRate, + totalRounds, + firstKillWinPct, + firstDeathWinPct, + pickPct, + banPct, + mapUrl: byMap.get(mapName)?.mapUrl ?? null, + }); + } + return [...byMap.values()]; + }, { base: BASE, teamId, slug }); + + if (!Array.isArray(rawRows)) throw new CommandExecutionError('hltv team-map-pool parser returned an unexpected shape'); + if (rawRows.length === 0) throw new EmptyResultError('hltv team-map-pool', `No map-pool rows found for team ${teamId}/${slug}`); + + const rows = rawRows.map((row) => { + const record = splitRecord(row.record); + const maps = record.wins === null ? null : (record.wins ?? 0) + (record.draws ?? 0) + (record.losses ?? 0); + return { + category: row.category, + key: row.key, + teamId: row.teamId, + team: row.team, + maps, + winMaps: record.wins, + lossMaps: record.losses, + winRatePct: parseNumber(row.winRate), + avgRoundDiff: null, + details: { + draws: record.draws, + totalRounds: parseNumber(row.totalRounds), + roundsPerMap: maps ? round(parseNumber(row.totalRounds) / maps) : null, + firstKillWinPct: parseNumber(row.firstKillWinPct), + firstDeathWinPct: parseNumber(row.firstDeathWinPct), + pickPct: parseNumber(row.pickPct), + banPct: parseNumber(row.banPct), + url: url.toString(), + }, + }; + }).sort((a, b) => (b.maps ?? -1) - (a.maps ?? -1) || String(a.key).localeCompare(String(b.key))); + + const totals = rows.reduce((acc, row) => { + acc.maps += row.maps ?? 0; + acc.winMaps += row.winMaps ?? 0; + acc.lossMaps += row.lossMaps ?? 0; + return acc; + }, { maps: 0, winMaps: 0, lossMaps: 0 }); + + return [ + { + category: 'summary', + key: 'all', + teamId, + team: rows[0]?.team ?? slug, + maps: totals.maps, + winMaps: totals.winMaps, + lossMaps: totals.lossMaps, + winRatePct: totals.maps ? round((totals.winMaps / totals.maps) * 100) : null, + avgRoundDiff: null, + details: { + mapsCovered: rows.length, + source: url.toString(), + }, + }, + ...rows, + ]; + }, +}); diff --git a/clis/hltv/team-matches.js b/clis/hltv/team-matches.js new file mode 100644 index 000000000..f15a4ba50 --- /dev/null +++ b/clis/hltv/team-matches.js @@ -0,0 +1,30 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { normalizeLimit, readTeamMatches } from './utils.js'; + +cli({ + site: 'hltv', + name: 'team-matches', + description: 'Read recent HLTV team map results from the stats team matches page', + access: 'read', + example: 'opencli hltv team-matches 6667/falcons --limit 10 -f json', + domain: 'www.hltv.org', + strategy: Strategy.UI, + browser: true, + navigateBefore: false, + args: [ + { name: 'team', type: 'string', positional: true, required: true, help: 'Team ref: 6667/falcons, team URL, or stats team URL' }, + { name: 'period', type: 'string', default: 'all', help: 'all / lastMonth / last3Months / last6Months / last12Months / YYYY / YYYY-MM-DD:YYYY-MM-DD' }, + { name: 'eventType', type: 'string', default: 'all', help: 'all / majors / bigEvents / mvpEvents / lan / online' }, + { name: 'event', type: 'string', default: 'all', help: 'all / event id / /events/:id URL / stats URL with event=' }, + { name: 'ranking', type: 'string', default: 'all', help: 'all / top5 / top10 / top20 / top30 / top50' }, + { name: 'map', type: 'string', default: 'all', help: 'all / ancient / anubis / dust2 / inferno / mirage / nuke / overpass / cache / cobblestone / season / train / tuscan / vertigo' }, + { name: 'version', type: 'string', default: 'both', help: 'both / cs2 / csgo' }, + { name: 'offset', type: 'int', default: 0, help: 'Pagination offset; must be a multiple of 100' }, + { name: 'limit', type: 'int', default: 100, help: 'Rows to return from the current page (max 100)' }, + ], + columns: ['rank', 'date', 'team', 'opponent', 'map', 'result', 'teamId', 'matchStatsId', 'details'], + func: async (page, args) => { + const limit = normalizeLimit(args.limit, 100, 100); + return readTeamMatches(page, args, limit); + }, +}); diff --git a/clis/hltv/utils.js b/clis/hltv/utils.js new file mode 100644 index 000000000..59563a9cd --- /dev/null +++ b/clis/hltv/utils.js @@ -0,0 +1,867 @@ +import { ArgumentError, CommandExecutionError, EmptyResultError, TimeoutError } from '@jackwener/opencli/errors'; + +export const BASE = 'https://www.hltv.org'; + +function isHltvHost(hostname) { + return hostname === 'hltv.org' || hostname === 'www.hltv.org'; +} + +function parseHltvUserUrl(raw, label) { + let url; + try { + url = new URL(raw); + } catch { + throw new ArgumentError(`${label} must be a valid hltv.org URL`); + } + if (!isHltvHost(url.hostname)) { + throw new ArgumentError(`${label} must be an hltv.org URL`); + } + return url; +} + +export const EVENT_TYPES = { + all: null, + majors: 'Majors', + bigEvents: 'BigEvents', + mvpEvents: 'MvpEvents', + lan: 'Lan', + online: 'Online', +}; + +export const RANKING_FILTERS = { + all: null, + top5: 'Top5', + top10: 'Top10', + top20: 'Top20', + top30: 'Top30', + top50: 'Top50', +}; + +export const MAPS = { + all: null, + ancient: 'de_ancient', + anubis: 'de_anubis', + dust2: 'de_dust2', + inferno: 'de_inferno', + mirage: 'de_mirage', + nuke: 'de_nuke', + overpass: 'de_overpass', + cache: 'de_cache', + cobblestone: 'de_cobblestone', + season: 'de_season', + train: 'de_train', + tuscan: 'de_tuscan', + vertigo: 'de_vertigo', +}; + +export const VERSIONS = { + both: null, + cs2: 'CS2', + csgo: 'CSGO', +}; + +export function normalizeLimit(value, defaultValue, maxValue, label = 'limit') { + const raw = value ?? defaultValue; + const n = Number(raw); + if (!Number.isInteger(n) || n <= 0) throw new ArgumentError(`${label} must be a positive integer`); + if (n > maxValue) throw new ArgumentError(`${label} must be <= ${maxValue}`); + return n; +} + +export function normalizeOffset(value, defaultValue = 0) { + const raw = value ?? defaultValue; + const n = Number(raw); + if (!Number.isInteger(n) || n < 0) throw new ArgumentError('offset must be a non-negative integer'); + if (n % 100 !== 0) throw new ArgumentError('offset must be a multiple of 100'); + return n; +} + +export function normalizeChoice(value, defaultValue, choices, label) { + const raw = String(value ?? defaultValue); + if (!Object.prototype.hasOwnProperty.call(choices, raw)) { + throw new ArgumentError(`${label} must be one of: ${Object.keys(choices).join(', ')}`); + } + return raw; +} + +export function parseNumber(value) { + const raw = String(value ?? '').replace(/\s+/g, ' ').trim(); + if (!raw || raw === '-' || raw.toLowerCase() === 'n/a') return null; + const match = raw.replace(/,/g, '').match(/-?\d+(?:\.\d+)?/); + if (!match) return null; + const n = Number(match[0]); + return Number.isFinite(n) ? n : null; +} + +export function parseMoneyUsd(value) { + return parseNumber(String(value ?? '').replace(/\$/g, '')); +} + +export function absolutizeUrl(value) { + if (!value) return null; + let url; + try { + url = new URL(value, BASE); + } catch { + throw new CommandExecutionError('HLTV parser returned a malformed URL'); + } + if (!isHltvHost(url.hostname)) { + throw new CommandExecutionError(`HLTV parser returned an off-domain URL: ${url.toString()}`); + } + return url.toString(); +} + +export function extractIdFromUrl(url, kind) { + if (!url) return null; + let parsed; + try { + parsed = new URL(url, BASE); + } catch { + return null; + } + if (!isHltvHost(parsed.hostname)) return null; + const path = parsed.pathname; + const patterns = { + player: /^\/player\/(\d+)\//, + team: /^\/team\/(\d+)\//, + event: /^\/events\/(\d+)\//, + article: /^\/news\/(\d+)\//, + matchStats: /^\/stats\/matches\/mapstatsid\/(\d+)\//, + statsSeries: /^\/stats\/matches\/(\d+)\//, + match: /^\/matches\/(\d+)\//, + }; + const match = path.match(patterns[kind]); + return match ? match[1] : null; +} + +export function parseEventRef(value) { + const raw = String(value ?? '').trim(); + if (!raw || raw === 'all') return null; + if (/^\d+$/.test(raw)) return raw; + + let url = null; + if (/^https?:\/\//i.test(raw)) url = parseHltvUserUrl(raw, 'event'); + if (url?.searchParams.get('event')) { + const eventId = url.searchParams.get('event'); + if (/^\d+$/.test(eventId)) return eventId; + throw new ArgumentError('event query parameter must be a numeric event id'); + } + + const path = (url ? url.pathname : raw).replace(/^\/+/, ''); + const eventPath = path.match(/^events\/(\d+)\//i); + if (eventPath) return eventPath[1]; + + throw new ArgumentError('event must be an event id, an /events/:id URL, a stats URL with event=, or all'); +} + +export function parseTeamRef(value, defaultValue = null) { + const raw = String(value ?? defaultValue ?? '').trim(); + if (!raw) throw new ArgumentError('team is required'); + + let path = raw; + if (/^https?:\/\//i.test(raw)) path = parseHltvUserUrl(raw, 'team').pathname; + path = path.replace(/^https?:\/\/[^/]+/i, '').replace(/^\/+/, ''); + + const statsMatch = path.match(/^stats\/teams(?:\/matches)?\/(\d+)\/([a-z0-9-]+)/i); + const teamMatch = path.match(/^team\/(\d+)\/([a-z0-9-]+)/i); + const compactMatch = path.match(/^(\d+)\/([a-z0-9-]+)$/i); + const match = statsMatch || teamMatch || compactMatch; + if (!match) { + throw new ArgumentError('team must be like 6667/falcons, a team URL, or a stats team URL'); + } + + return { teamId: match[1], slug: match[2].toLowerCase() }; +} + +export function parsePlayerRef(value, defaultValue = '3741/niko') { + const raw = String(value ?? defaultValue).trim(); + if (!raw) throw new ArgumentError('player is required'); + + let path = raw; + if (/^https?:\/\//i.test(raw)) path = parseHltvUserUrl(raw, 'player').pathname; + path = path.replace(/^https?:\/\/[^/]+/i, '').replace(/^\/+/, ''); + + const statsMatch = path.match(/^stats\/players(?:\/matches)?\/(\d+)\/([a-z0-9-]+)/i); + const playerMatch = path.match(/^player\/(\d+)\/([a-z0-9-]+)/i); + const compactMatch = path.match(/^(\d+)\/([a-z0-9-]+)$/i); + const match = statsMatch || playerMatch || compactMatch; + if (!match) { + throw new ArgumentError('player must be like 3741/niko, a player URL, or a stats player URL'); + } + + return { playerId: match[1], slug: match[2].toLowerCase() }; +} + +function formatDate(date) { + const yyyy = date.getUTCFullYear(); + const mm = String(date.getUTCMonth() + 1).padStart(2, '0'); + const dd = String(date.getUTCDate()).padStart(2, '0'); + return `${yyyy}-${mm}-${dd}`; +} + +function addMonths(date, months) { + const copy = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); + copy.setUTCMonth(copy.getUTCMonth() + months); + return copy; +} + +export function resolvePeriod(period) { + const raw = String(period ?? 'all').trim(); + if (raw === 'all') return null; + + const now = new Date(); + if (raw === 'lastMonth') return { startDate: formatDate(addMonths(now, -1)), endDate: formatDate(now) }; + if (raw === 'last3Months') return { startDate: formatDate(addMonths(now, -3)), endDate: formatDate(now) }; + if (raw === 'last6Months') return { startDate: formatDate(addMonths(now, -6)), endDate: formatDate(now) }; + if (raw === 'last12Months') return { startDate: formatDate(addMonths(now, -12)), endDate: formatDate(now) }; + if (/^\d{4}$/.test(raw)) return { startDate: `${raw}-01-01`, endDate: `${raw}-12-31` }; + if (/^\d{4}-\d{2}-\d{2}:\d{4}-\d{2}-\d{2}$/.test(raw)) { + const [startDate, endDate] = raw.split(':'); + return { startDate, endDate }; + } + + throw new ArgumentError('period must be all, lastMonth, last3Months, last6Months, last12Months, a year like 2025, or YYYY-MM-DD:YYYY-MM-DD'); +} + +export function buildPlayerUrl(player) { + const { playerId, slug } = parsePlayerRef(player); + return new URL(`/player/${playerId}/${slug}`, BASE); +} + +export function buildPlayerMatchesUrl(args) { + const { playerId, slug } = parsePlayerRef(args.player); + const eventType = normalizeChoice(args.eventType, 'all', EVENT_TYPES, 'eventType'); + const ranking = normalizeChoice(args.ranking, 'all', RANKING_FILTERS, 'ranking'); + const map = normalizeChoice(args.map, 'all', MAPS, 'map'); + const version = normalizeChoice(args.version, 'both', VERSIONS, 'version'); + const offset = normalizeOffset(args.offset, 0); + const period = String(args.period ?? 'all'); + const event = parseEventRef(args.event); + + const url = new URL(`/stats/players/matches/${playerId}/${slug}`, BASE); + const range = resolvePeriod(period); + if (range) { + url.searchParams.set('startDate', range.startDate); + url.searchParams.set('endDate', range.endDate); + } else { + url.searchParams.set('startDate', 'all'); + } + if (EVENT_TYPES[eventType]) url.searchParams.set('matchType', EVENT_TYPES[eventType]); + if (RANKING_FILTERS[ranking]) url.searchParams.set('rankingFilter', RANKING_FILTERS[ranking]); + if (MAPS[map]) url.searchParams.set('maps', MAPS[map]); + if (VERSIONS[version]) url.searchParams.set('csVersion', VERSIONS[version]); + if (event) url.searchParams.set('event', event); + if (offset > 0) url.searchParams.set('offset', String(offset)); + + return { url, playerId, slug, filters: { period, eventType, event: event ?? 'all', ranking, map, version, offset } }; +} + +export function buildTeamMatchesUrl(args) { + const { teamId, slug } = parseTeamRef(args.team); + const eventType = normalizeChoice(args.eventType, 'all', EVENT_TYPES, 'eventType'); + const ranking = normalizeChoice(args.ranking, 'all', RANKING_FILTERS, 'ranking'); + const map = normalizeChoice(args.map, 'all', MAPS, 'map'); + const version = normalizeChoice(args.version, 'both', VERSIONS, 'version'); + const offset = normalizeOffset(args.offset, 0); + const period = String(args.period ?? 'all'); + const event = parseEventRef(args.event); + + const url = new URL(`/team/${teamId}/${slug}`, BASE); + const range = resolvePeriod(period); + if (range) { + url.searchParams.set('startDate', range.startDate); + url.searchParams.set('endDate', range.endDate); + } else { + url.searchParams.set('startDate', 'all'); + } + if (EVENT_TYPES[eventType]) url.searchParams.set('matchType', EVENT_TYPES[eventType]); + if (RANKING_FILTERS[ranking]) url.searchParams.set('rankingFilter', RANKING_FILTERS[ranking]); + if (MAPS[map]) url.searchParams.set('maps', MAPS[map]); + if (VERSIONS[version]) url.searchParams.set('csVersion', VERSIONS[version]); + if (event) url.searchParams.set('event', event); + if (offset > 0) url.searchParams.set('offset', String(offset)); + + return { url, teamId, slug, filters: { period, eventType, event: event ?? 'all', ranking, map, version, offset } }; +} + +function rowResult(teamScore, opponentScore) { + if (teamScore === null || opponentScore === null) return null; + if (teamScore > opponentScore) return 'win'; + if (teamScore < opponentScore) return 'loss'; + return 'draw'; +} + +export async function readPlayerMatches(page, args, limit) { + const { url, playerId, slug, filters } = buildPlayerMatchesUrl(args); + + await gotoAndWait(page, url, '.stats-player-matches .stats-matches-table', 'hltv player matches page'); + + const rows = await page.evaluate((payload) => { + const clean = (value) => String(value ?? '').replace(/\s+/g, ' ').trim(); + const parseScoreName = (value) => { + const raw = clean(value); + const match = raw.match(/^(.*?)\s*\((\d+)\)$/); + if (!match) return { name: raw, score: null }; + return { name: match[1].trim(), score: Number(match[2]) }; + }; + const numberFrom = (value) => { + const match = String(value ?? '').replace(/,/g, '').match(/-?\d+(?:\.\d+)?/); + return match ? Number(match[0]) : null; + }; + const nickname = clean(document.querySelector('.context-item-name, .summaryShortInfo .context-item-name')?.textContent) + || clean(document.querySelector('.stats-profile-name, .playerNickname')?.textContent) + || payload.slug; + const filtersText = Object.entries(payload.filters).map(([key, value]) => `${key}=${value}`).join(','); + const out = []; + + for (const tr of document.querySelectorAll('.stats-matches-table tr.group-1, .stats-matches-table tr.group-2')) { + if (out.length >= payload.limit) break; + const cells = [...tr.children]; + if (cells.length < 7) continue; + const dateLink = cells[0].querySelector('a[href]'); + const matchStatsUrl = dateLink ? new URL(dateLink.getAttribute('href'), payload.base).toString() : null; + const playerTeam = parseScoreName(cells[1].innerText); + const opponent = parseScoreName(cells[2].innerText); + const killsDeaths = clean(cells[4].innerText).match(/(\d+)\s*-\s*(\d+)/); + const plusMinus = numberFrom(cells[5].innerText); + const rating = numberFrom(cells[6].innerText); + const result = playerTeam.score === null || opponent.score === null + ? null + : playerTeam.score > opponent.score ? 'win' : playerTeam.score < opponent.score ? 'loss' : 'draw'; + + out.push({ + rank: payload.offset + out.length + 1, + date: clean(cells[0].innerText), + playerTeam: playerTeam.name, + playerTeamScore: playerTeam.score, + opponent: opponent.name, + opponentScore: opponent.score, + map: clean(cells[3].innerText), + kills: killsDeaths ? Number(killsDeaths[1]) : null, + deaths: killsDeaths ? Number(killsDeaths[2]) : null, + plusMinus, + rating, + result, + matchStatsId: matchStatsUrl ? (new URL(matchStatsUrl).pathname.match(/mapstatsid\/(\d+)\//)?.[1] ?? null) : null, + matchStatsUrl, + playerId: payload.playerId, + nickname, + filters: filtersText, + }); + } + return out; + }, { base: BASE, playerId, slug, filters, limit, offset: filters.offset }); + + return assertRequiredFields(rows.map((row) => ({ + rank: row.rank, + date: parseHltvDate(row.date), + playerTeam: row.playerTeam, + opponent: row.opponent, + map: row.map, + kills: parseNumber(row.kills), + deaths: parseNumber(row.deaths), + plusMinus: parseNumber(row.plusMinus), + rating: parseNumber(row.rating), + playerId: row.playerId, + matchStatsId: row.matchStatsId ?? extractIdFromUrl(row.matchStatsUrl, 'matchStats'), + details: { + result: row.result, + playerTeamScore: parseNumber(row.playerTeamScore), + opponentScore: parseNumber(row.opponentScore), + matchStatsUrl: row.matchStatsUrl, + nickname: row.nickname, + filters: row.filters, + }, + })), 'hltv player-matches', ['rank', 'date', 'playerTeam', 'opponent', 'map', 'playerId', 'matchStatsId']); +} + +export async function readTeamMatches(page, args, limit) { + const { url, teamId, slug, filters } = buildTeamMatchesUrl(args); + + await gotoAndWait(page, url, '.team-row, .match-table', 'hltv team profile matches page'); + + const rows = await page.evaluate((payload) => { + const clean = (value) => String(value ?? '').replace(/\s+/g, ' ').trim(); + const teamName = clean(document.querySelector('.profile-team-name, .teamName, .context-item-name')?.textContent) + || payload.slug; + const filtersText = Object.entries(payload.filters).map(([key, value]) => `${key}=${value}`).join(','); + const out = []; + + for (const tr of document.querySelectorAll('tr.team-row')) { + if (out.length >= payload.limit) break; + const text = clean(tr.innerText); + if (text.includes('-:-')) continue; + const link = tr.querySelector('a[href*="/matches/"]'); + const matchUrl = link ? new URL(link.getAttribute('href'), payload.base).toString() : null; + const score = text.match(/\s(\d+)\s*:\s*(\d+)\s/); + if (!score) continue; + const before = text.slice(0, score.index).trim(); + const after = text.slice(score.index + score[0].length).replace(/\bMatch\b.*$/, '').trim(); + const date = (before.match(/\d{2}\/\d{2}\/\d{4}/)?.[0]) ?? (before.match(/\d{2}:\d{2}/)?.[0]) ?? ''; + const teamText = before.replace(date, '').trim() || teamName; + const opponent = after || clean(link?.textContent); + const teamScore = Number(score[1]); + const opponentScore = Number(score[2]); + out.push({ + rank: payload.offset + out.length + 1, + date, + team: teamText || teamName, + teamScore, + opponent, + opponentScore, + map: 'series', + event: null, + roundDiff: teamScore === null || opponentScore === null ? null : teamScore - opponentScore, + matchId: matchUrl ? (new URL(matchUrl).pathname.match(/\/matches\/(\d+)\//)?.[1] ?? null) : null, + matchUrl, + teamId: payload.teamId, + teamName, + filters: filtersText, + }); + } + return out; + }, { base: BASE, teamId, slug, filters, limit, offset: filters.offset }); + + return assertRequiredFields(rows.map((row) => ({ + rank: row.rank, + date: parseHltvDate(row.date), + team: row.teamName || row.team, + opponent: row.opponent, + map: row.map, + result: rowResult(parseNumber(row.teamScore), parseNumber(row.opponentScore)), + teamId: row.teamId, + matchStatsId: row.matchId, + details: { + teamScore: parseNumber(row.teamScore), + opponentScore: parseNumber(row.opponentScore), + event: row.event, + roundDiff: parseNumber(row.roundDiff), + matchUrl: row.matchUrl, + filters: row.filters, + }, + })), 'hltv team-matches', ['rank', 'date', 'team', 'opponent', 'teamId', 'matchStatsId']); +} + +export function parseHltvDate(value) { + const raw = String(value ?? '').trim(); + const shortMatch = raw.match(/^(\d{2})\/(\d{2})\/(\d{2})$/); + if (shortMatch) { + const [, dd, mm, yy] = shortMatch; + return `20${yy}-${mm}-${dd}`; + } + const longMatch = raw.match(/^(\d{2})\/(\d{2})\/(\d{4})$/); + if (longMatch) { + const [, dd, mm, yyyy] = longMatch; + return `${yyyy}-${mm}-${dd}`; + } + return raw; +} + +export function normalizeMatchUrl(value) { + const raw = String(value ?? '').trim(); + if (!raw) throw new ArgumentError('match is required'); + const url = /^https?:\/\//i.test(raw) ? parseHltvUserUrl(raw, 'match') : new URL(raw.replace(/^\/+/, ''), `${BASE}/`); + if (!/^\/(?:matches\/\d+\/|stats\/matches\/(?:mapstatsid\/)?\d+\/)/.test(url.pathname)) { + throw new ArgumentError('match must be an HLTV match, stats series, or mapstats URL'); + } + return url; +} + +function buildPerformanceUrl(mapstatsUrl) { + const url = new URL(mapstatsUrl, BASE); + url.pathname = url.pathname.replace('/stats/matches/mapstatsid/', '/stats/matches/performance/mapstatsid/'); + return url; +} + +export async function resolveMatchMapUrls(page, match) { + const url = normalizeMatchUrl(match); + if (/^\/stats\/matches\/mapstatsid\/\d+\//.test(url.pathname)) return [url.toString()]; + + await gotoAndWait(page, url, 'a[href*="/stats/matches/mapstatsid/"]', 'hltv match map link page'); + const links = await page.evaluate((payload) => { + const seen = new Set(); + const out = []; + for (const a of document.querySelectorAll('a[href*="/stats/matches/mapstatsid/"]')) { + const href = new URL(a.getAttribute('href'), payload.base); + href.search = ''; + href.hash = ''; + const key = href.pathname.match(/mapstatsid\/(\d+)\//)?.[1]; + if (!key || seen.has(key)) continue; + seen.add(key); + out.push(href.toString()); + } + return out; + }, { base: BASE }); + return assertRows(links, 'hltv match map urls'); +} + +export async function resolveStatsSeriesUrlFromMap(page, mapstatsUrl) { + const url = normalizeMatchUrl(mapstatsUrl); + if (!/^\/stats\/matches\/mapstatsid\/\d+\//.test(url.pathname)) { + throw new ArgumentError('series resolution requires a /stats/matches/mapstatsid/:id/:slug URL'); + } + + await gotoAndWait(page, url, 'a[href*="/stats/matches/"]', 'hltv match series link page'); + const seriesUrl = await page.evaluate((payload) => { + for (const a of document.querySelectorAll('a[href*="/stats/matches/"]')) { + const href = new URL(a.getAttribute('href'), payload.base); + href.search = ''; + href.hash = ''; + if (/^\/stats\/matches\/\d+\//.test(href.pathname)) return href.toString(); + } + return null; + }, { base: BASE }); + + return seriesUrl ?? url.toString(); +} + +export async function readMatchMap(page, match) { + const url = normalizeMatchUrl(match); + if (!/^\/stats\/matches\/mapstatsid\/\d+\//.test(url.pathname)) { + throw new ArgumentError('match map parser requires a /stats/matches/mapstatsid/:id/:slug URL'); + } + const matchStatsId = extractIdFromUrl(url.toString(), 'matchStats'); + + await gotoAndWait(page, url, '.stats-section.stats-match, .stats-table.totalstats', 'hltv match map page'); + + const rows = await page.evaluate((payload) => { + const clean = (value) => String(value ?? '').replace(/\s+/g, ' ').trim(); + const cleanLines = (value) => String(value ?? '').split('\n').map((line) => clean(line)).filter(Boolean); + const numberFrom = (value) => { + const match = String(value ?? '').replace(/,/g, '').match(/-?\d+(?:\.\d+)?/); + return match ? Number(match[0]) : null; + }; + const textOf = (root, selector) => clean(root.querySelector(selector)?.textContent); + const splitMainParen = (value) => { + const match = clean(value).match(/^(-?\d+(?:\.\d+)?)\s*(?:\(([-\d.]+)\))?/); + return { main: match ? Number(match[1]) : null, paren: match?.[2] !== undefined ? Number(match[2]) : null }; + }; + const infoLines = cleanLines(document.querySelector('.match-info-box')?.innerText); + const dateIndex = infoLines.findIndex((line) => /^\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}$/.test(line)); + const event = dateIndex > 0 ? infoLines.slice(0, dateIndex).join(' ') : null; + const dateTime = dateIndex >= 0 ? infoLines[dateIndex] : null; + const mapLabelIndex = infoLines.indexOf('Map'); + const selectedMap = mapLabelIndex >= 0 ? infoLines[mapLabelIndex + 1] : null; + const scoreStart = mapLabelIndex >= 0 ? mapLabelIndex + 2 : -1; + const teamOne = scoreStart >= 0 ? infoLines[scoreStart] : null; + const teamOneScore = scoreStart >= 0 ? numberFrom(infoLines[scoreStart + 1]) : null; + const teamTwo = scoreStart >= 0 ? infoLines[scoreStart + 2] : null; + const teamTwoScore = scoreStart >= 0 ? numberFrom(infoLines[scoreStart + 3]) : null; + const rows = []; + let currentTeam = null; + + for (const tr of document.querySelectorAll('.stats-table.totalstats tr')) { + const teamName = textOf(tr, '.st-teamname'); + if (teamName) { + currentTeam = teamName; + continue; + } + + const playerLink = tr.querySelector('a[href^="/stats/players/"], a[href*="/stats/players/"], a[href^="/player/"], a[href*="/player/"]'); + if (!playerLink || !currentTeam) continue; + const playerUrl = new URL(playerLink.getAttribute('href'), payload.base).toString(); + const playerId = new URL(playerUrl).pathname.match(/\/(?:stats\/players|player)\/(\d+)\//)?.[1] ?? null; + const kills = splitMainParen(textOf(tr, '.st-kills')); + const assists = splitMainParen(textOf(tr, '.st-assists')); + const deaths = splitMainParen(textOf(tr, '.st-deaths')); + const opponent = currentTeam === teamOne ? teamTwo : teamOne; + + rows.push({ + matchStatsId: payload.matchStatsId, + playerId, + playerName: clean(playerLink.textContent), + team: currentTeam, + kills: kills.main, + deaths: deaths.main, + adr: numberFrom(textOf(tr, '.st-adr')), + kastPct: numberFrom(textOf(tr, '.st-kast')), + rating: numberFrom(textOf(tr, '.st-rating')), + opKd: textOf(tr, '.st-opkd'), + details: { + map: selectedMap, + event, + dateTime, + teamScore: currentTeam === teamOne ? teamOneScore : teamTwoScore, + opponent, + opponentScore: currentTeam === teamOne ? teamTwoScore : teamOneScore, + headshots: kills.paren, + assists: assists.main, + flashAssists: assists.paren, + tradedDeaths: deaths.paren, + multiKills: numberFrom(textOf(tr, '.st-mks')), + clutches: numberFrom(textOf(tr, '.st-clutches')), + roundSwingPct: numberFrom(textOf(tr, '.st-roundSwing')), + }, + url: playerUrl, + }); + } + return rows; + }, { base: BASE, matchStatsId }); + + if (!Array.isArray(rows)) throw new CommandExecutionError('hltv match-map parser returned an unexpected shape'); + if (rows.length === 0) throw new CommandExecutionError('hltv match-map parser found no player rows'); + + return assertRequiredFields(rows.map((row) => ({ + matchStatsId: row.matchStatsId, + playerId: row.playerId, + playerName: row.playerName, + team: row.team, + kills: parseNumber(row.kills), + deaths: parseNumber(row.deaths), + adr: parseNumber(row.adr), + kastPct: parseNumber(row.kastPct), + rating: parseNumber(row.rating), + opKd: row.opKd, + details: row.details, + url: row.url, + })), 'hltv match-map', ['matchStatsId', 'playerId', 'playerName', 'team', 'url']); +} + +function round(value, digits = 2) { + if (!Number.isFinite(value)) return null; + return Number(value.toFixed(digits)); +} + +function summarizePlayers(mapRows) { + const byTeam = new Map(); + for (const row of mapRows) { + if (!byTeam.has(row.team)) { + byTeam.set(row.team, { + team: row.team, + opponent: row.details?.opponent ?? null, + maps: 0, + kills: 0, + deaths: 0, + ratingSum: 0, + ratingCount: 0, + players: 0, + scores: [], + }); + } + const bucket = byTeam.get(row.team); + bucket.players += 1; + bucket.kills += row.kills ?? 0; + bucket.deaths += row.deaths ?? 0; + if (Number.isFinite(row.rating)) { + bucket.ratingSum += row.rating; + bucket.ratingCount += 1; + } + if (Number.isFinite(row.details?.teamScore)) bucket.scores.push(row.details.teamScore); + } + return [...byTeam.values()].map((bucket) => ({ + team: bucket.team, + opponent: bucket.opponent, + kills: bucket.kills, + deaths: bucket.deaths, + avgRating: bucket.ratingCount ? round(bucket.ratingSum / bucket.ratingCount) : null, + score: bucket.scores[0] ?? null, + })); +} + +export async function readMatchSeries(page, match) { + const mapUrls = await resolveMatchMapUrls(page, match); + const outRows = []; + const mapSummaries = []; + const seriesTotals = new Map(); + let firstMeta = null; + + for (const mapUrl of mapUrls) { + const mapRows = await readMatchMap(page, mapUrl); + const first = mapRows[0]; + if (!firstMeta) firstMeta = first?.details ?? null; + const teams = summarizePlayers(mapRows); + const mapName = first?.details?.map ?? null; + const date = first?.details?.dateTime?.slice(0, 10) ?? null; + const event = first?.details?.event ?? null; + const teamLine = teams.map((team) => `${team.team} ${team.score ?? ''}`.trim()).join(' vs '); + + mapSummaries.push({ + matchStatsId: first?.matchStatsId ?? extractIdFromUrl(mapUrl, 'matchStats'), + map: mapName, + date, + event, + score: teamLine, + url: mapUrl, + teams, + }); + + for (const team of teams) { + if (!seriesTotals.has(team.team)) { + seriesTotals.set(team.team, { + team: team.team, + opponent: team.opponent, + maps: 0, + mapWins: 0, + kills: 0, + deaths: 0, + ratingSum: 0, + ratingCount: 0, + }); + } + const total = seriesTotals.get(team.team); + total.maps += 1; + total.mapWins += teams.length > 1 && team.score > (teams.find((other) => other.team !== team.team)?.score ?? -1) ? 1 : 0; + total.kills += team.kills; + total.deaths += team.deaths; + if (Number.isFinite(team.avgRating)) { + total.ratingSum += team.avgRating; + total.ratingCount += 1; + } + } + + for (const team of teams) { + outRows.push({ + rowType: 'map', + date, + event, + map: mapName, + team: team.team, + opponent: team.opponent, + score: team.score === null ? null : `${team.score}-${teams.find((other) => other.team !== team.team)?.score ?? ''}`, + matchStatsId: first?.matchStatsId ?? extractIdFromUrl(mapUrl, 'matchStats'), + playerId: null, + playerName: null, + rating: team.avgRating, + details: { + kills: team.kills, + deaths: team.deaths, + kdRatio: team.deaths > 0 ? round(team.kills / team.deaths) : null, + mapStatsUrl: mapUrl, + }, + }); + } + + for (const row of mapRows) { + outRows.push({ + rowType: 'player', + date: row.details?.dateTime?.slice(0, 10) ?? null, + event: row.details?.event ?? null, + map: row.details?.map ?? null, + team: row.team, + opponent: row.details?.opponent ?? null, + score: row.details?.teamScore === null ? null : `${row.details?.teamScore}-${row.details?.opponentScore}`, + matchStatsId: row.matchStatsId, + playerId: row.playerId, + playerName: row.playerName, + rating: row.rating, + details: { + kills: row.kills, + deaths: row.deaths, + adr: row.adr, + kastPct: row.kastPct, + opKd: row.opKd, + headshots: row.details?.headshots ?? null, + assists: row.details?.assists ?? null, + flashAssists: row.details?.flashAssists ?? null, + tradedDeaths: row.details?.tradedDeaths ?? null, + mapStatsUrl: mapUrl, + playerUrl: row.url, + }, + }); + } + } + + const summaryDetails = { + maps: mapUrls.length, + event: firstMeta?.event ?? null, + firstDate: mapSummaries.map((row) => row.date).filter(Boolean).sort()[0] ?? null, + lastDate: mapSummaries.map((row) => row.date).filter(Boolean).sort().at(-1) ?? null, + mapStatsIds: mapSummaries.map((row) => row.matchStatsId).join(','), + mapsPlayed: mapSummaries.map((row) => row.map).filter(Boolean).join(','), + mapScores: mapSummaries.map((row) => `${row.map}:${row.score}`).join('; '), + }; + const summaryRows = [...seriesTotals.values()].map((team) => ({ + rowType: 'summary', + date: summaryDetails.lastDate, + event: summaryDetails.event, + map: 'all', + team: team.team, + opponent: team.opponent, + score: `${team.mapWins}-${team.maps - team.mapWins}`, + matchStatsId: 'all', + playerId: null, + playerName: null, + rating: team.ratingCount ? round(team.ratingSum / team.ratingCount) : null, + details: { + ...summaryDetails, + mapsWon: team.mapWins, + mapsLost: team.maps - team.mapWins, + kills: team.kills, + deaths: team.deaths, + kdRatio: team.deaths > 0 ? round(team.kills / team.deaths) : null, + }, + })); + + return assertRows([...summaryRows, ...outRows], 'hltv match-series'); +} + +export async function readPerformanceKillMatrix(page, mapstatsUrl) { + const url = buildPerformanceUrl(mapstatsUrl); + await gotoAndWait(page, url, '.stats-table', 'hltv match performance page'); + + const matrix = await page.evaluate(() => { + const clean = (value) => String(value ?? '').replace(/\s+/g, ' ').trim(); + const parseCell = (value) => { + const match = clean(value).match(/^(\d+)\s*:\s*(\d+)$/); + return match ? { left: Number(match[1]), right: Number(match[2]) } : null; + }; + const table = [...document.querySelectorAll('table.stats-table')].find((candidate) => { + const text = clean(candidate.innerText); + return /\d+\s*:\s*\d+/.test(text); + }); + if (!table) return null; + const trs = [...table.querySelectorAll('tr')]; + const headerCells = [...trs[0]?.children ?? []].map((cell) => clean(cell.textContent)).filter(Boolean); + const entries = []; + for (const tr of trs.slice(1)) { + const cells = [...tr.children].map((cell) => clean(cell.textContent)); + const rowPlayer = cells[0]; + if (!rowPlayer) continue; + for (let i = 1; i < cells.length; i += 1) { + const parsed = parseCell(cells[i]); + const colPlayer = headerCells[i - 1]; + if (!parsed || !colPlayer) continue; + entries.push({ + rowPlayer, + colPlayer, + rowKillsCol: parsed.left, + colKillsRow: parsed.right, + }); + } + } + return entries; + }); + + if (!Array.isArray(matrix)) return []; + return matrix; +} + +export async function gotoAndWait(page, url, selector, label) { + try { + await page.goto(url.toString(), { waitUntil: 'domcontentloaded', settleMs: 1000, timeout: 20000 }); + await page.wait({ selector, timeout: 15000 }); + } catch (error) { + if (/timeout/i.test(String(error?.message ?? error))) { + throw new TimeoutError(label, 15); + } + throw new CommandExecutionError(`${label} failed: ${error?.message ?? error}`); + } +} + +export function assertRows(rows, command) { + if (!Array.isArray(rows)) throw new CommandExecutionError(`${command} parser returned an unexpected shape`); + if (rows.length === 0) throw new EmptyResultError(command, 'No rows were found in the visible HLTV page'); + return rows; +} + +export function assertRequiredFields(rows, command, fields) { + assertRows(rows, command); + for (const [index, row] of rows.entries()) { + for (const field of fields) { + if (row?.[field] === null || row?.[field] === undefined || row?.[field] === '') { + throw new CommandExecutionError(`${command} parser returned row ${index + 1} without required ${field}`); + } + } + } + return rows; +} diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 18995df29..fac6e79fa 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -73,6 +73,7 @@ export default defineConfig({ { text: 'Booking.com', link: '/adapters/browser/booking' }, { text: 'GeoGebra', link: '/adapters/browser/geogebra' }, { text: 'Reuters', link: '/adapters/browser/reuters' }, + { text: 'HLTV', link: '/adapters/browser/hltv' }, { text: 'SMZDM', link: '/adapters/browser/smzdm' }, { text: 'Jike', link: '/adapters/browser/jike' }, { text: 'Jimeng', link: '/adapters/browser/jimeng' }, diff --git a/docs/adapters/browser/hltv.md b/docs/adapters/browser/hltv.md new file mode 100644 index 000000000..77590c26a --- /dev/null +++ b/docs/adapters/browser/hltv.md @@ -0,0 +1,87 @@ +# HLTV + +**Mode**: 🌐 Browser · **Domain**: `hltv.org` + +The HLTV adapter reads visible CS2/CS:GO stats pages through the Browser +Bridge. HLTV does not expose a stable public JSON API for these views, so the +commands parse search results, player stats, match mapstats, team stats, and +series pages from the rendered DOM. + +## Commands + +| Command | Description | +|---------|-------------| +| `opencli hltv search ` | Search players, teams, events, and articles | +| `opencli hltv player-summary` | Read a player's summary page and complete stats link | +| `opencli hltv player-matches` | Read a player's stats Matches tab | +| `opencli hltv player-form` | Aggregate recent player maps by summary, map, and opponent | +| `opencli hltv player-map-pool` | Aggregate a player's recent maps into map-pool buckets | +| `opencli hltv player-vs-team ` | Filter a player's maps against one team | +| `opencli hltv player-teammate-impact ` | Compare shared and non-shared map samples for two teammates | +| `opencli hltv player-duel ` | Compare two players on shared maps, including direct kills when available | +| `opencli hltv match-map ` | Read all player rows from one mapstats page | +| `opencli hltv match-series ` | Expand a BO1/BO3/BO5 into summary, map, and player rows | +| `opencli hltv team-matches ` | Read recent team map results | +| `opencli hltv team-map-pool ` | Read a team's visible map-pool stats | +| `opencli hltv event-matches ` | Read stats match rows for one event | + +## Usage Examples + +```bash +# Search and discover HLTV entity links +opencli hltv search niko --limit 5 + +# Read player pages and recent form +opencli hltv player-summary --player 3741/niko +opencli hltv player-matches --player 3741/niko --limit 10 -f json +opencli hltv player-form --player 19230/m0nesy --limit 30 + +# Compare two players +opencli hltv player-duel 3741/niko 21167/donk +opencli hltv player-duel 19230/m0nesy 3741/niko --mode history --limit 10 + +# Expand a series or inspect one mapstats URL +opencli hltv match-series https://www.hltv.org/stats/matches/126993/spirit-vs-falcons +opencli hltv match-map "https://www.hltv.org/stats/matches/mapstatsid/231594/falcons-vs-natus-vincere" -f json + +# Team and event views +opencli hltv team-matches 6667/falcons --limit 10 +opencli hltv team-map-pool 11283/falcons +opencli hltv event-matches 8301 --limit 10 +``` + +## Common Subjects + +- Player refs accept `id/slug`, player URLs, and compatible stats player URLs. +- Team refs accept `id/slug`, team URLs, and compatible stats team URLs. +- Match refs accept normal match URLs, stats series URLs, and mapstats URLs where + supported. +- Event refs accept event IDs, `/events/:id/:slug` URLs, and stats URLs with an + `event=` query parameter. + +## Prerequisites + +- Chrome running with the [Browser Bridge extension](/guide/browser-bridge) + installed +- HLTV accessible in the active browser session +- If HLTV shows a Cloudflare or human-verification page, complete it in Chrome + before retrying the command + +## Notes + +- The adapter is read-only and does not require HLTV login. +- Heavy match or duel commands may load multiple HLTV pages and can take longer + than search or summary commands. +- Use `--window foreground --keep-tab true` when debugging HLTV page structure + or Cloudflare interruptions. +- Primary subjects use positional arguments; filters such as `--limit`, + `--period`, `--ranking`, `--map`, and `--version` remain named options. + +## Error Behaviour + +| Condition | Error | +|-----------|-------| +| Invalid player, team, event, match, limit, offset, or filter argument | `ArgumentError` | +| Visible HLTV page contains no rows for the requested subject/filter | `EmptyResultError` | +| HLTV page structure changed or parser returns an unexpected shape | `CommandExecutionError` | +| Browser navigation or selector wait exceeds the command timeout | `TimeoutError` | diff --git a/docs/adapters/index.md b/docs/adapters/index.md index 0599b10d9..63874372f 100644 --- a/docs/adapters/index.md +++ b/docs/adapters/index.md @@ -33,6 +33,7 @@ Run `opencli list` for the live registry. | **[ctrip](./browser/ctrip.md)** | `search` `hotel-suggest` | 🌐 Public | | **[booking](./browser/booking.md)** | `search` | 🌐 Public | | **[reuters](./browser/reuters.md)** | `search` `article-detail` | 🔐 Browser | +| **[hltv](./browser/hltv.md)** | `search` `player-summary` `player-matches` `player-form` `player-map-pool` `player-vs-team` `player-teammate-impact` `player-duel` `match-map` `match-series` `team-matches` `team-map-pool` `event-matches` | 🌐 Browser | | **[smzdm](./browser/smzdm.md)** | `search` | 🔐 Browser | | **[jike](./browser/jike.md)** | `feed` `search` `post` `topic` `user` `create` `comment` `like` `repost` `notifications` | 🔐 Browser | | **[ke](./browser/ke.md)** | `ershoufang` `zufang` `xiaoqu` `chengjiao` | 🔐 Browser | From 08d50d9b2441db0598631b41b4e40802854428f5 Mon Sep 17 00:00:00 2001 From: jakevin Date: Fri, 3 Jul 2026 22:10:09 +0800 Subject: [PATCH 17/27] fix(cli): tolerate OPENCLI_DAEMON_PORT when it equals the default port (#2074) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenCLIApp injects OPENCLI_DAEMON_PORT=19825 into the environment of every CLI it manages. The CLI hard-rejected the variable regardless of its value, so fresh OpenCLIApp installs failed on every command — including --version and doctor — with EX_CONFIG, and the daemon never started (#2068, #2072). A value equal to the default port carries no configuration at all; only a NON-default value is a genuine misconfiguration worth failing on. All three rejection points (main.ts entry, daemon startup, transport assert) now share isIgnorableDaemonPortEnv(). Also fixes the README multi-profile example that was missing the required browser positional (#1893). --- README.md | 2 +- src/browser/daemon-client.test.ts | 21 +++++++++++++++++++++ src/browser/daemon-transport.ts | 4 ++-- src/constants.ts | 14 ++++++++++++++ src/daemon.ts | 4 ++-- src/main.ts | 4 ++-- 6 files changed, 42 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 4f90cc6a6..efcfb4bc4 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ Each Chrome profile runs its own OpenCLI extension instance. If you use multiple opencli profile list opencli profile rename work opencli profile use work -opencli --profile work browser state +opencli --profile work browser main state ``` With only one connected profile, OpenCLI uses it automatically. With multiple connected profiles and no default, OpenCLI asks you to choose instead of guessing. diff --git a/src/browser/daemon-client.test.ts b/src/browser/daemon-client.test.ts index ccde1797a..0999a22eb 100644 --- a/src/browser/daemon-client.test.ts +++ b/src/browser/daemon-client.test.ts @@ -173,6 +173,27 @@ describe('daemon-client', () => { expect(vi.mocked(fetch)).not.toHaveBeenCalled(); }); + it('tolerates OPENCLI_DAEMON_PORT when it equals the default port (launchers inject it, #2068)', async () => { + vi.resetModules(); + vi.stubEnv('OPENCLI_DAEMON_PORT', '19825'); + const status = { + ok: true, + pid: 1, + uptime: 0, + extensionConnected: true, + pending: 0, + memoryMB: 1, + port: 19825, + }; + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: () => Promise.resolve(status), + } as Response); + + const freshClient = await import('./daemon-client.js'); + await expect(freshClient.fetchDaemonStatus()).resolves.toEqual(status); + }); + it('sendCommand includes the current pid in generated command ids', async () => { vi.spyOn(Date, 'now').mockReturnValue(1_763_000_000_000); vi.mocked(fetch).mockResolvedValue({ diff --git a/src/browser/daemon-transport.ts b/src/browser/daemon-transport.ts index bab2e8341..2fabcb8a1 100644 --- a/src/browser/daemon-transport.ts +++ b/src/browser/daemon-transport.ts @@ -1,4 +1,4 @@ -import { DEFAULT_DAEMON_PORT, unsupportedDaemonPortEnvMessage } from '../constants.js'; +import { DEFAULT_DAEMON_PORT, isIgnorableDaemonPortEnv, unsupportedDaemonPortEnvMessage } from '../constants.js'; const DAEMON_PORT = DEFAULT_DAEMON_PORT; const DAEMON_URL = `http://127.0.0.1:${DAEMON_PORT}`; @@ -13,7 +13,7 @@ class UnsupportedDaemonPortEnvError extends Error { function assertSupportedDaemonPortEnv(): void { const value = process.env.OPENCLI_DAEMON_PORT; - if (value !== undefined && value !== '') throw new UnsupportedDaemonPortEnvError(value); + if (!isIgnorableDaemonPortEnv(value)) throw new UnsupportedDaemonPortEnvError(value!); } export interface DaemonStatus { diff --git a/src/constants.ts b/src/constants.ts index 39742b378..a9403390c 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -12,6 +12,20 @@ export function unsupportedDaemonPortEnvMessage(value?: string): string { 'Unset OPENCLI_DAEMON_PORT and rerun opencli.'; } +/** + * True when OPENCLI_DAEMON_PORT carries no real configuration: unset, empty, + * or equal to the default port. Launchers (notably OpenCLIApp) inject the + * variable with the default value into every CLI they manage — rejecting that + * harmless redundancy bricked all commands on fresh installs (#2068). Only a + * NON-default value is a genuine misconfiguration worth failing on. + */ +export function isIgnorableDaemonPortEnv(value: string | undefined): boolean { + if (value === undefined) return true; + const trimmed = value.trim(); + if (!trimmed) return true; + return Number(trimmed) === DEFAULT_DAEMON_PORT; +} + /** URL query params that are volatile/ephemeral and should be stripped from patterns */ export const VOLATILE_PARAMS = new Set([ 'w_rid', 'wts', '_', 'callback', 'timestamp', 't', 'nonce', 'sign', diff --git a/src/daemon.ts b/src/daemon.ts index 4fc7eb486..a4ac0fa25 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -22,7 +22,7 @@ import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; import { WebSocketServer, WebSocket, type RawData } from 'ws'; -import { DEFAULT_DAEMON_PORT, unsupportedDaemonPortEnvMessage } from './constants.js'; +import { DEFAULT_DAEMON_PORT, isIgnorableDaemonPortEnv, unsupportedDaemonPortEnvMessage } from './constants.js'; import { EXIT_CODES } from './errors.js'; import { log } from './logger.js'; import { PKG_VERSION } from './version.js'; @@ -36,7 +36,7 @@ import { } from './daemon-utils.js'; const PORT = DEFAULT_DAEMON_PORT; -if (process.env.OPENCLI_DAEMON_PORT) { +if (!isIgnorableDaemonPortEnv(process.env.OPENCLI_DAEMON_PORT)) { log.error(unsupportedDaemonPortEnvMessage(process.env.OPENCLI_DAEMON_PORT)); process.exit(EXIT_CODES.USAGE_ERROR); } diff --git a/src/main.ts b/src/main.ts index ff227056b..b2883d5a2 100644 --- a/src/main.ts +++ b/src/main.ts @@ -22,7 +22,7 @@ import { findPackageRoot, getCliManifestPath } from './package-paths.js'; import { PKG_VERSION } from './version.js'; import { EXIT_CODES } from './errors.js'; import { isSupportedNodeVersion, MIN_SUPPORTED_NODE_MAJOR } from './runtime-detect.js'; -import { unsupportedDaemonPortEnvMessage } from './constants.js'; +import { isIgnorableDaemonPortEnv, unsupportedDaemonPortEnvMessage } from './constants.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -47,7 +47,7 @@ if (typeof (globalThis as { Bun?: unknown }).Bun === 'undefined' && !isSupported process.exit(EXIT_CODES.CONFIG_ERROR); } -if (process.env.OPENCLI_DAEMON_PORT) { +if (!isIgnorableDaemonPortEnv(process.env.OPENCLI_DAEMON_PORT)) { process.stderr.write(`error: ${unsupportedDaemonPortEnvMessage(process.env.OPENCLI_DAEMON_PORT)}\n`); process.exit(EXIT_CODES.CONFIG_ERROR); } From 18dce783da07955501a94fa67eec935d0c007b3a Mon Sep 17 00:00:00 2001 From: jakevin Date: Fri, 3 Jul 2026 22:11:34 +0800 Subject: [PATCH 18/27] fix(external): run Windows .cmd shims through the shell; non-zero exit on signal death (#2075) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit npm-installed CLIs on Windows are .cmd shims: `where` finds them (so the installed-check passes), but Node refuses to spawn them directly since the CVE-2024-27980 hardening — spawnSync fails with EINVAL/ENOENT and every CLI-hub passthrough to an npm-installed tool breaks (#1958). On that specific failure the passthrough now retries through the shell with each token quoted for cmd.exe. Also: a child killed by a signal left status null and opencli exited 0, reporting success to the calling shell/agent; signal death now maps to a non-zero exit code. --- src/external.test.ts | 51 ++++++++++++++++++++++++++++++++++++++++++++ src/external.ts | 34 +++++++++++++++++++++++++++-- 2 files changed, 83 insertions(+), 2 deletions(-) diff --git a/src/external.test.ts b/src/external.test.ts index 2d3e2cbcb..7a8ffe5cf 100644 --- a/src/external.test.ts +++ b/src/external.test.ts @@ -146,3 +146,54 @@ describe('installExternalCli', () => { expect(mockExecFileSync).toHaveBeenCalledTimes(1); }); }); + +const { spawnSync } = await import('node:child_process'); +const { executeExternalCli } = await import('./external.js'); + +describe('executeExternalCli passthrough', () => { + const spawnMock = vi.mocked(spawnSync); + const cli: ExternalCliConfig = { name: 'tg', binary: 'tg', description: '' } as ExternalCliConfig; + + beforeEach(() => { + spawnMock.mockReset(); + mockExecFileSync.mockReset(); + mockExecFileSync.mockReturnValue(Buffer.from('')); // isBinaryInstalled → true + process.exitCode = undefined; + }); + + it('retries through the shell on Windows when the binary is a .cmd shim (EINVAL)', () => { + mockPlatform.mockReturnValue('win32'); + const einval = Object.assign(new Error('spawnSync tg EINVAL'), { code: 'EINVAL' }); + spawnMock + .mockReturnValueOnce({ error: einval, status: null, signal: null } as unknown as ReturnType) + .mockReturnValueOnce({ status: 0, signal: null } as unknown as ReturnType); + + executeExternalCli('tg', ['send', 'hello world', '--to', 'a"b'], [cli]); + + expect(spawnMock).toHaveBeenCalledTimes(2); + expect(spawnMock).toHaveBeenNthCalledWith(1, 'tg', ['send', 'hello world', '--to', 'a"b'], { stdio: 'inherit' }); + // Shell fallback: one quoted command string, tokens with spaces/quotes wrapped for cmd.exe. + expect(spawnMock).toHaveBeenNthCalledWith(2, 'tg send "hello world" --to "a""b"', { stdio: 'inherit', shell: true }); + expect(process.exitCode).toBe(0); + }); + + it('does not retry through the shell on non-Windows platforms', () => { + mockPlatform.mockReturnValue('darwin'); + const einval = Object.assign(new Error('spawnSync tg EINVAL'), { code: 'EINVAL' }); + spawnMock.mockReturnValueOnce({ error: einval, status: null, signal: null } as unknown as ReturnType); + + executeExternalCli('tg', [], [cli]); + + expect(spawnMock).toHaveBeenCalledTimes(1); + expect(process.exitCode).toBe(1); + }); + + it('reports a non-zero exit code when the child dies from a signal', () => { + mockPlatform.mockReturnValue('darwin'); + spawnMock.mockReturnValueOnce({ status: null, signal: 'SIGKILL' } as unknown as ReturnType); + + executeExternalCli('tg', [], [cli]); + + expect(process.exitCode).toBe(1); + }); +}); diff --git a/src/external.ts b/src/external.ts index 881556cbb..030b2786d 100644 --- a/src/external.ts +++ b/src/external.ts @@ -197,18 +197,48 @@ export function executeExternalCli(name: string, args: string[], preloaded?: Ext } // 3. Passthrough execution with stdio inherited - const result = spawnSync(cli.binary, args, { stdio: 'inherit' }); + const result = spawnPassthrough(cli.binary, args); if (result.error) { log.error(`Failed to execute '${cli.binary}': ${result.error.message}`); process.exitCode = EXIT_CODES.GENERIC_ERROR; return; } - + + if (result.signal) { + // Killed by a signal — never report success to the calling shell/agent. + process.exitCode = EXIT_CODES.GENERIC_ERROR; + return; + } if (result.status !== null) { process.exitCode = result.status; } } +/** Quote a token for cmd.exe: wrap when it contains shell-significant chars, doubling inner quotes. */ +function quoteForCmdShell(token: string): string { + if (token !== '' && !/[\s"^&|<>%()]/.test(token)) return token; + return `"${token.replace(/"/g, '""')}"`; +} + +/** + * Run an external CLI with stdio inherited. + * + * On Windows, npm-installed CLIs are `.cmd` shims: `where` finds them (so the + * installed-check passes), but Node refuses to spawn them directly since the + * CVE-2024-27980 hardening — spawnSync fails with EINVAL (or ENOENT when + * PATHEXT resolution is skipped). Fall back to running through the shell in + * that case, quoting each token for cmd.exe. + */ +function spawnPassthrough(binary: string, args: string[]): ReturnType { + const direct = spawnSync(binary, args, { stdio: 'inherit' }); + const errorCode = (direct.error as NodeJS.ErrnoException | undefined)?.code; + if (os.platform() === 'win32' && (errorCode === 'EINVAL' || errorCode === 'ENOENT')) { + const command = [binary, ...args].map(quoteForCmdShell).join(' '); + return spawnSync(command, { stdio: 'inherit', shell: true }); + } + return direct; +} + export interface RegisterOptions { binary?: string; install?: string; From 9387cd9262aba46a9411957e6a00c82ed7027c97 Mon Sep 17 00:00:00 2001 From: jakevin Date: Fri, 3 Jul 2026 22:13:32 +0800 Subject: [PATCH 19/27] fix(browser): stale default profile must not veto live connections (#2073) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(browser): stale default profile must not veto live connections A persisted default profile (browser-profiles.json defaultContextId) has a lifetime that routinely exceeds the extension instance it names — reinstalling the extension or resetting Chrome regenerates the contextId. Since #1235 that stale preference was folded together with --profile/OPENCLI_PROFILE into one hard contextId on every command, so the daemon refused to serve it (profile_disconnected) even when exactly one live profile was connected, breaking the documented promise "with only one connected profile, OpenCLI uses it automatically" — and making doctor hang waiting for a dead profile. First-principles fix: distinguish REQUIREMENT from PREFERENCE end to end and let the component that knows live state arbitrate. - profile.ts resolves a ProfileSelection tagged 'explicit' (--profile arg, OPENCLI_PROFILE env — fail loud when offline) or 'preferred' (config default); profileRouteParams() maps it to the wire fields. - New wire field preferredContextId (both protocol copies); contextId keeps its strict semantics. Old daemons ignore the new field, which degrades to the no-contextId single-profile auto-use — exactly the documented behavior. - The daemon arbitrates via a pure, tested resolveProfileRoute(): requested → strict; preferred → use when connected, fall back to the only connected profile when not (logged once per stale id), ask with a stale-default hint when multiple are connected. - bridge/ensure only pin readiness to a profile for explicit requirements — a stale preference no longer makes connect()/doctor wait for a dead profile. - doctor surfaces the stale default with the fallback status and the recovery command (opencli profile use). * fix(cli): key saved-tab scope by the selected profile in getPageScope From adversarial review: getBrowserPage computed the target scope from the profile SELECTION (explicit or preferred), but getPageScope read only the explicit Page.contextId — so with a config-default profile the remembered tab was saved under "" and looked up under ":", silently forgetting the selection on every command. Both sites now key the scope by the selected profile. --- extension/src/protocol.ts | 8 +++- src/browser/bridge.ts | 16 +++++--- src/browser/daemon-client.test.ts | 32 +++++++++++++++- src/browser/daemon-client.ts | 23 ++++++++++-- src/browser/page.ts | 6 ++- src/browser/profile.test.ts | 57 ++++++++++++++++++++++++++++ src/browser/profile.ts | 44 +++++++++++++++++++--- src/cli.ts | 32 ++++++++++------ src/daemon-utils.ts | 62 +++++++++++++++++++++++++++++++ src/daemon.test.ts | 43 +++++++++++++++++++++ src/daemon.ts | 51 ++++++++++++++----------- src/doctor.test.ts | 32 ++++++++++++++++ src/doctor.ts | 18 +++++++++ src/execution.ts | 10 +++-- src/runtime.ts | 5 ++- 15 files changed, 382 insertions(+), 57 deletions(-) create mode 100644 src/browser/profile.test.ts diff --git a/extension/src/protocol.ts b/extension/src/protocol.ts index 240ed2fb8..e4e00e615 100644 --- a/extension/src/protocol.ts +++ b/extension/src/protocol.ts @@ -75,8 +75,14 @@ export interface Command { idleTimeout?: number; /** Frame index for cross-frame operations (0-based, from 'frames' action) */ frameIndex?: number; - /** Browser profile/context selected by the CLI. Used by the daemon for routing. */ + /** Browser profile/context REQUIRED by the CLI (--profile / env). Used by the daemon for strict routing. */ contextId?: string; + /** + * Browser profile/context PREFERRED by the CLI (persisted config default). + * Daemon-only routing hint: used when connected, otherwise the daemon falls + * back to the only connected profile. The extension ignores this field. + */ + preferredContextId?: string; /** * Daemon-side command timeout in seconds, set by the CLI transport. The * extension derives its CDP deadline from this so it fails just before the diff --git a/src/browser/bridge.ts b/src/browser/bridge.ts index c5654a0b0..5eeca0502 100644 --- a/src/browser/bridge.ts +++ b/src/browser/bridge.ts @@ -6,7 +6,7 @@ import type { ChildProcess } from 'node:child_process'; import type { IPage } from '../types.js'; import type { IBrowserFactory } from '../runtime.js'; import { Page } from './page.js'; -import { resolveProfileContextId } from './profile.js'; +import { profileRouteParams, resolveProfileSelection } from './profile.js'; import { ensureBrowserBridgeReady } from './daemon-lifecycle.js'; const DAEMON_SPAWN_TIMEOUT = 10000; // 10s to wait for daemon + extension @@ -25,7 +25,7 @@ export class BrowserBridge implements IBrowserFactory { return this._state; } - async connect(opts: { timeout?: number; session?: string; idleTimeout?: number; contextId?: string; windowMode?: 'foreground' | 'background'; surface?: 'browser' | 'adapter'; siteSession?: 'ephemeral' | 'persistent' } = {}): Promise { + async connect(opts: { timeout?: number; session?: string; idleTimeout?: number; contextId?: string; preferredContextId?: string; windowMode?: 'foreground' | 'background'; surface?: 'browser' | 'adapter'; siteSession?: 'ephemeral' | 'persistent' } = {}): Promise { if (this._state === 'connected' && this._page) return this._page; if (this._state === 'connecting') throw new Error('Already connecting'); if (this._state === 'closing') throw new Error('Session is closing'); @@ -34,10 +34,16 @@ export class BrowserBridge implements IBrowserFactory { this._state = 'connecting'; try { - const contextId = opts.contextId ?? resolveProfileContextId(); - await this._ensureDaemon(opts.timeout, contextId); + // Requirement vs preference: only an explicit contextId pins daemon + // readiness and command routing to a specific profile. A preferred one + // (config default) is arbitrated by the daemon against live connections, + // so a stale default cannot make connect() wait for a dead profile. + const routing = opts.contextId || opts.preferredContextId + ? { contextId: opts.contextId, preferredContextId: opts.preferredContextId } + : profileRouteParams(resolveProfileSelection()); + await this._ensureDaemon(opts.timeout, routing.contextId); if (!opts.session?.trim()) throw new Error('Browser session is required'); - this._page = new Page(opts.session.trim(), opts.idleTimeout, contextId, opts.windowMode, opts.surface, opts.siteSession); + this._page = new Page(opts.session.trim(), opts.idleTimeout, routing.contextId, opts.windowMode, opts.surface, opts.siteSession, routing.preferredContextId); this._state = 'connected'; return this._page; } catch (err) { diff --git a/src/browser/daemon-client.test.ts b/src/browser/daemon-client.test.ts index 0999a22eb..50d4edd94 100644 --- a/src/browser/daemon-client.test.ts +++ b/src/browser/daemon-client.test.ts @@ -215,7 +215,7 @@ describe('daemon-client', () => { expect(ids[0]).not.toBe(ids[1]); }); - it('sendCommand forwards OPENCLI_PROFILE as command contextId', async () => { + it('sendCommand forwards OPENCLI_PROFILE as a hard contextId requirement', async () => { vi.stubEnv('OPENCLI_PROFILE', 'work'); vi.spyOn(Date, 'now').mockReturnValue(1_763_000_000_000); vi.mocked(fetch).mockResolvedValue({ @@ -225,8 +225,36 @@ describe('daemon-client', () => { await sendCommand('exec', { code: '1 + 1' }); - const body = JSON.parse(String(vi.mocked(fetch).mock.calls[0][1]?.body)) as { contextId?: string }; + const body = JSON.parse(String(vi.mocked(fetch).mock.calls[0][1]?.body)) as { contextId?: string; preferredContextId?: string }; expect(body.contextId).toBe('work'); + expect(body.preferredContextId).toBeUndefined(); + }); + + it('sendCommand forwards the config default as a soft preferredContextId, not a requirement', async () => { + const fs = await import('node:fs'); + const os = await import('node:os'); + const path = await import('node:path'); + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-dc-profile-')); + fs.writeFileSync( + path.join(configDir, 'browser-profiles.json'), + JSON.stringify({ version: 1, aliases: {}, defaultContextId: 'zvypsyje' }), + ); + vi.stubEnv('OPENCLI_CONFIG_DIR', configDir); + vi.stubEnv('OPENCLI_PROFILE', ''); + try { + vi.mocked(fetch).mockResolvedValue({ + status: 200, + json: () => Promise.resolve({ id: 'server', ok: true, data: 'ok' }), + } as Response); + + await sendCommand('exec', { code: '1 + 1' }); + + const body = JSON.parse(String(vi.mocked(fetch).mock.calls[0][1]?.body)) as { contextId?: string; preferredContextId?: string }; + expect(body.preferredContextId).toBe('zvypsyje'); + expect(body.contextId).toBeUndefined(); + } finally { + fs.rmSync(configDir, { recursive: true, force: true }); + } }); it('sendCommand uses explicit windowMode before OPENCLI_WINDOW env fallback', async () => { diff --git a/src/browser/daemon-client.ts b/src/browser/daemon-client.ts index f01643603..220ee8afe 100644 --- a/src/browser/daemon-client.ts +++ b/src/browser/daemon-client.ts @@ -8,7 +8,7 @@ import { sleep } from '../utils.js'; import { BrowserConnectError } from '../errors.js'; import { COMMAND_RESULT_UNKNOWN_CODE, COMMAND_RESULT_UNKNOWN_HINT } from '../daemon-utils.js'; import { classifyBrowserError } from './errors.js'; -import { resolveProfileContextId } from './profile.js'; +import { profileRouteParams, resolveProfileSelection } from './profile.js'; import { DEFAULT_BROWSER_CONNECT_TIMEOUT } from './config.js'; import { ensureBrowserBridgeReady } from './daemon-lifecycle.js'; import { isPreDispatchError } from './bridge-readiness.js'; @@ -156,8 +156,15 @@ export interface DaemonCommand { idleTimeout?: number; /** Frame index for cross-frame operations (0-based, from 'frames' action) */ frameIndex?: number; - /** Browser profile/context to route the command to. */ + /** Browser profile/context REQUIRED for this command (--profile / OPENCLI_PROFILE). Fails loud when offline. */ contextId?: string; + /** + * Browser profile/context PREFERRED for this command (persisted config + * default). The daemon uses it when connected, and falls back to the only + * connected profile when it is not — a stale default must never veto live + * reality. Mutually exclusive with `contextId`. + */ + preferredContextId?: string; /** * Daemon-side command timeout in seconds. Set by the transport layer from * the effective command deadline; kept for older daemons — new code prefers @@ -231,7 +238,13 @@ async function sendCommandRaw( const envWindowMode = rawWindowMode === 'foreground' || rawWindowMode === 'background' ? rawWindowMode : undefined; - const contextId = params.contextId ?? resolveProfileContextId(); + // Requirement vs preference: an explicit contextId routes strictly; a + // preferred one is arbitrated by the daemon against live connections. + const routing = params.contextId || params.preferredContextId + ? { contextId: params.contextId, preferredContextId: params.preferredContextId } + : profileRouteParams(resolveProfileSelection()); + const contextId = routing.contextId; + const preferredContextId = routing.preferredContextId; const windowMode = params.windowMode ?? envWindowMode; let id = generateId(); @@ -245,6 +258,9 @@ async function sendCommandRaw( const remainingSeconds = Math.ceil((deadlineAt - Date.now()) / 1000); const ready = await ensureBrowserBridgeReady({ timeoutSeconds: Math.max(1, Math.min(DEFAULT_BROWSER_CONNECT_TIMEOUT, remainingSeconds)), + // Only an explicit requirement pins readiness to a specific profile — + // waiting for a stale preferred profile to come back would hang the + // ensure path even though the daemon can already serve the command. contextId, verbose: false, }); @@ -267,6 +283,7 @@ async function sendCommandRaw( timeout: timeoutSeconds, deadlineAt, ...(contextId && { contextId }), + ...(preferredContextId && { preferredContextId }), ...(windowMode && { windowMode }), }; try { diff --git a/src/browser/page.ts b/src/browser/page.ts index ae749f72e..7994ff30b 100644 --- a/src/browser/page.ts +++ b/src/browser/page.ts @@ -49,6 +49,8 @@ export class Page extends BasePage { private readonly windowMode?: 'foreground' | 'background', private readonly surface: 'browser' | 'adapter' = 'browser', private readonly siteSession?: 'ephemeral' | 'persistent', + /** Soft profile preference (config default) — daemon arbitrates; see profileRouteParams. */ + public readonly preferredContextId?: string, ) { super(); this._idleTimeout = idleTimeout; @@ -60,11 +62,12 @@ export class Page extends BasePage { private _networkCaptureWarned = false; /** Helper: spread session into command params */ - private _sessionOpts(): { session: string; surface: 'browser' | 'adapter'; idleTimeout?: number; contextId?: string; windowMode?: 'foreground' | 'background'; siteSession?: 'ephemeral' | 'persistent' } { + private _sessionOpts(): { session: string; surface: 'browser' | 'adapter'; idleTimeout?: number; contextId?: string; preferredContextId?: string; windowMode?: 'foreground' | 'background'; siteSession?: 'ephemeral' | 'persistent' } { return { session: this.session, surface: this.surface, ...(this.contextId && { contextId: this.contextId }), + ...(this.preferredContextId && { preferredContextId: this.preferredContextId }), ...(this._idleTimeout != null && { idleTimeout: this._idleTimeout }), ...(this.windowMode && { windowMode: this.windowMode }), ...(this.siteSession && { siteSession: this.siteSession }), @@ -77,6 +80,7 @@ export class Page extends BasePage { session: this.session, surface: this.surface, ...(this.contextId && { contextId: this.contextId }), + ...(this.preferredContextId && { preferredContextId: this.preferredContextId }), ...(this._page !== undefined && { page: this._page }), ...(this._idleTimeout != null && { idleTimeout: this._idleTimeout }), ...(this.windowMode && { windowMode: this.windowMode }), diff --git a/src/browser/profile.test.ts b/src/browser/profile.test.ts new file mode 100644 index 000000000..c030af331 --- /dev/null +++ b/src/browser/profile.test.ts @@ -0,0 +1,57 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { profileRouteParams, resolveProfileSelection } from './profile.js'; + +describe('profile selection (requirement vs preference)', () => { + let configDir: string; + + beforeEach(() => { + configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-profile-test-')); + vi.stubEnv('OPENCLI_CONFIG_DIR', configDir); + vi.stubEnv('OPENCLI_PROFILE', ''); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + fs.rmSync(configDir, { recursive: true, force: true }); + }); + + function writeConfig(config: object): void { + fs.writeFileSync(path.join(configDir, 'browser-profiles.json'), JSON.stringify(config)); + } + + it('tags an explicit --profile argument as explicit and resolves aliases', () => { + writeConfig({ version: 1, aliases: { work: 'zvypsyje' } }); + expect(resolveProfileSelection('work')).toEqual({ contextId: 'zvypsyje', source: 'explicit' }); + }); + + it('tags OPENCLI_PROFILE env as explicit', () => { + vi.stubEnv('OPENCLI_PROFILE', 'pavmrekj'); + expect(resolveProfileSelection()).toEqual({ contextId: 'pavmrekj', source: 'explicit' }); + }); + + it('tags the persisted config default as preferred, not explicit', () => { + writeConfig({ version: 1, aliases: {}, defaultContextId: 'zvypsyje' }); + expect(resolveProfileSelection()).toEqual({ contextId: 'zvypsyje', source: 'preferred' }); + }); + + it('explicit argument beats env beats config default', () => { + vi.stubEnv('OPENCLI_PROFILE', 'from-env'); + writeConfig({ version: 1, aliases: {}, defaultContextId: 'from-config' }); + expect(resolveProfileSelection('from-arg')).toEqual({ contextId: 'from-arg', source: 'explicit' }); + expect(resolveProfileSelection()).toEqual({ contextId: 'from-env', source: 'explicit' }); + }); + + it('returns undefined with no argument, env, or config default', () => { + expect(resolveProfileSelection()).toBeUndefined(); + }); + + it('profileRouteParams maps explicit → contextId and preferred → preferredContextId', () => { + expect(profileRouteParams({ contextId: 'a', source: 'explicit' })).toEqual({ contextId: 'a' }); + expect(profileRouteParams({ contextId: 'b', source: 'preferred' })).toEqual({ preferredContextId: 'b' }); + expect(profileRouteParams(undefined)).toEqual({}); + }); +}); diff --git a/src/browser/profile.ts b/src/browser/profile.ts index 1af3adbfa..46fa68738 100644 --- a/src/browser/profile.ts +++ b/src/browser/profile.ts @@ -53,13 +53,45 @@ export function saveProfileConfig(config: ProfileConfig): void { fs.writeFileSync(target, JSON.stringify(config, null, 2) + '\n', 'utf-8'); } -export function resolveProfileContextId(profile?: string): string | undefined { +export type ProfileSelection = { + contextId: string; + /** + * 'explicit' — the user demanded this profile right now (--profile argument + * or OPENCLI_PROFILE env): route strictly and fail loud if it is offline. + * 'preferred' — the persisted default from browser-profiles.json. A default + * is a preference, not a requirement: its lifetime routinely exceeds the + * extension instance it names (reinstalling the extension regenerates the + * contextId), so the daemon may fall back to the only connected profile + * when the preferred one is offline. + */ + source: 'explicit' | 'preferred'; +}; + +export function resolveProfileSelection(profile?: string): ProfileSelection | undefined { const config = loadProfileConfig(); - const requested = normalizeContextId(profile) - ?? normalizeContextId(process.env.OPENCLI_PROFILE) - ?? normalizeContextId(config.defaultContextId); - if (!requested) return undefined; - return config.aliases[requested] ?? requested; + const explicit = normalizeContextId(profile) ?? normalizeContextId(process.env.OPENCLI_PROFILE); + if (explicit) return { contextId: config.aliases[explicit] ?? explicit, source: 'explicit' }; + const preferred = normalizeContextId(config.defaultContextId); + if (preferred) return { contextId: config.aliases[preferred] ?? preferred, source: 'preferred' }; + return undefined; +} + +/** + * Map a selection to wire/connect routing params. Exactly one of the two + * fields is set — `contextId` is a hard requirement, `preferredContextId` + * lets the daemon arbitrate against live connections. + */ +export function profileRouteParams( + selection: ProfileSelection | undefined, +): { contextId?: string; preferredContextId?: string } { + if (!selection) return {}; + return selection.source === 'explicit' + ? { contextId: selection.contextId } + : { preferredContextId: selection.contextId }; +} + +export function resolveProfileContextId(profile?: string): string | undefined { + return resolveProfileSelection(profile)?.contextId; } export function aliasForContextId(config: ProfileConfig, contextId: string): string | undefined { diff --git a/src/cli.ts b/src/cli.ts index 87a374905..a57e807dd 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -37,7 +37,7 @@ import { daemonRestart, daemonStatus, daemonStop } from './commands/daemon.js'; import { log } from './logger.js'; import { bindTab, BrowserCommandError, sendCommand } from './browser/daemon-client.js'; import { fetchDaemonStatus } from './browser/daemon-transport.js'; -import { aliasForContextId, loadProfileConfig, renameProfile, resolveProfileContextId, setDefaultProfile } from './browser/profile.js'; +import { aliasForContextId, loadProfileConfig, profileRouteParams, renameProfile, resolveProfileSelection, setDefaultProfile, type ProfileSelection } from './browser/profile.js'; import { formatDaemonVersion, isDaemonStale } from './browser/daemon-version.js'; import { DEFAULT_BROWSER_CONNECT_TIMEOUT } from './browser/config.js'; import type { BrowserDownloadWaitResult, IPage, ScreenshotOptions } from './types.js'; @@ -519,7 +519,7 @@ async function resolveStoredBrowserTarget(page: import('./types.js').IPage, scop async function getBrowserPage( session: string, targetPage?: string, - contextId?: string, + profileSelection?: ProfileSelection, opts: { windowMode?: BrowserWindowMode } = {}, ): Promise { const { BrowserBridge } = await import('./browser/index.js'); @@ -531,11 +531,11 @@ async function getBrowserPage( timeout: DEFAULT_BROWSER_CONNECT_TIMEOUT, session, surface: 'browser', - ...(contextId && { contextId }), + ...profileRouteParams(profileSelection), ...(idleTimeout && idleTimeout > 0 && { idleTimeout }), windowMode: opts.windowMode ?? getBrowserWindowMode(undefined, 'foreground'), }); - const targetScope = getBrowserScope(session, contextId); + const targetScope = getBrowserScope(session, profileSelection?.contextId); const resolvedTargetPage = targetPage ? await resolveBrowserTargetInSession(page, targetPage, { scope: targetScope, source: 'explicit' }) : await resolveStoredBrowserTarget(page, targetScope); @@ -591,9 +591,9 @@ function getBrowserSession(command?: Command): string { throw new Error(' is a required positional argument: opencli browser '); } -function getBrowserContextId(command?: Command): string | undefined { +function getBrowserProfileSelection(command?: Command): ProfileSelection | undefined { const raw = getCommandOption(command, 'profile'); - return resolveProfileContextId(typeof raw === 'string' && raw.trim() ? raw.trim() : undefined); + return resolveProfileSelection(typeof raw === 'string' && raw.trim() ? raw.trim() : undefined); } function getPageSession(page: import('./types.js').IPage): string { @@ -603,8 +603,15 @@ function getPageSession(page: import('./types.js').IPage): string { } function getPageScope(page: import('./types.js').IPage): string { - const contextId = (page as unknown as { contextId?: unknown }).contextId; - return getBrowserScope(getPageSession(page), typeof contextId === 'string' && contextId.trim() ? contextId.trim() : undefined); + // Scope is keyed by the SELECTED profile (explicit or preferred), matching + // getBrowserPage's targetScope — reading only the explicit contextId would + // save and look up the remembered tab under different keys whenever the + // profile came from the config default. + const { contextId, preferredContextId } = page as unknown as { contextId?: unknown; preferredContextId?: unknown }; + const selected = typeof contextId === 'string' && contextId.trim() + ? contextId.trim() + : (typeof preferredContextId === 'string' && preferredContextId.trim() ? preferredContextId.trim() : undefined); + return getBrowserScope(getPageSession(page), selected); } type SnapshotSource = 'dom' | 'ax'; @@ -1005,9 +1012,9 @@ Examples: const command = args.at(-1) instanceof Command ? args.at(-1) as Command : undefined; const targetPage = getBrowserTargetId(command); const session = getBrowserSession(command); - const contextId = getBrowserContextId(command); + const profileSelection = getBrowserProfileSelection(command); const windowMode = getBrowserWindowMode(command, 'foreground'); - page = await getBrowserPage(session, targetPage, contextId, { windowMode }); + page = await getBrowserPage(session, targetPage, profileSelection, { windowMode }); await fn(page, ...args); } catch (err) { if (err instanceof BrowserConnectError) { @@ -1050,11 +1057,12 @@ Examples: return async (optsOrCommand: unknown, maybeCommand?: Command) => { const command = optsOrCommand instanceof Command ? optsOrCommand : maybeCommand; const session = getBrowserSession(command); - const contextId = getBrowserContextId(command); + const profileSelection = getBrowserProfileSelection(command); + const contextId = profileSelection?.contextId; try { const { BrowserBridge } = await import('./browser/index.js'); const bridge = new BrowserBridge(); - await bridge.connect({ timeout: DEFAULT_BROWSER_CONNECT_TIMEOUT, session, surface: 'browser', ...(contextId && { contextId }) }); + await bridge.connect({ timeout: DEFAULT_BROWSER_CONNECT_TIMEOUT, session, surface: 'browser', ...profileRouteParams(profileSelection) }); await fn({ session, contextId }); } catch (err) { if (err instanceof BrowserCommandError) { diff --git a/src/daemon-utils.ts b/src/daemon-utils.ts index 1837edbc8..dbfa6fb91 100644 --- a/src/daemon-utils.ts +++ b/src/daemon-utils.ts @@ -35,6 +35,68 @@ export function buildExtensionDisconnectFailure(input: { return buildCommandDispatchFailure(input.contextId); } +export type ProfileRouteInput = { + /** Hard requirement (--profile / OPENCLI_PROFILE) — never falls back. */ + requestedContextId?: string; + /** Soft preference (config defaultContextId) — arbitrated against live state. */ + preferredContextId?: string; + /** contextIds of currently connected extension profiles. */ + connectedContextIds: string[]; +}; + +export type ProfileRouteResult = + | { ok: true; contextId: string; /** set when a stale preference was overridden by the only live profile */ fallbackFrom?: string } + | { ok: false; errorCode: 'profile_disconnected' | 'profile_required' | 'extension_not_connected'; error: string; errorHint?: string }; + +/** + * Decide which extension profile serves a command. The arbiter lives with the + * daemon because the daemon is the only component that knows live connections: + * a REQUIREMENT fails loud when offline, a PREFERENCE falls back to the only + * connected profile — which keeps the documented promise "with only one + * connected profile, OpenCLI uses it automatically" true even when a persisted + * default outlives the extension instance it names. + */ +export function resolveProfileRoute(input: ProfileRouteInput): ProfileRouteResult { + const requested = input.requestedContextId?.trim() || undefined; + const preferred = input.preferredContextId?.trim() || undefined; + const connected = input.connectedContextIds; + + if (requested) { + if (connected.includes(requested)) return { ok: true, contextId: requested }; + return { + ok: false, + errorCode: 'profile_disconnected', + error: `Browser profile "${requested}" is not connected.`, + errorHint: PROFILE_DISCONNECTED_HINT, + }; + } + + if (preferred && connected.includes(preferred)) return { ok: true, contextId: preferred }; + + if (connected.length === 1) { + return { ok: true, contextId: connected[0], ...(preferred ? { fallbackFrom: preferred } : {}) }; + } + + if (connected.length > 1) { + return { + ok: false, + errorCode: 'profile_required', + error: preferred + ? `Default browser profile "${preferred}" is not connected and multiple profiles are available; choose one with --profile.` + : 'Multiple Browser Bridge profiles are connected; choose one with --profile.', + errorHint: preferred + ? 'Run opencli profile list, then update the stale default with opencli profile use or pass --profile .' + : 'Run opencli profile list, then use opencli --profile ... or opencli profile use .', + }; + } + + return { + ok: false, + errorCode: 'extension_not_connected', + error: 'Extension not connected. Please install the opencli Browser Bridge extension.', + }; +} + export function buildCommandTimeoutFailure(action: string, timeoutMs: number): DaemonFailureContract { return { message: `Browser ${action} command timed out after ${Math.round(timeoutMs / 1000)}s; it may still complete in the browser.`, diff --git a/src/daemon.test.ts b/src/daemon.test.ts index 9272ed8cc..4ca1fd9c1 100644 --- a/src/daemon.test.ts +++ b/src/daemon.test.ts @@ -8,6 +8,7 @@ import { buildExtensionDisconnectFailure, commandResultUnknownMessage, getResponseCorsHeaders, + resolveProfileRoute, } from './daemon-utils.js'; describe('getResponseCorsHeaders', () => { @@ -75,6 +76,48 @@ describe('daemon command dispatch', () => { }); }); + it('routes a REQUESTED profile strictly — fails loud when offline, even with one live profile', () => { + expect(resolveProfileRoute({ requestedContextId: 'zvypsyje', connectedContextIds: ['pavmrekj'] })).toMatchObject({ + ok: false, + errorCode: 'profile_disconnected', + }); + expect(resolveProfileRoute({ requestedContextId: 'pavmrekj', connectedContextIds: ['pavmrekj'] })).toEqual({ + ok: true, + contextId: 'pavmrekj', + }); + }); + + it('uses a PREFERRED profile when connected', () => { + expect(resolveProfileRoute({ preferredContextId: 'zvypsyje', connectedContextIds: ['zvypsyje', 'other'] })).toEqual({ + ok: true, + contextId: 'zvypsyje', + }); + }); + + it('falls back to the only connected profile when the preferred one is stale', () => { + expect(resolveProfileRoute({ preferredContextId: 'zvypsyje', connectedContextIds: ['pavmrekj'] })).toEqual({ + ok: true, + contextId: 'pavmrekj', + fallbackFrom: 'zvypsyje', + }); + }); + + it('asks the user to choose when the preferred profile is stale and multiple are connected', () => { + const route = resolveProfileRoute({ preferredContextId: 'zvypsyje', connectedContextIds: ['a', 'b'] }); + expect(route).toMatchObject({ ok: false, errorCode: 'profile_required' }); + if (!route.ok) { + expect(route.error).toContain('zvypsyje'); + expect(route.errorHint).toContain('opencli profile use'); + } + }); + + it('keeps the legacy no-selection behavior: single auto-use, multiple ask, none error', () => { + expect(resolveProfileRoute({ connectedContextIds: ['only'] })).toEqual({ ok: true, contextId: 'only' }); + expect(resolveProfileRoute({ connectedContextIds: ['a', 'b'] })).toMatchObject({ ok: false, errorCode: 'profile_required' }); + expect(resolveProfileRoute({ connectedContextIds: [] })).toMatchObject({ ok: false, errorCode: 'extension_not_connected' }); + expect(resolveProfileRoute({ preferredContextId: 'gone', connectedContextIds: [] })).toMatchObject({ ok: false, errorCode: 'extension_not_connected' }); + }); + it('classifies daemon-side command timeouts as command_result_unknown with a 408', () => { expect(buildCommandTimeoutFailure('navigate', 120_000)).toEqual({ message: 'Browser navigate command timed out after 120s; it may still complete in the browser.', diff --git a/src/daemon.ts b/src/daemon.ts index a4ac0fa25..6dc5fe8f0 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -29,10 +29,12 @@ import { PKG_VERSION } from './version.js'; import { DEFAULT_CONTEXT_ID } from './browser/profile.js'; import { recordExtensionVersion } from './update-check.js'; import { + PROFILE_DISCONNECTED_HINT, buildCommandDispatchFailure, buildCommandTimeoutFailure, buildExtensionDisconnectFailure, getResponseCorsHeaders, + resolveProfileRoute, } from './daemon-utils.js'; const PORT = DEFAULT_DAEMON_PORT; @@ -106,35 +108,37 @@ function activeProfiles(): ExtensionProfileConnection[] { return [...extensionProfiles.values()].filter((entry) => entry.ws.readyState === WebSocket.OPEN); } -function resolveExtensionConnection(contextId?: string): { +/** Stale defaults we already warned about — one log line per daemon lifetime. */ +const staleDefaultWarned = new Set(); + +function resolveExtensionConnection(contextId?: string, preferredContextId?: string): { connection?: ExtensionProfileConnection; errorCode?: 'extension_not_connected' | 'profile_required' | 'profile_disconnected'; error?: string; errorHint?: string; } { - const requestedContextId = typeof contextId === 'string' && contextId.trim() ? contextId.trim() : undefined; - if (requestedContextId) { - const connection = extensionProfiles.get(requestedContextId); - if (connection?.ws.readyState === WebSocket.OPEN) return { connection }; - return { - errorCode: 'profile_disconnected', - error: `Browser profile "${requestedContextId}" is not connected.`, - errorHint: 'Open that Chrome profile and make sure the OpenCLI extension is enabled, or choose another profile with opencli profile use .', - }; + const route = resolveProfileRoute({ + requestedContextId: typeof contextId === 'string' ? contextId : undefined, + preferredContextId: typeof preferredContextId === 'string' ? preferredContextId : undefined, + connectedContextIds: activeProfiles().map((entry) => entry.contextId), + }); + if (!route.ok) { + return { errorCode: route.errorCode, error: route.error, ...(route.errorHint ? { errorHint: route.errorHint } : {}) }; } - - const connected = activeProfiles(); - if (connected.length === 1) return { connection: connected[0] }; - if (connected.length > 1) { - return { - errorCode: 'profile_required', - error: 'Multiple Browser Bridge profiles are connected; choose one with --profile.', - errorHint: 'Run opencli profile list, then use opencli --profile ... or opencli profile use .', - }; + if (route.fallbackFrom && !staleDefaultWarned.has(route.fallbackFrom)) { + staleDefaultWarned.add(route.fallbackFrom); + log.warn( + `[daemon] Default profile "${route.fallbackFrom}" is not connected; ` + + `using the only connected profile "${route.contextId}". Update the default with: opencli profile use `, + ); } + const connection = extensionProfiles.get(route.contextId); + if (connection?.ws.readyState === WebSocket.OPEN) return { connection }; + // Connection raced away between arbitration and lookup. return { - errorCode: 'extension_not_connected', - error: 'Extension not connected. Please install the opencli Browser Bridge extension.', + errorCode: 'profile_disconnected', + error: `Browser profile "${route.contextId}" is not connected.`, + errorHint: PROFILE_DISCONNECTED_HINT, }; } @@ -319,7 +323,10 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise return; } - const route = resolveExtensionConnection(typeof body.contextId === 'string' ? body.contextId : undefined); + const route = resolveExtensionConnection( + typeof body.contextId === 'string' ? body.contextId : undefined, + typeof body.preferredContextId === 'string' ? body.preferredContextId : undefined, + ); if (!route.connection) { jsonResponse(res, route.errorCode === 'profile_required' ? 409 : 503, { id: body.id, diff --git a/src/doctor.test.ts b/src/doctor.test.ts index 67d336029..5ff160a5e 100644 --- a/src/doctor.test.ts +++ b/src/doctor.test.ts @@ -181,6 +181,38 @@ describe('doctor report rendering', () => { ])); }); + it('reports a stale default profile when it is not among the connected profiles', async () => { + const fs = await import('node:fs'); + const os = await import('node:os'); + const path = await import('node:path'); + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-doctor-profile-')); + fs.writeFileSync( + path.join(configDir, 'browser-profiles.json'), + JSON.stringify({ version: 1, aliases: { work: 'zvypsyje' }, defaultContextId: 'zvypsyje' }), + ); + vi.stubEnv('OPENCLI_CONFIG_DIR', configDir); + try { + mockGetDaemonHealth.mockResolvedValueOnce({ + state: 'ready', + status: { + extensionConnected: true, + extensionVersion: '1.0.22', + profiles: [{ contextId: 'pavmrekj', extensionConnected: true }], + }, + }); + + const report = await runBrowserDoctor(); + + expect(report.issues).toEqual(expect.arrayContaining([ + expect.stringContaining('Default browser profile is stale: work (zvypsyje)'), + ])); + expect(report.issues.join('\n')).toContain('fall back to the only connected profile: pavmrekj'); + } finally { + vi.unstubAllEnvs(); + fs.rmSync(configDir, { recursive: true, force: true }); + } + }); + it('reports flapping when live check succeeds but final status shows extension disconnected', async () => { mockGetDaemonHealth.mockResolvedValueOnce({ state: 'no-extension', status: { extensionConnected: false } }); diff --git a/src/doctor.ts b/src/doctor.ts index fdfc32dc2..53c6198a0 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -171,6 +171,24 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise p.contextId === staleDefault)) { + const alias = aliasForContextId(profileConfig, staleDefault); + const label = alias ? `${alias} (${staleDefault})` : staleDefault; + const fallbackNote = profiles.length === 1 + ? `Commands currently fall back to the only connected profile: ${profiles[0].contextId}.` + : 'Multiple profiles are connected, so commands will ask you to choose.'; + issues.push( + `Default browser profile is stale: ${label} is not connected (the extension instance it names no longer exists).\n` + + ` ${fallbackNote}\n` + + ' Refresh it with: opencli profile list, then opencli profile use .', + ); + } const extensionCompatRange = health.status?.extensionCompatRange; if (extensionVersion && opts.cliVersion && extensionCompatRange) { if (!satisfiesRange(opts.cliVersion, extensionCompatRange)) { diff --git a/src/execution.ts b/src/execution.ts index cec303930..edf2c4bbd 100644 --- a/src/execution.ts +++ b/src/execution.ts @@ -29,7 +29,7 @@ import { executePipeline } from './pipeline/index.js'; import { adapterLoadError, ArgumentError, CommandExecutionError, attachTraceReceipt, getErrorMessage } from './errors.js'; import { shouldUseBrowserSession } from './capabilityRouting.js'; import { getBrowserFactory, browserSession, runWithTimeout, DEFAULT_BROWSER_COMMAND_TIMEOUT, type BrowserWindowMode } from './runtime.js'; -import { resolveProfileContextId } from './browser/profile.js'; +import { profileRouteParams, resolveProfileSelection } from './browser/profile.js'; import { setDaemonCommandTimeoutSeconds } from './browser/daemon-client.js'; import { emitHook, type HookContext } from './hooks.js'; import { log } from './logger.js'; @@ -255,7 +255,11 @@ export async function executeCommand( } const BrowserFactory = getBrowserFactory(cmd.site); - const contextId = resolveProfileContextId(opts.profile); + // Requirement vs preference: --profile / OPENCLI_PROFILE route strictly; + // the config default is a soft preference the daemon arbitrates. + const profileSelection = resolveProfileSelection(opts.profile); + const profileRouting = profileRouteParams(profileSelection); + const contextId = profileSelection?.contextId; const internal = cmd as InternalCliCommand; const siteSession = resolveSiteSession(cmd, opts.siteSession); const session = resolveAdapterBrowserSession(cmd, siteSession); @@ -375,7 +379,7 @@ export async function executeCommand( if (!keepTab) await page.closeWindow?.().catch(() => {}); throw err; } - }, { session, cdpEndpoint, contextId, windowMode, surface: 'adapter', siteSession }); + }, { session, cdpEndpoint, ...profileRouting, windowMode, surface: 'adapter', siteSession }); } else { // Non-browser commands: enforce a timeout only when the command exposes // a `--timeout` arg (and the resolved value is positive). Without that diff --git a/src/runtime.ts b/src/runtime.ts index b0ec31953..1e1c229f1 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -54,14 +54,14 @@ export function withTimeoutMs( /** Interface for browser factory (BrowserBridge or test mocks) */ export interface IBrowserFactory { - connect(opts?: { timeout?: number; session?: string; cdpEndpoint?: string; contextId?: string; idleTimeout?: number; windowMode?: BrowserWindowMode; surface?: BrowserSurface; siteSession?: 'ephemeral' | 'persistent' }): Promise; + connect(opts?: { timeout?: number; session?: string; cdpEndpoint?: string; contextId?: string; preferredContextId?: string; idleTimeout?: number; windowMode?: BrowserWindowMode; surface?: BrowserSurface; siteSession?: 'ephemeral' | 'persistent' }): Promise; close(): Promise; } export async function browserSession( BrowserFactory: new () => IBrowserFactory, fn: (page: IPage) => Promise, - opts: { session?: string; cdpEndpoint?: string; contextId?: string; idleTimeout?: number; windowMode?: BrowserWindowMode; surface?: BrowserSurface; siteSession?: 'ephemeral' | 'persistent' } = {}, + opts: { session?: string; cdpEndpoint?: string; contextId?: string; preferredContextId?: string; idleTimeout?: number; windowMode?: BrowserWindowMode; surface?: BrowserSurface; siteSession?: 'ephemeral' | 'persistent' } = {}, ): Promise { const browser = new BrowserFactory(); try { @@ -70,6 +70,7 @@ export async function browserSession( session: opts.session, cdpEndpoint: opts.cdpEndpoint, contextId: opts.contextId, + preferredContextId: opts.preferredContextId, idleTimeout: opts.idleTimeout, windowMode: opts.windowMode, surface: opts.surface, From 338dc794a7546c25f3b4498064c9ef5ca1275594 Mon Sep 17 00:00:00 2001 From: jakevin Date: Sat, 4 Jul 2026 00:32:46 +0800 Subject: [PATCH 20/27] test(e2e): stabilize headed Chrome gate Use stable Chrome for Testing for headed E2E and keep e2e child-process timeouts below Vitest's framework timeout. --- .github/actions/setup-chrome/action.yml | 4 +++- tests/e2e/browser-ax-chrome.test.ts | 4 ++-- tests/e2e/helpers.ts | 5 ++++- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/.github/actions/setup-chrome/action.yml b/.github/actions/setup-chrome/action.yml index ddad034bf..d0c3abd75 100644 --- a/.github/actions/setup-chrome/action.yml +++ b/.github/actions/setup-chrome/action.yml @@ -13,7 +13,9 @@ runs: uses: browser-actions/setup-chrome@v2 id: setup-chrome with: - chrome-version: latest + # Stable Chrome for Testing keeps headed E2E on a released browser. + # `latest` pulls Chromium snapshots, which can break extension startup. + chrome-version: stable - name: Verify Chrome installation shell: bash diff --git a/tests/e2e/browser-ax-chrome.test.ts b/tests/e2e/browser-ax-chrome.test.ts index 0f7dd1c0f..63d1f5441 100644 --- a/tests/e2e/browser-ax-chrome.test.ts +++ b/tests/e2e/browser-ax-chrome.test.ts @@ -127,7 +127,7 @@ async function startFakeBridge(): Promise { server.close((err) => err ? reject(err) : resolve()); }); }, - waitForExtension: () => withTimeout(connected, 15_000, 'Timed out waiting for Browser Bridge extension to connect'), + waitForExtension: () => withTimeout(connected, 45_000, 'Timed out waiting for Browser Bridge extension to connect'), sendCommand: async (command) => { if (!ws || ws.readyState !== ws.OPEN) throw new Error('Extension WebSocket is not connected'); const id = `ax-e2e-${++nextId}`; @@ -307,7 +307,7 @@ describe('Browser Bridge AX real Chrome smoke', () => { if (process.env.CI) throw new Error(message); skipReason = message; } - }, 30_000); + }, 60_000); afterAll(async () => { await killProcess(chrome); diff --git a/tests/e2e/helpers.ts b/tests/e2e/helpers.ts index 4883ef551..013de567f 100644 --- a/tests/e2e/helpers.ts +++ b/tests/e2e/helpers.ts @@ -36,7 +36,10 @@ export async function runCli( args: string[], opts: { timeout?: number; env?: Record; maxBuffer?: number } = {}, ): Promise { - const timeout = opts.timeout ?? 30_000; + // Keep the child timeout below the common 30s Vitest timeout so flaky + // network commands return a structured non-zero result instead of killing + // the whole test at the framework layer. + const timeout = opts.timeout ?? 25_000; const maxBuffer = opts.maxBuffer ?? DEFAULT_MAX_BUFFER_BYTES; try { const runtime = process.env.OPENCLI_TEST_RUNTIME || 'node'; From 63db56d07b8c2dc6b8e5288a9b0a2a978e9e8e43 Mon Sep 17 00:00:00 2001 From: Zhongyue Lin <101193087+LeoLin990405@users.noreply.github.com> Date: Sat, 4 Jul 2026 00:42:23 +0800 Subject: [PATCH 21/27] fix(weibo): resolve uid before the full auth probe to avoid HTTP 400 (#2047) (#2055) * fix(weibo): resolve uid before the full auth probe to avoid HTTP 400 (#2047) `auth status --site weibo --full` failed with `HTTP 400 from /ajax/profile/info` even on a logged-in session: verifyWeiboIdentity fetched the bare /ajax/profile/info, which the current Weibo web app rejects without a uid. `weibo me` already works because it resolves the current uid first. Mirror that path: call getSelfUid(page) (which throws AuthRequiredError when no logged-in uid resolves), then probe /ajax/profile/info?uid=. Extract the probe into buildWeiboIdentityProbe(uid) and add clis/weibo/auth.test.js. * fix(weibo): unwrap auth identity probes --------- Co-authored-by: jackwener --- clis/weibo/auth.js | 38 ++++++++++++---- clis/weibo/auth.test.js | 96 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+), 9 deletions(-) create mode 100644 clis/weibo/auth.test.js diff --git a/clis/weibo/auth.js b/clis/weibo/auth.js index e2a1539a9..6be0e9d35 100644 --- a/clis/weibo/auth.js +++ b/clis/weibo/auth.js @@ -1,5 +1,6 @@ import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors'; import { registerSiteAuthCommands } from '../_shared/site-auth.js'; +import { getSelfUid, unwrapEvaluateResult } from './utils.js'; async function hasWeiboSessionCookie(page) { const cookies = await page.getCookies({ url: 'https://weibo.com' }); @@ -7,15 +8,14 @@ async function hasWeiboSessionCookie(page) { return names.has('SUB') && names.has('SUBP'); } -async function verifyWeiboIdentity(page) { - if (!await hasWeiboSessionCookie(page)) { - throw new AuthRequiredError('weibo.com', 'Weibo SUB / SUBP cookies missing'); - } - await page.goto('https://weibo.com/'); - await page.wait(3); - const result = await page.evaluate(`(async () => { +// Weibo's /ajax/profile/info requires the current uid — the bare endpoint +// returns HTTP 400 in the current web app. Mirror `weibo me`: resolve the uid +// first, then probe /ajax/profile/info?uid=. +function buildWeiboIdentityProbe(uid) { + const infoUrl = '/ajax/profile/info?uid=' + encodeURIComponent(uid); + return `(async () => { try { - const res = await fetch('/ajax/profile/info', { credentials: 'include', headers: { 'Accept': 'application/json' } }); + const res = await fetch(${JSON.stringify(infoUrl)}, { credentials: 'include', headers: { 'Accept': 'application/json' } }); if (res.status === 401 || res.status === 403) { return { kind: 'auth', detail: 'Weibo /ajax/profile/info HTTP ' + res.status }; } @@ -29,11 +29,29 @@ async function verifyWeiboIdentity(page) { } catch (e) { return { kind: 'exception', detail: String(e && e.message || e) }; } - })()`); + })()`; +} + +async function verifyWeiboIdentity(page) { + if (!await hasWeiboSessionCookie(page)) { + throw new AuthRequiredError('weibo.com', 'Weibo SUB / SUBP cookies missing'); + } + await page.goto('https://weibo.com/'); + await page.wait(3); + // getSelfUid throws AuthRequiredError when no logged-in uid can be resolved. + const uid = await getSelfUid(page); + if (typeof uid !== 'string' || !uid.trim()) { + throw new CommandExecutionError('Weibo uid resolver returned a malformed uid'); + } + const result = unwrapEvaluateResult(await page.evaluate(buildWeiboIdentityProbe(uid))); if (result?.kind === 'auth') throw new AuthRequiredError('weibo.com', result.detail); if (result?.kind === 'http') throw new CommandExecutionError(`HTTP ${result.httpStatus} from /ajax/profile/info`); if (result?.kind === 'exception') throw new CommandExecutionError(`Weibo whoami failed: ${result.detail}`); + if (!result || Array.isArray(result) || typeof result !== 'object') { + throw new CommandExecutionError('Weibo whoami returned malformed probe payload'); + } if (!result?.ok) throw new CommandExecutionError(`Unexpected Weibo probe: ${JSON.stringify(result)}`); + if (!result.user_id) throw new CommandExecutionError('Weibo whoami returned no user id'); return { user_id: result.user_id, screen_name: result.screen_name, profile_url: result.profile_url }; } @@ -51,3 +69,5 @@ registerSiteAuthCommands({ return verifyWeiboIdentity(page); }, }); + +export const __test__ = { buildWeiboIdentityProbe, verifyWeiboIdentity }; diff --git a/clis/weibo/auth.test.js b/clis/weibo/auth.test.js new file mode 100644 index 000000000..556d9d459 --- /dev/null +++ b/clis/weibo/auth.test.js @@ -0,0 +1,96 @@ +import { describe, expect, it, vi } from 'vitest'; +import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors'; +import { __test__ } from './auth.js'; + +function makePage({ cookies = [{ name: 'SUB' }, { name: 'SUBP' }], evalResults = [] } = {}) { + let i = 0; + return { + getCookies: vi.fn().mockResolvedValue(cookies), + goto: vi.fn().mockResolvedValue(undefined), + wait: vi.fn().mockResolvedValue(undefined), + evaluate: vi.fn().mockImplementation(() => Promise.resolve(evalResults[i++])), + }; +} + +describe('weibo auth identity probe', () => { + it('probes /ajax/profile/info with the resolved uid (not the bare 400-prone endpoint)', () => { + const script = __test__.buildWeiboIdentityProbe('12345'); + expect(script).toContain('/ajax/profile/info?uid=12345'); + // the uid-less form is what returns HTTP 400 — it must be gone + expect(script).not.toContain("fetch('/ajax/profile/info'"); + }); + + it('url-encodes the uid', () => { + expect(__test__.buildWeiboIdentityProbe('a b')).toContain('/ajax/profile/info?uid=a%20b'); + }); + + it('resolves the logged-in identity via uid before probing', async () => { + const page = makePage({ + evalResults: [ + '12345', // getSelfUid → current uid + { ok: true, user_id: '12345', screen_name: 'Alice', profile_url: '/u/12345' }, // probe + ], + }); + const result = await __test__.verifyWeiboIdentity(page); + expect(result).toEqual({ user_id: '12345', screen_name: 'Alice', profile_url: '/u/12345' }); + // the second evaluate is the identity probe, carrying the resolved uid + expect(page.evaluate.mock.calls[1][0]).toContain('/ajax/profile/info?uid=12345'); + }); + + it('unwraps Browser Bridge envelopes from uid and profile probes', async () => { + const page = makePage({ + evalResults: [ + { session: 's1', data: '12345' }, + { session: 's1', data: { ok: true, user_id: '12345', screen_name: 'Alice', profile_url: '/u/12345' } }, + ], + }); + await expect(__test__.verifyWeiboIdentity(page)).resolves.toEqual({ + user_id: '12345', + screen_name: 'Alice', + profile_url: '/u/12345', + }); + expect(page.evaluate.mock.calls[1][0]).toContain('/ajax/profile/info?uid=12345'); + }); + + it('typed-fails malformed uid payloads instead of probing /ajax/profile/info with garbage', async () => { + const page = makePage({ evalResults: [{ uid: '12345' }] }); + await expect(__test__.verifyWeiboIdentity(page)).rejects.toBeInstanceOf(CommandExecutionError); + expect(page.evaluate).toHaveBeenCalledTimes(1); + }); + + it('typed-fails malformed probe payloads', async () => { + const page = makePage({ evalResults: ['12345', null] }); + await expect(__test__.verifyWeiboIdentity(page)).rejects.toBeInstanceOf(CommandExecutionError); + }); + + it('throws AuthRequiredError when SUB/SUBP cookies are missing, before navigating', async () => { + const page = makePage({ cookies: [{ name: 'SUB' }] }); + await expect(__test__.verifyWeiboIdentity(page)).rejects.toBeInstanceOf(AuthRequiredError); + expect(page.goto).not.toHaveBeenCalled(); + }); + + it('throws AuthRequiredError when the logged-in uid cannot be resolved', async () => { + const page = makePage({ evalResults: [null, null] }); // getSelfUid store + config both empty + await expect(__test__.verifyWeiboIdentity(page)).rejects.toBeInstanceOf(AuthRequiredError); + }); + + it('maps a non-ok probe response to CommandExecutionError', async () => { + const page = makePage({ evalResults: ['12345', { kind: 'http', httpStatus: 400 }] }); + await expect(__test__.verifyWeiboIdentity(page)).rejects.toBeInstanceOf(CommandExecutionError); + }); + + it('maps an auth-kind probe response to AuthRequiredError', async () => { + const page = makePage({ evalResults: ['12345', { kind: 'auth', detail: 'anonymous' }] }); + await expect(__test__.verifyWeiboIdentity(page)).rejects.toBeInstanceOf(AuthRequiredError); + }); + + it('maps an exception-kind probe response to CommandExecutionError', async () => { + const page = makePage({ evalResults: ['12345', { kind: 'exception', detail: 'boom' }] }); + await expect(__test__.verifyWeiboIdentity(page)).rejects.toBeInstanceOf(CommandExecutionError); + }); + + it('typed-fails success-shaped probes missing user_id', async () => { + const page = makePage({ evalResults: ['12345', { ok: true, screen_name: 'Alice', profile_url: '/u/12345' }] }); + await expect(__test__.verifyWeiboIdentity(page)).rejects.toBeInstanceOf(CommandExecutionError); + }); +}); From f7ad36ba12219d011492e16dbe5cf782aac2bff1 Mon Sep 17 00:00:00 2001 From: jakevin Date: Sat, 4 Jul 2026 00:44:03 +0800 Subject: [PATCH 22/27] ci(e2e): pin macOS headed runner Pin headed E2E macOS coverage to macos-15 while macos-latest migrates to macOS 26. --- .github/workflows/e2e-headed.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/e2e-headed.yml b/.github/workflows/e2e-headed.yml index 8776f3104..fea5e2423 100644 --- a/.github/workflows/e2e-headed.yml +++ b/.github/workflows/e2e-headed.yml @@ -36,7 +36,9 @@ jobs: matrix: # NOTE: Windows excluded — browser-actions/setup-chrome hangs during # Chrome MSI installation on Windows runners (known issue). - os: [ubuntu-latest, macos-latest] + # Pin macOS instead of following macos-latest: the macOS 26 hosted + # image has been flaky for command-line unpacked extension startup. + os: [ubuntu-latest, macos-15] timeout-minutes: 20 steps: - uses: actions/checkout@v6 From 1db7b5f1e811addaef26f14dc98a0a0fd90d48b4 Mon Sep 17 00:00:00 2001 From: Zhongyue Lin <101193087+LeoLin990405@users.noreply.github.com> Date: Sat, 4 Jul 2026 00:46:58 +0800 Subject: [PATCH 23/27] fix(twitter): match localized delete menu and poll for late-hydrating article (#2001) (#2026) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(twitter): match localized delete menu and poll for late-hydrating article (#2001) twitter delete failed on a Simplified-Chinese X detail page: (1) the More caret was matched by aria-label === 'More', which X localizes (zh-Hans 更多), and (2) findTargetArticle() ran before the article's self-referential /status/ link hydrated on slow networks. Inside buildDeleteScript: - Prefer the language-agnostic [data-testid="caret"] (scoped to the matched article), falling back to a multilingual /^(More|更多)/ aria-label match. - Poll findTargetArticle() for ~5s (20 x 250ms) before giving up. - Broaden the Delete menu item to Delete/删除 and exclude the Lists item in both languages (List/列表). * fix(twitter): harden delete menu result handling * fix(twitter): scope delete menu items to opened menu --------- Co-authored-by: jackwener --- clis/twitter/delete.js | 28 +++++++++---- clis/twitter/delete.test.js | 81 ++++++++++++++++++++++++++++++++++++- 2 files changed, 101 insertions(+), 8 deletions(-) diff --git a/clis/twitter/delete.js b/clis/twitter/delete.js index 72e43caff..28c54dadc 100644 --- a/clis/twitter/delete.js +++ b/clis/twitter/delete.js @@ -1,31 +1,45 @@ import { cli, Strategy } from '@jackwener/opencli/registry'; import { CommandExecutionError } from '@jackwener/opencli/errors'; -import { parseTweetUrl, buildTwitterArticleScopeSource } from './shared.js'; +import { parseTweetUrl, buildTwitterArticleScopeSource, unwrapBrowserResult } from './shared.js'; function buildDeleteScript(tweetId) { return `(async () => { try { const visible = (el) => !!el && (el.offsetParent !== null || el.getClientRects().length > 0); ${buildTwitterArticleScopeSource(tweetId)} - const targetArticle = findTargetArticle(); + // The article's self-referential /status/ link can hydrate late on + // slow networks, so poll findTargetArticle() for ~5s before giving up. + let targetArticle = findTargetArticle(); + for (let i = 0; i < 20 && !targetArticle; i++) { + await new Promise(r => setTimeout(r, 250)); + targetArticle = findTargetArticle(); + } if (!targetArticle) { return { ok: false, message: 'Could not find the tweet card matching the requested URL.' }; } - const buttons = Array.from(targetArticle.querySelectorAll('button,[role="button"]')); - const moreMenu = buttons.find((el) => visible(el) && (el.getAttribute('aria-label') || '').trim() === 'More'); + const belongsToTargetArticle = (el) => el.closest('article') === targetArticle; + const buttons = Array.from(targetArticle.querySelectorAll('button,[role="button"]')).filter(belongsToTargetArticle); + // X localizes the "More" caret aria-label (zh-Hans: 更多), so prefer the + // language-agnostic data-testid and fall back to a multilingual label match. + const moreMenu = Array.from(targetArticle.querySelectorAll('[data-testid="caret"]')).filter(belongsToTargetArticle).find(visible) + || buttons.find((el) => visible(el) && /^(More|更多)/.test((el.getAttribute('aria-label') || '').trim())); if (!moreMenu) { return { ok: false, message: 'Could not find the "More" context menu on the matched tweet. Are you sure you are logged in and looking at a valid tweet?' }; } + const beforeMenuItems = new Set(document.querySelectorAll('[role="menuitem"]')); moreMenu.click(); await new Promise(r => setTimeout(r, 1000)); - const items = Array.from(document.querySelectorAll('[role="menuitem"]')); + const items = Array.from(document.querySelectorAll('[role="menuitem"]')) + .filter((item) => visible(item) && !beforeMenuItems.has(item)); const deleteBtn = items.find((item) => { const text = (item.textContent || '').trim(); - return text.includes('Delete') && !text.includes('List'); + // X localizes the menu item (zh-Hans: 删除); exclude the "Add/remove + // from Lists" item in both languages so we never click the wrong row. + return (text.includes('Delete') || text.includes('删除')) && !text.includes('List') && !text.includes('列表'); }); if (!deleteBtn) { @@ -69,7 +83,7 @@ cli({ const target = parseTweetUrl(kwargs.url); await page.goto(target.url); await page.wait({ selector: '[data-testid="primaryColumn"]' }); // Wait for tweet to load completely - const result = await page.evaluate(buildDeleteScript(target.id)); + const result = unwrapBrowserResult(await page.evaluate(buildDeleteScript(target.id))); if (result.ok) { // Wait for the deletion request to be processed await page.wait(2); diff --git a/clis/twitter/delete.test.js b/clis/twitter/delete.test.js index c3854661b..1798f995c 100644 --- a/clis/twitter/delete.test.js +++ b/clis/twitter/delete.test.js @@ -1,7 +1,8 @@ import { describe, expect, it, vi } from 'vitest'; +import { JSDOM } from 'jsdom'; import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors'; import { getRegistry } from '@jackwener/opencli/registry'; -import './delete.js'; +import { __test__ } from './delete.js'; describe('twitter delete command', () => { it('targets the matched tweet article instead of the first More button on the page', async () => { const cmd = getRegistry().get('twitter/delete'); @@ -27,6 +28,18 @@ describe('twitter delete command', () => { expect(script).toContain('__twGetStatusIdFromHref'); expect(script).toContain("document.querySelectorAll('article')"); expect(script).toContain("targetArticle.querySelectorAll('button,[role=\"button\"]')"); + expect(script).toContain("closest('article') === targetArticle"); + expect(script).toContain(".filter(belongsToTargetArticle)"); + // Localized "More" caret: prefer the language-agnostic data-testid, fall + // back to a multilingual aria-label match (zh-Hans 更多), and poll for the + // late-hydrating target article before giving up. + expect(script).toContain('[data-testid="caret"]'); + expect(script).toContain('/^(More|更多)/'); + expect(script).toContain('i < 20'); + // Delete menu item is localized (删除) and must exclude the Lists item in + // both languages (List / 列表). + expect(script).toContain('删除'); + expect(script).toContain('列表'); // Substring match must NOT appear — exact-id match only. expect(script).not.toContain("'/status/' + tweetId"); expect(result).toEqual([ @@ -58,6 +71,72 @@ describe('twitter delete command', () => { ]); expect(page.wait).toHaveBeenCalledTimes(1); }); + it('unwraps Browser Bridge evaluate envelopes before checking delete success', async () => { + const cmd = getRegistry().get('twitter/delete'); + expect(cmd?.func).toBeTypeOf('function'); + const page = { + goto: vi.fn().mockResolvedValue(undefined), + wait: vi.fn().mockResolvedValue(undefined), + evaluate: vi.fn().mockResolvedValue({ + session: 'twitter', + data: { ok: true, message: 'Tweet successfully deleted.' }, + }), + }; + const result = await cmd.func(page, { + url: 'https://x.com/alice/status/2040254679301718161', + }); + expect(result).toEqual([ + { + status: 'success', + message: 'Tweet successfully deleted.', + }, + ]); + expect(page.wait).toHaveBeenNthCalledWith(2, 2); + }); + it('ignores stale non-target delete menu items that existed before opening the matched tweet menu', async () => { + const dom = new JSDOM(` + +
删除
+ + + + `, { runScripts: 'outside-only', url: 'https://x.com/alice/status/2040254679301718161' }); + dom.window.setTimeout = (handler) => { + if (typeof handler === 'function') handler(); + return 0; + }; + Object.defineProperty(dom.window.HTMLElement.prototype, 'getClientRects', { + configurable: true, + value() { + return [{ bottom: 1, height: 1, left: 0, right: 1, top: 0, width: 1 }]; + }, + }); + let staleDeleteClicked = false; + let targetCaretClicked = false; + dom.window.document.querySelector('[data-stale-delete]')?.addEventListener('click', () => { + staleDeleteClicked = true; + }); + dom.window.document.querySelector('[data-target-caret]')?.addEventListener('click', () => { + targetCaretClicked = true; + const item = dom.window.document.createElement('div'); + item.setAttribute('role', 'menuitem'); + item.textContent = 'Pin to your profile'; + dom.window.document.body.appendChild(item); + }); + const result = await dom.window.eval(__test__.buildDeleteScript('2040254679301718161')); + expect(targetCaretClicked).toBe(true); + expect(staleDeleteClicked).toBe(false); + expect(result).toEqual({ + ok: false, + message: 'The matched tweet menu did not contain Delete. This tweet may not belong to you.', + }); + }); it('rejects malformed or off-domain URLs with ArgumentError before navigation', async () => { const cmd = getRegistry().get('twitter/delete'); expect(cmd?.func).toBeTypeOf('function'); From 8de1184da628e758e57e7de5c61a9116b0feaa87 Mon Sep 17 00:00:00 2001 From: jakevin Date: Sat, 4 Jul 2026 00:55:09 +0800 Subject: [PATCH 24/27] test(e2e): treat mac AX bridge startup as optional Keep Linux AX smoke release-blocking while allowing hosted macOS to skip when command-line unpacked extension startup is unavailable. --- tests/e2e/browser-ax-chrome.test.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/tests/e2e/browser-ax-chrome.test.ts b/tests/e2e/browser-ax-chrome.test.ts index 63d1f5441..7561d43af 100644 --- a/tests/e2e/browser-ax-chrome.test.ts +++ b/tests/e2e/browser-ax-chrome.test.ts @@ -269,6 +269,13 @@ function axText(axTree: unknown): string { return nodes.map((node: any) => String(node?.name?.value ?? '')).join('\n'); } +function shouldFailOnBridgeUnavailable(): boolean { + // Linux is the release-blocking real-extension gate. Hosted macOS runners + // can refuse command-line unpacked extension startup depending on the image + // and Chrome build; keep that visible but do not block releases on it. + return process.env.CI === 'true' && process.platform !== 'darwin'; +} + describe('Browser Bridge AX real Chrome smoke', () => { let bridge: FakeBridge | null = null; let site: TestSite | null = null; @@ -304,7 +311,7 @@ describe('Browser Bridge AX real Chrome smoke', () => { } catch (err) { const tail = chromeStderr.split('\n').slice(-30).join('\n').trim(); const message = `${err instanceof Error ? err.message : String(err)}${tail ? `\nChrome stderr:\n${tail}` : ''}`; - if (process.env.CI) throw new Error(message); + if (shouldFailOnBridgeUnavailable()) throw new Error(message); skipReason = message; } }, 60_000); @@ -327,7 +334,7 @@ describe('Browser Bridge AX real Chrome smoke', () => { it('returns AX nodes for parent and same-origin iframe, and probes cross-origin frame support', async () => { if (skipReason) { - if (process.env.CI) throw new Error(skipReason); + if (shouldFailOnBridgeUnavailable()) throw new Error(skipReason); console.warn(`skipped — ${skipReason}`); return; } From cad35e7a6a5ff3f7d6b859bfa4c45195c0390260 Mon Sep 17 00:00:00 2001 From: jakevin Date: Sat, 4 Jul 2026 01:10:55 +0800 Subject: [PATCH 25/27] chore(release): bump version to 1.8.6 Bump @jackwener/opencli to 1.8.6. --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2c1182bb3..eb95001a7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@jackwener/opencli", - "version": "1.8.5", + "version": "1.8.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@jackwener/opencli", - "version": "1.8.5", + "version": "1.8.6", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index cc124e664..2582ba83f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@jackwener/opencli", - "version": "1.8.5", + "version": "1.8.6", "publishConfig": { "access": "public" }, From 67344d5e36ee83ed93e23cde4a4bbd15a4040cd5 Mon Sep 17 00:00:00 2001 From: jakevin Date: Sat, 4 Jul 2026 01:37:38 +0800 Subject: [PATCH 26/27] test(e2e): run AX smoke headless on all platforms; add daemon transport contract E2E (#2081) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The AX real-Chrome smoke's contract is "the extension bridge works in a real Chrome", not "a window appears" — Linux already admitted that by faking a display with xvfb. Hosted macOS runners fail headed Chrome at the OS level (child processes lose the Mach port rendezvous with the browser process because CI jobs run outside a regular Aqua session), and PR #2079 papered over that by skipping the platform. Running the smoke with --headless=new removes the display/GUI-session dependency entirely: new headless is a full browser (MV3 service worker, chrome.debugger, --load-extension), verified locally against the same Chrome for Testing build CI uses. The smoke is release-blocking on every OS again; headed mode stays available locally via OPENCLI_E2E_HEADED=1. New daemon-transport contract E2E: the real dist/src/daemon.js process with a scripted fake extension, pinning the cross-layer contracts end to end — duplicate ids attach to the pending command without re-dispatch, deadlines produce a structured 408 command_result_unknown, extension death after dispatch yields command_result_unknown, a stale preferredContextId falls back to the only connected profile while an explicit contextId fails loud, and graceful shutdown flushes structured daemon_shutting_down 503s with exit code 0. No browser required; runs in the fixed-port project on every OS. --- .github/workflows/e2e-headed.yml | 26 ++- tests/e2e/browser-ax-chrome.test.ts | 18 +- tests/e2e/daemon-transport.test.ts | 323 ++++++++++++++++++++++++++++ vitest.config.ts | 2 +- 4 files changed, 353 insertions(+), 16 deletions(-) create mode 100644 tests/e2e/daemon-transport.test.ts diff --git a/.github/workflows/e2e-headed.yml b/.github/workflows/e2e-headed.yml index fea5e2423..c5d24645f 100644 --- a/.github/workflows/e2e-headed.yml +++ b/.github/workflows/e2e-headed.yml @@ -61,22 +61,26 @@ jobs: - name: Build extension run: npm run build --prefix extension - - name: Run AX Chrome smoke (Linux, via xvfb) - if: runner.os == 'Linux' - env: - CHROME_PATH: ${{ steps.setup-chrome.outputs.chrome-path }} - OPENCLI_AX_E2E: '1' - run: | - xvfb-run --auto-servernum --server-args="-screen 0 1280x720x24" \ - npx vitest run --project e2e tests/e2e/browser-ax-chrome.test.ts --reporter=verbose - - - name: Run AX Chrome smoke (macOS / Windows) - if: runner.os != 'Linux' + # The AX smoke runs Chrome in new-headless mode: a full browser (MV3 + # service worker + chrome.debugger + --load-extension) with no display + # server or GUI-session dependency, so the same command is release- + # blocking on every OS. Headed Chrome on hosted macOS fails Mach port + # rendezvous for child processes (CI jobs run outside a regular Aqua + # session); headless removes that entire failure class instead of + # skipping the platform. + - name: Run AX Chrome smoke (headless, all platforms) env: CHROME_PATH: ${{ steps.setup-chrome.outputs.chrome-path }} OPENCLI_AX_E2E: '1' run: npx vitest run --project e2e tests/e2e/browser-ax-chrome.test.ts --reporter=verbose + # Transport contract E2E: real daemon process + scripted fake extension. + # Pins the cross-layer contracts (waiter attach, deadline 408, dispatched + # disconnect, profile fallback, graceful shutdown) end to end with the + # actual daemon binary — no browser required, deterministic on every OS. + - name: Run daemon transport contract E2E + run: npx vitest run --project e2e-fixed-port tests/e2e/daemon-transport.test.ts --reporter=verbose + - name: Run E2E tests (Linux, via xvfb) if: runner.os == 'Linux' env: diff --git a/tests/e2e/browser-ax-chrome.test.ts b/tests/e2e/browser-ax-chrome.test.ts index 7561d43af..5d9267a8d 100644 --- a/tests/e2e/browser-ax-chrome.test.ts +++ b/tests/e2e/browser-ax-chrome.test.ts @@ -206,6 +206,16 @@ function findChromeExecutable(): string | null { function launchChrome(chromePath: string, userDataDir: string, startUrl: string): ChildProcess { return spawn(chromePath, [ + // This smoke's contract is "the extension bridge works in a real Chrome", + // not "a window appears": new headless is a full browser (MV3 service + // worker + chrome.debugger + --load-extension all supported) with no + // dependency on a display server or a GUI login session. That dependency + // is exactly what broke hosted runners: Linux needed xvfb, and macOS + // runners fail Mach port rendezvous for Chrome's child processes because + // CI jobs do not run in a regular Aqua session (bootstrap_look_up + // MachPortRendezvousServer → "Unknown service name"). Headed mode stays + // available for local debugging via OPENCLI_E2E_HEADED=1. + ...(process.env.OPENCLI_E2E_HEADED === '1' ? [] : ['--headless=new']), `--user-data-dir=${userDataDir}`, `--disable-extensions-except=${EXTENSION_DIR}`, `--load-extension=${EXTENSION_DIR}`, @@ -270,10 +280,10 @@ function axText(axTree: unknown): string { } function shouldFailOnBridgeUnavailable(): boolean { - // Linux is the release-blocking real-extension gate. Hosted macOS runners - // can refuse command-line unpacked extension startup depending on the image - // and Chrome build; keep that visible but do not block releases on it. - return process.env.CI === 'true' && process.platform !== 'darwin'; + // Release-blocking on every CI platform: with the smoke running Chrome in + // new-headless mode there is no display-server/GUI-session dependency left, + // so an unavailable bridge means a real product or packaging regression. + return process.env.CI === 'true'; } describe('Browser Bridge AX real Chrome smoke', () => { diff --git a/tests/e2e/daemon-transport.test.ts b/tests/e2e/daemon-transport.test.ts new file mode 100644 index 000000000..2ad837ab2 --- /dev/null +++ b/tests/e2e/daemon-transport.test.ts @@ -0,0 +1,323 @@ +/** + * Daemon transport contract E2E — the REAL daemon binary driven end to end. + * + * Complements browser-tabs.test.ts (fake daemon + real CLI) and + * browser-ax-chrome.test.ts (fake daemon + real extension) with the missing + * axis: a real `dist/src/daemon.js` process, a scripted fake extension on the + * WebSocket side, and raw HTTP on the client side. Each test pins a + * cross-layer contract that unit tests can only cover in isolation — the + * exact classes of bugs that historically shipped: + * + * - duplicate command ids attach to the pending command (no re-dispatch) + * - the per-command deadline produces a structured 408, not a hang + * - extension disconnect after dispatch yields command_result_unknown + * - a stale preferred profile falls back to the only connected profile + * - graceful shutdown flushes structured 503s instead of dropping sockets + * + * Requires port 19825 (the fixed bridge port); lives in the e2e-fixed-port + * project so it never runs concurrently with other fixed-port suites. + */ + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { spawn, type ChildProcess } from 'node:child_process'; +import { createConnection } from 'node:net'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import WebSocket from 'ws'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(__dirname, '../..'); +const DAEMON_ENTRY = path.join(ROOT, 'dist', 'src', 'daemon.js'); +const PORT = 19825; +const BASE = `http://127.0.0.1:${PORT}`; +const HEADERS = { 'X-OpenCLI': '1', 'Content-Type': 'application/json' }; + +type WireResult = { + id?: string; + ok: boolean; + data?: unknown; + error?: string; + errorCode?: string; + errorHint?: string; +}; + +/** Scripted stand-in for the Browser Bridge extension. */ +class FakeExtension { + private ws: WebSocket | null = null; + readonly received: Array> = []; + /** Per-action handler; return null to stay silent (simulate a hang). */ + onCommand: (cmd: Record) => WireResult | null = (cmd) => ({ + id: String(cmd.id), + ok: true, + data: { echo: cmd.action }, + }); + + async connect(contextId: string, version = '1.0.22'): Promise { + const ws = new WebSocket(`ws://127.0.0.1:${PORT}/ext`, { + headers: { origin: 'chrome-extension://e2e-fake-extension' }, + }); + this.ws = ws; + await new Promise((resolve, reject) => { + ws.once('open', () => { + ws.send(JSON.stringify({ type: 'hello', contextId, version, compatRange: '>=1.0.0' })); + resolve(); + }); + ws.once('error', reject); + }); + ws.on('message', (raw) => { + const cmd = JSON.parse(raw.toString()) as Record; + this.received.push(cmd); + const result = this.onCommand(cmd); + if (result) ws.send(JSON.stringify(result)); + }); + // Give the daemon a beat to register the hello before commands route. + await waitFor(async () => { + const status = await getStatus(); + return status?.extensionConnected === true || (status?.profiles ?? []).some((p: any) => p.contextId === contextId); + }, 5_000, 'daemon did not register the fake extension'); + } + + dispatchCountFor(id: string): number { + return this.received.filter((cmd) => cmd.id === id).length; + } + + send(result: WireResult): void { + this.ws?.send(JSON.stringify(result)); + } + + close(): void { + this.ws?.close(); + this.ws = null; + } + + terminate(): void { + this.ws?.terminate(); + this.ws = null; + } +} + +async function getStatus(): Promise { + try { + const res = await fetch(`${BASE}/status`, { headers: HEADERS, signal: AbortSignal.timeout(2_000) }); + if (!res.ok) return null; + return await res.json(); + } catch { + return null; + } +} + +async function postCommand(body: Record, timeoutMs = 15_000): Promise<{ status: number; result: WireResult }> { + const res = await fetch(`${BASE}/command`, { + method: 'POST', + headers: HEADERS, + body: JSON.stringify(body), + signal: AbortSignal.timeout(timeoutMs), + }); + return { status: res.status, result: (await res.json()) as WireResult }; +} + +async function waitFor(check: () => Promise | boolean, timeoutMs: number, message: string): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await check()) return; + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error(message); +} + +function isPortBusy(): Promise { + return new Promise((resolve) => { + const socket = createConnection({ port: PORT, host: '127.0.0.1' }); + socket.once('connect', () => { socket.destroy(); resolve(true); }); + socket.once('error', () => resolve(false)); + }); +} + +let nextId = 0; +function cmdId(): string { + return `e2e-transport-${process.pid}-${++nextId}`; +} + +describe('daemon transport contracts (real daemon)', () => { + let daemon: ChildProcess | null = null; + let skipReason = ''; + + beforeAll(async () => { + if (await isPortBusy()) { + skipReason = `Port ${PORT} is already in use; stop the local opencli daemon before running this suite`; + if (process.env.CI === 'true') throw new Error(skipReason); + return; + } + daemon = spawn(process.execPath, [DAEMON_ENTRY], { stdio: ['ignore', 'ignore', 'pipe'] }); + let stderr = ''; + daemon.stderr?.on('data', (chunk) => { stderr += chunk.toString(); }); + try { + await waitFor(async () => (await getStatus()) !== null, 10_000, 'daemon did not start'); + } catch (err) { + throw new Error(`${err instanceof Error ? err.message : String(err)}\ndaemon stderr:\n${stderr.slice(-2_000)}`); + } + }, 30_000); + + afterAll(async () => { + if (!daemon) return; + try { + await fetch(`${BASE}/shutdown`, { method: 'POST', headers: HEADERS, signal: AbortSignal.timeout(2_000) }); + } catch { /* daemon may already be gone */ } + await new Promise((resolve) => { + if (!daemon || daemon.exitCode !== null) return resolve(); + daemon.once('exit', () => resolve()); + setTimeout(() => { daemon?.kill('SIGKILL'); resolve(); }, 3_000); + }); + }); + + function guard(): boolean { + if (skipReason) { + console.warn(`skipped — ${skipReason}`); + return true; + } + return false; + } + + it('dispatches a command to the connected extension and correlates the result', async () => { + if (guard()) return; + const ext = new FakeExtension(); + await ext.connect('ctx-happy'); + try { + const id = cmdId(); + const { status, result } = await postCommand({ id, action: 'exec', code: '1 + 1', session: 's', surface: 'browser' }); + expect(status).toBe(200); + expect(result.ok).toBe(true); + expect(result.data).toEqual({ echo: 'exec' }); + expect(ext.dispatchCountFor(id)).toBe(1); + } finally { + ext.close(); + } + }); + + it('attaches a duplicate command id to the pending command instead of re-dispatching', async () => { + if (guard()) return; + const ext = new FakeExtension(); + let release: (() => void) | null = null; + const held = new Promise((resolve) => { release = resolve; }); + ext.onCommand = (cmd) => { + // Answer asynchronously so the duplicate arrives while pending. + void held.then(() => ext.send({ id: String(cmd.id), ok: true, data: 'once' })); + return null; + }; + await ext.connect('ctx-dup'); + try { + const id = cmdId(); + const first = postCommand({ id, action: 'navigate', url: 'https://example.com', session: 's', surface: 'browser' }); + await waitFor(() => ext.dispatchCountFor(id) === 1, 5_000, 'command was not dispatched'); + const second = postCommand({ id, action: 'navigate', url: 'https://example.com', session: 's', surface: 'browser' }); + // The duplicate must NOT reach the extension a second time. + await new Promise((resolve) => setTimeout(resolve, 300)); + expect(ext.dispatchCountFor(id)).toBe(1); + release!(); + const [a, b] = await Promise.all([first, second]); + expect(a.result.ok).toBe(true); + expect(b.result.ok).toBe(true); + expect(a.result.data).toBe('once'); + expect(b.result.data).toBe('once'); + expect(ext.dispatchCountFor(id)).toBe(1); + } finally { + ext.close(); + } + }); + + it('returns a structured 408 command_result_unknown when the deadline passes with no result', async () => { + if (guard()) return; + const ext = new FakeExtension(); + ext.onCommand = () => null; // simulate a wedged extension + await ext.connect('ctx-deadline'); + try { + const { status, result } = await postCommand({ + id: cmdId(), + action: 'exec', + code: 'while(true){}', + session: 's', + surface: 'browser', + deadlineAt: Date.now() + 1_500, + }); + expect(status).toBe(408); + expect(result.ok).toBe(false); + expect(result.errorCode).toBe('command_result_unknown'); + } finally { + ext.close(); + } + }); + + it('reports command_result_unknown when the extension dies after dispatch', async () => { + if (guard()) return; + const ext = new FakeExtension(); + ext.onCommand = () => null; + await ext.connect('ctx-dropout'); + const id = cmdId(); + const inflight = postCommand({ id, action: 'navigate', url: 'https://example.com', session: 's', surface: 'browser' }); + await waitFor(() => ext.dispatchCountFor(id) === 1, 5_000, 'command was not dispatched'); + ext.terminate(); // hard drop, as if the service worker was killed + const { status, result } = await inflight; + expect(status).toBe(503); + expect(result.ok).toBe(false); + expect(result.errorCode).toBe('command_result_unknown'); + }); + + it('serves a stale preferredContextId from the only connected profile, but fails loud for a required one', async () => { + if (guard()) return; + const ext = new FakeExtension(); + await ext.connect('ctx-live'); + try { + // Preference: stale default must not veto the only live profile. + const preferred = await postCommand({ + id: cmdId(), + action: 'exec', + code: '1', + session: 's', + surface: 'browser', + preferredContextId: 'ctx-ghost', + }); + expect(preferred.result.ok).toBe(true); + + // Requirement: an explicit profile fails loud when offline. + const required = await postCommand({ + id: cmdId(), + action: 'exec', + code: '1', + session: 's', + surface: 'browser', + contextId: 'ctx-ghost', + }); + expect(required.result.ok).toBe(false); + expect(required.result.errorCode).toBe('profile_disconnected'); + } finally { + ext.close(); + } + }); + + it('flushes a structured daemon_shutting_down 503 to in-flight dispatched commands on shutdown', async () => { + if (guard()) return; + const ext = new FakeExtension(); + ext.onCommand = () => null; + await ext.connect('ctx-shutdown'); + const id = cmdId(); + const inflight = postCommand({ id, action: 'navigate', url: 'https://example.com', session: 's', surface: 'browser' }); + await waitFor(() => ext.dispatchCountFor(id) === 1, 5_000, 'command was not dispatched'); + + await fetch(`${BASE}/shutdown`, { method: 'POST', headers: HEADERS, signal: AbortSignal.timeout(2_000) }); + + // The contract under test: a structured JSON response, not a socket hang-up. + const { status, result } = await inflight; + expect(status).toBe(503); + expect(result.ok).toBe(false); + expect(result.errorCode).toBe('daemon_shutting_down'); + + await new Promise((resolve) => { + if (!daemon || daemon.exitCode !== null) return resolve(); + daemon.once('exit', () => resolve()); + setTimeout(resolve, 3_000); + }); + expect(daemon?.exitCode).toBe(0); + daemon = null; // afterAll: nothing left to stop + ext.close(); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 8dd8035f5..8c3872ab1 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -31,7 +31,7 @@ export default defineConfig({ { test: { name: 'e2e-fixed-port', - include: ['tests/e2e/browser-tabs.test.ts'], + include: ['tests/e2e/browser-tabs.test.ts', 'tests/e2e/daemon-transport.test.ts'], fileParallelism: false, sequence: { groupOrder: 2 }, }, From 6129bb3953d5eebd8dd67f96802b320c723f50ca Mon Sep 17 00:00:00 2001 From: jakevin Date: Sat, 4 Jul 2026 02:12:22 +0800 Subject: [PATCH 27/27] ci(e2e): place each gate where its runner can run it deterministically (#2082) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #2081 tried to make the real-browser AX smoke run everywhere via --headless=new. That fixed headed macOS's Mach-port crash but exposed a second environment property: hosted runners don't reliably start the MV3 extension service worker in headless, so main went red on macOS anyway. Chasing headless reliability across runner images is the wrong axis. First principles: gate each check on the environment that can run it deterministically, and make sure every OS has a real blocking gate. - Real-browser extension smoke (AX tree + cross-frame CDP): Linux under xvfb is the one hosted environment where a real Chrome reliably starts an MV3 extension. It runs there, headed, release-blocking. It is not scheduled on macOS/Windows because neither can run it deterministically (headed macOS crashes on Mach port rendezvous outside an Aqua session; headless connects no SW). - Daemon transport contracts: no browser, deterministic, so they run blocking on all three OSes including Windows — macOS/Windows now have a real gate over the exact layer our recent bugs lived in (#2067/#2070/ #2073), not a skipped test that proves nothing. - Windows joins the matrix for the first time (transport gate); the setup-chrome action is skipped there since it hangs on the MSI path and Windows needs no browser. Local Chrome launch stays headed by default; OPENCLI_E2E_HEADLESS=1 opts into headless for display-less local runs. --- .github/workflows/e2e-headed.yml | 47 ++++++++++++++++++----------- tests/e2e/browser-ax-chrome.test.ts | 26 ++++++++-------- 2 files changed, 43 insertions(+), 30 deletions(-) diff --git a/.github/workflows/e2e-headed.yml b/.github/workflows/e2e-headed.yml index c5d24645f..e412568f7 100644 --- a/.github/workflows/e2e-headed.yml +++ b/.github/workflows/e2e-headed.yml @@ -34,11 +34,15 @@ jobs: strategy: fail-fast: false matrix: - # NOTE: Windows excluded — browser-actions/setup-chrome hangs during - # Chrome MSI installation on Windows runners (known issue). - # Pin macOS instead of following macos-latest: the macOS 26 hosted - # image has been flaky for command-line unpacked extension startup. - os: [ubuntu-latest, macos-15] + # Gate placement by what each runner can run deterministically: + # - the real-browser extension smoke needs a Chrome that reliably runs + # an MV3 extension, which only Linux+xvfb provides on hosted runners + # (headed macOS crashes on Mach port rendezvous outside an Aqua + # session; headless does not connect the extension SW there); + # - the daemon transport contracts need no browser and run blocking on + # every OS, so macOS/Windows get a real gate, not a skipped one. + # macOS pinned to 15 while the macOS 26 image stabilizes. + os: [ubuntu-latest, macos-15, windows-latest] timeout-minutes: 20 steps: - uses: actions/checkout@v6 @@ -51,7 +55,11 @@ jobs: - name: Install dependencies run: npm ci + # Linux runs the extension smoke and macOS runs the full real-site e2e + # suite; both need a real Chrome. Windows runs only the browser-free + # transport gate, and the setup-chrome action hangs on Windows anyway. - name: Setup Chrome + if: runner.os != 'Windows' uses: ./.github/actions/setup-chrome id: setup-chrome @@ -61,23 +69,26 @@ jobs: - name: Build extension run: npm run build --prefix extension - # The AX smoke runs Chrome in new-headless mode: a full browser (MV3 - # service worker + chrome.debugger + --load-extension) with no display - # server or GUI-session dependency, so the same command is release- - # blocking on every OS. Headed Chrome on hosted macOS fails Mach port - # rendezvous for child processes (CI jobs run outside a regular Aqua - # session); headless removes that entire failure class instead of - # skipping the platform. - - name: Run AX Chrome smoke (headless, all platforms) + # Real-browser extension smoke: Linux under xvfb is the one hosted + # environment where a real Chrome reliably starts an MV3 extension, so + # this is the release-blocking browser gate. Headed (not headless): + # headless does not connect the extension service worker on hosted + # runners. See the matrix comment for why macOS/Windows don't run it. + - name: Run AX Chrome smoke (Linux, real extension via xvfb) + if: runner.os == 'Linux' env: CHROME_PATH: ${{ steps.setup-chrome.outputs.chrome-path }} OPENCLI_AX_E2E: '1' - run: npx vitest run --project e2e tests/e2e/browser-ax-chrome.test.ts --reporter=verbose + OPENCLI_E2E_HEADED: '1' + run: | + xvfb-run --auto-servernum --server-args="-screen 0 1280x720x24" \ + npx vitest run --project e2e tests/e2e/browser-ax-chrome.test.ts --reporter=verbose # Transport contract E2E: real daemon process + scripted fake extension. # Pins the cross-layer contracts (waiter attach, deadline 408, dispatched # disconnect, profile fallback, graceful shutdown) end to end with the - # actual daemon binary — no browser required, deterministic on every OS. + # actual daemon binary — no browser required, so this is the blocking + # gate on EVERY OS, including macOS and Windows. - name: Run daemon transport contract E2E run: npx vitest run --project e2e-fixed-port tests/e2e/daemon-transport.test.ts --reporter=verbose @@ -89,8 +100,10 @@ jobs: xvfb-run --auto-servernum --server-args="-screen 0 1280x720x24" \ npx vitest run tests/e2e/ --reporter=verbose - - name: Run E2E tests (macOS / Windows) - if: runner.os != 'Linux' + # Real-site adapter e2e stays on Linux/macOS; Windows runs the two + # deterministic gates above (unit coverage in ci.yml already spans it). + - name: Run E2E tests (macOS) + if: runner.os == 'macOS' env: OPENCLI_AX_E2E: '0' run: npx vitest run tests/e2e/ --reporter=verbose diff --git a/tests/e2e/browser-ax-chrome.test.ts b/tests/e2e/browser-ax-chrome.test.ts index 5d9267a8d..62ff5cdbe 100644 --- a/tests/e2e/browser-ax-chrome.test.ts +++ b/tests/e2e/browser-ax-chrome.test.ts @@ -206,16 +206,14 @@ function findChromeExecutable(): string | null { function launchChrome(chromePath: string, userDataDir: string, startUrl: string): ChildProcess { return spawn(chromePath, [ - // This smoke's contract is "the extension bridge works in a real Chrome", - // not "a window appears": new headless is a full browser (MV3 service - // worker + chrome.debugger + --load-extension all supported) with no - // dependency on a display server or a GUI login session. That dependency - // is exactly what broke hosted runners: Linux needed xvfb, and macOS - // runners fail Mach port rendezvous for Chrome's child processes because - // CI jobs do not run in a regular Aqua session (bootstrap_look_up - // MachPortRendezvousServer → "Unknown service name"). Headed mode stays - // available for local debugging via OPENCLI_E2E_HEADED=1. - ...(process.env.OPENCLI_E2E_HEADED === '1' ? [] : ['--headless=new']), + // Headed by default under a real/virtual display (CI Linux runs this under + // xvfb). New-headless is convenient locally — no display needed — but on + // hosted runners it does not reliably start the MV3 extension service + // worker, so CI forces headed via OPENCLI_E2E_HEADED=1. Set OPENCLI_E2E_ + // HEADLESS=1 to opt into headless locally. + ...(process.env.OPENCLI_E2E_HEADLESS === '1' && process.env.OPENCLI_E2E_HEADED !== '1' + ? ['--headless=new'] + : []), `--user-data-dir=${userDataDir}`, `--disable-extensions-except=${EXTENSION_DIR}`, `--load-extension=${EXTENSION_DIR}`, @@ -280,9 +278,11 @@ function axText(axTree: unknown): string { } function shouldFailOnBridgeUnavailable(): boolean { - // Release-blocking on every CI platform: with the smoke running Chrome in - // new-headless mode there is no display-server/GUI-session dependency left, - // so an unavailable bridge means a real product or packaging regression. + // In CI this smoke is scheduled only on Linux (headed under xvfb — the one + // hosted environment where a real Chrome reliably starts an MV3 extension), + // so any bridge failure there is a real product/packaging regression and + // must block. macOS/Windows gate on the browser-free transport contracts + // instead; see the e2e-headed workflow for the rationale. return process.env.CI === 'true'; }