Merge pull request #449 from pikasTech/fix/issue-448-web-probe-stable

fix: stabilize hwlab web probe navigation
This commit is contained in:
Lyon
2026-06-16 12:37:26 +08:00
committed by GitHub
2 changed files with 477 additions and 22 deletions
+2
View File
@@ -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`,构建参数和网络模式也来自 YAMLconfirmed 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
View File
@@ -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),
};
}