diff --git a/scripts/src/hwlab-node-web-probe-runner-source.ts b/scripts/src/hwlab-node-web-probe-runner-source.ts index 341760ca..6603febe 100644 --- a/scripts/src/hwlab-node-web-probe-runner-source.ts +++ b/scripts/src/hwlab-node-web-probe-runner-source.ts @@ -309,10 +309,11 @@ async function formLoginOutcome(targetPage) { } function scriptHelpers() { + const livePage = createLivePageProxy(); const helpers = { browser, context, - page, + page: livePage, baseUrl, runDir, auth: publicAuth(auth), @@ -345,6 +346,27 @@ function scriptHelpers() { return helpers; } +function createLivePageProxy() { + return new Proxy({}, { + get(_target, prop) { + if (prop === "then") return undefined; + const target = page; + if (!target) throw stableProbeError("browser-load-jitter", "browser page is not initialized", publicReadiness({ error: "browser-page-missing" })); + const value = target[prop]; + return typeof value === "function" ? value.bind(target) : value; + }, + set(_target, prop, value) { + const target = page; + if (!target) throw stableProbeError("browser-load-jitter", "browser page is not initialized", publicReadiness({ error: "browser-page-missing" })); + target[prop] = value; + return true; + }, + has(_target, prop) { + return Boolean(page && prop in page); + } + }); +} + function recordStep(name, data = {}, options = {}) { const normalizedName = String(name || "step").replace(/[^A-Za-z0-9_.:-]/gu, "-").slice(0, 80) || "step"; const item = { @@ -542,12 +564,20 @@ async function fetchJson(target, options = {}) { parseError: typeof response.parseError === "string" ? response.parseError : null, error: typeof response.error === "string" ? response.error : null, }; + result.failureKind = result.ok ? null : classifyFetchFailureKind(result); if (options.throwOnError === true && result.ok !== true) { - throw stableProbeError("api-response-failed", result.error || result.statusText || "fetchJson failed", publicReadiness({ error: "api-response-failed" })); + throw stableProbeError(result.failureKind || "api-response-failed", result.error || result.statusText || "fetchJson failed", publicReadiness({ error: result.failureKind || "api-response-failed" })); } return result; } +function classifyFetchFailureKind(result) { + const text = String(result?.error ?? "") + " " + String(result?.statusText ?? ""); + if (Number(result?.status ?? 0) === 0 && /Failed to fetch|fetch-error|network|NetworkError/iu.test(text)) return "same-origin-api-fetch-failed"; + if (Number(result?.status ?? 0) === 401 || Number(result?.status ?? 0) === 403) return "auth-or-session-api-failed"; + return "api-response-failed"; +} + async function collectText(selector, options = {}) { options = normalizeHelperOptions(options); const normalizedSelector = String(selector || "").trim(); @@ -581,7 +611,136 @@ async function waitWorkbenchReady(options = {}) { const next = { ...options }; delete next.target; if (next.selectors === undefined) next.selectors = ["#workspace", "#command-input"]; - return gotoStable(target, next); + const stable = await gotoStable(target, next); + const readiness = await ensureWorkbenchComposerReady(next); + const result = { + ...stable, + shellReady: stable.ok === true, + sessionReady: readiness.sessionReady, + composerReady: readiness.composerReady, + composer: readiness.composer, + workspace: readiness.workspace, + sessionRepair: readiness.sessionRepair + }; + recordStep("workbench-ready", { + ok: result.shellReady && result.sessionReady && result.composerReady, + finalUrl: result.finalUrl, + shellReady: result.shellReady, + sessionReady: result.sessionReady, + composerReady: result.composerReady, + disabledReason: result.composer?.disabledReason ?? null, + sessionRepair: result.sessionRepair + }); + if (options.throwOnFailure === true && (!result.sessionReady || !result.composerReady)) { + const reason = result.composer?.disabledReason || (!result.sessionReady ? "session-not-selected" : "composer-disabled"); + throw stableProbeError(reason === "session_required" ? "session-not-selected" : "composer-disabled", "Workbench composer is not ready: " + JSON.stringify({ reason, composer: result.composer, workspace: result.workspace }), publicReadiness({ error: reason })); + } + return result; +} + +async function ensureWorkbenchComposerReady(options = {}) { + const before = await collectWorkbenchReadyState(); + let sessionRepair = null; + let after = before; + if (before.composer.disabledReason === "session_required" && options.ensureSession !== false) { + sessionRepair = await createProbeSessionFromUi(options); + after = await collectWorkbenchReadyState(); + } + const timeout = boundedInteger(options.composerTimeoutMs ?? options.readinessTimeoutMs ?? options.timeoutMs, Math.min(timeoutMs, 10000), 1, Math.max(timeoutMs, 60000)); + if (!after.composer.ready) { + await page.waitForFunction(() => { + const input = document.querySelector("#command-input"); + const send = document.querySelector("#command-send"); + const warning = document.querySelector(".composer-warning")?.textContent?.trim() || ""; + const activeTab = document.querySelector(".session-tab[data-active='true'], .session-tab[aria-selected='true']"); + return Boolean(input && send && activeTab && !input.disabled && !warning); + }, null, { timeout }).catch(() => null); + after = await collectWorkbenchReadyState(); + } + return { + shellReady: after.shellReady, + sessionReady: after.sessionReady, + composerReady: after.composer.ready, + composer: after.composer, + workspace: after.workspace, + before, + after, + sessionRepair + }; +} + +async function createProbeSessionFromUi(options = {}) { + const create = page.locator("#session-create").first(); + if (!(await create.isVisible({ timeout: Math.min(timeoutMs, 5000) }).catch(() => false))) { + return { ok: false, method: "ui-click", reason: "session-create-not-visible" }; + } + const before = await collectWorkbenchReadyState(); + await create.click(); + const timeout = boundedInteger(options.sessionCreateTimeoutMs ?? options.readinessTimeoutMs ?? options.timeoutMs, Math.min(timeoutMs, 15000), 1, Math.max(timeoutMs, 60000)); + await page.waitForFunction((initial) => { + const activeTab = document.querySelector(".session-tab[data-active='true'], .session-tab[aria-selected='true']"); + const sessionId = activeTab?.getAttribute("data-session-id") || ""; + const warning = document.querySelector(".composer-warning")?.textContent?.trim() || ""; + const input = document.querySelector("#command-input"); + return Boolean(activeTab && sessionId && sessionId !== initial.sessionId && input && !input.disabled && !warning); + }, { sessionId: before.workspace.activeSessionId }, { timeout }).catch(() => null); + const after = await collectWorkbenchReadyState(); + return { + ok: after.sessionReady && after.composer.ready, + method: "ui-click", + before: before.workspace, + after: after.workspace, + composer: after.composer + }; +} + +async function collectWorkbenchReadyState() { + return page.evaluate(() => { + const activeTab = document.querySelector(".session-tab[data-active='true'], .session-tab[aria-selected='true']"); + const routeMatch = window.location.pathname.match(/\/workbench\/sessions\/([^/]+)/u) || window.location.pathname.match(/\/workspace\/sessions\/([^/]+)/u); + const input = document.querySelector("#command-input"); + const send = document.querySelector("#command-send"); + const form = document.querySelector("#command-form"); + const warning = document.querySelector(".composer-warning")?.textContent?.trim() || null; + const mode = document.querySelector(".composer-mode")?.textContent?.trim() || null; + const draft = input && typeof input.value === "string" ? input.value : ""; + const sendAction = send?.getAttribute("data-action") || null; + const inputVisible = Boolean(input); + const sendVisible = Boolean(send); + const inputDisabled = input ? Boolean(input.disabled) : null; + const sendDisabled = send ? Boolean(send.disabled) : null; + const disabledReason = warning || (form?.getAttribute("title") === "session_required" ? "session_required" : null); + const sendDisabledReason = disabledReason || (sendDisabled && draft.trim().length === 0 && sendAction !== "cancel" ? "empty_draft" : sendDisabled ? "button_disabled" : null); + const sessionId = activeTab?.getAttribute("data-session-id") || null; + const conversationId = activeTab?.getAttribute("data-conversation-id") || (routeMatch ? decodeURIComponent(routeMatch[1] || "") : null); + const sessionReady = Boolean(sessionId || conversationId); + const composerReady = Boolean(inputVisible && sendVisible && inputDisabled === false && !disabledReason && sessionReady); + return { + shellReady: Boolean(document.querySelector("#workspace") && inputVisible), + sessionReady, + workspace: { + finalUrl: window.location.href, + routeConversationId: routeMatch ? decodeURIComponent(routeMatch[1] || "") : null, + activeSessionId: sessionId, + activeConversationId: conversationId, + activeStatus: activeTab?.getAttribute("data-status") || null, + tabCount: document.querySelectorAll(".session-tab").length + }, + composer: { + ready: composerReady, + inputVisible, + inputDisabled, + inputLength: draft.length, + sendVisible, + sendDisabled, + sendAction, + disabledReason, + sendDisabledReason, + mode, + formTitle: form?.getAttribute("title") || null + } + }; + }); } async function screenshotOnError(name = "failure.png", fn = null, options = {}) { @@ -1104,9 +1263,14 @@ function probeErrorCode(error, message) { const known = new Set([ "assertion-failed", "auth-login-failed", + "auth-or-session-api-failed", + "auth-redirect-login", "browser-failed", "browser-load-jitter", + "composer-disabled", "selector-timeout", + "same-origin-api-fetch-failed", + "session-not-selected", "api-not-sent", "api-response-failed", "script-api-misuse", @@ -1116,6 +1280,10 @@ function probeErrorCode(error, message) { if (explicit && known.has(explicit)) return explicit; if (known.has(message)) return message; if (isEvaluateApiMisuse(message)) return "script-api-misuse"; + if (/same-origin-api-fetch-failed|Failed to fetch/iu.test(message)) return "same-origin-api-fetch-failed"; + if (/auth-redirect-login|login\?redirect|authState.*login/iu.test(message)) return "auth-redirect-login"; + if (/session_required|session-not-selected|session not selected/iu.test(message)) return "session-not-selected"; + if (/composer-disabled|command-send|command bar|composer is not ready|button_disabled|disabledReason/iu.test(message)) return "composer-disabled"; if (/AssertionError|ERR_ASSERTION|\bassert(?:ion)?\b|expect\(.*\)|ok:false/iu.test(message)) return "assertion-failed"; if (/auth-login-failed|login form not visible|missing HWLAB_WEB_PASS/iu.test(message)) return "auth-login-failed"; if (/executable doesn't exist|browser executable|failed to launch|browserType\.launch|browser has been closed/iu.test(message)) return "browser-failed"; @@ -1124,6 +1292,11 @@ function probeErrorCode(error, message) { } function probeFailureKind(code, message) { + if (code === "auth-redirect-login") return "auth-redirect-login"; + if (code === "same-origin-api-fetch-failed") return "same-origin-api-fetch-failed"; + if (code === "composer-disabled") return "composer-disabled"; + if (code === "session-not-selected") return "session-not-selected"; + if (code === "auth-or-session-api-failed") return "auth-or-session-api-failed"; if (code === "script-api-misuse") return "script-api-misuse"; if (code === "auth-login-failed") return "auth-failed"; if (code === "browser-failed") return "browser-failed"; @@ -1133,6 +1306,9 @@ function probeFailureKind(code, message) { } function probeFailureGuidance(failureKind) { + if (failureKind === "composer-disabled" || failureKind === "session-not-selected") return "Inspect readiness.composer and readiness.workspace; waitWorkbenchReady can create/select a submit-capable session unless ensureSession:false is set."; + if (failureKind === "auth-redirect-login") return "Inspect auth/session bootstrap and finalUrl; the browser returned to /login after CLI-managed auth."; + if (failureKind === "same-origin-api-fetch-failed") return "Inspect the failed same-origin API path, current URL, auth state, and browser console/network evidence in the full report."; if (failureKind === "script-api-misuse") return evaluateSingleArgGuidance(); if (failureKind === "navigation-failed") return "Inspect probe.readiness for selector/API readiness details and lastScreenshot for the browser state."; if (failureKind === "auth-failed") return "Inspect probe.auth sourceRef/fingerprint/status; do not print or copy the web login secret."; @@ -1717,6 +1893,10 @@ function latestApiMatrixFromSteps(steps) { function classifyIssueFailureKind(kind, message = "") { const text = String(kind ?? "") + " " + String(message ?? ""); + if (/auth-redirect-login/iu.test(text)) return "auth-redirect-login"; + if (/composer-disabled/iu.test(text)) return "composer-disabled"; + if (/session-not-selected|session_required/iu.test(text)) return "session-not-selected"; + if (/same-origin-api-fetch-failed/iu.test(text)) return "same-origin-api-fetch-failed"; if (/api|fetch|network|Failed to fetch|HTTP|status/iu.test(text)) return "network-or-api-fetch-bug"; if (/script-api-misuse|page\.evaluate|safeEvaluate|Too many arguments/iu.test(text)) return "script-bug"; if (/auth|login|credential/iu.test(text)) return "target-auth-bug"; @@ -1727,6 +1907,9 @@ function classifyIssueFailureKind(kind, message = "") { } function issueNextAction(failureKind, payload) { + if (failureKind === "auth-redirect-login") return "Inspect summary.finalUrl, auth/session bootstrap, and target login redirect handling before rerunning fresh-session."; + if (failureKind === "composer-disabled" || failureKind === "session-not-selected") return "Inspect readiness.composer disabledReason and workspace/session summary; create or select a submit-capable session, then rerun the same probe."; + if (failureKind === "same-origin-api-fetch-failed") return "Inspect summary.apiMatrix failed rows, current URL, auth state, and browser console/network evidence in reportPath."; if (failureKind === "network-or-api-fetch-bug") return "Inspect summary.apiMatrix failed rows and retry the same node/lane after checking API availability."; if (failureKind === "script-bug") return "Fix the probe script or use safeEvaluate/safeFetchJson/fetchApiMatrix helpers, then rerun the same command."; if (failureKind === "target-auth-bug") return "Inspect credential sourceRef/fingerprint and target /auth/login state; do not print secrets.";