Files
pikasTech-unidesk/scripts/src/web-probe-superapi-smoke.ts
T
pikastech e8864d24b3
Pipelines as Code CI / hwlab-web-probe-sentinel-nc01- Success
Pipelines as Code CI / platform-infra-gitea-nc01- Success
Pipelines as Code CI / unidesk-host- Success
feat: support GitHub skill batch import
2026-07-19 05:33:26 +02:00

457 lines
21 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 {
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: { path: string; authenticatedSelector: 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 驱动,并复用统一管理员密码、远端浏览器、内存门禁和证据恢复链。",
"smoke 只读取 Skill、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 password = readFileSync(resolved.config.auth.passwordPath, "utf8").trim();
if (password === "") throw new Error("SuperAPI Web smoke 密码声明为空");
const fingerprint = `sha256:${createHash("sha256").update(password).digest("hex")}`;
const laneSpec = hwlabRuntimeLaneSpecForNode(resolved.config.runner.lane, resolved.config.runner.node);
const result = runManagedNodeWebProbeScript(
resolved.scriptOptions,
laneSpec,
{ username: resolved.config.auth.username, password },
{
product: "superapi",
target: options.target,
source: {
sourceRef: resolved.config.auth.passwordPath,
keys: [{ key: "contents", present: true }],
fingerprint,
valuesPrinted: false,
},
valuesPrinted: false,
},
{
mode: "basic-auth",
loginPath: resolved.config.authentication.path,
authenticatedSelector: resolved.config.authentication.authenticatedSelector,
},
);
return {
...result,
productSmoke: {
product: "superapi",
target: options.target,
profile: resolved.profileName,
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);
if (response.status() >= 400) 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 (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();
await skillNode.locator("summary").first().click();
await toolNode.locator("summary").first().click();
const skillDetailFields = await skillNode.locator(".readable-value").count();
const toolDetailFields = await toolNode.locator(".readable-value").count();
const semanticDetailsVisible = skillDetailFields >= 2 && 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,
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 });
}
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 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: {
path: pathValue(authentication.path, "webProbe.authentication.path"),
authenticatedSelector: text(authentication.authenticatedSelector, "webProbe.authentication.authenticatedSelector"),
},
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 {
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 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;
}