fix: stabilize hwlab web probe script readiness

This commit is contained in:
Codex
2026-06-18 09:17:52 +00:00
parent aabb9df3cd
commit e3a3d2b421
@@ -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.";