diff --git a/cspell.config.yaml b/cspell.config.yaml index 56d38a7..74f5d07 100644 --- a/cspell.config.yaml +++ b/cspell.config.yaml @@ -22,6 +22,7 @@ words: - darkcyan - darkgoldenrod - darkgray + - unmock - darkgreen - darkgrey - darkkhaki diff --git a/package.json b/package.json index eb4fd48..16d03d2 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "repository": { "url": "git+https://github.com/Digital-Alchemy-TS/core" }, - "version": "24.8.2", + "version": "24.8.3", "author": { "url": "https://github.com/zoe-codez", "name": "Zoe Codez" @@ -103,6 +103,9 @@ "text", "cobertura" ], + "coveragePathIgnorePatterns": [ + "src/testing/" + ], "preset": "ts-jest", "testEnvironment": "node", "moduleFileExtensions": [ diff --git a/src/extensions/wiring.extension.ts b/src/extensions/wiring.extension.ts index 9c78718..6edb28b 100644 --- a/src/extensions/wiring.extension.ts +++ b/src/extensions/wiring.extension.ts @@ -368,7 +368,7 @@ async function Bootstrap< // * Wire in various shutdown events processEvents.forEach((callback, event) => { process.on(event, callback); - logger.trace({ event, name: Bootstrap }, "shutdown event"); + logger.trace({ event, name: Bootstrap }, "register shutdown event"); }); // * Add in libraries @@ -402,6 +402,8 @@ async function Bootstrap< CONSTRUCT[i.name] = `${Date.now() - start}ms`; }); + logger.trace({ name: Bootstrap }, `library wiring complete`); + // * Finally the application if (options.bootLibrariesFirst) { logger.warn({ name: Bootstrap }, `bootLibrariesFirst`); diff --git a/src/helpers/config-environment-loader.helper.ts b/src/helpers/config-environment-loader.helper.ts index 81e7900..782dcbb 100644 --- a/src/helpers/config-environment-loader.helper.ts +++ b/src/helpers/config-environment-loader.helper.ts @@ -1,8 +1,5 @@ -import { config } from "dotenv"; -import { existsSync } from "fs"; import minimist from "minimist"; -import { join } from "path"; -import { argv, cwd, env } from "process"; +import { argv, env } from "process"; import { is, ServiceMap } from ".."; import { @@ -10,82 +7,67 @@ import { ConfigLoaderParams, ConfigLoaderReturn, findKey, + iSearchKey, + loadDotenv, ModuleConfiguration, + parseConfig, } from "./config.helper"; export async function ConfigLoaderEnvironment< S extends ServiceMap = ServiceMap, C extends ModuleConfiguration = ModuleConfiguration, >({ configs, internal, logger }: ConfigLoaderParams): ConfigLoaderReturn { - const { envFile } = internal.boot.options; - if (!is.empty(envFile) || existsSync(join(cwd(), ".env"))) { - const file = envFile ?? ".env"; - logger.trace({ file }, `loading env file`); - config({ override: true, path: envFile ?? ".env" }); - } - const environmentKeys = Object.keys(env); const CLI_SWITCHES = minimist(argv); const switchKeys = Object.keys(CLI_SWITCHES); - + const environmentKeys = Object.keys(env); const out: Partial = {}; + + // * merge dotenv into local vars + // accounts for `--env-file` switches, and whatever is passed in via bootstrap + loadDotenv(internal, CLI_SWITCHES, logger); + + // * go through all module configs.forEach((configuration, project) => { const cleanedProject = project.replaceAll("-", "_"); + // * run through each config for module Object.keys(configuration).forEach((key) => { + // > things to search for + // - MODULE_NAME_CONFIG_KEY (module + key, ex: app_NODE_ENV) + // - CONFIG_KEY (only key, ex: NODE_ENV) const noAppPath = `${cleanedProject}_${key}`; const search = [noAppPath, key]; const configPath = `${project}.${key}`; - // #MARK: cli switches - // Find an applicable switch + // * (preferred) Find an applicable cli switch const flag = findKey(search, switchKeys); if (flag) { - const formattedFlag = switchKeys.find((key) => - search.some((line) => - key.match( - new RegExp( - `^${line.replaceAll(new RegExp("[-_]", "gi"), "[-_]?")}$`, - "gi", - ), - ), - ), + const formattedFlag = iSearchKey(flag, switchKeys); + internal.utils.object.set( + out, + configPath, + parseConfig(configuration[key], CLI_SWITCHES[formattedFlag]), + ); + logger.trace( + { + flag: formattedFlag, + name: ConfigLoaderEnvironment, + path: configPath, + }, + `load config from [cli switch]`, ); - if (is.string(formattedFlag)) { - internal.utils.object.set( - out, - configPath, - CLI_SWITCHES[formattedFlag], - ); - logger.trace( - { - flag: formattedFlag, - name: ConfigLoaderEnvironment, - path: configPath, - }, - `load config from [cli switch]`, - ); - } return; } - // #MARK: environment variables - // Find an environment variable + // * (fallback) Find an environment variable const environment = findKey(search, environmentKeys); - if (is.empty(environment)) { - return; - } - const environmentName = environmentKeys.find((key) => - search.some((line) => - key.match( - new RegExp( - `^${line.replaceAll(new RegExp("[-_]", "gi"), "[-_]?")}$`, - "gi", - ), - ), - ), - ); - if (is.string(environmentName)) { - internal.utils.object.set(out, configPath, env[environmentName]); + if (!is.empty(environment)) { + const environmentName = iSearchKey(environment, environmentKeys); + internal.utils.object.set( + out, + configPath, + parseConfig(configuration[key], env[environmentName]), + ); logger.trace( { name: ConfigLoaderEnvironment, diff --git a/src/helpers/config.helper.ts b/src/helpers/config.helper.ts index 35ac360..72297e4 100644 --- a/src/helpers/config.helper.ts +++ b/src/helpers/config.helper.ts @@ -1,3 +1,9 @@ +import { config } from "dotenv"; +import { existsSync } from "fs"; +import { ParsedArgs } from "minimist"; +import { isAbsolute, join, normalize } from "path"; +import { cwd } from "process"; + import { ILogger, InternalDefinition, is } from ".."; import { ApplicationDefinition, ServiceMap } from "./wiring.helper"; @@ -12,10 +18,11 @@ export type ProjectConfigTypes = export type AnyConfig = | StringConfig | BooleanConfig - | InternalConfig + | InternalConfig | NumberConfig | RecordConfig | StringArrayConfig; + export interface BaseConfig { /** * If no other values are provided, what value should be injected? @@ -57,7 +64,7 @@ export interface BooleanConfig extends BaseConfig { * * TODO: JSON schema magic for validation / maybe config builder help */ -export type InternalConfig = BaseConfig & { +export type InternalConfig = BaseConfig & { default: VALUE; type: "internal"; }; @@ -177,3 +184,87 @@ export function findKey(source: T[], find: T[]) { }) ); } + +export function iSearchKey(target: string, source: string[]) { + return source.find((key) => + key.match( + new RegExp( + `^${target.replaceAll(new RegExp("[-_]", "gi"), "[-_]?")}$`, + "gi", + ), + ), + ); +} + +/** + * priorities: + * - --env-file + * - bootstrap envFile + * - cwd/.env (default file) + */ +export function loadDotenv( + internal: InternalDefinition, + CLI_SWITCHES: ParsedArgs, + logger: ILogger, +) { + let { envFile } = internal.boot.options; + const switchKeys = Object.keys(CLI_SWITCHES); + const searched = iSearchKey("env-file", switchKeys); + + // --env-file > bootstrap + if (!is.empty(CLI_SWITCHES[searched])) { + envFile = CLI_SWITCHES[searched]; + } + + let file: string; + + // * was provided an --env-file or something via boot + if (!is.empty(envFile)) { + const checkFile = isAbsolute(envFile) + ? normalize(envFile) + : join(cwd(), envFile); + if (existsSync(checkFile)) { + file = checkFile; + } else { + logger.warn( + { checkFile, envFile, name: loadDotenv }, + "invalid target for dotenv file", + ); + } + } + + // * attempt default file + if (is.empty(file)) { + const defaultFile = join(cwd(), ".env"); + if (existsSync(defaultFile)) { + file = defaultFile; + } else { + logger.debug({ name: loadDotenv }, "no .env found"); + } + } + + // ? each of the steps above verified the path as valid + if (!is.empty(file)) { + logger.trace({ file, name: loadDotenv }, `loading env file`); + config({ override: true, path: file }); + } +} + +export function parseConfig(config: AnyConfig, value: string) { + switch (config.type) { + case "string": { + return value; + } + case "number": { + return Number(value); + } + case "string[]": + case "record": + case "internal": { + return JSON.parse(value); + } + case "boolean": { + return ["y", "true"].includes(value.toLowerCase()); + } + } +} diff --git a/src/helpers/wiring.helper.ts b/src/helpers/wiring.helper.ts index 4513cf0..1c8382a 100644 --- a/src/helpers/wiring.helper.ts +++ b/src/helpers/wiring.helper.ts @@ -347,7 +347,7 @@ export type BootstrapOptions = { * * Default: `.env` */ - envFile?: string | string[]; + envFile?: string; }; export const WIRE_PROJECT = Symbol.for("wire-project"); diff --git a/src/testing/configuration.spec.ts b/src/testing/configuration.spec.ts index 4d82b7f..e8e51d8 100644 --- a/src/testing/configuration.spec.ts +++ b/src/testing/configuration.spec.ts @@ -1,4 +1,9 @@ -import { env } from "process"; +import { faker } from "@faker-js/faker"; +import dotenv from "dotenv"; +import fs from "fs"; +import { ParsedArgs } from "minimist"; +import { join } from "path"; +import { cwd, env } from "process"; import { ApplicationDefinition, @@ -6,12 +11,18 @@ import { ConfigLoaderFile, CreateApplication, CreateLibrary, + ILogger, INITIALIZE, + InternalConfig, + InternalDefinition, + loadDotenv, OptionalModuleConfiguration, + parseConfig, ServiceMap, TServiceParams, } from ".."; import { ConfigTesting } from "./config-testing.extension"; +import { createMockLogger } from "./helpers"; import { BASIC_BOOT, ServiceTest } from "./testing.helper"; describe("Configuration", () => { @@ -160,6 +171,7 @@ describe("Configuration", () => { }); }); + // #MARK: Environment describe("Environment", () => { afterEach(() => { delete env["current_weather"]; @@ -265,6 +277,7 @@ describe("Configuration", () => { }); }); + // #MARK: CLI Switches describe("CLI Switch", () => { beforeEach(() => { process.argv = ["/path/to/node", "/path/to/main"]; @@ -394,6 +407,7 @@ describe("Configuration", () => { }); }); + // #MARK: File describe("File", () => { it("resolves files in the correct order", async () => { let testFiles: ReturnType; @@ -446,4 +460,190 @@ describe("Configuration", () => { }); }); // #endregion + + describe("Support functions", () => { + // #MARK: parseConfig + describe("parseConfig", () => { + it("string config (no enum)", () => { + const value = faker.string.alphanumeric(); + const output = parseConfig({ type: "string" }, value); + expect(output).toBe(value); + }); + + it("string config (with enum)", () => { + const value = faker.string.alphanumeric(); + // no logic related to enum currently, might be future logic + const output = parseConfig( + { enum: ["hello", "world"], type: "string" }, + value, + ); + expect(output).toBe(value); + }); + + it("number config", () => { + const value = faker.string.numeric(); + const output = parseConfig({ type: "number" }, value); + expect(output).toBe(Number(value)); + }); + + it("string[] config", () => { + const value = JSON.stringify(["hello", "world"]); + const output = parseConfig({ type: "string[]" }, value); + expect(output).toEqual(["hello", "world"]); + }); + + it("record config", () => { + const value = JSON.stringify({ key: "value" }); + const output = parseConfig({ type: "record" }, value); + expect(output).toEqual({ key: "value" }); + }); + + it("internal config", () => { + const value = JSON.stringify({ internalKey: "internalValue" }); + const output = parseConfig( + { type: "internal" } as InternalConfig, + value, + ); + expect(output).toEqual({ internalKey: "internalValue" }); + }); + + it("boolean config (true case)", () => { + const value = "true"; + const output = parseConfig({ type: "boolean" }, value); + expect(output).toBe(true); + }); + + it("boolean config (false case)", () => { + const value = "false"; + const output = parseConfig({ type: "boolean" }, value); + expect(output).toBe(false); + }); + + it("boolean config (yes case)", () => { + const value = "y"; + const output = parseConfig({ type: "boolean" }, value); + expect(output).toBe(true); + }); + + it("boolean config (no case)", () => { + const value = "n"; + const output = parseConfig({ type: "boolean" }, value); + expect(output).toBe(false); + }); + }); + + describe("loadDotenv", () => { + let mockInternal: InternalDefinition; + let logger: ILogger; + + beforeEach(() => { + mockInternal = { + boot: { + options: { + envFile: "", + }, + }, + } as InternalDefinition; + logger = createMockLogger(); + }); + + it("should load env file from CLI switch if provided", () => { + jest.spyOn(fs, "existsSync").mockReturnValue(true); + const config = jest + .spyOn(dotenv, "config") + // @ts-expect-error idc + .mockReturnValue(() => undefined); + const CLI_SWITCHES = { + _: [], + "env-file": "path/to/env-file", + } as ParsedArgs; + + loadDotenv(mockInternal, CLI_SWITCHES, logger); + + expect(config).toHaveBeenCalledWith({ + override: true, + path: join(cwd(), "path/to/env-file"), + }); + }); + + it("should load env file from bootstrap if CLI switch is not provided", () => { + const config = jest + .spyOn(dotenv, "config") + // @ts-expect-error idc + .mockReturnValue(() => undefined); + jest.spyOn(fs, "existsSync").mockReturnValue(true); + mockInternal.boot.options.envFile = "path/to/bootstrap-env-file"; + + const CLI_SWITCHES = { + _: [], + "env-file": "", + } as ParsedArgs; + + loadDotenv(mockInternal, CLI_SWITCHES, logger); + + expect(config).toHaveBeenCalledWith({ + override: true, + path: join(cwd(), "path/to/bootstrap-env-file"), + }); + }); + + it("should load default .env file if no CLI switch or bootstrap envFile is provided", () => { + mockInternal.boot.options.envFile = ""; + jest.spyOn(fs, "existsSync").mockReturnValue(true); + + const config = jest + .spyOn(dotenv, "config") + // @ts-expect-error idc + .mockReturnValue(() => undefined); + + const CLI_SWITCHES = { + _: [], + "env-file": "", + } as ParsedArgs; + + loadDotenv(mockInternal, CLI_SWITCHES, logger); + + expect(config).toHaveBeenCalledWith({ + override: true, + path: join(cwd(), ".env"), + }); + }); + + it("should log a warning if the specified envFile does not exist", () => { + mockInternal.boot.options.envFile = "nonexistent-file"; + + const CLI_SWITCHES = { + _: [], + "env-file": "", + } as ParsedArgs; + jest.spyOn(fs, "existsSync").mockReturnValue(false); + + const config = jest + .spyOn(dotenv, "config") + // @ts-expect-error idc + .mockReturnValue(() => undefined); + + loadDotenv(mockInternal, CLI_SWITCHES, logger); + expect(config).not.toHaveBeenCalled(); + }); + + it("should do nothing if no valid envFile or .env file exists", () => { + mockInternal.boot.options.envFile = ""; + + const CLI_SWITCHES = { + _: [], + "env-file": "", + } as ParsedArgs; + jest.spyOn(fs, "existsSync").mockReturnValue(false); + + const config = jest + .spyOn(dotenv, "config") + // @ts-expect-error idc + .mockReturnValue(() => undefined); + + loadDotenv(mockInternal, CLI_SWITCHES, logger); + expect(config).not.toHaveBeenCalled(); + }); + }); + }); }); diff --git a/src/testing/helpers/index.ts b/src/testing/helpers/index.ts new file mode 100644 index 0000000..b8b12c3 --- /dev/null +++ b/src/testing/helpers/index.ts @@ -0,0 +1 @@ +export * from "./mock-logger"; diff --git a/src/testing/helpers/mock-logger.ts b/src/testing/helpers/mock-logger.ts new file mode 100644 index 0000000..a7be97d --- /dev/null +++ b/src/testing/helpers/mock-logger.ts @@ -0,0 +1,12 @@ +import { ILogger } from "../../extensions"; + +export function createMockLogger(): ILogger { + return { + debug: jest.fn(), + error: jest.fn(), + fatal: jest.fn(), + info: jest.fn(), + trace: jest.fn(), + warn: jest.fn(), + }; +} diff --git a/src/testing/helpers/test-main.ts b/src/testing/helpers/test-main.ts new file mode 100644 index 0000000..b1c9d3c --- /dev/null +++ b/src/testing/helpers/test-main.ts @@ -0,0 +1,44 @@ +// magic import, don't touch +import "../.."; + +import { CreateApplication } from "../../extensions"; +import { InternalConfig, TServiceParams } from "../../helpers"; + +type InternalData = { + foo: string; + bar: string; + test: boolean; +}; + +export const HOME_AUTOMATION = CreateApplication({ + configuration: { + TEST_OBJECT_CONFIG: { + default: { + bar: "no", + foo: "yes", + test: false, + }, + type: "internal", + } satisfies InternalConfig, + }, + // @ts-expect-error test + name: "test", + services: { + Test({ logger, config, lifecycle }: TServiceParams) { + lifecycle.onReady(() => { + // @ts-expect-error test + logger.warn({ config: config.test }); + }); + }, + }, +}); + +setImmediate(async () => { + await HOME_AUTOMATION.bootstrap({ + // bootLibrariesFirst: true, + configuration: { + // + }, + // showExtraBootStats: true, + }); +});