meocord 4.1.2 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/CHANGELOG.md +98 -0
  2. package/dist/cjs/_shared/{controller.decorator-BywwkU9O.cjs → controller.decorator-CRn9kWob.cjs} +295 -87
  3. package/dist/cjs/_shared/{providers-BMMLp2SN.cjs → providers-ChvMqnLd.cjs} +74 -40
  4. package/dist/cjs/_shared/{sharded-cooldown-store-B65iaiAy.cjs → sharded-cooldown-store-BdAUTdYs.cjs} +1 -1
  5. package/dist/cjs/_shared/{startup-roots-lFc69AxC.cjs → startup-roots-CQo8mSP8.cjs} +158 -72
  6. package/dist/cjs/_shared/{translate-error-BnM8FbSH.cjs → translate-error-DhDVbT5M.cjs} +12 -3
  7. package/dist/cjs/common/index.cjs +4 -4
  8. package/dist/cjs/core/index.cjs +195 -157
  9. package/dist/cjs/decorator/index.cjs +66 -57
  10. package/dist/cjs/testing/index.cjs +733 -301
  11. package/dist/cli.json +1 -1
  12. package/dist/esm/bin/app-template/README.md.template +3 -1
  13. package/dist/esm/bin/app-template/src/controllers/button/sample.button.controller.spec.ts.template +8 -4
  14. package/dist/esm/bin/app-template/src/controllers/button/sample.button.controller.ts.template +2 -2
  15. package/dist/esm/bin/app-template/src/guards/owner.guard.spec.ts.template +8 -4
  16. package/dist/esm/bin/app-template/src/guards/owner.guard.ts.template +1 -1
  17. package/dist/esm/bin/app-template/vitest.config.ts.template +1 -1
  18. package/dist/esm/bin/app-template/vitest.setup.ts.template +7 -3
  19. package/dist/esm/build/load-config.pre-entry.js +4 -1
  20. package/dist/esm/common/decorator.js +6 -6
  21. package/dist/esm/common/deprecation.js +2 -2
  22. package/dist/esm/common/errors.js +2 -2
  23. package/dist/esm/common/execution-context.js +1 -1
  24. package/dist/esm/common/metadata.js +1 -1
  25. package/dist/esm/common/route.js +10 -1
  26. package/dist/esm/common/translate-error.js +1 -1
  27. package/dist/esm/core/command-conflicts.js +34 -34
  28. package/dist/esm/core/component-routes.js +74 -31
  29. package/dist/esm/core/dispatcher.js +51 -7
  30. package/dist/esm/core/filter-runner.js +2 -2
  31. package/dist/esm/core/handler-pipeline.js +27 -14
  32. package/dist/esm/core/handler-registry.js +1 -1
  33. package/dist/esm/core/interceptor-runner.js +2 -2
  34. package/dist/esm/core/meocord-factory.js +196 -157
  35. package/dist/esm/core/message-routes.js +4 -3
  36. package/dist/esm/core/observer-runner.js +4 -3
  37. package/dist/esm/core/providers.js +38 -21
  38. package/dist/esm/core/route-specificity.js +4 -4
  39. package/dist/esm/core/scalar-types.js +15 -3
  40. package/dist/esm/core/shard-context.js +1 -1
  41. package/dist/esm/core/startup-roots.js +2 -3
  42. package/dist/esm/core/theme-runtime.js +2 -2
  43. package/dist/esm/decorator/app.decorator.js +12 -7
  44. package/dist/esm/decorator/command-builder.decorator.js +4 -4
  45. package/dist/esm/decorator/controller-class.decorator.js +9 -4
  46. package/dist/esm/decorator/controller.decorator.js +110 -67
  47. package/dist/esm/decorator/cooldown.decorator.js +3 -3
  48. package/dist/esm/decorator/defer.decorator.js +4 -4
  49. package/dist/esm/decorator/event.decorator.js +5 -5
  50. package/dist/esm/decorator/filter.decorator.js +7 -7
  51. package/dist/esm/decorator/guard.decorator.js +8 -8
  52. package/dist/esm/decorator/inject.decorator.js +4 -4
  53. package/dist/esm/decorator/interceptor.decorator.js +7 -7
  54. package/dist/esm/decorator/observer.decorator.js +4 -4
  55. package/dist/esm/decorator/service.decorator.js +4 -4
  56. package/dist/esm/decorator/theme.decorator.js +3 -3
  57. package/dist/esm/decorator/validation.decorator.js +10 -10
  58. package/dist/esm/package.json.js +1 -1
  59. package/dist/esm/testing/catalog.js +1 -1
  60. package/dist/esm/testing/cooldown-store-suite.js +18 -10
  61. package/dist/esm/testing/discord-defaults.js +60 -7
  62. package/dist/esm/testing/index.js +4 -2
  63. package/dist/esm/testing/inspect-handler.js +20 -3
  64. package/dist/esm/testing/meocord-testing-module.js +229 -188
  65. package/dist/esm/testing/mock-fn.js +80 -10
  66. package/dist/esm/testing/mock-interaction.js +223 -72
  67. package/dist/esm/testing/response.js +1 -1
  68. package/dist/esm/testing/routing.js +39 -14
  69. package/dist/esm/testing/startup-errors.js +26 -0
  70. package/dist/esm/testing/strict-mocks.js +45 -0
  71. package/dist/esm/testing/theme.js +1 -1
  72. package/dist/esm/util/bundle-entry.util.js +2 -1
  73. package/dist/esm/util/interaction.util.js +1 -1
  74. package/dist/esm/util/meocord-config-validation.util.js +1 -0
  75. package/dist/esm/util/metadata-keys.js +1 -0
  76. package/dist/esm/util/refusal.util.js +148 -8
  77. package/dist/types/common/index.d.cts +8 -8
  78. package/dist/types/common/index.d.ts +8 -8
  79. package/dist/types/core/index.d.cts +8 -6
  80. package/dist/types/core/index.d.ts +8 -6
  81. package/dist/types/decorator/index.d.cts +30 -29
  82. package/dist/types/decorator/index.d.ts +30 -29
  83. package/dist/types/{index-D7n0FBMm.d.ts → index-CdXsFgjE.d.ts} +62 -15
  84. package/dist/types/{index-DEP0NYrU.d.cts → index-nn5p7OoX.d.cts} +62 -15
  85. package/dist/types/interface/index.d.cts +1 -1
  86. package/dist/types/interface/index.d.ts +1 -1
  87. package/dist/types/{route-C-aMI7Sl.d.cts → route-BZgFoNeB.d.cts} +5 -2
  88. package/dist/types/{route-C-aMI7Sl.d.ts → route-BZgFoNeB.d.ts} +5 -2
  89. package/dist/types/testing/index.d.cts +245 -31
  90. package/dist/types/testing/index.d.ts +245 -31
  91. package/dist/types/{theme-resolvers-DedxyscT.d.ts → theme-resolvers-BcOJVzR9.d.ts} +1 -1
  92. package/dist/types/{theme-resolvers-COHdX5g6.d.cts → theme-resolvers-UKr3-6Ys.d.cts} +1 -1
  93. package/dist/types/{translator-J6tIUfdp.d.ts → translator-BViWKtLJ.d.ts} +1 -1
  94. package/dist/types/{translator-B4mc1lzJ.d.cts → translator-DHMkkCpg.d.cts} +1 -1
  95. package/package.json +3 -2
@@ -2,11 +2,11 @@
2
2
 
3
3
  require('reflect-metadata');
4
4
  var inversify = require('inversify');
5
- var providers = require('../_shared/providers-BMMLp2SN.cjs');
5
+ var providers = require('../_shared/providers-ChvMqnLd.cjs');
6
6
  var discord_js = require('discord.js');
7
7
  var enum_index = require('../enum/index.cjs');
8
- var controller_decorator = require('../_shared/controller.decorator-BywwkU9O.cjs');
9
- var startupRoots = require('../_shared/startup-roots-lFc69AxC.cjs');
8
+ var controller_decorator = require('../_shared/controller.decorator-CRn9kWob.cjs');
9
+ var startupRoots = require('../_shared/startup-roots-CQo8mSP8.cjs');
10
10
  var node_crypto = require('node:crypto');
11
11
  require('node:async_hooks');
12
12
  require('crypto');
@@ -21,7 +21,7 @@ require('dayjs/plugin/utc.js');
21
21
  require('dayjs/plugin/timezone.js');
22
22
  require('module');
23
23
  require('chalk');
24
- require('../_shared/translate-error-BnM8FbSH.cjs');
24
+ require('../_shared/translate-error-DhDVbT5M.cjs');
25
25
 
26
26
  // Framework-agnostic mock function, so `meocord/testing` depends on neither jest nor vitest.
27
27
  // Both detect it through `_isMockFunction` and read `fn.mock.calls` in their assertions.
@@ -52,12 +52,70 @@ require('../_shared/translate-error-BnM8FbSH.cjs');
52
52
  // Every mock createMockFn made, held weakly so the mocks of a finished test can still be collected
53
53
  const created = new Set();
54
54
  const collected = new FinalizationRegistry((ref)=>created.delete(ref));
55
+ // The runner's factory useMockFn registered, and for each of its mocks how to give back the implementation it was
56
+ // created with, through the runner's own setter, which a mock's behaviour may wrap
57
+ let runnerFactory;
58
+ const restoreImpl = new WeakMap();
59
+ /**
60
+ * Makes every mock `meocord/testing` creates with the test runner's own mock function, such as Vitest's `vi.fn`.
61
+ *
62
+ * Call it once in the runner's setup file. The runner then treats meocord's mocks as its own: its `clearMocks` and
63
+ * `mockReset` config and `vi.clearAllMocks()` reach them, its matchers read them, and its whole mock API is there.
64
+ * Without it, mocks are {@link createMockFn}'s own, which jest's and Vitest's `expect` read too.
65
+ *
66
+ * @remarks
67
+ * Each mock starts with the runner's mock holding meocord's behaviour, such as an interaction's replies, as its own
68
+ * implementation. {@link resetAllMocks} puts that behaviour back where the runner's `mockReset` drops it, as jest's and
69
+ * bun's do; a runner's own reset-all, such as jest's `resetMocks`, can't, so under jest and bun reset with
70
+ * {@link resetAllMocks}. node:test's `mock.fn` records calls in a shape of its own, so under node:test leave meocord's
71
+ * own function in place. Calling it again with the same factory does nothing; another, once a mock exists, throws.
72
+ *
73
+ * @param factory - The runner's mock function factory: `vi.fn`, `jest.fn` or bun's `mock`.
74
+ * @throws Error when a mock already exists from another factory, or when `factory` doesn't make a mock with jest's and
75
+ * Vitest's API.
76
+ *
77
+ * @example
78
+ * ```ts
79
+ * // vitest.setup.ts
80
+ * import { vi } from 'vitest'
81
+ *
82
+ * useMockFn(vi.fn)
83
+ * ```
84
+ *
85
+ * @group Testing
86
+ * @category Mocks
87
+ * @see {@link createMockFn}
88
+ */ function useMockFn(factory) {
89
+ if (factory === runnerFactory) return;
90
+ if ([
91
+ ...created
92
+ ].some((ref)=>ref.deref() !== undefined)) {
93
+ 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.");
94
+ }
95
+ const probe = factory();
96
+ const api = [
97
+ 'mockImplementation',
98
+ 'mockClear',
99
+ 'mockReset'
100
+ ];
101
+ if (!isMockFunction(probe) || !Array.isArray(probe.mock?.calls) || api.some((name)=>typeof probe[name] !== 'function')) {
102
+ 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.');
103
+ }
104
+ runnerFactory = factory;
105
+ }
106
+ /** Tracks a mock for clearAllMocks and resetAllMocks. */ function track(mockFn) {
107
+ const ref = new WeakRef(mockFn);
108
+ created.add(ref);
109
+ collected.register(mockFn, ref);
110
+ return mockFn;
111
+ }
55
112
  /**
56
113
  * Clears the calls every mock from `meocord/testing` has recorded, keeping what each was told to do.
57
114
  *
58
- * Vitest's `clearMocks` and jest's `clearAllMocks()` reach only their own `vi.fn()` and `jest.fn()` mocks. This one
59
- * covers `createMockFn` and everything built on it, such as `createMockInteraction` and `createMockClient`. To also
60
- * undo what a test set, use {@link resetAllMocks}.
115
+ * Vitest's `clearMocks` and jest's `clearAllMocks()` reach only their own `vi.fn()` and `jest.fn()` mocks, and
116
+ * meocord's too once {@link useMockFn} makes them with the runner's function. This one covers `createMockFn` and
117
+ * everything built on it, such as `createMockInteraction` and `createMockClient`, either way. To also undo what a test
118
+ * set, use {@link resetAllMocks}.
61
119
  *
62
120
  * @example
63
121
  * ```ts
@@ -93,13 +151,20 @@ const collected = new FinalizationRegistry((ref)=>created.delete(ref));
93
151
  * @category Mocks
94
152
  * @see {@link clearAllMocks}
95
153
  */ function resetAllMocks() {
96
- for (const ref of created)ref.deref()?.mockReset();
154
+ for (const ref of created){
155
+ const mockFn = ref.deref();
156
+ if (!mockFn) continue;
157
+ mockFn.mockReset();
158
+ // A runner whose reset drops the implementation the mock was created with, as jest's and bun's do, gets it back
159
+ restoreImpl.get(mockFn)?.();
160
+ }
97
161
  }
98
162
  /**
99
163
  * Creates a mock function that jest's and Vitest's `expect` both read.
100
164
  *
101
165
  * Use it for a function double in code that must not depend on one test runner, such as a shared test helper; in a
102
- * Vitest test, `vi.fn()` works as well. {@link clearAllMocks} and {@link resetAllMocks} reach it.
166
+ * Vitest test, `vi.fn()` works as well. {@link clearAllMocks} and {@link resetAllMocks} reach it. Once
167
+ * {@link useMockFn} registers a runner's function, it returns that runner's mock.
103
168
  *
104
169
  * @remarks
105
170
  * `mockReturnValue`, `mockResolvedValue`, `mockRejectedValue` and `mockImplementation` share one implementation, so
@@ -122,6 +187,14 @@ const collected = new FinalizationRegistry((ref)=>created.delete(ref));
122
187
  * @category Mocks
123
188
  * @see {@link MockInstance}
124
189
  */ function createMockFn(impl) {
190
+ if (runnerFactory) {
191
+ const runnerMock = runnerFactory(impl);
192
+ if (impl) {
193
+ const setImpl = runnerMock.mockImplementation.bind(runnerMock);
194
+ restoreImpl.set(runnerMock, ()=>setImpl(impl));
195
+ }
196
+ return track(runnerMock);
197
+ }
125
198
  // One persistent implementation and one queue of single-use ones, as in jest and vitest: the
126
199
  // four setters share the slot, so the last call wins, and the `*Once` variants share the queue,
127
200
  // so they run in declaration order.
@@ -234,10 +307,7 @@ const collected = new FinalizationRegistry((ref)=>created.delete(ref));
234
307
  name = n;
235
308
  return mockFn;
236
309
  };
237
- const ref = new WeakRef(mockFn);
238
- created.add(ref);
239
- collected.register(mockFn, ref);
240
- return mockFn;
310
+ return track(mockFn);
241
311
  }
242
312
 
243
313
  const VISIBLE = new Set([
@@ -279,7 +349,7 @@ const VISIBLE = new Set([
279
349
  * @group Testing
280
350
  * @category Inspection
281
351
  * @see {@link TestingModule}
282
- * @see {@link https://meocord.dev/docs/4.1/invoke-and-dispatch | Invoke and dispatch}
352
+ * @see {@link https://meocord.dev/docs/4.2/invoke-and-dispatch | Invoke and dispatch}
283
353
  */ function getResponse(interaction) {
284
354
  const state = controller_decorator.existingResponse(interaction);
285
355
  // A mock records every answer it gets; respond()'s edits through the channel are its own, so they are merged in
@@ -337,6 +407,50 @@ const VISIBLE = new Set([
337
407
  }, code, 400, 'POST', 'https://discord.com/api/v10/interactions', {});
338
408
  }
339
409
 
410
+ // Whether useStrictMocks() is on, and whether a mock was made before it could be
411
+ let strict = false;
412
+ let mockMade = false;
413
+ /** Whether mocks compute discord.js's state, as {@link useStrictMocks} turns on. */ const strictMocks = ()=>strict;
414
+ /** Notes that a mock was made, which useStrictMocks() must come before. */ function noteMockMade() {
415
+ mockMade = true;
416
+ }
417
+ /**
418
+ * Has every mock from `meocord/testing` compute the values discord.js computes, where it reads a placeholder otherwise.
419
+ *
420
+ * Call it once in the test runner's setup file. A message's `editable`, `deletable`, `pinnable`, `crosspostable`,
421
+ * `bulkDeletable`, `hasThread` and `partial`, a member's `manageable`, `kickable`, `bannable` and `moderatable`, a
422
+ * role's `editable`, a channel's and a thread's `viewable`, `manageable`, `deletable`, `joinable` and the rest, and
423
+ * `partial` on users, channels and reactions then run discord.js's own getters against the mock, and `message.thread`
424
+ * is `null` when the channel caches no thread under the message's id. No placeholder warning is logged.
425
+ *
426
+ * @remarks
427
+ * Those getters read what a server holds, so strict mocks also have it as Discord sends it: the bot's member is in
428
+ * the server's member cache from the start, @everyone has the permissions Discord gives it in a new server, and a
429
+ * channel or thread made without a server has one of its own, a thread with a text channel as its parent. A message
430
+ * has empty `content`, a thread is neither archived nor locked, and a reaction's `count` is 1. Give the bot's member
431
+ * a role to let it do more, such as kick a member its role ranks above. A value the test sets on a mock wins over the
432
+ * computed one. The next major version (5.0) computes these values without the call.
433
+ *
434
+ * @throws Error when a mock already exists, which was made without it.
435
+ *
436
+ * @example
437
+ * ```ts
438
+ * // vitest.setup.ts
439
+ * useStrictMocks()
440
+ * ```
441
+ *
442
+ * @group Testing
443
+ * @category Mocks
444
+ * @see {@link useMockFn}
445
+ * @see {@link https://meocord.dev/docs/4.2/mocks | Mocks}
446
+ */ function useStrictMocks() {
447
+ if (strict) return;
448
+ if (mockMade) {
449
+ throw new Error('useStrictMocks() goes before any mock is made: a mock made before it reads placeholders, and the two kinds would ' + "mix. Call it once, in the test runner's setup file.");
450
+ }
451
+ strict = true;
452
+ }
453
+
340
454
  /** A permission set with none, frozen as discord.js freezes the ones it gives. */ const noPermissions = ()=>new discord_js.PermissionsBitField().freeze();
341
455
  /** A property discord.js computes from the others: its own getter runs, reading the mock's values. */ const REAL_GETTER = Symbol('real getter');
342
456
  const snowflake = ()=>discord_js.SnowflakeUtil.generate().toString();
@@ -459,7 +573,14 @@ const snowflake = ()=>discord_js.SnowflakeUtil.generate().toString();
459
573
  version: ()=>1,
460
574
  context: ()=>null,
461
575
  // What Discord sends with an interaction in a server, the member's permissions there; typed for a cached one
462
- memberPermissions: (interaction)=>interaction.guildId && interaction.member instanceof discord_js.GuildMember ? interaction.member.permissions : null,
576
+ memberPermissions: (interaction)=>{
577
+ const { guildId } = interaction;
578
+ const member = interaction.member;
579
+ if (!guildId) return null;
580
+ if (member instanceof discord_js.GuildMember) return member.permissions;
581
+ // A raw member's permissions are the string Discord sends, read as discord.js reads them
582
+ return isRawMember(member) && !interaction.guild ? new discord_js.PermissionsBitField(BigInt(member.permissions)).freeze() : null;
583
+ },
463
584
  // The bot's permissions where the interaction was made: its member's in the channel, or none outside a server
464
585
  appPermissions: (interaction)=>{
465
586
  const { guild, channel } = interaction;
@@ -520,10 +641,55 @@ const snowflake = ()=>discord_js.SnowflakeUtil.generate().toString();
520
641
  nameAcronym: REAL_GETTER
521
642
  })
522
643
  ]);
523
- /** The default a mock of `target`'s class has for `key`, read from the nearest class that has one. */ function discordDefault(target, key) {
524
- for(let proto = Object.getPrototypeOf(target); proto !== null; proto = Object.getPrototypeOf(proto)){
525
- const found = DEFAULTS.get(proto)?.[key];
526
- if (found !== undefined) return found;
644
+ /**
645
+ * What strict mocks also read as Discord sends it, for discord.js's getters that read it: a server for a channel or
646
+ * thread made without one, a thread's parent text channel in it, and data that is otherwise a placeholder.
647
+ */ const STRICT_DEFAULTS = new Map([
648
+ defaultsOf(discord_js.Message, {
649
+ content: ()=>''
650
+ }),
651
+ defaultsOf(discord_js.Guild, {
652
+ rulesChannelId: ()=>null,
653
+ publicUpdatesChannelId: ()=>null
654
+ }),
655
+ defaultsOf(discord_js.ThreadChannel, {
656
+ guild: ()=>createMockGuild(),
657
+ guildId: (thread)=>thread.guild.id,
658
+ parentId: (thread)=>{
659
+ const parent = createMockChannel(discord_js.TextChannel, {
660
+ guild: thread.guild
661
+ });
662
+ thread.guild.channels.cache.set(parent.id, parent);
663
+ return parent.id;
664
+ },
665
+ parent: REAL_GETTER,
666
+ ownerId: snowflake,
667
+ archived: ()=>false,
668
+ locked: ()=>false
669
+ }),
670
+ defaultsOf(discord_js.GuildChannel, {
671
+ guild: ()=>createMockGuild()
672
+ }),
673
+ // Full once its members reach a user limit, which a voice channel has none of unless given one
674
+ defaultsOf(discord_js.BaseGuildVoiceChannel, {
675
+ userLimit: ()=>0,
676
+ full: REAL_GETTER
677
+ }),
678
+ defaultsOf(discord_js.MessageReaction, {
679
+ count: ()=>1
680
+ })
681
+ ]);
682
+ /** The default a mock of `target`'s class has for `key`, read from the nearest class that has one, strict ones first. */ function discordDefault(target, key) {
683
+ for (const defaults of strictMocks() ? [
684
+ STRICT_DEFAULTS,
685
+ DEFAULTS
686
+ ] : [
687
+ DEFAULTS
688
+ ]){
689
+ for(let proto = Object.getPrototypeOf(target); proto !== null; proto = Object.getPrototypeOf(proto)){
690
+ const found = defaults.get(proto)?.[key];
691
+ if (found !== undefined) return found;
692
+ }
527
693
  }
528
694
  return undefined;
529
695
  }
@@ -665,6 +831,7 @@ const SKIP = new Set([
665
831
  'then'
666
832
  ]);
667
833
  function stubDeep(instance, externalStubs, onSet) {
834
+ noteMockMade();
668
835
  const stubs = externalStubs ?? new Map();
669
836
  const proxy = new Proxy(instance, {
670
837
  get (target, prop) {
@@ -705,7 +872,11 @@ function stubDeep(instance, externalStubs, onSet) {
705
872
  const desc = Object.getOwnPropertyDescriptor(proto, key);
706
873
  if (desc !== undefined) {
707
874
  protoValue = desc.value;
708
- if (desc.get && PLACEHOLDER_BOOLEANS.get(proto)?.has(key)) warnBooleanPlaceholder(target, key);
875
+ if (desc.get && isPlaceholder(proto, key)) {
876
+ // Strict mocks run discord.js's getter, read live; otherwise the placeholder, with a warning once
877
+ if (strictMocks()) return Reflect.get(target, prop, proxy);
878
+ warnBooleanPlaceholder(target, key);
879
+ }
709
880
  break;
710
881
  }
711
882
  proto = Object.getPrototypeOf(proto);
@@ -795,9 +966,18 @@ const REAL_METHODS = new Map([
795
966
  ])
796
967
  ]
797
968
  ]);
969
+ // What strict mocks also run for real: a thread's permissions are its parent channel's, as discord.js reads them
970
+ const STRICT_REAL_METHODS = new Map([
971
+ [
972
+ discord_js.ThreadChannel.prototype,
973
+ new Set([
974
+ 'permissionsFor'
975
+ ])
976
+ ]
977
+ ]);
798
978
  function isRealMethod(target, key) {
799
979
  for(let proto = Object.getPrototypeOf(target); proto !== null; proto = Object.getPrototypeOf(proto)){
800
- if (REAL_METHODS.get(proto)?.has(key)) return true;
980
+ if (REAL_METHODS.get(proto)?.has(key) || strictMocks() && STRICT_REAL_METHODS.get(proto)?.has(key)) return true;
801
981
  }
802
982
  return false;
803
983
  }
@@ -857,6 +1037,12 @@ const PLACEHOLDER_BOOLEANS = new Map([
857
1037
  'joinable'
858
1038
  ])
859
1039
  ],
1040
+ [
1041
+ discord_js.VoiceChannel.prototype,
1042
+ new Set([
1043
+ 'speakable'
1044
+ ])
1045
+ ],
860
1046
  [
861
1047
  discord_js.User.prototype,
862
1048
  new Set([
@@ -876,11 +1062,18 @@ const PLACEHOLDER_BOOLEANS = new Map([
876
1062
  ])
877
1063
  ]
878
1064
  ]);
1065
+ /** Whether `proto`'s getter for `key` is one of those, or a subclass's override of one, such as a voice channel's `joinable`. */ function isPlaceholder(proto, key) {
1066
+ for(; proto !== null; proto = Object.getPrototypeOf(proto)){
1067
+ if (PLACEHOLDER_BOOLEANS.get(proto)?.has(key)) return true;
1068
+ }
1069
+ return false;
1070
+ }
879
1071
  const mockLogger = new controller_decorator.Logger('Mocks');
880
1072
  function warnBooleanPlaceholder(target, key) {
881
1073
  const name = Object.getPrototypeOf(target).constructor.name;
882
- const variable = name.charAt(0).toLowerCase() + name.slice(1);
883
- controller_decorator.warnPlaceholder(mockLogger, `${name}.${key}`, 'the mock computes it as discord.js does', `Set it on the mock, such as ${variable}.${key} = false, to test either way.`);
1074
+ // The class name in camel case, a leading acronym included: dmChannel, guildMember
1075
+ const variable = name.replace(/^[A-Z]+(?=[A-Z][a-z])|^[A-Z]/, (start)=>start.toLowerCase());
1076
+ controller_decorator.warnPlaceholder(mockLogger, `${name}.${key}`, 'the mock computes it as discord.js does', `Set it on the mock, such as ${variable}.${key} = false, to test either way, or call useStrictMocks() to have the mock compute it now.`);
884
1077
  }
885
1078
  // The client of a structure made without one: the one its guild has, else one of its own, made once
886
1079
  const ownClients = new WeakMap();
@@ -1263,22 +1456,13 @@ const isSnowflake = (id)=>typeof id === 'string' && /^\d{1,20}$/.test(id);
1263
1456
  }
1264
1457
  }
1265
1458
  /** Where a mock interaction keeps every answer it got, through respond() or discord.js directly, in order. */ const RESPONSE_LOG = Symbol('response log');
1266
- const ANSWER_METHODS = [
1267
- 'reply',
1268
- 'deferReply',
1269
- 'editReply',
1270
- 'followUp',
1271
- 'deleteReply',
1272
- 'update',
1273
- 'deferUpdate',
1274
- 'showModal'
1275
- ];
1276
1459
  /**
1277
- * `mock`, recording each call in `log` as it is made, with what it sent and, once it settles, what it rejected with.
1278
- * `withResponse` is how a call asks discord.js for the message back, not part of what it sends, so it is left out.
1279
- */ function recorded(method, mock, log) {
1280
- return new Proxy(mock, {
1281
- apply (target, self, args) {
1460
+ * A mock of an answer method, `impl` its behaviour, that records each call in `log` as it is made, with what it sent
1461
+ * and, once it settles, what it rejected with. The record wraps every behaviour the mock is given, its own and any a
1462
+ * test sets, so the mock stays the plain mock function a runner's matchers read, bun's included. `withResponse` is how
1463
+ * a call asks discord.js for the message back, not part of what it sends, so it is left out.
1464
+ */ function recordedMock(method, impl, log) {
1465
+ const record = (behaviour)=>function(...args) {
1282
1466
  const [payload] = args;
1283
1467
  const sent = payload && typeof payload === 'object' && 'withResponse' in payload ? Object.fromEntries(Object.entries(payload).filter(([key])=>key !== 'withResponse')) : payload;
1284
1468
  const call = {
@@ -1288,7 +1472,7 @@ const ANSWER_METHODS = [
1288
1472
  controller_decorator.stampCall(call);
1289
1473
  log.push(call);
1290
1474
  try {
1291
- const result = Reflect.apply(target, self, args);
1475
+ const result = Reflect.apply(behaviour, this, args);
1292
1476
  // Recorded and passed on, so a rejection nobody awaits is unhandled in the test, as it is on a bot
1293
1477
  if (result instanceof Promise) {
1294
1478
  return result.then(undefined, (error)=>{
@@ -1301,8 +1485,38 @@ const ANSWER_METHODS = [
1301
1485
  call.error = error;
1302
1486
  throw error;
1303
1487
  }
1304
- }
1305
- });
1488
+ };
1489
+ const mock = createMockFn(record(impl));
1490
+ const always = mock.mockImplementation.bind(mock);
1491
+ const once = mock.mockImplementationOnce.bind(mock);
1492
+ // Each way a test sets a behaviour goes through the record, a runner's own extras included where it has them
1493
+ const setters = {
1494
+ mockImplementation: (fn)=>fn,
1495
+ mockReturnValue: (value)=>()=>value,
1496
+ mockResolvedValue: (value)=>()=>Promise.resolve(value),
1497
+ mockRejectedValue: (value)=>()=>Promise.reject(value)
1498
+ };
1499
+ // In place of the mock's own, keeping whether each is among its keys
1500
+ const define = (name, value)=>Object.defineProperty(mock, name, {
1501
+ value,
1502
+ writable: true,
1503
+ enumerable: Object.getOwnPropertyDescriptor(mock, name)?.enumerable ?? false,
1504
+ configurable: true
1505
+ });
1506
+ for (const [name, behaviourOf] of Object.entries(setters)){
1507
+ define(name, (value)=>(always(record(behaviourOf(value))), mock));
1508
+ define(`${name}Once`, (value)=>(once(record(behaviourOf(value))), mock));
1509
+ }
1510
+ if (typeof mock.mockReturnThis === 'function') {
1511
+ define('mockReturnThis', ()=>(always(record(function() {
1512
+ return this;
1513
+ })), mock));
1514
+ }
1515
+ const withImplementation = mock.withImplementation;
1516
+ if (typeof withImplementation === 'function') {
1517
+ define('withImplementation', (fn, callback)=>withImplementation.call(mock, record(fn), callback));
1518
+ }
1519
+ return mock;
1306
1520
  }
1307
1521
  /** The interaction a mock options resolver was given to, for the server a user option's member is in. */ const optionOwners = new WeakMap();
1308
1522
  /** An error as discord.js's options resolver throws it: a `TypeError` with discord.js's code and message. */ const optionError = (code, ...args)=>new discord_js.DiscordjsTypeError(code, ...args);
@@ -1361,21 +1575,27 @@ function createMockInteraction(Class, props) {
1361
1575
  if (typeof flags === 'bigint') return (flags & 64n) !== 0n;
1362
1576
  return false;
1363
1577
  };
1364
- stubs.set('reply', createMockFn(async (...args)=>{
1578
+ // Every answer the interaction gets, through respond() or discord.js directly, in order, for getResponse
1579
+ const log = [];
1580
+ Object.defineProperty(instance, RESPONSE_LOG, {
1581
+ value: log
1582
+ });
1583
+ const answer = (method, impl)=>stubs.set(method, recordedMock(method, impl, log));
1584
+ answer('reply', async (...args)=>{
1365
1585
  if (instance.deferred || instance.replied) throw alreadyReplied();
1366
1586
  instance.replied = true;
1367
1587
  if (hasEphemeralFlag(args[0])) instance.ephemeral = true;
1368
- }));
1369
- stubs.set('deferReply', createMockFn(async (...args)=>{
1588
+ });
1589
+ answer('deferReply', async (...args)=>{
1370
1590
  if (instance.deferred || instance.replied) throw alreadyReplied();
1371
1591
  instance.deferred = true;
1372
1592
  if (hasEphemeralFlag(args[0])) instance.ephemeral = true;
1373
- }));
1374
- stubs.set('followUp', createMockFn(async ()=>{
1593
+ });
1594
+ answer('followUp', async ()=>{
1375
1595
  if (!instance.deferred && !instance.replied) throw notYetReplied('followUp');
1376
1596
  instance.replied = true;
1377
1597
  return createMockMessage();
1378
- }));
1598
+ });
1379
1599
  // The interaction's own message as Discord holds it: the message a component is on, or the original response.
1380
1600
  // An edit replaces it in the shape Discord stores, with ids and resolved media, stamped with when it was edited,
1381
1601
  // and fetchReply() reads that back, so code comparing the two sees what it would against Discord.
@@ -1384,7 +1604,7 @@ function createMockInteraction(Class, props) {
1384
1604
  // A command answered with a modal has no original response, and Discord refuses one asked for
1385
1605
  let modalAnswered = false;
1386
1606
  const unknownMessage = ()=>createDiscordError(10008, 'Unknown Message');
1387
- stubs.set('editReply', createMockFn(async (options)=>{
1607
+ answer('editReply', async (options)=>{
1388
1608
  if (!instance.deferred && !instance.replied) throw notYetReplied('editReply');
1389
1609
  if (modalAnswered) throw unknownMessage();
1390
1610
  instance.replied = true;
@@ -1394,42 +1614,33 @@ function createMockInteraction(Class, props) {
1394
1614
  ...held,
1395
1615
  components: held.returned ?? held.components
1396
1616
  });
1397
- }));
1617
+ });
1398
1618
  stubs.set('fetchReply', createMockFn(async ()=>{
1399
1619
  if (modalAnswered) throw unknownMessage();
1400
1620
  return messageFrom(current());
1401
1621
  }));
1402
- stubs.set('deleteReply', createMockFn(async ()=>{
1622
+ answer('deleteReply', async ()=>{
1403
1623
  if (!instance.deferred && !instance.replied) throw notYetReplied('deleteReply');
1404
1624
  if (modalAnswered) throw unknownMessage();
1405
- }));
1625
+ });
1406
1626
  // showModal — the first response of a command or a component, like reply()
1407
1627
  if (instance.type === discord_js.InteractionType.ApplicationCommand || instance.type === discord_js.InteractionType.MessageComponent) {
1408
- stubs.set('showModal', createMockFn(async ()=>{
1628
+ answer('showModal', async ()=>{
1409
1629
  if (instance.deferred || instance.replied) throw alreadyReplied();
1410
1630
  instance.replied = true;
1411
1631
  modalAnswered = instance.type === discord_js.InteractionType.ApplicationCommand;
1412
- }));
1632
+ });
1413
1633
  }
1414
1634
  // deferUpdate / update — components, and modals submitted from a message's component
1415
1635
  if (instance.type === discord_js.InteractionType.MessageComponent || instance.type === discord_js.InteractionType.ModalSubmit) {
1416
- stubs.set('update', createMockFn(async ()=>{
1636
+ answer('update', async ()=>{
1417
1637
  if (instance.deferred || instance.replied) throw alreadyReplied();
1418
1638
  instance.replied = true;
1419
- }));
1420
- stubs.set('deferUpdate', createMockFn(async ()=>{
1639
+ });
1640
+ answer('deferUpdate', async ()=>{
1421
1641
  if (instance.deferred || instance.replied) throw alreadyReplied();
1422
1642
  instance.deferred = true;
1423
- }));
1424
- }
1425
- // Every answer the interaction gets, through respond() or discord.js directly, in order, for getResponse
1426
- const log = [];
1427
- Object.defineProperty(instance, RESPONSE_LOG, {
1428
- value: log
1429
- });
1430
- for (const method of ANSWER_METHODS){
1431
- const stub = stubs.get(method);
1432
- if (stub) stubs.set(method, recorded(method, stub, log));
1643
+ });
1433
1644
  }
1434
1645
  }
1435
1646
  // Autocomplete is not repliable, but it has a response of its own: Discord accepts
@@ -1468,6 +1679,43 @@ function createMockInteraction(Class, props) {
1468
1679
  const unset = (key)=>!Object.prototype.hasOwnProperty.call(instance, key);
1469
1680
  const generatedId = unset('id') ? instance.id = nextSnowflake() : undefined;
1470
1681
  defineCreatedTime(instance, generatedId);
1682
+ // A raw member is from a server the bot isn't in: no cached guild, and its user the interaction's, as discord.js reads it
1683
+ const raw = own('member');
1684
+ // Given with a guild, a raw-shaped member is kept as the cached server's member it was before raw members
1685
+ if (isRawMember(raw) && own('guild')) {
1686
+ controller_decorator.warnOnce(mockLogger, "The member given is a raw member, from a server the bot isn't in, but the mock was given a guild, so it reads as a " + 'cached server: leave the guild out for a raw member, or give a GuildMember.');
1687
+ } else if (isRawMember(raw)) {
1688
+ const rawOnly = "The member given is a raw member, from a server the bot isn't in";
1689
+ if (!own('guildId')) throw new Error(`${rawOnly}, so the mock needs that server's guildId.`);
1690
+ if (own('channel') !== undefined) throw new Error(`${rawOnly}, where discord.js caches no channel, but the mock was given a channel: leave the channel out.`);
1691
+ const givenUser = own('user');
1692
+ if (givenUser && givenUser.id !== raw.user.id) {
1693
+ throw new Error(`The member given is user ${raw.user.id}'s, but the mock's user is ${givenUser.id}: give the same user, or leave one of them out.`);
1694
+ }
1695
+ if (!givenUser) {
1696
+ const { id, username, global_name: globalName, discriminator, avatar } = raw.user;
1697
+ instance.user = createMockUser({
1698
+ id,
1699
+ username,
1700
+ globalName: globalName ?? null,
1701
+ discriminator,
1702
+ avatar: avatar ?? null
1703
+ });
1704
+ }
1705
+ Object.defineProperty(instance, 'guild', {
1706
+ value: null,
1707
+ writable: true,
1708
+ enumerable: true,
1709
+ configurable: true
1710
+ });
1711
+ // Only its channelId: discord.js caches no channel from a server the bot isn't in
1712
+ Object.defineProperty(instance, 'channel', {
1713
+ value: null,
1714
+ writable: true,
1715
+ enumerable: true,
1716
+ configurable: true
1717
+ });
1718
+ }
1471
1719
  const userGiven = !unset('user');
1472
1720
  if (!userGiven) instance.user = mockUser();
1473
1721
  // Assigned once by discord.js's Base constructor, as on a message, and the gateway caches the user on it
@@ -1610,29 +1858,25 @@ function createMockInteraction(Class, props) {
1610
1858
  * on a double whose shape is an interface with nothing at runtime to read it from.
1611
1859
  */ function stubCallable() {
1612
1860
  const fn = createMockFn();
1613
- const nested = new Map();
1614
- return new Proxy(fn, {
1615
- get (target, prop) {
1616
- if (typeof prop === 'symbol') return Reflect.get(target, prop, target);
1617
- const key = prop;
1861
+ // Any property beyond the mock's own API is a nested mock, made on first read and kept on the function, out of its
1862
+ // keys. Its prototype makes them rather than a Proxy around it, so it stays the plain mock function matchers read
1863
+ const inherited = Object.getPrototypeOf(fn);
1864
+ Object.setPrototypeOf(fn, new Proxy(inherited, {
1865
+ get (proto, prop, receiver) {
1866
+ if (typeof prop === 'symbol' || prop in proto) return Reflect.get(proto, prop, receiver);
1618
1867
  // Never thenable — otherwise awaiting a mock hangs on itself
1619
- if (key === 'then') return undefined;
1620
- // The mock's own API (`mock`, `mockReturnValue`, `_isMockFunction`, …) and
1621
- // the function intrinsics pass straight through.
1622
- if (key in target) return Reflect.get(target, prop, target);
1623
- if (!nested.has(key)) nested.set(key, stubCallable());
1624
- return nested.get(key);
1625
- },
1626
- set (target, prop, value) {
1627
- Object.defineProperty(target, prop, {
1628
- value,
1868
+ if (prop === 'then') return undefined;
1869
+ const nested = stubCallable();
1870
+ Object.defineProperty(receiver, prop, {
1871
+ value: nested,
1629
1872
  writable: true,
1630
- enumerable: true,
1873
+ enumerable: false,
1631
1874
  configurable: true
1632
1875
  });
1633
- return true;
1876
+ return nested;
1634
1877
  }
1635
- });
1878
+ }));
1879
+ return fn;
1636
1880
  }
1637
1881
  /**
1638
1882
  * Creates a mock of any type, with no class needed, for service doubles and interfaces.
@@ -2108,10 +2352,16 @@ const CHANNEL_TYPE_GUARDS = [
2108
2352
  });
2109
2353
  return stubDeep(manager);
2110
2354
  }
2111
- /** A server's @everyone role, which has the server's id and ranks lowest, at position 0. */ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
2355
+ /** The permissions Discord gives a server's @everyone role in a server made today. */ const NEW_SERVER_EVERYONE_PERMISSIONS = 2248473465835073n;
2356
+ // A server's @everyone role, which has the server's id and ranks lowest, at position 0; with strict mocks, the
2357
+ // permissions Discord gives it in a new server
2358
+ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
2112
2359
  id: guildId,
2113
2360
  name: '@everyone',
2114
- position: 0
2361
+ position: 0,
2362
+ ...strictMocks() ? {
2363
+ permissions: new discord_js.PermissionsBitField(NEW_SERVER_EVERYONE_PERMISSIONS).freeze()
2364
+ } : {}
2115
2365
  });
2116
2366
  /**
2117
2367
  * A guild's role manager with `roles` in its cache, and the guild's @everyone role: the one given with the guild's id,
@@ -2139,6 +2389,68 @@ const CHANNEL_TYPE_GUARDS = [
2139
2389
  });
2140
2390
  return manager;
2141
2391
  }
2392
+ /**
2393
+ * Creates the member Discord sends with an interaction from a server the bot isn't in, as discord.js keeps it.
2394
+ *
2395
+ * Give it as an interaction's `member`, with the server's `guildId` and no `guild`, to test a user-installed command
2396
+ * run there: the interaction reads `inRawGuild()` true, `guild` and `channel` null, `user` the member's user and
2397
+ * `memberPermissions` the member's, and a user option's member is the plain member Discord resolves too.
2398
+ *
2399
+ * @remarks
2400
+ * It is plain data, not a `GuildMember`: `roles` are role ids, `permissions` is the permission bitfield as a string,
2401
+ * and the rest are as Discord sends them, `null` for what may be absent and `false` for a flag. Code that reads
2402
+ * `member.roles.cache` throws on it, as it does in Discord.
2403
+ *
2404
+ * @param overrides - Fields for the member; see {@link MockRawMemberOverrides}.
2405
+ * @returns The member, as an `APIInteractionGuildMember`.
2406
+ *
2407
+ * @example
2408
+ * ```ts
2409
+ * import { expect } from 'vitest'
2410
+ *
2411
+ * const member = createMockRawMember({ roles: ['300000000000000001'], permissions: [PermissionFlagsBits.SendMessages] })
2412
+ * const command = createMockInteraction(ChatInputCommandInteraction, { guildId: '100000000000000001', member })
2413
+ * expect(command.inRawGuild()).toBe(true)
2414
+ * ```
2415
+ *
2416
+ * @group Testing
2417
+ * @category Mocks
2418
+ * @see {@link createMockMember}
2419
+ */ function createMockRawMember(overrides = {}) {
2420
+ const { permissions, user, ...given } = overrides;
2421
+ return {
2422
+ user: {
2423
+ id: nextSnowflake(),
2424
+ username: 'user',
2425
+ discriminator: '0',
2426
+ global_name: null,
2427
+ avatar: null,
2428
+ ...user
2429
+ },
2430
+ roles: [],
2431
+ nick: null,
2432
+ avatar: null,
2433
+ banner: null,
2434
+ premium_since: null,
2435
+ communication_disabled_until: null,
2436
+ joined_at: new Date().toISOString(),
2437
+ deaf: false,
2438
+ mute: false,
2439
+ pending: false,
2440
+ flags: 0,
2441
+ ...given,
2442
+ permissions: String(new discord_js.PermissionsBitField(permissions ?? NEW_SERVER_EVERYONE_PERMISSIONS).bitfield)
2443
+ };
2444
+ }
2445
+ /** Whether `value` is a member Discord sends from a server the bot isn't in: plain data, not a `GuildMember`. */ function isRawMember(value) {
2446
+ if (typeof value !== 'object' || value === null || value instanceof discord_js.GuildMember) return false;
2447
+ const { roles, permissions, user } = value;
2448
+ return Array.isArray(roles) && typeof permissions === 'string' && typeof user === 'object' && user !== null;
2449
+ }
2450
+ /** A user option's member as Discord resolves it with an interaction from a server the bot isn't in. */ function rawResolvedMember() {
2451
+ const { user: _user, deaf: _deaf, mute: _mute, ...resolved } = createMockRawMember();
2452
+ return resolved;
2453
+ }
2142
2454
  /**
2143
2455
  * Creates a mock {@link GuildMember}: a user in a server, with the roles given.
2144
2456
  *
@@ -2289,6 +2601,8 @@ const CHANNEL_TYPE_GUARDS = [
2289
2601
  if (!Object.prototype.hasOwnProperty.call(item, 'guild')) item.guild = guild;
2290
2602
  }
2291
2603
  }
2604
+ // Strict mocks cache the bot's member from the start, as the gateway does when the bot joins, so lookups find it
2605
+ if (strictMocks()) void members.me;
2292
2606
  }
2293
2607
  /** The ids each kind of mention in `content` names, in order and once each. */ function mentionedIds(content) {
2294
2608
  const ids = (pattern)=>[
@@ -2511,7 +2825,8 @@ const CHANNEL_TYPE_GUARDS = [
2511
2825
  get: ()=>{
2512
2826
  const found = cacheOf(instance.channel.threads)?.get(instance.id);
2513
2827
  if (found) return found;
2514
- controller_decorator.warnPlaceholder(mockLogger, 'Message.thread', 'it is null unless the channel caches a thread under the message id, as discord.js reads it', 'Cache one with message.channel.threads.cache.set(message.id, thread).');
2828
+ if (strictMocks()) return null;
2829
+ controller_decorator.warnPlaceholder(mockLogger, 'Message.thread', 'it is null unless the channel caches a thread under the message id, as discord.js reads it', 'Cache one with message.channel.threads.cache.set(message.id, thread), or call useStrictMocks() to have it read null now.');
2515
2830
  return placeholderThread ?? (placeholderThread = stubDeep(Object.create(discord_js.ThreadChannel.prototype)));
2516
2831
  },
2517
2832
  set: (value)=>Object.defineProperty(instance, 'thread', {
@@ -2780,7 +3095,8 @@ const CHANNEL_TYPE_GUARDS = [
2780
3095
  if (!unowned.has(name)) unowned.set(name, memberIn(undefined, user.id, user));
2781
3096
  return unowned.get(name);
2782
3097
  }
2783
- if (!members.has(name)) members.set(name, owner.guildId ? memberFor(owner.guild, user, true) : null);
3098
+ const rawOwner = !owner.guild && isRawMember(Object.getOwnPropertyDescriptor(owner, 'member')?.value);
3099
+ if (!members.has(name)) members.set(name, owner.guildId ? rawOwner ? rawResolvedMember() : memberFor(owner.guild, user, true) : null);
2784
3100
  return members.get(name) ?? null;
2785
3101
  };
2786
3102
  base.getUser = createMockFn((name, required)=>{
@@ -2869,7 +3185,7 @@ const CHANNEL_TYPE_GUARDS = [
2869
3185
  * @category Module
2870
3186
  * @see {@link MeoCordTestingModule}
2871
3187
  * @see {@link getResponse}
2872
- * @see {@link https://meocord.dev/docs/4.1/invoke-and-dispatch | Invoke and dispatch}
3188
+ * @see {@link https://meocord.dev/docs/4.2/invoke-and-dispatch | Invoke and dispatch}
2873
3189
  */ class TestingModule {
2874
3190
  /**
2875
3191
  * Resolves the module's `useFactory` providers, awaiting those that return a promise, in dependency
@@ -3143,6 +3459,8 @@ const CHANNEL_TYPE_GUARDS = [
3143
3459
  controllerClasses: this.controllers,
3144
3460
  messageOptions: this.messageOptions,
3145
3461
  logger,
3462
+ // A test's fake clock can pass 5 s inside a handler, so the slow-handler warning waits to be asked for
3463
+ warnSlowMessageHandlers: false,
3146
3464
  // Strict: an answer MeoCord fails to build rejects the dispatch, where a bot logs it and carries on
3147
3465
  fallback: startupRoots.createFallback(logger, ()=>this.messageOptions, {
3148
3466
  strict: true,
@@ -3309,7 +3627,7 @@ const CHANNEL_TYPE_GUARDS = [
3309
3627
  * @group Testing
3310
3628
  * @category Module
3311
3629
  * @see {@link MeoCordTestingModule}
3312
- * @see {@link https://meocord.dev/docs/4.1/testing | The testing module}
3630
+ * @see {@link https://meocord.dev/docs/4.2/testing | The testing module}
3313
3631
  */ class TestingModuleBuilder {
3314
3632
  /**
3315
3633
  * Replaces a provider with a test double.
@@ -3457,199 +3775,236 @@ const CHANNEL_TYPE_GUARDS = [
3457
3775
  * its command's builder does not register, a customId pattern given as a command name, a builder that registers
3458
3776
  * another name, and component patterns that can match the same customId are named, as the bot names them.
3459
3777
  *
3778
+ * Each check reports every startup error it finds, and the first is thrown, unchanged; when there are several, each
3779
+ * is logged first, with the file it comes from. After {@link reportAllStartupErrors}, the errors decorators kept on
3780
+ * the classes the module runs are among them.
3781
+ *
3460
3782
  * @returns The compiled module.
3783
+ * @throws Error for the first startup error the checks find.
3461
3784
  */ compile() {
3462
3785
  // The bound the bot's config gives it, with the same words
3463
3786
  const shutdownTimeout = this.options.shutdownTimeout === undefined ? undefined : startupRoots.shutdownTimeoutProblem(this.options.shutdownTimeout);
3464
3787
  if (shutdownTimeout) throw new TypeError(`shutdownTimeout ${shutdownTimeout}.`);
3465
- const container = new inversify.Container();
3466
- const stages = this.globalStages();
3467
- if (stages) providers.bindGlobalStages(container, stages);
3468
- // Bound first, as in the app, so a class that injects it gets this instance
3469
- const appClasses = [];
3470
- const translator = ()=>container.isBound(controller_decorator.Translator) ? container.get(controller_decorator.Translator) : undefined;
3471
- container.bind(providers.HandlerRegistry).toConstantValue(new providers.HandlerRegistry(appClasses, messagesOf(this.options.app), translator));
3472
- // A testing module runs as one process, so a cross-shard call runs once, here
3473
- container.bind(providers.ShardContext).toConstantValue(new providers.ShardContext(undefined, providers.shardCallHandler(container, ()=>[
3474
- ...appClasses,
3475
- ...[
3476
- ...providers$1.keys()
3477
- ].filter(providers.isAppClassToken)
3478
- ], 'this testing module')));
3479
- providers.bindsOwnToken(container, providers.HandlerRegistry);
3480
- providers.bindsOwnToken(container, providers.ShardContext);
3481
- // Checked as the app checks its own: the app's providers in their order, then the test's, which replace them
3482
- // by token, then the overrides, which win
3483
- const providers$1 = providers.providerMap(this.wiring?.providers ?? [], '@MeoCord({ providers })');
3484
- for (const [token, provider] of providers.providerMap(this.options.providers ?? [], "the testing module's providers"))providers$1.set(token, provider);
3485
- for (const [token, override] of this.overrides)providers$1.set(token, override);
3486
- const services = this.wiring?.services ?? [];
3487
- const appOptions = this.options.app ? Reflect.getMetadata(controller_decorator.META.appOptions, this.options.app) : undefined;
3488
- // The app's own store, as the bot binds it, whether the module is given the app or built from it; none when the
3489
- // test provides the CooldownStore, so nothing builds the app's or asks for what it injects
3490
- const store = providers$1.has(controller_decorator.CooldownStore) ? undefined : appOptions?.cooldownStore;
3491
- // The themeFor class this module runs, the app's or overrideThemeFor's, bound on its own as the app binds it
3492
- const themeResolver = controller_decorator.themeResolverClass(stages?.themeFor?.resolvers);
3493
- // Every class the module runs, as the app finds them, except stage classes an override stands in for
3494
- const i18n = this.options.app && Reflect.getMetadata(controller_decorator.META.appOptions, this.options.app)?.i18n;
3495
- const startup = startupRoots.startupClasses({
3496
- controllers: this.options.controllers ?? [],
3497
- services,
3498
- providers: providers$1,
3499
- stages,
3500
- presenter: this.options.app && Reflect.getMetadata(controller_decorator.META.appOptions, this.options.app)?.presenter,
3501
- cooldownStore: store,
3502
- themeFor: stages?.themeFor?.resolvers,
3503
- observers: [
3504
- ...this.options.app ? providers.appObservers(this.options.app) : [],
3505
- ...this.options.observers ?? []
3506
- ],
3507
- stubs: [
3508
- ...this.guardOverrides.keys(),
3509
- ...this.interceptorOverrides.keys(),
3510
- ...this.filterOverrides.keys()
3511
- ]
3512
- });
3513
- const hasTranslator = i18n !== undefined || providers$1.has(controller_decorator.Translator);
3514
- startupRoots.assertStartupClasses(startup, {
3515
- translator: hasTranslator
3516
- });
3517
- providers.markStartupChecked(container, [
3518
- ...startup.classes,
3519
- ...providers.meocordClasses()
3520
- ], startupRoots.lateGuardCheck(providers$1, hasTranslator));
3521
- // The bot binds the Client it logs in with; a test gives its own, and is told so where one is needed
3522
- const needClient = startup.classes.filter((cls)=>providers.injectedTokens(cls).includes(discord_js.Client));
3523
- if (needClient.length > 0 && !providers$1.has(discord_js.Client)) {
3524
- container.bind(discord_js.Client).toDynamicValue(()=>{
3525
- 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() }.');
3526
- });
3527
- }
3528
- // Bind guard overrides — prevents inversify from auto-wiring guard dependencies
3529
- for (const [guardClass, stub] of this.guardOverrides){
3530
- container.bind(guardClass).toConstantValue(stub);
3531
- }
3532
- for (const [filterClass, stub] of this.filterOverrides){
3533
- container.bind(filterClass).toConstantValue(stub);
3534
- }
3535
- for (const [interceptorClass, stub] of this.interceptorOverrides){
3536
- container.bind(interceptorClass).toConstantValue(stub);
3537
- }
3538
- // The app's cooldown policy, so a test of a failing store sees what the bot would do
3539
- if (appOptions && !container.isBound(providers.COOLDOWN_POLICY)) container.bind(providers.COOLDOWN_POLICY).toConstantValue(providers.cooldownPolicyFrom(appOptions));
3540
- // The app's translator, unless a provider stands in for it
3541
- if (i18n && !providers$1.has(controller_decorator.Translator)) {
3542
- container.bind(controller_decorator.Translator).toConstantValue(i18n);
3543
- providers.bindsOwnToken(container, controller_decorator.Translator);
3544
- }
3545
- // Recursively bind controllers and their dependencies, skipping already-bound tokens
3546
- const bindClass = (cls)=>{
3547
- // A provided class is bound by its own provider, wherever in the list that provider comes
3548
- if (container.isBound(cls) || providers$1.has(cls)) return;
3549
- if (providers.injectedTokens(cls).includes(controller_decorator.ExecutionContext)) throw providers.singletonContextError(cls);
3550
- providers.assertStartupChecked(container, cls);
3551
- providers.makeInjectable(cls);
3552
- container.bind(cls).toSelf().inSingletonScope();
3553
- // By constructor type or @inject token, as the app binds them
3554
- for (const dep of providers.injectedTokens(cls)){
3555
- if (dep === controller_decorator.Translator && !container.isBound(controller_decorator.Translator)) throw controller_decorator.missingTranslatorError(cls);
3556
- if (providers.isAppClassToken(dep)) bindClass(dep);
3557
- }
3558
- };
3559
- // The app's own store, resolved like a service, unless the test provides the CooldownStore itself; bound first, as the
3560
- // app binds it, so a class that injects the token gets the store
3561
- if (!providers$1.has(controller_decorator.CooldownStore)) {
3562
- if (store) {
3563
- bindClass(store);
3564
- container.bind(controller_decorator.CooldownStore).toService(store);
3565
- } else {
3566
- container.bind(controller_decorator.CooldownStore).toConstantValue(new controller_decorator.MemoryCooldownStore());
3567
- }
3568
- providers.bindsOwnToken(container, controller_decorator.CooldownStore, store);
3569
- }
3570
- // Before the controllers, so a class token that is provided is not also bound as itself
3571
- for (const provider of providers$1.values())providers.bindProvider(container, provider, bindClass);
3572
- for (const ctrl of this.options.controllers ?? []){
3573
- bindClass(ctrl);
3574
- // Stamp container on controller class so @UseGuard works in tests too
3575
- Reflect.defineMetadata(controller_decorator.META.container, container, ctrl);
3576
- }
3577
- for (const service of services)bindClass(service);
3578
- if (themeResolver) bindClass(themeResolver);
3579
- // The classes whose @On and @Once handlers emit reaches: class providers bound as themselves, the
3580
- // controllers, and what they inject; factories resolve in the same order
3581
- const order = providers.resolutionOrder(container, providers$1, [
3582
- ...providers$1.keys(),
3583
- ...services,
3584
- ...themeResolver ? [
3585
- themeResolver
3586
- ] : [],
3587
- ...this.options.controllers ?? []
3588
- ], {
3589
- followOwnTokens: false
3590
- });
3591
- appClasses.push(...order.filter((token)=>{
3592
- const provider = providers$1.get(token);
3593
- return providers.isAppClassToken(token) && (!provider || providers.isClassProvider(provider) && provider.useClass === token);
3594
- }));
3595
- providers.assertProvided(container, providers$1, startup.classes, "the testing module's providers");
3596
- // Every class the module runs, so @UseGuard resolves guards on a direct call to any of them
3597
- for (const cls of startup.classes)Reflect.defineMetadata(controller_decorator.META.container, container, cls);
3598
- providers.prepareHandlerStages(container, appClasses);
3599
- const messages = messagesOf(this.options.app);
3600
- // As the app would at startup, refuses a message pattern that cannot be read or two that match the same messages
3601
- providers.buildMessageRoutes(this.options.controllers ?? [], messages);
3602
- // As the app does when it is created, so a test sees the refusal the bot would give
3603
- startupRoots.assertDistinctCommands(this.options.controllers ?? []);
3604
- // A handler with no builder is how a fixture is written, so only what is always a mistake is named
3605
- startupRoots.warnUnregisteredCommands(this.options.controllers ?? [], {
3606
- missingBuilders: false
3607
- });
3608
- startupRoots.warnInheritedRoutes(this.options.controllers ?? []);
3609
- startupRoots.warnOverlappingPatterns(this.options.controllers ?? []);
3610
- startupRoots.warnHandlersOffControllers(this.options.controllers ?? [], appClasses);
3611
- if (this.options.app) providers.bindAppPresenter(container, this.options.app);
3612
- const observers = [
3613
- ...this.options.app ? providers.appObservers(this.options.app) : [],
3614
- ...this.options.observers ?? []
3615
- ];
3616
- providers.assertObservers("the testing module's observers", observers);
3617
- providers.bindObservers(container, observers);
3618
- // The store calls ask: the test's own when it provides CooldownStore, else the app's
3619
- const boundStore = store ?? (providers$1.has(controller_decorator.CooldownStore) ? controller_decorator.CooldownStore : undefined);
3620
- // The order the app runs lifecycle hooks in: its cooldown store, then providers, controllers and observers, each
3621
- // after what it injects
3622
- const lifecycle = providers.resolutionOrder(container, providers$1, [
3623
- ...boundStore ? [
3624
- boundStore
3625
- ] : [],
3626
- ...providers$1.keys(),
3627
- ...services,
3628
- ...themeResolver ? [
3629
- themeResolver
3788
+ // With startupErrors: 'all', what the decorators kept on the classes this module runs
3789
+ const declared = controller_decorator.declaredErrorsOf([
3790
+ ...this.options.app ? [
3791
+ this.options.app
3630
3792
  ] : [],
3631
3793
  ...this.options.controllers ?? [],
3632
- ...observers
3633
- ]).map((token)=>({
3634
- token,
3635
- name: providers.tokenName(token),
3636
- dependencies: providers.tokenDependencies(container, providers$1, token),
3637
- ...token === boundStore && {
3638
- cooldownStore: true
3794
+ ...this.wiring?.services ?? [],
3795
+ ...this.options.observers ?? []
3796
+ ]);
3797
+ // Every error each check finds is reported, as the bot reports them, and the first is thrown
3798
+ try {
3799
+ return controller_decorator.collectStartupErrors(()=>{
3800
+ declared.forEach(controller_decorator.startupError);
3801
+ const container = new inversify.Container();
3802
+ const stages = this.globalStages();
3803
+ if (stages) providers.bindGlobalStages(container, stages);
3804
+ // Bound first, as in the app, so a class that injects it gets this instance
3805
+ const appClasses = [];
3806
+ const translator = ()=>container.isBound(controller_decorator.Translator) ? container.get(controller_decorator.Translator) : undefined;
3807
+ container.bind(providers.HandlerRegistry).toConstantValue(new providers.HandlerRegistry(appClasses, messagesOf(this.options.app), translator));
3808
+ // A testing module runs as one process, so a cross-shard call runs once, here
3809
+ container.bind(providers.ShardContext).toConstantValue(new providers.ShardContext(undefined, providers.shardCallHandler(container, ()=>[
3810
+ ...appClasses,
3811
+ ...[
3812
+ ...providers$1.keys()
3813
+ ].filter(providers.isAppClassToken)
3814
+ ], 'this testing module')));
3815
+ providers.bindsOwnToken(container, providers.HandlerRegistry);
3816
+ providers.bindsOwnToken(container, providers.ShardContext);
3817
+ // Checked as the app checks its own: the app's providers in their order, then the test's, which replace them
3818
+ // by token, then the overrides, which win
3819
+ const providers$1 = providers.providerMap(this.wiring?.providers ?? [], '@MeoCord({ providers })');
3820
+ for (const [token, provider] of providers.providerMap(this.options.providers ?? [], "the testing module's providers"))providers$1.set(token, provider);
3821
+ for (const [token, override] of this.overrides)providers$1.set(token, override);
3822
+ // Each pass of checks ends before the next step that is no check, so a module with one error stops where it always did
3823
+ controller_decorator.stopOnStartupErrors();
3824
+ const services = this.wiring?.services ?? [];
3825
+ const appOptions = this.options.app ? Reflect.getMetadata(controller_decorator.META.appOptions, this.options.app) : undefined;
3826
+ // The app's own store, as the bot binds it, whether the module is given the app or built from it; none when the
3827
+ // test provides the CooldownStore, so nothing builds the app's or asks for what it injects
3828
+ const store = providers$1.has(controller_decorator.CooldownStore) ? undefined : appOptions?.cooldownStore;
3829
+ // The themeFor class this module runs, the app's or overrideThemeFor's, bound on its own as the app binds it
3830
+ const themeResolver = controller_decorator.themeResolverClass(stages?.themeFor?.resolvers);
3831
+ // Every class the module runs, as the app finds them, except stage classes an override stands in for
3832
+ const i18n = this.options.app && Reflect.getMetadata(controller_decorator.META.appOptions, this.options.app)?.i18n;
3833
+ const startup = startupRoots.startupClasses({
3834
+ controllers: this.options.controllers ?? [],
3835
+ services,
3836
+ providers: providers$1,
3837
+ stages,
3838
+ presenter: this.options.app && Reflect.getMetadata(controller_decorator.META.appOptions, this.options.app)?.presenter,
3839
+ cooldownStore: store,
3840
+ themeFor: stages?.themeFor?.resolvers,
3841
+ observers: [
3842
+ ...this.options.app ? providers.appObservers(this.options.app) : [],
3843
+ ...this.options.observers ?? []
3844
+ ],
3845
+ stubs: [
3846
+ ...this.guardOverrides.keys(),
3847
+ ...this.interceptorOverrides.keys(),
3848
+ ...this.filterOverrides.keys()
3849
+ ]
3850
+ });
3851
+ const hasTranslator = i18n !== undefined || providers$1.has(controller_decorator.Translator);
3852
+ startupRoots.assertStartupClasses(startup, {
3853
+ translator: hasTranslator
3854
+ });
3855
+ // Those of the stages, guards and other classes they reach
3856
+ controller_decorator.declaredErrorsOf(startup.classes).forEach(controller_decorator.startupError);
3857
+ controller_decorator.stopOnStartupErrors();
3858
+ providers.markStartupChecked(container, [
3859
+ ...startup.classes,
3860
+ ...providers.meocordClasses()
3861
+ ], startupRoots.lateGuardCheck(providers$1, hasTranslator));
3862
+ // The bot binds the Client it logs in with; a test gives its own, and is told so where one is needed
3863
+ const needClient = startup.classes.filter((cls)=>providers.injectedTokens(cls).includes(discord_js.Client));
3864
+ if (needClient.length > 0 && !providers$1.has(discord_js.Client)) {
3865
+ container.bind(discord_js.Client).toDynamicValue(()=>{
3866
+ 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() }.');
3867
+ });
3868
+ }
3869
+ // Bind guard overrides — prevents inversify from auto-wiring guard dependencies
3870
+ for (const [guardClass, stub] of this.guardOverrides){
3871
+ container.bind(guardClass).toConstantValue(stub);
3872
+ }
3873
+ for (const [filterClass, stub] of this.filterOverrides){
3874
+ container.bind(filterClass).toConstantValue(stub);
3875
+ }
3876
+ for (const [interceptorClass, stub] of this.interceptorOverrides){
3877
+ container.bind(interceptorClass).toConstantValue(stub);
3639
3878
  }
3640
- }));
3641
- // Recorded as the container makes each one, so close() shuts down exactly what exists
3642
- const constructed = new Set();
3643
- for (const { token } of lifecycle){
3644
- container.onActivation(token, (_context, instance)=>{
3645
- constructed.add(token);
3646
- return instance;
3879
+ // The app's cooldown policy, so a test of a failing store sees what the bot would do
3880
+ if (appOptions && !container.isBound(providers.COOLDOWN_POLICY)) container.bind(providers.COOLDOWN_POLICY).toConstantValue(providers.cooldownPolicyFrom(appOptions));
3881
+ // The app's translator, unless a provider stands in for it
3882
+ if (i18n && !providers$1.has(controller_decorator.Translator)) {
3883
+ container.bind(controller_decorator.Translator).toConstantValue(i18n);
3884
+ providers.bindsOwnToken(container, controller_decorator.Translator);
3885
+ }
3886
+ // Recursively bind controllers and their dependencies, skipping already-bound tokens
3887
+ const bindClass = (cls)=>{
3888
+ // A provided class is bound by its own provider, wherever in the list that provider comes
3889
+ if (container.isBound(cls) || providers$1.has(cls)) return;
3890
+ if (providers.injectedTokens(cls).includes(controller_decorator.ExecutionContext)) return controller_decorator.startupError(providers.singletonContextError(cls));
3891
+ providers.assertStartupChecked(container, cls);
3892
+ providers.makeInjectable(cls);
3893
+ container.bind(cls).toSelf().inSingletonScope();
3894
+ // By constructor type or @inject token, as the app binds them
3895
+ for (const dep of providers.injectedTokens(cls)){
3896
+ if (dep === controller_decorator.Translator && !container.isBound(controller_decorator.Translator)) controller_decorator.startupError(controller_decorator.missingTranslatorError(cls));
3897
+ else if (providers.isAppClassToken(dep)) bindClass(dep);
3898
+ }
3899
+ };
3900
+ // The app's own store, resolved like a service, unless the test provides the CooldownStore itself; bound first, as the
3901
+ // app binds it, so a class that injects the token gets the store
3902
+ if (!providers$1.has(controller_decorator.CooldownStore)) {
3903
+ if (store) {
3904
+ bindClass(store);
3905
+ container.bind(controller_decorator.CooldownStore).toService(store);
3906
+ } else {
3907
+ container.bind(controller_decorator.CooldownStore).toConstantValue(new controller_decorator.MemoryCooldownStore());
3908
+ }
3909
+ providers.bindsOwnToken(container, controller_decorator.CooldownStore, store);
3910
+ }
3911
+ // Before the controllers, so a class token that is provided is not also bound as itself
3912
+ for (const provider of providers$1.values())providers.bindProvider(container, provider, bindClass);
3913
+ for (const ctrl of this.options.controllers ?? [])bindClass(ctrl);
3914
+ for (const service of services)bindClass(service);
3915
+ if (themeResolver) bindClass(themeResolver);
3916
+ // What follows walks what these bound, so a class refused here is not reported again as missing
3917
+ controller_decorator.stopOnStartupErrors();
3918
+ // The classes whose @On and @Once handlers emit reaches: class providers bound as themselves, the
3919
+ // controllers, and what they inject; factories resolve in the same order
3920
+ const order = providers.resolutionOrder(container, providers$1, [
3921
+ ...providers$1.keys(),
3922
+ ...services,
3923
+ ...themeResolver ? [
3924
+ themeResolver
3925
+ ] : [],
3926
+ ...this.options.controllers ?? []
3927
+ ], {
3928
+ followOwnTokens: false
3929
+ });
3930
+ appClasses.push(...order.filter((token)=>{
3931
+ const provider = providers$1.get(token);
3932
+ return providers.isAppClassToken(token) && (!provider || providers.isClassProvider(provider) && provider.useClass === token);
3933
+ }));
3934
+ providers.assertProvided(container, providers$1, startup.classes, "the testing module's providers");
3935
+ providers.prepareHandlerStages(container, appClasses);
3936
+ const messages = messagesOf(this.options.app);
3937
+ // As the app would at startup, refuses a message pattern that cannot be read or two that match the same messages
3938
+ providers.buildMessageRoutes(this.options.controllers ?? [], messages);
3939
+ // As the app does when it is created, so a test sees the refusal the bot would give
3940
+ startupRoots.assertDistinctCommands(this.options.controllers ?? []);
3941
+ if (this.options.app) providers.prepareAppPresenter(container, this.options.app);
3942
+ const observers = [
3943
+ ...this.options.app ? providers.appObservers(this.options.app) : [],
3944
+ ...this.options.observers ?? []
3945
+ ];
3946
+ providers.assertObservers("the testing module's observers", observers);
3947
+ // Only @Observer classes are bound as observers
3948
+ controller_decorator.stopOnStartupErrors();
3949
+ providers.bindObservers(container, observers);
3950
+ // The store calls ask: the test's own when it provides CooldownStore, else the app's
3951
+ const boundStore = store ?? (providers$1.has(controller_decorator.CooldownStore) ? controller_decorator.CooldownStore : undefined);
3952
+ // The order the app runs lifecycle hooks in: its cooldown store, then providers, controllers and observers, each
3953
+ // after what it injects
3954
+ const lifecycle = providers.resolutionOrder(container, providers$1, [
3955
+ ...boundStore ? [
3956
+ boundStore
3957
+ ] : [],
3958
+ ...providers$1.keys(),
3959
+ ...services,
3960
+ ...themeResolver ? [
3961
+ themeResolver
3962
+ ] : [],
3963
+ ...this.options.controllers ?? [],
3964
+ ...observers
3965
+ ]).map((token)=>({
3966
+ token,
3967
+ name: providers.tokenName(token),
3968
+ dependencies: providers.tokenDependencies(container, providers$1, token),
3969
+ ...token === boundStore && {
3970
+ cooldownStore: true
3971
+ }
3972
+ }));
3973
+ // Every check has run, and touched only the container: from here the module is made, once none found an error
3974
+ controller_decorator.stopOnStartupErrors();
3975
+ // Every class the module runs, so @UseGuard resolves guards on a direct call to any of them
3976
+ for (const cls of [
3977
+ ...this.options.controllers ?? [],
3978
+ ...startup.classes
3979
+ ])Reflect.defineMetadata(controller_decorator.META.container, container, cls);
3980
+ providers.prepareThemes(container, appClasses);
3981
+ // A handler with no builder is how a fixture is written, so only what is always a mistake is named
3982
+ startupRoots.warnUnregisteredCommands(this.options.controllers ?? [], {
3983
+ missingBuilders: false
3984
+ });
3985
+ startupRoots.warnInheritedRoutes(this.options.controllers ?? []);
3986
+ startupRoots.warnOverlappingPatterns(this.options.controllers ?? []);
3987
+ startupRoots.warnHandlersOffControllers(this.options.controllers ?? [], appClasses);
3988
+ if (this.options.app) providers.bindAppPresenter(container, this.options.app);
3989
+ // Recorded as the container makes each one, so close() shuts down exactly what exists
3990
+ const constructed = new Set();
3991
+ for (const { token } of lifecycle){
3992
+ container.onActivation(token, (_context, instance)=>{
3993
+ constructed.add(token);
3994
+ return instance;
3995
+ });
3996
+ }
3997
+ const warnUnanswered = this.options.app && Reflect.getMetadata(controller_decorator.META.appOptions, this.options.app)?.warnUnanswered;
3998
+ return new TestingModule(container, [
3999
+ ...this.options.controllers ?? []
4000
+ ], appClasses, providers$1, order, messages, lifecycle, constructed, warnUnanswered, services, this.options.shutdownTimeout ?? startupRoots.DEFAULT_SHUTDOWN_TIMEOUT_MS);
3647
4001
  });
4002
+ } catch (error) {
4003
+ if (controller_decorator.isRefusal(error) && !controller_decorator.isExplainedError(error)) {
4004
+ if (controller_decorator.logStartupErrors(error, (text)=>new controller_decorator.Logger('TestingModule').error(text), 'the testing module did not compile')) controller_decorator.markExplained(error);
4005
+ }
4006
+ throw error;
3648
4007
  }
3649
- const warnUnanswered = this.options.app && Reflect.getMetadata(controller_decorator.META.appOptions, this.options.app)?.warnUnanswered;
3650
- return new TestingModule(container, [
3651
- ...this.options.controllers ?? []
3652
- ], appClasses, providers$1, order, messages, lifecycle, constructed, warnUnanswered, services, this.options.shutdownTimeout ?? startupRoots.DEFAULT_SHUTDOWN_TIMEOUT_MS);
3653
4008
  }
3654
4009
  constructor(options, wiring){
3655
4010
  this.options = options;
@@ -3687,7 +4042,7 @@ const CHANNEL_TYPE_GUARDS = [
3687
4042
  * @category Module
3688
4043
  * @see {@link TestingModuleBuilder}
3689
4044
  * @see {@link TestingModule}
3690
- * @see {@link https://meocord.dev/docs/4.1/testing | The testing module}
4045
+ * @see {@link https://meocord.dev/docs/4.2/testing | The testing module}
3691
4046
  */ class MeoCordTestingModule {
3692
4047
  /**
3693
4048
  * Starts building a testing module from the classes a test needs.
@@ -3777,6 +4132,29 @@ const CHANNEL_TYPE_GUARDS = [
3777
4132
  providers.adviseInPlaceOfInjecting(TestingModuleBuilder, 'build it with MeoCordTestingModule.create(…) or MeoCordTestingModule.fromApp(…)');
3778
4133
  providers.adviseInPlaceOfInjecting(TestingModule, 'make it with MeoCordTestingModule.create(…).compile()');
3779
4134
 
4135
+ /**
4136
+ * Has decorators keep the startup errors they find, as a bot built with `startupErrors: 'all'` does, so a testing
4137
+ * module reports every one at once.
4138
+ *
4139
+ * Call it once in the test runner's setup file, before any test imports a controller: a decorator runs when its class
4140
+ * is defined, as its file is imported. A decorator given what it cannot use then throws nothing as the file loads, and
4141
+ * `MeoCordTestingModule.create(...).compile()` logs every startup error of the classes it runs, each with its handler
4142
+ * and file, then throws the first. Without it, a decorator throws its error as its class is defined.
4143
+ *
4144
+ * @example
4145
+ * ```ts
4146
+ * // vitest.setup.ts
4147
+ * reportAllStartupErrors()
4148
+ * ```
4149
+ *
4150
+ * @group Testing
4151
+ * @category Module
4152
+ * @see {@link MeoCordTestingModule}
4153
+ * @see {@link https://meocord.dev/docs/4.2/configuration | Configuration}
4154
+ */ function reportAllStartupErrors() {
4155
+ controller_decorator.setStartupErrorsMode('all');
4156
+ }
4157
+
3780
4158
  /** The options `@MeoCord` declares on an application class. */ function appOptionsOf(app) {
3781
4159
  const options = Reflect.getMetadata(controller_decorator.META.appOptions, app);
3782
4160
  if (!options) throw new TypeError(`${app.name || 'The given class'} is not decorated with @MeoCord().`);
@@ -3820,7 +4198,7 @@ providers.adviseInPlaceOfInjecting(TestingModule, 'make it with MeoCordTestingMo
3820
4198
  * @group Testing
3821
4199
  * @category Inspection
3822
4200
  * @see {@link findRouteConflicts}
3823
- * @see {@link https://meocord.dev/docs/4.1/components | Buttons, selects and modals}
4201
+ * @see {@link https://meocord.dev/docs/4.2/components | Buttons, selects and modals}
3824
4202
  */ function resolveRoute(app, input) {
3825
4203
  if ('content' in input) {
3826
4204
  const { messages = {} } = appOptionsOf(app);
@@ -3846,7 +4224,10 @@ providers.adviseInPlaceOfInjecting(TestingModule, 'make it with MeoCordTestingMo
3846
4224
  const { route, params, text } = matched;
3847
4225
  const method = route.meta.methodName;
3848
4226
  const typed = Object.keys(route.types).length > 0;
3849
- return {
4227
+ // The routes ranked behind it that the customId matches too, which dispatch never reaches for it
4228
+ const behind = routes.slice(routes.indexOf(route) + 1);
4229
+ const alsoMatches = behind.filter((other)=>other.meta.type === input.type && startupRoots.readCustomId(other.pattern, other.meta.regex, input.customId)).map((other)=>other.pattern);
4230
+ const resolved = {
3850
4231
  controller: route.controllerClass,
3851
4232
  method,
3852
4233
  handler: route.controllerClass.prototype[method],
@@ -3855,28 +4236,34 @@ providers.adviseInPlaceOfInjecting(TestingModule, 'make it with MeoCordTestingMo
3855
4236
  values: params
3856
4237
  }
3857
4238
  };
4239
+ return alsoMatches.length > 0 ? readableOnly(resolved, {
4240
+ alsoMatches
4241
+ }) : resolved;
3858
4242
  }
3859
4243
  /**
3860
- * Finds component patterns that can match the same `customId`, which MeoCord otherwise only warns about at startup.
4244
+ * Finds component patterns that rank equally and can match the same `customId`, which MeoCord otherwise only warns
4245
+ * about at startup.
3861
4246
  *
3862
- * Use it in a test to keep the warning from reaching production: two patterns that trade a literal for a parameter
3863
- * in opposite places both take one id, and neither is more specific. Patterns are compared within a component type.
4247
+ * Use it in a test to keep the warning from reaching production. Patterns rank segment by segment, a literal before a
4248
+ * param and then the narrower type, so two collide only with the same literals and equally narrow params at each
4249
+ * position, such as `{action:close|reopen}` and `{step:close|confirm}`, which both take `close`; the one listed
4250
+ * first runs. Patterns are compared within a component type.
3864
4251
  *
3865
4252
  * @param app - The application class decorated with `@MeoCord`.
3866
- * @returns Each pair of patterns that can match the same `customId`, with its component type.
4253
+ * @returns Each pair of patterns that rank equally and can match the same `customId`, with its component type.
3867
4254
  *
3868
4255
  * @example
3869
4256
  * ```ts
3870
4257
  * import { expect } from 'vitest'
3871
4258
  *
3872
4259
  * @Controller()
3873
- * class CardController {
3874
- * @Command('card/{id}/open', CommandType.BUTTON)
3875
- * async open(interaction: ButtonInteraction) { await respond(interaction).send('Opened.') }
3876
- * @Command('card/new/{kind}', CommandType.BUTTON)
3877
- * async create(interaction: ButtonInteraction) { await respond(interaction).send('Created.') }
4260
+ * class TicketController {
4261
+ * @Command('ticket/{action:close|reopen}', CommandType.BUTTON)
4262
+ * async act(interaction: ButtonInteraction) { await respond(interaction).send('Done.') }
4263
+ * @Command('ticket/{step:close|confirm}', CommandType.BUTTON)
4264
+ * async step(interaction: ButtonInteraction) { await respond(interaction).send('Next.') }
3878
4265
  * }
3879
- * @MeoCord({ controllers: [CardController], clientOptions: { intents: [] } })
4266
+ * @MeoCord({ controllers: [TicketController], clientOptions: { intents: [] } })
3880
4267
  * class App {}
3881
4268
  * expect(findRouteConflicts(App)).toHaveLength(1)
3882
4269
  * ```
@@ -3885,7 +4272,23 @@ providers.adviseInPlaceOfInjecting(TestingModule, 'make it with MeoCordTestingMo
3885
4272
  * @category Inspection
3886
4273
  * @see {@link resolveRoute}
3887
4274
  */ function findRouteConflicts(app) {
3888
- return startupRoots.findComponentRouteConflicts(startupRoots.buildComponentRoutes(controllersOf(app)));
4275
+ return startupRoots.findComponentRouteConflicts(startupRoots.buildComponentRoutes(controllersOf(app))).map(({ type, patterns, runs })=>readableOnly({
4276
+ type: type,
4277
+ patterns
4278
+ }, {
4279
+ runs
4280
+ }));
4281
+ }
4282
+ /**
4283
+ * `target` with `fields` added as properties a test reads but `toEqual` and its kin, which compare enumerable keys,
4284
+ * don't see, so an assertion written for the fields it had before still holds.
4285
+ */ function readableOnly(target, fields) {
4286
+ for (const [key, value] of Object.entries(fields))Object.defineProperty(target, key, {
4287
+ value,
4288
+ enumerable: false,
4289
+ configurable: true
4290
+ });
4291
+ return target;
3889
4292
  }
3890
4293
 
3891
4294
  /**
@@ -4032,7 +4435,7 @@ const isMeoCordKey = (key)=>key.startsWith('meocord.');
4032
4435
  * @group Testing
4033
4436
  * @category Inspection
4034
4437
  * @see {@link createTranslator}
4035
- * @see {@link https://meocord.dev/docs/4.1/localisation | Localisation}
4438
+ * @see {@link https://meocord.dev/docs/4.2/localisation | Localisation}
4036
4439
  */ function expectCompleteCatalog(translator, options = {}) {
4037
4440
  const catalogs = translator[controller_decorator.CATALOGS];
4038
4441
  if (!catalogs) throw new Error('expectCompleteCatalog takes a translator made by createTranslator.');
@@ -4078,6 +4481,7 @@ const isMeoCordKey = (key)=>key.startsWith('meocord.');
4078
4481
 
4079
4482
  /** Long enough for a store over the network to answer each case, short enough that the suite takes seconds. */ const CASE_TIMEOUT_MS = 10000;
4080
4483
  const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
4484
+ /** How far a store's clock may run from this process's, as a store's server keeps its own time. */ const CLOCK_SLACK_MS = 50;
4081
4485
  /**
4082
4486
  * Checks that a `CooldownStore` counts calls as `MemoryCooldownStore` does, as a suite of test cases.
4083
4487
  *
@@ -4088,7 +4492,8 @@ const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
4088
4492
  * The cases cover what a shared store most often gets wrong: a sliding window rather than fixed buckets,
4089
4493
  * `retryAfterMs` from the oldest call still in the window, calls in the same millisecond kept apart, and concurrent
4090
4494
  * calls at the limit where exactly one may pass. They use real time, with windows short enough that the suite takes
4091
- * a few seconds, and each case counts under keys of its own. Each window is a whole number of milliseconds, as
4495
+ * a few seconds and margins wide enough that a correct store passes when timers fire late, as on a busy CI runner,
4496
+ * and each case counts under keys of its own. Each window is a whole number of milliseconds, as
4092
4497
  * `@Cooldown` gives a store: it rounds its `seconds` to the millisecond.
4093
4498
  *
4094
4499
  * @param name - What the store is called in the report.
@@ -4105,7 +4510,7 @@ const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
4105
4510
  * @group Testing
4106
4511
  * @category Inspection
4107
4512
  * @see {@link CooldownStore}
4108
- * @see {@link https://meocord.dev/docs/4.1/recipes/cooldown-stores | Cooldown stores}
4513
+ * @see {@link https://meocord.dev/docs/4.2/recipes/cooldown-stores | Cooldown stores}
4109
4514
  */ function testCooldownStore(name, factory, { describe, it, expect }) {
4110
4515
  const run = node_crypto.randomUUID();
4111
4516
  const key = (label)=>`meocord-suite:${run}:${label}`;
@@ -4128,32 +4533,38 @@ const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
4128
4533
  expect(refused.retryAfterMs).toBeLessThanOrEqual(limit.windowMs);
4129
4534
  });
4130
4535
  test('slides its window: a call is allowed once the oldest leaves it, not when a bucket resets', async (store)=>{
4536
+ // A second between calls, in a 2 s window, leaves room for a timer that fires late on a busy runner
4131
4537
  const limit = {
4132
4538
  uses: 2,
4133
- windowMs: 500
4539
+ windowMs: 2000
4134
4540
  };
4135
4541
  await store.consume(key('sliding'), limit);
4136
- await sleep(250);
4542
+ await sleep(1000);
4137
4543
  await store.consume(key('sliding'), limit);
4138
4544
  const refused = await store.consume(key('sliding'), limit);
4139
4545
  expect(refused.allowed).toBe(false);
4140
4546
  await sleep(refused.retryAfterMs + 40);
4141
- // The first call has left the window; the second, 250ms younger, still holds its use.
4547
+ // The first call has left the window; the second, a second younger, still holds its use.
4142
4548
  expect((await store.consume(key('sliding'), limit)).allowed).toBe(true);
4143
4549
  expect((await store.consume(key('sliding'), limit)).allowed).toBe(false);
4144
4550
  });
4145
4551
  test('counts retryAfterMs from the oldest call still in the window', async (store)=>{
4146
4552
  const limit = {
4147
4553
  uses: 2,
4148
- windowMs: 1000
4554
+ windowMs: 2000
4149
4555
  };
4556
+ const firstSent = Date.now();
4150
4557
  await store.consume(key('retry'), limit);
4151
- await sleep(300);
4558
+ const firstDone = Date.now();
4559
+ await sleep(1000);
4560
+ const secondSent = Date.now();
4152
4561
  await store.consume(key('retry'), limit);
4153
- // From the oldest call, about 700ms remain; from the newest, about 1000ms would.
4154
4562
  const { retryAfterMs } = await store.consume(key('retry'), limit);
4155
- expect(retryAfterMs).toBeGreaterThan(limit.windowMs - 600);
4156
- expect(retryAfterMs).toBeLessThanOrEqual(limit.windowMs - 200);
4563
+ const thirdDone = Date.now();
4564
+ // What is left of the oldest call's window, by the times measured around each call, as timers may fire late;
4565
+ // counted from the newest call, nearly the whole window would be left instead
4566
+ expect(retryAfterMs).toBeLessThanOrEqual(limit.windowMs - (secondSent - firstDone) + CLOCK_SLACK_MS);
4567
+ expect(retryAfterMs).toBeGreaterThanOrEqual(limit.windowMs - (thirdDone - firstSent) - CLOCK_SLACK_MS);
4157
4568
  });
4158
4569
  test('counts each key on its own', async (store)=>{
4159
4570
  const limit = {
@@ -4501,7 +4912,7 @@ const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
4501
4912
  * @group Testing
4502
4913
  * @category Inspection
4503
4914
  * @see {@link createExecutionContext}
4504
- * @see {@link https://meocord.dev/docs/4.1/testing-recipes | Testing recipes}
4915
+ * @see {@link https://meocord.dev/docs/4.2/testing-recipes | Testing recipes}
4505
4916
  */ function inspectHandler(controller, methodName, options = {}) {
4506
4917
  const context = new controller_decorator.HandlerExecutionContext({
4507
4918
  controller,
@@ -4510,7 +4921,7 @@ const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
4510
4921
  });
4511
4922
  const globals = options.app ? providers.appStages(options.app) : undefined;
4512
4923
  const { guards, interceptors, filters } = providers.handlerStages(controller.prototype, methodName, globals);
4513
- return {
4924
+ const inspection = {
4514
4925
  controller,
4515
4926
  methodName,
4516
4927
  guards: Object.freeze([
@@ -4532,6 +4943,23 @@ const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
4532
4943
  get: (metadata)=>context.get(metadata),
4533
4944
  getAll: (metadata)=>context.getAll(metadata)
4534
4945
  };
4946
+ // Read by a test but not compared by toEqual, so an assertion on the result's other fields holds
4947
+ Object.defineProperty(inspection, 'inheritedRoutes', {
4948
+ value: Object.freeze(inheritedRoutesOf(controller.prototype, methodName)),
4949
+ enumerable: false
4950
+ });
4951
+ return inspection;
4952
+ }
4953
+ /** The routes `methodName` answers that the class declaring it last doesn't declare for it: those it inherits. */ function inheritedRoutesOf(prototype, methodName) {
4954
+ const answered = [
4955
+ ...new Set(controller_decorator.getHandlerRoutes(prototype).filter((route)=>route.method === methodName).map((route)=>route.label))
4956
+ ];
4957
+ // The nearest class with its own decorators on the method; a class that only overrides it declares none
4958
+ for(let at = prototype; at && at !== Object.prototype; at = Object.getPrototypeOf(at)){
4959
+ const own = controller_decorator.getDeclaredRoutes(at).filter((route)=>route.method === methodName).map((route)=>route.label);
4960
+ if (own.length > 0) return at === prototype ? answered.filter((label)=>!own.includes(label)) : answered;
4961
+ }
4962
+ return [];
4535
4963
  }
4536
4964
 
4537
4965
  // Themes createMockTheme made, which withTheme uses as they are
@@ -4568,7 +4996,7 @@ function mockTheme(overrides, where) {
4568
4996
  * @group Testing
4569
4997
  * @category Mocks
4570
4998
  * @see {@link withTheme}
4571
- * @see {@link https://meocord.dev/docs/4.1/theming | Theming}
4999
+ * @see {@link https://meocord.dev/docs/4.2/theming | Theming}
4572
5000
  */ function createMockTheme(...[overrides]) {
4573
5001
  return mockTheme(overrides, 'createMockTheme');
4574
5002
  }
@@ -4615,6 +5043,7 @@ exports.createMockGuild = createMockGuild;
4615
5043
  exports.createMockInteraction = createMockInteraction;
4616
5044
  exports.createMockMember = createMockMember;
4617
5045
  exports.createMockMessage = createMockMessage;
5046
+ exports.createMockRawMember = createMockRawMember;
4618
5047
  exports.createMockTheme = createMockTheme;
4619
5048
  exports.createMockUser = createMockUser;
4620
5049
  exports.createModalFields = createModalFields;
@@ -4623,7 +5052,10 @@ exports.findRouteConflicts = findRouteConflicts;
4623
5052
  exports.getResponse = getResponse;
4624
5053
  exports.inspectHandler = inspectHandler;
4625
5054
  exports.isMockFunction = isMockFunction;
5055
+ exports.reportAllStartupErrors = reportAllStartupErrors;
4626
5056
  exports.resetAllMocks = resetAllMocks;
4627
5057
  exports.resolveRoute = resolveRoute;
4628
5058
  exports.testCooldownStore = testCooldownStore;
5059
+ exports.useMockFn = useMockFn;
5060
+ exports.useStrictMocks = useStrictMocks;
4629
5061
  exports.withTheme = withTheme;