src/lib/logger/__tests__/logger.test.ts

Total Symbols
1
Lines of Code
324
Avg Complexity
1.0
Symbol Types
1

File Relationships

graph LR mockChrome["mockChrome"] mockChrome -->|calls| mockChrome click mockChrome "../symbols/7c8a27be4b1524cf.html"

Symbols by Kind

function 1

All Symbols

Name Kind Visibility Status Lines Signature
mockChrome function - 13-33 mockChrome( overrides: Record<string, unknown> = {}, ): : Record<string, unknown>

Full Source

import { beforeEach, describe, expect, it, vi, afterEach } from "vitest";

// Dynamic imports since logger stores state in module-level variables
let createLogger: (typeof import("@/lib/logger"))["createLogger"];
let initLogger: (typeof import("@/lib/logger"))["initLogger"];
let configureLogger: (typeof import("@/lib/logger"))["configureLogger"];
let getLogEntries: (typeof import("@/lib/logger/log-store"))["getLogEntries"];
let addLogEntry: (typeof import("@/lib/logger/log-store"))["addLogEntry"];
let clearLogEntries: (typeof import("@/lib/logger/log-store"))["clearLogEntries"];
let loadLogEntries: (typeof import("@/lib/logger/log-store"))["loadLogEntries"];
let onLogUpdate: (typeof import("@/lib/logger/log-store"))["onLogUpdate"];

function mockChrome(
  overrides: Record<string, unknown> = {},
): Record<string, unknown> {
  const mock = {
    storage: {
      local: {
        get: vi.fn().mockResolvedValue({}),
      },
      session: {
        get: vi.fn().mockResolvedValue({}),
        set: vi.fn().mockResolvedValue(undefined),
      },
      onChanged: {
        addListener: vi.fn(),
      },
    },
    ...overrides,
  };
  vi.stubGlobal("chrome", mock);
  return mock;
}

describe("logger", () => {
  beforeEach(async () => {
    vi.resetModules();
    vi.restoreAllMocks();
    vi.useFakeTimers();

    mockChrome();

    const logStore = await import("@/lib/logger/log-store");
    const logger = await import("@/lib/logger");

    createLogger = logger.createLogger;
    initLogger = logger.initLogger;
    configureLogger = logger.configureLogger;
    getLogEntries = logStore.getLogEntries;
    addLogEntry = logStore.addLogEntry;
    clearLogEntries = logStore.clearLogEntries;
    loadLogEntries = logStore.loadLogEntries;
    onLogUpdate = logStore.onLogUpdate;
  });

  afterEach(() => {
    vi.useRealTimers();
  });

  // ── createLogger ──────────────────────────────────────────────────────────

  describe("createLogger", () => {
    it("creates a logger with all expected methods", async () => {
      await initLogger();
      const log = createLogger("Test");

      expect(typeof log.debug).toBe("function");
      expect(typeof log.info).toBe("function");
      expect(typeof log.warn).toBe("function");
      expect(typeof log.error).toBe("function");
      expect(typeof log.group).toBe("function");
      expect(typeof log.groupCollapsed).toBe("function");
      expect(typeof log.groupEnd).toBe("function");
    });

    it("warn/error always emit regardless of enabled state", async () => {
      await initLogger();
      configureLogger({ enabled: false, level: "error" });

      const log = createLogger("WarnTest");
      log.warn("warning message");
      log.error("error message");

      const entries = getLogEntries();
      expect(entries.length).toBe(2);
      expect(entries[0].level).toBe("warn");
      expect(entries[1].level).toBe("error");
    });

    it("debug/info are silenced when not enabled", async () => {
      await initLogger();
      configureLogger({ enabled: false, level: "warn" });

      const log = createLogger("Silent");
      log.debug("should not appear");
      log.info("should not appear either");

      const entries = getLogEntries();
      expect(entries.length).toBe(0);
    });

    it("debug/info emit when enabled with appropriate level", async () => {
      await initLogger();
      configureLogger({ enabled: true, level: "debug" });

      const log = createLogger("Verbose");
      log.debug("debug msg");
      log.info("info msg");

      const entries = getLogEntries();
      expect(entries.length).toBe(2);
      expect(entries[0].msg).toBe("debug msg");
      expect(entries[1].msg).toBe("info msg");
    });

    it("respects level filtering — info level hides debug", async () => {
      await initLogger();
      configureLogger({ enabled: true, level: "info" });

      const log = createLogger("LevelFilter");
      log.debug("hidden");
      log.info("visible");

      const entries = getLogEntries();
      expect(entries.length).toBe(1);
      expect(entries[0].msg).toBe("visible");
    });

    it("formats namespace prefix in entries", async () => {
      await initLogger();
      configureLogger({ enabled: true, level: "debug" });

      const log = createLogger("MyModule");
      log.info("hello");

      const entries = getLogEntries();
      expect(entries[0].ns).toBe("[FillAll/MyModule]");
    });

    it("formats Error objects in log args", async () => {
      await initLogger();
      configureLogger({ enabled: true, level: "debug" });

      const log = createLogger("ErrFmt");
      log.error("failed:", new Error("boom"));

      const entries = getLogEntries();
      expect(entries[0].msg).toContain("boom");
    });

    it("formats objects as JSON in log args", async () => {
      await initLogger();
      configureLogger({ enabled: true, level: "debug" });

      const log = createLogger("ObjFmt");
      log.info("data:", { key: "value" });

      const entries = getLogEntries();
      expect(entries[0].msg).toContain('"key":"value"');
    });

    it("handles non-JSON-serializable objects gracefully", async () => {
      await initLogger();
      configureLogger({ enabled: true, level: "debug" });

      const log = createLogger("Circular");
      const obj: Record<string, unknown> = {};
      obj.self = obj; // circular reference
      log.info("circ:", obj);

      const entries = getLogEntries();
      // Should not throw, and fallback to String(obj)
      expect(entries[0].msg).toContain("circ:");
    });

    it("group/groupCollapsed emit as debug level", async () => {
      await initLogger();
      configureLogger({ enabled: true, level: "debug" });

      const log = createLogger("Group");
      log.group("section");
      log.groupCollapsed("collapsed");

      const entries = getLogEntries();
      expect(entries.length).toBe(2);
      expect(entries[0].level).toBe("debug");
      expect(entries[0].ns).toContain("section");
    });

    it("groupEnd is a no-op", async () => {
      await initLogger();
      const log = createLogger("Noop");

      // Should not throw
      expect(() => log.groupEnd()).not.toThrow();
    });
  });

  // ── initLogger ──────────────────────────────────────────────────────────

  describe("initLogger", () => {
    it("reads settings from chrome.storage.local", async () => {
      (chrome.storage.local.get as ReturnType<typeof vi.fn>).mockResolvedValue({
        fill_all_settings: { debugLog: true, logLevel: "debug" },
      });

      await initLogger();
      const log = createLogger("Init");
      log.debug("should appear after init");

      const entries = getLogEntries();
      expect(entries.some((e) => e.msg === "should appear after init")).toBe(
        true,
      );
    });

    it("defaults to disabled when no settings in storage", async () => {
      (chrome.storage.local.get as ReturnType<typeof vi.fn>).mockResolvedValue(
        {},
      );

      await initLogger();
      const log = createLogger("NoSettings");
      log.debug("hidden");

      const entries = getLogEntries();
      expect(entries.length).toBe(0);
    });

    it("works when chrome is undefined", async () => {
      vi.stubGlobal("chrome", undefined);

      // Need to re-import since chrome state is checked at import time
      vi.resetModules();
      const logger = await import("@/lib/logger");

      await logger.initLogger();
      const log = logger.createLogger("NoBrowser");
      log.warn("should still work");

      // Warn always emits
      const logStore = await import("@/lib/logger/log-store");
      const entries = logStore.getLogEntries();
      expect(entries.some((e) => e.msg === "should still work")).toBe(true);
    });

    it("flushes buffered messages after init", async () => {
      vi.resetModules();
      vi.stubGlobal("chrome", {
        storage: {
          local: {
            get: vi.fn().mockResolvedValue({
              fill_all_settings: { debugLog: true, logLevel: "debug" },
            }),
          },
          session: {
            get: vi.fn().mockResolvedValue({}),
            set: vi.fn().mockResolvedValue(undefined),
          },
          onChanged: { addListener: vi.fn() },
        },
      });

      const logger = await import("@/lib/logger");
      const log = logger.createLogger("Buffered");
      // Emit BEFORE init — should be buffered
      log.warn("before-init");

      await logger.initLogger();

      const logStore = await import("@/lib/logger/log-store");
      const entries = logStore.getLogEntries();
      expect(entries.some((e) => e.msg === "before-init")).toBe(true);
    });

    it("registers onChanged listener for real-time config updates", async () => {
      await initLogger();

      expect(chrome.storage.onChanged.addListener).toHaveBeenCalled();
    });

    it("handles storage.local.get failure gracefully", async () => {
      (chrome.storage.local.get as ReturnType<typeof vi.fn>).mockRejectedValue(
        new Error("storage error"),
      );

      // Should not throw
      await expect(initLogger()).resolves.not.toThrow();
    });
  });

  // ── configureLogger ───────────────────────────────────────────────────────

  describe("configureLogger", () => {
    it("enables debug logging at runtime", async () => {
      await initLogger();
      configureLogger({ enabled: true, level: "debug" });

      const log = createLogger("Dynamic");
      log.debug("now visible");

      const entries = getLogEntries();
      expect(entries.some((e) => e.msg === "now visible")).toBe(true);
    });

    it("allows partial config updates", async () => {
      await initLogger();
      configureLogger({ enabled: true, level: "debug" });

      const log = createLogger("Partial");
      log.debug("visible");

      // Disable only
      configureLogger({ enabled: false });
      log.debug("hidden now");

      const entries = getLogEntries();
      expect(entries.filter((e) => e.ns === "[FillAll/Partial]").length).toBe(
        1,
      );
    });
  });
});