import { mkdirSync, readFileSync, writeFileSync } 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"; import { boundedJsonDetail } from "./preview"; 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; } export interface E2ERunOptions { only: string[]; skip: string[]; } const NETWORK_CHECK_NAMES = [ "network:only-frontend-provider-ports", "network:core-public-blocked", "network:database-public-blocked", "network:findjob-public-blocked", "network:met-nonlinear-public-blocked", "network:todo-note-public-blocked", ] as const; const SERVICE_CHECK_NAMES = [ "core:internal-overview", "core:pgdata-usage", "provider:self-node-online", "provider:gateway-version-label", "provider:system-status", "provider:process-resource-status", "provider:docker-status", "provider:upgrade-plan", "provider-ingress:public-health", "microservice:catalog-findjob", "microservice:catalog-pipeline", "microservice:catalog-met-nonlinear", "microservice:catalog-todo-note", "microservice:findjob-status", "microservice:findjob-health", "microservice:findjob-summary", "microservice:findjob-jobs-preview", "microservice:pipeline-status", "microservice:pipeline-health", "microservice:pipeline-snapshot", "microservice:met-nonlinear-status", "microservice:met-nonlinear-health", "microservice:met-nonlinear-queue", "microservice:met-nonlinear-projects", "microservice:met-nonlinear-image", "microservice:todo-note-status", "microservice:todo-note-health", "microservice:todo-note-migrated-data", "microservice:todo-note-write-path", ] as const; const DATABASE_CHECK_NAMES = [ "database:named-volume-write", "database:provider-state", "database:todo-note-pg-storage", ] as const; const FRONTEND_CHECK_NAMES = [ "frontend:login-provider-visible", "frontend:public-provider-info-visible", "frontend:sidebar-collapse", "frontend:mobile-nav-fixed-height", "frontend:mobile-content-top-aligned", "frontend:pending-task-drilldown", "frontend:task-history-diagnostics", "frontend:no-naked-json-before-click", "frontend:raw-json-explicit-button", "frontend:system-monitor-visible", "frontend:process-resource-sorting", "frontend:upgrade-plan-dispatch", "frontend:docker-status-visible", "frontend:gateway-version-records-visible", "frontend:gateway-duration-subsecond-visible", "frontend:provider-operation-availability-visible", "frontend:overview-pgdata-visible", "frontend:microservice-catalog-visible", "frontend:todo-note-integrated-visible", "frontend:findjob-integrated-visible", "frontend:url-route-deeplink", "frontend:pipeline-integrated-visible", "frontend:pipeline-react-flow-visible", "frontend:pipeline-gantt-defaults", "frontend:pipeline-step-timeline-visible", "frontend:met-nonlinear-integrated-visible", "frontend:met-nonlinear-project-tree-detail", "frontend:met-nonlinear-queue-detail-speed", "frontend:no-console-errors", ] as const; const ALL_E2E_CHECK_NAMES = [ ...NETWORK_CHECK_NAMES, ...SERVICE_CHECK_NAMES, ...DATABASE_CHECK_NAMES, ...FRONTEND_CHECK_NAMES, ] as const; function uniqueText(values: string[]): string[] { const seen = new Set(); const ordered: string[] = []; for (const value of values) { if (!value || seen.has(value)) continue; seen.add(value); ordered.push(value); } return ordered; } function listOptionValues(args: string[], name: string): string[] { const values: string[] = []; for (let index = 0; index < args.length; index += 1) { if (args[index] !== name) continue; const raw = args[index + 1]; if (raw === undefined || raw.length === 0 || raw.startsWith("--")) throw new Error(`${name} requires a non-empty value`); values.push(raw); } return uniqueText(values.flatMap((value) => value.split(",")).map((value) => value.trim()).filter(Boolean)); } export function parseE2ERunOptions(args: string[]): E2ERunOptions { return { only: listOptionValues(args, "--only"), skip: listOptionValues(args, "--skip"), }; } function pipelineSnapshotConfigObject(pipeline: any): Record { return pipeline && typeof pipeline?.config === "object" && !Array.isArray(pipeline.config) ? pipeline.config as Record : {}; } function pipelineSnapshotNodeIds(value: any): string[] { if (Array.isArray(value)) return value.map((item) => typeof item === "string" ? item : String(item?.id || item?.nodeId || "")).filter(Boolean); if (Array.isArray(value?.nodes)) return pipelineSnapshotNodeIds(value.nodes); if (Array.isArray(value?.nodeIds)) return pipelineSnapshotNodeIds(value.nodeIds); return []; } function pipelineSnapshotEdges(pipeline: any): Array<{ source: string; target: string; edgeType: string }> { const config = pipelineSnapshotConfigObject(pipeline); const rawEdges = Array.isArray(config.edges) ? config.edges : Array.isArray(pipeline?.edges) ? pipeline.edges : []; return rawEdges.map((edge: any) => ({ source: String(edge?.from || edge?.source || ""), target: String(edge?.to || edge?.target || ""), edgeType: String(edge?.edgeType || ""), })).filter((edge) => edge.source && edge.target); } function pipelineSnapshotNodes(pipeline: any): string[] { const config = pipelineSnapshotConfigObject(pipeline); const rawNodes = Array.isArray(config.nodes) ? config.nodes : Array.isArray(pipeline?.nodes) ? pipeline.nodes : []; const nodeIds = new Set(rawNodes.map((node: any) => String(node?.id || node?.nodeId || "")).filter(Boolean)); const rawBatches = Array.isArray(config.topologicalBatches) ? config.topologicalBatches : Array.isArray(pipeline?.topologicalBatches) ? pipeline.topologicalBatches : []; for (const batch of rawBatches) pipelineSnapshotNodeIds(batch).forEach((nodeId) => nodeIds.add(nodeId)); for (const edge of pipelineSnapshotEdges(pipeline)) { nodeIds.add(edge.source); nodeIds.add(edge.target); } return Array.from(nodeIds); } function pipelineSnapshotNodeOrder(pipeline: any): string[] { const config = pipelineSnapshotConfigObject(pipeline); const rawBatches = Array.isArray(config.topologicalBatches) ? config.topologicalBatches : Array.isArray(pipeline?.topologicalBatches) ? pipeline.topologicalBatches : []; const explicit = rawBatches.map((batch: any) => pipelineSnapshotNodeIds(batch)).filter((batch: string[]) => batch.length > 0); if (explicit.length > 0) return explicit.flatMap((batch: string[]) => batch); const nodeIds = pipelineSnapshotNodes(pipeline); const idSet = new Set(nodeIds); const incoming = new Map(nodeIds.map((nodeId) => [nodeId, 0])); const outgoing = new Map(nodeIds.map((nodeId) => [nodeId, [] as string[]])); for (const edge of pipelineSnapshotEdges(pipeline).filter((item) => item.edgeType.toLowerCase() !== "rework")) { if (!idSet.has(edge.source) || !idSet.has(edge.target)) continue; outgoing.get(edge.source)?.push(edge.target); incoming.set(edge.target, (incoming.get(edge.target) || 0) + 1); } const levels = new Map(); const queue = nodeIds.filter((nodeId) => (incoming.get(nodeId) || 0) === 0); for (const nodeId of queue) levels.set(nodeId, 0); while (queue.length > 0) { const current = queue.shift()!; const nextLevel = (levels.get(current) || 0) + 1; for (const next of outgoing.get(current) || []) { incoming.set(next, Math.max(0, (incoming.get(next) || 0) - 1)); levels.set(next, Math.max(levels.get(next) || 0, nextLevel)); if ((incoming.get(next) || 0) === 0) queue.push(next); } } nodeIds.forEach((nodeId) => { if (!levels.has(nodeId)) levels.set(nodeId, 0); }); const maxLevel = Math.max(0, ...Array.from(levels.values())); return Array.from({ length: maxLevel + 1 }, (_item, level) => nodeIds.filter((nodeId) => levels.get(nodeId) === level)).flatMap((batch) => batch); } function escapedPatternRegex(value: string): string { return value.replace(/[|\\{}()[\]^$+?.]/g, "\\$&"); } function matchCheckPattern(name: string, pattern: string): boolean { const normalizedName = String(name || ""); const normalizedPattern = String(pattern || "").trim(); if (!normalizedPattern) return false; if (normalizedPattern.includes("*")) { const regex = new RegExp(`^${escapedPatternRegex(normalizedPattern).replace(/\\\*/g, ".*")}$`); return regex.test(normalizedName); } return normalizedName === normalizedPattern || normalizedName.startsWith(`${normalizedPattern}:`) || normalizedName.startsWith(`${normalizedPattern}-`); } function wantsCheck(options: E2ERunOptions, name: string): boolean { const included = options.only.length === 0 || options.only.some((pattern) => matchCheckPattern(name, pattern)); const skipped = options.skip.some((pattern) => matchCheckPattern(name, pattern)); return included && !skipped; } function wantsAnyCheck(options: E2ERunOptions, names: string[]): boolean { return names.some((name) => wantsCheck(options, name)); } function wantsPrefix(options: E2ERunOptions, prefix: string): boolean { if (options.only.length === 0) return !options.skip.some((pattern) => matchCheckPattern(`${prefix}:probe`, pattern) || matchCheckPattern(prefix, pattern)); return options.only.some((pattern) => { const normalizedPattern = String(pattern || "").trim(); return normalizedPattern === prefix || normalizedPattern.startsWith(`${prefix}:`) || normalizedPattern.startsWith(`${prefix}-`) || matchCheckPattern(`${prefix}:probe`, normalizedPattern); }) && !options.skip.some((pattern) => matchCheckPattern(`${prefix}:probe`, pattern) || matchCheckPattern(prefix, pattern)); } 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: boundedJsonDetail(detail, passed ? 4_000 : 24_000, { maxDepth: passed ? 3 : 5, maxArrayItems: passed ? 3 : 8, maxObjectKeys: passed ? 12 : 40, maxStringLength: passed ? 600 : 1600, }), }); } function addSelectedCheck(checks: E2ECheck[], options: E2ERunOptions, name: string, passed: boolean, detail: unknown): void { if (!wantsCheck(options, name)) return; addCheck(checks, name, passed, detail); } function safeTestId(value: string): string { return value.replace(/[^a-zA-Z0-9_-]/g, "_"); } function providerGatewayPackageVersion(): string { try { const raw = readFileSync(rootPath("src", "components", "provider-gateway", "package.json"), "utf8"); const parsed = JSON.parse(raw) as { version?: unknown }; return typeof parsed.version === "string" ? parsed.version : ""; } catch { return ""; } } 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", "--filter", "label=com.docker.compose.oneoff=False", "--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[], options: E2ERunOptions): 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); const findjobPublic = await fetchProbe(`http://${config.network.publicHost}:3254/api/health`, 2500); const metNonlinearPublic = await fetchProbe(`http://${config.network.publicHost}:3288/health`, 2500); const todoNotePublic = await fetchProbe(`http://${config.network.publicHost}:4211/api/health`, 2500); addSelectedCheck(checks, options, "network:only-frontend-provider-ports", !portsText.includes(`:${config.network.core.port}->`) && !portsText.includes(`:${config.network.database.port}->`), portSummary); addSelectedCheck(checks, options, "network:core-public-blocked", (corePublic as { reachable?: boolean }).reachable === false, corePublic); addSelectedCheck(checks, options, "network:database-public-blocked", (databasePublic as { reachable?: boolean }).reachable === false, databasePublic); addSelectedCheck(checks, options, "network:findjob-public-blocked", (findjobPublic as { reachable?: boolean }).reachable === false, findjobPublic); addSelectedCheck(checks, options, "network:met-nonlinear-public-blocked", (metNonlinearPublic as { reachable?: boolean }).reachable === false, metNonlinearPublic); addSelectedCheck(checks, options, "network:todo-note-public-blocked", (todoNotePublic as { reachable?: boolean }).reachable === false, todoNotePublic); } async function serviceChecks(config: UniDeskConfig, urls: PublicUrls, checks: E2ECheck[], options: E2ERunOptions): 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 microservices = dockerCoreJson("/api/microservices"); const findjobStatus = dockerCoreJson("/api/microservices/findjob/status"); const findjobHealth = dockerCoreJson("/api/microservices/findjob/health"); const findjobSummary = dockerCoreJson("/api/microservices/findjob/proxy/api/summary"); const findjobJobsPreview = dockerCoreJson("/api/microservices/findjob/proxy/api/jobs?__unideskArrayLimit=jobs:5"); const pipelineStatus = dockerCoreJson("/api/microservices/pipeline/status"); const pipelineHealth = dockerCoreJson("/api/microservices/pipeline/health"); const pipelineSnapshot = dockerCoreJson("/api/microservices/pipeline/proxy/api/snapshot?__unideskArrayLimit=registry.components:8,runs:3"); const metNonlinearStatus = dockerCoreJson("/api/microservices/met-nonlinear/status"); const metNonlinearHealth = dockerCoreJson("/api/microservices/met-nonlinear/health"); const metNonlinearQueue = dockerCoreJson("/api/microservices/met-nonlinear/proxy/api/queue?__unideskArrayLimit=jobs:10"); const metNonlinearProjects = dockerCoreJson("/api/microservices/met-nonlinear/proxy/api/projects?root=projects&limit=20&__unideskArrayLimit=projects:20"); const metNonlinearImages = dockerCoreJson("/api/microservices/met-nonlinear/proxy/api/images"); const todoNoteStatus = dockerCoreJson("/api/microservices/todo-note/status"); const todoNoteHealth = dockerCoreJson("/api/microservices/todo-note/health"); const todoNoteInstances = dockerCoreJson("/api/microservices/todo-note/proxy/api/instances"); const todoE2eName = `E2E Todo ${Date.now()}`; const todoNoteCreate = dockerCoreJson("/api/microservices/todo-note/proxy/api/instances", { method: "POST", body: { name: todoE2eName } }); const todoCreatedId = (todoNoteCreate as { body?: { id?: string } }).body?.id ?? ""; const todoNoteAdd = todoCreatedId ? dockerCoreJson(`/api/microservices/todo-note/proxy/api/instances/${encodeURIComponent(todoCreatedId)}/actions`, { method: "POST", body: { action: { type: "addTodo", title: "E2E migrated write path" } } }) : { ok: false, error: "missing created todo note id", todoNoteCreate }; const todoAddedItems = (todoNoteAdd as { body?: { todos?: Array<{ id?: string }> } }).body?.todos ?? []; const todoAddedId = todoAddedItems[0]?.id ?? ""; const todoNoteToggle = todoCreatedId && todoAddedId ? dockerCoreJson(`/api/microservices/todo-note/proxy/api/instances/${encodeURIComponent(todoCreatedId)}/actions`, { method: "POST", body: { action: { type: "toggleTodoCompleted", todoId: todoAddedId } } }) : { ok: false, error: "missing todo id", todoNoteAdd }; const todoNoteUndo = todoCreatedId ? dockerCoreJson(`/api/microservices/todo-note/proxy/api/instances/${encodeURIComponent(todoCreatedId)}/undo`, { method: "POST", body: {} }) : { ok: false, error: "missing created todo note id" }; const todoNoteDelete = todoCreatedId ? dockerCoreJson(`/api/microservices/todo-note/proxy/api/instances/${encodeURIComponent(todoCreatedId)}`, { method: "DELETE" }) : { ok: false, error: "missing created todo note id" }; const providerIngress = await fetchProbe(urls.providerIngressHealthUrl); const overviewBody = (coreOverview as { body?: { ok?: boolean; dbReady?: boolean; onlineNodeCount?: number; pgdata?: { volumeName?: string; databaseBytes?: number } } }).body; const nodeList = (coreNodes as { body?: { nodes?: Array<{ providerId?: string; status?: string; labels?: Record }> } }).body?.nodes ?? []; const mainNode = nodeList.find((node) => node.providerId === config.providerGateway.id); const expectedGatewayVersion = providerGatewayPackageVersion(); const systemStatuses = (systemStatus as { body?: { systemStatuses?: Array<{ providerId?: string; current?: { cpu?: { percent?: number }; memory?: { percent?: number; mode?: string; cacheBytes?: number }; disk?: { percent?: number }; processes?: Array<{ pid?: number; rssBytes?: number; cpuPercent?: number; command?: string }>; processSummary?: { defaultSort?: string; visible?: number; total?: number } }; history?: unknown[] }> } }).body?.systemStatuses ?? []; const mainSystem = systemStatuses.find((item) => item.providerId === config.providerGateway.id); const mainProcesses = mainSystem?.current?.processes ?? []; const processMemoryDescending = mainProcesses.length < 2 || mainProcesses.every((row, index, rows) => index === 0 || Number(rows[index - 1]?.rssBytes ?? 0) >= Number(row.rssBytes ?? 0)); 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); addSelectedCheck(checks, options, "core:internal-overview", (coreOverview as { ok?: boolean }).ok === true && overviewBody?.ok === true && overviewBody.dbReady === true && (overviewBody.onlineNodeCount ?? 0) >= 1, coreOverview); addSelectedCheck(checks, options, "core:pgdata-usage", (coreOverview as { ok?: boolean }).ok === true && overviewBody?.pgdata?.volumeName === config.database.volume && Number(overviewBody.pgdata.databaseBytes ?? 0) > 0, coreOverview); addSelectedCheck(checks, options, "provider:self-node-online", nodeList.some((node) => node.providerId === config.providerGateway.id && node.status === "online"), coreNodes); addSelectedCheck(checks, options, "provider:gateway-version-label", mainNode?.labels?.providerGatewayVersion === expectedGatewayVersion && mainNode?.labels?.providerGatewayUpgradePolicy === "always-enabled", { providerId: config.providerGateway.id, expectedGatewayVersion, labels: mainNode?.labels ?? null }); addSelectedCheck(checks, options, "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)); addSelectedCheck(checks, options, "provider:process-resource-status", mainProcesses.length > 0 && mainSystem?.current?.processSummary?.defaultSort === "memory_desc" && processMemoryDescending && mainProcesses.some((row) => Number.isFinite(row.pid) && Number.isFinite(row.rssBytes) && Number.isFinite(row.cpuPercent) && typeof row.command === "string"), { providerId: config.providerGateway.id, processSummary: mainSystem?.current?.processSummary, sample: mainProcesses.slice(0, 5) }); addSelectedCheck(checks, options, "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 microserviceList = (microservices as { body?: { microservices?: Array<{ id?: string; providerId?: string; backend?: { public?: boolean }; runtime?: { providerStatus?: string; container?: { name?: string; state?: string } } }> } }).body?.microservices ?? []; const findjob = microserviceList.find((service) => service.id === "findjob"); const pipeline = microserviceList.find((service) => service.id === "pipeline"); const metNonlinear = microserviceList.find((service) => service.id === "met-nonlinear"); const todoNote = microserviceList.find((service) => service.id === "todo-note"); const findjobSummaryBody = (findjobSummary as { body?: { totalJobs?: number; prioritizedJobs?: number } }).body; const findjobJobs = (findjobJobsPreview as { body?: { jobs?: unknown[]; _unidesk?: { arrayLimits?: { jobs?: { returnedLength?: number; originalLength?: number } } } } }).body; const todoNoteRows = (todoNoteInstances as { body?: { instances?: Array<{ id?: string; name?: string; todoCount?: number; completedCount?: number }> } }).body?.instances ?? []; const todoNoteNames = todoNoteRows.map((row) => row.name); const pipelineSnapshotBody = (pipelineSnapshot as { body?: { ok?: boolean; registry?: { ok?: boolean; components?: unknown[] }; pipelines?: unknown[]; runs?: unknown[]; _unidesk?: { arrayLimits?: { "registry.components"?: { returnedLength?: number; originalLength?: number }; runs?: { returnedLength?: number; originalLength?: number } } } } }).body; const metNonlinearHealthBody = (metNonlinearHealth as { body?: { ok?: boolean; targetGpu?: { name?: string; freeRatio?: number } | null; image?: { present?: boolean; image?: string } } }).body; const metNonlinearQueueBody = (metNonlinearQueue as { body?: { ok?: boolean; queue?: { counts?: Record; maxConcurrency?: number; targetGpuName?: string }; jobs?: unknown[] } }).body; const metNonlinearProjectsBody = (metNonlinearProjects as { body?: { ok?: boolean; projects?: unknown[] } }).body; const metNonlinearImagesBody = (metNonlinearImages as { body?: { ok?: boolean; mlImage?: { present?: boolean; image?: string } } }).body; const firstPipelineRun = Array.isArray(pipelineSnapshotBody?.runs) ? pipelineSnapshotBody.runs[0] as { runId?: string; pipelineId?: string; status?: string; updatedAt?: string } | undefined : undefined; const pipelineSnapshotDetail = { ok: (pipelineSnapshot as { ok?: boolean }).ok, status: (pipelineSnapshot as { status?: number }).status, body: { ok: pipelineSnapshotBody?.ok, registryOk: pipelineSnapshotBody?.registry?.ok, componentPreviewCount: pipelineSnapshotBody?.registry?.components?.length ?? 0, pipelinePreviewCount: pipelineSnapshotBody?.pipelines?.length ?? 0, runPreviewCount: pipelineSnapshotBody?.runs?.length ?? 0, firstRun: firstPipelineRun === undefined ? null : { runId: firstPipelineRun.runId, pipelineId: firstPipelineRun.pipelineId, status: firstPipelineRun.status, updatedAt: firstPipelineRun.updatedAt, }, arrayLimits: pipelineSnapshotBody?._unidesk?.arrayLimits, }, }; addSelectedCheck(checks, options, "microservice:catalog-findjob", (microservices as { ok?: boolean }).ok === true && findjob?.providerId === "D601" && findjob.backend?.public === false, { microservices }); addSelectedCheck(checks, options, "microservice:catalog-pipeline", (microservices as { ok?: boolean }).ok === true && pipeline?.providerId === "D601" && pipeline.backend?.public === false && pipeline.runtime?.container?.name === "pipeline-v2-webui", { microservices }); addSelectedCheck(checks, options, "microservice:catalog-met-nonlinear", (microservices as { ok?: boolean }).ok === true && metNonlinear?.providerId === "D601" && metNonlinear.backend?.public === false && metNonlinear.runtime?.container?.name === "met-nonlinear-ts", { microservices }); addSelectedCheck(checks, options, "microservice:catalog-todo-note", (microservices as { ok?: boolean }).ok === true && todoNote?.providerId === config.providerGateway.id && todoNote.backend?.public === false && todoNote.runtime?.container?.name === "todo-note-backend", { microservices }); addSelectedCheck(checks, options, "microservice:findjob-status", (findjobStatus as { ok?: boolean }).ok === true && (findjobStatus as { body?: { microservice?: { id?: string; providerId?: string } } }).body?.microservice?.providerId === "D601", findjobStatus); addSelectedCheck(checks, options, "microservice:findjob-health", (findjobHealth as { ok?: boolean; body?: { ok?: boolean } }).ok === true && (findjobHealth as { body?: { ok?: boolean } }).body?.ok === true, findjobHealth); addSelectedCheck(checks, options, "microservice:findjob-summary", (findjobSummary as { ok?: boolean }).ok === true && Number.isFinite(findjobSummaryBody?.totalJobs) && Number.isFinite(findjobSummaryBody?.prioritizedJobs), findjobSummary); addSelectedCheck(checks, options, "microservice:findjob-jobs-preview", (findjobJobsPreview as { ok?: boolean }).ok === true && Array.isArray(findjobJobs?.jobs) && (findjobJobs.jobs.length ?? 0) > 0 && (findjobJobs._unidesk?.arrayLimits?.jobs?.returnedLength ?? 0) <= 5, findjobJobsPreview); addSelectedCheck(checks, options, "microservice:pipeline-status", (pipelineStatus as { ok?: boolean }).ok === true && (pipelineStatus as { body?: { microservice?: { id?: string; providerId?: string } } }).body?.microservice?.providerId === "D601", pipelineStatus); addSelectedCheck(checks, options, "microservice:pipeline-health", (pipelineHealth as { ok?: boolean; body?: { ok?: boolean; service?: string } }).ok === true && (pipelineHealth as { body?: { ok?: boolean } }).body?.ok === true, pipelineHealth); addSelectedCheck(checks, options, "microservice:pipeline-snapshot", (pipelineSnapshot as { ok?: boolean }).ok === true && pipelineSnapshotBody?.ok === true && pipelineSnapshotBody.registry?.ok === true && Array.isArray(pipelineSnapshotBody.registry.components) && pipelineSnapshotBody.registry.components.length > 0 && Array.isArray(pipelineSnapshotBody.pipelines) && pipelineSnapshotBody.pipelines.length > 0 && Array.isArray(pipelineSnapshotBody.runs) && pipelineSnapshotBody.runs.length > 0 && (pipelineSnapshotBody._unidesk?.arrayLimits?.["registry.components"]?.returnedLength ?? 999) <= 8 && (pipelineSnapshotBody._unidesk?.arrayLimits?.runs?.returnedLength ?? 999) <= 3, pipelineSnapshotDetail); addSelectedCheck(checks, options, "microservice:met-nonlinear-status", (metNonlinearStatus as { ok?: boolean }).ok === true && (metNonlinearStatus as { body?: { microservice?: { id?: string; providerId?: string } } }).body?.microservice?.providerId === "D601", metNonlinearStatus); addSelectedCheck(checks, options, "microservice:met-nonlinear-health", (metNonlinearHealth as { ok?: boolean }).ok === true && metNonlinearHealthBody?.ok === true, metNonlinearHealth); addSelectedCheck(checks, options, "microservice:met-nonlinear-queue", (metNonlinearQueue as { ok?: boolean }).ok === true && metNonlinearQueueBody?.ok === true && typeof metNonlinearQueueBody.queue?.counts === "object" && metNonlinearQueueBody.queue?.targetGpuName === "2080 Ti", metNonlinearQueue); addSelectedCheck(checks, options, "microservice:met-nonlinear-projects", (metNonlinearProjects as { ok?: boolean }).ok === true && metNonlinearProjectsBody?.ok === true && Array.isArray(metNonlinearProjectsBody.projects) && metNonlinearProjectsBody.projects.length > 0, metNonlinearProjects); addSelectedCheck(checks, options, "microservice:met-nonlinear-image", (metNonlinearImages as { ok?: boolean }).ok === true && metNonlinearImagesBody?.ok === true && metNonlinearImagesBody.mlImage?.present === true && metNonlinearImagesBody.mlImage?.image === "met-nonlinear-ml:tf26", metNonlinearImages); addSelectedCheck(checks, options, "microservice:todo-note-status", (todoNoteStatus as { ok?: boolean }).ok === true && (todoNoteStatus as { body?: { microservice?: { id?: string; providerId?: string } } }).body?.microservice?.providerId === config.providerGateway.id, todoNoteStatus); addSelectedCheck(checks, options, "microservice:todo-note-health", (todoNoteHealth as { ok?: boolean; body?: { ok?: boolean; storage?: string } }).ok === true && (todoNoteHealth as { body?: { ok?: boolean; storage?: string } }).body?.ok === true && (todoNoteHealth as { body?: { storage?: string } }).body?.storage === "postgres", todoNoteHealth); addSelectedCheck(checks, options, "microservice:todo-note-migrated-data", (todoNoteInstances as { ok?: boolean }).ok === true && todoNoteRows.length >= 5 && ["CONSTAR", "大论文", "找工作", "小论文", "事务"].every((name) => todoNoteNames.includes(name)) && todoNoteRows.reduce((sum, row) => sum + Number(row.todoCount ?? 0), 0) >= 100, { todoNoteInstances }); addSelectedCheck(checks, options, "microservice:todo-note-write-path", (todoNoteCreate as { ok?: boolean }).ok === true && (todoNoteAdd as { ok?: boolean }).ok === true && (todoNoteToggle as { ok?: boolean }).ok === true && (todoNoteUndo as { ok?: boolean }).ok === true && (todoNoteDelete as { ok?: boolean }).ok === true, { todoNoteCreate, todoNoteAdd, todoNoteToggle, todoNoteUndo, todoNoteDelete }); 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?: { providerGatewayVersion?: string; targetProviderGatewayVersion?: string }; mode?: string } }; ok?: boolean }).task?.result; const upgradePlan = taskResult?.plan; addSelectedCheck(checks, options, "provider:upgrade-plan", (upgradeDispatch as { ok?: boolean }).ok === true && (upgradeTask as { ok?: boolean }).ok === true && taskResult?.mode === "plan" && upgradePlan !== undefined && upgradePlan.providerGatewayVersion === expectedGatewayVersion && upgradePlan.targetProviderGatewayVersion === expectedGatewayVersion, { expectedGatewayVersion, upgradeDispatch, upgradeTask }); addSelectedCheck(checks, options, "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[], options: E2ERunOptions): 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'; SELECT 'todo_note_instances=' || count(*) FROM todo_note_instances; WITH RECURSIVE todo_tree(instance_id, todo) AS ( SELECT id, jsonb_array_elements(todos) FROM todo_note_instances UNION ALL SELECT todo_tree.instance_id, child.value FROM todo_tree CROSS JOIN LATERAL jsonb_array_elements( CASE WHEN jsonb_typeof(todo_tree.todo->'children') = 'array' THEN todo_tree.todo->'children' ELSE '[]'::jsonb END ) AS child(value) ) SELECT 'todo_note_total_todos=' || count(*) FROM todo_tree; `; const marker = runPsql(config, markerSql); const todoNoteInstanceCount = Number(marker.stdout.match(/todo_note_instances=(\d+)/)?.[1] ?? NaN); const todoNoteTotalTodos = Number(marker.stdout.match(/todo_note_total_todos=(\d+)/)?.[1] ?? NaN); addSelectedCheck(checks, options, "database:named-volume-write", marker.ok && marker.stdout.includes(`marker=${markerId}`), marker); addSelectedCheck(checks, options, "database:provider-state", marker.ok && marker.stdout.includes("online_main_server=1"), marker); addSelectedCheck(checks, options, "database:todo-note-pg-storage", marker.ok && todoNoteInstanceCount >= 5 && todoNoteTotalTodos >= 100, { ...marker, todoNoteInstanceCount, todoNoteTotalTodos }); return markerId; } async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2ECheck[], options: E2ERunOptions): 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 wants = (name: string): boolean => wantsCheck(options, name); const wantsAny = (names: string[]): boolean => wantsAnyCheck(options, names); const needSidebar = wants("frontend:sidebar-collapse"); const needMobileRail = wants("frontend:mobile-nav-fixed-height"); const needMobileContent = wants("frontend:mobile-content-top-aligned"); const needPendingTask = wants("frontend:pending-task-drilldown") || needMobileContent; const needTaskHistory = wants("frontend:task-history-diagnostics"); const needOverviewBody = wantsAny([ "frontend:login-provider-visible", "frontend:overview-pgdata-visible", "frontend:no-naked-json-before-click", ]) || needPendingTask || needTaskHistory; const needRawProviderJson = wantsAny([ "frontend:public-provider-info-visible", "frontend:raw-json-explicit-button", ]); const needNodeMonitor = wantsAny([ "frontend:system-monitor-visible", "frontend:process-resource-sorting", "frontend:upgrade-plan-dispatch", ]); const needDocker = wants("frontend:docker-status-visible"); const needGatewayVersion = wantsAny([ "frontend:gateway-version-records-visible", "frontend:gateway-duration-subsecond-visible", "frontend:provider-operation-availability-visible", ]); const needMicroserviceCatalog = wants("frontend:microservice-catalog-visible"); const needTodoNote = wants("frontend:todo-note-integrated-visible"); const needFindJob = wants("frontend:findjob-integrated-visible"); const needRouteDeepLink = wants("frontend:url-route-deeplink"); const needPipeline = wantsAny([ "frontend:pipeline-integrated-visible", "frontend:pipeline-react-flow-visible", "frontend:pipeline-gantt-defaults", "frontend:pipeline-step-timeline-visible", ]); const needMetNonlinear = wantsAny([ "frontend:met-nonlinear-integrated-visible", "frontend:met-nonlinear-project-tree-detail", "frontend:met-nonlinear-queue-detail-speed", ]); 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)); page.on("dialog", (dialog) => dialog.accept()); 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 }); let railWidthBefore = 0; let railWidthCollapsed = 0; let mobileRailHeights: number[] = []; let mobileRailMax = 0; let mobileRailMin = 0; let mobileContentMetrics = { pageTop: 0, emptyTextOffset: 0 }; let bodyText = ""; let rawBlocksBefore = 0; let nakedJsonText = false; let pendingTaskText = ""; let taskHistoryText = ""; let rawText = ""; let monitorText = ""; let processTableText = ""; let processMemoryValues: number[] = []; let processDefaultMemoryDescending = false; let processMemorySortAria = ""; let processCpuValues: number[] = []; let processCpuDescending = false; let processCpuSortAria = ""; let upgradeControlText = ""; let dockerText = ""; let gatewayText = ""; let gatewayTextLower = ""; let gatewayHasSubsecondDuration = false; let gatewayHasRoundedZeroDuration = false; let sshAvailabilityTexts: string[] = []; let upgradeAvailabilityTexts: string[] = []; let microserviceCatalogText = ""; let todoNoteText = ""; let findjobText = ""; let routeDeepLinkText = ""; let routeInitialPath = ""; let routeDockerPath = ""; let routeBackIntermediatePath = ""; let routeBackPath = ""; let routeOverviewPath = ""; let routeOverviewText = ""; let pipelineText = ""; let pipelineFlowNodeCount = 0; let pipelineFlowEdgeCount = 0; let pipelineGanttScaleLabel = ""; let pipelineGanttAutoHideIdleChecked = true; let pipelineGanttHeaderNodeOrder: string[] = []; let pipelineSnapshotForFrontend: any = null; let pipelineStepTimelineText = ""; let pipelineSessionHeadText = ""; let firstPipelineStepSummaryText = ""; let pipelineTimelineMetrics = { clientWidth: 0, scrollWidth: 0, clientHeight: 0, scrollHeight: 0, hasHorizontalScroll: false }; let firstPipelineStepSummaryMetrics = { clientWidth: 0, scrollWidth: 0, clientHeight: 0, scrollHeight: 0, hasHorizontalScroll: false }; let firstPipelineStepExpandedText = ""; let metNonlinearInitialText = ""; let metProjectTreeText = ""; let metProjectDetailText = ""; let metCompletedText = ""; let metJobDetailText = ""; if (needSidebar) { railWidthBefore = await page.locator(".rail").evaluate((element) => Math.round(element.getBoundingClientRect().width)); await page.getByTestId("rail-toggle").click(); await page.waitForTimeout(120); railWidthCollapsed = await page.locator(".rail").evaluate((element) => Math.round(element.getBoundingClientRect().width)); await page.getByTestId("rail-toggle").click(); await page.waitForTimeout(80); } if (needMobileRail || needMobileContent) { await page.setViewportSize({ width: 390, height: 860 }); if (needMobileRail) { 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); } mobileRailMax = Math.max(...mobileRailHeights); mobileRailMin = Math.min(...mobileRailHeights); } if (needMobileContent) { await page.getByRole("button", { name: /任务调度/ }).click(); await page.getByRole("button", { name: /待处理任务/ }).click(); await page.waitForSelector('[data-testid="pending-task-page"]', { timeout: 5000 }); 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 }); } if (needOverviewBody || needRawProviderJson || needPendingTask || needTaskHistory) { await page.getByRole("button", { name: /运行总览/ }).click(); await page.getByRole("button", { name: /态势总览/ }).click(); await page.waitForSelector('[data-testid="overview-page"]', { timeout: 5000 }); if (needOverviewBody) { bodyText = await page.locator("body").innerText({ timeout: 5000 }); rawBlocksBefore = await page.locator("pre.raw-json").count(); nakedJsonText = bodyText.includes('{"') || bodyText.includes('"providerId"') || bodyText.includes('"labels"'); } if (needPendingTask) { await page.getByTestId("pending-task-card").click(); await page.waitForSelector('[data-testid="pending-task-page"]', { timeout: 5000 }); pendingTaskText = await page.locator('[data-testid="pending-task-page"]').innerText({ timeout: 5000 }); } if (needTaskHistory) { await page.getByRole("button", { name: /任务历史/ }).click(); await page.waitForSelector('[data-testid="task-history-page"]', { timeout: 5000 }); taskHistoryText = await page.locator('[data-testid="task-history-page"]').innerText({ timeout: 5000 }); } if (needRawProviderJson) { await page.getByRole("button", { name: /运行总览/ }).click(); await page.getByRole("button", { name: /态势总览/ }).click(); await page.getByTestId(`raw-node-${config.providerGateway.id.replace(/[^a-zA-Z0-9_-]/g, "_")}`).click(); await page.waitForSelector('[data-testid="raw-json"]', { timeout: 5000 }); rawText = await page.locator('[data-testid="raw-json"]').innerText({ timeout: 5000 }); await page.getByRole("button", { name: "关闭" }).click(); } } if (needNodeMonitor || needDocker || needGatewayVersion) { await page.getByRole("button", { name: /资源节点/ }).click(); if (needNodeMonitor) { 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.waitForSelector('[data-testid="process-resource-table"]', { 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("不含缓存") && text.includes("进程资源占用"); }, undefined, { timeout: 10000 }); monitorText = await page.locator('[data-testid="node-monitor-page"]').innerText({ timeout: 5000 }); processTableText = await page.locator('[data-testid="process-resource-table"]').innerText({ timeout: 5000 }); processMemoryValues = await page.locator('[data-testid="process-resource-table"] tbody tr').evaluateAll((rows) => rows.map((row) => Number((row as HTMLElement).dataset.memoryBytes || "0"))); processDefaultMemoryDescending = processMemoryValues.length > 0 && processMemoryValues.every((value, index, rows) => index === 0 || rows[index - 1] >= value); processMemorySortAria = await page.getByTestId("process-sort-memory").evaluate((element) => element.closest("th")?.getAttribute("aria-sort") || ""); await page.getByTestId("process-sort-cpu").click(); await page.waitForFunction(() => document.querySelector('[data-testid="process-sort-cpu"]')?.closest("th")?.getAttribute("aria-sort") === "descending", undefined, { timeout: 5000 }); processCpuValues = await page.locator('[data-testid="process-resource-table"] tbody tr').evaluateAll((rows) => rows.map((row) => Number((row as HTMLElement).dataset.cpuPercent || "0"))); processCpuDescending = processCpuValues.length > 0 && processCpuValues.every((value, index, rows) => index === 0 || rows[index - 1] >= value); processCpuSortAria = await page.getByTestId("process-sort-cpu").evaluate((element) => element.closest("th")?.getAttribute("aria-sort") || ""); await page.getByTestId("upgrade-plan-button").click(); await page.waitForFunction(() => document.body.innerText.includes("预检升级 已下发"), undefined, { timeout: 10000 }); upgradeControlText = await page.locator('[data-testid="provider-upgrade-control"]').innerText({ timeout: 5000 }); } if (needDocker) { 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 }); dockerText = await page.locator('[data-testid="docker-status-page"]').innerText({ timeout: 5000 }); } if (needGatewayVersion) { await page.getByRole("button", { name: /网关版本/ }).click(); await page.waitForSelector('[data-testid="gateway-version-page"]', { timeout: 10000 }); await page.waitForSelector(`[data-testid="gateway-version-${safeTestId(config.providerGateway.id)}"]`, { timeout: 10000 }); await page.waitForFunction(() => { const text = document.body.innerText.toLowerCase(); return text.includes("provider gateway 版本") && text.includes("远程更新记录") && text.includes("provider.upgrade") && text.includes("ssh 透传") && text.includes("远程更新"); }, undefined, { timeout: 10000 }); gatewayText = await page.locator('[data-testid="gateway-version-page"]').innerText({ timeout: 5000 }); gatewayTextLower = gatewayText.toLowerCase(); gatewayHasSubsecondDuration = /\b\d+\.\d+s\b|<0\.01s/.test(gatewayText); gatewayHasRoundedZeroDuration = /(^|\s)0s($|\s)/.test(gatewayText); sshAvailabilityTexts = await page.locator('[data-testid="gateway-version-page"] [data-testid^="ssh-availability-"]').evaluateAll((elements) => elements.map((element) => (element as HTMLElement).innerText)); upgradeAvailabilityTexts = await page.locator('[data-testid="gateway-version-page"] [data-testid^="upgrade-availability-"]').evaluateAll((elements) => elements.map((element) => (element as HTMLElement).innerText)); } } if (needMicroserviceCatalog || needTodoNote || needFindJob || needRouteDeepLink || needPipeline || needMetNonlinear) { await page.getByRole("button", { name: /微服务/ }).click(); if (needMicroserviceCatalog || needTodoNote || needFindJob || needRouteDeepLink || needPipeline || needMetNonlinear) { await page.waitForSelector('[data-testid="microservice-catalog-page"]', { timeout: 10000 }); } if (needMicroserviceCatalog) { await page.waitForSelector('[data-testid="microservice-row-findjob"]', { timeout: 10000 }); await page.waitForSelector('[data-testid="microservice-row-pipeline"]', { timeout: 10000 }); await page.waitForSelector('[data-testid="microservice-row-met-nonlinear"]', { timeout: 10000 }); await page.waitForSelector('[data-testid="microservice-row-todo-note"]', { timeout: 10000 }); microserviceCatalogText = await page.locator('[data-testid="microservice-catalog-page"]').innerText({ timeout: 5000 }); } if (needTodoNote) { await page.getByRole("button", { name: /Todo Note/ }).click(); await page.waitForSelector('[data-testid="todo-note-page"]', { timeout: 10000 }); await page.waitForFunction(() => { const text = document.body.innerText; const lower = text.toLowerCase(); return lower.includes("todo note 工作台") && text.includes("CONSTAR") && text.includes("大论文") && text.includes("找工作") && text.includes("小论文") && text.includes("事务") && text.includes("仅 UniDesk frontend 代理访问"); }, undefined, { timeout: 30000 }); const uiTodoListName = `UI E2E ${Date.now()}`; await page.getByLabel("新清单名称").fill(uiTodoListName); await page.getByRole("button", { name: "创建" }).click(); const uiTodoRow = page.locator(".todo-instance-row", { hasText: uiTodoListName }); await uiTodoRow.waitFor({ state: "visible", timeout: 10000 }); await uiTodoRow.click(); await page.waitForFunction((name) => { const active = document.querySelector('[data-testid="todo-note-page"] .todo-instance-row.active') as HTMLElement | null; return active?.innerText.includes(String(name)); }, uiTodoListName, { timeout: 10000 }); await page.waitForSelector('[data-testid="todo-note-tree"]', { timeout: 10000 }); await page.getByLabel("新增根任务").fill("UI E2E smoke task"); await page.getByRole("button", { name: "新增" }).click(); await page.waitForSelector("text=UI E2E smoke task", { timeout: 10000 }); todoNoteText = await page.locator('[data-testid="todo-note-page"]').innerText({ timeout: 5000 }); await uiTodoRow.click(); await page.waitForFunction((name) => { const active = document.querySelector('[data-testid="todo-note-page"] .todo-instance-row.active') as HTMLElement | null; return active?.innerText.includes(String(name)); }, uiTodoListName, { timeout: 10000 }); await page.getByRole("button", { name: "删除清单" }).click(); await page.waitForFunction((name) => !document.body.innerText.includes(String(name)), uiTodoListName, { timeout: 10000 }); } if (needFindJob) { await page.getByRole("button", { name: /FindJob/ }).click(); await page.waitForSelector('[data-testid="findjob-page"]', { timeout: 10000 }); await page.waitForFunction(() => { const text = document.body.innerText.toLowerCase(); const originalText = document.body.innerText; return text.includes("findjob 工作台".toLowerCase()) && text.includes("岗位总量") && text.includes("d601") && text.includes("近期岗位") && /岗位总量\s+\d+/.test(originalText) && /health\s+ok/i.test(originalText) && /[1-9]\d*\/[1-9]\d*\s+preview/i.test(originalText); }, undefined, { timeout: 30000 }); findjobText = await page.locator('[data-testid="findjob-page"]').innerText({ timeout: 5000 }); } if (needRouteDeepLink) { await page.goto(`${urls.frontendUrl}/app/pipeline/`, { waitUntil: "domcontentloaded", timeout: 15000 }); await page.waitForSelector('[data-testid="app-shell"]', { timeout: 10000 }); await page.waitForSelector('[data-testid="pipeline-page"]', { timeout: 15000 }); routeInitialPath = new URL(page.url()).pathname; routeDeepLinkText = await page.locator('[data-testid="pipeline-page"]').innerText({ timeout: 5000 }); await page.getByRole("button", { name: /资源节点/ }).click(); await page.getByRole("button", { name: /Docker 状态/ }).click(); await page.waitForSelector('[data-testid="docker-status-page"]', { timeout: 10000 }); routeDockerPath = new URL(page.url()).pathname; await page.goBack({ waitUntil: "domcontentloaded" }); routeBackIntermediatePath = new URL(page.url()).pathname; if (routeBackIntermediatePath !== "/app/pipeline/") { await page.goBack({ waitUntil: "domcontentloaded" }); } await page.waitForSelector('[data-testid="pipeline-page"]', { timeout: 15000 }); routeBackPath = new URL(page.url()).pathname; await page.goto(`${urls.frontendUrl}/ops/status/`, { waitUntil: "domcontentloaded", timeout: 15000 }); await page.waitForSelector('[data-testid="overview-page"]', { timeout: 10000 }); routeOverviewPath = new URL(page.url()).pathname; routeOverviewText = await page.locator('[data-testid="overview-page"]').innerText({ timeout: 5000 }); await page.getByRole("button", { name: /微服务/ }).click(); await page.waitForSelector('[data-testid="microservice-catalog-page"]', { timeout: 10000 }); } if (needPipeline) { await page.getByRole("button", { name: /Pipeline/ }).click(); await page.waitForSelector('[data-testid="pipeline-page"]', { timeout: 10000 }); await page.waitForSelector('[data-testid="pipeline-react-flow"] .react-flow__node', { timeout: 30000 }); await page.waitForFunction(() => { const text = document.body.innerText; const lower = text.toLowerCase(); return lower.includes("pipeline v2 工作台") && text.includes("控制图") && /epoch\s+甘特图/i.test(text) && text.includes("运行材料索引") && /Health\s+OK/i.test(text) && /组件\s+\d+/.test(text) && /运行记录\s+[1-9]\d*/.test(text); }, undefined, { timeout: 30000 }); pipelineFlowNodeCount = await page.locator('[data-testid="pipeline-react-flow"] .react-flow__node').count(); pipelineFlowEdgeCount = await page.locator('[data-testid="pipeline-react-flow"] .react-flow__edge').count(); pipelineText = await page.locator('[data-testid="pipeline-page"]').innerText({ timeout: 5000 }); pipelineGanttScaleLabel = await page.locator('[data-testid="pipeline-gantt-scale-label"]').innerText({ timeout: 5000 }); pipelineGanttAutoHideIdleChecked = await page.locator('[data-testid="pipeline-gantt-auto-hide-idle"]').isChecked(); pipelineGanttHeaderNodeOrder = await page.locator('[data-testid="pipeline-gantt-head-node"]').evaluateAll((elements) => elements.map((element) => element.getAttribute("data-node-id") || "").filter(Boolean)); pipelineSnapshotForFrontend = await page.evaluate(async () => { const response = await fetch("/api/microservices/pipeline/proxy/api/snapshot?__unideskArrayLimit=registry.components:8,runs:3", { credentials: "same-origin" }); return response.ok ? response.json() : null; }); if (wants("frontend:pipeline-step-timeline-visible")) { const firstGanttLine = page.locator('[data-testid="pipeline-epoch-gantt"] [data-testid="pipeline-gantt-line"]').first(); await firstGanttLine.scrollIntoViewIfNeeded({ timeout: 10000 }); await firstGanttLine.click({ force: true }); await page.waitForSelector('[data-testid="pipeline-step-timeline"] [data-testid="pipeline-opencode-step"]', { timeout: 30000 }); pipelineStepTimelineText = await page.locator('[data-testid="pipeline-step-timeline"]').innerText({ timeout: 5000 }); pipelineSessionHeadText = await page.locator('[data-testid="pipeline-step-timeline-session"]').innerText({ timeout: 5000 }); const firstPipelineStep = page.locator('[data-testid="pipeline-opencode-step"]').first(); const firstPipelineStepSummary = firstPipelineStep.locator('[data-testid="pipeline-opencode-step-summary"]'); firstPipelineStepSummaryText = await firstPipelineStepSummary.innerText({ timeout: 5000 }); pipelineTimelineMetrics = await page.locator('[data-testid="pipeline-step-timeline"]').evaluate((element) => { const target = element as HTMLElement; return { clientWidth: target.clientWidth, scrollWidth: target.scrollWidth, clientHeight: target.clientHeight, scrollHeight: target.scrollHeight, hasHorizontalScroll: target.scrollWidth > target.clientWidth + 1, }; }); firstPipelineStepSummaryMetrics = await firstPipelineStepSummary.evaluate((element) => { const target = element as HTMLElement; return { clientWidth: target.clientWidth, scrollWidth: target.scrollWidth, clientHeight: target.clientHeight, scrollHeight: target.scrollHeight, hasHorizontalScroll: target.scrollWidth > target.clientWidth + 1, }; }); await firstPipelineStepSummary.click({ force: true }); await firstPipelineStep.locator('[data-testid="pipeline-opencode-step-body"]').waitFor({ state: "visible", timeout: 10000 }); firstPipelineStepExpandedText = await firstPipelineStep.innerText({ timeout: 5000 }); } } if (needMetNonlinear) { await page.getByRole("button", { name: /MET Nonlinear/ }).click(); await page.waitForSelector('[data-testid="met-nonlinear-page"]', { timeout: 10000 }); await page.waitForFunction(() => { const text = document.body.innerText; const lower = text.toLowerCase(); return lower.includes("met nonlinear 训练编排") && text.includes("D601") && text.includes("Fork Project") && text.includes("加入待启动队列") && text.includes("启动队列") && text.includes("当前队列") && text.includes("GPU/镜像") && !text.includes("创建10个10轮任务") && text.includes("仅 UniDesk frontend 代理访问") && /Health\s+OK/i.test(text); }, undefined, { timeout: 30000 }); metNonlinearInitialText = await page.locator('[data-testid="met-nonlinear-page"]').innerText({ timeout: 5000 }); if (wants("frontend:met-nonlinear-project-tree-detail")) { await page.waitForSelector('[data-testid="met-project-tree"] .met-tree-row.project', { timeout: 30000 }); metProjectTreeText = await page.locator('[data-testid="met-project-tree"]').innerText({ timeout: 5000 }); await page.locator('[data-testid="met-project-tree"] .met-tree-row.project').first().click(); await page.waitForFunction(() => { const detail = document.querySelector('[data-testid="met-detail-panel"]') as HTMLElement | null; const text = detail?.innerText || ""; const lower = text.toLowerCase(); return lower.includes("project 详情") && lower.includes("config.json") && lower.includes("data/ 训练状态") && text.includes("模型参数") && text.includes("指标") && lower.includes("total params"); }, undefined, { timeout: 30000 }); metProjectDetailText = await page.locator('[data-testid="met-detail-panel"]').innerText({ timeout: 5000 }); } if (wants("frontend:met-nonlinear-queue-detail-speed")) { await page.getByTestId("met-tab-completed").click(); await page.waitForSelector('[data-testid^="met-job-row-"]', { timeout: 30000 }); metCompletedText = await page.locator('[data-testid="met-completed-pane"]').innerText({ timeout: 5000 }); await page.locator('[data-testid^="met-job-row-"]').first().click(); await page.waitForFunction(() => { const detail = document.querySelector('[data-testid="met-detail-panel"]') as HTMLElement | null; const text = detail?.innerText || ""; const lower = text.toLowerCase(); return text.includes("训练任务详情") && text.includes("任务状态") && text.includes("训练速度") && lower.includes("epoch/h") && lower.includes("config.json") && lower.includes("data/ 训练状态"); }, undefined, { timeout: 30000 }); metJobDetailText = await page.locator('[data-testid="met-detail-panel"]').innerText({ timeout: 5000 }); } } } await page.screenshot({ path: screenshotPath, fullPage: true }); const microserviceCatalogTextLower = microserviceCatalogText.toLowerCase(); const todoNoteTextLower = todoNoteText.toLowerCase(); const findjobTextLower = findjobText.toLowerCase(); const pipelineTextLower = pipelineText.toLowerCase(); const activePipeline = Array.isArray(pipelineSnapshotForFrontend?.pipelines) ? pipelineSnapshotForFrontend.pipelines[0] : null; const expectedGanttNodeOrder = activePipeline ? pipelineSnapshotNodeOrder(activePipeline) : []; const headerIndexByNodeId = new Map(pipelineGanttHeaderNodeOrder.map((nodeId, index) => [nodeId, index])); const downstreamViolations = activePipeline ? pipelineSnapshotEdges(activePipeline).filter((edge) => edge.edgeType.toLowerCase() !== "rework" && headerIndexByNodeId.has(edge.source) && headerIndexByNodeId.has(edge.target) && Number(headerIndexByNodeId.get(edge.source)) >= Number(headerIndexByNodeId.get(edge.target))) : []; const metNonlinearTextLower = metNonlinearInitialText.toLowerCase(); addSelectedCheck(checks, options, "frontend:login-provider-visible", bodyText.includes(config.providerGateway.id) && bodyText.includes(config.providerGateway.name) && bodyText.includes("核心在线"), { screenshotPath }); addSelectedCheck(checks, options, "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) }); addSelectedCheck(checks, options, "frontend:sidebar-collapse", railWidthBefore >= 160 && railWidthCollapsed <= 70, { railWidthBefore, railWidthCollapsed }); addSelectedCheck(checks, options, "frontend:mobile-nav-fixed-height", mobileRailHeights.length > 0 && mobileRailMax - mobileRailMin <= 1 && mobileRailMax <= 44, { mobileRailHeights }); addSelectedCheck(checks, options, "frontend:mobile-content-top-aligned", mobileContentMetrics.pageTop <= 190 && mobileContentMetrics.emptyTextOffset <= 14, { mobileContentMetrics }); addSelectedCheck(checks, options, "frontend:pending-task-drilldown", pendingTaskText.includes("待处理任务") && (pendingTaskText.includes("当前无待处理任务") || (pendingTaskText.includes("Provider") && pendingTaskText.includes("已等待"))), { pendingTaskPreview: pendingTaskText.slice(0, 600) }); addSelectedCheck(checks, options, "frontend:task-history-diagnostics", taskHistoryText.includes("任务耗时") && taskHistoryText.includes("诊断信息") && taskHistoryText.includes("失败原因") && taskHistoryText.includes("e2e forced failure for diagnostics"), { taskHistoryPreview: taskHistoryText.slice(0, 900) }); addSelectedCheck(checks, options, "frontend:no-naked-json-before-click", rawBlocksBefore === 0 && !nakedJsonText, { rawBlocksBefore, nakedJsonText }); addSelectedCheck(checks, options, "frontend:raw-json-explicit-button", rawText.includes('"providerId"') && rawText.includes(config.providerGateway.id), { rawTextPreview: rawText.slice(0, 400) }); addSelectedCheck(checks, options, "frontend:system-monitor-visible", monitorText.includes("任务管理器视图") && monitorText.includes("CPU") && monitorText.includes("Memory") && monitorText.includes("Disk") && monitorText.includes("不含缓存") && monitorText.includes("进程资源占用"), { monitorTextPreview: monitorText.slice(0, 1000) }); addSelectedCheck(checks, options, "frontend:process-resource-sorting", processTableText.includes("进程") && processTableText.includes("PID") && processTableText.includes("CPU") && processTableText.includes("内存") && processTableText.includes("磁盘 I/O") && processMemorySortAria === "descending" && processDefaultMemoryDescending && processCpuSortAria === "descending" && processCpuDescending, { processMemorySortAria, processCpuSortAria, processMemoryValues: processMemoryValues.slice(0, 12), processCpuValues: processCpuValues.slice(0, 12), processTablePreview: processTableText.slice(0, 1000) }); addSelectedCheck(checks, options, "frontend:upgrade-plan-dispatch", upgradeControlText.includes("预检升级 已下发") && upgradeControlText.includes("指定 Provider") && upgradeControlText.includes(`v${providerGatewayPackageVersion()}`), { providerId: config.providerGateway.id, upgradeControlPreview: upgradeControlText.slice(0, 500) }); addSelectedCheck(checks, options, "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) }); addSelectedCheck(checks, options, "frontend:gateway-version-records-visible", gatewayTextLower.includes("provider gateway 版本") && gatewayText.includes("远程更新记录") && gatewayTextLower.includes("gateway 版本") && gatewayText.includes(config.providerGateway.id) && gatewayText.includes(`v${providerGatewayPackageVersion()}`) && gatewayTextLower.includes("provider.upgrade"), { gatewayTextPreview: gatewayText.slice(0, 900) }); addSelectedCheck(checks, options, "frontend:gateway-duration-subsecond-visible", gatewayHasSubsecondDuration && !gatewayHasRoundedZeroDuration, { gatewayHasSubsecondDuration, gatewayHasRoundedZeroDuration, gatewayTextPreview: gatewayText.slice(0, 900) }); addSelectedCheck(checks, options, "frontend:provider-operation-availability-visible", sshAvailabilityTexts.length >= 1 && upgradeAvailabilityTexts.length >= 1 && sshAvailabilityTexts.every((text) => text.includes("SSH 透传")) && upgradeAvailabilityTexts.every((text) => text.includes("远程更新")) && upgradeAvailabilityTexts.some((text) => text.includes("always-enabled")), { sshAvailabilityTexts, upgradeAvailabilityTexts }); addSelectedCheck(checks, options, "frontend:overview-pgdata-visible", bodyText.includes("PGDATA") && bodyText.includes(config.database.volume), { bodyPreview: bodyText.slice(0, 800) }); addSelectedCheck(checks, options, "frontend:microservice-catalog-visible", microserviceCatalogTextLower.includes("findjob") && microserviceCatalogTextLower.includes("pipeline") && microserviceCatalogTextLower.includes("todo note") && microserviceCatalogTextLower.includes("met nonlinear") && microserviceCatalogText.includes("D601") && microserviceCatalogText.includes(config.providerGateway.id) && microserviceCatalogTextLower.includes("private") && microserviceCatalogText.includes("https://gitee.com/Lyon1998/findjob") && microserviceCatalogText.includes("https://github.com/pikasTech/pipeline") && microserviceCatalogText.includes("https://github.com/pikasTech/met_nonlinear") && microserviceCatalogText.includes("https://gitee.com/Lyon1998/todo_note"), { microserviceCatalogPreview: microserviceCatalogText.slice(0, 1600) }); addSelectedCheck(checks, options, "frontend:todo-note-integrated-visible", todoNoteTextLower.includes("todo note 工作台") && todoNoteText.includes("CONSTAR") && todoNoteText.includes("大论文") && todoNoteText.includes("UI E2E smoke task") && todoNoteText.includes("撤销") && todoNoteText.includes("重做") && todoNoteText.includes("全部展开") && todoNoteText.includes("仅 UniDesk frontend 代理访问"), { todoNoteTextPreview: todoNoteText.slice(0, 1400) }); addSelectedCheck(checks, options, "frontend:findjob-integrated-visible", findjobTextLower.includes("findjob 工作台".toLowerCase()) && findjobText.includes("岗位总量") && findjobText.includes("D601") && findjobText.includes("近期岗位") && findjobText.includes("仅 UniDesk frontend 代理访问") && /岗位总量\s+\d+/.test(findjobText) && /health\s+ok/i.test(findjobText) && /[1-9]\d*\/[1-9]\d*\s+preview/i.test(findjobText), { findjobTextPreview: findjobText.slice(0, 1200) }); addSelectedCheck(checks, options, "frontend:url-route-deeplink", routeInitialPath === "/app/pipeline/" && routeDockerPath === "/nodes/docker/" && routeBackPath === "/app/pipeline/" && routeOverviewPath === "/ops/status/" && routeDeepLinkText.toLowerCase().includes("pipeline v2 工作台".toLowerCase()) && routeOverviewText.includes("核心指标"), { routeInitialPath, routeDockerPath, routeBackIntermediatePath, routeBackPath, routeOverviewPath, routeDeepLinkPreview: routeDeepLinkText.slice(0, 1200), routeOverviewPreview: routeOverviewText.slice(0, 800) }); addSelectedCheck(checks, options, "frontend:pipeline-integrated-visible", pipelineTextLower.includes("pipeline v2 工作台".toLowerCase()) && pipelineText.includes("D601") && pipelineText.includes("控制图") && /epoch\s+甘特图/i.test(pipelineText) && pipelineText.includes("运行材料索引") && pipelineText.includes("仅 UniDesk frontend 代理访问") && /Health\s+OK/i.test(pipelineText) && /组件\s+\d+/.test(pipelineText) && /运行记录\s+[1-9]\d*/.test(pipelineText), { pipelineTextPreview: pipelineText.slice(0, 1200) }); addSelectedCheck(checks, options, "frontend:pipeline-react-flow-visible", pipelineFlowNodeCount > 0 && pipelineFlowEdgeCount > 0, { pipelineFlowNodeCount, pipelineFlowEdgeCount }); addSelectedCheck(checks, options, "frontend:pipeline-gantt-defaults", pipelineGanttScaleLabel.includes("100 px/min") && pipelineGanttAutoHideIdleChecked === false && pipelineGanttHeaderNodeOrder.length > 0 && downstreamViolations.length === 0, { pipelineGanttScaleLabel, pipelineGanttAutoHideIdleChecked, pipelineGanttHeaderNodeOrder, expectedGanttNodeOrder, downstreamViolations, }); addSelectedCheck(checks, options, "frontend:pipeline-step-timeline-visible", pipelineStepTimelineText.includes("OpenCode Step Timeline") && pipelineStepTimelineText.includes("时间") && pipelineStepTimelineText.includes("工具调用") && !pipelineStepTimelineText.includes("{\n") && pipelineSessionHeadText.includes("agent") && pipelineSessionHeadText.toLowerCase().includes("model") && !firstPipelineStepSummaryText.toLowerCase().includes("tokens") && !firstPipelineStepSummaryText.includes("MiniMax-M2.7") && !firstPipelineStepSummaryText.includes("\nbuild\n") && !pipelineTimelineMetrics.hasHorizontalScroll && !firstPipelineStepSummaryMetrics.hasHorizontalScroll && firstPipelineStepSummaryMetrics.clientHeight <= 190 && firstPipelineStepExpandedText.toLowerCase().includes("tokens") && firstPipelineStepExpandedText.includes("用户输入"), { pipelineStepTimelinePreview: pipelineStepTimelineText.slice(0, 1600), pipelineSessionHeadText, pipelineTimelineMetrics, firstPipelineStepSummaryMetrics, firstPipelineStepSummaryPreview: firstPipelineStepSummaryText.slice(0, 800), firstPipelineStepExpandedPreview: firstPipelineStepExpandedText.slice(0, 1000), }); addSelectedCheck(checks, options, "frontend:met-nonlinear-integrated-visible", metNonlinearTextLower.includes("met nonlinear 训练编排") && metNonlinearInitialText.includes("D601") && metNonlinearInitialText.includes("当前队列") && metNonlinearInitialText.includes("GPU/镜像") && metNonlinearInitialText.includes("Fork Project") && metNonlinearInitialText.includes("加入待启动队列") && metNonlinearInitialText.includes("启动队列") && !metNonlinearInitialText.includes("创建10个10轮任务") && metNonlinearInitialText.includes("仅 UniDesk frontend 代理访问") && /Health\s+OK/i.test(metNonlinearInitialText), { metNonlinearTextPreview: metNonlinearInitialText.slice(0, 1400) }); addSelectedCheck(checks, options, "frontend:met-nonlinear-project-tree-detail", metProjectTreeText.includes("projects") && metProjectTreeText.includes("ex_projects") && metProjectDetailText.toLowerCase().includes("project 详情") && metProjectDetailText.toLowerCase().includes("config.json") && metProjectDetailText.toLowerCase().includes("data/ 训练状态") && metProjectDetailText.includes("模型参数") && metProjectDetailText.includes("指标") && metProjectDetailText.toLowerCase().includes("total params") && !metProjectDetailText.includes('{\n'), { metProjectTreePreview: metProjectTreeText.slice(0, 1200), metProjectDetailPreview: metProjectDetailText.slice(0, 1400) }); addSelectedCheck(checks, options, "frontend:met-nonlinear-queue-detail-speed", metCompletedText.includes("速度") && metCompletedText.toLowerCase().includes("epoch/h") && metJobDetailText.includes("训练任务详情") && metJobDetailText.includes("训练速度") && metJobDetailText.toLowerCase().includes("epoch/h") && metJobDetailText.toLowerCase().includes("config.json") && metJobDetailText.toLowerCase().includes("data/ 训练状态"), { metCompletedPreview: metCompletedText.slice(0, 1200), metJobDetailPreview: metJobDetailText.slice(0, 1400) }); addSelectedCheck(checks, options, "frontend:no-console-errors", consoleErrors.length === 0, { consoleErrors }); return { screenshotPath, bodyText, consoleErrors }; } finally { await browser.close(); } } export async function runE2E( config: UniDeskConfig, options: E2ERunOptions = { only: [], skip: [] }, ): Promise { const selectedChecks = ALL_E2E_CHECK_NAMES.filter((name) => wantsCheck(options, name)); if (selectedChecks.length === 0) { throw new Error("e2e run selection matched no checks"); } const checks: E2ECheck[] = []; const urls = publicUrls(config); const needNetwork = wantsPrefix(options, "network"); const needService = wantsPrefix(options, "core") || wantsPrefix(options, "provider") || wantsPrefix(options, "provider-ingress") || wantsPrefix(options, "microservice"); const needDatabase = wantsPrefix(options, "database") || wantsCheck(options, "frontend:task-history-diagnostics"); const needFrontend = wantsPrefix(options, "frontend"); const executedSections: string[] = []; if (needNetwork) { executedSections.push("network"); await exposureChecks(config, urls, checks, options); } if (needService) { executedSections.push("service"); await serviceChecks(config, urls, checks, options); } let markerId: string | null = null; if (needDatabase) { executedSections.push("database"); markerId = databaseChecks(config, checks, options); } let frontend: { screenshotPath: string; bodyText: string; consoleErrors: string[] } | null = null; if (needFrontend) { executedSections.push("frontend"); frontend = await frontendCheck(config, urls, checks, options); } const ok = checks.every((check) => check.status === "passed"); const resultId = markerId ?? `e2e_${Date.now()}_${Math.random().toString(16).slice(2, 8)}`; const fullResult = { ok, urls, selection: { only: options.only, skip: options.skip, matchedChecks: selectedChecks, executedSections, }, markerId, screenshotPath: frontend?.screenshotPath ?? null, checks, }; mkdirSync(rootPath(".state", "e2e"), { recursive: true }); const resultPath = rootPath(".state", "e2e", `${resultId}_result.json`); writeFileSync(resultPath, `${JSON.stringify(fullResult, null, 2)}\n`, "utf8"); return { ok, urls, selection: { only: options.only, skip: options.skip, matchedChecks: selectedChecks, executedSections, }, markerId, screenshotPath: frontend?.screenshotPath ?? null, resultPath, checkCounts: { total: checks.length, passed: checks.filter((check) => check.status === "passed").length, failed: checks.filter((check) => check.status === "failed").length, }, checks: checks.map((check) => ({ name: check.name, status: check.status })), failedChecks: checks.filter((check) => check.status === "failed"), }; }