fix: 补齐 WebProbe 浏览器启动门禁

This commit is contained in:
AgentRun Codex
2026-07-13 07:34:31 +00:00
parent c998485d4a
commit 4b39c9ce41
9 changed files with 322 additions and 20 deletions
+6
View File
@@ -588,6 +588,12 @@ templates:
maxProcessReadConcurrency: 32
maxRealtimeTotalEntries: 512
maxRealtimeTotalBodyBytes: 4194304
browserEvidence:
screenshotMode: viewport
maxNetworkRequestEntries: 200
maxNetworkRequestBytes: 262144
maxNetworkFailureEntries: 100
maxNetworkFailureBytes: 131072
origins:
internal:
mode: k8s-service-cluster-ip
+17
View File
@@ -203,6 +203,15 @@ export interface HwlabRuntimeWebProbeObserverLifecycleSpec {
export interface HwlabRuntimeWebProbeResourcePolicySpec {
readonly memoryStartGuard: HwlabRuntimeWebProbeMemoryStartGuardSpec;
readonly observerLifecycle: HwlabRuntimeWebProbeObserverLifecycleSpec;
readonly browserEvidence: HwlabRuntimeWebProbeBrowserEvidenceSpec;
}
export interface HwlabRuntimeWebProbeBrowserEvidenceSpec {
readonly screenshotMode: "viewport";
readonly maxNetworkRequestEntries: number;
readonly maxNetworkRequestBytes: number;
readonly maxNetworkFailureEntries: number;
readonly maxNetworkFailureBytes: number;
}
export interface HwlabRuntimeWebProbeWorkbenchKafkaDebugReplaySpec {
@@ -1482,6 +1491,7 @@ function webProbeResourcePolicyConfig(value: unknown, path: string): HwlabRuntim
const raw = asRecord(value, path);
const memory = asRecord(raw.memoryStartGuard, `${path}.memoryStartGuard`);
const lifecycle = asRecord(raw.observerLifecycle, `${path}.observerLifecycle`);
const browserEvidence = asRecord(raw.browserEvidence, `${path}.browserEvidence`);
return {
memoryStartGuard: {
metric: enumStringField(memory, "metric", `${path}.memoryStartGuard`, ["MemAvailable"] as const),
@@ -1522,6 +1532,13 @@ function webProbeResourcePolicyConfig(value: unknown, path: string): HwlabRuntim
maxRealtimeTotalEntries: boundedIntegerField(lifecycle, "maxRealtimeTotalEntries", `${path}.observerLifecycle`, 1, 100_000),
maxRealtimeTotalBodyBytes: boundedIntegerField(lifecycle, "maxRealtimeTotalBodyBytes", `${path}.observerLifecycle`, 1024, 64 * 1024 * 1024),
},
browserEvidence: {
screenshotMode: enumStringField(browserEvidence, "screenshotMode", `${path}.browserEvidence`, ["viewport"] as const),
maxNetworkRequestEntries: boundedIntegerField(browserEvidence, "maxNetworkRequestEntries", `${path}.browserEvidence`, 1, 100_000),
maxNetworkRequestBytes: boundedIntegerField(browserEvidence, "maxNetworkRequestBytes", `${path}.browserEvidence`, 1024, 64 * 1024 * 1024),
maxNetworkFailureEntries: boundedIntegerField(browserEvidence, "maxNetworkFailureEntries", `${path}.browserEvidence`, 1, 100_000),
maxNetworkFailureBytes: boundedIntegerField(browserEvidence, "maxNetworkFailureBytes", `${path}.browserEvidence`, 1024, 64 * 1024 * 1024),
},
};
}
@@ -5,7 +5,8 @@ import { test } from "bun:test";
import type { CommandResult } from "./command";
import { rootPath } from "./config";
import { hwlabRuntimeLaneSpecForNode } from "./hwlab-node-lanes";
import { evaluateWebProbeMemoryGuard, runWebProbeMemoryGuard, webProbeMemoryStartGuardConfigRef } from "./hwlab-node-web-probe-memory-guard";
import { evaluateWebProbeMemoryGuard, runWebProbeMemoryGuard, webProbeBrowserEvidenceEnvAssignments, webProbeGuardedLaunchShell, webProbeMemoryStartGuardConfigRef } from "./hwlab-node-web-probe-memory-guard";
import { nodeWebProbeScriptRunnerSource } from "./hwlab-node-web-probe-runner-source";
const spec = hwlabRuntimeLaneSpecForNode("v03", "NC01");
const thresholdBytes = 4 * 1024 * 1024 * 1024;
@@ -23,6 +24,15 @@ function memoryPayload(availableBytes: number, extra: Record<string, unknown> =
};
}
function exportedFunctionSource(source: string, name: string): string {
const start = source.indexOf(`export function ${name}(`);
assert.ok(start >= 0, `missing exported function ${name}`);
const nextExport = source.indexOf("\nexport function ", start + 1);
const nextFunction = source.indexOf("\nfunction ", start + 1);
const next = [nextExport, nextFunction].filter((value) => value >= 0).sort((left, right) => left - right)[0] ?? -1;
return source.slice(start, next < 0 ? source.length : next);
}
test("WebProbe memory threshold is read from the single owning YAML policy", () => {
const policy = spec.webProbe?.resourcePolicy?.memoryStartGuard;
assert.equal(policy?.thresholdBytes, thresholdBytes);
@@ -34,6 +44,21 @@ test("WebProbe memory threshold is read from the single owning YAML policy", ()
assert.match(webProbeMemoryStartGuardConfigRef, /resourcePolicy\.memoryStartGuard$/u);
});
test("browser evidence limits and viewport screenshots are read from owning YAML", () => {
const policy = spec.webProbe?.resourcePolicy?.browserEvidence;
assert.deepEqual(policy, {
screenshotMode: "viewport",
maxNetworkRequestEntries: 200,
maxNetworkRequestBytes: 262144,
maxNetworkFailureEntries: 100,
maxNetworkFailureBytes: 131072,
});
const assignments = webProbeBrowserEvidenceEnvAssignments(spec).join(" ");
assert.match(assignments, /UNIDESK_WEB_PROBE_SCREENSHOT_MODE='viewport'/u);
assert.match(assignments, /UNIDESK_WEB_PROBE_MAX_NETWORK_REQUEST_ENTRIES='200'/u);
assert.match(assignments, /UNIDESK_WEB_PROBE_MAX_NETWORK_FAILURE_BYTES='131072'/u);
});
test("manual start is blocked at exactly 4 GiB and requires controlled GC then MemAvailable recheck", () => {
const guard = evaluateWebProbeMemoryGuard(spec, "manual-start", memoryPayload(thresholdBytes));
const memory = guard.memory as Record<string, unknown>;
@@ -141,3 +166,83 @@ test("manual and sentinel gates return before any observer launch path", () => {
const plannedStart = serviceSource.indexOf('"bun", "scripts/cli.ts", "web-probe", "observe", "start"');
assert.match(serviceSource.slice(plannedStart, plannedStart + 800), /--sentinel-cadence/u);
});
test("all browser-launch entries use the shared preflight and final launch guard", () => {
const probeSource = readFileSync(rootPath("scripts/src/hwlab-node/web-probe-observe.ts"), "utf8");
const scriptSource = readFileSync(rootPath("scripts/src/hwlab-node/web-observe-scripts.ts"), "utf8");
const sentinelSource = readFileSync(rootPath("scripts/src/hwlab-node-web-sentinel-p5.ts"), "utf8");
const parserSource = readFileSync(rootPath("scripts/src/hwlab-node/web-probe.ts"), "utf8");
const run = exportedFunctionSource(probeSource, "runNodeWebProbe");
const screenshot = exportedFunctionSource(probeSource, "runNodeWebProbeScreenshot");
const asyncRun = exportedFunctionSource(probeSource, "runNodeWebProbeAsync");
const script = exportedFunctionSource(scriptSource, "runNodeWebProbeScript");
const scriptShell = exportedFunctionSource(scriptSource, "nodeWebProbeScriptRemoteShell");
const dashboard = exportedFunctionSource(sentinelSource, "probeSentinelDashboardBrowser");
const parser = exportedFunctionSource(parserSource, "parseNodeWebProbeOptions");
assert.doesNotMatch(probeSource, /runNodeWebProbeMemoryGuard/u);
assert.match(run, /if \(options\.action === "script"\) return runNodeWebProbeScript/u);
assert.match(run, /if \(options\.commandTimeoutSeconds > 55\) return runNodeWebProbeAsync/u);
assert.match(run, /runWebProbeMemoryGuard\(spec, "manual-start"\)[\s\S]+webProbeGuardedLaunchShell\(spec, "manual-start"/u);
assert.match(screenshot, /runWebProbeMemoryGuard\(spec, "manual-start"\)[\s\S]+webProbeGuardedLaunchShell\(spec, "manual-start", webProbeScreenshotRemoteScript/u);
assert.match(asyncRun, /runWebProbeMemoryGuard\(spec, "manual-start"\)[\s\S]+webProbeGuardedLaunchShell\(spec, "manual-start"[\s\S]+"structured-output"\)/u);
assert.match(script, /runWebProbeMemoryGuard\(spec, "manual-start"\)[\s\S]+nodeWebProbeScriptRemoteShell/u);
assert.match(scriptShell, /webProbeGuardedLaunchShell\(spec, "manual-start"/u);
assert.match(parser, /if \(actionRaw === "opencode-smoke"\)[\s\S]+action: "script"/u);
assert.match(dashboard, /runWebProbeMemoryGuard\(state\.spec, "sentinel-cadence"\)[\s\S]+webProbeGuardedLaunchShell\(state\.spec, "sentinel-cadence"/u);
assert.match(dashboard, /UNIDESK_SENTINEL_DASHBOARD_TRIGGER=.*options\.action === "trigger"/u);
assert.match(dashboard, /UNIDESK_SENTINEL_DASHBOARD_CAPTURE=.*options\.action === "screenshot"/u);
});
test("read-only and control-plane entries stay outside browser launch guard", () => {
const dispatchSource = readFileSync(rootPath("scripts/src/hwlab-node/web-probe-observe.ts"), "utf8");
const dispatchStart = dispatchSource.indexOf("export function runNodeWebProbe(");
const dispatchEnd = dispatchSource.indexOf("export function runNodeWebProbeScreenshot", dispatchStart);
const dispatch = dispatchSource.slice(dispatchStart, dispatchEnd);
const readOnlyReturn = dispatch.indexOf('options.action === "observe" && options.observeAction !== "start"');
const firstGuard = dispatch.indexOf("runWebProbeMemoryGuard");
assert.ok(readOnlyReturn >= 0 && firstGuard > readOnlyReturn);
assert.match(dispatch.slice(0, readOnlyReturn), /options\.action === "sentinel"/u);
assert.match(dispatch.slice(0, readOnlyReturn), /options\.action === "screenshot"/u);
const sentinelDispatch = readFileSync(rootPath("scripts/src/hwlab-node-web-sentinel-cicd.ts"), "utf8");
const sentinel = exportedFunctionSource(sentinelDispatch, "runWebProbeSentinelCommand");
assert.doesNotMatch(sentinel, /runWebProbeMemoryGuard|webProbeGuardedLaunchShell/u);
assert.match(sentinel, /options\.kind === "dashboard"/u);
});
test("generated runner bounds network evidence and defaults screenshots to viewport", () => {
const source = nodeWebProbeScriptRunnerSource();
assert.match(source, /maxNetworkRequestEntries/u);
assert.match(source, /maxNetworkRequestBytes/u);
assert.match(source, /maxNetworkFailureEntries/u);
assert.match(source, /maxNetworkFailureBytes/u);
assert.match(source, /apiRequestEvidenceDropped/u);
assert.match(source, /apiFailureEvidenceDropped/u);
assert.doesNotMatch(source, /fullPage: true/u);
assert.equal((source.match(/fullPage: screenshotMode !== "viewport"/gu) ?? []).length, 2);
const parserSource = exportedFunctionSource(readFileSync(rootPath("scripts/src/hwlab-node/web-probe.ts"), "utf8"), "parseNodeWebProbeOptions");
assert.match(parserSource, /fullPage: args\.includes\("--full-page"\) && !args\.includes\("--no-full-page"\)/u);
const screenshotSource = readFileSync(rootPath("scripts/src/hwlab-node/web-probe-observe.ts"), "utf8");
assert.match(screenshotSource, /UNIDESK_WEB_PROBE_SCREENSHOT_FULL_PAGE === "1"/u);
assert.doesNotMatch(screenshotSource, /UNIDESK_WEB_PROBE_SCREENSHOT_FULL_PAGE !== "0"/u);
const sentinelSource = readFileSync(rootPath("scripts/src/hwlab-node-web-sentinel-p5.ts"), "utf8");
assert.match(sentinelSource, /UNIDESK_SENTINEL_DASHBOARD_FULL_PAGE === "1"/u);
assert.doesNotMatch(sentinelSource, /UNIDESK_SENTINEL_DASHBOARD_FULL_PAGE !== "0"/u);
});
test("final launch guard rechecks MemAvailable under the configured lock", () => {
const sync = webProbeGuardedLaunchShell(spec, "manual-start", "node runner.mjs");
const async = webProbeGuardedLaunchShell(spec, "manual-start", "node helper.mjs start", "structured-output");
assert.match(sync, /flock -w '15'/u);
assert.match(sync, /MemAvailable:/u);
assert.match(sync, /mem_available_bytes.*-le '4294967296'/u);
assert.match(sync, /setsid sh -c/u);
assert.match(sync, /kill -TERM -- "-\$browser_job_pid"/u);
assert.match(sync, /kill -KILL -- "-\$browser_job_pid"/u);
assert.match(async, /browserCreated === true/u);
assert.match(async, /data\.workerPid, data\.jobPid, data\.pid/u);
assert.match(async, /\/proc\/.*\/cmdline/u);
});
@@ -24,6 +24,127 @@ function memoryPolicy(spec: HwlabRuntimeLaneSpec): HwlabRuntimeWebProbeMemorySta
return spec.webProbe?.resourcePolicy?.memoryStartGuard ?? null;
}
function shellQuote(value: string): string {
return `'${value.replace(/'/g, `'"'"'`)}'`;
}
export function webProbeBrowserEvidenceEnvAssignments(spec: HwlabRuntimeLaneSpec): string[] {
const policy = spec.webProbe?.resourcePolicy?.browserEvidence;
if (policy === undefined) throw new Error("web-probe browser evidence policy is missing from the owning YAML");
return [
`UNIDESK_WEB_PROBE_SCREENSHOT_MODE=${shellQuote(policy.screenshotMode)}`,
`UNIDESK_WEB_PROBE_MAX_NETWORK_REQUEST_ENTRIES=${shellQuote(String(policy.maxNetworkRequestEntries))}`,
`UNIDESK_WEB_PROBE_MAX_NETWORK_REQUEST_BYTES=${shellQuote(String(policy.maxNetworkRequestBytes))}`,
`UNIDESK_WEB_PROBE_MAX_NETWORK_FAILURE_ENTRIES=${shellQuote(String(policy.maxNetworkFailureEntries))}`,
`UNIDESK_WEB_PROBE_MAX_NETWORK_FAILURE_BYTES=${shellQuote(String(policy.maxNetworkFailureBytes))}`,
];
}
export function webProbeGuardedLaunchShell(
spec: HwlabRuntimeLaneSpec,
mode: WebProbeMemoryGuardMode,
command: string,
readiness: "child-process" | "structured-output" = "child-process",
): string {
const policy = memoryPolicy(spec);
if (policy === null) throw new Error("web-probe memory start policy is missing from the owning YAML");
const comparator = mode === "manual-start" ? "-le" : "-lt";
const common = [
`exec 9>${shellQuote(policy.launchLockPath)}`,
`flock -w ${shellQuote(String(policy.launchLockTimeoutSeconds))} 9`,
"mem_available_kib=$(awk '$1 == \"MemAvailable:\" { print $2; exit }' /proc/meminfo)",
"case \"$mem_available_kib\" in ''|*[!0-9]*) printf '%s\\n' 'web-probe final memory guard could not read MemAvailable' >&2; exit 42;; esac",
"mem_available_bytes=$((mem_available_kib * 1024))",
`if [ "$mem_available_bytes" ${comparator} ${shellQuote(String(policy.thresholdBytes))} ]; then printf '%s\\n' 'web-probe final memory guard blocked browser launch' >&2; exit 42; fi`,
];
if (readiness === "structured-output") return [
...common,
"launch_output=$(mktemp)",
"launch_error=$(mktemp)",
"trap 'rm -f \"$launch_output\" \"$launch_error\"' EXIT",
`if ! ${command} >"$launch_output" 2>"$launch_error"; then cat "$launch_output"; cat "$launch_error" >&2; exit 43; fi`,
"cat \"$launch_output\"",
"cat \"$launch_error\" >&2",
`node - "$launch_output" ${shellQuote(String(policy.launchReadyTimeoutSeconds * 1000))} ${shellQuote(String(policy.launchReadyPollMilliseconds))} <<'UNIDESK_WEB_PROBE_STRUCTURED_READINESS'`,
"const fs = require('fs');",
"const text = fs.readFileSync(process.argv[2], 'utf8').trim();",
"const timeoutMs = Number(process.argv[3]);",
"const pollMs = Number(process.argv[4]);",
"let value = null; try { value = JSON.parse(text); } catch {}",
"const data = value && typeof value === 'object' && value.data && typeof value.data === 'object' ? value.data : value;",
"if (!data || typeof data !== 'object' || !data.jobId) process.exit(43);",
"if (data.browserCreated === true && Number.isInteger(data.browserPid) && data.browserPid > 0) process.exit(0);",
"const rootPid = [data.browserPid, data.workerPid, data.jobPid, data.pid].find((item) => Number.isInteger(item) && item > 0);",
"if (!rootPid) process.exit(43);",
"const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));",
"const browserDescendant = () => {",
" const rows = [];",
" for (const name of fs.readdirSync('/proc')) {",
" if (!/^[0-9]+$/.test(name)) continue;",
" try {",
" const stat = fs.readFileSync('/proc/' + name + '/stat', 'utf8');",
" const close = stat.lastIndexOf(')');",
" const fields = stat.slice(close + 2).split(' ');",
" const ppid = Number(fields[1]);",
" const command = fs.readFileSync('/proc/' + name + '/cmdline', 'utf8').replace(/\\0/g, ' ');",
" rows.push({ pid: Number(name), ppid, command });",
" } catch {}",
" }",
" const descendants = new Set([rootPid]);",
" let changed = true;",
" while (changed) { changed = false; for (const row of rows) if (descendants.has(row.ppid) && !descendants.has(row.pid)) { descendants.add(row.pid); changed = true; } }",
" return rows.some((row) => descendants.has(row.pid) && /(chromium|chrome|playwright)/i.test(row.command));",
"};",
"(async () => {",
" const deadline = Date.now() + timeoutMs;",
" while (Date.now() <= deadline) { if (browserDescendant()) process.exit(0); await sleep(pollMs); }",
" process.exit(43);",
"})().catch(() => process.exit(43));",
"UNIDESK_WEB_PROBE_STRUCTURED_READINESS",
"flock -u 9",
].join("\n");
return [
...common,
`setsid sh -c ${shellQuote(command)} &`,
"browser_job_pid=$!",
"browser_ready=false",
`browser_ready_deadline=$(( $(date +%s) + ${policy.launchReadyTimeoutSeconds} ))`,
"while kill -0 \"$browser_job_pid\" 2>/dev/null; do",
" if node - \"$browser_job_pid\" <<'UNIDESK_WEB_PROBE_CHILD_READINESS'",
"const fs = require('fs');",
"const rootPid = Number(process.argv[2]);",
"const rows = [];",
"for (const name of fs.readdirSync('/proc')) {",
" if (!/^[0-9]+$/.test(name)) continue;",
" try {",
" const stat = fs.readFileSync('/proc/' + name + '/stat', 'utf8');",
" const close = stat.lastIndexOf(')');",
" const fields = stat.slice(close + 2).split(' ');",
" rows.push({ pid: Number(name), ppid: Number(fields[1]), command: fs.readFileSync('/proc/' + name + '/cmdline', 'utf8').replace(/\\0/g, ' ') });",
" } catch {}",
"}",
"const descendants = new Set([rootPid]);",
"let changed = true;",
"while (changed) { changed = false; for (const row of rows) if (descendants.has(row.ppid) && !descendants.has(row.pid)) { descendants.add(row.pid); changed = true; } }",
"process.exit(rows.some((row) => descendants.has(row.pid) && /(chromium|chrome|playwright)/i.test(row.command)) ? 0 : 1);",
"UNIDESK_WEB_PROBE_CHILD_READINESS",
" then browser_ready=true; break; fi",
" if [ \"$(date +%s)\" -ge \"$browser_ready_deadline\" ]; then break; fi",
` sleep ${Math.max(0.05, policy.launchReadyPollMilliseconds / 1000)}`,
"done",
"flock -u 9",
"if [ \"$browser_ready\" != true ]; then",
" kill -TERM -- \"-$browser_job_pid\" 2>/dev/null || true",
" sleep 1",
" if kill -0 \"$browser_job_pid\" 2>/dev/null; then kill -KILL -- \"-$browser_job_pid\" 2>/dev/null || true; fi",
" wait \"$browser_job_pid\" 2>/dev/null || true",
" printf '%s\\n' 'web-probe browser launch readiness was not confirmed' >&2",
" exit 43",
"fi",
"wait \"$browser_job_pid\"",
].join("\n");
}
function humanBytes(value: number | null): string | null {
if (value === null || !Number.isFinite(value)) return null;
const units = ["B", "KiB", "MiB", "GiB", "TiB"];
@@ -20,6 +20,11 @@ const viewport = parseViewport(process.env.UNIDESK_WEB_PROBE_VIEWPORT || "1440x9
const browserProxyMode = parseBrowserProxyMode(process.env.UNIDESK_WEB_PROBE_BROWSER_PROXY_MODE || "auto");
const playwrightProxy = proxyConfigFromEnv(baseUrl);
const chromiumLaunchOptions = chromiumLaunchOptionsForProxy(playwrightProxy);
const screenshotMode = requiredEnum(process.env.UNIDESK_WEB_PROBE_SCREENSHOT_MODE, ["viewport"]);
const maxNetworkRequestEntries = requiredPositiveInteger(process.env.UNIDESK_WEB_PROBE_MAX_NETWORK_REQUEST_ENTRIES, "UNIDESK_WEB_PROBE_MAX_NETWORK_REQUEST_ENTRIES");
const maxNetworkRequestBytes = requiredPositiveInteger(process.env.UNIDESK_WEB_PROBE_MAX_NETWORK_REQUEST_BYTES, "UNIDESK_WEB_PROBE_MAX_NETWORK_REQUEST_BYTES");
const maxNetworkFailureEntries = requiredPositiveInteger(process.env.UNIDESK_WEB_PROBE_MAX_NETWORK_FAILURE_ENTRIES, "UNIDESK_WEB_PROBE_MAX_NETWORK_FAILURE_ENTRIES");
const maxNetworkFailureBytes = requiredPositiveInteger(process.env.UNIDESK_WEB_PROBE_MAX_NETWORK_FAILURE_BYTES, "UNIDESK_WEB_PROBE_MAX_NETWORK_FAILURE_BYTES");
const artifactRecords = [];
const readinessRecords = [];
const stepRecords = [];
@@ -1314,36 +1319,54 @@ function createNetworkTracker(targetPage, apiPattern) {
const started = Date.now();
const requests = [];
const failures = [];
let requestBytes = 0;
let requestDropped = 0;
let failureBytes = 0;
let failureDropped = 0;
const matches = createApiMatcher(apiPattern);
const appendBounded = (items, item, kind) => {
const bytes = Buffer.byteLength(JSON.stringify(item), "utf8");
const entryLimit = kind === "request" ? maxNetworkRequestEntries : maxNetworkFailureEntries;
const byteLimit = kind === "request" ? maxNetworkRequestBytes : maxNetworkFailureBytes;
const usedBytes = kind === "request" ? requestBytes : failureBytes;
if (items.length >= entryLimit || usedBytes + bytes > byteLimit) {
if (kind === "request") requestDropped += 1;
else failureDropped += 1;
return;
}
items.push(item);
if (kind === "request") requestBytes += bytes;
else failureBytes += bytes;
};
const onRequest = (request) => {
if (!matches(request.url())) return;
requests.push({
appendBounded(requests, {
method: request.method(),
path: urlPath(request.url()),
atMs: Date.now() - started,
});
}, "request");
};
const onResponse = (response) => {
if (!matches(response.url())) return;
if (response.status() >= 400) {
failures.push({
appendBounded(failures, {
type: "response",
status: response.status(),
statusText: response.statusText(),
path: urlPath(response.url()),
atMs: Date.now() - started,
});
}, "failure");
}
};
const onRequestFailed = (request) => {
if (!matches(request.url())) return;
failures.push({
appendBounded(failures, {
type: "requestfailed",
method: request.method(),
path: urlPath(request.url()),
failureText: request.failure()?.errorText ?? null,
atMs: Date.now() - started,
});
}, "failure");
};
targetPage.on("request", onRequest);
targetPage.on("response", onResponse);
@@ -1356,6 +1379,10 @@ function createNetworkTracker(targetPage, apiPattern) {
apiFailureCount: failures.length,
apiRequests: requests.slice(0, 5),
apiFailures: failures.slice(0, 5),
apiRequestEvidenceBytes: requestBytes,
apiRequestEvidenceDropped: requestDropped,
apiFailureEvidenceBytes: failureBytes,
apiFailureEvidenceDropped: failureDropped,
}),
dispose: () => {
targetPage.off("request", onRequest);
@@ -1476,7 +1503,7 @@ async function captureFailureScreenshot(name = "failure.png") {
async function screenshotPage(targetPage, name = "screenshot.png", options = {}) {
const file = artifactPath(name);
await targetPage.screenshot({ path: file, fullPage: true, ...options });
await targetPage.screenshot({ path: file, fullPage: screenshotMode !== "viewport", ...options });
return recordArtifact(file, "screenshot");
}
@@ -1910,6 +1937,17 @@ function positiveInteger(raw, fallback) {
return value;
}
function requiredPositiveInteger(raw, name) {
const value = Number(raw);
if (!Number.isInteger(value) || value <= 0) throw new Error("missing or invalid " + name);
return value;
}
function requiredEnum(raw, allowed) {
if (!allowed.includes(raw)) throw new Error("missing or invalid screenshot mode");
return raw;
}
function artifactPath(name) {
const safe = String(name || "artifact")
.replace(/[^A-Za-z0-9._-]/gu, "_")
@@ -1920,7 +1958,7 @@ function artifactPath(name) {
async function screenshot(name = "screenshot.png", options = {}) {
const file = artifactPath(name);
await page.screenshot({ path: file, fullPage: true, ...options });
await page.screenshot({ path: file, fullPage: screenshotMode !== "viewport", ...options });
return recordArtifact(file, "screenshot");
}
+6 -2
View File
@@ -9,6 +9,7 @@ import { startJob } from "./jobs";
import type { RenderedCliResult } from "./output";
import { runWebProbeRemoteArtifactJob } from "./web-probe-remote-artifact";
import { readWebProbeSentinelConfigRefTarget } from "./hwlab-node-web-sentinel-config-ref";
import { runWebProbeMemoryGuard, webProbeGuardedLaunchShell } from "./hwlab-node-web-probe-memory-guard";
import type { SentinelCicdState, WebProbeSentinelOptions } from "./hwlab-node-web-sentinel-cicd";
import {
clipTail,
@@ -1144,11 +1145,13 @@ export function runSentinelDashboard(state: SentinelCicdState, options: Extract<
}
export function probeSentinelDashboardBrowser(state: SentinelCicdState, options: Extract<WebProbeSentinelOptions, { kind: "dashboard" }>): Record<string, unknown> {
const memoryGuard = runWebProbeMemoryGuard(state.spec, "sentinel-cadence");
if (memoryGuard.status !== "allowed") return memoryGuard;
const publicBaseUrl = stringAt(state.publicExposure, "publicBaseUrl").replace(/\/$/u, "");
const dashboardUrl = sentinelDashboardUrl(publicBaseUrl, options.runId, options.errorId);
const [widthRaw, heightRaw] = options.viewport.split("x");
const screenshotName = options.action === "screenshot" ? dashboardScreenshotName(options, state) : "";
const script = [
const browserScript = [
"set -eu",
`export UNIDESK_SENTINEL_DASHBOARD_URL=${shellQuote(dashboardUrl)}`,
`export UNIDESK_SENTINEL_DASHBOARD_SCREENSHOT="$UNIDESK_WEB_PROBE_ARTIFACT_REMOTE_DIR"/${shellQuote(screenshotName)}`,
@@ -1178,6 +1181,7 @@ export function probeSentinelDashboardBrowser(state: SentinelCicdState, options:
"WEB_PROBE_SENTINEL_DASHBOARD_JS",
"node \"$UNIDESK_WEB_PROBE_ARTIFACT_REMOTE_DIR/web-probe-sentinel-dashboard.mjs\"",
].join("\n");
const script = webProbeGuardedLaunchShell(state.spec, "sentinel-cadence", browserScript);
const route = `${state.spec.nodeId}:${state.spec.workspace}`;
const job = runWebProbeRemoteArtifactJob({
route,
@@ -1263,7 +1267,7 @@ const triggerManual = process.env.UNIDESK_SENTINEL_DASHBOARD_TRIGGER === "1";
const width = Number(process.env.UNIDESK_SENTINEL_DASHBOARD_WIDTH || 1440);
const height = Number(process.env.UNIDESK_SENTINEL_DASHBOARD_HEIGHT || 900);
const timeout = Number(process.env.UNIDESK_SENTINEL_DASHBOARD_TIMEOUT_MS || 30000);
const fullPage = process.env.UNIDESK_SENTINEL_DASHBOARD_FULL_PAGE !== "0";
const fullPage = process.env.UNIDESK_SENTINEL_DASHBOARD_FULL_PAGE === "1";
const executablePath = process.env.UNIDESK_SENTINEL_DASHBOARD_EXECUTABLE_PATH || "";
const expectedSentinelId = process.env.UNIDESK_SENTINEL_DASHBOARD_EXPECTED_SENTINEL_ID || "";
const expectedRoutePrefix = process.env.UNIDESK_SENTINEL_DASHBOARD_EXPECTED_ROUTE_PREFIX || "/";
@@ -25,6 +25,7 @@ import { renderWebObserveWrapperContract } from "../hwlab-node-web-observe-wrapp
import { runWebProbeSentinelCommand, type WebProbeSentinelOptions } from "../hwlab-node-web-sentinel-cicd";
import { hwlabNodeHelp, hwlabNodeObservabilityHelp, hwlabNodeWebProbeHelp } from "../hwlab-node-help";
import { compactWebProbeResult, compactWebProbeScriptResult } from "../hwlab-node-web-probe-summary";
import { runWebProbeMemoryGuard, webProbeBrowserEvidenceEnvAssignments, webProbeGuardedLaunchShell } from "../hwlab-node-web-probe-memory-guard";
import { nodeObservabilityRecordingRuleExpression, nodeObservabilityRecordingRuleSummaries, nodeObservabilityWarningAlertExpression, nodeObservabilityWarningAlertSummaries } from "../hwlab-node-observability-promql";
import { runDelegatedHwlabNodeCommand, type DelegatedNodeDomain } from "../hwlab-node-transport";
import type { RenderedCliResult } from "../output";
@@ -537,7 +538,9 @@ export function runNodeWebProbeScript(
const commandLabel = options.commandLabel ?? `web-probe script --node ${options.node} --lane ${options.lane}${webProbeSemanticOriginArgs(options)}`;
const commandGovernance = webProbeScriptCommandGovernance(options);
const webProbeProxy = nodeWebProbeHostProxyEnv(spec, options.browserProxyMode);
const script = nodeWebProbeScriptRemoteShell(options, secretSpec, material.username ?? secretSpec.bootstrapAdminUsername, material.password ?? "", webProbeProxy, spec.webProbe?.playwrightBrowsersPath);
const memoryGuard = runWebProbeMemoryGuard(spec, "manual-start");
if (memoryGuard.status !== "allowed") return memoryGuard;
const script = nodeWebProbeScriptRemoteShell(options, spec, secretSpec, material.username ?? secretSpec.bootstrapAdminUsername, material.password ?? "", webProbeProxy, spec.webProbe?.playwrightBrowsersPath);
const result = runTransWorkspaceStdinScript(options.node, spec.workspace, script, options.commandTimeoutSeconds);
const commandTimedOut = result.timedOut || result.exitCode === 124;
const runPaths = webProbeScriptRunPathsFromStderr(result.stderr);
@@ -740,7 +743,7 @@ function webProbeSemanticOriginArgs(options: Pick<NodeWebProbeScriptOptions, "or
return options.originName === "internal" || options.originName === "public" ? ` --origin ${options.originName}` : "";
}
export function nodeWebProbeScriptRemoteShell(options: NodeWebProbeScriptOptions, secretSpec: RuntimeSecretSpec, username: string, password: string, webProbeProxy: NodeWebProbeHostProxyEnv, playwrightBrowsersPath: string | undefined): string {
export function nodeWebProbeScriptRemoteShell(options: NodeWebProbeScriptOptions, spec: HwlabRuntimeLaneSpec, secretSpec: RuntimeSecretSpec, username: string, password: string, webProbeProxy: NodeWebProbeHostProxyEnv, playwrightBrowsersPath: string | undefined): string {
const userScriptB64 = Buffer.from(options.scriptText, "utf8").toString("base64");
const runnerB64 = Buffer.from(nodeWebProbeScriptRunnerSource(), "utf8").toString("base64");
return [
@@ -756,8 +759,9 @@ export function nodeWebProbeScriptRemoteShell(options: NodeWebProbeScriptOptions
"runner=\"$run_dir/runner.mjs\"",
`node -e "require('fs').writeFileSync(process.argv[1], Buffer.from(process.argv[2], 'base64'))" "$user_script" ${shellQuote(userScriptB64)}`,
`node -e "require('fs').writeFileSync(process.argv[1], Buffer.from(process.argv[2], 'base64'))" "$runner" ${shellQuote(runnerB64)}`,
[
webProbeGuardedLaunchShell(spec, "manual-start", [
...webProbeProxy.envAssignments,
...webProbeBrowserEvidenceEnvAssignments(spec),
...(playwrightBrowsersPath === undefined ? [] : [
`PLAYWRIGHT_BROWSERS_PATH=${shellQuote(playwrightBrowsersPath)}`,
]),
@@ -770,7 +774,7 @@ export function nodeWebProbeScriptRemoteShell(options: NodeWebProbeScriptOptions
`UNIDESK_WEB_PROBE_VIEWPORT=${shellQuote(options.viewport)}`,
`UNIDESK_WEB_PROBE_BROWSER_PROXY_MODE=${shellQuote(options.browserProxyMode)}`,
"node \"$runner\"",
].join(" "),
].join(" ")),
].join("\n");
}
+11 -4
View File
@@ -26,6 +26,7 @@ import { renderWebObserveWrapperContract } from "../hwlab-node-web-observe-wrapp
import { runWebProbeSentinelCommand, type WebProbeSentinelDashboardAction, type WebProbeSentinelOptions, type WebProbeSentinelReportView, type WebProbeSentinelSourceAuthority, type WebProbeSentinelSourceOverrideOptions } from "../hwlab-node-web-sentinel-cicd";
import { hwlabNodeHelp, hwlabNodeObservabilityHelp, hwlabNodeWebProbeHelp } from "../hwlab-node-help";
import { compactWebProbeResult, compactWebProbeScriptResult } from "../hwlab-node-web-probe-summary";
import { runWebProbeMemoryGuard, webProbeBrowserEvidenceEnvAssignments, webProbeGuardedLaunchShell } from "../hwlab-node-web-probe-memory-guard";
import { nodeObservabilityRecordingRuleExpression, nodeObservabilityRecordingRuleSummaries, nodeObservabilityWarningAlertExpression, nodeObservabilityWarningAlertSummaries } from "../hwlab-node-observability-promql";
import { runDelegatedHwlabNodeCommand, type DelegatedNodeDomain } from "../hwlab-node-transport";
import { runWebProbeRemoteArtifactJob } from "../web-probe-remote-artifact";
@@ -890,11 +891,13 @@ export function runNodeWebProbe(options: NodeWebProbeOptions): Record<string, un
if (options.action === "observe") return runNodeWebProbeObserve(options, spec, secretSpec, material, credential);
if (options.action === "script") return runNodeWebProbeScript(options, spec, secretSpec, material, credential);
if (options.commandTimeoutSeconds > 55) return runNodeWebProbeAsync(options, spec, secretSpec, material, credential);
const memoryGuard = runWebProbeMemoryGuard(spec, "manual-start");
if (memoryGuard.status !== "allowed") return memoryGuard;
const probeArgs = nodeWebProbeRunArgs(options, "run");
const webProbeProxy = nodeWebProbeHostProxyEnv(spec, options.browserProxyMode);
const script = [
"set -eu",
`${[...webProbeProxy.envAssignments, `HWLAB_WEB_USER=${shellQuote(material.username ?? secretSpec.bootstrapAdminUsername)}`, `HWLAB_WEB_PASS=${shellQuote(material.password)}`].join(" ")} ${probeArgs.map(shellQuote).join(" ")}`,
webProbeGuardedLaunchShell(spec, "manual-start", `${[...webProbeProxy.envAssignments, ...webProbeBrowserEvidenceEnvAssignments(spec), `HWLAB_WEB_USER=${shellQuote(material.username ?? secretSpec.bootstrapAdminUsername)}`, `HWLAB_WEB_PASS=${shellQuote(material.password)}`].join(" ")} ${probeArgs.map(shellQuote).join(" ")}`),
].join("\n");
const result = runTransWorkspaceStdinScript(options.node, spec.workspace, script, options.commandTimeoutSeconds);
const probe = compactWebProbeResult(parseJsonObject(result.stdout));
@@ -932,8 +935,10 @@ export function runNodeWebProbe(options: NodeWebProbeOptions): Record<string, un
}
export function runNodeWebProbeScreenshot(options: NodeWebProbeScreenshotOptions, spec: HwlabRuntimeLaneSpec): Record<string, unknown> {
const memoryGuard = runWebProbeMemoryGuard(spec, "manual-start");
if (memoryGuard.status !== "allowed") return memoryGuard;
const route = `${options.node}:${spec.workspace}`;
const script = webProbeScreenshotRemoteScript(options);
const script = webProbeGuardedLaunchShell(spec, "manual-start", webProbeScreenshotRemoteScript(options));
const job = runWebProbeRemoteArtifactJob({
route,
localDir: options.localDir,
@@ -1095,7 +1100,7 @@ const width = Number(process.env.UNIDESK_WEB_PROBE_SCREENSHOT_WIDTH || 1440);
const height = Number(process.env.UNIDESK_WEB_PROBE_SCREENSHOT_HEIGHT || 900);
const timeout = Number(process.env.UNIDESK_WEB_PROBE_SCREENSHOT_TIMEOUT_MS || 30000);
const waitUntil = process.env.UNIDESK_WEB_PROBE_SCREENSHOT_WAIT_UNTIL || "networkidle";
const fullPage = process.env.UNIDESK_WEB_PROBE_SCREENSHOT_FULL_PAGE !== "0";
const fullPage = process.env.UNIDESK_WEB_PROBE_SCREENSHOT_FULL_PAGE === "1";
const selector = process.env.UNIDESK_WEB_PROBE_SCREENSHOT_SELECTOR || "";
const executablePath = process.env.UNIDESK_WEB_PROBE_SCREENSHOT_EXECUTABLE_PATH || "";
@@ -1234,9 +1239,11 @@ export function runNodeWebProbeAsync(
): Record<string, unknown> {
const startArgs = nodeWebProbeRunArgs(options, "start");
const webProbeProxy = nodeWebProbeHostProxyEnv(spec, options.browserProxyMode);
const memoryGuard = runWebProbeMemoryGuard(spec, "manual-start");
if (memoryGuard.status !== "allowed") return memoryGuard;
const startScript = [
"set -eu",
`${[...webProbeProxy.envAssignments, `HWLAB_WEB_USER=${shellQuote(material.username ?? secretSpec.bootstrapAdminUsername)}`, `HWLAB_WEB_PASS=${shellQuote(material.password ?? "")}`].join(" ")} ${startArgs.map(shellQuote).join(" ")}`,
webProbeGuardedLaunchShell(spec, "manual-start", `${[...webProbeProxy.envAssignments, ...webProbeBrowserEvidenceEnvAssignments(spec), `HWLAB_WEB_USER=${shellQuote(material.username ?? secretSpec.bootstrapAdminUsername)}`, `HWLAB_WEB_PASS=${shellQuote(material.password ?? "")}`].join(" ")} ${startArgs.map(shellQuote).join(" ")}`, "structured-output"),
].join("\n");
const startResult = runTransWorkspaceStdinScript(options.node, spec.workspace, startScript, 55);
const start = parseJsonObject(startResult.stdout);
+1 -1
View File
@@ -2130,7 +2130,7 @@ export function parseNodeWebProbeOptions(args: string[]): NodeWebProbeOptions {
name: parseWebProbeScreenshotName(optionValue(args, "--name") ?? `web-probe-${node.toLowerCase()}-${lane}.png`),
timeoutMs,
waitUntil: parseWebProbeScreenshotWaitUntil(optionValue(args, "--wait-until") ?? "networkidle"),
fullPage: !args.includes("--no-full-page"),
fullPage: args.includes("--full-page") && !args.includes("--no-full-page"),
selector: optionValue(args, "--selector") ?? null,
keepRemote: args.includes("--keep-remote"),
waitTimeoutMs,