From 0ca78d63eaf56f953d84cc36e97c0606cb66c92d Mon Sep 17 00:00:00 2001 From: pikastech Date: Sat, 18 Jul 2026 13:56:20 +0200 Subject: [PATCH] fix: harden web probe production closeout --- .agents/skills/unidesk-webdev/SKILL.md | 4 + config/hwlab-node-lanes.yaml | 32 ++++++++ .../R2.1_Task_Report.md | 22 +++++ .../MDTODO/web-observe-runtime-reliability.md | 3 + .../hwlab-node-web-probe-console-profile.ts | 14 +++- ...wlab-node-web-probe-console-verify.test.ts | 11 ++- .../hwlab-node-web-probe-console-verify.ts | 81 ++++++++++++++----- scripts/src/hwlab-node/entry.ts | 7 ++ .../src/hwlab-node/web-probe-origin.test.ts | 18 ++++- scripts/src/hwlab-node/web-probe-origin.ts | 8 ++ scripts/src/web-probe-artifact.test.ts | 17 ++++ scripts/src/web-probe-artifact.ts | 60 ++++++++++++-- scripts/src/web-probe-remote-artifact.ts | 2 +- 13 files changed, 247 insertions(+), 32 deletions(-) create mode 100644 docs/MDTODO/details/web-observe-runtime-reliability/R2.1_Task_Report.md diff --git a/.agents/skills/unidesk-webdev/SKILL.md b/.agents/skills/unidesk-webdev/SKILL.md index 3f4fd315..40c9b838 100644 --- a/.agents/skills/unidesk-webdev/SKILL.md +++ b/.agents/skills/unidesk-webdev/SKILL.md @@ -135,6 +135,10 @@ description: UniDesk Web 开发与受控浏览器验证技能。用户提到 Web - Cloud Console 全路由、多视口和通用产品交互收口: - 使用 `web-probe console-verify --node --lane --origin internal|public --profile `; - 路由、视口、selector、工作流、等待和证据上限只从 owning YAML profile 读取; + - 默认摘要直接返回有界 `pageErrors`、`consoleErrors`、`requestFailures` 和 `responseErrors`,白屏或 workflow 失败先看各数组首项,不再为首错重复启动浏览器; + - semantic internal origin 必须披露实际 namespace、Service、ClusterIP、port 和 scheme;production profile 必须由 production owning YAML 明确选择 production namespace,禁止继承后静默落到 development lane; + - 长任务生成的受控 JSON 与截图都由 remote artifact runner 下载;输出的绝对或相对 artifact 路径必须能直接交给 `web-probe artifact inspect`,禁止生成会被自身路径门禁拒绝的 NEXT; + - TaskTree profile 先从全局 TaskGroup 进入 group deep link,再选择真实 task;详情验收接受 dialog 或 docked panel,并只在 dialog 模式发送 Escape; - 不再复制临时脚本; - 只用于已部署页面的人工 closeout; - 不进入 CI/CD gate; diff --git a/config/hwlab-node-lanes.yaml b/config/hwlab-node-lanes.yaml index 6dfea5fa..ea43f0fb 100644 --- a/config/hwlab-node-lanes.yaml +++ b/config/hwlab-node-lanes.yaml @@ -350,6 +350,18 @@ lanes: version: v0.3 sourceBranch: release workspace: /root/hwlab-release + webProbe: + extends: templates.hwlabV03.webProbeWorkbench + playwrightBrowsersPath: "0" + overrides: + origins: + internal: + mode: k8s-service-cluster-ip + serviceName: hwlab-cloud-web + namespace: hwlab-production + port: 8080 + scheme: http + browserProxyMode: direct cicdRepo: /root/hwlab-release-cicd.git cicdRepoLock: /tmp/hwlab-release-cicd-repo.lock app: hwlab-node-production @@ -372,6 +384,18 @@ lanes: NC01: environment: production workspace: /root/hwlab-release + webProbe: + extends: templates.hwlabV03.webProbeWorkbench + playwrightBrowsersPath: "0" + overrides: + origins: + internal: + mode: k8s-service-cluster-ip + serviceName: hwlab-cloud-web + namespace: hwlab-production + port: 8080 + scheme: http + browserProxyMode: direct sourceWorkspace: install: stateDir: /tmp/hwlab-release-source-workspace-bootstrap @@ -929,7 +953,9 @@ templates: maxHorizontalOverflowPx: 2 outputLimits: failures: 100 + pageErrors: 100 consoleErrors: 100 + requestFailures: 100 responseErrors: 100 screenshots: 24 textPreviewChars: 160 @@ -1084,7 +1110,9 @@ templates: maxHorizontalOverflowPx: 2 outputLimits: failures: 20 + pageErrors: 20 consoleErrors: 20 + requestFailures: 20 responseErrors: 20 screenshots: 2 textPreviewChars: 160 @@ -1122,7 +1150,9 @@ templates: maxHorizontalOverflowPx: 2 outputLimits: failures: 20 + pageErrors: 20 consoleErrors: 20 + requestFailures: 20 responseErrors: 20 screenshots: 5 textPreviewChars: 200 @@ -1155,7 +1185,9 @@ templates: zoomInButtonName: 放大时间尺度 secondsScaleLabel: 秒 zoomScreenshotName: tasktree-timescale-second.png + detailSelector: .tasktree-detail-panel dialogSelector: "[role='dialog']" + dockedSelector: .tasktree-docked-detail reportSelector: .tasktree-reports article detailScreenshotName: tasktree-detail.png realtimeFanoutProfiles: diff --git a/docs/MDTODO/details/web-observe-runtime-reliability/R2.1_Task_Report.md b/docs/MDTODO/details/web-observe-runtime-reliability/R2.1_Task_Report.md new file mode 100644 index 00000000..0dcde818 --- /dev/null +++ b/docs/MDTODO/details/web-observe-runtime-reliability/R2.1_Task_Report.md @@ -0,0 +1,22 @@ +# R2.1 任务报告 + +## 结果 + +完成 UniDesk #2526 的共享 WebProbe 修复: + +- production/internal semantic origin 由 owning YAML 显式解析到 `hwlab-production/hwlab-cloud-web`,摘要披露 namespace、Service、ClusterIP、port 与 scheme。 +- `console-verify` 分别采集有界 `pageErrors`、`consoleErrors`、`requestFailures`、`responseErrors`,默认摘要优先保留四类首错。 +- TaskTree workflow 改为 TaskGroup 到 group deep link,再选择真实 task;详情接受 dialog 或 docked panel,仅 dialog 发送 Escape。 +- remote artifact runner 下载受控 JSON;`artifact inspect` 接受自身 NEXT 输出的绝对路径,并将默认 evidence 压缩到 CLI 输出预算内。 + +## 验证 + +- `bun test scripts/src/hwlab-node/web-probe-origin.test.ts scripts/src/hwlab-node-web-probe-console-verify.test.ts scripts/src/web-probe-artifact.test.ts` +- `bun scripts/cli.ts check --syntax-only` +- `git diff --check` +- L1:`web-probe console-verify --node NC01 --lane production --origin internal --profile tasktree` 正确选择 production Service,并直接报告 production 产品白屏首错 `TypeError: Cannot destructure property 'slots' of 'undefined'`。 +- 同次输出的绝对 report path 直接交给 `web-probe artifact inspect` 后 SHA 校验成功,默认输出未再触发路径拒绝或 stdout 截断。 + +## 后续边界 + +production TaskTree 产品白屏不属于共享工具修复,证据已补充到 [HWLAB #2662](https://github.com/pikasTech/HWLAB/issues/2662#issuecomment-5011146448),本任务未修改 HWLAB 产品代码。 diff --git a/docs/MDTODO/web-observe-runtime-reliability.md b/docs/MDTODO/web-observe-runtime-reliability.md index 02cf6757..f330ba7e 100644 --- a/docs/MDTODO/web-observe-runtime-reliability.md +++ b/docs/MDTODO/web-observe-runtime-reliability.md @@ -49,6 +49,9 @@ 完成 [UniDesk #2248](https://github.com/pikasTech/unidesk/issues/2248):新增 YAML-first `web-probe console-verify` typed command,按 node/lane/origin/profile 复用现有认证、远程浏览器、4 GiB `MemAvailable` 门禁、报告恢复和截图合同,一条命令完成 Cloud Console 路由×视口矩阵及可选 Projects/HWPOD/MDTODO/AgentRun 交互验证;配置拥有路由、视口、DOM 合同和等待,禁止写死节点、lane、origin、Issue 或 artifact,不作为 CI/CD gate且禁止 Vitest,完成任务后将详细报告写入[任务报告](./details/web-observe-runtime-reliability/R2_Task_Report.md)。 +### R2.1 [completed] + +解决 [UniDesk #2526](https://github.com/pikasTech/unidesk/issues/2526):修复 `console-verify` production internal origin 错选开发 namespace、失败摘要缺失首个 page/console/request/response 错误、命令给出的 artifact inspect 路径被自身拒绝,以及 TaskTree workflow 固定等待首次点击后的 dialog;保持 owning YAML、认证、脱敏和浏览器门禁,支持 group 到 task 后的 dialog/docked 详情,不修改产品代码或降低错误门禁,完成任务后将详细报告写入[任务报告](./details/web-observe-runtime-reliability/R2.1_Task_Report.md)。 ## R3 [completed] 修复 [UniDesk #2370](https://github.com/pikasTech/unidesk/issues/2370):WebProbe 已解析凭据但 Chromium 在首次导航前关闭且无 API 响应时,分类为浏览器运行时 blocker,并在单次有界输出披露 credential/request/API 分层事实,避免重复检查 Secret 与远端 artifact;不降低既有门禁、不阻塞业务交付,完成任务后将详细报告写入[任务报告](./details/web-observe-runtime-reliability/R3_Task_Report.md)。 diff --git a/scripts/src/hwlab-node-web-probe-console-profile.ts b/scripts/src/hwlab-node-web-probe-console-profile.ts index 09a85e39..b79a6210 100644 --- a/scripts/src/hwlab-node-web-probe-console-profile.ts +++ b/scripts/src/hwlab-node-web-probe-console-profile.ts @@ -62,7 +62,9 @@ export interface HwlabRuntimeWebProbeConsoleTasktreeWorkflowSpec { readonly zoomInButtonName: string; readonly secondsScaleLabel: string; readonly zoomScreenshotName: string; + readonly detailSelector: string; readonly dialogSelector: string; + readonly dockedSelector: string; readonly reportSelector: string; readonly detailScreenshotName: string; } @@ -97,7 +99,9 @@ export interface HwlabRuntimeWebProbeConsoleVerificationProfileSpec { readonly maxHorizontalOverflowPx: number; readonly outputLimits: { readonly failures: number; + readonly pageErrors: number; readonly consoleErrors: number; + readonly requestFailures: number; readonly responseErrors: number; readonly screenshots: number; readonly textPreviewChars: number; @@ -147,7 +151,9 @@ function consoleProfile(value: unknown, path: string): HwlabRuntimeWebProbeConso const routeIds = new Set(routes.map((item) => item.id)); const workflows = workflowConfig(raw.workflows, `${path}.workflows`, routeIds); const output = record(raw.outputLimits, `${path}.outputLimits`); - onlyKeys(output, ["failures", "consoleErrors", "responseErrors", "screenshots", "textPreviewChars"], `${path}.outputLimits`); + onlyKeys(output, [ + "failures", "pageErrors", "consoleErrors", "requestFailures", "responseErrors", "screenshots", "textPreviewChars", + ], `${path}.outputLimits`); const screenshotBudget = integer(output.screenshots, `${path}.outputLimits.screenshots`, 1, 1000); const matrixScreenshots = routes.filter((item) => item.screenshot).length * viewports.length; const workflowScreenshots = (workflows.agentObserver?.views.length ?? 0) @@ -164,7 +170,9 @@ function consoleProfile(value: unknown, path: string): HwlabRuntimeWebProbeConso maxHorizontalOverflowPx: integer(raw.maxHorizontalOverflowPx, `${path}.maxHorizontalOverflowPx`, 0, 1000), outputLimits: { failures: integer(output.failures, `${path}.outputLimits.failures`, 1, 1000), + pageErrors: integer(output.pageErrors, `${path}.outputLimits.pageErrors`, 1, 1000), consoleErrors: integer(output.consoleErrors, `${path}.outputLimits.consoleErrors`, 1, 1000), + requestFailures: integer(output.requestFailures, `${path}.outputLimits.requestFailures`, 1, 1000), responseErrors: integer(output.responseErrors, `${path}.outputLimits.responseErrors`, 1, 1000), screenshots: screenshotBudget, textPreviewChars: integer(output.textPreviewChars, `${path}.outputLimits.textPreviewChars`, 20, 2000), @@ -218,7 +226,7 @@ function tasktreeWorkflow(value: unknown, path: string, routeIds: ReadonlySet { maxHorizontalOverflowPx: 2, outputLimits: { failures: 20, + pageErrors: 20, consoleErrors: 20, + requestFailures: 20, responseErrors: 20, screenshots: 1, textPreviewChars: 120, @@ -61,6 +63,8 @@ test("Cloud Console verification profile is owned by lane YAML", () => { assert.equal(tasktree.workflows.tasktree?.reportSelector, ".tasktree-reports article"); assert.equal(tasktree.workflows.tasktree?.scaleSelector, ".tasktree-zoom output"); assert.equal(tasktree.workflows.tasktree?.secondsScaleLabel, "秒"); + assert.equal(tasktree.workflows.tasktree?.detailSelector, ".tasktree-detail-panel"); + assert.equal(tasktree.workflows.tasktree?.dockedSelector, ".tasktree-docked-detail"); assert.equal(tasktree.outputLimits.screenshots, 5); }); @@ -94,6 +98,11 @@ test("generated Cloud Console runner is valid JavaScript and contains no node or assert.match(script, /verifyTasktree/u); assert.match(script, /ganttInternalOverflowPx/u); assert.match(script, /page\.on\("pageerror"/u); + assert.match(script, /page\.on\("requestfailed"/u); + assert.match(script, /pageErrors/u); + assert.match(script, /requestFailures/u); + assert.match(script, /detailMode/u); + assert.match(script, /return \{\s+ok,\s+status:[\s\S]*?pageErrors,\s+consoleErrors,\s+requestFailures,\s+responseErrors,[\s\S]*?profileName: config\.profileName/u); }); test("long console verification uses the existing remote artifact job", () => { @@ -103,7 +112,7 @@ test("long console verification uses the existing remote artifact job", () => { assert.match(observeScripts, /runWebProbeRemoteArtifactJob/u); assert.match(observeScripts, /remote-artifact-download/u); assert.match(remoteArtifact, /--allow-text-transfer/u); - assert.match(remoteArtifact, /\*\.webp\|\*\/web-probe-script-report\.json/u); + assert.match(remoteArtifact, /\*\.webp\|\*\.json/u); }); test("console-verify parses into the existing managed script action", () => { diff --git a/scripts/src/hwlab-node-web-probe-console-verify.ts b/scripts/src/hwlab-node-web-probe-console-verify.ts index 99946f27..f283847b 100644 --- a/scripts/src/hwlab-node-web-probe-console-verify.ts +++ b/scripts/src/hwlab-node-web-probe-console-verify.ts @@ -13,10 +13,14 @@ export default async function consoleVerify({ page, context, goto, reloadStable, const failures = []; const pages = []; const screenshots = []; + const pageErrors = []; const consoleErrors = []; + const requestFailures = []; const responseErrors = []; let totalFailureCount = 0; + let totalPageErrorCount = 0; let totalConsoleErrorCount = 0; + let totalRequestFailureCount = 0; let totalResponseErrorCount = 0; const addFailure = (message) => { @@ -27,6 +31,22 @@ export default async function consoleVerify({ page, context, goto, reloadStable, totalConsoleErrorCount += 1; if (consoleErrors.length < config.outputLimits.consoleErrors) consoleErrors.push(String(message).slice(0, 300)); }; + const addPageError = (error) => { + totalPageErrorCount += 1; + if (pageErrors.length < config.outputLimits.pageErrors) { + pageErrors.push(String(error?.stack || errorMessage(error)).slice(0, 600)); + } + }; + const addRequestFailure = (request) => { + totalRequestFailureCount += 1; + if (requestFailures.length < config.outputLimits.requestFailures) { + requestFailures.push({ + method: request.method(), + url: request.url().replace(/[?#].*$/u, ""), + errorText: String(request.failure()?.errorText || "request failed").slice(0, 300), + }); + } + }; const addResponseError = (response) => { totalResponseErrorCount += 1; if (responseErrors.length < config.outputLimits.responseErrors) { @@ -54,7 +74,8 @@ export default async function consoleVerify({ page, context, goto, reloadStable, page.on("console", (message) => { if (message.type() === "error") addConsoleError(message.text()); }); - page.on("pageerror", (error) => addConsoleError("pageerror: " + String(error?.stack || errorMessage(error)))); + page.on("pageerror", addPageError); + page.on("requestfailed", addRequestFailure); page.on("response", (response) => { if (response.status() >= 500) addResponseError(response); }); @@ -150,6 +171,10 @@ export default async function consoleVerify({ page, context, goto, reloadStable, failureCount: totalFailureCount, consoleErrors, consoleErrorCount: totalConsoleErrorCount, + pageErrors, + pageErrorCount: totalPageErrorCount, + requestFailures, + requestFailureCount: totalRequestFailureCount, responseErrors, responseErrorCount: totalResponseErrorCount, screenshots, @@ -168,14 +193,20 @@ export default async function consoleVerify({ page, context, goto, reloadStable, return { ok, status: ok ? "pass" : "blocked", + pageErrors, + consoleErrors, + requestFailures, + responseErrors, + failureCount: totalFailureCount, + failures, + pageErrorCount: totalPageErrorCount, + consoleErrorCount: totalConsoleErrorCount, + requestFailureCount: totalRequestFailureCount, + responseErrorCount: totalResponseErrorCount, profileName: config.profileName, configRef: config.configRef, routeSamples: pages.length, viewportCount: config.viewports.length, - failureCount: totalFailureCount, - failures, - consoleErrorCount: totalConsoleErrorCount, - responseErrorCount: totalResponseErrorCount, screenshotCount: screenshots.length, report, workflowEvidence, @@ -186,6 +217,10 @@ export default async function consoleVerify({ page, context, goto, reloadStable, screenshotSha256: screenshots.map((item) => item?.sha256).filter((item) => typeof item === "string"), failureCount: totalFailureCount, failures, + pageErrors, + consoleErrors, + requestFailures, + responseErrors, valuesRedacted: true, }, valuesRedacted: true, @@ -386,23 +421,31 @@ export default async function consoleVerify({ page, context, goto, reloadStable, timeline.zoomed = { ...zoomedTimeline, ...pinnedColumn }; } await firstTask.click(); - await page.locator(workflow.dialogSelector).waitFor({ state: "visible", timeout: config.navigationTimeoutMs }); await wait(config.settleMs); - const detail = await page.evaluate((workflowConfig) => { - const dialog = document.querySelector(workflowConfig.dialogSelector); - const rect = dialog?.getBoundingClientRect(); - return { - dialogVisible: Boolean(rect && rect.width > 0 && rect.height > 0), - dialogInsideViewport: Boolean(rect && rect.left >= 0 && rect.top >= 0 && rect.right <= innerWidth + 1 && rect.bottom <= innerHeight + 1), - reportCount: document.querySelectorAll(workflowConfig.reportSelector).length, - }; - }, workflow); - if (!detail.dialogVisible || !detail.dialogInsideViewport) addFailure("TaskTree: detail dialog is not bounded by the viewport"); - if (detail.reportCount < 1) addFailure("TaskTree: selected task has no execution report"); - const artifact = await captureScreenshot(workflow.detailScreenshotName); - await page.keyboard.press("Escape"); const taskDeepLink = new URL(page.url()).pathname; if (!taskDeepLink.startsWith(requiredRoute(workflow.routeId).path + "/")) addFailure("TaskTree: group deep link missing " + taskDeepLink); + const groupTask = page.locator(workflow.taskSelector).first(); + await groupTask.waitFor({ state: "visible", timeout: config.navigationTimeoutMs }); + await groupTask.click(); + await page.locator(workflow.detailSelector).waitFor({ state: "visible", timeout: config.navigationTimeoutMs }); + await wait(config.settleMs); + const detail = await page.evaluate((workflowConfig) => { + const detailPanel = document.querySelector(workflowConfig.detailSelector); + const dialog = document.querySelector(workflowConfig.dialogSelector); + const docked = document.querySelector(workflowConfig.dockedSelector); + const rect = detailPanel?.getBoundingClientRect(); + return { + detailVisible: Boolean(rect && rect.width > 0 && rect.height > 0), + detailInsideViewport: Boolean(rect && rect.left >= 0 && rect.top >= 0 && rect.right <= innerWidth + 1 && rect.bottom <= innerHeight + 1), + detailMode: dialog ? "dialog" : docked ? "docked" : "unknown", + reportCount: detailPanel?.querySelectorAll(workflowConfig.reportSelector).length || 0, + }; + }, workflow); + if (!detail.detailVisible || !detail.detailInsideViewport) addFailure("TaskTree: detail panel is not bounded by the viewport"); + if (detail.detailMode === "unknown") addFailure("TaskTree: detail panel is neither dialog nor docked"); + if (detail.reportCount < 1) addFailure("TaskTree: selected task has no execution report"); + const artifact = await captureScreenshot(workflow.detailScreenshotName); + if (detail.detailMode === "dialog") await page.keyboard.press("Escape"); await reloadStable({ selectors: [requiredRoute(workflow.routeId).readySelector], readinessTimeoutMs: config.navigationTimeoutMs }); if (new URL(page.url()).pathname !== taskDeepLink) addFailure("TaskTree: group deep link did not survive refresh"); return { taskDeepLink, ...timeline, ...detail, detailScreenshotSha256: artifact?.sha256 || null }; diff --git a/scripts/src/hwlab-node/entry.ts b/scripts/src/hwlab-node/entry.ts index 3c8a5638..d80f41c5 100644 --- a/scripts/src/hwlab-node/entry.ts +++ b/scripts/src/hwlab-node/entry.ts @@ -64,6 +64,13 @@ export interface WebProbeOriginSelection { readonly originName: WebProbeOriginName; readonly originMode: "k8s-service-cluster-ip" | "public" | "custom-url"; readonly originConfigPath: string | null; + readonly originSource?: { + readonly namespace: string; + readonly serviceName: string; + readonly clusterIp: string; + readonly port: number; + readonly scheme: string; + }; readonly browserProxyMode: WebProbeBrowserProxyMode; readonly browserProxyModeSource: "cli" | "yaml-origin" | "yaml-web-probe" | "default"; } diff --git a/scripts/src/hwlab-node/web-probe-origin.test.ts b/scripts/src/hwlab-node/web-probe-origin.test.ts index 53f74fe0..ea1ddf33 100644 --- a/scripts/src/hwlab-node/web-probe-origin.test.ts +++ b/scripts/src/hwlab-node/web-probe-origin.test.ts @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import { test } from "bun:test"; import { hwlabRuntimeLaneSpecForNode } from "../hwlab-node-lanes"; -import { parseWebProbeOriginName, resolveNodeWebProbeCliOrigin, resolveNodeWebProbeOrigin } from "./web-probe-origin"; +import { parseWebProbeOriginName, resolveNodeWebProbeCliOrigin, resolveNodeWebProbeOrigin, webProbeOriginSummary } from "./web-probe-origin"; const spec = hwlabRuntimeLaneSpecForNode("v03", "NC01"); @@ -51,6 +51,22 @@ test("internal semantic origin resolves the current Service address without YAML assert.equal(origin.url, "http://10.43.99.7:8080"); assert.equal(origin.browserProxyMode, "direct"); assert.equal(origin.browserProxyModeSource, "yaml-origin"); + assert.deepEqual(webProbeOriginSummary(origin).source, { + namespace: "hwlab-v03", + serviceName: "hwlab-cloud-web", + clusterIp: "10.43.99.7", + port: 8080, + scheme: "http", + }); +}); + +test("production internal origin is owned by the production namespace", () => { + const production = hwlabRuntimeLaneSpecForNode("production", "NC01"); + const origin = resolveNodeWebProbeOrigin(production, "internal", (_spec, namespace) => { + assert.equal(namespace, "hwlab-production"); + return "10.43.99.8"; + }); + assert.equal(origin.originSource?.namespace, "hwlab-production"); }); test("--origin and custom --url are mutually exclusive", () => { diff --git a/scripts/src/hwlab-node/web-probe-origin.ts b/scripts/src/hwlab-node/web-probe-origin.ts index e8a4bf9a..9ecb8244 100644 --- a/scripts/src/hwlab-node/web-probe-origin.ts +++ b/scripts/src/hwlab-node/web-probe-origin.ts @@ -15,6 +15,7 @@ export function webProbeOriginSummary(origin: WebProbeOriginSelection): Record { @@ -31,4 +33,19 @@ test("web-probe artifact inspect 只接受受控 artifact 路径和显式 SHA", "--path", ".state/web-probe-script-artifacts/web-probe-script-d601-v03-fixture-report.json", "--sha256", sha256, ]).path, ".state/web-probe-script-artifacts/web-probe-script-d601-v03-fixture-report.json"); + const absolutePath = rootPath(".state/web-probe-script-artifacts/web-probe-script-d601-v03-fixture-report.json"); + assert.equal(parseWebProbeArtifactInspectOptions([ + "inspect", + "--node", "D601", + "--lane", "v03", + "--path", absolutePath, + "--sha256", sha256, + ]).path, absolutePath); + assert.throws(() => parseWebProbeArtifactInspectOptions([ + "inspect", + "--node", "D601", + "--lane", "v03", + "--path", resolve(".state/web-probe-script-artifacts/nested/report.json"), + "--sha256", sha256, + ]), /受控 web-probe script artifact/u); }); diff --git a/scripts/src/web-probe-artifact.ts b/scripts/src/web-probe-artifact.ts index cde4cd9a..366a3253 100644 --- a/scripts/src/web-probe-artifact.ts +++ b/scripts/src/web-probe-artifact.ts @@ -1,5 +1,6 @@ import { createHash } from "node:crypto"; import { readFileSync } from "node:fs"; +import { basename, dirname, relative, resolve } from "node:path"; import { resolveCliChildJsonCommandResult } from "./cli-child-json-recovery"; import { rootPath } from "./config"; @@ -99,8 +100,21 @@ function compactDownloadedReport(value: unknown): Record { ok: report.ok === true, status: typeof report.status === "string" ? report.status : null, failureKind: typeof report.failureKind === "string" ? report.failureKind : null, - summary: compactArtifactValue(summary), - issueEvidence: compactArtifactValue(report.issueEvidence), + summary: { + ok: summary.ok === true, + status: summary.status ?? null, + degradedReason: summary.degradedReason ?? null, + failureKind: summary.failureKind ?? null, + failedCondition: compactArtifactValue(summary.failedCondition), + nextAction: compactArtifactValue(summary.nextAction), + baseUrl: summary.baseUrl ?? null, + finalUrl: summary.finalUrl ?? null, + reportPath: summary.reportPath ?? null, + reportSha256: summary.reportSha256 ?? null, + lastStep: compactArtifactValue(summary.lastStep), + issueEvidence: compactIssueEvidence(summary.issueEvidence), + }, + issueEvidence: compactIssueEvidence(report.issueEvidence), failureEvidence: compactArtifactValue(report.failureEvidence), lastStep: compactArtifactValue(report.lastStep ?? summary.lastStep), steps: compactArtifactValue(report.steps), @@ -109,20 +123,45 @@ function compactDownloadedReport(value: unknown): Record { }; } +function compactIssueEvidence(value: unknown): unknown { + const issue = objectValue(value); + const result = objectValue(issue.result); + if (!("pageErrors" in result || "consoleErrors" in result || "requestFailures" in result || "responseErrors" in result)) { + return compactArtifactValue(value); + } + return { + ok: issue.ok ?? result.ok ?? null, + status: issue.status ?? result.status ?? null, + profileName: result.profileName ?? null, + configRef: result.configRef ?? null, + failureCount: result.failureCount ?? null, + failures: compactArtifactValue(result.failures), + pageErrorCount: result.pageErrorCount ?? null, + pageErrors: compactArtifactValue(result.pageErrors), + consoleErrorCount: result.consoleErrorCount ?? null, + consoleErrors: compactArtifactValue(result.consoleErrors), + requestFailureCount: result.requestFailureCount ?? null, + requestFailures: compactArtifactValue(result.requestFailures), + responseErrorCount: result.responseErrorCount ?? null, + responseErrors: compactArtifactValue(result.responseErrors), + valuesRedacted: true, + }; +} + function compactArtifactValue(value: unknown, depth = 0): unknown { if (value === null || value === undefined) return value ?? null; - if (typeof value === "string") return value.replace(/\s+/gu, " ").trim().slice(0, 600); + if (typeof value === "string") return value.replace(/\s+/gu, " ").trim().slice(0, 300); if (typeof value === "number" || typeof value === "boolean") return value; - if (depth >= 6) return "[max-depth]"; - if (Array.isArray(value)) return value.slice(-8).map((item) => compactArtifactValue(item, depth + 1)); + if (depth >= 5) return "[max-depth]"; + if (Array.isArray(value)) return value.slice(0, 4).map((item) => compactArtifactValue(item, depth + 1)); if (typeof value === "object") { const output: Record = {}; - for (const [key, nested] of Object.entries(value as Record).slice(0, 32)) { + for (const [key, nested] of Object.entries(value as Record).slice(0, 24)) { output[key] = compactArtifactValue(nested, depth + 1); } return output; } - return String(value).slice(0, 600); + return String(value).slice(0, 300); } function objectValue(value: unknown): Record { @@ -130,7 +169,12 @@ function objectValue(value: unknown): Record { } function isSafeDownloadedWebProbeArtifactPath(path: string): boolean { - return /^\.state\/web-probe-script-artifacts\/[A-Za-z0-9_.-]+[.](?:png|json)$/u.test(path); + const root = resolve(rootPath(".state/web-probe-script-artifacts")); + const candidate = resolve(rootPath(path)); + const relativePath = relative(root, candidate); + return dirname(relativePath) === "." + && basename(relativePath) === relativePath + && /^[A-Za-z0-9_.-]+[.](?:png|json)$/u.test(relativePath); } export function artifactInspectScript(options: WebProbeArtifactInspectOptions): string { diff --git a/scripts/src/web-probe-remote-artifact.ts b/scripts/src/web-probe-remote-artifact.ts index f50c5ad7..b2b8c736 100644 --- a/scripts/src/web-probe-remote-artifact.ts +++ b/scripts/src/web-probe-remote-artifact.ts @@ -382,7 +382,7 @@ function remoteArtifactRunnerScript(remoteDir: string, runId: string, stdoutTail ': > "$artifacts_file"', 'find "$remote_dir" -type f | while IFS= read -r file; do', ' case "$file" in "$user_script"|"$stdout_file"|"$stderr_file"|"$artifacts_file"|"$manifest_file"|"$remote_dir/runner.sh"|"$remote_dir/submit.out"|"$remote_dir/submit.err"|"$remote_dir/pid") continue ;; esac', - ' case "$file" in *.png|*.jpg|*.jpeg|*.webp|*/web-probe-script-report.json)', + ' case "$file" in *.png|*.jpg|*.jpeg|*.webp|*.json)', ' bytes=$(wc -c < "$file" | tr -d "[:space:]")', ' digest=$(sha256_file "$file")', ' printf "artifact\\t%s\\t%s\\t%s\\n" "$bytes" "$digest" "$file" >> "$artifacts_file"',