1571 lines
58 KiB
TypeScript
1571 lines
58 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 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);
|
|
context = await browser.newContext({ viewport });
|
|
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 = !(safeResult && typeof safeResult === "object" && safeResult.ok === false);
|
|
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,
|
|
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,
|
|
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) {
|
|
const apiAuth = await authenticateWithApiRetries(browserContext);
|
|
if (apiAuth.ok) return apiAuth;
|
|
return authenticateWithFormFallback(browserContext, apiAuth);
|
|
}
|
|
|
|
async function authenticateWithApiRetries(browserContext) {
|
|
const loginUrl = new URL("/auth/login", baseUrl).toString();
|
|
const attempts = [];
|
|
const maxAttempts = 3;
|
|
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
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,
|
|
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,
|
|
fallbackUsed: false,
|
|
valuesRedacted: true,
|
|
};
|
|
}
|
|
if (!retryable) break;
|
|
} catch (error) {
|
|
attempts.push({
|
|
attempt,
|
|
method: "api",
|
|
status: 0,
|
|
statusText: "request-error",
|
|
retryable: true,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
cookiePresent: false,
|
|
});
|
|
}
|
|
if (attempt < maxAttempts) await sleep(300 * attempt);
|
|
}
|
|
const cookieState = await readAuthCookieState(browserContext);
|
|
const last = attempts[attempts.length - 1] ?? null;
|
|
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),
|
|
fallbackUsed: false,
|
|
retryable: authAttemptsRetryable(attempts),
|
|
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 helpers = {
|
|
browser,
|
|
context,
|
|
page,
|
|
baseUrl,
|
|
runDir,
|
|
auth: publicAuth(auth),
|
|
artifactPath,
|
|
screenshot,
|
|
screenshotOnError,
|
|
jsonArtifact,
|
|
sha256File,
|
|
wait: sleep,
|
|
goto,
|
|
gotoRaw,
|
|
gotoStable,
|
|
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 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,
|
|
};
|
|
if (options.throwOnError === true && result.ok !== true) {
|
|
throw stableProbeError("api-response-failed", result.error || result.statusText || "fetchJson failed", publicReadiness({ error: "api-response-failed" }));
|
|
}
|
|
return result;
|
|
}
|
|
|
|
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"];
|
|
return gotoStable(target, next);
|
|
}
|
|
|
|
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 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",
|
|
"readinessTimeoutMs",
|
|
"retryDelayMs",
|
|
"reusePage",
|
|
"screenshotOnFailure",
|
|
"selectorState",
|
|
"selectors",
|
|
"stable",
|
|
]) {
|
|
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) || "script returned ok:false";
|
|
const error = new Error(message);
|
|
error.code = "assertion-failed";
|
|
return error;
|
|
}
|
|
|
|
function extractFailureMessage(value) {
|
|
if (!value || typeof value !== "object") return null;
|
|
for (const key of ["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",
|
|
"browser-failed",
|
|
"browser-load-jitter",
|
|
"selector-timeout",
|
|
"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 (/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 === "script-api-misuse") return "script-api-misuse";
|
|
if (code === "auth-login-failed") return "auth-failed";
|
|
if (code === "browser-failed") return "browser-failed";
|
|
if (code === "browser-load-jitter" || code === "selector-timeout" || code === "api-not-sent" || code === "api-response-failed") return "navigation-failed";
|
|
if (/browser has been closed|browser executable|failed to launch/iu.test(message)) return "browser-failed";
|
|
return "assertion-failed";
|
|
}
|
|
|
|
function probeFailureGuidance(failureKind) {
|
|
if (failureKind === "script-api-misuse") return evaluateSingleArgGuidance();
|
|
if (failureKind === "navigation-failed") return "Inspect probe.readiness for selector/API readiness details and lastScreenshot for the browser state.";
|
|
if (failureKind === "auth-failed") return "Inspect probe.auth sourceRef/fingerprint/status; do not print or copy the web login secret.";
|
|
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;
|
|
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,
|
|
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
|
|
: 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 > 8) 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(finalPayload), null, 2) + "\n");
|
|
}
|
|
|
|
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,
|
|
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,
|
|
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 (/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 === "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.";
|
|
}
|
|
`;
|
|
}
|