From 3333e6aa499d24cf4b2f07aca869d66dcdeadbf4 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 17 Jul 2026 19:44:05 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=20L1=20native=20Web?= =?UTF-8?q?=20=E5=8D=95=E5=91=BD=E4=BB=A4=E9=AA=8C=E6=94=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agents/skills/unidesk-webdev/SKILL.md | 2 + .../unidesk-webdev/references/web-probe.md | 6 + config/hwlab-node-lanes.yaml | 27 +++ scripts/src/hwlab-node-help.ts | 3 + scripts/src/hwlab-node-lanes.ts | 18 ++ ...ab-node-web-probe-native-readiness.test.ts | 77 ++++++++ .../hwlab-node-web-probe-native-readiness.ts | 180 ++++++++++++++++++ scripts/src/hwlab-node/web-observe-scripts.ts | 4 +- scripts/src/hwlab-node/web-probe.ts | 58 +++++- 9 files changed, 373 insertions(+), 2 deletions(-) create mode 100644 scripts/src/hwlab-node-web-probe-native-readiness.test.ts create mode 100644 scripts/src/hwlab-node-web-probe-native-readiness.ts diff --git a/.agents/skills/unidesk-webdev/SKILL.md b/.agents/skills/unidesk-webdev/SKILL.md index 4cb03914..d4c079ad 100644 --- a/.agents/skills/unidesk-webdev/SKILL.md +++ b/.agents/skills/unidesk-webdev/SKILL.md @@ -78,6 +78,8 @@ description: UniDesk Web 开发与受控浏览器验证技能。用户提到 Web - 当前微服务前端、API 和 Worker 使用独立端口与生命周期,不得抢占或停止其他 L1 会话; - Vite 必须绑定可访问的 native 端口并保持 HMR,浏览器端 HMR 地址不得写成 `0.0.0.0`。 - L1 公网调试入口交给用户前必须完成浏览器 readiness 验收: + - 使用 `bun scripts/cli.ts web-probe native-readiness --node --lane --profile ` 一次完成验收; + - 公网 IP、port、path、DOM、交互和失败判据只从 owning YAML 读取,禁止用 `--url` 或 `--origin` 覆盖; - 监听成功、端口可达、HTTP 200、API smoke 或源码测试通过都不能单独判定 L1 可用; - 必须通过 `web-probe` 从用户将使用的公网 IP 和 port 打开页面,确认首屏挂载、稳定 DOM 和至少一个基础交互可完成; - 必须确认没有阻塞 bootstrap 的 `pageerror`、`console.error`、关键失败请求或持续主线程卡顿; diff --git a/.agents/skills/unidesk-webdev/references/web-probe.md b/.agents/skills/unidesk-webdev/references/web-probe.md index a22fe8a7..cbcd4194 100644 --- a/.agents/skills/unidesk-webdev/references/web-probe.md +++ b/.agents/skills/unidesk-webdev/references/web-probe.md @@ -4,6 +4,12 @@ - 交互流程使用 `observe start`、`observe command`、`observe collect` 和 `observe analyze`; - `run` 或 `script` 只用于有界探测和 smoke; - 收尾证据引用 observer id、command id、artifact/report SHA、screenshot SHA 和关键有界字段。 +- L1 native Web 公网 readiness: + - 使用 `bun scripts/cli.ts web-probe native-readiness --node --lane --profile `; + - 公网 IP 和 port 来自 lane 的 `nativeDevelopment`,页面与判据来自 `nativeReadinessProfiles`; + - 单次命令必须同时判定 DOM 挂载、基础交互、`console.error`、`pageerror`、关键失败响应和截图; + - 顶层 `status=pass` 才能向用户披露入口可用,`blocked` 必须继续修复或报告 browser readiness blocker; + - 命令不接受 `--url` 或 `--origin`,避免绕过 owning YAML 选择其他运行面。 - Cloud Console 页面矩阵: - 使用 `bun scripts/cli.ts web-probe console-verify --node --lane --origin internal|public --profile `; - 配置真相位于 `config/hwlab-node-lanes.yaml#templates.*.webProbeWorkbench.consoleVerificationProfiles`; diff --git a/config/hwlab-node-lanes.yaml b/config/hwlab-node-lanes.yaml index 70bc15e6..8b095054 100644 --- a/config/hwlab-node-lanes.yaml +++ b/config/hwlab-node-lanes.yaml @@ -753,6 +753,33 @@ templates: requestTimeoutMs: 30000 initialDelayMs: 500 maxDelayMs: 10000 + defaultNativeReadinessProfile: workbench + nativeReadinessProfiles: + workbench: + scheme: http + path: /workbench + navigationTimeoutMs: 30000 + settleMs: 500 + commandTimeoutSeconds: 120 + viewport: + width: 1920 + height: 1080 + readySelectors: + - "#workspace" + - .desktop-sidebar-toggle + minBodyTextLength: 20 + interaction: + selector: .desktop-sidebar-toggle + observedSelector: .platform-sidebar + observedAttribute: data-collapsed + failedResponseStatus: 400 + criticalPathPrefixes: + - /v1/ + screenshotName: native-workbench-ready.png + outputLimits: + errors: 20 + failedResponses: 20 + textPreviewChars: 160 defaultConsoleVerificationProfile: cloud-console consoleVerificationProfiles: cloud-console: diff --git a/scripts/src/hwlab-node-help.ts b/scripts/src/hwlab-node-help.ts index 54d5ddc2..d83528e0 100644 --- a/scripts/src/hwlab-node-help.ts +++ b/scripts/src/hwlab-node-help.ts @@ -90,6 +90,7 @@ export function hwlabNodeWebProbeHelp(): Record { "bun scripts/cli.ts web-probe run --node --lane --origin internal --wait-messages-ms 1000", "bun scripts/cli.ts web-probe opencode-smoke --node --lane --origin internal --message 'hi'", "bun scripts/cli.ts web-probe console-verify --node --lane --origin internal --profile ", + "bun scripts/cli.ts web-probe native-readiness --node --lane --profile ", "bun scripts/cli.ts web-probe product-smoke --product selfmedia --target NC01 --profile voice-algorithm --edition --algorithm ", "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 { 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 { "Every script result starts with machine-readable `UNIDESK_WEB_PROBE_COMMAND_PROMOTION_HINT ` 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.", diff --git a/scripts/src/hwlab-node-lanes.ts b/scripts/src/hwlab-node-lanes.ts index 789b3214..ad25919f 100644 --- a/scripts/src/hwlab-node-lanes.ts +++ b/scripts/src/hwlab-node-lanes.ts @@ -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>; + readonly defaultNativeReadinessProfile?: string; + readonly nativeReadinessProfiles?: Readonly>; readonly realtimeFanoutProfiles?: Readonly>; 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 ? {} diff --git a/scripts/src/hwlab-node-web-probe-native-readiness.test.ts b/scripts/src/hwlab-node-web-probe-native-readiness.test.ts new file mode 100644 index 00000000..10898e7f --- /dev/null +++ b/scripts/src/hwlab-node-web-probe-native-readiness.test.ts @@ -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 { + 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); +}); diff --git a/scripts/src/hwlab-node-web-probe-native-readiness.ts b/scripts/src/hwlab-node-web-probe-native-readiness.ts new file mode 100644 index 00000000..04ccd852 --- /dev/null +++ b/scripts/src/hwlab-node-web-probe-native-readiness.ts @@ -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> { + 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 { if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path} must be an object`); return value as Record; } +function onlyKeys(value: Record, 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(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)); } diff --git a/scripts/src/hwlab-node/web-observe-scripts.ts b/scripts/src/hwlab-node/web-observe-scripts.ts index 5a13fa45..fa62d7b0 100644 --- a/scripts/src/hwlab-node/web-observe-scripts.ts +++ b/scripts/src/hwlab-node/web-observe-scripts.ts @@ -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)}`, diff --git a/scripts/src/hwlab-node/web-probe.ts b/scripts/src/hwlab-node/web-probe.ts index 6e6607aa..8c375170 100644 --- a/scripts/src/hwlab-node/web-probe.ts +++ b/scripts/src/hwlab-node/web-probe.ts @@ -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