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.
- package/CHANGELOG.md +98 -0
- package/dist/cjs/_shared/{controller.decorator-BywwkU9O.cjs → controller.decorator-CRn9kWob.cjs} +295 -87
- package/dist/cjs/_shared/{providers-BMMLp2SN.cjs → providers-ChvMqnLd.cjs} +74 -40
- package/dist/cjs/_shared/{sharded-cooldown-store-B65iaiAy.cjs → sharded-cooldown-store-BdAUTdYs.cjs} +1 -1
- package/dist/cjs/_shared/{startup-roots-lFc69AxC.cjs → startup-roots-CQo8mSP8.cjs} +158 -72
- package/dist/cjs/_shared/{translate-error-BnM8FbSH.cjs → translate-error-DhDVbT5M.cjs} +12 -3
- package/dist/cjs/common/index.cjs +4 -4
- package/dist/cjs/core/index.cjs +195 -157
- package/dist/cjs/decorator/index.cjs +66 -57
- package/dist/cjs/testing/index.cjs +733 -301
- package/dist/cli.json +1 -1
- package/dist/esm/bin/app-template/README.md.template +3 -1
- package/dist/esm/bin/app-template/src/controllers/button/sample.button.controller.spec.ts.template +8 -4
- package/dist/esm/bin/app-template/src/controllers/button/sample.button.controller.ts.template +2 -2
- package/dist/esm/bin/app-template/src/guards/owner.guard.spec.ts.template +8 -4
- package/dist/esm/bin/app-template/src/guards/owner.guard.ts.template +1 -1
- package/dist/esm/bin/app-template/vitest.config.ts.template +1 -1
- package/dist/esm/bin/app-template/vitest.setup.ts.template +7 -3
- package/dist/esm/build/load-config.pre-entry.js +4 -1
- package/dist/esm/common/decorator.js +6 -6
- package/dist/esm/common/deprecation.js +2 -2
- package/dist/esm/common/errors.js +2 -2
- package/dist/esm/common/execution-context.js +1 -1
- package/dist/esm/common/metadata.js +1 -1
- package/dist/esm/common/route.js +10 -1
- package/dist/esm/common/translate-error.js +1 -1
- package/dist/esm/core/command-conflicts.js +34 -34
- package/dist/esm/core/component-routes.js +74 -31
- package/dist/esm/core/dispatcher.js +51 -7
- package/dist/esm/core/filter-runner.js +2 -2
- package/dist/esm/core/handler-pipeline.js +27 -14
- package/dist/esm/core/handler-registry.js +1 -1
- package/dist/esm/core/interceptor-runner.js +2 -2
- package/dist/esm/core/meocord-factory.js +196 -157
- package/dist/esm/core/message-routes.js +4 -3
- package/dist/esm/core/observer-runner.js +4 -3
- package/dist/esm/core/providers.js +38 -21
- package/dist/esm/core/route-specificity.js +4 -4
- package/dist/esm/core/scalar-types.js +15 -3
- package/dist/esm/core/shard-context.js +1 -1
- package/dist/esm/core/startup-roots.js +2 -3
- package/dist/esm/core/theme-runtime.js +2 -2
- package/dist/esm/decorator/app.decorator.js +12 -7
- package/dist/esm/decorator/command-builder.decorator.js +4 -4
- package/dist/esm/decorator/controller-class.decorator.js +9 -4
- package/dist/esm/decorator/controller.decorator.js +110 -67
- package/dist/esm/decorator/cooldown.decorator.js +3 -3
- package/dist/esm/decorator/defer.decorator.js +4 -4
- package/dist/esm/decorator/event.decorator.js +5 -5
- package/dist/esm/decorator/filter.decorator.js +7 -7
- package/dist/esm/decorator/guard.decorator.js +8 -8
- package/dist/esm/decorator/inject.decorator.js +4 -4
- package/dist/esm/decorator/interceptor.decorator.js +7 -7
- package/dist/esm/decorator/observer.decorator.js +4 -4
- package/dist/esm/decorator/service.decorator.js +4 -4
- package/dist/esm/decorator/theme.decorator.js +3 -3
- package/dist/esm/decorator/validation.decorator.js +10 -10
- package/dist/esm/package.json.js +1 -1
- package/dist/esm/testing/catalog.js +1 -1
- package/dist/esm/testing/cooldown-store-suite.js +18 -10
- package/dist/esm/testing/discord-defaults.js +60 -7
- package/dist/esm/testing/index.js +4 -2
- package/dist/esm/testing/inspect-handler.js +20 -3
- package/dist/esm/testing/meocord-testing-module.js +229 -188
- package/dist/esm/testing/mock-fn.js +80 -10
- package/dist/esm/testing/mock-interaction.js +223 -72
- package/dist/esm/testing/response.js +1 -1
- package/dist/esm/testing/routing.js +39 -14
- package/dist/esm/testing/startup-errors.js +26 -0
- package/dist/esm/testing/strict-mocks.js +45 -0
- package/dist/esm/testing/theme.js +1 -1
- package/dist/esm/util/bundle-entry.util.js +2 -1
- package/dist/esm/util/interaction.util.js +1 -1
- package/dist/esm/util/meocord-config-validation.util.js +1 -0
- package/dist/esm/util/metadata-keys.js +1 -0
- package/dist/esm/util/refusal.util.js +148 -8
- package/dist/types/common/index.d.cts +8 -8
- package/dist/types/common/index.d.ts +8 -8
- package/dist/types/core/index.d.cts +8 -6
- package/dist/types/core/index.d.ts +8 -6
- package/dist/types/decorator/index.d.cts +30 -29
- package/dist/types/decorator/index.d.ts +30 -29
- package/dist/types/{index-D7n0FBMm.d.ts → index-CdXsFgjE.d.ts} +62 -15
- package/dist/types/{index-DEP0NYrU.d.cts → index-nn5p7OoX.d.cts} +62 -15
- package/dist/types/interface/index.d.cts +1 -1
- package/dist/types/interface/index.d.ts +1 -1
- package/dist/types/{route-C-aMI7Sl.d.cts → route-BZgFoNeB.d.cts} +5 -2
- package/dist/types/{route-C-aMI7Sl.d.ts → route-BZgFoNeB.d.ts} +5 -2
- package/dist/types/testing/index.d.cts +245 -31
- package/dist/types/testing/index.d.ts +245 -31
- package/dist/types/{theme-resolvers-DedxyscT.d.ts → theme-resolvers-BcOJVzR9.d.ts} +1 -1
- package/dist/types/{theme-resolvers-COHdX5g6.d.cts → theme-resolvers-UKr3-6Ys.d.cts} +1 -1
- package/dist/types/{translator-J6tIUfdp.d.ts → translator-BViWKtLJ.d.ts} +1 -1
- package/dist/types/{translator-B4mc1lzJ.d.cts → translator-DHMkkCpg.d.cts} +1 -1
- 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-
|
|
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-
|
|
9
|
-
var startupRoots = require('../_shared/startup-roots-
|
|
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-
|
|
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
|
|
59
|
-
*
|
|
60
|
-
*
|
|
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)
|
|
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
|
-
|
|
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.
|
|
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)=>
|
|
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
|
-
/**
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
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 &&
|
|
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
|
-
|
|
883
|
-
|
|
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
|
-
*
|
|
1278
|
-
*
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1636
|
+
answer('update', async ()=>{
|
|
1417
1637
|
if (instance.deferred || instance.replied) throw alreadyReplied();
|
|
1418
1638
|
instance.replied = true;
|
|
1419
|
-
})
|
|
1420
|
-
|
|
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
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
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 (
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
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:
|
|
1873
|
+
enumerable: false,
|
|
1631
1874
|
configurable: true
|
|
1632
1875
|
});
|
|
1633
|
-
return
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
3466
|
-
const
|
|
3467
|
-
|
|
3468
|
-
|
|
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
|
-
...
|
|
3633
|
-
|
|
3634
|
-
|
|
3635
|
-
|
|
3636
|
-
|
|
3637
|
-
|
|
3638
|
-
|
|
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
|
-
|
|
3642
|
-
|
|
3643
|
-
|
|
3644
|
-
|
|
3645
|
-
|
|
3646
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
|
3863
|
-
*
|
|
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
|
|
3874
|
-
* @Command('
|
|
3875
|
-
* async
|
|
3876
|
-
* @Command('
|
|
3877
|
-
* async
|
|
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: [
|
|
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.
|
|
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
|
|
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.
|
|
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:
|
|
4539
|
+
windowMs: 2000
|
|
4134
4540
|
};
|
|
4135
4541
|
await store.consume(key('sliding'), limit);
|
|
4136
|
-
await sleep(
|
|
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,
|
|
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:
|
|
4554
|
+
windowMs: 2000
|
|
4149
4555
|
};
|
|
4556
|
+
const firstSent = Date.now();
|
|
4150
4557
|
await store.consume(key('retry'), limit);
|
|
4151
|
-
|
|
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
|
-
|
|
4156
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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;
|