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 { 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 { 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 { 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 { 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 { 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 { 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, }; }