feat(logging): add leveled logger facade
TRACES: | DR-204 | UT-201
The Rust half of the app logs through the `log` crate behind `env_logger`,
with `LevelFilter::Info` by default and `RUST_LOG` to turn the volume up
without a rebuild. The frontend had no equivalent at all: every
`console.log` written during development shipped to end users.
`createLogger(scope)` gives the frontend the same shape:
- four levels (debug/info/warn/error), gated by severity;
- verbose in dev, `warn` in production — warn and error are never gated
away, because a silent failure in a networked media client is worse to
support than a noisy console;
- `localStorage["jellytau:logLevel"]`, read once at init, as the
`RUST_LOG` equivalent so a user can gather verbose logs for a bug
report without a rebuild. Guarded for SSR and for webviews where
storage access throws;
- the scope replaces the hand-written `"[Scope] …"` prefixes;
- a thin pass-through: arguments reach `console.*` untouched and by
reference, and `console` is resolved at call time so devtools
overrides and test spies still see everything.
This commit is contained in:
@@ -0,0 +1,318 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
LOG_LEVEL_STORAGE_KEY,
|
||||
createLogger,
|
||||
defaultLogLevel,
|
||||
getLogLevel,
|
||||
isLevelEnabled,
|
||||
parseLogLevel,
|
||||
readStoredLogLevel,
|
||||
resetLogLevel,
|
||||
setLogLevel,
|
||||
type LogLevel,
|
||||
} from "./logger";
|
||||
|
||||
/**
|
||||
* Frontend leveled logging facade.
|
||||
*
|
||||
* TRACES: | DR-204 | UT-201
|
||||
*
|
||||
* The frontend used to ship 468 ungated `console.*` calls to end users — no
|
||||
* levels, no gate, no way to turn them off. These tests pin the three
|
||||
* properties that make the replacement safe to rely on:
|
||||
*
|
||||
* 1. **Gating is by severity, and errors/warnings are never gated away.** A
|
||||
* production build suppresses chatter, but a user-visible failure must still
|
||||
* reach the console or a bug report has nothing in it.
|
||||
* 2. **The scope is what replaces the hand-written `"[Scope] …"` prefixes**, so
|
||||
* it has to land in the message rather than beside it, and it must not
|
||||
* mangle the remaining arguments.
|
||||
* 3. **Reading the `localStorage` override can never throw.** `localStorage` is
|
||||
* absent under SSR and *throws on access* in a webview with storage
|
||||
* disabled; logging must not be what takes the app down.
|
||||
*/
|
||||
|
||||
/** Spies for all four backing console methods. */
|
||||
function spyConsole() {
|
||||
return {
|
||||
log: vi.spyOn(console, "log").mockImplementation(() => {}),
|
||||
info: vi.spyOn(console, "info").mockImplementation(() => {}),
|
||||
warn: vi.spyOn(console, "warn").mockImplementation(() => {}),
|
||||
error: vi.spyOn(console, "error").mockImplementation(() => {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Swap `globalThis.localStorage` for the duration of a test. */
|
||||
function stubStorage(value: unknown) {
|
||||
const original = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
value,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
return () => {
|
||||
if (original) Object.defineProperty(globalThis, "localStorage", original);
|
||||
else delete (globalThis as { localStorage?: unknown }).localStorage;
|
||||
};
|
||||
}
|
||||
|
||||
describe("parseLogLevel", () => {
|
||||
it("accepts every level name", () => {
|
||||
for (const level of ["debug", "info", "warn", "error"] as LogLevel[]) {
|
||||
expect(parseLogLevel(level)).toBe(level);
|
||||
}
|
||||
});
|
||||
|
||||
it("is case- and whitespace-insensitive, because a human types the override", () => {
|
||||
expect(parseLogLevel(" DEBUG ")).toBe("debug");
|
||||
expect(parseLogLevel("Warn")).toBe("warn");
|
||||
});
|
||||
|
||||
it("rejects anything that is not a level", () => {
|
||||
expect(parseLogLevel("trace")).toBeNull();
|
||||
expect(parseLogLevel("")).toBeNull();
|
||||
expect(parseLogLevel(null)).toBeNull();
|
||||
expect(parseLogLevel(undefined)).toBeNull();
|
||||
expect(parseLogLevel(3)).toBeNull();
|
||||
expect(parseLogLevel({ level: "debug" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("level gating", () => {
|
||||
let restoreLevel: LogLevel;
|
||||
|
||||
beforeEach(() => {
|
||||
restoreLevel = getLogLevel();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setLogLevel(restoreLevel);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("emits everything at debug", () => {
|
||||
setLogLevel("debug");
|
||||
const spies = spyConsole();
|
||||
const log = createLogger("Test");
|
||||
|
||||
log.debug("d");
|
||||
log.info("i");
|
||||
log.warn("w");
|
||||
log.error("e");
|
||||
|
||||
expect(spies.log).toHaveBeenCalledTimes(1);
|
||||
expect(spies.info).toHaveBeenCalledTimes(1);
|
||||
expect(spies.warn).toHaveBeenCalledTimes(1);
|
||||
expect(spies.error).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("suppresses debug and info at warn — the production default", () => {
|
||||
setLogLevel("warn");
|
||||
const spies = spyConsole();
|
||||
const log = createLogger("Test");
|
||||
|
||||
log.debug("d");
|
||||
log.info("i");
|
||||
log.warn("w");
|
||||
log.error("e");
|
||||
|
||||
expect(spies.log).not.toHaveBeenCalled();
|
||||
expect(spies.info).not.toHaveBeenCalled();
|
||||
expect(spies.warn).toHaveBeenCalledTimes(1);
|
||||
expect(spies.error).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("still emits errors at the most restrictive level", () => {
|
||||
// A silent failure is worse to support than a noisy console: there is no
|
||||
// level at which `error` is dropped.
|
||||
setLogLevel("error");
|
||||
const spies = spyConsole();
|
||||
const log = createLogger("Test");
|
||||
|
||||
log.debug("d");
|
||||
log.info("i");
|
||||
log.warn("w");
|
||||
log.error("boom");
|
||||
|
||||
expect(spies.log).not.toHaveBeenCalled();
|
||||
expect(spies.info).not.toHaveBeenCalled();
|
||||
expect(spies.warn).not.toHaveBeenCalled();
|
||||
expect(spies.error).toHaveBeenCalledWith("[Test] boom");
|
||||
});
|
||||
|
||||
it("reports which levels are enabled", () => {
|
||||
setLogLevel("warn");
|
||||
expect(isLevelEnabled("debug")).toBe(false);
|
||||
expect(isLevelEnabled("info")).toBe(false);
|
||||
expect(isLevelEnabled("warn")).toBe(true);
|
||||
expect(isLevelEnabled("error")).toBe(true);
|
||||
});
|
||||
|
||||
it("maps debug to console.log, not console.debug", () => {
|
||||
// console.debug lands in the browser's hidden "Verbose" bucket, which would
|
||||
// make dev logging invisible in exactly the builds that want it.
|
||||
setLogLevel("debug");
|
||||
const spies = spyConsole();
|
||||
const debugSpy = vi.spyOn(console, "debug").mockImplementation(() => {});
|
||||
|
||||
createLogger("Test").debug("hello");
|
||||
|
||||
expect(debugSpy).not.toHaveBeenCalled();
|
||||
expect(spies.log).toHaveBeenCalledWith("[Test] hello");
|
||||
});
|
||||
});
|
||||
|
||||
describe("scope prefixing", () => {
|
||||
let restoreLevel: LogLevel;
|
||||
|
||||
beforeEach(() => {
|
||||
restoreLevel = getLogLevel();
|
||||
setLogLevel("debug");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setLogLevel(restoreLevel);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("folds the scope into a leading string message", () => {
|
||||
const spies = spyConsole();
|
||||
createLogger("VideoPlayer").warn("seek failed");
|
||||
|
||||
expect(spies.warn).toHaveBeenCalledWith("[VideoPlayer] seek failed");
|
||||
});
|
||||
|
||||
it("passes trailing arguments through untouched, by reference", () => {
|
||||
const spies = spyConsole();
|
||||
const payload = { itemId: "abc", nested: { position: 12 } };
|
||||
const err = new Error("nope");
|
||||
|
||||
createLogger("Queue").error("failed to advance:", payload, err, 42);
|
||||
|
||||
expect(spies.error).toHaveBeenCalledWith("[Queue] failed to advance:", payload, err, 42);
|
||||
// Same object, not a copy — devtools inspection depends on this.
|
||||
expect(spies.error.mock.calls[0][1]).toBe(payload);
|
||||
expect(spies.error.mock.calls[0][2]).toBe(err);
|
||||
});
|
||||
|
||||
it("prepends the scope as its own argument when the first argument is not a string", () => {
|
||||
const spies = spyConsole();
|
||||
const err = new Error("boom");
|
||||
|
||||
createLogger("Auth").error(err);
|
||||
|
||||
expect(spies.error).toHaveBeenCalledWith("[Auth]", err);
|
||||
expect(spies.error.mock.calls[0][1]).toBe(err);
|
||||
});
|
||||
|
||||
it("handles a call with no arguments at all", () => {
|
||||
const spies = spyConsole();
|
||||
createLogger("Auth").debug();
|
||||
|
||||
expect(spies.log).toHaveBeenCalledWith("[Auth]");
|
||||
});
|
||||
|
||||
it("keeps separate scopes independent", () => {
|
||||
const spies = spyConsole();
|
||||
createLogger("NextEpisode").info("advancing");
|
||||
createLogger("PlayerPage").info("advancing");
|
||||
|
||||
expect(spies.info).toHaveBeenNthCalledWith(1, "[NextEpisode] advancing");
|
||||
expect(spies.info).toHaveBeenNthCalledWith(2, "[PlayerPage] advancing");
|
||||
});
|
||||
|
||||
it("resolves console methods at call time so spies and overrides are honoured", () => {
|
||||
// A cached `console.warn` reference would bypass a devtools override or a
|
||||
// later-installed spy — and every existing test that asserts on log output.
|
||||
const log = createLogger("Late");
|
||||
const late = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
log.warn("after the fact");
|
||||
|
||||
expect(late).toHaveBeenCalledWith("[Late] after the fact");
|
||||
});
|
||||
});
|
||||
|
||||
describe("localStorage override", () => {
|
||||
let restoreStorage: () => void = () => {};
|
||||
let restoreLevel: LogLevel;
|
||||
|
||||
beforeEach(() => {
|
||||
restoreLevel = getLogLevel();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
restoreStorage();
|
||||
restoreStorage = () => {};
|
||||
setLogLevel(restoreLevel);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("reads the level a user set to gather logs for a bug report", () => {
|
||||
restoreStorage = stubStorage({ getItem: vi.fn(() => "debug") });
|
||||
|
||||
expect(readStoredLogLevel()).toBe("debug");
|
||||
expect(resetLogLevel()).toBe("debug");
|
||||
expect(isLevelEnabled("debug")).toBe(true);
|
||||
});
|
||||
|
||||
it("looks the level up under jellytau:logLevel", () => {
|
||||
const getItem = vi.fn(() => "error");
|
||||
restoreStorage = stubStorage({ getItem });
|
||||
|
||||
readStoredLogLevel();
|
||||
|
||||
expect(getItem).toHaveBeenCalledWith(LOG_LEVEL_STORAGE_KEY);
|
||||
expect(LOG_LEVEL_STORAGE_KEY).toBe("jellytau:logLevel");
|
||||
});
|
||||
|
||||
it("falls back to the build default for a missing or bogus value", () => {
|
||||
restoreStorage = stubStorage({ getItem: vi.fn(() => null) });
|
||||
expect(readStoredLogLevel()).toBeNull();
|
||||
expect(resetLogLevel()).toBe(defaultLogLevel());
|
||||
|
||||
restoreStorage();
|
||||
restoreStorage = stubStorage({ getItem: vi.fn(() => "extremely-verbose") });
|
||||
expect(readStoredLogLevel()).toBeNull();
|
||||
expect(resetLogLevel()).toBe(defaultLogLevel());
|
||||
});
|
||||
|
||||
it("survives localStorage being absent (SSR)", () => {
|
||||
restoreStorage = stubStorage(undefined);
|
||||
|
||||
expect(() => readStoredLogLevel()).not.toThrow();
|
||||
expect(readStoredLogLevel()).toBeNull();
|
||||
expect(resetLogLevel()).toBe(defaultLogLevel());
|
||||
});
|
||||
|
||||
it("survives localStorage throwing on access (storage disabled)", () => {
|
||||
restoreStorage = stubStorage({
|
||||
getItem: () => {
|
||||
throw new DOMException("The operation is insecure.", "SecurityError");
|
||||
},
|
||||
});
|
||||
|
||||
expect(() => readStoredLogLevel()).not.toThrow();
|
||||
expect(readStoredLogLevel()).toBeNull();
|
||||
});
|
||||
|
||||
it("survives localStorage being a stale object with no getItem", () => {
|
||||
restoreStorage = stubStorage({});
|
||||
|
||||
expect(() => readStoredLogLevel()).not.toThrow();
|
||||
expect(readStoredLogLevel()).toBeNull();
|
||||
});
|
||||
|
||||
it("does not let a broken localStorage break logging itself", () => {
|
||||
restoreStorage = stubStorage({
|
||||
getItem: () => {
|
||||
throw new Error("nope");
|
||||
},
|
||||
});
|
||||
resetLogLevel();
|
||||
const spies = spyConsole();
|
||||
|
||||
expect(() => createLogger("Boot").error("still reaches the console")).not.toThrow();
|
||||
expect(spies.error).toHaveBeenCalledWith("[Boot] still reaches the console");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user