feat(web-probe): add selfmedia voice algorithm smoke

This commit is contained in:
Codex
2026-07-16 08:15:32 +02:00
parent 4299109281
commit d264060ed9
12 changed files with 815 additions and 15 deletions
+5
View File
@@ -52,6 +52,11 @@ description: UniDesk Web 开发与受控浏览器验证技能。用户提到 Web
- 只用于已部署页面的人工 closeout;
- 不进入 CI/CD gate
- 不替代 Workbench Kafka、Trace 或性能专项命令。
- 产品级 RESTful 深链选择、保存与重载持久化 smoke:
- 使用 `web-probe product-smoke --product <product> --target <target> --profile <profile> --edition <edition-id> --algorithm <algorithm-id>`
- 产品 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。
@@ -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` 截图验证;
+54
View File
@@ -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
+2 -1
View File
@@ -948,11 +948,12 @@ function hwlabNodeHelpSummary(): unknown {
function webProbeHelpSummary(): unknown {
return {
command: "web-probe run|script|console-verify|observe|sentinel --node <node> --lane <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 <node> --lane <lane> --origin internal --profile <yaml-profile>",
"bun scripts/cli.ts web-probe product-smoke --product selfmedia --target NC01 --edition <edition-id> --algorithm <algorithm-id>",
"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",
+3
View File
@@ -88,6 +88,7 @@ export function hwlabNodeWebProbeHelp(): Record<string, unknown> {
"bun scripts/cli.ts web-probe run --node <node> --lane <lane> --origin internal --wait-messages-ms 1000",
"bun scripts/cli.ts web-probe opencode-smoke --node <node> --lane <lane> --origin internal --message 'hi'",
"bun scripts/cli.ts web-probe console-verify --node <node> --lane <lane> --origin internal --profile <yaml-profile>",
"bun scripts/cli.ts web-probe product-smoke --product selfmedia --target NC01 --profile voice-algorithm --edition <edition-id> --algorithm <algorithm-id>",
"bun scripts/cli.ts web-probe script --node <node> --lane <lane> --origin internal --script-file .state/probes/workbench.mjs",
"bun scripts/cli.ts web-probe screenshot --node <node> --lane <lane> --origin internal --path /workbench --viewport 1440x900",
"bun scripts/cli.ts web-probe observe start --node <node> --lane <lane> --origin internal --target-path /workbench --sample-interval-ms 5000",
@@ -130,6 +131,7 @@ export function hwlabNodeWebProbeHelp(): Record<string, unknown> {
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<string, unknown> {
"Every script result starts with machine-readable `UNIDESK_WEB_PROBE_COMMAND_PROMOTION_HINT <json>` 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 <id> instead of repeating --node/--lane/--state-dir.",
@@ -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, "_")
+53 -4
View File
@@ -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<string, unknown>,
): Record<string, unknown> {
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<string, unknown>,
authentication: WebProbeScriptAuthenticationProfile,
): Record<string, unknown> {
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<NodeWebProbeScriptOptions, "or
return options.originName === "internal" || options.originName === "public" ? ` --origin ${options.originName}` : "";
}
export function nodeWebProbeScriptRemoteShell(options: NodeWebProbeScriptOptions, spec: HwlabRuntimeLaneSpec, secretSpec: RuntimeSecretSpec, username: string, password: string, webProbeProxy: NodeWebProbeHostProxyEnv, playwrightBrowsersPath: string | undefined): string {
export function nodeWebProbeScriptRemoteShell(
options: NodeWebProbeScriptOptions,
spec: HwlabRuntimeLaneSpec,
material: WebProbeScriptCredentialMaterial,
authentication: WebProbeScriptAuthenticationProfile,
webProbeProxy: NodeWebProbeHostProxyEnv,
playwrightBrowsersPath: string | undefined,
): string {
const userScriptB64 = Buffer.from(options.scriptText, "utf8").toString("base64");
const runnerB64 = Buffer.from(nodeWebProbeScriptRunnerSource(), "utf8").toString("base64");
return [
@@ -777,8 +822,12 @@ export function nodeWebProbeScriptRemoteShell(options: NodeWebProbeScriptOptions
`PLAYWRIGHT_BROWSERS_PATH=${shellQuote(playwrightBrowsersPath)}`,
]),
`HWLAB_WEB_BASE_URL=${shellQuote(options.url)}`,
`HWLAB_WEB_USER=${shellQuote(username)}`,
`HWLAB_WEB_PASS=${shellQuote(password)}`,
`HWLAB_WEB_USER=${shellQuote(material.username)}`,
`HWLAB_WEB_PASS=${shellQuote(material.password)}`,
`UNIDESK_WEB_PROBE_AUTH_PATH=${shellQuote(authentication.loginPath)}`,
`UNIDESK_WEB_PROBE_AUTH_USERNAME_FIELD=${shellQuote(authentication.usernameField)}`,
`UNIDESK_WEB_PROBE_AUTH_PASSWORD_FIELD=${shellQuote(authentication.passwordField)}`,
`UNIDESK_WEB_PROBE_AUTH_COOKIE_NAMES=${shellQuote(JSON.stringify(authentication.cookieNames))}`,
"UNIDESK_WEB_PROBE_RUN_DIR=\"$run_dir\"",
"UNIDESK_WEB_PROBE_USER_SCRIPT=\"$user_script\"",
`UNIDESK_WEB_PROBE_TIMEOUT_MS=${shellQuote(String(options.timeoutMs))}`,
+60
View File
@@ -140,6 +140,23 @@ export interface EnvSourceFileMaterial {
valuesPrinted: false;
}
export interface DeclaredSecretValuesMaterial {
readonly values: Readonly<Record<string, string>>;
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<string, unknown> {
return {
command: "secrets plan|sync|status",
@@ -1015,6 +1032,49 @@ export function fingerprintSecretValues(values: Record<string, string>, 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<string, string> = {};
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<string, string>): void {
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
const lines = Object.keys(values).sort().map((key) => `${key}=${quoteEnv(values[key])}`);
+191
View File
@@ -57,6 +57,65 @@ export interface SelfMediaDeliveryTarget {
readonly credentialUsername: string;
};
readonly deployment: Record<string, unknown>;
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<Record<string, SelfMediaWebProbeSmokeProfileSpec>>;
}
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<string, unknown>): 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<string, unknown>): 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;
@@ -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);
});
+307
View File
@@ -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<string, string>();
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<string, unknown> {
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<string, string>, 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;
}
+2
View File
@@ -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<string, unknown> {
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;