feat: integrate todo note microservice and modularize frontend

This commit is contained in:
Codex
2026-05-05 10:33:26 +00:00
parent abd40fa252
commit 1d0046dc50
28 changed files with 2121 additions and 381 deletions
+2
View File
@@ -60,6 +60,7 @@ export interface UniDeskMicroserviceConfig {
proxyMode: string;
frontendOnly: boolean;
public: boolean;
allowedMethods: string[];
allowedPathPrefixes: string[];
healthPath: string;
timeoutMs: number;
@@ -152,6 +153,7 @@ function microserviceConfig(item: Record<string, unknown>, index: number): UniDe
proxyMode: stringField(backend, "proxyMode", `${path}.backend`),
frontendOnly: booleanField(backend, "frontendOnly", `${path}.backend`),
public: booleanField(backend, "public", `${path}.backend`),
allowedMethods: stringArrayField(backend, "allowedMethods", `${path}.backend`),
allowedPathPrefixes: stringArrayField(backend, "allowedPathPrefixes", `${path}.backend`),
healthPath: stringField(backend, "healthPath", `${path}.backend`),
timeoutMs: numberField(backend, "timeoutMs", `${path}.backend`),
+5 -3
View File
@@ -18,7 +18,7 @@ export interface ContainerStatus {
ports: string;
}
const rebuildableServices = ["backend-core", "frontend", "provider-gateway"] as const;
const rebuildableServices = ["backend-core", "frontend", "provider-gateway", "todo-note"] as const;
export type RebuildableService = typeof rebuildableServices[number];
export function isRebuildableService(value: string | undefined): value is RebuildableService {
@@ -79,6 +79,8 @@ export function writeComposeEnv(config: UniDeskConfig, freshLogPrefix: boolean):
UNIDESK_DATABASE_USER: config.database.user,
UNIDESK_DATABASE_PASSWORD: config.database.password,
UNIDESK_DATABASE_NAME: config.database.name,
UNIDESK_DATABASE_VOLUME: config.database.volume,
UNIDESK_DATABASE_VOLUME_SIZE: config.database.volumeSize,
UNIDESK_PROVIDER_TOKEN: config.providerGateway.token,
UNIDESK_PROVIDER_ID: config.providerGateway.id,
UNIDESK_PROVIDER_NAME: config.providerGateway.name,
@@ -130,7 +132,7 @@ export function startStack(config: UniDeskConfig): unknown {
const downCommand = [...compose, "down", "--remove-orphans"];
const upCommand = [...compose, "up", "-d", "--build"];
const command = ["bash", "-lc", `set -euo pipefail; ${shellJoin(downCommand)}; ${shellJoin(upCommand)}`];
const job = startJob("server_start", command, "Build and start UniDesk database, core, frontend, and provider gateway containers");
const job = startJob("server_start", command, "Build and start UniDesk database, core, frontend, provider gateway, and managed microservice containers");
return { job, runtimeEnv, command, ports: fixedPorts(config) };
}
@@ -301,7 +303,7 @@ export function stackLogs(config: UniDeskConfig, tailBytes: number): unknown {
const currentFiles = allFiles.filter((path) => basename(path).startsWith(runtimeEnv.logPrefix));
const selectedFiles = (currentFiles.length > 0 ? currentFiles : allFiles.slice(-12)).slice(-12);
const files = selectedFiles.map((path) => ({ path, name: basename(path), tail: tailFile(path, tailBytes) }));
const containerNames = ["unidesk-database", "unidesk-backend-core", "unidesk-frontend", "unidesk-provider-gateway-main"];
const containerNames = ["unidesk-database", "unidesk-backend-core", "unidesk-frontend", "unidesk-provider-gateway-main", "todo-note-backend"];
const docker = containerNames.map((name) => {
const result = runCommand(["docker", "logs", "--tail", "40", name], repoRoot);
return { name, exitCode: result.exitCode, stdoutTail: result.stdout.slice(-tailBytes), stderrTail: result.stderr.slice(-tailBytes) };
+101 -2
View File
@@ -222,10 +222,12 @@ async function exposureChecks(config: UniDeskConfig, urls: PublicUrls, checks: E
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 todoNotePublic = await fetchProbe(`http://${config.network.publicHost}:4211/api/health`, 2500);
addCheck(checks, "network:only-frontend-provider-ports", !portsText.includes(`:${config.network.core.port}->`) && !portsText.includes(`:${config.network.database.port}->`), portSummary);
addCheck(checks, "network:core-public-blocked", (corePublic as { reachable?: boolean }).reachable === false, corePublic);
addCheck(checks, "network:database-public-blocked", (databasePublic as { reachable?: boolean }).reachable === false, databasePublic);
addCheck(checks, "network:findjob-public-blocked", (findjobPublic as { reachable?: boolean }).reachable === false, findjobPublic);
addCheck(checks, "network:todo-note-public-blocked", (todoNotePublic as { reachable?: boolean }).reachable === false, todoNotePublic);
}
async function serviceChecks(config: UniDeskConfig, urls: PublicUrls, checks: E2ECheck[]): Promise<void> {
@@ -241,8 +243,28 @@ async function serviceChecks(config: UniDeskConfig, urls: PublicUrls, checks: E2
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 todoNoteStatus = dockerCoreJson("/api/microservices/todo-note/status");
const todoNoteHealth = dockerCoreJson("/api/microservices/todo-note/health");
const todoNoteInstances = dockerCoreJson("/api/microservices/todo-note/proxy/api/instances");
const todoE2eName = `E2E Todo ${Date.now()}`;
const todoNoteCreate = dockerCoreJson("/api/microservices/todo-note/proxy/api/instances", { method: "POST", body: { name: todoE2eName } });
const todoCreatedId = (todoNoteCreate as { body?: { id?: string } }).body?.id ?? "";
const todoNoteAdd = todoCreatedId
? dockerCoreJson(`/api/microservices/todo-note/proxy/api/instances/${encodeURIComponent(todoCreatedId)}/actions`, { method: "POST", body: { action: { type: "addTodo", title: "E2E migrated write path" } } })
: { ok: false, error: "missing created todo note id", todoNoteCreate };
const todoAddedItems = (todoNoteAdd as { body?: { todos?: Array<{ id?: string }> } }).body?.todos ?? [];
const todoAddedId = todoAddedItems[0]?.id ?? "";
const todoNoteToggle = todoCreatedId && todoAddedId
? dockerCoreJson(`/api/microservices/todo-note/proxy/api/instances/${encodeURIComponent(todoCreatedId)}/actions`, { method: "POST", body: { action: { type: "toggleTodoCompleted", todoId: todoAddedId } } })
: { ok: false, error: "missing todo id", todoNoteAdd };
const todoNoteUndo = todoCreatedId
? dockerCoreJson(`/api/microservices/todo-note/proxy/api/instances/${encodeURIComponent(todoCreatedId)}/undo`, { method: "POST", body: {} })
: { ok: false, error: "missing created todo note id" };
const todoNoteDelete = todoCreatedId
? dockerCoreJson(`/api/microservices/todo-note/proxy/api/instances/${encodeURIComponent(todoCreatedId)}`, { method: "DELETE" })
: { ok: false, error: "missing created todo note id" };
const providerIngress = await fetchProbe(urls.providerIngressHealthUrl);
const overviewBody = (coreOverview as { body?: { ok?: boolean; dbReady?: boolean; onlineNodeCount?: number } }).body;
const overviewBody = (coreOverview as { body?: { ok?: boolean; dbReady?: boolean; onlineNodeCount?: number; pgdata?: { volumeName?: string; databaseBytes?: number } } }).body;
const nodeList = (coreNodes as { body?: { nodes?: Array<{ providerId?: string; status?: string; labels?: Record<string, unknown> }> } }).body?.nodes ?? [];
const mainNode = nodeList.find((node) => node.providerId === config.providerGateway.id);
const expectedGatewayVersion = providerGatewayPackageVersion();
@@ -251,6 +273,7 @@ async function serviceChecks(config: UniDeskConfig, urls: PublicUrls, checks: E2
const dockerStatuses = (dockerStatus as { body?: { dockerStatuses?: Array<{ providerId?: string; dockerStatus?: { counts?: { containers?: number }; containers?: unknown[] } }> } }).body?.dockerStatuses ?? [];
const mainDocker = dockerStatuses.find((item) => item.providerId === config.providerGateway.id);
addCheck(checks, "core:internal-overview", (coreOverview as { ok?: boolean }).ok === true && overviewBody?.ok === true && overviewBody.dbReady === true && (overviewBody.onlineNodeCount ?? 0) >= 1, coreOverview);
addCheck(checks, "core:pgdata-usage", (coreOverview as { ok?: boolean }).ok === true && overviewBody?.pgdata?.volumeName === config.database.volume && Number(overviewBody.pgdata.databaseBytes ?? 0) > 0, coreOverview);
addCheck(checks, "provider:self-node-online", nodeList.some((node) => node.providerId === config.providerGateway.id && node.status === "online"), coreNodes);
addCheck(checks, "provider:gateway-version-label", mainNode?.labels?.providerGatewayVersion === expectedGatewayVersion && mainNode?.labels?.providerGatewayUpgradePolicy === "always-enabled", { providerId: config.providerGateway.id, expectedGatewayVersion, labels: mainNode?.labels ?? null });
addCheck(checks, "provider:system-status", (systemStatus as { ok?: boolean }).ok === true && mainSystem?.current !== undefined && Number.isFinite(mainSystem.current.cpu?.percent) && Number.isFinite(mainSystem.current.memory?.percent) && mainSystem.current.memory?.mode === "actual_without_cache" && Number.isFinite(mainSystem.current.memory?.cacheBytes) && Number.isFinite(mainSystem.current.disk?.percent) && (mainSystem.history?.length ?? 0) > 0, systemStatusCheckDetail(systemStatus, config.providerGateway.id));
@@ -258,8 +281,11 @@ async function serviceChecks(config: UniDeskConfig, urls: PublicUrls, checks: E2
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 todoNote = microserviceList.find((service) => service.id === "todo-note");
const findjobSummaryBody = (findjobSummary as { body?: { totalJobs?: number; prioritizedJobs?: number } }).body;
const findjobJobs = (findjobJobsPreview as { body?: { jobs?: unknown[]; _unidesk?: { arrayLimits?: { jobs?: { returnedLength?: number; originalLength?: number } } } } }).body;
const todoNoteRows = (todoNoteInstances as { body?: { instances?: Array<{ id?: string; name?: string; todoCount?: number; completedCount?: number }> } }).body?.instances ?? [];
const todoNoteNames = todoNoteRows.map((row) => row.name);
const pipelineSnapshotBody = (pipelineSnapshot as { body?: { ok?: boolean; registry?: { ok?: boolean; components?: unknown[] }; pipelines?: unknown[]; runs?: unknown[]; _unidesk?: { arrayLimits?: { "registry.components"?: { returnedLength?: number; originalLength?: number }; runs?: { returnedLength?: number; originalLength?: number } } } } }).body;
const firstPipelineRun = Array.isArray(pipelineSnapshotBody?.runs)
? pipelineSnapshotBody.runs[0] as { runId?: string; pipelineId?: string; status?: string; updatedAt?: string } | undefined
@@ -284,6 +310,7 @@ async function serviceChecks(config: UniDeskConfig, urls: PublicUrls, checks: E2
};
addCheck(checks, "microservice:catalog-findjob", (microservices as { ok?: boolean }).ok === true && findjob?.providerId === "D601" && findjob.backend?.public === false, { microservices });
addCheck(checks, "microservice:catalog-pipeline", (microservices as { ok?: boolean }).ok === true && pipeline?.providerId === "D601" && pipeline.backend?.public === false && pipeline.runtime?.container?.name === "pipeline-v2-webui", { microservices });
addCheck(checks, "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 });
addCheck(checks, "microservice:findjob-status", (findjobStatus as { ok?: boolean }).ok === true && (findjobStatus as { body?: { microservice?: { id?: string; providerId?: string } } }).body?.microservice?.providerId === "D601", findjobStatus);
addCheck(checks, "microservice:findjob-health", (findjobHealth as { ok?: boolean; body?: { ok?: boolean } }).ok === true && (findjobHealth as { body?: { ok?: boolean } }).body?.ok === true, findjobHealth);
addCheck(checks, "microservice:findjob-summary", (findjobSummary as { ok?: boolean }).ok === true && Number.isFinite(findjobSummaryBody?.totalJobs) && Number.isFinite(findjobSummaryBody?.prioritizedJobs), findjobSummary);
@@ -291,6 +318,10 @@ async function serviceChecks(config: UniDeskConfig, urls: PublicUrls, checks: E2
addCheck(checks, "microservice:pipeline-status", (pipelineStatus as { ok?: boolean }).ok === true && (pipelineStatus as { body?: { microservice?: { id?: string; providerId?: string } } }).body?.microservice?.providerId === "D601", pipelineStatus);
addCheck(checks, "microservice:pipeline-health", (pipelineHealth as { ok?: boolean; body?: { ok?: boolean; service?: string } }).ok === true && (pipelineHealth as { body?: { ok?: boolean } }).body?.ok === true, pipelineHealth);
addCheck(checks, "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);
addCheck(checks, "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);
addCheck(checks, "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);
addCheck(checks, "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 });
addCheck(checks, "microservice:todo-note-write-path", (todoNoteCreate as { ok?: boolean }).ok === true && (todoNoteAdd as { ok?: boolean }).ok === true && (todoNoteToggle as { ok?: boolean }).ok === true && (todoNoteUndo as { ok?: boolean }).ok === true && (todoNoteDelete as { ok?: boolean }).ok === true, { todoNoteCreate, todoNoteAdd, todoNoteToggle, todoNoteUndo, todoNoteDelete });
const upgradeDispatch = dockerCoreJson("/api/dispatch", {
method: "POST",
body: { providerId: config.providerGateway.id, command: "provider.upgrade", payload: { source: "cli-e2e", mode: "plan" } },
@@ -327,10 +358,27 @@ function databaseChecks(config: UniDeskConfig, checks: E2ECheck[]): string {
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);
addCheck(checks, "database:named-volume-write", marker.ok && marker.stdout.includes(`marker=${markerId}`), marker);
addCheck(checks, "database:provider-state", marker.ok && marker.stdout.includes("online_main_server=1"), marker);
addCheck(checks, "database:todo-note-pg-storage", marker.ok && todoNoteInstanceCount >= 5 && todoNoteTotalTodos >= 100, { ...marker, todoNoteInstanceCount, todoNoteTotalTodos });
return markerId;
}
@@ -346,6 +394,7 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2
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);
@@ -359,6 +408,12 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2
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 });
const railWidthBefore = await page.locator(".rail").evaluate((element) => Math.round(element.getBoundingClientRect().width));
await page.getByTestId("rail-toggle").click();
await page.waitForTimeout(120);
const railWidthCollapsed = await page.locator(".rail").evaluate((element) => Math.round(element.getBoundingClientRect().width));
await page.getByTestId("rail-toggle").click();
await page.waitForTimeout(80);
await page.setViewportSize({ width: 390, height: 860 });
const mobileRailHeights: number[] = [];
for (const moduleLabel of ["运行总览", "资源节点", "任务调度", "微服务", "系统配置"]) {
@@ -441,7 +496,43 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2
await page.waitForSelector('[data-testid="microservice-catalog-page"]', { timeout: 10000 });
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-todo-note"]', { timeout: 10000 });
const microserviceCatalogText = await page.locator('[data-testid="microservice-catalog-page"]').innerText({ timeout: 5000 });
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 });
const 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 });
await page.getByRole("button", { name: /FindJob/ }).click();
await page.waitForSelector('[data-testid="findjob-page"]', { timeout: 10000 });
await page.waitForFunction(() => {
@@ -458,6 +549,7 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2
const findjobText = await page.locator('[data-testid="findjob-page"]').innerText({ timeout: 5000 });
await page.getByRole("button", { name: /Pipeline/ }).click();
await page.waitForSelector('[data-testid="pipeline-page"]', { timeout: 10000 });
await page.waitForSelector('[data-testid="pipeline-react-flow"] .react-flow__node', { timeout: 30000 });
await page.waitForFunction(() => {
const text = document.body.innerText;
const lower = text.toLowerCase();
@@ -468,12 +560,16 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2
&& /组件\s+\d+/.test(text)
&& /运行记录\s+[1-9]\d*/.test(text);
}, undefined, { timeout: 30000 });
const pipelineFlowNodeCount = await page.locator('[data-testid="pipeline-react-flow"] .react-flow__node').count();
const pipelineFlowEdgeCount = await page.locator('[data-testid="pipeline-react-flow"] .react-flow__edge').count();
const pipelineText = await page.locator('[data-testid="pipeline-page"]').innerText({ timeout: 5000 });
const microserviceCatalogTextLower = microserviceCatalogText.toLowerCase();
const todoNoteTextLower = todoNoteText.toLowerCase();
const findjobTextLower = findjobText.toLowerCase();
const pipelineTextLower = pipelineText.toLowerCase();
addCheck(checks, "frontend:login-provider-visible", bodyText.includes(config.providerGateway.id) && bodyText.includes(config.providerGateway.name) && bodyText.includes("核心在线"), { screenshotPath });
addCheck(checks, "frontend:public-provider-info-visible", publicFrontendReached && bodyText.includes(config.providerGateway.id) && bodyText.includes(config.providerGateway.name) && rawText.includes('"status": "online"') && rawText.includes(`"providerId": "${config.providerGateway.id}"`), { frontendUrl: urls.frontendUrl, landedUrl, providerId: config.providerGateway.id, rawTextPreview: rawText.slice(0, 400) });
addCheck(checks, "frontend:sidebar-collapse", railWidthBefore >= 160 && railWidthCollapsed <= 70, { railWidthBefore, railWidthCollapsed });
addCheck(checks, "frontend:mobile-nav-fixed-height", mobileRailMax - mobileRailMin <= 1 && mobileRailMax <= 44, { mobileRailHeights });
addCheck(checks, "frontend:mobile-content-top-aligned", mobileContentMetrics.pageTop <= 190 && mobileContentMetrics.emptyTextOffset <= 14, { mobileContentMetrics });
addCheck(checks, "frontend:pending-task-drilldown", pendingTaskText.includes("待处理任务") && (pendingTaskText.includes("当前无待处理任务") || (pendingTaskText.includes("Provider") && pendingTaskText.includes("已等待"))), { pendingTaskPreview: pendingTaskText.slice(0, 600) });
@@ -485,9 +581,12 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2
addCheck(checks, "frontend:docker-status-visible", dockerText.toLowerCase().includes("docker desktop 视图") && dockerText.toLowerCase().includes("containers") && dockerText.includes("unidesk_pgdata_10gb") && (dockerText.includes("unidesk-frontend") || dockerText.includes("unidesk-backend-core")), { dockerTextPreview: dockerText.slice(0, 800) });
addCheck(checks, "frontend:gateway-version-records-visible", gatewayTextLower.includes("provider gateway 版本") && gatewayText.includes("自动更新记录") && gatewayText.includes(config.providerGateway.id) && gatewayText.includes(`v${providerGatewayPackageVersion()}`) && gatewayText.includes("provider.upgrade"), { gatewayTextPreview: gatewayText.slice(0, 900) });
addCheck(checks, "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 });
addCheck(checks, "frontend:microservice-catalog-visible", microserviceCatalogTextLower.includes("findjob") && microserviceCatalogTextLower.includes("pipeline") && microserviceCatalogText.includes("D601") && microserviceCatalogTextLower.includes("private") && microserviceCatalogText.includes("https://gitee.com/Lyon1998/findjob") && microserviceCatalogText.includes("https://github.com/pikasTech/pipeline"), { microserviceCatalogPreview: microserviceCatalogText.slice(0, 1200) });
addCheck(checks, "frontend:overview-pgdata-visible", bodyText.includes("PGDATA") && bodyText.includes(config.database.volume), { bodyPreview: bodyText.slice(0, 800) });
addCheck(checks, "frontend:microservice-catalog-visible", microserviceCatalogTextLower.includes("findjob") && microserviceCatalogTextLower.includes("pipeline") && microserviceCatalogTextLower.includes("todo note") && 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://gitee.com/Lyon1998/todo_note"), { microserviceCatalogPreview: microserviceCatalogText.slice(0, 1400) });
addCheck(checks, "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) });
addCheck(checks, "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) });
addCheck(checks, "frontend:pipeline-integrated-visible", pipelineTextLower.includes("pipeline v2 工作台".toLowerCase()) && pipelineText.includes("D601") && pipelineText.includes("控制图") && pipelineText.includes("最近运行") && pipelineText.includes("仅 UniDesk frontend 代理访问") && /Health\s+OK/i.test(pipelineText) && /组件\s+\d+/.test(pipelineText) && /运行记录\s+[1-9]\d*/.test(pipelineText), { pipelineTextPreview: pipelineText.slice(0, 1200) });
addCheck(checks, "frontend:pipeline-react-flow-visible", pipelineFlowNodeCount > 0 && pipelineFlowEdgeCount > 0, { pipelineFlowNodeCount, pipelineFlowEdgeCount });
addCheck(checks, "frontend:no-console-errors", consoleErrors.length === 0, { consoleErrors });
return { screenshotPath, bodyText, consoleErrors };
} finally {