fix: stabilize hwlab web-probe script navigation

This commit is contained in:
Codex
2026-06-18 10:21:02 +00:00
parent 5ba0e9b0c7
commit 79a0e53834
4 changed files with 309 additions and 7 deletions
@@ -326,6 +326,9 @@ function scriptHelpers() {
goto,
gotoRaw,
gotoStable,
reloadStable,
safeReload,
gotoCurrentStable,
waitWorkbenchReady,
waitForReady,
fetchJson,
@@ -819,6 +822,155 @@ async function gotoRaw(target = "/workbench", options = {}) {
return page.url();
}
async function reloadStable(options = {}) {
return stableCurrentPageNavigation("reload-stable", null, normalizeHelperOptions(options));
}
async function safeReload(options = {}) {
try {
return await reloadStable(options);
} catch (error) {
const failure = classifiedProbeError(error);
return {
ok: false,
failureKind: failure.failureKind,
error: failure.code,
errorMessage: sanitize(failure.message),
guidance: failure.guidance,
beforeUrl: error && typeof error === "object" && typeof error.beforeUrl === "string" ? error.beforeUrl : null,
finalUrl: currentPageUrl(),
readiness: publicReadiness({ error: failure.code, failureKind: failure.failureKind }),
};
}
}
async function gotoCurrentStable(options = {}) {
options = normalizeHelperOptions(options);
const target = typeof options.url === "string" && options.url.length > 0 ? new URL(options.url, baseUrl).toString() : currentPageUrl();
if (!target) throw stableProbeError("browser-load-jitter", "cannot retry current page navigation because the browser page has no URL", publicReadiness({ error: "browser-page-missing" }));
return stableCurrentPageNavigation("goto-current-stable", target, options);
}
async function stableCurrentPageNavigation(operation, targetUrl, options = {}) {
options = normalizeHelperOptions(options);
const attempts = boundedInteger(options.attempts, 3, 1, 5);
const retryDelayMs = boundedInteger(options.retryDelayMs, 350, 0, 5000);
const beforeUrl = currentPageUrl();
let retryUrl = targetUrl;
let lastRecord = null;
for (let attempt = 1; attempt <= attempts; attempt += 1) {
if (!page || page.isClosed()) page = await context.newPage();
const attemptPage = page;
const attemptBeforeUrl = currentPageUrl();
if (!retryUrl && !attemptBeforeUrl) {
throw stableProbeError("browser-load-jitter", "cannot reload because the browser page has no URL", publicReadiness({ error: "browser-page-missing" }));
}
const navigationTarget = retryUrl || attemptBeforeUrl;
const readinessSelectors = normalizeSelectorList(options.selectors, defaultReadinessSelectors(navigationTarget || baseUrl));
const network = createNetworkTracker(attemptPage, options.apiPattern);
const record = {
ok: false,
attempt,
attempts,
policy: operation,
targetPath: urlPath(navigationTarget),
beforePath: urlPath(attemptBeforeUrl),
finalPath: null,
stage: operation,
error: null,
message: null,
selector: null,
selectors: [],
apiRequestsSent: false,
apiRequestCount: 0,
apiResponseFailed: false,
apiFailureCount: 0,
screenshot: null,
durationMs: 0,
};
const started = Date.now();
try {
if (operation === "reload-stable" && attempt === 1) {
await attemptPage.reload(navigationOptions(options));
} else {
await attemptPage.goto(navigationTarget, navigationOptions(options));
}
retryUrl = attemptPage.url();
record.finalPath = urlPath(attemptPage.url());
const readiness = await waitForReadyInternal(attemptPage, {
...options,
selectors: readinessSelectors,
url: attemptPage.url(),
}, network);
applyReadinessToRecord(record, readiness);
if (!readiness.ok) {
record.screenshot = await captureReadinessScreenshot(attemptPage, attempt, options);
lastRecord = record;
} else {
record.ok = true;
record.stage = "ready";
lastRecord = record;
}
} catch (error) {
record.finalPath = attemptPage && !attemptPage.isClosed() ? urlPath(attemptPage.url()) : null;
record.error = classifyNavigationError(error);
record.message = error instanceof Error ? error.message : String(error);
record.screenshot = await captureReadinessScreenshot(attemptPage, attempt, options);
lastRecord = record;
} finally {
Object.assign(record, network.summary());
record.durationMs = Date.now() - started;
network.dispose();
readinessRecords.push(record);
}
if (record.ok) {
const result = {
ok: true,
operation,
attempt,
attempts,
beforeUrl,
finalUrl: page.url(),
readiness: record,
summary: publicReadiness(),
};
recordStep(typeof options.name === "string" ? options.name : operation, {
ok: true,
operation,
attempt,
attempts,
beforeUrl,
finalUrl: result.finalUrl,
});
Object.defineProperty(result, "page", {
enumerable: false,
value: page,
});
return result;
}
if (attempt < attempts && retryDelayMs > 0) await sleep(retryDelayMs);
}
const code = lastRecord?.error || "browser-load-jitter";
const message = lastRecord?.message || operation + " did not become ready";
recordStep(typeof options.name === "string" ? options.name : operation, {
ok: false,
operation,
attempts,
beforeUrl,
finalUrl: currentPageUrl(),
error: code,
message,
screenshot: lastRecord?.screenshot ?? null,
});
const error = stableProbeError(code, message, publicReadiness({ error: code }));
error.beforeUrl = beforeUrl;
throw error;
}
async function gotoStable(target = "/workbench", options = {}) {
options = normalizeHelperOptions(options);
const url = new URL(target, baseUrl).toString();
@@ -1126,13 +1278,16 @@ function navigationOptions(options = {}) {
"expectApiRequest",
"failOnApiResponseFailed",
"freshPage",
"name",
"readinessTimeoutMs",
"record",
"retryDelayMs",
"reusePage",
"screenshotOnFailure",
"selectorState",
"selectors",
"stable",
"url",
]) {
delete next[key];
}
@@ -1300,7 +1455,8 @@ function probeFailureKind(code, message) {
if (code === "script-api-misuse") return "script-api-misuse";
if (code === "auth-login-failed") return "auth-failed";
if (code === "browser-failed") return "browser-failed";
if (code === "browser-load-jitter" || code === "selector-timeout" || code === "api-not-sent" || code === "api-response-failed") return "navigation-failed";
if (code === "browser-load-jitter") return "browser-navigation-jitter";
if (code === "selector-timeout" || code === "api-not-sent" || code === "api-response-failed") return "navigation-failed";
if (/browser has been closed|browser executable|failed to launch/iu.test(message)) return "browser-failed";
return "assertion-failed";
}
@@ -1310,6 +1466,7 @@ function probeFailureGuidance(failureKind) {
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 === "browser-navigation-jitter") return "Use reloadStable(), gotoCurrentStable(), or safeReload() to retry the same browser navigation; inspect readiness.last, lastUrl, and screenshots before treating this as an API fetch failure.";
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 === "browser-failed") return "Inspect Playwright/browser-launcher availability on the target workspace.";
@@ -1765,6 +1922,7 @@ function compactSummaryForStdout(summary) {
nextAction: typeof value.nextAction === "string" ? compactText(value.nextAction, 1200) : value.nextAction ?? null,
baseUrl: value.baseUrl ?? null,
finalUrl: value.finalUrl ?? null,
lastUrl: value.lastUrl ?? null,
scriptSha256: value.scriptSha256 ?? null,
runDir: value.runDir ?? runDir,
reportPath: value.reportPath ?? null,
@@ -1869,6 +2027,7 @@ function scriptIssueSummary(payload) {
nextAction: ok ? null : issueNextAction(failureKind, payload),
baseUrl: payload?.baseUrl ?? null,
finalUrl: payload?.finalUrl ?? payload?.lastUrl ?? null,
lastUrl: payload?.lastUrl ?? payload?.finalUrl ?? null,
scriptSha256: payload?.scriptSha256 ?? null,
runDir,
reportPath: payload?.reportPath ?? null,
@@ -1897,6 +2056,7 @@ function classifyIssueFailureKind(kind, message = "") {
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 (/browser-navigation-jitter|browser-load-jitter|page\.(?:reload|goto)|gotoCurrentStable|reloadStable|safeReload|ERR_NETWORK_CHANGED|ERR_ABORTED|net::|navigation failed|domcontentloaded|chrome-error:\/\/chromewebdata/iu.test(text)) return "browser-navigation-jitter";
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";
@@ -1910,6 +2070,7 @@ 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 === "browser-navigation-jitter") return "Use reloadStable(), gotoCurrentStable(), or safeReload() to retry the same URL/page reload with bounded attempts; inspect summary.finalUrl, summary.lastScreenshot, and readiness attempts instead of the API matrix first.";
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.";