Files
pikasTech-unidesk/scripts/src/hwlab-node-web-probe-runner-source.ts
T
2026-06-30 13:05:34 +00:00

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.";
}
`;
}