diff --git a/.agents/skills/unidesk-webdev/SKILL.md b/.agents/skills/unidesk-webdev/SKILL.md index c3eff6cb..5423d0bd 100644 --- a/.agents/skills/unidesk-webdev/SKILL.md +++ b/.agents/skills/unidesk-webdev/SKILL.md @@ -104,6 +104,8 @@ description: UniDesk Web 开发与受控浏览器验证技能。用户提到 Web - 不替代 Workbench Kafka、Trace 或性能专项命令。 - 产品级 RESTful 深链选择、保存与重载持久化 smoke: - 使用 `web-probe product-smoke --product --target --profile --edition --algorithm `; + - 默认 `--json` 只返回有界判定摘要、认证请求到达状态、失败类型、报告/截图哈希和受控下钻命令;只有一次性需要完整投影时显式使用 `--full` 或 `--raw`; + - 首次登录导航在 `status=0`、无请求/响应和零步骤时出现浏览器关闭或 `ERR_INSUFFICIENT_RESOURCES`,必须分类为 `browser-runtime-blocker`,不得误报密码或目标认证失败; - 失败输出中的报告或截图使用 `web-probe artifact inspect --node --lane --path --sha256 sha256:` 受控下钻;禁止调用者猜测 runner workspace 或直接读取完整报告; - 产品 target、semantic origin、认证 sourceRef、登录合同、selector、保存请求、视口和证据上限只从产品 owning YAML 读取; - 认证合同可声明 API session、同页表单或 BrowserContext Basic Auth,产品命令不得自行读取或拼接认证 header; diff --git a/scripts/src/hwlab-node-web-probe-summary.ts b/scripts/src/hwlab-node-web-probe-summary.ts index b019fa4c..9367b962 100644 --- a/scripts/src/hwlab-node-web-probe-summary.ts +++ b/scripts/src/hwlab-node-web-probe-summary.ts @@ -148,11 +148,13 @@ export function classifyWebProbeBrowserRuntimeAuthFailure(report: Record 0); const firstNavigationClosed = /Target page, context or browser has been closed/iu.test(message); - if (!credentialResolved || !firstNavigationClosed || auth.status !== 0 || response !== null || stepCount !== 0 || explicitRequestReached || apiResponseReached) { + const browserResourceExhausted = /net::ERR_INSUFFICIENT_RESOURCES|net::ERR_OUT_OF_MEMORY/iu.test(message); + if (!credentialResolved || (!firstNavigationClosed && !browserResourceExhausted) || auth.status !== 0 || response !== null || stepCount !== 0 || explicitRequestReached || apiResponseReached) { return null; } + const code = browserResourceExhausted ? "browser-runtime-resource-exhausted" : "browser-runtime-closed"; return { - code: "browser-runtime-closed", + code, failureKind: "browser-runtime-blocker", credentialResolved: true, requestReached: false, diff --git a/scripts/src/web-probe-product-smoke.test.ts b/scripts/src/web-probe-product-smoke.test.ts index cd8f1c84..d41c14ac 100644 --- a/scripts/src/web-probe-product-smoke.test.ts +++ b/scripts/src/web-probe-product-smoke.test.ts @@ -15,6 +15,7 @@ import { compactWebProbeScriptResult, } from "./hwlab-node-web-probe-summary"; import { + compactWebProbeProductSmokeProjection, parseWebProbeProductSmokeOptions, resolveWebProbeProductSmoke, } from "./web-probe-product-smoke"; @@ -336,6 +337,103 @@ test("首次 UI 导航关闭且未发出认证请求时分类为浏览器运行 }); }); +test("首次 UI 导航资源耗尽不误报目标认证失败", () => { + const report = { + ok: false, + status: "blocked", + error: "auth-login-failed", + errorMessage: "auth-login-failed", + auth: { + ok: false, + method: "ui-form-memory", + status: 0, + usernamePresent: true, + attempts: [{ method: "ui-form-memory", response: null, finalPath: "blank" }], + errorSummary: { message: "page.goto: net::ERR_INSUFFICIENT_RESOURCES at https://example.test/login", finalPath: "blank" }, + }, + script: { ok: false, steps: [] }, + steps: [], + }; + const blocker = classifyWebProbeBrowserRuntimeAuthFailure(report); + assert.equal(blocker?.code, "browser-runtime-resource-exhausted"); + assert.equal(blocker?.failureKind, "browser-runtime-blocker"); + assert.equal(blocker?.credentialResolved, true); + assert.equal(blocker?.requestReached, false); + assert.equal(blocker?.apiResponseReached, false); + + const probe = compactWebProbeScriptResult(report); + assert.equal(probe?.error, "browser-runtime-resource-exhausted"); + assert.equal(probe?.failureKind, "browser-runtime-blocker"); + const projection = compactWebProbeProductSmokeProjection({ + ok: false, + status: "blocked", + node: "NC01", + lane: "v03", + probe, + }); + assert.equal(projection.degradedReason, "browser-runtime-resource-exhausted"); + assert.equal(projection.failureKind, "browser-runtime-blocker"); + assert.deepEqual(projection.reachability, { + credentialResolved: true, + requestReached: false, + apiResponseReached: false, + errorSummary: { + message: "page.goto: net::ERR_INSUFFICIENT_RESOURCES at https://example.test/login", + status: 0, + response: null, + stepCount: 0, + valuesRedacted: true, + }, + }); +}); + +test("product-smoke 默认机器投影有界且保留一次判定所需字段", () => { + const reportSha256 = `sha256:${"a".repeat(64)}`; + const projection = compactWebProbeProductSmokeProjection({ + ok: false, + status: "blocked", + command: "web-probe product-smoke --product fixture --target NC01 --profile admin", + node: "NC01", + lane: "v03", + url: "https://fixture.example.test", + degradedReason: "browser-runtime-resource-exhausted", + failureKind: "browser-runtime-blocker", + issueEvidence: { + credentialResolved: true, + requestReached: false, + apiResponseReached: false, + failedCondition: "page.goto: net::ERR_INSUFFICIENT_RESOURCES", + errorSummary: { message: "page.goto: net::ERR_INSUFFICIENT_RESOURCES" }, + result: { noisy: "x".repeat(30_000) }, + }, + probe: { + reportPath: ".state/web-probe-script/run.fixture/web-probe-script-report.json", + reportSha256, + artifacts: { items: [{ kind: "json", path: ".state/report.json", sha256: reportSha256, byteCount: 30_000 }] }, + noisy: "x".repeat(30_000), + }, + result: { stdoutTail: "x".repeat(30_000) }, + productSmoke: { product: "fixture", target: "NC01", profile: "admin", valuesPrinted: false }, + }); + assert.equal(projection.failureKind, "browser-runtime-blocker"); + assert.deepEqual(projection.reachability, { + credentialResolved: true, + requestReached: false, + apiResponseReached: false, + errorSummary: { message: "page.goto: net::ERR_INSUFFICIENT_RESOURCES" }, + }); + assert.match(String((projection.next as Record).report), /web-probe artifact inspect/u); + assert.ok(Buffer.byteLength(JSON.stringify(projection)) < 10_240); +}); + +test("product-smoke 接受显式完整披露标志且不把它当产品参数", () => { + const parsed = parseWebProbeProductSmokeOptions([ + "--product", "pikaoa", "--target", "NC01", "--profile", "admin-mvp", "--runtime", "development", "--full", "--raw", + ]); + assert.equal(parsed.product, "pikaoa"); + assert.equal(parsed.runtime, "development"); +}); + test("真实认证响应或已发出的登录请求不降级为浏览器运行时 blocker", () => { const base = { auth: { diff --git a/scripts/src/web-probe-product-smoke.ts b/scripts/src/web-probe-product-smoke.ts index 28f040ba..bced31e3 100644 --- a/scripts/src/web-probe-product-smoke.ts +++ b/scripts/src/web-probe-product-smoke.ts @@ -45,6 +45,7 @@ export function parseWebProbeProductSmokeOptions(args: string[]): WebProbeProduc const known = new Set(["--product", "--target", "--profile", "--edition", "--algorithm", "--runtime"]); for (let index = 0; index < args.length; index += 1) { const item = args[index]; + if (item === "--full" || item === "--raw") continue; const equals = item.indexOf("="); const key = equals > 0 ? item.slice(0, equals) : item; if (!known.has(key)) throw new Error(`web-probe product-smoke 不支持参数 ${item}`); @@ -63,6 +64,61 @@ export function parseWebProbeProductSmokeOptions(args: string[]): WebProbeProduc }; } +export function compactWebProbeProductSmokeProjection(result: Record): Record { + const probe = machineRecord(result.probe); + const summary = machineRecord(result.summary); + const issueEvidence = machineRecord(result.issueEvidence); + const probeIssueEvidence = machineRecord(probe.issueEvidence); + const auth = machineRecord(probe.auth); + const failureEvidence = machineRecord(probe.failureEvidence); + const reportLoad = machineRecord(result.reportLoad); + const artifactItems = machineArtifacts(probe, machineRecord(result.recoveredArtifacts)); + const evidenceSources = [issueEvidence, probeIssueEvidence, summary, auth]; + const reportPath = firstMachineText([reportLoad.path, probe.reportPath, issueEvidence.reportPath, probeIssueEvidence.reportPath]); + const reportSha256 = firstMachineText([probe.reportSha256, issueEvidence.reportSha256, probeIssueEvidence.reportSha256]); + const node = machineText(result.node); + const lane = machineText(result.lane); + const reportInspect = node !== null && lane !== null && reportPath !== null && reportSha256 !== null + ? `bun scripts/cli.ts web-probe artifact inspect --node ${node} --lane ${lane} --path ${reportPath} --sha256 ${reportSha256}` + : null; + return { + commandPromotionHint: machineRecord(result.commandPromotionHint), + ok: result.ok === true, + status: machineText(result.status), + command: machineText(result.command), + node, + lane, + url: machineText(result.url), + degradedReason: firstMachineText([ + result.degradedReason, + issueEvidence.degradedReason, + probeIssueEvidence.degradedReason, + summary.degradedReason, + probe.error, + ]), + failureKind: firstMachineText([ + result.failureKind, + issueEvidence.failureKind, + probeIssueEvidence.failureKind, + summary.failureKind, + probe.failureKind, + ]), + reachability: { + credentialResolved: firstMachineBoolean(evidenceSources, "credentialResolved"), + requestReached: firstMachineBoolean(evidenceSources, "requestReached"), + apiResponseReached: firstMachineBoolean(evidenceSources, "apiResponseReached"), + errorSummary: firstMachineRecord(evidenceSources, "errorSummary"), + }, + failedCondition: firstMachineText([issueEvidence.failedCondition, probeIssueEvidence.failedCondition, summary.failedCondition]), + failureEvidence: Object.keys(failureEvidence).length === 0 ? null : failureEvidence, + report: { source: machineText(reportLoad.source), path: reportPath, sha256: reportSha256 }, + artifacts: artifactItems, + productSmoke: machineRecord(result.productSmoke), + next: { report: reportInspect, rerun: machineText(result.command) }, + valuesPrinted: false, + }; +} + export function resolveWebProbeProductSmoke(options: WebProbeProductSmokeOptions): ResolvedProductSmoke { if (options.product === "pikaoa") return resolvePikaoaProductSmoke(options); if (options.product !== "selfmedia") throw new Error(`未知 --product ${options.product};当前声明的产品为 pikaoa、selfmedia`); @@ -756,3 +812,44 @@ function simpleId(value: string, key: string): string { if (!/^[A-Za-z0-9._-]+$/u.test(value) || value.length > 120) throw new Error(`${key} 必须是最长 120 字符的简单标识`); return value; } + +function machineRecord(value: unknown): Record { + return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : {}; +} + +function machineText(value: unknown): string | null { + return typeof value === "string" && value.trim() !== "" ? value : null; +} + +function firstMachineText(values: unknown[]): string | null { + for (const value of values) { + const parsed = machineText(value); + if (parsed !== null) return parsed; + } + return null; +} + +function firstMachineBoolean(sources: Record[], key: string): boolean | null { + for (const source of sources) if (typeof source[key] === "boolean") return source[key] as boolean; + return null; +} + +function firstMachineRecord(sources: Record[], key: string): Record | null { + for (const source of sources) { + const value = machineRecord(source[key]); + if (Object.keys(value).length > 0) return value; + } + return null; +} + +function machineArtifacts(probe: Record, recovered: Record): Record[] { + const primary = machineRecord(probe.artifacts); + const fallback = machineRecord(recovered.artifacts); + const values = Array.isArray(primary.items) ? primary.items : Array.isArray(fallback.items) ? fallback.items : []; + return values.slice(-6).map(machineRecord).map((item) => ({ + kind: machineText(item.kind), + path: machineText(item.path), + sha256: machineText(item.sha256), + byteCount: typeof item.byteCount === "number" ? item.byteCount : null, + })); +} diff --git a/scripts/src/web-probe.ts b/scripts/src/web-probe.ts index 10c78a3d..d2c3b3b5 100644 --- a/scripts/src/web-probe.ts +++ b/scripts/src/web-probe.ts @@ -6,7 +6,7 @@ import { hwlabNodeWebProbeHelp, hwlabNodeWebProbeScreenshotHelp } from "./hwlab- import { parseNodeWebProbeOptions } from "./hwlab-node/web-probe"; import { runNodeWebProbe } from "./hwlab-node/web-probe-observe"; import { runWebProbeArtifactInspect } from "./web-probe-artifact"; -import { runWebProbeProductSmoke } from "./web-probe-product-smoke"; +import { compactWebProbeProductSmokeProjection, runWebProbeProductSmoke } from "./web-probe-product-smoke"; export function webProbeHelp(): Record { return hwlabNodeWebProbeHelp(); @@ -26,7 +26,9 @@ export async function runWebProbeCommand(_config: Config, args: string[]): Promi const result = runWebProbeProductSmoke(parsedArgs.slice(1)); if (format === "text") return result; const { renderedText: _renderedText, contentType: _contentType, ...projection } = result; - if (format === "json") return projection; + if (format === "json") return args.includes("--full") || args.includes("--raw") + ? projection + : compactWebProbeProductSmokeProjection(projection); return { ...result, renderedText: Bun.YAML.stringify(projection), contentType: "application/yaml" }; } const result = runNodeWebProbe(parseNodeWebProbeOptions(parsedArgs));