Files
pikasTech-unidesk/scripts/src/web-probe-temporal-smoke.ts
T
2026-07-16 20:11:21 +02:00

201 lines
9.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, unknown> {
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; }
}`;
}