fix: stabilize hwlab web probe script readiness
This commit is contained in:
@@ -309,10 +309,11 @@ async function formLoginOutcome(targetPage) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function scriptHelpers() {
|
function scriptHelpers() {
|
||||||
|
const livePage = createLivePageProxy();
|
||||||
const helpers = {
|
const helpers = {
|
||||||
browser,
|
browser,
|
||||||
context,
|
context,
|
||||||
page,
|
page: livePage,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
runDir,
|
runDir,
|
||||||
auth: publicAuth(auth),
|
auth: publicAuth(auth),
|
||||||
@@ -345,6 +346,27 @@ function scriptHelpers() {
|
|||||||
return helpers;
|
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 = {}) {
|
function recordStep(name, data = {}, options = {}) {
|
||||||
const normalizedName = String(name || "step").replace(/[^A-Za-z0-9_.:-]/gu, "-").slice(0, 80) || "step";
|
const normalizedName = String(name || "step").replace(/[^A-Za-z0-9_.:-]/gu, "-").slice(0, 80) || "step";
|
||||||
const item = {
|
const item = {
|
||||||
@@ -542,12 +564,20 @@ async function fetchJson(target, options = {}) {
|
|||||||
parseError: typeof response.parseError === "string" ? response.parseError : null,
|
parseError: typeof response.parseError === "string" ? response.parseError : null,
|
||||||
error: typeof response.error === "string" ? response.error : null,
|
error: typeof response.error === "string" ? response.error : null,
|
||||||
};
|
};
|
||||||
|
result.failureKind = result.ok ? null : classifyFetchFailureKind(result);
|
||||||
if (options.throwOnError === true && result.ok !== true) {
|
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;
|
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 = {}) {
|
async function collectText(selector, options = {}) {
|
||||||
options = normalizeHelperOptions(options);
|
options = normalizeHelperOptions(options);
|
||||||
const normalizedSelector = String(selector || "").trim();
|
const normalizedSelector = String(selector || "").trim();
|
||||||
@@ -581,7 +611,136 @@ async function waitWorkbenchReady(options = {}) {
|
|||||||
const next = { ...options };
|
const next = { ...options };
|
||||||
delete next.target;
|
delete next.target;
|
||||||
if (next.selectors === undefined) next.selectors = ["#workspace", "#command-input"];
|
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 = {}) {
|
async function screenshotOnError(name = "failure.png", fn = null, options = {}) {
|
||||||
@@ -1104,9 +1263,14 @@ function probeErrorCode(error, message) {
|
|||||||
const known = new Set([
|
const known = new Set([
|
||||||
"assertion-failed",
|
"assertion-failed",
|
||||||
"auth-login-failed",
|
"auth-login-failed",
|
||||||
|
"auth-or-session-api-failed",
|
||||||
|
"auth-redirect-login",
|
||||||
"browser-failed",
|
"browser-failed",
|
||||||
"browser-load-jitter",
|
"browser-load-jitter",
|
||||||
|
"composer-disabled",
|
||||||
"selector-timeout",
|
"selector-timeout",
|
||||||
|
"same-origin-api-fetch-failed",
|
||||||
|
"session-not-selected",
|
||||||
"api-not-sent",
|
"api-not-sent",
|
||||||
"api-response-failed",
|
"api-response-failed",
|
||||||
"script-api-misuse",
|
"script-api-misuse",
|
||||||
@@ -1116,6 +1280,10 @@ function probeErrorCode(error, message) {
|
|||||||
if (explicit && known.has(explicit)) return explicit;
|
if (explicit && known.has(explicit)) return explicit;
|
||||||
if (known.has(message)) return message;
|
if (known.has(message)) return message;
|
||||||
if (isEvaluateApiMisuse(message)) return "script-api-misuse";
|
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 (/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 (/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";
|
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) {
|
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 === "script-api-misuse") return "script-api-misuse";
|
||||||
if (code === "auth-login-failed") return "auth-failed";
|
if (code === "auth-login-failed") return "auth-failed";
|
||||||
if (code === "browser-failed") return "browser-failed";
|
if (code === "browser-failed") return "browser-failed";
|
||||||
@@ -1133,6 +1306,9 @@ function probeFailureKind(code, message) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function probeFailureGuidance(failureKind) {
|
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 === "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 === "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.";
|
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 = "") {
|
function classifyIssueFailureKind(kind, message = "") {
|
||||||
const text = String(kind ?? "") + " " + String(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 (/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 (/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";
|
if (/auth|login|credential/iu.test(text)) return "target-auth-bug";
|
||||||
@@ -1727,6 +1907,9 @@ function classifyIssueFailureKind(kind, message = "") {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function issueNextAction(failureKind, payload) {
|
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 === "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 === "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.";
|
if (failureKind === "target-auth-bug") return "Inspect credential sourceRef/fingerprint and target /auth/login state; do not print secrets.";
|
||||||
|
|||||||
Reference in New Issue
Block a user