Files
pikasTech-unidesk/scripts/src/web-probe-superapi-smoke.ts
T
pikastech 64c2c19bd1
Pipelines as Code CI / hwlab-web-probe-sentinel-nc01- Success
Pipelines as Code CI / platform-infra-gitea-nc01- Failed
Pipelines as Code CI / unidesk-host- Success
feat: 验收 SuperAPI 普通用户权限
2026-07-19 11:18:07 +02:00

614 lines
29 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 { 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;
}