meocord 4.1.2 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/CHANGELOG.md +98 -0
  2. package/dist/cjs/_shared/{controller.decorator-BywwkU9O.cjs → controller.decorator-CRn9kWob.cjs} +295 -87
  3. package/dist/cjs/_shared/{providers-BMMLp2SN.cjs → providers-ChvMqnLd.cjs} +74 -40
  4. package/dist/cjs/_shared/{sharded-cooldown-store-B65iaiAy.cjs → sharded-cooldown-store-BdAUTdYs.cjs} +1 -1
  5. package/dist/cjs/_shared/{startup-roots-lFc69AxC.cjs → startup-roots-CQo8mSP8.cjs} +158 -72
  6. package/dist/cjs/_shared/{translate-error-BnM8FbSH.cjs → translate-error-DhDVbT5M.cjs} +12 -3
  7. package/dist/cjs/common/index.cjs +4 -4
  8. package/dist/cjs/core/index.cjs +195 -157
  9. package/dist/cjs/decorator/index.cjs +66 -57
  10. package/dist/cjs/testing/index.cjs +733 -301
  11. package/dist/cli.json +1 -1
  12. package/dist/esm/bin/app-template/README.md.template +3 -1
  13. package/dist/esm/bin/app-template/src/controllers/button/sample.button.controller.spec.ts.template +8 -4
  14. package/dist/esm/bin/app-template/src/controllers/button/sample.button.controller.ts.template +2 -2
  15. package/dist/esm/bin/app-template/src/guards/owner.guard.spec.ts.template +8 -4
  16. package/dist/esm/bin/app-template/src/guards/owner.guard.ts.template +1 -1
  17. package/dist/esm/bin/app-template/vitest.config.ts.template +1 -1
  18. package/dist/esm/bin/app-template/vitest.setup.ts.template +7 -3
  19. package/dist/esm/build/load-config.pre-entry.js +4 -1
  20. package/dist/esm/common/decorator.js +6 -6
  21. package/dist/esm/common/deprecation.js +2 -2
  22. package/dist/esm/common/errors.js +2 -2
  23. package/dist/esm/common/execution-context.js +1 -1
  24. package/dist/esm/common/metadata.js +1 -1
  25. package/dist/esm/common/route.js +10 -1
  26. package/dist/esm/common/translate-error.js +1 -1
  27. package/dist/esm/core/command-conflicts.js +34 -34
  28. package/dist/esm/core/component-routes.js +74 -31
  29. package/dist/esm/core/dispatcher.js +51 -7
  30. package/dist/esm/core/filter-runner.js +2 -2
  31. package/dist/esm/core/handler-pipeline.js +27 -14
  32. package/dist/esm/core/handler-registry.js +1 -1
  33. package/dist/esm/core/interceptor-runner.js +2 -2
  34. package/dist/esm/core/meocord-factory.js +196 -157
  35. package/dist/esm/core/message-routes.js +4 -3
  36. package/dist/esm/core/observer-runner.js +4 -3
  37. package/dist/esm/core/providers.js +38 -21
  38. package/dist/esm/core/route-specificity.js +4 -4
  39. package/dist/esm/core/scalar-types.js +15 -3
  40. package/dist/esm/core/shard-context.js +1 -1
  41. package/dist/esm/core/startup-roots.js +2 -3
  42. package/dist/esm/core/theme-runtime.js +2 -2
  43. package/dist/esm/decorator/app.decorator.js +12 -7
  44. package/dist/esm/decorator/command-builder.decorator.js +4 -4
  45. package/dist/esm/decorator/controller-class.decorator.js +9 -4
  46. package/dist/esm/decorator/controller.decorator.js +110 -67
  47. package/dist/esm/decorator/cooldown.decorator.js +3 -3
  48. package/dist/esm/decorator/defer.decorator.js +4 -4
  49. package/dist/esm/decorator/event.decorator.js +5 -5
  50. package/dist/esm/decorator/filter.decorator.js +7 -7
  51. package/dist/esm/decorator/guard.decorator.js +8 -8
  52. package/dist/esm/decorator/inject.decorator.js +4 -4
  53. package/dist/esm/decorator/interceptor.decorator.js +7 -7
  54. package/dist/esm/decorator/observer.decorator.js +4 -4
  55. package/dist/esm/decorator/service.decorator.js +4 -4
  56. package/dist/esm/decorator/theme.decorator.js +3 -3
  57. package/dist/esm/decorator/validation.decorator.js +10 -10
  58. package/dist/esm/package.json.js +1 -1
  59. package/dist/esm/testing/catalog.js +1 -1
  60. package/dist/esm/testing/cooldown-store-suite.js +18 -10
  61. package/dist/esm/testing/discord-defaults.js +60 -7
  62. package/dist/esm/testing/index.js +4 -2
  63. package/dist/esm/testing/inspect-handler.js +20 -3
  64. package/dist/esm/testing/meocord-testing-module.js +229 -188
  65. package/dist/esm/testing/mock-fn.js +80 -10
  66. package/dist/esm/testing/mock-interaction.js +223 -72
  67. package/dist/esm/testing/response.js +1 -1
  68. package/dist/esm/testing/routing.js +39 -14
  69. package/dist/esm/testing/startup-errors.js +26 -0
  70. package/dist/esm/testing/strict-mocks.js +45 -0
  71. package/dist/esm/testing/theme.js +1 -1
  72. package/dist/esm/util/bundle-entry.util.js +2 -1
  73. package/dist/esm/util/interaction.util.js +1 -1
  74. package/dist/esm/util/meocord-config-validation.util.js +1 -0
  75. package/dist/esm/util/metadata-keys.js +1 -0
  76. package/dist/esm/util/refusal.util.js +148 -8
  77. package/dist/types/common/index.d.cts +8 -8
  78. package/dist/types/common/index.d.ts +8 -8
  79. package/dist/types/core/index.d.cts +8 -6
  80. package/dist/types/core/index.d.ts +8 -6
  81. package/dist/types/decorator/index.d.cts +30 -29
  82. package/dist/types/decorator/index.d.ts +30 -29
  83. package/dist/types/{index-D7n0FBMm.d.ts → index-CdXsFgjE.d.ts} +62 -15
  84. package/dist/types/{index-DEP0NYrU.d.cts → index-nn5p7OoX.d.cts} +62 -15
  85. package/dist/types/interface/index.d.cts +1 -1
  86. package/dist/types/interface/index.d.ts +1 -1
  87. package/dist/types/{route-C-aMI7Sl.d.cts → route-BZgFoNeB.d.cts} +5 -2
  88. package/dist/types/{route-C-aMI7Sl.d.ts → route-BZgFoNeB.d.ts} +5 -2
  89. package/dist/types/testing/index.d.cts +245 -31
  90. package/dist/types/testing/index.d.ts +245 -31
  91. package/dist/types/{theme-resolvers-DedxyscT.d.ts → theme-resolvers-BcOJVzR9.d.ts} +1 -1
  92. package/dist/types/{theme-resolvers-COHdX5g6.d.cts → theme-resolvers-UKr3-6Ys.d.cts} +1 -1
  93. package/dist/types/{translator-J6tIUfdp.d.ts → translator-BViWKtLJ.d.ts} +1 -1
  94. package/dist/types/{translator-B4mc1lzJ.d.cts → translator-DHMkkCpg.d.cts} +1 -1
  95. package/package.json +3 -2
@@ -7,7 +7,7 @@ import { CooldownStore, MemoryCooldownStore } from '../common/cooldown-store.js'
7
7
  import { ExecutionContext } from '../common/execution-context.js';
8
8
  import { Translator, missingTranslatorError } from '../common/translator.js';
9
9
  import { injectedTokens, singletonContextError } from '../core/guard-runner.js';
10
- import { appPresenterOf, runHandler, appStages, bindGlobalStages, prepareHandlerStages, bindAppPresenter } from '../core/handler-pipeline.js';
10
+ import { appPresenterOf, runHandler, appStages, bindGlobalStages, prepareHandlerStages, prepareAppPresenter, prepareThemes, bindAppPresenter } from '../core/handler-pipeline.js';
11
11
  import { setPresenter } from '../common/response/presenter.js';
12
12
  import { componentRouteFor, commandMismatch, handlerInput } from '../core/handler-input.js';
13
13
  import { buildComponentRoutes } from '../core/component-routes.js';
@@ -21,11 +21,11 @@ import { messageParamsFor, buildMessageRoutes } from '../core/message-routes.js'
21
21
  import { assertDistinctCommands, warnUnregisteredCommands, warnInheritedRoutes, warnOverlappingPatterns, warnHandlersOffControllers } from '../core/command-conflicts.js';
22
22
  import { messageCommandHooks } from '../core/message-params.js';
23
23
  import { appObservers, assertObservers, bindObservers } from '../core/observer-runner.js';
24
- import { makeInjectable } from '../util/injectable.util.js';
25
24
  import { HandlerRegistry } from '../core/handler-registry.js';
26
25
  import { adviseInPlaceOfInjecting, meocordClasses } from '../core/meocord-classes.js';
27
26
  import { startupClasses, assertStartupClasses, lateGuardCheck } from '../core/startup-roots.js';
28
27
  import { markStartupChecked, assertStartupChecked } from '../core/startup-checked.js';
28
+ import { makeInjectable } from '../util/injectable.util.js';
29
29
  import { ShardContext, shardCallHandler } from '../core/shard-context.js';
30
30
  import { isAppClassToken } from '../core/lifecycle-order.js';
31
31
  import { lifecycleEntry, runShutdownSequence, runReadyHooks, callsSettled } from '../core/lifecycle-hooks.js';
@@ -33,6 +33,8 @@ import { createMockClient } from './mock-interaction.js';
33
33
  import { Dispatcher } from '../core/dispatcher.js';
34
34
  import { createFallback } from '../core/fallback.js';
35
35
  import { Logger } from '../common/logger.js';
36
+ import { isExplainedError, markExplained } from '../common/explained-error.js';
37
+ import { declaredErrorsOf, collectStartupErrors, startupError, stopOnStartupErrors, isRefusal, logStartupErrors } from '../util/refusal.util.js';
36
38
  import { DEFAULT_SHUTDOWN_TIMEOUT_MS, shutdownTimeoutProblem } from '../util/shutdown-timeout.util.js';
37
39
  import { resolveProviders, tokenName, bindsOwnToken, providerMap, bindProvider, resolutionOrder, isClassProvider, assertProvided, tokenDependencies } from '../core/providers.js';
38
40
  import { getEventHandlers } from '../decorator/event.decorator.js';
@@ -69,7 +71,7 @@ import { isUserOutcome } from '../common/user-outcome.js';
69
71
  * @category Module
70
72
  * @see {@link MeoCordTestingModule}
71
73
  * @see {@link getResponse}
72
- * @see {@link https://meocord.dev/docs/4.1/invoke-and-dispatch | Invoke and dispatch}
74
+ * @see {@link https://meocord.dev/docs/4.2/invoke-and-dispatch | Invoke and dispatch}
73
75
  */ class TestingModule {
74
76
  /**
75
77
  * Resolves the module's `useFactory` providers, awaiting those that return a promise, in dependency
@@ -343,6 +345,8 @@ import { isUserOutcome } from '../common/user-outcome.js';
343
345
  controllerClasses: this.controllers,
344
346
  messageOptions: this.messageOptions,
345
347
  logger,
348
+ // A test's fake clock can pass 5 s inside a handler, so the slow-handler warning waits to be asked for
349
+ warnSlowMessageHandlers: false,
346
350
  // Strict: an answer MeoCord fails to build rejects the dispatch, where a bot logs it and carries on
347
351
  fallback: createFallback(logger, ()=>this.messageOptions, {
348
352
  strict: true,
@@ -509,7 +513,7 @@ import { isUserOutcome } from '../common/user-outcome.js';
509
513
  * @group Testing
510
514
  * @category Module
511
515
  * @see {@link MeoCordTestingModule}
512
- * @see {@link https://meocord.dev/docs/4.1/testing | The testing module}
516
+ * @see {@link https://meocord.dev/docs/4.2/testing | The testing module}
513
517
  */ class TestingModuleBuilder {
514
518
  /**
515
519
  * Replaces a provider with a test double.
@@ -657,199 +661,236 @@ import { isUserOutcome } from '../common/user-outcome.js';
657
661
  * its command's builder does not register, a customId pattern given as a command name, a builder that registers
658
662
  * another name, and component patterns that can match the same customId are named, as the bot names them.
659
663
  *
664
+ * Each check reports every startup error it finds, and the first is thrown, unchanged; when there are several, each
665
+ * is logged first, with the file it comes from. After {@link reportAllStartupErrors}, the errors decorators kept on
666
+ * the classes the module runs are among them.
667
+ *
660
668
  * @returns The compiled module.
669
+ * @throws Error for the first startup error the checks find.
661
670
  */ compile() {
662
671
  // The bound the bot's config gives it, with the same words
663
672
  const shutdownTimeout = this.options.shutdownTimeout === undefined ? undefined : shutdownTimeoutProblem(this.options.shutdownTimeout);
664
673
  if (shutdownTimeout) throw new TypeError(`shutdownTimeout ${shutdownTimeout}.`);
665
- const container = new Container();
666
- const stages = this.globalStages();
667
- if (stages) bindGlobalStages(container, stages);
668
- // Bound first, as in the app, so a class that injects it gets this instance
669
- const appClasses = [];
670
- const translator = ()=>container.isBound(Translator) ? container.get(Translator) : undefined;
671
- container.bind(HandlerRegistry).toConstantValue(new HandlerRegistry(appClasses, messagesOf(this.options.app), translator));
672
- // A testing module runs as one process, so a cross-shard call runs once, here
673
- container.bind(ShardContext).toConstantValue(new ShardContext(undefined, shardCallHandler(container, ()=>[
674
- ...appClasses,
675
- ...[
676
- ...providers.keys()
677
- ].filter(isAppClassToken)
678
- ], 'this testing module')));
679
- bindsOwnToken(container, HandlerRegistry);
680
- bindsOwnToken(container, ShardContext);
681
- // Checked as the app checks its own: the app's providers in their order, then the test's, which replace them
682
- // by token, then the overrides, which win
683
- const providers = providerMap(this.wiring?.providers ?? [], '@MeoCord({ providers })');
684
- for (const [token, provider] of providerMap(this.options.providers ?? [], "the testing module's providers"))providers.set(token, provider);
685
- for (const [token, override] of this.overrides)providers.set(token, override);
686
- const services = this.wiring?.services ?? [];
687
- const appOptions = this.options.app ? Reflect.getMetadata(META.appOptions, this.options.app) : undefined;
688
- // The app's own store, as the bot binds it, whether the module is given the app or built from it; none when the
689
- // test provides the CooldownStore, so nothing builds the app's or asks for what it injects
690
- const store = providers.has(CooldownStore) ? undefined : appOptions?.cooldownStore;
691
- // The themeFor class this module runs, the app's or overrideThemeFor's, bound on its own as the app binds it
692
- const themeResolver = themeResolverClass(stages?.themeFor?.resolvers);
693
- // Every class the module runs, as the app finds them, except stage classes an override stands in for
694
- const i18n = this.options.app && Reflect.getMetadata(META.appOptions, this.options.app)?.i18n;
695
- const startup = startupClasses({
696
- controllers: this.options.controllers ?? [],
697
- services,
698
- providers,
699
- stages,
700
- presenter: this.options.app && Reflect.getMetadata(META.appOptions, this.options.app)?.presenter,
701
- cooldownStore: store,
702
- themeFor: stages?.themeFor?.resolvers,
703
- observers: [
704
- ...this.options.app ? appObservers(this.options.app) : [],
705
- ...this.options.observers ?? []
706
- ],
707
- stubs: [
708
- ...this.guardOverrides.keys(),
709
- ...this.interceptorOverrides.keys(),
710
- ...this.filterOverrides.keys()
711
- ]
712
- });
713
- const hasTranslator = i18n !== undefined || providers.has(Translator);
714
- assertStartupClasses(startup, {
715
- translator: hasTranslator
716
- });
717
- markStartupChecked(container, [
718
- ...startup.classes,
719
- ...meocordClasses()
720
- ], lateGuardCheck(providers, hasTranslator));
721
- // The bot binds the Client it logs in with; a test gives its own, and is told so where one is needed
722
- const needClient = startup.classes.filter((cls)=>injectedTokens(cls).includes(Client));
723
- if (needClient.length > 0 && !providers.has(Client)) {
724
- container.bind(Client).toDynamicValue(()=>{
725
- throw new Error(`${needClient.map((cls)=>cls.name).join(', ')} ${needClient.length === 1 ? 'injects' : 'inject'} the Discord Client, ` + 'which a testing module does not make: give one in its providers, such as ' + '{ provide: Client, useValue: createMockClient() }.');
726
- });
727
- }
728
- // Bind guard overrides — prevents inversify from auto-wiring guard dependencies
729
- for (const [guardClass, stub] of this.guardOverrides){
730
- container.bind(guardClass).toConstantValue(stub);
731
- }
732
- for (const [filterClass, stub] of this.filterOverrides){
733
- container.bind(filterClass).toConstantValue(stub);
734
- }
735
- for (const [interceptorClass, stub] of this.interceptorOverrides){
736
- container.bind(interceptorClass).toConstantValue(stub);
737
- }
738
- // The app's cooldown policy, so a test of a failing store sees what the bot would do
739
- if (appOptions && !container.isBound(COOLDOWN_POLICY)) container.bind(COOLDOWN_POLICY).toConstantValue(cooldownPolicyFrom(appOptions));
740
- // The app's translator, unless a provider stands in for it
741
- if (i18n && !providers.has(Translator)) {
742
- container.bind(Translator).toConstantValue(i18n);
743
- bindsOwnToken(container, Translator);
744
- }
745
- // Recursively bind controllers and their dependencies, skipping already-bound tokens
746
- const bindClass = (cls)=>{
747
- // A provided class is bound by its own provider, wherever in the list that provider comes
748
- if (container.isBound(cls) || providers.has(cls)) return;
749
- if (injectedTokens(cls).includes(ExecutionContext)) throw singletonContextError(cls);
750
- assertStartupChecked(container, cls);
751
- makeInjectable(cls);
752
- container.bind(cls).toSelf().inSingletonScope();
753
- // By constructor type or @inject token, as the app binds them
754
- for (const dep of injectedTokens(cls)){
755
- if (dep === Translator && !container.isBound(Translator)) throw missingTranslatorError(cls);
756
- if (isAppClassToken(dep)) bindClass(dep);
757
- }
758
- };
759
- // The app's own store, resolved like a service, unless the test provides the CooldownStore itself; bound first, as the
760
- // app binds it, so a class that injects the token gets the store
761
- if (!providers.has(CooldownStore)) {
762
- if (store) {
763
- bindClass(store);
764
- container.bind(CooldownStore).toService(store);
765
- } else {
766
- container.bind(CooldownStore).toConstantValue(new MemoryCooldownStore());
767
- }
768
- bindsOwnToken(container, CooldownStore, store);
769
- }
770
- // Before the controllers, so a class token that is provided is not also bound as itself
771
- for (const provider of providers.values())bindProvider(container, provider, bindClass);
772
- for (const ctrl of this.options.controllers ?? []){
773
- bindClass(ctrl);
774
- // Stamp container on controller class so @UseGuard works in tests too
775
- Reflect.defineMetadata(META.container, container, ctrl);
776
- }
777
- for (const service of services)bindClass(service);
778
- if (themeResolver) bindClass(themeResolver);
779
- // The classes whose @On and @Once handlers emit reaches: class providers bound as themselves, the
780
- // controllers, and what they inject; factories resolve in the same order
781
- const order = resolutionOrder(container, providers, [
782
- ...providers.keys(),
783
- ...services,
784
- ...themeResolver ? [
785
- themeResolver
786
- ] : [],
787
- ...this.options.controllers ?? []
788
- ], {
789
- followOwnTokens: false
790
- });
791
- appClasses.push(...order.filter((token)=>{
792
- const provider = providers.get(token);
793
- return isAppClassToken(token) && (!provider || isClassProvider(provider) && provider.useClass === token);
794
- }));
795
- assertProvided(container, providers, startup.classes, "the testing module's providers");
796
- // Every class the module runs, so @UseGuard resolves guards on a direct call to any of them
797
- for (const cls of startup.classes)Reflect.defineMetadata(META.container, container, cls);
798
- prepareHandlerStages(container, appClasses);
799
- const messages = messagesOf(this.options.app);
800
- // As the app would at startup, refuses a message pattern that cannot be read or two that match the same messages
801
- buildMessageRoutes(this.options.controllers ?? [], messages);
802
- // As the app does when it is created, so a test sees the refusal the bot would give
803
- assertDistinctCommands(this.options.controllers ?? []);
804
- // A handler with no builder is how a fixture is written, so only what is always a mistake is named
805
- warnUnregisteredCommands(this.options.controllers ?? [], {
806
- missingBuilders: false
807
- });
808
- warnInheritedRoutes(this.options.controllers ?? []);
809
- warnOverlappingPatterns(this.options.controllers ?? []);
810
- warnHandlersOffControllers(this.options.controllers ?? [], appClasses);
811
- if (this.options.app) bindAppPresenter(container, this.options.app);
812
- const observers = [
813
- ...this.options.app ? appObservers(this.options.app) : [],
814
- ...this.options.observers ?? []
815
- ];
816
- assertObservers("the testing module's observers", observers);
817
- bindObservers(container, observers);
818
- // The store calls ask: the test's own when it provides CooldownStore, else the app's
819
- const boundStore = store ?? (providers.has(CooldownStore) ? CooldownStore : undefined);
820
- // The order the app runs lifecycle hooks in: its cooldown store, then providers, controllers and observers, each
821
- // after what it injects
822
- const lifecycle = resolutionOrder(container, providers, [
823
- ...boundStore ? [
824
- boundStore
825
- ] : [],
826
- ...providers.keys(),
827
- ...services,
828
- ...themeResolver ? [
829
- themeResolver
674
+ // With startupErrors: 'all', what the decorators kept on the classes this module runs
675
+ const declared = declaredErrorsOf([
676
+ ...this.options.app ? [
677
+ this.options.app
830
678
  ] : [],
831
679
  ...this.options.controllers ?? [],
832
- ...observers
833
- ]).map((token)=>({
834
- token,
835
- name: tokenName(token),
836
- dependencies: tokenDependencies(container, providers, token),
837
- ...token === boundStore && {
838
- cooldownStore: true
680
+ ...this.wiring?.services ?? [],
681
+ ...this.options.observers ?? []
682
+ ]);
683
+ // Every error each check finds is reported, as the bot reports them, and the first is thrown
684
+ try {
685
+ return collectStartupErrors(()=>{
686
+ declared.forEach(startupError);
687
+ const container = new Container();
688
+ const stages = this.globalStages();
689
+ if (stages) bindGlobalStages(container, stages);
690
+ // Bound first, as in the app, so a class that injects it gets this instance
691
+ const appClasses = [];
692
+ const translator = ()=>container.isBound(Translator) ? container.get(Translator) : undefined;
693
+ container.bind(HandlerRegistry).toConstantValue(new HandlerRegistry(appClasses, messagesOf(this.options.app), translator));
694
+ // A testing module runs as one process, so a cross-shard call runs once, here
695
+ container.bind(ShardContext).toConstantValue(new ShardContext(undefined, shardCallHandler(container, ()=>[
696
+ ...appClasses,
697
+ ...[
698
+ ...providers.keys()
699
+ ].filter(isAppClassToken)
700
+ ], 'this testing module')));
701
+ bindsOwnToken(container, HandlerRegistry);
702
+ bindsOwnToken(container, ShardContext);
703
+ // Checked as the app checks its own: the app's providers in their order, then the test's, which replace them
704
+ // by token, then the overrides, which win
705
+ const providers = providerMap(this.wiring?.providers ?? [], '@MeoCord({ providers })');
706
+ for (const [token, provider] of providerMap(this.options.providers ?? [], "the testing module's providers"))providers.set(token, provider);
707
+ for (const [token, override] of this.overrides)providers.set(token, override);
708
+ // Each pass of checks ends before the next step that is no check, so a module with one error stops where it always did
709
+ stopOnStartupErrors();
710
+ const services = this.wiring?.services ?? [];
711
+ const appOptions = this.options.app ? Reflect.getMetadata(META.appOptions, this.options.app) : undefined;
712
+ // The app's own store, as the bot binds it, whether the module is given the app or built from it; none when the
713
+ // test provides the CooldownStore, so nothing builds the app's or asks for what it injects
714
+ const store = providers.has(CooldownStore) ? undefined : appOptions?.cooldownStore;
715
+ // The themeFor class this module runs, the app's or overrideThemeFor's, bound on its own as the app binds it
716
+ const themeResolver = themeResolverClass(stages?.themeFor?.resolvers);
717
+ // Every class the module runs, as the app finds them, except stage classes an override stands in for
718
+ const i18n = this.options.app && Reflect.getMetadata(META.appOptions, this.options.app)?.i18n;
719
+ const startup = startupClasses({
720
+ controllers: this.options.controllers ?? [],
721
+ services,
722
+ providers,
723
+ stages,
724
+ presenter: this.options.app && Reflect.getMetadata(META.appOptions, this.options.app)?.presenter,
725
+ cooldownStore: store,
726
+ themeFor: stages?.themeFor?.resolvers,
727
+ observers: [
728
+ ...this.options.app ? appObservers(this.options.app) : [],
729
+ ...this.options.observers ?? []
730
+ ],
731
+ stubs: [
732
+ ...this.guardOverrides.keys(),
733
+ ...this.interceptorOverrides.keys(),
734
+ ...this.filterOverrides.keys()
735
+ ]
736
+ });
737
+ const hasTranslator = i18n !== undefined || providers.has(Translator);
738
+ assertStartupClasses(startup, {
739
+ translator: hasTranslator
740
+ });
741
+ // Those of the stages, guards and other classes they reach
742
+ declaredErrorsOf(startup.classes).forEach(startupError);
743
+ stopOnStartupErrors();
744
+ markStartupChecked(container, [
745
+ ...startup.classes,
746
+ ...meocordClasses()
747
+ ], lateGuardCheck(providers, hasTranslator));
748
+ // The bot binds the Client it logs in with; a test gives its own, and is told so where one is needed
749
+ const needClient = startup.classes.filter((cls)=>injectedTokens(cls).includes(Client));
750
+ if (needClient.length > 0 && !providers.has(Client)) {
751
+ container.bind(Client).toDynamicValue(()=>{
752
+ throw new Error(`${needClient.map((cls)=>cls.name).join(', ')} ${needClient.length === 1 ? 'injects' : 'inject'} the Discord Client, ` + 'which a testing module does not make: give one in its providers, such as ' + '{ provide: Client, useValue: createMockClient() }.');
753
+ });
754
+ }
755
+ // Bind guard overrides — prevents inversify from auto-wiring guard dependencies
756
+ for (const [guardClass, stub] of this.guardOverrides){
757
+ container.bind(guardClass).toConstantValue(stub);
758
+ }
759
+ for (const [filterClass, stub] of this.filterOverrides){
760
+ container.bind(filterClass).toConstantValue(stub);
761
+ }
762
+ for (const [interceptorClass, stub] of this.interceptorOverrides){
763
+ container.bind(interceptorClass).toConstantValue(stub);
764
+ }
765
+ // The app's cooldown policy, so a test of a failing store sees what the bot would do
766
+ if (appOptions && !container.isBound(COOLDOWN_POLICY)) container.bind(COOLDOWN_POLICY).toConstantValue(cooldownPolicyFrom(appOptions));
767
+ // The app's translator, unless a provider stands in for it
768
+ if (i18n && !providers.has(Translator)) {
769
+ container.bind(Translator).toConstantValue(i18n);
770
+ bindsOwnToken(container, Translator);
839
771
  }
840
- }));
841
- // Recorded as the container makes each one, so close() shuts down exactly what exists
842
- const constructed = new Set();
843
- for (const { token } of lifecycle){
844
- container.onActivation(token, (_context, instance)=>{
845
- constructed.add(token);
846
- return instance;
772
+ // Recursively bind controllers and their dependencies, skipping already-bound tokens
773
+ const bindClass = (cls)=>{
774
+ // A provided class is bound by its own provider, wherever in the list that provider comes
775
+ if (container.isBound(cls) || providers.has(cls)) return;
776
+ if (injectedTokens(cls).includes(ExecutionContext)) return startupError(singletonContextError(cls));
777
+ assertStartupChecked(container, cls);
778
+ makeInjectable(cls);
779
+ container.bind(cls).toSelf().inSingletonScope();
780
+ // By constructor type or @inject token, as the app binds them
781
+ for (const dep of injectedTokens(cls)){
782
+ if (dep === Translator && !container.isBound(Translator)) startupError(missingTranslatorError(cls));
783
+ else if (isAppClassToken(dep)) bindClass(dep);
784
+ }
785
+ };
786
+ // The app's own store, resolved like a service, unless the test provides the CooldownStore itself; bound first, as the
787
+ // app binds it, so a class that injects the token gets the store
788
+ if (!providers.has(CooldownStore)) {
789
+ if (store) {
790
+ bindClass(store);
791
+ container.bind(CooldownStore).toService(store);
792
+ } else {
793
+ container.bind(CooldownStore).toConstantValue(new MemoryCooldownStore());
794
+ }
795
+ bindsOwnToken(container, CooldownStore, store);
796
+ }
797
+ // Before the controllers, so a class token that is provided is not also bound as itself
798
+ for (const provider of providers.values())bindProvider(container, provider, bindClass);
799
+ for (const ctrl of this.options.controllers ?? [])bindClass(ctrl);
800
+ for (const service of services)bindClass(service);
801
+ if (themeResolver) bindClass(themeResolver);
802
+ // What follows walks what these bound, so a class refused here is not reported again as missing
803
+ stopOnStartupErrors();
804
+ // The classes whose @On and @Once handlers emit reaches: class providers bound as themselves, the
805
+ // controllers, and what they inject; factories resolve in the same order
806
+ const order = resolutionOrder(container, providers, [
807
+ ...providers.keys(),
808
+ ...services,
809
+ ...themeResolver ? [
810
+ themeResolver
811
+ ] : [],
812
+ ...this.options.controllers ?? []
813
+ ], {
814
+ followOwnTokens: false
815
+ });
816
+ appClasses.push(...order.filter((token)=>{
817
+ const provider = providers.get(token);
818
+ return isAppClassToken(token) && (!provider || isClassProvider(provider) && provider.useClass === token);
819
+ }));
820
+ assertProvided(container, providers, startup.classes, "the testing module's providers");
821
+ prepareHandlerStages(container, appClasses);
822
+ const messages = messagesOf(this.options.app);
823
+ // As the app would at startup, refuses a message pattern that cannot be read or two that match the same messages
824
+ buildMessageRoutes(this.options.controllers ?? [], messages);
825
+ // As the app does when it is created, so a test sees the refusal the bot would give
826
+ assertDistinctCommands(this.options.controllers ?? []);
827
+ if (this.options.app) prepareAppPresenter(container, this.options.app);
828
+ const observers = [
829
+ ...this.options.app ? appObservers(this.options.app) : [],
830
+ ...this.options.observers ?? []
831
+ ];
832
+ assertObservers("the testing module's observers", observers);
833
+ // Only @Observer classes are bound as observers
834
+ stopOnStartupErrors();
835
+ bindObservers(container, observers);
836
+ // The store calls ask: the test's own when it provides CooldownStore, else the app's
837
+ const boundStore = store ?? (providers.has(CooldownStore) ? CooldownStore : undefined);
838
+ // The order the app runs lifecycle hooks in: its cooldown store, then providers, controllers and observers, each
839
+ // after what it injects
840
+ const lifecycle = resolutionOrder(container, providers, [
841
+ ...boundStore ? [
842
+ boundStore
843
+ ] : [],
844
+ ...providers.keys(),
845
+ ...services,
846
+ ...themeResolver ? [
847
+ themeResolver
848
+ ] : [],
849
+ ...this.options.controllers ?? [],
850
+ ...observers
851
+ ]).map((token)=>({
852
+ token,
853
+ name: tokenName(token),
854
+ dependencies: tokenDependencies(container, providers, token),
855
+ ...token === boundStore && {
856
+ cooldownStore: true
857
+ }
858
+ }));
859
+ // Every check has run, and touched only the container: from here the module is made, once none found an error
860
+ stopOnStartupErrors();
861
+ // Every class the module runs, so @UseGuard resolves guards on a direct call to any of them
862
+ for (const cls of [
863
+ ...this.options.controllers ?? [],
864
+ ...startup.classes
865
+ ])Reflect.defineMetadata(META.container, container, cls);
866
+ prepareThemes(container, appClasses);
867
+ // A handler with no builder is how a fixture is written, so only what is always a mistake is named
868
+ warnUnregisteredCommands(this.options.controllers ?? [], {
869
+ missingBuilders: false
870
+ });
871
+ warnInheritedRoutes(this.options.controllers ?? []);
872
+ warnOverlappingPatterns(this.options.controllers ?? []);
873
+ warnHandlersOffControllers(this.options.controllers ?? [], appClasses);
874
+ if (this.options.app) bindAppPresenter(container, this.options.app);
875
+ // Recorded as the container makes each one, so close() shuts down exactly what exists
876
+ const constructed = new Set();
877
+ for (const { token } of lifecycle){
878
+ container.onActivation(token, (_context, instance)=>{
879
+ constructed.add(token);
880
+ return instance;
881
+ });
882
+ }
883
+ const warnUnanswered = this.options.app && Reflect.getMetadata(META.appOptions, this.options.app)?.warnUnanswered;
884
+ return new TestingModule(container, [
885
+ ...this.options.controllers ?? []
886
+ ], appClasses, providers, order, messages, lifecycle, constructed, warnUnanswered, services, this.options.shutdownTimeout ?? DEFAULT_SHUTDOWN_TIMEOUT_MS);
847
887
  });
888
+ } catch (error) {
889
+ if (isRefusal(error) && !isExplainedError(error)) {
890
+ if (logStartupErrors(error, (text)=>new Logger('TestingModule').error(text), 'the testing module did not compile')) markExplained(error);
891
+ }
892
+ throw error;
848
893
  }
849
- const warnUnanswered = this.options.app && Reflect.getMetadata(META.appOptions, this.options.app)?.warnUnanswered;
850
- return new TestingModule(container, [
851
- ...this.options.controllers ?? []
852
- ], appClasses, providers, order, messages, lifecycle, constructed, warnUnanswered, services, this.options.shutdownTimeout ?? DEFAULT_SHUTDOWN_TIMEOUT_MS);
853
894
  }
854
895
  constructor(options, wiring){
855
896
  this.options = options;
@@ -887,7 +928,7 @@ import { isUserOutcome } from '../common/user-outcome.js';
887
928
  * @category Module
888
929
  * @see {@link TestingModuleBuilder}
889
930
  * @see {@link TestingModule}
890
- * @see {@link https://meocord.dev/docs/4.1/testing | The testing module}
931
+ * @see {@link https://meocord.dev/docs/4.2/testing | The testing module}
891
932
  */ class MeoCordTestingModule {
892
933
  /**
893
934
  * Starts building a testing module from the classes a test needs.
@@ -27,12 +27,70 @@
27
27
  // Every mock createMockFn made, held weakly so the mocks of a finished test can still be collected
28
28
  const created = new Set();
29
29
  const collected = new FinalizationRegistry((ref)=>created.delete(ref));
30
+ // The runner's factory useMockFn registered, and for each of its mocks how to give back the implementation it was
31
+ // created with, through the runner's own setter, which a mock's behaviour may wrap
32
+ let runnerFactory;
33
+ const restoreImpl = new WeakMap();
34
+ /**
35
+ * Makes every mock `meocord/testing` creates with the test runner's own mock function, such as Vitest's `vi.fn`.
36
+ *
37
+ * Call it once in the runner's setup file. The runner then treats meocord's mocks as its own: its `clearMocks` and
38
+ * `mockReset` config and `vi.clearAllMocks()` reach them, its matchers read them, and its whole mock API is there.
39
+ * Without it, mocks are {@link createMockFn}'s own, which jest's and Vitest's `expect` read too.
40
+ *
41
+ * @remarks
42
+ * Each mock starts with the runner's mock holding meocord's behaviour, such as an interaction's replies, as its own
43
+ * implementation. {@link resetAllMocks} puts that behaviour back where the runner's `mockReset` drops it, as jest's and
44
+ * bun's do; a runner's own reset-all, such as jest's `resetMocks`, can't, so under jest and bun reset with
45
+ * {@link resetAllMocks}. node:test's `mock.fn` records calls in a shape of its own, so under node:test leave meocord's
46
+ * own function in place. Calling it again with the same factory does nothing; another, once a mock exists, throws.
47
+ *
48
+ * @param factory - The runner's mock function factory: `vi.fn`, `jest.fn` or bun's `mock`.
49
+ * @throws Error when a mock already exists from another factory, or when `factory` doesn't make a mock with jest's and
50
+ * Vitest's API.
51
+ *
52
+ * @example
53
+ * ```ts
54
+ * // vitest.setup.ts
55
+ * import { vi } from 'vitest'
56
+ *
57
+ * useMockFn(vi.fn)
58
+ * ```
59
+ *
60
+ * @group Testing
61
+ * @category Mocks
62
+ * @see {@link createMockFn}
63
+ */ function useMockFn(factory) {
64
+ if (factory === runnerFactory) return;
65
+ if ([
66
+ ...created
67
+ ].some((ref)=>ref.deref() !== undefined)) {
68
+ throw new Error("useMockFn() goes before any mock is made: a mock made before it keeps meocord's own function, and the two kinds " + "would mix. Call it once, in the test runner's setup file.");
69
+ }
70
+ const probe = factory();
71
+ const api = [
72
+ 'mockImplementation',
73
+ 'mockClear',
74
+ 'mockReset'
75
+ ];
76
+ if (!isMockFunction(probe) || !Array.isArray(probe.mock?.calls) || api.some((name)=>typeof probe[name] !== 'function')) {
77
+ throw new Error("useMockFn() takes a function that makes mocks with jest's and Vitest's API, such as vi.fn, jest.fn or bun's " + "mock. node:test's mock.fn records calls in its own shape, so under node:test leave meocord's own mock function " + 'in place.');
78
+ }
79
+ runnerFactory = factory;
80
+ }
81
+ /** Tracks a mock for clearAllMocks and resetAllMocks. */ function track(mockFn) {
82
+ const ref = new WeakRef(mockFn);
83
+ created.add(ref);
84
+ collected.register(mockFn, ref);
85
+ return mockFn;
86
+ }
30
87
  /**
31
88
  * Clears the calls every mock from `meocord/testing` has recorded, keeping what each was told to do.
32
89
  *
33
- * Vitest's `clearMocks` and jest's `clearAllMocks()` reach only their own `vi.fn()` and `jest.fn()` mocks. This one
34
- * covers `createMockFn` and everything built on it, such as `createMockInteraction` and `createMockClient`. To also
35
- * undo what a test set, use {@link resetAllMocks}.
90
+ * Vitest's `clearMocks` and jest's `clearAllMocks()` reach only their own `vi.fn()` and `jest.fn()` mocks, and
91
+ * meocord's too once {@link useMockFn} makes them with the runner's function. This one covers `createMockFn` and
92
+ * everything built on it, such as `createMockInteraction` and `createMockClient`, either way. To also undo what a test
93
+ * set, use {@link resetAllMocks}.
36
94
  *
37
95
  * @example
38
96
  * ```ts
@@ -68,13 +126,20 @@ const collected = new FinalizationRegistry((ref)=>created.delete(ref));
68
126
  * @category Mocks
69
127
  * @see {@link clearAllMocks}
70
128
  */ function resetAllMocks() {
71
- for (const ref of created)ref.deref()?.mockReset();
129
+ for (const ref of created){
130
+ const mockFn = ref.deref();
131
+ if (!mockFn) continue;
132
+ mockFn.mockReset();
133
+ // A runner whose reset drops the implementation the mock was created with, as jest's and bun's do, gets it back
134
+ restoreImpl.get(mockFn)?.();
135
+ }
72
136
  }
73
137
  /**
74
138
  * Creates a mock function that jest's and Vitest's `expect` both read.
75
139
  *
76
140
  * Use it for a function double in code that must not depend on one test runner, such as a shared test helper; in a
77
- * Vitest test, `vi.fn()` works as well. {@link clearAllMocks} and {@link resetAllMocks} reach it.
141
+ * Vitest test, `vi.fn()` works as well. {@link clearAllMocks} and {@link resetAllMocks} reach it. Once
142
+ * {@link useMockFn} registers a runner's function, it returns that runner's mock.
78
143
  *
79
144
  * @remarks
80
145
  * `mockReturnValue`, `mockResolvedValue`, `mockRejectedValue` and `mockImplementation` share one implementation, so
@@ -97,6 +162,14 @@ const collected = new FinalizationRegistry((ref)=>created.delete(ref));
97
162
  * @category Mocks
98
163
  * @see {@link MockInstance}
99
164
  */ function createMockFn(impl) {
165
+ if (runnerFactory) {
166
+ const runnerMock = runnerFactory(impl);
167
+ if (impl) {
168
+ const setImpl = runnerMock.mockImplementation.bind(runnerMock);
169
+ restoreImpl.set(runnerMock, ()=>setImpl(impl));
170
+ }
171
+ return track(runnerMock);
172
+ }
100
173
  // One persistent implementation and one queue of single-use ones, as in jest and vitest: the
101
174
  // four setters share the slot, so the last call wins, and the `*Once` variants share the queue,
102
175
  // so they run in declaration order.
@@ -209,10 +282,7 @@ const collected = new FinalizationRegistry((ref)=>created.delete(ref));
209
282
  name = n;
210
283
  return mockFn;
211
284
  };
212
- const ref = new WeakRef(mockFn);
213
- created.add(ref);
214
- collected.register(mockFn, ref);
215
- return mockFn;
285
+ return track(mockFn);
216
286
  }
217
287
 
218
- export { clearAllMocks, createMockFn, isMockFunction, resetAllMocks };
288
+ export { clearAllMocks, createMockFn, isMockFunction, resetAllMocks, useMockFn };