meocord 4.2.0 → 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 (95) hide show
  1. package/CHANGELOG.md +264 -0
  2. package/THIRD_PARTY_NOTICES.md +14 -13
  3. package/dist/cjs/_shared/{controller.decorator-CRn9kWob.cjs → controller.decorator-XKMlcUZz.cjs} +376 -100
  4. package/dist/cjs/_shared/{providers-ChvMqnLd.cjs → providers-BZgJyfZS.cjs} +145 -36
  5. package/dist/cjs/_shared/{sharded-cooldown-store-BdAUTdYs.cjs → sharded-cooldown-store-CsJJyCZd.cjs} +10 -7
  6. package/dist/cjs/_shared/{startup-roots-CQo8mSP8.cjs → startup-roots-CgfPQrNl.cjs} +219 -63
  7. package/dist/cjs/_shared/{translate-error-DhDVbT5M.cjs → translate-error-BUAR1WYG.cjs} +10 -5
  8. package/dist/cjs/common/index.cjs +3 -3
  9. package/dist/cjs/core/index.cjs +185 -168
  10. package/dist/cjs/decorator/index.cjs +21 -6
  11. package/dist/cjs/testing/index.cjs +1594 -286
  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 +84 -10
  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 +37 -6
  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/fallback.js +20 -11
  37. package/dist/esm/core/guard-runner.js +16 -3
  38. package/dist/esm/core/handler-input.js +4 -3
  39. package/dist/esm/core/lifecycle-hooks.js +134 -47
  40. package/dist/esm/core/meocord-factory.js +55 -32
  41. package/dist/esm/core/meocord.app.js +116 -75
  42. package/dist/esm/core/providers.js +7 -2
  43. package/dist/esm/core/running-calls.js +43 -0
  44. package/dist/esm/core/shard-context.js +5 -6
  45. package/dist/esm/core/shard-manager.js +16 -10
  46. package/dist/esm/core/startup-roots.js +21 -1
  47. package/dist/esm/core/theme-resolvers.js +4 -1
  48. package/dist/esm/core/theme-scope.js +3 -1
  49. package/dist/esm/core/theme-validation.js +17 -10
  50. package/dist/esm/decorator/controller.decorator.js +56 -20
  51. package/dist/esm/decorator/cooldown.decorator.js +17 -0
  52. package/dist/esm/decorator/guard.decorator.js +3 -2
  53. package/dist/esm/package.json.js +1 -1
  54. package/dist/esm/testing/catalog.js +26 -10
  55. package/dist/esm/testing/cooldown-store-suite.js +67 -5
  56. package/dist/esm/testing/discord-defaults.js +205 -30
  57. package/dist/esm/testing/meocord-testing-module.js +134 -28
  58. package/dist/esm/testing/mock-fn.js +107 -26
  59. package/dist/esm/testing/mock-interaction.js +1026 -172
  60. package/dist/esm/testing/snowflake.js +9 -2
  61. package/dist/esm/testing/strict-mocks.js +37 -17
  62. package/dist/esm/util/common.util.js +47 -19
  63. package/dist/esm/util/dev-runner.util.js +8 -8
  64. package/dist/esm/util/generator-cli.util.js +4 -5
  65. package/dist/esm/util/interaction.util.js +2 -2
  66. package/dist/esm/util/meocord-config-loader.util.js +10 -2
  67. package/dist/esm/util/meocord-config-validation.util.js +3 -5
  68. package/dist/esm/util/meocord-source-config.util.js +65 -20
  69. package/dist/esm/util/method-wrapper.util.js +16 -0
  70. package/dist/esm/util/platform.util.js +10 -10
  71. package/dist/esm/util/shard-process.util.js +4 -1
  72. package/dist/esm/util/sharding-mode.util.js +5 -7
  73. package/dist/esm/util/shared-state.util.js +21 -0
  74. package/dist/esm/util/tsconfig-paths.util.js +29 -0
  75. package/dist/esm/util/tsconfig.util.js +10 -10
  76. package/dist/esm/util/value.util.js +12 -1
  77. package/dist/types/common/index.d.cts +6 -6
  78. package/dist/types/common/index.d.ts +6 -6
  79. package/dist/types/core/index.d.cts +3 -2
  80. package/dist/types/core/index.d.ts +3 -2
  81. package/dist/types/decorator/index.d.cts +1 -0
  82. package/dist/types/decorator/index.d.ts +1 -0
  83. package/dist/types/{index-CdXsFgjE.d.ts → index-DKJclIi-.d.ts} +45 -21
  84. package/dist/types/{index-nn5p7OoX.d.cts → index-Dlf0ZZtS.d.cts} +45 -21
  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-BZgFoNeB.d.cts → route-Bh1U13yy.d.cts} +3 -2
  88. package/dist/types/{route-BZgFoNeB.d.ts → route-Bh1U13yy.d.ts} +3 -2
  89. package/dist/types/testing/index.d.cts +175 -53
  90. package/dist/types/testing/index.d.ts +175 -53
  91. package/dist/types/{theme-resolvers-UKr3-6Ys.d.cts → theme-resolvers-CyEz9pnv.d.cts} +1 -1
  92. package/dist/types/{theme-resolvers-BcOJVzR9.d.ts → theme-resolvers-DA204O-q.d.ts} +1 -1
  93. package/dist/types/{translator-DHMkkCpg.d.cts → translator-Bg7hwxDF.d.cts} +21 -8
  94. package/dist/types/{translator-BViWKtLJ.d.ts → translator-CeP4bCzp.d.ts} +21 -8
  95. 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,13 +23,13 @@ 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';
@@ -38,6 +38,9 @@ import { declaredErrorsOf, collectStartupErrors, startupError, stopOnStartupErro
38
38
  import { DEFAULT_SHUTDOWN_TIMEOUT_MS, shutdownTimeoutProblem } from '../util/shutdown-timeout.util.js';
39
39
  import { resolveProviders, tokenName, bindsOwnToken, providerMap, bindProvider, resolutionOrder, isClassProvider, assertProvided, tokenDependencies } from '../core/providers.js';
40
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';
41
44
  import { META } from '../util/metadata-keys.js';
42
45
  import { isUserOutcome } from '../common/user-outcome.js';
43
46
 
@@ -110,7 +113,7 @@ import { isUserOutcome } from '../common/user-outcome.js';
110
113
  await this.resolving;
111
114
  if (options.ready) {
112
115
  const { client = createMockClient(), primary = true } = options.ready === true ? {} : options.ready;
113
- this.readying ?? (this.readying = this.runReady(client, primary));
116
+ this.readying ?? (this.readying = this.readyPass.run(()=>this.runReady(client, primary)));
114
117
  await this.readying;
115
118
  }
116
119
  return this;
@@ -125,12 +128,21 @@ import { isUserOutcome } from '../common/user-outcome.js';
125
128
  * It first gives up the theme read outside calls, if `init({ ready: true })` made it this module's;
126
129
  * reads outside calls then return MeoCord's defaults until another module or app is ready.
127
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
+ *
128
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
129
136
  * `onShutdown`, the calls `invoke`, `dispatch` and `emit` have under way finish first, then the store operations
130
137
  * they started, so the store's last writes still reach it. The module stops waiting for the whole sequence after
131
138
  * its `shutdownTimeout`, 10 seconds unless set, and logs that it did. Give a test whose fake store never answers, or
132
139
  * whose `onShutdown` never settles, a short `shutdownTimeout`.
133
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
+ *
134
146
  * @returns Once every hook has run. Rejects with the error of a hook that failed, or an
135
147
  * `AggregateError` naming each when several did.
136
148
  *
@@ -145,14 +157,18 @@ import { isUserOutcome } from '../common/user-outcome.js';
145
157
  * expect(module.get(POOL).ended).toBe(true)
146
158
  * ```
147
159
  */ async close() {
160
+ // An onReady awaiting close() would otherwise hold up the close that waits for it
161
+ this.readyPass.excuseCurrent();
148
162
  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) ?? {}));
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) ?? {}));
153
168
  const failures = [];
154
169
  await runShutdownSequence(this.container, entries, {
155
170
  drainCalls: ()=>callsSettled(this.calls),
171
+ readyPass: this.readyPass,
156
172
  timeoutMs: this.shutdownTimeout,
157
173
  hookFailed: (name, error)=>failures.push({
158
174
  name,
@@ -166,6 +182,11 @@ import { isUserOutcome } from '../common/user-outcome.js';
166
182
  })());
167
183
  await this.closing;
168
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
+ }
169
190
  async runReady(client, primary) {
170
191
  // As the bot claims it before it comes online, so onReady reads the app's theme
171
192
  claimAmbientAppTheme(this.container);
@@ -181,6 +202,8 @@ import { isUserOutcome } from '../common/user-outcome.js';
181
202
  }, [], {
182
203
  resolveFailed: failed,
183
204
  hookFailed: failed
205
+ }, {
206
+ runOnReady: this.readyPass.runOnReady
184
207
  });
185
208
  throwFailures('onReady', failures);
186
209
  }
@@ -252,7 +275,9 @@ import { isUserOutcome } from '../common/user-outcome.js';
252
275
  * @returns Whether the handler ran, and the error a filter handled, if any. Rejects with an error no
253
276
  * filter handles, or with the error a filter throws: the built-in fallback, which answers such
254
277
  * 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.
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.
256
281
  *
257
282
  * @example
258
283
  * ```ts
@@ -274,6 +299,7 @@ import { isUserOutcome } from '../common/user-outcome.js';
274
299
  * expect(outcome.error).toBeInstanceOf(GuardDeniedError)
275
300
  * ```
276
301
  */ async invoke(controller, methodName, ...args) {
302
+ this.warnIfClosed();
277
303
  await this.init();
278
304
  if (!this.controllers.includes(controller)) {
279
305
  throw new Error(`${controller.name} is not a controller of this testing module. Add it to \`controllers\`.`);
@@ -303,6 +329,7 @@ import { isUserOutcome } from '../common/user-outcome.js';
303
329
  hooks = messageCommandHooks(route, params, first, start, given, this.messageOptions.types);
304
330
  }
305
331
  }
332
+ this.checkArgumentCount(controller, methodName, instance[methodName], first, callArgs.length);
306
333
  const presenter = appPresenterOf(this.container);
307
334
  // A message command's errors are drawn by the app's presenter too, as an interaction's are
308
335
  const client = first instanceof BaseInteraction || first instanceof Message ? first.client : undefined;
@@ -319,6 +346,19 @@ import { isUserOutcome } from '../common/user-outcome.js';
319
346
  error
320
347
  };
321
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
+ }
322
362
  /** Counts `call` as under way until it settles. */ track(call) {
323
363
  this.calls.add(call);
324
364
  void call.then(()=>this.calls.delete(call), ()=>this.calls.delete(call));
@@ -364,6 +404,7 @@ import { isUserOutcome } from '../common/user-outcome.js';
364
404
  return this.dispatcher;
365
405
  }
366
406
  async dispatch(input, options) {
407
+ this.warnIfClosed();
367
408
  await this.init();
368
409
  this.registerClient(input);
369
410
  const dispatcher = this.dispatcherOf();
@@ -381,6 +422,7 @@ import { isUserOutcome } from '../common/user-outcome.js';
381
422
  unhandled: (error)=>unhandled.push(error)
382
423
  };
383
424
  if (options) {
425
+ noteReactionDispatch(input);
384
426
  await this.track(dispatcher.reaction(input, {
385
427
  user: options.user,
386
428
  action: options.action ?? ReactionHandlerAction.ADD
@@ -410,15 +452,20 @@ import { isUserOutcome } from '../common/user-outcome.js';
410
452
  };
411
453
  }
412
454
  /**
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.
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`.
417
463
  *
418
464
  * @param event - The client event, such as `'guildMemberAdd'`.
419
465
  * @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.
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.
422
469
  *
423
470
  * @example
424
471
  * ```ts
@@ -430,6 +477,7 @@ import { isUserOutcome } from '../common/user-outcome.js';
430
477
  * expect(ran).toBe(1)
431
478
  * ```
432
479
  */ async emit(event, ...args) {
480
+ this.warnIfClosed();
433
481
  await this.init();
434
482
  const calls = [];
435
483
  for (const cls of this.eventClasses){
@@ -474,7 +522,10 @@ import { isUserOutcome } from '../common/user-outcome.js';
474
522
  this.appWarnUnanswered = appWarnUnanswered;
475
523
  this.services = services;
476
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;
477
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();
478
529
  /** The calls under way, each removed once it settles: what `close()` lets finish before the cooldown store stops. */ this.calls = new Set();
479
530
  }
480
531
  }
@@ -539,6 +590,10 @@ import { isUserOutcome } from '../common/user-outcome.js';
539
590
  /**
540
591
  * Replaces a guard with a stub wherever it applies, globally or on a controller or handler.
541
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
+ *
542
597
  * @param guard - The guard class to replace.
543
598
  * @example
544
599
  * ```ts
@@ -556,6 +611,10 @@ import { isUserOutcome } from '../common/user-outcome.js';
556
611
  * Replaces an interceptor with a stub wherever it applies, globally or on a controller or handler.
557
612
  * The stub's `intercept` receives the context and `next`; call `next.handle()` to run the handler.
558
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
+ *
559
618
  * @param interceptor - The interceptor class to replace.
560
619
  * @example
561
620
  * ```ts
@@ -573,6 +632,10 @@ import { isUserOutcome } from '../common/user-outcome.js';
573
632
  * Replaces an exception filter with a stub wherever it applies. The filter's `@Catch` still decides
574
633
  * which errors reach the stub.
575
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
+ *
576
639
  * @param filter - The filter class to replace.
577
640
  * @example
578
641
  * ```ts
@@ -705,6 +768,39 @@ import { isUserOutcome } from '../common/user-outcome.js';
705
768
  const providers = providerMap(this.wiring?.providers ?? [], '@MeoCord({ providers })');
706
769
  for (const [token, provider] of providerMap(this.options.providers ?? [], "the testing module's providers"))providers.set(token, provider);
707
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
+ }
708
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
709
805
  stopOnStartupErrors();
710
806
  const services = this.wiring?.services ?? [];
@@ -716,12 +812,13 @@ import { isUserOutcome } from '../common/user-outcome.js';
716
812
  const themeResolver = themeResolverClass(stages?.themeFor?.resolvers);
717
813
  // Every class the module runs, as the app finds them, except stage classes an override stands in for
718
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;
719
816
  const startup = startupClasses({
720
817
  controllers: this.options.controllers ?? [],
721
818
  services,
722
819
  providers,
723
820
  stages,
724
- presenter: this.options.app && Reflect.getMetadata(META.appOptions, this.options.app)?.presenter,
821
+ presenter: appPresenterClass,
725
822
  cooldownStore: store,
726
823
  themeFor: stages?.themeFor?.resolvers,
727
824
  observers: [
@@ -752,16 +849,8 @@ import { isUserOutcome } from '../common/user-outcome.js';
752
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() }.');
753
850
  });
754
851
  }
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
- }
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);
765
854
  // The app's cooldown policy, so a test of a failing store sees what the bot would do
766
855
  if (appOptions && !container.isBound(COOLDOWN_POLICY)) container.bind(COOLDOWN_POLICY).toConstantValue(cooldownPolicyFrom(appOptions));
767
856
  // The app's translator, unless a provider stands in for it
@@ -835,6 +924,9 @@ import { isUserOutcome } from '../common/user-outcome.js';
835
924
  bindObservers(container, observers);
836
925
  // The store calls ask: the test's own when it provides CooldownStore, else the app's
837
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);
838
930
  // The order the app runs lifecycle hooks in: its cooldown store, then providers, controllers and observers, each
839
931
  // after what it injects
840
932
  const lifecycle = resolutionOrder(container, providers, [
@@ -843,6 +935,7 @@ import { isUserOutcome } from '../common/user-outcome.js';
843
935
  ] : [],
844
936
  ...providers.keys(),
845
937
  ...services,
938
+ ...stageServices,
846
939
  ...themeResolver ? [
847
940
  themeResolver
848
941
  ] : [],
@@ -863,6 +956,11 @@ import { isUserOutcome } from '../common/user-outcome.js';
863
956
  ...this.options.controllers ?? [],
864
957
  ...startup.classes
865
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
+ ]);
866
964
  prepareThemes(container, appClasses);
867
965
  // A handler with no builder is how a fixture is written, so only what is always a mistake is named
868
966
  warnUnregisteredCommands(this.options.controllers ?? [], {
@@ -936,7 +1034,15 @@ import { isUserOutcome } from '../common/user-outcome.js';
936
1034
  * @param options - The controllers, providers, observers and app the module is built from.
937
1035
  * @returns The builder, to override what the test replaces before `compile()`.
938
1036
  */ static create(options) {
939
- 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
+ });
940
1046
  }
941
1047
  /**
942
1048
  * Starts building a testing module from a whole `@MeoCord` app, wired as the bot wires it.
@@ -1,7 +1,7 @@
1
+ import { shared } from '../util/shared-state.util.js';
2
+
1
3
  // Framework-agnostic mock function, so `meocord/testing` depends on neither jest nor vitest.
2
4
  // Both detect it through `_isMockFunction` and read `fn.mock.calls` in their assertions.
3
- // Each call signature of `T`, up to four, as a union: `Parameters` and `ReturnType` read only an overloaded function's
4
- // last signature, such as the list form of a manager's `fetch`
5
5
  /**
6
6
  * Tells whether a value is a mock function: one from `meocord/testing`, `jest.fn()` or `vi.fn()`.
7
7
  *
@@ -24,13 +24,20 @@
24
24
  */ function isMockFunction(fn) {
25
25
  return typeof fn === 'function' && '_isMockFunction' in fn && fn._isMockFunction === true;
26
26
  }
27
- // Every mock createMockFn made, held weakly so the mocks of a finished test can still be collected
28
- const created = new Set();
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();
27
+ /**
28
+ * What `useMockFn` set and the mocks made, kept for both builds of this meocord version, so a setup file in one module
29
+ * format sets them for specs in the other:
30
+ * - `invocationOrder`: when each call of meocord's own mocks came, across all of them, as Vitest numbers its own;
31
+ * - `created`: every mock createMockFn made, held weakly so the mocks of a finished test can still be collected;
32
+ * - `runnerFactory` and `restoreImpl`: the runner's factory useMockFn registered, and for each of its mocks how to
33
+ * give back the implementation it was created with, through the runner's own setter, which a mock's behaviour may wrap.
34
+ */ const mocks = shared('mocks', ()=>({
35
+ invocationOrder: 0,
36
+ created: new Set(),
37
+ runnerFactory: undefined,
38
+ restoreImpl: new WeakMap()
39
+ }));
40
+ const collected = new FinalizationRegistry((ref)=>mocks.created.delete(ref));
34
41
  /**
35
42
  * Makes every mock `meocord/testing` creates with the test runner's own mock function, such as Vitest's `vi.fn`.
36
43
  *
@@ -61,26 +68,36 @@ const restoreImpl = new WeakMap();
61
68
  * @category Mocks
62
69
  * @see {@link createMockFn}
63
70
  */ function useMockFn(factory) {
64
- if (factory === runnerFactory) return;
71
+ if (factory === mocks.runnerFactory) return;
65
72
  if ([
66
- ...created
73
+ ...mocks.created
67
74
  ].some((ref)=>ref.deref() !== undefined)) {
68
75
  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
76
  }
70
- const probe = factory();
77
+ const refusal = (cause)=>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.', cause === undefined ? undefined : {
78
+ cause
79
+ });
80
+ let probe;
81
+ try {
82
+ probe = factory();
83
+ } catch (error) {
84
+ // node:test's mock.fn, given on its own, throws for want of its tracker
85
+ throw refusal(error);
86
+ }
71
87
  const api = [
72
88
  'mockImplementation',
73
89
  'mockClear',
74
90
  'mockReset'
75
91
  ];
76
92
  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.');
93
+ throw refusal();
78
94
  }
79
- runnerFactory = factory;
95
+ mocks.runnerFactory = factory;
80
96
  }
97
+ /** Whether mocks are made with a runner's function, which {@link useMockFn} registered. */ const usesRunnerMockFn = ()=>mocks.runnerFactory !== undefined;
81
98
  /** Tracks a mock for clearAllMocks and resetAllMocks. */ function track(mockFn) {
82
99
  const ref = new WeakRef(mockFn);
83
- created.add(ref);
100
+ mocks.created.add(ref);
84
101
  collected.register(mockFn, ref);
85
102
  return mockFn;
86
103
  }
@@ -103,7 +120,7 @@ const restoreImpl = new WeakMap();
103
120
  * @category Mocks
104
121
  * @see {@link resetAllMocks}
105
122
  */ function clearAllMocks() {
106
- for (const ref of created)ref.deref()?.mockClear();
123
+ for (const ref of mocks.created)ref.deref()?.mockClear();
107
124
  }
108
125
  /**
109
126
  * Clears every mock from `meocord/testing`, and puts each back to the implementation it was created with.
@@ -126,12 +143,12 @@ const restoreImpl = new WeakMap();
126
143
  * @category Mocks
127
144
  * @see {@link clearAllMocks}
128
145
  */ function resetAllMocks() {
129
- for (const ref of created){
146
+ for (const ref of mocks.created){
130
147
  const mockFn = ref.deref();
131
148
  if (!mockFn) continue;
132
149
  mockFn.mockReset();
133
150
  // 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)?.();
151
+ mocks.restoreImpl.get(mockFn)?.();
135
152
  }
136
153
  }
137
154
  /**
@@ -145,6 +162,14 @@ const restoreImpl = new WeakMap();
145
162
  * `mockReturnValue`, `mockResolvedValue`, `mockRejectedValue` and `mockImplementation` share one implementation, so
146
163
  * the last one set wins. The `*Once` variants queue, and run first, in the order they were set.
147
164
  *
165
+ * It records what Vitest's `toHaveResolved` and `toHaveBeenCalledBefore` read as well. Its call order is counted
166
+ * across meocord's own mocks, apart from the runner's, so compare the order of two of meocord's mocks, or of two of the
167
+ * runner's, not one of each.
168
+ *
169
+ * A promise the implementation returns is handed back as one that settles the same way, not as the same object: test
170
+ * it with `toEqual`, `toHaveReturnedWith` or by awaiting it, not with `toBe`. A property set on the given promise, such
171
+ * as a `cancel` method, isn't carried over.
172
+ *
148
173
  * @param impl - The implementation to run until another is set.
149
174
  *
150
175
  * @example
@@ -162,11 +187,12 @@ const restoreImpl = new WeakMap();
162
187
  * @category Mocks
163
188
  * @see {@link MockInstance}
164
189
  */ function createMockFn(impl) {
190
+ const { runnerFactory } = mocks;
165
191
  if (runnerFactory) {
166
192
  const runnerMock = runnerFactory(impl);
167
193
  if (impl) {
168
194
  const setImpl = runnerMock.mockImplementation.bind(runnerMock);
169
- restoreImpl.set(runnerMock, ()=>setImpl(impl));
195
+ mocks.restoreImpl.set(runnerMock, ()=>setImpl(impl));
170
196
  }
171
197
  return track(runnerMock);
172
198
  }
@@ -179,9 +205,20 @@ const restoreImpl = new WeakMap();
179
205
  const calls = [];
180
206
  const results = [];
181
207
  const instances = [];
208
+ const settledResults = [];
209
+ const invocationCallOrder = [];
210
+ const contexts = [];
182
211
  const mockFn = function(...args) {
183
212
  calls.push(args);
184
213
  instances.push(this);
214
+ contexts.push(this);
215
+ invocationCallOrder.push(++mocks.invocationOrder);
216
+ // Updated in place as the call settles, so a promise's outcome shows once it resolves or rejects
217
+ const settled = {
218
+ type: 'incomplete',
219
+ value: undefined
220
+ };
221
+ settledResults.push(settled);
185
222
  let type = 'return';
186
223
  let value;
187
224
  try {
@@ -192,10 +229,36 @@ const restoreImpl = new WeakMap();
192
229
  } else {
193
230
  value = undefined;
194
231
  }
232
+ if (value instanceof Promise) {
233
+ // Returned in its place, so a rejection nobody awaits stays unhandled in the test, as it is on a bot: a handler
234
+ // on the implementation's own promise would mark it handled
235
+ value = value.then((resolved)=>{
236
+ Object.assign(settled, {
237
+ type: 'fulfilled',
238
+ value: resolved
239
+ });
240
+ return resolved;
241
+ }, (rejected)=>{
242
+ Object.assign(settled, {
243
+ type: 'rejected',
244
+ value: rejected
245
+ });
246
+ throw rejected;
247
+ });
248
+ } else {
249
+ Object.assign(settled, {
250
+ type: 'fulfilled',
251
+ value
252
+ });
253
+ }
195
254
  return value;
196
255
  } catch (err) {
197
256
  type = 'throw';
198
257
  value = err;
258
+ Object.assign(settled, {
259
+ type: 'rejected',
260
+ value: err
261
+ });
199
262
  throw err;
200
263
  } finally{
201
264
  results.push({
@@ -223,6 +286,18 @@ const restoreImpl = new WeakMap();
223
286
  return calls.length > 0 ? calls[calls.length - 1] : undefined;
224
287
  }
225
288
  };
289
+ // Out of the mock's keys, so a test comparing `mock` as a whole sees the shape it always had
290
+ Object.defineProperties(mock, {
291
+ settledResults: {
292
+ get: ()=>settledResults
293
+ },
294
+ invocationCallOrder: {
295
+ get: ()=>invocationCallOrder
296
+ },
297
+ contexts: {
298
+ get: ()=>contexts
299
+ }
300
+ });
226
301
  Object.defineProperty(mockFn, 'mock', {
227
302
  value: mock,
228
303
  enumerable: false
@@ -259,16 +334,22 @@ const restoreImpl = new WeakMap();
259
334
  onceQueue.push(fn);
260
335
  return mockFn;
261
336
  };
337
+ const forgetCalls = ()=>{
338
+ for (const record of [
339
+ calls,
340
+ results,
341
+ instances,
342
+ settledResults,
343
+ invocationCallOrder,
344
+ contexts
345
+ ])record.length = 0;
346
+ };
262
347
  mockFn.mockClear = ()=>{
263
- calls.length = 0;
264
- results.length = 0;
265
- instances.length = 0;
348
+ forgetCalls();
266
349
  return mockFn;
267
350
  };
268
351
  mockFn.mockReset = ()=>{
269
- calls.length = 0;
270
- results.length = 0;
271
- instances.length = 0;
352
+ forgetCalls();
272
353
  onceQueue = [];
273
354
  currentImpl = impl;
274
355
  return mockFn;
@@ -285,4 +366,4 @@ const restoreImpl = new WeakMap();
285
366
  return track(mockFn);
286
367
  }
287
368
 
288
- export { clearAllMocks, createMockFn, isMockFunction, resetAllMocks, useMockFn };
369
+ export { clearAllMocks, createMockFn, isMockFunction, resetAllMocks, useMockFn, usesRunnerMockFn };