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.
- package/CHANGELOG.md +104 -0
- package/README.md +23 -23
- 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 +736 -307
- 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 +226 -78
- 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);
|
|
@@ -1325,13 +1539,10 @@ function createMockInteraction(Class, props) {
|
|
|
1325
1539
|
instance[key] = value;
|
|
1326
1540
|
}
|
|
1327
1541
|
}
|
|
1328
|
-
//
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1636
|
+
answer('update', async ()=>{
|
|
1420
1637
|
if (instance.deferred || instance.replied) throw alreadyReplied();
|
|
1421
1638
|
instance.replied = true;
|
|
1422
|
-
})
|
|
1423
|
-
|
|
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
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
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 (
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
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:
|
|
1873
|
+
enumerable: false,
|
|
1634
1874
|
configurable: true
|
|
1635
1875
|
});
|
|
1636
|
-
return
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
3469
|
-
const
|
|
3470
|
-
|
|
3471
|
-
|
|
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
|
-
...
|
|
3636
|
-
|
|
3637
|
-
|
|
3638
|
-
|
|
3639
|
-
|
|
3640
|
-
|
|
3641
|
-
|
|
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
|
-
|
|
3645
|
-
|
|
3646
|
-
|
|
3647
|
-
|
|
3648
|
-
|
|
3649
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
|
3866
|
-
*
|
|
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
|
|
3877
|
-
* @Command('
|
|
3878
|
-
* async
|
|
3879
|
-
* @Command('
|
|
3880
|
-
* 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.') }
|
|
3881
4265
|
* }
|
|
3882
|
-
* @MeoCord({ controllers: [
|
|
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.
|
|
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
|
|
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.
|
|
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:
|
|
4539
|
+
windowMs: 2000
|
|
4137
4540
|
};
|
|
4138
4541
|
await store.consume(key('sliding'), limit);
|
|
4139
|
-
await sleep(
|
|
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,
|
|
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:
|
|
4554
|
+
windowMs: 2000
|
|
4152
4555
|
};
|
|
4556
|
+
const firstSent = Date.now();
|
|
4153
4557
|
await store.consume(key('retry'), limit);
|
|
4154
|
-
|
|
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
|
-
|
|
4159
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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;
|