diff --git a/deno.json b/deno.json index 8898fe6..2d9259b 100644 --- a/deno.json +++ b/deno.json @@ -1,6 +1,6 @@ { "name": "@tabirun/app", - "version": "0.1.1", + "version": "0.1.2", "license": "MIT", "nodeModulesDir": "auto", "publish": { diff --git a/logs/logger.ts b/logs/logger.ts index 87a6cac..3803897 100644 --- a/logs/logger.ts +++ b/logs/logger.ts @@ -13,9 +13,11 @@ const COLORS = { */ export class TabiLogger { private _source: string; + public readonly ephemeral: EphemeralLogger; constructor(source: string) { this._source = source; + this.ephemeral = new EphemeralLogger(this); } /** Log success message */ @@ -38,17 +40,8 @@ export class TabiLogger { console.error(this.format("error", message.toString())); } - /** - * Log ephemeral message that overwrites the previous line. - * Useful for status updates that change frequently (e.g., render notifications). - */ - public ephemeral(message: string) { - // \r returns to start of line, \x1b[K clears from cursor to end of line - const output = "\r\x1b[K" + this.format("info", message); - Deno.stdout.writeSync(new TextEncoder().encode(output)); - } - - private format(logLevel: keyof typeof COLORS, message: string) { + /** Format a log message (exposed for EphemeralLogger) */ + public format(logLevel: keyof typeof COLORS, message: string): string { const level = `${COLORS[logLevel]}[${ logLevel.charAt(0).toUpperCase() + logLevel.slice(1) }]${COLORS.reset}`; @@ -57,3 +50,44 @@ export class TabiLogger { return `${source}${level} ${message}`; } } + +/** + * Ephemeral logger that overwrites the previous line. + * Useful for status updates that change frequently (e.g., render notifications). + */ +class EphemeralLogger { + private _logger: TabiLogger; + + constructor(logger: TabiLogger) { + this._logger = logger; + } + + private write( + logLevel: "success" | "info" | "warn" | "error", + message: string, + ) { + // \r returns to start of line, \x1b[K clears from cursor to end of line + const output = "\r\x1b[K" + this._logger.format(logLevel, message); + Deno.stdout.writeSync(new TextEncoder().encode(output)); + } + + /** Log ephemeral success message */ + public success(message: string) { + this.write("success", message); + } + + /** Log ephemeral info message */ + public info(message: string) { + this.write("info", message); + } + + /** Log ephemeral warning message */ + public warn(message: string) { + this.write("warn", message); + } + + /** Log ephemeral error message */ + public error(message: string | Error) { + this.write("error", message.toString()); + } +} diff --git a/logs/tests/logger.test.ts b/logs/tests/logger.test.ts index 197a32d..60154d2 100644 --- a/logs/tests/logger.test.ts +++ b/logs/tests/logger.test.ts @@ -1,5 +1,5 @@ import { describe, it } from "@std/testing/bdd"; -import { assertSpyCall, spy } from "@std/testing/mock"; +import { assertSpyCall, spy, type Stub, stub } from "@std/testing/mock"; import { TabiLogger } from "../logger.ts"; const logSpy = spy(console, "log"); @@ -7,6 +7,16 @@ const infoSpy = spy(console, "info"); const warnSpy = spy(console, "warn"); const errorSpy = spy(console, "error"); +let stdoutStub: Stub; + +function setupStdoutStub() { + stdoutStub = stub(Deno.stdout, "writeSync", () => 0); +} + +function teardownStdoutStub() { + stdoutStub.restore(); +} + describe("TabiLogger", () => { it("should log a message with the 'success' level", () => { const logger = new TabiLogger("Tabi"); @@ -52,4 +62,96 @@ describe("TabiLogger", () => { args: ["\x1b[35m[Tabi]\x1b[0m\x1b[31m[Error]\x1b[0m Test message"], }); }); + + describe("ephemeral", () => { + it("should log ephemeral success message", () => { + setupStdoutStub(); + try { + const logger = new TabiLogger("Tabi"); + logger.ephemeral.success("Status update"); + + assertSpyCall(stdoutStub, 0, { + args: [ + new TextEncoder().encode( + "\r\x1b[K\x1b[35m[Tabi]\x1b[0m\x1b[32m[Success]\x1b[0m Status update", + ), + ], + }); + } finally { + teardownStdoutStub(); + } + }); + + it("should log ephemeral info message", () => { + setupStdoutStub(); + try { + const logger = new TabiLogger("Tabi"); + logger.ephemeral.info("Status update"); + + assertSpyCall(stdoutStub, 0, { + args: [ + new TextEncoder().encode( + "\r\x1b[K\x1b[35m[Tabi]\x1b[0m\x1b[94m[Info]\x1b[0m Status update", + ), + ], + }); + } finally { + teardownStdoutStub(); + } + }); + + it("should log ephemeral warn message", () => { + setupStdoutStub(); + try { + const logger = new TabiLogger("Tabi"); + logger.ephemeral.warn("Status update"); + + assertSpyCall(stdoutStub, 0, { + args: [ + new TextEncoder().encode( + "\r\x1b[K\x1b[35m[Tabi]\x1b[0m\x1b[33m[Warn]\x1b[0m Status update", + ), + ], + }); + } finally { + teardownStdoutStub(); + } + }); + + it("should log ephemeral error message", () => { + setupStdoutStub(); + try { + const logger = new TabiLogger("Tabi"); + logger.ephemeral.error("Status update"); + + assertSpyCall(stdoutStub, 0, { + args: [ + new TextEncoder().encode( + "\r\x1b[K\x1b[35m[Tabi]\x1b[0m\x1b[31m[Error]\x1b[0m Status update", + ), + ], + }); + } finally { + teardownStdoutStub(); + } + }); + + it("should log ephemeral error with Error object", () => { + setupStdoutStub(); + try { + const logger = new TabiLogger("Tabi"); + logger.ephemeral.error(new Error("Something went wrong")); + + assertSpyCall(stdoutStub, 0, { + args: [ + new TextEncoder().encode( + "\r\x1b[K\x1b[35m[Tabi]\x1b[0m\x1b[31m[Error]\x1b[0m Error: Something went wrong", + ), + ], + }); + } finally { + teardownStdoutStub(); + } + }); + }); });