meocord 4.2.0 → 4.2.2

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 (103) hide show
  1. package/CHANGELOG.md +322 -0
  2. package/THIRD_PARTY_NOTICES.md +14 -13
  3. package/dist/cjs/_shared/{controller.decorator-CRn9kWob.cjs → controller.decorator-U2NBWcoU.cjs} +416 -108
  4. package/dist/cjs/_shared/{providers-ChvMqnLd.cjs → providers-B4MOFBrW.cjs} +202 -50
  5. package/dist/cjs/_shared/{sharded-cooldown-store-BdAUTdYs.cjs → sharded-cooldown-store-HTUb3Ii_.cjs} +10 -7
  6. package/dist/cjs/_shared/{startup-roots-CQo8mSP8.cjs → startup-roots-lA5xkBFq.cjs} +228 -71
  7. package/dist/cjs/_shared/{translate-error-DhDVbT5M.cjs → translate-error-CbBHUu5P.cjs} +10 -5
  8. package/dist/cjs/common/index.cjs +3 -3
  9. package/dist/cjs/core/index.cjs +188 -169
  10. package/dist/cjs/decorator/index.cjs +52 -7
  11. package/dist/cjs/testing/index.cjs +1854 -311
  12. package/dist/cli.json +1 -1
  13. package/dist/esm/bin/generator.js +4 -5
  14. package/dist/esm/bin/helper/filter-generator.helper.js +4 -5
  15. package/dist/esm/bin/helper/guard-generator.helper.js +4 -5
  16. package/dist/esm/bin/helper/interceptor-generator.helper.js +4 -5
  17. package/dist/esm/bin/helper/observer-generator.helper.js +4 -5
  18. package/dist/esm/bin/helper/pipe-generator.helper.js +4 -5
  19. package/dist/esm/bin/helper/service-generator.helper.js +4 -5
  20. package/dist/esm/bin/meocord.js +84 -25
  21. package/dist/esm/build/load-config.pre-entry.js +13 -3
  22. package/dist/esm/build/rsbuild-config.js +16 -12
  23. package/dist/esm/build/typed-externals.js +36 -0
  24. package/dist/esm/common/cooldown-store.js +10 -2
  25. package/dist/esm/common/errors.js +25 -0
  26. package/dist/esm/common/meocord-text.js +11 -8
  27. package/dist/esm/common/response/call-order.js +9 -4
  28. package/dist/esm/common/response/presenter.js +33 -6
  29. package/dist/esm/common/response/response-state.js +89 -15
  30. package/dist/esm/common/route.js +11 -4
  31. package/dist/esm/common/sharded-cooldown-store.js +1 -1
  32. package/dist/esm/common/translator.js +52 -11
  33. package/dist/esm/core/command-registration.js +93 -24
  34. package/dist/esm/core/command-routes.js +2 -2
  35. package/dist/esm/core/cooldown-runner.js +29 -8
  36. package/dist/esm/core/dispatcher.js +9 -8
  37. package/dist/esm/core/fallback.js +20 -11
  38. package/dist/esm/core/filter-runner.js +2 -2
  39. package/dist/esm/core/guard-runner.js +16 -3
  40. package/dist/esm/core/handler-input.js +4 -3
  41. package/dist/esm/core/handler-pipeline.js +10 -4
  42. package/dist/esm/core/lifecycle-hooks.js +134 -47
  43. package/dist/esm/core/meocord-factory.js +59 -34
  44. package/dist/esm/core/meocord.app.js +116 -75
  45. package/dist/esm/core/message-help.js +32 -6
  46. package/dist/esm/core/observer-runner.js +2 -1
  47. package/dist/esm/core/providers.js +19 -4
  48. package/dist/esm/core/running-calls.js +43 -0
  49. package/dist/esm/core/shard-context.js +5 -6
  50. package/dist/esm/core/shard-manager.js +16 -10
  51. package/dist/esm/core/stage-scope.js +2 -1
  52. package/dist/esm/core/startup-roots.js +21 -1
  53. package/dist/esm/core/theme-resolvers.js +4 -1
  54. package/dist/esm/core/theme-scope.js +7 -2
  55. package/dist/esm/core/theme-validation.js +17 -10
  56. package/dist/esm/decorator/app.decorator.js +31 -0
  57. package/dist/esm/decorator/controller.decorator.js +56 -20
  58. package/dist/esm/decorator/cooldown.decorator.js +17 -0
  59. package/dist/esm/decorator/filter.decorator.js +2 -2
  60. package/dist/esm/decorator/guard.decorator.js +3 -2
  61. package/dist/esm/package.json.js +1 -1
  62. package/dist/esm/testing/catalog.js +20 -13
  63. package/dist/esm/testing/cooldown-store-suite.js +67 -5
  64. package/dist/esm/testing/discord-defaults.js +225 -30
  65. package/dist/esm/testing/meocord-testing-module.js +158 -44
  66. package/dist/esm/testing/mock-fn.js +107 -26
  67. package/dist/esm/testing/mock-interaction.js +1250 -182
  68. package/dist/esm/testing/snowflake.js +9 -2
  69. package/dist/esm/testing/strict-mocks.js +40 -17
  70. package/dist/esm/util/common.util.js +47 -19
  71. package/dist/esm/util/dev-runner.util.js +8 -8
  72. package/dist/esm/util/generator-cli.util.js +4 -5
  73. package/dist/esm/util/interaction.util.js +2 -2
  74. package/dist/esm/util/meocord-config-loader.util.js +10 -2
  75. package/dist/esm/util/meocord-config-validation.util.js +3 -5
  76. package/dist/esm/util/meocord-source-config.util.js +65 -20
  77. package/dist/esm/util/method-wrapper.util.js +16 -0
  78. package/dist/esm/util/platform.util.js +10 -10
  79. package/dist/esm/util/shard-process.util.js +4 -1
  80. package/dist/esm/util/sharding-mode.util.js +5 -7
  81. package/dist/esm/util/shared-state.util.js +21 -0
  82. package/dist/esm/util/tsconfig-paths.util.js +29 -0
  83. package/dist/esm/util/tsconfig.util.js +10 -10
  84. package/dist/esm/util/value.util.js +30 -2
  85. package/dist/types/common/index.d.cts +10 -7
  86. package/dist/types/common/index.d.ts +10 -7
  87. package/dist/types/core/index.d.cts +3 -2
  88. package/dist/types/core/index.d.ts +3 -2
  89. package/dist/types/decorator/index.d.cts +1 -0
  90. package/dist/types/decorator/index.d.ts +1 -0
  91. package/dist/types/{index-CdXsFgjE.d.ts → index-DKJclIi-.d.ts} +45 -21
  92. package/dist/types/{index-nn5p7OoX.d.cts → index-Dlf0ZZtS.d.cts} +45 -21
  93. package/dist/types/interface/index.d.cts +1 -1
  94. package/dist/types/interface/index.d.ts +1 -1
  95. package/dist/types/{route-BZgFoNeB.d.cts → route-Bh1U13yy.d.cts} +3 -2
  96. package/dist/types/{route-BZgFoNeB.d.ts → route-Bh1U13yy.d.ts} +3 -2
  97. package/dist/types/testing/index.d.cts +198 -62
  98. package/dist/types/testing/index.d.ts +198 -62
  99. package/dist/types/{theme-resolvers-UKr3-6Ys.d.cts → theme-resolvers-CyEz9pnv.d.cts} +1 -1
  100. package/dist/types/{theme-resolvers-BcOJVzR9.d.ts → theme-resolvers-DA204O-q.d.ts} +1 -1
  101. package/dist/types/{translator-DHMkkCpg.d.cts → translator-Bg7hwxDF.d.cts} +21 -8
  102. package/dist/types/{translator-BViWKtLJ.d.ts → translator-CeP4bCzp.d.ts} +21 -8
  103. package/package.json +2 -2
@@ -6,7 +6,7 @@ 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';
9
+ import { injectedTokens, singletonContextError, recordInstanceContainers } from '../core/guard-runner.js';
10
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';
@@ -23,21 +23,25 @@ import { messageCommandHooks } from '../core/message-params.js';
23
23
  import { appObservers, assertObservers, bindObservers } from '../core/observer-runner.js';
24
24
  import { HandlerRegistry } from '../core/handler-registry.js';
25
25
  import { adviseInPlaceOfInjecting, meocordClasses } from '../core/meocord-classes.js';
26
- import { startupClasses, assertStartupClasses, lateGuardCheck } from '../core/startup-roots.js';
26
+ import { startupClasses, assertStartupClasses, lateGuardCheck, stageDependencies } from '../core/startup-roots.js';
27
27
  import { markStartupChecked, assertStartupChecked } from '../core/startup-checked.js';
28
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
36
  import { isExplainedError, markExplained } from '../common/explained-error.js';
37
- import { declaredErrorsOf, collectStartupErrors, startupError, stopOnStartupErrors, isRefusal, logStartupErrors } from '../util/refusal.util.js';
37
+ import { collectStartupErrors, declaredErrorsOf, startupError, stopOnStartupErrors, isRefusal, logStartupErrors } from '../util/refusal.util.js';
38
38
  import { DEFAULT_SHUTDOWN_TIMEOUT_MS, shutdownTimeoutProblem } from '../util/shutdown-timeout.util.js';
39
- import { resolveProviders, tokenName, bindsOwnToken, providerMap, bindProvider, resolutionOrder, isClassProvider, assertProvided, tokenDependencies } from '../core/providers.js';
39
+ import { isConstructor, describeValue } from '../util/value.util.js';
40
+ import { resolveProviders, tokenName, bindsOwnToken, providerMap, assertProvidedServices, bindProvider, resolutionOrder, isClassProvider, assertProvided, tokenDependencies } from '../core/providers.js';
40
41
  import { getEventHandlers } from '../decorator/event.decorator.js';
42
+ import { getReactionHandlers } from '../decorator/controller.decorator.js';
43
+ import { strictMocks } from './strict-mocks.js';
44
+ import { warnOnce } from '../common/deprecation.js';
41
45
  import { META } from '../util/metadata-keys.js';
42
46
  import { isUserOutcome } from '../common/user-outcome.js';
43
47
 
@@ -110,7 +114,7 @@ import { isUserOutcome } from '../common/user-outcome.js';
110
114
  await this.resolving;
111
115
  if (options.ready) {
112
116
  const { client = createMockClient(), primary = true } = options.ready === true ? {} : options.ready;
113
- this.readying ?? (this.readying = this.runReady(client, primary));
117
+ this.readying ?? (this.readying = this.readyPass.run(()=>this.runReady(client, primary)));
114
118
  await this.readying;
115
119
  }
116
120
  return this;
@@ -125,12 +129,21 @@ import { isUserOutcome } from '../common/user-outcome.js';
125
129
  * It first gives up the theme read outside calls, if `init({ ready: true })` made it this module's;
126
130
  * reads outside calls then return MeoCord's defaults until another module or app is ready.
127
131
  *
132
+ * A stopped bot runs nothing more. Once `close()` is called, a module still runs what `dispatch`, `invoke` and `emit`
133
+ * are given, against services shut down, or shutting down, and warns once that it is closed, so a `close()` in the
134
+ * wrong place shows.
135
+ *
128
136
  * The cooldown store and what it injects shut down last, in the same sequence as the bot's. When any of them has an
129
137
  * `onShutdown`, the calls `invoke`, `dispatch` and `emit` have under way finish first, then the store operations
130
138
  * they started, so the store's last writes still reach it. The module stops waiting for the whole sequence after
131
139
  * its `shutdownTimeout`, 10 seconds unless set, and logs that it did. Give a test whose fake store never answers, or
132
140
  * whose `onShutdown` never settles, a short `shutdownTimeout`.
133
141
  *
142
+ * A `close()` while `init({ ready: true })` runs the `onReady` hooks waits for them first, within the same
143
+ * `shutdownTimeout`. An `onReady` still running once only the hooks' share of it is left is named in a warning, and
144
+ * its class is shut down all the same, as everything the module constructed is. One that calls `close()` itself isn't
145
+ * waited for.
146
+ *
134
147
  * @returns Once every hook has run. Rejects with the error of a hook that failed, or an
135
148
  * `AggregateError` naming each when several did.
136
149
  *
@@ -145,14 +158,18 @@ import { isUserOutcome } from '../common/user-outcome.js';
145
158
  * expect(module.get(POOL).ended).toBe(true)
146
159
  * ```
147
160
  */ async close() {
161
+ // An onReady awaiting close() would otherwise hold up the close that waits for it
162
+ this.readyPass.excuseCurrent();
148
163
  this.closing ?? (this.closing = (async ()=>{
149
- // A close during init waits for the hooks it started, so it shuts down whatever they constructed
150
- await this.readying?.catch(()=>undefined);
151
- 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
152
- .map((unit)=>lifecycleEntry(unit, this.container.get(unit.token) ?? {}));
164
+ // A close during init waits for the providers it started, and the sequence for its onReady hooks, so it shuts
165
+ // down whatever they constructed
166
+ await this.resolving?.catch(()=>undefined);
167
+ 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
168
+ .map((unit)=>lifecycleEntry(unit, this.container.get(unit.token) ?? {}));
153
169
  const failures = [];
154
170
  await runShutdownSequence(this.container, entries, {
155
171
  drainCalls: ()=>callsSettled(this.calls),
172
+ readyPass: this.readyPass,
156
173
  timeoutMs: this.shutdownTimeout,
157
174
  hookFailed: (name, error)=>failures.push({
158
175
  name,
@@ -166,6 +183,11 @@ import { isUserOutcome } from '../common/user-outcome.js';
166
183
  })());
167
184
  await this.closing;
168
185
  }
186
+ /** Warns once, after `close()`, that a call runs against services already shut down. */ warnIfClosed() {
187
+ if (this.closing === undefined || this.warnedClosed) return;
188
+ this.warnedClosed = true;
189
+ 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.');
190
+ }
169
191
  async runReady(client, primary) {
170
192
  // As the bot claims it before it comes online, so onReady reads the app's theme
171
193
  claimAmbientAppTheme(this.container);
@@ -181,6 +203,8 @@ import { isUserOutcome } from '../common/user-outcome.js';
181
203
  }, [], {
182
204
  resolveFailed: failed,
183
205
  hookFailed: failed
206
+ }, {
207
+ runOnReady: this.readyPass.runOnReady
184
208
  });
185
209
  throwFailures('onReady', failures);
186
210
  }
@@ -252,7 +276,9 @@ import { isUserOutcome } from '../common/user-outcome.js';
252
276
  * @returns Whether the handler ran, and the error a filter handled, if any. Rejects with an error no
253
277
  * filter handles, or with the error a filter throws: the built-in fallback, which answers such
254
278
  * errors in the bot, does not run here. Rejects before running anything with an interaction or a
255
- * message the handler's route does not match, naming both.
279
+ * message the handler's route does not match, naming both. Given fewer arguments than the handler
280
+ * declares, with a first that is neither an interaction nor a message, such as a reaction without its
281
+ * `ReactionEvent`, it rejects under `useStrictMocks()`, and otherwise warns once and runs the handler.
256
282
  *
257
283
  * @example
258
284
  * ```ts
@@ -274,6 +300,7 @@ import { isUserOutcome } from '../common/user-outcome.js';
274
300
  * expect(outcome.error).toBeInstanceOf(GuardDeniedError)
275
301
  * ```
276
302
  */ async invoke(controller, methodName, ...args) {
303
+ this.warnIfClosed();
277
304
  await this.init();
278
305
  if (!this.controllers.includes(controller)) {
279
306
  throw new Error(`${controller.name} is not a controller of this testing module. Add it to \`controllers\`.`);
@@ -303,6 +330,7 @@ import { isUserOutcome } from '../common/user-outcome.js';
303
330
  hooks = messageCommandHooks(route, params, first, start, given, this.messageOptions.types);
304
331
  }
305
332
  }
333
+ this.checkArgumentCount(controller, methodName, instance[methodName], first, callArgs.length);
306
334
  const presenter = appPresenterOf(this.container);
307
335
  // A message command's errors are drawn by the app's presenter too, as an interaction's are
308
336
  const client = first instanceof BaseInteraction || first instanceof Message ? first.client : undefined;
@@ -319,6 +347,19 @@ import { isUserOutcome } from '../common/user-outcome.js';
319
347
  error
320
348
  };
321
349
  }
350
+ /**
351
+ * Refuses under `useStrictMocks()`, or warns once otherwise, a call that gives a handler fewer arguments than it
352
+ * declares when `invoke` builds none for it, as for a reaction given without its `ReactionEvent`.
353
+ */ checkArgumentCount(controller, methodName, method, first, given) {
354
+ if (first instanceof BaseInteraction || first instanceof Message || method.length <= given) return;
355
+ const reaction = getReactionHandlers(controller.prototype).some((handler)=>handler.method === methodName);
356
+ 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.');
357
+ if (strictMocks()) throw new Error(problem);
358
+ const key = `${controller.name}.${methodName}`;
359
+ if (this.warnedArgumentCounts.has(key)) return;
360
+ this.warnedArgumentCounts.add(key);
361
+ new Logger('TestingModule').warn(problem);
362
+ }
322
363
  /** Counts `call` as under way until it settles. */ track(call) {
323
364
  this.calls.add(call);
324
365
  void call.then(()=>this.calls.delete(call), ()=>this.calls.delete(call));
@@ -364,6 +405,7 @@ import { isUserOutcome } from '../common/user-outcome.js';
364
405
  return this.dispatcher;
365
406
  }
366
407
  async dispatch(input, options) {
408
+ this.warnIfClosed();
367
409
  await this.init();
368
410
  this.registerClient(input);
369
411
  const dispatcher = this.dispatcherOf();
@@ -381,6 +423,7 @@ import { isUserOutcome } from '../common/user-outcome.js';
381
423
  unhandled: (error)=>unhandled.push(error)
382
424
  };
383
425
  if (options) {
426
+ noteReactionDispatch(input);
384
427
  await this.track(dispatcher.reaction(input, {
385
428
  user: options.user,
386
429
  action: options.action ?? ReactionHandlerAction.ADD
@@ -410,15 +453,20 @@ import { isUserOutcome } from '../common/user-outcome.js';
410
453
  };
411
454
  }
412
455
  /**
413
- * Emits a client event to the module's `@On` and `@Once` handlers, through the same pipeline the app
414
- * runs them in: the global guards of the module's `app`, then each handler's own. Handlers on the
415
- * module's controllers, class providers and their dependencies all receive it. A `@Once` handler
416
- * handles only the first event, as it would on a client.
456
+ * Emits a client event to the module's `@On` and `@Once` handlers, through the pipeline the app runs
457
+ * them in up to its event fallback: the global guards of the module's `app`, then each handler's own.
458
+ * Handlers on the module's controllers, class providers and their dependencies all receive it. A
459
+ * `@Once` handler handles only the first event, as it would on a client.
460
+ *
461
+ * The bot's event fallback doesn't run here. On a bot, a guard's `GuardDeniedError` is logged and the
462
+ * event skipped, and a handler's `UserError` is logged and, when the event carries a message, answered
463
+ * as a reply to it; here both reject `emit` as any error does, so a test checks them with `rejects`.
417
464
  *
418
465
  * @param event - The client event, such as `'guildMemberAdd'`.
419
466
  * @param args - The event's arguments, typed from discord.js's `ClientEvents`.
420
- * @returns How many handlers ran. Rejects once every handler has settled if any threw: with that
421
- * error when one handler failed, or an `AggregateError` of them when several did.
467
+ * @returns How many handlers ran. Rejects once every handler has settled if any threw, a guard's
468
+ * `GuardDeniedError` and a handler's `UserError` included: with that error when one handler failed,
469
+ * or an `AggregateError` of them when several did.
422
470
  *
423
471
  * @example
424
472
  * ```ts
@@ -430,6 +478,7 @@ import { isUserOutcome } from '../common/user-outcome.js';
430
478
  * expect(ran).toBe(1)
431
479
  * ```
432
480
  */ async emit(event, ...args) {
481
+ this.warnIfClosed();
433
482
  await this.init();
434
483
  const calls = [];
435
484
  for (const cls of this.eventClasses){
@@ -474,7 +523,10 @@ import { isUserOutcome } from '../common/user-outcome.js';
474
523
  this.appWarnUnanswered = appWarnUnanswered;
475
524
  this.services = services;
476
525
  this.shutdownTimeout = shutdownTimeout;
526
+ /** The `onReady` hooks' pass, which `close()` waits for, less one whose `onReady` called `close()`. */ this.readyPass = new ReadyPass();
527
+ this.warnedClosed = false;
477
528
  /** The `@Once` handlers that have already handled their event, as a client forgets its once listeners. */ this.firedOnce = new Set();
529
+ /** The handlers already warned about for being given fewer arguments than they take, so each is named once. */ this.warnedArgumentCounts = new Set();
478
530
  /** The calls under way, each removed once it settles: what `close()` lets finish before the cooldown store stops. */ this.calls = new Set();
479
531
  }
480
532
  }
@@ -539,6 +591,10 @@ import { isUserOutcome } from '../common/user-outcome.js';
539
591
  /**
540
592
  * Replaces a guard with a stub wherever it applies, globally or on a controller or handler.
541
593
  *
594
+ * It stands in for the guard wherever the guard is bound, a provider of the same token included, and wins over an
595
+ * `overrideProvider` of it. The stub needs a `canActivate`, its own or its prototype's: `compile()` refuses one without
596
+ * it under `useStrictMocks()`, and otherwise warns.
597
+ *
542
598
  * @param guard - The guard class to replace.
543
599
  * @example
544
600
  * ```ts
@@ -556,6 +612,10 @@ import { isUserOutcome } from '../common/user-outcome.js';
556
612
  * Replaces an interceptor with a stub wherever it applies, globally or on a controller or handler.
557
613
  * The stub's `intercept` receives the context and `next`; call `next.handle()` to run the handler.
558
614
  *
615
+ * It stands in for the interceptor wherever it is bound, a provider of the same token included, and wins over an
616
+ * `overrideProvider` of it. A stub without `intercept` is refused by `compile()` under `useStrictMocks()`, and
617
+ * otherwise warned about.
618
+ *
559
619
  * @param interceptor - The interceptor class to replace.
560
620
  * @example
561
621
  * ```ts
@@ -573,6 +633,10 @@ import { isUserOutcome } from '../common/user-outcome.js';
573
633
  * Replaces an exception filter with a stub wherever it applies. The filter's `@Catch` still decides
574
634
  * which errors reach the stub.
575
635
  *
636
+ * It stands in for the filter wherever it is bound, a provider of the same token included, and wins over an
637
+ * `overrideProvider` of it. A stub without `catch` is refused by `compile()` under `useStrictMocks()`, and otherwise
638
+ * warned about.
639
+ *
576
640
  * @param filter - The filter class to replace.
577
641
  * @example
578
642
  * ```ts
@@ -705,9 +769,52 @@ import { isUserOutcome } from '../common/user-outcome.js';
705
769
  const providers = providerMap(this.wiring?.providers ?? [], '@MeoCord({ providers })');
706
770
  for (const [token, provider] of providerMap(this.options.providers ?? [], "the testing module's providers"))providers.set(token, provider);
707
771
  for (const [token, override] of this.overrides)providers.set(token, override);
772
+ // A stage override stands in for its class wherever the class is bound. A stage also provided takes the stub in
773
+ // place of its provider, one binding per token, and runs lifecycle hooks as that provider would; any other is
774
+ // bound on its own below, as the bot never makes a lifecycle unit of it. A stub that can't run is named here
775
+ const stageStubs = new Map();
776
+ for (const [method, overrides, kind] of [
777
+ [
778
+ 'canActivate',
779
+ this.guardOverrides,
780
+ 'overrideGuard'
781
+ ],
782
+ [
783
+ 'intercept',
784
+ this.interceptorOverrides,
785
+ 'overrideInterceptor'
786
+ ],
787
+ [
788
+ 'catch',
789
+ this.filterOverrides,
790
+ 'overrideFilter'
791
+ ]
792
+ ]){
793
+ for (const [stage, stub] of overrides){
794
+ if (providers.has(stage)) providers.set(stage, {
795
+ provide: stage,
796
+ useValue: stub
797
+ });
798
+ else stageStubs.set(stage, stub);
799
+ if (typeof stub[method] === 'function') continue;
800
+ const problem = `${kind}(${stage.name}).useValue(…) has no ${method} method.`;
801
+ if (strictMocks()) startupError(new Error(problem));
802
+ else warnOnce(new Logger('TestingModule'), `${problem} A call that reaches ${stage.name} fails; useStrictMocks() refuses it here.`);
803
+ }
804
+ }
708
805
  // 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
806
  stopOnStartupErrors();
807
+ // What the walk below takes as classes, reported with what the walk finds; an entry refused here is left out of it
710
808
  const services = this.wiring?.services ?? [];
809
+ if (this.options.app) assertProvidedServices(this.options.app.name, services, providers, "@MeoCord({ providers }) or the testing module's providers");
810
+ for (const controller of this.options.controllers ?? []){
811
+ if (!isConstructor(controller)) startupError(new Error(`the testing module's controllers takes classes, not ${describeValue(controller)}.`));
812
+ }
813
+ const observers = [
814
+ ...this.options.app ? appObservers(this.options.app) : [],
815
+ ...this.options.observers ?? []
816
+ ];
817
+ assertObservers("the testing module's observers", observers);
711
818
  const appOptions = this.options.app ? Reflect.getMetadata(META.appOptions, this.options.app) : undefined;
712
819
  // 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
820
  // test provides the CooldownStore, so nothing builds the app's or asks for what it injects
@@ -716,18 +823,16 @@ import { isUserOutcome } from '../common/user-outcome.js';
716
823
  const themeResolver = themeResolverClass(stages?.themeFor?.resolvers);
717
824
  // Every class the module runs, as the app finds them, except stage classes an override stands in for
718
825
  const i18n = this.options.app && Reflect.getMetadata(META.appOptions, this.options.app)?.i18n;
826
+ const appPresenterClass = this.options.app && Reflect.getMetadata(META.appOptions, this.options.app)?.presenter;
719
827
  const startup = startupClasses({
720
- controllers: this.options.controllers ?? [],
828
+ controllers: (this.options.controllers ?? []).filter(isConstructor),
721
829
  services,
722
830
  providers,
723
831
  stages,
724
- presenter: this.options.app && Reflect.getMetadata(META.appOptions, this.options.app)?.presenter,
832
+ presenter: appPresenterClass,
725
833
  cooldownStore: store,
726
834
  themeFor: stages?.themeFor?.resolvers,
727
- observers: [
728
- ...this.options.app ? appObservers(this.options.app) : [],
729
- ...this.options.observers ?? []
730
- ],
835
+ observers,
731
836
  stubs: [
732
837
  ...this.guardOverrides.keys(),
733
838
  ...this.interceptorOverrides.keys(),
@@ -752,16 +857,8 @@ import { isUserOutcome } from '../common/user-outcome.js';
752
857
  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
858
  });
754
859
  }
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
- }
860
+ // A stage override nothing provides, bound before the classes so none of them binds the stage itself
861
+ for (const [stage, stub] of stageStubs)container.bind(stage).toConstantValue(stub);
765
862
  // The app's cooldown policy, so a test of a failing store sees what the bot would do
766
863
  if (appOptions && !container.isBound(COOLDOWN_POLICY)) container.bind(COOLDOWN_POLICY).toConstantValue(cooldownPolicyFrom(appOptions));
767
864
  // The app's translator, unless a provider stands in for it
@@ -825,16 +922,12 @@ import { isUserOutcome } from '../common/user-outcome.js';
825
922
  // As the app does when it is created, so a test sees the refusal the bot would give
826
923
  assertDistinctCommands(this.options.controllers ?? []);
827
924
  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
925
  bindObservers(container, observers);
836
926
  // The store calls ask: the test's own when it provides CooldownStore, else the app's
837
927
  const boundStore = store ?? (providers.has(CooldownStore) ? CooldownStore : undefined);
928
+ // What the stages and presenter inject, as the bot makes it; a stage the test replaced contributes nothing
929
+ const stageServices = stageDependencies(startup, providers, appPresenterClass);
930
+ for (const service of stageServices)bindClass(service);
838
931
  // The order the app runs lifecycle hooks in: its cooldown store, then providers, controllers and observers, each
839
932
  // after what it injects
840
933
  const lifecycle = resolutionOrder(container, providers, [
@@ -843,6 +936,7 @@ import { isUserOutcome } from '../common/user-outcome.js';
843
936
  ] : [],
844
937
  ...providers.keys(),
845
938
  ...services,
939
+ ...stageServices,
846
940
  ...themeResolver ? [
847
941
  themeResolver
848
942
  ] : [],
@@ -863,6 +957,11 @@ import { isUserOutcome } from '../common/user-outcome.js';
863
957
  ...this.options.controllers ?? [],
864
958
  ...startup.classes
865
959
  ])Reflect.defineMetadata(META.container, container, cls);
960
+ // And each instance with the module that made it, as another module made from the same classes stamps them again
961
+ recordInstanceContainers(container, [
962
+ ...this.options.controllers ?? [],
963
+ ...startup.classes
964
+ ]);
866
965
  prepareThemes(container, appClasses);
867
966
  // A handler with no builder is how a fixture is written, so only what is always a mistake is named
868
967
  warnUnregisteredCommands(this.options.controllers ?? [], {
@@ -936,7 +1035,15 @@ import { isUserOutcome } from '../common/user-outcome.js';
936
1035
  * @param options - The controllers, providers, observers and app the module is built from.
937
1036
  * @returns The builder, to override what the test replaces before `compile()`.
938
1037
  */ static create(options) {
939
- return new TestingModuleBuilder(options);
1038
+ // A controller listed twice is one controller, as the bot and fromApp take it
1039
+ return new TestingModuleBuilder({
1040
+ ...options,
1041
+ ...options.controllers && {
1042
+ controllers: [
1043
+ ...new Set(options.controllers)
1044
+ ]
1045
+ }
1046
+ });
940
1047
  }
941
1048
  /**
942
1049
  * Starts building a testing module from a whole `@MeoCord` app, wired as the bot wires it.
@@ -954,7 +1061,8 @@ import { isUserOutcome } from '../common/user-outcome.js';
954
1061
  * @param app - The class `@MeoCord` decorates.
955
1062
  * @param options - Providers that replace the app's by token, extra controllers, and observers.
956
1063
  * @returns The builder, whose `override*` methods still apply, for `compile()`.
957
- * @throws TypeError when `app` is not a `@MeoCord` class.
1064
+ * @throws TypeError when `app` is not a `@MeoCord` class. After {@link reportAllStartupErrors}, an app whose
1065
+ * `@MeoCord` was refused throws the error it was refused for instead.
958
1066
  *
959
1067
  * @example
960
1068
  * ```ts
@@ -995,7 +1103,13 @@ import { isUserOutcome } from '../common/user-outcome.js';
995
1103
  * ```
996
1104
  */ static fromApp(app, options = {}) {
997
1105
  const appOptions = Reflect.getMetadata(META.appOptions, app);
998
- if (!appOptions) throw new TypeError(`${app?.name ?? String(app)} is not a @MeoCord app: fromApp takes the class @MeoCord decorates.`);
1106
+ if (!appOptions) {
1107
+ // A refused @MeoCord stores no options, so its own errors, kept with startupErrors: 'all', say why
1108
+ collectStartupErrors(()=>declaredErrorsOf([
1109
+ app
1110
+ ]).forEach(startupError));
1111
+ throw new TypeError(`${app?.name ?? String(app)} is not a @MeoCord app: fromApp takes the class @MeoCord decorates.`);
1112
+ }
999
1113
  const controllers = [
1000
1114
  ...new Set([
1001
1115
  ...appOptions.controllers,