Files
pikasTech-unidesk/scripts/src/e2e.ts
T
2026-05-05 01:26:13 +00:00

390 lines
24 KiB
TypeScript

import { mkdirSync } from "node:fs";
import { connect } from "node:net";
import { join } from "node:path";
import { chromium } from "playwright";
import { runCommand } from "./command";
import { type UniDeskConfig, repoRoot, rootPath } from "./config";
type CheckStatus = "passed" | "failed";
interface E2ECheck {
name: string;
status: CheckStatus;
detail: unknown;
}
interface PublicUrls {
frontendUrl: string;
providerIngressHealthUrl: string;
providerIngressWsUrl: string;
blockedCoreUrl: string;
blockedDatabaseHost: string;
blockedDatabasePort: number;
}
function publicUrls(config: UniDeskConfig): PublicUrls {
return {
frontendUrl: `http://${config.network.publicHost}:${config.network.frontend.port}`,
providerIngressHealthUrl: `http://${config.network.publicHost}:${config.network.providerIngress.port}/health`,
providerIngressWsUrl: `ws://${config.network.publicHost}:${config.network.providerIngress.port}/ws/provider`,
blockedCoreUrl: `http://${config.network.publicHost}:${config.network.core.port}`,
blockedDatabaseHost: config.network.publicHost,
blockedDatabasePort: config.network.database.port,
};
}
async function fetchProbe(url: string, timeoutMs = 8000): Promise<unknown> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, { signal: controller.signal });
const text = await response.text();
let body: unknown = text;
try { body = text.length > 0 ? JSON.parse(text) : null; } catch { /* keep text body */ }
return { reachable: true, ok: response.ok, status: response.status, body };
} catch (error) {
return { reachable: false, ok: false, error: error instanceof Error ? error.message : String(error) };
} finally {
clearTimeout(timer);
}
}
function tcpProbe(host: string, port: number, timeoutMs = 2500): Promise<unknown> {
return new Promise((resolve) => {
const socket = connect({ host, port });
let settled = false;
const finish = (detail: unknown): void => {
if (settled) return;
settled = true;
socket.destroy();
resolve(detail);
};
socket.setTimeout(timeoutMs);
socket.once("connect", () => finish({ reachable: true, ok: true, host, port }));
socket.once("timeout", () => finish({ reachable: false, ok: false, host, port, error: "timeout" }));
socket.once("error", (error) => finish({ reachable: false, ok: false, host, port, error: error.message }));
});
}
function addCheck(checks: E2ECheck[], name: string, passed: boolean, detail: unknown): void {
checks.push({ name, status: passed ? "passed" : "failed", detail });
}
function runPsql(config: UniDeskConfig, sql: string): { ok: boolean; stdout: string; stderr: string; exitCode: number | null } {
const result = runCommand([
"docker",
"exec",
"unidesk-database",
"psql",
"-U",
config.database.user,
"-d",
config.database.name,
"-v",
"ON_ERROR_STOP=1",
"-tA",
"-c",
sql,
], repoRoot);
return { ok: result.exitCode === 0, stdout: result.stdout.trim(), stderr: result.stderr.trim(), exitCode: result.exitCode };
}
function dockerCoreJson(path: string, init?: { method?: string; body?: unknown }): unknown {
const code = `
const res = await fetch(${JSON.stringify(`http://127.0.0.1:8080${path}`)}, ${JSON.stringify({
method: init?.method ?? "GET",
headers: init?.body === undefined ? undefined : { "content-type": "application/json" },
body: init?.body === undefined ? undefined : JSON.stringify(init.body),
})});
const text = await res.text();
let body = null;
try { body = text ? JSON.parse(text) : null; } catch { body = { text }; }
console.log(JSON.stringify({ ok: res.ok, status: res.status, body }));
`;
const result = runCommand(["docker", "exec", "unidesk-backend-core", "bun", "-e", code], repoRoot);
if (result.exitCode !== 0) return { ok: false, exitCode: result.exitCode, stdout: result.stdout.slice(-1200), stderr: result.stderr.slice(-1200) };
try {
return JSON.parse(result.stdout.trim()) as unknown;
} catch {
return { ok: true, stdout: result.stdout.slice(-1200), stderr: result.stderr.slice(-1200) };
}
}
async function waitForTaskStatus(taskId: string, expected: string, timeoutMs = 10_000): Promise<unknown> {
const started = Date.now();
let latest: unknown = null;
while (Date.now() - started < timeoutMs) {
latest = dockerCoreJson("/api/tasks?limit=40");
const tasks = (latest as { body?: { tasks?: Array<{ id?: string; status?: string; result?: unknown }> } }).body?.tasks ?? [];
const task = tasks.find((item) => item.id === taskId);
if (task?.status === expected) return { ok: true, task };
if (task?.status === "failed") return { ok: false, task };
await Bun.sleep(500);
}
return { ok: false, timeoutMs, latest };
}
function dockerPortSummary(): unknown {
const result = runCommand([
"docker",
"ps",
"--filter",
"label=com.docker.compose.project=unidesk",
"--format",
"{{.Names}}\t{{.Ports}}",
], repoRoot);
return {
ok: result.exitCode === 0,
exitCode: result.exitCode,
rows: result.stdout.trim().split("\n").filter(Boolean).map((line) => {
const [name, ports = ""] = line.split("\t");
return { name, ports };
}),
stderr: result.stderr.trim(),
};
}
function dockerStatusCheckDetail(dockerStatus: unknown, providerId: string): unknown {
const response = dockerStatus as { ok?: boolean; status?: number; body?: { dockerStatuses?: Array<{ providerId?: string; name?: string; nodeStatus?: string; updatedAt?: string; dockerStatus?: { ok?: boolean; socketPresent?: boolean; collectedAt?: string; counts?: unknown; daemon?: unknown; containers?: Array<{ id?: string; name?: string; image?: string; state?: string; status?: string; ports?: string }> } }> } };
const item = response.body?.dockerStatuses?.find((entry) => entry.providerId === providerId);
return {
ok: response.ok,
status: response.status,
providerId,
nodeStatus: item?.nodeStatus,
updatedAt: item?.updatedAt,
dockerStatus: item?.dockerStatus === undefined ? null : {
ok: item.dockerStatus.ok,
socketPresent: item.dockerStatus.socketPresent,
collectedAt: item.dockerStatus.collectedAt,
counts: item.dockerStatus.counts,
daemon: item.dockerStatus.daemon,
containersPreview: (item.dockerStatus.containers ?? []).slice(0, 8).map((container) => ({
id: container.id,
name: container.name,
image: container.image,
state: container.state,
status: container.status,
ports: container.ports,
})),
},
};
}
function systemStatusCheckDetail(systemStatus: unknown, providerId: string): unknown {
const response = systemStatus as { ok?: boolean; status?: number; body?: { systemStatuses?: Array<{ providerId?: string; name?: string; nodeStatus?: string; updatedAt?: string; current?: { ok?: boolean; collectedAt?: string; cpu?: unknown; memory?: unknown; disk?: unknown }; history?: unknown[] }> } };
const item = response.body?.systemStatuses?.find((entry) => entry.providerId === providerId);
return {
ok: response.ok,
status: response.status,
providerId,
nodeStatus: item?.nodeStatus,
updatedAt: item?.updatedAt,
current: item?.current === undefined ? null : {
ok: item.current.ok,
collectedAt: item.current.collectedAt,
cpu: item.current.cpu,
memory: item.current.memory,
disk: item.current.disk,
},
historyCount: item?.history?.length ?? 0,
historyPreview: (item?.history ?? []).slice(-8),
};
}
async function exposureChecks(config: UniDeskConfig, urls: PublicUrls, checks: E2ECheck[]): Promise<void> {
const portSummary = dockerPortSummary() as { rows?: Array<{ name: string; ports: string }> };
const portsText = (portSummary.rows ?? []).map((row) => `${row.name} ${row.ports}`).join("\n");
const corePublic = await fetchProbe(`${urls.blockedCoreUrl}/health`, 2500);
const databasePublic = await tcpProbe(urls.blockedDatabaseHost, urls.blockedDatabasePort);
addCheck(checks, "network:only-frontend-provider-ports", !portsText.includes(`:${config.network.core.port}->`) && !portsText.includes(`:${config.network.database.port}->`), portSummary);
addCheck(checks, "network:core-public-blocked", (corePublic as { reachable?: boolean }).reachable === false, corePublic);
addCheck(checks, "network:database-public-blocked", (databasePublic as { reachable?: boolean }).reachable === false, databasePublic);
}
async function serviceChecks(config: UniDeskConfig, urls: PublicUrls, checks: E2ECheck[]): Promise<void> {
const coreOverview = dockerCoreJson("/api/overview");
const coreNodes = dockerCoreJson("/api/nodes");
const systemStatus = dockerCoreJson("/api/nodes/system-status?limit=24");
const dockerStatus = dockerCoreJson("/api/nodes/docker-status");
const providerIngress = await fetchProbe(urls.providerIngressHealthUrl);
const overviewBody = (coreOverview as { body?: { ok?: boolean; dbReady?: boolean; onlineNodeCount?: number } }).body;
const nodeList = (coreNodes as { body?: { nodes?: Array<{ providerId?: string; status?: string }> } }).body?.nodes ?? [];
const systemStatuses = (systemStatus as { body?: { systemStatuses?: Array<{ providerId?: string; current?: { cpu?: { percent?: number }; memory?: { percent?: number; mode?: string; cacheBytes?: number }; disk?: { percent?: number } }; history?: unknown[] }> } }).body?.systemStatuses ?? [];
const mainSystem = systemStatuses.find((item) => item.providerId === config.providerGateway.id);
const dockerStatuses = (dockerStatus as { body?: { dockerStatuses?: Array<{ providerId?: string; dockerStatus?: { counts?: { containers?: number }; containers?: unknown[] } }> } }).body?.dockerStatuses ?? [];
const mainDocker = dockerStatuses.find((item) => item.providerId === config.providerGateway.id);
addCheck(checks, "core:internal-overview", (coreOverview as { ok?: boolean }).ok === true && overviewBody?.ok === true && overviewBody.dbReady === true && (overviewBody.onlineNodeCount ?? 0) >= 1, coreOverview);
addCheck(checks, "provider:self-node-online", nodeList.some((node) => node.providerId === config.providerGateway.id && node.status === "online"), coreNodes);
addCheck(checks, "provider:system-status", (systemStatus as { ok?: boolean }).ok === true && mainSystem?.current !== undefined && Number.isFinite(mainSystem.current.cpu?.percent) && Number.isFinite(mainSystem.current.memory?.percent) && mainSystem.current.memory?.mode === "actual_without_cache" && Number.isFinite(mainSystem.current.memory?.cacheBytes) && Number.isFinite(mainSystem.current.disk?.percent) && (mainSystem.history?.length ?? 0) > 0, systemStatusCheckDetail(systemStatus, config.providerGateway.id));
addCheck(checks, "provider:docker-status", (dockerStatus as { ok?: boolean }).ok === true && mainDocker?.dockerStatus !== undefined && ((mainDocker.dockerStatus.counts?.containers ?? 0) > 0 || (mainDocker.dockerStatus.containers?.length ?? 0) > 0), dockerStatusCheckDetail(dockerStatus, config.providerGateway.id));
const upgradeDispatch = dockerCoreJson("/api/dispatch", {
method: "POST",
body: { providerId: config.providerGateway.id, command: "provider.upgrade", payload: { source: "cli-e2e", mode: "plan" } },
});
const upgradeTaskId = (upgradeDispatch as { body?: { taskId?: string } }).body?.taskId ?? "";
const upgradeTask = upgradeTaskId ? await waitForTaskStatus(upgradeTaskId, "succeeded") : { ok: false, error: "missing taskId", upgradeDispatch };
const taskResult = (upgradeTask as { task?: { result?: { plan?: unknown; mode?: string } }; ok?: boolean }).task?.result;
addCheck(checks, "provider:upgrade-plan", (upgradeDispatch as { ok?: boolean }).ok === true && (upgradeTask as { ok?: boolean }).ok === true && taskResult?.mode === "plan" && taskResult.plan !== undefined, { upgradeDispatch, upgradeTask });
addCheck(checks, "provider-ingress:public-health", (providerIngress as { ok?: boolean; body?: { ok?: boolean } }).ok === true && (providerIngress as { body?: { ok?: boolean } }).body?.ok === true, providerIngress);
}
function databaseChecks(config: UniDeskConfig, checks: E2ECheck[]): string {
const markerId = `e2e_${Date.now()}_${Math.random().toString(16).slice(2, 8)}`;
const failedTaskId = `task_${markerId}_failed_diagnostic`;
const markerSql = `
CREATE TABLE IF NOT EXISTS unidesk_e2e_markers (
id TEXT PRIMARY KEY,
source TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
INSERT INTO unidesk_e2e_markers (id, source) VALUES ('${markerId}', 'cli-e2e');
INSERT INTO unidesk_tasks (id, provider_id, command, status, payload, result, created_at, updated_at)
VALUES (
'${failedTaskId}',
'${config.providerGateway.id}',
'echo',
'failed',
'{"source":"cli-e2e","case":"history-diagnostics"}'::jsonb,
'{"error":"e2e forced failure for diagnostics","exitCode":23,"stderr":"simulated provider failure"}'::jsonb,
now() - interval '83 seconds',
now()
);
SELECT 'marker=' || id FROM unidesk_e2e_markers WHERE id = '${markerId}';
SELECT 'marker_count=' || count(*) FROM unidesk_e2e_markers;
SELECT 'failed_task=' || id FROM unidesk_tasks WHERE id = '${failedTaskId}' AND status = 'failed';
SELECT 'online_main_server=' || count(*) FROM unidesk_nodes WHERE provider_id = '${config.providerGateway.id}' AND status = 'online';
`;
const marker = runPsql(config, markerSql);
addCheck(checks, "database:named-volume-write", marker.ok && marker.stdout.includes(`marker=${markerId}`), marker);
addCheck(checks, "database:provider-state", marker.ok && marker.stdout.includes("online_main_server=1"), marker);
return markerId;
}
async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2ECheck[]): Promise<{ screenshotPath: string; bodyText: string; consoleErrors: string[] }> {
const e2eDir = rootPath(".state", "e2e");
mkdirSync(e2eDir, { recursive: true });
const screenshotPath = join(e2eDir, `${new Date().toISOString().replace(/[-:.TZ]/g, "")}_frontend.png`);
const consoleErrors: string[] = [];
const browser = await chromium.launch({ headless: true });
try {
const page = await browser.newPage({ viewport: { width: 1440, height: 920 } });
page.on("console", (message) => {
if (message.type() === "error") consoleErrors.push(message.text());
});
page.on("pageerror", (error) => consoleErrors.push(error.message));
await page.goto(urls.frontendUrl, { waitUntil: "domcontentloaded", timeout: 15000 });
await page.waitForSelector('[data-testid="login-screen"]', { timeout: 10000 });
await page.fill('input[name="username"]', config.auth.username);
await page.fill('input[name="password"]', config.auth.password);
await page.click('button[type="submit"]');
await page.waitForSelector('[data-testid="app-shell"]', { timeout: 10000 });
await page.waitForFunction(() => document.querySelector('[data-testid="conn-text"]')?.textContent?.includes("核心在线"), undefined, { timeout: 15000 });
const landedUrl = page.url();
const publicOrigin = new URL(urls.frontendUrl).origin;
const landed = new URL(landedUrl);
const publicFrontendReached = landed.origin === publicOrigin && !["127.0.0.1", "localhost", "::1"].includes(landed.hostname);
await page.waitForSelector(`text=${config.providerGateway.id}`, { timeout: 10000 });
await page.waitForSelector(`text=${config.providerGateway.name}`, { timeout: 10000 });
await page.setViewportSize({ width: 390, height: 860 });
const mobileRailHeights: number[] = [];
for (const moduleLabel of ["运行总览", "资源节点", "任务调度", "系统配置"]) {
await page.getByRole("button", { name: new RegExp(moduleLabel) }).click();
await page.waitForTimeout(80);
const height = await page.locator(".rail").evaluate((element) => Math.round(element.getBoundingClientRect().height));
mobileRailHeights.push(height);
}
const mobileRailMax = Math.max(...mobileRailHeights);
const mobileRailMin = Math.min(...mobileRailHeights);
await page.getByRole("button", { name: /任务调度/ }).click();
await page.getByRole("button", { name: /待处理任务/ }).click();
await page.waitForSelector('[data-testid="pending-task-page"]', { timeout: 5000 });
const mobileContentMetrics = await page.locator('[data-testid="pending-task-page"]').evaluate((element) => {
const pageTop = element.getBoundingClientRect().top;
const empty = element.querySelector(".empty-state");
const emptyBox = empty?.getBoundingClientRect();
const emptyStrong = empty?.querySelector("strong")?.getBoundingClientRect();
return {
pageTop: Math.round(pageTop),
emptyTextOffset: emptyBox && emptyStrong ? Math.round(emptyStrong.top - emptyBox.top) : 0,
};
});
await page.setViewportSize({ width: 1440, height: 920 });
await page.getByRole("button", { name: /运行总览/ }).click();
await page.getByRole("button", { name: /态势总览/ }).click();
const bodyText = await page.locator("body").innerText({ timeout: 5000 });
const rawBlocksBefore = await page.locator("pre.raw-json").count();
const nakedJsonText = bodyText.includes('{"') || bodyText.includes('"providerId"') || bodyText.includes('"labels"');
await page.getByTestId("pending-task-card").click();
await page.waitForSelector('[data-testid="pending-task-page"]', { timeout: 5000 });
const pendingTaskText = await page.locator('[data-testid="pending-task-page"]').innerText({ timeout: 5000 });
await page.getByRole("button", { name: /任务历史/ }).click();
await page.waitForSelector('[data-testid="task-history-page"]', { timeout: 5000 });
const taskHistoryText = await page.locator('[data-testid="task-history-page"]').innerText({ timeout: 5000 });
await page.getByRole("button", { name: /运行总览/ }).click();
await page.getByRole("button", { name: /态势总览/ }).click();
await page.screenshot({ path: screenshotPath, fullPage: true });
await page.getByTestId(`raw-node-${config.providerGateway.id.replace(/[^a-zA-Z0-9_-]/g, "_")}`).click();
await page.waitForSelector('[data-testid="raw-json"]', { timeout: 5000 });
const rawText = await page.locator('[data-testid="raw-json"]').innerText({ timeout: 5000 });
await page.getByRole("button", { name: "关闭" }).click();
await page.getByRole("button", { name: /资源节点/ }).click();
await page.getByRole("button", { name: /资源监控/ }).click();
await page.waitForSelector('[data-testid="node-monitor-page"]', { timeout: 10000 });
await page.locator('[data-testid="node-monitor-page"]').getByRole("button", { name: new RegExp(config.providerGateway.id) }).click();
await page.waitForSelector('[data-testid="metric-chart-cpu"]', { timeout: 10000 });
await page.waitForSelector('[data-testid="metric-chart-memory"]', { timeout: 10000 });
await page.waitForSelector('[data-testid="metric-chart-disk"]', { timeout: 10000 });
await page.waitForFunction(() => {
const text = document.body.innerText.toLowerCase();
return text.includes("任务管理器视图") && text.includes("cpu") && text.includes("memory") && text.includes("disk") && text.includes("不含缓存");
}, undefined, { timeout: 10000 });
const monitorText = await page.locator('[data-testid="node-monitor-page"]').innerText({ timeout: 5000 });
await page.getByTestId("upgrade-plan-button").click();
await page.waitForFunction(() => document.body.innerText.includes("预检升级 已下发"), undefined, { timeout: 10000 });
const upgradeControlText = await page.locator('[data-testid="provider-upgrade-control"]').innerText({ timeout: 5000 });
await page.getByRole("button", { name: /Docker 状态/ }).click();
await page.waitForSelector('[data-testid="docker-status-page"]', { timeout: 10000 });
await page.locator('[data-testid="docker-status-page"]').getByRole("button", { name: new RegExp(config.providerGateway.id) }).click();
await page.waitForSelector('[data-testid="docker-container-table"]', { timeout: 10000 });
await page.waitForSelector('[data-testid="database-volume-card"]', { timeout: 10000 });
await page.waitForFunction(() => {
const text = document.body.innerText.toLowerCase();
return text.includes("docker desktop 视图") && text.includes("containers") && text.includes("unidesk_pgdata_10gb");
}, undefined, { timeout: 10000 });
const dockerText = await page.locator('[data-testid="docker-status-page"]').innerText({ timeout: 5000 });
addCheck(checks, "frontend:login-provider-visible", bodyText.includes(config.providerGateway.id) && bodyText.includes(config.providerGateway.name) && bodyText.includes("核心在线"), { screenshotPath });
addCheck(checks, "frontend:public-provider-info-visible", publicFrontendReached && bodyText.includes(config.providerGateway.id) && bodyText.includes(config.providerGateway.name) && rawText.includes('"status": "online"') && rawText.includes(`"providerId": "${config.providerGateway.id}"`), { frontendUrl: urls.frontendUrl, landedUrl, providerId: config.providerGateway.id, rawTextPreview: rawText.slice(0, 400) });
addCheck(checks, "frontend:mobile-nav-fixed-height", mobileRailMax - mobileRailMin <= 1 && mobileRailMax <= 44, { mobileRailHeights });
addCheck(checks, "frontend:mobile-content-top-aligned", mobileContentMetrics.pageTop <= 190 && mobileContentMetrics.emptyTextOffset <= 14, { mobileContentMetrics });
addCheck(checks, "frontend:pending-task-drilldown", pendingTaskText.includes("待处理任务") && (pendingTaskText.includes("当前无待处理任务") || (pendingTaskText.includes("Provider") && pendingTaskText.includes("已等待"))), { pendingTaskPreview: pendingTaskText.slice(0, 600) });
addCheck(checks, "frontend:task-history-diagnostics", taskHistoryText.includes("任务耗时") && taskHistoryText.includes("诊断信息") && taskHistoryText.includes("失败原因") && taskHistoryText.includes("e2e forced failure for diagnostics"), { taskHistoryPreview: taskHistoryText.slice(0, 900) });
addCheck(checks, "frontend:no-naked-json-before-click", rawBlocksBefore === 0 && !nakedJsonText, { rawBlocksBefore, nakedJsonText });
addCheck(checks, "frontend:raw-json-explicit-button", rawText.includes('"providerId"') && rawText.includes(config.providerGateway.id), { rawTextPreview: rawText.slice(0, 400) });
addCheck(checks, "frontend:system-monitor-visible", monitorText.includes("任务管理器视图") && monitorText.includes("CPU") && monitorText.includes("Memory") && monitorText.includes("Disk") && monitorText.includes("不含缓存"), { monitorTextPreview: monitorText.slice(0, 800) });
addCheck(checks, "frontend:upgrade-plan-dispatch", upgradeControlText.includes("预检升级 已下发"), { providerId: config.providerGateway.id, upgradeControlPreview: upgradeControlText.slice(0, 500) });
addCheck(checks, "frontend:docker-status-visible", dockerText.toLowerCase().includes("docker desktop 视图") && dockerText.toLowerCase().includes("containers") && dockerText.includes("unidesk_pgdata_10gb") && (dockerText.includes("unidesk-frontend") || dockerText.includes("unidesk-backend-core")), { dockerTextPreview: dockerText.slice(0, 800) });
addCheck(checks, "frontend:no-console-errors", consoleErrors.length === 0, { consoleErrors });
return { screenshotPath, bodyText, consoleErrors };
} finally {
await browser.close();
}
}
export async function runE2E(config: UniDeskConfig): Promise<unknown> {
const checks: E2ECheck[] = [];
const urls = publicUrls(config);
await exposureChecks(config, urls, checks);
await serviceChecks(config, urls, checks);
const markerId = databaseChecks(config, checks);
const frontend = await frontendCheck(config, urls, checks);
const ok = checks.every((check) => check.status === "passed");
return {
ok,
urls,
markerId,
screenshotPath: frontend.screenshotPath,
checks,
};
}