From 24243170752e219deedbe8010d585147b9f454ee Mon Sep 17 00:00:00 2001 From: Zoe Date: Sat, 19 Oct 2024 17:43:20 -0500 Subject: [PATCH] Misc QOL (#97) --- package.json | 2 +- .../config-environment-loader.helper.ts | 30 +- src/helpers/config.helper.ts | 24 +- src/helpers/cron.helper.ts | 9 +- src/helpers/module.helper.ts | 26 +- src/helpers/wiring.helper.ts | 33 ++- src/services/configuration.extension.ts | 171 +++++++----- src/services/internal.extension.ts | 6 + src/services/scheduler.extension.ts | 24 +- src/services/wiring.extension.ts | 39 ++- src/testing/test-module.ts | 29 +- testing/als.spec.ts | 49 +++- testing/configuration.spec.ts | 263 +++++++++++++++--- testing/scheduler.spec.ts | 6 +- testing/testing.spec.ts | 24 +- testing/wiring.spec.ts | 18 ++ 16 files changed, 579 insertions(+), 174 deletions(-) diff --git a/package.json b/package.json index 14e8612..9e94fdd 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "repository": { "url": "git+https://github.com/Digital-Alchemy-TS/core" }, - "version": "24.10.4", + "version": "24.10.5", "author": { "url": "https://github.com/zoe-codez", "name": "Zoe Codez" diff --git a/src/helpers/config-environment-loader.helper.ts b/src/helpers/config-environment-loader.helper.ts index 7dce802..f6ce087 100644 --- a/src/helpers/config-environment-loader.helper.ts +++ b/src/helpers/config-environment-loader.helper.ts @@ -1,3 +1,4 @@ +/* eslint-disable unicorn/prevent-abbreviations */ import minimist from "minimist"; import { env } from "process"; @@ -6,6 +7,7 @@ import { AbstractConfig, ConfigLoaderParams, ConfigLoaderReturn, + DataTypes, findKey, iSearchKey, loadDotenv, @@ -20,6 +22,13 @@ export async function ConfigLoaderEnvironment< const CLI_SWITCHES = minimist(process.argv); const switchKeys = Object.keys(CLI_SWITCHES); const out: Partial = {}; + const canEnvironment = internal.boot.options?.configSources?.env ?? true; + const canArgv = internal.boot.options?.configSources?.argv ?? true; + + const shouldArgv = (source: DataTypes[]) => + canArgv && (!is.array(source) || source.includes("argv")); + const shouldEnv = (source: DataTypes[]) => + canEnvironment && (!is.array(source) || source.includes("env")); // * merge dotenv into local vars // accounts for `--env-file` switches, and whatever is passed in via bootstrap @@ -32,6 +41,7 @@ export async function ConfigLoaderEnvironment< // * run through each config for module Object.keys(configuration).forEach(key => { + const { source } = configs.get(project)[key]; // > things to search for // - MODULE_NAME_CONFIG_KEY (module + key, ex: app_NODE_ENV) // - CONFIG_KEY (only key, ex: NODE_ENV) @@ -41,14 +51,14 @@ export async function ConfigLoaderEnvironment< // * (preferred) Find an applicable cli switch const flag = findKey(search, switchKeys); - if (flag) { + if (flag && shouldArgv(source)) { const formattedFlag = iSearchKey(flag, switchKeys); internal.utils.object.set( out, configPath, parseConfig(configuration[key], CLI_SWITCHES[formattedFlag]), ); - logger.trace( + logger.debug( { flag: formattedFlag, name: ConfigLoaderEnvironment, @@ -61,14 +71,16 @@ export async function ConfigLoaderEnvironment< // * (fallback) Find an environment variable const environment = findKey(search, environmentKeys); - if (!is.empty(environment)) { + if (!is.empty(environment) && shouldEnv(source)) { const environmentName = iSearchKey(environment, environmentKeys); - internal.utils.object.set( - out, - configPath, - parseConfig(configuration[key], env[environmentName]), - ); - logger.trace( + if (!is.string(env[environmentName]) || !is.empty(env[environmentName])) { + internal.utils.object.set( + out, + configPath, + parseConfig(configuration[key], env[environmentName]), + ); + } + logger.debug( { name: ConfigLoaderEnvironment, path: configPath, diff --git a/src/helpers/config.helper.ts b/src/helpers/config.helper.ts index e1ece07..5dd6b1a 100644 --- a/src/helpers/config.helper.ts +++ b/src/helpers/config.helper.ts @@ -20,6 +20,21 @@ import { TInjectedConfig, } from "./wiring.helper"; +export interface ConfigLoaderSource { + /** + * will be checked for values unless `sources` is defined without argv + */ + argv: true; + /** + * will be checked for values unless `env` is defined without argv + */ + env: true; + /** + * will be checked for values unless `file` is defined without argv + */ + file: true; +} + export type CodeConfigDefinition = Record; export type ProjectConfigTypes = | "string" @@ -52,6 +67,11 @@ export interface BaseConfig { required?: boolean; type: ProjectConfigTypes; + + /** + * Where this can be loaded from + */ + source?: (keyof ConfigLoaderSource)[]; } export type KnownConfigs = Map; export interface StringConfig extends BaseConfig { @@ -272,9 +292,10 @@ export type DigitalAlchemyConfiguration = { [INITIALIZE]: ( application: ApplicationDefinition, ) => Promise; - [INJECTED_DEFINITIONS]: () => TInjectedConfig; + [INJECTED_DEFINITIONS]: TInjectedConfig; [LOAD_PROJECT]: (library: string, definitions: CodeConfigDefinition) => void; getDefinitions: () => KnownConfigs; + registerLoader: (loader: ConfigLoader, type: DataTypes) => void; merge: (incoming: Partial) => PartialConfiguration; /** * Not a replacement for `onPostConfig` @@ -310,3 +331,4 @@ export type OnConfigUpdateCallback< Project extends keyof TInjectedConfig, Property extends keyof TInjectedConfig[Project], > = (project: Project, property: Property) => TBlackHole; +export type DataTypes = keyof ConfigLoaderSource; diff --git a/src/helpers/cron.helper.ts b/src/helpers/cron.helper.ts index df92e04..41ffc1b 100644 --- a/src/helpers/cron.helper.ts +++ b/src/helpers/cron.helper.ts @@ -109,11 +109,16 @@ export type SchedulerSlidingOptions = SchedulerOptions & { next: () => Dayjs | string | number | Date | undefined; }; -export type ScheduleRemove = () => TBlackHole; +export type ScheduleRemove = (() => TBlackHole) & { remove: () => TBlackHole }; + +export const makeRemover = (remover: () => TBlackHole): ScheduleRemove => { + const cast = remover as ScheduleRemove; + cast.remove = remover; + return cast; +}; export type DigitalAlchemyScheduler = { cron: (options: SchedulerCronOptions) => ScheduleRemove; - interval: (options: SchedulerIntervalOptions) => ScheduleRemove; sliding: (options: SchedulerSlidingOptions) => ScheduleRemove; setInterval: (callback: () => TBlackHole, ms: number) => ScheduleRemove; setTimeout: (callback: () => TBlackHole, ms: number) => ScheduleRemove; diff --git a/src/helpers/module.helper.ts b/src/helpers/module.helper.ts index 1b43457..c53d03e 100644 --- a/src/helpers/module.helper.ts +++ b/src/helpers/module.helper.ts @@ -21,6 +21,7 @@ export type ExtendOptions = { keepConfiguration?: boolean; }; +// #MARK: DigitalAlchemyModule export type DigitalAlchemyModule = { services: S; configuration: C; @@ -31,6 +32,7 @@ export type DigitalAlchemyModule ModuleExtension; }; +// #MARK: CreateModuleOptions export type CreateModuleOptions = { services: S; configuration: C; @@ -43,6 +45,7 @@ export type CreateModuleOptions = { appendLibrary: (library: TLibrary) => ModuleExtension; appendService: (name: string, target: ServiceFunction) => ModuleExtension; @@ -93,6 +96,7 @@ export type ModuleExtension iTestRunner; }; +// #MARK: createModule export function createModule( options: CreateModuleOptions, ): DigitalAlchemyModule { @@ -164,7 +168,7 @@ export function createModule { const depends = {} as Record; - workingModule.depends.forEach(i => (depends[i.name] = i)); + workingModule.depends?.forEach(i => (depends[i.name] = i)); appendLibrary.forEach((value, key) => (depends[key] = value)); return CreateApplication({ @@ -173,13 +177,13 @@ export function createModule { const depends = {} as Record; - workingModule.depends.forEach(i => (depends[i.name] = i)); + workingModule.depends?.forEach(i => (depends[i.name] = i)); appendLibrary.forEach((value, key) => (depends[key] = value)); return CreateLibrary({ @@ -212,28 +216,30 @@ export function createModule( application: ApplicationDefinition, ) => { return createModule({ - configuration: application.configuration || ({} as C), - depends: application.libraries || [], + configuration: application.configuration, + depends: application.libraries, name: application.name, optionalDepends: [], - priorityInit: application.priorityInit || [], + priorityInit: application.priorityInit, services: application.services, }); }; +// #MARK: fromLibrary createModule.fromLibrary = ( library: LibraryDefinition, ) => { return createModule({ - configuration: library.configuration || ({} as C), - depends: library.depends || [], + configuration: library.configuration, + depends: library.depends, name: library.name, - optionalDepends: library.optionalDepends || [], - priorityInit: library.priorityInit || [], + optionalDepends: library.optionalDepends, + priorityInit: library.priorityInit, services: library.services, }); }; diff --git a/src/helpers/wiring.helper.ts b/src/helpers/wiring.helper.ts index be5e143..866d795 100644 --- a/src/helpers/wiring.helper.ts +++ b/src/helpers/wiring.helper.ts @@ -20,7 +20,7 @@ import { import { AnyConfig, BooleanConfig, - ConfigLoader, + DataTypes, InternalConfig, NumberConfig, OptionalModuleConfiguration, @@ -39,7 +39,6 @@ export type ApplicationConfigurationOptions< S extends ServiceMap, C extends OptionalModuleConfiguration, > = { - configurationLoaders?: ConfigLoader[]; name: keyof LoadedModules; services: S; libraries?: LibraryDefinition[]; @@ -98,16 +97,6 @@ export type TScheduler = { schedule: Schedule | Schedule[]; }, ) => ScheduleRemove; - /** - * Run code on a regular periodic interval - * - * @deprecated use `scheduler.setInterval` - */ - interval: ( - options: SchedulerOptions & { - interval: number; - }, - ) => ScheduleRemove; /** * Run code at a different time every {period} * @@ -158,7 +147,7 @@ export type TInjectedConfig = { [ModuleName in keyof ModuleConfigs]: ConfigTypes; }; -// #region +// #region Special // SEE DOCS http://docs.digital-alchemy.app/docs/core/declaration-merging // eslint-disable-next-line @typescript-eslint/no-empty-object-type export interface AsyncLogData { @@ -309,6 +298,7 @@ export type LibraryConfigurationOptions< priorityInit?: Extract[]; }; +// #MARK: PartialConfiguration export type PartialConfiguration = Partial<{ [ModuleName in keyof ModuleConfigs]: Partial>; }>; @@ -372,8 +362,14 @@ export type BootstrapOptions = { * Default: `.env` */ envFile?: string; + + /** + * all properties default true if not provided + */ + configSources?: Partial>; }; +// #MARK: LoggerOptions export type LoggerOptions = { /** * Generic data to include as data payload for all logs @@ -381,6 +377,7 @@ export type LoggerOptions = { * Can be used to provide application tags when using a log aggregator */ mergeData?: object; + /** * Adjust the format of the timestamp at the start of the log * @@ -444,6 +441,7 @@ type Wire = { ) => Promise; }; +// #MARK: LibraryDefinition export type LibraryDefinition< S extends ServiceMap, C extends OptionalModuleConfiguration, @@ -452,6 +450,7 @@ export type LibraryDefinition< type: "library"; }; +// #MARK: ApplicationDefinition export type ApplicationDefinition< S extends ServiceMap, C extends OptionalModuleConfiguration, @@ -460,11 +459,12 @@ export type ApplicationDefinition< logger: ILogger; type: "application"; booted: boolean; - bootstrap: (options?: BootstrapOptions) => Promise; + bootstrap: (options?: BootstrapOptions) => Promise; teardown: () => Promise; }; export type TLibrary = LibraryDefinition; +// #MARK: buildSortOrder export function buildSortOrder( app: ApplicationDefinition, logger: ILogger, @@ -499,7 +499,7 @@ export function buildSortOrder context as TContext; export const WIRING_CONTEXT = COERCE_CONTEXT("boilerplate:wiring"); +// #MARK: validateLibrary export function validateLibrary( project: string, serviceList: S, @@ -565,6 +566,7 @@ export function validateLibrary( } } +// #MARK: wireOrder export function wireOrder(priority: T[], list: T[]): T[] { const out = [...(priority || [])]; if (!is.empty(priority)) { @@ -581,6 +583,7 @@ export function wireOrder(priority: T[], list: T[]): T[] { return temporary; } +// #MARK: CreateLibrary export function CreateLibrary({ name: libraryName, configuration = {} as C, diff --git a/src/services/configuration.extension.ts b/src/services/configuration.extension.ts index cf70649..54a08f7 100644 --- a/src/services/configuration.extension.ts +++ b/src/services/configuration.extension.ts @@ -1,13 +1,15 @@ import { + AbstractConfig, ApplicationDefinition, BootstrapException, CodeConfigDefinition, ConfigLoader, ConfigLoaderEnvironment, - configLoaderFile, + DataTypes, deepExtend, DigitalAlchemyConfiguration, eachSeries, + ILogger, KnownConfigs, OnConfigUpdateCallback, OptionalModuleConfiguration, @@ -32,35 +34,35 @@ export function Configuration({ event, lifecycle, internal, - // ! THIS DOES NOT EXIST BEFORE PRE INIT - logger, }: TServiceParams): DigitalAlchemyConfiguration { // modern problems require modern solutions + let logger: ILogger; lifecycle.onPreInit(() => (logger = internal.boilerplate.logger.context(context))); - - const configuration: PartialConfiguration = {}; const configDefinitions: KnownConfigs = new Map(); + const configuration: PartialConfiguration = {}; + const loaded = new Set(); + const loaders = new Map(); - function injectedDefinitions(): TInjectedConfig { - const out = {} as Record; - return new Proxy(out as TInjectedConfig, { - get(_, project: keyof TInjectedConfig) { - return internal.utils.object.get(configuration, project) ?? {}; - }, - has(_, key: keyof TInjectedConfig) { - Object.keys(configuration).forEach(key => (out[key as keyof typeof out] ??= {})); - return Object.keys(configuration).includes(key); - }, - ownKeys() { - Object.keys(configuration).forEach(key => (out[key as keyof typeof out] ??= {})); - return Object.keys(configuration); - }, - set() { - return false; - }, - }); - } + // #MARK: + const proxyData = {} as Record; + const configValueProxy = new Proxy(proxyData as TInjectedConfig, { + get(_, project: keyof TInjectedConfig) { + return { ...internal.utils.object.get(configuration, project) }; + }, + has(_, key: keyof TInjectedConfig) { + Object.keys(configuration).forEach(key => (proxyData[key as keyof typeof proxyData] ??= {})); + return Object.keys(configuration).includes(key); + }, + ownKeys() { + Object.keys(configuration).forEach(key => (proxyData[key as keyof typeof proxyData] ??= {})); + return Object.keys(configuration); + }, + set() { + return false; + }, + }); + // #MARK: setConfig function setConfig< Project extends keyof TInjectedConfig, Property extends keyof TInjectedConfig[Project], @@ -70,6 +72,7 @@ export function Configuration({ event.emit(EVENT_CONFIGURATION_UPDATED, project, property); } + // #MARK: validateConfig function validateConfig() { // * validate // - ensure all required properties have been defined @@ -91,34 +94,63 @@ export function Configuration({ }); } - // #MARK: Initialize + // #MARK: registerLoader + function registerLoader(loader: ConfigLoader, name: DataTypes) { + loaders.set(name, loader); + } + + // #MARK: mergeConfig + function mergeConfig(data: Partial, type: DataTypes[]) { + // * prevents loaders from setting properties that they aren't supposed to + configDefinitions.forEach((project, name) => { + const keys = Object.keys(project) as (keyof typeof project)[]; + keys.forEach(key => { + const { source } = project[key]; + if (is.array(source) && !type.some(i => source.includes(i))) { + return; + } + const moduleConfig = data[name as keyof AbstractConfig]; + if (moduleConfig && key in moduleConfig) { + internal.utils.object.set( + configuration, + [name, key].join("."), + data[name as keyof AbstractConfig][key], + ); + } + }); + }); + } + + // #MARK: initialize async function initialize( application: ApplicationDefinition, ): Promise { - const configLoaders = - internal.boot.application.configurationLoaders ?? - ([ConfigLoaderEnvironment, configLoaderFile] as ConfigLoader[]); - const start = performance.now(); - // * were configs disabled? - if (is.empty(configLoaders)) { - validateConfig(); - if (!configuration.boilerplate.IS_TEST) { - logger.warn({ name: initialize }, `no config loaders defined`); - } - return `${(performance.now() - start).toFixed(DECIMALS)}ms`; - } - - // * load! - await eachSeries(configLoaders, async loader => { - const merge = await loader({ + mergeConfig( + await ConfigLoaderEnvironment({ application, configs: configDefinitions, internal, logger, - }); - deepExtend(configuration, merge); + }), + ["env", "argv"], + ); + mergeConfig( + await ConfigLoaderEnvironment({ + application, + configs: configDefinitions, + internal, + logger, + }), + ["file"], + ); + + // * load! + await eachSeries([...loaders.entries()], async ([type, loader]) => { + mergeConfig(await loader({ application, configs: configDefinitions, internal, logger }), [ + type, + ]); }); validateConfig(); @@ -126,12 +158,12 @@ export function Configuration({ return `${(performance.now() - start).toFixed(DECIMALS)}ms`; } + // #MARK: merge function merge(merge: Partial) { return deepExtend(configuration, merge); } - const loaded = new Set(); - + // #MARK: loadProject function loadProject(library: string, definitions: CodeConfigDefinition) { if (loaded.has(library)) { return; @@ -156,9 +188,35 @@ export function Configuration({ } } + // #MARK: onUpdate + function onUpdate< + Project extends keyof TInjectedConfig, + Property extends Extract, + >(callback: OnConfigUpdateCallback, project?: Project, property?: Property) { + event.on(EVENT_CONFIGURATION_UPDATED, (updatedProject, updatedProperty) => { + if (!is.empty(project) && project !== updatedProject) { + return; + } + if (!is.empty(property) && property !== updatedProperty) { + return; + } + callback(updatedProject, updatedProperty); + }); + } + + // #MARK: return { + /** + * @internal + */ [INITIALIZE]: initialize, - [INJECTED_DEFINITIONS]: injectedDefinitions, + /** + * @internal + */ + [INJECTED_DEFINITIONS]: configValueProxy, + /** + * @internal + */ [LOAD_PROJECT]: loadProject, /** @@ -171,22 +229,11 @@ export function Configuration({ * * intended for initial loading workflows */ - merge: merge, - - onUpdate< - Project extends keyof TInjectedConfig, - Property extends Extract, - >(callback: OnConfigUpdateCallback, project?: Project, property?: Property) { - event.on(EVENT_CONFIGURATION_UPDATED, (updatedProject, updatedProperty) => { - if (!is.empty(project) && project !== updatedProject) { - return; - } - if (!is.empty(property) && property !== updatedProperty) { - return; - } - callback(updatedProject, updatedProperty); - }); - }, + merge, + + onUpdate, + + registerLoader, set: setConfig as TSetConfig, }; diff --git a/src/services/internal.extension.ts b/src/services/internal.extension.ts index 10ad4de..46abf2e 100644 --- a/src/services/internal.extension.ts +++ b/src/services/internal.extension.ts @@ -7,6 +7,7 @@ import { ARRAY_OFFSET, BootstrapOptions, DAY, + deepExtend, FIRST, GetApis, HOUR, @@ -98,6 +99,7 @@ export class InternalUtils { // #region .object public object = { + deepExtend, del(object: T, path: string): void { const keys = path.split("."); let current = object as unknown; // Starting with the object as an unknown type @@ -190,6 +192,10 @@ export class InternalDefinition { * Utility methods provided by boilerplate */ public boilerplate: Pick, "configuration" | "logger">; + /** + * alias for `internal.boilerplate.configuration` + */ + public config: GetApis["configuration"]; public boot: { /** * Options that were passed into bootstrap diff --git a/src/services/scheduler.extension.ts b/src/services/scheduler.extension.ts index cf20fa0..1fc6029 100644 --- a/src/services/scheduler.extension.ts +++ b/src/services/scheduler.extension.ts @@ -4,6 +4,7 @@ import { schedule } from "node-cron"; import { is, TContext } from ".."; import { BootstrapException, + makeRemover, SchedulerBuilder, SchedulerCronOptions, ScheduleRemove, @@ -40,17 +41,17 @@ export function Scheduler({ logger, lifecycle, internal }: TServiceParams): Sche cronJob.start(); }); - const stopFunction = () => { + const stopFunction = makeRemover(() => { logger.trace({ context, name: cron, schedule: cronSchedule }, `stopping`); cronJob.stop(); - }; + }); stop.add(stopFunction); stopFunctions.push(stopFunction); return stopFunction; }); - return () => stopFunctions.forEach(stop => stop()); + return makeRemover(() => stopFunctions.forEach(stop => stop())); } // #MARK: interval @@ -58,14 +59,13 @@ export function Scheduler({ logger, lifecycle, internal }: TServiceParams): Sche let runningInterval: ReturnType; lifecycle.onReady(() => { logger.trace({ context, name: interval }, "starting"); - runningInterval = setInterval(async () => await internal.safeExec(exec), interval); }); - const stopFunction = () => { + const stopFunction = makeRemover(() => { if (runningInterval) { clearInterval(runningInterval); } - }; + }); stop.add(stopFunction); return stopFunction; } @@ -125,13 +125,13 @@ export function Scheduler({ logger, lifecycle, internal }: TServiceParams): Sche // find value for now (boot) lifecycle.onReady(() => waitForNext()); - return () => { + return makeRemover(() => { scheduleStop(); if (timeout) { clearTimeout(timeout); timeout = undefined; } - }; + }); } return { @@ -146,13 +146,13 @@ export function Scheduler({ logger, lifecycle, internal }: TServiceParams): Sche } timer = setInterval(async () => await internal.safeExec(callback), ms); }); - const remove = () => { + const remove = makeRemover(() => { stopped = true; stop.delete(remove); if (timer) { clearInterval(timer); } - }; + }); stop.add(remove); return remove; }, @@ -168,13 +168,13 @@ export function Scheduler({ logger, lifecycle, internal }: TServiceParams): Sche await internal.safeExec(callback); }, ms); }); - const remove = () => { + const remove = makeRemover(() => { stopped = true; stop.delete(remove); if (timer) { clearTimeout(timer); } - }; + }); stop.add(remove); return remove; }, diff --git a/src/services/wiring.extension.ts b/src/services/wiring.extension.ts index 16189ad..fc5db5e 100644 --- a/src/services/wiring.extension.ts +++ b/src/services/wiring.extension.ts @@ -52,9 +52,6 @@ function createBoilerplate() { // While it SEEMS LIKE this can be safely moved, it causes code init race conditions. return CreateLibrary({ configuration: { - ALS_ENABLED: { - type: "string", - }, /** * Only usable by **cli switch**. * Pass path to a config file for loader @@ -65,12 +62,13 @@ function createBoilerplate() { */ CONFIG: { description: [ - "Consumable as CLI switch only", "If provided, all other file based configurations will be ignored", "Environment variables + CLI switches will operate normally", ].join(". "), + source: ["argv"], type: "string", }, + /** * > by default true when: * @@ -88,6 +86,7 @@ function createBoilerplate() { description: "Quick reference for if this app is currently running with test mode", type: "boolean", }, + /** * ### `trace` * @@ -125,6 +124,7 @@ function createBoilerplate() { enum: ["silent", "trace", "info", "warn", "debug", "error", "fatal"], type: "string", } satisfies StringConfig as StringConfig, + /** * Reference to `process.env.NODE_ENV` by default, `"local"` if not provided */ @@ -214,7 +214,6 @@ const BOILERPLATE = (internal: InternalDefinition) => export function CreateApplication({ name, services = {} as S, - configurationLoaders, libraries = [], configuration = {} as C, priorityInit = [], @@ -273,12 +272,12 @@ export function CreateApplication, options: BootstrapOptions, internal: InternalDefinition, -) { +): Promise { const initTime = performance.now(); internal.boot = { application, @@ -417,11 +416,13 @@ async function bootstrap { @@ -455,7 +456,7 @@ async function bootstrap { start = performance.now(); - logger.info({ name: bootstrap }, `[%s] init project`, i.name); + logger.debug({ name: bootstrap }, `[%s] init project`, i.name); await i[WIRE_PROJECT](internal, wireService); CONSTRUCT[i.name] = `${(performance.now() - start).toFixed(DECIMALS)}ms`; }); @@ -468,7 +469,7 @@ async function bootstrap + wireService( + application.name, + "bootstrap", + i => done(i), + internal.boot.lifecycle.events, + internal, + ), + ); } catch (error) { if (options?.configuration?.boilerplate?.LOG_LEVEL !== "silent") { // eslint-disable-next-line no-console @@ -532,6 +542,9 @@ async function bootstrap **note**: you probably don't need to do this, it's not even documented */ module_config?: ModuleConfiguration; + + /** + * all properties default true if not provided + */ + configSources?: Partial>; }; export type LibraryTestRunner = @@ -114,6 +115,15 @@ export type iTestRunner iTestRunner; /** + * cannot be used with `.run` + * + * returns params instead of running an inline service + */ + serviceParams: () => Promise; + + /** + * cannot be used with `.serviceParams` + * * returns reference to app that was booted */ run: ( @@ -213,10 +223,9 @@ export function TestRunner await app.teardown(); return app; }, + serviceParams() { + return new Promise(done => libraryTestRunner.run(done)); + }, setOptions(options: TestingBootstrapOptions) { bootOptions = deepExtend(bootOptions, options); return libraryTestRunner; diff --git a/testing/als.spec.ts b/testing/als.spec.ts index 57a2568..02b1921 100644 --- a/testing/als.spec.ts +++ b/testing/als.spec.ts @@ -1,10 +1,55 @@ -import { TestRunner } from "../src"; +import { AsyncLocalStorage } from "async_hooks"; + +import { sleep, TestRunner } from "../src"; describe("ALS", () => { it("exists", async () => { - expect.assertions(1); + expect.assertions(2); await TestRunner().run(({ als }) => { expect(als).toBeDefined(); + + const storage = als.asyncStorage(); + expect(storage).toBeInstanceOf(AsyncLocalStorage); + }); + }); + + it("enterWith", async () => { + await TestRunner().run(({ als }) => { + als.enterWith({ + // @ts-expect-error testing + test: true, + }); + const data = als.getStore(); + // @ts-expect-error testing + expect(data.test).toBe(true); + }); + }); + + it("getLogData", async () => { + await TestRunner().run(({ als }) => { + als.enterWith({ + logs: undefined, + }); + const data = als.getLogData(); + expect(data).toEqual({}); + }); + }); + + it("getLogData", async () => { + await TestRunner().run(({ als }) => { + als.enterWith({ logs: { test: true } }); + const data = als.getLogData(); + expect(data).toEqual({ test: true }); + }); + }); + + it("run", async () => { + await TestRunner().run(async ({ als }) => { + const done = jest.fn(); + const data = { logs: {} }; + als.run(data, done); + await sleep(0); + expect(done).toHaveBeenCalled(); }); }); }); diff --git a/testing/configuration.spec.ts b/testing/configuration.spec.ts index 7d97b87..49bbd60 100644 --- a/testing/configuration.spec.ts +++ b/testing/configuration.spec.ts @@ -18,6 +18,7 @@ import { CreateApplication, CreateLibrary, createMockLogger, + DataTypes, ILogger, InternalConfig, InternalDefinition, @@ -188,16 +189,15 @@ describe("Configuration", () => { it("should be configured at the correct time in the lifecycle", async () => { expect.assertions(2); const spy = jest.fn().mockReturnValue({}); - await TestRunner() - .setOptions({ configLoader: async () => spy() }) - .run(({ lifecycle }) => { - lifecycle.onPreInit(() => { - expect(spy).not.toHaveBeenCalled(); - }); - lifecycle.onPostConfig(() => { - expect(spy).toHaveBeenCalled(); - }); + await TestRunner().run(({ lifecycle, internal }) => { + internal.config.registerLoader(spy, "test" as DataTypes); + lifecycle.onPreInit(() => { + expect(spy).not.toHaveBeenCalled(); }); + lifecycle.onPostConfig(() => { + expect(spy).toHaveBeenCalled(); + }); + }); }); it("defaults NODE_ENV to local", async () => { @@ -348,26 +348,6 @@ describe("Configuration", () => { expect("boilerplate" in config).toBe(true); }); }); - - it("should not find variables without loaders", async () => { - expect.assertions(1); - env["DO_NOT_LOAD"] = "env"; - await TestRunner() - .setOptions({ - module_config: { - DO_NOT_LOAD: { - default: "unloaded", - type: "string", - }, - }, - }) - .run(({ config, lifecycle }) => { - lifecycle.onPostConfig(() => { - // @ts-expect-error testing - expect(config.testing.DO_NOT_LOAD).toBe("unloaded"); - }); - }); - }); }); // #MARK: Environment @@ -440,6 +420,107 @@ describe("Configuration", () => { }); }); + it("ignores when env is disabled", async () => { + expect.assertions(1); + env["current_weather"] = "sunny"; + await TestRunner() + .setOptions({ configSources: { env: false } }) + .setOptions({ + module_config: { + CURRENT_WEATHER: { + default: "raining", + type: "string", + }, + }, + }) + .run(({ config, lifecycle }) => { + lifecycle.onPostConfig(() => { + // @ts-expect-error testing + expect(config.testing.CURRENT_WEATHER).toBe("raining"); + }); + }); + }); + + it("ignore non-matching source", async () => { + expect.assertions(1); + env["BAR"] = "fizz"; + await TestRunner() + .appendLibrary( + CreateLibrary({ + configuration: { + BAR: { + default: "buzz", + source: [], + type: "string", + }, + }, + // @ts-expect-error testing + name: "foo", + services: {}, + }), + ) + .setOptions({ loadConfigs: true }) + .run(({ config, lifecycle }) => { + lifecycle.onPostConfig(() => { + // @ts-expect-error testing + expect(config.foo.BAR).toBe("buzz"); + }); + }); + }); + + it("matches correct source", async () => { + expect.assertions(1); + env["BAR"] = "fizz"; + await TestRunner() + .appendLibrary( + CreateLibrary({ + configuration: { + BAR: { + default: "buzz", + source: ["env"], + type: "string", + }, + }, + // @ts-expect-error testing + name: "foo", + services: {}, + }), + ) + .setOptions({ loadConfigs: true }) + .run(({ config, lifecycle }) => { + lifecycle.onPostConfig(() => { + // @ts-expect-error testing + expect(config.foo.BAR).toBe("fizz"); + }); + }); + }); + + it("matches any source", async () => { + expect.assertions(1); + env["BAR"] = "fizz"; + await TestRunner() + .appendLibrary( + CreateLibrary({ + configuration: { + BAR: { + default: "buzz", + type: "string", + }, + }, + // @ts-expect-error testing + name: "foo", + services: {}, + }), + ) + .setOptions({ loadConfigs: true }) + .run(({ config, lifecycle }) => { + lifecycle.onPostConfig(() => { + // @ts-expect-error testing + expect(config.foo.BAR).toBe("fizz"); + }); + }); + }); + it("should wrong case (mixed)", async () => { expect.assertions(1); env["current_WEATHER"] = "hail"; @@ -551,6 +632,107 @@ describe("Configuration", () => { }); }); + it("ignore when disabled", async () => { + expect.assertions(1); + process.argv.push("--current_WEATHER", "hail"); + await TestRunner() + .setOptions({ configSources: { argv: false }, loadConfigs: true }) + .setOptions({ + module_config: { + CURRENT_WEATHER: { + default: "raining", + type: "string", + }, + }, + }) + .run(({ config, lifecycle }) => { + lifecycle.onPostConfig(() => { + // @ts-expect-error testing + expect(config.testing.CURRENT_WEATHER).toBe("raining"); + }); + }); + }); + + it("ignore non-matching source", async () => { + expect.assertions(1); + process.argv.push("--BAR", "fizz"); + await TestRunner() + .appendLibrary( + CreateLibrary({ + configuration: { + BAR: { + default: "buzz", + source: [], + type: "string", + }, + }, + // @ts-expect-error testing + name: "foo", + services: {}, + }), + ) + .setOptions({ loadConfigs: true }) + .run(({ config, lifecycle }) => { + lifecycle.onPostConfig(() => { + // @ts-expect-error testing + expect(config.foo.BAR).toBe("buzz"); + }); + }); + }); + + it("matches correct source", async () => { + expect.assertions(1); + process.argv.push("--BAR", "fizz"); + await TestRunner() + .appendLibrary( + CreateLibrary({ + configuration: { + BAR: { + default: "buzz", + source: ["argv"], + type: "string", + }, + }, + // @ts-expect-error testing + name: "foo", + services: {}, + }), + ) + .setOptions({ loadConfigs: true }) + .run(({ config, lifecycle }) => { + lifecycle.onPostConfig(() => { + // @ts-expect-error testing + expect(config.foo.BAR).toBe("fizz"); + }); + }); + }); + + it("matches any source", async () => { + expect.assertions(1); + process.argv.push("--BAR", "fizz"); + await TestRunner() + .appendLibrary( + CreateLibrary({ + configuration: { + BAR: { + default: "buzz", + type: "string", + }, + }, + // @ts-expect-error testing + name: "foo", + services: {}, + }), + ) + .setOptions({ loadConfigs: true }) + .run(({ config, lifecycle }) => { + lifecycle.onPostConfig(() => { + // @ts-expect-error testing + expect(config.foo.BAR).toBe("fizz"); + }); + }); + }); + it("is valid with equals signs", async () => { expect.assertions(1); process.argv.push("--current_WEATHER=hail"); @@ -578,9 +760,9 @@ describe("Configuration", () => { it("resolves files in the correct order", async () => { let testFiles: ReturnType = undefined; - jest.spyOn(global.console, "error").mockImplementation(() => {}); - jest.spyOn(global.console, "warn").mockImplementation(() => {}); - jest.spyOn(global.console, "log").mockImplementation(() => {}); + jest.spyOn(globalThis.console, "error").mockImplementation(() => {}); + jest.spyOn(globalThis.console, "warn").mockImplementation(() => {}); + jest.spyOn(globalThis.console, "log").mockImplementation(() => {}); const helper = CreateApplication({ configurationLoaders: [], // @ts-expect-error Testing @@ -628,6 +810,23 @@ describe("Configuration", () => { } }); + it("auto detects paths", async () => { + jest.spyOn(fs, "existsSync").mockReturnValueOnce(true); + // @ts-expect-error rest isn't needed + jest.spyOn(fs, "statSync").mockImplementation(() => ({ isFile: () => true })); + const spy = jest.spyOn(is, "empty").mockImplementation(() => true); + await configLoaderFile({ + application: { + // @ts-expect-error testing + name: "test", + }, + configs: undefined, + internal: undefined, + logger: createMockLogger(), + }); + expect(spy).toHaveBeenCalledWith(["/etc/test"]); + }); + // #MARK: --config describe("--config", () => { it("handles bad parsing", async () => { diff --git a/testing/scheduler.spec.ts b/testing/scheduler.spec.ts index b6f5a1b..09eb68d 100644 --- a/testing/scheduler.spec.ts +++ b/testing/scheduler.spec.ts @@ -29,7 +29,7 @@ describe("Scheduler", () => { jest.useFakeTimers(); const spy = jest.fn(); const app = await TestRunner().run(({ scheduler }) => { - // eslint-disable-next-line sonarjs/deprecation + // @ts-expect-error it's temporarily still here scheduler.interval({ exec: spy, interval: MINUTE, @@ -72,7 +72,7 @@ describe("Scheduler", () => { it("stops early", async () => { const spy = jest.fn(); - const intervalSpy = jest.spyOn(global, "setInterval"); + const intervalSpy = jest.spyOn(globalThis, "setInterval"); const app = await TestRunner().run(({ scheduler }) => { const remove = scheduler.setInterval(spy, MINUTE); remove(); @@ -112,7 +112,7 @@ describe("Scheduler", () => { it("stops early", async () => { const spy = jest.fn(); - const intervalSpy = jest.spyOn(global, "setTimeout"); + const intervalSpy = jest.spyOn(globalThis, "setTimeout"); const app = await TestRunner().run(({ scheduler }) => { const remove = scheduler.setTimeout(spy, MINUTE); remove(); diff --git a/testing/testing.spec.ts b/testing/testing.spec.ts index a74fe69..3496f28 100644 --- a/testing/testing.spec.ts +++ b/testing/testing.spec.ts @@ -26,6 +26,7 @@ describe("Testing", () => { const overrideLibrary = CreateLibrary({ // @ts-expect-error testing name: "example", + priorityInit: ["test"], services: { test: function () { return () => false; @@ -288,10 +289,25 @@ describe("Testing", () => { // #MARK: Outputs describe("Outputs", () => { - it("can create application modules", () => { - expect.assertions(1); - const test = createModule.fromLibrary(testingLibrary).extend().toApplication(); - expect(test.type).toBe("application"); + describe("Applications", () => { + it("can create", () => { + expect.assertions(1); + const test = createModule.fromLibrary(testingLibrary).extend().toApplication(); + expect(test.type).toBe("application"); + }); + + it("preserves priorityInit", async () => { + expect.assertions(1); + const test = createModule.fromLibrary(overrideLibrary).extend().toLibrary(); + expect(test.priorityInit).toEqual(["test"]); + }); + }); + it("can return params instead of running", async () => { + const params = await TestRunner().serviceParams(); + expect(params.lifecycle).toBeDefined(); + expect(params.logger).toBeDefined(); + expect(params.context).toBeDefined(); + expect(params.config).toBeDefined(); }); describe("optionalDepends", () => { diff --git a/testing/wiring.spec.ts b/testing/wiring.spec.ts index 66f4f01..d4cec32 100644 --- a/testing/wiring.spec.ts +++ b/testing/wiring.spec.ts @@ -757,6 +757,17 @@ describe("Wiring", () => { ); }); + it("shouldn't process double teardown pt2", async () => { + expect.assertions(1); + const spy = jest.fn(); + const app = await TestRunner().run(({ lifecycle }) => { + lifecycle.onPreShutdown(spy); + }); + await Promise.all([app.teardown(), app.teardown(), app.teardown()]); + + expect(spy).toHaveBeenCalledTimes(1); + }); + it("phase should be teardown after teardown starts", async () => { expect.assertions(1); @@ -812,6 +823,13 @@ describe("Wiring", () => { }); expect(i.boot.constructComplete.size).not.toEqual(0); }); + + it("has config alias", async () => { + expect.assertions(1); + await TestRunner().run(({ internal }) => { + expect(internal.config).toBe(internal.boilerplate.configuration); + }); + }); }); // #MARK: Wiring