Merge pull request #449 from pikasTech/fix/issue-448-web-probe-stable
fix: stabilize hwlab web probe navigation
This commit is contained in:
@@ -24,6 +24,8 @@ 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。需要自定义 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、status、attempts、fallback 和 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。
|
||||
|
||||
`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 当成输入基础镜像。
|
||||
|
||||
`hwlab nodes control-plane infra tools-image status|build|logs --node D601 --lane v03` 是 D601 tools image 的受控入口。Dockerfile 必须由 `config/hwlab-node-control-plane.yaml` 的 `tekton.toolsImage.dockerfileInline` 声明,输入镜像必须列在 `publicBaseImages`,构建参数和网络模式也来自 YAML;confirmed build 只在 D601 后台异步构建并推送到 node-local registry,返回 status/logs 轮询命令。`hwlab nodes control-plane infra argo status|apply|logs --node D601 --lane v03` 是 D601 Argo CD 的声明式安装入口。Argo 版本、官方 manifest URL、镜像 rewrite/preload、field manager、imagePullPolicy、CRD 列表、期望 Deployment/StatefulSet 以及生成的 AppProject/Application 都必须来自同一个 YAML;`argo apply --confirm` 只执行可重复 server-side apply 和后台轮询,不把原生 `kubectl apply`、手工 Argo CLI 或临时 manifest 作为正式安装路径。
|
||||
|
||||
+475
-22
@@ -256,11 +256,11 @@ 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 ({ page, goto, screenshot }) => {\n await page.route('**/v1/agent/conversations**', route => setTimeout(() => route.continue(), 3000));\n await goto('/workbench');\n await screenshot('workbench.png');\n return { finalUrl: page.url() };\n};\nJS",
|
||||
"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",
|
||||
],
|
||||
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 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/gotoStable/waitForReady helpers and must not handle secrets itself.",
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -3740,6 +3740,7 @@ const userScript = path.resolve(process.env.UNIDESK_WEB_PROBE_USER_SCRIPT || pat
|
||||
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 = [];
|
||||
let browser;
|
||||
let context;
|
||||
let page;
|
||||
@@ -3760,25 +3761,7 @@ try {
|
||||
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({
|
||||
browser,
|
||||
context,
|
||||
page,
|
||||
baseUrl,
|
||||
runDir,
|
||||
auth: publicAuth(auth),
|
||||
artifactPath,
|
||||
screenshot,
|
||||
jsonArtifact,
|
||||
sha256File,
|
||||
wait: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
||||
goto: async (target = "/workbench", options = {}) => {
|
||||
const url = new URL(target, baseUrl).toString();
|
||||
await page.goto(url, { waitUntil: "domcontentloaded", timeout: timeoutMs, ...options });
|
||||
return page.url();
|
||||
},
|
||||
request: context.request,
|
||||
});
|
||||
const scriptResult = await fn(scriptHelpers());
|
||||
const safeResult = sanitize(scriptResult);
|
||||
const scriptOk = !(safeResult && typeof safeResult === "object" && safeResult.ok === false);
|
||||
await emit({
|
||||
@@ -3791,11 +3774,13 @@ try {
|
||||
finalUrl: page.url(),
|
||||
auth: publicAuth(auth),
|
||||
script: { ok: scriptOk, result: safeResult },
|
||||
readiness: publicReadiness(),
|
||||
artifacts: { runDir, items: artifactRecords },
|
||||
safety: { valuesRedacted: true, secretValuesPrinted: false }
|
||||
});
|
||||
process.exitCode = scriptOk ? 0 : 2;
|
||||
} catch (error) {
|
||||
const failure = classifiedProbeError(error);
|
||||
await emit({
|
||||
ok: false,
|
||||
status: "blocked",
|
||||
@@ -3805,7 +3790,9 @@ try {
|
||||
baseUrl,
|
||||
finalUrl: page ? page.url() : null,
|
||||
auth: auth === null ? null : publicAuth(auth),
|
||||
error: sanitize(error instanceof Error ? error.message : String(error)),
|
||||
error: failure.code,
|
||||
errorMessage: sanitize(error instanceof Error ? error.message : String(error)),
|
||||
readiness: publicReadiness({ error: failure.code }),
|
||||
artifacts: { runDir, items: artifactRecords },
|
||||
safety: { valuesRedacted: true, secretValuesPrinted: false }
|
||||
});
|
||||
@@ -4000,6 +3987,470 @@ async function formLoginOutcome(targetPage) {
|
||||
return handle.jsonValue().catch(() => null);
|
||||
}
|
||||
|
||||
function scriptHelpers() {
|
||||
const helpers = {
|
||||
browser,
|
||||
context,
|
||||
page,
|
||||
baseUrl,
|
||||
runDir,
|
||||
auth: publicAuth(auth),
|
||||
artifactPath,
|
||||
screenshot,
|
||||
jsonArtifact,
|
||||
sha256File,
|
||||
wait: sleep,
|
||||
goto,
|
||||
gotoRaw,
|
||||
gotoStable,
|
||||
waitForReady,
|
||||
getPage: () => page,
|
||||
request: context.request,
|
||||
};
|
||||
Object.defineProperty(helpers, "currentPage", {
|
||||
enumerable: false,
|
||||
get: () => page,
|
||||
});
|
||||
return helpers;
|
||||
}
|
||||
|
||||
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 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 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";
|
||||
return {
|
||||
code,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
detail: error && typeof error === "object" ? error.readiness ?? null : null,
|
||||
};
|
||||
}
|
||||
|
||||
function stableProbeError(code, message, readiness) {
|
||||
const error = new Error(message);
|
||||
error.code = code;
|
||||
error.readiness = readiness;
|
||||
return error;
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
async function responseSummary(response) {
|
||||
const status = response.status();
|
||||
let bodyPreview = null;
|
||||
@@ -4172,8 +4623,10 @@ function compactWebProbeScriptResult(report: Record<string, unknown> | null): Re
|
||||
finalUrl: typeof report.finalUrl === "string" ? report.finalUrl : null,
|
||||
auth: record(report.auth),
|
||||
script: record(report.script),
|
||||
readiness: record(report.readiness),
|
||||
artifacts: record(report.artifacts),
|
||||
error: typeof report.error === "string" ? report.error : null,
|
||||
errorMessage: typeof report.errorMessage === "string" ? report.errorMessage : null,
|
||||
safety: record(report.safety),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user