feat(web-probe): add selfmedia voice algorithm smoke
This commit is contained in:
@@ -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` 截图验证;
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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, "_")
|
||||
|
||||
@@ -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))}`,
|
||||
|
||||
@@ -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])}`);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user