201 lines
9.1 KiB
TypeScript
201 lines
9.1 KiB
TypeScript
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; }
|
||
}`;
|
||
}
|