feat: 增加 L1 native Web 单命令验收

This commit is contained in:
Codex
2026-07-17 19:44:05 +02:00
parent f38090da6f
commit 3333e6aa49
9 changed files with 373 additions and 2 deletions
+3
View File
@@ -90,6 +90,7 @@ export function hwlabNodeWebProbeHelp(): Record<string, unknown> {
"bun scripts/cli.ts web-probe run --node <node> --lane <lane> --origin internal --wait-messages-ms 1000",
"bun scripts/cli.ts web-probe opencode-smoke --node <node> --lane <lane> --origin internal --message 'hi'",
"bun scripts/cli.ts web-probe console-verify --node <node> --lane <lane> --origin internal --profile <yaml-profile>",
"bun scripts/cli.ts web-probe native-readiness --node <node> --lane <lane> --profile <yaml-profile>",
"bun scripts/cli.ts web-probe product-smoke --product selfmedia --target NC01 --profile voice-algorithm --edition <edition-id> --algorithm <algorithm-id>",
"bun scripts/cli.ts web-probe product-smoke --product pikaoa --target NC01 --profile admin-mvp",
"bun scripts/cli.ts web-probe product-smoke --product apistate --target NC01 --profile operations",
@@ -129,6 +130,7 @@ export function hwlabNodeWebProbeHelp(): Record<string, unknown> {
run: "Run the repo-owned scripts/web-live-dom-probe.mjs helper.",
"opencode-smoke": "Run the repo-owned OpenCode iframe/direct-host composer smoke and require DOM assistant text plus EventSource update/finish/idle evidence.",
"console-verify": "Run a YAML-profiled Cloud Console route and viewport matrix with optional Projects, HWPOD, MDTODO and AgentRun interaction contracts.",
"native-readiness": "从 owning YAML 解析 L1 公网 IP 和端口,并单次完成 DOM、交互、浏览器错误、关键失败响应与截图验收。",
"product-smoke": "按产品 owning YAML 执行受控登录和页面验证,支持 API、表单及 Basic Auth 认证合同。",
script: "Run caller-provided Playwright JS after CLI-managed /auth/login; scripts must not handle secrets themselves.",
screenshot: "Capture a page through the selected YAML semantic origin and node/lane remote browser, then download PNG artifacts to the caller /tmp by default.",
@@ -143,6 +145,7 @@ export function hwlabNodeWebProbeHelp(): Record<string, unknown> {
"Every script result starts with machine-readable `UNIDESK_WEB_PROBE_COMMAND_PROMOTION_HINT <json>` guidance; repo-owned generated commands report reuse instead of ad-hoc promotion.",
"`web-probe opencode-smoke` is the repo-owned OpenCode smoke; prefer it over repeating one-off OpenCode Playwright snippets.",
"`web-probe console-verify` reads route, viewport, selector, workflow, wait and evidence bounds from the selected owning YAML profile and reuses the existing authenticated script runner.",
"`web-probe native-readiness` 只接受 node、lane 和 YAML profile;公网 IP、端口、路径、selector、交互与失败判据禁止由 URL 参数覆盖。",
"`web-probe product-smoke` 从产品 YAML 和 Secret sourceRef 读取运行目标、认证合同与凭据,仅输出 presence、fingerprint、DOM/网络摘要及工件哈希。",
"observe is passive by default; user actions must be explicit observe command entries in control.jsonl.",
"observe gc keeps manifest, heartbeat, control/error logs and analysis reports, and only removes dead-run raw samples/browser/network/screenshot artifacts after YAML-configured retention.",
+18
View File
@@ -8,6 +8,7 @@ import { isIP } from "node:net";
import { rootPath } from "./config";
import { HWLAB_NODE_CONTROL_PLANE_CONFIG_PATH } from "./hwlab-node-control-plane-model";
import { parseWebProbeConsoleVerificationProfiles, type HwlabRuntimeWebProbeConsoleVerificationProfileSpec } from "./hwlab-node-web-probe-console-profile";
import { parseWebProbeNativeReadinessProfiles, type HwlabRuntimeWebProbeNativeReadinessProfileSpec } from "./hwlab-node-web-probe-native-readiness";
import { materializeYamlComposition } from "./yaml-composition";
import { resolveConfigRefString } from "./ops/config-refs";
@@ -168,6 +169,8 @@ export interface HwlabRuntimeWebProbeSpec {
readonly projectManagement?: HwlabRuntimeWebProbeProjectManagementSpec;
readonly defaultConsoleVerificationProfile?: string;
readonly consoleVerificationProfiles?: Readonly<Record<string, HwlabRuntimeWebProbeConsoleVerificationProfileSpec>>;
readonly defaultNativeReadinessProfile?: string;
readonly nativeReadinessProfiles?: Readonly<Record<string, HwlabRuntimeWebProbeNativeReadinessProfileSpec>>;
readonly realtimeFanoutProfiles?: Readonly<Record<string, HwlabRuntimeWebProbeRealtimeFanoutProfileSpec>>;
readonly workbenchKafkaDebugReplay?: HwlabRuntimeWebProbeWorkbenchKafkaDebugReplaySpec;
readonly workbenchTraceReadability?: HwlabRuntimeWebProbeWorkbenchTraceReadabilitySpec;
@@ -1649,6 +1652,19 @@ function webProbeConfig(value: unknown, path: string): HwlabRuntimeWebProbeSpec
if (defaultConsoleVerificationProfile !== undefined && consoleVerificationProfiles?.[defaultConsoleVerificationProfile] === undefined) {
throw new Error(`${path}.defaultConsoleVerificationProfile references unknown profile ${defaultConsoleVerificationProfile}`);
}
const nativeReadinessProfiles = raw.nativeReadinessProfiles === undefined
? undefined
: parseWebProbeNativeReadinessProfiles(raw.nativeReadinessProfiles, `${path}.nativeReadinessProfiles`);
const defaultNativeReadinessProfile = optionalStringField(raw, "defaultNativeReadinessProfile", path);
if (defaultNativeReadinessProfile !== undefined && nativeReadinessProfiles === undefined) {
throw new Error(`${path}.nativeReadinessProfiles must be declared when defaultNativeReadinessProfile is set`);
}
if (nativeReadinessProfiles !== undefined && defaultNativeReadinessProfile === undefined) {
throw new Error(`${path}.defaultNativeReadinessProfile must select one nativeReadinessProfiles entry`);
}
if (defaultNativeReadinessProfile !== undefined && nativeReadinessProfiles?.[defaultNativeReadinessProfile] === undefined) {
throw new Error(`${path}.defaultNativeReadinessProfile references unknown profile ${defaultNativeReadinessProfile}`);
}
return {
...(browserProxyMode === undefined ? {} : { browserProxyMode }),
...(playwrightBrowsersPath === undefined ? {} : { playwrightBrowsersPath }),
@@ -1661,6 +1677,8 @@ function webProbeConfig(value: unknown, path: string): HwlabRuntimeWebProbeSpec
...(raw.projectManagement === undefined ? {} : { projectManagement: webProbeProjectManagementConfig(raw.projectManagement, `${path}.projectManagement`) }),
...(defaultConsoleVerificationProfile === undefined ? {} : { defaultConsoleVerificationProfile }),
...(consoleVerificationProfiles === undefined ? {} : { consoleVerificationProfiles }),
...(defaultNativeReadinessProfile === undefined ? {} : { defaultNativeReadinessProfile }),
...(nativeReadinessProfiles === undefined ? {} : { nativeReadinessProfiles }),
...(raw.realtimeFanoutProfiles === undefined ? {} : { realtimeFanoutProfiles: webProbeRealtimeFanoutProfilesConfig(raw.realtimeFanoutProfiles, `${path}.realtimeFanoutProfiles`) }),
...(raw.workbenchKafkaDebugReplay === undefined
? {}
@@ -0,0 +1,77 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { test } from "bun:test";
import { rootPath } from "./config";
import { hwlabRuntimeLaneSpecForNode } from "./hwlab-node-lanes";
import { nodeWebProbeNativeReadinessScript, parseWebProbeNativeReadinessProfiles } from "./hwlab-node-web-probe-native-readiness";
import { parseNodeWebProbeOptions } from "./hwlab-node/web-probe";
function fixtureProfile(): Record<string, unknown> {
return {
scheme: "http",
path: "/tool",
navigationTimeoutMs: 30000,
settleMs: 100,
commandTimeoutSeconds: 120,
viewport: { width: 1280, height: 720 },
readySelectors: ["#app", "button.toggle"],
minBodyTextLength: 8,
interaction: { selector: "button.toggle", observedSelector: "aside.panel", observedAttribute: "data-collapsed" },
failedResponseStatus: 400,
criticalPathPrefixes: ["/api/"],
screenshotName: "ready.png",
outputLimits: { errors: 10, failedResponses: 10, textPreviewChars: 120 },
};
}
test("native readiness profile is owned by lane YAML", () => {
const spec = hwlabRuntimeLaneSpecForNode("v03", "NC01");
assert.equal(spec.webProbe?.defaultNativeReadinessProfile, "workbench");
const profile = spec.webProbe?.nativeReadinessProfiles?.workbench;
assert.ok(profile);
assert.equal(profile.path, "/workbench");
assert.deepEqual(profile.readySelectors, ["#workspace", ".desktop-sidebar-toggle"]);
assert.equal(profile.interaction.observedSelector, ".platform-sidebar");
assert.equal(profile.interaction.observedAttribute, "data-collapsed");
assert.equal(spec.nativeDevelopment?.workbench.publicHost, "152.53.229.148");
assert.equal(spec.nativeDevelopment?.workbench.web.port, 5173);
});
test("native readiness profile rejects unknown fields", () => {
assert.throws(() => parseWebProbeNativeReadinessProfiles({ fixture: { ...fixtureProfile(), fallbackUrl: "http://example.test" } }, "fixture.profiles"), /unsupported fields: fallbackUrl/u);
});
test("generated native readiness script is valid and generic", () => {
const profile = parseWebProbeNativeReadinessProfiles({ fixture: fixtureProfile() }, "fixture.profiles").fixture;
assert.ok(profile);
const script = nodeWebProbeNativeReadinessScript("fixture", profile, "config/example.yaml#profiles.fixture");
assert.doesNotThrow(() => new Function(script.replace("export default ", "")));
assert.match(script, /page\.on\("console"/u);
assert.match(script, /page\.on\("pageerror"/u);
assert.match(script, /page\.on\("requestfailed"/u);
assert.match(script, /interactionChanged/u);
assert.match(script, /jsonArtifact\("native-web-readiness\.json"/u);
const source = readFileSync(rootPath("scripts/src/hwlab-node-web-probe-native-readiness.ts"), "utf8");
assert.doesNotMatch(source, /NC01|v03|152\.53\.229\.148|5173|#workspace|desktop-sidebar-toggle/u);
});
test("native-readiness is one managed command resolved from owning YAML", () => {
const options = parseNodeWebProbeOptions(["native-readiness", "--node", "NC01", "--lane", "v03", "--profile", "workbench"]);
assert.equal(options.action, "script");
if (options.action !== "script") return;
assert.equal(options.url, "http://152.53.229.148:5173");
assert.equal(options.originName, "custom");
assert.equal(options.viewport, "1920x1080");
assert.equal(options.commandTimeoutSeconds, 120);
assert.equal(options.suppressAdHocWarning, true);
assert.equal(options.scriptSource.path, "builtin:native-readiness/workbench");
assert.match(options.commandLabel ?? "", /web-probe native-readiness/u);
const managedRunner = readFileSync(rootPath("scripts/src/hwlab-node/web-observe-scripts.ts"), "utf8");
assert.match(managedRunner, /asyncJob !== null \? "remote-artifact-job-report"/u);
});
test("native-readiness rejects URL and origin overrides", () => {
assert.throws(() => parseNodeWebProbeOptions(["native-readiness", "--node", "NC01", "--lane", "v03", "--url", "http://127.0.0.1:5173"]), /unknown option: --url/u);
assert.throws(() => parseNodeWebProbeOptions(["native-readiness", "--node", "NC01", "--lane", "v03", "--origin", "public"]), /unknown option: --origin/u);
});
@@ -0,0 +1,180 @@
// Responsibility: Typed YAML profile and managed browser script for L1 native Web readiness.
export interface HwlabRuntimeWebProbeNativeReadinessProfileSpec {
readonly scheme: "http" | "https";
readonly path: string;
readonly navigationTimeoutMs: number;
readonly settleMs: number;
readonly commandTimeoutSeconds: number;
readonly viewport: { readonly width: number; readonly height: number };
readonly readySelectors: readonly string[];
readonly minBodyTextLength: number;
readonly interaction: {
readonly selector: string;
readonly observedSelector: string;
readonly observedAttribute: string;
};
readonly failedResponseStatus: number;
readonly criticalPathPrefixes: readonly string[];
readonly screenshotName: string;
readonly outputLimits: {
readonly errors: number;
readonly failedResponses: number;
readonly textPreviewChars: number;
};
}
export function parseWebProbeNativeReadinessProfiles(
value: unknown,
path: string,
): Readonly<Record<string, HwlabRuntimeWebProbeNativeReadinessProfileSpec>> {
const raw = object(value, path);
const entries = Object.entries(raw);
if (entries.length < 1 || entries.length > 20) throw new Error(`${path} must contain 1-20 profiles`);
return Object.fromEntries(entries.map(([name, profile]) => {
simpleId(name, `${path}.${name}`);
return [name, nativeReadinessProfile(profile, `${path}.${name}`)];
}));
}
export function nodeWebProbeNativeReadinessScript(
profileName: string,
profile: HwlabRuntimeWebProbeNativeReadinessProfileSpec,
configRef: string,
): string {
const config = { profileName, configRef, ...profile };
return String.raw`const config = ${JSON.stringify(config)};
export default async function nativeReadiness({ page, goto, wait, screenshot, jsonArtifact, recordStep }) {
const consoleErrors = [];
const pageErrors = [];
const failedResponses = [];
let consoleErrorCount = 0;
let pageErrorCount = 0;
let failedResponseCount = 0;
const short = (value, limit = 300) => String(value || "").slice(0, limit);
const criticalPath = (url) => {
try {
const path = new URL(url).pathname;
return config.criticalPathPrefixes.some((prefix) => path.startsWith(prefix));
} catch {
return false;
}
};
page.on("console", (message) => {
if (message.type() !== "error") return;
consoleErrorCount += 1;
if (consoleErrors.length < config.outputLimits.errors) consoleErrors.push(short(message.text()));
});
page.on("pageerror", (error) => {
pageErrorCount += 1;
if (pageErrors.length < config.outputLimits.errors) pageErrors.push(short(error?.stack || error?.message || error));
});
page.on("requestfailed", (request) => {
if (!criticalPath(request.url())) return;
failedResponseCount += 1;
if (failedResponses.length < config.outputLimits.failedResponses) {
failedResponses.push({ method: request.method(), path: new URL(request.url()).pathname, failure: short(request.failure()?.errorText, 160) });
}
});
page.on("response", (response) => {
if (response.status() < config.failedResponseStatus || !criticalPath(response.url())) return;
failedResponseCount += 1;
if (failedResponses.length < config.outputLimits.failedResponses) {
failedResponses.push({ method: response.request().method(), path: new URL(response.url()).pathname, status: response.status() });
}
});
await page.setViewportSize(config.viewport);
await goto(config.path, { selectors: config.readySelectors, readinessTimeoutMs: config.navigationTimeoutMs, attempts: 2 });
if (config.settleMs > 0) await wait(config.settleMs);
const before = await page.locator(config.interaction.observedSelector).getAttribute(config.interaction.observedAttribute);
const mounted = await page.evaluate(({ selectors, minBodyTextLength, textPreviewChars }) => {
const visible = (selector) => {
const element = document.querySelector(selector);
const rect = element?.getBoundingClientRect();
const style = element ? getComputedStyle(element) : null;
return Boolean(rect && rect.width > 0 && rect.height > 0 && style?.display !== "none" && style?.visibility !== "hidden");
};
const bodyText = (document.body?.textContent || "").replace(/\s+/gu, " ").trim();
return {
readyState: document.readyState,
selectorsVisible: selectors.map((selector) => ({ selector, visible: visible(selector) })),
bodyTextLength: bodyText.length,
bodyTextPreview: bodyText.slice(0, textPreviewChars),
bodyTextSufficient: bodyText.length >= minBodyTextLength,
finalPath: location.pathname + location.search,
title: document.title,
};
}, { selectors: config.readySelectors, minBodyTextLength: config.minBodyTextLength, textPreviewChars: config.outputLimits.textPreviewChars });
await page.locator(config.interaction.selector).click({ timeout: config.navigationTimeoutMs });
if (config.settleMs > 0) await wait(config.settleMs);
const after = await page.locator(config.interaction.observedSelector).getAttribute(config.interaction.observedAttribute);
const interactionChanged = before !== after;
const screenshotArtifact = await screenshot(config.screenshotName);
const selectorsReady = mounted.selectorsVisible.every((item) => item.visible);
const ok = selectorsReady && mounted.bodyTextSufficient && interactionChanged
&& consoleErrorCount === 0 && pageErrorCount === 0 && failedResponseCount === 0;
const evidence = {
ok,
status: ok ? "pass" : "blocked",
profileName: config.profileName,
configRef: config.configRef,
mounted,
interaction: { selector: config.interaction.selector, observedSelector: config.interaction.observedSelector, observedAttribute: config.interaction.observedAttribute, before, after, changed: interactionChanged },
consoleErrorCount,
consoleErrors,
pageErrorCount,
pageErrors,
failedResponseCount,
failedResponses,
screenshot: screenshotArtifact,
valuesRedacted: true,
};
const report = await jsonArtifact("native-web-readiness.json", evidence);
recordStep("native-web-readiness", { ok, selectorsReady, bodyTextSufficient: mounted.bodyTextSufficient, interactionChanged, consoleErrorCount, pageErrorCount, failedResponseCount });
return { ...evidence, report };
}
`;
}
function nativeReadinessProfile(value: unknown, path: string): HwlabRuntimeWebProbeNativeReadinessProfileSpec {
const raw = object(value, path);
onlyKeys(raw, ["scheme", "path", "navigationTimeoutMs", "settleMs", "commandTimeoutSeconds", "viewport", "readySelectors", "minBodyTextLength", "interaction", "failedResponseStatus", "criticalPathPrefixes", "screenshotName", "outputLimits"], path);
const viewport = object(raw.viewport, `${path}.viewport`);
onlyKeys(viewport, ["width", "height"], `${path}.viewport`);
const interaction = object(raw.interaction, `${path}.interaction`);
onlyKeys(interaction, ["selector", "observedSelector", "observedAttribute"], `${path}.interaction`);
const limits = object(raw.outputLimits, `${path}.outputLimits`);
onlyKeys(limits, ["errors", "failedResponses", "textPreviewChars"], `${path}.outputLimits`);
const screenshotName = boundedText(raw.screenshotName, `${path}.screenshotName`, 120);
if (!/^[A-Za-z0-9._-]+\.png$/u.test(screenshotName)) throw new Error(`${path}.screenshotName must be a safe PNG filename`);
return {
scheme: enumText(raw.scheme, `${path}.scheme`, ["http", "https"]),
path: absolutePath(raw.path, `${path}.path`),
navigationTimeoutMs: integer(raw.navigationTimeoutMs, `${path}.navigationTimeoutMs`, 1000, 120_000),
settleMs: integer(raw.settleMs, `${path}.settleMs`, 0, 30_000),
commandTimeoutSeconds: integer(raw.commandTimeoutSeconds, `${path}.commandTimeoutSeconds`, 60, 3600),
viewport: { width: integer(viewport.width, `${path}.viewport.width`, 240, 7680), height: integer(viewport.height, `${path}.viewport.height`, 240, 4320) },
readySelectors: textArray(raw.readySelectors, `${path}.readySelectors`, 1, 20, 500),
minBodyTextLength: integer(raw.minBodyTextLength, `${path}.minBodyTextLength`, 1, 100_000),
interaction: {
selector: boundedText(interaction.selector, `${path}.interaction.selector`, 500),
observedSelector: boundedText(interaction.observedSelector, `${path}.interaction.observedSelector`, 500),
observedAttribute: boundedText(interaction.observedAttribute, `${path}.interaction.observedAttribute`, 100),
},
failedResponseStatus: integer(raw.failedResponseStatus, `${path}.failedResponseStatus`, 400, 599),
criticalPathPrefixes: textArray(raw.criticalPathPrefixes, `${path}.criticalPathPrefixes`, 1, 20, 300).map((item, index) => absolutePath(item, `${path}.criticalPathPrefixes[${index}]`)),
screenshotName,
outputLimits: { errors: integer(limits.errors, `${path}.outputLimits.errors`, 1, 100), failedResponses: integer(limits.failedResponses, `${path}.outputLimits.failedResponses`, 1, 100), textPreviewChars: integer(limits.textPreviewChars, `${path}.outputLimits.textPreviewChars`, 20, 2000) },
};
}
function object(value: unknown, path: string): Record<string, unknown> { if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path} must be an object`); return value as Record<string, unknown>; }
function onlyKeys(value: Record<string, unknown>, allowed: readonly string[], path: string): void { const extra = Object.keys(value).filter((key) => !allowed.includes(key)); if (extra.length > 0) throw new Error(`${path} unsupported fields: ${extra.sort().join(", ")}`); }
function boundedText(value: unknown, path: string, max: number): string { if (typeof value !== "string" || value.length < 1 || value.length > max) throw new Error(`${path} must be a 1-${max} character string`); return value; }
function simpleId(value: string, path: string): string { if (!/^[a-z][a-z0-9-]*$/u.test(value)) throw new Error(`${path} must be a lower-case id`); return value; }
function integer(value: unknown, path: string, min: number, max: number): number { if (!Number.isInteger(value) || Number(value) < min || Number(value) > max) throw new Error(`${path} must be an integer from ${min} to ${max}`); return Number(value); }
function enumText<T extends string>(value: unknown, path: string, allowed: readonly T[]): T { if (typeof value !== "string" || !allowed.includes(value as T)) throw new Error(`${path} must be one of ${allowed.join(", ")}`); return value as T; }
function absolutePath(value: unknown, path: string): string { const text = boundedText(value, path, 1000); if (!text.startsWith("/")) throw new Error(`${path} must start with /`); return text; }
function textArray(value: unknown, path: string, min: number, max: number, itemMax: number): string[] { if (!Array.isArray(value) || value.length < min || value.length > max) throw new Error(`${path} must contain ${min}-${max} items`); return value.map((item, index) => boundedText(item, `${path}[${index}]`, itemMax)); }
@@ -639,7 +639,9 @@ export function runManagedNodeWebProbeScript(
result: reportResult,
requestedStdoutType: "web-probe script report JSON",
acceptParsed: isWebProbeScriptReportContract,
forceArtifactFallbackReason: commandTimedOut ? "remote-command-timeout" : null,
forceArtifactFallbackReason: commandTimedOut
? "remote-command-timeout"
: asyncJob !== null ? "remote-artifact-job-report" : null,
artifactFallback: {
path: runPaths.reportPath,
nextCommand: runPaths.reportPath === null ? null : `trans ${options.node}:${spec.workspace} cat ${shellQuote(runPaths.reportPath)}`,
+57 -1
View File
@@ -16,6 +16,7 @@ import { CliInputError } from "../output";
import { HWLAB_NODE_CONTROL_PLANE_CONFIG_PATH, hwlabNodeControlPlaneCiGitWorkspaceSecret, hwlabNodeControlPlaneInfraHelp, runHwlabNodeControlPlaneInfra } from "../hwlab-node-control-plane";
import { hwlabRuntimeLaneConfigPath, hwlabRuntimeLaneIds, hwlabRuntimeLaneSpec, hwlabRuntimeLaneSpecForNode, hwlabRuntimeNodeIds, isHwlabRuntimeLane, type HwlabRuntimeLane, type HwlabRuntimeLaneSpec, type HwlabRuntimeObservabilityRecordingRuleSpec, type HwlabRuntimeObservabilitySpec, type HwlabRuntimeObservabilityWarningAlertSpec, type HwlabRuntimePublicExposureSpec, type HwlabRuntimeWebProbeAlertThresholdsSpec, type HwlabRuntimeWebProbeProjectManagementSpec } from "../hwlab-node-lanes";
import { nodeWebProbeConsoleVerifyScript } from "../hwlab-node-web-probe-console-verify";
import { nodeWebProbeNativeReadinessScript } from "../hwlab-node-web-probe-native-readiness";
import { nodeWebProbeScriptRunnerSource } from "../hwlab-node-web-probe-runner-source";
import { nodeWebObserveAnalyzerSource } from "../hwlab-node-web-observe-analyzer-source";
import { nodeWebObserveRunnerSource } from "../hwlab-node-web-observe-runner-source";
@@ -2087,7 +2088,7 @@ export function rewriteDelegatedNodeString(value: string, scoped: ReturnType<typ
export function parseNodeWebProbeOptions(args: string[]): NodeWebProbeOptions {
const [actionRaw] = args;
if (actionRaw !== "run" && actionRaw !== "script" && actionRaw !== "screenshot" && actionRaw !== "observe" && actionRaw !== "sentinel" && actionRaw !== "opencode-smoke" && actionRaw !== "console-verify") throw new Error("web-probe usage: run|script|screenshot|observe|sentinel|opencode-smoke|console-verify --node NODE --lane vNN [--origin internal|public]");
if (actionRaw !== "run" && actionRaw !== "script" && actionRaw !== "screenshot" && actionRaw !== "observe" && actionRaw !== "sentinel" && actionRaw !== "opencode-smoke" && actionRaw !== "console-verify" && actionRaw !== "native-readiness") throw new Error("web-probe usage: run|script|screenshot|observe|sentinel|opencode-smoke|console-verify|native-readiness --node NODE --lane vNN [--origin internal|public]");
if (actionRaw === "sentinel") return parseNodeWebProbeSentinelOptions(args.slice(1));
if (actionRaw === "observe") {
const normalized = normalizeNodeWebProbeObserveArgs(args.slice(1));
@@ -2122,6 +2123,61 @@ export function parseNodeWebProbeOptions(args: string[]): NodeWebProbeOptions {
const browserProxyMode = browserProxyModeRaw === undefined ? undefined : parseWebProbeBrowserProxyMode(browserProxyModeRaw);
return resolveNodeWebProbeCliOrigin(spec, optionValue(args, "--origin"), optionValue(args, "--url"), browserProxyMode);
};
if (actionRaw === "native-readiness") {
assertKnownOptions(args.slice(1), new Set([
"--node",
"--lane",
"--profile",
"--timeout-ms",
"--command-timeout-seconds",
]), new Set([]));
const native = spec.nativeDevelopment?.workbench;
if (native === undefined) throw new Error(`${hwlabRuntimeLaneConfigPath()} node=${node} lane=${lane} does not declare nativeDevelopment.workbench`);
const profiles = spec.webProbe?.nativeReadinessProfiles;
const profileName = optionValue(args, "--profile") ?? spec.webProbe?.defaultNativeReadinessProfile;
if (profiles === undefined || profileName === undefined) {
throw new Error(`${hwlabRuntimeLaneConfigPath()} node=${node} lane=${lane} does not declare a default native readiness profile`);
}
const profile = profiles[profileName];
if (profile === undefined) {
throw new Error(`web-probe native-readiness unknown --profile ${profileName}; configured profiles: ${Object.keys(profiles).sort().join(", ")}`);
}
const timeoutMs = positiveIntegerOption(args, "--timeout-ms", profile.navigationTimeoutMs, 120_000);
const commandTimeoutSeconds = positiveIntegerOption(args, "--command-timeout-seconds", profile.commandTimeoutSeconds, 3600);
const configRef = `${hwlabRuntimeLaneConfigPath()}#templates.*.webProbeWorkbench.nativeReadinessProfiles.${profileName}`;
const scriptText = nodeWebProbeNativeReadinessScript(profileName, profile, configRef);
const url = `${profile.scheme}://${native.publicHost}:${native.web.port}`;
const selectedOrigin = resolveNodeWebProbeCliOrigin(spec, undefined, url, "direct");
const commandLabel = `web-probe native-readiness --node ${node} --lane ${lane} --profile ${profileName}`;
return {
action: "script",
node,
lane,
url: selectedOrigin.url,
originName: selectedOrigin.originName,
originMode: selectedOrigin.originMode,
originConfigPath: `${hwlabRuntimeLaneConfigPath()}#lanes.${lane}.targets.${node}.nativeDevelopment.workbench`,
timeoutMs,
viewport: `${profile.viewport.width}x${profile.viewport.height}`,
browserProxyMode: selectedOrigin.browserProxyMode,
browserProxyModeSource: selectedOrigin.browserProxyModeSource,
commandTimeoutSeconds,
scriptText,
commandLabel,
suppressAdHocWarning: true,
generatedHints: [
"L1 native Web readiness is a repo-owned typed command; public IP, port, path, DOM, interaction and failure rules come from owning YAML.",
"Pass requires mounted DOM, a completed interaction, no browser errors, no critical failed responses and a screenshot artifact.",
],
generatedPreferredCommands: { rerun: commandLabel },
scriptSource: {
kind: "generated",
path: `builtin:native-readiness/${profileName}`,
byteCount: Buffer.byteLength(scriptText),
sha256: `sha256:${createHash("sha256").update(scriptText).digest("hex")}`,
},
};
}
if (actionRaw === "console-verify") {
assertKnownOptions(args.slice(1), new Set([
"--node",