feat: 增加 L1 native Web 单命令验收
This commit is contained in:
@@ -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.",
|
||||
|
||||
@@ -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)}`,
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user