feat: 前端统一 Logger 模块 — 接口、双流 Sink、ESLint 规则、测试

This commit is contained in:
2026-06-01 14:26:17 +08:00
parent 60843f7dbf
commit 4c72754739
11 changed files with 1003 additions and 2 deletions

View File

@@ -40,7 +40,7 @@ describe("ErrorBoundary", () => {
expect(screen.getByText("渲染错误")).not.toBeNull();
expect(screen.getByText("页面渲染出现异常,请刷新重试")).not.toBeNull();
expect(screen.getByRole("button", { name: "刷新页面" })).not.toBeNull();
expect(errors.some((line) => line.includes("渲染错误:"))).toBe(true);
expect(errors.some((line) => line.includes("[Alfred:ERROR] 渲染错误"))).toBe(true);
});
test("点击刷新页面按钮不会破坏错误兜底界面", () => {

View File

@@ -0,0 +1,74 @@
/* eslint-disable @typescript-eslint/no-empty-function */
import { describe, expect, mock, test } from "bun:test";
import { createElement } from "react";
import type { Logger } from "../../../src/web/utils/logger";
import { useLogger } from "../../../src/web/hooks/use-logger";
import { renderWithProviders } from "../test-utils";
function HookTester({ onMount }: { onMount: (logger: Logger) => void }) {
const logger = useLogger();
onMount(logger);
return null;
}
describe("useLogger", () => {
test("返回 Logger 实例含所有方法", () => {
let logger: Logger | undefined;
const onMount = (l: Logger) => {
logger = l;
};
renderWithProviders(createElement(HookTester, { onMount }));
expect(logger).toBeDefined();
expect(typeof logger!.debug).toBe("function");
expect(typeof logger!.info).toBe("function");
expect(typeof logger!.warn).toBe("function");
expect(typeof logger!.error).toBe("function");
expect(typeof logger!.child).toBe("function");
expect(typeof logger!.setLevel).toBe("function");
});
test("调用 logger.warn 时静默不抛异常", () => {
const warnSpy = mock(() => {});
const origWarn = console.warn;
console.warn = warnSpy;
let logger: Logger | undefined;
renderWithProviders(
createElement(HookTester, {
onMount: (l: Logger) => {
logger = l;
},
}),
);
expect(() => logger!.warn("测试警告")).not.toThrow();
console.warn = origWarn;
expect(warnSpy).toHaveBeenCalled();
});
test("调用 logger.error 时静默不抛异常", () => {
const errorSpy = mock(() => {});
const origError = console.error;
console.error = errorSpy;
let logger: Logger | undefined;
renderWithProviders(
createElement(HookTester, {
onMount: (l: Logger) => {
logger = l;
},
}),
);
expect(() => logger!.error("测试错误")).not.toThrow();
console.error = origError;
expect(errorSpy).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,390 @@
/* eslint-disable @typescript-eslint/no-empty-function */
import { describe, expect, mock, test } from "bun:test";
import type { Sink } from "../../../src/web/utils/logger";
import {
AntdMessageSink,
ConsoleSink,
createConsoleLogger,
createDefaultLogger,
createMemoryLogger,
createNoopLogger,
} from "../../../src/web/utils/logger";
describe("ConsoleSink", () => {
test("调试环境输出 debug 级别", () => {
const sink = new ConsoleSink(false);
const spy = mock((..._args: unknown[]) => {});
const orig = console.log;
console.log = spy;
sink.write("debug", "测试消息", undefined, {});
console.log = orig;
expect(spy).toHaveBeenCalledTimes(1);
const call = spy.mock.calls[0]!;
expect(call[0]).toMatch(/\[Alfred:DEBUG\] 测试消息/);
});
test("生产环境屏蔽 debug 级别", () => {
const sink = new ConsoleSink(true);
const spy = mock((..._args: unknown[]) => {});
const orig = console.log;
console.log = spy;
sink.write("debug", "测试消息", undefined, {});
console.log = orig;
expect(spy).toHaveBeenCalledTimes(0);
});
test("生产环境屏蔽 info 级别", () => {
const sink = new ConsoleSink(true);
const spy = mock((..._args: unknown[]) => {});
const orig = console.log;
console.log = spy;
sink.write("info", "测试消息", undefined, {});
console.log = orig;
expect(spy).toHaveBeenCalledTimes(0);
});
test("生产环境保留 warn 级别", () => {
const sink = new ConsoleSink(true);
const spy = mock((..._args: unknown[]) => {});
const orig = console.warn;
console.warn = spy;
sink.write("warn", "测试消息", undefined, {});
console.warn = orig;
expect(spy).toHaveBeenCalledTimes(1);
const call = spy.mock.calls[0]!;
expect(call[0]).toMatch(/\[Alfred:WARN\] 测试消息/);
});
test("生产环境保留 error 级别", () => {
const sink = new ConsoleSink(true);
const spy = mock((..._args: unknown[]) => {});
const orig = console.error;
console.error = spy;
sink.write("error", "测试消息", undefined, {});
console.error = orig;
expect(spy).toHaveBeenCalledTimes(1);
const call = spy.mock.calls[0]!;
expect(call[0]).toMatch(/\[Alfred:ERROR\] 测试消息/);
});
test("绑定信息追加到消息后缀", () => {
const sink = new ConsoleSink(false);
const spy = mock((..._args: unknown[]) => {});
const orig = console.log;
console.log = spy;
sink.write("info", "测试消息", undefined, { id: "123", page: "projects" });
console.log = orig;
expect(spy).toHaveBeenCalledTimes(1);
const call = spy.mock.calls[0]!;
expect(call[0]).toMatch(/\[Alfred:INFO\] 测试消息 \[id=123\]\[page=projects\]/);
});
test("data 透传不序列化", () => {
const sink = new ConsoleSink(false);
const spy = mock((..._args: unknown[]) => {});
const orig = console.error;
console.error = spy;
const err = new Error("测试错误");
sink.write("error", "失败", err, {});
console.error = orig;
expect(spy).toHaveBeenCalledTimes(1);
const call = spy.mock.calls[0]!;
expect(call[1]).toBe(err);
});
test("error 级别映射到 console.error", () => {
const sink = new ConsoleSink(false);
const logSpy = mock((..._args: unknown[]) => {});
const errorSpy = mock((..._args: unknown[]) => {});
const origLog = console.log;
const origError = console.error;
console.log = logSpy;
console.error = errorSpy;
sink.write("error", "错误", undefined, {});
console.log = origLog;
console.error = origError;
expect(errorSpy).toHaveBeenCalledTimes(1);
expect(logSpy).toHaveBeenCalledTimes(0);
});
test("warn 级别映射到 console.warn", () => {
const sink = new ConsoleSink(false);
const logSpy = mock((..._args: unknown[]) => {});
const warnSpy = mock((..._args: unknown[]) => {});
const origLog = console.log;
const origWarn = console.warn;
console.log = logSpy;
console.warn = warnSpy;
sink.write("warn", "警告", undefined, {});
console.log = origLog;
console.warn = origWarn;
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(logSpy).toHaveBeenCalledTimes(0);
});
});
describe("AntdMessageSink", () => {
test("warn 级别调用 message.warning", () => {
const warningSpy = mock(() => {});
const messageApi = {
error: mock(() => {}),
info: mock(() => {}),
loading: mock(() => {}),
success: mock(() => {}),
warning: warningSpy,
};
const sink = new AntdMessageSink(messageApi as never);
sink.write("warn", "操作警告", undefined, {});
expect(warningSpy).toHaveBeenCalledWith("操作警告");
});
test("error 级别调用 message.error", () => {
const errorSpy = mock(() => {});
const messageApi = {
error: errorSpy,
info: mock(() => {}),
loading: mock(() => {}),
success: mock(() => {}),
warning: mock(() => {}),
};
const sink = new AntdMessageSink(messageApi as never);
sink.write("error", "操作失败", undefined, {});
expect(errorSpy).toHaveBeenCalledWith("操作失败");
});
test("debug 级别不触发 notification", () => {
const messageApi = {
error: mock(() => {}),
info: mock(() => {}),
loading: mock(() => {}),
success: mock(() => {}),
warning: mock(() => {}),
};
const sink = new AntdMessageSink(messageApi as never);
sink.write("debug", "调试消息", undefined, {});
expect(messageApi.info).toHaveBeenCalledTimes(0);
expect(messageApi.warning).toHaveBeenCalledTimes(0);
expect(messageApi.error).toHaveBeenCalledTimes(0);
});
test("info 级别不触发 notification", () => {
const messageApi = {
error: mock(() => {}),
info: mock(() => {}),
loading: mock(() => {}),
success: mock(() => {}),
warning: mock(() => {}),
};
const sink = new AntdMessageSink(messageApi as never);
sink.write("info", "信息消息", undefined, {});
expect(messageApi.info).toHaveBeenCalledTimes(0);
expect(messageApi.warning).toHaveBeenCalledTimes(0);
expect(messageApi.error).toHaveBeenCalledTimes(0);
});
});
describe("NoopLogger", () => {
test("所有方法静默不抛异常", () => {
const logger = createNoopLogger();
logger.debug("调试");
logger.info("信息");
logger.warn("警告");
logger.error("错误");
expect(logger.child({ page: "test" })).toBe(logger);
});
});
describe("MemoryLogger", () => {
test("记录所有级别的日志", () => {
const logger = createMemoryLogger();
logger.debug("调试");
logger.info("信息");
logger.warn("警告");
logger.error("错误");
expect(logger.entries).toEqual([
{ data: undefined, level: "debug", message: "调试" },
{ data: undefined, level: "info", message: "信息" },
{ data: undefined, level: "warn", message: "警告" },
{ data: undefined, level: "error", message: "错误" },
]);
});
test("记录附带 data 的日志", () => {
const logger = createMemoryLogger();
const err = new Error("测试");
logger.error("失败", err);
expect(logger.entries[0]).toEqual({ data: err, level: "error", message: "失败" });
});
});
describe("DefaultLogger isProduction", () => {
function createSpySink(): { entries: Array<{ data: unknown; level: string; message: string }>; sink: Sink } {
const entries: Array<{ data: unknown; level: string; message: string }> = [];
return {
entries,
sink: {
write(level, message, data) {
entries.push({ data, level, message });
},
},
};
}
test("isProduction=true 时 debug/info 不记录", () => {
const spy = createSpySink();
const logger = createDefaultLogger([spy.sink], true);
logger.debug("调试");
logger.info("信息");
expect(spy.entries).toHaveLength(0);
});
test("isProduction=true 时 warn/error 正常记录", () => {
const spy = createSpySink();
const logger = createDefaultLogger([spy.sink], true);
logger.warn("警告");
logger.error("错误");
expect(spy.entries).toHaveLength(2);
expect(spy.entries[0]!.level).toBe("warn");
expect(spy.entries[1]!.level).toBe("error");
});
test("isProduction=false 时 debug/info 正常记录", () => {
const spy = createSpySink();
const logger = createDefaultLogger([spy.sink], false);
logger.debug("调试");
logger.info("信息");
expect(spy.entries).toHaveLength(2);
expect(spy.entries[0]!.level).toBe("debug");
expect(spy.entries[1]!.level).toBe("info");
});
});
describe("child() 作用域", () => {
test("child 绑定信息输出到日志前缀", () => {
const spy = mock((..._args: unknown[]) => {});
const orig = console.warn;
console.warn = spy;
const sink = new ConsoleSink(false);
const logger = createDefaultLogger([sink], false).child({ page: "projects" });
logger.warn("测试");
console.warn = orig;
expect(spy).toHaveBeenCalledTimes(1);
const call = spy.mock.calls[0]!;
expect(call[0]).toMatch(/\[Alfred:WARN\] 测试 \[page=projects\]/);
});
test("嵌套 child 追加绑定", () => {
const spy = mock((..._args: unknown[]) => {});
const orig = console.warn;
console.warn = spy;
const sink = new ConsoleSink(false);
const logger = createDefaultLogger([sink], false).child({ page: "projects" }).child({ action: "delete" });
logger.warn("测试");
console.warn = orig;
expect(spy).toHaveBeenCalledTimes(1);
const call = spy.mock.calls[0]!;
expect(call[0]).toMatch(/\[Alfred:WARN\] 测试 \[page=projects\]\[action=delete\]/);
});
test("嵌套 child 同 key 覆盖", () => {
const spy = mock((..._args: unknown[]) => {});
const orig = console.warn;
console.warn = spy;
const sink = new ConsoleSink(false);
const logger = createDefaultLogger([sink], false).child({ page: "projects" }).child({ page: "models" });
logger.warn("测试");
console.warn = orig;
expect(spy).toHaveBeenCalledTimes(1);
const call = spy.mock.calls[0]!;
expect(call[0]).toMatch(/\[Alfred:WARN\] 测试 \[page=models\]/);
});
});
describe("setLevel 运行时调整", () => {
test("setLevel 可提高最小输出级别", () => {
const spy = (() => {
const entries: Array<{ level: string; message: string }> = [];
return {
entries,
sink: {
write(level: string, message: string) {
entries.push({ level, message });
},
},
};
})();
const logger = createDefaultLogger([spy.sink], false);
logger.debug("调试");
expect(spy.entries).toHaveLength(1);
logger.setLevel("error");
logger.debug("调试2");
logger.warn("警告");
logger.error("错误");
expect(spy.entries).toHaveLength(2);
expect(spy.entries[1]!.level).toBe("error");
expect(spy.entries[1]!.message).toBe("错误");
});
});
describe("createConsoleLogger", () => {
test("返回非 null Logger 实例", () => {
const logger = createConsoleLogger();
expect(logger).not.toBeNull();
expect(typeof logger.debug).toBe("function");
expect(typeof logger.info).toBe("function");
expect(typeof logger.warn).toBe("function");
expect(typeof logger.error).toBe("function");
expect(typeof logger.child).toBe("function");
expect(typeof logger.setLevel).toBe("function");
});
});