From 31fc5d3c1fe3ca7cbcdfe20ea7cf989f7c699ed8 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 20 Jun 2026 15:26:15 +0000 Subject: [PATCH] feat: allow web observer provider selection --- scripts/src/hwlab-node-help.ts | 3 +- scripts/src/hwlab-node-impl.ts | 12 +++- .../hwlab-node-web-observe-runner-source.ts | 69 +++++++++++++++++++ 3 files changed, 80 insertions(+), 4 deletions(-) diff --git a/scripts/src/hwlab-node-help.ts b/scripts/src/hwlab-node-help.ts index 62117d82..d7050f9a 100644 --- a/scripts/src/hwlab-node-help.ts +++ b/scripts/src/hwlab-node-help.ts @@ -68,6 +68,7 @@ export function hwlabNodeWebProbeHelp(): Record { "bun scripts/cli.ts hwlab nodes web-probe run --node D601 --lane v03 --url https://hwlab.pikapython.com --fresh-session --message 'ping'", "bun scripts/cli.ts hwlab nodes web-probe script --node D601 --lane v03 --script-file .state/probes/workbench.mjs", "bun scripts/cli.ts hwlab nodes web-probe observe start --node D601 --lane v03 --target-path /workbench --sample-interval-ms 5000", + "bun scripts/cli.ts hwlab nodes web-probe observe command --node D601 --lane v03 --state-dir .state/web-observe/D601/v03/YYYY/MM/DD/ --type selectProvider --provider codex-api", "bun scripts/cli.ts hwlab nodes web-probe observe command --node D601 --lane v03 --state-dir .state/web-observe/D601/v03/YYYY/MM/DD/ --type sendPrompt --text 'ping'", "bun scripts/cli.ts hwlab nodes web-probe observe status --node D601 --lane v03 --state-dir .state/web-observe/D601/v03/YYYY/MM/DD/", "bun scripts/cli.ts hwlab nodes web-probe observe analyze --node D601 --lane v03 --state-dir .state/web-observe/D601/v03/YYYY/MM/DD/", @@ -82,7 +83,7 @@ export function hwlabNodeWebProbeHelp(): Record { "Prefer --script-file for reusable probes; stdin heredocs remain supported for one-off probes.", "Issue-ready evidence is available under issueEvidence and summary.issueEvidence; full script report is persisted under probe.reportPath with a SHA-256 fingerprint.", "observe sampling is passive by default: it records DOM summaries and natural page request/response/requestfailed events with observerInitiated=false; it does not actively fetch Workbench APIs, reload, switch sessions, route/intercept, or call repair helpers.", - "observe command actions are explicit user/control actions and are appended to control.jsonl; use --type sendPrompt/goto/screenshot/mark/stop and keep prompt text out of issue comments by citing textHash/textBytes.", + "observe command actions are explicit user/control actions and are appended to control.jsonl; use --type selectProvider/sendPrompt/goto/screenshot/mark/stop and keep prompt text out of issue comments by citing textHash/textBytes.", "observe analyze is offline-only: it reads artifact JSONL and writes analysis/report.md plus analysis/report.json without accessing Workbench APIs or driving the browser.", "Use recordStep(name, data) or fetchApiMatrix(paths) to keep structured partial evidence when a later step fails.", "Use reloadStable(), gotoCurrentStable(), or safeReload() for bounded retries around page reload/current-URL navigation jitter such as ERR_NETWORK_CHANGED.", diff --git a/scripts/src/hwlab-node-impl.ts b/scripts/src/hwlab-node-impl.ts index 1d6f04a7..4ea39259 100644 --- a/scripts/src/hwlab-node-impl.ts +++ b/scripts/src/hwlab-node-impl.ts @@ -58,7 +58,7 @@ interface NodeWebProbeScriptOptions { } type NodeWebProbeObserveAction = "start" | "status" | "command" | "stop" | "collect" | "analyze"; -type NodeWebProbeObserveCommandType = "login" | "preflight" | "goto" | "sendPrompt" | "clickSession" | "screenshot" | "mark" | "stop"; +type NodeWebProbeObserveCommandType = "login" | "preflight" | "goto" | "sendPrompt" | "selectProvider" | "clickSession" | "screenshot" | "mark" | "stop"; interface NodeWebProbeObserveOptions { action: "observe"; @@ -83,6 +83,7 @@ interface NodeWebProbeObserveOptions { commandPath: string | null; commandLabel: string | null; commandSessionId: string | null; + commandProvider: string | null; } type NodeWebProbeOptions = NodeWebProbeRunOptions | NodeWebProbeScriptOptions | NodeWebProbeObserveOptions; @@ -6062,7 +6063,8 @@ function parseNodeWebProbeObserveOptions(args: string[], node: string, lane: str "--text", "--path", "--label", - "--session-id", + "--session-id", + "--provider", ]), new Set(["--force"])); const commandTypeRaw = optionValue(args, "--type") ?? null; const commandType = commandTypeRaw === null ? null : parseNodeWebProbeObserveCommandType(commandTypeRaw); @@ -6096,6 +6098,7 @@ function parseNodeWebProbeObserveOptions(args: string[], node: string, lane: str commandPath: optionValue(args, "--path") ?? null, commandLabel: optionValue(args, "--label") ?? null, commandSessionId: optionValue(args, "--session-id") ?? null, + commandProvider: optionValue(args, "--provider") ?? null, }; } @@ -6105,12 +6108,13 @@ function parseNodeWebProbeObserveCommandType(value: string): NodeWebProbeObserve || value === "preflight" || value === "goto" || value === "sendPrompt" + || value === "selectProvider" || value === "clickSession" || value === "screenshot" || value === "mark" || value === "stop" ) return value; - throw new Error(`web-probe observe command --type must be login, preflight, goto, sendPrompt, clickSession, screenshot, mark, or stop; got ${value}`); + throw new Error(`web-probe observe command --type must be login, preflight, goto, sendPrompt, selectProvider, clickSession, screenshot, mark, or stop; got ${value}`); } function nodeWebProbeAutoCommandTimeoutSeconds(input: { @@ -6503,6 +6507,7 @@ function runNodeWebProbeObserveCommand(options: NodeWebProbeObserveOptions, spec text: options.commandText, label: options.commandLabel, sessionId: options.commandSessionId, + provider: options.commandProvider, }; const payloadB64 = Buffer.from(JSON.stringify(payload), "utf8").toString("base64"); const script = [ @@ -6674,6 +6679,7 @@ function commandSummaryForOutput(payload: Record): Record { + const normalized = String(name).toLowerCase(); + const visible = (element) => { + const rect = element.getBoundingClientRect(); + const style = window.getComputedStyle(element); + return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none"; + }; + for (const select of Array.from(document.querySelectorAll("select")).filter(visible)) { + const options = Array.from(select.options || []); + const option = options.find((item) => String(item.value || "").toLowerCase().includes(normalized) || String(item.textContent || "").toLowerCase().includes(normalized)); + if (!option) continue; + select.value = option.value; + select.dispatchEvent(new Event("input", { bubbles: true })); + select.dispatchEvent(new Event("change", { bubbles: true })); + return { kind: "native-select", value: option.value, text: option.textContent || "" }; + } + return null; + }, target).catch(() => null); + if (nativeSelect) { + await page.waitForTimeout(500); + return { beforeUrl, afterUrl: currentPageUrl(), provider: target, selected: nativeSelect, pageId }; + } + const optionVisible = page.getByText(target, { exact: false }).last(); + if (await optionVisible.isVisible().catch(() => false)) { + await optionVisible.click(); + await page.waitForTimeout(500); + return { beforeUrl, afterUrl: currentPageUrl(), provider: target, selected: { kind: "visible-text" }, pageId }; + } + const control = page.locator([ + '[data-testid*="provider" i]', + '[data-testid*="profile" i]', + '[data-testid*="model" i]', + '[aria-label*="provider" i]', + '[aria-label*="profile" i]', + '[aria-label*="model" i]', + '[aria-label*="模型"]', + '[aria-label*="提供"]', + '[role="combobox"]', + '[aria-haspopup="listbox"]', + 'button' + ].join(", ")); + const count = Math.min(await control.count().catch(() => 0), 60); + const attempts = []; + for (let index = count - 1; index >= 0; index -= 1) { + const item = control.nth(index); + const visible = await item.isVisible().catch(() => false); + if (!visible) continue; + const label = await item.evaluate((element) => String(element.getAttribute("aria-label") || element.getAttribute("data-testid") || element.textContent || "").slice(0, 200)).catch(() => ""); + if (!/provider|profile|model|模型|提供|codex|openai|moon|api/iu.test(label)) continue; + attempts.push({ index, label }); + await item.click({ timeout: 3000 }).catch(() => null); + await page.waitForTimeout(300); + const option = page.getByText(target, { exact: false }).last(); + if (await option.isVisible().catch(() => false)) { + await option.click(); + await page.waitForTimeout(700); + return { beforeUrl, afterUrl: currentPageUrl(), provider: target, selected: { kind: "opened-control", controlIndex: index, label }, attempts, pageId }; + } + await page.keyboard.press("Escape").catch(() => null); + } + throw new Error("provider option not found: " + target + "; attempts=" + JSON.stringify(attempts.slice(-10))); +} + async function clickSession(sessionId) { if (!sessionId) throw new Error("clickSession requires --session-id or --value"); const beforeUrl = currentPageUrl(); @@ -441,6 +509,7 @@ function commandInputSummary(command) { path: command.path || null, url: command.url ? safeUrl(command.url) : null, sessionId: command.sessionId || command.value || null, + provider: command.provider || null, label: command.label ? truncate(command.label, 200) : null, textHash: text === null ? null : sha256Text(text), textBytes: text === null ? null : Buffer.byteLength(text),