diff --git a/src/utils/logging/process-logger.ts b/src/utils/logging/process-logger.ts new file mode 100644 index 0000000..d5f134e --- /dev/null +++ b/src/utils/logging/process-logger.ts @@ -0,0 +1,131 @@ +import { DEFAULT_NEWLINE } from "@/utils/environment"; +import { Logger } from "./logger"; + +/** + * Represents a delegate type that consumes log messages. + */ +interface LogConsumer { + /** + * Processes a given log message. + * + * @param message - A log message to process. + */ + (message: string): void; +} + +/** + * The `process` object provides information about, and control over, the current Node.js process. + */ +interface Process { + /** + * A stream connected to `stdout` (fd `1`). + */ + stdout?: { + /** + * Sends data on the socket. + */ + write: LogConsumer; + }; +} + +/** + * A logger implementation that dumps formatted log messages to `stdout`. + * + * Compatible with GitHub Actions. + * + * @remarks + * + * https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#setting-a-debug-message + */ +export class ProcessLogger implements Logger { + /** + * A function to consume produced log messages. + */ + private readonly _logConsumer: LogConsumer; + + /** + * The newline sequence to use when writing logs. + */ + private readonly _newline: string; + + /** + * Constructs a new {@link ProcessLogger} instance. + * + * @param process - The process this logger is attached to. Defaults to `globalThis.process`. + * @param newline - The newline sequence to use when writing logs. Defaults to `os.EOL`. + */ + constructor(process?: Process, newline?: string); + + /** + * Constructs a new {@link ProcessLogger} instance. + * + * @param logConsumer - The function to consume log messages. + * @param newline - The newline sequence to use when writing logs. Defaults to `os.EOL`. + */ + constructor(logConsumer: LogConsumer, newline?: string); + + /** + * Constructs a new {@link ProcessLogger} instance. + * + * @param processOrLogConsumer - A process this logger is attached to, or a function to consume log messages. + * @param newline - The newline sequence to use when writing logs. Defaults to `os.EOL`. + */ + constructor(processOrLogConsumer: Process | LogConsumer, newline?: string) { + if (typeof processOrLogConsumer === "function") { + this._logConsumer = processOrLogConsumer; + } else { + const process = processOrLogConsumer ?? globalThis.process; + this._logConsumer = + typeof process.stdout?.write === "function" + ? msg => process.stdout.write(msg) + : (() => {}); + } + this._newline = newline ?? DEFAULT_NEWLINE; + } + + /** + * @inheritdoc + */ + fatal(message: string | Error): void { + this.error(message); + } + + /** + * @inheritdoc + */ + error(message: string | Error): void { + this.log(message, "error") + } + + /** + * @inheritdoc + */ + warn(message: string | Error): void { + this.log(message, "warning") + } + + /** + * @inheritdoc + */ + info(message: string | Error): void { + this.log(message); + } + + /** + * @inheritdoc + */ + debug(message: string | Error): void { + this.log(message, "debug"); + } + + /** + * Logs a message with an optional log level. + * + * @param message - The message to log. + * @param level - Optional log level string. + */ + private log(message: string | Error, level?: string): void { + const cmd = level ? `::${level}::` : ""; + this._logConsumer(`${cmd}${message}${this._newline}`); + } +} diff --git a/tests/unit/utils/logging/process-logger.spec.ts b/tests/unit/utils/logging/process-logger.spec.ts new file mode 100644 index 0000000..4745232 --- /dev/null +++ b/tests/unit/utils/logging/process-logger.spec.ts @@ -0,0 +1,129 @@ +import { ProcessLogger } from "@/utils/logging/process-logger"; + +interface MockProcess { + stdout: { + write: jest.Mock; + }; +} + +function createMockProcess(): MockProcess { + return { + stdout: { + write: jest.fn(), + }, + }; +} + +describe("ProcessLogger", () => { + describe("constructor", () => { + test("constructs a new instance with the provided process", () => { + const process = createMockProcess(); + const logger = new ProcessLogger(process, "\n"); + + logger.info("Info"); + + expect(process.stdout.write).toHaveBeenCalledTimes(1); + expect(process.stdout.write).toHaveBeenCalledWith("Info\n"); + }); + + test("constructor uses provided newline sequence", () => { + const process = createMockProcess(); + const logger = new ProcessLogger(process, "\n\n"); + + logger.info("Info"); + + expect(process.stdout.write).toHaveBeenCalledTimes(1); + expect(process.stdout.write).toHaveBeenCalledWith("Info\n\n"); + }); + + test("constructor uses provided log consumer", () => { + const logConsumer = jest.fn(); + const logger = new ProcessLogger(logConsumer, "\n"); + + logger.info("Info"); + + expect(logConsumer).toHaveBeenCalledTimes(1); + expect(logConsumer).toHaveBeenCalledWith("Info\n"); + }); + + test("constructor uses provided log consumer and newline sequence", () => { + const logConsumer = jest.fn(); + const logger = new ProcessLogger(logConsumer, "\n\n"); + + logger.info("Info"); + + expect(logConsumer).toHaveBeenCalledTimes(1); + expect(logConsumer).toHaveBeenCalledWith("Info\n\n"); + }); + }); + + describe("fatal", () => { + test("redirects the call to process.stdout.write", () => { + const process = createMockProcess(); + const logger = new ProcessLogger(process, "\n"); + + logger.fatal("Fatal Error"); + logger.fatal(new Error("Fatal Error")); + + expect(process.stdout.write).toHaveBeenCalledTimes(2); + expect(process.stdout.write).toHaveBeenNthCalledWith(1, "::error::Fatal Error\n"); + expect(process.stdout.write).toHaveBeenNthCalledWith(2, "::error::Error: Fatal Error\n"); + }); + }); + + describe("error", () => { + test("redirects the call to process.stdout.write", () => { + const process = createMockProcess(); + const logger = new ProcessLogger(process, "\n"); + + logger.error("Error"); + logger.error(new Error("Error")); + + expect(process.stdout.write).toHaveBeenCalledTimes(2); + expect(process.stdout.write).toHaveBeenNthCalledWith(1, "::error::Error\n"); + expect(process.stdout.write).toHaveBeenNthCalledWith(2, "::error::Error: Error\n"); + }); + }); + + describe("warn", () => { + test("redirects the call to process.stdout.write", () => { + const process = createMockProcess(); + const logger = new ProcessLogger(process, "\n"); + + logger.warn("Warning"); + logger.warn(new Error("Warning")); + + expect(process.stdout.write).toHaveBeenCalledTimes(2); + expect(process.stdout.write).toHaveBeenNthCalledWith(1, "::warning::Warning\n"); + expect(process.stdout.write).toHaveBeenNthCalledWith(2, "::warning::Error: Warning\n"); + }); + }); + + describe("info", () => { + test("redirects the call to process.stdout.write", () => { + const process = createMockProcess(); + const logger = new ProcessLogger(process, "\n"); + + logger.info("Info"); + logger.info(new Error("Info")); + + expect(process.stdout.write).toHaveBeenCalledTimes(2); + expect(process.stdout.write).toHaveBeenNthCalledWith(1, "Info\n"); + expect(process.stdout.write).toHaveBeenNthCalledWith(2, "Error: Info\n"); + }); + }); + + describe("debug", () => { + test("redirects the call to process.stdout.write", () => { + const process = createMockProcess(); + const logger = new ProcessLogger(process, "\n"); + + logger.debug("Debug Info"); + logger.debug(new Error("Debug Info")); + + expect(process.stdout.write).toHaveBeenCalledTimes(2); + expect(process.stdout.write).toHaveBeenNthCalledWith(1, "::debug::Debug Info\n"); + expect(process.stdout.write).toHaveBeenNthCalledWith(2, "::debug::Error: Debug Info\n"); + }); + }); +});