diff --git a/.agents/skills/unidesk-temporal/SKILL.md b/.agents/skills/unidesk-temporal/SKILL.md index 6af0f391..5eec548c 100644 --- a/.agents/skills/unidesk-temporal/SKILL.md +++ b/.agents/skills/unidesk-temporal/SKILL.md @@ -84,7 +84,19 @@ bun scripts/cli.ts platform-infra public-edge status --target NC01 - 逻辑 namespace `unidesk` 存在。 - `https://temporal.hwpod.com/healthz` 返回 `200`。 - 未认证访问 Web 根路径返回 `401`。 -- 管理登录仅使用 repo-owned typed `web-probe` 命令;缺少 Temporal typed login smoke 时记录工具缺口,不得临时读取统一密码或复制一次性浏览器脚本。 +- 管理页面使用 repo-owned typed `web-probe` 命令验收: + +```bash +bun scripts/cli.ts web-probe product-smoke \ + --product temporal \ + --target NC01 \ + --profile admin-readonly +``` + +- 命令从 Temporal owning YAML 解析公网入口、runner、Basic Auth 合同、SecretRef、视口和证据上限: + - 禁止传入 URL、用户名或密码覆盖; + - 只输出 Secret presence/fingerprint、DOM/网络/控制台有界摘要和工件哈希; + - 必须保持 `mutation=false`、`valuesPrinted=false`。 - `public-edge status` 可能被其他站点的探针拉低;必须同时核对 Temporal 单站点证据,不能把无关站点故障归因给 Temporal。 ## Native Workflow Smoke diff --git a/.agents/skills/unidesk-webdev/SKILL.md b/.agents/skills/unidesk-webdev/SKILL.md index e7a738fc..71c0be47 100644 --- a/.agents/skills/unidesk-webdev/SKILL.md +++ b/.agents/skills/unidesk-webdev/SKILL.md @@ -65,6 +65,7 @@ description: UniDesk Web 开发与受控浏览器验证技能。用户提到 Web - 产品级 RESTful 深链选择、保存与重载持久化 smoke: - 使用 `web-probe product-smoke --product --target --profile --edition --algorithm `; - 产品 target、semantic origin、认证 sourceRef、登录合同、selector、保存请求、视口和证据上限只从产品 owning YAML 读取; + - 认证合同可声明 API session、同页表单或 BrowserContext Basic Auth,产品命令不得自行读取或拼接认证 header; - 结果只保留有界 DOM/网络摘要、report SHA、screenshot SHA、presence 与 fingerprint,禁止输出账号、密码或 cookie; - disabled target 必须拒绝执行,不得用 CLI URL 或凭据参数绕过。 - ApiState 多页面只读 smoke: diff --git a/config/platform-infra/temporal.yaml b/config/platform-infra/temporal.yaml index ad8a92ce..8d722e3a 100644 --- a/config/platform-infra/temporal.yaml +++ b/config/platform-infra/temporal.yaml @@ -6,6 +6,7 @@ metadata: owner: platform-infra relatedIssues: - 2358 + - 2374 defaults: targetId: NC01 @@ -69,3 +70,38 @@ publicExposure: upstream: http://temporal-ui.temporal.svc.cluster.local:8080 healthPath: /healthz expectedA: 152.53.229.148 + +webProbe: + enabled: true + productId: temporal + runner: + node: NC01 + lane: v03 + origin: + browserProxyMode: direct + authentication: + mode: basic-auth + path: /namespaces/unidesk/workflows + authenticatedSelector: body + credentials: + configRef: config/secrets-distribution.yaml + targetId: temporal-nc01 + secretName: temporal-web-admin + passwordTargetKey: password + defaultSmokeProfile: admin-readonly + smokeProfiles: + admin-readonly: + path: /namespaces/unidesk/workflows + readySelector: body + minimumBodyTextBytes: 32 + screenshotName: temporal-admin-readonly.png + viewport: + width: 1920 + height: 1080 + navigationTimeoutMs: 30000 + settleMs: 1000 + commandTimeoutSeconds: 120 + outputLimits: + failures: 8 + network: 24 + console: 12 diff --git a/scripts/src/help.ts b/scripts/src/help.ts index 24adc2d2..5d65b1ea 100644 --- a/scripts/src/help.ts +++ b/scripts/src/help.ts @@ -959,6 +959,7 @@ function webProbeHelpSummary(): unknown { "bun scripts/cli.ts web-probe product-smoke --product selfmedia --target NC01 --edition --algorithm ", "bun scripts/cli.ts web-probe product-smoke --product pikaoa --target NC01 --profile admin-mvp", "bun scripts/cli.ts web-probe product-smoke --product apistate --target NC01 --profile operations", + "bun scripts/cli.ts web-probe product-smoke --product temporal --target NC01 --profile admin-readonly", "bun scripts/cli.ts web-probe observe start --node NC01 --lane v03 --target-path /workbench --sample-interval-ms 5000", "bun scripts/cli.ts web-probe observe collect webobs-xxxx --view turn-summary", "bun scripts/cli.ts web-probe observe collect webobs-xxxx --view timeline --turn 1", diff --git a/scripts/src/hwlab-node-help.ts b/scripts/src/hwlab-node-help.ts index b0940626..1a18ce64 100644 --- a/scripts/src/hwlab-node-help.ts +++ b/scripts/src/hwlab-node-help.ts @@ -91,20 +91,13 @@ export function hwlabNodeWebProbeHelp(): Record { "bun scripts/cli.ts web-probe product-smoke --product selfmedia --target NC01 --profile voice-algorithm --edition --algorithm ", "bun scripts/cli.ts web-probe product-smoke --product pikaoa --target NC01 --profile admin-mvp", "bun scripts/cli.ts web-probe product-smoke --product apistate --target NC01 --profile operations", + "bun scripts/cli.ts web-probe product-smoke --product temporal --target NC01 --profile admin-readonly", "bun scripts/cli.ts web-probe script --node --lane --origin internal --script-file .state/probes/workbench.mjs", "bun scripts/cli.ts web-probe screenshot --node --lane --origin internal --path /workbench --viewport 1440x900", "bun scripts/cli.ts web-probe observe start --node --lane --origin internal --target-path /workbench --sample-interval-ms 5000", "bun scripts/cli.ts web-probe observe command webobs-xxxx --type sendPrompt --text 'ping'", "bun scripts/cli.ts web-probe observe command webobs-xxxx --type validateRealtimeFanout --profile pure-kafka-live --provider gpt.pika --text '' --second-text ''", - "bun scripts/cli.ts web-probe observe command webobs-xxxx --type validateExistingSessionRefresh --profile pure-kafka-live --session-id ses_existing", - "bun scripts/cli.ts web-probe observe command webobs-xxxx --type validateWorkbenchKafkaDebugReplay", - "bun scripts/cli.ts web-probe observe command webobs-xxxx --type validateWorkbenchTraceReadability", "bun scripts/cli.ts web-probe observe status webobs-xxxx --command-id cmd-xxxx", - "bun scripts/cli.ts web-probe observe command webobs-xxxx --type closeMdtodoSourceConfig", - "bun scripts/cli.ts web-probe observe command webobs-xxxx --type selectMdtodoFile --filename 20260609_频率判断_用户反馈.md", - "bun scripts/cli.ts web-probe observe command webobs-xxxx --type openMdtodoReportPreview --task R1 --link R1.1", - "bun scripts/cli.ts web-probe observe command webobs-xxxx --type toggleMdtodoReportFullscreen --text toggle", - "bun scripts/cli.ts web-probe observe command webobs-xxxx --type editMdtodoTaskInline --task R1 --field body --text 'body updated through web-probe command'", "bun scripts/cli.ts web-probe observe command webobs-xxxx --type launchWorkbenchFromMdtodo --filename 20260610-LVDT-旧过程归档.md --task R5.1 --provider dsflash-go", "bun scripts/cli.ts web-probe observe command webobs-xxxx --type performanceCapture --duration-ms 5000 --wait-ms 8000", "bun scripts/cli.ts web-probe observe status webobs-xxxx", @@ -133,7 +126,7 @@ export function hwlabNodeWebProbeHelp(): Record { run: "Run the repo-owned scripts/web-live-dom-probe.mjs helper.", "opencode-smoke": "Run the repo-owned OpenCode iframe/direct-host composer smoke and require DOM assistant text plus EventSource update/finish/idle evidence.", "console-verify": "Run a YAML-profiled Cloud Console route and viewport matrix with optional Projects, HWPOD, MDTODO and AgentRun interaction contracts.", - "product-smoke": "按产品 owning YAML 执行受控登录和 RESTful 深链验证;当前提供 SelfMedia 语音算法闭环与 PikaOA 管理页面矩阵。", + "product-smoke": "按产品 owning YAML 执行受控登录和页面验证,支持 API、表单及 Basic Auth 认证合同。", 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.", diff --git a/scripts/src/hwlab-node-web-probe-runner-source.ts b/scripts/src/hwlab-node-web-probe-runner-source.ts index b8164386..539a0ace 100644 --- a/scripts/src/hwlab-node-web-probe-runner-source.ts +++ b/scripts/src/hwlab-node-web-probe-runner-source.ts @@ -48,8 +48,12 @@ try { await browserStage("chromium-launch-start"); browser = await launcher.launchChromium(chromium, chromiumLaunchOptions); await browserStage("chromium-launched"); - context = await browser.newContext({ viewport, ...(playwrightProxy === null ? {} : { proxy: playwrightProxy }) }); - if (authProfile.mode === "ui-form-memory") page = await context.newPage(); + context = await browser.newContext({ + viewport, + ...(playwrightProxy === null ? {} : { proxy: playwrightProxy }), + ...(authProfile.mode === "basic-auth" ? { httpCredentials: { username, password, send: "always" } } : {}), + }); + if (authProfile.mode === "ui-form-memory" || authProfile.mode === "basic-auth") page = await context.newPage(); auth = await authenticate(context, page); if (!auth.ok) throw new Error("auth-login-failed"); if (page === undefined) page = await context.newPage(); @@ -130,9 +134,81 @@ async function browserStage(stage) { async function authenticate(browserContext, loginPage) { if (authProfile.mode === "ui-form-memory") return authenticateWithUiFormMemory(loginPage); + if (authProfile.mode === "basic-auth") return authenticateWithBasicAuth(loginPage); return authenticateWithApiRetries(browserContext); } +async function authenticateWithBasicAuth(loginPage) { + if (!loginPage) throw new Error("basic-auth requires a page"); + const attempt = { method: "basic-auth", loginPath: authProfile.loginPath, unauthenticatedResponse: null, response: null, finalPath: null, readySelectorPresent: false }; + try { + let unauthenticatedContext; + try { + unauthenticatedContext = await browser.newContext({ + viewport, + ...(playwrightProxy === null ? {} : { proxy: playwrightProxy }), + }); + const unauthenticatedResponse = await unauthenticatedContext.request.get(new URL(authProfile.loginPath, baseUrl).toString(), { + failOnStatusCode: false, + timeout: timeoutMs, + }); + attempt.unauthenticatedResponse = await responseSummary(unauthenticatedResponse); + if (unauthenticatedResponse.status() !== 401) { + throw new Error("basic-auth unauthenticated boundary did not return 401"); + } + } finally { + await unauthenticatedContext?.close().catch(() => undefined); + } + const response = await loginPage.goto(new URL(authProfile.loginPath, baseUrl).toString(), { + waitUntil: "domcontentloaded", + timeout: timeoutMs, + }); + if (response !== null) attempt.response = await responseSummary(response); + if (response === null || !response.ok()) throw new Error("basic-auth navigation did not return a successful response"); + await loginPage.locator(authProfile.authenticatedSelector).first().waitFor({ state: "visible", timeout: timeoutMs }); + attempt.finalPath = new URL(loginPage.url()).pathname; + attempt.readySelectorPresent = true; + assertNoCredentialLeak(loginPage.url()); + return { + ok: true, + method: "basic-auth", + loginUrl: authProfile.loginPath, + status: response.status(), + statusText: response.statusText(), + unauthenticatedStatus: attempt.unauthenticatedResponse?.status ?? null, + cookiePresent: null, + cookieNames: [], + attempts: [attempt], + retryCount: 0, + retryMaxAttempts: 1, + retryExhausted: false, + fallbackUsed: false, + finalPath: attempt.finalPath, + readySelectorPresent: true, + valuesRedacted: true, + }; + } catch (error) { + attempt.finalPath = safePath(loginPage.url()); + return { + ok: false, + method: "basic-auth", + loginUrl: authProfile.loginPath, + status: attempt.response?.status ?? 0, + statusText: "basic-auth-login-failed", + unauthenticatedStatus: attempt.unauthenticatedResponse?.status ?? null, + cookiePresent: null, + cookieNames: [], + attempts: [attempt], + retryCount: 0, + retryMaxAttempts: 1, + retryExhausted: true, + fallbackUsed: false, + errorSummary: { message: error instanceof Error ? error.message : String(error), finalPath: attempt.finalPath }, + valuesRedacted: true, + }; + } +} + async function authenticateWithApiRetries(browserContext) { const loginUrl = new URL(authProfile.loginPath, baseUrl).toString(); const attempts = []; @@ -1959,6 +2035,7 @@ function publicAuth(value) { loginPath: value.loginUrl, status: value.status, statusText: value.statusText, + unauthenticatedStatus: value.unauthenticatedStatus ?? null, cookiePresent: value.cookiePresent, cookieNames: value.cookieNames, attempts: value.attempts ?? null, @@ -2084,6 +2161,13 @@ function parseAuthenticationProfile(raw) { authenticatedSelector: requiredSelector(profile.authenticatedSelector, "authentication.authenticatedSelector"), }; } + if (profile.mode === "basic-auth") { + return { + mode: profile.mode, + loginPath, + authenticatedSelector: requiredSelector(profile.authenticatedSelector, "authentication.authenticatedSelector"), + }; + } throw new Error("unsupported authentication.mode"); } diff --git a/scripts/src/hwlab-node/web-observe-scripts.ts b/scripts/src/hwlab-node/web-observe-scripts.ts index 9886694d..0da709e4 100644 --- a/scripts/src/hwlab-node/web-observe-scripts.ts +++ b/scripts/src/hwlab-node/web-observe-scripts.ts @@ -52,6 +52,10 @@ export type WebProbeScriptAuthenticationProfile = { readonly submitSelector: string; readonly authenticatedPath: string; readonly authenticatedSelector: string; +} | { + readonly mode: "basic-auth"; + readonly loginPath: string; + readonly authenticatedSelector: string; }; export interface WebProbeScriptCredentialMaterial { diff --git a/scripts/src/platform-infra-temporal.ts b/scripts/src/platform-infra-temporal.ts index f0358bf2..26d38d5b 100644 --- a/scripts/src/platform-infra-temporal.ts +++ b/scripts/src/platform-infra-temporal.ts @@ -22,7 +22,7 @@ const configLabel = "config/platform-infra/temporal.yaml"; const secretRoot = "/root/.unidesk/.state/secrets"; const fieldManager = "unidesk-platform-temporal"; -interface TemporalTarget { +export interface TemporalTarget { id: string; route: string; namespace: string; @@ -30,7 +30,34 @@ interface TemporalTarget { enabled: boolean; } -interface TemporalConfig { +export interface TemporalWebProbeSmokeProfile { + path: string; + readySelector: string; + minimumBodyTextBytes: number; + screenshotName: string; + viewport: { width: number; height: number }; + navigationTimeoutMs: number; + settleMs: number; + commandTimeoutSeconds: number; + outputLimits: { failures: number; network: number; console: number }; +} + +export interface TemporalWebProbeSpec { + enabled: boolean; + productId: "temporal"; + runner: { node: string; lane: string }; + origin: { browserProxyMode: "auto" | "direct" }; + authentication: { + mode: "basic-auth"; + path: string; + authenticatedSelector: string; + credentials: { configRef: string; targetId: string; secretName: string; passwordTargetKey: string }; + }; + defaultSmokeProfile: string; + smokeProfiles: Record; +} + +export interface TemporalConfig { defaults: { targetId: string }; images: { server: string; ui: string; authProxy: string; pullPolicy: string }; targets: TemporalTarget[]; @@ -73,6 +100,7 @@ interface TemporalConfig { healthPath: string; expectedA: string; }; + webProbe: TemporalWebProbeSpec; } interface DatabaseMaterial { @@ -112,7 +140,7 @@ export async function runTemporalCommand(config: UniDeskConfig, args: string[]): function plan(options: ReturnType): Record { const temporal = readTemporalConfig(); - const target = resolveTarget(temporal, options.targetId); + const target = resolveTemporalTarget(temporal, options.targetId); const manifest = renderManifest(temporal, target, null); const checks = policyChecks(temporal, target, manifest); return { @@ -135,7 +163,7 @@ function plan(options: ReturnType): Record): Promise> { const temporal = readTemporalConfig(); - const target = resolveTarget(temporal, options.targetId); + const target = resolveTemporalTarget(temporal, options.targetId); const checks = policyChecks(temporal, target, renderManifest(temporal, target, null)); if (!checks.every((check) => check.ok)) return { ok: false, action: "platform-infra-temporal-apply", mode: "policy-blocked", policy: checks }; if (options.confirm && !options.wait) { @@ -186,7 +214,7 @@ async function observe( options: ReturnType, ): Promise> { const temporal = readTemporalConfig(); - const target = resolveTarget(temporal, options.targetId); + const target = resolveTemporalTarget(temporal, options.targetId); const result = await capture(config, target.route, ["sh"], statusScript(temporal, target, action === "validate")); const parsed = parseJsonOutput(result.stdout); return { @@ -513,7 +541,7 @@ function readDatabaseMaterial(temporal: TemporalConfig): DatabaseMaterial { return { ...material, fingerprint: fingerprintSecretValues(material, Object.keys(material)) }; } -function readTemporalConfig(): TemporalConfig { +export function readTemporalConfig(): TemporalConfig { const root = readYamlRecord(configPath, "platform-infra-temporal"); const defaults = recordField(root, "defaults", configLabel); const images = recordField(root, "images", configLabel); @@ -524,6 +552,41 @@ function readTemporalConfig(): TemporalConfig { const ui = recordField(runtime, "ui", `${configLabel}.runtime`); const uiAuth = recordField(ui, "auth", `${configLabel}.runtime.ui`); const exposure = recordField(root, "publicExposure", configLabel); + const webProbe = recordField(root, "webProbe", configLabel); + const webProbeRunner = recordField(webProbe, "runner", `${configLabel}.webProbe`); + const webProbeOrigin = recordField(webProbe, "origin", `${configLabel}.webProbe`); + const webProbeAuth = recordField(webProbe, "authentication", `${configLabel}.webProbe`); + const webProbeCredentials = recordField(webProbeAuth, "credentials", `${configLabel}.webProbe.authentication`); + const webProbeProfiles = recordField(webProbe, "smokeProfiles", `${configLabel}.webProbe`); + const smokeProfiles = Object.fromEntries(Object.entries(webProbeProfiles).map(([name, value]) => { + const path = `${configLabel}.webProbe.smokeProfiles.${name}`; + const profile = recordField({ value }, "value", path); + const viewport = recordField(profile, "viewport", path); + const limits = recordField(profile, "outputLimits", path); + return [name, { + path: stringField(profile, "path", path), + readySelector: stringField(profile, "readySelector", path), + minimumBodyTextBytes: integerField(profile, "minimumBodyTextBytes", path), + screenshotName: stringField(profile, "screenshotName", path), + viewport: { width: integerField(viewport, "width", `${path}.viewport`), height: integerField(viewport, "height", `${path}.viewport`) }, + navigationTimeoutMs: integerField(profile, "navigationTimeoutMs", path), + settleMs: integerField(profile, "settleMs", path), + commandTimeoutSeconds: integerField(profile, "commandTimeoutSeconds", path), + outputLimits: { + failures: integerField(limits, "failures", `${path}.outputLimits`), + network: integerField(limits, "network", `${path}.outputLimits`), + console: integerField(limits, "console", `${path}.outputLimits`), + }, + } satisfies TemporalWebProbeSmokeProfile]; + })); + const defaultSmokeProfile = stringField(webProbe, "defaultSmokeProfile", `${configLabel}.webProbe`); + if (smokeProfiles[defaultSmokeProfile] === undefined) throw new Error(`${configLabel}.webProbe.defaultSmokeProfile must reference smokeProfiles`); + const webProbeMode = stringField(webProbeAuth, "mode", `${configLabel}.webProbe.authentication`); + if (webProbeMode !== "basic-auth") throw new Error(`${configLabel}.webProbe.authentication.mode must be basic-auth`); + const productId = stringField(webProbe, "productId", `${configLabel}.webProbe`); + if (productId !== "temporal") throw new Error(`${configLabel}.webProbe.productId must be temporal`); + const browserProxyMode = stringField(webProbeOrigin, "browserProxyMode", `${configLabel}.webProbe.origin`); + if (browserProxyMode !== "auto" && browserProxyMode !== "direct") throw new Error(`${configLabel}.webProbe.origin.browserProxyMode must be auto or direct`); return { defaults: { targetId: stringField(defaults, "targetId", `${configLabel}.defaults`) }, images: { @@ -585,6 +648,28 @@ function readTemporalConfig(): TemporalConfig { healthPath: stringField(exposure, "healthPath", `${configLabel}.publicExposure`), expectedA: stringField(exposure, "expectedA", `${configLabel}.publicExposure`), }, + webProbe: { + enabled: booleanField(webProbe, "enabled", `${configLabel}.webProbe`), + productId, + runner: { + node: stringField(webProbeRunner, "node", `${configLabel}.webProbe.runner`), + lane: stringField(webProbeRunner, "lane", `${configLabel}.webProbe.runner`), + }, + origin: { browserProxyMode }, + authentication: { + mode: webProbeMode, + path: stringField(webProbeAuth, "path", `${configLabel}.webProbe.authentication`), + authenticatedSelector: stringField(webProbeAuth, "authenticatedSelector", `${configLabel}.webProbe.authentication`), + credentials: { + configRef: stringField(webProbeCredentials, "configRef", `${configLabel}.webProbe.authentication.credentials`), + targetId: stringField(webProbeCredentials, "targetId", `${configLabel}.webProbe.authentication.credentials`), + secretName: stringField(webProbeCredentials, "secretName", `${configLabel}.webProbe.authentication.credentials`), + passwordTargetKey: stringField(webProbeCredentials, "passwordTargetKey", `${configLabel}.webProbe.authentication.credentials`), + }, + }, + defaultSmokeProfile, + smokeProfiles, + }, }; } @@ -597,7 +682,7 @@ function deploymentConfig(record: Record, path: string, portKey }; } -function resolveTarget(temporal: TemporalConfig, requested: string | null): TemporalTarget { +export function resolveTemporalTarget(temporal: TemporalConfig, requested: string | null): TemporalTarget { const id = requested ?? temporal.defaults.targetId; const target = temporal.targets.find((item) => item.id === id); if (target === undefined) throw new Error(`${configLabel}.targets does not contain ${id}`); diff --git a/scripts/src/web-probe-product-smoke.test.ts b/scripts/src/web-probe-product-smoke.test.ts index 6f5475f2..2ed9e916 100644 --- a/scripts/src/web-probe-product-smoke.test.ts +++ b/scripts/src/web-probe-product-smoke.test.ts @@ -4,6 +4,7 @@ import { test } from "bun:test"; import { rootPath } from "./config"; import { readSub2RankConfig } from "./platform-infra-sub2rank-config"; +import { readTemporalConfig } from "./platform-infra-temporal"; import { readDeclaredSecretValues } from "./secrets"; import { resolveSelfMediaDeliveryTarget } from "./selfmedia-config"; import { pikaoaDevelopmentRuntimeConfigRef, resolvePikaoaDevelopmentRuntimeTargetByConfigRef } from "./pikaoa-runtime"; @@ -17,6 +18,10 @@ import { resolveWebProbeProductSmoke, } from "./web-probe-product-smoke"; import { apiStateOperationsSmokeScript } from "./web-probe-apistate-smoke"; +import { + resolveTemporalProductSmoke, + temporalAdminReadonlySmokeScript, +} from "./web-probe-temporal-smoke"; const fixture = { product: "selfmedia", @@ -122,6 +127,66 @@ test("ApiState product-smoke 只需要产品、目标和 YAML profile", () => { assert.match(script, /screenshot\(item\.screenshotName\)/u); }); +test("Temporal Basic Auth smoke 只从 owning YAML 和统一 Secret 声明解析", () => { + const parsed = parseWebProbeProductSmokeOptions([ + "--product", "temporal", + "--target", "NC01", + "--profile", "admin-readonly", + ]); + assert.deepEqual(parsed, { + product: "temporal", + target: "NC01", + profile: "admin-readonly", + edition: null, + algorithm: null, + runtime: null, + }); + const temporal = readTemporalConfig(); + assert.equal(temporal.webProbe.authentication.mode, "basic-auth"); + assert.equal(temporal.webProbe.authentication.path, "/namespaces/unidesk/workflows"); + assert.equal(temporal.webProbe.authentication.credentials.configRef, "config/secrets-distribution.yaml"); + assert.equal(temporal.webProbe.authentication.credentials.secretName, "temporal-web-admin"); + assert.equal(temporal.webProbe.authentication.credentials.passwordTargetKey, "password"); + const resolved = resolveTemporalProductSmoke({ target: parsed.target, profile: parsed.profile }); + assert.equal(resolved.scriptOptions.url, "https://temporal.hwpod.com"); + assert.equal(resolved.scriptOptions.viewport, "1920x1080"); + assert.equal(resolved.profile.path, "/namespaces/unidesk/workflows"); + assert.equal(resolved.scriptOptions.scriptSource.path, "builtin:product-smoke/temporal/admin-readonly"); + assert.doesNotMatch(resolved.commandLabel, /https?:\/\//u); + const script = temporalAdminReadonlySmokeScript(resolved.profileName, resolved.profile, resolved.configRef, temporal.publicExposure.healthPath); + assert.doesNotThrow(() => new Function(script.replace("export default ", ""))); + assert.doesNotMatch(script, /\.click\(|\.fill\(|\.press\(/u); + assert.match(script, /mutation: false/u); + assert.match(script, /valuesRedacted: true/u); + assert.match(script, /failure\.includes\("net::ERR_ABORTED"\)/u); + assert.match(script, /health\.status !== 200/u); +}); + +test("managed runner 通过 BrowserContext httpCredentials 泛化 Basic Auth", () => { + const runner = readFileSync(rootPath("scripts/src/hwlab-node-web-probe-runner-source.ts"), "utf8"); + assert.match(runner, /authProfile\.mode === "basic-auth"/u); + assert.match(runner, /httpCredentials: \{ username, password, send: "always" \}/u); + assert.match(runner, /authenticateWithBasicAuth/u); + assert.match(runner, /unauthenticatedResponse\.status\(\) !== 401/u); + assert.match(runner, /valuesRedacted: true/u); +}); + +test("Temporal 统一 Secret 摘要不包含凭据值", () => { + const temporal = readTemporalConfig(); + const credentials = temporal.webProbe.authentication.credentials; + const material = readDeclaredSecretValues({ + configPath: credentials.configRef, + targetId: credentials.targetId, + secretName: credentials.secretName, + targetKeys: [credentials.passwordTargetKey], + }); + assert.equal(material.summary.valuesPrinted, false); + assert.equal(material.summary.keys[0]?.present, true); + 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 web-probe,无临时脚本警告", () => { const resolved = resolveWebProbeProductSmoke(fixture); assert.equal(resolved.scriptOptions.action, "script"); diff --git a/scripts/src/web-probe-product-smoke.ts b/scripts/src/web-probe-product-smoke.ts index 958faa02..833f8197 100644 --- a/scripts/src/web-probe-product-smoke.ts +++ b/scripts/src/web-probe-product-smoke.ts @@ -1,6 +1,7 @@ import { createHash } from "node:crypto"; import { runWebProbeApiStateSmoke } from "./web-probe-apistate-smoke"; +import { runWebProbeTemporalSmoke } from "./web-probe-temporal-smoke"; import { hwlabRuntimeLaneSpecForNode, isHwlabRuntimeLane } from "./hwlab-node-lanes"; import { readDeclaredSecretValues } from "./secrets"; import { @@ -128,6 +129,12 @@ export function runWebProbeProductSmoke(args: string[]): Record if (parsed.edition !== null || parsed.algorithm !== null) throw new Error("ApiState product-smoke 不接受 --edition 或 --algorithm"); return runWebProbeApiStateSmoke({ target: parsed.target, profile: parsed.profile }); } + if (parsed.product === "temporal") { + if (parsed.edition !== null || parsed.algorithm !== null || parsed.runtime !== null) { + throw new Error("Temporal product-smoke 不接受 --edition、--algorithm 或 --runtime"); + } + return runWebProbeTemporalSmoke({ target: parsed.target, profile: parsed.profile }); + } const resolved = resolveWebProbeProductSmoke(parsed); const webProbe = resolved.target.webProbe; const credentials = webProbe.authentication.credentials; diff --git a/scripts/src/web-probe-temporal-smoke.ts b/scripts/src/web-probe-temporal-smoke.ts new file mode 100644 index 00000000..dccf5ffb --- /dev/null +++ b/scripts/src/web-probe-temporal-smoke.ts @@ -0,0 +1,200 @@ +import { createHash } from "node:crypto"; + +import { hwlabRuntimeLaneSpecForNode, isHwlabRuntimeLane } from "./hwlab-node-lanes"; +import type { NodeWebProbeScriptOptions } from "./hwlab-node/entry"; +import { runManagedNodeWebProbeScript } from "./hwlab-node/web-observe-scripts"; +import { + readTemporalConfig, + resolveTemporalTarget, + type TemporalWebProbeSmokeProfile, +} from "./platform-infra-temporal"; +import { readDeclaredSecretValues } from "./secrets"; + +export interface TemporalProductSmokeOptions { + readonly target: string; + readonly profile: string | null; +} + +export function resolveTemporalProductSmoke(options: TemporalProductSmokeOptions): { + readonly targetId: string; + readonly profileName: string; + readonly profile: TemporalWebProbeSmokeProfile; + readonly configRef: string; + readonly commandLabel: string; + readonly scriptOptions: NodeWebProbeScriptOptions; +} { + const temporal = readTemporalConfig(); + const target = resolveTemporalTarget(temporal, options.target); + const webProbe = temporal.webProbe; + if (!webProbe.enabled) throw new Error("config/platform-infra/temporal.yaml.webProbe.enabled=false,禁止执行 Temporal 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(`Temporal Web smoke runner lane 未在 HWLAB YAML 中声明:${webProbe.runner.lane}`); + hwlabRuntimeLaneSpecForNode(webProbe.runner.lane, webProbe.runner.node); + const configRef = `config/platform-infra/temporal.yaml#webProbe.smokeProfiles.${profileName}`; + const commandLabel = `web-probe product-smoke --product temporal --target ${target.id} --profile ${profileName}`; + const scriptText = temporalAdminReadonlySmokeScript(profileName, profile, configRef, temporal.publicExposure.healthPath); + const scriptOptions: NodeWebProbeScriptOptions = { + action: "script", + node: webProbe.runner.node, + lane: webProbe.runner.lane, + url: temporal.publicExposure.publicBaseUrl, + originName: "public", + originMode: "public", + originConfigPath: "config/platform-infra/temporal.yaml#publicExposure.publicBaseUrl", + 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: [ + "该命令由 Temporal owning YAML 驱动,并复用统一管理员密码、远端浏览器、内存门禁和证据恢复链。", + "smoke 只执行 Basic Auth 后的页面导航、DOM/控制台/网络读取和截图,不修改 Temporal 状态。", + ], + generatedPreferredCommands: { rerun: commandLabel }, + scriptSource: { + kind: "generated", + path: `builtin:product-smoke/temporal/${profileName}`, + byteCount: Buffer.byteLength(scriptText), + sha256: `sha256:${createHash("sha256").update(scriptText).digest("hex")}`, + }, + }; + return { targetId: target.id, profileName, profile, configRef, commandLabel, scriptOptions }; +} + +export function runWebProbeTemporalSmoke(options: TemporalProductSmokeOptions): Record { + const temporal = readTemporalConfig(); + const resolved = resolveTemporalProductSmoke(options); + const webProbe = temporal.webProbe; + const credentials = webProbe.authentication.credentials; + const declared = readDeclaredSecretValues({ + configPath: credentials.configRef, + targetId: credentials.targetId, + secretName: credentials.secretName, + targetKeys: [credentials.passwordTargetKey], + }); + const password = declared.values[credentials.passwordTargetKey]; + if (password === undefined) throw new Error("Temporal Web smoke 密码声明不完整"); + const laneSpec = hwlabRuntimeLaneSpecForNode(webProbe.runner.lane, webProbe.runner.node); + const result = runManagedNodeWebProbeScript( + resolved.scriptOptions, + laneSpec, + { username: temporal.runtime.ui.auth.username, password }, + { product: "temporal", target: resolved.targetId, source: declared.summary, valuesPrinted: false }, + { + mode: "basic-auth", + loginPath: webProbe.authentication.path, + authenticatedSelector: webProbe.authentication.authenticatedSelector, + }, + ); + return { + ...result, + productSmoke: { + product: "temporal", + target: resolved.targetId, + profile: resolved.profileName, + configRef: resolved.configRef, + mutation: false, + valuesPrinted: false, + }, + valuesPrinted: false, + }; +} + +export function temporalAdminReadonlySmokeScript( + profileName: string, + profile: TemporalWebProbeSmokeProfile, + configRef: string, + healthPath: string, +): string { + const config = { profileName, configRef, healthPath, ...profile }; + return String.raw`const config = ${JSON.stringify(config)}; + +export default async function temporalAdminReadonlySmoke({ page, goto, wait, screenshot, jsonArtifact, recordStep }) { + const failures = []; + const network = []; + const consoleErrors = []; + let networkCount = 0; + let consoleCount = 0; + const addFailure = (value) => { + if (failures.length < config.outputLimits.failures) failures.push(String(value).replace(/\s+/gu, " ").slice(0, 300)); + }; + page.on("response", (response) => { + const parsed = safeUrl(response.url()); + if (parsed === null || parsed.origin !== new URL(page.url()).origin) return; + networkCount += 1; + if (network.length < config.outputLimits.network) network.push({ method: response.request().method(), path: parsed.pathname, status: response.status() }); + if (response.status() >= 500) addFailure("HTTP " + response.status() + " " + parsed.pathname); + }); + page.on("requestfailed", (request) => { + const parsed = safeUrl(request.url()); + if (parsed === null) return; + networkCount += 1; + const failure = String(request.failure()?.errorText || "request failed").slice(0, 160); + const browserAborted = failure.includes("net::ERR_ABORTED"); + if (network.length < config.outputLimits.network) network.push({ method: request.method(), path: parsed.pathname, status: null, failure, browserAborted }); + if (!browserAborted) addFailure(parsed.pathname + ":" + failure); + }); + page.on("console", (message) => { + if (message.type() !== "error") return; + consoleCount += 1; + const text = message.text().replace(/\s+/gu, " ").slice(0, 240); + if (consoleErrors.length < config.outputLimits.console) consoleErrors.push({ type: "error", text }); + addFailure("console.error:" + text); + }); + page.on("pageerror", (error) => { + consoleCount += 1; + const text = String(error?.message || error).replace(/\s+/gu, " ").slice(0, 240); + if (consoleErrors.length < config.outputLimits.console) consoleErrors.push({ type: "pageerror", text }); + addFailure("pageerror:" + text); + }); + + await goto(config.path, { selectors: [config.readySelector], readinessTimeoutMs: config.navigationTimeoutMs, attempts: 3 }); + if (config.settleMs > 0) await wait(config.settleMs); + const healthResponse = await page.request.get(new URL(config.healthPath, page.url()).toString(), { timeout: config.navigationTimeoutMs }); + const health = { path: config.healthPath, status: healthResponse.status(), ok: healthResponse.ok() }; + if (!health.ok || health.status !== 200) addFailure("健康检查未返回 200"); + const dom = await page.evaluate(({ readySelector }) => { + const ready = document.querySelector(readySelector); + const rect = ready?.getBoundingClientRect(); + const bodyText = document.body?.innerText || ""; + return { + title: document.title.slice(0, 160), + bodyTextBytes: new TextEncoder().encode(bodyText).byteLength, + readySelectorPresent: ready !== null, + readySelectorVisible: Boolean(rect && rect.width > 0 && rect.height > 0), + appElementCount: document.querySelectorAll("main, nav, [role=main], #root, #app").length, + viewport: { width: window.innerWidth, height: window.innerHeight }, + document: { width: document.documentElement.scrollWidth, height: document.documentElement.scrollHeight }, + }; + }, { readySelector: config.readySelector }); + if (dom.bodyTextBytes < config.minimumBodyTextBytes) addFailure("页面正文小于 YAML 声明的最小字节数"); + if (!dom.readySelectorPresent || !dom.readySelectorVisible) addFailure("YAML 声明的 readySelector 不可见"); + const shot = await screenshot(config.screenshotName); + const summary = { + ok: failures.length === 0, + finalPath: new URL(page.url()).pathname, + health, + dom, + networkCount, + network, + consoleCount, + consoleErrors, + failures, + screenshot: shot, + mutation: false, + valuesRedacted: true, + }; + const artifact = await jsonArtifact("temporal-admin-readonly.json", summary); + recordStep("验证 Temporal Basic Auth 管理页面", { ...summary, artifact }); + return { ...summary, artifact }; +} + +function safeUrl(value) { + try { return new URL(value); } catch { return null; } +}`; +}