From 3c92f64f21a24c6834472a53f6016b78cc4d89aa Mon Sep 17 00:00:00 2001 From: Lyon <88232613+pikasTech@users.noreply.github.com> Date: Mon, 22 Jun 2026 21:29:07 +0800 Subject: [PATCH] fix(web-probe): require workbench readiness after navigation (#682) Co-authored-by: Codex --- .../hwlab-node-web-observe-runner-source.ts | 74 ++++++++++++++++++- 1 file changed, 70 insertions(+), 4 deletions(-) diff --git a/scripts/src/hwlab-node-web-observe-runner-source.ts b/scripts/src/hwlab-node-web-observe-runner-source.ts index 403dca33..bb1841d2 100644 --- a/scripts/src/hwlab-node-web-observe-runner-source.ts +++ b/scripts/src/hwlab-node-web-observe-runner-source.ts @@ -557,12 +557,18 @@ async function gotoTarget(rawTarget) { const response = await page.goto(target, { waitUntil: "domcontentloaded", timeout: 30000 }); await page.waitForTimeout(1000).catch(() => {}); const httpStatus = response ? response.status() : null; + const readiness = await waitForTargetPageReady(page, target, { timeoutMs: 15000 }); + if (!readiness.ok) { + const error = new Error("workbench-app-not-ready: " + (readiness.reason || "target page did not expose the Workbench shell")); + error.navigationReadiness = readiness; + throw error; + } const pageProvenance = await refreshPageProvenance("goto", httpStatus); - attempts.push({ attempt, ok: true, httpStatus }); - return { beforeUrl, afterUrl: currentPageUrl(), httpStatus, pageId, pageProvenance: compactPageProvenance(pageProvenance), attempts }; + attempts.push({ attempt, ok: true, httpStatus, readiness }); + return { beforeUrl, afterUrl: currentPageUrl(), httpStatus, pageId, pageProvenance: compactPageProvenance(pageProvenance), readiness, attempts }; } catch (error) { const message = error instanceof Error ? error.message : String(error); - attempts.push({ attempt, ok: false, failureKind: navigationFailureKind(message), message: redactErrorMessage(message) }); + attempts.push({ attempt, ok: false, failureKind: navigationFailureKind(message), message: redactErrorMessage(message), readiness: error?.navigationReadiness ?? null }); if (attempt >= 3 || !isRetryableNavigationError(message)) { throw Object.assign(new Error(message), { attempts, target }); } @@ -661,7 +667,7 @@ function compactPageProvenance(value) { } function isRetryableNavigationError(message) { - return /net::ERR_NETWORK_CHANGED|net::ERR_ABORTED|net::ERR_CONNECTION_RESET|Navigation timeout/iu.test(String(message || "")); + return /net::ERR_NETWORK_CHANGED|net::ERR_ABORTED|net::ERR_CONNECTION_RESET|Navigation timeout|workbench-app-not-ready/iu.test(String(message || "")); } function navigationFailureKind(message) { @@ -670,6 +676,7 @@ function navigationFailureKind(message) { if (/net::ERR_ABORTED/iu.test(text)) return "net::ERR_ABORTED"; if (/net::ERR_CONNECTION_RESET/iu.test(text)) return "net::ERR_CONNECTION_RESET"; if (/Navigation timeout/iu.test(text)) return "navigation-timeout"; + if (/workbench-app-not-ready/iu.test(text)) return "workbench-app-not-ready"; return "navigation-error"; } @@ -679,6 +686,65 @@ function redactErrorMessage(message) { .replace(/(Bearer\s+)[A-Za-z0-9._~+/=-]+/gu, "$1[redacted]"); } +async function waitForTargetPageReady(targetPage, targetUrl, options = {}) { + const timeoutMs = Number.isFinite(Number(options.timeoutMs)) ? Math.max(1, Number(options.timeoutMs)) : 15000; + const targetPathname = safeUrlPath(targetUrl) || ""; + if (!isWorkbenchPathname(targetPathname)) return { ok: true, reason: "not-workbench-route", valuesRedacted: true }; + const started = Date.now(); + await targetPage.waitForFunction(() => { + const visible = (element) => { + if (!element) return false; + const rect = element.getBoundingClientRect(); + const style = window.getComputedStyle(element); + return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none"; + }; + const workspace = document.querySelector("#workspace, .workbench-route"); + const sessionCreate = document.querySelector("#session-create"); + const commandInput = document.querySelector("#command-input"); + const activeTab = document.querySelector(".session-tab[data-active='true'], .session-tab[aria-selected='true']"); + const warning = document.querySelector(".composer-warning"); + return Boolean(visible(workspace) && (visible(sessionCreate) || visible(commandInput) || visible(activeTab) || visible(warning))); + }, null, { timeout: timeoutMs }).catch(() => null); + const snapshot = await workbenchReadinessSnapshot(targetPage); + const ok = snapshot.workbenchShellVisible === true && (snapshot.sessionCreateVisible === true || snapshot.commandInputPresent === true || snapshot.activeTabPresent === true || snapshot.warningPresent === true); + return { + ok, + reason: ok ? "workbench-ready" : snapshot.loginVisible ? "login-visible" : "workbench-app-not-ready", + durationMs: Date.now() - started, + snapshot, + valuesRedacted: true + }; +} + +async function workbenchReadinessSnapshot(targetPage) { + return targetPage.evaluate(() => { + const visible = (element) => { + if (!element) return false; + const rect = element.getBoundingClientRect(); + const style = window.getComputedStyle(element); + return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none"; + }; + return { + url: window.location.href, + path: window.location.pathname, + readyState: document.readyState, + workbenchShellVisible: visible(document.querySelector("#workspace, .workbench-route")), + sessionCreateVisible: visible(document.querySelector("#session-create")), + commandInputPresent: visible(document.querySelector("#command-input")), + activeTabPresent: visible(document.querySelector(".session-tab[data-active='true'], .session-tab[aria-selected='true']")), + warningPresent: visible(document.querySelector(".composer-warning")), + loginVisible: visible(document.querySelector("form.login-card, .login-card, [data-testid='login']")), + bodyTextHash: sha256Text(String(document.body?.innerText || "").slice(0, 2000)), + valuesRedacted: true + }; + }).catch((error) => ({ error: errorSummary(error), valuesRedacted: true })); +} + +function isWorkbenchPathname(value) { + const pathname = String(value || ""); + return pathname === "/workbench" || pathname === "/workspace" || pathname.startsWith("/workbench/") || pathname.startsWith("/workspace/"); +} + async function createSessionFromUi() { const beforeUrl = currentPageUrl(); const before = await workbenchSessionSnapshot();