Files
UHD_Media_Tool/apps/web/app/components/logs/logs.test.ts
T

241 lines
9.2 KiB
TypeScript

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>): 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<string, ((ev: MessageEvent<string>) => void)[]>();
const es = {
onerror: null as ((ev: Event) => unknown) | null,
addEventListener: (t: string, fn: (ev: MessageEvent<string>) => 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<string>);
},
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("显示例行记录");
});
});