Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
241 lines
9.2 KiB
TypeScript
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("显示例行记录");
|
|
});
|
|
});
|