614 lines
29 KiB
TypeScript
614 lines
29 KiB
TypeScript
import { createHash } from "node:crypto";
|
||
import { readFileSync } from "node:fs";
|
||
|
||
import { hwlabRuntimeLaneSpecForNode, isHwlabRuntimeLane } from "./hwlab-node-lanes";
|
||
import type { NodeWebProbeScriptOptions } from "./hwlab-node/entry";
|
||
import { runManagedNodeWebProbeScript } from "./hwlab-node/web-observe-scripts";
|
||
|
||
const configPath = "/root/superapi/config/superapi.yaml";
|
||
|
||
interface SmokePage {
|
||
id: string;
|
||
path: string;
|
||
readySelector: string;
|
||
screenshotName: string;
|
||
interaction: "none" | "github-scan" | "api-key-bindings" | "history-detail";
|
||
}
|
||
|
||
interface SmokeProfile {
|
||
actor: "admin" | "user";
|
||
pages: SmokePage[];
|
||
viewport: { width: number; height: number };
|
||
navigationTimeoutMs: number;
|
||
settleMs: number;
|
||
commandTimeoutSeconds: number;
|
||
outputLimits: { failures: number; network: number; console: number };
|
||
}
|
||
|
||
interface SuperApiWebProbeConfig {
|
||
productId: string;
|
||
authentication: {
|
||
mode: "ui-form-memory";
|
||
path: string;
|
||
usernameSelector: string;
|
||
passwordSelector: string;
|
||
submitSelector: string;
|
||
authenticatedPath: string;
|
||
authenticatedSelector: string;
|
||
};
|
||
userAuthentication: {
|
||
mode: "ui-form-memory";
|
||
path: string;
|
||
providerId: string;
|
||
usernameSelector: string;
|
||
passwordSelector: string;
|
||
submitSelector: string;
|
||
authenticatedPath: string;
|
||
authenticatedSelector: string;
|
||
credentials: {
|
||
sourceRef: string;
|
||
usernameKey: string;
|
||
passwordKey: string;
|
||
};
|
||
};
|
||
runner: { node: string; lane: string };
|
||
origin: { nativeBaseUrl: string; browserProxyMode: "auto" | "direct" };
|
||
defaultSmokeProfile: string;
|
||
smokeProfiles: Record<string, SmokeProfile>;
|
||
auth: { username: string; passwordPath: string };
|
||
}
|
||
|
||
export interface SuperApiProductSmokeOptions {
|
||
target: string;
|
||
profile: string | null;
|
||
}
|
||
|
||
export function resolveSuperApiProductSmoke(options: SuperApiProductSmokeOptions): {
|
||
config: SuperApiWebProbeConfig;
|
||
profileName: string;
|
||
profile: SmokeProfile;
|
||
configRef: string;
|
||
commandLabel: string;
|
||
scriptOptions: NodeWebProbeScriptOptions;
|
||
} {
|
||
const config = readSuperApiWebProbeConfig();
|
||
if (config.productId !== "superapi") throw new Error(`${configPath} webProbe.productId 必须是 superapi`);
|
||
if (options.target !== config.runner.node) throw new Error(`SuperAPI target 必须是 owning YAML 声明的 ${config.runner.node}`);
|
||
if (!isHwlabRuntimeLane(config.runner.lane)) throw new Error(`SuperAPI Web smoke runner lane 未在 HWLAB YAML 中声明:${config.runner.lane}`);
|
||
hwlabRuntimeLaneSpecForNode(config.runner.lane, config.runner.node);
|
||
const profileName = options.profile ?? config.defaultSmokeProfile;
|
||
const profile = config.smokeProfiles[profileName];
|
||
if (profile === undefined) throw new Error(`未知 --profile ${profileName};可用项:${Object.keys(config.smokeProfiles).sort().join(", ")}`);
|
||
const configRef = `${configPath}#webProbe.smokeProfiles.${profileName}`;
|
||
const commandLabel = `web-probe product-smoke --product superapi --target ${options.target} --profile ${profileName}`;
|
||
const scriptText = superApiManagementSmokeScript(profileName, profile, configRef);
|
||
const scriptOptions: NodeWebProbeScriptOptions = {
|
||
action: "script",
|
||
node: config.runner.node,
|
||
lane: config.runner.lane,
|
||
url: config.origin.nativeBaseUrl,
|
||
originName: "native",
|
||
originMode: "public",
|
||
originConfigPath: `${configPath}#webProbe.origin.nativeBaseUrl`,
|
||
browserProxyMode: config.origin.browserProxyMode,
|
||
browserProxyModeSource: "yaml-origin",
|
||
timeoutMs: profile.navigationTimeoutMs,
|
||
viewport: `${profile.viewport.width}x${profile.viewport.height}`,
|
||
commandTimeoutSeconds: profile.commandTimeoutSeconds,
|
||
scriptText,
|
||
commandLabel,
|
||
suppressAdHocWarning: true,
|
||
generatedHints: [
|
||
`该命令由 SuperAPI owning YAML 驱动,并使用 ${profile.actor === "admin" ? "统一管理员" : "普通测试用户"}凭据、远端浏览器、内存门禁和证据恢复链。`,
|
||
profile.actor === "admin"
|
||
? "smoke 只读取 Skill、API Key 和请求历史页面,不执行管理写操作。"
|
||
: "smoke 只读取 Skill 和当前用户 API Key,并验证请求历史和 API Key 注册入口不可访问。",
|
||
],
|
||
generatedPreferredCommands: { rerun: commandLabel },
|
||
scriptSource: {
|
||
kind: "generated",
|
||
path: `builtin:product-smoke/superapi/${profileName}`,
|
||
byteCount: Buffer.byteLength(scriptText),
|
||
sha256: `sha256:${createHash("sha256").update(scriptText).digest("hex")}`,
|
||
},
|
||
};
|
||
return { config, profileName, profile, configRef, commandLabel, scriptOptions };
|
||
}
|
||
|
||
export function runWebProbeSuperApiSmoke(options: SuperApiProductSmokeOptions): Record<string, unknown> {
|
||
const resolved = resolveSuperApiProductSmoke(options);
|
||
const credential = resolved.profile.actor === "admin"
|
||
? {
|
||
username: resolved.config.auth.username,
|
||
password: readFileSync(resolved.config.auth.passwordPath, "utf8").trim(),
|
||
sourceRef: resolved.config.auth.passwordPath,
|
||
keys: ["contents"],
|
||
authentication: resolved.config.authentication,
|
||
}
|
||
: readUserCredential(resolved.config);
|
||
if (credential.password === "") throw new Error("SuperAPI Web smoke 密码声明为空");
|
||
const fingerprint = `sha256:${createHash("sha256")
|
||
.update(`${credential.username}\0${credential.password}`)
|
||
.digest("hex")}`;
|
||
const laneSpec = hwlabRuntimeLaneSpecForNode(resolved.config.runner.lane, resolved.config.runner.node);
|
||
const result = runManagedNodeWebProbeScript(
|
||
resolved.scriptOptions,
|
||
laneSpec,
|
||
{ username: credential.username, password: credential.password },
|
||
{
|
||
product: "superapi",
|
||
target: options.target,
|
||
source: {
|
||
sourceRef: credential.sourceRef,
|
||
keys: credential.keys.map((key) => ({ key, present: true })),
|
||
fingerprint,
|
||
valuesPrinted: false,
|
||
},
|
||
valuesPrinted: false,
|
||
},
|
||
{
|
||
mode: "ui-form-memory",
|
||
loginPath: credential.authentication.path,
|
||
usernameSelector: credential.authentication.usernameSelector,
|
||
passwordSelector: credential.authentication.passwordSelector,
|
||
submitSelector: credential.authentication.submitSelector,
|
||
authenticatedPath: credential.authentication.authenticatedPath,
|
||
authenticatedSelector: credential.authentication.authenticatedSelector,
|
||
},
|
||
);
|
||
return {
|
||
...result,
|
||
productSmoke: {
|
||
product: "superapi",
|
||
target: options.target,
|
||
profile: resolved.profileName,
|
||
actor: resolved.profile.actor,
|
||
configRef: resolved.configRef,
|
||
mutation: false,
|
||
valuesPrinted: false,
|
||
},
|
||
valuesPrinted: false,
|
||
};
|
||
}
|
||
|
||
export function superApiManagementSmokeScript(
|
||
profileName: string,
|
||
profile: SmokeProfile,
|
||
configRef: string,
|
||
): string {
|
||
const config = { profileName, configRef, ...profile };
|
||
return String.raw`const config = ${JSON.stringify(config)};
|
||
|
||
export default async function superApiManagementSmoke({ page, goto, wait, screenshot, jsonArtifact, recordStep }) {
|
||
const failures = [];
|
||
const network = [];
|
||
const consoleErrors = [];
|
||
const pages = [];
|
||
const interactions = [];
|
||
const screenshots = [];
|
||
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;
|
||
const item = { method: response.request().method(), path: parsed.pathname, status: response.status() };
|
||
if (network.length < config.outputLimits.network) network.push(item);
|
||
const expectedDenied = config.actor === "user"
|
||
&& response.status() === 403
|
||
&& parsed.pathname === "/api/admin/history";
|
||
if (response.status() >= 400 && !expectedDenied) 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);
|
||
});
|
||
|
||
for (const item of config.pages) {
|
||
await goto(item.path, { selectors: ["html"], readinessTimeoutMs: config.navigationTimeoutMs, attempts: 3 });
|
||
if (config.settleMs > 0) await wait(config.settleMs);
|
||
const dom = await page.evaluate(({ id, readySelector }) => {
|
||
const root = document.querySelector(readySelector);
|
||
const rect = root?.getBoundingClientRect();
|
||
return {
|
||
pageId: root?.getAttribute("data-page") || null,
|
||
title: document.title,
|
||
heading: root?.querySelector("h1")?.textContent?.trim() || null,
|
||
activeNav: document.querySelector('nav a[aria-current="page"]')?.textContent?.trim() || null,
|
||
rowCount: root?.querySelectorAll("tbody tr").length || 0,
|
||
readySelectorPresent: root !== null,
|
||
readySelectorVisible: Boolean(rect && rect.width > 0 && rect.height > 0),
|
||
error: root?.querySelector(".error-bar")?.textContent?.trim() || null,
|
||
expectedPageId: id,
|
||
};
|
||
}, { id: item.id, readySelector: item.readySelector });
|
||
if (dom.pageId !== item.id || !dom.readySelectorPresent || !dom.readySelectorVisible || dom.error) {
|
||
addFailure("页面 " + item.id + " DOM 未就绪");
|
||
}
|
||
const shot = await screenshot(item.screenshotName);
|
||
screenshots.push(shot);
|
||
if (item.interaction === "api-key-bindings" && dom.rowCount > 0) {
|
||
await page.locator(item.readySelector + " tbody tr").first().click();
|
||
const dialog = page.getByRole("dialog", { name: /Skill 绑定/u });
|
||
await dialog.waitFor({ state: "visible", timeout: config.navigationTimeoutMs });
|
||
const select = dialog.getByRole("combobox", { name: "选择要绑定的 Skill" });
|
||
const bindingEvidence = await select.evaluate((element) => {
|
||
const option = element.querySelector("option");
|
||
const rect = element.getBoundingClientRect();
|
||
return {
|
||
placeholder: option?.textContent?.trim() || null,
|
||
visible: rect.width > 0 && rect.height > 0,
|
||
width: Math.round(rect.width),
|
||
};
|
||
});
|
||
const ok = bindingEvidence.placeholder === "请选择要绑定的 Skill"
|
||
&& bindingEvidence.visible
|
||
&& bindingEvidence.width >= 240;
|
||
if (!ok) addFailure("API Key Skill 下拉框占位符或布局不完整");
|
||
const interactionShot = await screenshot("superapi-api-key-bindings.png");
|
||
screenshots.push(interactionShot);
|
||
interactions.push({ id: item.interaction, ok, ...bindingEvidence, screenshot: interactionShot });
|
||
await dialog.getByLabel("关闭", { exact: true }).click();
|
||
if (config.actor === "admin") {
|
||
await page.getByRole("button", { name: "注册 API Key", exact: true }).click();
|
||
const registerDialog = page.getByRole("dialog", { name: "注册 API Key" });
|
||
await registerDialog.waitFor({ state: "visible", timeout: config.navigationTimeoutMs });
|
||
const registrationEvidence = await registerDialog.evaluate((root) => {
|
||
const alias = root.querySelector("input:not([type='password'])");
|
||
const apiKey = root.querySelector("input[type='password']");
|
||
return {
|
||
aliasVisible: Boolean(alias && alias.getBoundingClientRect().width > 0),
|
||
apiKeyVisible: Boolean(apiKey && apiKey.getBoundingClientRect().width > 0),
|
||
providerLoginAbsent: root.querySelector("input[type='email']") === null,
|
||
};
|
||
});
|
||
const registrationOk = registrationEvidence.aliasVisible
|
||
&& registrationEvidence.apiKeyVisible
|
||
&& registrationEvidence.providerLoginAbsent;
|
||
if (!registrationOk) addFailure("管理员 API Key 手工注册入口不完整");
|
||
const registrationShot = await screenshot("superapi-api-key-admin-register.png");
|
||
screenshots.push(registrationShot);
|
||
interactions.push({
|
||
id: "api-key-admin-register",
|
||
ok: registrationOk,
|
||
...registrationEvidence,
|
||
screenshot: registrationShot,
|
||
});
|
||
await registerDialog.getByLabel("关闭", { exact: true }).click();
|
||
}
|
||
}
|
||
if (item.interaction === "github-scan") {
|
||
await page.getByRole("button", { name: "从 GitHub 导入" }).click();
|
||
const dialog = page.getByRole("dialog", { name: "从 GitHub 批量导入 Skill" });
|
||
await dialog.waitFor({ state: "visible", timeout: config.navigationTimeoutMs });
|
||
await dialog.getByRole("button", { name: "扫描仓库" }).click();
|
||
await dialog.locator(".github-skill-list > div").first().waitFor({ state: "visible", timeout: config.navigationTimeoutMs });
|
||
const scanEvidence = await dialog.evaluate((root) => {
|
||
const rows = Array.from(root.querySelectorAll(".github-skill-list > div"));
|
||
const names = rows.map((row) => row.querySelector("strong")?.textContent?.trim() || "");
|
||
const commit = root.querySelector(".github-scan > header code")?.textContent?.trim() || "";
|
||
return { count: rows.length, names, commit };
|
||
});
|
||
const expectedNames = [
|
||
"rtthread-create-bsp", "rtthread-create-pkg", "rtthread-env",
|
||
"rtthread-git-commit", "rtthread-git-rebase", "rtthread-review", "rtthread-simplify",
|
||
];
|
||
const ok = scanEvidence.count === 7
|
||
&& scanEvidence.commit.length === 40
|
||
&& expectedNames.every((name) => scanEvidence.names.includes(name));
|
||
if (!ok) addFailure("GitHub Skill 扫描结果不完整");
|
||
const interactionShot = await screenshot("superapi-github-skill-scan.png");
|
||
screenshots.push(interactionShot);
|
||
interactions.push({ id: item.interaction, ok, ...scanEvidence, screenshot: interactionShot });
|
||
await dialog.locator("footer.modal-actions").getByRole("button", { name: "关闭", exact: true }).click();
|
||
}
|
||
if (item.interaction === "history-detail" && dom.rowCount > 0) {
|
||
await page.locator(item.readySelector + " tbody tr").first().click();
|
||
const dialog = page.getByRole("dialog", { name: "请求详情" });
|
||
await dialog.waitFor({ state: "visible", timeout: config.navigationTimeoutMs });
|
||
const readablePanels = dialog.locator(".readable-columns > section");
|
||
const requestPanel = readablePanels.nth(0);
|
||
const requestSections = await readablePanels.nth(0).locator(".readable-section").evaluateAll((elements) =>
|
||
elements.map((element) => element.getAttribute("data-section-id")));
|
||
const responseSections = await readablePanels.nth(1).locator(".readable-section").evaluateAll((elements) =>
|
||
elements.map((element) => element.getAttribute("data-section-id")));
|
||
const readableVisible = await dialog.locator(".readable-columns").isVisible();
|
||
const skillNode = requestPanel.locator('[data-section-id="skills"] .readable-node').first();
|
||
const toolNode = requestPanel.locator('[data-section-id="tools"] .readable-node').first();
|
||
const skillNodeCount = await requestPanel.locator('[data-section-id="skills"] .readable-node').count();
|
||
const toolNodeCount = await requestPanel.locator('[data-section-id="tools"] .readable-node').count();
|
||
if (skillNodeCount > 0) await skillNode.locator("summary").first().click();
|
||
if (toolNodeCount > 0) await toolNode.locator("summary").first().click();
|
||
const skillDetailFields = skillNodeCount > 0 ? await skillNode.locator(".readable-value").count() : 0;
|
||
const toolDetailFields = toolNodeCount > 0 ? await toolNode.locator(".readable-value").count() : 0;
|
||
const semanticDetailsVisible = (skillNodeCount === 0 || skillDetailFields >= 2)
|
||
&& (toolNodeCount === 0 || toolDetailFields >= 2);
|
||
await dialog.getByRole("button", { name: "Raw", exact: true }).click();
|
||
const rawVisible = await dialog.locator(".raw-columns").isVisible();
|
||
const rawPanelCount = await dialog.locator(".raw-columns > section > pre").count();
|
||
const readableHiddenInRaw = await dialog.locator(".readable-columns").count() === 0;
|
||
await dialog.getByRole("button", { name: "分块", exact: true }).click();
|
||
const readableRestored = await dialog.locator(".readable-columns").isVisible();
|
||
const requestSemantic = ["systemPrompt", "userPrompts", "skills", "tools"].every((id) => requestSections.includes(id));
|
||
const responseSemantic = ["overview", "outputs", "usage"].every((id) => responseSections.includes(id));
|
||
const ok = readableVisible
|
||
&& requestSemantic
|
||
&& responseSemantic
|
||
&& semanticDetailsVisible
|
||
&& rawVisible
|
||
&& rawPanelCount === 2
|
||
&& readableHiddenInRaw
|
||
&& readableRestored;
|
||
if (!ok) addFailure("请求详情分块或 Raw 模式不可用");
|
||
const interactionShot = await screenshot("superapi-history-detail-readable.png");
|
||
screenshots.push(interactionShot);
|
||
interactions.push({
|
||
id: item.interaction,
|
||
ok,
|
||
requestSections,
|
||
responseSections,
|
||
requestSemantic,
|
||
responseSemantic,
|
||
skillNodeCount,
|
||
toolNodeCount,
|
||
skillDetailFields,
|
||
toolDetailFields,
|
||
semanticDetailsVisible,
|
||
readableVisible,
|
||
rawVisible,
|
||
rawPanelCount,
|
||
readableHiddenInRaw,
|
||
readableRestored,
|
||
screenshot: interactionShot,
|
||
});
|
||
await dialog.getByLabel("关闭", { exact: true }).click();
|
||
}
|
||
pages.push({ id: item.id, path: new URL(page.url()).pathname, dom, screenshot: shot });
|
||
recordStep("读取 SuperAPI " + item.id + " 页面", { ok: dom.pageId === item.id && !dom.error, path: new URL(page.url()).pathname, rowCount: dom.rowCount });
|
||
}
|
||
if (config.actor === "user") {
|
||
await goto("/api-keys", { selectors: ["[data-page='api-keys']"], readinessTimeoutMs: config.navigationTimeoutMs, attempts: 3 });
|
||
const accessEvidence = await page.evaluate(() => {
|
||
const navigationLabels = Array.from(document.querySelectorAll("nav a"))
|
||
.map((item) => item.textContent?.trim() || "");
|
||
const registerVisible = Array.from(document.querySelectorAll("button"))
|
||
.some((item) => item.textContent?.includes("注册 API Key"));
|
||
return {
|
||
historyNavigationHidden: !navigationLabels.includes("请求历史"),
|
||
registerVisible,
|
||
};
|
||
});
|
||
const historyResponse = await page.request.get(new URL("/api/admin/history", page.url()).toString(), {
|
||
headers: { accept: "application/json" },
|
||
});
|
||
const historyStatus = historyResponse.status();
|
||
await goto("/history", { selectors: ["[data-page='skills']"], readinessTimeoutMs: config.navigationTimeoutMs, attempts: 3 });
|
||
const deepLinkPath = new URL(page.url()).pathname;
|
||
const accessOk = accessEvidence.historyNavigationHidden
|
||
&& accessEvidence.registerVisible === false
|
||
&& historyStatus === 403
|
||
&& deepLinkPath === "/skills";
|
||
if (!accessOk) addFailure("普通用户请求历史或 API Key 注册入口仍可访问");
|
||
interactions.push({
|
||
id: "ordinary-user-access",
|
||
ok: accessOk,
|
||
...accessEvidence,
|
||
historyStatus,
|
||
deepLinkPath,
|
||
});
|
||
}
|
||
const mutatingRequests = network.filter((item) =>
|
||
!["GET", "HEAD"].includes(item.method)
|
||
&& !(item.method === "POST" && item.path === "/api/admin/skills/github/scan"));
|
||
if (mutatingRequests.length > 0) addFailure("smoke 观察到管理写请求");
|
||
const evidence = {
|
||
profileName: config.profileName,
|
||
configRef: config.configRef,
|
||
pages,
|
||
interactions,
|
||
network: { count: networkCount, items: network, mutatingRequests },
|
||
console: { count: consoleCount, items: consoleErrors },
|
||
failures,
|
||
screenshots,
|
||
mutation: false,
|
||
valuesRedacted: true,
|
||
};
|
||
const report = await jsonArtifact("superapi-management-smoke.json", evidence);
|
||
const ok = failures.length === 0 && pages.length === config.pages.length && mutatingRequests.length === 0;
|
||
return {
|
||
ok,
|
||
status: ok ? "pass" : "blocked",
|
||
...evidence,
|
||
report,
|
||
issueEvidence: {
|
||
ok,
|
||
status: ok ? "pass" : "blocked",
|
||
failedCondition: ok ? null : failures[0] || "SuperAPI 多页面 smoke 未完成",
|
||
result: { pageCount: pages.length, pages, network: evidence.network, console: evidence.console, mutation: false },
|
||
reportPath: report?.path || null,
|
||
reportSha256: report?.sha256 || null,
|
||
screenshots,
|
||
valuesRedacted: true,
|
||
},
|
||
};
|
||
|
||
function safeUrl(value) {
|
||
try { return new URL(value); } catch { return null; }
|
||
}
|
||
}`;
|
||
}
|
||
|
||
function readSuperApiWebProbeConfig(): SuperApiWebProbeConfig {
|
||
const root = Bun.YAML.parse(readFileSync(configPath, "utf8")) as Record<string, unknown>;
|
||
const webProbe = record(root.webProbe, "webProbe");
|
||
if (webProbe.enabled !== true) throw new Error(`${configPath} webProbe.enabled=false`);
|
||
const authentication = record(webProbe.authentication, "webProbe.authentication");
|
||
const userAuthentication = record(webProbe.userAuthentication, "webProbe.userAuthentication");
|
||
const userCredentials = record(userAuthentication.credentials, "webProbe.userAuthentication.credentials");
|
||
const runner = record(webProbe.runner, "webProbe.runner");
|
||
const origin = record(webProbe.origin, "webProbe.origin");
|
||
const exposure = record(root.publicExposure, "publicExposure");
|
||
const auth = record(exposure.managementAuth, "publicExposure.managementAuth");
|
||
const password = record(auth.password, "publicExposure.managementAuth.password");
|
||
const profiles = record(webProbe.smokeProfiles, "webProbe.smokeProfiles");
|
||
return {
|
||
productId: text(webProbe.productId, "webProbe.productId"),
|
||
authentication: {
|
||
mode: authentication.mode === "ui-form-memory"
|
||
? "ui-form-memory"
|
||
: (() => { throw new Error("webProbe.authentication.mode 必须是 ui-form-memory"); })(),
|
||
path: pathValue(authentication.path, "webProbe.authentication.path"),
|
||
usernameSelector: text(authentication.usernameSelector, "webProbe.authentication.usernameSelector"),
|
||
passwordSelector: text(authentication.passwordSelector, "webProbe.authentication.passwordSelector"),
|
||
submitSelector: text(authentication.submitSelector, "webProbe.authentication.submitSelector"),
|
||
authenticatedPath: pathValue(authentication.authenticatedPath, "webProbe.authentication.authenticatedPath"),
|
||
authenticatedSelector: text(authentication.authenticatedSelector, "webProbe.authentication.authenticatedSelector"),
|
||
},
|
||
userAuthentication: {
|
||
mode: userAuthentication.mode === "ui-form-memory"
|
||
? "ui-form-memory"
|
||
: (() => { throw new Error("webProbe.userAuthentication.mode 必须是 ui-form-memory"); })(),
|
||
path: pathValue(userAuthentication.path, "webProbe.userAuthentication.path"),
|
||
providerId: text(userAuthentication.providerId, "webProbe.userAuthentication.providerId"),
|
||
usernameSelector: text(userAuthentication.usernameSelector, "webProbe.userAuthentication.usernameSelector"),
|
||
passwordSelector: text(userAuthentication.passwordSelector, "webProbe.userAuthentication.passwordSelector"),
|
||
submitSelector: text(userAuthentication.submitSelector, "webProbe.userAuthentication.submitSelector"),
|
||
authenticatedPath: pathValue(userAuthentication.authenticatedPath, "webProbe.userAuthentication.authenticatedPath"),
|
||
authenticatedSelector: text(userAuthentication.authenticatedSelector, "webProbe.userAuthentication.authenticatedSelector"),
|
||
credentials: {
|
||
sourceRef: absolutePath(userCredentials.sourceRef, "webProbe.userAuthentication.credentials.sourceRef"),
|
||
usernameKey: text(userCredentials.usernameKey, "webProbe.userAuthentication.credentials.usernameKey"),
|
||
passwordKey: text(userCredentials.passwordKey, "webProbe.userAuthentication.credentials.passwordKey"),
|
||
},
|
||
},
|
||
runner: { node: text(runner.node, "webProbe.runner.node"), lane: text(runner.lane, "webProbe.runner.lane") },
|
||
origin: {
|
||
nativeBaseUrl: httpsUrl(origin.nativeBaseUrl, "webProbe.origin.nativeBaseUrl"),
|
||
browserProxyMode: proxyMode(origin.browserProxyMode),
|
||
},
|
||
defaultSmokeProfile: text(webProbe.defaultSmokeProfile, "webProbe.defaultSmokeProfile"),
|
||
smokeProfiles: Object.fromEntries(Object.entries(profiles).map(([name, value]) => [name, smokeProfile(value, name)])),
|
||
auth: {
|
||
username: text(auth.username, "publicExposure.managementAuth.username"),
|
||
passwordPath: absolutePath(password.sourceRef, "publicExposure.managementAuth.password.sourceRef"),
|
||
},
|
||
};
|
||
}
|
||
|
||
function smokeProfile(value: unknown, name: string): SmokeProfile {
|
||
const input = record(value, `webProbe.smokeProfiles.${name}`);
|
||
const viewport = record(input.viewport, `webProbe.smokeProfiles.${name}.viewport`);
|
||
const limits = record(input.outputLimits, `webProbe.smokeProfiles.${name}.outputLimits`);
|
||
if (!Array.isArray(input.pages) || input.pages.length === 0) throw new Error(`webProbe.smokeProfiles.${name}.pages 必须是非空列表`);
|
||
return {
|
||
actor: input.actor === "admin" || input.actor === "user"
|
||
? input.actor
|
||
: (() => { throw new Error(`webProbe.smokeProfiles.${name}.actor 必须是 admin 或 user`); })(),
|
||
pages: input.pages.map((page, index) => {
|
||
const item = record(page, `webProbe.smokeProfiles.${name}.pages[${index}]`);
|
||
return {
|
||
id: text(item.id, "page.id"),
|
||
path: pathValue(item.path, "page.path"),
|
||
readySelector: text(item.readySelector, "page.readySelector"),
|
||
screenshotName: text(item.screenshotName, "page.screenshotName"),
|
||
interaction: interaction(item.interaction),
|
||
};
|
||
}),
|
||
viewport: { width: integer(viewport.width, "viewport.width"), height: integer(viewport.height, "viewport.height") },
|
||
navigationTimeoutMs: integer(input.navigationTimeoutMs, "navigationTimeoutMs"),
|
||
settleMs: integer(input.settleMs, "settleMs"),
|
||
commandTimeoutSeconds: integer(input.commandTimeoutSeconds, "commandTimeoutSeconds"),
|
||
outputLimits: {
|
||
failures: integer(limits.failures, "outputLimits.failures"),
|
||
network: integer(limits.network, "outputLimits.network"),
|
||
console: integer(limits.console, "outputLimits.console"),
|
||
},
|
||
};
|
||
}
|
||
|
||
function readUserCredential(config: SuperApiWebProbeConfig): {
|
||
username: string;
|
||
password: string;
|
||
sourceRef: string;
|
||
keys: string[];
|
||
authentication: SuperApiWebProbeConfig["userAuthentication"];
|
||
} {
|
||
const source = config.userAuthentication.credentials;
|
||
const parsed = record(
|
||
JSON.parse(readFileSync(source.sourceRef, "utf8")) as unknown,
|
||
"webProbe.userAuthentication.credentials source",
|
||
);
|
||
return {
|
||
username: text(parsed[source.usernameKey], `credential.${source.usernameKey}`),
|
||
password: text(parsed[source.passwordKey], `credential.${source.passwordKey}`),
|
||
sourceRef: source.sourceRef,
|
||
keys: [source.usernameKey, source.passwordKey],
|
||
authentication: config.userAuthentication,
|
||
};
|
||
}
|
||
|
||
function interaction(value: unknown): SmokePage["interaction"] {
|
||
if (value === undefined) return "none";
|
||
if (value === "github-scan" || value === "api-key-bindings" || value === "history-detail") return value;
|
||
throw new Error("page.interaction 必须是 github-scan、api-key-bindings 或 history-detail");
|
||
}
|
||
|
||
function record(value: unknown, name: string): Record<string, unknown> {
|
||
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${name} 必须是对象`);
|
||
return value as Record<string, unknown>;
|
||
}
|
||
|
||
function text(value: unknown, name: string): string {
|
||
if (typeof value !== "string" || value.trim() === "") throw new Error(`${name} 必须是非空字符串`);
|
||
return value.trim();
|
||
}
|
||
|
||
function integer(value: unknown, name: string): number {
|
||
if (!Number.isSafeInteger(value) || Number(value) < 0) throw new Error(`${name} 必须是非负整数`);
|
||
return Number(value);
|
||
}
|
||
|
||
function pathValue(value: unknown, name: string): string {
|
||
const parsed = text(value, name);
|
||
if (!parsed.startsWith("/")) throw new Error(`${name} 必须是绝对 URL path`);
|
||
return parsed;
|
||
}
|
||
|
||
function absolutePath(value: unknown, name: string): string {
|
||
const parsed = text(value, name);
|
||
if (!parsed.startsWith("/")) throw new Error(`${name} 必须是绝对路径`);
|
||
return parsed;
|
||
}
|
||
|
||
function httpsUrl(value: unknown, name: string): string {
|
||
const parsed = new URL(text(value, name));
|
||
if (parsed.protocol !== "https:") throw new Error(`${name} 必须使用 HTTPS`);
|
||
return parsed.toString().replace(/\/$/u, "");
|
||
}
|
||
|
||
function proxyMode(value: unknown): "auto" | "direct" {
|
||
if (value !== "auto" && value !== "direct") throw new Error("webProbe.origin.browserProxyMode 必须是 auto 或 direct");
|
||
return value;
|
||
}
|