diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..9a0c1c8 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +yarn.lock linguist-generated diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..2843801 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,22 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Attach to process", + "type": "node", + "request": "attach", + "port": 9229, + "skipFiles": [ + // Node.js internal core modules + "/**", + + // Ignore all dependencies (optional) + // "${workspaceFolder}/node_modules/**", + // "!${workspaceFolder}/node_modules/@digital-alchemy/**", + ], + } + ] +} diff --git a/package.json b/package.json index d38fdc4..d18e005 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "lint": "eslint src", "prepublishOnly": "yarn build", "test": "./scripts/test.sh", - "upgrade": "ncu -u; yarn" + "upgrade": "yarn up '@digital-alchemy/*'" }, "bugs": { "email": "bugs@digital-alchemy.app", diff --git a/src/extensions/logger.extension.ts b/src/extensions/logger.extension.ts index 7883682..768ca65 100644 --- a/src/extensions/logger.extension.ts +++ b/src/extensions/logger.extension.ts @@ -163,7 +163,7 @@ export async function Logger({ lifecycle, config, internal }: TServiceParams) { .slice(SYMBOL_START, SYMBOL_END) .join("\n"); } - if (["warn", "error", "log"].includes(key)) { + if (["warn", "error", "fatal"].includes(key)) { // eslint-disable-next-line no-console console.error(message); return; @@ -206,21 +206,23 @@ export async function Logger({ lifecycle, config, internal }: TServiceParams) { } const updateShouldLog = () => { - CURRENT_LOG_LEVEL = config.boilerplate.LOG_LEVEL; + if (!is.empty(config.boilerplate.LOG_LEVEL)) { + CURRENT_LOG_LEVEL = config.boilerplate.LOG_LEVEL; + } LOG_LEVELS.forEach((key: TConfigLogLevel) => { shouldILog[key] = LOG_LEVEL_PRIORITY[key] >= LOG_LEVEL_PRIORITY[CURRENT_LOG_LEVEL]; }); }; - // #endregion // #MARK: lifecycle - lifecycle.onPostConfig(updateShouldLog); + lifecycle.onPostConfig(() => internal.boilerplate.logger.updateShouldLog()); internal.boilerplate.configuration.onUpdate( - updateShouldLog, + () => internal.boilerplate.logger.updateShouldLog(), "boilerplate", "LOG_LEVEL", ); + updateShouldLog(); // #MARK: return object return { @@ -228,24 +230,43 @@ export async function Logger({ lifecycle, config, internal }: TServiceParams) { * Create a new logger instance for a given context */ context, + /** * Retrieve a reference to the base logger used to emit from */ getBaseLogger: () => logger, + + /** + * for testing + */ + getShouldILog: () => ({ ...shouldILog }), + + /** + * exposed for testing + */ + prettyFormatMessage, + /** * Modify the base logger * * Note: Extension still handles LOG_LEVEL logic */ setBaseLogger: (base: ILogger) => (logger = base), + /** * Set the enabled/disabled state of the message pretty formatting logic */ setPrettyFormat: (state: boolean) => (prettyFormat = state), + /** * Logger instance of last resort */ systemLogger: context("digital-alchemy:system-logger"), + + /** + * exposed for testing + */ + updateShouldLog, }; } // #endregion diff --git a/src/extensions/wiring.extension.ts b/src/extensions/wiring.extension.ts index a19b1cb..9c78718 100644 --- a/src/extensions/wiring.extension.ts +++ b/src/extensions/wiring.extension.ts @@ -279,7 +279,8 @@ async function WireService( return loaded[service]; } catch (error) { // Init errors at this level are considered blocking / fatal - (logger || console).error("initialization error", error); + // eslint-disable-next-line no-console + console.error("initialization error", error); exit(); } } diff --git a/src/helpers/utilities.helper.ts b/src/helpers/utilities.helper.ts index e823cc5..ebf29d1 100644 --- a/src/helpers/utilities.helper.ts +++ b/src/helpers/utilities.helper.ts @@ -90,23 +90,41 @@ export function sleep(target: number | Date = SECOND): SleepReturn { return out; } -export const ACTIVE_THROTTLE = new Map(); +export const ACTIVE_THROTTLE = new Set(); +export const ACTIVE_DEBOUNCE = new Map(); /** - * > 🦶🔫 - careful about creating memory leaks! + * allow initial call, then block for a period */ export async function throttle( identifier: string, timeout: number, ): Promise { - const current = ACTIVE_THROTTLE.get(identifier); + if (ACTIVE_THROTTLE.has(identifier)) { + return; + } + + ACTIVE_THROTTLE.add(identifier); + + await sleep(timeout); + ACTIVE_THROTTLE.delete(identifier); +} + +/** + * wait for duration after call before allowing next, extends for calls inside window + */ +export async function debounce( + identifier: string, + timeout: number, +): Promise { + const current = ACTIVE_DEBOUNCE.get(identifier); if (current) { current.kill("stop"); } const delay = sleep(timeout); - ACTIVE_THROTTLE.set(identifier, delay); + ACTIVE_DEBOUNCE.set(identifier, delay); await delay; - ACTIVE_THROTTLE.delete(identifier); + ACTIVE_DEBOUNCE.delete(identifier); } export const asyncNoop = async () => await sleep(NONE); diff --git a/src/testing/logger.spec.ts b/src/testing/logger.spec.ts new file mode 100644 index 0000000..ae55c8c --- /dev/null +++ b/src/testing/logger.spec.ts @@ -0,0 +1,168 @@ +/* eslint-disable unicorn/escape-case */ +/* eslint-disable unicorn/no-hex-escape */ +// magic import, do not remove / put anything above +import ".."; + +import { CreateApplication, is } from "../extensions"; +import { + ApplicationDefinition, + BootstrapOptions, + OptionalModuleConfiguration, + ServiceMap, + TServiceParams, +} from "../helpers"; + +const BASIC_BOOT = { + configuration: { + boilerplate: { LOG_LEVEL: "silent" }, + }, +} as BootstrapOptions; + +describe("Logger", () => { + let application: ApplicationDefinition< + ServiceMap, + OptionalModuleConfiguration + >; + + afterEach(async () => { + if (application) { + await application.teardown(); + application = undefined; + } + jest.restoreAllMocks(); + }); + + describe("Configuration Interactions", () => { + it("can log stuff by default", async () => { + application = CreateApplication({ + configurationLoaders: [], + // @ts-expect-error For unit testing + name: "testing", + services: { + Test({ internal }: TServiceParams) { + expect(is.empty(internal.boilerplate.logger.getShouldILog())).toBe( + false, + ); + }, + }, + }); + await application.bootstrap(BASIC_BOOT); + }); + + it("updates onPostConfig", async () => { + application = CreateApplication({ + configurationLoaders: [], + // @ts-expect-error For unit testing + name: "testing", + services: { + Test({ internal, lifecycle }: TServiceParams) { + const spy = jest.spyOn( + internal.boilerplate.logger, + "updateShouldLog", + ); + lifecycle.onReady(() => { + expect(spy).toHaveBeenCalled(); + }); + }, + }, + }); + await application.bootstrap(BASIC_BOOT); + }); + + it("updates when LOG_LEVEL changes", async () => { + application = CreateApplication({ + configurationLoaders: [], + // @ts-expect-error For unit testing + name: "testing", + services: { + Test({ internal }: TServiceParams) { + const spy = jest.spyOn( + internal.boilerplate.logger, + "updateShouldLog", + ); + internal.boilerplate.configuration.set( + "boilerplate", + "LOG_LEVEL", + "warn", + ); + expect(spy).toHaveBeenCalled(); + }, + }, + }); + await application.bootstrap(BASIC_BOOT); + }); + }); + + describe("Pretty Formatting", () => { + let params: TServiceParams; + const getChalk = async () => (await import("chalk")).default; + let chalk: Awaited>; + const frontDash = " - "; + let YELLOW_DASH: string; + let BLUE_TICK: string; + + beforeAll(async () => { + chalk = await getChalk(); + YELLOW_DASH = chalk.yellowBright(frontDash); + BLUE_TICK = chalk.blue(`>`); + application = CreateApplication({ + configurationLoaders: [], + // @ts-expect-error For unit testing + name: "test", + services: { + Test(serviceParams: TServiceParams) { + params = serviceParams; + }, + }, + }); + await application.bootstrap(BASIC_BOOT); + }); + + it("should return the original message if it exceeds MAX_CUTOFF", () => { + const longMessage = "a".repeat(2001); + expect( + params.internal.boilerplate.logger.prettyFormatMessage(longMessage), + ).toBe(longMessage); + }); + + it("should highlight text with # in yellow", () => { + const message = "partA#partB"; + const expected = chalk.yellow("partA#partB"); + expect( + params.internal.boilerplate.logger.prettyFormatMessage(message), + ).toBe(expected); + }); + + it('should highlight ">" in blue between square brackets', () => { + const message = "[A] > [B] > [C]"; + const expected = `${chalk.bold.magenta("A")} ${BLUE_TICK} ${chalk.bold.magenta("B")} ${BLUE_TICK} ${chalk.bold.magenta("C")}`; + expect( + params.internal.boilerplate.logger.prettyFormatMessage(message), + ).toBe(expected); + }); + + it("should strip brackets and highlight text in magenta", () => { + const message = "[Text]"; + const expected = chalk.bold.magenta("Text"); + expect( + params.internal.boilerplate.logger.prettyFormatMessage(message), + ).toBe(expected); + }); + + it("should strip braces and highlight text in gray", () => { + const message = "{Text}"; + const expected = chalk.bold.gray("Text"); + expect( + params.internal.boilerplate.logger.prettyFormatMessage(message), + ).toBe(expected); + }); + + it("should highlight dash at the start of the message in yellow", () => { + const message = " - Text"; + const expected = `${YELLOW_DASH}Text`; + expect( + params.internal.boilerplate.logger.prettyFormatMessage(message), + ).toBe(expected); + }); + }); +}); diff --git a/src/testing/utilities.spec.ts b/src/testing/utilities.spec.ts index eca1e0e..d80abdd 100644 --- a/src/testing/utilities.spec.ts +++ b/src/testing/utilities.spec.ts @@ -1,4 +1,4 @@ -import { ACTIVE_THROTTLE, sleep, throttle } from "../helpers"; +import { ACTIVE_THROTTLE, debounce, sleep } from "../helpers"; describe("utilities", () => { describe("sleep", () => { @@ -49,54 +49,54 @@ describe("utilities", () => { }); }); - describe("throttle", () => { + describe("debounce", () => { it("should delay execution by the specified timeout", async () => { const identifier = "test-id"; const timeout = 10; const start = Date.now(); - await throttle(identifier, timeout); + await debounce(identifier, timeout); const end = Date.now(); expect(end - start).toBeGreaterThanOrEqual(timeout); }); - it("should cancel the previous throttle if called with the same identifier", async () => { + it("should cancel the previous debounce if called with the same identifier", async () => { const identifier = "test-id"; const timeout1 = 20; const timeout2 = 10; const start = Date.now(); - throttle(identifier, timeout1); - await throttle(identifier, timeout2); + debounce(identifier, timeout1); + await debounce(identifier, timeout2); const end = Date.now(); expect(end - start).toBeLessThan(timeout1); expect(end - start).toBeGreaterThanOrEqual(timeout2); }); - it("should allow multiple identifiers to be throttled independently", async () => { + it("should allow multiple identifiers to be debounced independently", async () => { const identifier1 = "test-id-1"; const identifier2 = "test-id-2"; const timeout1 = 10; const timeout2 = 10; const start1 = Date.now(); - throttle(identifier1, timeout1); + debounce(identifier1, timeout1); const start2 = Date.now(); - await throttle(identifier2, timeout2); + await debounce(identifier2, timeout2); const end1 = Date.now(); expect(end1 - start1).toBeGreaterThanOrEqual(timeout1); expect(end1 - start2).toBeGreaterThanOrEqual(timeout2); }); - it("should clear the throttle once the timeout has passed", async () => { + it("should clear the debounce once the timeout has passed", async () => { const identifier = "test-id"; const timeout = 100; - await throttle(identifier, timeout); + await debounce(identifier, timeout); expect(ACTIVE_THROTTLE.has(identifier)).toBe(false); });