import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { LOG_STREAM_EVENT, type LogEntry } from "@umt/contracts"; import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { MemoryRouter } from "react-router"; import { describe, expect, it } from "vitest"; import { LogStreamClient, type LogStreamStatus, upsertEntries } from "~/api/logs.ts"; import { qk } from "~/lib/query-keys.ts"; import LogsPage from "~/routes/console/logs.tsx"; import { agentGoalFor, logBadge, logSubtitle, logsPath, logTitle, objectText, sinceToFrom } from "./labels.ts"; import { agentContextFor } from "./log-detail.tsx"; // 日志页:标题 / 徽章 / 摘要、筛选参数、流的暂停与续传、问智能体的上下文、页面渲染。 const entry = (over: Partial): LogEntry => ({ id: "audit_event:1", type: "audit", source: "audit_event", refId: "1", at: "2026-10-06T08:00:00.000Z", finishedAt: null, durationMs: null, level: "info", status: "settings.update", title: "settings.update", message: null, object: null, actor: { type: "user", id: "admin" }, extra: {}, ...over, }); const task = entry({ id: "engine_run:r1", type: "task", source: "engine_run", refId: "r1", level: "error", status: "failed", title: "沙丘2", message: "qB 拒绝了种子", object: { type: "download_task", id: "d1", label: "沙丘2" }, actor: null, extra: { workflowName: "submitDownload", businessKind: "download", businessTitle: "沙丘2" }, }); describe("标题、摘要、徽章", () => { it("任务:业务标题 + 工作流;没有业务对象时写做什么", () => { expect(logTitle(task)).toBe("沙丘2"); expect(logSubtitle(task)).toBe("提交下载 · qB 拒绝了种子"); expect(logBadge(task)).toEqual({ label: "失败", tone: "destructive" }); const sys = { ...task, extra: { workflowName: "snapshotSite" }, message: null, level: "success" as const }; expect(logTitle(sys)).toBe("站点抓取"); // 智能体会话超出预算:级别 warn,徽章写具体结果 const agent = { ...task, level: "warn" as const, status: "completed", extra: { outcome: "budget_exceeded" } }; expect(logBadge(agent).label).toBe("超出预算"); }); it("定时任务:已跳过 / 巡检;审计:动作中文名;对象文字", () => { const skipped = entry({ source: "schedule_run", type: "auto", status: "skipped", title: "RSS 抓取" }); expect(logBadge(skipped).label).toBe("已跳过"); const check = entry({ source: "schedule_run", type: "auto", status: "ok", level: "success", extra: { jobKind: "agent_check" }, }); expect(logSubtitle(check)).toBe("智能体巡检"); expect(logTitle(entry({ status: "admin.login_failed", type: "auth" }))).toBe("登录失败"); expect(logTitle(entry({ status: "downloader_instance.create" }))).toBe("新建下载器"); expect(objectText({ type: "scheduled_job", id: "x", label: null })).toBe("定时任务"); expect(objectText({ type: "download_task", id: "x", label: "沙丘2" })).toBe("沙丘2"); }); }); describe("筛选参数", () => { it("时间预设按分钟取整;未知预设为不限", () => { const now = Date.parse("2026-10-06T08:30:45.123Z"); expect(sinceToFrom("1h", now)).toBe("2026-10-06T07:30:00.000Z"); expect(sinceToFrom("", now)).toBeUndefined(); expect(sinceToFrom("bogus", now)).toBeUndefined(); }); it("logsPath 只带非空参数", () => { expect(logsPath()).toBe("/console/logs"); expect(logsPath({ type: "task", level: "", objectId: "a b" })).toBe("/console/logs?type=task&objectId=a+b"); }); }); describe("流", () => { /** 假 EventSource:记下 URL,可手动派发事件 */ function fakeFactory() { const made: { url: string; closed: boolean; fire: (type: string, data?: unknown) => void; error: () => void }[] = []; const factory = (url: string) => { const listeners = new Map) => void)[]>(); const es = { onerror: null as ((ev: Event) => unknown) | null, addEventListener: (t: string, fn: (ev: MessageEvent) => void) => listeners.set(t, [...(listeners.get(t) ?? []), fn]), close: () => { rec.closed = true; }, }; const rec = { url, closed: false, fire: (type: string, data: unknown = {}) => { for (const fn of listeners.get(type) ?? []) fn({ data: JSON.stringify(data) } as MessageEvent); }, error: () => es.onerror?.(new Event("error")), }; made.push(rec); return es as unknown as EventSource; }; return { made, factory }; } it("连接带筛选与 since;暂停断开;继续从收到的最新一条续上", () => { const { made, factory } = fakeFactory(); const got: LogEntry[] = []; const statuses: LogStreamStatus[] = []; const c = new LogStreamClient( { type: "auth", level: "warn" }, { onEntry: (e) => got.push(e), onStatus: (s) => statuses.push(s) }, "2026-10-06T08:00:00.000Z", factory, ); c.start(); expect(made).toHaveLength(1); const u = new URL(made[0]!.url, "http://x"); expect(u.pathname).toBe("/api/v1/logs/stream"); expect(Object.fromEntries(u.searchParams)).toEqual({ type: "auth", level: "warn", since: "2026-10-06T08:00:00.000Z", }); made[0]!.fire("ready"); made[0]!.fire(LOG_STREAM_EVENT, entry({ id: "audit_event:2", at: "2026-10-06T08:05:00.000Z" })); expect(got.map((e) => e.id)).toEqual(["audit_event:2"]); expect(statuses).toEqual(["connecting", "open"]); // 暂停 c.stop(); expect(made[0]!.closed).toBe(true); expect(statuses.at(-1)).toBe("off"); // 暂停期间列表刷新出更旧的条目不会把起点往回拨 c.advance("2026-10-06T07:00:00.000Z"); c.start(); expect(made).toHaveLength(2); expect(new URL(made[1]!.url, "http://x").searchParams.get("since")).toBe("2026-10-06T08:05:00.000Z"); c.stop(); }); it("出错后重连;停止后不再重连", () => { const { made, factory } = fakeFactory(); const statuses: LogStreamStatus[] = []; const c = new LogStreamClient({}, { onEntry: () => {}, onStatus: (s) => statuses.push(s) }, null, factory); c.start(); made[0]!.error(); expect(statuses).toContain("reconnecting"); c.stop(); made[0]!.error(); expect(made).toHaveLength(1); }); it("按 id 覆盖合并,时间倒序", () => { const a = entry({ id: "a", at: "2026-10-06T08:00:00.000Z" }); const b = entry({ id: "b", at: "2026-10-06T08:01:00.000Z", level: "running" }); const b2 = { ...b, level: "success" as const }; const c = entry({ id: "c", at: "2026-10-06T07:59:00.000Z" }); expect(upsertEntries([b, a], [c, b2]).map((e) => [e.id, e.level])).toEqual([ ["b", "success"], ["a", "info"], ["c", "info"], ]); }); }); describe("问智能体", () => { it("目标里按时间正序列出日志与 id;单条任务带上任务与下载任务上下文", () => { const later = entry({ id: "audit_event:9", at: "2026-10-06T09:00:00.000Z", status: "capability.denied" }); const goal = agentGoalFor([later, task]); expect(goal.startsWith("看看这 2 条日志")).toBe(true); expect(goal.indexOf("engine_run:r1")).toBeLessThan(goal.indexOf("audit_event:9")); expect(goal).toContain("qB 拒绝了种子"); const ctx = agentContextFor([task]); expect(ctx).toMatchObject({ taskId: "r1", downloadTaskId: "d1", label: "日志:沙丘2" }); expect(agentContextFor([task, later]).label).toBe("日志:2 条"); }); it("太长时截断,不超过智能体目标上限", () => { const many = Array.from({ length: 200 }, (_, i) => entry({ id: `audit_event:${i}`, message: "x".repeat(200), at: new Date(Date.UTC(2026, 9, 6, 0, i)).toISOString(), }), ); const goal = agentGoalFor(many); expect(goal.length).toBeLessThanOrEqual(6100); expect(goal).toMatch(/另有 \d+ 条未列出/); }); }); describe("日志页渲染", () => { it("筛选写在 URL;列表显示时间、类型、结果、摘要;默认实时", () => { const client = new QueryClient(); client.setQueryData(qk.list("logs", { type: "task" }), { pages: [{ items: [task], nextCursor: null }], pageParams: [undefined], }); const html = renderToStaticMarkup( createElement( QueryClientProvider, { client }, createElement(MemoryRouter, { initialEntries: ["/console/logs?type=task"] }, createElement(LogsPage)), ), ); expect(html).toContain("定时任务"); expect(html).toContain("沙丘2"); expect(html).toContain("qB 拒绝了种子"); expect(html).toContain("暂停"); expect(html).toContain("显示例行记录"); expect(html).toContain("问智能体"); }); it("定时任务页签渲染计划看板", () => { const client = new QueryClient(); const html = renderToStaticMarkup( createElement( QueryClientProvider, { client }, createElement(MemoryRouter, { initialEntries: ["/console/logs?tab=schedules"] }, createElement(LogsPage)), ), ); expect(html).toContain('aria-selected="true"'); expect(html).not.toContain("显示例行记录"); }); });