2359 lines
94 KiB
TypeScript
2359 lines
94 KiB
TypeScript
// SPEC: PJ2026-01060505 Workbench Performance draft-2026-06-17-p0.
|
|
// Responsibility: Source for the authenticated HWLAB node web-probe runner.
|
|
|
|
export function nodeWebProbeScriptRunnerSource(): string {
|
|
return String.raw`#!/usr/bin/env node
|
|
import { createHash } from "node:crypto";
|
|
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
|
|
const startedAtMs = Date.now();
|
|
const startedAt = new Date(startedAtMs).toISOString();
|
|
const baseUrl = normalizeBaseUrl(process.env.HWLAB_WEB_BASE_URL);
|
|
const username = process.env.HWLAB_WEB_USER || "admin";
|
|
const password = process.env.HWLAB_WEB_PASS || "";
|
|
const runDir = path.resolve(process.env.UNIDESK_WEB_PROBE_RUN_DIR || ".state/web-probe-script/run-manual");
|
|
const userScript = path.resolve(process.env.UNIDESK_WEB_PROBE_USER_SCRIPT || path.join(runDir, "user-script.mjs"));
|
|
const timeoutMs = positiveInteger(process.env.UNIDESK_WEB_PROBE_TIMEOUT_MS, 30000);
|
|
const viewport = parseViewport(process.env.UNIDESK_WEB_PROBE_VIEWPORT || "1440x900");
|
|
const browserProxyMode = parseBrowserProxyMode(process.env.UNIDESK_WEB_PROBE_BROWSER_PROXY_MODE || "auto");
|
|
const playwrightProxy = proxyConfigFromEnv(baseUrl);
|
|
const chromiumLaunchOptions = chromiumLaunchOptionsForProxy(playwrightProxy);
|
|
const artifactRecords = [];
|
|
const readinessRecords = [];
|
|
const stepRecords = [];
|
|
let browser;
|
|
let context;
|
|
let page;
|
|
let auth = null;
|
|
let cookieSecrets = [];
|
|
let userScriptSha256 = null;
|
|
|
|
try {
|
|
if (!password) throw new Error("missing HWLAB_WEB_PASS");
|
|
await mkdir(runDir, { recursive: true, mode: 0o700 });
|
|
userScriptSha256 = await sha256File(userScript).catch(() => null);
|
|
const launcher = await import(pathToFileURL(path.resolve("scripts/src/browser-launcher.mjs")).href);
|
|
const { chromium } = await launcher.importPlaywright();
|
|
browser = await launcher.launchChromium(chromium, chromiumLaunchOptions);
|
|
context = await browser.newContext({ viewport, ...(playwrightProxy === null ? {} : { proxy: playwrightProxy }) });
|
|
auth = await authenticate(context);
|
|
if (!auth.ok) throw new Error("auth-login-failed");
|
|
page = await context.newPage();
|
|
|
|
const mod = await import(pathToFileURL(userScript).href + "?t=" + Date.now());
|
|
const fn = typeof mod.default === "function" ? mod.default : typeof mod.run === "function" ? mod.run : typeof mod.probe === "function" ? mod.probe : null;
|
|
if (fn === null) throw new Error("custom script must export default, run, or probe function");
|
|
const scriptResult = await fn(scriptHelpers());
|
|
const safeResult = sanitize(scriptResult);
|
|
const scriptOk = scriptResultOk(safeResult);
|
|
const failure = scriptOk ? null : classifiedProbeError(assertionResultError(safeResult));
|
|
const lastScreenshot = scriptOk ? null : await captureFailureScreenshot("failure.png");
|
|
const lastUrl = currentPageUrl();
|
|
await emit({
|
|
ok: scriptOk,
|
|
status: scriptOk ? "pass" : "blocked",
|
|
command: "web-probe-script",
|
|
generatedAt: new Date().toISOString(),
|
|
startedAt,
|
|
baseUrl,
|
|
network: publicNetwork(playwrightProxy),
|
|
finalUrl: lastUrl,
|
|
lastUrl,
|
|
scriptSha256: userScriptSha256,
|
|
runDir,
|
|
auth: publicAuth(auth),
|
|
script: { ok: scriptOk, result: safeResult, steps: publicSteps() },
|
|
steps: publicSteps(),
|
|
failureKind: failure ? failure.failureKind : null,
|
|
error: failure ? failure.code : null,
|
|
errorMessage: failure ? sanitize(failure.message) : null,
|
|
guidance: failure ? failure.guidance : null,
|
|
lastScreenshot,
|
|
readiness: publicReadiness(failure ? { error: failure.code, failureKind: failure.failureKind } : {}),
|
|
artifacts: { runDir, items: artifactRecords },
|
|
safety: { valuesRedacted: true, secretValuesPrinted: false }
|
|
});
|
|
process.exitCode = scriptOk ? 0 : 2;
|
|
} catch (error) {
|
|
const failure = classifiedProbeError(error);
|
|
const lastScreenshot = await captureFailureScreenshot("failure.png");
|
|
const lastUrl = currentPageUrl();
|
|
await emit({
|
|
ok: false,
|
|
status: "blocked",
|
|
command: "web-probe-script",
|
|
generatedAt: new Date().toISOString(),
|
|
startedAt,
|
|
baseUrl,
|
|
network: publicNetwork(playwrightProxy),
|
|
finalUrl: lastUrl,
|
|
lastUrl,
|
|
scriptSha256: userScriptSha256,
|
|
runDir,
|
|
auth: auth === null ? null : publicAuth(auth),
|
|
script: { ok: false, steps: publicSteps() },
|
|
steps: publicSteps(),
|
|
failureKind: failure.failureKind,
|
|
error: failure.code,
|
|
errorMessage: sanitize(failure.message),
|
|
guidance: failure.guidance,
|
|
lastScreenshot,
|
|
readiness: publicReadiness({ error: failure.code, failureKind: failure.failureKind }),
|
|
artifacts: { runDir, items: artifactRecords },
|
|
safety: { valuesRedacted: true, secretValuesPrinted: false }
|
|
});
|
|
process.exitCode = 2;
|
|
} finally {
|
|
if (browser) await browser.close().catch(() => {});
|
|
}
|
|
|
|
async function authenticate(browserContext) {
|
|
return authenticateWithApiRetries(browserContext);
|
|
}
|
|
|
|
async function authenticateWithApiRetries(browserContext) {
|
|
const loginUrl = new URL("/auth/login", baseUrl).toString();
|
|
const attempts = [];
|
|
const maxAttempts = 5;
|
|
const initialDelayMs = 250;
|
|
const maxDelayMs = 5000;
|
|
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
const retryDelayMs = attempt < maxAttempts ? Math.min(maxDelayMs, initialDelayMs * (2 ** (attempt - 1))) : 0;
|
|
try {
|
|
const response = await browserContext.request.post(loginUrl, {
|
|
data: { username, password },
|
|
headers: { accept: "application/json", "content-type": "application/json" },
|
|
timeout: Math.min(timeoutMs, 12000),
|
|
});
|
|
const summary = await responseSummary(response);
|
|
const cookieState = await readAuthCookieState(browserContext);
|
|
const retryable = isRetryableAuthStatus(response.status());
|
|
const item = {
|
|
attempt,
|
|
retryAttempt: attempt,
|
|
retryMaxAttempts: maxAttempts,
|
|
retryLabel: attempt + "/" + maxAttempts,
|
|
retryDelayMs: retryable && attempt < maxAttempts ? retryDelayMs : 0,
|
|
method: "api",
|
|
...summary,
|
|
retryable,
|
|
cookiePresent: cookieState.cookiePresent,
|
|
cookieNames: cookieState.cookieNames,
|
|
};
|
|
attempts.push(item);
|
|
if (response.ok() && cookieState.cookiePresent) {
|
|
return {
|
|
ok: true,
|
|
method: "api",
|
|
loginUrl: new URL("/auth/login", baseUrl).pathname,
|
|
status: response.status(),
|
|
statusText: response.statusText(),
|
|
cookiePresent: true,
|
|
cookieNames: cookieState.cookieNames,
|
|
attempts,
|
|
retryCount: attempt - 1,
|
|
retryMaxAttempts: maxAttempts,
|
|
lastRetryLabel: attempt + "/" + maxAttempts,
|
|
retryExhausted: false,
|
|
fallbackUsed: false,
|
|
valuesRedacted: true,
|
|
};
|
|
}
|
|
if (!retryable) break;
|
|
} catch (error) {
|
|
attempts.push({
|
|
attempt,
|
|
retryAttempt: attempt,
|
|
retryMaxAttempts: maxAttempts,
|
|
retryLabel: attempt + "/" + maxAttempts,
|
|
retryDelayMs: attempt < maxAttempts ? retryDelayMs : 0,
|
|
method: "api",
|
|
status: 0,
|
|
statusText: "request-error",
|
|
retryable: true,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
cookiePresent: false,
|
|
});
|
|
}
|
|
if (attempt < maxAttempts && attempts[attempts.length - 1]?.retryable === true) await sleep(retryDelayMs);
|
|
}
|
|
const cookieState = await readAuthCookieState(browserContext);
|
|
const last = attempts[attempts.length - 1] ?? null;
|
|
const retryable = authAttemptsRetryable(attempts);
|
|
return {
|
|
ok: false,
|
|
method: "api",
|
|
loginUrl: new URL("/auth/login", baseUrl).pathname,
|
|
status: typeof last?.status === "number" ? last.status : 0,
|
|
statusText: typeof last?.statusText === "string" ? last.statusText : "api-login-failed",
|
|
cookiePresent: cookieState.cookiePresent,
|
|
cookieNames: cookieState.cookieNames,
|
|
attempts,
|
|
retryCount: retryCountFromAttempts(attempts),
|
|
retryMaxAttempts: maxAttempts,
|
|
lastRetryLabel: last?.retryLabel ?? null,
|
|
retryExhausted: retryable && attempts.length >= maxAttempts,
|
|
fallbackUsed: false,
|
|
retryable,
|
|
errorSummary: last,
|
|
valuesRedacted: true,
|
|
};
|
|
}
|
|
|
|
async function authenticateWithFormFallback(browserContext, apiAuth) {
|
|
const loginPage = await browserContext.newPage();
|
|
const fallback = {
|
|
method: "form",
|
|
startPath: "/workbench",
|
|
surface: null,
|
|
finalPath: null,
|
|
response: null,
|
|
outcome: null,
|
|
error: null,
|
|
};
|
|
try {
|
|
await loginPage.goto(new URL("/workbench", baseUrl).toString(), { waitUntil: "domcontentloaded", timeout: timeoutMs });
|
|
const surface = await waitForAuthSurface(loginPage);
|
|
fallback.surface = surface;
|
|
if (surface === "workspace") {
|
|
const cookieState = await readAuthCookieState(browserContext);
|
|
fallback.finalPath = new URL(loginPage.url()).pathname;
|
|
return {
|
|
ok: cookieState.cookiePresent,
|
|
method: "form-fallback",
|
|
loginUrl: new URL("/auth/login", baseUrl).pathname,
|
|
status: cookieState.cookiePresent ? 200 : 0,
|
|
statusText: cookieState.cookiePresent ? "already-authenticated" : "workspace-without-session-cookie",
|
|
cookiePresent: cookieState.cookiePresent,
|
|
cookieNames: cookieState.cookieNames,
|
|
attempts: apiAuth.attempts,
|
|
retryCount: apiAuth.retryCount ?? retryCountFromAttempts(apiAuth.attempts),
|
|
fallbackUsed: true,
|
|
retryable: apiAuth.retryable === true,
|
|
apiErrorSummary: apiAuth.errorSummary,
|
|
fallback,
|
|
valuesRedacted: true,
|
|
};
|
|
}
|
|
if (surface !== "login-card" && surface !== "legacy-id") {
|
|
throw new Error("login form not visible");
|
|
}
|
|
|
|
const usernameInput = loginPage.locator('#login-username, input[autocomplete="username"], .login-card input').first();
|
|
const passwordInput = loginPage.locator('#login-password, input[autocomplete="current-password"], .login-card input[type="password"], input[type="password"]').first();
|
|
const submitButton = loginPage.locator('#login-submit, .login-card button[type="submit"], button[type="submit"]').first();
|
|
await usernameInput.fill(username);
|
|
await passwordInput.fill(password);
|
|
await loginPage.waitForFunction((expected) => {
|
|
const user = document.querySelector('#login-username, input[autocomplete="username"], .login-card input');
|
|
const pass = document.querySelector('#login-password, input[autocomplete="current-password"], .login-card input[type="password"], input[type="password"]');
|
|
const submit = document.querySelector('#login-submit, .login-card button[type="submit"], button[type="submit"]');
|
|
return Boolean(user && pass && user.value === expected.user && pass.value.length === expected.passwordLength && !submit?.disabled);
|
|
}, { user: username, passwordLength: password.length }, { timeout: Math.min(timeoutMs, 5000) }).catch(() => null);
|
|
|
|
const loginResponsePromise = loginPage.waitForResponse((response) => responsePathMatches(response.url(), "/auth/login"), { timeout: Math.min(timeoutMs, 15000) }).catch(() => null);
|
|
await Promise.allSettled([
|
|
loginPage.waitForFunction(() => document.querySelector("#workspace") || document.querySelector("#command-input"), null, { timeout: Math.min(timeoutMs, 15000) }),
|
|
submitButton.click(),
|
|
]);
|
|
const loginResponse = await loginResponsePromise;
|
|
if (loginResponse) fallback.response = await responseSummary(loginResponse);
|
|
fallback.outcome = await formLoginOutcome(loginPage);
|
|
fallback.finalPath = new URL(loginPage.url()).pathname;
|
|
assertNoCredentialLeak(loginPage.url());
|
|
|
|
const cookieState = await readAuthCookieState(browserContext);
|
|
return {
|
|
ok: cookieState.cookiePresent,
|
|
method: "form-fallback",
|
|
loginUrl: new URL("/auth/login", baseUrl).pathname,
|
|
status: fallback.response?.status ?? (cookieState.cookiePresent ? 200 : 0),
|
|
statusText: fallback.response?.statusText ?? (cookieState.cookiePresent ? "OK" : "form-login-no-cookie"),
|
|
cookiePresent: cookieState.cookiePresent,
|
|
cookieNames: cookieState.cookieNames,
|
|
attempts: apiAuth.attempts,
|
|
retryCount: apiAuth.retryCount ?? retryCountFromAttempts(apiAuth.attempts),
|
|
fallbackUsed: true,
|
|
retryable: apiAuth.retryable === true,
|
|
apiErrorSummary: apiAuth.errorSummary,
|
|
fallback,
|
|
valuesRedacted: true,
|
|
};
|
|
} catch (error) {
|
|
fallback.error = error instanceof Error ? error.message : String(error);
|
|
fallback.finalPath = loginPage ? new URL(loginPage.url()).pathname : null;
|
|
const cookieState = await readAuthCookieState(browserContext).catch(() => ({ cookiePresent: false, cookieNames: [] }));
|
|
return {
|
|
ok: false,
|
|
method: "form-fallback",
|
|
loginUrl: new URL("/auth/login", baseUrl).pathname,
|
|
status: fallback.response?.status ?? 0,
|
|
statusText: fallback.response?.statusText ?? "form-login-failed",
|
|
cookiePresent: cookieState.cookiePresent,
|
|
cookieNames: cookieState.cookieNames,
|
|
attempts: apiAuth.attempts,
|
|
retryCount: apiAuth.retryCount ?? retryCountFromAttempts(apiAuth.attempts),
|
|
fallbackUsed: true,
|
|
retryable: apiAuth.retryable === true,
|
|
apiErrorSummary: apiAuth.errorSummary,
|
|
fallback,
|
|
valuesRedacted: true,
|
|
};
|
|
} finally {
|
|
await loginPage.close().catch(() => {});
|
|
}
|
|
}
|
|
|
|
async function waitForAuthSurface(targetPage) {
|
|
const handle = await targetPage.waitForFunction(() => {
|
|
if (document.querySelector("#workspace") || document.querySelector("#command-input")) return "workspace";
|
|
if (document.querySelector("#login-username")) return "legacy-id";
|
|
if (document.querySelector('input[autocomplete="username"]') && document.querySelector('input[autocomplete="current-password"]')) return "login-card";
|
|
if (document.querySelector(".login-card input")) return "login-card";
|
|
return "";
|
|
}, null, { timeout: Math.min(timeoutMs, 12000) }).catch(() => null);
|
|
if (!handle) return null;
|
|
return handle.jsonValue().catch(() => null);
|
|
}
|
|
|
|
async function formLoginOutcome(targetPage) {
|
|
const handle = await targetPage.waitForFunction(() => {
|
|
if (document.querySelector("#workspace") || document.querySelector("#command-input")) return { kind: "workspace" };
|
|
const errorText = document.querySelector(".form-error, .login-error, [role='alert'], .error, .text-red-500, .text-destructive")?.textContent?.trim();
|
|
if (errorText) return { kind: "error", errorText };
|
|
return null;
|
|
}, null, { timeout: Math.min(timeoutMs, 5000) }).catch(() => null);
|
|
if (!handle) return null;
|
|
return handle.jsonValue().catch(() => null);
|
|
}
|
|
|
|
function scriptHelpers() {
|
|
const livePage = createLivePageProxy();
|
|
const helpers = {
|
|
browser,
|
|
context,
|
|
page: livePage,
|
|
baseUrl,
|
|
runDir,
|
|
auth: publicAuth(auth),
|
|
artifactPath,
|
|
screenshot,
|
|
screenshotOnError,
|
|
jsonArtifact,
|
|
sha256File,
|
|
wait: sleep,
|
|
goto,
|
|
gotoRaw,
|
|
gotoStable,
|
|
reloadStable,
|
|
safeReload,
|
|
gotoCurrentStable,
|
|
waitWorkbenchReady,
|
|
waitForReady,
|
|
fetchJson,
|
|
safeFetchJson,
|
|
fetchApiMatrix,
|
|
recordStep,
|
|
collectText,
|
|
safeEvaluate,
|
|
summarizeWorkspace,
|
|
summarizeConversation,
|
|
getPage: () => page,
|
|
request: context.request,
|
|
};
|
|
Object.defineProperty(helpers, "currentPage", {
|
|
enumerable: false,
|
|
get: () => page,
|
|
});
|
|
return helpers;
|
|
}
|
|
|
|
function proxyConfigFromEnv(targetBaseUrl) {
|
|
if (browserProxyMode === "direct") return null;
|
|
let target;
|
|
try {
|
|
target = new URL(targetBaseUrl);
|
|
} catch {
|
|
return null;
|
|
}
|
|
const noProxy = process.env.NO_PROXY || process.env.no_proxy || "";
|
|
if (noProxyMatches(target.hostname, noProxy)) return null;
|
|
const raw = target.protocol === "https:"
|
|
? process.env.HTTPS_PROXY || process.env.https_proxy || process.env.ALL_PROXY || process.env.all_proxy || process.env.HTTP_PROXY || process.env.http_proxy || ""
|
|
: process.env.HTTP_PROXY || process.env.http_proxy || process.env.ALL_PROXY || process.env.all_proxy || process.env.HTTPS_PROXY || process.env.https_proxy || "";
|
|
if (!raw) return null;
|
|
return { server: raw };
|
|
}
|
|
|
|
function chromiumLaunchOptionsForProxy(proxy) {
|
|
const baseArgs = chromiumLowResourceArgs();
|
|
const base = { env: browserProcessEnvWithoutProxy() };
|
|
if (proxy === null) return { ...base, args: [...baseArgs, "--no-proxy-server"] };
|
|
return { ...base, proxy, args: baseArgs };
|
|
}
|
|
|
|
function chromiumLowResourceArgs() {
|
|
return [];
|
|
}
|
|
|
|
function browserProcessEnvWithoutProxy() {
|
|
const blocked = new Set(["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "all_proxy", "no_proxy"]);
|
|
const env = {};
|
|
for (const [key, value] of Object.entries(process.env)) {
|
|
if (!blocked.has(key) && value !== undefined) env[key] = value;
|
|
}
|
|
return env;
|
|
}
|
|
|
|
function noProxyMatches(hostname, rawList) {
|
|
const host = String(hostname || "").toLowerCase();
|
|
if (!host) return false;
|
|
return String(rawList || "").split(",").some((raw) => {
|
|
let item = raw.trim().toLowerCase();
|
|
if (!item) return false;
|
|
if (item === "*") return true;
|
|
item = item.replace(/^\*\./u, ".");
|
|
const portIndex = item.lastIndexOf(":");
|
|
if (portIndex > -1 && !item.includes("]")) item = item.slice(0, portIndex);
|
|
if (item.startsWith(".")) return host === item.slice(1) || host.endsWith(item);
|
|
return host === item;
|
|
});
|
|
}
|
|
|
|
function publicNetwork(proxy) {
|
|
return {
|
|
proxy: proxy === null ? { enabled: false, source: "env", valuesRedacted: true } : {
|
|
enabled: true,
|
|
source: "env",
|
|
server: publicProxyServer(proxy.server),
|
|
valuesRedacted: true,
|
|
},
|
|
browser: {
|
|
proxyMode: proxy === null ? "direct-no-proxy-server" : "explicit-playwright-proxy",
|
|
requestedProxyMode: browserProxyMode,
|
|
proxyEnvCleared: true,
|
|
valuesRedacted: true,
|
|
},
|
|
valuesRedacted: true,
|
|
};
|
|
}
|
|
|
|
function parseBrowserProxyMode(raw) {
|
|
if (raw === "auto" || raw === "direct") return raw;
|
|
return "auto";
|
|
}
|
|
|
|
function publicProxyServer(raw) {
|
|
try {
|
|
const parsed = new URL(String(raw || ""));
|
|
parsed.username = "";
|
|
parsed.password = "";
|
|
const value = parsed.toString();
|
|
if (parsed.pathname === "/" && parsed.search === "" && parsed.hash === "") return value.replace(/\/$/u, "");
|
|
return value;
|
|
} catch {
|
|
return String(raw || "").replace(/\/\/[^/@]+@/u, "//[redacted]@");
|
|
}
|
|
}
|
|
|
|
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 = {
|
|
index: stepRecords.length,
|
|
name: normalizedName,
|
|
ok: data && typeof data === "object" && typeof data.ok === "boolean" ? data.ok : null,
|
|
atMs: Date.now() - startedAtMs,
|
|
data: sanitize(data),
|
|
};
|
|
stepRecords.push(item);
|
|
while (stepRecords.length > boundedInteger(options.maxSteps, 80, 1, 200)) stepRecords.shift();
|
|
return deepClonePlain(item);
|
|
}
|
|
|
|
function publicSteps() {
|
|
return stepRecords.slice(-50).map((item) => deepClonePlain(item));
|
|
}
|
|
|
|
async function safeFetchJson(target, options = {}) {
|
|
try {
|
|
return await fetchJson(target, { ...normalizeHelperOptions(options), throwOnError: false });
|
|
} catch (error) {
|
|
const failure = classifiedProbeError(error);
|
|
return {
|
|
ok: false,
|
|
path: typeof target === "string" ? urlPath(target) : String(target ?? ""),
|
|
status: 0,
|
|
statusText: "helper-error",
|
|
failureKind: failure.failureKind,
|
|
error: sanitize(failure.message),
|
|
guidance: failure.guidance,
|
|
};
|
|
}
|
|
}
|
|
|
|
async function fetchApiMatrix(paths, options = {}) {
|
|
options = normalizeHelperOptions(options);
|
|
const items = normalizeApiMatrixItems(paths);
|
|
const rows = [];
|
|
for (const item of items) {
|
|
const response = await safeFetchJson(item.path, {
|
|
headers: item.headers ?? options.headers,
|
|
method: item.method ?? options.method,
|
|
body: item.body ?? undefined,
|
|
});
|
|
rows.push(compactApiMatrixResponse(item, response));
|
|
}
|
|
const failed = rows.filter((row) => row.ok !== true);
|
|
const matrix = {
|
|
ok: failed.length === 0,
|
|
count: rows.length,
|
|
okCount: rows.length - failed.length,
|
|
failedCount: failed.length,
|
|
items: rows,
|
|
};
|
|
if (options.record !== false) recordStep(typeof options.name === "string" ? options.name : "api-matrix", matrix);
|
|
return matrix;
|
|
}
|
|
|
|
function normalizeApiMatrixItems(value) {
|
|
const list = Array.isArray(value) ? value : [value];
|
|
return list.slice(0, 20).map((item, index) => {
|
|
if (typeof item === "string") return { name: item, path: item, method: "GET" };
|
|
if (item && typeof item === "object") {
|
|
const path = typeof item.path === "string" ? item.path : typeof item.url === "string" ? item.url : "/";
|
|
return {
|
|
name: typeof item.name === "string" ? item.name : path,
|
|
path,
|
|
method: typeof item.method === "string" ? item.method : "GET",
|
|
headers: item.headers,
|
|
body: item.body,
|
|
};
|
|
}
|
|
return { name: "item-" + index, path: "/", method: "GET" };
|
|
});
|
|
}
|
|
|
|
function compactApiMatrixResponse(item, response) {
|
|
const body = response && typeof response.body === "object" && response.body !== null ? response.body : null;
|
|
return {
|
|
name: item.name,
|
|
path: response?.path ?? item.path,
|
|
method: item.method ?? "GET",
|
|
ok: response?.ok === true,
|
|
status: response?.status ?? null,
|
|
statusText: response?.statusText ?? null,
|
|
error: response?.error ?? response?.parseError ?? null,
|
|
failureKind: response?.failureKind ?? null,
|
|
bodyKeys: body && !Array.isArray(body) ? Object.keys(body).slice(0, 20) : [],
|
|
bodyPreview: body ? normalizeTextPreview(JSON.stringify(sanitize(body)), 240) : response?.textPreview ?? null,
|
|
};
|
|
}
|
|
|
|
async function safeEvaluate(fn, arg = undefined, options = {}) {
|
|
options = normalizeHelperOptions(options);
|
|
if (typeof fn !== "function") {
|
|
return {
|
|
ok: false,
|
|
failureKind: "script-api-misuse",
|
|
error: "safeEvaluate requires a function",
|
|
guidance: evaluateSingleArgGuidance(),
|
|
};
|
|
}
|
|
try {
|
|
return { ok: true, value: sanitize(await page.evaluate(fn, arg)) };
|
|
} catch (error) {
|
|
const failure = classifiedProbeError(markEvaluateError(error));
|
|
if (options.throwOnError === true) throw markEvaluateError(error);
|
|
return {
|
|
ok: false,
|
|
failureKind: failure.failureKind,
|
|
error: sanitize(failure.message),
|
|
guidance: failure.guidance,
|
|
};
|
|
}
|
|
}
|
|
|
|
async function fetchJson(target, options = {}) {
|
|
options = normalizeHelperOptions(options);
|
|
let url;
|
|
try {
|
|
url = new URL(String(target || "/"), baseUrl).toString();
|
|
} catch (error) {
|
|
return {
|
|
ok: false,
|
|
path: String(target ?? ""),
|
|
status: 0,
|
|
statusText: "invalid-url",
|
|
error: error instanceof Error ? error.message : String(error),
|
|
};
|
|
}
|
|
|
|
const headers = normalizeFetchHeaders(options.headers);
|
|
const method = typeof options.method === "string" && options.method.length > 0 ? options.method : "GET";
|
|
const body = normalizeFetchBody(options.body, headers);
|
|
const evaluated = await safeEvaluate(async ({ url: requestUrl, method: requestMethod, headers: requestHeaders, body: requestBody }) => {
|
|
try {
|
|
const response = await fetch(requestUrl, {
|
|
method: requestMethod,
|
|
headers: requestHeaders,
|
|
body: requestBody === null ? undefined : requestBody,
|
|
credentials: "include",
|
|
});
|
|
const text = await response.text();
|
|
let json = null;
|
|
let parseError = null;
|
|
if (text.length > 0) {
|
|
try {
|
|
json = JSON.parse(text);
|
|
} catch (error) {
|
|
parseError = error instanceof Error ? error.message : String(error);
|
|
}
|
|
}
|
|
return {
|
|
ok: response.ok,
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
url: response.url,
|
|
body: json,
|
|
textPreview: json === null ? text.slice(0, 1000) : null,
|
|
parseError,
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
ok: false,
|
|
status: 0,
|
|
statusText: "fetch-error",
|
|
error: error instanceof Error ? error.message : String(error),
|
|
};
|
|
}
|
|
}, { url, method, headers, body });
|
|
|
|
if (evaluated.ok !== true) {
|
|
const result = {
|
|
ok: false,
|
|
path: urlPath(url),
|
|
status: 0,
|
|
statusText: "page-evaluate-failed",
|
|
failureKind: evaluated.failureKind ?? "script-api-misuse",
|
|
error: evaluated.error ?? "page.evaluate failed",
|
|
guidance: evaluated.guidance ?? null,
|
|
};
|
|
if (options.throwOnError === true) throw stableProbeError("api-response-failed", result.error, publicReadiness({ error: "api-response-failed" }));
|
|
return result;
|
|
}
|
|
|
|
const response = evaluated.value && typeof evaluated.value === "object" ? evaluated.value : {};
|
|
const result = {
|
|
ok: response.ok === true,
|
|
path: urlPath(response.url || url),
|
|
status: Number.isInteger(response.status) ? response.status : 0,
|
|
statusText: typeof response.statusText === "string" ? response.statusText : null,
|
|
body: response.body ?? null,
|
|
textPreview: typeof response.textPreview === "string" ? response.textPreview : null,
|
|
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(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();
|
|
if (normalizedSelector.length === 0) return { ok: false, selector: normalizedSelector, count: 0, texts: [], error: "selector is required" };
|
|
const limit = boundedInteger(options.limit, 20, 1, 200);
|
|
const textLimit = boundedInteger(options.textLimit, 1000, 80, 6000);
|
|
const waitTimeoutMs = boundedInteger(options.timeoutMs ?? options.waitTimeoutMs, Math.min(timeoutMs, 5000), 1, Math.max(timeoutMs, 60000));
|
|
const state = typeof options.state === "string" ? options.state : "attached";
|
|
try {
|
|
const locator = page.locator(normalizedSelector);
|
|
if (options.wait !== false) await locator.first().waitFor({ state, timeout: waitTimeoutMs });
|
|
const count = await locator.count();
|
|
const texts = (await locator.allTextContents())
|
|
.slice(0, limit)
|
|
.map((text) => normalizeTextPreview(text, textLimit));
|
|
return { ok: true, selector: normalizedSelector, count, returned: texts.length, texts };
|
|
} catch (error) {
|
|
return {
|
|
ok: false,
|
|
selector: normalizedSelector,
|
|
count: 0,
|
|
texts: [],
|
|
error: error instanceof Error ? error.message : String(error),
|
|
};
|
|
}
|
|
}
|
|
|
|
async function waitWorkbenchReady(options = {}) {
|
|
options = normalizeHelperOptions(options);
|
|
const target = typeof options.target === "string" && options.target.length > 0 ? options.target : "/workbench";
|
|
const next = { ...options };
|
|
delete next.target;
|
|
if (next.selectors === undefined) next.selectors = ["#workspace", "#command-input"];
|
|
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 railExpansion = await ensureSessionRailExpandedForProbe();
|
|
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", railExpansion };
|
|
}
|
|
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,
|
|
railExpansion
|
|
};
|
|
}
|
|
|
|
async function ensureSessionRailExpandedForProbe() {
|
|
const before = await collectWorkbenchReadyState();
|
|
if (before?.workspace?.sessionCreateVisible === true) {
|
|
return { ok: true, action: "already-visible", before: before.workspace, after: before.workspace, valuesRedacted: true };
|
|
}
|
|
const toggle = page.locator("#session-collapse-toggle").first();
|
|
const toggleVisible = await toggle.isVisible({ timeout: Math.min(timeoutMs, 2000) }).catch(() => false);
|
|
if (before?.workspace?.sessionRailCollapsed !== true || !toggleVisible) {
|
|
return { ok: false, action: "not-expanded", reason: before?.workspace?.sessionRailCollapsed === true ? "collapse-toggle-not-visible" : "session-rail-not-collapsed", before: before.workspace, after: before.workspace, valuesRedacted: true };
|
|
}
|
|
await toggle.click();
|
|
await page.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 rail = document.querySelector("#session-sidebar");
|
|
return Boolean(visible(document.querySelector("#session-create")) || (rail && rail.getAttribute("data-collapsed") === "false"));
|
|
}, null, { timeout: Math.min(timeoutMs, 5000) }).catch(() => null);
|
|
const after = await collectWorkbenchReadyState();
|
|
return { ok: after?.workspace?.sessionCreateVisible === true, action: "expanded-session-rail", before: before.workspace, after: after.workspace, valuesRedacted: true };
|
|
}
|
|
|
|
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 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 sessionCreate = document.querySelector("#session-create");
|
|
const sessionRail = document.querySelector("#session-sidebar");
|
|
const sessionCollapseToggle = document.querySelector("#session-collapse-toggle");
|
|
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,
|
|
sessionCreatePresent: Boolean(sessionCreate),
|
|
sessionCreateVisible: visible(sessionCreate),
|
|
sessionRailPresent: Boolean(sessionRail),
|
|
sessionRailCollapsed: sessionRail ? sessionRail.getAttribute("data-collapsed") === "true" || sessionRail.classList.contains("is-collapsed") : null,
|
|
sessionCollapseTogglePresent: Boolean(sessionCollapseToggle),
|
|
sessionCollapseToggleVisible: visible(sessionCollapseToggle),
|
|
sessionCollapseToggleExpanded: sessionCollapseToggle ? sessionCollapseToggle.getAttribute("aria-expanded") : null
|
|
},
|
|
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 = {}) {
|
|
if (typeof name === "function") {
|
|
options = normalizeHelperOptions(fn);
|
|
fn = name;
|
|
name = "failure.png";
|
|
}
|
|
if (typeof fn !== "function") return captureFailureScreenshot(String(name || "failure.png"));
|
|
try {
|
|
return await fn();
|
|
} catch (error) {
|
|
const shot = await captureFailureScreenshot(String(name || "failure.png"));
|
|
if (error && typeof error === "object") error.screenshot = shot;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function summarizeWorkspace(options = {}) {
|
|
options = normalizeHelperOptions(options);
|
|
const projectId = typeof options.projectId === "string" && options.projectId.length > 0 ? options.projectId : inferProjectId();
|
|
const response = await fetchJson("/v1/workbench/workspace?projectId=" + encodeURIComponent(projectId));
|
|
if (response.ok !== true) return { ...response, projectId };
|
|
const body = response.body && typeof response.body === "object" ? response.body : {};
|
|
const conversations = firstArrayAtPaths(body, ["conversations", "workspace.conversations", "data.conversations"]);
|
|
return {
|
|
ok: true,
|
|
path: response.path,
|
|
status: response.status,
|
|
projectId,
|
|
selectedConversationId: firstStringAtPaths(body, ["selectedConversationId", "workspace.selectedConversationId", "data.selectedConversationId"]),
|
|
sessionId: firstStringAtPaths(body, ["sessionId", "workspace.sessionId", "data.sessionId"]),
|
|
conversationCount: conversations ? conversations.length : null,
|
|
conversationPreview: conversations ? conversations.slice(0, 8).map(summarizeConversationItem) : [],
|
|
};
|
|
}
|
|
|
|
async function summarizeConversation(input = {}) {
|
|
const options = typeof input === "string" ? { conversationId: input } : normalizeHelperOptions(input);
|
|
let workspace = null;
|
|
let conversationId = typeof options.conversationId === "string" && options.conversationId.length > 0 ? options.conversationId : inferConversationIdFromUrl();
|
|
if (!conversationId) {
|
|
workspace = await summarizeWorkspace(options);
|
|
conversationId = typeof workspace.selectedConversationId === "string" ? workspace.selectedConversationId : null;
|
|
}
|
|
if (!conversationId) {
|
|
return { ok: false, error: "conversationId is required and could not be inferred from the current workbench", workspace };
|
|
}
|
|
const response = await fetchJson("/v1/agent/conversations/" + encodeURIComponent(conversationId));
|
|
if (response.ok !== true) return { ...response, conversationId, workspace };
|
|
const body = response.body && typeof response.body === "object" ? response.body : {};
|
|
const messages = firstArrayAtPaths(body, ["messages", "conversation.messages", "data.messages"]) || [];
|
|
return {
|
|
ok: true,
|
|
path: response.path,
|
|
status: response.status,
|
|
conversationId,
|
|
title: firstStringAtPaths(body, ["title", "conversation.title", "data.title"]),
|
|
lastTraceId: firstStringAtPaths(body, ["lastTraceId", "conversation.lastTraceId", "data.lastTraceId"]),
|
|
messageCount: messages.length,
|
|
lastMessage: messages.length > 0 ? summarizeMessage(messages[messages.length - 1]) : null,
|
|
};
|
|
}
|
|
|
|
async function goto(target = "/workbench", options = {}) {
|
|
options = normalizeHelperOptions(options);
|
|
if (options && typeof options === "object" && options.stable === false) return gotoRaw(target, options);
|
|
const stable = await gotoStable(target, options);
|
|
return stable.finalUrl;
|
|
}
|
|
|
|
async function gotoRaw(target = "/workbench", options = {}) {
|
|
options = normalizeHelperOptions(options);
|
|
const url = new URL(target, baseUrl).toString();
|
|
await page.goto(url, navigationOptions(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();
|
|
const attempts = boundedInteger(options.attempts, 3, 1, 5);
|
|
const retryDelayMs = boundedInteger(options.retryDelayMs, 350, 0, 5000);
|
|
const readinessSelectors = normalizeSelectorList(options.selectors, defaultReadinessSelectors(url));
|
|
let lastRecord = null;
|
|
|
|
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
const useFreshPage = attempt > 1 || options.freshPage === true || options.reusePage === false || !page || page.isClosed();
|
|
if (useFreshPage) {
|
|
const previousPage = page;
|
|
page = await context.newPage();
|
|
if (previousPage && previousPage !== page && !previousPage.isClosed()) await previousPage.close().catch(() => {});
|
|
}
|
|
|
|
const attemptPage = page;
|
|
const network = createNetworkTracker(attemptPage, options.apiPattern);
|
|
const record = {
|
|
ok: false,
|
|
attempt,
|
|
attempts,
|
|
policy: useFreshPage ? "fresh-page" : "reuse-current-page",
|
|
targetPath: urlPath(url),
|
|
finalPath: null,
|
|
stage: "navigate",
|
|
error: null,
|
|
message: null,
|
|
selector: null,
|
|
selectors: [],
|
|
apiRequestsSent: false,
|
|
apiRequestCount: 0,
|
|
apiResponseFailed: false,
|
|
apiFailureCount: 0,
|
|
screenshot: null,
|
|
durationMs: 0,
|
|
};
|
|
const started = Date.now();
|
|
|
|
try {
|
|
await attemptPage.goto(url, navigationOptions(options));
|
|
record.finalPath = urlPath(attemptPage.url());
|
|
const readiness = await waitForReadyInternal(attemptPage, {
|
|
...options,
|
|
selectors: readinessSelectors,
|
|
url,
|
|
}, network);
|
|
applyReadinessToRecord(record, readiness);
|
|
if (!readiness.ok) {
|
|
record.screenshot = await captureReadinessScreenshot(attemptPage, attempt, options);
|
|
lastRecord = record;
|
|
} else if (options.expectApiRequest === true && !readiness.apiRequestsSent) {
|
|
record.ok = false;
|
|
record.stage = "api";
|
|
record.error = "api-not-sent";
|
|
record.message = "expected API request was not observed before readiness finished";
|
|
record.screenshot = await captureReadinessScreenshot(attemptPage, attempt, options);
|
|
lastRecord = record;
|
|
} else if (options.failOnApiResponseFailed === true && readiness.apiResponseFailed) {
|
|
record.ok = false;
|
|
record.stage = "api";
|
|
record.error = "api-response-failed";
|
|
record.message = "API request failed during readiness";
|
|
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,
|
|
attempt,
|
|
attempts,
|
|
finalUrl: page.url(),
|
|
readiness: record,
|
|
summary: publicReadiness(),
|
|
};
|
|
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 || "page did not become ready";
|
|
throw stableProbeError(code, message, publicReadiness({ error: code }));
|
|
}
|
|
|
|
async function waitForReady(options = {}) {
|
|
options = normalizeHelperOptions(options);
|
|
const readiness = await waitForReadyInternal(options.page || page, options, null);
|
|
readinessRecords.push({
|
|
...readiness,
|
|
attempt: readinessRecords.length + 1,
|
|
attempts: 1,
|
|
policy: "wait-current-page",
|
|
durationMs: readiness.durationMs,
|
|
});
|
|
if (options.throwOnFailure === true && !readiness.ok) {
|
|
throw stableProbeError(readiness.error || "selector-timeout", readiness.message || "page did not become ready", publicReadiness({ error: readiness.error || "selector-timeout" }));
|
|
}
|
|
return readiness;
|
|
}
|
|
|
|
async function waitForReadyInternal(targetPage, options = {}, network = null) {
|
|
const started = Date.now();
|
|
const url = typeof options.url === "string" ? options.url : targetPage.url();
|
|
const readinessTimeoutMs = boundedInteger(options.readinessTimeoutMs ?? options.timeoutMs, timeoutMs, 1, Math.max(timeoutMs, 60000));
|
|
const selectorState = typeof options.selectorState === "string" ? options.selectorState : "visible";
|
|
const selectors = normalizeSelectorList(options.selectors, defaultReadinessSelectors(url));
|
|
const activeSelector = typeof options.activeSelector === "string" ? options.activeSelector : typeof options.activeTabSelector === "string" ? options.activeTabSelector : null;
|
|
const result = {
|
|
ok: false,
|
|
stage: "load-state",
|
|
error: null,
|
|
message: null,
|
|
targetPath: urlPath(url),
|
|
finalPath: null,
|
|
selector: null,
|
|
selectors: [],
|
|
apiRequestsSent: false,
|
|
apiRequestCount: 0,
|
|
apiResponseFailed: false,
|
|
apiFailureCount: 0,
|
|
durationMs: 0,
|
|
};
|
|
|
|
try {
|
|
await targetPage.waitForLoadState(typeof options.loadState === "string" ? options.loadState : "domcontentloaded", { timeout: readinessTimeoutMs });
|
|
result.finalPath = urlPath(targetPage.url());
|
|
for (const selector of selectors) {
|
|
result.stage = "selector";
|
|
result.selector = selector;
|
|
await targetPage.locator(selector).first().waitFor({ state: selectorState, timeout: readinessTimeoutMs });
|
|
result.selectors.push({ selector, state: selectorState, matched: true });
|
|
}
|
|
if (activeSelector !== null) {
|
|
result.stage = "active-selector";
|
|
result.selector = activeSelector;
|
|
await targetPage.locator(activeSelector).first().waitFor({ state: "visible", timeout: readinessTimeoutMs });
|
|
result.selectors.push({ selector: activeSelector, state: "visible", matched: true, role: "active" });
|
|
}
|
|
result.ok = true;
|
|
result.stage = "ready";
|
|
} catch (error) {
|
|
result.ok = false;
|
|
result.error = result.stage === "selector" || result.stage === "active-selector" ? "selector-timeout" : "browser-load-jitter";
|
|
result.message = error instanceof Error ? error.message : String(error);
|
|
} finally {
|
|
Object.assign(result, network ? network.summary() : emptyNetworkSummary());
|
|
result.durationMs = Date.now() - started;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function applyReadinessToRecord(record, readiness) {
|
|
record.ok = readiness.ok;
|
|
record.stage = readiness.stage;
|
|
record.error = readiness.error;
|
|
record.message = readiness.message;
|
|
record.selector = readiness.selector;
|
|
record.selectors = readiness.selectors;
|
|
record.finalPath = readiness.finalPath;
|
|
record.apiRequestsSent = readiness.apiRequestsSent;
|
|
record.apiRequestCount = readiness.apiRequestCount;
|
|
record.apiResponseFailed = readiness.apiResponseFailed;
|
|
record.apiFailureCount = readiness.apiFailureCount;
|
|
}
|
|
|
|
function createNetworkTracker(targetPage, apiPattern) {
|
|
const started = Date.now();
|
|
const requests = [];
|
|
const failures = [];
|
|
const matches = createApiMatcher(apiPattern);
|
|
const onRequest = (request) => {
|
|
if (!matches(request.url())) return;
|
|
requests.push({
|
|
method: request.method(),
|
|
path: urlPath(request.url()),
|
|
atMs: Date.now() - started,
|
|
});
|
|
};
|
|
const onResponse = (response) => {
|
|
if (!matches(response.url())) return;
|
|
if (response.status() >= 400) {
|
|
failures.push({
|
|
type: "response",
|
|
status: response.status(),
|
|
statusText: response.statusText(),
|
|
path: urlPath(response.url()),
|
|
atMs: Date.now() - started,
|
|
});
|
|
}
|
|
};
|
|
const onRequestFailed = (request) => {
|
|
if (!matches(request.url())) return;
|
|
failures.push({
|
|
type: "requestfailed",
|
|
method: request.method(),
|
|
path: urlPath(request.url()),
|
|
failureText: request.failure()?.errorText ?? null,
|
|
atMs: Date.now() - started,
|
|
});
|
|
};
|
|
targetPage.on("request", onRequest);
|
|
targetPage.on("response", onResponse);
|
|
targetPage.on("requestfailed", onRequestFailed);
|
|
return {
|
|
summary: () => ({
|
|
apiRequestsSent: requests.length > 0,
|
|
apiRequestCount: requests.length,
|
|
apiResponseFailed: failures.length > 0,
|
|
apiFailureCount: failures.length,
|
|
apiRequests: requests.slice(0, 5),
|
|
apiFailures: failures.slice(0, 5),
|
|
}),
|
|
dispose: () => {
|
|
targetPage.off("request", onRequest);
|
|
targetPage.off("response", onResponse);
|
|
targetPage.off("requestfailed", onRequestFailed);
|
|
},
|
|
};
|
|
}
|
|
|
|
function createApiMatcher(pattern) {
|
|
if (pattern === false) return () => false;
|
|
if (pattern instanceof RegExp) return (rawUrl) => pattern.test(rawUrl);
|
|
if (typeof pattern === "function") return (rawUrl) => {
|
|
try {
|
|
return pattern(rawUrl) === true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
};
|
|
const normalized = typeof pattern === "string" && pattern.length > 0 ? pattern : "/v1";
|
|
return (rawUrl) => {
|
|
try {
|
|
const parsed = new URL(rawUrl);
|
|
if (normalized.startsWith("/")) return parsed.pathname === normalized || parsed.pathname.startsWith(normalized.replace(/\/$/u, "") + "/");
|
|
return rawUrl.includes(normalized);
|
|
} catch {
|
|
return false;
|
|
}
|
|
};
|
|
}
|
|
|
|
function emptyNetworkSummary() {
|
|
return {
|
|
apiRequestsSent: false,
|
|
apiRequestCount: 0,
|
|
apiResponseFailed: false,
|
|
apiFailureCount: 0,
|
|
apiRequests: [],
|
|
apiFailures: [],
|
|
};
|
|
}
|
|
|
|
function defaultReadinessSelectors(rawUrl) {
|
|
try {
|
|
const pathname = new URL(rawUrl, baseUrl).pathname;
|
|
if (pathname === "/" || pathname === "/workbench" || pathname.startsWith("/workbench/")) return ["#workspace", "#command-input"];
|
|
} catch {
|
|
return ["body"];
|
|
}
|
|
return ["body"];
|
|
}
|
|
|
|
function normalizeSelectorList(value, fallback) {
|
|
if (value === false || value === null) return [];
|
|
if (Array.isArray(value)) return value.map((item) => String(item).trim()).filter(Boolean).slice(0, 10);
|
|
if (typeof value === "string" && value.trim().length > 0) return [value.trim()];
|
|
return fallback;
|
|
}
|
|
|
|
function normalizeHelperOptions(value) {
|
|
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
}
|
|
|
|
function navigationOptions(options = {}) {
|
|
options = normalizeHelperOptions(options);
|
|
const next = { ...options };
|
|
for (const key of [
|
|
"activeSelector",
|
|
"activeTabSelector",
|
|
"apiPattern",
|
|
"attempts",
|
|
"expectApiRequest",
|
|
"failOnApiResponseFailed",
|
|
"freshPage",
|
|
"name",
|
|
"readinessTimeoutMs",
|
|
"record",
|
|
"retryDelayMs",
|
|
"reusePage",
|
|
"screenshotOnFailure",
|
|
"selectorState",
|
|
"selectors",
|
|
"stable",
|
|
"url",
|
|
]) {
|
|
delete next[key];
|
|
}
|
|
return {
|
|
waitUntil: "domcontentloaded",
|
|
timeout: timeoutMs,
|
|
...next,
|
|
};
|
|
}
|
|
|
|
async function captureReadinessScreenshot(targetPage, attempt, options) {
|
|
if (options.screenshotOnFailure === false || !targetPage || targetPage.isClosed()) return null;
|
|
try {
|
|
return await screenshotPage(targetPage, "readiness-attempt-" + attempt + ".png");
|
|
} catch (error) {
|
|
return {
|
|
error: error instanceof Error ? error.message : String(error),
|
|
};
|
|
}
|
|
}
|
|
|
|
async function captureFailureScreenshot(name = "failure.png") {
|
|
if (!page || page.isClosed()) return null;
|
|
try {
|
|
return await screenshotPage(page, name);
|
|
} catch (error) {
|
|
return {
|
|
kind: "screenshot",
|
|
path: artifactPath(name),
|
|
error: error instanceof Error ? error.message : String(error),
|
|
};
|
|
}
|
|
}
|
|
|
|
async function screenshotPage(targetPage, name = "screenshot.png", options = {}) {
|
|
const file = artifactPath(name);
|
|
await targetPage.screenshot({ path: file, fullPage: true, ...options });
|
|
return recordArtifact(file, "screenshot");
|
|
}
|
|
|
|
function publicReadiness(extra = {}) {
|
|
const attempts = readinessRecords.slice(-10).map((record) => ({
|
|
ok: record.ok === true,
|
|
attempt: record.attempt ?? null,
|
|
attempts: record.attempts ?? null,
|
|
policy: record.policy ?? null,
|
|
targetPath: record.targetPath ?? null,
|
|
finalPath: record.finalPath ?? null,
|
|
stage: record.stage ?? null,
|
|
error: record.error ?? null,
|
|
message: typeof record.message === "string" ? record.message.slice(0, 500) : null,
|
|
selector: record.selector ?? null,
|
|
selectors: clonePlainList(record.selectors, 10),
|
|
apiRequestsSent: record.apiRequestsSent === true,
|
|
apiRequestCount: Number.isInteger(record.apiRequestCount) ? record.apiRequestCount : 0,
|
|
apiResponseFailed: record.apiResponseFailed === true,
|
|
apiFailureCount: Number.isInteger(record.apiFailureCount) ? record.apiFailureCount : 0,
|
|
apiRequests: clonePlainList(record.apiRequests, 5),
|
|
apiFailures: clonePlainList(record.apiFailures, 5),
|
|
screenshot: clonePlainObject(record.screenshot),
|
|
durationMs: Number.isInteger(record.durationMs) ? record.durationMs : null,
|
|
}));
|
|
return {
|
|
attemptCount: readinessRecords.length,
|
|
last: attempts.length > 0 ? deepClonePlain(attempts[attempts.length - 1]) : null,
|
|
attempts,
|
|
...extra,
|
|
};
|
|
}
|
|
|
|
function deepClonePlain(value) {
|
|
return JSON.parse(JSON.stringify(value));
|
|
}
|
|
|
|
function clonePlainList(value, limit) {
|
|
if (!Array.isArray(value)) return [];
|
|
return value.slice(0, limit).map((item) => clonePlainObject(item));
|
|
}
|
|
|
|
function clonePlainObject(value) {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) return value ?? null;
|
|
return { ...value };
|
|
}
|
|
|
|
function classifyNavigationError(error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
if (/timeout|navigation|net::|target page|browser has been closed|page has been closed/iu.test(message)) return "browser-load-jitter";
|
|
return "browser-load-jitter";
|
|
}
|
|
|
|
function classifiedProbeError(error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
const code = probeErrorCode(error, message);
|
|
const failureKind = probeFailureKind(code, message);
|
|
return {
|
|
code,
|
|
failureKind,
|
|
message,
|
|
detail: error && typeof error === "object" ? error.readiness ?? null : null,
|
|
guidance: probeFailureGuidance(failureKind),
|
|
};
|
|
}
|
|
|
|
function stableProbeError(code, message, readiness) {
|
|
const error = new Error(message);
|
|
error.code = code;
|
|
error.readiness = readiness;
|
|
return error;
|
|
}
|
|
|
|
function assertionResultError(value) {
|
|
const message = extractFailureMessage(value) || scriptFailureSignalMessage(value) || "script returned a failure result";
|
|
const error = new Error(message);
|
|
error.code = "assertion-failed";
|
|
return error;
|
|
}
|
|
|
|
function scriptResultOk(value) {
|
|
if (!value || typeof value !== "object") return true;
|
|
if (value.ok === false) return false;
|
|
if (value.pass === false) return false;
|
|
if (value.success === false) return false;
|
|
return true;
|
|
}
|
|
|
|
function scriptFailureSignalMessage(value) {
|
|
if (!value || typeof value !== "object") return null;
|
|
if (value.pass === false) return "script returned pass:false";
|
|
if (value.success === false) return "script returned success:false";
|
|
if (value.ok === false) return "script returned ok:false";
|
|
return null;
|
|
}
|
|
|
|
function extractFailureMessage(value) {
|
|
if (!value || typeof value !== "object") return null;
|
|
for (const key of ["failedCondition", "errorMessage", "message", "error", "reason", "summary"]) {
|
|
const nested = value[key];
|
|
if (typeof nested === "string" && nested.length > 0) return nested;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
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",
|
|
"script-failed",
|
|
]);
|
|
const explicit = error && typeof error === "object" && typeof error.code === "string" ? error.code : null;
|
|
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";
|
|
if (/timeout|navigation|net::|target page|page has been closed|page did not become ready|selector/iu.test(message)) return "browser-load-jitter";
|
|
return "script-failed";
|
|
}
|
|
|
|
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";
|
|
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";
|
|
}
|
|
|
|
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 === "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.";
|
|
return null;
|
|
}
|
|
|
|
function isEvaluateApiMisuse(message) {
|
|
return /Too many arguments.*wrap them in an object|page\.evaluate.*one serializable argument|safeEvaluate requires a function/iu.test(String(message));
|
|
}
|
|
|
|
function evaluateSingleArgGuidance() {
|
|
return "Playwright page.evaluate accepts one serializable argument; use page.evaluate(({ a, b }) => ..., { a, b }) or safeEvaluate(fn, { a, b }).";
|
|
}
|
|
|
|
function markEvaluateError(error) {
|
|
if (error && typeof error === "object" && isEvaluateApiMisuse(error instanceof Error ? error.message : String(error))) {
|
|
error.code = "script-api-misuse";
|
|
}
|
|
return error;
|
|
}
|
|
|
|
function currentPageUrl() {
|
|
try {
|
|
return page && !page.isClosed() ? page.url() : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function urlPath(rawUrl) {
|
|
try {
|
|
const parsed = new URL(rawUrl, baseUrl);
|
|
return parsed.pathname + parsed.search;
|
|
} catch {
|
|
return String(rawUrl);
|
|
}
|
|
}
|
|
|
|
function boundedInteger(raw, fallback, min, max) {
|
|
const value = Number(raw ?? fallback);
|
|
if (!Number.isInteger(value)) return fallback;
|
|
return Math.max(min, Math.min(max, value));
|
|
}
|
|
|
|
function inferProjectId() {
|
|
try {
|
|
const current = page && !page.isClosed() ? new URL(page.url()) : null;
|
|
const fromUrl = current ? current.searchParams.get("projectId") : null;
|
|
if (fromUrl) return fromUrl;
|
|
} catch {
|
|
// Fall through to the Workbench default.
|
|
}
|
|
return "prj_hwpod_workbench";
|
|
}
|
|
|
|
function inferConversationIdFromUrl() {
|
|
try {
|
|
if (!page || page.isClosed()) return null;
|
|
const pathname = new URL(page.url()).pathname;
|
|
const match = pathname.match(/\/(?:workbench|workspace)\/sessions\/([^/]+)/u);
|
|
return match ? decodeURIComponent(match[1]) : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function firstStringAtPaths(value, paths) {
|
|
for (const pathSpec of paths) {
|
|
const nested = valueAtPath(value, pathSpec);
|
|
if (typeof nested === "string" && nested.length > 0) return nested;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function firstArrayAtPaths(value, paths) {
|
|
for (const pathSpec of paths) {
|
|
const nested = valueAtPath(value, pathSpec);
|
|
if (Array.isArray(nested)) return nested;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function valueAtPath(value, pathSpec) {
|
|
let current = value;
|
|
for (const key of String(pathSpec).split(".")) {
|
|
if (!current || typeof current !== "object") return null;
|
|
current = current[key];
|
|
}
|
|
return current ?? null;
|
|
}
|
|
|
|
function summarizeConversationItem(value) {
|
|
if (!value || typeof value !== "object") return value;
|
|
return {
|
|
conversationId: firstStringAtPaths(value, ["conversationId", "id"]),
|
|
title: firstStringAtPaths(value, ["title", "name"]),
|
|
lastTraceId: firstStringAtPaths(value, ["lastTraceId", "traceId"]),
|
|
status: firstStringAtPaths(value, ["status", "state"]),
|
|
};
|
|
}
|
|
|
|
function summarizeMessage(value) {
|
|
if (!value || typeof value !== "object") return value ?? null;
|
|
return {
|
|
role: firstStringAtPaths(value, ["role", "type", "sender"]),
|
|
status: firstStringAtPaths(value, ["status", "state"]),
|
|
traceId: firstStringAtPaths(value, ["traceId", "runnerTrace.traceId"]),
|
|
textPreview: normalizeTextPreview(firstStringAtPaths(value, ["content", "text", "message", "finalResponse"]) || "", 500),
|
|
};
|
|
}
|
|
|
|
function normalizeTextPreview(text, limit) {
|
|
return String(text ?? "").replace(/\s+/gu, " ").trim().slice(0, limit);
|
|
}
|
|
|
|
function normalizeFetchHeaders(value) {
|
|
const headers = {};
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) return headers;
|
|
for (const [key, nested] of Object.entries(value)) {
|
|
if (typeof nested === "string") headers[key] = nested;
|
|
}
|
|
return headers;
|
|
}
|
|
|
|
function normalizeFetchBody(value, headers) {
|
|
if (value === undefined || value === null) return null;
|
|
if (typeof value === "string") return value;
|
|
const hasContentType = Object.keys(headers).some((key) => key.toLowerCase() === "content-type");
|
|
if (!hasContentType) headers["content-type"] = "application/json";
|
|
return JSON.stringify(value);
|
|
}
|
|
|
|
function isRetryableAuthStatus(status) {
|
|
return status === 0 || status === 429 || status >= 500;
|
|
}
|
|
|
|
function retryCountFromAttempts(attempts) {
|
|
return Math.max(0, Array.isArray(attempts) ? attempts.length - 1 : 0);
|
|
}
|
|
|
|
function authAttemptsRetryable(attempts) {
|
|
if (!Array.isArray(attempts) || attempts.length === 0) return false;
|
|
return attempts.some((attempt) => attempt && typeof attempt === "object" && attempt.retryable === true);
|
|
}
|
|
|
|
async function responseSummary(response) {
|
|
const status = response.status();
|
|
let bodyPreview = null;
|
|
if (status >= 400) {
|
|
try {
|
|
bodyPreview = redactString(await response.text()).replace(/\s+/gu, " ").slice(0, 500);
|
|
} catch {
|
|
bodyPreview = null;
|
|
}
|
|
}
|
|
return {
|
|
status,
|
|
statusText: response.statusText(),
|
|
bodyPreview,
|
|
};
|
|
}
|
|
|
|
async function readAuthCookieState(browserContext) {
|
|
const cookies = await browserContext.cookies(baseUrl);
|
|
cookieSecrets = cookies.map((cookie) => cookie.value).filter(Boolean);
|
|
const cookieNames = cookies.map((cookie) => cookie.name).sort();
|
|
return {
|
|
cookiePresent: cookieNames.includes("hwlab_session"),
|
|
cookieNames,
|
|
};
|
|
}
|
|
|
|
function responsePathMatches(rawUrl, path) {
|
|
try {
|
|
return new URL(rawUrl).pathname === path;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function assertNoCredentialLeak(value) {
|
|
const finalUrl = new URL(value);
|
|
if (finalUrl.searchParams.has("username") || finalUrl.searchParams.has("password")) {
|
|
throw new Error("login credentials leaked into URL query");
|
|
}
|
|
}
|
|
|
|
function sleep(ms) {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
function publicAuth(value) {
|
|
const retryCount = Number.isInteger(value.retryCount) ? value.retryCount : retryCountFromAttempts(value.attempts);
|
|
const transientObserved = authAttemptsRetryable(value.attempts);
|
|
const ok = value.ok === true;
|
|
const retryable = ok ? false : value.retryable === true || transientObserved;
|
|
const retryMaxAttempts = Number.isInteger(value.retryMaxAttempts) ? value.retryMaxAttempts : null;
|
|
const lastRetryLabel = typeof value.lastRetryLabel === "string"
|
|
? value.lastRetryLabel
|
|
: Array.isArray(value.attempts) && value.attempts.length > 0
|
|
? value.attempts[value.attempts.length - 1]?.retryLabel ?? null
|
|
: null;
|
|
const retryExhausted = value.retryExhausted === true || (retryable && retryMaxAttempts !== null && Array.isArray(value.attempts) && value.attempts.length >= retryMaxAttempts);
|
|
return {
|
|
ok,
|
|
method: value.method ?? null,
|
|
origin: new URL(baseUrl).origin,
|
|
loginPath: value.loginUrl,
|
|
status: value.status,
|
|
statusText: value.statusText,
|
|
cookiePresent: value.cookiePresent,
|
|
cookieNames: value.cookieNames,
|
|
attempts: value.attempts ?? null,
|
|
retryCount,
|
|
retryMaxAttempts,
|
|
lastRetryLabel,
|
|
retryExhausted,
|
|
fallbackUsed: value.fallbackUsed === true || value.method === "form-fallback",
|
|
fallback: value.fallback ?? null,
|
|
errorSummary: cloneSummary(value.errorSummary ?? value.apiErrorSummary ?? null),
|
|
degradedReason: ok ? null : "auth-login-failed",
|
|
retryable,
|
|
transientObserved,
|
|
commanderAction: ok
|
|
? null
|
|
: retryExhausted
|
|
? "auth retry exhausted; stop this web-probe run and inspect target /auth/login upstream before retrying"
|
|
: retryable
|
|
? "retry same web-probe command after short backoff; inspect target /auth/login and Cloud Web/API rollout if repeated"
|
|
: "inspect bootstrap admin credential source and target user state",
|
|
fingerprint: authSummaryFingerprint(value),
|
|
username,
|
|
valuesRedacted: true,
|
|
};
|
|
}
|
|
|
|
function authSummaryFingerprint(value) {
|
|
const payload = JSON.stringify({
|
|
origin: new URL(baseUrl).origin,
|
|
loginPath: value.loginUrl ?? null,
|
|
method: value.method ?? null,
|
|
status: value.status ?? null,
|
|
statusText: value.statusText ?? null,
|
|
cookiePresent: value.cookiePresent === true,
|
|
retryCount: Number.isInteger(value.retryCount) ? value.retryCount : retryCountFromAttempts(value.attempts),
|
|
fallbackUsed: value.fallbackUsed === true || value.method === "form-fallback",
|
|
});
|
|
return "sha256:" + createHash("sha256").update(payload).digest("hex").slice(0, 16);
|
|
}
|
|
|
|
function cloneSummary(value) {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) return value ?? null;
|
|
return { ...value };
|
|
}
|
|
|
|
function normalizeBaseUrl(raw) {
|
|
if (!raw) throw new Error("missing HWLAB_WEB_BASE_URL");
|
|
const parsed = new URL(raw);
|
|
parsed.hash = "";
|
|
parsed.search = "";
|
|
return parsed.toString().replace(/\/+$/u, "/");
|
|
}
|
|
|
|
function parseViewport(raw) {
|
|
const match = String(raw).match(/^([0-9]+)x([0-9]+)$/u);
|
|
if (!match) throw new Error("viewport must look like 1440x900");
|
|
return { width: Number(match[1]), height: Number(match[2]) };
|
|
}
|
|
|
|
function positiveInteger(raw, fallback) {
|
|
const value = Number(raw || fallback);
|
|
if (!Number.isInteger(value) || value <= 0) return fallback;
|
|
return value;
|
|
}
|
|
|
|
function artifactPath(name) {
|
|
const safe = String(name || "artifact")
|
|
.replace(/[^A-Za-z0-9._-]/gu, "_")
|
|
.replace(/^\.+/u, "_")
|
|
.slice(0, 120) || "artifact";
|
|
return path.join(runDir, safe);
|
|
}
|
|
|
|
async function screenshot(name = "screenshot.png", options = {}) {
|
|
const file = artifactPath(name);
|
|
await page.screenshot({ path: file, fullPage: true, ...options });
|
|
return recordArtifact(file, "screenshot");
|
|
}
|
|
|
|
async function jsonArtifact(name, value) {
|
|
const file = artifactPath(name);
|
|
await writeFile(file, JSON.stringify(sanitize(value), null, 2) + "\n", "utf8");
|
|
return recordArtifact(file, "json");
|
|
}
|
|
|
|
async function recordArtifact(file, kind) {
|
|
const fileStat = await stat(file);
|
|
const item = {
|
|
kind,
|
|
path: file,
|
|
byteCount: fileStat.size,
|
|
sha256: await sha256File(file),
|
|
};
|
|
artifactRecords.push({ ...item });
|
|
return { ...item };
|
|
}
|
|
|
|
async function sha256File(file) {
|
|
const buffer = await readFile(file);
|
|
return "sha256:" + createHash("sha256").update(buffer).digest("hex");
|
|
}
|
|
|
|
function sanitize(value, depth = 0, seen = new WeakSet()) {
|
|
if (value === null || value === undefined) return value ?? null;
|
|
if (typeof value === "string") return redactString(value).slice(0, 6000);
|
|
if (typeof value === "number" || typeof value === "boolean") return value;
|
|
if (typeof value === "bigint") return String(value);
|
|
if (typeof value === "function" || typeof value === "symbol") return "[" + typeof value + "]";
|
|
if (depth > 12) return "[max-depth]";
|
|
if (Array.isArray(value)) return value.slice(0, 200).map((item) => sanitize(item, depth + 1, seen));
|
|
if (typeof value === "object") {
|
|
if (seen.has(value)) return "[circular]";
|
|
seen.add(value);
|
|
const out = {};
|
|
for (const [key, nested] of Object.entries(value).slice(0, 200)) {
|
|
if (/password|passwd|secret|token|authorization|cookie|api[_-]?key/iu.test(key) && typeof nested === "string") {
|
|
out[key] = "<redacted>";
|
|
} else {
|
|
out[key] = sanitize(nested, depth + 1, seen);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
return String(value);
|
|
}
|
|
|
|
function redactString(text) {
|
|
let next = String(text);
|
|
for (const secret of [password, ...cookieSecrets].filter(Boolean)) {
|
|
next = next.split(secret).join("<redacted>");
|
|
}
|
|
return next;
|
|
}
|
|
|
|
async function emit(payload) {
|
|
const enriched = {
|
|
...payload,
|
|
steps: publicSteps(),
|
|
artifacts: { runDir, items: artifactRecords },
|
|
};
|
|
enriched.summary = scriptIssueSummary(enriched);
|
|
const reportFile = artifactPath("web-probe-script-report.json");
|
|
let reportArtifact = null;
|
|
try {
|
|
await writeFile(reportFile, JSON.stringify(sanitize(enriched), null, 2) + "\n", "utf8");
|
|
const fileStat = await stat(reportFile);
|
|
reportArtifact = {
|
|
kind: "json",
|
|
path: reportFile,
|
|
byteCount: fileStat.size,
|
|
sha256: await sha256File(reportFile),
|
|
};
|
|
artifactRecords.push({ ...reportArtifact });
|
|
} catch (error) {
|
|
reportArtifact = {
|
|
kind: "json",
|
|
path: reportFile,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
};
|
|
}
|
|
const finalPayload = {
|
|
...enriched,
|
|
reportPath: reportArtifact && typeof reportArtifact.path === "string" ? reportArtifact.path : null,
|
|
reportSha256: reportArtifact && typeof reportArtifact.sha256 === "string" ? reportArtifact.sha256 : null,
|
|
artifacts: { runDir, items: artifactRecords },
|
|
};
|
|
finalPayload.summary = scriptIssueSummary(finalPayload);
|
|
process.stdout.write(JSON.stringify(sanitize(compactStdoutPayload(finalPayload)), null, 2) + "\n");
|
|
}
|
|
|
|
function compactStdoutPayload(payload) {
|
|
const issueEvidence = compactIssueEvidenceForStdout(payload?.summary?.issueEvidence ?? issueEvidenceFromPayload(payload));
|
|
return {
|
|
ok: payload?.ok === true,
|
|
status: payload?.status ?? null,
|
|
command: payload?.command ?? "web-probe-script",
|
|
generatedAt: payload?.generatedAt ?? null,
|
|
startedAt: payload?.startedAt ?? null,
|
|
baseUrl: payload?.baseUrl ?? null,
|
|
finalUrl: payload?.finalUrl ?? payload?.lastUrl ?? null,
|
|
lastUrl: payload?.lastUrl ?? null,
|
|
scriptSha256: payload?.scriptSha256 ?? null,
|
|
runDir,
|
|
reportPath: payload?.reportPath ?? null,
|
|
reportSha256: payload?.reportSha256 ?? null,
|
|
auth: payload?.auth ?? null,
|
|
script: compactScriptForStdout(payload?.script),
|
|
steps: compactStepsForStdout(publicSteps()),
|
|
failureKind: payload?.failureKind ?? null,
|
|
error: payload?.error ?? null,
|
|
errorMessage: typeof payload?.errorMessage === "string" ? compactText(payload.errorMessage, 1200) : payload?.errorMessage ?? null,
|
|
guidance: typeof payload?.guidance === "string" ? compactText(payload.guidance, 1200) : payload?.guidance ?? null,
|
|
lastScreenshot: compactArtifactForStdout(payload?.lastScreenshot),
|
|
readiness: compactJsonForStdout(payload?.readiness),
|
|
artifacts: compactArtifactsForStdout(payload?.artifacts),
|
|
issueEvidence,
|
|
summary: compactSummaryForStdout(payload?.summary),
|
|
safety: {
|
|
valuesRedacted: true,
|
|
secretValuesPrinted: false,
|
|
stdoutCompacted: true,
|
|
fullReportInArtifact: true,
|
|
},
|
|
};
|
|
}
|
|
|
|
function compactScriptForStdout(script) {
|
|
const value = script && typeof script === "object" ? script : {};
|
|
const steps = Array.isArray(value.steps) ? value.steps : [];
|
|
return {
|
|
ok: value.ok === true,
|
|
result: compactJsonForEvidence(value.result),
|
|
stepCount: steps.length,
|
|
};
|
|
}
|
|
|
|
function compactStepsForStdout(steps) {
|
|
if (!Array.isArray(steps)) return [];
|
|
return steps.slice(-3).map((step) => compactStepForStdout(step));
|
|
}
|
|
|
|
function compactStepForStdout(step) {
|
|
const value = step && typeof step === "object" ? step : {};
|
|
return {
|
|
index: Number.isFinite(value.index) ? value.index : null,
|
|
name: typeof value.name === "string" ? value.name : null,
|
|
ok: typeof value.ok === "boolean" ? value.ok : null,
|
|
atMs: Number.isFinite(value.atMs) ? value.atMs : null,
|
|
data: compactStepDataForStdout(value.data),
|
|
};
|
|
}
|
|
|
|
function compactStepDataForStdout(data) {
|
|
if (data && typeof data === "object" && !Array.isArray(data) && Array.isArray(data.items) && typeof data.failedCount === "number") {
|
|
return compactApiMatrixForStdout(data);
|
|
}
|
|
if (data && typeof data === "object" && !Array.isArray(data)) {
|
|
const out = {};
|
|
for (const [key, value] of Object.entries(data).slice(0, 8)) {
|
|
if (key === "apiMatrix") out[key] = compactApiMatrixForStdout(value);
|
|
else if (/screenshot/iu.test(key)) out[key] = compactArtifactForStdout(value);
|
|
else out[key] = compactJsonForStdout(value);
|
|
}
|
|
return out;
|
|
}
|
|
return compactJsonForStdout(data);
|
|
}
|
|
|
|
function compactSummaryForStdout(summary) {
|
|
const value = summary && typeof summary === "object" ? summary : {};
|
|
return {
|
|
ok: value.ok === true,
|
|
status: value.status ?? null,
|
|
degradedReason: value.degradedReason ?? null,
|
|
failureKind: value.failureKind ?? null,
|
|
failedCondition: typeof value.failedCondition === "string" ? compactText(value.failedCondition, 1200) : value.failedCondition ?? null,
|
|
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,
|
|
reportSha256: value.reportSha256 ?? null,
|
|
lastScreenshot: compactArtifactForStdout(value.lastScreenshot),
|
|
screenshots: Array.isArray(value.screenshots) ? value.screenshots.slice(-5).map((item) => compactArtifactForStdout(item)).filter(Boolean) : [],
|
|
apiMatrix: compactApiMatrixForStdout(value.apiMatrix),
|
|
stepCount: Number.isFinite(value.stepCount) ? value.stepCount : null,
|
|
lastStep: compactStepForStdout(value.lastStep),
|
|
issueEvidence: compactIssueEvidenceForStdout(value.issueEvidence),
|
|
valuesRedacted: true,
|
|
};
|
|
}
|
|
|
|
function compactArtifactsForStdout(artifacts) {
|
|
const items = Array.isArray(artifacts?.items) ? artifacts.items : artifactRecords;
|
|
return {
|
|
runDir,
|
|
count: items.length,
|
|
screenshots: items.filter((item) => item && typeof item === "object" && item.kind === "screenshot").slice(-5).map((item) => compactArtifactForStdout(item)).filter(Boolean),
|
|
items: items.slice(-12).map((item) => compactArtifactForStdout(item)).filter(Boolean),
|
|
};
|
|
}
|
|
|
|
function compactArtifactForStdout(item) {
|
|
if (!item || typeof item !== "object") return null;
|
|
return {
|
|
kind: item.kind ?? null,
|
|
path: item.path ?? null,
|
|
byteCount: Number.isFinite(item.byteCount) ? item.byteCount : null,
|
|
sha256: item.sha256 ?? null,
|
|
error: typeof item.error === "string" ? compactText(item.error, 600) : item.error ?? null,
|
|
};
|
|
}
|
|
|
|
function compactApiMatrixForStdout(matrix) {
|
|
if (!matrix || typeof matrix !== "object" || Array.isArray(matrix)) return null;
|
|
return {
|
|
ok: matrix.ok === true,
|
|
count: Number.isFinite(matrix.count) ? matrix.count : null,
|
|
okCount: Number.isFinite(matrix.okCount) ? matrix.okCount : null,
|
|
failedCount: Number.isFinite(matrix.failedCount) ? matrix.failedCount : null,
|
|
items: Array.isArray(matrix.items)
|
|
? matrix.items.slice(0, 12).map((item) => {
|
|
const row = item && typeof item === "object" ? item : {};
|
|
return {
|
|
name: typeof row.name === "string" ? row.name : null,
|
|
path: typeof row.path === "string" ? row.path : null,
|
|
method: typeof row.method === "string" ? row.method : null,
|
|
ok: row.ok === true,
|
|
status: Number.isFinite(row.status) ? row.status : null,
|
|
error: typeof row.error === "string" ? compactText(row.error, 300) : row.error ?? null,
|
|
failureKind: typeof row.failureKind === "string" ? row.failureKind : null,
|
|
bodyKeys: Array.isArray(row.bodyKeys) ? row.bodyKeys.filter((key) => typeof key === "string").slice(0, 12) : [],
|
|
};
|
|
})
|
|
: [],
|
|
};
|
|
}
|
|
|
|
function compactJsonForStdout(value, depth = 0) {
|
|
if (value === null || value === undefined) return value ?? null;
|
|
if (typeof value === "string") return compactText(value, 180);
|
|
if (typeof value === "number" || typeof value === "boolean") return value;
|
|
if (typeof value === "bigint") return String(value);
|
|
if (typeof value === "function" || typeof value === "symbol") return "[" + typeof value + "]";
|
|
if (depth >= 3) return "[max-depth]";
|
|
if (Array.isArray(value)) return value.slice(0, 4).map((item) => compactJsonForStdout(item, depth + 1));
|
|
if (typeof value === "object") {
|
|
const out = {};
|
|
for (const [key, nested] of Object.entries(value).slice(0, 8)) {
|
|
out[key] = compactJsonForStdout(nested, depth + 1);
|
|
}
|
|
return out;
|
|
}
|
|
return compactText(String(value), 180);
|
|
}
|
|
|
|
function issueEvidenceFromPayload(payload) {
|
|
const steps = publicSteps();
|
|
const artifacts = Array.isArray(payload?.artifacts?.items) ? payload.artifacts.items : artifactRecords;
|
|
const screenshots = artifacts
|
|
.filter((item) => item && typeof item === "object" && item.kind === "screenshot")
|
|
.slice(-5)
|
|
.map((item) => compactArtifactForStdout(item))
|
|
.filter(Boolean);
|
|
const ok = payload?.ok === true;
|
|
const degradedReason = ok ? null : payload?.error ?? payload?.failureKind ?? "web-probe-script-failed";
|
|
const failureKind = ok ? null : classifyIssueFailureKind(payload?.failureKind ?? payload?.error ?? degradedReason, payload?.errorMessage);
|
|
const failedCondition = ok ? null : payload?.errorMessage ?? payload?.error ?? payload?.failureKind ?? "script did not pass";
|
|
const script = payload?.script && typeof payload.script === "object" ? payload.script : {};
|
|
return {
|
|
ok,
|
|
status: payload?.status ?? null,
|
|
degradedReason,
|
|
failureKind,
|
|
failedCondition,
|
|
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,
|
|
reportSha256: payload?.reportSha256 ?? null,
|
|
result: compactJsonForEvidence(script.result),
|
|
apiMatrix: compactApiMatrixForStdout(latestApiMatrixFromSteps(steps)),
|
|
lastStep: steps.length > 0 ? compactStepForEvidence(steps[steps.length - 1]) : null,
|
|
steps: steps.slice(-3).map((step) => compactStepForEvidence(step)),
|
|
lastScreenshot: compactArtifactForStdout(payload?.lastScreenshot),
|
|
screenshots,
|
|
valuesRedacted: true,
|
|
};
|
|
}
|
|
|
|
function compactIssueEvidenceForStdout(value) {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
return {
|
|
ok: value.ok === true,
|
|
status: value.status ?? null,
|
|
degradedReason: typeof value.degradedReason === "string" ? compactText(value.degradedReason, 600) : value.degradedReason ?? null,
|
|
failureKind: typeof value.failureKind === "string" ? compactText(value.failureKind, 300) : value.failureKind ?? null,
|
|
failedCondition: typeof value.failedCondition === "string" ? compactText(value.failedCondition, 1200) : value.failedCondition ?? null,
|
|
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,
|
|
reportSha256: value.reportSha256 ?? null,
|
|
result: compactJsonForEvidence(value.result),
|
|
apiMatrix: compactApiMatrixForStdout(value.apiMatrix),
|
|
lastStep: compactStepForEvidence(value.lastStep),
|
|
steps: Array.isArray(value.steps) ? value.steps.slice(-3).map((step) => compactStepForEvidence(step)) : [],
|
|
lastScreenshot: compactArtifactForStdout(value.lastScreenshot),
|
|
screenshots: Array.isArray(value.screenshots) ? value.screenshots.slice(-5).map((item) => compactArtifactForStdout(item)).filter(Boolean) : [],
|
|
valuesRedacted: true,
|
|
};
|
|
}
|
|
|
|
function compactStepForEvidence(step) {
|
|
if (!step || typeof step !== "object" || Array.isArray(step)) return null;
|
|
return {
|
|
index: Number.isFinite(step.index) ? step.index : null,
|
|
name: typeof step.name === "string" ? step.name : null,
|
|
ok: typeof step.ok === "boolean" ? step.ok : null,
|
|
atMs: Number.isFinite(step.atMs) ? step.atMs : null,
|
|
data: compactJsonForEvidence(step.data),
|
|
};
|
|
}
|
|
|
|
function compactJsonForEvidence(value, depth = 0) {
|
|
if (value === null || value === undefined) return value ?? null;
|
|
if (typeof value === "string") return compactText(value, 240);
|
|
if (typeof value === "number" || typeof value === "boolean") return value;
|
|
if (typeof value === "bigint") return String(value);
|
|
if (typeof value === "function" || typeof value === "symbol") return "[" + typeof value + "]";
|
|
if (depth >= 4) return "[max-depth]";
|
|
if (Array.isArray(value)) return value.slice(0, 6).map((item) => compactJsonForEvidence(item, depth + 1));
|
|
if (typeof value === "object") {
|
|
const out = {};
|
|
for (const [key, nested] of Object.entries(value).slice(0, 12)) {
|
|
out[key] = compactJsonForEvidence(nested, depth + 1);
|
|
}
|
|
return out;
|
|
}
|
|
return compactText(String(value), 240);
|
|
}
|
|
|
|
function compactText(value, maxChars) {
|
|
return redactString(String(value)).replace(/\s+/gu, " ").trim().slice(0, maxChars);
|
|
}
|
|
|
|
function scriptIssueSummary(payload) {
|
|
const artifacts = Array.isArray(payload?.artifacts?.items) ? payload.artifacts.items : artifactRecords;
|
|
const screenshots = artifacts
|
|
.filter((item) => item && typeof item === "object" && item.kind === "screenshot")
|
|
.slice(-5)
|
|
.map((item) => ({
|
|
path: item.path ?? null,
|
|
sha256: item.sha256 ?? null,
|
|
byteCount: item.byteCount ?? null,
|
|
}));
|
|
const apiMatrix = latestApiMatrixFromSteps(publicSteps());
|
|
const ok = payload?.ok === true;
|
|
const degradedReason = ok ? null : payload?.error ?? payload?.failureKind ?? "web-probe-script-failed";
|
|
const failureKind = ok ? null : classifyIssueFailureKind(payload?.failureKind ?? payload?.error ?? degradedReason, payload?.errorMessage);
|
|
return {
|
|
ok,
|
|
status: payload?.status ?? null,
|
|
degradedReason,
|
|
failureKind,
|
|
failedCondition: ok ? null : payload?.errorMessage ?? payload?.error ?? payload?.failureKind ?? "script did not pass",
|
|
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,
|
|
reportSha256: payload?.reportSha256 ?? null,
|
|
lastScreenshot: payload?.lastScreenshot ?? null,
|
|
screenshots,
|
|
apiMatrix,
|
|
stepCount: stepRecords.length,
|
|
lastStep: stepRecords.length > 0 ? deepClonePlain(stepRecords[stepRecords.length - 1]) : null,
|
|
issueEvidence: issueEvidenceFromPayload(payload),
|
|
valuesRedacted: true,
|
|
};
|
|
}
|
|
|
|
function latestApiMatrixFromSteps(steps) {
|
|
for (let index = steps.length - 1; index >= 0; index -= 1) {
|
|
const step = steps[index];
|
|
const data = step && typeof step === "object" ? step.data : null;
|
|
if (data && typeof data === "object" && Array.isArray(data.items) && typeof data.failedCount === "number") return data;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
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 (/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";
|
|
if (/browser|chromium|playwright|executable/iu.test(text)) return "browser-environment-bug";
|
|
if (/assert|expect|ok:false|validation|final-response|message/iu.test(text)) return "unmet-expectation";
|
|
if (/script|ReferenceError|TypeError|SyntaxError/iu.test(text)) return "script-bug";
|
|
return "user-facing-web-bug";
|
|
}
|
|
|
|
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.";
|
|
if (failureKind === "browser-environment-bug") return "Inspect Playwright/browser-launcher availability in the target workspace.";
|
|
if (failureKind === "unmet-expectation") return "Inspect summary.failedCondition, DOM/API steps, and screenshots to decide whether the Web behavior or the assertion is stale.";
|
|
return payload?.guidance ?? "Inspect reportPath for full redacted details and rerun the same node/lane entry after the root cause is fixed.";
|
|
}
|
|
`;
|
|
}
|