logger.test.ts 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175
  1. import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
  2. import {
  3. createLogger,
  4. createStderrSink,
  5. createJsonLineSink,
  6. nullSink,
  7. type LogEntry,
  8. } from "../utils/logger.js";
  9. import { createStreamMonitor } from "../llm/provider.js";
  10. describe("createStderrSink", () => {
  11. const originalWrite = process.stderr.write;
  12. const captured: string[] = [];
  13. beforeEach(() => {
  14. captured.length = 0;
  15. process.stderr.write = ((chunk: unknown) => {
  16. captured.push(String(chunk));
  17. return true;
  18. }) as typeof process.stderr.write;
  19. });
  20. afterEach(() => {
  21. process.stderr.write = originalWrite;
  22. });
  23. it("filters entries below minLevel", () => {
  24. const sink = createStderrSink({ minLevel: "warn", enableColors: false });
  25. const logger = createLogger({ tag: "test", sinks: [sink] });
  26. logger.info("should be filtered");
  27. expect(captured).toHaveLength(0);
  28. logger.warn("should pass");
  29. expect(captured).toHaveLength(1);
  30. expect(captured[0]).toContain("should pass");
  31. });
  32. it("includes ANSI escape codes when enableColors=true", () => {
  33. const sink = createStderrSink({ minLevel: "info", enableColors: true });
  34. const logger = createLogger({ tag: "color-test", sinks: [sink] });
  35. logger.info("colored message");
  36. expect(captured[0]).toContain("\x1b[36m"); // cyan for info
  37. expect(captured[0]).toContain("\x1b[0m"); // reset
  38. });
  39. it("omits ANSI escape codes when enableColors=false", () => {
  40. const sink = createStderrSink({ minLevel: "info", enableColors: false });
  41. const logger = createLogger({ tag: "no-color", sinks: [sink] });
  42. logger.info("plain message");
  43. expect(captured[0]).not.toContain("\x1b[");
  44. expect(captured[0]).toContain("plain message");
  45. expect(captured[0]).toContain("[no-color]");
  46. });
  47. });
  48. describe("createJsonLineSink", () => {
  49. it("outputs parseable JSON, one entry per line", () => {
  50. const chunks: string[] = [];
  51. const writable = {
  52. write(data: string) {
  53. chunks.push(data);
  54. return true;
  55. },
  56. } as unknown as NodeJS.WritableStream;
  57. const sink = createJsonLineSink(writable);
  58. const logger = createLogger({ tag: "json-test", sinks: [sink] });
  59. logger.info("first message");
  60. logger.warn("second message", { key: "value" });
  61. expect(chunks).toHaveLength(2);
  62. const entry1 = JSON.parse(chunks[0]!) as LogEntry;
  63. expect(entry1.level).toBe("info");
  64. expect(entry1.tag).toBe("json-test");
  65. expect(entry1.message).toBe("first message");
  66. expect(entry1.timestamp).toBeTruthy();
  67. const entry2 = JSON.parse(chunks[1]!) as LogEntry;
  68. expect(entry2.level).toBe("warn");
  69. expect(entry2.ctx).toEqual({ key: "value" });
  70. });
  71. });
  72. describe("nullSink", () => {
  73. it("does not throw", () => {
  74. const logger = createLogger({ tag: "null-test", sinks: [nullSink] });
  75. expect(() => {
  76. logger.debug("ignored");
  77. logger.info("ignored");
  78. logger.warn("ignored");
  79. logger.error("ignored");
  80. }).not.toThrow();
  81. });
  82. });
  83. describe("Logger.child()", () => {
  84. it("replaces tag and merges context", () => {
  85. const entries: LogEntry[] = [];
  86. const sink = {
  87. write(entry: LogEntry) {
  88. entries.push(entry);
  89. },
  90. };
  91. const parent = createLogger({
  92. tag: "parent",
  93. sinks: [sink],
  94. baseCtx: { parentKey: "pv" },
  95. });
  96. const child = parent.child("child", { childKey: "cv" });
  97. child.info("from child");
  98. expect(entries).toHaveLength(1);
  99. expect(entries[0]!.tag).toBe("child");
  100. expect(entries[0]!.ctx).toEqual({ parentKey: "pv", childKey: "cv" });
  101. });
  102. });
  103. describe("createStreamMonitor", () => {
  104. beforeEach(() => {
  105. vi.useFakeTimers();
  106. });
  107. afterEach(() => {
  108. vi.useRealTimers();
  109. });
  110. it("calls onProgress at interval with correct counts", () => {
  111. const calls: Array<{ totalChars: number; chineseChars: number; status: string }> = [];
  112. const monitor = createStreamMonitor((progress) => {
  113. calls.push({
  114. totalChars: progress.totalChars,
  115. chineseChars: progress.chineseChars,
  116. status: progress.status,
  117. });
  118. }, 1000);
  119. monitor.onChunk("hello");
  120. monitor.onChunk("世界你好");
  121. // Advance past one interval
  122. vi.advanceTimersByTime(1000);
  123. expect(calls).toHaveLength(1);
  124. expect(calls[0]!.totalChars).toBe(9); // 5 + 4
  125. expect(calls[0]!.chineseChars).toBe(4);
  126. expect(calls[0]!.status).toBe("streaming");
  127. // More chunks
  128. monitor.onChunk("abc");
  129. vi.advanceTimersByTime(1000);
  130. expect(calls).toHaveLength(2);
  131. expect(calls[1]!.totalChars).toBe(12);
  132. monitor.stop();
  133. // stop() emits a final "done" event
  134. expect(calls).toHaveLength(3);
  135. expect(calls[2]!.status).toBe("done");
  136. expect(calls[2]!.totalChars).toBe(12);
  137. });
  138. it("works without onProgress (undefined)", () => {
  139. const monitor = createStreamMonitor(undefined);
  140. expect(() => {
  141. monitor.onChunk("test data");
  142. monitor.onChunk("更多数据");
  143. monitor.stop();
  144. }).not.toThrow();
  145. });
  146. });