2315 lines
160 KiB
TypeScript
2315 lines
160 KiB
TypeScript
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
import { connect } from "node:net";
|
|
import { join } from "node:path";
|
|
import { chromium, type Page } from "playwright";
|
|
import { createRouteRegistry, MODULES } from "../../src/components/frontend/src/navigation";
|
|
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:claudeqq-public-blocked",
|
|
"network:todo-note-public-blocked",
|
|
"network:codex-queue-public-blocked",
|
|
] as const;
|
|
|
|
const SERVICE_CHECK_NAMES = [
|
|
"core:internal-overview",
|
|
"core:pgdata-usage",
|
|
"core:performance-api",
|
|
"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-claudeqq",
|
|
"microservice:catalog-todo-note",
|
|
"microservice:catalog-codex-queue",
|
|
"microservice:findjob-status",
|
|
"microservice:findjob-health",
|
|
"microservice:findjob-summary",
|
|
"microservice:findjob-jobs-preview",
|
|
"microservice:pipeline-status",
|
|
"microservice:pipeline-health",
|
|
"microservice:pipeline-snapshot",
|
|
"microservice:pipeline-oa-event-flow",
|
|
"microservice:met-nonlinear-status",
|
|
"microservice:met-nonlinear-health",
|
|
"microservice:met-nonlinear-queue",
|
|
"microservice:met-nonlinear-projects",
|
|
"microservice:met-nonlinear-image",
|
|
"microservice:claudeqq-status",
|
|
"microservice:claudeqq-health",
|
|
"microservice:claudeqq-napcat-login",
|
|
"microservice:claudeqq-events",
|
|
"microservice:claudeqq-subscriptions",
|
|
"microservice:todo-note-status",
|
|
"microservice:todo-note-health",
|
|
"microservice:todo-note-migrated-data",
|
|
"microservice:todo-note-write-path",
|
|
"microservice:codex-queue-status",
|
|
"microservice:codex-queue-health",
|
|
"microservice:codex-queue-tasks",
|
|
] 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:performance-panel-visible",
|
|
"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:codex-queue-integrated-visible",
|
|
"frontend:codex-queue-initial-prompt-full-expand",
|
|
"frontend:codex-queue-trace-full-load",
|
|
"frontend:codex-queue-judge-wrap",
|
|
"frontend:claudeqq-integrated-visible",
|
|
"frontend:url-route-deeplink",
|
|
"frontend:pipeline-integrated-visible",
|
|
"frontend:pipeline-react-flow-visible",
|
|
"frontend:pipeline-sidebars-collapsible",
|
|
"frontend:pipeline-gantt-defaults",
|
|
"frontend:pipeline-gantt-frontend-y-accuracy",
|
|
"frontend:pipeline-gantt-export",
|
|
"frontend:pipeline-gantt-observation-live-running",
|
|
"frontend:pipeline-step-timeline-visible",
|
|
"frontend:pipeline-oa-event-flow-visible",
|
|
"frontend:pipeline-minimax-quota-visible",
|
|
"frontend:met-nonlinear-integrated-visible",
|
|
"frontend:met-nonlinear-project-tree-detail",
|
|
"frontend:met-nonlinear-queue-detail-speed",
|
|
"frontend:layout-overflow-desktop",
|
|
"frontend:layout-overflow-mobile",
|
|
"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<string>();
|
|
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<string, unknown> {
|
|
return pipeline && typeof pipeline?.config === "object" && !Array.isArray(pipeline.config) ? pipeline.config as Record<string, unknown> : {};
|
|
}
|
|
|
|
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 : [];
|
|
const edges: Array<{ source: string; target: string; edgeType: string }> = rawEdges.map((edge: any) => ({
|
|
source: String(edge?.from || edge?.source || ""),
|
|
target: String(edge?.to || edge?.target || ""),
|
|
edgeType: String(edge?.edgeType || ""),
|
|
}));
|
|
return edges.filter((edge) => edge.source.length > 0 && edge.target.length > 0);
|
|
}
|
|
|
|
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<string>(rawNodes.map((node: any) => String(node?.id || node?.nodeId || "")).filter((nodeId: string) => nodeId.length > 0));
|
|
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 pipelineSnapshotMonitorNodeIds(pipeline: any): string[] {
|
|
const config = pipelineSnapshotConfigObject(pipeline);
|
|
const rawNodes = Array.isArray(config.nodes) ? config.nodes : Array.isArray(pipeline?.nodes) ? pipeline.nodes : [];
|
|
return rawNodes.map((node: any) => {
|
|
const nodeId = String(node?.id || node?.nodeId || "");
|
|
const kind = String(node?.kind || "").toLowerCase();
|
|
const monitor = node?.instanceInputs?.monitor;
|
|
const componentRef = node?.componentRef || {};
|
|
const componentId = String(componentRef?.id || componentRef?.componentClass || "");
|
|
const isMonitor = kind === "procedure"
|
|
&& (node?.instanceInputs?.monitorMode === true || monitor?.enabled === true || componentId.toLowerCase().includes("monitor"));
|
|
return isMonitor ? nodeId : "";
|
|
}).filter(Boolean);
|
|
}
|
|
|
|
function pipelineOrderWithLeadingMonitors(nodeIds: string[], monitorNodeIds: string[]): string[] {
|
|
const monitorSet = new Set(monitorNodeIds);
|
|
const leading = monitorNodeIds.filter((nodeId) => nodeIds.includes(nodeId));
|
|
return leading.length === 0 ? nodeIds : [...leading, ...nodeIds.filter((nodeId) => !monitorSet.has(nodeId))];
|
|
}
|
|
|
|
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);
|
|
const monitorNodeIds = pipelineSnapshotMonitorNodeIds(pipeline);
|
|
if (explicit.length > 0) return pipelineOrderWithLeadingMonitors(explicit.flatMap((batch: string[]) => batch), monitorNodeIds);
|
|
|
|
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<string, number>();
|
|
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 pipelineOrderWithLeadingMonitors(Array.from({ length: maxLevel + 1 }, (_item, level) => nodeIds.filter((nodeId) => levels.get(nodeId) === level)).flatMap((batch) => batch), monitorNodeIds);
|
|
}
|
|
|
|
type OverflowProbe = {
|
|
viewport: string;
|
|
label: string;
|
|
path: string;
|
|
testId: string;
|
|
ok: boolean;
|
|
documentOverflowX: number;
|
|
offenders: Array<{
|
|
tag: string;
|
|
className: string;
|
|
testId: string;
|
|
text: string;
|
|
left: number;
|
|
right: number;
|
|
width: number;
|
|
parentClassName: string;
|
|
}>;
|
|
};
|
|
|
|
const LAYOUT_OVERFLOW_PAGE_TEST_IDS: Record<string, string> = {
|
|
"/ops/status/": "overview-page",
|
|
"/ops/performance/": "performance-page",
|
|
"/nodes/monitor/": "node-monitor-page",
|
|
"/nodes/docker/": "docker-status-page",
|
|
"/nodes/gateway/": "gateway-version-page",
|
|
"/tasks/pending/": "pending-task-page",
|
|
"/tasks/history/": "task-history-page",
|
|
"/app/catalog/": "microservice-catalog-page",
|
|
"/app/todo-note/": "todo-note-page",
|
|
"/app/findjob/": "findjob-page",
|
|
"/app/pipeline/": "pipeline-page",
|
|
"/app/met-nonlinear/": "met-nonlinear-page",
|
|
"/app/claudeqq/": "claudeqq-page",
|
|
"/app/codex-queue/": "codex-queue-page",
|
|
};
|
|
|
|
function layoutOverflowTargets(): Array<{ label: string; path: string; testId: string }> {
|
|
const registry = createRouteRegistry(MODULES);
|
|
return registry.modules.flatMap((module) =>
|
|
module.tabs.map((tab) => ({
|
|
label: `${module.id}:${tab.id}`,
|
|
path: tab.canonicalPath,
|
|
testId: LAYOUT_OVERFLOW_PAGE_TEST_IDS[tab.canonicalPath] || "app-shell",
|
|
})));
|
|
}
|
|
|
|
async function collectLayoutOverflow(page: Page, viewport: { width: number; height: number }, viewportName: string): Promise<OverflowProbe[]> {
|
|
const targets = layoutOverflowTargets();
|
|
const origin = new URL(page.url()).origin;
|
|
const results: OverflowProbe[] = [];
|
|
await page.setViewportSize(viewport);
|
|
for (const target of targets) {
|
|
await page.goto(`${origin}${target.path}`, { waitUntil: "domcontentloaded", timeout: 15000 });
|
|
await page.waitForSelector(`[data-testid="${target.testId}"]`, { timeout: 15000 }).catch(() => undefined);
|
|
await page.waitForTimeout(160);
|
|
const result = await page.evaluate(({ viewportName: currentViewport, label, path, testId }) => {
|
|
const viewportWidth = document.documentElement.clientWidth;
|
|
const documentOverflowX = Math.max(0, document.documentElement.scrollWidth - viewportWidth, document.body.scrollWidth - viewportWidth);
|
|
const allowed = (element: Element): boolean => {
|
|
const html = element as HTMLElement;
|
|
if (html.closest(".raw-dialog, .raw-json, .performance-table-wrap, .process-table-wrap, .docker-table-wrap, .table-wrap, .met-project-table, .pipeline-flow-frame, .react-flow, .codex-transcript, .codex-task-list, .todo-tree-scroll, .tabs, .rail")) return true;
|
|
const style = getComputedStyle(html);
|
|
return ["auto", "scroll"].includes(style.overflowX);
|
|
};
|
|
const offenders = Array.from(document.body.querySelectorAll("*")).flatMap((element) => {
|
|
const html = element as HTMLElement;
|
|
if (!(html instanceof HTMLElement) || allowed(html)) return [];
|
|
const rect = html.getBoundingClientRect();
|
|
if (rect.width <= 0 || rect.height <= 0) return [];
|
|
const excessLeft = rect.left < -2;
|
|
const excessRight = rect.right > viewportWidth + 2;
|
|
if (!excessLeft && !excessRight) return [];
|
|
const parent = html.parentElement;
|
|
return [{
|
|
tag: html.tagName.toLowerCase(),
|
|
className: String(html.className || "").slice(0, 160),
|
|
testId: html.getAttribute("data-testid") || "",
|
|
text: (html.innerText || html.textContent || "").replace(/\s+/g, " ").trim().slice(0, 160),
|
|
left: Math.round(rect.left),
|
|
right: Math.round(rect.right),
|
|
width: Math.round(rect.width),
|
|
parentClassName: String(parent?.className || "").slice(0, 160),
|
|
}];
|
|
}).slice(0, 20);
|
|
return {
|
|
viewport: currentViewport,
|
|
label,
|
|
path,
|
|
testId,
|
|
ok: documentOverflowX <= 2 && offenders.length === 0,
|
|
documentOverflowX: Math.round(documentOverflowX),
|
|
offenders,
|
|
};
|
|
}, { viewportName, label: target.label, path: target.path, testId: target.testId });
|
|
results.push(result);
|
|
}
|
|
return results;
|
|
}
|
|
|
|
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<unknown> {
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
try {
|
|
const response = await fetch(url, { signal: controller.signal });
|
|
const text = await response.text();
|
|
let body: unknown = text;
|
|
try { body = text.length > 0 ? JSON.parse(text) : null; } catch { /* keep text body */ }
|
|
return { reachable: true, ok: response.ok, status: response.status, body };
|
|
} catch (error) {
|
|
return { reachable: false, ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
function tcpProbe(host: string, port: number, timeoutMs = 2500): Promise<unknown> {
|
|
return new Promise((resolve) => {
|
|
const socket = connect({ host, port });
|
|
let settled = false;
|
|
const finish = (detail: unknown): void => {
|
|
if (settled) return;
|
|
settled = true;
|
|
socket.destroy();
|
|
resolve(detail);
|
|
};
|
|
socket.setTimeout(timeoutMs);
|
|
socket.once("connect", () => finish({ reachable: true, ok: true, host, port }));
|
|
socket.once("timeout", () => finish({ reachable: false, ok: false, host, port, error: "timeout" }));
|
|
socket.once("error", (error) => finish({ reachable: false, ok: false, host, port, error: error.message }));
|
|
});
|
|
}
|
|
|
|
function addCheck(checks: E2ECheck[], name: string, passed: boolean, detail: unknown): void {
|
|
checks.push({
|
|
name,
|
|
status: passed ? "passed" : "failed",
|
|
detail: 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) };
|
|
}
|
|
}
|
|
|
|
function shellQuote(value: string): string {
|
|
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
}
|
|
|
|
function pipelineDetailHasLiveObservation(detail: unknown): boolean {
|
|
const body = (detail as { body?: { procedureRuns?: unknown[]; controlEvents?: unknown[] } }).body;
|
|
const text = JSON.stringify(body ?? {});
|
|
const procedureRuns = Array.isArray(body?.procedureRuns) ? body.procedureRuns : [];
|
|
const hasObservation = text.includes("node-long-running-observation");
|
|
const hasRunning = procedureRuns.some((procedure: any) =>
|
|
String(procedure?.status?.status || procedure?.artifact?.status || procedure?.status || "").toLowerCase() === "running");
|
|
return hasObservation && hasRunning;
|
|
}
|
|
|
|
function findPipelineLiveObservationCandidate(): { ok: boolean; candidate: { pipelineId: string; runId: string } | null; latest?: unknown } {
|
|
const snapshot = dockerCoreJson("/api/microservices/pipeline/proxy/api/snapshot?__unideskArrayLimit=registry.components:2,runs:30");
|
|
const runs = (snapshot as { body?: { runs?: Array<{ runId?: string; pipelineId?: string }> } }).body?.runs ?? [];
|
|
let latest: unknown = snapshot;
|
|
for (const run of runs) {
|
|
const runId = String(run?.runId || "");
|
|
if (!runId) continue;
|
|
const detail = dockerCoreJson(`/api/microservices/pipeline/proxy/api/node-control/runs/${encodeURIComponent(runId)}?tail=180&view=timeline&_=${Date.now()}`);
|
|
latest = detail;
|
|
if (pipelineDetailHasLiveObservation(detail)) {
|
|
return { ok: true, candidate: { pipelineId: String(run?.pipelineId || ""), runId }, latest: detail };
|
|
}
|
|
}
|
|
return { ok: false, candidate: null, latest };
|
|
}
|
|
|
|
function startPipelineLiveObservationRun(): { ok: boolean; runId: string; command: string[]; exitCode: number | null; stdout: string; stderr: string } {
|
|
const runId = `e2e_observe_live_${Date.now()}`;
|
|
const task = "UniDesk E2E live Gantt verification: keep a real node running long enough to receive OA long-running observation events.";
|
|
const startCommand = [
|
|
"npx tsx scripts/pipeline-cli.ts pipeline start",
|
|
"--id monitor-management-behavior-test",
|
|
`--run-id ${shellQuote(runId)}`,
|
|
"--timeout-ms 1200000",
|
|
`--task ${shellQuote(task)}`,
|
|
].join(" ");
|
|
const remoteCommand = `cd /home/ubuntu/pipeline && ${startCommand}`;
|
|
const command = ["bun", "scripts/cli.ts", "ssh", "D601", remoteCommand];
|
|
const result = runCommand(command, repoRoot);
|
|
return {
|
|
ok: result.exitCode === 0,
|
|
runId,
|
|
command,
|
|
exitCode: result.exitCode,
|
|
stdout: result.stdout.slice(-4000),
|
|
stderr: result.stderr.slice(-4000),
|
|
};
|
|
}
|
|
|
|
async function ensurePipelineLiveObservationCandidate(): Promise<unknown> {
|
|
const existing = findPipelineLiveObservationCandidate();
|
|
if (existing.candidate) return { ...existing, ok: true, reused: true };
|
|
const started = startPipelineLiveObservationRun();
|
|
if (!started.ok) return { ok: false, stage: "start", started, existing };
|
|
const startedAt = Date.now();
|
|
let latest: unknown = null;
|
|
while (Date.now() - startedAt < 240_000) {
|
|
const candidate = findPipelineLiveObservationCandidate();
|
|
latest = candidate.latest;
|
|
if (candidate.candidate) return { ...candidate, ok: true, reused: false, started };
|
|
await Bun.sleep(10_000);
|
|
}
|
|
return { ok: false, stage: "wait-live-observation", timeoutMs: 240_000, started, latest };
|
|
}
|
|
|
|
async function waitForTaskStatus(taskId: string, expected: string, timeoutMs = 10_000): Promise<unknown> {
|
|
const started = Date.now();
|
|
let latest: unknown = null;
|
|
while (Date.now() - started < timeoutMs) {
|
|
latest = dockerCoreJson("/api/tasks?limit=40");
|
|
const tasks = (latest as { body?: { tasks?: Array<{ id?: string; status?: string; result?: unknown }> } }).body?.tasks ?? [];
|
|
const task = tasks.find((item) => item.id === taskId);
|
|
if (task?.status === expected) return { ok: true, task };
|
|
if (task?.status === "failed") return { ok: false, task };
|
|
await Bun.sleep(500);
|
|
}
|
|
return { ok: false, timeoutMs, latest };
|
|
}
|
|
|
|
function dockerPortSummary(): unknown {
|
|
const result = runCommand([
|
|
"docker",
|
|
"ps",
|
|
"--filter",
|
|
"label=com.docker.compose.project=unidesk",
|
|
"--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<void> {
|
|
const portSummary = dockerPortSummary() as { rows?: Array<{ name: string; ports: string }> };
|
|
const portsText = (portSummary.rows ?? []).map((row) => `${row.name} ${row.ports}`).join("\n");
|
|
const corePublic = await fetchProbe(`${urls.blockedCoreUrl}/health`, 2500);
|
|
const databasePublic = await tcpProbe(urls.blockedDatabaseHost, urls.blockedDatabasePort);
|
|
const findjobPublic = await fetchProbe(`http://${config.network.publicHost}:3254/api/health`, 2500);
|
|
const metNonlinearPublic = await fetchProbe(`http://${config.network.publicHost}:3288/health`, 2500);
|
|
const claudeqqPublic = await fetchProbe(`http://${config.network.publicHost}:3290/health`, 2500);
|
|
const todoNotePublic = await fetchProbe(`http://${config.network.publicHost}:4211/api/health`, 2500);
|
|
const codexQueuePublic = await fetchProbe(`http://${config.network.publicHost}:14222/health`, 2500);
|
|
addSelectedCheck(checks, options, "network:only-frontend-provider-ports", !portsText.includes(`:${config.network.core.port}->`) && !portsText.includes(`:${config.network.database.port}->`) && !portsText.includes(":14222->"), 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:claudeqq-public-blocked", (claudeqqPublic as { reachable?: boolean }).reachable === false, claudeqqPublic);
|
|
addSelectedCheck(checks, options, "network:todo-note-public-blocked", (todoNotePublic as { reachable?: boolean }).reachable === false, todoNotePublic);
|
|
addSelectedCheck(checks, options, "network:codex-queue-public-blocked", (codexQueuePublic as { reachable?: boolean }).reachable === false, codexQueuePublic);
|
|
}
|
|
|
|
async function serviceChecks(config: UniDeskConfig, urls: PublicUrls, checks: E2ECheck[], options: E2ERunOptions): Promise<void> {
|
|
const coreOverview = dockerCoreJson("/api/overview");
|
|
const corePerformance = dockerCoreJson("/api/performance");
|
|
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 pipelineOaEventFlow = dockerCoreJson("/api/microservices/pipeline/proxy/api/oa-event-flow/diagnostics");
|
|
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 claudeqqStatus = dockerCoreJson("/api/microservices/claudeqq/status");
|
|
const claudeqqHealth = dockerCoreJson("/api/microservices/claudeqq/health");
|
|
const claudeqqNapcatLogin = dockerCoreJson("/api/microservices/claudeqq/proxy/api/napcat/login");
|
|
const claudeqqEvents = dockerCoreJson("/api/microservices/claudeqq/proxy/api/events/recent?limit=5");
|
|
const claudeqqSubscriptions = dockerCoreJson("/api/microservices/claudeqq/proxy/api/events/subscriptions");
|
|
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 codexQueueStatus = dockerCoreJson("/api/microservices/codex-queue/status");
|
|
const codexQueueHealth = dockerCoreJson("/api/microservices/codex-queue/health");
|
|
const codexQueueTasks = dockerCoreJson("/api/microservices/codex-queue/proxy/api/tasks?limit=5");
|
|
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 corePerformanceBody = (corePerformance as { body?: { ok?: boolean; requests?: { componentSummary?: unknown[] }; operations?: { summary?: unknown[] }; database?: { pgdata?: { volumeName?: string }; codexQueueStorage?: { table?: string } } } }).body;
|
|
const nodeList = (coreNodes as { body?: { nodes?: Array<{ providerId?: string; status?: string; labels?: Record<string, unknown> }> } }).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, "core:performance-api", (corePerformance as { ok?: boolean }).ok === true && corePerformanceBody?.ok === true && Array.isArray(corePerformanceBody.requests?.componentSummary) && Array.isArray(corePerformanceBody.operations?.summary) && corePerformanceBody.database?.pgdata?.volumeName === config.database.volume && corePerformanceBody.database?.codexQueueStorage?.table === "unidesk_codex_queue_tasks", corePerformance);
|
|
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 claudeqq = microserviceList.find((service) => service.id === "claudeqq");
|
|
const todoNote = microserviceList.find((service) => service.id === "todo-note");
|
|
const codexQueue = microserviceList.find((service) => service.id === "codex-queue");
|
|
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 pipelineOaBody = (pipelineOaEventFlow as { body?: { ok?: boolean; mode?: string; forbiddenResiduals?: unknown[]; runs?: Array<{ runId?: string; nodeFinishedCount?: number; nodeFinishedWithPolicyCount?: number; monitorAuditNodeFinishedCount?: number; noAuditPolicyCount?: number; controlQueuedCount?: number; controlAppliedCount?: number }>; hasNeutralNodeFinishedEvidence?: boolean; hasNoAuditPolicyEvidence?: boolean; hasAuditPolicyEvidence?: boolean } }).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<string, number>; 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 claudeqqHealthBody = (claudeqqHealth as { body?: { ok?: boolean; service?: string; endpoints?: string[]; subscriptions?: { count?: number; enabled?: number }; napcat?: { containerized?: boolean; qrcode?: { available?: boolean; dataUrl?: string } } } }).body;
|
|
const claudeqqNapcatLoginBody = (claudeqqNapcatLogin as { body?: { ok?: boolean; napcat?: { containerized?: boolean; loginState?: string; qrcode?: { available?: boolean; dataUrl?: string } }; login?: { ready?: boolean; state?: string } } }).body;
|
|
const claudeqqEventsBody = (claudeqqEvents as { body?: { ok?: boolean; events?: unknown[]; count?: number } }).body;
|
|
const claudeqqSubscriptionsBody = (claudeqqSubscriptions as { body?: { ok?: boolean; subscriptions?: unknown[]; count?: number } }).body;
|
|
const codexQueueHealthBody = (codexQueueHealth as { body?: { ok?: boolean; queue?: { defaultModel?: string; judgeConfigured?: boolean; modelReasoningEfforts?: Record<string, string> } } }).body;
|
|
const codexQueueTasksBody = (codexQueueTasks as { body?: { ok?: boolean; queue?: { defaultModel?: string; modelReasoningEfforts?: Record<string, string> }; tasks?: unknown[] } }).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-control", { 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-claudeqq", (microservices as { ok?: boolean }).ok === true && claudeqq?.providerId === "D601" && claudeqq.backend?.public === false && claudeqq.runtime?.container?.name === "claudeqq-backend", { 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:catalog-codex-queue", (microservices as { ok?: boolean }).ok === true && codexQueue?.providerId === config.providerGateway.id && codexQueue.backend?.public === false && codexQueue.runtime?.container?.name === "codex-queue-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:pipeline-oa-event-flow",
|
|
(pipelineOaEventFlow as { ok?: boolean }).ok === true
|
|
&& pipelineOaBody?.ok === true
|
|
&& pipelineOaBody.mode === "oa-event-flow-100"
|
|
&& Array.isArray(pipelineOaBody.forbiddenResiduals)
|
|
&& pipelineOaBody.forbiddenResiduals.length === 0
|
|
&& pipelineOaBody.hasNeutralNodeFinishedEvidence === true
|
|
&& pipelineOaBody.hasNoAuditPolicyEvidence === true
|
|
&& pipelineOaBody.hasAuditPolicyEvidence === true
|
|
&& (pipelineOaBody.runs ?? []).some((run) => Number(run.noAuditPolicyCount || 0) > 0)
|
|
&& (pipelineOaBody.runs ?? []).some((run) => Number(run.monitorAuditNodeFinishedCount || 0) > 0 && Number(run.controlQueuedCount || 0) > 0 && Number(run.controlAppliedCount || 0) > 0)
|
|
&& (pipelineOaBody.runs ?? []).every((run) => Number(run.nodeFinishedWithPolicyCount || 0) === 0),
|
|
{
|
|
ok: (pipelineOaEventFlow as { ok?: boolean }).ok,
|
|
mode: pipelineOaBody?.mode,
|
|
forbiddenResiduals: pipelineOaBody?.forbiddenResiduals,
|
|
hasNeutralNodeFinishedEvidence: pipelineOaBody?.hasNeutralNodeFinishedEvidence,
|
|
hasNoAuditPolicyEvidence: pipelineOaBody?.hasNoAuditPolicyEvidence,
|
|
hasAuditPolicyEvidence: pipelineOaBody?.hasAuditPolicyEvidence,
|
|
runs: (pipelineOaBody?.runs ?? []).slice(0, 8),
|
|
});
|
|
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:claudeqq-status", (claudeqqStatus as { ok?: boolean }).ok === true && (claudeqqStatus as { body?: { microservice?: { id?: string; providerId?: string } } }).body?.microservice?.providerId === "D601", claudeqqStatus);
|
|
addSelectedCheck(checks, options, "microservice:claudeqq-health", (claudeqqHealth as { ok?: boolean }).ok === true && claudeqqHealthBody?.ok === true && claudeqqHealthBody.service === "claudeqq" && (claudeqqHealthBody.endpoints ?? []).includes("/api/push/text") && (claudeqqHealthBody.endpoints ?? []).includes("/api/napcat/login"), claudeqqHealth);
|
|
addSelectedCheck(checks, options, "microservice:claudeqq-napcat-login", (claudeqqNapcatLogin as { ok?: boolean }).ok === true && claudeqqNapcatLoginBody?.ok === true && claudeqqNapcatLoginBody.napcat?.containerized === true && typeof claudeqqNapcatLoginBody.login?.state === "string" && (claudeqqNapcatLoginBody.login?.ready === true || (claudeqqNapcatLoginBody.napcat?.qrcode?.available === true && String(claudeqqNapcatLoginBody.napcat.qrcode.dataUrl || "").startsWith("data:image/"))), claudeqqNapcatLogin);
|
|
addSelectedCheck(checks, options, "microservice:claudeqq-events", (claudeqqEvents as { ok?: boolean }).ok === true && claudeqqEventsBody?.ok === true && Array.isArray(claudeqqEventsBody.events), claudeqqEvents);
|
|
addSelectedCheck(checks, options, "microservice:claudeqq-subscriptions", (claudeqqSubscriptions as { ok?: boolean }).ok === true && claudeqqSubscriptionsBody?.ok === true && Array.isArray(claudeqqSubscriptionsBody.subscriptions), claudeqqSubscriptions);
|
|
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 });
|
|
addSelectedCheck(checks, options, "microservice:codex-queue-status", (codexQueueStatus as { ok?: boolean }).ok === true && (codexQueueStatus as { body?: { microservice?: { id?: string; providerId?: string } } }).body?.microservice?.providerId === config.providerGateway.id, codexQueueStatus);
|
|
addSelectedCheck(checks, options, "microservice:codex-queue-health", (codexQueueHealth as { ok?: boolean }).ok === true && codexQueueHealthBody?.ok === true && codexQueueHealthBody.queue?.defaultModel === "gpt-5.5" && codexQueueHealthBody.queue?.modelReasoningEfforts?.["gpt-5.5"] === "xhigh", codexQueueHealth);
|
|
addSelectedCheck(checks, options, "microservice:codex-queue-tasks", (codexQueueTasks as { ok?: boolean }).ok === true && codexQueueTasksBody?.ok === true && Array.isArray(codexQueueTasksBody.tasks) && codexQueueTasksBody.queue?.defaultModel === "gpt-5.5" && codexQueueTasksBody.queue?.modelReasoningEfforts?.["gpt-5.5"] === "xhigh", codexQueueTasks);
|
|
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 needPerformancePanel = wants("frontend:performance-panel-visible");
|
|
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 needCodexQueue = wantsAny([
|
|
"frontend:codex-queue-integrated-visible",
|
|
"frontend:codex-queue-initial-prompt-full-expand",
|
|
"frontend:codex-queue-trace-full-load",
|
|
"frontend:codex-queue-judge-wrap",
|
|
]);
|
|
const needClaudeqq = wants("frontend:claudeqq-integrated-visible");
|
|
const needRouteDeepLink = wants("frontend:url-route-deeplink");
|
|
const needPipeline = wantsAny([
|
|
"frontend:pipeline-integrated-visible",
|
|
"frontend:pipeline-react-flow-visible",
|
|
"frontend:pipeline-sidebars-collapsible",
|
|
"frontend:pipeline-gantt-defaults",
|
|
"frontend:pipeline-gantt-frontend-y-accuracy",
|
|
"frontend:pipeline-gantt-export",
|
|
"frontend:pipeline-gantt-observation-live-running",
|
|
"frontend:pipeline-step-timeline-visible",
|
|
"frontend:pipeline-oa-event-flow-visible",
|
|
"frontend:pipeline-minimax-quota-visible",
|
|
]);
|
|
const needMetNonlinear = wantsAny([
|
|
"frontend:met-nonlinear-integrated-visible",
|
|
"frontend:met-nonlinear-project-tree-detail",
|
|
"frontend:met-nonlinear-queue-detail-speed",
|
|
]);
|
|
const needLayoutOverflowDesktop = wants("frontend:layout-overflow-desktop");
|
|
const needLayoutOverflowMobile = wants("frontend:layout-overflow-mobile");
|
|
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 performanceText = "";
|
|
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 codexQueueText = "";
|
|
let codexQueueOutputText = "";
|
|
let codexQueueTaskCount = 0;
|
|
let codexQueueOptions: string[] = [];
|
|
let codexQueueSwitchMetrics: any = { optionCount: 0, switched: false };
|
|
let codexQueueSubmitQueueControl: any = { tagName: "", createButtonVisible: false, oldInputMissing: false };
|
|
let codexQueueTracePlacement: any = { firstChildIsTrace: false, noPageTopStatus: false, filterInsideTracePanel: false, traceStatusVisible: false, markAllReadVisible: false };
|
|
let codexQueueGlobalStatus: any = { activeMicroserviceVisible: false };
|
|
let codexQueuePromptDefaultEmpty = false;
|
|
let codexQueueSubmitGuard: any = { batchRowVisible: false, disabledBeforeConfirm: false, enabledAfterConfirm: false, waitElementMissingBeforeSubmit: false };
|
|
let codexQueueScrollbarMetrics: any = { transcriptThin: false, toolHorizontalHidden: true };
|
|
let codexInitialPromptFullMetrics: any = { candidateFound: false };
|
|
let codexTraceFullMetrics: any = { candidateFound: false };
|
|
let codexJudgeWrapMetrics: any = { checked: false };
|
|
let claudeqqText = "";
|
|
let routeDeepLinkText = "";
|
|
let routeInitialPath = "";
|
|
let routeDockerPath = "";
|
|
let routeBackIntermediatePath = "";
|
|
let routeBackPath = "";
|
|
let routeOverviewPath = "";
|
|
let routeOverviewText = "";
|
|
let routeCodexPath = "";
|
|
let routeCodexShellMetrics: any = { appShell: false, standalone: false, railText: "", topbar: false, tabsText: "", codexPage: false };
|
|
let pipelineText = "";
|
|
let pipelineOaPanelText = "";
|
|
let pipelineMinimaxQuotaText = "";
|
|
let pipelinePriorityOrder: any = { desktop: [], mobile: [] };
|
|
let pipelineSidebarMetrics: any = {
|
|
nodeDefaultOpen: "",
|
|
nodeAfterClickOpen: "",
|
|
nodeAfterCollapseOpen: "",
|
|
nodeToggleEnabledAfterCollapse: false,
|
|
ganttDefaultOpen: "",
|
|
ganttLineCount: 0,
|
|
ganttAfterClickOpen: "",
|
|
ganttAfterCollapseOpen: "",
|
|
ganttToggleEnabledAfterCollapse: false,
|
|
mobileNodeCollapsed: "",
|
|
mobileGanttCollapsed: "",
|
|
};
|
|
let pipelineFlowNodeCount = 0;
|
|
let pipelineFlowEdgeCount = 0;
|
|
let pipelineSelectedId = "";
|
|
let pipelineGanttScaleLabel = "";
|
|
let pipelineGanttAutoHideIdleChecked = true;
|
|
let pipelineGanttHeaderNodeOrder: string[] = [];
|
|
let pipelineGanttFrontendYMetrics: any = { layoutSource: "", checked: 0, maxDelta: null, violations: [] };
|
|
let pipelineGanttExportInfo: any = { downloaded: false, suggestedFilename: "", savePath: "", bytes: 0 };
|
|
let pipelineSnapshotForFrontend: any = null;
|
|
let pipelineObservationSetup: any = null;
|
|
let pipelineObservationGanttMetrics: any = { candidate: null, observationArrowCount: 0, observationSourceMarkerCount: 0, observationArrowTargetInsetsPx: [], liveRunningBarCount: 0, liveRunningHeights: [], runningAnimationNames: [], arrowPairs: [], hasLiveSweep: false };
|
|
let pipelineStepTimelineText = "";
|
|
let pipelineSessionHeadText = "";
|
|
let firstPipelineStepSummaryText = "";
|
|
let pipelineTimelineMetrics = { clientWidth: 0, scrollWidth: 0, clientHeight: 0, scrollHeight: 0, hasHorizontalScroll: false, flowConnectorVisible: false, maxStepIdleGapMs: 0, idleGapCount: 0, oldPipelineStepStyleCount: 0, emptyAttemptDetailCount: 0, selectedNodeId: "", selectedProcedureRunId: "" };
|
|
let firstPipelineStepSummaryMetrics = { clientWidth: 0, scrollWidth: 0, clientHeight: 0, scrollHeight: 0, hasHorizontalScroll: false };
|
|
let firstPipelineStepExpandedText = "";
|
|
let metNonlinearInitialText = "";
|
|
let metProjectTreeText = "";
|
|
let metProjectDetailText = "";
|
|
let metCompletedText = "";
|
|
let metJobDetailText = "";
|
|
let layoutOverflowDesktop: OverflowProbe[] = [];
|
|
let layoutOverflowMobile: OverflowProbe[] = [];
|
|
|
|
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 (wants("frontend:pipeline-gantt-observation-live-running")) {
|
|
pipelineObservationSetup = await ensurePipelineLiveObservationCandidate();
|
|
}
|
|
|
|
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 || needPerformancePanel) {
|
|
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 (needPerformancePanel) {
|
|
await page.getByRole("button", { name: /性能面板/ }).click();
|
|
await page.waitForSelector('[data-testid="performance-page"]', { timeout: 10000 });
|
|
await page.waitForSelector('[data-testid="performance-memory-chart"]', { timeout: 10000 });
|
|
await page.waitForFunction(() => {
|
|
const text = document.body.innerText;
|
|
return text.includes("性能面板")
|
|
&& text.includes("组件汇总")
|
|
&& text.includes("最近失败请求")
|
|
&& text.includes("内部操作汇总")
|
|
&& text.includes("最近慢操作")
|
|
&& text.includes("Bwebui");
|
|
}, undefined, { timeout: 15000 });
|
|
performanceText = await page.locator('[data-testid="performance-page"]').innerText({ timeout: 5000 });
|
|
}
|
|
}
|
|
|
|
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 || needCodexQueue || needClaudeqq || needRouteDeepLink || needPipeline || needMetNonlinear) {
|
|
await page.getByRole("button", { name: /用户服务/ }).click();
|
|
if (needMicroserviceCatalog || needTodoNote || needFindJob || needCodexQueue || needClaudeqq || 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-claudeqq"]', { timeout: 10000 });
|
|
await page.waitForSelector('[data-testid="microservice-row-todo-note"]', { timeout: 10000 });
|
|
await page.waitForSelector('[data-testid="microservice-row-codex-queue"]', { 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 (needCodexQueue) {
|
|
await page.getByRole("button", { name: /Codex Queue/ }).click();
|
|
await page.waitForSelector('[data-testid="codex-queue-page"]', { timeout: 10000 });
|
|
await page.waitForFunction(() => {
|
|
const text = document.body.innerText;
|
|
const lower = text.toLowerCase();
|
|
return lower.includes("codex queue")
|
|
&& text.includes("gpt-5.4-mini")
|
|
&& text.includes("gpt-5.4")
|
|
&& text.includes("gpt-5.5")
|
|
&& text.includes("仅 UniDesk frontend 代理访问")
|
|
&& text.includes("提交任务")
|
|
&& text.includes("入队份数")
|
|
&& text.includes("追加 prompt")
|
|
&& text.includes("打断")
|
|
&& lower.includes("attempts");
|
|
}, undefined, { timeout: 30000 });
|
|
await page.waitForSelector('[data-testid="codex-queue-filter-select"]', { timeout: 10000 });
|
|
codexQueueTracePlacement = await page.evaluate(() => ({
|
|
firstChildIsTrace: Boolean(document.querySelector('[data-testid="codex-queue-page"] > .codex-session-stage:first-child .codex-output-panel')),
|
|
noPageTopStatus: document.querySelector('[data-testid="codex-queue-page"] > [data-testid="codex-top-status"]') === null,
|
|
filterInsideTracePanel: Boolean(document.querySelector('.codex-output-panel [data-testid="codex-queue-filter-select"]')),
|
|
traceStatusVisible: /排队\s*\d+.*运行\s*\d+.*结束未读\s*\d+/s.test(document.querySelector('[data-testid="codex-trace-status-summary"]')?.textContent || ""),
|
|
markAllReadVisible: Boolean(document.querySelector('.codex-output-panel [data-testid="codex-mark-all-read-button"]')),
|
|
}));
|
|
codexQueueGlobalStatus = await page.evaluate(() => {
|
|
const text = document.querySelector('[data-testid="active-microservice-status"]')?.textContent || "";
|
|
return { activeMicroserviceVisible: /Codex Queue.*在线|codex queue.*在线/i.test(text), text };
|
|
});
|
|
await page.waitForSelector('[data-testid="codex-queue-id-select"]', { timeout: 10000 });
|
|
await page.waitForSelector('[data-testid="codex-create-queue-button"]', { timeout: 10000 });
|
|
codexQueueSubmitQueueControl = await page.evaluate(() => {
|
|
const select = document.querySelector('[data-testid="codex-queue-id-select"]') as HTMLSelectElement | null;
|
|
const button = document.querySelector('[data-testid="codex-create-queue-button"]') as HTMLButtonElement | null;
|
|
const prompt = document.querySelector('[data-testid="codex-queue-task-form"] textarea') as HTMLTextAreaElement | null;
|
|
const maxAttempts = document.querySelector('[data-testid="codex-max-attempts-input"]') as HTMLInputElement | null;
|
|
const moveSelect = document.querySelector('[data-testid="codex-task-queue-move-select"]') as HTMLSelectElement | null;
|
|
const moveButton = document.querySelector('[data-testid="codex-task-queue-move-button"]') as HTMLButtonElement | null;
|
|
return {
|
|
tagName: select?.tagName.toLowerCase() || "",
|
|
optionCount: select?.options.length ?? 0,
|
|
createButtonVisible: Boolean(button && button.offsetParent !== null),
|
|
oldInputMissing: document.querySelector('[data-testid="codex-queue-id-input"]') === null,
|
|
promptDefaultEmpty: (prompt?.value || "") === "",
|
|
maxAttemptsMax: maxAttempts?.max || "",
|
|
maxAttemptsValue: maxAttempts?.value || "",
|
|
moveQueueVisible: Boolean(moveSelect && moveSelect.offsetParent !== null && moveButton && moveButton.offsetParent !== null),
|
|
};
|
|
});
|
|
codexQueuePromptDefaultEmpty = Boolean(codexQueueSubmitQueueControl.promptDefaultEmpty);
|
|
await page.locator('[data-testid="codex-queue-task-form"] textarea').fill("e2e batch guard one\n---\ne2e batch guard two");
|
|
await page.waitForSelector('[data-testid="codex-batch-confirm-row"]', { timeout: 5000 });
|
|
codexQueueSubmitGuard = await page.evaluate(() => {
|
|
const row = document.querySelector('[data-testid="codex-batch-confirm-row"]') as HTMLElement | null;
|
|
const checkbox = document.querySelector('[data-testid="codex-batch-confirm-checkbox"]') as HTMLInputElement | null;
|
|
const button = document.querySelector('[data-testid="codex-enqueue-button"]') as HTMLButtonElement | null;
|
|
const wait = document.querySelector('[data-testid="codex-submit-wait"]') as HTMLElement | null;
|
|
return {
|
|
batchRowVisible: Boolean(row && row.offsetParent !== null),
|
|
checkboxVisible: Boolean(checkbox && checkbox.offsetParent !== null),
|
|
disabledBeforeConfirm: Boolean(button?.disabled),
|
|
buttonTextBeforeConfirm: button?.textContent || "",
|
|
waitElementMissingBeforeSubmit: wait === null,
|
|
};
|
|
});
|
|
await page.locator('[data-testid="codex-batch-confirm-checkbox"]').check();
|
|
await page.waitForFunction(() => {
|
|
const button = document.querySelector('[data-testid="codex-enqueue-button"]') as HTMLButtonElement | null;
|
|
return button !== null && !button.disabled;
|
|
}, undefined, { timeout: 5000 });
|
|
codexQueueSubmitGuard = {
|
|
...codexQueueSubmitGuard,
|
|
...(await page.evaluate(() => {
|
|
const button = document.querySelector('[data-testid="codex-enqueue-button"]') as HTMLButtonElement | null;
|
|
return {
|
|
enabledAfterConfirm: Boolean(button && !button.disabled),
|
|
buttonTextAfterConfirm: button?.textContent || "",
|
|
};
|
|
})),
|
|
};
|
|
await page.locator('[data-testid="codex-clear-input-button"]').click();
|
|
codexQueueOptions = await page.locator('[data-testid="codex-queue-filter-select"] option').evaluateAll((options) => options.map((option) => (option as HTMLOptionElement).textContent || ""));
|
|
codexQueueSwitchMetrics = await page.locator('[data-testid="codex-queue-filter-select"] option').evaluateAll((options) => ({
|
|
optionCount: options.length,
|
|
queueValues: options.map((option) => (option as HTMLOptionElement).value).filter((value) => value !== "__all__"),
|
|
switched: false,
|
|
}));
|
|
await page.waitForSelector('[data-testid="codex-output"]', { timeout: 10000 });
|
|
await page.waitForFunction(() => {
|
|
const taskCount = document.querySelectorAll('[data-testid^="codex-task-codex_"]').length;
|
|
const output = document.querySelector('[data-testid="codex-output"]')?.textContent || "";
|
|
return taskCount === 0 || output.includes("Submitted prompt");
|
|
}, undefined, { timeout: 15000 });
|
|
codexQueueScrollbarMetrics = await page.evaluate(() => {
|
|
const transcript = document.querySelector('.codex-transcript') as HTMLElement | null;
|
|
const toolBlock = document.querySelector('.codex-transcript-item.ran .codex-transcript-command, .codex-transcript-item.ran .codex-transcript-body, .codex-transcript-item.explored .codex-transcript-command, .codex-transcript-item.explored .codex-transcript-body, .codex-transcript-item.edited .codex-transcript-command, .codex-transcript-item.edited .codex-transcript-body') as HTMLElement | null;
|
|
const transcriptStyle = transcript ? getComputedStyle(transcript) : null;
|
|
const toolStyle = toolBlock ? getComputedStyle(toolBlock) : null;
|
|
return {
|
|
transcriptThin: transcriptStyle?.scrollbarWidth === "thin",
|
|
transcriptScrollbarWidth: transcriptStyle?.scrollbarWidth || "",
|
|
toolFound: toolBlock !== null,
|
|
toolHorizontalHidden: toolBlock === null || toolStyle?.scrollbarWidth === "none",
|
|
toolScrollbarWidth: toolStyle?.scrollbarWidth || "",
|
|
toolOverflowX: toolStyle?.overflowX || "",
|
|
};
|
|
});
|
|
if (wants("frontend:codex-queue-judge-wrap")) {
|
|
codexJudgeWrapMetrics = await page.evaluate(() => {
|
|
const measure = (element: HTMLElement | null): any => {
|
|
if (element === null) return { found: false };
|
|
const style = getComputedStyle(element);
|
|
const rect = element.getBoundingClientRect();
|
|
const horizontalOverflowPx = Math.max(0, element.scrollWidth - element.clientWidth);
|
|
return {
|
|
found: true,
|
|
tag: element.tagName.toLowerCase(),
|
|
className: String(element.className || ""),
|
|
clientWidth: Math.round(element.clientWidth),
|
|
scrollWidth: Math.round(element.scrollWidth),
|
|
offsetWidth: Math.round(element.offsetWidth),
|
|
rectWidth: Math.round(rect.width),
|
|
horizontalOverflowPx: Math.round(horizontalOverflowPx),
|
|
noHorizontalOverflow: horizontalOverflowPx <= 1,
|
|
whiteSpace: style.whiteSpace,
|
|
overflowWrap: style.overflowWrap,
|
|
wordBreak: style.wordBreak,
|
|
overflowX: style.overflowX,
|
|
};
|
|
};
|
|
const longReason = `judge-regression-${"x".repeat(420)}`;
|
|
const longContinuePrompt = `continue-${"y".repeat(420)}\nnext-line-${"z".repeat(420)}`;
|
|
const probe = document.createElement("section");
|
|
probe.className = "codex-progressive-card codex-progressive-judge";
|
|
probe.setAttribute("data-testid", "codex-judge-wrap-probe");
|
|
probe.style.cssText = "position: fixed; left: 16px; top: 16px; width: 320px; max-width: 320px; opacity: 0; pointer-events: none; z-index: -1;";
|
|
const card = document.createElement("div");
|
|
card.className = "codex-judge-card";
|
|
card.setAttribute("data-testid", "codex-judge-wrap-probe-card");
|
|
const badge = document.createElement("span");
|
|
badge.className = "status-badge retry";
|
|
badge.textContent = "retry";
|
|
const confidence = document.createElement("strong");
|
|
confidence.textContent = "92% confidence";
|
|
const reason = document.createElement("p");
|
|
reason.setAttribute("data-testid", "codex-judge-wrap-probe-reason");
|
|
reason.textContent = longReason;
|
|
const continuePrompt = document.createElement("pre");
|
|
continuePrompt.setAttribute("data-testid", "codex-judge-wrap-probe-continue");
|
|
continuePrompt.textContent = longContinuePrompt;
|
|
card.append(badge, confidence, reason, continuePrompt);
|
|
probe.append(card);
|
|
document.body.append(probe);
|
|
const probeMetrics = {
|
|
card: measure(card),
|
|
reason: measure(reason),
|
|
continuePrompt: measure(continuePrompt),
|
|
};
|
|
probe.remove();
|
|
const actualCards = Array.from(document.querySelectorAll('[data-testid$="-judge-card"], [data-testid="codex-task-judge-card"], .codex-progressive-judge .codex-judge-card'))
|
|
.filter((element): element is HTMLElement => element instanceof HTMLElement)
|
|
.slice(0, 8)
|
|
.map((element) => measure(element));
|
|
const noActualOverflow = actualCards.every((item: any) => item.noHorizontalOverflow === true);
|
|
const reasonWraps = probeMetrics.reason.overflowWrap === "anywhere" || probeMetrics.reason.wordBreak === "break-word";
|
|
const continueWraps = probeMetrics.continuePrompt.whiteSpace === "pre-wrap" && (probeMetrics.continuePrompt.overflowWrap === "anywhere" || probeMetrics.continuePrompt.wordBreak === "break-word");
|
|
return {
|
|
checked: true,
|
|
tokenLength: longReason.length,
|
|
probeWidth: 320,
|
|
probe: probeMetrics,
|
|
actualCardCount: actualCards.length,
|
|
actualCards,
|
|
reasonWraps,
|
|
continueWraps,
|
|
noActualOverflow,
|
|
ok: probeMetrics.card.noHorizontalOverflow === true
|
|
&& probeMetrics.reason.noHorizontalOverflow === true
|
|
&& probeMetrics.continuePrompt.noHorizontalOverflow === true
|
|
&& reasonWraps
|
|
&& continueWraps
|
|
&& noActualOverflow,
|
|
};
|
|
});
|
|
}
|
|
codexQueueTaskCount = await page.locator('[data-testid^="codex-task-codex_"]').count();
|
|
if (wants("frontend:codex-queue-initial-prompt-full-expand")) {
|
|
codexInitialPromptFullMetrics = await page.evaluate(async () => {
|
|
const tasksResponse = await fetch("/api/microservices/codex-queue/proxy/api/tasks?limit=300&lite=1&devReady=0", { credentials: "same-origin" });
|
|
const tasksPayload = await tasksResponse.json().catch(() => null);
|
|
const tasks = Array.isArray(tasksPayload?.tasks) ? tasksPayload.tasks : [];
|
|
const candidate = tasks.find((task: any) => Array.isArray(task?.referenceTaskIds) && task.referenceTaskIds.length > 0);
|
|
if (!candidate?.id) return { candidateFound: false };
|
|
const metaResponse = await fetch(`/api/microservices/codex-queue/proxy/api/tasks/${encodeURIComponent(String(candidate.id))}?meta=1`, { credentials: "same-origin" });
|
|
const metaPayload = await metaResponse.json().catch(() => null);
|
|
const fullPrompt = String(metaPayload?.task?.prompt || "");
|
|
const displayPrompt = String(metaPayload?.task?.displayPrompt || "");
|
|
return {
|
|
candidateFound: true,
|
|
taskId: String(candidate.id),
|
|
promptChars: fullPrompt.length,
|
|
displayPromptChars: displayPrompt.length,
|
|
hasResolvedReference: fullPrompt.includes("# Codex Queue 已解析引用上下文"),
|
|
hasCurrentTaskMarker: fullPrompt.includes("# 本次任务"),
|
|
hasReferenceTaskId: /codex_\\d+_[A-Za-z0-9_-]+/u.test(fullPrompt),
|
|
};
|
|
});
|
|
if (codexInitialPromptFullMetrics.candidateFound) {
|
|
const refTaskCard = page.getByTestId(`codex-task-${codexInitialPromptFullMetrics.taskId}`);
|
|
await refTaskCard.scrollIntoViewIfNeeded({ timeout: 10000 });
|
|
await refTaskCard.click();
|
|
await page.waitForSelector('[data-testid="codex-initial-prompt-full"]', { timeout: 15000 });
|
|
codexInitialPromptFullMetrics.initialDefaultOpen = await page.getByTestId("codex-initial-prompt-full").evaluate((element) => (element as HTMLDetailsElement).open);
|
|
await page.locator('[data-testid="codex-initial-prompt-full"] summary').click();
|
|
await page.waitForFunction(() => (document.querySelector('[data-testid="codex-initial-prompt-full"]') as HTMLDetailsElement | null)?.open === true, undefined, { timeout: 5000 });
|
|
const initialFullText = await page.getByTestId("codex-initial-prompt-full-text").innerText({ timeout: 5000 });
|
|
codexInitialPromptFullMetrics.initialExpanded = await page.getByTestId("codex-initial-prompt-full").evaluate((element) => (element as HTMLDetailsElement).open);
|
|
codexInitialPromptFullMetrics.initialFullHasReference = initialFullText.includes("# Codex Queue 已解析引用上下文") || initialFullText.includes("引用 Codex Queue");
|
|
codexInitialPromptFullMetrics.initialFullHasCurrentTask = initialFullText.includes("# 本次任务") || initialFullText.includes("本次任务:");
|
|
codexInitialPromptFullMetrics.initialFullChars = initialFullText.length;
|
|
codexInitialPromptFullMetrics.legacyPromptPanelMissing = await page.evaluate(() =>
|
|
document.querySelector(".codex-prompt-panel") === null
|
|
&& document.querySelector('[data-testid="codex-task-prompt-detail"]') === null
|
|
&& document.querySelector('[data-testid="codex-final-prompt-full"]') === null,
|
|
);
|
|
}
|
|
}
|
|
if (wants("frontend:codex-queue-trace-full-load")) {
|
|
codexTraceFullMetrics = await page.evaluate(async () => {
|
|
const tasksResponse = await fetch("/api/codex-queue-direct/api/tasks?limit=300&lite=1&devReady=0", { credentials: "same-origin" });
|
|
const tasksPayload = await tasksResponse.json().catch(() => null);
|
|
const tasks = Array.isArray(tasksPayload?.tasks) ? tasksPayload.tasks : [];
|
|
const terminal = new Set(["succeeded", "failed", "canceled"]);
|
|
for (const task of tasks) {
|
|
const taskId = String(task?.id || "");
|
|
if (!taskId || !terminal.has(String(task?.status || "")) || Number(task?.outputCount || 0) < 20) continue;
|
|
const transcriptResponse = await fetch(`/api/codex-queue-direct/api/tasks/${encodeURIComponent(taskId)}/transcript?afterSeq=0&limit=120&fullText=1`, { credentials: "same-origin" });
|
|
const transcriptPayload = await transcriptResponse.json().catch(() => null);
|
|
const transcript = Array.isArray(transcriptPayload?.transcript) ? transcriptPayload.transcript : [];
|
|
const toolCount = transcript.filter((line: any) => ["ran", "explored", "edited"].includes(String(line?.kind || ""))).length;
|
|
if (toolCount >= 8) {
|
|
return {
|
|
candidateFound: true,
|
|
taskId,
|
|
apiTotal: Number(transcriptPayload?.total || transcript.length),
|
|
apiToolCount: toolCount,
|
|
apiHasInitialPrompt: transcript.some((line: any) => String(line?.title || "") === "Submitted prompt"),
|
|
};
|
|
}
|
|
}
|
|
return { candidateFound: false, taskCount: tasks.length };
|
|
});
|
|
if (codexTraceFullMetrics.candidateFound) {
|
|
for (let index = 0; index < 8; index += 1) {
|
|
if (await page.getByTestId(`codex-task-${codexTraceFullMetrics.taskId}`).count()) break;
|
|
const moreButton = page.getByTestId("codex-load-more-tasks-button");
|
|
if (!(await moreButton.count())) break;
|
|
await moreButton.scrollIntoViewIfNeeded().catch(() => undefined);
|
|
await moreButton.click();
|
|
await page.waitForTimeout(500);
|
|
}
|
|
const traceTaskCard = page.getByTestId(`codex-task-${codexTraceFullMetrics.taskId}`);
|
|
await traceTaskCard.scrollIntoViewIfNeeded({ timeout: 10000 });
|
|
await traceTaskCard.click();
|
|
await page.waitForFunction(() => {
|
|
const pageElement = document.querySelector('[data-testid="codex-queue-page"]') as HTMLElement | null;
|
|
const output = document.querySelector('[data-testid="codex-output"]') as HTMLElement | null;
|
|
const toolCount = output?.querySelectorAll('.codex-transcript-item.ran, .codex-transcript-item.explored, .codex-transcript-item.edited').length ?? 0;
|
|
return pageElement?.getAttribute("data-load-state") === "complete" && toolCount >= 8;
|
|
}, undefined, { timeout: 30000 });
|
|
codexTraceFullMetrics = {
|
|
...codexTraceFullMetrics,
|
|
...(await page.evaluate(() => {
|
|
const pageElement = document.querySelector('[data-testid="codex-queue-page"]') as HTMLElement | null;
|
|
const output = document.querySelector('[data-testid="codex-output"]') as HTMLElement | null;
|
|
const text = output?.textContent || "";
|
|
return {
|
|
uiItemCount: output?.querySelectorAll('.codex-transcript-item').length ?? 0,
|
|
uiToolCount: output?.querySelectorAll('.codex-transcript-item.ran, .codex-transcript-item.explored, .codex-transcript-item.edited').length ?? 0,
|
|
uiHasInitialPrompt: text.includes("Submitted prompt"),
|
|
uiHasToolTrace: /Ran|Explored|Edited|Tool calls/.test(text),
|
|
loadState: pageElement?.getAttribute("data-load-state") || "",
|
|
loadPartial: pageElement?.getAttribute("data-load-partial") || "",
|
|
loadRows: Number(pageElement?.getAttribute("data-load-transcript-rows") || 0),
|
|
loadButtonVisible: document.querySelector('[data-testid="codex-load-full-trace-button"]') !== null,
|
|
};
|
|
})),
|
|
};
|
|
}
|
|
}
|
|
codexQueueOutputText = await page.locator('[data-testid="codex-output"]').innerText({ timeout: 5000 });
|
|
codexQueueText = await page.locator('[data-testid="codex-queue-page"]').innerText({ timeout: 5000 });
|
|
codexQueueOptions = await page.locator('[data-testid="codex-queue-filter-select"] option').evaluateAll((options) => options.map((option) => (option as HTMLOptionElement).textContent || ""));
|
|
codexQueueSubmitQueueControl = await page.evaluate(() => {
|
|
const select = document.querySelector('[data-testid="codex-queue-id-select"]') as HTMLSelectElement | null;
|
|
const button = document.querySelector('[data-testid="codex-create-queue-button"]') as HTMLButtonElement | null;
|
|
const prompt = document.querySelector('[data-testid="codex-queue-task-form"] textarea') as HTMLTextAreaElement | null;
|
|
const maxAttempts = document.querySelector('[data-testid="codex-max-attempts-input"]') as HTMLInputElement | null;
|
|
const moveSelect = document.querySelector('[data-testid="codex-task-queue-move-select"]') as HTMLSelectElement | null;
|
|
const moveButton = document.querySelector('[data-testid="codex-task-queue-move-button"]') as HTMLButtonElement | null;
|
|
return {
|
|
tagName: select?.tagName.toLowerCase() || "",
|
|
optionCount: select?.options.length ?? 0,
|
|
createButtonVisible: Boolean(button && button.offsetParent !== null),
|
|
oldInputMissing: document.querySelector('[data-testid="codex-queue-id-input"]') === null,
|
|
promptDefaultEmpty: (prompt?.value || "") === "",
|
|
maxAttemptsMax: maxAttempts?.max || "",
|
|
maxAttemptsValue: maxAttempts?.value || "",
|
|
moveQueueVisible: Boolean(moveSelect && moveSelect.offsetParent !== null && moveButton && moveButton.offsetParent !== null),
|
|
};
|
|
});
|
|
codexQueuePromptDefaultEmpty = Boolean(codexQueueSubmitQueueControl.promptDefaultEmpty);
|
|
codexQueueSwitchMetrics = await page.locator('[data-testid="codex-queue-filter-select"] option').evaluateAll((options) => ({
|
|
optionCount: options.length,
|
|
queueValues: options.map((option) => (option as HTMLOptionElement).value).filter((value) => value !== "__all__"),
|
|
switched: false,
|
|
}));
|
|
if (codexQueueSwitchMetrics.queueValues.length > 0) {
|
|
const targetQueueId = String(codexQueueSwitchMetrics.queueValues.find((value: string) => value !== "default") || codexQueueSwitchMetrics.queueValues[0]);
|
|
await page.getByTestId("codex-queue-filter-select").selectOption(targetQueueId);
|
|
await page.waitForFunction((queueId) => {
|
|
const text = document.body.innerText;
|
|
return text.includes(`view=${queueId}`) || text.includes(`${queueId} ·`);
|
|
}, targetQueueId, { timeout: 10000 });
|
|
await page.waitForFunction((queueId) => {
|
|
const cards = Array.from(document.querySelectorAll('[data-testid^="codex-task-codex_"]')).map((node) => (node as HTMLElement).innerText);
|
|
return cards.length === 0 || cards.every((text) => text.includes(`queue=${queueId}`));
|
|
}, targetQueueId, { timeout: 15000 });
|
|
codexQueueSwitchMetrics = { ...codexQueueSwitchMetrics, targetQueueId, switched: true };
|
|
await page.getByTestId("codex-queue-filter-select").selectOption("__all__");
|
|
}
|
|
}
|
|
if (needClaudeqq) {
|
|
await page.getByRole("button", { name: /ClaudeQQ/ }).click();
|
|
await page.waitForSelector('[data-testid="claudeqq-page"]', { timeout: 10000 });
|
|
await page.waitForFunction(() => {
|
|
const text = document.body.innerText;
|
|
const lower = text.toLowerCase();
|
|
return lower.includes("claudeqq 工作台")
|
|
&& text.includes("D601")
|
|
&& text.includes("QQ 事件订阅")
|
|
&& text.includes("消息推送")
|
|
&& lower.includes("napcat 容器登录")
|
|
&& text.includes("事件缓存")
|
|
&& text.includes("仅 UniDesk frontend 代理访问");
|
|
}, undefined, { timeout: 30000 });
|
|
await page.waitForFunction(() => document.body.innerText.includes("最近 QQ 事件"), undefined, { timeout: 10000 });
|
|
claudeqqText = await page.locator('[data-testid="claudeqq-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.goto(`${urls.frontendUrl}/app/codex-queue/`, { waitUntil: "domcontentloaded", timeout: 15000 });
|
|
await page.waitForSelector('[data-testid="app-shell"]', { timeout: 10000 });
|
|
await page.waitForSelector('[data-testid="codex-queue-page"]', { timeout: 15000 });
|
|
routeCodexPath = new URL(page.url()).pathname;
|
|
routeCodexShellMetrics = await page.evaluate(() => ({
|
|
appShell: Boolean(document.querySelector('[data-testid="app-shell"]')),
|
|
standalone: Boolean(document.querySelector('[data-testid="codex-queue-standalone"]')),
|
|
railText: document.querySelector(".rail")?.textContent || "",
|
|
topbar: Boolean(document.querySelector(".topbar")),
|
|
tabsText: document.querySelector(".tabs")?.textContent || "",
|
|
codexPage: Boolean(document.querySelector('[data-testid="codex-queue-page"]')),
|
|
}));
|
|
await page.locator('.rail button[title="用户服务"]').click();
|
|
await page.getByRole("button", { name: /^服务目录$/ }).click();
|
|
await page.waitForSelector('[data-testid="microservice-catalog-page"]', { timeout: 10000 });
|
|
}
|
|
if (needPipeline) {
|
|
await page.getByRole("button", { name: "Pipeline", exact: true }).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();
|
|
await page.waitForFunction(() => document.querySelectorAll('[data-testid="pipeline-react-flow"] .react-flow__edge').length > 0, undefined, { timeout: 10000 }).catch(() => undefined);
|
|
pipelineFlowEdgeCount = await page.locator('[data-testid="pipeline-react-flow"] .react-flow__edge').count();
|
|
pipelineSelectedId = await page.locator('[data-testid="pipeline-select"]').evaluate((element) => (element as HTMLSelectElement).value);
|
|
await page.waitForFunction(() => {
|
|
const panel = document.querySelector('[data-testid="pipeline-oa-event-flow-panel"]') as HTMLElement | null;
|
|
const text = panel?.innerText || "";
|
|
const lower = text.toLowerCase();
|
|
return lower.includes("oa flow")
|
|
&& text.includes("100%")
|
|
&& lower.includes("no-audit")
|
|
&& lower.includes("monitor 审核")
|
|
&& text.includes("禁止残留")
|
|
&& text.includes("policy-in-detail 0");
|
|
}, undefined, { timeout: 30000 });
|
|
pipelineText = await page.locator('[data-testid="pipeline-page"]').innerText({ timeout: 5000 });
|
|
pipelineOaPanelText = await page.locator('[data-testid="pipeline-oa-event-flow-panel"]').innerText({ timeout: 5000 });
|
|
pipelineMinimaxQuotaText = await page.locator('[data-testid="pipeline-minimax-quota-panel"]').innerText({ timeout: 5000 });
|
|
const pipelinePanelOrder = async () => page.locator('[data-testid="pipeline-page"] .pipeline-grid > .panel .panel-head h2').evaluateAll((elements) => elements.map((element) => (element as HTMLElement).innerText.trim()).filter(Boolean));
|
|
pipelinePriorityOrder.desktop = await pipelinePanelOrder();
|
|
await page.setViewportSize({ width: 390, height: 860 });
|
|
await page.waitForTimeout(120);
|
|
pipelinePriorityOrder.mobile = await pipelinePanelOrder();
|
|
await page.setViewportSize({ width: 1440, height: 920 });
|
|
await page.waitForTimeout(120);
|
|
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();
|
|
await page.waitForFunction(() => document.querySelectorAll('[data-testid="pipeline-gantt-head-node"]').length > 0, undefined, { timeout: 15000 });
|
|
pipelineGanttHeaderNodeOrder = await page.locator('[data-testid="pipeline-gantt-head-node"]').evaluateAll((elements) =>
|
|
elements.map((element) => element.getAttribute("data-node-id") || "").filter(Boolean));
|
|
pipelineSidebarMetrics.nodeDefaultOpen = await page.getByTestId("pipeline-control-shell").getAttribute("data-sidebar-open") || "";
|
|
const firstFlowNode = page.locator('[data-testid="pipeline-react-flow"] .react-flow__node').first();
|
|
await firstFlowNode.scrollIntoViewIfNeeded({ timeout: 10000 });
|
|
await firstFlowNode.click({ force: true });
|
|
const firstNodeClickOpened = await page.waitForFunction(() => document.querySelector('[data-testid="pipeline-control-shell"]')?.getAttribute("data-sidebar-open") === "true", undefined, { timeout: 2500 })
|
|
.then(() => true)
|
|
.catch(() => false);
|
|
if (!firstNodeClickOpened) {
|
|
const box = await firstFlowNode.boundingBox();
|
|
if (box) await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
|
|
}
|
|
await page.waitForFunction(() => document.querySelector('[data-testid="pipeline-control-shell"]')?.getAttribute("data-sidebar-open") === "true", undefined, { timeout: 15000 });
|
|
await page.waitForSelector('[data-testid="pipeline-node-control"]', { timeout: 5000 });
|
|
pipelineSidebarMetrics.nodeAfterClickOpen = await page.getByTestId("pipeline-control-shell").getAttribute("data-sidebar-open") || "";
|
|
await page.getByTestId("pipeline-node-sidebar-collapse").click();
|
|
await page.waitForFunction(() => document.querySelector('[data-testid="pipeline-control-shell"]')?.getAttribute("data-sidebar-open") === "false", undefined, { timeout: 10000 });
|
|
pipelineSidebarMetrics.nodeAfterCollapseOpen = await page.getByTestId("pipeline-control-shell").getAttribute("data-sidebar-open") || "";
|
|
pipelineSidebarMetrics.nodeToggleEnabledAfterCollapse = !(await page.getByTestId("pipeline-node-sidebar-toggle").isDisabled());
|
|
pipelineSidebarMetrics.ganttDefaultOpen = await page.getByTestId("pipeline-gantt-detail-layout").getAttribute("data-sidebar-open") || "";
|
|
const sidebarProbeGanttLines = page.locator('[data-testid="pipeline-epoch-gantt"] [data-testid="pipeline-gantt-line"]');
|
|
pipelineSidebarMetrics.ganttLineCount = await sidebarProbeGanttLines.count();
|
|
if (pipelineSidebarMetrics.ganttLineCount > 0) {
|
|
const firstSidebarProbeGanttLine = sidebarProbeGanttLines.first();
|
|
await firstSidebarProbeGanttLine.scrollIntoViewIfNeeded({ timeout: 10000 });
|
|
await firstSidebarProbeGanttLine.click({ force: true });
|
|
const firstGanttClickOpened = await page.waitForFunction(() => document.querySelector('[data-testid="pipeline-gantt-detail-layout"]')?.getAttribute("data-sidebar-open") === "true", undefined, { timeout: 2500 })
|
|
.then(() => true)
|
|
.catch(() => false);
|
|
if (!firstGanttClickOpened) {
|
|
const box = await firstSidebarProbeGanttLine.boundingBox();
|
|
if (box) await page.mouse.click(box.x + box.width / 2, box.y + Math.max(2, Math.min(box.height / 2, 12)));
|
|
}
|
|
await page.waitForFunction(() => document.querySelector('[data-testid="pipeline-gantt-detail-layout"]')?.getAttribute("data-sidebar-open") === "true", undefined, { timeout: 15000 });
|
|
await page.waitForSelector('[data-testid="pipeline-gantt-detail-panel"]', { timeout: 5000 });
|
|
pipelineSidebarMetrics.ganttAfterClickOpen = await page.getByTestId("pipeline-gantt-detail-layout").getAttribute("data-sidebar-open") || "";
|
|
await page.getByTestId("pipeline-gantt-sidebar-collapse").click();
|
|
await page.waitForFunction(() => document.querySelector('[data-testid="pipeline-gantt-detail-layout"]')?.getAttribute("data-sidebar-open") === "false", undefined, { timeout: 10000 });
|
|
pipelineSidebarMetrics.ganttAfterCollapseOpen = await page.getByTestId("pipeline-gantt-detail-layout").getAttribute("data-sidebar-open") || "";
|
|
pipelineSidebarMetrics.ganttToggleEnabledAfterCollapse = !(await page.getByTestId("pipeline-gantt-sidebar-toggle").isDisabled());
|
|
}
|
|
await page.setViewportSize({ width: 390, height: 860 });
|
|
await page.waitForTimeout(120);
|
|
pipelineSidebarMetrics.mobileNodeCollapsed = await page.getByTestId("pipeline-control-shell").getAttribute("data-sidebar-open") || "";
|
|
pipelineSidebarMetrics.mobileGanttCollapsed = await page.getByTestId("pipeline-gantt-detail-layout").getAttribute("data-sidebar-open") || "";
|
|
await page.setViewportSize({ width: 1440, height: 920 });
|
|
await page.waitForTimeout(120);
|
|
if (wants("frontend:pipeline-gantt-frontend-y-accuracy")) {
|
|
pipelineGanttFrontendYMetrics = await page.locator('[data-testid="pipeline-epoch-gantt"]').evaluate((element) => {
|
|
const root = element as HTMLElement;
|
|
const startMs = Number(root.dataset.startMs || "0");
|
|
const endMs = Number(root.dataset.endMs || "0");
|
|
const chartHeight = Number(root.dataset.chartHeight || "0");
|
|
const duration = Math.max(1, endMs - startMs);
|
|
const expectedY = (ms: number): number => Math.max(0, Math.min(1, (ms - startMs) / duration)) * chartHeight;
|
|
const violations: any[] = [];
|
|
let checked = 0;
|
|
let maxDelta = 0;
|
|
const addCheck = (kind: string, id: string, actual: number, expected: number, tolerance = 1.25): void => {
|
|
if (!Number.isFinite(actual) || !Number.isFinite(expected)) {
|
|
violations.push({ kind, id, actual, expected, reason: "non-finite" });
|
|
return;
|
|
}
|
|
checked += 1;
|
|
const delta = Math.abs(actual - expected);
|
|
maxDelta = Math.max(maxDelta, delta);
|
|
if (delta > tolerance) violations.push({ kind, id, actual: Math.round(actual * 1000) / 1000, expected: Math.round(expected * 1000) / 1000, delta: Math.round(delta * 1000) / 1000 });
|
|
};
|
|
const parseTop = (node: HTMLElement): number => Number.parseFloat(node.style.top || window.getComputedStyle(node).top || "NaN");
|
|
const parseHeight = (node: HTMLElement): number => Number.parseFloat(node.style.height || window.getComputedStyle(node).height || "NaN");
|
|
for (const tick of Array.from(root.querySelectorAll('[data-testid="pipeline-gantt-tick"]')) as HTMLElement[]) {
|
|
const ms = Number(tick.dataset.ms || "NaN");
|
|
const y = Number(tick.dataset.y || parseTop(tick));
|
|
const top = parseTop(tick);
|
|
addCheck("tick-data-y", tick.dataset.ms || "", y, expectedY(ms));
|
|
addCheck("tick-style-top", tick.dataset.ms || "", top, expectedY(ms));
|
|
}
|
|
for (const line of Array.from(root.querySelectorAll('[data-testid="pipeline-gantt-line"]')) as HTMLElement[]) {
|
|
const id = line.dataset.procedureRunId || line.dataset.nodeId || "";
|
|
const start = Number(line.dataset.startMs || "NaN");
|
|
const end = Number(line.dataset.endMs || "NaN");
|
|
const y1 = Number(line.dataset.y1 || "NaN");
|
|
const y2 = Number(line.dataset.y2 || "NaN");
|
|
const top = parseTop(line);
|
|
const height = parseHeight(line);
|
|
const expectedStartY = expectedY(start);
|
|
const expectedEndY = expectedY(end);
|
|
const expectedHeight = Math.max(line.dataset.live === "true" ? 24 : 10, expectedEndY - expectedStartY);
|
|
addCheck("line-y1", id, y1, expectedStartY);
|
|
addCheck("line-y2", id, y2, expectedEndY);
|
|
addCheck("line-style-top", id, top, expectedStartY);
|
|
addCheck("line-style-height", id, height, expectedHeight, 1.5);
|
|
}
|
|
for (const marker of Array.from(root.querySelectorAll('[data-marker-id]')) as HTMLElement[]) {
|
|
const ms = Number(marker.dataset.ms || "NaN");
|
|
const y = Number(marker.dataset.y || parseTop(marker));
|
|
const top = parseTop(marker);
|
|
addCheck("marker-data-y", marker.dataset.markerId || "", y, expectedY(ms));
|
|
addCheck("marker-style-top", marker.dataset.markerId || "", top, expectedY(ms));
|
|
}
|
|
return {
|
|
layoutSource: root.dataset.layoutSource || "",
|
|
startMs,
|
|
endMs,
|
|
chartHeight,
|
|
checked,
|
|
maxDelta: Math.round(maxDelta * 1000) / 1000,
|
|
violations: violations.slice(0, 12),
|
|
tickCount: root.querySelectorAll('[data-testid="pipeline-gantt-tick"]').length,
|
|
lineCount: root.querySelectorAll('[data-testid="pipeline-gantt-line"]').length,
|
|
markerCount: root.querySelectorAll('[data-marker-id]').length,
|
|
};
|
|
});
|
|
}
|
|
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-gantt-export")) {
|
|
const exportButton = page.getByTestId("pipeline-export-gantt");
|
|
await exportButton.waitFor({ state: "visible", timeout: 10000 });
|
|
await page.waitForFunction(() => {
|
|
const button = document.querySelector('[data-testid="pipeline-export-gantt"]') as HTMLButtonElement | null;
|
|
return Boolean(button && !button.disabled);
|
|
}, undefined, { timeout: 10000 });
|
|
const [download] = await Promise.all([
|
|
page.waitForEvent("download", { timeout: 15000 }),
|
|
exportButton.click(),
|
|
]);
|
|
const suggestedFilename = download.suggestedFilename();
|
|
const safeFilename = suggestedFilename.replace(/[^a-zA-Z0-9_.-]+/g, "-") || "pipeline-gantt.png";
|
|
const savePath = join(e2eDir, `${Date.now()}_${safeFilename}`);
|
|
await download.saveAs(savePath);
|
|
const bytes = readFileSync(savePath).byteLength;
|
|
pipelineGanttExportInfo = { downloaded: true, suggestedFilename, savePath, bytes };
|
|
}
|
|
if (wants("frontend:pipeline-step-timeline-visible")) {
|
|
const regressionGanttLine = page.locator('[data-testid="pipeline-epoch-gantt"] [data-testid="pipeline-gantt-line"][data-node-id="repair-1"]').first();
|
|
const firstGanttLine = (await regressionGanttLine.count()) > 0
|
|
? regressionGanttLine
|
|
: page.locator('[data-testid="pipeline-epoch-gantt"] [data-testid="pipeline-gantt-line"]').first();
|
|
await firstGanttLine.scrollIntoViewIfNeeded({ timeout: 10000 });
|
|
const selectedGanttAttrs = await firstGanttLine.evaluate((element) => {
|
|
const target = element as HTMLElement;
|
|
return {
|
|
selectedNodeId: target.dataset.nodeId || "",
|
|
selectedProcedureRunId: target.dataset.procedureRunId || "",
|
|
};
|
|
});
|
|
await firstGanttLine.click({ force: true });
|
|
await page.waitForSelector('[data-testid="pipeline-step-timeline"] [data-testid="pipeline-opencode-step-trace"]', { 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 pipelineTrace = page.locator('[data-testid="pipeline-opencode-step-trace"]').first();
|
|
const pipelineTraceHead = page.locator('[data-testid="pipeline-step-timeline"] .pipeline-trace-head').first();
|
|
firstPipelineStepSummaryText = await pipelineTraceHead.innerText({ timeout: 5000 });
|
|
pipelineTimelineMetrics = await page.locator('[data-testid="pipeline-step-timeline"]').evaluate((element, selected) => {
|
|
const target = element as HTMLElement;
|
|
const oldStepCards = Array.from(target.querySelectorAll('[data-testid="pipeline-opencode-step"], .pipeline-opencode-flow, .pipeline-opencode-step')) as HTMLElement[];
|
|
return {
|
|
clientWidth: target.clientWidth,
|
|
scrollWidth: target.scrollWidth,
|
|
clientHeight: target.clientHeight,
|
|
scrollHeight: target.scrollHeight,
|
|
hasHorizontalScroll: target.scrollWidth > target.clientWidth + 1,
|
|
flowConnectorVisible: false,
|
|
maxStepIdleGapMs: 0,
|
|
idleGapCount: 0,
|
|
oldPipelineStepStyleCount: oldStepCards.length,
|
|
emptyAttemptDetailCount: target.innerText.includes("暂无 attempt 详情") ? 1 : 0,
|
|
selectedNodeId: selected.selectedNodeId,
|
|
selectedProcedureRunId: selected.selectedProcedureRunId,
|
|
};
|
|
}, selectedGanttAttrs);
|
|
firstPipelineStepSummaryMetrics = await pipelineTrace.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,
|
|
};
|
|
});
|
|
firstPipelineStepExpandedText = await pipelineTrace.innerText({ timeout: 5000 });
|
|
}
|
|
if (wants("frontend:pipeline-gantt-observation-live-running")) {
|
|
const candidate = await page.evaluate(async () => {
|
|
const snapshotResponse = await fetch("/api/microservices/pipeline/proxy/api/snapshot?__unideskArrayLimit=registry.components:2,runs:30", { credentials: "same-origin" });
|
|
if (!snapshotResponse.ok) return null;
|
|
const snapshot = await snapshotResponse.json();
|
|
const runs = Array.isArray(snapshot?.runs) ? snapshot.runs : [];
|
|
for (const run of runs) {
|
|
const runId = String(run?.runId || "");
|
|
if (!runId) continue;
|
|
const detailResponse = await fetch(`/api/microservices/pipeline/proxy/api/node-control/runs/${encodeURIComponent(runId)}?tail=160&view=timeline&_=${Date.now()}`, { credentials: "same-origin" });
|
|
if (!detailResponse.ok) continue;
|
|
const detail = await detailResponse.json();
|
|
const hasObservation = JSON.stringify(detail).includes("node-long-running-observation");
|
|
const hasRunning = (Array.isArray(detail?.procedureRuns) ? detail.procedureRuns : []).some((procedure: any) =>
|
|
String(procedure?.status?.status || procedure?.artifact?.status || procedure?.status || "").toLowerCase() === "running");
|
|
if (hasObservation && hasRunning) return { pipelineId: String(run?.pipelineId || detail?.request?.pipelineId || ""), runId };
|
|
}
|
|
return null;
|
|
});
|
|
if (candidate?.pipelineId && candidate?.runId) {
|
|
await page.locator('[data-testid="pipeline-select"]').selectOption(candidate.pipelineId);
|
|
await page.waitForFunction((runId) => Array.from(document.querySelectorAll('[data-testid="pipeline-run-select"] option')).some((option) => (option as HTMLOptionElement).value === runId), candidate.runId, { timeout: 15000 });
|
|
await page.locator('[data-testid="pipeline-run-select"]').selectOption(candidate.runId);
|
|
await page.waitForFunction((runId) => document.querySelector('[data-testid="pipeline-epoch-gantt"]')?.getAttribute("data-run-id") === runId, candidate.runId, { timeout: 15000 });
|
|
await page.waitForFunction(() => document.querySelectorAll('[data-testid="pipeline-gantt-observation-arrow"]').length > 0, undefined, { timeout: 15000 });
|
|
await page.waitForFunction(() => document.querySelectorAll('[data-testid="pipeline-gantt-line"][data-status="running"][data-live="true"]').length > 0, undefined, { timeout: 15000 });
|
|
await page.waitForTimeout(1200);
|
|
pipelineObservationGanttMetrics = await page.locator('[data-testid="pipeline-epoch-gantt"]').evaluate((element, selectedCandidate) => {
|
|
const root = element as HTMLElement;
|
|
const arrows = Array.from(root.querySelectorAll('[data-testid="pipeline-gantt-observation-arrow"]'));
|
|
const liveBars = Array.from(root.querySelectorAll('[data-testid="pipeline-gantt-line"][data-status="running"][data-live="true"]')) as HTMLElement[];
|
|
const svg = root.querySelector(".pipeline-gantt-arrow-layer") as SVGSVGElement | null;
|
|
const svgRect = svg?.getBoundingClientRect();
|
|
const markerElements = Array.from(root.querySelectorAll("[data-marker-id]")) as HTMLElement[];
|
|
const markerById = new Map(markerElements.map((marker) => [marker.getAttribute("data-marker-id") || "", marker]));
|
|
return {
|
|
candidate: selectedCandidate,
|
|
observationArrowCount: arrows.length,
|
|
observationSourceMarkerCount: root.querySelectorAll('[data-marker-id^="observation-source:"]').length,
|
|
arrowPairs: arrows.map((arrow) => `${arrow.getAttribute("data-source-node-id") || ""}->${arrow.getAttribute("data-target-node-id") || ""}`),
|
|
observationArrowTargetInsetsPx: arrows.map((arrow) => {
|
|
const path = arrow as SVGPathElement;
|
|
const targetMarker = markerById.get(path.getAttribute("data-target-marker-id") || "");
|
|
if (!svgRect || !targetMarker || typeof path.getTotalLength !== "function") return null;
|
|
const end = path.getPointAtLength(path.getTotalLength());
|
|
const markerRect = targetMarker.getBoundingClientRect();
|
|
const markerCenterX = markerRect.left + markerRect.width / 2 - svgRect.left;
|
|
const markerCenterY = markerRect.top + markerRect.height / 2 - svgRect.top;
|
|
return Math.round(Math.hypot(markerCenterX - end.x, markerCenterY - end.y));
|
|
}).filter((value) => typeof value === "number"),
|
|
liveRunningBarCount: liveBars.length,
|
|
liveRunningHeights: liveBars.map((bar) => Math.round(bar.getBoundingClientRect().height)),
|
|
runningAnimationNames: liveBars.map((bar) => window.getComputedStyle(bar).animationName),
|
|
hasLiveSweep: liveBars.some((bar) => window.getComputedStyle(bar, "::after").animationName.includes("ganttLiveSweep")),
|
|
};
|
|
}, candidate);
|
|
pipelineObservationGanttMetrics = { ...pipelineObservationGanttMetrics, setup: pipelineObservationSetup };
|
|
} else {
|
|
pipelineObservationGanttMetrics = { ...pipelineObservationGanttMetrics, candidate, setup: pipelineObservationSetup };
|
|
}
|
|
}
|
|
}
|
|
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 });
|
|
}
|
|
}
|
|
}
|
|
|
|
if (needLayoutOverflowDesktop) {
|
|
layoutOverflowDesktop = await collectLayoutOverflow(page, { width: 1440, height: 920 }, "desktop");
|
|
}
|
|
if (needLayoutOverflowMobile) {
|
|
layoutOverflowMobile = await collectLayoutOverflow(page, { width: 390, height: 860 }, "mobile");
|
|
await page.setViewportSize({ width: 1440, height: 920 });
|
|
}
|
|
|
|
await page.screenshot({ path: screenshotPath, fullPage: true });
|
|
const microserviceCatalogTextLower = microserviceCatalogText.toLowerCase();
|
|
const todoNoteTextLower = todoNoteText.toLowerCase();
|
|
const findjobTextLower = findjobText.toLowerCase();
|
|
const codexQueueTextLower = codexQueueText.toLowerCase();
|
|
const claudeqqTextLower = claudeqqText.toLowerCase();
|
|
const pipelineTextLower = pipelineText.toLowerCase();
|
|
const activePipeline = Array.isArray(pipelineSnapshotForFrontend?.pipelines)
|
|
? pipelineSnapshotForFrontend.pipelines.find((pipeline: any) => String(pipeline?.id || "") === pipelineSelectedId) || 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.toLowerCase().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:performance-panel-visible", performanceText.includes("性能面板") && performanceText.includes("Bwebui") && performanceText.includes("组件汇总") && performanceText.includes("最近失败请求") && performanceText.includes("内部操作汇总") && performanceText.includes("最近慢操作"), { performanceTextPreview: performanceText.slice(0, 1200) });
|
|
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") && microserviceCatalogTextLower.includes("claudeqq") && microserviceCatalogTextLower.includes("codex queue") && 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/agent_skills") && microserviceCatalogText.includes("https://gitee.com/Lyon1998/todo_note") && microserviceCatalogText.includes("https://github.com/pikasTech/unidesk"), { microserviceCatalogPreview: microserviceCatalogText.slice(0, 2000) });
|
|
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:codex-queue-integrated-visible", codexQueueTextLower.includes("codex queue") && codexQueueText.includes("gpt-5.4-mini") && codexQueueText.includes("gpt-5.4") && codexQueueText.includes("gpt-5.5") && codexQueueText.includes("提交任务") && codexQueueText.includes("入队份数") && codexQueueText.includes("追加 prompt") && codexQueueText.includes("打断") && codexQueueTextLower.includes("查看 queue") && codexQueueText.includes("创建 queue") && codexQueueOptions.some((text) => text.includes("All queues")) && codexQueueTracePlacement.firstChildIsTrace === true && codexQueueTracePlacement.noPageTopStatus === true && codexQueueTracePlacement.filterInsideTracePanel === true && codexQueueTracePlacement.traceStatusVisible === true && codexQueueTracePlacement.markAllReadVisible === true && codexQueueGlobalStatus.activeMicroserviceVisible === true && codexQueueSubmitQueueControl.tagName === "select" && codexQueueSubmitQueueControl.createButtonVisible === true && codexQueueSubmitQueueControl.oldInputMissing === true && codexQueueSubmitQueueControl.maxAttemptsMax === "99" && codexQueueSubmitQueueControl.maxAttemptsValue === "99" && codexQueueSubmitQueueControl.moveQueueVisible === true && codexQueuePromptDefaultEmpty === true && codexQueueSubmitGuard.batchRowVisible === true && codexQueueSubmitGuard.checkboxVisible === true && codexQueueSubmitGuard.disabledBeforeConfirm === true && codexQueueSubmitGuard.enabledAfterConfirm === true && codexQueueSubmitGuard.waitElementMissingBeforeSubmit === true && codexQueueScrollbarMetrics.transcriptThin === true && codexQueueScrollbarMetrics.toolHorizontalHidden === true && (codexQueueSwitchMetrics.optionCount <= 1 || codexQueueSwitchMetrics.switched === true) && codexQueueTextLower.includes("attempts") && codexQueueText.includes("仅 UniDesk frontend 代理访问") && (codexQueueTaskCount === 0 || codexQueueOutputText.includes("Submitted prompt")), { codexQueueTaskCount, codexQueueOptions, codexQueueSwitchMetrics, codexQueueSubmitQueueControl, codexQueueSubmitGuard, codexQueueScrollbarMetrics, codexQueuePromptDefaultEmpty, codexQueueTracePlacement, codexQueueGlobalStatus, codexQueueOutputPreview: codexQueueOutputText.slice(0, 900), codexQueueTextPreview: codexQueueText.slice(0, 1400) });
|
|
addSelectedCheck(checks, options, "frontend:codex-queue-initial-prompt-full-expand",
|
|
codexInitialPromptFullMetrics.candidateFound === false
|
|
|| (
|
|
codexInitialPromptFullMetrics.promptChars > codexInitialPromptFullMetrics.displayPromptChars
|
|
&& codexInitialPromptFullMetrics.initialDefaultOpen === false
|
|
&& codexInitialPromptFullMetrics.initialExpanded === true
|
|
&& codexInitialPromptFullMetrics.initialFullHasReference === true
|
|
&& codexInitialPromptFullMetrics.initialFullHasCurrentTask === true
|
|
&& codexInitialPromptFullMetrics.legacyPromptPanelMissing === true
|
|
),
|
|
{ codexInitialPromptFullMetrics });
|
|
addSelectedCheck(checks, options, "frontend:codex-queue-trace-full-load",
|
|
codexTraceFullMetrics.candidateFound === false
|
|
|| (
|
|
codexTraceFullMetrics.apiTotal >= 20
|
|
&& codexTraceFullMetrics.apiToolCount >= 8
|
|
&& codexTraceFullMetrics.apiHasInitialPrompt === true
|
|
&& codexTraceFullMetrics.uiItemCount >= 10
|
|
&& codexTraceFullMetrics.uiToolCount >= 8
|
|
&& codexTraceFullMetrics.uiHasInitialPrompt === true
|
|
&& codexTraceFullMetrics.uiHasToolTrace === true
|
|
&& codexTraceFullMetrics.loadState === "complete"
|
|
&& codexTraceFullMetrics.loadPartial !== "true"
|
|
),
|
|
{ codexTraceFullMetrics });
|
|
addSelectedCheck(checks, options, "frontend:codex-queue-judge-wrap",
|
|
codexJudgeWrapMetrics.checked === true && codexJudgeWrapMetrics.ok === true,
|
|
{ codexJudgeWrapMetrics });
|
|
addSelectedCheck(checks, options, "frontend:claudeqq-integrated-visible", claudeqqTextLower.includes("claudeqq 工作台") && claudeqqText.includes("D601") && claudeqqText.includes("QQ 事件订阅") && claudeqqText.includes("消息推送") && claudeqqText.includes("事件缓存") && claudeqqText.includes("主用户私聊账号") && claudeqqText.includes("645275593") && claudeqqTextLower.includes("napcat 容器登录") && (claudeqqText.includes("二维码") || claudeqqText.includes("QR SOURCE") || claudeqqText.includes("QR Source") || claudeqqText.includes("已登录")) && claudeqqText.includes("仅 UniDesk frontend 代理访问") && !claudeqqText.includes("{\n"), { claudeqqTextPreview: claudeqqText.slice(0, 1400) });
|
|
addSelectedCheck(checks, options, "frontend:url-route-deeplink", routeInitialPath === "/app/pipeline/" && routeDockerPath === "/nodes/docker/" && routeBackPath === "/app/pipeline/" && routeOverviewPath === "/ops/status/" && routeCodexPath === "/app/codex-queue/" && routeDeepLinkText.toLowerCase().includes("pipeline v2 工作台".toLowerCase()) && routeOverviewText.includes("核心指标") && routeCodexShellMetrics.appShell === true && routeCodexShellMetrics.standalone === false && routeCodexShellMetrics.topbar === true && routeCodexShellMetrics.codexPage === true && String(routeCodexShellMetrics.railText || "").includes("用户服务") && String(routeCodexShellMetrics.tabsText || "").includes("Codex Queue"), { routeInitialPath, routeDockerPath, routeBackIntermediatePath, routeBackPath, routeOverviewPath, routeCodexPath, routeCodexShellMetrics, 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("控制图")
|
|
&& 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)
|
|
&& pipelinePriorityOrder.desktop[0] === "控制图"
|
|
&& /epoch\s+甘特图/i.test(String(pipelinePriorityOrder.desktop[1] || ""))
|
|
&& pipelinePriorityOrder.mobile[0] === "控制图"
|
|
&& /epoch\s+甘特图/i.test(String(pipelinePriorityOrder.mobile[1] || "")),
|
|
{ pipelinePriorityOrder, pipelineTextPreview: pipelineText.slice(0, 1200) });
|
|
addSelectedCheck(checks, options, "frontend:pipeline-react-flow-visible", pipelineFlowNodeCount > 0 && pipelineFlowEdgeCount > 0, { pipelineFlowNodeCount, pipelineFlowEdgeCount });
|
|
addSelectedCheck(checks, options, "frontend:pipeline-sidebars-collapsible",
|
|
pipelineSidebarMetrics.nodeDefaultOpen === "false"
|
|
&& pipelineSidebarMetrics.nodeAfterClickOpen === "true"
|
|
&& pipelineSidebarMetrics.nodeAfterCollapseOpen === "false"
|
|
&& pipelineSidebarMetrics.nodeToggleEnabledAfterCollapse === true
|
|
&& pipelineSidebarMetrics.ganttDefaultOpen === "false"
|
|
&& Number(pipelineSidebarMetrics.ganttLineCount || 0) > 0
|
|
&& pipelineSidebarMetrics.ganttAfterClickOpen === "true"
|
|
&& pipelineSidebarMetrics.ganttAfterCollapseOpen === "false"
|
|
&& pipelineSidebarMetrics.ganttToggleEnabledAfterCollapse === true
|
|
&& pipelineSidebarMetrics.mobileNodeCollapsed === "false"
|
|
&& pipelineSidebarMetrics.mobileGanttCollapsed === "false",
|
|
{ pipelineSidebarMetrics });
|
|
addSelectedCheck(checks, options, "frontend:pipeline-minimax-quota-visible",
|
|
pipelineMinimaxQuotaText.includes("MiniMax")
|
|
&& pipelineMinimaxQuotaText.includes("当前窗口")
|
|
&& pipelineMinimaxQuotaText.includes("剩余额度")
|
|
&& pipelineMinimaxQuotaText.includes("重置时间")
|
|
&& !pipelineMinimaxQuotaText.includes("{"),
|
|
{ pipelineMinimaxQuotaPreview: pipelineMinimaxQuotaText.slice(0, 1000) });
|
|
addSelectedCheck(checks, options, "frontend:pipeline-oa-event-flow-visible",
|
|
pipelineOaPanelText.toLowerCase().includes("oa flow")
|
|
&& pipelineOaPanelText.includes("100%")
|
|
&& pipelineOaPanelText.toLowerCase().includes("no-audit")
|
|
&& pipelineOaPanelText.toLowerCase().includes("monitor 审核")
|
|
&& pipelineOaPanelText.includes("禁止残留")
|
|
&& pipelineOaPanelText.includes("policy-in-detail 0")
|
|
&& !pipelineOaPanelText.includes("{"),
|
|
{ pipelineOaPanelPreview: pipelineOaPanelText.slice(0, 1400) });
|
|
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-gantt-frontend-y-accuracy",
|
|
pipelineGanttFrontendYMetrics.layoutSource === "frontend-y"
|
|
&& Number(pipelineGanttFrontendYMetrics.checked || 0) > 0
|
|
&& Number(pipelineGanttFrontendYMetrics.maxDelta || 0) <= 1.25
|
|
&& (pipelineGanttFrontendYMetrics.violations || []).length === 0,
|
|
{ pipelineGanttFrontendYMetrics });
|
|
addSelectedCheck(checks, options, "frontend:pipeline-gantt-export",
|
|
pipelineGanttExportInfo.downloaded === true
|
|
&& Number(pipelineGanttExportInfo.bytes || 0) > 2048
|
|
&& /\.(png|svg)$/i.test(String(pipelineGanttExportInfo.suggestedFilename || "")),
|
|
{ pipelineGanttExportInfo });
|
|
addSelectedCheck(checks, options, "frontend:pipeline-gantt-observation-live-running",
|
|
Boolean(pipelineObservationGanttMetrics?.candidate)
|
|
&& Number(pipelineObservationGanttMetrics?.observationArrowCount || 0) > 0
|
|
&& Number(pipelineObservationGanttMetrics?.observationSourceMarkerCount || 0) === 0
|
|
&& (pipelineObservationGanttMetrics?.observationArrowTargetInsetsPx || []).some((value: number) => value >= 8)
|
|
&& Number(pipelineObservationGanttMetrics?.liveRunningBarCount || 0) > 0
|
|
&& (pipelineObservationGanttMetrics?.liveRunningHeights || []).some((height: number) => height >= 24)
|
|
&& (pipelineObservationGanttMetrics?.runningAnimationNames || []).some((name: string) => String(name || "").includes("ganttPulse"))
|
|
&& pipelineObservationGanttMetrics?.hasLiveSweep === true,
|
|
{ pipelineObservationGanttMetrics });
|
|
addSelectedCheck(checks, options, "frontend:pipeline-step-timeline-visible",
|
|
pipelineStepTimelineText.includes("OpenCode Trace")
|
|
&& pipelineStepTimelineText.includes("Codex Queue")
|
|
&& pipelineStepTimelineText.toLowerCase().includes("tools")
|
|
&& pipelineStepTimelineText.includes("Trace")
|
|
&& !firstPipelineStepSummaryText.includes("{\n")
|
|
&& pipelineSessionHeadText.includes("agent")
|
|
&& pipelineSessionHeadText.toLowerCase().includes("model")
|
|
&& !firstPipelineStepSummaryText.toLowerCase().includes("tokens")
|
|
&& !firstPipelineStepSummaryText.includes("\nbuild\n")
|
|
&& !pipelineTimelineMetrics.hasHorizontalScroll
|
|
&& !pipelineTimelineMetrics.flowConnectorVisible
|
|
&& Number(pipelineTimelineMetrics.oldPipelineStepStyleCount || 0) === 0
|
|
&& Number(pipelineTimelineMetrics.emptyAttemptDetailCount || 0) === 0
|
|
&& !firstPipelineStepSummaryMetrics.hasHorizontalScroll
|
|
&& firstPipelineStepSummaryMetrics.clientHeight <= 900
|
|
&& (firstPipelineStepExpandedText.includes("Message")
|
|
|| firstPipelineStepExpandedText.includes("Tool calls")
|
|
|| firstPipelineStepExpandedText.includes("Ran")),
|
|
{
|
|
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:layout-overflow-desktop", layoutOverflowDesktop.length > 0 && layoutOverflowDesktop.every((item) => item.ok), { probes: layoutOverflowDesktop });
|
|
addSelectedCheck(checks, options, "frontend:layout-overflow-mobile", layoutOverflowMobile.length > 0 && layoutOverflowMobile.every((item) => item.ok), { probes: layoutOverflowMobile });
|
|
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<unknown> {
|
|
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"),
|
|
};
|
|
}
|