meocord 4.1.1 → 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 (96) hide show
  1. package/CHANGELOG.md +104 -0
  2. package/README.md +23 -23
  3. package/dist/cjs/_shared/{controller.decorator-BywwkU9O.cjs → controller.decorator-CRn9kWob.cjs} +295 -87
  4. package/dist/cjs/_shared/{providers-BMMLp2SN.cjs → providers-ChvMqnLd.cjs} +74 -40
  5. package/dist/cjs/_shared/{sharded-cooldown-store-B65iaiAy.cjs → sharded-cooldown-store-BdAUTdYs.cjs} +1 -1
  6. package/dist/cjs/_shared/{startup-roots-lFc69AxC.cjs → startup-roots-CQo8mSP8.cjs} +158 -72
  7. package/dist/cjs/_shared/{translate-error-BnM8FbSH.cjs → translate-error-DhDVbT5M.cjs} +12 -3
  8. package/dist/cjs/common/index.cjs +4 -4
  9. package/dist/cjs/core/index.cjs +195 -157
  10. package/dist/cjs/decorator/index.cjs +66 -57
  11. package/dist/cjs/testing/index.cjs +736 -307
  12. package/dist/cli.json +1 -1
  13. package/dist/esm/bin/app-template/README.md.template +3 -1
  14. package/dist/esm/bin/app-template/src/controllers/button/sample.button.controller.spec.ts.template +8 -4
  15. package/dist/esm/bin/app-template/src/controllers/button/sample.button.controller.ts.template +2 -2
  16. package/dist/esm/bin/app-template/src/guards/owner.guard.spec.ts.template +8 -4
  17. package/dist/esm/bin/app-template/src/guards/owner.guard.ts.template +1 -1
  18. package/dist/esm/bin/app-template/vitest.config.ts.template +1 -1
  19. package/dist/esm/bin/app-template/vitest.setup.ts.template +7 -3
  20. package/dist/esm/build/load-config.pre-entry.js +4 -1
  21. package/dist/esm/common/decorator.js +6 -6
  22. package/dist/esm/common/deprecation.js +2 -2
  23. package/dist/esm/common/errors.js +2 -2
  24. package/dist/esm/common/execution-context.js +1 -1
  25. package/dist/esm/common/metadata.js +1 -1
  26. package/dist/esm/common/route.js +10 -1
  27. package/dist/esm/common/translate-error.js +1 -1
  28. package/dist/esm/core/command-conflicts.js +34 -34
  29. package/dist/esm/core/component-routes.js +74 -31
  30. package/dist/esm/core/dispatcher.js +51 -7
  31. package/dist/esm/core/filter-runner.js +2 -2
  32. package/dist/esm/core/handler-pipeline.js +27 -14
  33. package/dist/esm/core/handler-registry.js +1 -1
  34. package/dist/esm/core/interceptor-runner.js +2 -2
  35. package/dist/esm/core/meocord-factory.js +196 -157
  36. package/dist/esm/core/message-routes.js +4 -3
  37. package/dist/esm/core/observer-runner.js +4 -3
  38. package/dist/esm/core/providers.js +38 -21
  39. package/dist/esm/core/route-specificity.js +4 -4
  40. package/dist/esm/core/scalar-types.js +15 -3
  41. package/dist/esm/core/shard-context.js +1 -1
  42. package/dist/esm/core/startup-roots.js +2 -3
  43. package/dist/esm/core/theme-runtime.js +2 -2
  44. package/dist/esm/decorator/app.decorator.js +12 -7
  45. package/dist/esm/decorator/command-builder.decorator.js +4 -4
  46. package/dist/esm/decorator/controller-class.decorator.js +9 -4
  47. package/dist/esm/decorator/controller.decorator.js +110 -67
  48. package/dist/esm/decorator/cooldown.decorator.js +3 -3
  49. package/dist/esm/decorator/defer.decorator.js +4 -4
  50. package/dist/esm/decorator/event.decorator.js +5 -5
  51. package/dist/esm/decorator/filter.decorator.js +7 -7
  52. package/dist/esm/decorator/guard.decorator.js +8 -8
  53. package/dist/esm/decorator/inject.decorator.js +4 -4
  54. package/dist/esm/decorator/interceptor.decorator.js +7 -7
  55. package/dist/esm/decorator/observer.decorator.js +4 -4
  56. package/dist/esm/decorator/service.decorator.js +4 -4
  57. package/dist/esm/decorator/theme.decorator.js +3 -3
  58. package/dist/esm/decorator/validation.decorator.js +10 -10
  59. package/dist/esm/package.json.js +1 -1
  60. package/dist/esm/testing/catalog.js +1 -1
  61. package/dist/esm/testing/cooldown-store-suite.js +18 -10
  62. package/dist/esm/testing/discord-defaults.js +60 -7
  63. package/dist/esm/testing/index.js +4 -2
  64. package/dist/esm/testing/inspect-handler.js +20 -3
  65. package/dist/esm/testing/meocord-testing-module.js +229 -188
  66. package/dist/esm/testing/mock-fn.js +80 -10
  67. package/dist/esm/testing/mock-interaction.js +226 -78
  68. package/dist/esm/testing/response.js +1 -1
  69. package/dist/esm/testing/routing.js +39 -14
  70. package/dist/esm/testing/startup-errors.js +26 -0
  71. package/dist/esm/testing/strict-mocks.js +45 -0
  72. package/dist/esm/testing/theme.js +1 -1
  73. package/dist/esm/util/bundle-entry.util.js +2 -1
  74. package/dist/esm/util/interaction.util.js +1 -1
  75. package/dist/esm/util/meocord-config-validation.util.js +1 -0
  76. package/dist/esm/util/metadata-keys.js +1 -0
  77. package/dist/esm/util/refusal.util.js +148 -8
  78. package/dist/types/common/index.d.cts +8 -8
  79. package/dist/types/common/index.d.ts +8 -8
  80. package/dist/types/core/index.d.cts +8 -6
  81. package/dist/types/core/index.d.ts +8 -6
  82. package/dist/types/decorator/index.d.cts +30 -29
  83. package/dist/types/decorator/index.d.ts +30 -29
  84. package/dist/types/{index-D7n0FBMm.d.ts → index-CdXsFgjE.d.ts} +62 -15
  85. package/dist/types/{index-DEP0NYrU.d.cts → index-nn5p7OoX.d.cts} +62 -15
  86. package/dist/types/interface/index.d.cts +1 -1
  87. package/dist/types/interface/index.d.ts +1 -1
  88. package/dist/types/{route-C-aMI7Sl.d.cts → route-BZgFoNeB.d.cts} +5 -2
  89. package/dist/types/{route-C-aMI7Sl.d.ts → route-BZgFoNeB.d.ts} +5 -2
  90. package/dist/types/testing/index.d.cts +245 -31
  91. package/dist/types/testing/index.d.ts +245 -31
  92. package/dist/types/{theme-resolvers-DedxyscT.d.ts → theme-resolvers-BcOJVzR9.d.ts} +1 -1
  93. package/dist/types/{theme-resolvers-COHdX5g6.d.cts → theme-resolvers-UKr3-6Ys.d.cts} +1 -1
  94. package/dist/types/{translator-J6tIUfdp.d.ts → translator-BViWKtLJ.d.ts} +1 -1
  95. package/dist/types/{translator-B4mc1lzJ.d.cts → translator-DHMkkCpg.d.cts} +1 -1
  96. 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);
@@ -1325,13 +1539,10 @@ function createMockInteraction(Class, props) {
1325
1539
  instance[key] = value;
1326
1540
  }
1327
1541
  }
1328
- // Wire each type guard as a mock fn calling the real prototype implementation.
1329
- // Correct by default; overridable per test via .mockReturnValue().
1542
+ // Each type guard runs the real prototype implementation, as the mock's own, so a reset keeps it; a test may override it
1330
1543
  for (const name of TYPE_GUARD_METHODS){
1331
1544
  const method = findPrototypeMethod(instance, name);
1332
- if (method !== null) {
1333
- stubs.set(name, createMockFn().mockImplementation(()=>method.call(instance)));
1334
- }
1545
+ if (method !== null) stubs.set(name, createMockFn(()=>method.call(instance)));
1335
1546
  }
1336
1547
  // Guild checks read the mock's own data, since discord.js resolves `guild` through a client the
1337
1548
  // mock lacks: a guildId is a guild, a guild object with it a cached one, neither a DM. A member
@@ -1344,7 +1555,7 @@ function createMockInteraction(Class, props) {
1344
1555
  inRawGuild: ()=>Boolean(own('guildId') && !own('guild') && hasMember())
1345
1556
  };
1346
1557
  for (const [name, check] of Object.entries(guildChecks)){
1347
- if (findPrototypeMethod(instance, name) !== null) stubs.set(name, createMockFn().mockImplementation(check));
1558
+ if (findPrototypeMethod(instance, name) !== null) stubs.set(name, createMockFn(check));
1348
1559
  }
1349
1560
  // Set up reply state machine for repliable interactions
1350
1561
  const isRepliableMethod = findPrototypeMethod(instance, 'isRepliable');
@@ -1364,21 +1575,27 @@ function createMockInteraction(Class, props) {
1364
1575
  if (typeof flags === 'bigint') return (flags & 64n) !== 0n;
1365
1576
  return false;
1366
1577
  };
1367
- 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)=>{
1368
1585
  if (instance.deferred || instance.replied) throw alreadyReplied();
1369
1586
  instance.replied = true;
1370
1587
  if (hasEphemeralFlag(args[0])) instance.ephemeral = true;
1371
- }));
1372
- stubs.set('deferReply', createMockFn(async (...args)=>{
1588
+ });
1589
+ answer('deferReply', async (...args)=>{
1373
1590
  if (instance.deferred || instance.replied) throw alreadyReplied();
1374
1591
  instance.deferred = true;
1375
1592
  if (hasEphemeralFlag(args[0])) instance.ephemeral = true;
1376
- }));
1377
- stubs.set('followUp', createMockFn(async ()=>{
1593
+ });
1594
+ answer('followUp', async ()=>{
1378
1595
  if (!instance.deferred && !instance.replied) throw notYetReplied('followUp');
1379
1596
  instance.replied = true;
1380
1597
  return createMockMessage();
1381
- }));
1598
+ });
1382
1599
  // The interaction's own message as Discord holds it: the message a component is on, or the original response.
1383
1600
  // An edit replaces it in the shape Discord stores, with ids and resolved media, stamped with when it was edited,
1384
1601
  // and fetchReply() reads that back, so code comparing the two sees what it would against Discord.
@@ -1387,7 +1604,7 @@ function createMockInteraction(Class, props) {
1387
1604
  // A command answered with a modal has no original response, and Discord refuses one asked for
1388
1605
  let modalAnswered = false;
1389
1606
  const unknownMessage = ()=>createDiscordError(10008, 'Unknown Message');
1390
- stubs.set('editReply', createMockFn(async (options)=>{
1607
+ answer('editReply', async (options)=>{
1391
1608
  if (!instance.deferred && !instance.replied) throw notYetReplied('editReply');
1392
1609
  if (modalAnswered) throw unknownMessage();
1393
1610
  instance.replied = true;
@@ -1397,42 +1614,33 @@ function createMockInteraction(Class, props) {
1397
1614
  ...held,
1398
1615
  components: held.returned ?? held.components
1399
1616
  });
1400
- }));
1617
+ });
1401
1618
  stubs.set('fetchReply', createMockFn(async ()=>{
1402
1619
  if (modalAnswered) throw unknownMessage();
1403
1620
  return messageFrom(current());
1404
1621
  }));
1405
- stubs.set('deleteReply', createMockFn(async ()=>{
1622
+ answer('deleteReply', async ()=>{
1406
1623
  if (!instance.deferred && !instance.replied) throw notYetReplied('deleteReply');
1407
1624
  if (modalAnswered) throw unknownMessage();
1408
- }));
1625
+ });
1409
1626
  // showModal — the first response of a command or a component, like reply()
1410
1627
  if (instance.type === discord_js.InteractionType.ApplicationCommand || instance.type === discord_js.InteractionType.MessageComponent) {
1411
- stubs.set('showModal', createMockFn(async ()=>{
1628
+ answer('showModal', async ()=>{
1412
1629
  if (instance.deferred || instance.replied) throw alreadyReplied();
1413
1630
  instance.replied = true;
1414
1631
  modalAnswered = instance.type === discord_js.InteractionType.ApplicationCommand;
1415
- }));
1632
+ });
1416
1633
  }
1417
1634
  // deferUpdate / update — components, and modals submitted from a message's component
1418
1635
  if (instance.type === discord_js.InteractionType.MessageComponent || instance.type === discord_js.InteractionType.ModalSubmit) {
1419
- stubs.set('update', createMockFn(async ()=>{
1636
+ answer('update', async ()=>{
1420
1637
  if (instance.deferred || instance.replied) throw alreadyReplied();
1421
1638
  instance.replied = true;
1422
- }));
1423
- stubs.set('deferUpdate', createMockFn(async ()=>{
1639
+ });
1640
+ answer('deferUpdate', async ()=>{
1424
1641
  if (instance.deferred || instance.replied) throw alreadyReplied();
1425
1642
  instance.deferred = true;
1426
- }));
1427
- }
1428
- // Every answer the interaction gets, through respond() or discord.js directly, in order, for getResponse
1429
- const log = [];
1430
- Object.defineProperty(instance, RESPONSE_LOG, {
1431
- value: log
1432
- });
1433
- for (const method of ANSWER_METHODS){
1434
- const stub = stubs.get(method);
1435
- if (stub) stubs.set(method, recorded(method, stub, log));
1643
+ });
1436
1644
  }
1437
1645
  }
1438
1646
  // Autocomplete is not repliable, but it has a response of its own: Discord accepts
@@ -1471,6 +1679,43 @@ function createMockInteraction(Class, props) {
1471
1679
  const unset = (key)=>!Object.prototype.hasOwnProperty.call(instance, key);
1472
1680
  const generatedId = unset('id') ? instance.id = nextSnowflake() : undefined;
1473
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
+ }
1474
1719
  const userGiven = !unset('user');
1475
1720
  if (!userGiven) instance.user = mockUser();
1476
1721
  // Assigned once by discord.js's Base constructor, as on a message, and the gateway caches the user on it
@@ -1613,29 +1858,25 @@ function createMockInteraction(Class, props) {
1613
1858
  * on a double whose shape is an interface with nothing at runtime to read it from.
1614
1859
  */ function stubCallable() {
1615
1860
  const fn = createMockFn();
1616
- const nested = new Map();
1617
- return new Proxy(fn, {
1618
- get (target, prop) {
1619
- if (typeof prop === 'symbol') return Reflect.get(target, prop, target);
1620
- 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);
1621
1867
  // Never thenable — otherwise awaiting a mock hangs on itself
1622
- if (key === 'then') return undefined;
1623
- // The mock's own API (`mock`, `mockReturnValue`, `_isMockFunction`, …) and
1624
- // the function intrinsics pass straight through.
1625
- if (key in target) return Reflect.get(target, prop, target);
1626
- if (!nested.has(key)) nested.set(key, stubCallable());
1627
- return nested.get(key);
1628
- },
1629
- set (target, prop, value) {
1630
- Object.defineProperty(target, prop, {
1631
- value,
1868
+ if (prop === 'then') return undefined;
1869
+ const nested = stubCallable();
1870
+ Object.defineProperty(receiver, prop, {
1871
+ value: nested,
1632
1872
  writable: true,
1633
- enumerable: true,
1873
+ enumerable: false,
1634
1874
  configurable: true
1635
1875
  });
1636
- return true;
1876
+ return nested;
1637
1877
  }
1638
- });
1878
+ }));
1879
+ return fn;
1639
1880
  }
1640
1881
  /**
1641
1882
  * Creates a mock of any type, with no class needed, for service doubles and interfaces.
@@ -2111,10 +2352,16 @@ const CHANNEL_TYPE_GUARDS = [
2111
2352
  });
2112
2353
  return stubDeep(manager);
2113
2354
  }
2114
- /** 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, {
2115
2359
  id: guildId,
2116
2360
  name: '@everyone',
2117
- position: 0
2361
+ position: 0,
2362
+ ...strictMocks() ? {
2363
+ permissions: new discord_js.PermissionsBitField(NEW_SERVER_EVERYONE_PERMISSIONS).freeze()
2364
+ } : {}
2118
2365
  });
2119
2366
  /**
2120
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,
@@ -2142,6 +2389,68 @@ const CHANNEL_TYPE_GUARDS = [
2142
2389
  });
2143
2390
  return manager;
2144
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
+ }
2145
2454
  /**
2146
2455
  * Creates a mock {@link GuildMember}: a user in a server, with the roles given.
2147
2456
  *
@@ -2292,6 +2601,8 @@ const CHANNEL_TYPE_GUARDS = [
2292
2601
  if (!Object.prototype.hasOwnProperty.call(item, 'guild')) item.guild = guild;
2293
2602
  }
2294
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;
2295
2606
  }
2296
2607
  /** The ids each kind of mention in `content` names, in order and once each. */ function mentionedIds(content) {
2297
2608
  const ids = (pattern)=>[
@@ -2514,7 +2825,8 @@ const CHANNEL_TYPE_GUARDS = [
2514
2825
  get: ()=>{
2515
2826
  const found = cacheOf(instance.channel.threads)?.get(instance.id);
2516
2827
  if (found) return found;
2517
- 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.');
2518
2830
  return placeholderThread ?? (placeholderThread = stubDeep(Object.create(discord_js.ThreadChannel.prototype)));
2519
2831
  },
2520
2832
  set: (value)=>Object.defineProperty(instance, 'thread', {
@@ -2783,7 +3095,8 @@ const CHANNEL_TYPE_GUARDS = [
2783
3095
  if (!unowned.has(name)) unowned.set(name, memberIn(undefined, user.id, user));
2784
3096
  return unowned.get(name);
2785
3097
  }
2786
- 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);
2787
3100
  return members.get(name) ?? null;
2788
3101
  };
2789
3102
  base.getUser = createMockFn((name, required)=>{
@@ -2872,7 +3185,7 @@ const CHANNEL_TYPE_GUARDS = [
2872
3185
  * @category Module
2873
3186
  * @see {@link MeoCordTestingModule}
2874
3187
  * @see {@link getResponse}
2875
- * @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}
2876
3189
  */ class TestingModule {
2877
3190
  /**
2878
3191
  * Resolves the module's `useFactory` providers, awaiting those that return a promise, in dependency
@@ -3146,6 +3459,8 @@ const CHANNEL_TYPE_GUARDS = [
3146
3459
  controllerClasses: this.controllers,
3147
3460
  messageOptions: this.messageOptions,
3148
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,
3149
3464
  // Strict: an answer MeoCord fails to build rejects the dispatch, where a bot logs it and carries on
3150
3465
  fallback: startupRoots.createFallback(logger, ()=>this.messageOptions, {
3151
3466
  strict: true,
@@ -3312,7 +3627,7 @@ const CHANNEL_TYPE_GUARDS = [
3312
3627
  * @group Testing
3313
3628
  * @category Module
3314
3629
  * @see {@link MeoCordTestingModule}
3315
- * @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}
3316
3631
  */ class TestingModuleBuilder {
3317
3632
  /**
3318
3633
  * Replaces a provider with a test double.
@@ -3460,199 +3775,236 @@ const CHANNEL_TYPE_GUARDS = [
3460
3775
  * its command's builder does not register, a customId pattern given as a command name, a builder that registers
3461
3776
  * another name, and component patterns that can match the same customId are named, as the bot names them.
3462
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
+ *
3463
3782
  * @returns The compiled module.
3783
+ * @throws Error for the first startup error the checks find.
3464
3784
  */ compile() {
3465
3785
  // The bound the bot's config gives it, with the same words
3466
3786
  const shutdownTimeout = this.options.shutdownTimeout === undefined ? undefined : startupRoots.shutdownTimeoutProblem(this.options.shutdownTimeout);
3467
3787
  if (shutdownTimeout) throw new TypeError(`shutdownTimeout ${shutdownTimeout}.`);
3468
- const container = new inversify.Container();
3469
- const stages = this.globalStages();
3470
- if (stages) providers.bindGlobalStages(container, stages);
3471
- // Bound first, as in the app, so a class that injects it gets this instance
3472
- const appClasses = [];
3473
- const translator = ()=>container.isBound(controller_decorator.Translator) ? container.get(controller_decorator.Translator) : undefined;
3474
- container.bind(providers.HandlerRegistry).toConstantValue(new providers.HandlerRegistry(appClasses, messagesOf(this.options.app), translator));
3475
- // A testing module runs as one process, so a cross-shard call runs once, here
3476
- container.bind(providers.ShardContext).toConstantValue(new providers.ShardContext(undefined, providers.shardCallHandler(container, ()=>[
3477
- ...appClasses,
3478
- ...[
3479
- ...providers$1.keys()
3480
- ].filter(providers.isAppClassToken)
3481
- ], 'this testing module')));
3482
- providers.bindsOwnToken(container, providers.HandlerRegistry);
3483
- providers.bindsOwnToken(container, providers.ShardContext);
3484
- // Checked as the app checks its own: the app's providers in their order, then the test's, which replace them
3485
- // by token, then the overrides, which win
3486
- const providers$1 = providers.providerMap(this.wiring?.providers ?? [], '@MeoCord({ providers })');
3487
- for (const [token, provider] of providers.providerMap(this.options.providers ?? [], "the testing module's providers"))providers$1.set(token, provider);
3488
- for (const [token, override] of this.overrides)providers$1.set(token, override);
3489
- const services = this.wiring?.services ?? [];
3490
- const appOptions = this.options.app ? Reflect.getMetadata(controller_decorator.META.appOptions, this.options.app) : undefined;
3491
- // The app's own store, as the bot binds it, whether the module is given the app or built from it; none when the
3492
- // test provides the CooldownStore, so nothing builds the app's or asks for what it injects
3493
- const store = providers$1.has(controller_decorator.CooldownStore) ? undefined : appOptions?.cooldownStore;
3494
- // The themeFor class this module runs, the app's or overrideThemeFor's, bound on its own as the app binds it
3495
- const themeResolver = controller_decorator.themeResolverClass(stages?.themeFor?.resolvers);
3496
- // Every class the module runs, as the app finds them, except stage classes an override stands in for
3497
- const i18n = this.options.app && Reflect.getMetadata(controller_decorator.META.appOptions, this.options.app)?.i18n;
3498
- const startup = startupRoots.startupClasses({
3499
- controllers: this.options.controllers ?? [],
3500
- services,
3501
- providers: providers$1,
3502
- stages,
3503
- presenter: this.options.app && Reflect.getMetadata(controller_decorator.META.appOptions, this.options.app)?.presenter,
3504
- cooldownStore: store,
3505
- themeFor: stages?.themeFor?.resolvers,
3506
- observers: [
3507
- ...this.options.app ? providers.appObservers(this.options.app) : [],
3508
- ...this.options.observers ?? []
3509
- ],
3510
- stubs: [
3511
- ...this.guardOverrides.keys(),
3512
- ...this.interceptorOverrides.keys(),
3513
- ...this.filterOverrides.keys()
3514
- ]
3515
- });
3516
- const hasTranslator = i18n !== undefined || providers$1.has(controller_decorator.Translator);
3517
- startupRoots.assertStartupClasses(startup, {
3518
- translator: hasTranslator
3519
- });
3520
- providers.markStartupChecked(container, [
3521
- ...startup.classes,
3522
- ...providers.meocordClasses()
3523
- ], startupRoots.lateGuardCheck(providers$1, hasTranslator));
3524
- // The bot binds the Client it logs in with; a test gives its own, and is told so where one is needed
3525
- const needClient = startup.classes.filter((cls)=>providers.injectedTokens(cls).includes(discord_js.Client));
3526
- if (needClient.length > 0 && !providers$1.has(discord_js.Client)) {
3527
- container.bind(discord_js.Client).toDynamicValue(()=>{
3528
- 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() }.');
3529
- });
3530
- }
3531
- // Bind guard overrides — prevents inversify from auto-wiring guard dependencies
3532
- for (const [guardClass, stub] of this.guardOverrides){
3533
- container.bind(guardClass).toConstantValue(stub);
3534
- }
3535
- for (const [filterClass, stub] of this.filterOverrides){
3536
- container.bind(filterClass).toConstantValue(stub);
3537
- }
3538
- for (const [interceptorClass, stub] of this.interceptorOverrides){
3539
- container.bind(interceptorClass).toConstantValue(stub);
3540
- }
3541
- // The app's cooldown policy, so a test of a failing store sees what the bot would do
3542
- if (appOptions && !container.isBound(providers.COOLDOWN_POLICY)) container.bind(providers.COOLDOWN_POLICY).toConstantValue(providers.cooldownPolicyFrom(appOptions));
3543
- // The app's translator, unless a provider stands in for it
3544
- if (i18n && !providers$1.has(controller_decorator.Translator)) {
3545
- container.bind(controller_decorator.Translator).toConstantValue(i18n);
3546
- providers.bindsOwnToken(container, controller_decorator.Translator);
3547
- }
3548
- // Recursively bind controllers and their dependencies, skipping already-bound tokens
3549
- const bindClass = (cls)=>{
3550
- // A provided class is bound by its own provider, wherever in the list that provider comes
3551
- if (container.isBound(cls) || providers$1.has(cls)) return;
3552
- if (providers.injectedTokens(cls).includes(controller_decorator.ExecutionContext)) throw providers.singletonContextError(cls);
3553
- providers.assertStartupChecked(container, cls);
3554
- providers.makeInjectable(cls);
3555
- container.bind(cls).toSelf().inSingletonScope();
3556
- // By constructor type or @inject token, as the app binds them
3557
- for (const dep of providers.injectedTokens(cls)){
3558
- if (dep === controller_decorator.Translator && !container.isBound(controller_decorator.Translator)) throw controller_decorator.missingTranslatorError(cls);
3559
- if (providers.isAppClassToken(dep)) bindClass(dep);
3560
- }
3561
- };
3562
- // The app's own store, resolved like a service, unless the test provides the CooldownStore itself; bound first, as the
3563
- // app binds it, so a class that injects the token gets the store
3564
- if (!providers$1.has(controller_decorator.CooldownStore)) {
3565
- if (store) {
3566
- bindClass(store);
3567
- container.bind(controller_decorator.CooldownStore).toService(store);
3568
- } else {
3569
- container.bind(controller_decorator.CooldownStore).toConstantValue(new controller_decorator.MemoryCooldownStore());
3570
- }
3571
- providers.bindsOwnToken(container, controller_decorator.CooldownStore, store);
3572
- }
3573
- // Before the controllers, so a class token that is provided is not also bound as itself
3574
- for (const provider of providers$1.values())providers.bindProvider(container, provider, bindClass);
3575
- for (const ctrl of this.options.controllers ?? []){
3576
- bindClass(ctrl);
3577
- // Stamp container on controller class so @UseGuard works in tests too
3578
- Reflect.defineMetadata(controller_decorator.META.container, container, ctrl);
3579
- }
3580
- for (const service of services)bindClass(service);
3581
- if (themeResolver) bindClass(themeResolver);
3582
- // The classes whose @On and @Once handlers emit reaches: class providers bound as themselves, the
3583
- // controllers, and what they inject; factories resolve in the same order
3584
- const order = providers.resolutionOrder(container, providers$1, [
3585
- ...providers$1.keys(),
3586
- ...services,
3587
- ...themeResolver ? [
3588
- themeResolver
3589
- ] : [],
3590
- ...this.options.controllers ?? []
3591
- ], {
3592
- followOwnTokens: false
3593
- });
3594
- appClasses.push(...order.filter((token)=>{
3595
- const provider = providers$1.get(token);
3596
- return providers.isAppClassToken(token) && (!provider || providers.isClassProvider(provider) && provider.useClass === token);
3597
- }));
3598
- providers.assertProvided(container, providers$1, startup.classes, "the testing module's providers");
3599
- // Every class the module runs, so @UseGuard resolves guards on a direct call to any of them
3600
- for (const cls of startup.classes)Reflect.defineMetadata(controller_decorator.META.container, container, cls);
3601
- providers.prepareHandlerStages(container, appClasses);
3602
- const messages = messagesOf(this.options.app);
3603
- // As the app would at startup, refuses a message pattern that cannot be read or two that match the same messages
3604
- providers.buildMessageRoutes(this.options.controllers ?? [], messages);
3605
- // As the app does when it is created, so a test sees the refusal the bot would give
3606
- startupRoots.assertDistinctCommands(this.options.controllers ?? []);
3607
- // A handler with no builder is how a fixture is written, so only what is always a mistake is named
3608
- startupRoots.warnUnregisteredCommands(this.options.controllers ?? [], {
3609
- missingBuilders: false
3610
- });
3611
- startupRoots.warnInheritedRoutes(this.options.controllers ?? []);
3612
- startupRoots.warnOverlappingPatterns(this.options.controllers ?? []);
3613
- startupRoots.warnHandlersOffControllers(this.options.controllers ?? [], appClasses);
3614
- if (this.options.app) providers.bindAppPresenter(container, this.options.app);
3615
- const observers = [
3616
- ...this.options.app ? providers.appObservers(this.options.app) : [],
3617
- ...this.options.observers ?? []
3618
- ];
3619
- providers.assertObservers("the testing module's observers", observers);
3620
- providers.bindObservers(container, observers);
3621
- // The store calls ask: the test's own when it provides CooldownStore, else the app's
3622
- const boundStore = store ?? (providers$1.has(controller_decorator.CooldownStore) ? controller_decorator.CooldownStore : undefined);
3623
- // The order the app runs lifecycle hooks in: its cooldown store, then providers, controllers and observers, each
3624
- // after what it injects
3625
- const lifecycle = providers.resolutionOrder(container, providers$1, [
3626
- ...boundStore ? [
3627
- boundStore
3628
- ] : [],
3629
- ...providers$1.keys(),
3630
- ...services,
3631
- ...themeResolver ? [
3632
- 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
3633
3792
  ] : [],
3634
3793
  ...this.options.controllers ?? [],
3635
- ...observers
3636
- ]).map((token)=>({
3637
- token,
3638
- name: providers.tokenName(token),
3639
- dependencies: providers.tokenDependencies(container, providers$1, token),
3640
- ...token === boundStore && {
3641
- 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);
3642
3878
  }
3643
- }));
3644
- // Recorded as the container makes each one, so close() shuts down exactly what exists
3645
- const constructed = new Set();
3646
- for (const { token } of lifecycle){
3647
- container.onActivation(token, (_context, instance)=>{
3648
- constructed.add(token);
3649
- 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);
3650
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;
3651
4007
  }
3652
- const warnUnanswered = this.options.app && Reflect.getMetadata(controller_decorator.META.appOptions, this.options.app)?.warnUnanswered;
3653
- return new TestingModule(container, [
3654
- ...this.options.controllers ?? []
3655
- ], appClasses, providers$1, order, messages, lifecycle, constructed, warnUnanswered, services, this.options.shutdownTimeout ?? startupRoots.DEFAULT_SHUTDOWN_TIMEOUT_MS);
3656
4008
  }
3657
4009
  constructor(options, wiring){
3658
4010
  this.options = options;
@@ -3690,7 +4042,7 @@ const CHANNEL_TYPE_GUARDS = [
3690
4042
  * @category Module
3691
4043
  * @see {@link TestingModuleBuilder}
3692
4044
  * @see {@link TestingModule}
3693
- * @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}
3694
4046
  */ class MeoCordTestingModule {
3695
4047
  /**
3696
4048
  * Starts building a testing module from the classes a test needs.
@@ -3780,6 +4132,29 @@ const CHANNEL_TYPE_GUARDS = [
3780
4132
  providers.adviseInPlaceOfInjecting(TestingModuleBuilder, 'build it with MeoCordTestingModule.create(…) or MeoCordTestingModule.fromApp(…)');
3781
4133
  providers.adviseInPlaceOfInjecting(TestingModule, 'make it with MeoCordTestingModule.create(…).compile()');
3782
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
+
3783
4158
  /** The options `@MeoCord` declares on an application class. */ function appOptionsOf(app) {
3784
4159
  const options = Reflect.getMetadata(controller_decorator.META.appOptions, app);
3785
4160
  if (!options) throw new TypeError(`${app.name || 'The given class'} is not decorated with @MeoCord().`);
@@ -3823,7 +4198,7 @@ providers.adviseInPlaceOfInjecting(TestingModule, 'make it with MeoCordTestingMo
3823
4198
  * @group Testing
3824
4199
  * @category Inspection
3825
4200
  * @see {@link findRouteConflicts}
3826
- * @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}
3827
4202
  */ function resolveRoute(app, input) {
3828
4203
  if ('content' in input) {
3829
4204
  const { messages = {} } = appOptionsOf(app);
@@ -3849,7 +4224,10 @@ providers.adviseInPlaceOfInjecting(TestingModule, 'make it with MeoCordTestingMo
3849
4224
  const { route, params, text } = matched;
3850
4225
  const method = route.meta.methodName;
3851
4226
  const typed = Object.keys(route.types).length > 0;
3852
- 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 = {
3853
4231
  controller: route.controllerClass,
3854
4232
  method,
3855
4233
  handler: route.controllerClass.prototype[method],
@@ -3858,28 +4236,34 @@ providers.adviseInPlaceOfInjecting(TestingModule, 'make it with MeoCordTestingMo
3858
4236
  values: params
3859
4237
  }
3860
4238
  };
4239
+ return alsoMatches.length > 0 ? readableOnly(resolved, {
4240
+ alsoMatches
4241
+ }) : resolved;
3861
4242
  }
3862
4243
  /**
3863
- * 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.
3864
4246
  *
3865
- * Use it in a test to keep the warning from reaching production: two patterns that trade a literal for a parameter
3866
- * 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.
3867
4251
  *
3868
4252
  * @param app - The application class decorated with `@MeoCord`.
3869
- * @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.
3870
4254
  *
3871
4255
  * @example
3872
4256
  * ```ts
3873
4257
  * import { expect } from 'vitest'
3874
4258
  *
3875
4259
  * @Controller()
3876
- * class CardController {
3877
- * @Command('card/{id}/open', CommandType.BUTTON)
3878
- * async open(interaction: ButtonInteraction) { await respond(interaction).send('Opened.') }
3879
- * @Command('card/new/{kind}', CommandType.BUTTON)
3880
- * 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.') }
3881
4265
  * }
3882
- * @MeoCord({ controllers: [CardController], clientOptions: { intents: [] } })
4266
+ * @MeoCord({ controllers: [TicketController], clientOptions: { intents: [] } })
3883
4267
  * class App {}
3884
4268
  * expect(findRouteConflicts(App)).toHaveLength(1)
3885
4269
  * ```
@@ -3888,7 +4272,23 @@ providers.adviseInPlaceOfInjecting(TestingModule, 'make it with MeoCordTestingMo
3888
4272
  * @category Inspection
3889
4273
  * @see {@link resolveRoute}
3890
4274
  */ function findRouteConflicts(app) {
3891
- 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;
3892
4292
  }
3893
4293
 
3894
4294
  /**
@@ -4035,7 +4435,7 @@ const isMeoCordKey = (key)=>key.startsWith('meocord.');
4035
4435
  * @group Testing
4036
4436
  * @category Inspection
4037
4437
  * @see {@link createTranslator}
4038
- * @see {@link https://meocord.dev/docs/4.1/localisation | Localisation}
4438
+ * @see {@link https://meocord.dev/docs/4.2/localisation | Localisation}
4039
4439
  */ function expectCompleteCatalog(translator, options = {}) {
4040
4440
  const catalogs = translator[controller_decorator.CATALOGS];
4041
4441
  if (!catalogs) throw new Error('expectCompleteCatalog takes a translator made by createTranslator.');
@@ -4081,6 +4481,7 @@ const isMeoCordKey = (key)=>key.startsWith('meocord.');
4081
4481
 
4082
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;
4083
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;
4084
4485
  /**
4085
4486
  * Checks that a `CooldownStore` counts calls as `MemoryCooldownStore` does, as a suite of test cases.
4086
4487
  *
@@ -4091,7 +4492,8 @@ const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
4091
4492
  * The cases cover what a shared store most often gets wrong: a sliding window rather than fixed buckets,
4092
4493
  * `retryAfterMs` from the oldest call still in the window, calls in the same millisecond kept apart, and concurrent
4093
4494
  * calls at the limit where exactly one may pass. They use real time, with windows short enough that the suite takes
4094
- * 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
4095
4497
  * `@Cooldown` gives a store: it rounds its `seconds` to the millisecond.
4096
4498
  *
4097
4499
  * @param name - What the store is called in the report.
@@ -4108,7 +4510,7 @@ const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
4108
4510
  * @group Testing
4109
4511
  * @category Inspection
4110
4512
  * @see {@link CooldownStore}
4111
- * @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}
4112
4514
  */ function testCooldownStore(name, factory, { describe, it, expect }) {
4113
4515
  const run = node_crypto.randomUUID();
4114
4516
  const key = (label)=>`meocord-suite:${run}:${label}`;
@@ -4131,32 +4533,38 @@ const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
4131
4533
  expect(refused.retryAfterMs).toBeLessThanOrEqual(limit.windowMs);
4132
4534
  });
4133
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
4134
4537
  const limit = {
4135
4538
  uses: 2,
4136
- windowMs: 500
4539
+ windowMs: 2000
4137
4540
  };
4138
4541
  await store.consume(key('sliding'), limit);
4139
- await sleep(250);
4542
+ await sleep(1000);
4140
4543
  await store.consume(key('sliding'), limit);
4141
4544
  const refused = await store.consume(key('sliding'), limit);
4142
4545
  expect(refused.allowed).toBe(false);
4143
4546
  await sleep(refused.retryAfterMs + 40);
4144
- // 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.
4145
4548
  expect((await store.consume(key('sliding'), limit)).allowed).toBe(true);
4146
4549
  expect((await store.consume(key('sliding'), limit)).allowed).toBe(false);
4147
4550
  });
4148
4551
  test('counts retryAfterMs from the oldest call still in the window', async (store)=>{
4149
4552
  const limit = {
4150
4553
  uses: 2,
4151
- windowMs: 1000
4554
+ windowMs: 2000
4152
4555
  };
4556
+ const firstSent = Date.now();
4153
4557
  await store.consume(key('retry'), limit);
4154
- await sleep(300);
4558
+ const firstDone = Date.now();
4559
+ await sleep(1000);
4560
+ const secondSent = Date.now();
4155
4561
  await store.consume(key('retry'), limit);
4156
- // From the oldest call, about 700ms remain; from the newest, about 1000ms would.
4157
4562
  const { retryAfterMs } = await store.consume(key('retry'), limit);
4158
- expect(retryAfterMs).toBeGreaterThan(limit.windowMs - 600);
4159
- 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);
4160
4568
  });
4161
4569
  test('counts each key on its own', async (store)=>{
4162
4570
  const limit = {
@@ -4504,7 +4912,7 @@ const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
4504
4912
  * @group Testing
4505
4913
  * @category Inspection
4506
4914
  * @see {@link createExecutionContext}
4507
- * @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}
4508
4916
  */ function inspectHandler(controller, methodName, options = {}) {
4509
4917
  const context = new controller_decorator.HandlerExecutionContext({
4510
4918
  controller,
@@ -4513,7 +4921,7 @@ const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
4513
4921
  });
4514
4922
  const globals = options.app ? providers.appStages(options.app) : undefined;
4515
4923
  const { guards, interceptors, filters } = providers.handlerStages(controller.prototype, methodName, globals);
4516
- return {
4924
+ const inspection = {
4517
4925
  controller,
4518
4926
  methodName,
4519
4927
  guards: Object.freeze([
@@ -4535,6 +4943,23 @@ const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
4535
4943
  get: (metadata)=>context.get(metadata),
4536
4944
  getAll: (metadata)=>context.getAll(metadata)
4537
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 [];
4538
4963
  }
4539
4964
 
4540
4965
  // Themes createMockTheme made, which withTheme uses as they are
@@ -4571,7 +4996,7 @@ function mockTheme(overrides, where) {
4571
4996
  * @group Testing
4572
4997
  * @category Mocks
4573
4998
  * @see {@link withTheme}
4574
- * @see {@link https://meocord.dev/docs/4.1/theming | Theming}
4999
+ * @see {@link https://meocord.dev/docs/4.2/theming | Theming}
4575
5000
  */ function createMockTheme(...[overrides]) {
4576
5001
  return mockTheme(overrides, 'createMockTheme');
4577
5002
  }
@@ -4618,6 +5043,7 @@ exports.createMockGuild = createMockGuild;
4618
5043
  exports.createMockInteraction = createMockInteraction;
4619
5044
  exports.createMockMember = createMockMember;
4620
5045
  exports.createMockMessage = createMockMessage;
5046
+ exports.createMockRawMember = createMockRawMember;
4621
5047
  exports.createMockTheme = createMockTheme;
4622
5048
  exports.createMockUser = createMockUser;
4623
5049
  exports.createModalFields = createModalFields;
@@ -4626,7 +5052,10 @@ exports.findRouteConflicts = findRouteConflicts;
4626
5052
  exports.getResponse = getResponse;
4627
5053
  exports.inspectHandler = inspectHandler;
4628
5054
  exports.isMockFunction = isMockFunction;
5055
+ exports.reportAllStartupErrors = reportAllStartupErrors;
4629
5056
  exports.resetAllMocks = resetAllMocks;
4630
5057
  exports.resolveRoute = resolveRoute;
4631
5058
  exports.testCooldownStore = testCooldownStore;
5059
+ exports.useMockFn = useMockFn;
5060
+ exports.useStrictMocks = useStrictMocks;
4632
5061
  exports.withTheme = withTheme;