meocord 4.1.2 → 4.2.1

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 (140) hide show
  1. package/CHANGELOG.md +362 -0
  2. package/THIRD_PARTY_NOTICES.md +14 -13
  3. package/dist/cjs/_shared/{controller.decorator-BywwkU9O.cjs → controller.decorator-XKMlcUZz.cjs} +669 -185
  4. package/dist/cjs/_shared/{providers-BMMLp2SN.cjs → providers-BZgJyfZS.cjs} +218 -75
  5. package/dist/cjs/_shared/{sharded-cooldown-store-B65iaiAy.cjs → sharded-cooldown-store-CsJJyCZd.cjs} +10 -7
  6. package/dist/cjs/_shared/{startup-roots-lFc69AxC.cjs → startup-roots-CgfPQrNl.cjs} +374 -132
  7. package/dist/cjs/_shared/{translate-error-BnM8FbSH.cjs → translate-error-BUAR1WYG.cjs} +21 -7
  8. package/dist/cjs/common/index.cjs +4 -4
  9. package/dist/cjs/core/index.cjs +365 -310
  10. package/dist/cjs/decorator/index.cjs +85 -61
  11. package/dist/cjs/testing/index.cjs +2236 -496
  12. package/dist/cli.json +1 -1
  13. package/dist/esm/bin/app-template/README.md.template +3 -1
  14. package/dist/esm/bin/app-template/src/controllers/button/sample.button.controller.spec.ts.template +8 -4
  15. package/dist/esm/bin/app-template/src/controllers/button/sample.button.controller.ts.template +2 -2
  16. package/dist/esm/bin/app-template/src/guards/owner.guard.spec.ts.template +8 -4
  17. package/dist/esm/bin/app-template/src/guards/owner.guard.ts.template +1 -1
  18. package/dist/esm/bin/app-template/vitest.config.ts.template +1 -1
  19. package/dist/esm/bin/app-template/vitest.setup.ts.template +7 -3
  20. package/dist/esm/bin/generator.js +4 -5
  21. package/dist/esm/bin/helper/filter-generator.helper.js +4 -5
  22. package/dist/esm/bin/helper/guard-generator.helper.js +4 -5
  23. package/dist/esm/bin/helper/interceptor-generator.helper.js +4 -5
  24. package/dist/esm/bin/helper/observer-generator.helper.js +4 -5
  25. package/dist/esm/bin/helper/pipe-generator.helper.js +4 -5
  26. package/dist/esm/bin/helper/service-generator.helper.js +4 -5
  27. package/dist/esm/bin/meocord.js +84 -25
  28. package/dist/esm/build/load-config.pre-entry.js +17 -4
  29. package/dist/esm/build/rsbuild-config.js +16 -12
  30. package/dist/esm/build/typed-externals.js +36 -0
  31. package/dist/esm/common/cooldown-store.js +10 -2
  32. package/dist/esm/common/decorator.js +6 -6
  33. package/dist/esm/common/deprecation.js +2 -2
  34. package/dist/esm/common/errors.js +27 -2
  35. package/dist/esm/common/execution-context.js +1 -1
  36. package/dist/esm/common/meocord-text.js +11 -8
  37. package/dist/esm/common/metadata.js +1 -1
  38. package/dist/esm/common/response/call-order.js +9 -4
  39. package/dist/esm/common/response/presenter.js +33 -6
  40. package/dist/esm/common/response/response-state.js +84 -10
  41. package/dist/esm/common/route.js +21 -5
  42. package/dist/esm/common/sharded-cooldown-store.js +1 -1
  43. package/dist/esm/common/translate-error.js +1 -1
  44. package/dist/esm/common/translator.js +37 -6
  45. package/dist/esm/core/command-conflicts.js +34 -34
  46. package/dist/esm/core/command-registration.js +93 -24
  47. package/dist/esm/core/command-routes.js +2 -2
  48. package/dist/esm/core/component-routes.js +74 -31
  49. package/dist/esm/core/cooldown-runner.js +29 -8
  50. package/dist/esm/core/dispatcher.js +51 -7
  51. package/dist/esm/core/fallback.js +20 -11
  52. package/dist/esm/core/filter-runner.js +2 -2
  53. package/dist/esm/core/guard-runner.js +16 -3
  54. package/dist/esm/core/handler-input.js +4 -3
  55. package/dist/esm/core/handler-pipeline.js +27 -14
  56. package/dist/esm/core/handler-registry.js +1 -1
  57. package/dist/esm/core/interceptor-runner.js +2 -2
  58. package/dist/esm/core/lifecycle-hooks.js +134 -47
  59. package/dist/esm/core/meocord-factory.js +241 -179
  60. package/dist/esm/core/meocord.app.js +116 -75
  61. package/dist/esm/core/message-routes.js +4 -3
  62. package/dist/esm/core/observer-runner.js +4 -3
  63. package/dist/esm/core/providers.js +45 -23
  64. package/dist/esm/core/route-specificity.js +4 -4
  65. package/dist/esm/core/running-calls.js +43 -0
  66. package/dist/esm/core/scalar-types.js +15 -3
  67. package/dist/esm/core/shard-context.js +6 -7
  68. package/dist/esm/core/shard-manager.js +16 -10
  69. package/dist/esm/core/startup-roots.js +23 -4
  70. package/dist/esm/core/theme-resolvers.js +4 -1
  71. package/dist/esm/core/theme-runtime.js +2 -2
  72. package/dist/esm/core/theme-scope.js +3 -1
  73. package/dist/esm/core/theme-validation.js +17 -10
  74. package/dist/esm/decorator/app.decorator.js +12 -7
  75. package/dist/esm/decorator/command-builder.decorator.js +4 -4
  76. package/dist/esm/decorator/controller-class.decorator.js +9 -4
  77. package/dist/esm/decorator/controller.decorator.js +162 -83
  78. package/dist/esm/decorator/cooldown.decorator.js +20 -3
  79. package/dist/esm/decorator/defer.decorator.js +4 -4
  80. package/dist/esm/decorator/event.decorator.js +5 -5
  81. package/dist/esm/decorator/filter.decorator.js +7 -7
  82. package/dist/esm/decorator/guard.decorator.js +11 -10
  83. package/dist/esm/decorator/inject.decorator.js +4 -4
  84. package/dist/esm/decorator/interceptor.decorator.js +7 -7
  85. package/dist/esm/decorator/observer.decorator.js +4 -4
  86. package/dist/esm/decorator/service.decorator.js +4 -4
  87. package/dist/esm/decorator/theme.decorator.js +3 -3
  88. package/dist/esm/decorator/validation.decorator.js +10 -10
  89. package/dist/esm/package.json.js +1 -1
  90. package/dist/esm/testing/catalog.js +27 -11
  91. package/dist/esm/testing/cooldown-store-suite.js +85 -15
  92. package/dist/esm/testing/discord-defaults.js +249 -21
  93. package/dist/esm/testing/index.js +4 -2
  94. package/dist/esm/testing/inspect-handler.js +20 -3
  95. package/dist/esm/testing/meocord-testing-module.js +352 -205
  96. package/dist/esm/testing/mock-fn.js +173 -22
  97. package/dist/esm/testing/mock-interaction.js +1214 -209
  98. package/dist/esm/testing/response.js +1 -1
  99. package/dist/esm/testing/routing.js +39 -14
  100. package/dist/esm/testing/snowflake.js +9 -2
  101. package/dist/esm/testing/startup-errors.js +26 -0
  102. package/dist/esm/testing/strict-mocks.js +65 -0
  103. package/dist/esm/testing/theme.js +1 -1
  104. package/dist/esm/util/bundle-entry.util.js +2 -1
  105. package/dist/esm/util/common.util.js +47 -19
  106. package/dist/esm/util/dev-runner.util.js +8 -8
  107. package/dist/esm/util/generator-cli.util.js +4 -5
  108. package/dist/esm/util/interaction.util.js +3 -3
  109. package/dist/esm/util/meocord-config-loader.util.js +10 -2
  110. package/dist/esm/util/meocord-config-validation.util.js +4 -5
  111. package/dist/esm/util/meocord-source-config.util.js +65 -20
  112. package/dist/esm/util/metadata-keys.js +1 -0
  113. package/dist/esm/util/method-wrapper.util.js +16 -0
  114. package/dist/esm/util/platform.util.js +10 -10
  115. package/dist/esm/util/refusal.util.js +148 -8
  116. package/dist/esm/util/shard-process.util.js +4 -1
  117. package/dist/esm/util/sharding-mode.util.js +5 -7
  118. package/dist/esm/util/shared-state.util.js +21 -0
  119. package/dist/esm/util/tsconfig-paths.util.js +29 -0
  120. package/dist/esm/util/tsconfig.util.js +10 -10
  121. package/dist/esm/util/value.util.js +12 -1
  122. package/dist/types/common/index.d.cts +8 -8
  123. package/dist/types/common/index.d.ts +8 -8
  124. package/dist/types/core/index.d.cts +10 -7
  125. package/dist/types/core/index.d.ts +10 -7
  126. package/dist/types/decorator/index.d.cts +31 -29
  127. package/dist/types/decorator/index.d.ts +31 -29
  128. package/dist/types/{index-D7n0FBMm.d.ts → index-DKJclIi-.d.ts} +107 -36
  129. package/dist/types/{index-DEP0NYrU.d.cts → index-Dlf0ZZtS.d.cts} +107 -36
  130. package/dist/types/interface/index.d.cts +1 -1
  131. package/dist/types/interface/index.d.ts +1 -1
  132. package/dist/types/{route-C-aMI7Sl.d.cts → route-Bh1U13yy.d.cts} +8 -4
  133. package/dist/types/{route-C-aMI7Sl.d.ts → route-Bh1U13yy.d.ts} +8 -4
  134. package/dist/types/testing/index.d.cts +406 -70
  135. package/dist/types/testing/index.d.ts +406 -70
  136. package/dist/types/{theme-resolvers-COHdX5g6.d.cts → theme-resolvers-CyEz9pnv.d.cts} +1 -1
  137. package/dist/types/{theme-resolvers-DedxyscT.d.ts → theme-resolvers-DA204O-q.d.ts} +1 -1
  138. package/dist/types/{translator-J6tIUfdp.d.ts → translator-Bg7hwxDF.d.cts} +21 -8
  139. package/dist/types/{translator-B4mc1lzJ.d.cts → translator-CeP4bCzp.d.ts} +21 -8
  140. package/package.json +4 -3
@@ -6,8 +6,8 @@ import { ReactionHandlerAction } from '../enum/controller.enum.js';
6
6
  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
- import { injectedTokens, singletonContextError } from '../core/guard-runner.js';
10
- import { appPresenterOf, runHandler, appStages, bindGlobalStages, prepareHandlerStages, bindAppPresenter } from '../core/handler-pipeline.js';
9
+ import { injectedTokens, singletonContextError, recordInstanceContainers } from '../core/guard-runner.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,21 +21,26 @@ 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
- import { startupClasses, assertStartupClasses, lateGuardCheck } from '../core/startup-roots.js';
26
+ import { startupClasses, assertStartupClasses, lateGuardCheck, stageDependencies } 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
- import { lifecycleEntry, runShutdownSequence, runReadyHooks, callsSettled } from '../core/lifecycle-hooks.js';
32
- import { createMockClient } from './mock-interaction.js';
31
+ import { runShutdownSequence, runReadyHooks, ReadyPass, lifecycleEntry, callsSettled } from '../core/lifecycle-hooks.js';
32
+ import { createMockClient, noteReactionDispatch } 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';
41
+ import { getReactionHandlers } from '../decorator/controller.decorator.js';
42
+ import { strictMocks } from './strict-mocks.js';
43
+ import { warnOnce } from '../common/deprecation.js';
39
44
  import { META } from '../util/metadata-keys.js';
40
45
  import { isUserOutcome } from '../common/user-outcome.js';
41
46
 
@@ -69,7 +74,7 @@ import { isUserOutcome } from '../common/user-outcome.js';
69
74
  * @category Module
70
75
  * @see {@link MeoCordTestingModule}
71
76
  * @see {@link getResponse}
72
- * @see {@link https://meocord.dev/docs/4.1/invoke-and-dispatch | Invoke and dispatch}
77
+ * @see {@link https://meocord.dev/docs/4.2/invoke-and-dispatch | Invoke and dispatch}
73
78
  */ class TestingModule {
74
79
  /**
75
80
  * Resolves the module's `useFactory` providers, awaiting those that return a promise, in dependency
@@ -108,7 +113,7 @@ import { isUserOutcome } from '../common/user-outcome.js';
108
113
  await this.resolving;
109
114
  if (options.ready) {
110
115
  const { client = createMockClient(), primary = true } = options.ready === true ? {} : options.ready;
111
- this.readying ?? (this.readying = this.runReady(client, primary));
116
+ this.readying ?? (this.readying = this.readyPass.run(()=>this.runReady(client, primary)));
112
117
  await this.readying;
113
118
  }
114
119
  return this;
@@ -123,12 +128,21 @@ import { isUserOutcome } from '../common/user-outcome.js';
123
128
  * It first gives up the theme read outside calls, if `init({ ready: true })` made it this module's;
124
129
  * reads outside calls then return MeoCord's defaults until another module or app is ready.
125
130
  *
131
+ * A stopped bot runs nothing more. Once `close()` is called, a module still runs what `dispatch`, `invoke` and `emit`
132
+ * are given, against services shut down, or shutting down, and warns once that it is closed, so a `close()` in the
133
+ * wrong place shows.
134
+ *
126
135
  * The cooldown store and what it injects shut down last, in the same sequence as the bot's. When any of them has an
127
136
  * `onShutdown`, the calls `invoke`, `dispatch` and `emit` have under way finish first, then the store operations
128
137
  * they started, so the store's last writes still reach it. The module stops waiting for the whole sequence after
129
138
  * its `shutdownTimeout`, 10 seconds unless set, and logs that it did. Give a test whose fake store never answers, or
130
139
  * whose `onShutdown` never settles, a short `shutdownTimeout`.
131
140
  *
141
+ * A `close()` while `init({ ready: true })` runs the `onReady` hooks waits for them first, within the same
142
+ * `shutdownTimeout`. An `onReady` still running once only the hooks' share of it is left is named in a warning, and
143
+ * its class is shut down all the same, as everything the module constructed is. One that calls `close()` itself isn't
144
+ * waited for.
145
+ *
132
146
  * @returns Once every hook has run. Rejects with the error of a hook that failed, or an
133
147
  * `AggregateError` naming each when several did.
134
148
  *
@@ -143,14 +157,18 @@ import { isUserOutcome } from '../common/user-outcome.js';
143
157
  * expect(module.get(POOL).ended).toBe(true)
144
158
  * ```
145
159
  */ async close() {
160
+ // An onReady awaiting close() would otherwise hold up the close that waits for it
161
+ this.readyPass.excuseCurrent();
146
162
  this.closing ?? (this.closing = (async ()=>{
147
- // A close during init waits for the hooks it started, so it shuts down whatever they constructed
148
- await this.readying?.catch(()=>undefined);
149
- const entries = this.lifecycle.filter((unit)=>this.constructed.has(unit.token))// Already made, so this returns the instance; a provided value may be anything, null included
150
- .map((unit)=>lifecycleEntry(unit, this.container.get(unit.token) ?? {}));
163
+ // A close during init waits for the providers it started, and the sequence for its onReady hooks, so it shuts
164
+ // down whatever they constructed
165
+ await this.resolving?.catch(()=>undefined);
166
+ const entries = ()=>this.lifecycle.filter((unit)=>this.constructed.has(unit.token))// Already made, so this returns the instance; a provided value may be anything, null included
167
+ .map((unit)=>lifecycleEntry(unit, this.container.get(unit.token) ?? {}));
151
168
  const failures = [];
152
169
  await runShutdownSequence(this.container, entries, {
153
170
  drainCalls: ()=>callsSettled(this.calls),
171
+ readyPass: this.readyPass,
154
172
  timeoutMs: this.shutdownTimeout,
155
173
  hookFailed: (name, error)=>failures.push({
156
174
  name,
@@ -164,6 +182,11 @@ import { isUserOutcome } from '../common/user-outcome.js';
164
182
  })());
165
183
  await this.closing;
166
184
  }
185
+ /** Warns once, after `close()`, that a call runs against services already shut down. */ warnIfClosed() {
186
+ if (this.closing === undefined || this.warnedClosed) return;
187
+ this.warnedClosed = true;
188
+ new Logger('TestingModule').warn('This testing module is closed, and its services are shut down, or shutting down, so dispatch, invoke and emit run ' + 'against that state. Close it after the last call, in afterEach.');
189
+ }
167
190
  async runReady(client, primary) {
168
191
  // As the bot claims it before it comes online, so onReady reads the app's theme
169
192
  claimAmbientAppTheme(this.container);
@@ -179,6 +202,8 @@ import { isUserOutcome } from '../common/user-outcome.js';
179
202
  }, [], {
180
203
  resolveFailed: failed,
181
204
  hookFailed: failed
205
+ }, {
206
+ runOnReady: this.readyPass.runOnReady
182
207
  });
183
208
  throwFailures('onReady', failures);
184
209
  }
@@ -250,7 +275,9 @@ import { isUserOutcome } from '../common/user-outcome.js';
250
275
  * @returns Whether the handler ran, and the error a filter handled, if any. Rejects with an error no
251
276
  * filter handles, or with the error a filter throws: the built-in fallback, which answers such
252
277
  * errors in the bot, does not run here. Rejects before running anything with an interaction or a
253
- * message the handler's route does not match, naming both.
278
+ * message the handler's route does not match, naming both. Given fewer arguments than the handler
279
+ * declares, with a first that is neither an interaction nor a message, such as a reaction without its
280
+ * `ReactionEvent`, it rejects under `useStrictMocks()`, and otherwise warns once and runs the handler.
254
281
  *
255
282
  * @example
256
283
  * ```ts
@@ -272,6 +299,7 @@ import { isUserOutcome } from '../common/user-outcome.js';
272
299
  * expect(outcome.error).toBeInstanceOf(GuardDeniedError)
273
300
  * ```
274
301
  */ async invoke(controller, methodName, ...args) {
302
+ this.warnIfClosed();
275
303
  await this.init();
276
304
  if (!this.controllers.includes(controller)) {
277
305
  throw new Error(`${controller.name} is not a controller of this testing module. Add it to \`controllers\`.`);
@@ -301,6 +329,7 @@ import { isUserOutcome } from '../common/user-outcome.js';
301
329
  hooks = messageCommandHooks(route, params, first, start, given, this.messageOptions.types);
302
330
  }
303
331
  }
332
+ this.checkArgumentCount(controller, methodName, instance[methodName], first, callArgs.length);
304
333
  const presenter = appPresenterOf(this.container);
305
334
  // A message command's errors are drawn by the app's presenter too, as an interaction's are
306
335
  const client = first instanceof BaseInteraction || first instanceof Message ? first.client : undefined;
@@ -317,6 +346,19 @@ import { isUserOutcome } from '../common/user-outcome.js';
317
346
  error
318
347
  };
319
348
  }
349
+ /**
350
+ * Refuses under `useStrictMocks()`, or warns once otherwise, a call that gives a handler fewer arguments than it
351
+ * declares when `invoke` builds none for it, as for a reaction given without its `ReactionEvent`.
352
+ */ checkArgumentCount(controller, methodName, method, first, given) {
353
+ if (first instanceof BaseInteraction || first instanceof Message || method.length <= given) return;
354
+ const reaction = getReactionHandlers(controller.prototype).some((handler)=>handler.method === methodName);
355
+ const problem = `${controller.name}.${methodName} takes ${method.length} arguments, and invoke was given ${given}: ` + (reaction ? 'pass its ReactionEvent after the reaction, as { user, action }.' : 'pass each argument it takes.');
356
+ if (strictMocks()) throw new Error(problem);
357
+ const key = `${controller.name}.${methodName}`;
358
+ if (this.warnedArgumentCounts.has(key)) return;
359
+ this.warnedArgumentCounts.add(key);
360
+ new Logger('TestingModule').warn(problem);
361
+ }
320
362
  /** Counts `call` as under way until it settles. */ track(call) {
321
363
  this.calls.add(call);
322
364
  void call.then(()=>this.calls.delete(call), ()=>this.calls.delete(call));
@@ -343,6 +385,8 @@ import { isUserOutcome } from '../common/user-outcome.js';
343
385
  controllerClasses: this.controllers,
344
386
  messageOptions: this.messageOptions,
345
387
  logger,
388
+ // A test's fake clock can pass 5 s inside a handler, so the slow-handler warning waits to be asked for
389
+ warnSlowMessageHandlers: false,
346
390
  // Strict: an answer MeoCord fails to build rejects the dispatch, where a bot logs it and carries on
347
391
  fallback: createFallback(logger, ()=>this.messageOptions, {
348
392
  strict: true,
@@ -360,6 +404,7 @@ import { isUserOutcome } from '../common/user-outcome.js';
360
404
  return this.dispatcher;
361
405
  }
362
406
  async dispatch(input, options) {
407
+ this.warnIfClosed();
363
408
  await this.init();
364
409
  this.registerClient(input);
365
410
  const dispatcher = this.dispatcherOf();
@@ -377,6 +422,7 @@ import { isUserOutcome } from '../common/user-outcome.js';
377
422
  unhandled: (error)=>unhandled.push(error)
378
423
  };
379
424
  if (options) {
425
+ noteReactionDispatch(input);
380
426
  await this.track(dispatcher.reaction(input, {
381
427
  user: options.user,
382
428
  action: options.action ?? ReactionHandlerAction.ADD
@@ -406,15 +452,20 @@ import { isUserOutcome } from '../common/user-outcome.js';
406
452
  };
407
453
  }
408
454
  /**
409
- * Emits a client event to the module's `@On` and `@Once` handlers, through the same pipeline the app
410
- * runs them in: the global guards of the module's `app`, then each handler's own. Handlers on the
411
- * module's controllers, class providers and their dependencies all receive it. A `@Once` handler
412
- * handles only the first event, as it would on a client.
455
+ * Emits a client event to the module's `@On` and `@Once` handlers, through the pipeline the app runs
456
+ * them in up to its event fallback: the global guards of the module's `app`, then each handler's own.
457
+ * Handlers on the module's controllers, class providers and their dependencies all receive it. A
458
+ * `@Once` handler handles only the first event, as it would on a client.
459
+ *
460
+ * The bot's event fallback doesn't run here. On a bot, a guard's `GuardDeniedError` is logged and the
461
+ * event skipped, and a handler's `UserError` is logged and, when the event carries a message, answered
462
+ * as a reply to it; here both reject `emit` as any error does, so a test checks them with `rejects`.
413
463
  *
414
464
  * @param event - The client event, such as `'guildMemberAdd'`.
415
465
  * @param args - The event's arguments, typed from discord.js's `ClientEvents`.
416
- * @returns How many handlers ran. Rejects once every handler has settled if any threw: with that
417
- * error when one handler failed, or an `AggregateError` of them when several did.
466
+ * @returns How many handlers ran. Rejects once every handler has settled if any threw, a guard's
467
+ * `GuardDeniedError` and a handler's `UserError` included: with that error when one handler failed,
468
+ * or an `AggregateError` of them when several did.
418
469
  *
419
470
  * @example
420
471
  * ```ts
@@ -426,6 +477,7 @@ import { isUserOutcome } from '../common/user-outcome.js';
426
477
  * expect(ran).toBe(1)
427
478
  * ```
428
479
  */ async emit(event, ...args) {
480
+ this.warnIfClosed();
429
481
  await this.init();
430
482
  const calls = [];
431
483
  for (const cls of this.eventClasses){
@@ -470,7 +522,10 @@ import { isUserOutcome } from '../common/user-outcome.js';
470
522
  this.appWarnUnanswered = appWarnUnanswered;
471
523
  this.services = services;
472
524
  this.shutdownTimeout = shutdownTimeout;
525
+ /** The `onReady` hooks' pass, which `close()` waits for, less one whose `onReady` called `close()`. */ this.readyPass = new ReadyPass();
526
+ this.warnedClosed = false;
473
527
  /** The `@Once` handlers that have already handled their event, as a client forgets its once listeners. */ this.firedOnce = new Set();
528
+ /** The handlers already warned about for being given fewer arguments than they take, so each is named once. */ this.warnedArgumentCounts = new Set();
474
529
  /** The calls under way, each removed once it settles: what `close()` lets finish before the cooldown store stops. */ this.calls = new Set();
475
530
  }
476
531
  }
@@ -509,7 +564,7 @@ import { isUserOutcome } from '../common/user-outcome.js';
509
564
  * @group Testing
510
565
  * @category Module
511
566
  * @see {@link MeoCordTestingModule}
512
- * @see {@link https://meocord.dev/docs/4.1/testing | The testing module}
567
+ * @see {@link https://meocord.dev/docs/4.2/testing | The testing module}
513
568
  */ class TestingModuleBuilder {
514
569
  /**
515
570
  * Replaces a provider with a test double.
@@ -535,6 +590,10 @@ import { isUserOutcome } from '../common/user-outcome.js';
535
590
  /**
536
591
  * Replaces a guard with a stub wherever it applies, globally or on a controller or handler.
537
592
  *
593
+ * It stands in for the guard wherever the guard is bound, a provider of the same token included, and wins over an
594
+ * `overrideProvider` of it. The stub needs a `canActivate`, its own or its prototype's: `compile()` refuses one without
595
+ * it under `useStrictMocks()`, and otherwise warns.
596
+ *
538
597
  * @param guard - The guard class to replace.
539
598
  * @example
540
599
  * ```ts
@@ -552,6 +611,10 @@ import { isUserOutcome } from '../common/user-outcome.js';
552
611
  * Replaces an interceptor with a stub wherever it applies, globally or on a controller or handler.
553
612
  * The stub's `intercept` receives the context and `next`; call `next.handle()` to run the handler.
554
613
  *
614
+ * It stands in for the interceptor wherever it is bound, a provider of the same token included, and wins over an
615
+ * `overrideProvider` of it. A stub without `intercept` is refused by `compile()` under `useStrictMocks()`, and
616
+ * otherwise warned about.
617
+ *
555
618
  * @param interceptor - The interceptor class to replace.
556
619
  * @example
557
620
  * ```ts
@@ -569,6 +632,10 @@ import { isUserOutcome } from '../common/user-outcome.js';
569
632
  * Replaces an exception filter with a stub wherever it applies. The filter's `@Catch` still decides
570
633
  * which errors reach the stub.
571
634
  *
635
+ * It stands in for the filter wherever it is bound, a provider of the same token included, and wins over an
636
+ * `overrideProvider` of it. A stub without `catch` is refused by `compile()` under `useStrictMocks()`, and otherwise
637
+ * warned about.
638
+ *
572
639
  * @param filter - The filter class to replace.
573
640
  * @example
574
641
  * ```ts
@@ -657,199 +724,271 @@ import { isUserOutcome } from '../common/user-outcome.js';
657
724
  * its command's builder does not register, a customId pattern given as a command name, a builder that registers
658
725
  * another name, and component patterns that can match the same customId are named, as the bot names them.
659
726
  *
727
+ * Each check reports every startup error it finds, and the first is thrown, unchanged; when there are several, each
728
+ * is logged first, with the file it comes from. After {@link reportAllStartupErrors}, the errors decorators kept on
729
+ * the classes the module runs are among them.
730
+ *
660
731
  * @returns The compiled module.
732
+ * @throws Error for the first startup error the checks find.
661
733
  */ compile() {
662
734
  // The bound the bot's config gives it, with the same words
663
735
  const shutdownTimeout = this.options.shutdownTimeout === undefined ? undefined : shutdownTimeoutProblem(this.options.shutdownTimeout);
664
736
  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
737
+ // With startupErrors: 'all', what the decorators kept on the classes this module runs
738
+ const declared = declaredErrorsOf([
739
+ ...this.options.app ? [
740
+ this.options.app
830
741
  ] : [],
831
742
  ...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
743
+ ...this.wiring?.services ?? [],
744
+ ...this.options.observers ?? []
745
+ ]);
746
+ // Every error each check finds is reported, as the bot reports them, and the first is thrown
747
+ try {
748
+ return collectStartupErrors(()=>{
749
+ declared.forEach(startupError);
750
+ const container = new Container();
751
+ const stages = this.globalStages();
752
+ if (stages) bindGlobalStages(container, stages);
753
+ // Bound first, as in the app, so a class that injects it gets this instance
754
+ const appClasses = [];
755
+ const translator = ()=>container.isBound(Translator) ? container.get(Translator) : undefined;
756
+ container.bind(HandlerRegistry).toConstantValue(new HandlerRegistry(appClasses, messagesOf(this.options.app), translator));
757
+ // A testing module runs as one process, so a cross-shard call runs once, here
758
+ container.bind(ShardContext).toConstantValue(new ShardContext(undefined, shardCallHandler(container, ()=>[
759
+ ...appClasses,
760
+ ...[
761
+ ...providers.keys()
762
+ ].filter(isAppClassToken)
763
+ ], 'this testing module')));
764
+ bindsOwnToken(container, HandlerRegistry);
765
+ bindsOwnToken(container, ShardContext);
766
+ // Checked as the app checks its own: the app's providers in their order, then the test's, which replace them
767
+ // by token, then the overrides, which win
768
+ const providers = providerMap(this.wiring?.providers ?? [], '@MeoCord({ providers })');
769
+ for (const [token, provider] of providerMap(this.options.providers ?? [], "the testing module's providers"))providers.set(token, provider);
770
+ for (const [token, override] of this.overrides)providers.set(token, override);
771
+ // A stage override stands in for its class wherever the class is bound. A stage also provided takes the stub in
772
+ // place of its provider, one binding per token, and runs lifecycle hooks as that provider would; any other is
773
+ // bound on its own below, as the bot never makes a lifecycle unit of it. A stub that can't run is named here
774
+ const stageStubs = new Map();
775
+ for (const [method, overrides, kind] of [
776
+ [
777
+ 'canActivate',
778
+ this.guardOverrides,
779
+ 'overrideGuard'
780
+ ],
781
+ [
782
+ 'intercept',
783
+ this.interceptorOverrides,
784
+ 'overrideInterceptor'
785
+ ],
786
+ [
787
+ 'catch',
788
+ this.filterOverrides,
789
+ 'overrideFilter'
790
+ ]
791
+ ]){
792
+ for (const [stage, stub] of overrides){
793
+ if (providers.has(stage)) providers.set(stage, {
794
+ provide: stage,
795
+ useValue: stub
796
+ });
797
+ else stageStubs.set(stage, stub);
798
+ if (typeof stub[method] === 'function') continue;
799
+ const problem = `${kind}(${stage.name}).useValue(…) has no ${method} method.`;
800
+ if (strictMocks()) startupError(new Error(problem));
801
+ else warnOnce(new Logger('TestingModule'), `${problem} A call that reaches ${stage.name} fails; useStrictMocks() refuses it here.`);
802
+ }
803
+ }
804
+ // Each pass of checks ends before the next step that is no check, so a module with one error stops where it always did
805
+ stopOnStartupErrors();
806
+ const services = this.wiring?.services ?? [];
807
+ const appOptions = this.options.app ? Reflect.getMetadata(META.appOptions, this.options.app) : undefined;
808
+ // The app's own store, as the bot binds it, whether the module is given the app or built from it; none when the
809
+ // test provides the CooldownStore, so nothing builds the app's or asks for what it injects
810
+ const store = providers.has(CooldownStore) ? undefined : appOptions?.cooldownStore;
811
+ // The themeFor class this module runs, the app's or overrideThemeFor's, bound on its own as the app binds it
812
+ const themeResolver = themeResolverClass(stages?.themeFor?.resolvers);
813
+ // Every class the module runs, as the app finds them, except stage classes an override stands in for
814
+ const i18n = this.options.app && Reflect.getMetadata(META.appOptions, this.options.app)?.i18n;
815
+ const appPresenterClass = this.options.app && Reflect.getMetadata(META.appOptions, this.options.app)?.presenter;
816
+ const startup = startupClasses({
817
+ controllers: this.options.controllers ?? [],
818
+ services,
819
+ providers,
820
+ stages,
821
+ presenter: appPresenterClass,
822
+ cooldownStore: store,
823
+ themeFor: stages?.themeFor?.resolvers,
824
+ observers: [
825
+ ...this.options.app ? appObservers(this.options.app) : [],
826
+ ...this.options.observers ?? []
827
+ ],
828
+ stubs: [
829
+ ...this.guardOverrides.keys(),
830
+ ...this.interceptorOverrides.keys(),
831
+ ...this.filterOverrides.keys()
832
+ ]
833
+ });
834
+ const hasTranslator = i18n !== undefined || providers.has(Translator);
835
+ assertStartupClasses(startup, {
836
+ translator: hasTranslator
837
+ });
838
+ // Those of the stages, guards and other classes they reach
839
+ declaredErrorsOf(startup.classes).forEach(startupError);
840
+ stopOnStartupErrors();
841
+ markStartupChecked(container, [
842
+ ...startup.classes,
843
+ ...meocordClasses()
844
+ ], lateGuardCheck(providers, hasTranslator));
845
+ // The bot binds the Client it logs in with; a test gives its own, and is told so where one is needed
846
+ const needClient = startup.classes.filter((cls)=>injectedTokens(cls).includes(Client));
847
+ if (needClient.length > 0 && !providers.has(Client)) {
848
+ container.bind(Client).toDynamicValue(()=>{
849
+ 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() }.');
850
+ });
851
+ }
852
+ // A stage override nothing provides, bound before the classes so none of them binds the stage itself
853
+ for (const [stage, stub] of stageStubs)container.bind(stage).toConstantValue(stub);
854
+ // The app's cooldown policy, so a test of a failing store sees what the bot would do
855
+ if (appOptions && !container.isBound(COOLDOWN_POLICY)) container.bind(COOLDOWN_POLICY).toConstantValue(cooldownPolicyFrom(appOptions));
856
+ // The app's translator, unless a provider stands in for it
857
+ if (i18n && !providers.has(Translator)) {
858
+ container.bind(Translator).toConstantValue(i18n);
859
+ bindsOwnToken(container, Translator);
839
860
  }
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;
861
+ // Recursively bind controllers and their dependencies, skipping already-bound tokens
862
+ const bindClass = (cls)=>{
863
+ // A provided class is bound by its own provider, wherever in the list that provider comes
864
+ if (container.isBound(cls) || providers.has(cls)) return;
865
+ if (injectedTokens(cls).includes(ExecutionContext)) return startupError(singletonContextError(cls));
866
+ assertStartupChecked(container, cls);
867
+ makeInjectable(cls);
868
+ container.bind(cls).toSelf().inSingletonScope();
869
+ // By constructor type or @inject token, as the app binds them
870
+ for (const dep of injectedTokens(cls)){
871
+ if (dep === Translator && !container.isBound(Translator)) startupError(missingTranslatorError(cls));
872
+ else if (isAppClassToken(dep)) bindClass(dep);
873
+ }
874
+ };
875
+ // The app's own store, resolved like a service, unless the test provides the CooldownStore itself; bound first, as the
876
+ // app binds it, so a class that injects the token gets the store
877
+ if (!providers.has(CooldownStore)) {
878
+ if (store) {
879
+ bindClass(store);
880
+ container.bind(CooldownStore).toService(store);
881
+ } else {
882
+ container.bind(CooldownStore).toConstantValue(new MemoryCooldownStore());
883
+ }
884
+ bindsOwnToken(container, CooldownStore, store);
885
+ }
886
+ // Before the controllers, so a class token that is provided is not also bound as itself
887
+ for (const provider of providers.values())bindProvider(container, provider, bindClass);
888
+ for (const ctrl of this.options.controllers ?? [])bindClass(ctrl);
889
+ for (const service of services)bindClass(service);
890
+ if (themeResolver) bindClass(themeResolver);
891
+ // What follows walks what these bound, so a class refused here is not reported again as missing
892
+ stopOnStartupErrors();
893
+ // The classes whose @On and @Once handlers emit reaches: class providers bound as themselves, the
894
+ // controllers, and what they inject; factories resolve in the same order
895
+ const order = resolutionOrder(container, providers, [
896
+ ...providers.keys(),
897
+ ...services,
898
+ ...themeResolver ? [
899
+ themeResolver
900
+ ] : [],
901
+ ...this.options.controllers ?? []
902
+ ], {
903
+ followOwnTokens: false
904
+ });
905
+ appClasses.push(...order.filter((token)=>{
906
+ const provider = providers.get(token);
907
+ return isAppClassToken(token) && (!provider || isClassProvider(provider) && provider.useClass === token);
908
+ }));
909
+ assertProvided(container, providers, startup.classes, "the testing module's providers");
910
+ prepareHandlerStages(container, appClasses);
911
+ const messages = messagesOf(this.options.app);
912
+ // As the app would at startup, refuses a message pattern that cannot be read or two that match the same messages
913
+ buildMessageRoutes(this.options.controllers ?? [], messages);
914
+ // As the app does when it is created, so a test sees the refusal the bot would give
915
+ assertDistinctCommands(this.options.controllers ?? []);
916
+ if (this.options.app) prepareAppPresenter(container, this.options.app);
917
+ const observers = [
918
+ ...this.options.app ? appObservers(this.options.app) : [],
919
+ ...this.options.observers ?? []
920
+ ];
921
+ assertObservers("the testing module's observers", observers);
922
+ // Only @Observer classes are bound as observers
923
+ stopOnStartupErrors();
924
+ bindObservers(container, observers);
925
+ // The store calls ask: the test's own when it provides CooldownStore, else the app's
926
+ const boundStore = store ?? (providers.has(CooldownStore) ? CooldownStore : undefined);
927
+ // What the stages and presenter inject, as the bot makes it; a stage the test replaced contributes nothing
928
+ const stageServices = stageDependencies(startup, providers, appPresenterClass);
929
+ for (const service of stageServices)bindClass(service);
930
+ // The order the app runs lifecycle hooks in: its cooldown store, then providers, controllers and observers, each
931
+ // after what it injects
932
+ const lifecycle = resolutionOrder(container, providers, [
933
+ ...boundStore ? [
934
+ boundStore
935
+ ] : [],
936
+ ...providers.keys(),
937
+ ...services,
938
+ ...stageServices,
939
+ ...themeResolver ? [
940
+ themeResolver
941
+ ] : [],
942
+ ...this.options.controllers ?? [],
943
+ ...observers
944
+ ]).map((token)=>({
945
+ token,
946
+ name: tokenName(token),
947
+ dependencies: tokenDependencies(container, providers, token),
948
+ ...token === boundStore && {
949
+ cooldownStore: true
950
+ }
951
+ }));
952
+ // Every check has run, and touched only the container: from here the module is made, once none found an error
953
+ stopOnStartupErrors();
954
+ // Every class the module runs, so @UseGuard resolves guards on a direct call to any of them
955
+ for (const cls of [
956
+ ...this.options.controllers ?? [],
957
+ ...startup.classes
958
+ ])Reflect.defineMetadata(META.container, container, cls);
959
+ // And each instance with the module that made it, as another module made from the same classes stamps them again
960
+ recordInstanceContainers(container, [
961
+ ...this.options.controllers ?? [],
962
+ ...startup.classes
963
+ ]);
964
+ prepareThemes(container, appClasses);
965
+ // A handler with no builder is how a fixture is written, so only what is always a mistake is named
966
+ warnUnregisteredCommands(this.options.controllers ?? [], {
967
+ missingBuilders: false
968
+ });
969
+ warnInheritedRoutes(this.options.controllers ?? []);
970
+ warnOverlappingPatterns(this.options.controllers ?? []);
971
+ warnHandlersOffControllers(this.options.controllers ?? [], appClasses);
972
+ if (this.options.app) bindAppPresenter(container, this.options.app);
973
+ // Recorded as the container makes each one, so close() shuts down exactly what exists
974
+ const constructed = new Set();
975
+ for (const { token } of lifecycle){
976
+ container.onActivation(token, (_context, instance)=>{
977
+ constructed.add(token);
978
+ return instance;
979
+ });
980
+ }
981
+ const warnUnanswered = this.options.app && Reflect.getMetadata(META.appOptions, this.options.app)?.warnUnanswered;
982
+ return new TestingModule(container, [
983
+ ...this.options.controllers ?? []
984
+ ], appClasses, providers, order, messages, lifecycle, constructed, warnUnanswered, services, this.options.shutdownTimeout ?? DEFAULT_SHUTDOWN_TIMEOUT_MS);
847
985
  });
986
+ } catch (error) {
987
+ if (isRefusal(error) && !isExplainedError(error)) {
988
+ if (logStartupErrors(error, (text)=>new Logger('TestingModule').error(text), 'the testing module did not compile')) markExplained(error);
989
+ }
990
+ throw error;
848
991
  }
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
992
  }
854
993
  constructor(options, wiring){
855
994
  this.options = options;
@@ -887,7 +1026,7 @@ import { isUserOutcome } from '../common/user-outcome.js';
887
1026
  * @category Module
888
1027
  * @see {@link TestingModuleBuilder}
889
1028
  * @see {@link TestingModule}
890
- * @see {@link https://meocord.dev/docs/4.1/testing | The testing module}
1029
+ * @see {@link https://meocord.dev/docs/4.2/testing | The testing module}
891
1030
  */ class MeoCordTestingModule {
892
1031
  /**
893
1032
  * Starts building a testing module from the classes a test needs.
@@ -895,7 +1034,15 @@ import { isUserOutcome } from '../common/user-outcome.js';
895
1034
  * @param options - The controllers, providers, observers and app the module is built from.
896
1035
  * @returns The builder, to override what the test replaces before `compile()`.
897
1036
  */ static create(options) {
898
- return new TestingModuleBuilder(options);
1037
+ // A controller listed twice is one controller, as the bot and fromApp take it
1038
+ return new TestingModuleBuilder({
1039
+ ...options,
1040
+ ...options.controllers && {
1041
+ controllers: [
1042
+ ...new Set(options.controllers)
1043
+ ]
1044
+ }
1045
+ });
899
1046
  }
900
1047
  /**
901
1048
  * Starts building a testing module from a whole `@MeoCord` app, wired as the bot wires it.