import { suite } from "@alinea/suite"; import { assert } from "@std/assert/assert"; import { assertEquals } from "@std/assert/equals"; import { assertMatch } from "@std/assert/match"; import { assertStringIncludes } from "@std/assert/string-includes"; import type { LogRecord } from "@logtape/logtape"; import { type CategoryColorMap, getPrettyFormatter, prettyFormatter, } from "./formatter.ts"; const test = suite(import.meta); function createLogRecord( level: LogRecord["level"], category: string[], message: LogRecord["message"], timestamp: number = Date.now(), ): LogRecord { // Convert message array to template strings format for rawMessage const rawMessage = typeof message === "string" ? message : message.filter((_, i) => i % 2 === 0).join("{}"); return { level, category, message, rawMessage, properties: {}, timestamp, }; } test("prettyFormatter basic output", () => { const record = createLogRecord( "info", ["app", "server"], ["Server started on port ", 3000], ); const output = prettyFormatter(record); // Should contain emoji, level, category, and message assertMatch(output, /✨/); assertMatch(output, /info/); // Default level format is "full" assertMatch(output, /app·server/); assertMatch(output, /Server started on port/); assertMatch(output, /3000/); }); test("getPrettyFormatter() with no colors", () => { const formatter = getPrettyFormatter({ colors: false }); const record = createLogRecord( "error", ["app", "auth"], ["Authentication failed"], ); const output = formatter(record); // Should not contain ANSI escape codes assertEquals(output.includes("\x1b["), false); assertMatch(output, /❌ error/); // Default level format is "full" assertMatch(output, /app·auth/); }); test("getPrettyFormatter() with custom icons", () => { const formatter = getPrettyFormatter({ icons: { info: "ℹ️ ", error: "🔥", }, }); const infoRecord = createLogRecord("info", ["test"], ["Info message"]); const errorRecord = createLogRecord("error", ["test"], ["Error message"]); assertMatch(formatter(infoRecord), /ℹ️/); assertMatch(formatter(errorRecord), /🔥/); }); test("getPrettyFormatter() with no icons", () => { const formatter = getPrettyFormatter({ icons: false }); const record = createLogRecord("info", ["test"], ["Message"]); const output = formatter(record); // Should not contain any emoji assertEquals(output.includes("✨"), false); assertEquals(output.includes("🐛"), false); }); test("getPrettyFormatter() with timestamp", () => { const timestamp = new Date("2024-01-15T12:34:56Z").getTime(); // Time only - note UTC timezone handling const timeFormatter = getPrettyFormatter({ timestamp: "time" }); const record = createLogRecord("info", ["test"], ["Message"], timestamp); const timeOutput = timeFormatter(record); assertMatch(timeOutput, /\d{2}:\d{2}:\d{2}/); // Date and time const datetimeFormatter = getPrettyFormatter({ timestamp: "date-time" }); const datetimeOutput = datetimeFormatter(record); assertMatch(datetimeOutput, /2024-01-15/); assertMatch(datetimeOutput, /\d{2}:\d{2}:\d{2}/); // Custom formatter const customFormatter = getPrettyFormatter({ timestamp: (ts) => new Date(ts).toISOString(), }); const customOutput = customFormatter(record); assertMatch(customOutput, /2024-01-15T12:34:56/); // Test function returning null const nullFormatter = getPrettyFormatter({ timestamp: () => null, }); const nullOutput = nullFormatter(record); assertEquals(nullOutput.includes("2024"), false); // Test none timestamp const noneFormatter = getPrettyFormatter({ timestamp: "none" }); const noneOutput = noneFormatter(record); assertEquals(noneOutput.includes("2024"), false); }); test("getPrettyFormatter() category truncation", () => { const formatter = getPrettyFormatter({ categoryWidth: 15, categoryTruncate: "middle", }); const record = createLogRecord( "info", ["app", "server", "http", "middleware"], ["Request processed"], ); const output = formatter(record); // Category should be truncated and contain app assertMatch(output, /app/); assertMatch(output, /…/); }); test("getPrettyFormatter() with null colors", () => { const formatter = getPrettyFormatter({ levelColors: { info: null, // No color }, categoryColor: null, }); const record = createLogRecord("info", ["test"], ["Message"]); const result = formatter(record); // Should work without errors and have basic formatting assertStringIncludes(result, "info"); // Default level format is "full" assertStringIncludes(result, "test"); assertStringIncludes(result, "Message"); }); test("getPrettyFormatter() with values", () => { const formatter = getPrettyFormatter(); const record = createLogRecord( "debug", ["app"], ["User data: ", { id: 123, name: "John" }, ", array: ", [1, 2, 3]], ); const output = formatter(record); assertMatch(output, /User data:/); assertMatch(output, /123/); assertMatch(output, /John/); assertMatch(output, /1.*2.*3/); }); test("getPrettyFormatter() all log levels", () => { const formatter = getPrettyFormatter(); const levels: LogRecord["level"][] = [ "trace", "debug", "info", "warning", "error", "fatal", ]; const expectedIcons = ["🔍", "🐛", "✨", "⚡", "❌", "💀"]; levels.forEach((level, i) => { const record = createLogRecord(level, ["test"], [`${level} message`]); const output = formatter(record); assertMatch(output, new RegExp(expectedIcons[i])); // Check for full level format (default) assertMatch(output, new RegExp(level)); }); }); test("getPrettyFormatter() alignment", () => { const formatter = getPrettyFormatter({ align: true, colors: false }); const records = [ createLogRecord("info", ["app"], ["Short"]), createLogRecord("warning", ["app"], ["Longer level"]), ]; const outputs = records.map((r) => formatter(r)); // With alignment, warning (longer) should have more padding before the category // Just check that both outputs contain the expected content assertMatch(outputs[0], /✨ info.*app.*Short/); // Default level format is "full" assertMatch(outputs[1], /⚡.*warning.*app.*Longer level/); // Default level format is "full" }); test("getPrettyFormatter() no alignment", () => { const formatter = getPrettyFormatter({ align: false, colors: false }); const record = createLogRecord("info", ["app"], ["Message"]); const output = formatter(record); // Should still be formatted but without padding assertMatch(output, /✨ info app Message/); // Default level format is "full" }); test("getPrettyFormatter() with hex colors", () => { const formatter = getPrettyFormatter({ levelColors: { info: "#00ff00", // Bright green error: "#ff0000", // Bright red }, categoryColor: "#888888", messageColor: "#cccccc", }); const record = createLogRecord("info", ["test"], ["Message"]); const result = formatter(record); // Should contain true color ANSI codes for hex colors assertStringIncludes(result, "\x1b[38;2;0;255;0m"); // #00ff00 converted to RGB }); test("getPrettyFormatter() with rgb colors", () => { const formatter = getPrettyFormatter({ levelColors: { info: "rgb(255,128,0)", // Orange }, timestampColor: "rgb(100,100,100)", timestamp: "time", }); const record = createLogRecord("info", ["test"], ["Message"]); const result = formatter(record); // Should contain true color ANSI codes for RGB colors assertStringIncludes(result, "\x1b[38;2;255;128;0m"); // rgb(255,128,0) assertStringIncludes(result, "\x1b[38;2;100;100;100m"); // timestamp color }); test("getPrettyFormatter() with level formats", () => { const abbr = getPrettyFormatter({ level: "ABBR" }); const full = getPrettyFormatter({ level: "FULL" }); const letter = getPrettyFormatter({ level: "L" }); const custom = getPrettyFormatter({ level: (level) => `[${level}]` }); const record = createLogRecord("info", ["test"], ["Message"]); const abbrResult = abbr(record); const fullResult = full(record); const letterResult = letter(record); const customResult = custom(record); assertStringIncludes(abbrResult, "INF"); assertStringIncludes(fullResult, "INFO"); assertStringIncludes(letterResult, "I"); assertStringIncludes(customResult, "[info]"); }); test("getPrettyFormatter() with extended timestamp formats", () => { const timestamp = new Date("2023-05-15T10:30:00.000Z").getTime(); const record = createLogRecord("info", ["test"], ["Message"], timestamp); // Test all TextFormatterOptions timestamp formats const dateTimeTimezone = getPrettyFormatter({ timestamp: "date-time-timezone", }); const dateTimeTz = getPrettyFormatter({ timestamp: "date-time-tz" }); const dateTime = getPrettyFormatter({ timestamp: "date-time" }); const timeTimezone = getPrettyFormatter({ timestamp: "time-timezone" }); const timeTz = getPrettyFormatter({ timestamp: "time-tz" }); const rfc3339 = getPrettyFormatter({ timestamp: "rfc3339" }); const dateOnly = getPrettyFormatter({ timestamp: "date" }); const datetime = getPrettyFormatter({ timestamp: "date-time" }); const none = getPrettyFormatter({ timestamp: "none" }); const disabled = getPrettyFormatter({ timestamp: "disabled" }); const dateTimeTimezoneResult = dateTimeTimezone(record); const dateTimeTzResult = dateTimeTz(record); const dateTimeResult = dateTime(record); const timeTimezoneResult = timeTimezone(record); const timeTzResult = timeTz(record); const rfc3339Result = rfc3339(record); const dateOnlyResult = dateOnly(record); const datetimeResult = datetime(record); const noneResult = none(record); const disabledResult = disabled(record); // Check that appropriate timestamps are included assertStringIncludes(dateTimeTimezoneResult, "2023-05-15"); assertStringIncludes(dateTimeTimezoneResult, "+00:00"); assertStringIncludes(dateTimeTzResult, "2023-05-15"); assertStringIncludes(dateTimeTzResult, "+00"); assertStringIncludes(dateTimeResult, "2023-05-15"); assertStringIncludes(timeTimezoneResult, "10:30:00"); assertStringIncludes(timeTzResult, "10:30:00"); assertStringIncludes(rfc3339Result, "2023-05-15T10:30:00.000Z"); assertStringIncludes(dateOnlyResult, "2023-05-15"); assertStringIncludes(datetimeResult, "2023-05-15 10:30:00"); // Check that none/disabled don't include timestamps assertEquals(noneResult.includes("2023"), false); assertEquals(disabledResult.includes("2023"), false); }); test("getPrettyFormatter() with styles", () => { const formatter = getPrettyFormatter({ levelStyle: "bold", categoryStyle: "italic", messageStyle: "underline", timestampStyle: "strikethrough", timestamp: "time", }); const record = createLogRecord("info", ["test"], ["Message"]); const result = formatter(record); // Should contain ANSI style codes assertStringIncludes(result, "\x1b[1m"); // bold assertStringIncludes(result, "\x1b[3m"); // italic assertStringIncludes(result, "\x1b[4m"); // underline assertStringIncludes(result, "\x1b[9m"); // strikethrough }); test("getPrettyFormatter() with custom category separator", () => { const formatter = getPrettyFormatter({ categorySeparator: ">", colors: false, }); const record = createLogRecord("info", ["app", "web", "server"], ["Message"]); const result = formatter(record); assertStringIncludes(result, "app>web>server"); }); test("getPrettyFormatter() with ANSI colors", () => { const formatter = getPrettyFormatter({ levelColors: { info: "green", error: "red", }, categoryColor: "blue", }); const record = createLogRecord("info", ["test"], ["Message"]); const result = formatter(record); // Should contain ANSI color codes assertStringIncludes(result, "\x1b[32m"); // green assertStringIncludes(result, "\x1b[34m"); // blue }); test("Color helper functions with 3-digit hex", () => { const formatter = getPrettyFormatter({ levelColors: { info: "#fff", // 3-digit hex }, }); const record = createLogRecord("info", ["test"], ["Message"]); const result = formatter(record); // Should contain converted RGB codes assertStringIncludes(result, "\x1b[38;2;255;255;255m"); // #fff -> rgb(255,255,255) }); test("getPrettyFormatter() with category color mapping", () => { const categoryColorMap: CategoryColorMap = new Map([ [["app", "auth"], "#ff6b6b"], // red for app.auth.* [["app", "db"], "#4ecdc4"], // teal for app.db.* [["app"], "#45b7d1"], // blue for app.* (fallback) [["lib"], "#96ceb4"], // green for lib.* ]); const formatter = getPrettyFormatter({ categoryColorMap, colors: true, }); // Test exact match const authRecord = createLogRecord("info", ["app", "auth", "login"], [ "User logged in", ]); const authResult = formatter(authRecord); assertStringIncludes(authResult, "\x1b[38;2;255;107;107m"); // #ff6b6b // Test prefix fallback const miscRecord = createLogRecord("info", ["app", "utils"], [ "Utility called", ]); const miscResult = formatter(miscRecord); assertStringIncludes(miscResult, "\x1b[38;2;69;183;209m"); // #45b7d1 // Test different prefix const libRecord = createLogRecord("info", ["lib", "http"], ["HTTP request"]); const libResult = formatter(libRecord); assertStringIncludes(libResult, "\x1b[38;2;150;206;180m"); // #96ceb4 }); test("Category color mapping precedence", () => { const categoryColorMap: CategoryColorMap = new Map([ [["app", "auth", "jwt"], "#ff0000"], // Most specific [["app", "auth"], "#00ff00"], // Less specific [["app"], "#0000ff"], // Least specific ]); const formatter = getPrettyFormatter({ categoryColorMap, colors: true, }); // Should match most specific pattern const jwtRecord = createLogRecord("info", ["app", "auth", "jwt", "verify"], [ "Token verified", ]); const jwtResult = formatter(jwtRecord); assertStringIncludes(jwtResult, "\x1b[38;2;255;0;0m"); // #ff0000 // Should match less specific pattern const authRecord = createLogRecord("info", ["app", "auth", "session"], [ "Session created", ]); const authResult = formatter(authRecord); assertStringIncludes(authResult, "\x1b[38;2;0;255;0m"); // #00ff00 // Should match least specific pattern const appRecord = createLogRecord("info", ["app", "server"], [ "Server started", ]); const appResult = formatter(appRecord); assertStringIncludes(appResult, "\x1b[38;2;0;0;255m"); // #0000ff }); test("Category color mapping with no match", () => { const categoryColorMap: CategoryColorMap = new Map([ [["app"], "#ff0000"], ]); const formatter = getPrettyFormatter({ categoryColorMap, categoryColor: "#00ff00", // fallback color colors: true, }); // Should use fallback color for non-matching category const record = createLogRecord("info", ["system", "kernel"], [ "Kernel message", ]); const result = formatter(record); assertStringIncludes(result, "\x1b[38;2;0;255;0m"); // fallback #00ff00 }); test("Interpolated values with proper color reset/reapply", () => { const formatter = getPrettyFormatter({ messageColor: "#ffffff", messageStyle: "dim", colors: true, }); const record = createLogRecord("info", ["test"], [ "User data: ", { id: 123, name: "John" }, ", status: ", "active", ]); const result = formatter(record); // Should contain proper color reset/reapply around interpolated values // The exact ANSI codes depend on inspect() output, but we should see resets assertStringIncludes(result, "\x1b[0m"); // Reset code should be present assertStringIncludes(result, "\x1b[2m"); // Dim style should be reapplied assertStringIncludes(result, "\x1b[38;2;255;255;255m"); // White color should be reapplied }); test("Multiple styles combination", () => { const formatter = getPrettyFormatter({ levelStyle: ["bold", "underline"], categoryStyle: ["dim", "italic"], messageStyle: ["bold", "strikethrough"], timestampStyle: ["dim", "underline"], timestamp: "time", colors: true, }); const record = createLogRecord("info", ["test"], ["Message"]); const result = formatter(record); // Should contain multiple ANSI style codes combined assertStringIncludes(result, "\x1b[1m"); // bold assertStringIncludes(result, "\x1b[4m"); // underline assertStringIncludes(result, "\x1b[2m"); // dim assertStringIncludes(result, "\x1b[3m"); // italic assertStringIncludes(result, "\x1b[9m"); // strikethrough }); ("Bun" in globalThis ? test.skip : test)( "Word wrapping enabled by default", () => { const formatter = getPrettyFormatter({ colors: false, }); const longMessage = "This is a very long message that would normally exceed the typical console width and should be wrapped when word wrapping is enabled by default."; const record = createLogRecord("info", ["test"], [longMessage]); const result = formatter(record); // Should contain multiple line breaks due to wrapping const lines = result.split("\n"); assert(lines.length > 2); // More than just content + trailing newline due to wrapping // First line should contain the beginning of the message assert(lines[0].includes("This is a very long message")); }, ); test("Word wrapping can be disabled", () => { const formatter = getPrettyFormatter({ colors: false, wordWrap: false, }); const longMessage = "This is a very long message that would normally exceed the typical console width but should not be wrapped when word wrapping is explicitly disabled."; const record = createLogRecord("info", ["test"], [longMessage]); const result = formatter(record); // Should not contain any line breaks in the message (only the trailing newline) const lines = result.split("\n"); assertEquals(lines.length, 2); // One content line + one empty line from trailing newline assertStringIncludes(lines[0], longMessage); }); test("Word wrapping with 80", () => { const formatter = getPrettyFormatter({ wordWrap: 80, colors: false, align: false, }); const longMessage = "This is a very long message that should be wrapped at approximately 80 characters when word wrapping is enabled with the default width setting."; const record = createLogRecord("info", ["test"], [longMessage]); const result = formatter(record); // Should contain multiple lines due to wrapping const lines = result.split("\n"); assert(lines.length > 2); // More than just content + trailing newline // Each content line should be roughly within the wrap width const contentLines = lines.filter((line) => line.length > 0); for (const line of contentLines) { assert(line.length <= 85); // Allow some tolerance for word boundaries } }); test("Word wrapping with custom width", () => { const formatter = getPrettyFormatter({ wordWrap: 40, colors: false, align: false, }); const longMessage = "This is a message that should be wrapped at 40 characters maximum width."; const record = createLogRecord("info", ["test"], [longMessage]); const result = formatter(record); // Should contain multiple lines due to aggressive wrapping const lines = result.split("\n"); assert(lines.length > 2); // Each content line should be within 40 characters const contentLines = lines.filter((line) => line.length > 0); for (const line of contentLines) { assert(line.length <= 45); // Allow some tolerance } }); test("Word wrapping with proper indentation", () => { const formatter = getPrettyFormatter({ wordWrap: 50, colors: false, align: false, }); const longMessage = "This is a long message that should wrap with proper indentation to align with the message column."; const record = createLogRecord("info", ["app"], [longMessage]); const result = formatter(record); const lines = result.split("\n"); const contentLines = lines.filter((line) => line.length > 0); // Should have multiple lines due to wrapping assert(contentLines.length > 1); // First line starts with icon assert(contentLines[0].startsWith("✨ info")); // Check that lines are properly wrapped at word boundaries // With align: false, the format should be "✨ info app message..." // and continuation lines should be properly indented assert( contentLines.length >= 2, "Should have at least 2 lines from wrapping", ); }); test("getPrettyFormatter() with consistent icon spacing", () => { // Test with custom icons of different display widths const formatter = getPrettyFormatter({ icons: { info: "ℹ️", // 2 width emoji warning: "!", // 1 width character error: "🚨🚨", // 4 width (2 emojis) }, colors: false, align: true, wordWrap: 50, }); const longMessage = "This is a long message that should wrap consistently"; const infoRecord = createLogRecord("info", ["test"], [longMessage]); const warningRecord = createLogRecord("warning", ["test"], [longMessage]); const errorRecord = createLogRecord("error", ["test"], [longMessage]); const infoResult = formatter(infoRecord); const warningResult = formatter(warningRecord); const errorResult = formatter(errorRecord); // Split into lines and get continuation lines const infoLines = infoResult.split("\n").filter((line) => line.length > 0); const warningLines = warningResult.split("\n").filter((line) => line.length > 0 ); const errorLines = errorResult.split("\n").filter((line) => line.length > 0); // All should have multiple lines due to wrapping assert(infoLines.length > 1, "Info should wrap to multiple lines"); assert(warningLines.length > 1, "Warning should wrap to multiple lines"); assert(errorLines.length > 1, "Error should wrap to multiple lines"); // Check that continuation lines are indented to the same position // despite different icon widths if ( infoLines.length > 1 && warningLines.length > 1 && errorLines.length > 1 ) { const infoIndent = infoLines[1].search(/\S/); const warningIndent = warningLines[1].search(/\S/); const errorIndent = errorLines[1].search(/\S/); // All continuation lines should start at the same position assertEquals( infoIndent, warningIndent, "Info and warning should have same indentation", ); assertEquals( warningIndent, errorIndent, "Warning and error should have same indentation", ); } }); test("getPrettyFormatter() with automatic width detection", () => { const formatter = getPrettyFormatter({ wordWrap: true, // Auto-detect width colors: false, }); const longMessage = "This is a long message that should wrap at the detected terminal width"; const record = createLogRecord("info", ["test"], [longMessage]); const result = formatter(record); // Should have wrapped at some reasonable width const lines = result.split("\n").filter((line) => line.length > 0); assert(lines.length >= 1, "Should have at least one line"); // If wrapping occurred, continuation lines should be properly indented if (lines.length > 1) { const firstLine = lines[0]; const continuationLine = lines[1]; assert(firstLine.includes("✨"), "First line should contain icon"); assert( continuationLine.startsWith(" "), "Continuation line should be indented", ); } }); test("getPrettyFormatter() with multiline interpolated values", () => { const formatter = getPrettyFormatter({ wordWrap: 60, colors: false, align: true, }); // Create an error that will have multiline output const error = new Error("Test error message"); const record = createLogRecord("error", ["test"], [ "Exception occurred: ", error, ]); const result = formatter(record); const lines = result.split("\n").filter((line) => line.length > 0); // Should have multiple lines due to error stack trace assert( lines.length >= 2, "Should have multiple lines for error with stack trace", ); // First line should contain our message and start of error assert( lines[0].includes("Exception occurred:"), "First line should contain our message", ); assert(lines[0].includes("Error:"), "First line should contain error start"); // Error message might be on first or second line depending on wrapping const fullOutput = result; assert( fullOutput.includes("Test error message"), "Output should contain error message", ); // Check that continuation lines are properly indented (should start with significant whitespace) for (let i = 1; i < lines.length; i++) { const line = lines[i]; const trimmedLine = line.trimStart(); const indentLength = line.length - trimmedLine.length; assert( indentLength >= 10, `Line ${i} should be indented (has ${indentLength} spaces)`, ); } // Should contain stack trace somewhere const stackTraceLine = lines.find((line) => line.trim().startsWith("at ")); assert(stackTraceLine, "Should contain a stack trace line"); const trimmedStackTrace = stackTraceLine.trimStart(); const stackIndentLength = stackTraceLine.length - trimmedStackTrace.length; assert(stackIndentLength >= 10, "Stack trace should be properly indented"); }); test("getPrettyFormatter() with multiline interpolated values (no align)", () => { const formatter = getPrettyFormatter({ wordWrap: 50, colors: false, align: false, }); const error = new Error("Test error"); const record = createLogRecord("error", ["app"], [ "Error: ", error, ]); const result = formatter(record); const lines = result.split("\n").filter((line) => line.length > 0); // Should have multiple lines assert(lines.length >= 2, "Should have multiple lines for error"); // Check that stack trace lines are properly indented relative to the message start const firstLine = lines[0]; assert( firstLine.includes("❌ error app Error:"), "First line should contain prefix and message start", ); if (lines.length > 1) { const stackTraceLine = lines.find((line) => line.trim().startsWith("at ")); if (stackTraceLine) { // Stack trace should be indented to align with message content assert( stackTraceLine.length > stackTraceLine.trimStart().length, "Stack trace line should be indented", ); } } });