fix: improve hwlab web-probe script diagnostics (#462)
Co-authored-by: Codex <codex@noreply.local>
This commit is contained in:
@@ -24,7 +24,9 @@ G14/D601 v03 的 bootstrap admin password 是 HWLAB runtime Secret 生命周期
|
||||
|
||||
`hwlab nodes web-probe run --node <node> --lane <lane> [--url <public-origin>]` 是 HWLAB Cloud Web DOM probe 的受控指挥入口。它从 `config/hwlab-node-lanes.yaml` 解析目标 workspace、public URL 和 bootstrap admin sourceRef,在 UniDesk 指挥侧读取 owner-only 明文后只通过一次性 stdin/env 注入目标 workspace 的 `scripts/web-live-dom-probe.mjs`;stdout 只披露 sourceRef、sourceKey、presence、fingerprint、注入方式、DOM 摘要和 artifact hash,不打印密码。缺少 sourceRef 或 source 文件时应结构化返回 `web_login_secret_missing`,不能回退历史默认密码或要求把 secret 复制到 D601/G14 目标 host。Code Agent Trace 实时性验收使用 `--trace-sample-count <N>` 和 `--trace-sample-interval-ms <ms>` 透传到目标 helper,输出每次采样的 agent status、trace presence/status、row count、empty label 和最新 row preview,用于证明运行中渐进拉取;这类采样不能由终态截图替代。需要自定义 Playwright route/intercept、in-flight DOM 读取或专用截图时,使用 `hwlab nodes web-probe script --node <node> --lane <lane> <<'JS' ... JS`,由 CLI 负责同一 sourceRef 凭据解析、`/auth/login` 建立 `hwlab_session`、已认证 `browser/context/page/baseUrl` 注入和 artifact path/hash 摘要;自定义脚本不得自行读取或打印 Web 登录凭据。`web-probe script` 托管登录先对同源 `/auth/login` 做短重试;仍未拿到 `hwlab_session` 时自动回到当前 Cloud Web 登录表单,以浏览器方式提交同一凭据。`probe.auth` 只输出 method、origin、loginPath、status、attempts、retryCount、fallbackUsed、fallback、retryable、transientObserved、fingerprint、commanderAction 和 redacted errorSummary,不打印密码、cookie 或可复制 session 值。
|
||||
|
||||
`web-probe script` 的默认 `goto('/workbench')` 是稳定导航边界:它会先复用当前 page,失败后有限次切 fresh page 重试,并等待 workbench 基础 DOM(默认 `#workspace` 和 `#command-input`)可见;需要显式控制时使用注入的 `gotoStable(target, { selectors, activeSelector, attempts, readinessTimeoutMs })`、`waitForReady({ selectors })`、`gotoRaw()` 和 `getPage()`。稳定化失败必须在 `probe.readiness` 中低噪声披露 attempt、阶段、selector、是否观察到 `/v1` API request、API failure 摘要和失败截图 artifact;分类值固定为 `browser-load-jitter`、`selector-timeout`、`api-not-sent`、`api-response-failed`,避免把“页面没准备好/请求未发出”和“后端响应失败”混成同一种 selector timeout。runner 不在用户脚本执行前抢先导航同一 page,保证脚本仍可先安装 `page.route` 或 context route;如重试切换 fresh page,后续脚本应通过 `gotoStable()` 返回值或 `getPage()` 取得当前 page。
|
||||
`web-probe script` 的默认 `goto('/workbench')` 是稳定导航边界:它会先复用当前 page,失败后有限次切 fresh page 重试,并等待 workbench 基础 DOM(默认 `#workspace` 和 `#command-input`)可见;需要显式控制时使用注入的 `gotoStable(target, { selectors, activeSelector, attempts, readinessTimeoutMs })`、`waitWorkbenchReady({ selectors })`、`waitForReady({ selectors })`、`gotoRaw()` 和 `getPage()`。稳定化失败必须在 `probe.readiness` 中低噪声披露 attempt、阶段、selector、是否观察到 `/v1` API request、API failure 摘要和失败截图 artifact;分类值固定为 `browser-load-jitter`、`selector-timeout`、`api-not-sent`、`api-response-failed`,避免把“页面没准备好/请求未发出”和“后端响应失败”混成同一种 selector timeout。runner 不在用户脚本执行前抢先导航同一 page,保证脚本仍可先安装 `page.route` 或 context route;如重试切换 fresh page,后续脚本应通过 `gotoStable()` 返回值或 `getPage()` 取得当前 page。
|
||||
|
||||
`web-probe script` 的调试 helper 必须覆盖常见 Workbench 探针动作:`fetchJson(path)` 在已登录页面上下文里按同源 cookie 请求 JSON,失败返回 `{ ok:false, path, status, error }` 而不是吞掉证据;`collectText(selector)` 返回 selector 命中数量和文本摘要;`safeEvaluate(fn, arg)` 固定使用 Playwright 单参数规则;`screenshotOnError(name, fn)` 在用户断言抛错时落 `failure.png`;`summarizeWorkspace()` 与 `summarizeConversation()` 只输出会话/消息摘要,不打印 cookie、密码或 token。可复用脚本优先用 `--script-file <path>`,一次性探针才用 stdin heredoc。Playwright `page.evaluate` 只能传一个可序列化参数;需要多个值时必须写成 `page.evaluate(({ a, b }) => ..., { a, b })` 或使用 `safeEvaluate(fn, { a, b })`。脚本抛错或返回 `{ ok:false }` 时,`probe` 顶层必须保留 `failureKind=script-api-misuse|assertion-failed|navigation-failed|auth-failed|browser-failed`、`errorMessage`、`scriptSha256`、`runDir`、`lastUrl` 和 `lastScreenshot`;默认失败截图文件名是 `failure.png`,调用方脚本源码保存在同一 `runDir` 以便复查。
|
||||
|
||||
`hwlab nodes control-plane infra plan|status|apply --node D601 --lane v03` 是 D601 HWLAB v03 节点本地 CI/CD 与 git-mirror 前置控制面的 YAML 驱动入口,配置真相源是 `config/hwlab-node-control-plane.yaml`。`plan` 只读展示 YAML target 和将渲染的 control-plane 对象;`status` 只读观察 D601 Tekton、CI namespace、git-mirror、Argo、node-local registry 和 tools image readiness;`apply --dry-run` 只输出 manifest 摘要;`apply --confirm` 只收敛 D601 control-plane bootstrap 对象,不触发 HWLAB runtime rollout,不创建 PK01 DB,也不修改 Caddy/FRP。tools image 的 node-local registry 地址只能作为输出 artifact;输入 base image 必须由 YAML 声明为公开 registry 来源,缺少 output image 时应在 `status.next.blockers` 中体现,而不是把现有 node-local image 当成输入基础镜像。
|
||||
|
||||
|
||||
+447
-12
@@ -282,12 +282,18 @@ export function hwlabNodeWebProbeHelp(): Record<string, unknown> {
|
||||
examples: [
|
||||
"bun scripts/cli.ts hwlab nodes web-probe run --node D601 --lane v03 --wait-messages-ms 1000",
|
||||
"bun scripts/cli.ts hwlab nodes web-probe run --node D601 --lane v03 --url https://hwlab.pikapython.com --fresh-session --message 'ping'",
|
||||
"bun scripts/cli.ts hwlab nodes web-probe script --node D601 --lane v03 <<'JS'\nexport default async ({ gotoStable, screenshot }) => {\n const ready = await gotoStable('/workbench', { selectors: ['#workspace', '#command-input'] });\n await screenshot('workbench.png');\n return { finalUrl: ready.finalUrl, readiness: ready.readiness };\n};\nJS",
|
||||
"bun scripts/cli.ts hwlab nodes web-probe script --node D601 --lane v03 --script-file .state/probes/workbench.mjs",
|
||||
"bun scripts/cli.ts hwlab nodes web-probe script --node D601 --lane v03 <<'JS'\nexport default async ({ waitWorkbenchReady, fetchJson, collectText, safeEvaluate, screenshot }) => {\n const ready = await waitWorkbenchReady();\n const workspace = await fetchJson('/v1/workbench/workspace?projectId=prj_hwpod_workbench');\n const workspaceText = await collectText('#workspace');\n const evaluated = await safeEvaluate(({ a, b }) => ({ sum: a + b }), { a: 1, b: 2 });\n await screenshot('workbench.png');\n return { finalUrl: ready.finalUrl, workspaceOk: workspace.ok, workspaceText, evaluated };\n};\nJS",
|
||||
],
|
||||
actions: {
|
||||
run: "Run the repo-owned scripts/web-live-dom-probe.mjs helper.",
|
||||
script: "Run caller-provided Playwright JS after CLI-managed /auth/login; the script receives authenticated browser/context/page/gotoStable/waitForReady helpers and must not handle secrets itself.",
|
||||
script: "Run caller-provided Playwright JS after CLI-managed /auth/login; the script receives authenticated browser/context/page plus fetchJson/collectText/safeEvaluate/waitWorkbenchReady/screenshotOnError/summarizeWorkspace/summarizeConversation helpers and must not handle secrets itself.",
|
||||
},
|
||||
notes: [
|
||||
"Prefer --script-file for reusable probes; stdin heredocs remain supported for one-off probes.",
|
||||
"Playwright page.evaluate accepts one serializable argument; use page.evaluate(({ a, b }) => ..., { a, b }) or safeEvaluate(fn, { a, b }).",
|
||||
"Failures include failureKind, errorMessage, scriptSha256, runDir, lastUrl, and lastScreenshot when a screenshot can be captured.",
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4756,10 +4762,12 @@ 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);
|
||||
@@ -4774,6 +4782,9 @@ try {
|
||||
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",
|
||||
@@ -4781,16 +4792,26 @@ try {
|
||||
generatedAt: new Date().toISOString(),
|
||||
startedAt,
|
||||
baseUrl,
|
||||
finalUrl: page.url(),
|
||||
finalUrl: lastUrl,
|
||||
lastUrl,
|
||||
scriptSha256: userScriptSha256,
|
||||
runDir,
|
||||
auth: publicAuth(auth),
|
||||
script: { ok: scriptOk, result: safeResult },
|
||||
readiness: publicReadiness(),
|
||||
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",
|
||||
@@ -4798,11 +4819,17 @@ try {
|
||||
generatedAt: new Date().toISOString(),
|
||||
startedAt,
|
||||
baseUrl,
|
||||
finalUrl: page ? page.url() : null,
|
||||
finalUrl: lastUrl,
|
||||
lastUrl,
|
||||
scriptSha256: userScriptSha256,
|
||||
runDir,
|
||||
auth: auth === null ? null : publicAuth(auth),
|
||||
failureKind: failure.failureKind,
|
||||
error: failure.code,
|
||||
errorMessage: sanitize(error instanceof Error ? error.message : String(error)),
|
||||
readiness: publicReadiness({ 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 }
|
||||
});
|
||||
@@ -5025,13 +5052,20 @@ function scriptHelpers() {
|
||||
auth: publicAuth(auth),
|
||||
artifactPath,
|
||||
screenshot,
|
||||
screenshotOnError,
|
||||
jsonArtifact,
|
||||
sha256File,
|
||||
wait: sleep,
|
||||
goto,
|
||||
gotoRaw,
|
||||
gotoStable,
|
||||
waitWorkbenchReady,
|
||||
waitForReady,
|
||||
fetchJson,
|
||||
collectText,
|
||||
safeEvaluate,
|
||||
summarizeWorkspace,
|
||||
summarizeConversation,
|
||||
getPage: () => page,
|
||||
request: context.request,
|
||||
};
|
||||
@@ -5042,6 +5076,214 @@ function scriptHelpers() {
|
||||
return helpers;
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -5391,6 +5633,19 @@ async function captureReadinessScreenshot(targetPage, attempt, options) {
|
||||
}
|
||||
}
|
||||
|
||||
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 });
|
||||
@@ -5448,12 +5703,15 @@ function classifyNavigationError(error) {
|
||||
}
|
||||
|
||||
function classifiedProbeError(error) {
|
||||
const known = new Set(["browser-load-jitter", "selector-timeout", "api-not-sent", "api-response-failed", "auth-login-failed"]);
|
||||
const code = error && typeof error === "object" && known.has(error.code) ? error.code : error instanceof Error && known.has(error.message) ? error.message : "script-failed";
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const code = probeErrorCode(error, message);
|
||||
const failureKind = probeFailureKind(code, message);
|
||||
return {
|
||||
code,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
failureKind,
|
||||
message,
|
||||
detail: error && typeof error === "object" ? error.readiness ?? null : null,
|
||||
guidance: probeFailureGuidance(failureKind),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5464,6 +5722,85 @@ function stableProbeError(code, message, 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);
|
||||
@@ -5479,6 +5816,94 @@ function boundedInteger(raw, fallback, min, max) {
|
||||
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;
|
||||
}
|
||||
@@ -5638,8 +6063,8 @@ async function recordArtifact(file, kind) {
|
||||
byteCount: fileStat.size,
|
||||
sha256: await sha256File(file),
|
||||
};
|
||||
artifactRecords.push(item);
|
||||
return item;
|
||||
artifactRecords.push({ ...item });
|
||||
return { ...item };
|
||||
}
|
||||
|
||||
async function sha256File(file) {
|
||||
@@ -5692,8 +6117,14 @@ function compactWebProbeScriptResult(report: Record<string, unknown> | null): Re
|
||||
status: typeof report.status === "string" ? report.status : null,
|
||||
baseUrl: typeof report.baseUrl === "string" ? report.baseUrl : null,
|
||||
finalUrl: typeof report.finalUrl === "string" ? report.finalUrl : null,
|
||||
lastUrl: typeof report.lastUrl === "string" ? report.lastUrl : null,
|
||||
scriptSha256: typeof report.scriptSha256 === "string" ? report.scriptSha256 : null,
|
||||
runDir: typeof report.runDir === "string" ? report.runDir : null,
|
||||
auth: record(report.auth),
|
||||
script: record(report.script),
|
||||
failureKind: typeof report.failureKind === "string" ? report.failureKind : null,
|
||||
guidance: typeof report.guidance === "string" ? report.guidance : null,
|
||||
lastScreenshot: nullableRecord(report.lastScreenshot),
|
||||
readiness: record(report.readiness),
|
||||
artifacts: record(report.artifacts),
|
||||
error: typeof report.error === "string" ? report.error : null,
|
||||
@@ -8441,6 +8872,10 @@ function record(value: unknown): Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
function nullableRecord(value: unknown): Record<string, unknown> | null {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : null;
|
||||
}
|
||||
|
||||
function stringValue(value: unknown, path: string): string {
|
||||
if (typeof value !== "string" || value.length === 0) throw new Error(`${path} must be a non-empty string`);
|
||||
return value;
|
||||
|
||||
Reference in New Issue
Block a user