diff --git a/.agents/skills/unidesk-webdev/SKILL.md b/.agents/skills/unidesk-webdev/SKILL.md index cee329e8..c4124f8d 100644 --- a/.agents/skills/unidesk-webdev/SKILL.md +++ b/.agents/skills/unidesk-webdev/SKILL.md @@ -52,6 +52,11 @@ description: UniDesk Web 开发与受控浏览器验证技能。用户提到 Web - 只用于已部署页面的人工 closeout; - 不进入 CI/CD gate; - 不替代 Workbench Kafka、Trace 或性能专项命令。 +- 产品级 RESTful 深链选择、保存与重载持久化 smoke: + - 使用 `web-probe product-smoke --product --target --profile --edition --algorithm `; + - 产品 target、semantic origin、认证 sourceRef、登录合同、selector、保存请求、视口和证据上限只从产品 owning YAML 读取; + - 结果只保留有界 DOM/网络摘要、report SHA、screenshot SHA、presence 与 fingerprint,禁止输出账号、密码或 cookie; + - disabled target 必须拒绝执行,不得用 CLI URL 或凭据参数绕过。 - 沉淀后的 typed command 必须复用 observer start 时选中的 semantic origin;不得在 command 内嵌 URL/IP,也不得为 internal/public 各复制一份 command。 - Workbench 性能卡顿调查优先用 `observe command --type performanceCapture` + `observe analyze` + `observe collect --view performance-summary`;`performance-summary` 必须保持首屏 bounded,只输出 LongTask/LoAF/event-loop gap 摘要、CPU profile hotspots/stacks 和 sourceFiles,下钻完整 profile/report 时再显式读取 artifact。 - Project Management/MDTODO closeout 必须区分 `control` 页和被动 `observer` 页:显式 `observe command` 的 command result、control URL 和对应截图是用户动作证据;observer 周期刷新或 stop 后根路由空态只能作为对照信号,不能覆盖 command result。涉及报告的验收要同时记录 `reportPreviewVisible`、`reportFullscreenVisible`、报告 deep link 和截图 SHA。 diff --git a/.agents/skills/unidesk-webdev/references/browser-smoke.md b/.agents/skills/unidesk-webdev/references/browser-smoke.md index 6ad2d825..d7a980fb 100644 --- a/.agents/skills/unidesk-webdev/references/browser-smoke.md +++ b/.agents/skills/unidesk-webdev/references/browser-smoke.md @@ -12,6 +12,11 @@ - 先扩展 `web-probe observe command` 或领域 typed command; - 认证、Secret sourceRef、目标解析、截图、artifact 和脱敏继续复用现有合同; - 不新增第二浏览器 runner、wrapper 或状态目录。 +- 产品配置项的真实选择与保存: + - 使用 `web-probe product-smoke` 直接打开 owning YAML 声明的 RESTful 深链; + - 选择后必须观察页面成功状态,发生变更时还要观察声明的保存请求; + - 重载同一深链并确认目标选项持久化; + - closeout 记录有界 DOM、网络 method/path/status、report SHA 和 screenshot SHA。 - xterm 使用 canvas renderer 时: - 不得把 `.xterm-row` 或 `textContent` 为空判定为终端无输出; - 视觉布局通过 `web-probe` 截图验证; diff --git a/config/selfmedia.yaml b/config/selfmedia.yaml index e1958a82..9e22d8a9 100644 --- a/config/selfmedia.yaml +++ b/config/selfmedia.yaml @@ -256,6 +256,58 @@ delivery: - --last sourcePath: /app skillsPath: /home/codex/.codex/skills + webProbe: + enabled: true + productId: selfmedia + runner: + node: NC01 + lane: v03 + origin: + name: public + mode: public + baseUrl: https://media-dev.hwpod.com + browserProxyMode: auto + authentication: + mode: json-post-session-cookie + path: /api/v1/auth/session + credentials: + configRef: config/secrets-distribution.yaml + targetId: selfmedia-nc01 + secretName: selfmedia-access + usernameTargetKey: admin-username + passwordTargetKey: admin-password + requestFields: + username: username + password: password + cookieNames: + - selfmedia_operator + defaultSmokeProfile: voice-algorithm + smokeProfiles: + voice-algorithm: + kind: select-and-save + routeTemplate: /editions/{editionId}/resources/audio + readySelector: .production-settings + identitySelector: select[aria-label="选择资讯版本"] + selectSelector: select[aria-label="语音算法"] + saveButtonName: 保存设置 + statusSelector: .production-settings__status + successTexts: + - 本期设置已保存 + - 设置没有变化 + saveRequest: + method: PUT + pathTemplate: /api/v1/editions/{editionId}/resources/metadata + screenshotName: selfmedia-voice-algorithm.png + viewport: + width: 1920 + height: 1080 + navigationTimeoutMs: 30000 + settleMs: 500 + commandTimeoutSeconds: 120 + outputLimits: + options: 20 + network: 12 + failures: 12 NC01_PRODUCTION: extends: NC01 node: NC01 @@ -282,6 +334,8 @@ delivery: author: name: SelfMedia NC01 Production CI email: selfmedia-production-nc01-ci@unidesk.local + webProbe: + enabled: false deployment: metadata: name: selfmedia-production-nc01 diff --git a/scripts/src/help.ts b/scripts/src/help.ts index 7951b41d..591d6d2f 100644 --- a/scripts/src/help.ts +++ b/scripts/src/help.ts @@ -948,11 +948,12 @@ function hwlabNodeHelpSummary(): unknown { function webProbeHelpSummary(): unknown { return { - command: "web-probe run|script|console-verify|observe|sentinel --node --lane ", + command: "web-probe run|script|console-verify|product-smoke|observe|sentinel", output: "json", usage: [ "bun scripts/cli.ts web-probe run --node NC01 --lane v03 --wait-messages-ms 1000", "bun scripts/cli.ts web-probe console-verify --node --lane --origin internal --profile ", + "bun scripts/cli.ts web-probe product-smoke --product selfmedia --target NC01 --edition --algorithm ", "bun scripts/cli.ts web-probe observe start --node NC01 --lane v03 --target-path /workbench --sample-interval-ms 5000", "bun scripts/cli.ts web-probe observe collect webobs-xxxx --view turn-summary", "bun scripts/cli.ts web-probe observe collect webobs-xxxx --view timeline --turn 1", diff --git a/scripts/src/hwlab-node-help.ts b/scripts/src/hwlab-node-help.ts index 9c721358..037b04a5 100644 --- a/scripts/src/hwlab-node-help.ts +++ b/scripts/src/hwlab-node-help.ts @@ -88,6 +88,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 product-smoke --product selfmedia --target NC01 --profile voice-algorithm --edition --algorithm ", "bun scripts/cli.ts web-probe script --node --lane --origin internal --script-file .state/probes/workbench.mjs", "bun scripts/cli.ts web-probe screenshot --node --lane --origin internal --path /workbench --viewport 1440x900", "bun scripts/cli.ts web-probe observe start --node --lane --origin internal --target-path /workbench --sample-interval-ms 5000", @@ -130,6 +131,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.", + "product-smoke": "按产品 owning YAML 执行受控登录、RESTful 深链交互、保存与重载持久化验证;当前提供 SelfMedia 语音算法选择闭环。", 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.", observe: "Start, inspect, control, stop, collect, analyze, and garbage-collect raw artifacts for long-running observers.", @@ -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 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.", "After observe start, prefer observe status|command|stop|collect|analyze instead of repeating --node/--lane/--state-dir.", diff --git a/scripts/src/hwlab-node-web-probe-runner-source.ts b/scripts/src/hwlab-node-web-probe-runner-source.ts index 13d11855..70ea894e 100644 --- a/scripts/src/hwlab-node-web-probe-runner-source.ts +++ b/scripts/src/hwlab-node-web-probe-runner-source.ts @@ -11,8 +11,12 @@ import { pathToFileURL } from "node:url"; const startedAtMs = Date.now(); const startedAt = new Date(startedAtMs).toISOString(); const baseUrl = normalizeBaseUrl(process.env.HWLAB_WEB_BASE_URL); -const username = process.env.HWLAB_WEB_USER || "admin"; +const username = process.env.HWLAB_WEB_USER || ""; const password = process.env.HWLAB_WEB_PASS || ""; +const authPath = requiredAbsolutePath(process.env.UNIDESK_WEB_PROBE_AUTH_PATH, "UNIDESK_WEB_PROBE_AUTH_PATH"); +const authUsernameField = requiredIdentifier(process.env.UNIDESK_WEB_PROBE_AUTH_USERNAME_FIELD, "UNIDESK_WEB_PROBE_AUTH_USERNAME_FIELD"); +const authPasswordField = requiredIdentifier(process.env.UNIDESK_WEB_PROBE_AUTH_PASSWORD_FIELD, "UNIDESK_WEB_PROBE_AUTH_PASSWORD_FIELD"); +const authCookieNames = requiredStringArray(process.env.UNIDESK_WEB_PROBE_AUTH_COOKIE_NAMES, "UNIDESK_WEB_PROBE_AUTH_COOKIE_NAMES"); const runDir = path.resolve(process.env.UNIDESK_WEB_PROBE_RUN_DIR || ".state/web-probe-script/run-manual"); const userScript = path.resolve(process.env.UNIDESK_WEB_PROBE_USER_SCRIPT || path.join(runDir, "user-script.mjs")); const timeoutMs = positiveInteger(process.env.UNIDESK_WEB_PROBE_TIMEOUT_MS, 30000); @@ -36,6 +40,7 @@ let cookieSecrets = []; let userScriptSha256 = null; try { + if (!username) throw new Error("missing HWLAB_WEB_USER"); if (!password) throw new Error("missing HWLAB_WEB_PASS"); await mkdir(runDir, { recursive: true, mode: 0o700 }); userScriptSha256 = await sha256File(userScript).catch(() => null); @@ -120,7 +125,7 @@ async function authenticate(browserContext) { } async function authenticateWithApiRetries(browserContext) { - const loginUrl = new URL("/auth/login", baseUrl).toString(); + const loginUrl = new URL(authPath, baseUrl).toString(); const attempts = []; const maxAttempts = 5; const initialDelayMs = 250; @@ -129,7 +134,7 @@ async function authenticateWithApiRetries(browserContext) { const retryDelayMs = attempt < maxAttempts ? Math.min(maxDelayMs, initialDelayMs * (2 ** (attempt - 1))) : 0; try { const response = await browserContext.request.post(loginUrl, { - data: { username, password }, + data: { [authUsernameField]: username, [authPasswordField]: password }, headers: { accept: "application/json", "content-type": "application/json" }, timeout: Math.min(timeoutMs, 12000), }); @@ -153,7 +158,7 @@ async function authenticateWithApiRetries(browserContext) { return { ok: true, method: "api", - loginUrl: new URL("/auth/login", baseUrl).pathname, + loginUrl: authPath, status: response.status(), statusText: response.statusText(), cookiePresent: true, @@ -191,7 +196,7 @@ async function authenticateWithApiRetries(browserContext) { return { ok: false, method: "api", - loginUrl: new URL("/auth/login", baseUrl).pathname, + loginUrl: authPath, status: typeof last?.status === "number" ? last.status : 0, statusText: typeof last?.statusText === "string" ? last.statusText : "api-login-failed", cookiePresent: cookieState.cookiePresent, @@ -1830,7 +1835,7 @@ async function readAuthCookieState(browserContext) { cookieSecrets = cookies.map((cookie) => cookie.value).filter(Boolean); const cookieNames = cookies.map((cookie) => cookie.name).sort(); return { - cookiePresent: cookieNames.includes("hwlab_session"), + cookiePresent: authCookieNames.some((name) => cookieNames.includes(name)), cookieNames, }; } @@ -1889,12 +1894,12 @@ function publicAuth(value) { commanderAction: ok ? null : retryExhausted - ? "auth retry exhausted; stop this web-probe run and inspect target /auth/login upstream before retrying" + ? "auth retry exhausted; stop this web-probe run and inspect the configured login path before retrying" : retryable - ? "retry same web-probe command after short backoff; inspect target /auth/login and Cloud Web/API rollout if repeated" - : "inspect bootstrap admin credential source and target user state", + ? "retry same web-probe command after short backoff; inspect the configured login path and target rollout if repeated" + : "inspect bootstrap admin credential source and target user state", fingerprint: authSummaryFingerprint(value), - username, + usernamePresent: username.length > 0, valuesRedacted: true, }; } @@ -1949,6 +1954,33 @@ function requiredEnum(raw, allowed) { return raw; } +function requiredAbsolutePath(raw, name) { + const value = String(raw || ""); + if (!value.startsWith("/") || value.includes("\0") || value.includes("?") || value.includes("#")) { + throw new Error("missing or invalid " + name); + } + return value; +} + +function requiredIdentifier(raw, name) { + const value = String(raw || ""); + if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(value)) throw new Error("missing or invalid " + name); + return value; +} + +function requiredStringArray(raw, name) { + let parsed; + try { + parsed = JSON.parse(String(raw || "")); + } catch { + throw new Error("missing or invalid " + name); + } + if (!Array.isArray(parsed) || parsed.length === 0 || parsed.some((item) => typeof item !== "string" || !/^[A-Za-z0-9._-]+$/u.test(item))) { + throw new Error("missing or invalid " + name); + } + return [...new Set(parsed)]; +} + function artifactPath(name) { const safe = String(name || "artifact") .replace(/[^A-Za-z0-9._-]/gu, "_") diff --git a/scripts/src/hwlab-node/web-observe-scripts.ts b/scripts/src/hwlab-node/web-observe-scripts.ts index 841d7852..70d2d88c 100644 --- a/scripts/src/hwlab-node/web-observe-scripts.ts +++ b/scripts/src/hwlab-node/web-observe-scripts.ts @@ -38,6 +38,25 @@ import { renderWebProbeScriptResult } from "./web-observe-render"; import { nodeWebProbeHostProxyEnv } from "./web-probe-observe"; import { webProbeOriginSummary } from "./web-probe-origin"; +export interface WebProbeScriptAuthenticationProfile { + readonly loginPath: string; + readonly usernameField: string; + readonly passwordField: string; + readonly cookieNames: readonly string[]; +} + +export interface WebProbeScriptCredentialMaterial { + readonly username: string; + readonly password: string; +} + +const hwlabWebProbeAuthentication: WebProbeScriptAuthenticationProfile = { + loginPath: "/auth/login", + usernameField: "username", + passwordField: "password", + cookieNames: ["hwlab_session"], +}; + export function nodeWebObserveStatusNodeScript(tailLines: number, node: string, lane: string, commandId: string | null = null): string { return `node -e ${shellQuote(` const fs=require('fs'),path=require('path'); @@ -545,13 +564,32 @@ export function runNodeWebProbeScript( secretSpec: RuntimeSecretSpec, material: BootstrapAdminPasswordMaterial, credential: Record, +): Record { + return runManagedNodeWebProbeScript( + options, + spec, + { + username: material.username ?? secretSpec.bootstrapAdminUsername, + password: material.password ?? "", + }, + credential, + hwlabWebProbeAuthentication, + ); +} + +export function runManagedNodeWebProbeScript( + options: NodeWebProbeScriptOptions, + spec: HwlabRuntimeLaneSpec, + material: WebProbeScriptCredentialMaterial, + credential: Record, + authentication: WebProbeScriptAuthenticationProfile, ): Record { const commandLabel = options.commandLabel ?? `web-probe script --node ${options.node} --lane ${options.lane}${webProbeSemanticOriginArgs(options)}`; const commandGovernance = webProbeScriptCommandGovernance(options); const webProbeProxy = nodeWebProbeHostProxyEnv(spec, options.browserProxyMode); const memoryGuard = runWebProbeMemoryGuard(spec, "manual-start"); if (memoryGuard.status !== "allowed") return memoryGuard; - const script = nodeWebProbeScriptRemoteShell(options, spec, secretSpec, material.username ?? secretSpec.bootstrapAdminUsername, material.password ?? "", webProbeProxy, spec.webProbe?.playwrightBrowsersPath); + const script = nodeWebProbeScriptRemoteShell(options, spec, material, authentication, webProbeProxy, spec.webProbe?.playwrightBrowsersPath); const result = runTransWorkspaceStdinScript(options.node, spec.workspace, script, options.commandTimeoutSeconds); const commandTimedOut = result.timedOut || result.exitCode === 124; const runPaths = webProbeScriptRunPathsFromStderr(result.stderr); @@ -754,7 +792,14 @@ function webProbeSemanticOriginArgs(options: Pick>; + readonly summary: { + readonly configRef: string; + readonly targetId: string; + readonly secretName: string; + readonly keys: readonly { + readonly sourceRef: string; + readonly sourceKey: string; + readonly targetKey: string; + readonly present: boolean; + }[]; + readonly fingerprint: string; + readonly valuesPrinted: false; + }; +} + export function secretsHelp(): Record { return { command: "secrets plan|sync|status", @@ -1015,6 +1032,49 @@ export function fingerprintSecretValues(values: Record, keys: st return fingerprintValues(values, keys); } +export function readDeclaredSecretValues(params: { + configPath: string; + targetId: string; + secretName: string; + targetKeys: readonly string[]; +}): DeclaredSecretValuesMaterial { + const config = readSecretDistributionConfig(params.configPath); + const secret = config.kubernetesSecrets.find((item) => item.targetId === params.targetId && item.name === params.secretName); + if (secret === undefined) { + throw new Error(`${config.configPath} does not declare kubernetesSecrets name=${params.secretName} targetId=${params.targetId}`); + } + const mappings = params.targetKeys.map((targetKey) => { + const mapping = secret.data.find((item) => item.targetKey === targetKey); + if (mapping === undefined) throw new Error(`${config.configPath}.kubernetesSecrets.${params.secretName} does not map targetKey ${targetKey}`); + return mapping; + }); + const sources = inspectSources(config, false, new Set(mappings.map((item) => item.sourceRef))); + const values: Record = {}; + const keys = mappings.map((mapping) => { + const value = sources.materials.get(mapping.sourceRef)?.values[mapping.sourceKey]; + const present = typeof value === "string" && value.length > 0; + if (!present) throw new Error(`${mapping.sourceRef} is missing required key ${mapping.sourceKey}`); + values[mapping.targetKey] = value; + return { + sourceRef: mapping.sourceRef, + sourceKey: mapping.sourceKey, + targetKey: mapping.targetKey, + present, + }; + }); + return { + values, + summary: { + configRef: config.configPath, + targetId: params.targetId, + secretName: params.secretName, + keys, + fingerprint: fingerprintValues(values, [...params.targetKeys]), + valuesPrinted: false, + }, + }; +} + function writeEnvFile(path: string, values: Record): void { mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); const lines = Object.keys(values).sort().map((key) => `${key}=${quoteEnv(values[key])}`); diff --git a/scripts/src/selfmedia-config.ts b/scripts/src/selfmedia-config.ts index 9011150f..3f7fb95c 100644 --- a/scripts/src/selfmedia-config.ts +++ b/scripts/src/selfmedia-config.ts @@ -57,6 +57,65 @@ export interface SelfMediaDeliveryTarget { readonly credentialUsername: string; }; readonly deployment: Record; + readonly webProbe: SelfMediaWebProbeSpec; +} + +export interface SelfMediaWebProbeSpec { + readonly enabled: boolean; + readonly productId: string; + readonly runner: { + readonly node: string; + readonly lane: string; + }; + readonly origin: { + readonly name: "public"; + readonly mode: "public"; + readonly baseUrl: string; + readonly browserProxyMode: "auto" | "direct"; + }; + readonly authentication: { + readonly mode: "json-post-session-cookie"; + readonly path: string; + readonly credentials: { + readonly configRef: string; + readonly targetId: string; + readonly secretName: string; + readonly usernameTargetKey: string; + readonly passwordTargetKey: string; + }; + readonly requestFields: { + readonly username: string; + readonly password: string; + }; + readonly cookieNames: readonly string[]; + }; + readonly defaultSmokeProfile: string; + readonly smokeProfiles: Readonly>; +} + +export interface SelfMediaWebProbeSmokeProfileSpec { + readonly kind: "select-and-save"; + readonly routeTemplate: string; + readonly readySelector: string; + readonly identitySelector: string; + readonly selectSelector: string; + readonly saveButtonName: string; + readonly statusSelector: string; + readonly successTexts: readonly string[]; + readonly saveRequest: { + readonly method: "PUT" | "POST" | "PATCH"; + readonly pathTemplate: string; + }; + readonly screenshotName: string; + readonly viewport: { readonly width: number; readonly height: number }; + readonly navigationTimeoutMs: number; + readonly settleMs: number; + readonly commandTimeoutSeconds: number; + readonly outputLimits: { + readonly options: number; + readonly network: number; + readonly failures: number; + }; } export interface SelfMediaCutoverTarget { @@ -167,6 +226,7 @@ function parseDeliveryTarget(id: string, value: Record): SelfMe const proxy = record(build.proxy, `${path}.build.proxy`); const gitops = record(value.gitops, `${path}.gitops`); const deployment = record(value.deployment, `${path}.deployment`); + const webProbe = parseSelfMediaWebProbe(value.webProbe, `${path}.webProbe`); const target = record(deployment.target, `${path}.deployment.target`); const publicExposure = record(deployment.publicExposure, `${path}.deployment.publicExposure`); const noProxy = stringArray(proxy.noProxy, `${path}.build.proxy.noProxy`); @@ -252,6 +312,96 @@ function parseDeliveryTarget(id: string, value: Record): SelfMe credentialUsername: text(gitops.credentialUsername, `${path}.gitops.credentialUsername`), }, deployment: structuredClone(deployment), + webProbe, + }; +} + +function parseSelfMediaWebProbe(value: unknown, path: string): SelfMediaWebProbeSpec { + const raw = record(value, path); + const runner = record(raw.runner, `${path}.runner`); + const origin = record(raw.origin, `${path}.origin`); + const authentication = record(raw.authentication, `${path}.authentication`); + const credentials = record(authentication.credentials, `${path}.authentication.credentials`); + const requestFields = record(authentication.requestFields, `${path}.authentication.requestFields`); + const profiles = record(raw.smokeProfiles, `${path}.smokeProfiles`); + const smokeProfiles = Object.fromEntries(Object.entries(profiles).map(([id, profile]) => [ + simpleId(id, `${path}.smokeProfiles.${id}`), + parseSelfMediaWebProbeProfile(profile, `${path}.smokeProfiles.${id}`), + ])); + const defaultSmokeProfile = simpleId(text(raw.defaultSmokeProfile, `${path}.defaultSmokeProfile`), `${path}.defaultSmokeProfile`); + if (smokeProfiles[defaultSmokeProfile] === undefined) throw new Error(`${selfMediaConfigRef}.${path}.defaultSmokeProfile must reference smokeProfiles`); + return { + enabled: boolean(raw.enabled, `${path}.enabled`), + productId: simpleId(text(raw.productId, `${path}.productId`), `${path}.productId`), + runner: { + node: simpleId(text(runner.node, `${path}.runner.node`), `${path}.runner.node`), + lane: simpleId(text(runner.lane, `${path}.runner.lane`), `${path}.runner.lane`), + }, + origin: { + name: enumValue(origin.name, `${path}.origin.name`, ["public"] as const), + mode: enumValue(origin.mode, `${path}.origin.mode`, ["public"] as const), + baseUrl: url(origin.baseUrl, `${path}.origin.baseUrl`), + browserProxyMode: enumValue(origin.browserProxyMode, `${path}.origin.browserProxyMode`, ["auto", "direct"] as const), + }, + authentication: { + mode: enumValue(authentication.mode, `${path}.authentication.mode`, ["json-post-session-cookie"] as const), + path: httpPath(authentication.path, `${path}.authentication.path`), + credentials: { + configRef: relativePath(credentials.configRef, `${path}.authentication.credentials.configRef`), + targetId: simpleId(text(credentials.targetId, `${path}.authentication.credentials.targetId`), `${path}.authentication.credentials.targetId`), + secretName: simpleId(text(credentials.secretName, `${path}.authentication.credentials.secretName`), `${path}.authentication.credentials.secretName`), + usernameTargetKey: secretKey(credentials.usernameTargetKey, `${path}.authentication.credentials.usernameTargetKey`), + passwordTargetKey: secretKey(credentials.passwordTargetKey, `${path}.authentication.credentials.passwordTargetKey`), + }, + requestFields: { + username: jsonField(requestFields.username, `${path}.authentication.requestFields.username`), + password: jsonField(requestFields.password, `${path}.authentication.requestFields.password`), + }, + cookieNames: stringArray(authentication.cookieNames, `${path}.authentication.cookieNames`).map((item, index) => secretKey(item, `${path}.authentication.cookieNames[${index}]`)), + }, + defaultSmokeProfile, + smokeProfiles, + }; +} + +function parseSelfMediaWebProbeProfile(value: unknown, path: string): SelfMediaWebProbeSmokeProfileSpec { + const raw = record(value, path); + const saveRequest = record(raw.saveRequest, `${path}.saveRequest`); + const viewport = record(raw.viewport, `${path}.viewport`); + const limits = record(raw.outputLimits, `${path}.outputLimits`); + const routeTemplate = httpPathTemplate(raw.routeTemplate, `${path}.routeTemplate`); + const requestTemplate = httpPathTemplate(saveRequest.pathTemplate, `${path}.saveRequest.pathTemplate`); + if (!routeTemplate.includes("{editionId}") || !requestTemplate.includes("{editionId}")) { + throw new Error(`${selfMediaConfigRef}.${path} route and request templates must contain {editionId}`); + } + const screenshotName = text(raw.screenshotName, `${path}.screenshotName`); + if (!/^[A-Za-z0-9._-]+\.png$/u.test(screenshotName)) throw new Error(`${selfMediaConfigRef}.${path}.screenshotName must be a safe PNG filename`); + return { + kind: enumValue(raw.kind, `${path}.kind`, ["select-and-save"] as const), + routeTemplate, + readySelector: boundedText(raw.readySelector, `${path}.readySelector`, 500), + identitySelector: boundedText(raw.identitySelector, `${path}.identitySelector`, 500), + selectSelector: boundedText(raw.selectSelector, `${path}.selectSelector`, 500), + saveButtonName: boundedText(raw.saveButtonName, `${path}.saveButtonName`, 100), + statusSelector: boundedText(raw.statusSelector, `${path}.statusSelector`, 500), + successTexts: stringArray(raw.successTexts, `${path}.successTexts`).map((item, index) => boundedText(item, `${path}.successTexts[${index}]`, 200)), + saveRequest: { + method: enumValue(saveRequest.method, `${path}.saveRequest.method`, ["PUT", "POST", "PATCH"] as const), + pathTemplate: requestTemplate, + }, + screenshotName, + viewport: { + width: positiveInteger(viewport.width, `${path}.viewport.width`), + height: positiveInteger(viewport.height, `${path}.viewport.height`), + }, + navigationTimeoutMs: positiveInteger(raw.navigationTimeoutMs, `${path}.navigationTimeoutMs`), + settleMs: positiveInteger(raw.settleMs, `${path}.settleMs`), + commandTimeoutSeconds: positiveInteger(raw.commandTimeoutSeconds, `${path}.commandTimeoutSeconds`), + outputLimits: { + options: positiveInteger(limits.options, `${path}.outputLimits.options`), + network: positiveInteger(limits.network, `${path}.outputLimits.network`), + failures: positiveInteger(limits.failures, `${path}.outputLimits.failures`), + }, }; } @@ -361,11 +511,52 @@ function text(value: unknown, path: string): string { return value; } +function boundedText(value: unknown, path: string, max: number): string { + const result = text(value, path); + if (result.length > max) throw new Error(`${selfMediaConfigRef}.${path} must be at most ${max} chars`); + return result; +} + +function simpleId(value: string, path: string): string { + if (!/^[A-Za-z0-9._-]+$/u.test(value) || value.length > 100) throw new Error(`${selfMediaConfigRef}.${path} must be a simple id up to 100 chars`); + return value; +} + +function jsonField(value: unknown, path: string): string { + const result = text(value, path); + if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(result)) throw new Error(`${selfMediaConfigRef}.${path} must be a JSON field name`); + return result; +} + +function secretKey(value: unknown, path: string): string { + const result = text(value, path); + if (!/^[A-Za-z0-9._-]+$/u.test(result)) throw new Error(`${selfMediaConfigRef}.${path} must be a Secret key`); + return result; +} + +function httpPath(value: unknown, path: string): string { + const result = text(value, path); + if (!result.startsWith("/") || result.includes("\0") || result.includes("?") || result.includes("#")) { + throw new Error(`${selfMediaConfigRef}.${path} must be an absolute HTTP path without query or fragment`); + } + return result; +} + +function httpPathTemplate(value: unknown, path: string): string { + return httpPath(value, path); +} + function integer(value: unknown, path: string): number { if (!Number.isInteger(value)) throw new Error(`${selfMediaConfigRef}.${path} must be an integer`); return value as number; } +function positiveInteger(value: unknown, path: string): number { + const result = integer(value, path); + if (result <= 0) throw new Error(`${selfMediaConfigRef}.${path} must be positive`); + return result; +} + function boolean(value: unknown, path: string): boolean { if (typeof value !== "boolean") throw new Error(`${selfMediaConfigRef}.${path} must be a boolean`); return value; diff --git a/scripts/src/web-probe-product-smoke.test.ts b/scripts/src/web-probe-product-smoke.test.ts new file mode 100644 index 00000000..5db365d3 --- /dev/null +++ b/scripts/src/web-probe-product-smoke.test.ts @@ -0,0 +1,91 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { test } from "bun:test"; + +import { rootPath } from "./config"; +import { readDeclaredSecretValues } from "./secrets"; +import { resolveSelfMediaDeliveryTarget } from "./selfmedia-config"; +import { webProbeScriptCommandGovernance } from "./hwlab-node/web-observe-scripts"; +import { + parseWebProbeProductSmokeOptions, + resolveWebProbeProductSmoke, +} from "./web-probe-product-smoke"; + +const fixture = { + product: "selfmedia", + target: "NC01", + profile: "voice-algorithm", + edition: "embedded-ai-chipmakers-20260714", + algorithm: "cosyvoice3", +} as const; + +test("SelfMedia 产品 smoke 完全由 owning YAML 解析,生产目标保持禁用", () => { + const development = resolveSelfMediaDeliveryTarget("NC01"); + const production = resolveSelfMediaDeliveryTarget("NC01_PRODUCTION"); + assert.equal(development.webProbe.enabled, true); + assert.equal(development.webProbe.origin.baseUrl, "https://media-dev.hwpod.com"); + assert.equal(development.webProbe.authentication.path, "/api/v1/auth/session"); + assert.equal(development.webProbe.smokeProfiles["voice-algorithm"]?.kind, "select-and-save"); + assert.equal(production.webProbe.enabled, false); + assert.equal(production.gitops.credentialSecretName, "pac-gitea-selfmedia-nc01"); + assert.throws(() => resolveWebProbeProductSmoke({ ...fixture, target: "NC01_PRODUCTION" }), /enabled=false/u); +}); + +test("product-smoke 参数是单一 typed command,拒绝 URL 和未知参数", () => { + assert.deepEqual(parseWebProbeProductSmokeOptions([ + "--product", fixture.product, + "--target", fixture.target, + "--profile", fixture.profile, + "--edition", fixture.edition, + "--algorithm", fixture.algorithm, + ]), fixture); + assert.throws(() => parseWebProbeProductSmokeOptions([ + "--product", fixture.product, + "--target", fixture.target, + "--edition", fixture.edition, + "--algorithm", fixture.algorithm, + "--url", "https://example.invalid", + ]), /不支持参数 --url/u); +}); + +test("生成脚本可解析并复用 managed web-probe,无临时脚本警告", () => { + const resolved = resolveWebProbeProductSmoke(fixture); + assert.equal(resolved.scriptOptions.action, "script"); + assert.equal(resolved.scriptOptions.scriptSource.kind, "generated"); + assert.equal(resolved.scriptOptions.scriptSource.path, "builtin:product-smoke/selfmedia/voice-algorithm"); + assert.equal(resolved.scriptOptions.suppressAdHocWarning, true); + assert.doesNotThrow(() => new Function(resolved.scriptOptions.scriptText.replace("export default ", ""))); + assert.match(resolved.scriptOptions.scriptText, /reloadStable/u); + assert.match(resolved.scriptOptions.scriptText, /reportSha256/u); + assert.doesNotMatch(resolved.commandLabel, /https?:\/\//u); + const governance = webProbeScriptCommandGovernance(resolved.scriptOptions); + assert.deepEqual(governance.warnings, []); + assert.equal(governance.commandPromotionHint.repoOwnedTypedCommand, true); +}); + +test("受控 Secret 读取只公开 sourceRef、presence 与 fingerprint", () => { + const target = resolveSelfMediaDeliveryTarget("NC01"); + const credentials = target.webProbe.authentication.credentials; + const material = readDeclaredSecretValues({ + configPath: credentials.configRef, + targetId: credentials.targetId, + secretName: credentials.secretName, + targetKeys: [credentials.usernameTargetKey, credentials.passwordTargetKey], + }); + assert.equal(material.summary.valuesPrinted, false); + assert.ok(material.summary.keys.every((item) => item.present)); + assert.match(material.summary.fingerprint, /^sha256:/u); + const publicText = JSON.stringify(material.summary); + for (const value of Object.values(material.values)) assert.equal(publicText.includes(JSON.stringify(value)), false); +}); + +test("managed runner 的登录路径、字段和 cookie 都来自显式认证 profile", () => { + const runner = readFileSync(rootPath("scripts/src/hwlab-node-web-probe-runner-source.ts"), "utf8"); + const managed = readFileSync(rootPath("scripts/src/hwlab-node/web-observe-scripts.ts"), "utf8"); + assert.match(runner, /new URL\(authPath, baseUrl\)/u); + assert.match(runner, /\[authUsernameField\]/u); + assert.match(runner, /\[authPasswordField\]/u); + assert.match(runner, /authCookieNames\.some/u); + assert.match(managed, /UNIDESK_WEB_PROBE_AUTH_PATH/u); + assert.match(managed, /runManagedNodeWebProbeScript/u); +}); diff --git a/scripts/src/web-probe-product-smoke.ts b/scripts/src/web-probe-product-smoke.ts new file mode 100644 index 00000000..2d70ccae --- /dev/null +++ b/scripts/src/web-probe-product-smoke.ts @@ -0,0 +1,307 @@ +import { createHash } from "node:crypto"; + +import { hwlabRuntimeLaneSpecForNode, isHwlabRuntimeLane } from "./hwlab-node-lanes"; +import { readDeclaredSecretValues } from "./secrets"; +import { + resolveSelfMediaDeliveryTarget, + selfMediaDeliveryConfigRef, + type SelfMediaDeliveryTarget, + type SelfMediaWebProbeSmokeProfileSpec, +} from "./selfmedia-config"; +import type { NodeWebProbeScriptOptions } from "./hwlab-node/entry"; +import { runManagedNodeWebProbeScript } from "./hwlab-node/web-observe-scripts"; + +export interface WebProbeProductSmokeOptions { + readonly product: string; + readonly target: string; + readonly profile: string | null; + readonly edition: string; + readonly algorithm: string; +} + +interface ResolvedProductSmoke { + readonly target: SelfMediaDeliveryTarget; + readonly profileName: string; + readonly profile: SelfMediaWebProbeSmokeProfileSpec; + readonly configRef: string; + readonly commandLabel: string; + readonly scriptOptions: NodeWebProbeScriptOptions; +} + +export function parseWebProbeProductSmokeOptions(args: string[]): WebProbeProductSmokeOptions { + const values = new Map(); + const known = new Set(["--product", "--target", "--profile", "--edition", "--algorithm"]); + for (let index = 0; index < args.length; index += 1) { + const item = args[index]; + 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}`); + const value = equals > 0 ? item.slice(equals + 1) : args[++index]; + if (value === undefined || value.startsWith("--")) throw new Error(`${key} 需要一个值`); + if (values.has(key)) throw new Error(`${key} 不能重复`); + values.set(key, simpleId(value, key)); + } + return { + product: requiredValue(values, "--product"), + target: requiredValue(values, "--target"), + profile: values.get("--profile") ?? null, + edition: requiredValue(values, "--edition"), + algorithm: requiredValue(values, "--algorithm"), + }; +} + +export function resolveWebProbeProductSmoke(options: WebProbeProductSmokeOptions): ResolvedProductSmoke { + if (options.product !== "selfmedia") throw new Error(`未知 --product ${options.product};当前声明的产品为 selfmedia`); + const target = resolveSelfMediaDeliveryTarget(options.target); + const webProbe = target.webProbe; + if (webProbe.productId !== options.product) { + throw new Error(`${selfMediaDeliveryConfigRef(target.id)}.webProbe.productId 与 --product 不一致`); + } + if (!webProbe.enabled) throw new Error(`${selfMediaDeliveryConfigRef(target.id)}.webProbe.enabled=false,禁止执行产品 Web smoke`); + const profileName = options.profile ?? webProbe.defaultSmokeProfile; + const profile = webProbe.smokeProfiles[profileName]; + if (profile === undefined) { + throw new Error(`未知 --profile ${profileName};可用项:${Object.keys(webProbe.smokeProfiles).sort().join(", ")}`); + } + if (!isHwlabRuntimeLane(webProbe.runner.lane)) throw new Error(`Web smoke runner lane 未在 HWLAB YAML 中声明:${webProbe.runner.lane}`); + hwlabRuntimeLaneSpecForNode(webProbe.runner.lane, webProbe.runner.node); + + const configRef = `${selfMediaDeliveryConfigRef(target.id)}.webProbe.smokeProfiles.${profileName}`; + const commandLabel = [ + "web-probe product-smoke", + `--product ${options.product}`, + `--target ${target.id}`, + `--profile ${profileName}`, + `--edition ${options.edition}`, + `--algorithm ${options.algorithm}`, + ].join(" "); + const scriptText = selfMediaVoiceAlgorithmSmokeScript(options.edition, options.algorithm, profileName, profile, configRef); + const scriptOptions: NodeWebProbeScriptOptions = { + action: "script", + node: webProbe.runner.node, + lane: webProbe.runner.lane, + url: webProbe.origin.baseUrl, + originName: webProbe.origin.name, + originMode: webProbe.origin.mode, + originConfigPath: `${selfMediaDeliveryConfigRef(target.id)}.webProbe.origin`, + browserProxyMode: webProbe.origin.browserProxyMode, + browserProxyModeSource: "yaml-origin", + timeoutMs: profile.navigationTimeoutMs, + viewport: `${profile.viewport.width}x${profile.viewport.height}`, + commandTimeoutSeconds: profile.commandTimeoutSeconds, + scriptText, + commandLabel, + suppressAdHocWarning: true, + generatedHints: [ + "该命令由 SelfMedia owning YAML 驱动,并复用受控认证、远端浏览器、内存门禁及证据恢复链。", + "再次验证同一功能时复用 product-smoke,不要复制生成的浏览器脚本。", + ], + generatedPreferredCommands: { rerun: commandLabel }, + scriptSource: { + kind: "generated", + path: `builtin:product-smoke/${options.product}/${profileName}`, + byteCount: Buffer.byteLength(scriptText), + sha256: `sha256:${createHash("sha256").update(scriptText).digest("hex")}`, + }, + }; + return { target, profileName, profile, configRef, commandLabel, scriptOptions }; +} + +export function runWebProbeProductSmoke(args: string[]): Record { + const parsed = parseWebProbeProductSmokeOptions(args); + const resolved = resolveWebProbeProductSmoke(parsed); + const webProbe = resolved.target.webProbe; + const credentials = webProbe.authentication.credentials; + const declared = readDeclaredSecretValues({ + configPath: credentials.configRef, + targetId: credentials.targetId, + secretName: credentials.secretName, + targetKeys: [credentials.usernameTargetKey, credentials.passwordTargetKey], + }); + const username = declared.values[credentials.usernameTargetKey]; + const password = declared.values[credentials.passwordTargetKey]; + if (username === undefined || password === undefined) throw new Error("SelfMedia Web smoke 凭据声明不完整"); + const lane = webProbe.runner.lane; + if (!isHwlabRuntimeLane(lane)) throw new Error(`Web smoke runner lane 未在 HWLAB YAML 中声明:${lane}`); + const spec = hwlabRuntimeLaneSpecForNode(lane, webProbe.runner.node); + const result = runManagedNodeWebProbeScript( + resolved.scriptOptions, + spec, + { username, password }, + { + product: parsed.product, + target: resolved.target.id, + source: declared.summary, + valuesPrinted: false, + }, + { + loginPath: webProbe.authentication.path, + usernameField: webProbe.authentication.requestFields.username, + passwordField: webProbe.authentication.requestFields.password, + cookieNames: webProbe.authentication.cookieNames, + }, + ); + return { + ...result, + productSmoke: { + product: parsed.product, + target: resolved.target.id, + profile: resolved.profileName, + edition: parsed.edition, + algorithm: parsed.algorithm, + configRef: resolved.configRef, + valuesPrinted: false, + }, + valuesPrinted: false, + }; +} + +export function selfMediaVoiceAlgorithmSmokeScript( + editionId: string, + algorithm: string, + profileName: string, + profile: SelfMediaWebProbeSmokeProfileSpec, + configRef: string, +): string { + const config = { editionId, algorithm, profileName, configRef, ...profile }; + return String.raw`const config = ${JSON.stringify(config)}; + +export default async function selfMediaVoiceAlgorithmSmoke({ page, goto, reloadStable, wait, screenshot, jsonArtifact, recordStep }) { + const failures = []; + const network = []; + let networkCount = 0; + let beforeAlgorithm = null; + let savedAlgorithm = null; + let reloadedAlgorithm = null; + let editionValue = null; + let statusText = null; + let options = []; + + const addFailure = (value) => { + if (failures.length < config.outputLimits.failures) failures.push(String(value).replace(/\s+/gu, " ").slice(0, 300)); + }; + const addNetwork = (value) => { + networkCount += 1; + if (network.length < config.outputLimits.network) network.push(value); + }; + page.on("response", (response) => { + const request = response.request(); + const parsed = safeUrl(response.url()); + if (parsed === null || parsed.origin !== new URL(page.url() || "http://invalid").origin) return; + addNetwork({ method: request.method(), path: parsed.pathname, status: response.status() }); + }); + page.on("requestfailed", (request) => { + const parsed = safeUrl(request.url()); + if (parsed !== null) addNetwork({ method: request.method(), path: parsed.pathname, status: null, failure: String(request.failure()?.errorText || "request failed").slice(0, 160) }); + }); + + const route = renderPath(config.routeTemplate); + const savePath = renderPath(config.saveRequest.pathTemplate); + try { + await goto(route, { selectors: [config.readySelector], readinessTimeoutMs: config.navigationTimeoutMs, attempts: 3 }); + if (config.settleMs > 0) await wait(config.settleMs); + const identity = page.locator(config.identitySelector).first(); + const select = page.locator(config.selectSelector).first(); + if (await identity.count() !== 1) throw new Error("期次身份控件不存在或不唯一"); + if (await select.count() !== 1) throw new Error("语音算法控件不存在或不唯一"); + editionValue = await identity.inputValue(); + if (editionValue !== config.editionId) throw new Error("深链期次不一致:" + editionValue); + options = await select.evaluate((element, limit) => [...element.options].slice(0, limit).map((item) => ({ value: item.value, label: item.textContent?.trim().slice(0, 120) || "", disabled: item.disabled })), config.outputLimits.options); + const selectedOption = options.find((item) => item.value === config.algorithm && item.disabled !== true); + if (!selectedOption) throw new Error("指定语音算法不在可选项中:" + config.algorithm); + beforeAlgorithm = await select.inputValue(); + await select.selectOption(config.algorithm); + savedAlgorithm = await select.inputValue(); + if (savedAlgorithm !== config.algorithm) throw new Error("语音算法选择未生效"); + recordStep("选择语音算法", { ok: true, editionId: editionValue, beforeAlgorithm, selectedAlgorithm: savedAlgorithm, optionCount: options.length }); + + const saveButton = page.getByRole("button", { name: config.saveButtonName, exact: true }).first(); + if (await saveButton.count() !== 1) throw new Error("保存设置按钮不存在或不唯一"); + await saveButton.click(); + await page.waitForFunction(({ selector, successTexts }) => { + const value = document.querySelector(selector)?.textContent?.replace(/\s+/gu, " ").trim() || ""; + return successTexts.includes(value); + }, { selector: config.statusSelector, successTexts: config.successTexts }, { timeout: config.navigationTimeoutMs }); + statusText = (await page.locator(config.statusSelector).first().textContent())?.replace(/\s+/gu, " ").trim() || ""; + const saveNetwork = network.filter((item) => item.method === config.saveRequest.method && item.path === savePath); + if (beforeAlgorithm !== config.algorithm && !saveNetwork.some((item) => Number(item.status) >= 200 && Number(item.status) < 300)) { + throw new Error("算法发生变化,但未观察到成功的保存请求"); + } + recordStep("保存语音算法", { ok: true, statusText, request: saveNetwork.slice(-1)[0] || null }); + + await reloadStable({ selectors: [config.readySelector], readinessTimeoutMs: config.navigationTimeoutMs, attempts: 3 }); + if (config.settleMs > 0) await wait(config.settleMs); + editionValue = await page.locator(config.identitySelector).first().inputValue(); + reloadedAlgorithm = await page.locator(config.selectSelector).first().inputValue(); + if (editionValue !== config.editionId) throw new Error("重载后期次不一致:" + editionValue); + if (reloadedAlgorithm !== config.algorithm) throw new Error("重载后语音算法未持久化:" + reloadedAlgorithm); + recordStep("重载确认持久化", { ok: true, editionId: editionValue, algorithm: reloadedAlgorithm, finalPath: new URL(page.url()).pathname }); + } catch (error) { + addFailure(error instanceof Error ? error.message : String(error)); + recordStep("语音算法闭环失败", { ok: false, failure: failures[failures.length - 1] || "unknown" }); + } + + const shot = await screenshot(config.screenshotName).catch((error) => { + addFailure("截图失败:" + (error instanceof Error ? error.message : String(error))); + return null; + }); + const finalPath = safeUrl(page.url())?.pathname || null; + const evidence = { + profileName: config.profileName, + configRef: config.configRef, + editionId: config.editionId, + requestedAlgorithm: config.algorithm, + beforeAlgorithm, + savedAlgorithm, + reloadedAlgorithm, + statusText, + finalPath, + dom: { editionValue, selectedAlgorithm: reloadedAlgorithm || savedAlgorithm, options, optionCount: options.length }, + network: { count: networkCount, items: network }, + failures, + valuesRedacted: true, + }; + const report = await jsonArtifact("selfmedia-voice-algorithm-smoke.json", evidence).catch((error) => { + addFailure("报告写入失败:" + (error instanceof Error ? error.message : String(error))); + return null; + }); + const ok = failures.length === 0 && reloadedAlgorithm === config.algorithm && editionValue === config.editionId; + return { + ok, + status: ok ? "pass" : "blocked", + ...evidence, + failures, + report, + screenshot: shot, + issueEvidence: { + ok, + status: ok ? "pass" : "blocked", + failedCondition: ok ? null : failures[0] || "语音算法闭环失败", + result: evidence, + reportPath: report?.path || null, + reportSha256: report?.sha256 || null, + screenshots: shot ? [shot] : [], + valuesRedacted: true, + }, + valuesRedacted: true, + }; + + function renderPath(template) { + return template.replaceAll("{editionId}", encodeURIComponent(config.editionId)); + } + function safeUrl(value) { + try { return new URL(value); } catch { return null; } + } +}`; +} + +function requiredValue(values: Map, key: string): string { + const value = values.get(key); + if (value === undefined) throw new Error(`web-probe product-smoke 缺少 ${key}`); + return value; +} + +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; +} diff --git a/scripts/src/web-probe.ts b/scripts/src/web-probe.ts index a023664c..34572867 100644 --- a/scripts/src/web-probe.ts +++ b/scripts/src/web-probe.ts @@ -5,6 +5,7 @@ import type { RenderedCliResult } from "./output"; import { hwlabNodeWebProbeHelp } 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"; export function webProbeHelp(): Record { return hwlabNodeWebProbeHelp(); @@ -14,6 +15,7 @@ export async function runWebProbeCommand(_config: Config, args: string[]): Promi 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"); + if (parsedArgs[0] === "product-smoke") return runWebProbeProductSmoke(parsedArgs.slice(1)); const result = runNodeWebProbe(parseNodeWebProbeOptions(parsedArgs)); if (format === "text" || !("projection" in result) || result.projection === undefined) return result; if (format === "json") return result.projection;