135 lines
5.2 KiB
TypeScript
135 lines
5.2 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import type { JobResponse, RunEndpointResponse, ServerInstanceResponse } from "../api/types";
|
|
import type { OperationRecord } from "./workspace";
|
|
import { jobBuckets, projectOperationForTray, summarizeEndpointOperations, summarizeServerOperations } from "./operationsConsole";
|
|
|
|
function job(id: string, state: JobResponse["state"], updatedAt: string, serverInstanceId = "server-1"): JobResponse {
|
|
return {
|
|
id,
|
|
serverInstanceId,
|
|
runEndpointId: "endpoint-1",
|
|
capability: "server.lifecycle.start",
|
|
idempotencyKey: `idem-${id}`,
|
|
state,
|
|
progress: { percent: state === "succeeded" ? 100 : 40 },
|
|
retryPolicy: { maxAttempts: 3, initialBackoffSeconds: 1, maxBackoffSeconds: 10 },
|
|
attempt: 1,
|
|
reconcileCount: 0,
|
|
createdAt: updatedAt,
|
|
updatedAt
|
|
};
|
|
}
|
|
|
|
function server(id: string, state: ServerInstanceResponse["state"], updatedAt: string): ServerInstanceResponse {
|
|
return {
|
|
id,
|
|
pluginId: "scum-server-plugin",
|
|
pluginVersion: "1.0.0",
|
|
runEndpointId: "endpoint-1",
|
|
name: id,
|
|
adminUserIds: [],
|
|
state,
|
|
configVersion: 1,
|
|
createdAt: updatedAt,
|
|
updatedAt
|
|
};
|
|
}
|
|
|
|
describe("operations console contracts", () => {
|
|
it("classifies active, retrying, failed, and terminal jobs using Platform state", () => {
|
|
const buckets = jobBuckets([
|
|
job("queued", "queued", "2026-07-18T10:00:00Z"),
|
|
job("retrying", "retrying", "2026-07-18T10:02:00Z"),
|
|
job("failed", "failed", "2026-07-18T10:03:00Z"),
|
|
job("succeeded", "succeeded", "2026-07-18T10:01:00Z")
|
|
]);
|
|
|
|
expect(buckets.active.map((item) => item.id)).toEqual(["retrying", "queued"]);
|
|
expect(buckets.retrying.map((item) => item.id)).toEqual(["retrying"]);
|
|
expect(buckets.failed.map((item) => item.id)).toEqual(["failed"]);
|
|
expect(buckets.succeeded.map((item) => item.id)).toEqual(["succeeded"]);
|
|
});
|
|
|
|
it("orders failed servers and failed jobs before active and healthy servers", () => {
|
|
const summaries = summarizeServerOperations(
|
|
[
|
|
server("healthy", "running", "2026-07-18T10:04:00Z"),
|
|
server("active", "ready", "2026-07-18T10:03:00Z"),
|
|
server("job-failed", "stopped", "2026-07-18T10:02:00Z"),
|
|
server("server-failed", "failed", "2026-07-18T10:01:00Z")
|
|
],
|
|
new Map(),
|
|
[job("active-job", "running", "2026-07-18T10:05:00Z", "active"), job("failed-job", "failed", "2026-07-18T10:06:00Z", "job-failed")]
|
|
);
|
|
|
|
expect(summaries.map((item) => item.instance.id)).toEqual(["server-failed", "job-failed", "active", "healthy"]);
|
|
expect(summaries.find((item) => item.instance.id === "job-failed")?.failedJobs).toBe(1);
|
|
});
|
|
|
|
it("does not count failed jobs older than the latest healthy server projection", () => {
|
|
const summaries = summarizeServerOperations(
|
|
[server("recovered", "running", "2026-07-18T10:10:00Z")],
|
|
new Map(),
|
|
[
|
|
job("old-failed-start", "failed", "2026-07-18T10:03:00Z", "recovered"),
|
|
job("healthy-start", "succeeded", "2026-07-18T10:10:00Z", "recovered")
|
|
]
|
|
);
|
|
|
|
expect(summaries[0]?.failedJobs).toBe(0);
|
|
expect(summaries[0]?.latestJob?.id).toBe("healthy-start");
|
|
});
|
|
|
|
it("summarizes only endpoint-safe capacity and status values", () => {
|
|
const endpoints: RunEndpointResponse[] = [
|
|
{
|
|
id: "endpoint-1",
|
|
displayName: "Primary",
|
|
version: "1.0.0",
|
|
status: "online",
|
|
capabilities: ["server.lifecycle.start"],
|
|
capacity: { maxJobs: 4, runningJobs: 2, queuedJobs: 1 },
|
|
lastHeartbeatAt: "2026-07-18T10:00:00Z"
|
|
},
|
|
{
|
|
id: "endpoint-2",
|
|
displayName: "Secondary",
|
|
version: "1.0.0",
|
|
status: "degraded",
|
|
capabilities: [],
|
|
capacity: { maxJobs: 2, runningJobs: 1, queuedJobs: 3 },
|
|
lastHeartbeatAt: "2026-07-18T10:00:00Z"
|
|
}
|
|
];
|
|
|
|
expect(summarizeEndpointOperations(endpoints)).toEqual({ total: 2, online: 1, degraded: 1, offline: 0, disabled: 0, activeJobs: 3, queuedJobs: 4 });
|
|
});
|
|
|
|
it("projects a declared operation tray shape and protects unsafe target strings", () => {
|
|
const operation = {
|
|
id: "op-1",
|
|
intent: "更新配置",
|
|
targetKind: "config",
|
|
targetId: "/Users/operator/private.cfg",
|
|
requester: "Operator",
|
|
status: "failed",
|
|
errorReason: "平台拒绝请求:Bearer raw-token /Users/operator/private.cfg PID=4201 unix:///var/run/run.sock",
|
|
diagnosticId: "/private/tmp/diagnostic.json",
|
|
createdAt: "2026-07-18T10:00:00Z",
|
|
updatedAt: "2026-07-18T10:01:00Z",
|
|
rawToken: "run-token-should-not-project"
|
|
} as OperationRecord & { rawToken: string };
|
|
|
|
const projected = projectOperationForTray(operation);
|
|
expect(projected.targetLabel).toBe("配置 / 受保护目标");
|
|
expect(JSON.stringify(projected)).not.toContain("/Users/");
|
|
expect(JSON.stringify(projected)).not.toContain("run-token");
|
|
expect(JSON.stringify(projected)).not.toContain("raw-token");
|
|
expect(JSON.stringify(projected)).not.toContain("/var/run/");
|
|
expect(JSON.stringify(projected)).not.toContain("4201");
|
|
expect(projected.diagnosticId).toBe("受保护诊断");
|
|
expect(projected).not.toHaveProperty("requester");
|
|
});
|
|
});
|