Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion deno.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@tabirun/app",
"version": "0.1.1",
"version": "0.1.2",
"license": "MIT",
"nodeModulesDir": "auto",
"publish": {
Expand Down
56 changes: 45 additions & 11 deletions logs/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand All @@ -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}`;
Expand All @@ -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());
}
}
104 changes: 103 additions & 1 deletion logs/tests/logger.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,22 @@
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");
const infoSpy = spy(console, "info");
const warnSpy = spy(console, "warn");
const errorSpy = spy(console, "error");

let stdoutStub: Stub<typeof Deno.stdout, [Uint8Array], number>;

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");
Expand Down Expand Up @@ -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();
}
});
});
});