diff --git a/.agents/skills/unidesk-webdev/SKILL.md b/.agents/skills/unidesk-webdev/SKILL.md index e48ea11c..d0acb563 100644 --- a/.agents/skills/unidesk-webdev/SKILL.md +++ b/.agents/skills/unidesk-webdev/SKILL.md @@ -33,6 +33,11 @@ description: UniDesk Web 开发与受控浏览器验证技能。用户提到 Web - 每层证据必须标明 `browser=blocked`、实际覆盖范围和未覆盖的视觉/DOM/交互边界; - 用户目标只要求 CLI/API/CaseRun 终态时,同 dispatcher 的 CLI E2E 可以完成该目标,但不得冒充浏览器或视觉验收。 - 调试 HWLAB Cloud Web/Workbench 业务或功能 bug 时,使用 YAML 声明的 `--origin internal`;验收 public exposure、DNS、FRP、Caddy 或公网用户入口时,显式使用 `--origin public`。 +- `web-probe screenshot` 的 semantic origin 示例:`bun scripts/cli.ts web-probe screenshot --node --lane --origin internal --path /workbench --viewport 1440x900`。 +- `web-probe screenshot` 的 custom/local 示例: + - custom:`bun scripts/cli.ts web-probe screenshot --node --lane --url 'https://custom.example/workbench' --viewport 1440x900`; + - local:`bun scripts/cli.ts web-probe screenshot --node --lane --url 'http://127.0.0.1:4173/workbench' --viewport 1440x900`; + - `--url` 已包含完整路径,与 `--path` 互斥;参数错误时按 CLI 返回的 `correctionCommands` 移除 `--path`。 - `--url` 只是 custom/local 一次性逃生口,与 `--origin` 互斥;禁止通过手写 URL 或 IP 选择内网/公网运行面。本地 fake-server、localhost、dist 静态服务和 custom URL 只能作为开发 preflight 证据。 - 禁止在本地或 master server 直接跑 `vue-tsc` / 前端全量 typecheck 作为默认验证;本地只做语法级检查和真实入口复测,完整类型检查交给 CI、PipelineRun 或明确指定的受控构建运行面。 - HWLAB Cloud Web 的 Vitest 防误用规则: diff --git a/docs/MDTODO/details/web-observe-runtime-reliability/R4_Task_Report.md b/docs/MDTODO/details/web-observe-runtime-reliability/R4_Task_Report.md new file mode 100644 index 00000000..ce997209 --- /dev/null +++ b/docs/MDTODO/details/web-observe-runtime-reliability/R4_Task_Report.md @@ -0,0 +1,39 @@ +# R4 任务报告:改进 web-probe screenshot 参数纠错 + +## 任务回链 + +- MDTODO:`docs/MDTODO/web-observe-runtime-reliability.md#R4`。 +- GitHub Issue:`pikasTech/unidesk#2425`。 +- 交付类型:`pr-lightweight`。 +- rollout:`not-applicable`。 + +## 问题与根因 + +- `web-probe screenshot --url --path ` 会正确拒绝互斥参数,但只返回普通字符串错误。 +- `web-probe screenshot --help` 被顶层帮助提前接管,没有 screenshot 专属的 semantic、custom 和 local 完整示例。 +- 错误输出没有 typed reason、`mutation=false` 和可直接执行的修正命令,用户需要自行推断应删除哪个参数。 + +## 修改 + +- 新增 `web-probe screenshot` scoped help: + - semantic origin 使用 `--origin internal --path /workbench`; + - custom URL 和 localhost URL 直接包含完整路径; + - 明确 `--url` 与 `--path` 互斥。 +- 将互斥校验改为 `CliInputError`: + - `code=web-probe-screenshot-url-path-mutually-exclusive`; + - `reason=custom-url-already-includes-full-path-remove-path`; + - `mutation=false`; + - `correctionCommands` 保留原调用参数并移除 `--path`。 +- 同步 `unidesk-webdev` 高频 semantic、custom 和 local 示例。 +- 不修改浏览器能力、资源门禁、CI/CD、YAML semantic origin 或运行面。 + +## 验证 + +- 运行 screenshot scoped help 合同,确认输出非空并包含三类完整示例。 +- 运行互斥参数 JSON 合同,确认 typed reason、`mutation=false` 和修正命令。 +- 运行相关 Bun 定向测试与轻量语法检查。 + +## 结论 + +- R4 已完成源码、合同和文档收敛。 +- 该修改只影响 CLI 帮助与输入错误可见性,`rollout=not-applicable`。 diff --git a/docs/MDTODO/web-observe-runtime-reliability.md b/docs/MDTODO/web-observe-runtime-reliability.md index e0154289..e254181a 100644 --- a/docs/MDTODO/web-observe-runtime-reliability.md +++ b/docs/MDTODO/web-observe-runtime-reliability.md @@ -53,6 +53,6 @@ 修复 [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)。 -## R4 [in_progress] +## R4 [completed] 改进 [UniDesk #2425](https://github.com/pikasTech/unidesk/issues/2425) 的 `web-probe screenshot` custom/local 可见性:scoped help 明确 `--url` 与 `--path` 互斥,校验失败返回 typed reason、`mutation=false` 和可直接执行的修正示例,同步 webdev skill;只改帮助/错误与文档,不改浏览器能力、资源门禁、CI/CD 或运行面,完成任务后将详细报告写入[任务报告](./details/web-observe-runtime-reliability/R4_Task_Report.md)。 diff --git a/scripts/src/cli-input-errors.test.ts b/scripts/src/cli-input-errors.test.ts index 6c57b562..a5be3e30 100644 --- a/scripts/src/cli-input-errors.test.ts +++ b/scripts/src/cli-input-errors.test.ts @@ -53,6 +53,42 @@ describe("CLI input error hierarchy", () => { expect(result.stdout).not.toContain("platform-infra-gitea.ts:"); }); + test("web-probe screenshot returns a typed non-mutating correction for --url with --path", () => { + const result = runCli( + "web-probe", + "screenshot", + "--node", + "NC01", + "--lane", + "v03", + "--url", + "https://custom.example/workbench", + "--path", + "/workbench", + "--json", + ); + expect(result.status).toBe(1); + expect(result.stderr).toBe(""); + const payload = JSON.parse(result.stdout) as { + ok: boolean; + error: { + kind: string; + code: string; + reason: string; + mutation: boolean; + correctionCommands: string[]; + }; + }; + expect(payload.ok).toBe(false); + expect(payload.error.kind).toBe("cli-input"); + expect(payload.error.code).toBe("web-probe-screenshot-url-path-mutually-exclusive"); + expect(payload.error.reason).toBe("custom-url-already-includes-full-path-remove-path"); + expect(payload.error.mutation).toBe(false); + expect(payload.error.correctionCommands).toEqual([ + "bun scripts/cli.ts web-probe 'screenshot' '--node' 'NC01' '--lane' 'v03' '--url' 'https://custom.example/workbench'", + ]); + }); + test("Gitea full and raw input errors preserve machine JSON without a stack", () => { for (const outputFlag of ["--full", "--raw"]) { const result = runCli("platform-infra", "gitea", "status", "--target", "NC01", outputFlag, "--definitely-unsupported"); diff --git a/scripts/src/help.ts b/scripts/src/help.ts index 633b1c21..0919b437 100644 --- a/scripts/src/help.ts +++ b/scripts/src/help.ts @@ -1078,6 +1078,12 @@ export async function staticNamespaceHelp(args: string[]): Promise ({ ok: true, commands: [(await import("./pikaoa-attachments-backup")).pikaoaAttachmentsBackupHelp()] }), { command: "pikaoa", commands: ["attachments-backup"] }); if (top === "platform-db") return platformDbHelp(); if (top === "secrets") return secretsHelp(); + if (top === "web-probe" && sub === "screenshot") { + return loadHelp( + async () => (await import("./web-probe")).webProbeScreenshotHelp(), + { command: "web-probe screenshot", mutation: false }, + ); + } if (top === "web-probe") return loadHelp(async () => (await import("./web-probe")).webProbeHelp(), webProbeHelpSummary()); if (top === "hwlab" && (sub === "node" || sub === "nodes") && args[2] === "web-probe") return null; if (top === "hwlab" && (sub === "node" || sub === "nodes") && args[2] === "control-plane" && args[3] === "infra") { diff --git a/scripts/src/hwlab-node-help.ts b/scripts/src/hwlab-node-help.ts index b210104f..9682f0df 100644 --- a/scripts/src/hwlab-node-help.ts +++ b/scripts/src/hwlab-node-help.ts @@ -162,6 +162,33 @@ export function hwlabNodeWebProbeHelp(): Record { }; } +export function hwlabNodeWebProbeScreenshotHelp(): Record { + return { + ok: true, + command: "web-probe screenshot", + description: "通过目标 node/lane 的受控远程浏览器采集截图;semantic origin 使用 --path,custom/local URL 直接包含完整路径。", + usage: [ + "bun scripts/cli.ts web-probe screenshot --node --lane --origin internal --path /workbench --viewport 1440x900", + "bun scripts/cli.ts web-probe screenshot --node --lane --url 'https://custom.example/workbench' --viewport 1440x900", + "bun scripts/cli.ts web-probe screenshot --node --lane --url 'http://127.0.0.1:4173/workbench' --viewport 1440x900", + ], + options: { + "--origin": "从 owning YAML 选择 internal 或 public semantic origin;可与 --path 配合。", + "--path": "附加到 semantic origin 的绝对路径;与 --url 互斥。", + "--url": "custom/local 一次性 URL,必须包含完整路径;与 --origin、--path 互斥。", + "--viewport": "截图视口,默认 1440x900。", + "--selector": "等待指定 selector 后再截图。", + "--full-page|--no-full-page": "选择整页或当前视口截图。", + }, + notes: [ + "使用 --url 时不要再传 --path;custom/local URL 已包含完整路径。", + "internal/public 运行面必须通过 owning YAML 的 --origin 选择,禁止用手写 URL 或 IP 替代。", + "该命令只采集证据,不修改浏览器能力、资源门禁或运行面。", + ], + mutation: false, + }; +} + export function hwlabNodeObservabilityHelp(): Record { return { ok: true, diff --git a/scripts/src/hwlab-node/web-probe-help.test.ts b/scripts/src/hwlab-node/web-probe-help.test.ts index df584163..3ce558da 100644 --- a/scripts/src/hwlab-node/web-probe-help.test.ts +++ b/scripts/src/hwlab-node/web-probe-help.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import { test } from "bun:test"; -import { hwlabNodeWebProbeHelp } from "../hwlab-node-help"; +import { hwlabNodeWebProbeHelp, hwlabNodeWebProbeScreenshotHelp } from "../hwlab-node-help"; test("web-probe help promotes reusable Workbench debug and product Trace commands", () => { const help = hwlabNodeWebProbeHelp(); @@ -12,3 +12,14 @@ test("web-probe help promotes reusable Workbench debug and product Trace command assert.match(notes, /validateWorkbenchTraceReadability/u); assert.match(notes, /排除隔离调试面板/u); }); + +test("web-probe screenshot scoped help explains semantic, custom and local URL usage", () => { + const help = hwlabNodeWebProbeScreenshotHelp(); + const usage = Array.isArray(help.usage) ? help.usage.join("\n") : ""; + const notes = Array.isArray(help.notes) ? help.notes.join("\n") : ""; + assert.match(usage, /--origin internal --path \/workbench/u); + assert.match(usage, /--url 'https:\/\/custom\.example\/workbench'/u); + assert.match(usage, /--url 'http:\/\/127\.0\.0\.1:4173\/workbench'/u); + assert.match(notes, /--url.*不要再传 --path/u); + assert.equal(help.mutation, false); +}); diff --git a/scripts/src/hwlab-node/web-probe.ts b/scripts/src/hwlab-node/web-probe.ts index 56d2188e..ce42f28b 100644 --- a/scripts/src/hwlab-node/web-probe.ts +++ b/scripts/src/hwlab-node/web-probe.ts @@ -11,7 +11,8 @@ import { dirname, isAbsolute, join } from "node:path"; import { repoRoot, rootPath, type Config } from "../config"; import { runCommand, type CommandResult } from "../command"; import { startJob } from "../jobs"; -import { classifySshTcpPoolFailure } from "../ssh"; +import { classifySshTcpPoolFailure, shellQuote } from "../ssh"; +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"; @@ -2200,7 +2201,18 @@ export function parseNodeWebProbeOptions(args: string[]): NodeWebProbeOptions { ])); const url = optionValue(args, "--url"); const targetPath = optionValue(args, "--path") ?? null; - if (url !== undefined && targetPath !== null) throw new Error("web-probe screenshot accepts --url or --path, not both"); + if (url !== undefined && targetPath !== null) { + const correctionCommand = `bun scripts/cli.ts web-probe ${removeOptionWithValue(args, "--path").map(shellQuote).join(" ")}`; + throw new CliInputError("web-probe screenshot 的 --url 与 --path 互斥;custom/local URL 已包含完整路径,请移除 --path", { + code: "web-probe-screenshot-url-path-mutually-exclusive", + reason: "custom-url-already-includes-full-path-remove-path", + mutation: false, + argument: "--url/--path", + usage: correctionCommand, + hint: "保留 --url 中的完整路径并移除 --path;semantic origin 调用则保留 --origin 与 --path,并移除 --url。", + correctionCommands: [correctionCommand], + }); + } const selectedOrigin = resolveOrigin(); const timeoutMs = positiveIntegerOption(args, "--timeout-ms", 30000, 120000); const waitTimeoutMs = positiveIntegerOption(args, "--wait-timeout-ms", Math.max(90000, timeoutMs + 30000), 600000); @@ -2406,6 +2418,20 @@ export function parseNodeWebProbeOptions(args: string[]): NodeWebProbeOptions { }; } +function removeOptionWithValue(args: string[], option: string): string[] { + const result: string[] = []; + for (let index = 0; index < args.length; index += 1) { + const item = args[index] ?? ""; + if (item === option) { + index += 1; + continue; + } + if (item.startsWith(`${option}=`)) continue; + result.push(item); + } + return result; +} + function nodeWebProbeOpencodeSmokeScript(input: { path: string; message: string; expectText: string | null; responseTimeoutMs: number }): string { const config = { path: input.path, diff --git a/scripts/src/output.ts b/scripts/src/output.ts index 2cc8202c..c2daacaf 100644 --- a/scripts/src/output.ts +++ b/scripts/src/output.ts @@ -21,10 +21,13 @@ export interface RenderedCliResult { export interface CliInputErrorOptions { code: string; + reason?: string; + mutation?: false; argument?: string; usage?: string | string[]; supported?: string[]; hint?: string; + correctionCommands?: string[]; } /** @@ -32,19 +35,25 @@ export interface CliInputErrorOptions { */ export class CliInputError extends Error { readonly code: string; + readonly reason?: string; + readonly mutation?: false; readonly argument?: string; readonly usage?: string | string[]; readonly supported?: string[]; readonly hint?: string; + readonly correctionCommands?: string[]; constructor(message: string, options: CliInputErrorOptions) { super(message); this.name = "CliInputError"; this.code = options.code; + this.reason = options.reason; + this.mutation = options.mutation; this.argument = options.argument; this.usage = options.usage; this.supported = options.supported; this.hint = options.hint; + this.correctionCommands = options.correctionCommands; } } @@ -113,10 +122,13 @@ function renderCliInputErrorText(command: string, error: CliInputError): string `ERROR cli-input/${compactErrorLine(error.code)}`, `command: ${compactErrorLine(command)}`, `message: ${compactErrorLine(error.message)}`, + ...(error.reason === undefined ? [] : [`reason: ${compactErrorLine(error.reason)}`]), + ...(error.mutation === undefined ? [] : [`mutation: ${String(error.mutation)}`]), ...(error.argument === undefined ? [] : [`argument: ${compactErrorLine(error.argument)}`]), ...(usage === undefined ? [] : [`usage: ${compactErrorLine(usage)}`]), ...(error.supported === undefined ? [] : [`supported: ${compactErrorLine(error.supported.join(" | "))}`]), ...(error.hint === undefined ? [] : [`hint: ${compactErrorLine(error.hint)}`]), + ...(error.correctionCommands === undefined ? [] : error.correctionCommands.map((item) => `correction: ${compactErrorLine(item)}`)), "debug: UNIDESK_CLI_DEBUG=1", ].join("\n"); } @@ -177,10 +189,13 @@ function cliInputErrorPayload(error: CliInputError): Record { kind: "cli-input", message: error.message, retryable: false, + ...(error.reason !== undefined ? { reason: error.reason } : {}), + ...(error.mutation !== undefined ? { mutation: error.mutation } : {}), ...(error.argument !== undefined ? { argument: error.argument } : {}), ...(error.usage !== undefined ? { usage: error.usage } : {}), ...(error.supported !== undefined ? { supported: error.supported } : {}), ...(error.hint !== undefined ? { hint: error.hint } : {}), + ...(error.correctionCommands !== undefined ? { correctionCommands: error.correctionCommands } : {}), ...(debug ? { debug: true, stack: error.stack ?? null } : { debug: false }), }; } diff --git a/scripts/src/web-probe.ts b/scripts/src/web-probe.ts index 34572867..0571ff9b 100644 --- a/scripts/src/web-probe.ts +++ b/scripts/src/web-probe.ts @@ -2,7 +2,7 @@ // Responsibility: Top-level web-probe CLI adapter and legacy hwlab nodes web-probe migration guard. import type { Config } from "./config"; import type { RenderedCliResult } from "./output"; -import { hwlabNodeWebProbeHelp } from "./hwlab-node-help"; +import { hwlabNodeWebProbeHelp, hwlabNodeWebProbeScreenshotHelp } from "./hwlab-node-help"; import { parseNodeWebProbeOptions } from "./hwlab-node/web-probe"; import { runNodeWebProbe } from "./hwlab-node/web-probe-observe"; import { runWebProbeProductSmoke } from "./web-probe-product-smoke"; @@ -11,7 +11,12 @@ export function webProbeHelp(): Record { return hwlabNodeWebProbeHelp(); } +export function webProbeScreenshotHelp(): Record { + return hwlabNodeWebProbeScreenshotHelp(); +} + export async function runWebProbeCommand(_config: Config, args: string[]): Promise | RenderedCliResult> { + if (args[0] === "screenshot" && (args.includes("--help") || args.includes("-h") || args[1] === "help")) return webProbeScreenshotHelp(); if (args.length === 0 || args.includes("--help") || args.includes("-h") || args[0] === "help") return webProbeHelp(); const format = args.includes("--json") ? "json" : args.includes("--yaml") ? "yaml" : "text"; const parsedArgs = args.filter((item) => item !== "--json" && item !== "--yaml");