meocord 4.1.2 → 4.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +362 -0
- package/THIRD_PARTY_NOTICES.md +14 -13
- package/dist/cjs/_shared/{controller.decorator-BywwkU9O.cjs → controller.decorator-XKMlcUZz.cjs} +669 -185
- package/dist/cjs/_shared/{providers-BMMLp2SN.cjs → providers-BZgJyfZS.cjs} +218 -75
- package/dist/cjs/_shared/{sharded-cooldown-store-B65iaiAy.cjs → sharded-cooldown-store-CsJJyCZd.cjs} +10 -7
- package/dist/cjs/_shared/{startup-roots-lFc69AxC.cjs → startup-roots-CgfPQrNl.cjs} +374 -132
- package/dist/cjs/_shared/{translate-error-BnM8FbSH.cjs → translate-error-BUAR1WYG.cjs} +21 -7
- package/dist/cjs/common/index.cjs +4 -4
- package/dist/cjs/core/index.cjs +365 -310
- package/dist/cjs/decorator/index.cjs +85 -61
- package/dist/cjs/testing/index.cjs +2236 -496
- 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/bin/generator.js +4 -5
- package/dist/esm/bin/helper/filter-generator.helper.js +4 -5
- package/dist/esm/bin/helper/guard-generator.helper.js +4 -5
- package/dist/esm/bin/helper/interceptor-generator.helper.js +4 -5
- package/dist/esm/bin/helper/observer-generator.helper.js +4 -5
- package/dist/esm/bin/helper/pipe-generator.helper.js +4 -5
- package/dist/esm/bin/helper/service-generator.helper.js +4 -5
- package/dist/esm/bin/meocord.js +84 -25
- package/dist/esm/build/load-config.pre-entry.js +17 -4
- package/dist/esm/build/rsbuild-config.js +16 -12
- package/dist/esm/build/typed-externals.js +36 -0
- package/dist/esm/common/cooldown-store.js +10 -2
- package/dist/esm/common/decorator.js +6 -6
- package/dist/esm/common/deprecation.js +2 -2
- package/dist/esm/common/errors.js +27 -2
- package/dist/esm/common/execution-context.js +1 -1
- package/dist/esm/common/meocord-text.js +11 -8
- package/dist/esm/common/metadata.js +1 -1
- package/dist/esm/common/response/call-order.js +9 -4
- package/dist/esm/common/response/presenter.js +33 -6
- package/dist/esm/common/response/response-state.js +84 -10
- package/dist/esm/common/route.js +21 -5
- package/dist/esm/common/sharded-cooldown-store.js +1 -1
- package/dist/esm/common/translate-error.js +1 -1
- package/dist/esm/common/translator.js +37 -6
- package/dist/esm/core/command-conflicts.js +34 -34
- package/dist/esm/core/command-registration.js +93 -24
- package/dist/esm/core/command-routes.js +2 -2
- package/dist/esm/core/component-routes.js +74 -31
- package/dist/esm/core/cooldown-runner.js +29 -8
- package/dist/esm/core/dispatcher.js +51 -7
- package/dist/esm/core/fallback.js +20 -11
- package/dist/esm/core/filter-runner.js +2 -2
- package/dist/esm/core/guard-runner.js +16 -3
- package/dist/esm/core/handler-input.js +4 -3
- 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/lifecycle-hooks.js +134 -47
- package/dist/esm/core/meocord-factory.js +241 -179
- package/dist/esm/core/meocord.app.js +116 -75
- package/dist/esm/core/message-routes.js +4 -3
- package/dist/esm/core/observer-runner.js +4 -3
- package/dist/esm/core/providers.js +45 -23
- package/dist/esm/core/route-specificity.js +4 -4
- package/dist/esm/core/running-calls.js +43 -0
- package/dist/esm/core/scalar-types.js +15 -3
- package/dist/esm/core/shard-context.js +6 -7
- package/dist/esm/core/shard-manager.js +16 -10
- package/dist/esm/core/startup-roots.js +23 -4
- package/dist/esm/core/theme-resolvers.js +4 -1
- package/dist/esm/core/theme-runtime.js +2 -2
- package/dist/esm/core/theme-scope.js +3 -1
- package/dist/esm/core/theme-validation.js +17 -10
- 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 +162 -83
- package/dist/esm/decorator/cooldown.decorator.js +20 -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 +11 -10
- 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 +27 -11
- package/dist/esm/testing/cooldown-store-suite.js +85 -15
- package/dist/esm/testing/discord-defaults.js +249 -21
- 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 +352 -205
- package/dist/esm/testing/mock-fn.js +173 -22
- package/dist/esm/testing/mock-interaction.js +1214 -209
- package/dist/esm/testing/response.js +1 -1
- package/dist/esm/testing/routing.js +39 -14
- package/dist/esm/testing/snowflake.js +9 -2
- package/dist/esm/testing/startup-errors.js +26 -0
- package/dist/esm/testing/strict-mocks.js +65 -0
- package/dist/esm/testing/theme.js +1 -1
- package/dist/esm/util/bundle-entry.util.js +2 -1
- package/dist/esm/util/common.util.js +47 -19
- package/dist/esm/util/dev-runner.util.js +8 -8
- package/dist/esm/util/generator-cli.util.js +4 -5
- package/dist/esm/util/interaction.util.js +3 -3
- package/dist/esm/util/meocord-config-loader.util.js +10 -2
- package/dist/esm/util/meocord-config-validation.util.js +4 -5
- package/dist/esm/util/meocord-source-config.util.js +65 -20
- package/dist/esm/util/metadata-keys.js +1 -0
- package/dist/esm/util/method-wrapper.util.js +16 -0
- package/dist/esm/util/platform.util.js +10 -10
- package/dist/esm/util/refusal.util.js +148 -8
- package/dist/esm/util/shard-process.util.js +4 -1
- package/dist/esm/util/sharding-mode.util.js +5 -7
- package/dist/esm/util/shared-state.util.js +21 -0
- package/dist/esm/util/tsconfig-paths.util.js +29 -0
- package/dist/esm/util/tsconfig.util.js +10 -10
- package/dist/esm/util/value.util.js +12 -1
- 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 +10 -7
- package/dist/types/core/index.d.ts +10 -7
- package/dist/types/decorator/index.d.cts +31 -29
- package/dist/types/decorator/index.d.ts +31 -29
- package/dist/types/{index-D7n0FBMm.d.ts → index-DKJclIi-.d.ts} +107 -36
- package/dist/types/{index-DEP0NYrU.d.cts → index-Dlf0ZZtS.d.cts} +107 -36
- 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-Bh1U13yy.d.cts} +8 -4
- package/dist/types/{route-C-aMI7Sl.d.ts → route-Bh1U13yy.d.ts} +8 -4
- package/dist/types/testing/index.d.cts +406 -70
- package/dist/types/testing/index.d.ts +406 -70
- package/dist/types/{theme-resolvers-COHdX5g6.d.cts → theme-resolvers-CyEz9pnv.d.cts} +1 -1
- package/dist/types/{theme-resolvers-DedxyscT.d.ts → theme-resolvers-DA204O-q.d.ts} +1 -1
- package/dist/types/{translator-J6tIUfdp.d.ts → translator-Bg7hwxDF.d.cts} +21 -8
- package/dist/types/{translator-B4mc1lzJ.d.cts → translator-CeP4bCzp.d.ts} +21 -8
- package/package.json +4 -3
|
@@ -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-BZgJyfZS.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-XKMlcUZz.cjs');
|
|
9
|
+
var startupRoots = require('../_shared/startup-roots-CgfPQrNl.cjs');
|
|
10
10
|
var node_crypto = require('node:crypto');
|
|
11
11
|
require('node:async_hooks');
|
|
12
12
|
require('crypto');
|
|
@@ -21,12 +21,10 @@ 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-BUAR1WYG.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.
|
|
28
|
-
// Each call signature of `T`, up to four, as a union: `Parameters` and `ReturnType` read only an overloaded function's
|
|
29
|
-
// last signature, such as the list form of a manager's `fetch`
|
|
30
28
|
/**
|
|
31
29
|
* Tells whether a value is a mock function: one from `meocord/testing`, `jest.fn()` or `vi.fn()`.
|
|
32
30
|
*
|
|
@@ -49,15 +47,90 @@ require('../_shared/translate-error-BnM8FbSH.cjs');
|
|
|
49
47
|
*/ function isMockFunction(fn) {
|
|
50
48
|
return typeof fn === 'function' && '_isMockFunction' in fn && fn._isMockFunction === true;
|
|
51
49
|
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
50
|
+
/**
|
|
51
|
+
* What `useMockFn` set and the mocks made, kept for both builds of this meocord version, so a setup file in one module
|
|
52
|
+
* format sets them for specs in the other:
|
|
53
|
+
* - `invocationOrder`: when each call of meocord's own mocks came, across all of them, as Vitest numbers its own;
|
|
54
|
+
* - `created`: every mock createMockFn made, held weakly so the mocks of a finished test can still be collected;
|
|
55
|
+
* - `runnerFactory` and `restoreImpl`: the runner's factory useMockFn registered, and for each of its mocks how to
|
|
56
|
+
* give back the implementation it was created with, through the runner's own setter, which a mock's behaviour may wrap.
|
|
57
|
+
*/ const mocks$1 = controller_decorator.shared('mocks', ()=>({
|
|
58
|
+
invocationOrder: 0,
|
|
59
|
+
created: new Set(),
|
|
60
|
+
runnerFactory: undefined,
|
|
61
|
+
restoreImpl: new WeakMap()
|
|
62
|
+
}));
|
|
63
|
+
const collected = new FinalizationRegistry((ref)=>mocks$1.created.delete(ref));
|
|
64
|
+
/**
|
|
65
|
+
* Makes every mock `meocord/testing` creates with the test runner's own mock function, such as Vitest's `vi.fn`.
|
|
66
|
+
*
|
|
67
|
+
* Call it once in the runner's setup file. The runner then treats meocord's mocks as its own: its `clearMocks` and
|
|
68
|
+
* `mockReset` config and `vi.clearAllMocks()` reach them, its matchers read them, and its whole mock API is there.
|
|
69
|
+
* Without it, mocks are {@link createMockFn}'s own, which jest's and Vitest's `expect` read too.
|
|
70
|
+
*
|
|
71
|
+
* @remarks
|
|
72
|
+
* Each mock starts with the runner's mock holding meocord's behaviour, such as an interaction's replies, as its own
|
|
73
|
+
* implementation. {@link resetAllMocks} puts that behaviour back where the runner's `mockReset` drops it, as jest's and
|
|
74
|
+
* bun's do; a runner's own reset-all, such as jest's `resetMocks`, can't, so under jest and bun reset with
|
|
75
|
+
* {@link resetAllMocks}. node:test's `mock.fn` records calls in a shape of its own, so under node:test leave meocord's
|
|
76
|
+
* own function in place. Calling it again with the same factory does nothing; another, once a mock exists, throws.
|
|
77
|
+
*
|
|
78
|
+
* @param factory - The runner's mock function factory: `vi.fn`, `jest.fn` or bun's `mock`.
|
|
79
|
+
* @throws Error when a mock already exists from another factory, or when `factory` doesn't make a mock with jest's and
|
|
80
|
+
* Vitest's API.
|
|
81
|
+
*
|
|
82
|
+
* @example
|
|
83
|
+
* ```ts
|
|
84
|
+
* // vitest.setup.ts
|
|
85
|
+
* import { vi } from 'vitest'
|
|
86
|
+
*
|
|
87
|
+
* useMockFn(vi.fn)
|
|
88
|
+
* ```
|
|
89
|
+
*
|
|
90
|
+
* @group Testing
|
|
91
|
+
* @category Mocks
|
|
92
|
+
* @see {@link createMockFn}
|
|
93
|
+
*/ function useMockFn(factory) {
|
|
94
|
+
if (factory === mocks$1.runnerFactory) return;
|
|
95
|
+
if ([
|
|
96
|
+
...mocks$1.created
|
|
97
|
+
].some((ref)=>ref.deref() !== undefined)) {
|
|
98
|
+
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.");
|
|
99
|
+
}
|
|
100
|
+
const refusal = (cause)=>new Error("useMockFn() takes a function that makes mocks with jest's and Vitest's API, such as vi.fn, jest.fn or bun's " + "mock. node:test's mock.fn records calls in its own shape, so under node:test leave meocord's own mock function " + 'in place.', cause === undefined ? undefined : {
|
|
101
|
+
cause
|
|
102
|
+
});
|
|
103
|
+
let probe;
|
|
104
|
+
try {
|
|
105
|
+
probe = factory();
|
|
106
|
+
} catch (error) {
|
|
107
|
+
// node:test's mock.fn, given on its own, throws for want of its tracker
|
|
108
|
+
throw refusal(error);
|
|
109
|
+
}
|
|
110
|
+
const api = [
|
|
111
|
+
'mockImplementation',
|
|
112
|
+
'mockClear',
|
|
113
|
+
'mockReset'
|
|
114
|
+
];
|
|
115
|
+
if (!isMockFunction(probe) || !Array.isArray(probe.mock?.calls) || api.some((name)=>typeof probe[name] !== 'function')) {
|
|
116
|
+
throw refusal();
|
|
117
|
+
}
|
|
118
|
+
mocks$1.runnerFactory = factory;
|
|
119
|
+
}
|
|
120
|
+
/** Whether mocks are made with a runner's function, which {@link useMockFn} registered. */ const usesRunnerMockFn = ()=>mocks$1.runnerFactory !== undefined;
|
|
121
|
+
/** Tracks a mock for clearAllMocks and resetAllMocks. */ function track(mockFn) {
|
|
122
|
+
const ref = new WeakRef(mockFn);
|
|
123
|
+
mocks$1.created.add(ref);
|
|
124
|
+
collected.register(mockFn, ref);
|
|
125
|
+
return mockFn;
|
|
126
|
+
}
|
|
55
127
|
/**
|
|
56
128
|
* Clears the calls every mock from `meocord/testing` has recorded, keeping what each was told to do.
|
|
57
129
|
*
|
|
58
|
-
* Vitest's `clearMocks` and jest's `clearAllMocks()` reach only their own `vi.fn()` and `jest.fn()` mocks
|
|
59
|
-
*
|
|
60
|
-
*
|
|
130
|
+
* Vitest's `clearMocks` and jest's `clearAllMocks()` reach only their own `vi.fn()` and `jest.fn()` mocks, and
|
|
131
|
+
* meocord's too once {@link useMockFn} makes them with the runner's function. This one covers `createMockFn` and
|
|
132
|
+
* everything built on it, such as `createMockInteraction` and `createMockClient`, either way. To also undo what a test
|
|
133
|
+
* set, use {@link resetAllMocks}.
|
|
61
134
|
*
|
|
62
135
|
* @example
|
|
63
136
|
* ```ts
|
|
@@ -70,7 +143,7 @@ const collected = new FinalizationRegistry((ref)=>created.delete(ref));
|
|
|
70
143
|
* @category Mocks
|
|
71
144
|
* @see {@link resetAllMocks}
|
|
72
145
|
*/ function clearAllMocks() {
|
|
73
|
-
for (const ref of created)ref.deref()?.mockClear();
|
|
146
|
+
for (const ref of mocks$1.created)ref.deref()?.mockClear();
|
|
74
147
|
}
|
|
75
148
|
/**
|
|
76
149
|
* Clears every mock from `meocord/testing`, and puts each back to the implementation it was created with.
|
|
@@ -93,18 +166,33 @@ const collected = new FinalizationRegistry((ref)=>created.delete(ref));
|
|
|
93
166
|
* @category Mocks
|
|
94
167
|
* @see {@link clearAllMocks}
|
|
95
168
|
*/ function resetAllMocks() {
|
|
96
|
-
for (const ref of created)
|
|
169
|
+
for (const ref of mocks$1.created){
|
|
170
|
+
const mockFn = ref.deref();
|
|
171
|
+
if (!mockFn) continue;
|
|
172
|
+
mockFn.mockReset();
|
|
173
|
+
// A runner whose reset drops the implementation the mock was created with, as jest's and bun's do, gets it back
|
|
174
|
+
mocks$1.restoreImpl.get(mockFn)?.();
|
|
175
|
+
}
|
|
97
176
|
}
|
|
98
177
|
/**
|
|
99
178
|
* Creates a mock function that jest's and Vitest's `expect` both read.
|
|
100
179
|
*
|
|
101
180
|
* 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.
|
|
181
|
+
* Vitest test, `vi.fn()` works as well. {@link clearAllMocks} and {@link resetAllMocks} reach it. Once
|
|
182
|
+
* {@link useMockFn} registers a runner's function, it returns that runner's mock.
|
|
103
183
|
*
|
|
104
184
|
* @remarks
|
|
105
185
|
* `mockReturnValue`, `mockResolvedValue`, `mockRejectedValue` and `mockImplementation` share one implementation, so
|
|
106
186
|
* the last one set wins. The `*Once` variants queue, and run first, in the order they were set.
|
|
107
187
|
*
|
|
188
|
+
* It records what Vitest's `toHaveResolved` and `toHaveBeenCalledBefore` read as well. Its call order is counted
|
|
189
|
+
* across meocord's own mocks, apart from the runner's, so compare the order of two of meocord's mocks, or of two of the
|
|
190
|
+
* runner's, not one of each.
|
|
191
|
+
*
|
|
192
|
+
* A promise the implementation returns is handed back as one that settles the same way, not as the same object: test
|
|
193
|
+
* it with `toEqual`, `toHaveReturnedWith` or by awaiting it, not with `toBe`. A property set on the given promise, such
|
|
194
|
+
* as a `cancel` method, isn't carried over.
|
|
195
|
+
*
|
|
108
196
|
* @param impl - The implementation to run until another is set.
|
|
109
197
|
*
|
|
110
198
|
* @example
|
|
@@ -122,6 +210,15 @@ const collected = new FinalizationRegistry((ref)=>created.delete(ref));
|
|
|
122
210
|
* @category Mocks
|
|
123
211
|
* @see {@link MockInstance}
|
|
124
212
|
*/ function createMockFn(impl) {
|
|
213
|
+
const { runnerFactory } = mocks$1;
|
|
214
|
+
if (runnerFactory) {
|
|
215
|
+
const runnerMock = runnerFactory(impl);
|
|
216
|
+
if (impl) {
|
|
217
|
+
const setImpl = runnerMock.mockImplementation.bind(runnerMock);
|
|
218
|
+
mocks$1.restoreImpl.set(runnerMock, ()=>setImpl(impl));
|
|
219
|
+
}
|
|
220
|
+
return track(runnerMock);
|
|
221
|
+
}
|
|
125
222
|
// One persistent implementation and one queue of single-use ones, as in jest and vitest: the
|
|
126
223
|
// four setters share the slot, so the last call wins, and the `*Once` variants share the queue,
|
|
127
224
|
// so they run in declaration order.
|
|
@@ -131,9 +228,20 @@ const collected = new FinalizationRegistry((ref)=>created.delete(ref));
|
|
|
131
228
|
const calls = [];
|
|
132
229
|
const results = [];
|
|
133
230
|
const instances = [];
|
|
231
|
+
const settledResults = [];
|
|
232
|
+
const invocationCallOrder = [];
|
|
233
|
+
const contexts = [];
|
|
134
234
|
const mockFn = function(...args) {
|
|
135
235
|
calls.push(args);
|
|
136
236
|
instances.push(this);
|
|
237
|
+
contexts.push(this);
|
|
238
|
+
invocationCallOrder.push(++mocks$1.invocationOrder);
|
|
239
|
+
// Updated in place as the call settles, so a promise's outcome shows once it resolves or rejects
|
|
240
|
+
const settled = {
|
|
241
|
+
type: 'incomplete',
|
|
242
|
+
value: undefined
|
|
243
|
+
};
|
|
244
|
+
settledResults.push(settled);
|
|
137
245
|
let type = 'return';
|
|
138
246
|
let value;
|
|
139
247
|
try {
|
|
@@ -144,10 +252,36 @@ const collected = new FinalizationRegistry((ref)=>created.delete(ref));
|
|
|
144
252
|
} else {
|
|
145
253
|
value = undefined;
|
|
146
254
|
}
|
|
255
|
+
if (value instanceof Promise) {
|
|
256
|
+
// Returned in its place, so a rejection nobody awaits stays unhandled in the test, as it is on a bot: a handler
|
|
257
|
+
// on the implementation's own promise would mark it handled
|
|
258
|
+
value = value.then((resolved)=>{
|
|
259
|
+
Object.assign(settled, {
|
|
260
|
+
type: 'fulfilled',
|
|
261
|
+
value: resolved
|
|
262
|
+
});
|
|
263
|
+
return resolved;
|
|
264
|
+
}, (rejected)=>{
|
|
265
|
+
Object.assign(settled, {
|
|
266
|
+
type: 'rejected',
|
|
267
|
+
value: rejected
|
|
268
|
+
});
|
|
269
|
+
throw rejected;
|
|
270
|
+
});
|
|
271
|
+
} else {
|
|
272
|
+
Object.assign(settled, {
|
|
273
|
+
type: 'fulfilled',
|
|
274
|
+
value
|
|
275
|
+
});
|
|
276
|
+
}
|
|
147
277
|
return value;
|
|
148
278
|
} catch (err) {
|
|
149
279
|
type = 'throw';
|
|
150
280
|
value = err;
|
|
281
|
+
Object.assign(settled, {
|
|
282
|
+
type: 'rejected',
|
|
283
|
+
value: err
|
|
284
|
+
});
|
|
151
285
|
throw err;
|
|
152
286
|
} finally{
|
|
153
287
|
results.push({
|
|
@@ -175,6 +309,18 @@ const collected = new FinalizationRegistry((ref)=>created.delete(ref));
|
|
|
175
309
|
return calls.length > 0 ? calls[calls.length - 1] : undefined;
|
|
176
310
|
}
|
|
177
311
|
};
|
|
312
|
+
// Out of the mock's keys, so a test comparing `mock` as a whole sees the shape it always had
|
|
313
|
+
Object.defineProperties(mock, {
|
|
314
|
+
settledResults: {
|
|
315
|
+
get: ()=>settledResults
|
|
316
|
+
},
|
|
317
|
+
invocationCallOrder: {
|
|
318
|
+
get: ()=>invocationCallOrder
|
|
319
|
+
},
|
|
320
|
+
contexts: {
|
|
321
|
+
get: ()=>contexts
|
|
322
|
+
}
|
|
323
|
+
});
|
|
178
324
|
Object.defineProperty(mockFn, 'mock', {
|
|
179
325
|
value: mock,
|
|
180
326
|
enumerable: false
|
|
@@ -211,16 +357,22 @@ const collected = new FinalizationRegistry((ref)=>created.delete(ref));
|
|
|
211
357
|
onceQueue.push(fn);
|
|
212
358
|
return mockFn;
|
|
213
359
|
};
|
|
360
|
+
const forgetCalls = ()=>{
|
|
361
|
+
for (const record of [
|
|
362
|
+
calls,
|
|
363
|
+
results,
|
|
364
|
+
instances,
|
|
365
|
+
settledResults,
|
|
366
|
+
invocationCallOrder,
|
|
367
|
+
contexts
|
|
368
|
+
])record.length = 0;
|
|
369
|
+
};
|
|
214
370
|
mockFn.mockClear = ()=>{
|
|
215
|
-
|
|
216
|
-
results.length = 0;
|
|
217
|
-
instances.length = 0;
|
|
371
|
+
forgetCalls();
|
|
218
372
|
return mockFn;
|
|
219
373
|
};
|
|
220
374
|
mockFn.mockReset = ()=>{
|
|
221
|
-
|
|
222
|
-
results.length = 0;
|
|
223
|
-
instances.length = 0;
|
|
375
|
+
forgetCalls();
|
|
224
376
|
onceQueue = [];
|
|
225
377
|
currentImpl = impl;
|
|
226
378
|
return mockFn;
|
|
@@ -234,10 +386,7 @@ const collected = new FinalizationRegistry((ref)=>created.delete(ref));
|
|
|
234
386
|
name = n;
|
|
235
387
|
return mockFn;
|
|
236
388
|
};
|
|
237
|
-
|
|
238
|
-
created.add(ref);
|
|
239
|
-
collected.register(mockFn, ref);
|
|
240
|
-
return mockFn;
|
|
389
|
+
return track(mockFn);
|
|
241
390
|
}
|
|
242
391
|
|
|
243
392
|
const VISIBLE = new Set([
|
|
@@ -279,7 +428,7 @@ const VISIBLE = new Set([
|
|
|
279
428
|
* @group Testing
|
|
280
429
|
* @category Inspection
|
|
281
430
|
* @see {@link TestingModule}
|
|
282
|
-
* @see {@link https://meocord.dev/docs/4.
|
|
431
|
+
* @see {@link https://meocord.dev/docs/4.2/invoke-and-dispatch | Invoke and dispatch}
|
|
283
432
|
*/ function getResponse(interaction) {
|
|
284
433
|
const state = controller_decorator.existingResponse(interaction);
|
|
285
434
|
// A mock records every answer it gets; respond()'s edits through the channel are its own, so they are merged in
|
|
@@ -337,9 +486,136 @@ const VISIBLE = new Set([
|
|
|
337
486
|
}, code, 400, 'POST', 'https://discord.com/api/v10/interactions', {});
|
|
338
487
|
}
|
|
339
488
|
|
|
489
|
+
/**
|
|
490
|
+
* The id of the bot every mock client is logged in as, so a test can mention it. Fixed, and below the
|
|
491
|
+
* ids other mocks take, so it is the same on every run and in any test order, and no mock shares it.
|
|
492
|
+
*/ const MOCK_BOT_ID = '1300000000000000000';
|
|
493
|
+
/**
|
|
494
|
+
* The last id a mock was given, for both builds of this meocord version; ids count up from a real snowflake, so each
|
|
495
|
+
* mock's is its own.
|
|
496
|
+
*/ const ids = controller_decorator.shared('mockIds', ()=>({
|
|
497
|
+
last: 1400000000000000000n
|
|
498
|
+
}));
|
|
499
|
+
/** A snowflake-shaped id nothing else a mock holds in this run has, for a mock or the media it resolves. */ const nextSnowflake = ()=>String(++ids.last);
|
|
500
|
+
|
|
501
|
+
// Whether useStrictMocks() is on, and whether a mock was made before it could be, for both builds of this version
|
|
502
|
+
const settings = controller_decorator.shared('strictMocks', ()=>({
|
|
503
|
+
strict: false,
|
|
504
|
+
mockMade: false
|
|
505
|
+
}));
|
|
506
|
+
/** Whether mocks compute discord.js's state, as {@link useStrictMocks} turns on. */ const strictMocks = ()=>settings.strict;
|
|
507
|
+
/** Notes that a mock was made, which useStrictMocks() must come before. */ function noteMockMade() {
|
|
508
|
+
settings.mockMade = true;
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* Has every mock from `meocord/testing` compute the values discord.js computes, where it reads a placeholder otherwise.
|
|
512
|
+
*
|
|
513
|
+
* Call it once in the test runner's setup file. A message's `editable`, `deletable`, `pinnable`, `crosspostable`,
|
|
514
|
+
* `bulkDeletable`, `hasThread` and `partial`, a member's `manageable`, `kickable`, `bannable` and `moderatable`, a
|
|
515
|
+
* role's `editable`, a channel's and a thread's `viewable`, `manageable`, `deletable`, `joinable` and the rest, a voice
|
|
516
|
+
* channel's `full`, and `partial` on users, channels and reactions then run discord.js's own getters against the mock,
|
|
517
|
+
* and `message.thread` is `null` when the channel caches no thread under the message's id. So do a message's
|
|
518
|
+
* `editedAt`, a member's `presence`, a server's `verified` and `systemChannel` and a channel's `parent`, each `null` or
|
|
519
|
+
* `false` until the test gives what it is computed from. A reaction's `me` is `false`, a message's
|
|
520
|
+
* `mentions.repliedUser` and a modal's `message` are `null`, and a reaction has a whole message of its own, so
|
|
521
|
+
* MeoCord's dispatcher fetches neither. No placeholder warning is logged.
|
|
522
|
+
*
|
|
523
|
+
* @remarks
|
|
524
|
+
* Those getters read what a server holds, so strict mocks also have it as Discord sends it: the bot's member is in the
|
|
525
|
+
* server's member cache from the start, @everyone has the permissions Discord gives it in a new server, and a channel
|
|
526
|
+
* or thread made without a server has one of its own, a thread with a text channel as its parent. A message has empty
|
|
527
|
+
* `content`, a thread is neither archived nor locked, and a reaction's `count` is 1. A user, server or channel made
|
|
528
|
+
* with a generated id was created when the mock was made, as a message is. An interaction's `memberPermissions` apply
|
|
529
|
+
* its channel's overwrites, a thread's parent's for a thread, to its member's permissions. An interaction is in its
|
|
530
|
+
* message's channel and server, a guild's or a member's server, or, given a `guildId` alone, a server the bot isn't in.
|
|
531
|
+
* A message the bot sends is its `client.user`'s, and one it sends in a DM is in that DM. Give the bot's member a role
|
|
532
|
+
* 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 computed
|
|
533
|
+
* one. An interaction's answer that discord.js would refuse, such as a second `reply()` after one a test set with
|
|
534
|
+
* `mockResolvedValue`, is refused too, where without it the mock runs it and warns. The next major version (5.0)
|
|
535
|
+
* computes these values, and refuses those answers, without the call.
|
|
536
|
+
*
|
|
537
|
+
* `invoke` also refuses a call that gives a handler fewer arguments than it declares where it builds none, such as a
|
|
538
|
+
* reaction handler given its reaction without its `ReactionEvent`; without strict mocks, it warns once and calls it.
|
|
539
|
+
*
|
|
540
|
+
* `compile()` also refuses an `overrideGuard`, `overrideInterceptor` or `overrideFilter` stub without the stage's
|
|
541
|
+
* method, `canActivate`, `intercept` or `catch`; without strict mocks, it warns and builds the module.
|
|
542
|
+
*
|
|
543
|
+
* @throws Error when a mock already exists, which was made without it.
|
|
544
|
+
*
|
|
545
|
+
* @example
|
|
546
|
+
* ```ts
|
|
547
|
+
* // vitest.setup.ts
|
|
548
|
+
* useStrictMocks()
|
|
549
|
+
* ```
|
|
550
|
+
*
|
|
551
|
+
* @group Testing
|
|
552
|
+
* @category Mocks
|
|
553
|
+
* @see {@link useMockFn}
|
|
554
|
+
* @see {@link https://meocord.dev/docs/4.2/mocks | Mocks}
|
|
555
|
+
*/ function useStrictMocks() {
|
|
556
|
+
if (settings.strict) return;
|
|
557
|
+
if (settings.mockMade) {
|
|
558
|
+
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.");
|
|
559
|
+
}
|
|
560
|
+
settings.strict = true;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const mockLogger$1 = new controller_decorator.Logger('Mocks');
|
|
564
|
+
/**
|
|
565
|
+
* A member's permissions where an interaction was made, as Discord computes them: under strict mocks, every permission
|
|
566
|
+
* for an Administrator or the owner, else the channel's overwrites on top of `member.permissions`, so a test that sets
|
|
567
|
+
* those keeps the base; otherwise `member.permissions`, with a warning once where strict mocks would read otherwise.
|
|
568
|
+
*/ function inChannel(interaction, member) {
|
|
569
|
+
const base = member.permissions;
|
|
570
|
+
// Only a server the mock caches has the overwrites to apply
|
|
571
|
+
const { guild } = interaction;
|
|
572
|
+
if (!(guild instanceof discord_js.Guild)) return base;
|
|
573
|
+
const computed = base.has(discord_js.PermissionFlagsBits.Administrator) || member.id === guild.ownerId ? new discord_js.PermissionsBitField(discord_js.PermissionsBitField.All).freeze() : withOverwrites(base, overwritingChannel(interaction, guild), member);
|
|
574
|
+
if (strictMocks()) return computed;
|
|
575
|
+
if (!computed.equals(base)) {
|
|
576
|
+
const lost = new discord_js.PermissionsBitField(base).remove(computed).toArray();
|
|
577
|
+
const gained = new discord_js.PermissionsBitField(computed).remove(base).toArray();
|
|
578
|
+
const change = lost.length > 0 ? [
|
|
579
|
+
`without ${lost.join(', ')}`
|
|
580
|
+
] : [];
|
|
581
|
+
if (gained.length > 0) change.push(`with ${gained.join(', ')}`);
|
|
582
|
+
controller_decorator.warnOnce(mockLogger$1, `${interaction.constructor.name}.memberPermissions reads the member's own permissions here, without the ` + "channel's overwrites or every permission an Administrator or the owner has; in the next major version (5.0) " + `it reads them as Discord computes them in the channel, ${change.join(' and ')}. Set memberPermissions on the ` + 'mock, or call useStrictMocks() to compute them now.');
|
|
583
|
+
}
|
|
584
|
+
// Unless the test set them, a member's permissions come from its roles, @everyone's included
|
|
585
|
+
if (!Object.prototype.hasOwnProperty.call(member, 'permissions')) warnOnUnsetEveryone(interaction, guild, 'memberPermissions', base);
|
|
586
|
+
return base;
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* Warns once, as `key`, where `permissions` read a server's @everyone role default mode made with none, and lack what
|
|
590
|
+
* strict mocks give @everyone in a new server.
|
|
591
|
+
*/ function warnOnUnsetEveryone(interaction, guild, key, permissions) {
|
|
592
|
+
const everyone = guild.roles.everyone;
|
|
593
|
+
if (!everyone || !isUnsetEveryone(everyone) || permissions.has(NEW_SERVER_EVERYONE_PERMISSIONS)) return;
|
|
594
|
+
controller_decorator.warnOnce(mockLogger$1, `${interaction.constructor.name}.${key} reads the server's @everyone role here with no permissions; in the next major ` + 'version (5.0) it has the permissions Discord gives @everyone in a new server, as strict mocks read it. Set ' + 'guild.roles.everyone.permissions, or call useStrictMocks() to read it so now.');
|
|
595
|
+
}
|
|
596
|
+
/**
|
|
597
|
+
* The channel whose overwrites apply where an interaction was made, read without making or caching one: a thread's
|
|
598
|
+
* parent, or undefined where the channel, or a thread's parent, is one the mock would make, which has none.
|
|
599
|
+
*/ function overwritingChannel(interaction, guild) {
|
|
600
|
+
const channel = heldChannelOf(interaction, guild);
|
|
601
|
+
// A thread's parent by the id the test gave it or one already read, so strict mocks' parent isn't made for this read
|
|
602
|
+
const parentId = (thread)=>Object.prototype.hasOwnProperty.call(thread, 'parentId') ? thread.parentId : madeParentIds.get(thread);
|
|
603
|
+
const at = channel instanceof discord_js.ThreadChannel ? guild.channels.cache.get(parentId(channel) ?? '') : channel;
|
|
604
|
+
return at instanceof discord_js.GuildChannel ? at : undefined;
|
|
605
|
+
}
|
|
606
|
+
// The parent id strict mocks made for each thread made without one, once read
|
|
607
|
+
const madeParentIds = new WeakMap();
|
|
608
|
+
// Private in discord.js's typings, though they are what its own permissionsFor computes with; called on the prototype
|
|
609
|
+
// so a test's stub on the channel stays for its caller
|
|
610
|
+
const channelPermissions = discord_js.GuildChannel.prototype;
|
|
611
|
+
/** `base` with `channel`'s overwrites for `member` applied, in discord.js's order: @everyone's, the roles', the member's. */ function withOverwrites(base, channel, member) {
|
|
612
|
+
if (!channel) return base;
|
|
613
|
+
// Given the member and its roles as verified, as discord.js's memberPermissions does, so it resolves nothing again
|
|
614
|
+
const overwrites = channelPermissions.overwritesFor.call(channel, member, true, member.roles.cache);
|
|
615
|
+
return new discord_js.PermissionsBitField(base).remove(overwrites.everyone?.deny ?? 0n).add(overwrites.everyone?.allow ?? 0n).remove(overwrites.roles.length > 0 ? overwrites.roles.map((role)=>role.deny) : 0n).add(overwrites.roles.length > 0 ? overwrites.roles.map((role)=>role.allow) : 0n).remove(overwrites.member?.deny ?? 0n).add(overwrites.member?.allow ?? 0n).freeze();
|
|
616
|
+
}
|
|
340
617
|
/** A permission set with none, frozen as discord.js freezes the ones it gives. */ const noPermissions = ()=>new discord_js.PermissionsBitField().freeze();
|
|
341
618
|
/** A property discord.js computes from the others: its own getter runs, reading the mock's values. */ const REAL_GETTER = Symbol('real getter');
|
|
342
|
-
const snowflake = ()=>discord_js.SnowflakeUtil.generate().toString();
|
|
343
619
|
/** A class and its defaults, typed together so a default that stops fitting its property fails to compile. */ const defaultsOf = (Class, defaults)=>[
|
|
344
620
|
Class.prototype,
|
|
345
621
|
defaults
|
|
@@ -352,7 +628,7 @@ const snowflake = ()=>discord_js.SnowflakeUtil.generate().toString();
|
|
|
352
628
|
defaultsOf(discord_js.Guild, {
|
|
353
629
|
name: ()=>'Guild',
|
|
354
630
|
preferredLocale: ()=>discord_js.Locale.EnglishUS,
|
|
355
|
-
ownerId:
|
|
631
|
+
ownerId: nextSnowflake,
|
|
356
632
|
memberCount: ()=>1,
|
|
357
633
|
available: ()=>true,
|
|
358
634
|
large: ()=>false,
|
|
@@ -365,6 +641,9 @@ const snowflake = ()=>discord_js.SnowflakeUtil.generate().toString();
|
|
|
365
641
|
shardId: ()=>0,
|
|
366
642
|
nsfwLevel: ()=>discord_js.GuildNSFWLevel.Default,
|
|
367
643
|
verificationLevel: ()=>discord_js.GuildVerificationLevel.None,
|
|
644
|
+
presences: (guild)=>ownedManager(discord_js.PresenceManager.prototype, {
|
|
645
|
+
guild
|
|
646
|
+
}),
|
|
368
647
|
premiumTier: ()=>discord_js.GuildPremiumTier.None,
|
|
369
648
|
createdTimestamp: REAL_GETTER,
|
|
370
649
|
createdAt: REAL_GETTER
|
|
@@ -379,6 +658,7 @@ const snowflake = ()=>discord_js.SnowflakeUtil.generate().toString();
|
|
|
379
658
|
discriminator: ()=>'0',
|
|
380
659
|
avatar: ()=>null,
|
|
381
660
|
system: ()=>false,
|
|
661
|
+
flags: ()=>new discord_js.UserFlagsBitField(),
|
|
382
662
|
tag: REAL_GETTER,
|
|
383
663
|
displayName: REAL_GETTER,
|
|
384
664
|
defaultAvatarURL: REAL_GETTER,
|
|
@@ -399,12 +679,12 @@ const snowflake = ()=>discord_js.SnowflakeUtil.generate().toString();
|
|
|
399
679
|
premiumSinceTimestamp: ()=>null,
|
|
400
680
|
communicationDisabledUntilTimestamp: ()=>null,
|
|
401
681
|
pending: ()=>false,
|
|
682
|
+
flags: ()=>new discord_js.GuildMemberFlagsBitField().freeze(),
|
|
402
683
|
displayName: REAL_GETTER,
|
|
403
684
|
roles: (member)=>memberRoles(member, []),
|
|
404
685
|
permissions: REAL_GETTER
|
|
405
686
|
}),
|
|
406
687
|
defaultsOf(discord_js.Role, {
|
|
407
|
-
id: snowflake,
|
|
408
688
|
name: ()=>'role',
|
|
409
689
|
// Above @everyone, which a server's role manager makes at position 0
|
|
410
690
|
position: ()=>1,
|
|
@@ -442,30 +722,71 @@ const snowflake = ()=>discord_js.SnowflakeUtil.generate().toString();
|
|
|
442
722
|
poll: ()=>null,
|
|
443
723
|
interactionMetadata: ()=>null,
|
|
444
724
|
stickers: ()=>new discord_js.Collection(),
|
|
725
|
+
messageSnapshots: ()=>new discord_js.Collection(),
|
|
726
|
+
reactions: (message)=>ownedManager(discord_js.ReactionManager.prototype, {
|
|
727
|
+
message
|
|
728
|
+
}),
|
|
445
729
|
url: REAL_GETTER
|
|
446
730
|
}),
|
|
447
731
|
// A command's name and a component's customId say which handler a call is for, so they stay the test's to give
|
|
732
|
+
defaultsOf(discord_js.ChatInputCommandInteraction, {
|
|
733
|
+
options: ()=>createChatInputOptions()
|
|
734
|
+
}),
|
|
448
735
|
defaultsOf(discord_js.CommandInteraction, {
|
|
449
|
-
commandId:
|
|
736
|
+
commandId: nextSnowflake,
|
|
450
737
|
commandGuildId: ()=>null
|
|
451
738
|
}),
|
|
452
739
|
defaultsOf(discord_js.AutocompleteInteraction, {
|
|
453
|
-
commandId:
|
|
454
|
-
commandGuildId: ()=>null
|
|
740
|
+
commandId: nextSnowflake,
|
|
741
|
+
commandGuildId: ()=>null,
|
|
742
|
+
options: ()=>createChatInputOptions()
|
|
743
|
+
}),
|
|
744
|
+
defaultsOf(discord_js.MessageReaction, {
|
|
745
|
+
users: (reaction)=>ownedManager(discord_js.ReactionUserManager.prototype, {
|
|
746
|
+
reaction
|
|
747
|
+
})
|
|
455
748
|
}),
|
|
456
749
|
defaultsOf(discord_js.BaseInteraction, {
|
|
457
|
-
applicationId:
|
|
750
|
+
applicationId: nextSnowflake,
|
|
458
751
|
token: ()=>'mock-interaction-token',
|
|
459
752
|
version: ()=>1,
|
|
460
753
|
context: ()=>null,
|
|
754
|
+
entitlements: ()=>new discord_js.Collection(),
|
|
461
755
|
// What Discord sends with an interaction in a server, the member's permissions there; typed for a cached one
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
756
|
+
// Each reads where the interaction is as its own, and warns by its own name where strict mocks place it otherwise
|
|
757
|
+
memberPermissions: (interaction)=>readPlaceAs(interaction, 'memberPermissions', ()=>{
|
|
758
|
+
const { guildId } = interaction;
|
|
759
|
+
const member = interaction.member;
|
|
760
|
+
if (!guildId) return null;
|
|
761
|
+
if (member instanceof discord_js.GuildMember) return inChannel(interaction, member);
|
|
762
|
+
// A raw member's permissions are the string Discord sends, read as discord.js reads them
|
|
763
|
+
return isRawMember(member) && !interaction.guild ? new discord_js.PermissionsBitField(BigInt(member.permissions)).freeze() : null;
|
|
764
|
+
}),
|
|
765
|
+
// The bot's permissions where the interaction was made: its member's in the channel, or in a thread its parent, as
|
|
766
|
+
// discord.js computes them; none outside a server
|
|
767
|
+
appPermissions: (interaction)=>readPlaceAs(interaction, 'appPermissions', ()=>{
|
|
768
|
+
const { guild } = interaction;
|
|
769
|
+
const me = guild instanceof discord_js.Guild ? botMemberOf(guild) : null;
|
|
770
|
+
if (!(me instanceof discord_js.GuildMember) || heldChannelOf(interaction, guild) === null) return noPermissions();
|
|
771
|
+
// discord.js's own computation, given its own overwritesFor over a test's stub on the channel; a channel the
|
|
772
|
+
// mock would make has no overwrites
|
|
773
|
+
const at = overwritingChannel(interaction, guild) ?? {
|
|
774
|
+
guild,
|
|
775
|
+
permissionOverwrites: {
|
|
776
|
+
cache: new discord_js.Collection()
|
|
777
|
+
}
|
|
778
|
+
};
|
|
779
|
+
const channel = Object.create(at, {
|
|
780
|
+
overwritesFor: {
|
|
781
|
+
value: channelPermissions.overwritesFor
|
|
782
|
+
}
|
|
783
|
+
});
|
|
784
|
+
const permissions = channelPermissions.memberPermissions.call(channel, me, true);
|
|
785
|
+
// The bot's member has @everyone's too
|
|
786
|
+
warnOnUnsetEveryone(interaction, guild, 'appPermissions', permissions);
|
|
787
|
+
return permissions;
|
|
788
|
+
}, // A server the bot isn't in has no guild in either mode, so no permissions in both
|
|
789
|
+
(place)=>!place.raw)
|
|
469
790
|
}),
|
|
470
791
|
defaultsOf(discord_js.TextChannel, {
|
|
471
792
|
type: ()=>discord_js.ChannelType.GuildText,
|
|
@@ -501,7 +822,7 @@ const snowflake = ()=>discord_js.SnowflakeUtil.generate().toString();
|
|
|
501
822
|
}),
|
|
502
823
|
defaultsOf(discord_js.GuildChannel, {
|
|
503
824
|
name: ()=>'channel',
|
|
504
|
-
guildId: (channel)=>typeof channel.guild?.id === 'string' ? channel.guild.id :
|
|
825
|
+
guildId: (channel)=>typeof channel.guild?.id === 'string' ? channel.guild.id : nextSnowflake(),
|
|
505
826
|
position: ()=>0,
|
|
506
827
|
rawPosition: ()=>0,
|
|
507
828
|
parentId: ()=>null
|
|
@@ -520,20 +841,139 @@ const snowflake = ()=>discord_js.SnowflakeUtil.generate().toString();
|
|
|
520
841
|
nameAcronym: REAL_GETTER
|
|
521
842
|
})
|
|
522
843
|
]);
|
|
523
|
-
/**
|
|
844
|
+
/**
|
|
845
|
+
* What strict mocks also read as Discord sends it, for discord.js's getters that read it: a server for a channel or
|
|
846
|
+
* thread made without one, a thread's parent text channel in it, and data that is otherwise a placeholder.
|
|
847
|
+
*/ const STRICT_DEFAULTS = new Map([
|
|
848
|
+
defaultsOf(discord_js.Message, {
|
|
849
|
+
content: ()=>'',
|
|
850
|
+
editedTimestamp: ()=>null,
|
|
851
|
+
editedAt: REAL_GETTER
|
|
852
|
+
}),
|
|
853
|
+
defaultsOf(discord_js.MessageMentions, {
|
|
854
|
+
repliedUser: ()=>null
|
|
855
|
+
}),
|
|
856
|
+
defaultsOf(discord_js.Guild, {
|
|
857
|
+
rulesChannelId: ()=>null,
|
|
858
|
+
publicUpdatesChannelId: ()=>null,
|
|
859
|
+
systemChannel: REAL_GETTER
|
|
860
|
+
}),
|
|
861
|
+
defaultsOf(discord_js.BaseGuild, {
|
|
862
|
+
verified: REAL_GETTER
|
|
863
|
+
}),
|
|
864
|
+
defaultsOf(discord_js.GuildMember, {
|
|
865
|
+
presence: REAL_GETTER
|
|
866
|
+
}),
|
|
867
|
+
defaultsOf(discord_js.ModalSubmitInteraction, {
|
|
868
|
+
message: ()=>null
|
|
869
|
+
}),
|
|
870
|
+
defaultsOf(discord_js.ThreadChannel, {
|
|
871
|
+
guild: ()=>createMockGuild(),
|
|
872
|
+
guildId: (thread)=>thread.guild.id,
|
|
873
|
+
parentId: (thread)=>{
|
|
874
|
+
const parent = createMockChannel(discord_js.TextChannel, {
|
|
875
|
+
guild: thread.guild
|
|
876
|
+
});
|
|
877
|
+
thread.guild.channels.cache.set(parent.id, parent);
|
|
878
|
+
madeParentIds.set(thread, parent.id);
|
|
879
|
+
return parent.id;
|
|
880
|
+
},
|
|
881
|
+
parent: REAL_GETTER,
|
|
882
|
+
ownerId: nextSnowflake,
|
|
883
|
+
archived: ()=>false,
|
|
884
|
+
locked: ()=>false
|
|
885
|
+
}),
|
|
886
|
+
defaultsOf(discord_js.GuildChannel, {
|
|
887
|
+
guild: ()=>createMockGuild(),
|
|
888
|
+
parent: REAL_GETTER
|
|
889
|
+
}),
|
|
890
|
+
// Full once its members reach a user limit, which a voice channel has none of unless given one
|
|
891
|
+
defaultsOf(discord_js.BaseGuildVoiceChannel, {
|
|
892
|
+
userLimit: ()=>0,
|
|
893
|
+
full: REAL_GETTER
|
|
894
|
+
}),
|
|
895
|
+
// A reaction's message, as discord.js always has one, is a whole one
|
|
896
|
+
defaultsOf(discord_js.MessageReaction, {
|
|
897
|
+
count: ()=>1,
|
|
898
|
+
me: ()=>false,
|
|
899
|
+
message: ()=>createMockMessage()
|
|
900
|
+
})
|
|
901
|
+
]);
|
|
902
|
+
/**
|
|
903
|
+
* What a mock reads as a placeholder unless strict mocks read it as discord.js gives it, by the value a test sets for
|
|
904
|
+
* that, which the warning on reading one names.
|
|
905
|
+
*/ const PLACEHOLDER_VALUES = new Map([
|
|
906
|
+
[
|
|
907
|
+
discord_js.MessageReaction.prototype,
|
|
908
|
+
{
|
|
909
|
+
me: 'false',
|
|
910
|
+
message: 'createMockMessage()'
|
|
911
|
+
}
|
|
912
|
+
],
|
|
913
|
+
[
|
|
914
|
+
discord_js.MessageMentions.prototype,
|
|
915
|
+
{
|
|
916
|
+
repliedUser: 'null'
|
|
917
|
+
}
|
|
918
|
+
],
|
|
919
|
+
[
|
|
920
|
+
discord_js.Message.prototype,
|
|
921
|
+
{
|
|
922
|
+
editedAt: 'null'
|
|
923
|
+
}
|
|
924
|
+
],
|
|
925
|
+
[
|
|
926
|
+
discord_js.GuildMember.prototype,
|
|
927
|
+
{
|
|
928
|
+
presence: 'null'
|
|
929
|
+
}
|
|
930
|
+
],
|
|
931
|
+
[
|
|
932
|
+
discord_js.BaseGuild.prototype,
|
|
933
|
+
{
|
|
934
|
+
verified: 'false'
|
|
935
|
+
}
|
|
936
|
+
],
|
|
937
|
+
[
|
|
938
|
+
discord_js.Guild.prototype,
|
|
939
|
+
{
|
|
940
|
+
systemChannel: 'null'
|
|
941
|
+
}
|
|
942
|
+
],
|
|
943
|
+
[
|
|
944
|
+
discord_js.GuildChannel.prototype,
|
|
945
|
+
{
|
|
946
|
+
parent: 'null'
|
|
947
|
+
}
|
|
948
|
+
],
|
|
949
|
+
[
|
|
950
|
+
discord_js.ModalSubmitInteraction.prototype,
|
|
951
|
+
{
|
|
952
|
+
message: 'null'
|
|
953
|
+
}
|
|
954
|
+
]
|
|
955
|
+
]);
|
|
956
|
+
/** The value a test sets for `key` on a mock of `target`'s class, when a mock reads it as a placeholder. */ function placeholderValue(target, key) {
|
|
524
957
|
for(let proto = Object.getPrototypeOf(target); proto !== null; proto = Object.getPrototypeOf(proto)){
|
|
525
|
-
const
|
|
526
|
-
if (
|
|
958
|
+
const value = PLACEHOLDER_VALUES.get(proto)?.[key];
|
|
959
|
+
if (value !== undefined) return value;
|
|
960
|
+
}
|
|
961
|
+
return undefined;
|
|
962
|
+
}
|
|
963
|
+
/** 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) {
|
|
964
|
+
for (const defaults of strictMocks() ? [
|
|
965
|
+
STRICT_DEFAULTS,
|
|
966
|
+
DEFAULTS
|
|
967
|
+
] : [
|
|
968
|
+
DEFAULTS
|
|
969
|
+
]){
|
|
970
|
+
for(let proto = Object.getPrototypeOf(target); proto !== null; proto = Object.getPrototypeOf(proto)){
|
|
971
|
+
const found = defaults.get(proto)?.[key];
|
|
972
|
+
if (found !== undefined) return found;
|
|
973
|
+
}
|
|
527
974
|
}
|
|
528
975
|
return undefined;
|
|
529
976
|
}
|
|
530
|
-
|
|
531
|
-
/**
|
|
532
|
-
* The id of the bot every mock client is logged in as, so a test can mention it. Fixed, and below the
|
|
533
|
-
* ids other mocks take, so it is the same on every run and in any test order, and no mock shares it.
|
|
534
|
-
*/ const MOCK_BOT_ID = '1300000000000000000';
|
|
535
|
-
/** The last id a mock was given; ids count up from a real snowflake, so each mock's is its own. */ let lastSnowflake = 1400000000000000000n;
|
|
536
|
-
/** A snowflake-shaped id nothing else a mock holds in this run has, for a mock or the media it resolves. */ const nextSnowflake = ()=>String(++lastSnowflake);
|
|
537
977
|
|
|
538
978
|
/** Where a mock resolves unfurled media to, as Discord's media proxy would. */ const PROXY = 'https://images-ext-1.discordapp.net/external/mock';
|
|
539
979
|
/** Unfurled media as Discord stores it: the url given, resolved to a proxied image of a fixed size. */ function resolvedMedia(media) {
|
|
@@ -664,8 +1104,15 @@ const SKIP = new Set([
|
|
|
664
1104
|
'toJSON',
|
|
665
1105
|
'then'
|
|
666
1106
|
]);
|
|
1107
|
+
// Each mock's own instance, which the proxy's traps receive as their target
|
|
1108
|
+
const mockTargets = new WeakMap();
|
|
1109
|
+
// Warnings a read answered by the stub logs first, by the mock's instance and the property
|
|
1110
|
+
const stubReadWarnings = new WeakMap();
|
|
667
1111
|
function stubDeep(instance, externalStubs, onSet) {
|
|
1112
|
+
noteMockMade();
|
|
668
1113
|
const stubs = externalStubs ?? new Map();
|
|
1114
|
+
// Placeholders read so far, by the value strict mocks give: a read of a kept one still warns, once
|
|
1115
|
+
const placeholders = new Map();
|
|
669
1116
|
const proxy = new Proxy(instance, {
|
|
670
1117
|
get (target, prop) {
|
|
671
1118
|
// Always pass through symbols
|
|
@@ -679,12 +1126,17 @@ function stubDeep(instance, externalStubs, onSet) {
|
|
|
679
1126
|
if (Object.prototype.hasOwnProperty.call(target, key)) {
|
|
680
1127
|
return Reflect.get(target, prop, target);
|
|
681
1128
|
}
|
|
1129
|
+
stubReadWarnings.get(target)?.get(key)?.();
|
|
682
1130
|
// Skip passthrough props — return prototype value as-is
|
|
683
1131
|
if (SKIP.has(key)) {
|
|
684
1132
|
return Reflect.get(target, prop, target);
|
|
685
1133
|
}
|
|
686
1134
|
// Return cached stub
|
|
687
|
-
if (stubs.has(key))
|
|
1135
|
+
if (stubs.has(key)) {
|
|
1136
|
+
const kept = placeholders.get(key);
|
|
1137
|
+
if (kept !== undefined) warnPlaceholderRead(target, key, kept);
|
|
1138
|
+
return stubs.get(key);
|
|
1139
|
+
}
|
|
688
1140
|
// A value discord.js always gives, in its shape, rather than a stub: kept once read, or its getter run live
|
|
689
1141
|
const known = discordDefault(target, key);
|
|
690
1142
|
if (known === REAL_GETTER) {
|
|
@@ -698,6 +1150,12 @@ function stubDeep(instance, externalStubs, onSet) {
|
|
|
698
1150
|
stubs.set(key, value);
|
|
699
1151
|
return value;
|
|
700
1152
|
}
|
|
1153
|
+
// A value strict mocks read as discord.js gives it, which is a placeholder otherwise
|
|
1154
|
+
const strictValue = strictMocks() ? undefined : placeholderValue(target, key);
|
|
1155
|
+
if (strictValue !== undefined) {
|
|
1156
|
+
placeholders.set(key, strictValue);
|
|
1157
|
+
warnPlaceholderRead(target, key, strictValue);
|
|
1158
|
+
}
|
|
701
1159
|
// Walk the prototype chain to check if it's a function
|
|
702
1160
|
let proto = Object.getPrototypeOf(target);
|
|
703
1161
|
let protoValue;
|
|
@@ -705,14 +1163,18 @@ function stubDeep(instance, externalStubs, onSet) {
|
|
|
705
1163
|
const desc = Object.getOwnPropertyDescriptor(proto, key);
|
|
706
1164
|
if (desc !== undefined) {
|
|
707
1165
|
protoValue = desc.value;
|
|
708
|
-
if (desc.get &&
|
|
1166
|
+
if (desc.get && isPlaceholder(proto, key)) {
|
|
1167
|
+
// Strict mocks run discord.js's getter, read live; otherwise the placeholder, with a warning once
|
|
1168
|
+
if (strictMocks()) return Reflect.get(target, prop, proxy);
|
|
1169
|
+
warnPlaceholderRead(target, key, 'false');
|
|
1170
|
+
}
|
|
709
1171
|
break;
|
|
710
1172
|
}
|
|
711
1173
|
proto = Object.getPrototypeOf(proto);
|
|
712
1174
|
}
|
|
713
1175
|
// A client the mock's structures share: its guild's, or one of its own
|
|
714
1176
|
if (key === 'client' && target instanceof discord_js.Base) return clientOf(target);
|
|
715
|
-
const stub = typeof protoValue === 'function' ? isRealMethod(target, key) ? createMockFn((...args)=>protoValue.apply(proxy, args)) : methodStub(target, key, protoValue, ()=>proxy) : stubDeep({});
|
|
1177
|
+
const stub = typeof protoValue === 'function' ? isRealMethod(target, key) ? createMockFn((...args)=>quietly(target, key, ()=>protoValue.apply(proxy, args))) : methodStub(target, key, protoValue, ()=>proxy) : stubDeep({});
|
|
716
1178
|
stubs.set(key, stub);
|
|
717
1179
|
return stub;
|
|
718
1180
|
},
|
|
@@ -721,7 +1183,10 @@ function stubDeep(instance, externalStubs, onSet) {
|
|
|
721
1183
|
// and a plain write against one of those throws. Defining an own
|
|
722
1184
|
// data property shadows the accessor, which is what test setup means.
|
|
723
1185
|
set (target, prop, value) {
|
|
724
|
-
|
|
1186
|
+
// An own accessor with a setter, such as a context menu's target, takes the value itself
|
|
1187
|
+
const setter = Object.getOwnPropertyDescriptor(target, prop)?.set;
|
|
1188
|
+
if (setter) setter.call(target, value);
|
|
1189
|
+
else Object.defineProperty(target, prop, {
|
|
725
1190
|
value,
|
|
726
1191
|
writable: true,
|
|
727
1192
|
enumerable: true,
|
|
@@ -731,6 +1196,7 @@ function stubDeep(instance, externalStubs, onSet) {
|
|
|
731
1196
|
return true;
|
|
732
1197
|
}
|
|
733
1198
|
});
|
|
1199
|
+
mockTargets.set(proxy, instance);
|
|
734
1200
|
return proxy;
|
|
735
1201
|
}
|
|
736
1202
|
// discord.js methods that read only what the mock holds, so they run for real: looking items up in a manager's cache,
|
|
@@ -795,9 +1261,18 @@ const REAL_METHODS = new Map([
|
|
|
795
1261
|
])
|
|
796
1262
|
]
|
|
797
1263
|
]);
|
|
1264
|
+
// What strict mocks also run for real: a thread's permissions are its parent channel's, as discord.js reads them
|
|
1265
|
+
const STRICT_REAL_METHODS = new Map([
|
|
1266
|
+
[
|
|
1267
|
+
discord_js.ThreadChannel.prototype,
|
|
1268
|
+
new Set([
|
|
1269
|
+
'permissionsFor'
|
|
1270
|
+
])
|
|
1271
|
+
]
|
|
1272
|
+
]);
|
|
798
1273
|
function isRealMethod(target, key) {
|
|
799
1274
|
for(let proto = Object.getPrototypeOf(target); proto !== null; proto = Object.getPrototypeOf(proto)){
|
|
800
|
-
if (REAL_METHODS.get(proto)?.has(key)) return true;
|
|
1275
|
+
if (REAL_METHODS.get(proto)?.has(key) || strictMocks() && STRICT_REAL_METHODS.get(proto)?.has(key)) return true;
|
|
801
1276
|
}
|
|
802
1277
|
return false;
|
|
803
1278
|
}
|
|
@@ -854,7 +1329,14 @@ const PLACEHOLDER_BOOLEANS = new Map([
|
|
|
854
1329
|
[
|
|
855
1330
|
discord_js.BaseGuildVoiceChannel.prototype,
|
|
856
1331
|
new Set([
|
|
857
|
-
'joinable'
|
|
1332
|
+
'joinable',
|
|
1333
|
+
'full'
|
|
1334
|
+
])
|
|
1335
|
+
],
|
|
1336
|
+
[
|
|
1337
|
+
discord_js.VoiceChannel.prototype,
|
|
1338
|
+
new Set([
|
|
1339
|
+
'speakable'
|
|
858
1340
|
])
|
|
859
1341
|
],
|
|
860
1342
|
[
|
|
@@ -876,11 +1358,49 @@ const PLACEHOLDER_BOOLEANS = new Map([
|
|
|
876
1358
|
])
|
|
877
1359
|
]
|
|
878
1360
|
]);
|
|
1361
|
+
/** 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) {
|
|
1362
|
+
for(; proto !== null; proto = Object.getPrototypeOf(proto)){
|
|
1363
|
+
if (PLACEHOLDER_BOOLEANS.get(proto)?.has(key)) return true;
|
|
1364
|
+
}
|
|
1365
|
+
return false;
|
|
1366
|
+
}
|
|
879
1367
|
const mockLogger = new controller_decorator.Logger('Mocks');
|
|
880
|
-
|
|
1368
|
+
// Mock reactions, and their messages, that MeoCord's dispatcher reads `partial` on
|
|
1369
|
+
const dispatchedReactions = new WeakSet();
|
|
1370
|
+
let quiet = 0;
|
|
1371
|
+
/**
|
|
1372
|
+
* Runs a real method, keeping quiet about the placeholders `mentions.has()` reads: a placeholder `repliedUser` matches
|
|
1373
|
+
* no user, so its answer is discord.js's either way.
|
|
1374
|
+
*/ function quietly(target, key, run) {
|
|
1375
|
+
if (!(target instanceof discord_js.MessageMentions && key === 'has')) return run();
|
|
1376
|
+
quiet++;
|
|
1377
|
+
try {
|
|
1378
|
+
return run();
|
|
1379
|
+
} finally{
|
|
1380
|
+
quiet--;
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
/**
|
|
1384
|
+
* Marks a mock reaction and its message as read by MeoCord's dispatcher, so a placeholder `partial` it reads warns
|
|
1385
|
+
* that MeoCord read it, and fetched for it.
|
|
1386
|
+
*/ function noteReactionDispatch(reaction) {
|
|
1387
|
+
const target = mockTargets.get(reaction);
|
|
1388
|
+
if (!target) return;
|
|
1389
|
+
dispatchedReactions.add(target);
|
|
1390
|
+
const message = mockTargets.get(reaction.message ?? {});
|
|
1391
|
+
if (message) dispatchedReactions.add(message);
|
|
1392
|
+
}
|
|
1393
|
+
function warnPlaceholderRead(target, key, strictValue) {
|
|
1394
|
+
if (quiet > 0) return;
|
|
881
1395
|
const name = Object.getPrototypeOf(target).constructor.name;
|
|
882
|
-
|
|
883
|
-
|
|
1396
|
+
if (key === 'partial' && dispatchedReactions.has(target)) {
|
|
1397
|
+
const fetched = target instanceof discord_js.MessageReaction ? 'the reaction' : "the reaction's message";
|
|
1398
|
+
controller_decorator.warnPlaceholder(mockLogger, `${name}.${key}`, 'the mock computes it as discord.js does', `MeoCord's dispatcher read it while dispatching the reaction, and fetched ${fetched} since the placeholder is truthy. ` + 'Set reaction.partial = false and reaction.message.partial = false on the mock, or call useStrictMocks() to have the mock compute them now.');
|
|
1399
|
+
return;
|
|
1400
|
+
}
|
|
1401
|
+
// The class name in camel case, a leading acronym included: dmChannel, guildMember
|
|
1402
|
+
const variable = name.replace(/^[A-Z]+(?=[A-Z][a-z])|^[A-Z]/, (start)=>start.toLowerCase());
|
|
1403
|
+
controller_decorator.warnPlaceholder(mockLogger, `${name}.${key}`, 'the mock computes it as discord.js does', `Set it on the mock, such as ${variable}.${key} = ${strictValue}, to test either way, or call useStrictMocks() to have the mock compute it now.`);
|
|
884
1404
|
}
|
|
885
1405
|
// The client of a structure made without one: the one its guild has, else one of its own, made once
|
|
886
1406
|
const ownClients = new WeakMap();
|
|
@@ -988,25 +1508,37 @@ const MANAGER_ITEMS = [
|
|
|
988
1508
|
],
|
|
989
1509
|
[
|
|
990
1510
|
discord_js.RoleManager,
|
|
991
|
-
(id)=>createMockInteraction(discord_js.Role,
|
|
992
|
-
id
|
|
993
|
-
|
|
1511
|
+
(id, manager)=>createMockInteraction(discord_js.Role, {
|
|
1512
|
+
...id ? {
|
|
1513
|
+
id
|
|
1514
|
+
} : {},
|
|
1515
|
+
...guildOf(manager) ? {
|
|
1516
|
+
guild: guildOf(manager)
|
|
1517
|
+
} : {}
|
|
1518
|
+
})
|
|
994
1519
|
],
|
|
995
1520
|
[
|
|
996
1521
|
discord_js.GuildBanManager,
|
|
997
1522
|
()=>createMockInteraction(discord_js.GuildBan)
|
|
998
1523
|
],
|
|
1524
|
+
// A message fetched from a channel is in it, and another user's
|
|
999
1525
|
[
|
|
1000
1526
|
discord_js.GuildMessageManager,
|
|
1001
|
-
(id)=>createMockMessage(
|
|
1002
|
-
id
|
|
1003
|
-
|
|
1527
|
+
(id, manager)=>createMockMessage({
|
|
1528
|
+
...id ? {
|
|
1529
|
+
id
|
|
1530
|
+
} : {},
|
|
1531
|
+
...channelOfManager(manager)
|
|
1532
|
+
})
|
|
1004
1533
|
],
|
|
1005
1534
|
[
|
|
1006
1535
|
discord_js.DMMessageManager,
|
|
1007
|
-
(id)=>createMockMessage(
|
|
1008
|
-
id
|
|
1009
|
-
|
|
1536
|
+
(id, manager)=>createMockMessage({
|
|
1537
|
+
...id ? {
|
|
1538
|
+
id
|
|
1539
|
+
} : {},
|
|
1540
|
+
...channelOfManager(manager)
|
|
1541
|
+
})
|
|
1010
1542
|
],
|
|
1011
1543
|
[
|
|
1012
1544
|
discord_js.GuildTextThreadManager,
|
|
@@ -1024,6 +1556,12 @@ const MANAGER_ITEMS = [
|
|
|
1024
1556
|
discord_js.ThreadMemberManager,
|
|
1025
1557
|
()=>createMockInteraction(discord_js.ThreadMember)
|
|
1026
1558
|
],
|
|
1559
|
+
[
|
|
1560
|
+
discord_js.ReactionUserManager,
|
|
1561
|
+
(id)=>createMockUser(id ? {
|
|
1562
|
+
id
|
|
1563
|
+
} : {})
|
|
1564
|
+
],
|
|
1027
1565
|
[
|
|
1028
1566
|
discord_js.ApplicationCommandManager,
|
|
1029
1567
|
(id)=>createMockInteraction(discord_js.ApplicationCommand, id ? {
|
|
@@ -1051,6 +1589,12 @@ const MANAGER_ITEMS = [
|
|
|
1051
1589
|
const returnsPromise = (key, method)=>method.constructor.name === 'AsyncFunction' || PROMISE_METHODS.has(key) || /^set[A-Z]/.test(key) && !SYNC_SETTERS.has(key);
|
|
1052
1590
|
// The ids a fetch asks for: an id, a discord.js object, or options naming one or several, such as `{ user: id }` or
|
|
1053
1591
|
// `{ user: [ids] }`; undefined for a fetch of a list
|
|
1592
|
+
/** The channel a message manager belongs to, as `createMockMessage` takes it. */ const channelOfManager = (manager)=>{
|
|
1593
|
+
const channel = manager.channel;
|
|
1594
|
+
return channel instanceof discord_js.BaseChannel ? {
|
|
1595
|
+
channel: channel
|
|
1596
|
+
} : {};
|
|
1597
|
+
};
|
|
1054
1598
|
function fetchedIds(args) {
|
|
1055
1599
|
const idOf = (value)=>typeof value === 'string' ? value : value instanceof discord_js.Base ? value.id : undefined;
|
|
1056
1600
|
const [first] = args;
|
|
@@ -1086,6 +1630,10 @@ function fetchedIds(args) {
|
|
|
1086
1630
|
if (key === 'send' && target instanceof discord_js.User) {
|
|
1087
1631
|
return createMockFn(async (...args)=>(await receiver().createDM()).send(...args));
|
|
1088
1632
|
}
|
|
1633
|
+
// A channel's send resolves to the message it sent there
|
|
1634
|
+
if (key === 'send' && target instanceof discord_js.BaseChannel) {
|
|
1635
|
+
return createMockFn(async (options)=>sentMessage(sent(options, false), receiver()));
|
|
1636
|
+
}
|
|
1089
1637
|
if (MESSAGE_METHODS.has(key)) return createMockFn(async ()=>createMockMessage());
|
|
1090
1638
|
if (target instanceof discord_js.BaseManager) {
|
|
1091
1639
|
const item = MANAGER_ITEMS.find(([Manager])=>Manager.prototype.isPrototypeOf(target))?.[1];
|
|
@@ -1102,7 +1650,24 @@ function fetchedIds(args) {
|
|
|
1102
1650
|
return ids === undefined ? new discord_js.Collection() : one(ids);
|
|
1103
1651
|
});
|
|
1104
1652
|
}
|
|
1105
|
-
if (item &&
|
|
1653
|
+
if (item && key === 'edit') return createMockFn(async ()=>item(undefined, receiver()));
|
|
1654
|
+
// A ban resolves to the member, user or id it was given, as discord.js's does, and caches nothing
|
|
1655
|
+
if (key === 'create' && target instanceof discord_js.GuildBanManager) {
|
|
1656
|
+
return createMockFn(async (user)=>{
|
|
1657
|
+
if (user instanceof discord_js.GuildMember) return user;
|
|
1658
|
+
if (user instanceof discord_js.User) return receiver().guild.members.resolve(user) ?? user;
|
|
1659
|
+
return typeof user === 'string' ? user : user?.id;
|
|
1660
|
+
});
|
|
1661
|
+
}
|
|
1662
|
+
// Every other manager here caches what its create() makes, as discord.js's does; check a manager's create when
|
|
1663
|
+
// adding it to MANAGER_ITEMS
|
|
1664
|
+
if (item && key === 'create') {
|
|
1665
|
+
return createMockFn(async ()=>{
|
|
1666
|
+
const made = item(undefined, receiver());
|
|
1667
|
+
if (typeof made.id === 'string') cacheOf(receiver())?.set(made.id, made);
|
|
1668
|
+
return made;
|
|
1669
|
+
});
|
|
1670
|
+
}
|
|
1106
1671
|
} else if (target instanceof discord_js.Base && (SELF_METHODS.has(key) || /^set[A-Z]/.test(key))) {
|
|
1107
1672
|
return createMockFn(async ()=>receiver());
|
|
1108
1673
|
}
|
|
@@ -1262,23 +1827,46 @@ const isSnowflake = (id)=>typeof id === 'string' && /^\d{1,20}$/.test(id);
|
|
|
1262
1827
|
});
|
|
1263
1828
|
}
|
|
1264
1829
|
}
|
|
1265
|
-
/** 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
1830
|
/**
|
|
1277
|
-
*
|
|
1278
|
-
*
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1831
|
+
* Gives a user, server or channel mock its creation time. Under strict mocks, a generated id gives the time the mock
|
|
1832
|
+
* was made, as a message's. Otherwise it gives that id's own time, a fixed day in 2025, with a warning once when read.
|
|
1833
|
+
* An id the test gives decides it in both modes, and a value the test sets replaces it.
|
|
1834
|
+
*/ function defineMadeTime(mock, generatedId) {
|
|
1835
|
+
if (strictMocks()) return defineCreatedTime(mock, generatedId);
|
|
1836
|
+
const own = (key)=>Object.prototype.hasOwnProperty.call(mock, key);
|
|
1837
|
+
const name = Object.getPrototypeOf(mock).constructor.name;
|
|
1838
|
+
if (!own('createdTimestamp')) {
|
|
1839
|
+
Object.defineProperty(mock, 'createdTimestamp', {
|
|
1840
|
+
get () {
|
|
1841
|
+
if (this.id === generatedId) {
|
|
1842
|
+
controller_decorator.warnOnce(mockLogger, `${name}.createdTimestamp reads the time of its generated id here, a fixed day in 2025; in the next major version (5.0) ` + 'it is the time the mock was made. Give the mock an id or a createdTimestamp, or call useStrictMocks() to have it read the time the mock was made now.');
|
|
1843
|
+
}
|
|
1844
|
+
return isSnowflake(this.id) ? discord_js.SnowflakeUtil.timestampFrom(this.id) : null;
|
|
1845
|
+
},
|
|
1846
|
+
configurable: true
|
|
1847
|
+
});
|
|
1848
|
+
}
|
|
1849
|
+
if (!own('createdAt')) {
|
|
1850
|
+
Object.defineProperty(mock, 'createdAt', {
|
|
1851
|
+
get () {
|
|
1852
|
+
return this.createdTimestamp === null ? null : new Date(this.createdTimestamp);
|
|
1853
|
+
},
|
|
1854
|
+
configurable: true
|
|
1855
|
+
});
|
|
1856
|
+
}
|
|
1857
|
+
}
|
|
1858
|
+
/**
|
|
1859
|
+
* Where a mock interaction keeps every answer it got, through respond() or discord.js directly, in order, under a key
|
|
1860
|
+
* both builds of this meocord version read.
|
|
1861
|
+
*/ const RESPONSE_LOG = Symbol.for(controller_decorator.sharedName('responseLog'));
|
|
1862
|
+
/**
|
|
1863
|
+
* A mock of an answer method, `impl` its behaviour, that records each call in `log` as it is made, with what it sent
|
|
1864
|
+
* and, once it settles, what it rejected with. The record wraps every behaviour the mock is given, its own and any a
|
|
1865
|
+
* test sets, so the mock stays the plain mock function a runner's matchers read, bun's included, and `gate` keeps the
|
|
1866
|
+
* reply state whichever behaviour runs. `withResponse` is how a call asks discord.js for the message back, not part of
|
|
1867
|
+
* what it sends, so it is left out.
|
|
1868
|
+
*/ function recordedMock(method, impl, log, gate) {
|
|
1869
|
+
const record = (behaviour, byTest = true)=>function(...args) {
|
|
1282
1870
|
const [payload] = args;
|
|
1283
1871
|
const sent = payload && typeof payload === 'object' && 'withResponse' in payload ? Object.fromEntries(Object.entries(payload).filter(([key])=>key !== 'withResponse')) : payload;
|
|
1284
1872
|
const call = {
|
|
@@ -1287,34 +1875,81 @@ const ANSWER_METHODS = [
|
|
|
1287
1875
|
};
|
|
1288
1876
|
controller_decorator.stampCall(call);
|
|
1289
1877
|
log.push(call);
|
|
1878
|
+
let undo;
|
|
1290
1879
|
try {
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
if (result instanceof Promise) {
|
|
1294
|
-
return result.then(undefined, (error)=>{
|
|
1295
|
-
call.error = error;
|
|
1296
|
-
throw error;
|
|
1297
|
-
});
|
|
1298
|
-
}
|
|
1299
|
-
return result;
|
|
1880
|
+
// Before the behaviour, as discord.js changes the state at once: a second answer made without awaiting is refused
|
|
1881
|
+
if (gate) undo = gate(args, byTest);
|
|
1300
1882
|
} catch (error) {
|
|
1883
|
+
call.error = error;
|
|
1884
|
+
return Promise.reject(error);
|
|
1885
|
+
}
|
|
1886
|
+
const failed = (error)=>{
|
|
1887
|
+
undo?.();
|
|
1301
1888
|
call.error = error;
|
|
1302
1889
|
throw error;
|
|
1890
|
+
};
|
|
1891
|
+
try {
|
|
1892
|
+
const result = Reflect.apply(behaviour, this, args);
|
|
1893
|
+
// Recorded and passed on, so a rejection nobody awaits is unhandled in the test, as it is on a bot
|
|
1894
|
+
if (result instanceof Promise) return result.then(undefined, failed);
|
|
1895
|
+
return result;
|
|
1896
|
+
} catch (error) {
|
|
1897
|
+
return failed(error);
|
|
1303
1898
|
}
|
|
1304
|
-
}
|
|
1305
|
-
|
|
1899
|
+
};
|
|
1900
|
+
const mock = createMockFn(record(impl, false));
|
|
1901
|
+
const always = mock.mockImplementation.bind(mock);
|
|
1902
|
+
const once = mock.mockImplementationOnce.bind(mock);
|
|
1903
|
+
// Each way a test sets a behaviour goes through the record, a runner's own extras included where it has them
|
|
1904
|
+
const setters = {
|
|
1905
|
+
mockImplementation: (fn)=>fn,
|
|
1906
|
+
mockReturnValue: (value)=>()=>value,
|
|
1907
|
+
mockResolvedValue: (value)=>()=>Promise.resolve(value),
|
|
1908
|
+
mockRejectedValue: (value)=>()=>Promise.reject(value)
|
|
1909
|
+
};
|
|
1910
|
+
// In place of the mock's own, keeping whether each is among its keys
|
|
1911
|
+
const define = (name, value)=>Object.defineProperty(mock, name, {
|
|
1912
|
+
value,
|
|
1913
|
+
writable: true,
|
|
1914
|
+
enumerable: Object.getOwnPropertyDescriptor(mock, name)?.enumerable ?? false,
|
|
1915
|
+
configurable: true
|
|
1916
|
+
});
|
|
1917
|
+
for (const [name, behaviourOf] of Object.entries(setters)){
|
|
1918
|
+
define(name, (value)=>(always(record(behaviourOf(value))), mock));
|
|
1919
|
+
define(`${name}Once`, (value)=>(once(record(behaviourOf(value))), mock));
|
|
1920
|
+
}
|
|
1921
|
+
if (typeof mock.mockReturnThis === 'function') {
|
|
1922
|
+
define('mockReturnThis', ()=>(always(record(function() {
|
|
1923
|
+
return this;
|
|
1924
|
+
})), mock));
|
|
1925
|
+
}
|
|
1926
|
+
const withImplementation = mock.withImplementation;
|
|
1927
|
+
if (typeof withImplementation === 'function') {
|
|
1928
|
+
define('withImplementation', (fn, callback)=>withImplementation.call(mock, record(fn), callback));
|
|
1929
|
+
}
|
|
1930
|
+
return mock;
|
|
1306
1931
|
}
|
|
1307
1932
|
/** The interaction a mock options resolver was given to, for the server a user option's member is in. */ const optionOwners = new WeakMap();
|
|
1933
|
+
/** An error as discord.js throws it, with its code, and with `message` in place of discord.js's own text when given. */ function discordjsError(code, message) {
|
|
1934
|
+
const error = new discord_js.DiscordjsError(code);
|
|
1935
|
+
if (message !== undefined) error.message = message;
|
|
1936
|
+
return error;
|
|
1937
|
+
}
|
|
1308
1938
|
/** 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);
|
|
1309
1939
|
// In discord.js's error codes, though missing from its typings
|
|
1310
1940
|
const INVALID_CHANNEL_TYPE = 'CommandInteractionOptionInvalidChannelType';
|
|
1311
1941
|
/** Records the interaction a mock options resolver belongs to. */ const ownOptions = (interaction, options)=>{
|
|
1312
1942
|
if (typeof options === 'object' && options !== null) optionOwners.set(options, interaction);
|
|
1313
1943
|
};
|
|
1314
|
-
/** A mock user that is a person, with an id of its own unless given one. */ const mockUser = (id
|
|
1315
|
-
|
|
1944
|
+
/** A mock user that is a person, with an id of its own unless given one. */ const mockUser = (id)=>{
|
|
1945
|
+
const generatedId = id === undefined ? nextSnowflake() : undefined;
|
|
1946
|
+
const user = Object.assign(Object.create(discord_js.User.prototype), {
|
|
1947
|
+
id: id ?? generatedId,
|
|
1316
1948
|
bot: false
|
|
1317
|
-
})
|
|
1949
|
+
});
|
|
1950
|
+
defineMadeTime(user, generatedId);
|
|
1951
|
+
return stubDeep(user);
|
|
1952
|
+
};
|
|
1318
1953
|
function createMockInteraction(Class, props) {
|
|
1319
1954
|
const instance = Object.create(Class.prototype);
|
|
1320
1955
|
const stubs = new Map();
|
|
@@ -1334,14 +1969,36 @@ function createMockInteraction(Class, props) {
|
|
|
1334
1969
|
// mock lacks: a guildId is a guild, a guild object with it a cached one, neither a DM. A member
|
|
1335
1970
|
// not given is the user's on an interaction, and the auto-stub elsewhere, so only one set to null or undefined fails.
|
|
1336
1971
|
const own = (key)=>Object.prototype.hasOwnProperty.call(instance, key) ? instance[key] : undefined;
|
|
1972
|
+
let strictPlace;
|
|
1337
1973
|
const hasMember = ()=>!Object.prototype.hasOwnProperty.call(instance, 'member') || Boolean(instance.member);
|
|
1338
1974
|
const guildChecks = {
|
|
1339
1975
|
inGuild: ()=>Boolean(own('guildId') && hasMember()),
|
|
1340
1976
|
inCachedGuild: ()=>Boolean(own('guildId') && own('guild') && hasMember()),
|
|
1341
1977
|
inRawGuild: ()=>Boolean(own('guildId') && !own('guild') && hasMember())
|
|
1342
1978
|
};
|
|
1979
|
+
// What each answers where strict mocks place the mock: the place's guildId and guild for those the test left out, and
|
|
1980
|
+
// a member wherever there is a guildId, unless the test gave one
|
|
1981
|
+
const given = new Set(Object.keys(props ?? {}));
|
|
1982
|
+
const strictChecks = (place)=>{
|
|
1983
|
+
const pick = (key, placed)=>given.has(key) || placed === undefined ? own(key) : placed;
|
|
1984
|
+
const guildId = pick('guildId', place.guildId);
|
|
1985
|
+
const guild = place.raw ? null : pick('guild', place.guild);
|
|
1986
|
+
const member = given.has('member') ? Boolean(own('member')) : Boolean(guildId);
|
|
1987
|
+
return {
|
|
1988
|
+
inGuild: Boolean(guildId && member),
|
|
1989
|
+
inCachedGuild: Boolean(guildId && guild && member),
|
|
1990
|
+
inRawGuild: Boolean(guildId && !guild && member)
|
|
1991
|
+
};
|
|
1992
|
+
};
|
|
1993
|
+
// Each reads where the mock is as its own, and in default mode warns once, by its own name, where strict mocks answer
|
|
1994
|
+
// otherwise or refuse the mock
|
|
1343
1995
|
for (const [name, check] of Object.entries(guildChecks)){
|
|
1344
|
-
if (findPrototypeMethod(instance, name)
|
|
1996
|
+
if (findPrototypeMethod(instance, name) === null) continue;
|
|
1997
|
+
stubs.set(name, createMockFn(()=>{
|
|
1998
|
+
const answer = readPlaceQuietly(check);
|
|
1999
|
+
if (strictPlace && !strictMocks() && (strictPlace.refused || strictChecks(strictPlace)[name] !== answer)) placeWarners.get(instance)?.warn(`${name}()`);
|
|
2000
|
+
return answer;
|
|
2001
|
+
}));
|
|
1345
2002
|
}
|
|
1346
2003
|
// Set up reply state machine for repliable interactions
|
|
1347
2004
|
const isRepliableMethod = findPrototypeMethod(instance, 'isRepliable');
|
|
@@ -1350,86 +2007,204 @@ function createMockInteraction(Class, props) {
|
|
|
1350
2007
|
instance.replied = false;
|
|
1351
2008
|
instance.deferred = false;
|
|
1352
2009
|
instance.ephemeral = false;
|
|
1353
|
-
|
|
1354
|
-
const
|
|
2010
|
+
// discord.js's errors, with their codes; a call before any answer keeps the message the mock has always given
|
|
2011
|
+
const alreadyReplied = ()=>discordjsError(discord_js.DiscordjsErrorCodes.InteractionAlreadyReplied);
|
|
2012
|
+
const notYetReplied = (method)=>discordjsError(discord_js.DiscordjsErrorCodes.InteractionNotReplied, `Cannot call ${method}() before replying or deferring.`);
|
|
1355
2013
|
// Only `flags` is read: discord.js deprecates the `ephemeral: true` reply option, and honouring it here would let
|
|
1356
|
-
// a test pass against a deprecated call.
|
|
2014
|
+
// a test pass against a deprecated call. Read as discord.js reads them, so a flag it refuses throws here too.
|
|
1357
2015
|
const hasEphemeralFlag = (options)=>{
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
if (typeof flags === 'bigint') return (flags & 64n) !== 0n;
|
|
1362
|
-
return false;
|
|
2016
|
+
const flags = options?.flags;
|
|
2017
|
+
if (typeof options !== 'object' || flags === null || flags === undefined) return false;
|
|
2018
|
+
return new discord_js.MessageFlagsBitField(flags).has(discord_js.MessageFlags.Ephemeral);
|
|
1363
2019
|
};
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
if (instance.deferred || instance.replied) throw alreadyReplied();
|
|
1371
|
-
instance.deferred = true;
|
|
1372
|
-
if (hasEphemeralFlag(args[0])) instance.ephemeral = true;
|
|
1373
|
-
}));
|
|
1374
|
-
stubs.set('followUp', createMockFn(async ()=>{
|
|
1375
|
-
if (!instance.deferred && !instance.replied) throw notYetReplied('followUp');
|
|
1376
|
-
instance.replied = true;
|
|
1377
|
-
return createMockMessage();
|
|
1378
|
-
}));
|
|
1379
|
-
// The interaction's own message as Discord holds it: the message a component is on, or the original response.
|
|
1380
|
-
// An edit replaces it in the shape Discord stores, with ids and resolved media, stamped with when it was edited,
|
|
1381
|
-
// and fetchReply() reads that back, so code comparing the two sees what it would against Discord.
|
|
1382
|
-
let held;
|
|
1383
|
-
const current = ()=>held ?? (held = heldFrom(instance.message));
|
|
2020
|
+
const built = (held)=>typeof held === 'function' ? held() : held;
|
|
2021
|
+
let original;
|
|
2022
|
+
let originalGone = false;
|
|
2023
|
+
const followUps = new Map();
|
|
2024
|
+
// Whether the last answer ran a behaviour a test set, after which a second answer runs with a warning by default
|
|
2025
|
+
let answeredByTest = false;
|
|
1384
2026
|
// A command answered with a modal has no original response, and Discord refuses one asked for
|
|
1385
2027
|
let modalAnswered = false;
|
|
2028
|
+
const isComponent = instance.type === discord_js.InteractionType.MessageComponent;
|
|
2029
|
+
// The original response of a component, or of a modal submitted from one, is the message it's on
|
|
2030
|
+
const componentMessage = ()=>heldFrom(instance.message);
|
|
2031
|
+
const snapshot = ()=>{
|
|
2032
|
+
const saved = {
|
|
2033
|
+
replied: instance.replied,
|
|
2034
|
+
deferred: instance.deferred,
|
|
2035
|
+
ephemeral: instance.ephemeral,
|
|
2036
|
+
original,
|
|
2037
|
+
originalGone,
|
|
2038
|
+
followUps: new Map(followUps),
|
|
2039
|
+
answeredByTest,
|
|
2040
|
+
modalAnswered
|
|
2041
|
+
};
|
|
2042
|
+
return ()=>{
|
|
2043
|
+
Object.assign(instance, {
|
|
2044
|
+
replied: saved.replied,
|
|
2045
|
+
deferred: saved.deferred,
|
|
2046
|
+
ephemeral: saved.ephemeral
|
|
2047
|
+
});
|
|
2048
|
+
({ original, originalGone, answeredByTest, modalAnswered } = saved);
|
|
2049
|
+
followUps.clear();
|
|
2050
|
+
for (const [id, held] of saved.followUps)followUps.set(id, held);
|
|
2051
|
+
};
|
|
2052
|
+
};
|
|
2053
|
+
/**
|
|
2054
|
+
* Refuses an answer as discord.js does. Where the refusal turns on a behaviour a test set, this answer's or the
|
|
2055
|
+
* one before it, which ran without one, default mode warns and lets it run; strict mocks refuse it.
|
|
2056
|
+
*/ const refuse = (method, error, byTest)=>{
|
|
2057
|
+
if (!(byTest || answeredByTest) || strictMocks()) throw error;
|
|
2058
|
+
controller_decorator.warnOnce(mockLogger, `${method}() here is refused by discord.js with "${error.message}", but the mock runs it, as a test set this ` + 'answer or the one before it. In the next major version (5.0) the mock refuses it; call useStrictMocks() to ' + 'have it refused now.');
|
|
2059
|
+
};
|
|
2060
|
+
const answered = ()=>instance.deferred || instance.replied;
|
|
2061
|
+
/**
|
|
2062
|
+
* What an answer sends, built now, as discord.js builds it when sending. One it refuses to build is refused under
|
|
2063
|
+
* strict mocks; default mode warns and holds it to build when first read.
|
|
2064
|
+
*/ const hold = (method, build)=>{
|
|
2065
|
+
try {
|
|
2066
|
+
return build();
|
|
2067
|
+
} catch (error) {
|
|
2068
|
+
if (strictMocks()) throw error;
|
|
2069
|
+
controller_decorator.warnOnce(mockLogger, `${method}() here sends what discord.js refuses to build: "${error.message}". The mock sends it and ` + 'builds it when read; in the next major version (5.0) it rejects, as discord.js does. Call useStrictMocks() to ' + 'have it rejected now.');
|
|
2070
|
+
return build;
|
|
2071
|
+
}
|
|
2072
|
+
};
|
|
2073
|
+
/** A first answer: refused once the interaction has one, else it replies or defers. */ const first = (method, kind, then)=>(args, byTest)=>{
|
|
2074
|
+
if (answered()) refuse(method, alreadyReplied(), byTest);
|
|
2075
|
+
// Read first: flags discord.js refuses throw before anything changes
|
|
2076
|
+
const ephemeral = method === 'reply' || method === 'deferReply' ? hasEphemeralFlag(args[0]) : undefined;
|
|
2077
|
+
const undo = snapshot();
|
|
2078
|
+
instance[kind] = true;
|
|
2079
|
+
if (ephemeral) instance.ephemeral = true;
|
|
2080
|
+
answeredByTest = byTest;
|
|
2081
|
+
try {
|
|
2082
|
+
then(args);
|
|
2083
|
+
} catch (error) {
|
|
2084
|
+
undo();
|
|
2085
|
+
throw error;
|
|
2086
|
+
}
|
|
2087
|
+
return undo;
|
|
2088
|
+
};
|
|
2089
|
+
/** A later answer: refused before the interaction has one. */ const later = (method, then)=>(args, byTest)=>{
|
|
2090
|
+
if (!answered()) refuse(method, notYetReplied(method), byTest);
|
|
2091
|
+
const undo = snapshot();
|
|
2092
|
+
try {
|
|
2093
|
+
then(args);
|
|
2094
|
+
} catch (error) {
|
|
2095
|
+
undo();
|
|
2096
|
+
throw error;
|
|
2097
|
+
}
|
|
2098
|
+
return undo;
|
|
2099
|
+
};
|
|
2100
|
+
/** The id an edit, fetch or delete names, `undefined` for the original response. */ const targetId = (message)=>{
|
|
2101
|
+
if (message === undefined || message === '@original') return undefined;
|
|
2102
|
+
return typeof message === 'string' ? message : message.id ?? undefined;
|
|
2103
|
+
};
|
|
2104
|
+
/** The message an edit, fetch or delete reaches, or why Discord refuses it: it is gone or never was. */ const target = (id)=>{
|
|
2105
|
+
if (id !== undefined) {
|
|
2106
|
+
const held = followUps.get(id);
|
|
2107
|
+
if (held) followUps.set(id, built(held));
|
|
2108
|
+
return held && built(held);
|
|
2109
|
+
}
|
|
2110
|
+
if (originalGone || modalAnswered) return undefined;
|
|
2111
|
+
// Answered without one of the mock's calls, as after a 40060 or a test setting `replied`: the original is there
|
|
2112
|
+
if (!original && answered()) original = componentMessage();
|
|
2113
|
+
if (original) original = built(original);
|
|
2114
|
+
// An answer's message gets its id when first read, as it would be built then without strict mocks
|
|
2115
|
+
if (original?.id === '') original = {
|
|
2116
|
+
...original,
|
|
2117
|
+
id: nextSnowflake()
|
|
2118
|
+
};
|
|
2119
|
+
return original;
|
|
2120
|
+
};
|
|
1386
2121
|
const unknownMessage = ()=>createDiscordError(10008, 'Unknown Message');
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
2122
|
+
// Where its answers are sent, read as the mock's own, so a placement warning stays the test's
|
|
2123
|
+
const answeredIn = ()=>readPlaceQuietly(()=>[
|
|
2124
|
+
instance.channel,
|
|
2125
|
+
instance.client
|
|
2126
|
+
]);
|
|
2127
|
+
// What each answer's message made for itself, by its id, so every read of it has the same author and place
|
|
2128
|
+
const madePlaces = new Map();
|
|
2129
|
+
const madeFor = (id)=>madePlaces.get(id) ?? madePlaces.set(id, {}).get(id);
|
|
2130
|
+
// Each call's message, read by its behaviour right after the gate made it
|
|
2131
|
+
let made;
|
|
2132
|
+
let followUpMade;
|
|
2133
|
+
// Every answer the interaction gets, through respond() or discord.js directly, in order, for getResponse
|
|
2134
|
+
const log = [];
|
|
2135
|
+
Object.defineProperty(instance, RESPONSE_LOG, {
|
|
2136
|
+
value: log
|
|
2137
|
+
});
|
|
2138
|
+
const answer = (method, impl, gate)=>stubs.set(method, recordedMock(method, impl, log, gate));
|
|
2139
|
+
answer('reply', async ()=>undefined, first('reply', 'replied', ([options])=>original = hold('reply', ()=>sent(options, false))));
|
|
2140
|
+
answer('deferReply', async ()=>undefined, first('deferReply', 'deferred', ()=>original = sent(undefined)));
|
|
2141
|
+
answer('followUp', // The follow-up as sent, which an edit, fetch or delete reaches by its id
|
|
2142
|
+
async ()=>{
|
|
2143
|
+
const held = followUps.get(followUpMade);
|
|
2144
|
+
// One held to build when read resolves to its id alone, as building it now would refuse it
|
|
2145
|
+
return typeof held === 'function' ? createMockMessage({
|
|
2146
|
+
id: followUpMade
|
|
2147
|
+
}) : messageFrom(held, madeFor(held.id), ...answeredIn());
|
|
2148
|
+
}, later('followUp', ([options])=>{
|
|
1390
2149
|
instance.replied = true;
|
|
1391
|
-
|
|
1392
|
-
//
|
|
1393
|
-
|
|
2150
|
+
// Held as it is when sent, as Discord stores it, so a later change to what was passed isn't seen; its id taken
|
|
2151
|
+
// once built, so one that fails to build under strict mocks takes none
|
|
2152
|
+
const held = hold('followUp', ()=>sent(options, false));
|
|
2153
|
+
const id = nextSnowflake();
|
|
2154
|
+
made = undefined;
|
|
2155
|
+
followUps.set(id, typeof held === 'function' ? ()=>({
|
|
2156
|
+
...held(),
|
|
2157
|
+
id
|
|
2158
|
+
}) : {
|
|
1394
2159
|
...held,
|
|
1395
|
-
|
|
2160
|
+
id
|
|
1396
2161
|
});
|
|
2162
|
+
followUpMade = id;
|
|
1397
2163
|
}));
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
2164
|
+
// An edit replaces the message in the shape Discord stores, with ids and resolved media, stamped with when it was
|
|
2165
|
+
// edited, and fetchReply() reads that back, so code comparing the two sees what it would against Discord
|
|
2166
|
+
answer('editReply', async ()=>{
|
|
2167
|
+
if (!made) throw unknownMessage();
|
|
2168
|
+
// The response has the message as Discord answers the edit, before its uploaded files have loaded again
|
|
2169
|
+
return messageFrom({
|
|
2170
|
+
...made,
|
|
2171
|
+
components: made.returned ?? made.components
|
|
2172
|
+
}, madeFor(made.id), ...answeredIn());
|
|
2173
|
+
}, later('editReply', ([options])=>{
|
|
2174
|
+
const id = targetId(options?.message);
|
|
2175
|
+
const held = target(id);
|
|
2176
|
+
made = held && edited(held, options);
|
|
2177
|
+
if (!made) return;
|
|
2178
|
+
if (id === undefined) {
|
|
2179
|
+
original = made;
|
|
2180
|
+
instance.replied = true;
|
|
2181
|
+
} else followUps.set(id, made);
|
|
1401
2182
|
}));
|
|
1402
|
-
stubs.set('
|
|
1403
|
-
|
|
1404
|
-
if (
|
|
2183
|
+
stubs.set('fetchReply', createMockFn(async (message)=>{
|
|
2184
|
+
const held = target(targetId(message));
|
|
2185
|
+
if (!held) throw unknownMessage();
|
|
2186
|
+
return messageFrom(held, madeFor(held.id), ...answeredIn());
|
|
2187
|
+
}));
|
|
2188
|
+
answer('deleteReply', async ()=>{
|
|
2189
|
+
if (!made) throw unknownMessage();
|
|
2190
|
+
}, later('deleteReply', ([message])=>{
|
|
2191
|
+
const id = targetId(message);
|
|
2192
|
+
made = target(id);
|
|
2193
|
+
if (!made) return;
|
|
2194
|
+
if (id === undefined) originalGone = true;
|
|
2195
|
+
else followUps.delete(id);
|
|
1405
2196
|
}));
|
|
1406
2197
|
// showModal — the first response of a command or a component, like reply()
|
|
1407
|
-
if (instance.type === discord_js.InteractionType.ApplicationCommand ||
|
|
1408
|
-
|
|
1409
|
-
if (instance.deferred || instance.replied) throw alreadyReplied();
|
|
1410
|
-
instance.replied = true;
|
|
2198
|
+
if (instance.type === discord_js.InteractionType.ApplicationCommand || isComponent) {
|
|
2199
|
+
answer('showModal', async ()=>undefined, first('showModal', 'replied', ()=>{
|
|
1411
2200
|
modalAnswered = instance.type === discord_js.InteractionType.ApplicationCommand;
|
|
2201
|
+
if (isComponent) original = componentMessage();
|
|
1412
2202
|
}));
|
|
1413
2203
|
}
|
|
1414
2204
|
// deferUpdate / update — components, and modals submitted from a message's component
|
|
1415
|
-
if (
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
instance.replied = true;
|
|
1419
|
-
}));
|
|
1420
|
-
stubs.set('deferUpdate', createMockFn(async ()=>{
|
|
1421
|
-
if (instance.deferred || instance.replied) throw alreadyReplied();
|
|
1422
|
-
instance.deferred = true;
|
|
1423
|
-
}));
|
|
1424
|
-
}
|
|
1425
|
-
// Every answer the interaction gets, through respond() or discord.js directly, in order, for getResponse
|
|
1426
|
-
const log = [];
|
|
1427
|
-
Object.defineProperty(instance, RESPONSE_LOG, {
|
|
1428
|
-
value: log
|
|
1429
|
-
});
|
|
1430
|
-
for (const method of ANSWER_METHODS){
|
|
1431
|
-
const stub = stubs.get(method);
|
|
1432
|
-
if (stub) stubs.set(method, recorded(method, stub, log));
|
|
2205
|
+
if (isComponent || instance.type === discord_js.InteractionType.ModalSubmit) {
|
|
2206
|
+
answer('update', async ()=>undefined, first('update', 'replied', ([options])=>original = hold('update', ()=>edited(heldFrom(instance.message, false), options))));
|
|
2207
|
+
answer('deferUpdate', async ()=>undefined, first('deferUpdate', 'deferred', ()=>original = componentMessage()));
|
|
1433
2208
|
}
|
|
1434
2209
|
}
|
|
1435
2210
|
// Autocomplete is not repliable, but it has a response of its own: Discord accepts
|
|
@@ -1439,7 +2214,7 @@ function createMockInteraction(Class, props) {
|
|
|
1439
2214
|
if (instance.type === discord_js.InteractionType.ApplicationCommandAutocomplete) {
|
|
1440
2215
|
instance.responded = false;
|
|
1441
2216
|
stubs.set('respond', createMockFn(async (choices)=>{
|
|
1442
|
-
if (instance.responded) throw
|
|
2217
|
+
if (instance.responded) throw discordjsError(discord_js.DiscordjsErrorCodes.InteractionAlreadyReplied);
|
|
1443
2218
|
// Discord refuses a list longer than its limit, and the menu shows nothing
|
|
1444
2219
|
if (Array.isArray(choices) && choices.length > MAX_AUTOCOMPLETE_CHOICES) {
|
|
1445
2220
|
throw createDiscordError(50035, `Invalid Form Body\ndata.choices[BASE_TYPE_MAX_LENGTH]: Must be ${MAX_AUTOCOMPLETE_CHOICES} or fewer in length.`);
|
|
@@ -1468,6 +2243,71 @@ function createMockInteraction(Class, props) {
|
|
|
1468
2243
|
const unset = (key)=>!Object.prototype.hasOwnProperty.call(instance, key);
|
|
1469
2244
|
const generatedId = unset('id') ? instance.id = nextSnowflake() : undefined;
|
|
1470
2245
|
defineCreatedTime(instance, generatedId);
|
|
2246
|
+
// Strict mocks place it where its message, guild or member is, and a guildId alone in a server the bot isn't in
|
|
2247
|
+
// Read as the mock's own, so a message's placement warning stays the test's
|
|
2248
|
+
strictPlace = readPlaceQuietly(()=>strictPlaceOf(own));
|
|
2249
|
+
if (strictPlace && strictMocks()) {
|
|
2250
|
+
if (strictPlace.refused) throw new Error(strictPlace.refused);
|
|
2251
|
+
const set = (key, value)=>Object.defineProperty(instance, key, {
|
|
2252
|
+
value,
|
|
2253
|
+
writable: true,
|
|
2254
|
+
enumerable: true,
|
|
2255
|
+
configurable: true
|
|
2256
|
+
});
|
|
2257
|
+
if (strictPlace.channel && unset('channel')) set('channel', strictPlace.channel);
|
|
2258
|
+
if (strictPlace.guildId !== undefined && unset('guildId')) set('guildId', strictPlace.guildId);
|
|
2259
|
+
if (strictPlace.guild !== undefined && unset('guild')) set('guild', strictPlace.guild);
|
|
2260
|
+
if (strictPlace.raw) {
|
|
2261
|
+
const user = own('user');
|
|
2262
|
+
const rawUser = user && {
|
|
2263
|
+
id: user.id,
|
|
2264
|
+
username: user.username,
|
|
2265
|
+
global_name: user.globalName ?? null,
|
|
2266
|
+
discriminator: user.discriminator,
|
|
2267
|
+
avatar: user.avatar ?? null
|
|
2268
|
+
};
|
|
2269
|
+
set('member', createMockRawMember(rawUser ? {
|
|
2270
|
+
user: rawUser
|
|
2271
|
+
} : {}));
|
|
2272
|
+
}
|
|
2273
|
+
}
|
|
2274
|
+
// 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
|
|
2275
|
+
const raw = own('member');
|
|
2276
|
+
// Given with a guild, a raw-shaped member is kept as the cached server's member it was before raw members
|
|
2277
|
+
if (isRawMember(raw) && own('guild')) {
|
|
2278
|
+
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.');
|
|
2279
|
+
} else if (isRawMember(raw)) {
|
|
2280
|
+
const rawOnly = "The member given is a raw member, from a server the bot isn't in";
|
|
2281
|
+
if (!own('guildId')) throw new Error(`${rawOnly}, so the mock needs that server's guildId.`);
|
|
2282
|
+
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.`);
|
|
2283
|
+
const givenUser = own('user');
|
|
2284
|
+
if (givenUser && givenUser.id !== raw.user.id) {
|
|
2285
|
+
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.`);
|
|
2286
|
+
}
|
|
2287
|
+
if (!givenUser) {
|
|
2288
|
+
const { id, username, global_name: globalName, discriminator, avatar } = raw.user;
|
|
2289
|
+
instance.user = createMockUser({
|
|
2290
|
+
id,
|
|
2291
|
+
username,
|
|
2292
|
+
globalName: globalName ?? null,
|
|
2293
|
+
discriminator,
|
|
2294
|
+
avatar: avatar ?? null
|
|
2295
|
+
});
|
|
2296
|
+
}
|
|
2297
|
+
Object.defineProperty(instance, 'guild', {
|
|
2298
|
+
value: null,
|
|
2299
|
+
writable: true,
|
|
2300
|
+
enumerable: true,
|
|
2301
|
+
configurable: true
|
|
2302
|
+
});
|
|
2303
|
+
// Only its channelId: discord.js caches no channel from a server the bot isn't in
|
|
2304
|
+
Object.defineProperty(instance, 'channel', {
|
|
2305
|
+
value: null,
|
|
2306
|
+
writable: true,
|
|
2307
|
+
enumerable: true,
|
|
2308
|
+
configurable: true
|
|
2309
|
+
});
|
|
2310
|
+
}
|
|
1471
2311
|
const userGiven = !unset('user');
|
|
1472
2312
|
if (!userGiven) instance.user = mockUser();
|
|
1473
2313
|
// Assigned once by discord.js's Base constructor, as on a message, and the gateway caches the user on it
|
|
@@ -1513,12 +2353,13 @@ function createMockInteraction(Class, props) {
|
|
|
1513
2353
|
configurable: true
|
|
1514
2354
|
});
|
|
1515
2355
|
}
|
|
1516
|
-
// The channel it came from, as the gateway caches it for an interaction:
|
|
2356
|
+
// The channel it came from, as the gateway caches it for an interaction: its server's cached one, looked up on each
|
|
2357
|
+
// read so a channel the test caches later is the one read, or the user's DM
|
|
1517
2358
|
let channel;
|
|
1518
2359
|
if (unset('channel')) {
|
|
1519
2360
|
Object.defineProperty(instance, 'channel', {
|
|
1520
2361
|
get: ()=>{
|
|
1521
|
-
if (channel === undefined) {
|
|
2362
|
+
if (channel === undefined || own('guildId') && own('guild') instanceof discord_js.Guild) {
|
|
1522
2363
|
channel = channelFor(own('guild'), own('guildId'), own('channelId'), own('user'));
|
|
1523
2364
|
cacheChannel(channel, own('guild'), own('client'));
|
|
1524
2365
|
}
|
|
@@ -1573,7 +2414,73 @@ function createMockInteraction(Class, props) {
|
|
|
1573
2414
|
live('targetMember', ()=>own('guildId') ? memberFor(own('guild'), targetUser(), true, own('guildId')) : null);
|
|
1574
2415
|
}
|
|
1575
2416
|
}
|
|
2417
|
+
// A message context menu's target: the message given, else one made for its targetId. The message is the one
|
|
2418
|
+
// source: targetId reads its id, and setting either one moves the other with it
|
|
2419
|
+
if (discord_js.MessageContextMenuCommandInteraction.prototype.isPrototypeOf(instance)) {
|
|
2420
|
+
let given = own('targetMessage');
|
|
2421
|
+
let explicitId = own('targetId');
|
|
2422
|
+
if (given && given.id === undefined) given.id = nextSnowflake();
|
|
2423
|
+
if (given && explicitId !== undefined && explicitId !== given.id) {
|
|
2424
|
+
const conflict = `The targetMessage given is message ${given.id}, but the mock's targetId is ${explicitId}: give the same id, or leave one of them out.`;
|
|
2425
|
+
if (strictMocks()) throw new Error(conflict);
|
|
2426
|
+
controller_decorator.warnOnce(mockLogger, `${conflict} The mock reads targetMessage's id; under useStrictMocks(), as in the next major version (5.0), it refuses the pair.`);
|
|
2427
|
+
explicitId = undefined;
|
|
2428
|
+
}
|
|
2429
|
+
const generated = given || explicitId !== undefined ? undefined : nextSnowflake();
|
|
2430
|
+
let made;
|
|
2431
|
+
const accessor = (key, get, set)=>Object.defineProperty(instance, key, {
|
|
2432
|
+
get,
|
|
2433
|
+
set,
|
|
2434
|
+
enumerable: true,
|
|
2435
|
+
configurable: true
|
|
2436
|
+
});
|
|
2437
|
+
const targetId = ()=>explicitId ?? given?.id ?? generated;
|
|
2438
|
+
accessor('targetId', targetId, (value)=>{
|
|
2439
|
+
explicitId = value;
|
|
2440
|
+
given = undefined;
|
|
2441
|
+
});
|
|
2442
|
+
// In the channel the menu was used in, and its server, as the message a menu is used on is: its channel is the
|
|
2443
|
+
// menu's, read live, so making the message makes and caches no channel. Read as the mock's own, so a placement
|
|
2444
|
+
// warning stays the test's
|
|
2445
|
+
const makeTarget = ()=>readPlaceQuietly(()=>{
|
|
2446
|
+
const [guild, guildId] = [
|
|
2447
|
+
own('guild'),
|
|
2448
|
+
own('guildId') ?? null
|
|
2449
|
+
];
|
|
2450
|
+
const message = messageMock({
|
|
2451
|
+
id: targetId(),
|
|
2452
|
+
guild: guildId && guild instanceof discord_js.Guild ? guild : null,
|
|
2453
|
+
client: instance.client
|
|
2454
|
+
}, undefined, {}, {
|
|
2455
|
+
channelId: own('channelId'),
|
|
2456
|
+
guildId,
|
|
2457
|
+
channel: ()=>readPlaceQuietly(()=>instance.channel)
|
|
2458
|
+
});
|
|
2459
|
+
// Where strict mocks place the menu elsewhere, its target's place warns as the menu's does, by the target's name
|
|
2460
|
+
const warner = placeWarners.get(instance);
|
|
2461
|
+
const target = mockTargets.get(message) ?? message;
|
|
2462
|
+
for (const key of warner?.keys ?? [])warnOnRead(target, key, ()=>readingPlace > 0 ? void placeReadsSeen?.add(key) : warner.warn(`targetMessage.${key}`));
|
|
2463
|
+
return message;
|
|
2464
|
+
});
|
|
2465
|
+
accessor('targetMessage', ()=>given ?? (made?.id === targetId() ? made : made = makeTarget()), (value)=>{
|
|
2466
|
+
given = value;
|
|
2467
|
+
if (given && given.id === undefined) given.id = nextSnowflake();
|
|
2468
|
+
explicitId = undefined;
|
|
2469
|
+
});
|
|
2470
|
+
}
|
|
2471
|
+
}
|
|
2472
|
+
// A user has an id of its own, as Discord sends one, and was created when the mock was made under strict mocks
|
|
2473
|
+
if (discord_js.User.prototype.isPrototypeOf(instance) && !Object.prototype.hasOwnProperty.call(instance, 'id')) {
|
|
2474
|
+
const generatedId = instance.id = nextSnowflake();
|
|
2475
|
+
defineMadeTime(instance, generatedId);
|
|
2476
|
+
}
|
|
2477
|
+
// A role too, created when the mock was made in both modes
|
|
2478
|
+
if (discord_js.Role.prototype.isPrototypeOf(instance) && !Object.prototype.hasOwnProperty.call(instance, 'id')) {
|
|
2479
|
+
const generatedId = instance.id = nextSnowflake();
|
|
2480
|
+
defineCreatedTime(instance, generatedId);
|
|
1576
2481
|
}
|
|
2482
|
+
// An attachment too, from the same count as every mock's
|
|
2483
|
+
if (discord_js.Attachment.prototype.isPrototypeOf(instance) && !Object.prototype.hasOwnProperty.call(instance, 'id')) instance.id = nextSnowflake();
|
|
1577
2484
|
// Discord sends the user's locale with every interaction, and the server's preferred one with one made in a server
|
|
1578
2485
|
if (discord_js.BaseInteraction.prototype.isPrototypeOf(instance)) {
|
|
1579
2486
|
if (own('locale') === undefined) instance.locale = discord_js.Locale.EnglishUS;
|
|
@@ -1597,6 +2504,32 @@ function createMockInteraction(Class, props) {
|
|
|
1597
2504
|
}
|
|
1598
2505
|
for (const kind of choices)if (!Object.prototype.hasOwnProperty.call(instance, kind)) instance[kind] = new discord_js.Collection();
|
|
1599
2506
|
}
|
|
2507
|
+
// In default mode, where strict mocks place it elsewhere, reading where it is warns
|
|
2508
|
+
if (strictPlace && !strictMocks()) warnOnPlaceReads(instance, strictPlace, new Set(Object.keys(props ?? {})));
|
|
2509
|
+
// A message whose place the mock made moves to where the test placed the interaction under strict mocks, and is then
|
|
2510
|
+
// one the test placed; in default mode it stays, and reading its place warns
|
|
2511
|
+
const message = own('message');
|
|
2512
|
+
const placed = [
|
|
2513
|
+
'channel',
|
|
2514
|
+
'guild',
|
|
2515
|
+
'guildId'
|
|
2516
|
+
].some((key)=>Object.keys(props ?? {}).includes(key));
|
|
2517
|
+
if (discord_js.BaseInteraction.prototype.isPrototypeOf(instance) && placed && typeof message === 'object' && message !== null && placeWasMade(message)) {
|
|
2518
|
+
if (strictMocks()) {
|
|
2519
|
+
const guild = own('guild');
|
|
2520
|
+
const channel = instance.channel;
|
|
2521
|
+
Object.assign(message, {
|
|
2522
|
+
guild: guild ?? null,
|
|
2523
|
+
guildId: own('guildId') ?? null,
|
|
2524
|
+
...channel && {
|
|
2525
|
+
channel,
|
|
2526
|
+
channelId: channel.id
|
|
2527
|
+
}
|
|
2528
|
+
});
|
|
2529
|
+
if (guild) memberFor(guild, message.author, true);
|
|
2530
|
+
placesMade.delete(mockTargets.get(message) ?? message);
|
|
2531
|
+
} else warnOnMadePlaceReads(mockTargets.get(message) ?? message);
|
|
2532
|
+
}
|
|
1600
2533
|
// Options assigned after creation, directly or through Object.assign, belong to it as those given do
|
|
1601
2534
|
return stubDeep(instance, stubs, (prop, value)=>{
|
|
1602
2535
|
if (prop === 'options') ownOptions(instance, value);
|
|
@@ -1608,31 +2541,36 @@ function createMockInteraction(Class, props) {
|
|
|
1608
2541
|
/**
|
|
1609
2542
|
* A mock function that answers property access with another one, so `cache.store.flush()` works
|
|
1610
2543
|
* on a double whose shape is an interface with nothing at runtime to read it from.
|
|
1611
|
-
*/ function stubCallable() {
|
|
2544
|
+
*/ function stubCallable(member) {
|
|
1612
2545
|
const fn = createMockFn();
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
2546
|
+
// jest's mock reads `_protoImpl` from itself whenever it's called, so the prototype below must not answer it
|
|
2547
|
+
if (usesRunnerMockFn()) Object.defineProperty(fn, '_protoImpl', {
|
|
2548
|
+
value: undefined,
|
|
2549
|
+
writable: true,
|
|
2550
|
+
enumerable: false,
|
|
2551
|
+
configurable: true
|
|
2552
|
+
});
|
|
2553
|
+
// Any property beyond the mock's own API is a nested mock, made on first read and kept on the function, out of its
|
|
2554
|
+
// keys. Its prototype makes them rather than a Proxy around it, so it stays the plain mock function matchers read
|
|
2555
|
+
const inherited = Object.getPrototypeOf(fn);
|
|
2556
|
+
Object.setPrototypeOf(fn, new Proxy(inherited, {
|
|
2557
|
+
get (proto, prop, receiver) {
|
|
2558
|
+
if (typeof prop === 'symbol' || prop in proto) return Reflect.get(proto, prop, receiver);
|
|
1618
2559
|
// Never thenable — otherwise awaiting a mock hangs on itself
|
|
1619
|
-
if (
|
|
1620
|
-
//
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
},
|
|
1626
|
-
set (target, prop, value) {
|
|
1627
|
-
Object.defineProperty(target, prop, {
|
|
1628
|
-
value,
|
|
2560
|
+
if (prop === 'then') return undefined;
|
|
2561
|
+
// jest's matchers take a function whose `calls` has `all` and `count` for a jasmine spy, read before `mock.calls`
|
|
2562
|
+
if (member === 'calls' && (prop === 'all' || prop === 'count')) return undefined;
|
|
2563
|
+
const nested = stubCallable(prop);
|
|
2564
|
+
Object.defineProperty(receiver, prop, {
|
|
2565
|
+
value: nested,
|
|
1629
2566
|
writable: true,
|
|
1630
|
-
enumerable:
|
|
2567
|
+
enumerable: false,
|
|
1631
2568
|
configurable: true
|
|
1632
2569
|
});
|
|
1633
|
-
return
|
|
2570
|
+
return nested;
|
|
1634
2571
|
}
|
|
1635
|
-
});
|
|
2572
|
+
}));
|
|
2573
|
+
return fn;
|
|
1636
2574
|
}
|
|
1637
2575
|
/**
|
|
1638
2576
|
* Creates a mock of any type, with no class needed, for service doubles and interfaces.
|
|
@@ -1687,6 +2625,7 @@ function createMockInteraction(Class, props) {
|
|
|
1687
2625
|
if (key === 'then') return undefined;
|
|
1688
2626
|
// An explicitly supplied prop wins over the auto-stub.
|
|
1689
2627
|
if (Object.prototype.hasOwnProperty.call(instance, key)) return Reflect.get(instance, prop, instance);
|
|
2628
|
+
// The double itself is no function a matcher reads, so a `calls` member here keeps its `all` and `count`
|
|
1690
2629
|
if (!stubs.has(key)) stubs.set(key, stubCallable());
|
|
1691
2630
|
return stubs.get(key);
|
|
1692
2631
|
},
|
|
@@ -1726,11 +2665,12 @@ function createMockInteraction(Class, props) {
|
|
|
1726
2665
|
* @group Testing
|
|
1727
2666
|
* @category Mocks
|
|
1728
2667
|
* @see {@link createMockInteraction}
|
|
1729
|
-
*/
|
|
1730
|
-
|
|
2668
|
+
*/ function createMockUser(props = {}) {
|
|
2669
|
+
return createMockInteraction(discord_js.User, {
|
|
1731
2670
|
bot: false,
|
|
1732
2671
|
...props
|
|
1733
2672
|
});
|
|
2673
|
+
}
|
|
1734
2674
|
/**
|
|
1735
2675
|
* Creates a mock {@link Client}, with `users`, `channels`, `guilds` and `application.commands` ready to stub.
|
|
1736
2676
|
*
|
|
@@ -1844,6 +2784,18 @@ const HOLDS = [
|
|
|
1844
2784
|
[
|
|
1845
2785
|
discord_js.PermissionOverwriteManager.prototype,
|
|
1846
2786
|
discord_js.PermissionOverwrites
|
|
2787
|
+
],
|
|
2788
|
+
[
|
|
2789
|
+
discord_js.ReactionManager.prototype,
|
|
2790
|
+
discord_js.MessageReaction
|
|
2791
|
+
],
|
|
2792
|
+
[
|
|
2793
|
+
discord_js.ReactionUserManager.prototype,
|
|
2794
|
+
discord_js.User
|
|
2795
|
+
],
|
|
2796
|
+
[
|
|
2797
|
+
discord_js.PresenceManager.prototype,
|
|
2798
|
+
discord_js.Presence
|
|
1847
2799
|
]
|
|
1848
2800
|
];
|
|
1849
2801
|
/**
|
|
@@ -1859,6 +2811,11 @@ const HOLDS = [
|
|
|
1859
2811
|
value: cache,
|
|
1860
2812
|
writable: true
|
|
1861
2813
|
});
|
|
2814
|
+
// The same collection where discord.js's own methods read it past a subclass's `cache`, as `super.cache` does
|
|
2815
|
+
Object.defineProperty(manager, '_cache', {
|
|
2816
|
+
value: cache,
|
|
2817
|
+
writable: true
|
|
2818
|
+
});
|
|
1862
2819
|
const holds = HOLDS.find(([Manager])=>Manager === prototype || Object.prototype.isPrototypeOf.call(Manager, prototype))?.[1];
|
|
1863
2820
|
if (holds) Object.defineProperty(manager, 'holds', {
|
|
1864
2821
|
value: holds
|
|
@@ -1866,14 +2823,25 @@ const HOLDS = [
|
|
|
1866
2823
|
let own;
|
|
1867
2824
|
Object.defineProperty(manager, 'client', {
|
|
1868
2825
|
get: ()=>{
|
|
1869
|
-
const owner = manager.guild ?? manager.channel ?? manager.thread;
|
|
2826
|
+
const owner = manager.guild ?? manager.channel ?? manager.thread ?? manager.message;
|
|
1870
2827
|
return owner instanceof discord_js.Base ? owner.client : own ?? (own = createMockClient());
|
|
1871
2828
|
},
|
|
1872
|
-
|
|
2829
|
+
// A client the test sets is the one read from then on, as on any other mock
|
|
2830
|
+
set: (value)=>Object.defineProperty(manager, 'client', {
|
|
2831
|
+
value,
|
|
2832
|
+
writable: true,
|
|
2833
|
+
enumerable: true,
|
|
2834
|
+
configurable: true
|
|
2835
|
+
}),
|
|
1873
2836
|
configurable: true
|
|
1874
2837
|
});
|
|
1875
2838
|
return stubDeep(manager);
|
|
1876
2839
|
}
|
|
2840
|
+
/** An empty manager of `prototype` that `owner` names its own, such as `{ message }` for a message's reactions. */ function ownedManager(prototype, owner) {
|
|
2841
|
+
const manager = managerWith(prototype, undefined);
|
|
2842
|
+
Object.assign(manager, owner);
|
|
2843
|
+
return manager;
|
|
2844
|
+
}
|
|
1877
2845
|
/**
|
|
1878
2846
|
* Creates a mock {@link Guild}, with the `members`, `channels`, `roles` and `bans` managers ready to stub.
|
|
1879
2847
|
*
|
|
@@ -1910,8 +2878,9 @@ const HOLDS = [
|
|
|
1910
2878
|
* @see {@link createMockMessage}
|
|
1911
2879
|
*/ function createMockGuild(overrides = {}) {
|
|
1912
2880
|
const instance = Object.create(discord_js.Guild.prototype);
|
|
1913
|
-
|
|
1914
|
-
|
|
2881
|
+
const generatedId = overrides.id === undefined ? nextSnowflake() : undefined;
|
|
2882
|
+
instance.id = overrides.id ?? generatedId;
|
|
2883
|
+
defineMadeTime(instance, generatedId);
|
|
1915
2884
|
if (overrides.name !== undefined) instance.name = overrides.name;
|
|
1916
2885
|
if (overrides.preferredLocale !== undefined) instance.preferredLocale = overrides.preferredLocale;
|
|
1917
2886
|
instance.members = managerWith(discord_js.GuildMemberManager.prototype, overrides.members);
|
|
@@ -1956,7 +2925,8 @@ const HOLDS = [
|
|
|
1956
2925
|
* @see {@link createMockGuild}
|
|
1957
2926
|
*/ function createMockChannel(Class, props = {}) {
|
|
1958
2927
|
const instance = Object.create(Class.prototype);
|
|
1959
|
-
|
|
2928
|
+
const generatedId = props.id === undefined ? nextSnowflake() : undefined;
|
|
2929
|
+
instance.id = generatedId;
|
|
1960
2930
|
const is = (Base)=>Base.prototype.isPrototypeOf(Class.prototype) || Class === Base;
|
|
1961
2931
|
// Text and announcement channels: messages, and threads made in the channel
|
|
1962
2932
|
if (is(discord_js.TextChannel) || is(discord_js.NewsChannel)) {
|
|
@@ -1984,6 +2954,7 @@ const HOLDS = [
|
|
|
1984
2954
|
// A server's channel has its overwrites, none until a test puts some in the cache, which its permissions read
|
|
1985
2955
|
if (is(discord_js.GuildChannel) && !is(discord_js.ThreadChannel)) instance.permissionOverwrites = managerWith(discord_js.PermissionOverwriteManager.prototype, undefined);
|
|
1986
2956
|
Object.assign(instance, props);
|
|
2957
|
+
defineMadeTime(instance, generatedId);
|
|
1987
2958
|
const stubs = new Map();
|
|
1988
2959
|
const channel = stubDeep(instance, stubs);
|
|
1989
2960
|
// Each manager knows its channel, as discord.js's do, and a thread's members their thread
|
|
@@ -2108,11 +3079,25 @@ const CHANNEL_TYPE_GUARDS = [
|
|
|
2108
3079
|
});
|
|
2109
3080
|
return stubDeep(manager);
|
|
2110
3081
|
}
|
|
2111
|
-
/**
|
|
3082
|
+
/** The permissions Discord gives a server's @everyone role in a server made today. */ const NEW_SERVER_EVERYONE_PERMISSIONS = 2248473465835073n;
|
|
3083
|
+
// The @everyone roles default mode made with no permissions, where strict mocks give a new server's
|
|
3084
|
+
const madeEveryones = new WeakSet();
|
|
3085
|
+
/** Whether `role` is an @everyone role default mode made, whose permissions the test hasn't set. */ const isUnsetEveryone = (role)=>madeEveryones.has(role) && !Object.prototype.hasOwnProperty.call(role, 'permissions');
|
|
3086
|
+
// A server's @everyone role, which has the server's id and ranks lowest, at position 0; with strict mocks, the
|
|
3087
|
+
// permissions Discord gives it in a new server
|
|
3088
|
+
function everyoneRole(guildId) {
|
|
3089
|
+
const strict = strictMocks();
|
|
3090
|
+
const role = createMockInteraction(discord_js.Role, {
|
|
2112
3091
|
id: guildId,
|
|
2113
3092
|
name: '@everyone',
|
|
2114
|
-
position: 0
|
|
3093
|
+
position: 0,
|
|
3094
|
+
...strict ? {
|
|
3095
|
+
permissions: new discord_js.PermissionsBitField(NEW_SERVER_EVERYONE_PERMISSIONS).freeze()
|
|
3096
|
+
} : {}
|
|
2115
3097
|
});
|
|
3098
|
+
if (!strict) madeEveryones.add(role);
|
|
3099
|
+
return role;
|
|
3100
|
+
}
|
|
2116
3101
|
/**
|
|
2117
3102
|
* A guild's role manager with `roles` in its cache, and the guild's @everyone role: the one given with the guild's id,
|
|
2118
3103
|
* or a role of its own at position 0, as every server has one.
|
|
@@ -2139,6 +3124,68 @@ const CHANNEL_TYPE_GUARDS = [
|
|
|
2139
3124
|
});
|
|
2140
3125
|
return manager;
|
|
2141
3126
|
}
|
|
3127
|
+
/**
|
|
3128
|
+
* Creates the member Discord sends with an interaction from a server the bot isn't in, as discord.js keeps it.
|
|
3129
|
+
*
|
|
3130
|
+
* Give it as an interaction's `member`, with the server's `guildId` and no `guild`, to test a user-installed command
|
|
3131
|
+
* run there: the interaction reads `inRawGuild()` true, `guild` and `channel` null, `user` the member's user and
|
|
3132
|
+
* `memberPermissions` the member's, and a user option's member is the plain member Discord resolves too.
|
|
3133
|
+
*
|
|
3134
|
+
* @remarks
|
|
3135
|
+
* It is plain data, not a `GuildMember`: `roles` are role ids, `permissions` is the permission bitfield as a string,
|
|
3136
|
+
* and the rest are as Discord sends them, `null` for what may be absent and `false` for a flag. Code that reads
|
|
3137
|
+
* `member.roles.cache` throws on it, as it does in Discord.
|
|
3138
|
+
*
|
|
3139
|
+
* @param overrides - Fields for the member; see {@link MockRawMemberOverrides}.
|
|
3140
|
+
* @returns The member, as an `APIInteractionGuildMember`.
|
|
3141
|
+
*
|
|
3142
|
+
* @example
|
|
3143
|
+
* ```ts
|
|
3144
|
+
* import { expect } from 'vitest'
|
|
3145
|
+
*
|
|
3146
|
+
* const member = createMockRawMember({ roles: ['300000000000000001'], permissions: [PermissionFlagsBits.SendMessages] })
|
|
3147
|
+
* const command = createMockInteraction(ChatInputCommandInteraction, { guildId: '100000000000000001', member })
|
|
3148
|
+
* expect(command.inRawGuild()).toBe(true)
|
|
3149
|
+
* ```
|
|
3150
|
+
*
|
|
3151
|
+
* @group Testing
|
|
3152
|
+
* @category Mocks
|
|
3153
|
+
* @see {@link createMockMember}
|
|
3154
|
+
*/ function createMockRawMember(overrides = {}) {
|
|
3155
|
+
const { permissions, user, ...given } = overrides;
|
|
3156
|
+
return {
|
|
3157
|
+
user: {
|
|
3158
|
+
id: nextSnowflake(),
|
|
3159
|
+
username: 'user',
|
|
3160
|
+
discriminator: '0',
|
|
3161
|
+
global_name: null,
|
|
3162
|
+
avatar: null,
|
|
3163
|
+
...user
|
|
3164
|
+
},
|
|
3165
|
+
roles: [],
|
|
3166
|
+
nick: null,
|
|
3167
|
+
avatar: null,
|
|
3168
|
+
banner: null,
|
|
3169
|
+
premium_since: null,
|
|
3170
|
+
communication_disabled_until: null,
|
|
3171
|
+
joined_at: new Date().toISOString(),
|
|
3172
|
+
deaf: false,
|
|
3173
|
+
mute: false,
|
|
3174
|
+
pending: false,
|
|
3175
|
+
flags: 0,
|
|
3176
|
+
...given,
|
|
3177
|
+
permissions: String(new discord_js.PermissionsBitField(permissions ?? NEW_SERVER_EVERYONE_PERMISSIONS).bitfield)
|
|
3178
|
+
};
|
|
3179
|
+
}
|
|
3180
|
+
/** Whether `value` is a member Discord sends from a server the bot isn't in: plain data, not a `GuildMember`. */ function isRawMember(value) {
|
|
3181
|
+
if (typeof value !== 'object' || value === null || value instanceof discord_js.GuildMember) return false;
|
|
3182
|
+
const { roles, permissions, user } = value;
|
|
3183
|
+
return Array.isArray(roles) && typeof permissions === 'string' && typeof user === 'object' && user !== null;
|
|
3184
|
+
}
|
|
3185
|
+
/** A user option's member as Discord resolves it with an interaction from a server the bot isn't in. */ function rawResolvedMember() {
|
|
3186
|
+
const { user: _user, deaf: _deaf, mute: _mute, ...resolved } = createMockRawMember();
|
|
3187
|
+
return resolved;
|
|
3188
|
+
}
|
|
2142
3189
|
/**
|
|
2143
3190
|
* Creates a mock {@link GuildMember}: a user in a server, with the roles given.
|
|
2144
3191
|
*
|
|
@@ -2150,8 +3197,9 @@ const CHANNEL_TYPE_GUARDS = [
|
|
|
2150
3197
|
* highest, by position, then the lower id. `permissions` are computed as discord.js computes them: every permission for
|
|
2151
3198
|
* the server's owner, and otherwise its roles' permissions combined, @everyone's included, so a member with no roles has
|
|
2152
3199
|
* @everyone's alone. The member is put in its guild's member cache, so an interaction or a message from its user in that
|
|
2153
|
-
* server has it as its `member`, and `memberPermissions` are its permissions
|
|
2154
|
-
* user's `send()` and the user's
|
|
3200
|
+
* server has it as its `member`, and `memberPermissions` are its permissions, with the interaction's channel's
|
|
3201
|
+
* overwrites applied under `useStrictMocks()`. A DM sent to the member goes through its user's `send()` and the user's
|
|
3202
|
+
* one DM channel.
|
|
2155
3203
|
*
|
|
2156
3204
|
* @param overrides - The member's user, server, roles and nickname; see {@link MockMemberOverrides}.
|
|
2157
3205
|
* @returns The mock member.
|
|
@@ -2198,6 +3246,218 @@ const CHANNEL_TYPE_GUARDS = [
|
|
|
2198
3246
|
return guild ? cached(cacheOf(guild.channels), channelId, make) : make();
|
|
2199
3247
|
}
|
|
2200
3248
|
/** A server's id as a refusal names it, or a DM. */ const placeName = (guildId)=>guildId === null ? 'a DM' : `server ${guildId}`;
|
|
3249
|
+
/**
|
|
3250
|
+
* Where strict mocks place an interaction: in its message's channel and server, in a guild's or a member's server, and
|
|
3251
|
+
* with a guildId alone in a server the bot isn't in. Undefined where a channel given, or nothing, places it.
|
|
3252
|
+
*/ // Mock messages whose channel and server the mock made, as neither was given
|
|
3253
|
+
const placesMade = new WeakSet();
|
|
3254
|
+
// Messages whose place reads already warn, so a message reused for several interactions is wrapped once
|
|
3255
|
+
const madePlaceWarned = new WeakSet();
|
|
3256
|
+
/**
|
|
3257
|
+
* Has a message whose place the mock made warn once per key when its place is read, in default mode, where strict mocks
|
|
3258
|
+
* move it to the interaction it was given to. A read made while reading another of these, or by the mock, stays quiet.
|
|
3259
|
+
*/ function warnOnMadePlaceReads(message) {
|
|
3260
|
+
if (madePlaceWarned.has(message)) return;
|
|
3261
|
+
madePlaceWarned.add(message);
|
|
3262
|
+
for (const key of [
|
|
3263
|
+
'guild',
|
|
3264
|
+
'guildId',
|
|
3265
|
+
'channel',
|
|
3266
|
+
'channelId',
|
|
3267
|
+
'member'
|
|
3268
|
+
]){
|
|
3269
|
+
const descriptor = Object.getOwnPropertyDescriptor(message, key);
|
|
3270
|
+
if (!descriptor) continue;
|
|
3271
|
+
let value = descriptor.value;
|
|
3272
|
+
Object.defineProperty(message, key, {
|
|
3273
|
+
get: ()=>{
|
|
3274
|
+
if (readingPlace === 0) {
|
|
3275
|
+
controller_decorator.warnOnce(mockLogger, `Message.${key} reads the place createMockMessage() made for this message here, though the interaction it was ` + "given to is placed elsewhere; in the next major version (5.0) the message moves to the interaction's place. " + 'Give the message a channel or guild, or call useStrictMocks() to move it now.');
|
|
3276
|
+
}
|
|
3277
|
+
readingPlace++;
|
|
3278
|
+
try {
|
|
3279
|
+
return descriptor.get ? descriptor.get.call(message) : value;
|
|
3280
|
+
} finally{
|
|
3281
|
+
readingPlace--;
|
|
3282
|
+
}
|
|
3283
|
+
},
|
|
3284
|
+
set: (next)=>{
|
|
3285
|
+
if (descriptor.set) descriptor.set.call(message, next);
|
|
3286
|
+
else value = next;
|
|
3287
|
+
},
|
|
3288
|
+
enumerable: true,
|
|
3289
|
+
configurable: true
|
|
3290
|
+
});
|
|
3291
|
+
}
|
|
3292
|
+
}
|
|
3293
|
+
// The DM channel each message the bot sent in a DM in default mode was sent in, where strict mocks place it
|
|
3294
|
+
const sentInDm = new WeakMap();
|
|
3295
|
+
/** Whether a message is one whose channel and server the mock made, which a test placing an interaction overrides. */ const placeWasMade = (message)=>placesMade.has(mockTargets.get(message) ?? message);
|
|
3296
|
+
function strictPlaceOf(own) {
|
|
3297
|
+
const message = own('message');
|
|
3298
|
+
const channel = own('channel');
|
|
3299
|
+
const guild = own('guild');
|
|
3300
|
+
const guildId = own('guildId');
|
|
3301
|
+
const member = own('member');
|
|
3302
|
+
// A message whose place the mock made has none a test meant, so where the test placed the interaction wins
|
|
3303
|
+
const placed = channel !== undefined || guild !== undefined || guildId !== undefined;
|
|
3304
|
+
if (typeof message === 'object' && message !== null && typeof message.channelId === 'string' && !(placed && placeWasMade(message))) {
|
|
3305
|
+
// One the bot sent in a DM is in that DM, as strict mocks place it, though default mode reads a server for it
|
|
3306
|
+
const dm = sentInDm.get(mockTargets.get(message) ?? message);
|
|
3307
|
+
const channelId = dm ? dm.id : message.channelId;
|
|
3308
|
+
const at = dm ? null : message.guildId ?? null;
|
|
3309
|
+
const refused = channel && channel.id !== channelId ? `The message given is in channel ${channelId}, but the mock's channel is ${channel.id}: give the message's channel, or leave one of them out.` : guild !== undefined && (guild?.id ?? null) !== at ? `The message given is in ${placeName(at)}, but the mock's guild is ${placeName(guild?.id ?? null)}: give the message's guild, or leave one of them out.` : guildId !== undefined && guildId !== at ? `The message given is in ${placeName(at)}, but the mock's guildId is ${placeName(guildId)}: give the message's guildId, or leave one of them out.` : undefined;
|
|
3310
|
+
return {
|
|
3311
|
+
given: 'message',
|
|
3312
|
+
channel: dm ?? message.channel,
|
|
3313
|
+
guildId: at,
|
|
3314
|
+
guild: dm ? null : message.guild ?? null,
|
|
3315
|
+
refused
|
|
3316
|
+
};
|
|
3317
|
+
}
|
|
3318
|
+
if (guild instanceof discord_js.Guild && guildId === undefined) return {
|
|
3319
|
+
given: 'guild',
|
|
3320
|
+
guildId: guild.id
|
|
3321
|
+
};
|
|
3322
|
+
if (member instanceof discord_js.GuildMember && member.guild instanceof discord_js.Guild && guild === undefined && guildId === undefined) {
|
|
3323
|
+
return {
|
|
3324
|
+
given: 'member',
|
|
3325
|
+
guild: member.guild,
|
|
3326
|
+
guildId: member.guild.id
|
|
3327
|
+
};
|
|
3328
|
+
}
|
|
3329
|
+
if (typeof guildId === 'string' && guild === undefined && channel === undefined && !(member instanceof discord_js.GuildMember) && !isRawMember(member)) {
|
|
3330
|
+
return {
|
|
3331
|
+
given: 'guildId',
|
|
3332
|
+
raw: true
|
|
3333
|
+
};
|
|
3334
|
+
}
|
|
3335
|
+
return undefined;
|
|
3336
|
+
}
|
|
3337
|
+
// Set while one of the reads below runs, so what it reads in turn doesn't warn again
|
|
3338
|
+
let readingPlace = 0;
|
|
3339
|
+
/** Runs `read` as the mock's own read of where a mock is, which warns about no placement. */ function readPlaceQuietly(read) {
|
|
3340
|
+
readingPlace++;
|
|
3341
|
+
try {
|
|
3342
|
+
return read();
|
|
3343
|
+
} finally{
|
|
3344
|
+
readingPlace--;
|
|
3345
|
+
}
|
|
3346
|
+
}
|
|
3347
|
+
// Each interaction's strict place and its warning, by the read it names, for the mock's own reads that warn as the test's
|
|
3348
|
+
const placeWarners = new WeakMap();
|
|
3349
|
+
// The place keys a read of the mock's own read while it runs, so it warns by its own name where one would warn
|
|
3350
|
+
let placeReadsSeen;
|
|
3351
|
+
/**
|
|
3352
|
+
* Runs `read`, a value of `mock`'s own that reads where the mock is, quietly; in default mode it warns once, as `key`,
|
|
3353
|
+
* where one of those reads would warn, since strict mocks place the mock otherwise, unless `differs` says the value is
|
|
3354
|
+
* the same in that place.
|
|
3355
|
+
*/ function readPlaceAs(mock, key, read, differs = ()=>true) {
|
|
3356
|
+
const outer = placeReadsSeen;
|
|
3357
|
+
const seen = placeReadsSeen = new Set();
|
|
3358
|
+
try {
|
|
3359
|
+
return readPlaceQuietly(read);
|
|
3360
|
+
} finally{
|
|
3361
|
+
placeReadsSeen = outer;
|
|
3362
|
+
const warner = placeWarners.get(mockTargets.get(mock) ?? mock);
|
|
3363
|
+
if (seen.size > 0 && warner && differs(warner.place)) warner.warn(key);
|
|
3364
|
+
}
|
|
3365
|
+
}
|
|
3366
|
+
/** What reading `key`, where strict mocks place an interaction otherwise, warns: what it reads now, and in 5.0. */ function placeWarning(name, key, place) {
|
|
3367
|
+
if (place.refused) {
|
|
3368
|
+
return `${name}.${key} reads where the mock's channel, guild or guildId put it, not where its message is; under useStrictMocks(), as in the next major version (5.0), the mock is refused: ${place.refused}`;
|
|
3369
|
+
}
|
|
3370
|
+
const why = {
|
|
3371
|
+
message: [
|
|
3372
|
+
'a place other than its message\'s',
|
|
3373
|
+
"the mock is in its message's channel and server",
|
|
3374
|
+
"Give it the message's channel, or call useStrictMocks() to place it there now."
|
|
3375
|
+
],
|
|
3376
|
+
guild: [
|
|
3377
|
+
"a DM's value, though the mock was given a guild",
|
|
3378
|
+
'the mock is in that guild',
|
|
3379
|
+
'Give its guildId or one of its channels too, or call useStrictMocks() to place it there now.'
|
|
3380
|
+
],
|
|
3381
|
+
member: [
|
|
3382
|
+
"a DM's value, though the mock was given a member of a server",
|
|
3383
|
+
"the mock is in the member's server",
|
|
3384
|
+
"Give the member's guild too, or call useStrictMocks() to place it there now."
|
|
3385
|
+
],
|
|
3386
|
+
guildId: [
|
|
3387
|
+
'a server the bot is in, though the mock was given only a guildId',
|
|
3388
|
+
"a guildId alone is a server the bot isn't in: guild and channel are null, and the member is a raw one",
|
|
3389
|
+
'Give a guild too, or call useStrictMocks() to read it so now.'
|
|
3390
|
+
]
|
|
3391
|
+
}[place.given];
|
|
3392
|
+
return `${name}.${key} reads ${why[0]} here; in the next major version (5.0) ${why[1]}. ${why[2]}`;
|
|
3393
|
+
}
|
|
3394
|
+
/** Has a read of `instance`'s `key` call `warn` first, and what that read reads in turn stay quiet. */ function warnOnRead(instance, key, warn) {
|
|
3395
|
+
const descriptor = Object.getOwnPropertyDescriptor(instance, key);
|
|
3396
|
+
// One the mock answers with a stub stays unset, as the mock's own checks read it, and warns as the stub is read
|
|
3397
|
+
if (!descriptor) {
|
|
3398
|
+
if (!stubReadWarnings.has(instance)) stubReadWarnings.set(instance, new Map());
|
|
3399
|
+
stubReadWarnings.get(instance).set(key, warn);
|
|
3400
|
+
return;
|
|
3401
|
+
}
|
|
3402
|
+
const read = ()=>descriptor.get ? descriptor.get.call(instance) : descriptor.value;
|
|
3403
|
+
Object.defineProperty(instance, key, {
|
|
3404
|
+
get: ()=>{
|
|
3405
|
+
warn();
|
|
3406
|
+
readingPlace++;
|
|
3407
|
+
try {
|
|
3408
|
+
return read();
|
|
3409
|
+
} finally{
|
|
3410
|
+
readingPlace--;
|
|
3411
|
+
}
|
|
3412
|
+
},
|
|
3413
|
+
set: (next)=>Object.defineProperty(instance, key, {
|
|
3414
|
+
value: next,
|
|
3415
|
+
writable: true,
|
|
3416
|
+
enumerable: true,
|
|
3417
|
+
configurable: true
|
|
3418
|
+
}),
|
|
3419
|
+
enumerable: true,
|
|
3420
|
+
configurable: true
|
|
3421
|
+
});
|
|
3422
|
+
}
|
|
3423
|
+
/**
|
|
3424
|
+
* Has each of an interaction's `guildId`, `guild`, `channelId`, `channel` and `member` that strict mocks read otherwise
|
|
3425
|
+
* warn once when read, unless the test gave it.
|
|
3426
|
+
*/ function warnOnPlaceReads(instance, place, given) {
|
|
3427
|
+
const value = (key)=>{
|
|
3428
|
+
const descriptor = Object.getOwnPropertyDescriptor(instance, key);
|
|
3429
|
+
return descriptor?.get ? undefined : descriptor?.value;
|
|
3430
|
+
};
|
|
3431
|
+
const keys = place.refused ? [
|
|
3432
|
+
'guildId',
|
|
3433
|
+
'guild',
|
|
3434
|
+
'channelId',
|
|
3435
|
+
'channel'
|
|
3436
|
+
] : place.raw ? [
|
|
3437
|
+
'guild',
|
|
3438
|
+
'channel',
|
|
3439
|
+
'member'
|
|
3440
|
+
] : place.guildId !== value('guildId') ? [
|
|
3441
|
+
'guildId',
|
|
3442
|
+
'guild',
|
|
3443
|
+
'channelId',
|
|
3444
|
+
'channel',
|
|
3445
|
+
'member'
|
|
3446
|
+
] : place.channel && place.channel.id !== value('channelId') ? [
|
|
3447
|
+
'channelId',
|
|
3448
|
+
'channel'
|
|
3449
|
+
] : [];
|
|
3450
|
+
const name = Object.getPrototypeOf(instance).constructor.name;
|
|
3451
|
+
placeWarners.set(instance, {
|
|
3452
|
+
place,
|
|
3453
|
+
keys,
|
|
3454
|
+
warn: (read)=>controller_decorator.warnOnce(mockLogger, placeWarning(name, read, place))
|
|
3455
|
+
});
|
|
3456
|
+
for (const key of keys.filter((each)=>!given.has(each))){
|
|
3457
|
+
// A read the mock makes stays quiet, noted for a read of the mock's own to warn by its name
|
|
3458
|
+
warnOnRead(instance, key, ()=>readingPlace > 0 ? void placeReadsSeen?.add(key) : controller_decorator.warnOnce(mockLogger, placeWarning(name, key, place)));
|
|
3459
|
+
}
|
|
3460
|
+
}
|
|
2201
3461
|
/**
|
|
2202
3462
|
* Where a channel a test gives puts the mock made in it, read from the channel as discord.js reads it: no server for a
|
|
2203
3463
|
* DM channel, else the server the channel names. A server channel that names none is put in `fallback`'s, and named
|
|
@@ -2242,9 +3502,11 @@ const CHANNEL_TYPE_GUARDS = [
|
|
|
2242
3502
|
cached(cacheOf(manager), id, ()=>channel);
|
|
2243
3503
|
}
|
|
2244
3504
|
}
|
|
2245
|
-
/** The guild a mock message carries: a guild with the same stubbed managers as createMockGuild. */ function createMockGuildForMessage(id
|
|
3505
|
+
/** The guild a mock message carries: a guild with the same stubbed managers as createMockGuild. */ function createMockGuildForMessage(id) {
|
|
2246
3506
|
const guild = Object.create(discord_js.Guild.prototype);
|
|
2247
|
-
|
|
3507
|
+
const generatedId = id === undefined ? nextSnowflake() : undefined;
|
|
3508
|
+
guild.id = id ?? generatedId;
|
|
3509
|
+
defineMadeTime(guild, generatedId);
|
|
2248
3510
|
guild.members = managerWith(discord_js.GuildMemberManager.prototype, undefined);
|
|
2249
3511
|
guild.channels = managerWith(discord_js.GuildChannelManager.prototype, undefined);
|
|
2250
3512
|
guild.roles = guildRoleManager(guild.id, undefined);
|
|
@@ -2253,6 +3515,23 @@ const CHANNEL_TYPE_GUARDS = [
|
|
|
2253
3515
|
settleGuild(guild, proxy);
|
|
2254
3516
|
return proxy;
|
|
2255
3517
|
}
|
|
3518
|
+
// Each settled guild's bot member as `members.me` reads it, without caching one made for the read
|
|
3519
|
+
const botMembers = new WeakMap();
|
|
3520
|
+
/** The bot's member in `guild` for the mock's own reads: the one `members.me` reads, made without caching it there. */ function botMemberOf(guild) {
|
|
3521
|
+
return botMembers.get(guild)?.() ?? guild.members.me;
|
|
3522
|
+
}
|
|
3523
|
+
/**
|
|
3524
|
+
* The channel an interaction's `channel` reads, for the mock's own reads: `null` in a DM, else one set on it or its
|
|
3525
|
+
* server's cached one, without making or caching one. Undefined where `channel` would make one, which has no overwrites.
|
|
3526
|
+
*/ function heldChannelOf(interaction, guild) {
|
|
3527
|
+
const target = mockTargets.get(interaction) ?? interaction;
|
|
3528
|
+
return readPlaceQuietly(()=>{
|
|
3529
|
+
const set = Object.getOwnPropertyDescriptor(target, 'channel');
|
|
3530
|
+
if (set && 'value' in set) return set.value;
|
|
3531
|
+
const { guildId, channelId } = target;
|
|
3532
|
+
return guildId ? cacheOf(guild.channels)?.get(channelId) : null;
|
|
3533
|
+
});
|
|
3534
|
+
}
|
|
2256
3535
|
/** Gives a guild's managers the guild, as discord.js's do, so what they fetch or make is in it. */ function homeManagers(instance, guild) {
|
|
2257
3536
|
for (const key of [
|
|
2258
3537
|
'members',
|
|
@@ -2274,11 +3553,13 @@ const CHANNEL_TYPE_GUARDS = [
|
|
|
2274
3553
|
configurable: true
|
|
2275
3554
|
});
|
|
2276
3555
|
const members = instance.members;
|
|
3556
|
+
const botMember = (keep)=>{
|
|
3557
|
+
const { user } = guild.client;
|
|
3558
|
+
return keep ? cached(cacheOf(members), user.id, ()=>memberIn(guild, user.id, user)) : cacheOf(members)?.get(user.id) ?? memberIn(guild, user.id, user);
|
|
3559
|
+
};
|
|
3560
|
+
botMembers.set(guild, ()=>botMember(false));
|
|
2277
3561
|
Object.defineProperty(members, 'me', {
|
|
2278
|
-
get: ()=>
|
|
2279
|
-
const { user } = guild.client;
|
|
2280
|
-
return cached(cacheOf(members), user.id, ()=>memberIn(guild, user.id, user));
|
|
2281
|
-
},
|
|
3562
|
+
get: ()=>botMember(true),
|
|
2282
3563
|
configurable: true
|
|
2283
3564
|
});
|
|
2284
3565
|
for (const key of [
|
|
@@ -2289,6 +3570,8 @@ const CHANNEL_TYPE_GUARDS = [
|
|
|
2289
3570
|
if (!Object.prototype.hasOwnProperty.call(item, 'guild')) item.guild = guild;
|
|
2290
3571
|
}
|
|
2291
3572
|
}
|
|
3573
|
+
// Strict mocks cache the bot's member from the start, as the gateway does when the bot joins, so lookups find it
|
|
3574
|
+
if (strictMocks()) void members.me;
|
|
2292
3575
|
}
|
|
2293
3576
|
/** The ids each kind of mention in `content` names, in order and once each. */ function mentionedIds(content) {
|
|
2294
3577
|
const ids = (pattern)=>[
|
|
@@ -2350,8 +3633,11 @@ const CHANNEL_TYPE_GUARDS = [
|
|
|
2350
3633
|
};
|
|
2351
3634
|
// Own values, so the prototype's getters, which read the raw mention data, are not reached
|
|
2352
3635
|
const instance = Object.create(discord_js.MessageMentions.prototype);
|
|
3636
|
+
const parsedUsers = new discord_js.Collection(mentioned.users);
|
|
2353
3637
|
for (const [key, value] of Object.entries({
|
|
2354
3638
|
...mentioned,
|
|
3639
|
+
parsedUsers,
|
|
3640
|
+
crosspostedChannels: new discord_js.Collection(),
|
|
2355
3641
|
everyone: false,
|
|
2356
3642
|
client,
|
|
2357
3643
|
guild: guild ?? null
|
|
@@ -2364,11 +3650,14 @@ const CHANNEL_TYPE_GUARDS = [
|
|
|
2364
3650
|
return stubDeep(instance);
|
|
2365
3651
|
}
|
|
2366
3652
|
/** A value as its API JSON: a builder or discord.js structure through `toJSON()`, anything else as it is. */ const jsonOf = (value)=>value && typeof value.toJSON === 'function' ? value.toJSON() : value;
|
|
2367
|
-
/**
|
|
3653
|
+
/**
|
|
3654
|
+
* The message a reply method starts from: the one a component is on, as it was, or an empty original response. Without
|
|
3655
|
+
* `allocate`, one with no id of its own gets `''`, for its holder to give it one when first read.
|
|
3656
|
+
*/ function heldFrom(message, allocate = true) {
|
|
2368
3657
|
// Read only what the message really has: a mock's unset fields are stubs
|
|
2369
3658
|
const from = message ?? {};
|
|
2370
3659
|
return {
|
|
2371
|
-
id: typeof from.id === 'string' ? from.id : nextSnowflake(),
|
|
3660
|
+
id: typeof from.id === 'string' ? from.id : allocate ? nextSnowflake() : '',
|
|
2372
3661
|
content: typeof from.content === 'string' ? from.content : undefined,
|
|
2373
3662
|
components: Array.isArray(from.components) ? from.components.map(jsonOf) : [],
|
|
2374
3663
|
embeds: Array.isArray(from.embeds) ? from.embeds.map(jsonOf) : [],
|
|
@@ -2376,6 +3665,15 @@ const CHANNEL_TYPE_GUARDS = [
|
|
|
2376
3665
|
editedTimestamp: typeof from.editedTimestamp === 'number' ? from.editedTimestamp : null
|
|
2377
3666
|
};
|
|
2378
3667
|
}
|
|
3668
|
+
/** A message as an answer sends it: a new one, with what the answer gave, never edited. */ const sent = (options, allocate = true)=>{
|
|
3669
|
+
// Its id taken only once it builds, so a payload that fails to build takes none
|
|
3670
|
+
const held = edited(heldFrom(undefined, false), options);
|
|
3671
|
+
return {
|
|
3672
|
+
...held,
|
|
3673
|
+
id: allocate ? nextSnowflake() : '',
|
|
3674
|
+
editedTimestamp: null
|
|
3675
|
+
};
|
|
3676
|
+
};
|
|
2379
3677
|
/** A held message after an edit: what the edit sets replaced in Discord's shape, and a later edit stamp. */ function edited(held, options) {
|
|
2380
3678
|
const edit = typeof options === 'string' ? {
|
|
2381
3679
|
content: options
|
|
@@ -2404,29 +3702,28 @@ const CHANNEL_TYPE_GUARDS = [
|
|
|
2404
3702
|
editedTimestamp: Math.max(Date.now(), (held.editedTimestamp ?? 0) + 1)
|
|
2405
3703
|
};
|
|
2406
3704
|
}
|
|
2407
|
-
/** A mock message showing a held message. */ function messageFrom(held) {
|
|
2408
|
-
return
|
|
3705
|
+
/** A mock message showing a held message, one the bot sent in `channel` by `client`, with what `made` keeps. */ function messageFrom(held, made, channel, client) {
|
|
3706
|
+
return sentMessage(held, channel, client, {
|
|
2409
3707
|
id: held.id,
|
|
2410
|
-
...held.content !== undefined && {
|
|
2411
|
-
content: held.content
|
|
2412
|
-
},
|
|
2413
|
-
components: held.components,
|
|
2414
|
-
embeds: held.embeds,
|
|
2415
|
-
...held.flags !== undefined && {
|
|
2416
|
-
flags: held.flags
|
|
2417
|
-
},
|
|
2418
3708
|
editedTimestamp: held.editedTimestamp
|
|
2419
|
-
});
|
|
3709
|
+
}, made);
|
|
2420
3710
|
}
|
|
2421
3711
|
/**
|
|
2422
|
-
* A component or embed as a message holds it: a discord.js instance as it is
|
|
2423
|
-
* its API JSON at the time of the call,
|
|
2424
|
-
*/ function asHeld(value) {
|
|
3712
|
+
* A component or embed as a message holds it: a discord.js instance as it is, anything else as the discord.js class
|
|
3713
|
+
* built from its API JSON at the time of the call, as discord.js builds it, an unknown type as a plain `Component`.
|
|
3714
|
+
*/ function asHeld(value, kind) {
|
|
2425
3715
|
if (value instanceof discord_js.Component || value instanceof discord_js.Embed) return value;
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
3716
|
+
// A copy, since discord.js's classes keep what they are given, one level deep
|
|
3717
|
+
const json = structuredClone(jsonOf(value));
|
|
3718
|
+
if (kind === 'embed') return new discord_js.Embed(json);
|
|
3719
|
+
// An action row builds each child with discord.js's own createComponent, which it doesn't export
|
|
3720
|
+
const row = new discord_js.ActionRow({
|
|
3721
|
+
type: discord_js.ComponentType.ActionRow,
|
|
3722
|
+
components: [
|
|
3723
|
+
json
|
|
3724
|
+
]
|
|
3725
|
+
});
|
|
3726
|
+
return row.components[0];
|
|
2430
3727
|
}
|
|
2431
3728
|
/**
|
|
2432
3729
|
* Creates a mock {@link Message} that tracks whether it has been deleted.
|
|
@@ -2435,12 +3732,14 @@ const CHANNEL_TYPE_GUARDS = [
|
|
|
2435
3732
|
* `@MessageHandler` does not skip it.
|
|
2436
3733
|
*
|
|
2437
3734
|
* @remarks
|
|
2438
|
-
* `delete()`, `edit()`, `reply()`, `react()`, `pin()` and `unpin()` throw once the message is deleted
|
|
2439
|
-
* and `reply()`
|
|
2440
|
-
*
|
|
2441
|
-
* one
|
|
2442
|
-
*
|
|
2443
|
-
*
|
|
3735
|
+
* `delete()`, `edit()`, `reply()`, `react()`, `pin()` and `unpin()` throw once the message is deleted. `edit()` changes
|
|
3736
|
+
* this message and resolves to it, and `reply()` resolves to the reply, sent in this message's channel and holding what
|
|
3737
|
+
* was sent, as a channel's `send()` does. Under `useStrictMocks()` a message the bot sends is its `client.user`'s, and
|
|
3738
|
+
* one sent in a DM is in that DM. Without overrides the message is empty, in a server, from a new person who is its
|
|
3739
|
+
* `member`; with `guild: null` it is a direct message. Give `author` to send several messages as one user, such as to
|
|
3740
|
+
* reach a per-user cooldown. An author given, and the users, roles and channels the content mentions, are cached as the
|
|
3741
|
+
* gateway delivers them. It is typed as discord.js emits a message, never in a group DM, so it fits `messageCreate` and
|
|
3742
|
+
* the other message events.
|
|
2444
3743
|
*
|
|
2445
3744
|
* @param overrides - The message's content, components and the rest; see {@link MockMessageOverrides}.
|
|
2446
3745
|
*
|
|
@@ -2463,11 +3762,18 @@ const CHANNEL_TYPE_GUARDS = [
|
|
|
2463
3762
|
* @see {@link MockMessageOverrides}
|
|
2464
3763
|
* @see {@link createMockGuild}
|
|
2465
3764
|
*/ function createMockMessage(overrides = {}) {
|
|
3765
|
+
return messageMock(overrides);
|
|
3766
|
+
}
|
|
3767
|
+
function messageMock(overrides = {}, sentBy, made = {}, at) {
|
|
3768
|
+
var _made, _made1, _made2;
|
|
2466
3769
|
const instance = Object.create(discord_js.Message.prototype);
|
|
2467
3770
|
const stubs = new Map();
|
|
3771
|
+
const unplaced = overrides.channel === undefined && overrides.guild === undefined;
|
|
3772
|
+
// A message the bot sent is placed where it was sent, even where default mode reads a server of its own for a DM
|
|
3773
|
+
if (unplaced && !sentBy) placesMade.add(instance);
|
|
2468
3774
|
instance.deleted = false;
|
|
2469
3775
|
// Constructor-assigned — set as prototype-based stubs; a user rather than a bot, as dispatch handles only those
|
|
2470
|
-
instance.author = overrides.author ?? mockUser();
|
|
3776
|
+
instance.author = overrides.author ?? (_made = made).author ?? (_made.author = mockUser());
|
|
2471
3777
|
// Getters on the prototype, which the proxy would answer with a stub object: own properties shadow them
|
|
2472
3778
|
// A channel given says where the message was sent, unless its guild is given too
|
|
2473
3779
|
const place = overrides.channel ? placeOf(overrides.channel, overrides.guild === undefined ? undefined : overrides.guild?.id ?? null, ()=>{
|
|
@@ -2477,22 +3783,37 @@ const CHANNEL_TYPE_GUARDS = [
|
|
|
2477
3783
|
guild
|
|
2478
3784
|
};
|
|
2479
3785
|
}) : undefined;
|
|
2480
|
-
const guild = overrides.guild !== undefined ? overrides.guild : place?.guildId !== undefined ? place.guildId === null ? null : place.guild ?? createMockGuildForMessage(place.guildId) : createMockGuildForMessage();
|
|
2481
|
-
const
|
|
2482
|
-
|
|
3786
|
+
const guild = overrides.guild !== undefined ? overrides.guild : place?.guildId !== undefined ? place.guildId === null ? null : place.guild ?? createMockGuildForMessage(place.guildId) : unplaced ? (_made1 = made).guild ?? (_made1.guild = createMockGuildForMessage()) : createMockGuildForMessage();
|
|
3787
|
+
const madeChannel = ()=>channelFor(guild, guild?.id ?? null, nextSnowflake(), instance.author);
|
|
3788
|
+
const channel = at ? undefined : overrides.channel ?? (unplaced ? (_made2 = made).channel ?? (_made2.channel = madeChannel()) : madeChannel());
|
|
3789
|
+
if (channel) Object.defineProperty(instance, 'channel', {
|
|
2483
3790
|
value: channel,
|
|
2484
|
-
writable: true
|
|
3791
|
+
writable: true,
|
|
3792
|
+
configurable: true
|
|
2485
3793
|
});
|
|
3794
|
+
else {
|
|
3795
|
+
Object.defineProperty(instance, 'channel', {
|
|
3796
|
+
get: at.channel,
|
|
3797
|
+
set: (value)=>Object.defineProperty(instance, 'channel', {
|
|
3798
|
+
value,
|
|
3799
|
+
writable: true,
|
|
3800
|
+
configurable: true
|
|
3801
|
+
}),
|
|
3802
|
+
configurable: true
|
|
3803
|
+
});
|
|
3804
|
+
}
|
|
2486
3805
|
Object.defineProperty(instance, 'guild', {
|
|
2487
3806
|
value: guild,
|
|
2488
|
-
writable: true
|
|
3807
|
+
writable: true,
|
|
3808
|
+
configurable: true
|
|
2489
3809
|
});
|
|
2490
3810
|
// The author as a member of the message's server; a direct message has none. A given author's member is the one
|
|
2491
3811
|
// the server caches, as the gateway resolves it, so every message from that author has the same member
|
|
2492
|
-
// The author's member, cached as the gateway caches it with the message; `member` reads the cache, as discord.js
|
|
3812
|
+
// The author's member, cached as the gateway caches it with the message; `member` reads the cache, as discord.js
|
|
3813
|
+
// does, without calling the manager's resolve a test may stub
|
|
2493
3814
|
if (guild) memberFor(guild, instance.author, true);
|
|
2494
3815
|
Object.defineProperty(instance, 'member', {
|
|
2495
|
-
get: ()=>guild ? guild.members
|
|
3816
|
+
get: ()=>instance.guild ? cacheOf(instance.guild.members)?.get(instance.author.id) ?? null : null,
|
|
2496
3817
|
set: (value)=>Object.defineProperty(instance, 'member', {
|
|
2497
3818
|
value,
|
|
2498
3819
|
writable: true,
|
|
@@ -2503,15 +3824,16 @@ const CHANNEL_TYPE_GUARDS = [
|
|
|
2503
3824
|
configurable: true
|
|
2504
3825
|
});
|
|
2505
3826
|
// In a server's text channel, the ids matching the objects; with no guild, a DM
|
|
2506
|
-
instance.channelId = channel.
|
|
2507
|
-
instance.guildId = guild?.id ?? null;
|
|
3827
|
+
instance.channelId = channel?.id ?? at.channelId;
|
|
3828
|
+
instance.guildId = at ? at.guildId : guild?.id ?? null;
|
|
2508
3829
|
// The thread its channel caches under its id, as discord.js reads it; until 5.0, a placeholder thread otherwise
|
|
2509
3830
|
let placeholderThread;
|
|
2510
3831
|
Object.defineProperty(instance, 'thread', {
|
|
2511
3832
|
get: ()=>{
|
|
2512
3833
|
const found = cacheOf(instance.channel.threads)?.get(instance.id);
|
|
2513
3834
|
if (found) return found;
|
|
2514
|
-
|
|
3835
|
+
if (strictMocks()) return null;
|
|
3836
|
+
controller_decorator.warnPlaceholder(mockLogger, 'Message.thread', 'it is null unless the channel caches a thread under the message id, as discord.js reads it', 'Cache one with message.channel.threads.cache.set(message.id, thread), or call useStrictMocks() to have it read null now.');
|
|
2515
3837
|
return placeholderThread ?? (placeholderThread = stubDeep(Object.create(discord_js.ThreadChannel.prototype)));
|
|
2516
3838
|
},
|
|
2517
3839
|
set: (value)=>Object.defineProperty(instance, 'thread', {
|
|
@@ -2533,7 +3855,7 @@ const CHANNEL_TYPE_GUARDS = [
|
|
|
2533
3855
|
configurable: true
|
|
2534
3856
|
});
|
|
2535
3857
|
if (guild instanceof discord_js.Guild) cached(cacheOf(client.guilds), guild.id, ()=>guild);
|
|
2536
|
-
cacheChannel(channel, guild, client);
|
|
3858
|
+
if (channel) cacheChannel(channel, guild, client);
|
|
2537
3859
|
const userCache = cacheOf(client.users);
|
|
2538
3860
|
for (const user of [
|
|
2539
3861
|
...overrides.users ?? [],
|
|
@@ -2543,8 +3865,8 @@ const CHANNEL_TYPE_GUARDS = [
|
|
|
2543
3865
|
instance.mentions = mentionsOf(overrides.content, client, guild);
|
|
2544
3866
|
// Data a message always has, real rather than stubbed, so code reading it sees an empty message
|
|
2545
3867
|
instance.flags = new discord_js.MessageFlagsBitField(overrides.flags);
|
|
2546
|
-
instance.components = (overrides.components ?? []).map(asHeld);
|
|
2547
|
-
instance.embeds = (overrides.embeds ?? []).map(asHeld);
|
|
3868
|
+
instance.components = (overrides.components ?? []).map((component)=>asHeld(component, 'component'));
|
|
3869
|
+
instance.embeds = (overrides.embeds ?? []).map((embed)=>asHeld(embed, 'embed'));
|
|
2548
3870
|
instance.attachments = new discord_js.Collection();
|
|
2549
3871
|
instance.editedTimestamp = overrides.editedTimestamp ?? null;
|
|
2550
3872
|
const generatedId = overrides.id === undefined ? nextSnowflake() : undefined;
|
|
@@ -2557,14 +3879,27 @@ const CHANNEL_TYPE_GUARDS = [
|
|
|
2557
3879
|
if (instance.deleted) throw alreadyDeleted();
|
|
2558
3880
|
instance.deleted = true;
|
|
2559
3881
|
}));
|
|
2560
|
-
|
|
3882
|
+
// An edit changes this message, as Discord edits it in place, and resolves to it
|
|
3883
|
+
stubs.set('edit', createMockFn(async (options)=>{
|
|
2561
3884
|
if (instance.deleted) throw alreadyDeleted();
|
|
2562
|
-
|
|
3885
|
+
const held = edited(heldFrom(undefined, false), options);
|
|
3886
|
+
const edit = typeof options === 'string' ? {
|
|
3887
|
+
content: options
|
|
3888
|
+
} : options ?? {};
|
|
3889
|
+
if (edit.content !== undefined) instance.content = held.content;
|
|
3890
|
+
if (edit.components !== undefined) instance.components = held.components.map((component)=>asHeld(component, 'component'));
|
|
3891
|
+
if (edit.embeds !== undefined) instance.embeds = held.embeds.map((embed)=>asHeld(embed, 'embed'));
|
|
3892
|
+
if (edit.flags !== undefined) instance.flags = new discord_js.MessageFlagsBitField(held.flags);
|
|
3893
|
+
instance.editedTimestamp = Math.max(Date.now(), (instance.editedTimestamp ?? 0) + 1);
|
|
3894
|
+
return message;
|
|
2563
3895
|
}));
|
|
2564
|
-
stubs.set('reply', createMockFn(async ()=>{
|
|
3896
|
+
stubs.set('reply', createMockFn(async (options)=>{
|
|
2565
3897
|
if (instance.deleted) throw alreadyDeleted();
|
|
2566
|
-
return
|
|
3898
|
+
return sentMessage(sent(options, false), instance.channel, instance.client);
|
|
2567
3899
|
}));
|
|
3900
|
+
// Crossposting publishes this message, and forwarding sends one in the channel it is forwarded to
|
|
3901
|
+
stubs.set('crosspost', createMockFn(async ()=>message));
|
|
3902
|
+
stubs.set('forward', createMockFn(async (channel)=>sentMessage(sent(undefined, false), channel, instance.client)));
|
|
2568
3903
|
stubs.set('react', createMockFn(async ()=>{
|
|
2569
3904
|
if (instance.deleted) throw alreadyDeleted();
|
|
2570
3905
|
}));
|
|
@@ -2574,7 +3909,85 @@ const CHANNEL_TYPE_GUARDS = [
|
|
|
2574
3909
|
stubs.set('unpin', createMockFn(async ()=>{
|
|
2575
3910
|
if (instance.deleted) throw alreadyDeleted();
|
|
2576
3911
|
}));
|
|
2577
|
-
|
|
3912
|
+
if (sentBy) warnOnSentReads(instance, stubs, sentBy);
|
|
3913
|
+
const message = stubDeep(instance, stubs);
|
|
3914
|
+
return message;
|
|
3915
|
+
}
|
|
3916
|
+
/**
|
|
3917
|
+
* Has a message the bot sent warn once where strict mocks read it otherwise: its author, the bot's `client.user` under
|
|
3918
|
+
* strict mocks, and, for one sent in a DM, the server it reads as being in.
|
|
3919
|
+
*/ function warnOnSentReads(instance, stubs, sentBy) {
|
|
3920
|
+
// A read made while reading another of these, as `member` reads `author`, warns only for the outer one
|
|
3921
|
+
let reading = 0;
|
|
3922
|
+
const read = (key, warning)=>{
|
|
3923
|
+
const descriptor = Object.getOwnPropertyDescriptor(instance, key);
|
|
3924
|
+
Object.defineProperty(instance, key, {
|
|
3925
|
+
get: ()=>{
|
|
3926
|
+
if (reading === 0) controller_decorator.warnOnce(mockLogger, warning);
|
|
3927
|
+
reading++;
|
|
3928
|
+
try {
|
|
3929
|
+
return descriptor?.get ? descriptor.get.call(instance) : descriptor?.value;
|
|
3930
|
+
} finally{
|
|
3931
|
+
reading--;
|
|
3932
|
+
}
|
|
3933
|
+
},
|
|
3934
|
+
set: (next)=>Object.defineProperty(instance, key, {
|
|
3935
|
+
value: next,
|
|
3936
|
+
writable: true,
|
|
3937
|
+
enumerable: true,
|
|
3938
|
+
configurable: true
|
|
3939
|
+
}),
|
|
3940
|
+
enumerable: true,
|
|
3941
|
+
configurable: true
|
|
3942
|
+
});
|
|
3943
|
+
};
|
|
3944
|
+
if (sentBy.bot) {
|
|
3945
|
+
read('author', "Message.author reads a user here, though the bot sent this message; in the next major version (5.0) it is the bot's " + "client.user, as Discord sends it. Call useStrictMocks() to read client.user now.");
|
|
3946
|
+
}
|
|
3947
|
+
if (!sentBy.dm) return;
|
|
3948
|
+
const inDm = (what)=>`Message.${what} reads a server here, though this message was sent in a DM; in the next major version (5.0) it reads ` + "as a DM's, with no server. Call useStrictMocks() to read it so now.";
|
|
3949
|
+
const inGuild = Boolean(instance.guildId);
|
|
3950
|
+
for (const key of [
|
|
3951
|
+
'guild',
|
|
3952
|
+
'guildId',
|
|
3953
|
+
'member'
|
|
3954
|
+
])read(key, inDm(key));
|
|
3955
|
+
stubs.set('inGuild', createMockFn(()=>(controller_decorator.warnOnce(mockLogger, inDm('inGuild()')), inGuild)));
|
|
3956
|
+
}
|
|
3957
|
+
/**
|
|
3958
|
+
* A message the bot sent, as Discord returns it: in the channel it was sent in and that channel's server, holding what
|
|
3959
|
+
* was sent. Under strict mocks it is the bot's `client.user`'s, and one sent in a DM is in that DM; default mode places
|
|
3960
|
+
* a DM send in a server of its own and keeps another author, with a warning when read.
|
|
3961
|
+
*/ function sentMessage(held, channel, client, fields = {}, made) {
|
|
3962
|
+
const by = client ?? channel?.client;
|
|
3963
|
+
const inChannel = channel instanceof discord_js.BaseChannel;
|
|
3964
|
+
const dm = inChannel && channel.isDMBased();
|
|
3965
|
+
const strict = strictMocks();
|
|
3966
|
+
const message = messageMock({
|
|
3967
|
+
...held.content !== undefined && {
|
|
3968
|
+
content: held.content
|
|
3969
|
+
},
|
|
3970
|
+
components: held.components,
|
|
3971
|
+
embeds: held.embeds,
|
|
3972
|
+
...held.flags !== undefined && {
|
|
3973
|
+
flags: held.flags
|
|
3974
|
+
},
|
|
3975
|
+
...inChannel && (!dm || strict) && {
|
|
3976
|
+
channel: channel
|
|
3977
|
+
},
|
|
3978
|
+
...by && {
|
|
3979
|
+
client: by
|
|
3980
|
+
},
|
|
3981
|
+
...strict && by?.user && {
|
|
3982
|
+
author: by.user
|
|
3983
|
+
},
|
|
3984
|
+
...fields
|
|
3985
|
+
}, strict ? undefined : {
|
|
3986
|
+
bot: Boolean(by?.user),
|
|
3987
|
+
dm
|
|
3988
|
+
}, made);
|
|
3989
|
+
if (dm && !strict) sentInDm.set(mockTargets.get(message) ?? message, channel);
|
|
3990
|
+
return message;
|
|
2578
3991
|
}
|
|
2579
3992
|
/** The option type Discord would have sent for a given JavaScript value. */ function optionTypeOf(value) {
|
|
2580
3993
|
if (typeof value === 'boolean') return discord_js.ApplicationCommandOptionType.Boolean;
|
|
@@ -2663,11 +4076,32 @@ const CHANNEL_TYPE_GUARDS = [
|
|
|
2663
4076
|
else if (isEntity) option.user = value;
|
|
2664
4077
|
return option;
|
|
2665
4078
|
}
|
|
4079
|
+
/**
|
|
4080
|
+
* The option an autocomplete's user is typing, as Discord sends it: its value a string, `''` before anything is typed.
|
|
4081
|
+
* A number given for it is its digits under strict mocks; otherwise it stays a number, with a warning when read.
|
|
4082
|
+
*/ function focusedOptionData(name, value) {
|
|
4083
|
+
const option = toOptionData(name, value ?? '');
|
|
4084
|
+
option.focused = true;
|
|
4085
|
+
if (typeof value === 'number') {
|
|
4086
|
+
if (strictMocks()) option.value = String(value);
|
|
4087
|
+
else {
|
|
4088
|
+
Object.defineProperty(option, 'value', {
|
|
4089
|
+
get: ()=>{
|
|
4090
|
+
controller_decorator.warnOnce(mockLogger, `The focused option "${name}" reads the number ${value} here, where Discord sends a string; in the next major version (5.0) ` + `the mock gives it as one. Give it as a string, such as '${value}', or call useStrictMocks() to have the mock give it now.`);
|
|
4091
|
+
return value;
|
|
4092
|
+
},
|
|
4093
|
+
enumerable: true
|
|
4094
|
+
});
|
|
4095
|
+
}
|
|
4096
|
+
}
|
|
4097
|
+
return option;
|
|
4098
|
+
}
|
|
2666
4099
|
/**
|
|
2667
4100
|
* Nests the supplied options under the subcommand path they were invoked through,
|
|
2668
4101
|
* matching the shape Discord sends rather than a flat list.
|
|
2669
|
-
*/ function buildOptionData(subcommandGroup, subcommand, values, memberOf) {
|
|
2670
|
-
const leaves = Object.entries(values).map(([name, value])=>toOptionData(name, value, memberOf));
|
|
4102
|
+
*/ function buildOptionData(subcommandGroup, subcommand, values, memberOf, focused) {
|
|
4103
|
+
const leaves = Object.entries(values).map(([name, value])=>name === focused ? focusedOptionData(name, value) : toOptionData(name, value, memberOf));
|
|
4104
|
+
if (focused !== null && !Object.hasOwn(values, focused)) leaves.push(focusedOptionData(focused, null));
|
|
2671
4105
|
if (subcommand === null) return leaves;
|
|
2672
4106
|
const sub = {
|
|
2673
4107
|
name: subcommand,
|
|
@@ -2707,6 +4141,10 @@ const CHANNEL_TYPE_GUARDS = [
|
|
|
2707
4141
|
* A whole number is an Integer option and a fraction a Number one, so
|
|
2708
4142
|
* `getInteger()` reads only a whole number, and `getNumber()` reads either.
|
|
2709
4143
|
*
|
|
4144
|
+
* The `focused` option reads as Discord sends it, through `getFocused()`, `getFocused(true)` and `data` alike: a
|
|
4145
|
+
* string, `''` when no value is given, and marked `focused` in `data`. A number given for it is its digits under
|
|
4146
|
+
* `useStrictMocks()`; otherwise it stays the number, with a warning when read.
|
|
4147
|
+
*
|
|
2710
4148
|
* @typeParam Cached - `any` by default, to match `createMockInteraction(ChatInputCommandInteraction)`; pass it when the
|
|
2711
4149
|
* interaction under test is cache-pinned.
|
|
2712
4150
|
*
|
|
@@ -2780,7 +4218,8 @@ const CHANNEL_TYPE_GUARDS = [
|
|
|
2780
4218
|
if (!unowned.has(name)) unowned.set(name, memberIn(undefined, user.id, user));
|
|
2781
4219
|
return unowned.get(name);
|
|
2782
4220
|
}
|
|
2783
|
-
|
|
4221
|
+
const rawOwner = !owner.guild && isRawMember(Object.getOwnPropertyDescriptor(owner, 'member')?.value);
|
|
4222
|
+
if (!members.has(name)) members.set(name, owner.guildId ? rawOwner ? rawResolvedMember() : memberFor(owner.guild, user, true) : null);
|
|
2784
4223
|
return members.get(name) ?? null;
|
|
2785
4224
|
};
|
|
2786
4225
|
base.getUser = createMockFn((name, required)=>{
|
|
@@ -2826,15 +4265,19 @@ const CHANNEL_TYPE_GUARDS = [
|
|
|
2826
4265
|
], ()=>false, required));
|
|
2827
4266
|
base.getFocused = createMockFn((getFull)=>{
|
|
2828
4267
|
if (focused === null) throw optionError(discord_js.DiscordjsErrorCodes.AutocompleteInteractionOptionNoFocusedOption);
|
|
2829
|
-
|
|
4268
|
+
// The one options.data holds, so the two agree
|
|
4269
|
+
const option = base._hoistedOptions.find((each)=>each.focused);
|
|
2830
4270
|
return getFull === true ? {
|
|
2831
|
-
...option
|
|
2832
|
-
focused: true
|
|
4271
|
+
...option
|
|
2833
4272
|
} : option.value;
|
|
2834
4273
|
});
|
|
2835
4274
|
// `data` is what the framework reads to build a handler's params, so it is materialised here rather than
|
|
2836
4275
|
// auto-stubbed, or every params assertion would see an empty record.
|
|
2837
|
-
base.data = buildOptionData(subcommandGroup, subcommand, values, memberOf);
|
|
4276
|
+
base.data = buildOptionData(subcommandGroup, subcommand, values, memberOf, focused);
|
|
4277
|
+
// The fields discord.js's constructor sets from it, which its toString() reads
|
|
4278
|
+
base._group = subcommandGroup;
|
|
4279
|
+
base._subcommand = subcommand;
|
|
4280
|
+
base._hoistedOptions = subcommand === null ? base.data : (subcommandGroup === null ? base.data[0] : base.data[0].options[0]).options;
|
|
2838
4281
|
const resolver = stubDeep(base);
|
|
2839
4282
|
return resolver;
|
|
2840
4283
|
}
|
|
@@ -2869,7 +4312,7 @@ const CHANNEL_TYPE_GUARDS = [
|
|
|
2869
4312
|
* @category Module
|
|
2870
4313
|
* @see {@link MeoCordTestingModule}
|
|
2871
4314
|
* @see {@link getResponse}
|
|
2872
|
-
* @see {@link https://meocord.dev/docs/4.
|
|
4315
|
+
* @see {@link https://meocord.dev/docs/4.2/invoke-and-dispatch | Invoke and dispatch}
|
|
2873
4316
|
*/ class TestingModule {
|
|
2874
4317
|
/**
|
|
2875
4318
|
* Resolves the module's `useFactory` providers, awaiting those that return a promise, in dependency
|
|
@@ -2908,7 +4351,7 @@ const CHANNEL_TYPE_GUARDS = [
|
|
|
2908
4351
|
await this.resolving;
|
|
2909
4352
|
if (options.ready) {
|
|
2910
4353
|
const { client = createMockClient(), primary = true } = options.ready === true ? {} : options.ready;
|
|
2911
|
-
this.readying ?? (this.readying = this.runReady(client, primary));
|
|
4354
|
+
this.readying ?? (this.readying = this.readyPass.run(()=>this.runReady(client, primary)));
|
|
2912
4355
|
await this.readying;
|
|
2913
4356
|
}
|
|
2914
4357
|
return this;
|
|
@@ -2923,12 +4366,21 @@ const CHANNEL_TYPE_GUARDS = [
|
|
|
2923
4366
|
* It first gives up the theme read outside calls, if `init({ ready: true })` made it this module's;
|
|
2924
4367
|
* reads outside calls then return MeoCord's defaults until another module or app is ready.
|
|
2925
4368
|
*
|
|
4369
|
+
* A stopped bot runs nothing more. Once `close()` is called, a module still runs what `dispatch`, `invoke` and `emit`
|
|
4370
|
+
* are given, against services shut down, or shutting down, and warns once that it is closed, so a `close()` in the
|
|
4371
|
+
* wrong place shows.
|
|
4372
|
+
*
|
|
2926
4373
|
* The cooldown store and what it injects shut down last, in the same sequence as the bot's. When any of them has an
|
|
2927
4374
|
* `onShutdown`, the calls `invoke`, `dispatch` and `emit` have under way finish first, then the store operations
|
|
2928
4375
|
* they started, so the store's last writes still reach it. The module stops waiting for the whole sequence after
|
|
2929
4376
|
* its `shutdownTimeout`, 10 seconds unless set, and logs that it did. Give a test whose fake store never answers, or
|
|
2930
4377
|
* whose `onShutdown` never settles, a short `shutdownTimeout`.
|
|
2931
4378
|
*
|
|
4379
|
+
* A `close()` while `init({ ready: true })` runs the `onReady` hooks waits for them first, within the same
|
|
4380
|
+
* `shutdownTimeout`. An `onReady` still running once only the hooks' share of it is left is named in a warning, and
|
|
4381
|
+
* its class is shut down all the same, as everything the module constructed is. One that calls `close()` itself isn't
|
|
4382
|
+
* waited for.
|
|
4383
|
+
*
|
|
2932
4384
|
* @returns Once every hook has run. Rejects with the error of a hook that failed, or an
|
|
2933
4385
|
* `AggregateError` naming each when several did.
|
|
2934
4386
|
*
|
|
@@ -2943,14 +4395,18 @@ const CHANNEL_TYPE_GUARDS = [
|
|
|
2943
4395
|
* expect(module.get(POOL).ended).toBe(true)
|
|
2944
4396
|
* ```
|
|
2945
4397
|
*/ async close() {
|
|
4398
|
+
// An onReady awaiting close() would otherwise hold up the close that waits for it
|
|
4399
|
+
this.readyPass.excuseCurrent();
|
|
2946
4400
|
this.closing ?? (this.closing = (async ()=>{
|
|
2947
|
-
// A close during init waits for the
|
|
2948
|
-
|
|
2949
|
-
|
|
2950
|
-
.
|
|
4401
|
+
// A close during init waits for the providers it started, and the sequence for its onReady hooks, so it shuts
|
|
4402
|
+
// down whatever they constructed
|
|
4403
|
+
await this.resolving?.catch(()=>undefined);
|
|
4404
|
+
const entries = ()=>this.lifecycle.filter((unit)=>this.constructed.has(unit.token))// Already made, so this returns the instance; a provided value may be anything, null included
|
|
4405
|
+
.map((unit)=>startupRoots.lifecycleEntry(unit, this.container.get(unit.token) ?? {}));
|
|
2951
4406
|
const failures = [];
|
|
2952
4407
|
await startupRoots.runShutdownSequence(this.container, entries, {
|
|
2953
4408
|
drainCalls: ()=>startupRoots.callsSettled(this.calls),
|
|
4409
|
+
readyPass: this.readyPass,
|
|
2954
4410
|
timeoutMs: this.shutdownTimeout,
|
|
2955
4411
|
hookFailed: (name, error)=>failures.push({
|
|
2956
4412
|
name,
|
|
@@ -2964,6 +4420,11 @@ const CHANNEL_TYPE_GUARDS = [
|
|
|
2964
4420
|
})());
|
|
2965
4421
|
await this.closing;
|
|
2966
4422
|
}
|
|
4423
|
+
/** Warns once, after `close()`, that a call runs against services already shut down. */ warnIfClosed() {
|
|
4424
|
+
if (this.closing === undefined || this.warnedClosed) return;
|
|
4425
|
+
this.warnedClosed = true;
|
|
4426
|
+
new controller_decorator.Logger('TestingModule').warn('This testing module is closed, and its services are shut down, or shutting down, so dispatch, invoke and emit run ' + 'against that state. Close it after the last call, in afterEach.');
|
|
4427
|
+
}
|
|
2967
4428
|
async runReady(client, primary) {
|
|
2968
4429
|
// As the bot claims it before it comes online, so onReady reads the app's theme
|
|
2969
4430
|
providers.claimAmbientAppTheme(this.container);
|
|
@@ -2979,6 +4440,8 @@ const CHANNEL_TYPE_GUARDS = [
|
|
|
2979
4440
|
}, [], {
|
|
2980
4441
|
resolveFailed: failed,
|
|
2981
4442
|
hookFailed: failed
|
|
4443
|
+
}, {
|
|
4444
|
+
runOnReady: this.readyPass.runOnReady
|
|
2982
4445
|
});
|
|
2983
4446
|
throwFailures('onReady', failures);
|
|
2984
4447
|
}
|
|
@@ -3050,7 +4513,9 @@ const CHANNEL_TYPE_GUARDS = [
|
|
|
3050
4513
|
* @returns Whether the handler ran, and the error a filter handled, if any. Rejects with an error no
|
|
3051
4514
|
* filter handles, or with the error a filter throws: the built-in fallback, which answers such
|
|
3052
4515
|
* errors in the bot, does not run here. Rejects before running anything with an interaction or a
|
|
3053
|
-
* message the handler's route does not match, naming both.
|
|
4516
|
+
* message the handler's route does not match, naming both. Given fewer arguments than the handler
|
|
4517
|
+
* declares, with a first that is neither an interaction nor a message, such as a reaction without its
|
|
4518
|
+
* `ReactionEvent`, it rejects under `useStrictMocks()`, and otherwise warns once and runs the handler.
|
|
3054
4519
|
*
|
|
3055
4520
|
* @example
|
|
3056
4521
|
* ```ts
|
|
@@ -3072,6 +4537,7 @@ const CHANNEL_TYPE_GUARDS = [
|
|
|
3072
4537
|
* expect(outcome.error).toBeInstanceOf(GuardDeniedError)
|
|
3073
4538
|
* ```
|
|
3074
4539
|
*/ async invoke(controller, methodName, ...args) {
|
|
4540
|
+
this.warnIfClosed();
|
|
3075
4541
|
await this.init();
|
|
3076
4542
|
if (!this.controllers.includes(controller)) {
|
|
3077
4543
|
throw new Error(`${controller.name} is not a controller of this testing module. Add it to \`controllers\`.`);
|
|
@@ -3101,6 +4567,7 @@ const CHANNEL_TYPE_GUARDS = [
|
|
|
3101
4567
|
hooks = providers.messageCommandHooks(route, params, first, start, given, this.messageOptions.types);
|
|
3102
4568
|
}
|
|
3103
4569
|
}
|
|
4570
|
+
this.checkArgumentCount(controller, methodName, instance[methodName], first, callArgs.length);
|
|
3104
4571
|
const presenter = providers.appPresenterOf(this.container);
|
|
3105
4572
|
// A message command's errors are drawn by the app's presenter too, as an interaction's are
|
|
3106
4573
|
const client = first instanceof discord_js.BaseInteraction || first instanceof discord_js.Message ? first.client : undefined;
|
|
@@ -3117,6 +4584,19 @@ const CHANNEL_TYPE_GUARDS = [
|
|
|
3117
4584
|
error
|
|
3118
4585
|
};
|
|
3119
4586
|
}
|
|
4587
|
+
/**
|
|
4588
|
+
* Refuses under `useStrictMocks()`, or warns once otherwise, a call that gives a handler fewer arguments than it
|
|
4589
|
+
* declares when `invoke` builds none for it, as for a reaction given without its `ReactionEvent`.
|
|
4590
|
+
*/ checkArgumentCount(controller, methodName, method, first, given) {
|
|
4591
|
+
if (first instanceof discord_js.BaseInteraction || first instanceof discord_js.Message || method.length <= given) return;
|
|
4592
|
+
const reaction = controller_decorator.getReactionHandlers(controller.prototype).some((handler)=>handler.method === methodName);
|
|
4593
|
+
const problem = `${controller.name}.${methodName} takes ${method.length} arguments, and invoke was given ${given}: ` + (reaction ? 'pass its ReactionEvent after the reaction, as { user, action }.' : 'pass each argument it takes.');
|
|
4594
|
+
if (strictMocks()) throw new Error(problem);
|
|
4595
|
+
const key = `${controller.name}.${methodName}`;
|
|
4596
|
+
if (this.warnedArgumentCounts.has(key)) return;
|
|
4597
|
+
this.warnedArgumentCounts.add(key);
|
|
4598
|
+
new controller_decorator.Logger('TestingModule').warn(problem);
|
|
4599
|
+
}
|
|
3120
4600
|
/** Counts `call` as under way until it settles. */ track(call) {
|
|
3121
4601
|
this.calls.add(call);
|
|
3122
4602
|
void call.then(()=>this.calls.delete(call), ()=>this.calls.delete(call));
|
|
@@ -3143,6 +4623,8 @@ const CHANNEL_TYPE_GUARDS = [
|
|
|
3143
4623
|
controllerClasses: this.controllers,
|
|
3144
4624
|
messageOptions: this.messageOptions,
|
|
3145
4625
|
logger,
|
|
4626
|
+
// A test's fake clock can pass 5 s inside a handler, so the slow-handler warning waits to be asked for
|
|
4627
|
+
warnSlowMessageHandlers: false,
|
|
3146
4628
|
// Strict: an answer MeoCord fails to build rejects the dispatch, where a bot logs it and carries on
|
|
3147
4629
|
fallback: startupRoots.createFallback(logger, ()=>this.messageOptions, {
|
|
3148
4630
|
strict: true,
|
|
@@ -3160,6 +4642,7 @@ const CHANNEL_TYPE_GUARDS = [
|
|
|
3160
4642
|
return this.dispatcher;
|
|
3161
4643
|
}
|
|
3162
4644
|
async dispatch(input, options) {
|
|
4645
|
+
this.warnIfClosed();
|
|
3163
4646
|
await this.init();
|
|
3164
4647
|
this.registerClient(input);
|
|
3165
4648
|
const dispatcher = this.dispatcherOf();
|
|
@@ -3177,6 +4660,7 @@ const CHANNEL_TYPE_GUARDS = [
|
|
|
3177
4660
|
unhandled: (error)=>unhandled.push(error)
|
|
3178
4661
|
};
|
|
3179
4662
|
if (options) {
|
|
4663
|
+
noteReactionDispatch(input);
|
|
3180
4664
|
await this.track(dispatcher.reaction(input, {
|
|
3181
4665
|
user: options.user,
|
|
3182
4666
|
action: options.action ?? enum_index.ReactionHandlerAction.ADD
|
|
@@ -3206,15 +4690,20 @@ const CHANNEL_TYPE_GUARDS = [
|
|
|
3206
4690
|
};
|
|
3207
4691
|
}
|
|
3208
4692
|
/**
|
|
3209
|
-
* Emits a client event to the module's `@On` and `@Once` handlers, through the
|
|
3210
|
-
*
|
|
3211
|
-
* module's controllers, class providers and their dependencies all receive it. A
|
|
3212
|
-
* handles only the first event, as it would on a client.
|
|
4693
|
+
* Emits a client event to the module's `@On` and `@Once` handlers, through the pipeline the app runs
|
|
4694
|
+
* them in up to its event fallback: the global guards of the module's `app`, then each handler's own.
|
|
4695
|
+
* Handlers on the module's controllers, class providers and their dependencies all receive it. A
|
|
4696
|
+
* `@Once` handler handles only the first event, as it would on a client.
|
|
4697
|
+
*
|
|
4698
|
+
* The bot's event fallback doesn't run here. On a bot, a guard's `GuardDeniedError` is logged and the
|
|
4699
|
+
* event skipped, and a handler's `UserError` is logged and, when the event carries a message, answered
|
|
4700
|
+
* as a reply to it; here both reject `emit` as any error does, so a test checks them with `rejects`.
|
|
3213
4701
|
*
|
|
3214
4702
|
* @param event - The client event, such as `'guildMemberAdd'`.
|
|
3215
4703
|
* @param args - The event's arguments, typed from discord.js's `ClientEvents`.
|
|
3216
|
-
* @returns How many handlers ran. Rejects once every handler has settled if any threw
|
|
3217
|
-
*
|
|
4704
|
+
* @returns How many handlers ran. Rejects once every handler has settled if any threw, a guard's
|
|
4705
|
+
* `GuardDeniedError` and a handler's `UserError` included: with that error when one handler failed,
|
|
4706
|
+
* or an `AggregateError` of them when several did.
|
|
3218
4707
|
*
|
|
3219
4708
|
* @example
|
|
3220
4709
|
* ```ts
|
|
@@ -3226,6 +4715,7 @@ const CHANNEL_TYPE_GUARDS = [
|
|
|
3226
4715
|
* expect(ran).toBe(1)
|
|
3227
4716
|
* ```
|
|
3228
4717
|
*/ async emit(event, ...args) {
|
|
4718
|
+
this.warnIfClosed();
|
|
3229
4719
|
await this.init();
|
|
3230
4720
|
const calls = [];
|
|
3231
4721
|
for (const cls of this.eventClasses){
|
|
@@ -3270,7 +4760,10 @@ const CHANNEL_TYPE_GUARDS = [
|
|
|
3270
4760
|
this.appWarnUnanswered = appWarnUnanswered;
|
|
3271
4761
|
this.services = services;
|
|
3272
4762
|
this.shutdownTimeout = shutdownTimeout;
|
|
4763
|
+
/** The `onReady` hooks' pass, which `close()` waits for, less one whose `onReady` called `close()`. */ this.readyPass = new startupRoots.ReadyPass();
|
|
4764
|
+
this.warnedClosed = false;
|
|
3273
4765
|
/** The `@Once` handlers that have already handled their event, as a client forgets its once listeners. */ this.firedOnce = new Set();
|
|
4766
|
+
/** The handlers already warned about for being given fewer arguments than they take, so each is named once. */ this.warnedArgumentCounts = new Set();
|
|
3274
4767
|
/** The calls under way, each removed once it settles: what `close()` lets finish before the cooldown store stops. */ this.calls = new Set();
|
|
3275
4768
|
}
|
|
3276
4769
|
}
|
|
@@ -3309,7 +4802,7 @@ const CHANNEL_TYPE_GUARDS = [
|
|
|
3309
4802
|
* @group Testing
|
|
3310
4803
|
* @category Module
|
|
3311
4804
|
* @see {@link MeoCordTestingModule}
|
|
3312
|
-
* @see {@link https://meocord.dev/docs/4.
|
|
4805
|
+
* @see {@link https://meocord.dev/docs/4.2/testing | The testing module}
|
|
3313
4806
|
*/ class TestingModuleBuilder {
|
|
3314
4807
|
/**
|
|
3315
4808
|
* Replaces a provider with a test double.
|
|
@@ -3335,6 +4828,10 @@ const CHANNEL_TYPE_GUARDS = [
|
|
|
3335
4828
|
/**
|
|
3336
4829
|
* Replaces a guard with a stub wherever it applies, globally or on a controller or handler.
|
|
3337
4830
|
*
|
|
4831
|
+
* It stands in for the guard wherever the guard is bound, a provider of the same token included, and wins over an
|
|
4832
|
+
* `overrideProvider` of it. The stub needs a `canActivate`, its own or its prototype's: `compile()` refuses one without
|
|
4833
|
+
* it under `useStrictMocks()`, and otherwise warns.
|
|
4834
|
+
*
|
|
3338
4835
|
* @param guard - The guard class to replace.
|
|
3339
4836
|
* @example
|
|
3340
4837
|
* ```ts
|
|
@@ -3352,6 +4849,10 @@ const CHANNEL_TYPE_GUARDS = [
|
|
|
3352
4849
|
* Replaces an interceptor with a stub wherever it applies, globally or on a controller or handler.
|
|
3353
4850
|
* The stub's `intercept` receives the context and `next`; call `next.handle()` to run the handler.
|
|
3354
4851
|
*
|
|
4852
|
+
* It stands in for the interceptor wherever it is bound, a provider of the same token included, and wins over an
|
|
4853
|
+
* `overrideProvider` of it. A stub without `intercept` is refused by `compile()` under `useStrictMocks()`, and
|
|
4854
|
+
* otherwise warned about.
|
|
4855
|
+
*
|
|
3355
4856
|
* @param interceptor - The interceptor class to replace.
|
|
3356
4857
|
* @example
|
|
3357
4858
|
* ```ts
|
|
@@ -3369,6 +4870,10 @@ const CHANNEL_TYPE_GUARDS = [
|
|
|
3369
4870
|
* Replaces an exception filter with a stub wherever it applies. The filter's `@Catch` still decides
|
|
3370
4871
|
* which errors reach the stub.
|
|
3371
4872
|
*
|
|
4873
|
+
* It stands in for the filter wherever it is bound, a provider of the same token included, and wins over an
|
|
4874
|
+
* `overrideProvider` of it. A stub without `catch` is refused by `compile()` under `useStrictMocks()`, and otherwise
|
|
4875
|
+
* warned about.
|
|
4876
|
+
*
|
|
3372
4877
|
* @param filter - The filter class to replace.
|
|
3373
4878
|
* @example
|
|
3374
4879
|
* ```ts
|
|
@@ -3457,199 +4962,271 @@ const CHANNEL_TYPE_GUARDS = [
|
|
|
3457
4962
|
* its command's builder does not register, a customId pattern given as a command name, a builder that registers
|
|
3458
4963
|
* another name, and component patterns that can match the same customId are named, as the bot names them.
|
|
3459
4964
|
*
|
|
4965
|
+
* Each check reports every startup error it finds, and the first is thrown, unchanged; when there are several, each
|
|
4966
|
+
* is logged first, with the file it comes from. After {@link reportAllStartupErrors}, the errors decorators kept on
|
|
4967
|
+
* the classes the module runs are among them.
|
|
4968
|
+
*
|
|
3460
4969
|
* @returns The compiled module.
|
|
4970
|
+
* @throws Error for the first startup error the checks find.
|
|
3461
4971
|
*/ compile() {
|
|
3462
4972
|
// The bound the bot's config gives it, with the same words
|
|
3463
4973
|
const shutdownTimeout = this.options.shutdownTimeout === undefined ? undefined : startupRoots.shutdownTimeoutProblem(this.options.shutdownTimeout);
|
|
3464
4974
|
if (shutdownTimeout) throw new TypeError(`shutdownTimeout ${shutdownTimeout}.`);
|
|
3465
|
-
|
|
3466
|
-
const
|
|
3467
|
-
|
|
3468
|
-
|
|
3469
|
-
const appClasses = [];
|
|
3470
|
-
const translator = ()=>container.isBound(controller_decorator.Translator) ? container.get(controller_decorator.Translator) : undefined;
|
|
3471
|
-
container.bind(providers.HandlerRegistry).toConstantValue(new providers.HandlerRegistry(appClasses, messagesOf(this.options.app), translator));
|
|
3472
|
-
// A testing module runs as one process, so a cross-shard call runs once, here
|
|
3473
|
-
container.bind(providers.ShardContext).toConstantValue(new providers.ShardContext(undefined, providers.shardCallHandler(container, ()=>[
|
|
3474
|
-
...appClasses,
|
|
3475
|
-
...[
|
|
3476
|
-
...providers$1.keys()
|
|
3477
|
-
].filter(providers.isAppClassToken)
|
|
3478
|
-
], 'this testing module')));
|
|
3479
|
-
providers.bindsOwnToken(container, providers.HandlerRegistry);
|
|
3480
|
-
providers.bindsOwnToken(container, providers.ShardContext);
|
|
3481
|
-
// Checked as the app checks its own: the app's providers in their order, then the test's, which replace them
|
|
3482
|
-
// by token, then the overrides, which win
|
|
3483
|
-
const providers$1 = providers.providerMap(this.wiring?.providers ?? [], '@MeoCord({ providers })');
|
|
3484
|
-
for (const [token, provider] of providers.providerMap(this.options.providers ?? [], "the testing module's providers"))providers$1.set(token, provider);
|
|
3485
|
-
for (const [token, override] of this.overrides)providers$1.set(token, override);
|
|
3486
|
-
const services = this.wiring?.services ?? [];
|
|
3487
|
-
const appOptions = this.options.app ? Reflect.getMetadata(controller_decorator.META.appOptions, this.options.app) : undefined;
|
|
3488
|
-
// The app's own store, as the bot binds it, whether the module is given the app or built from it; none when the
|
|
3489
|
-
// test provides the CooldownStore, so nothing builds the app's or asks for what it injects
|
|
3490
|
-
const store = providers$1.has(controller_decorator.CooldownStore) ? undefined : appOptions?.cooldownStore;
|
|
3491
|
-
// The themeFor class this module runs, the app's or overrideThemeFor's, bound on its own as the app binds it
|
|
3492
|
-
const themeResolver = controller_decorator.themeResolverClass(stages?.themeFor?.resolvers);
|
|
3493
|
-
// Every class the module runs, as the app finds them, except stage classes an override stands in for
|
|
3494
|
-
const i18n = this.options.app && Reflect.getMetadata(controller_decorator.META.appOptions, this.options.app)?.i18n;
|
|
3495
|
-
const startup = startupRoots.startupClasses({
|
|
3496
|
-
controllers: this.options.controllers ?? [],
|
|
3497
|
-
services,
|
|
3498
|
-
providers: providers$1,
|
|
3499
|
-
stages,
|
|
3500
|
-
presenter: this.options.app && Reflect.getMetadata(controller_decorator.META.appOptions, this.options.app)?.presenter,
|
|
3501
|
-
cooldownStore: store,
|
|
3502
|
-
themeFor: stages?.themeFor?.resolvers,
|
|
3503
|
-
observers: [
|
|
3504
|
-
...this.options.app ? providers.appObservers(this.options.app) : [],
|
|
3505
|
-
...this.options.observers ?? []
|
|
3506
|
-
],
|
|
3507
|
-
stubs: [
|
|
3508
|
-
...this.guardOverrides.keys(),
|
|
3509
|
-
...this.interceptorOverrides.keys(),
|
|
3510
|
-
...this.filterOverrides.keys()
|
|
3511
|
-
]
|
|
3512
|
-
});
|
|
3513
|
-
const hasTranslator = i18n !== undefined || providers$1.has(controller_decorator.Translator);
|
|
3514
|
-
startupRoots.assertStartupClasses(startup, {
|
|
3515
|
-
translator: hasTranslator
|
|
3516
|
-
});
|
|
3517
|
-
providers.markStartupChecked(container, [
|
|
3518
|
-
...startup.classes,
|
|
3519
|
-
...providers.meocordClasses()
|
|
3520
|
-
], startupRoots.lateGuardCheck(providers$1, hasTranslator));
|
|
3521
|
-
// The bot binds the Client it logs in with; a test gives its own, and is told so where one is needed
|
|
3522
|
-
const needClient = startup.classes.filter((cls)=>providers.injectedTokens(cls).includes(discord_js.Client));
|
|
3523
|
-
if (needClient.length > 0 && !providers$1.has(discord_js.Client)) {
|
|
3524
|
-
container.bind(discord_js.Client).toDynamicValue(()=>{
|
|
3525
|
-
throw new Error(`${needClient.map((cls)=>cls.name).join(', ')} ${needClient.length === 1 ? 'injects' : 'inject'} the Discord Client, ` + 'which a testing module does not make: give one in its providers, such as ' + '{ provide: Client, useValue: createMockClient() }.');
|
|
3526
|
-
});
|
|
3527
|
-
}
|
|
3528
|
-
// Bind guard overrides — prevents inversify from auto-wiring guard dependencies
|
|
3529
|
-
for (const [guardClass, stub] of this.guardOverrides){
|
|
3530
|
-
container.bind(guardClass).toConstantValue(stub);
|
|
3531
|
-
}
|
|
3532
|
-
for (const [filterClass, stub] of this.filterOverrides){
|
|
3533
|
-
container.bind(filterClass).toConstantValue(stub);
|
|
3534
|
-
}
|
|
3535
|
-
for (const [interceptorClass, stub] of this.interceptorOverrides){
|
|
3536
|
-
container.bind(interceptorClass).toConstantValue(stub);
|
|
3537
|
-
}
|
|
3538
|
-
// The app's cooldown policy, so a test of a failing store sees what the bot would do
|
|
3539
|
-
if (appOptions && !container.isBound(providers.COOLDOWN_POLICY)) container.bind(providers.COOLDOWN_POLICY).toConstantValue(providers.cooldownPolicyFrom(appOptions));
|
|
3540
|
-
// The app's translator, unless a provider stands in for it
|
|
3541
|
-
if (i18n && !providers$1.has(controller_decorator.Translator)) {
|
|
3542
|
-
container.bind(controller_decorator.Translator).toConstantValue(i18n);
|
|
3543
|
-
providers.bindsOwnToken(container, controller_decorator.Translator);
|
|
3544
|
-
}
|
|
3545
|
-
// Recursively bind controllers and their dependencies, skipping already-bound tokens
|
|
3546
|
-
const bindClass = (cls)=>{
|
|
3547
|
-
// A provided class is bound by its own provider, wherever in the list that provider comes
|
|
3548
|
-
if (container.isBound(cls) || providers$1.has(cls)) return;
|
|
3549
|
-
if (providers.injectedTokens(cls).includes(controller_decorator.ExecutionContext)) throw providers.singletonContextError(cls);
|
|
3550
|
-
providers.assertStartupChecked(container, cls);
|
|
3551
|
-
providers.makeInjectable(cls);
|
|
3552
|
-
container.bind(cls).toSelf().inSingletonScope();
|
|
3553
|
-
// By constructor type or @inject token, as the app binds them
|
|
3554
|
-
for (const dep of providers.injectedTokens(cls)){
|
|
3555
|
-
if (dep === controller_decorator.Translator && !container.isBound(controller_decorator.Translator)) throw controller_decorator.missingTranslatorError(cls);
|
|
3556
|
-
if (providers.isAppClassToken(dep)) bindClass(dep);
|
|
3557
|
-
}
|
|
3558
|
-
};
|
|
3559
|
-
// The app's own store, resolved like a service, unless the test provides the CooldownStore itself; bound first, as the
|
|
3560
|
-
// app binds it, so a class that injects the token gets the store
|
|
3561
|
-
if (!providers$1.has(controller_decorator.CooldownStore)) {
|
|
3562
|
-
if (store) {
|
|
3563
|
-
bindClass(store);
|
|
3564
|
-
container.bind(controller_decorator.CooldownStore).toService(store);
|
|
3565
|
-
} else {
|
|
3566
|
-
container.bind(controller_decorator.CooldownStore).toConstantValue(new controller_decorator.MemoryCooldownStore());
|
|
3567
|
-
}
|
|
3568
|
-
providers.bindsOwnToken(container, controller_decorator.CooldownStore, store);
|
|
3569
|
-
}
|
|
3570
|
-
// Before the controllers, so a class token that is provided is not also bound as itself
|
|
3571
|
-
for (const provider of providers$1.values())providers.bindProvider(container, provider, bindClass);
|
|
3572
|
-
for (const ctrl of this.options.controllers ?? []){
|
|
3573
|
-
bindClass(ctrl);
|
|
3574
|
-
// Stamp container on controller class so @UseGuard works in tests too
|
|
3575
|
-
Reflect.defineMetadata(controller_decorator.META.container, container, ctrl);
|
|
3576
|
-
}
|
|
3577
|
-
for (const service of services)bindClass(service);
|
|
3578
|
-
if (themeResolver) bindClass(themeResolver);
|
|
3579
|
-
// The classes whose @On and @Once handlers emit reaches: class providers bound as themselves, the
|
|
3580
|
-
// controllers, and what they inject; factories resolve in the same order
|
|
3581
|
-
const order = providers.resolutionOrder(container, providers$1, [
|
|
3582
|
-
...providers$1.keys(),
|
|
3583
|
-
...services,
|
|
3584
|
-
...themeResolver ? [
|
|
3585
|
-
themeResolver
|
|
3586
|
-
] : [],
|
|
3587
|
-
...this.options.controllers ?? []
|
|
3588
|
-
], {
|
|
3589
|
-
followOwnTokens: false
|
|
3590
|
-
});
|
|
3591
|
-
appClasses.push(...order.filter((token)=>{
|
|
3592
|
-
const provider = providers$1.get(token);
|
|
3593
|
-
return providers.isAppClassToken(token) && (!provider || providers.isClassProvider(provider) && provider.useClass === token);
|
|
3594
|
-
}));
|
|
3595
|
-
providers.assertProvided(container, providers$1, startup.classes, "the testing module's providers");
|
|
3596
|
-
// Every class the module runs, so @UseGuard resolves guards on a direct call to any of them
|
|
3597
|
-
for (const cls of startup.classes)Reflect.defineMetadata(controller_decorator.META.container, container, cls);
|
|
3598
|
-
providers.prepareHandlerStages(container, appClasses);
|
|
3599
|
-
const messages = messagesOf(this.options.app);
|
|
3600
|
-
// As the app would at startup, refuses a message pattern that cannot be read or two that match the same messages
|
|
3601
|
-
providers.buildMessageRoutes(this.options.controllers ?? [], messages);
|
|
3602
|
-
// As the app does when it is created, so a test sees the refusal the bot would give
|
|
3603
|
-
startupRoots.assertDistinctCommands(this.options.controllers ?? []);
|
|
3604
|
-
// A handler with no builder is how a fixture is written, so only what is always a mistake is named
|
|
3605
|
-
startupRoots.warnUnregisteredCommands(this.options.controllers ?? [], {
|
|
3606
|
-
missingBuilders: false
|
|
3607
|
-
});
|
|
3608
|
-
startupRoots.warnInheritedRoutes(this.options.controllers ?? []);
|
|
3609
|
-
startupRoots.warnOverlappingPatterns(this.options.controllers ?? []);
|
|
3610
|
-
startupRoots.warnHandlersOffControllers(this.options.controllers ?? [], appClasses);
|
|
3611
|
-
if (this.options.app) providers.bindAppPresenter(container, this.options.app);
|
|
3612
|
-
const observers = [
|
|
3613
|
-
...this.options.app ? providers.appObservers(this.options.app) : [],
|
|
3614
|
-
...this.options.observers ?? []
|
|
3615
|
-
];
|
|
3616
|
-
providers.assertObservers("the testing module's observers", observers);
|
|
3617
|
-
providers.bindObservers(container, observers);
|
|
3618
|
-
// The store calls ask: the test's own when it provides CooldownStore, else the app's
|
|
3619
|
-
const boundStore = store ?? (providers$1.has(controller_decorator.CooldownStore) ? controller_decorator.CooldownStore : undefined);
|
|
3620
|
-
// The order the app runs lifecycle hooks in: its cooldown store, then providers, controllers and observers, each
|
|
3621
|
-
// after what it injects
|
|
3622
|
-
const lifecycle = providers.resolutionOrder(container, providers$1, [
|
|
3623
|
-
...boundStore ? [
|
|
3624
|
-
boundStore
|
|
3625
|
-
] : [],
|
|
3626
|
-
...providers$1.keys(),
|
|
3627
|
-
...services,
|
|
3628
|
-
...themeResolver ? [
|
|
3629
|
-
themeResolver
|
|
4975
|
+
// With startupErrors: 'all', what the decorators kept on the classes this module runs
|
|
4976
|
+
const declared = controller_decorator.declaredErrorsOf([
|
|
4977
|
+
...this.options.app ? [
|
|
4978
|
+
this.options.app
|
|
3630
4979
|
] : [],
|
|
3631
4980
|
...this.options.controllers ?? [],
|
|
3632
|
-
...
|
|
3633
|
-
|
|
3634
|
-
|
|
3635
|
-
|
|
3636
|
-
|
|
3637
|
-
|
|
3638
|
-
|
|
4981
|
+
...this.wiring?.services ?? [],
|
|
4982
|
+
...this.options.observers ?? []
|
|
4983
|
+
]);
|
|
4984
|
+
// Every error each check finds is reported, as the bot reports them, and the first is thrown
|
|
4985
|
+
try {
|
|
4986
|
+
return controller_decorator.collectStartupErrors(()=>{
|
|
4987
|
+
declared.forEach(controller_decorator.startupError);
|
|
4988
|
+
const container = new inversify.Container();
|
|
4989
|
+
const stages = this.globalStages();
|
|
4990
|
+
if (stages) providers.bindGlobalStages(container, stages);
|
|
4991
|
+
// Bound first, as in the app, so a class that injects it gets this instance
|
|
4992
|
+
const appClasses = [];
|
|
4993
|
+
const translator = ()=>container.isBound(controller_decorator.Translator) ? container.get(controller_decorator.Translator) : undefined;
|
|
4994
|
+
container.bind(providers.HandlerRegistry).toConstantValue(new providers.HandlerRegistry(appClasses, messagesOf(this.options.app), translator));
|
|
4995
|
+
// A testing module runs as one process, so a cross-shard call runs once, here
|
|
4996
|
+
container.bind(providers.ShardContext).toConstantValue(new providers.ShardContext(undefined, providers.shardCallHandler(container, ()=>[
|
|
4997
|
+
...appClasses,
|
|
4998
|
+
...[
|
|
4999
|
+
...providers$1.keys()
|
|
5000
|
+
].filter(providers.isAppClassToken)
|
|
5001
|
+
], 'this testing module')));
|
|
5002
|
+
providers.bindsOwnToken(container, providers.HandlerRegistry);
|
|
5003
|
+
providers.bindsOwnToken(container, providers.ShardContext);
|
|
5004
|
+
// Checked as the app checks its own: the app's providers in their order, then the test's, which replace them
|
|
5005
|
+
// by token, then the overrides, which win
|
|
5006
|
+
const providers$1 = providers.providerMap(this.wiring?.providers ?? [], '@MeoCord({ providers })');
|
|
5007
|
+
for (const [token, provider] of providers.providerMap(this.options.providers ?? [], "the testing module's providers"))providers$1.set(token, provider);
|
|
5008
|
+
for (const [token, override] of this.overrides)providers$1.set(token, override);
|
|
5009
|
+
// A stage override stands in for its class wherever the class is bound. A stage also provided takes the stub in
|
|
5010
|
+
// place of its provider, one binding per token, and runs lifecycle hooks as that provider would; any other is
|
|
5011
|
+
// bound on its own below, as the bot never makes a lifecycle unit of it. A stub that can't run is named here
|
|
5012
|
+
const stageStubs = new Map();
|
|
5013
|
+
for (const [method, overrides, kind] of [
|
|
5014
|
+
[
|
|
5015
|
+
'canActivate',
|
|
5016
|
+
this.guardOverrides,
|
|
5017
|
+
'overrideGuard'
|
|
5018
|
+
],
|
|
5019
|
+
[
|
|
5020
|
+
'intercept',
|
|
5021
|
+
this.interceptorOverrides,
|
|
5022
|
+
'overrideInterceptor'
|
|
5023
|
+
],
|
|
5024
|
+
[
|
|
5025
|
+
'catch',
|
|
5026
|
+
this.filterOverrides,
|
|
5027
|
+
'overrideFilter'
|
|
5028
|
+
]
|
|
5029
|
+
]){
|
|
5030
|
+
for (const [stage, stub] of overrides){
|
|
5031
|
+
if (providers$1.has(stage)) providers$1.set(stage, {
|
|
5032
|
+
provide: stage,
|
|
5033
|
+
useValue: stub
|
|
5034
|
+
});
|
|
5035
|
+
else stageStubs.set(stage, stub);
|
|
5036
|
+
if (typeof stub[method] === 'function') continue;
|
|
5037
|
+
const problem = `${kind}(${stage.name}).useValue(…) has no ${method} method.`;
|
|
5038
|
+
if (strictMocks()) controller_decorator.startupError(new Error(problem));
|
|
5039
|
+
else controller_decorator.warnOnce(new controller_decorator.Logger('TestingModule'), `${problem} A call that reaches ${stage.name} fails; useStrictMocks() refuses it here.`);
|
|
5040
|
+
}
|
|
3639
5041
|
}
|
|
3640
|
-
|
|
3641
|
-
|
|
3642
|
-
|
|
3643
|
-
|
|
3644
|
-
|
|
3645
|
-
|
|
3646
|
-
|
|
5042
|
+
// Each pass of checks ends before the next step that is no check, so a module with one error stops where it always did
|
|
5043
|
+
controller_decorator.stopOnStartupErrors();
|
|
5044
|
+
const services = this.wiring?.services ?? [];
|
|
5045
|
+
const appOptions = this.options.app ? Reflect.getMetadata(controller_decorator.META.appOptions, this.options.app) : undefined;
|
|
5046
|
+
// The app's own store, as the bot binds it, whether the module is given the app or built from it; none when the
|
|
5047
|
+
// test provides the CooldownStore, so nothing builds the app's or asks for what it injects
|
|
5048
|
+
const store = providers$1.has(controller_decorator.CooldownStore) ? undefined : appOptions?.cooldownStore;
|
|
5049
|
+
// The themeFor class this module runs, the app's or overrideThemeFor's, bound on its own as the app binds it
|
|
5050
|
+
const themeResolver = controller_decorator.themeResolverClass(stages?.themeFor?.resolvers);
|
|
5051
|
+
// Every class the module runs, as the app finds them, except stage classes an override stands in for
|
|
5052
|
+
const i18n = this.options.app && Reflect.getMetadata(controller_decorator.META.appOptions, this.options.app)?.i18n;
|
|
5053
|
+
const appPresenterClass = this.options.app && Reflect.getMetadata(controller_decorator.META.appOptions, this.options.app)?.presenter;
|
|
5054
|
+
const startup = startupRoots.startupClasses({
|
|
5055
|
+
controllers: this.options.controllers ?? [],
|
|
5056
|
+
services,
|
|
5057
|
+
providers: providers$1,
|
|
5058
|
+
stages,
|
|
5059
|
+
presenter: appPresenterClass,
|
|
5060
|
+
cooldownStore: store,
|
|
5061
|
+
themeFor: stages?.themeFor?.resolvers,
|
|
5062
|
+
observers: [
|
|
5063
|
+
...this.options.app ? providers.appObservers(this.options.app) : [],
|
|
5064
|
+
...this.options.observers ?? []
|
|
5065
|
+
],
|
|
5066
|
+
stubs: [
|
|
5067
|
+
...this.guardOverrides.keys(),
|
|
5068
|
+
...this.interceptorOverrides.keys(),
|
|
5069
|
+
...this.filterOverrides.keys()
|
|
5070
|
+
]
|
|
5071
|
+
});
|
|
5072
|
+
const hasTranslator = i18n !== undefined || providers$1.has(controller_decorator.Translator);
|
|
5073
|
+
startupRoots.assertStartupClasses(startup, {
|
|
5074
|
+
translator: hasTranslator
|
|
5075
|
+
});
|
|
5076
|
+
// Those of the stages, guards and other classes they reach
|
|
5077
|
+
controller_decorator.declaredErrorsOf(startup.classes).forEach(controller_decorator.startupError);
|
|
5078
|
+
controller_decorator.stopOnStartupErrors();
|
|
5079
|
+
providers.markStartupChecked(container, [
|
|
5080
|
+
...startup.classes,
|
|
5081
|
+
...providers.meocordClasses()
|
|
5082
|
+
], startupRoots.lateGuardCheck(providers$1, hasTranslator));
|
|
5083
|
+
// The bot binds the Client it logs in with; a test gives its own, and is told so where one is needed
|
|
5084
|
+
const needClient = startup.classes.filter((cls)=>providers.injectedTokens(cls).includes(discord_js.Client));
|
|
5085
|
+
if (needClient.length > 0 && !providers$1.has(discord_js.Client)) {
|
|
5086
|
+
container.bind(discord_js.Client).toDynamicValue(()=>{
|
|
5087
|
+
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() }.');
|
|
5088
|
+
});
|
|
5089
|
+
}
|
|
5090
|
+
// A stage override nothing provides, bound before the classes so none of them binds the stage itself
|
|
5091
|
+
for (const [stage, stub] of stageStubs)container.bind(stage).toConstantValue(stub);
|
|
5092
|
+
// The app's cooldown policy, so a test of a failing store sees what the bot would do
|
|
5093
|
+
if (appOptions && !container.isBound(providers.COOLDOWN_POLICY)) container.bind(providers.COOLDOWN_POLICY).toConstantValue(providers.cooldownPolicyFrom(appOptions));
|
|
5094
|
+
// The app's translator, unless a provider stands in for it
|
|
5095
|
+
if (i18n && !providers$1.has(controller_decorator.Translator)) {
|
|
5096
|
+
container.bind(controller_decorator.Translator).toConstantValue(i18n);
|
|
5097
|
+
providers.bindsOwnToken(container, controller_decorator.Translator);
|
|
5098
|
+
}
|
|
5099
|
+
// Recursively bind controllers and their dependencies, skipping already-bound tokens
|
|
5100
|
+
const bindClass = (cls)=>{
|
|
5101
|
+
// A provided class is bound by its own provider, wherever in the list that provider comes
|
|
5102
|
+
if (container.isBound(cls) || providers$1.has(cls)) return;
|
|
5103
|
+
if (providers.injectedTokens(cls).includes(controller_decorator.ExecutionContext)) return controller_decorator.startupError(providers.singletonContextError(cls));
|
|
5104
|
+
providers.assertStartupChecked(container, cls);
|
|
5105
|
+
providers.makeInjectable(cls);
|
|
5106
|
+
container.bind(cls).toSelf().inSingletonScope();
|
|
5107
|
+
// By constructor type or @inject token, as the app binds them
|
|
5108
|
+
for (const dep of providers.injectedTokens(cls)){
|
|
5109
|
+
if (dep === controller_decorator.Translator && !container.isBound(controller_decorator.Translator)) controller_decorator.startupError(controller_decorator.missingTranslatorError(cls));
|
|
5110
|
+
else if (providers.isAppClassToken(dep)) bindClass(dep);
|
|
5111
|
+
}
|
|
5112
|
+
};
|
|
5113
|
+
// The app's own store, resolved like a service, unless the test provides the CooldownStore itself; bound first, as the
|
|
5114
|
+
// app binds it, so a class that injects the token gets the store
|
|
5115
|
+
if (!providers$1.has(controller_decorator.CooldownStore)) {
|
|
5116
|
+
if (store) {
|
|
5117
|
+
bindClass(store);
|
|
5118
|
+
container.bind(controller_decorator.CooldownStore).toService(store);
|
|
5119
|
+
} else {
|
|
5120
|
+
container.bind(controller_decorator.CooldownStore).toConstantValue(new controller_decorator.MemoryCooldownStore());
|
|
5121
|
+
}
|
|
5122
|
+
providers.bindsOwnToken(container, controller_decorator.CooldownStore, store);
|
|
5123
|
+
}
|
|
5124
|
+
// Before the controllers, so a class token that is provided is not also bound as itself
|
|
5125
|
+
for (const provider of providers$1.values())providers.bindProvider(container, provider, bindClass);
|
|
5126
|
+
for (const ctrl of this.options.controllers ?? [])bindClass(ctrl);
|
|
5127
|
+
for (const service of services)bindClass(service);
|
|
5128
|
+
if (themeResolver) bindClass(themeResolver);
|
|
5129
|
+
// What follows walks what these bound, so a class refused here is not reported again as missing
|
|
5130
|
+
controller_decorator.stopOnStartupErrors();
|
|
5131
|
+
// The classes whose @On and @Once handlers emit reaches: class providers bound as themselves, the
|
|
5132
|
+
// controllers, and what they inject; factories resolve in the same order
|
|
5133
|
+
const order = providers.resolutionOrder(container, providers$1, [
|
|
5134
|
+
...providers$1.keys(),
|
|
5135
|
+
...services,
|
|
5136
|
+
...themeResolver ? [
|
|
5137
|
+
themeResolver
|
|
5138
|
+
] : [],
|
|
5139
|
+
...this.options.controllers ?? []
|
|
5140
|
+
], {
|
|
5141
|
+
followOwnTokens: false
|
|
5142
|
+
});
|
|
5143
|
+
appClasses.push(...order.filter((token)=>{
|
|
5144
|
+
const provider = providers$1.get(token);
|
|
5145
|
+
return providers.isAppClassToken(token) && (!provider || providers.isClassProvider(provider) && provider.useClass === token);
|
|
5146
|
+
}));
|
|
5147
|
+
providers.assertProvided(container, providers$1, startup.classes, "the testing module's providers");
|
|
5148
|
+
providers.prepareHandlerStages(container, appClasses);
|
|
5149
|
+
const messages = messagesOf(this.options.app);
|
|
5150
|
+
// As the app would at startup, refuses a message pattern that cannot be read or two that match the same messages
|
|
5151
|
+
providers.buildMessageRoutes(this.options.controllers ?? [], messages);
|
|
5152
|
+
// As the app does when it is created, so a test sees the refusal the bot would give
|
|
5153
|
+
startupRoots.assertDistinctCommands(this.options.controllers ?? []);
|
|
5154
|
+
if (this.options.app) providers.prepareAppPresenter(container, this.options.app);
|
|
5155
|
+
const observers = [
|
|
5156
|
+
...this.options.app ? providers.appObservers(this.options.app) : [],
|
|
5157
|
+
...this.options.observers ?? []
|
|
5158
|
+
];
|
|
5159
|
+
providers.assertObservers("the testing module's observers", observers);
|
|
5160
|
+
// Only @Observer classes are bound as observers
|
|
5161
|
+
controller_decorator.stopOnStartupErrors();
|
|
5162
|
+
providers.bindObservers(container, observers);
|
|
5163
|
+
// The store calls ask: the test's own when it provides CooldownStore, else the app's
|
|
5164
|
+
const boundStore = store ?? (providers$1.has(controller_decorator.CooldownStore) ? controller_decorator.CooldownStore : undefined);
|
|
5165
|
+
// What the stages and presenter inject, as the bot makes it; a stage the test replaced contributes nothing
|
|
5166
|
+
const stageServices = startupRoots.stageDependencies(startup, providers$1, appPresenterClass);
|
|
5167
|
+
for (const service of stageServices)bindClass(service);
|
|
5168
|
+
// The order the app runs lifecycle hooks in: its cooldown store, then providers, controllers and observers, each
|
|
5169
|
+
// after what it injects
|
|
5170
|
+
const lifecycle = providers.resolutionOrder(container, providers$1, [
|
|
5171
|
+
...boundStore ? [
|
|
5172
|
+
boundStore
|
|
5173
|
+
] : [],
|
|
5174
|
+
...providers$1.keys(),
|
|
5175
|
+
...services,
|
|
5176
|
+
...stageServices,
|
|
5177
|
+
...themeResolver ? [
|
|
5178
|
+
themeResolver
|
|
5179
|
+
] : [],
|
|
5180
|
+
...this.options.controllers ?? [],
|
|
5181
|
+
...observers
|
|
5182
|
+
]).map((token)=>({
|
|
5183
|
+
token,
|
|
5184
|
+
name: providers.tokenName(token),
|
|
5185
|
+
dependencies: providers.tokenDependencies(container, providers$1, token),
|
|
5186
|
+
...token === boundStore && {
|
|
5187
|
+
cooldownStore: true
|
|
5188
|
+
}
|
|
5189
|
+
}));
|
|
5190
|
+
// Every check has run, and touched only the container: from here the module is made, once none found an error
|
|
5191
|
+
controller_decorator.stopOnStartupErrors();
|
|
5192
|
+
// Every class the module runs, so @UseGuard resolves guards on a direct call to any of them
|
|
5193
|
+
for (const cls of [
|
|
5194
|
+
...this.options.controllers ?? [],
|
|
5195
|
+
...startup.classes
|
|
5196
|
+
])Reflect.defineMetadata(controller_decorator.META.container, container, cls);
|
|
5197
|
+
// And each instance with the module that made it, as another module made from the same classes stamps them again
|
|
5198
|
+
providers.recordInstanceContainers(container, [
|
|
5199
|
+
...this.options.controllers ?? [],
|
|
5200
|
+
...startup.classes
|
|
5201
|
+
]);
|
|
5202
|
+
providers.prepareThemes(container, appClasses);
|
|
5203
|
+
// A handler with no builder is how a fixture is written, so only what is always a mistake is named
|
|
5204
|
+
startupRoots.warnUnregisteredCommands(this.options.controllers ?? [], {
|
|
5205
|
+
missingBuilders: false
|
|
5206
|
+
});
|
|
5207
|
+
startupRoots.warnInheritedRoutes(this.options.controllers ?? []);
|
|
5208
|
+
startupRoots.warnOverlappingPatterns(this.options.controllers ?? []);
|
|
5209
|
+
startupRoots.warnHandlersOffControllers(this.options.controllers ?? [], appClasses);
|
|
5210
|
+
if (this.options.app) providers.bindAppPresenter(container, this.options.app);
|
|
5211
|
+
// Recorded as the container makes each one, so close() shuts down exactly what exists
|
|
5212
|
+
const constructed = new Set();
|
|
5213
|
+
for (const { token } of lifecycle){
|
|
5214
|
+
container.onActivation(token, (_context, instance)=>{
|
|
5215
|
+
constructed.add(token);
|
|
5216
|
+
return instance;
|
|
5217
|
+
});
|
|
5218
|
+
}
|
|
5219
|
+
const warnUnanswered = this.options.app && Reflect.getMetadata(controller_decorator.META.appOptions, this.options.app)?.warnUnanswered;
|
|
5220
|
+
return new TestingModule(container, [
|
|
5221
|
+
...this.options.controllers ?? []
|
|
5222
|
+
], appClasses, providers$1, order, messages, lifecycle, constructed, warnUnanswered, services, this.options.shutdownTimeout ?? startupRoots.DEFAULT_SHUTDOWN_TIMEOUT_MS);
|
|
3647
5223
|
});
|
|
5224
|
+
} catch (error) {
|
|
5225
|
+
if (controller_decorator.isRefusal(error) && !controller_decorator.isExplainedError(error)) {
|
|
5226
|
+
if (controller_decorator.logStartupErrors(error, (text)=>new controller_decorator.Logger('TestingModule').error(text), 'the testing module did not compile')) controller_decorator.markExplained(error);
|
|
5227
|
+
}
|
|
5228
|
+
throw error;
|
|
3648
5229
|
}
|
|
3649
|
-
const warnUnanswered = this.options.app && Reflect.getMetadata(controller_decorator.META.appOptions, this.options.app)?.warnUnanswered;
|
|
3650
|
-
return new TestingModule(container, [
|
|
3651
|
-
...this.options.controllers ?? []
|
|
3652
|
-
], appClasses, providers$1, order, messages, lifecycle, constructed, warnUnanswered, services, this.options.shutdownTimeout ?? startupRoots.DEFAULT_SHUTDOWN_TIMEOUT_MS);
|
|
3653
5230
|
}
|
|
3654
5231
|
constructor(options, wiring){
|
|
3655
5232
|
this.options = options;
|
|
@@ -3687,7 +5264,7 @@ const CHANNEL_TYPE_GUARDS = [
|
|
|
3687
5264
|
* @category Module
|
|
3688
5265
|
* @see {@link TestingModuleBuilder}
|
|
3689
5266
|
* @see {@link TestingModule}
|
|
3690
|
-
* @see {@link https://meocord.dev/docs/4.
|
|
5267
|
+
* @see {@link https://meocord.dev/docs/4.2/testing | The testing module}
|
|
3691
5268
|
*/ class MeoCordTestingModule {
|
|
3692
5269
|
/**
|
|
3693
5270
|
* Starts building a testing module from the classes a test needs.
|
|
@@ -3695,7 +5272,15 @@ const CHANNEL_TYPE_GUARDS = [
|
|
|
3695
5272
|
* @param options - The controllers, providers, observers and app the module is built from.
|
|
3696
5273
|
* @returns The builder, to override what the test replaces before `compile()`.
|
|
3697
5274
|
*/ static create(options) {
|
|
3698
|
-
|
|
5275
|
+
// A controller listed twice is one controller, as the bot and fromApp take it
|
|
5276
|
+
return new TestingModuleBuilder({
|
|
5277
|
+
...options,
|
|
5278
|
+
...options.controllers && {
|
|
5279
|
+
controllers: [
|
|
5280
|
+
...new Set(options.controllers)
|
|
5281
|
+
]
|
|
5282
|
+
}
|
|
5283
|
+
});
|
|
3699
5284
|
}
|
|
3700
5285
|
/**
|
|
3701
5286
|
* Starts building a testing module from a whole `@MeoCord` app, wired as the bot wires it.
|
|
@@ -3777,6 +5362,29 @@ const CHANNEL_TYPE_GUARDS = [
|
|
|
3777
5362
|
providers.adviseInPlaceOfInjecting(TestingModuleBuilder, 'build it with MeoCordTestingModule.create(…) or MeoCordTestingModule.fromApp(…)');
|
|
3778
5363
|
providers.adviseInPlaceOfInjecting(TestingModule, 'make it with MeoCordTestingModule.create(…).compile()');
|
|
3779
5364
|
|
|
5365
|
+
/**
|
|
5366
|
+
* Has decorators keep the startup errors they find, as a bot built with `startupErrors: 'all'` does, so a testing
|
|
5367
|
+
* module reports every one at once.
|
|
5368
|
+
*
|
|
5369
|
+
* Call it once in the test runner's setup file, before any test imports a controller: a decorator runs when its class
|
|
5370
|
+
* is defined, as its file is imported. A decorator given what it cannot use then throws nothing as the file loads, and
|
|
5371
|
+
* `MeoCordTestingModule.create(...).compile()` logs every startup error of the classes it runs, each with its handler
|
|
5372
|
+
* and file, then throws the first. Without it, a decorator throws its error as its class is defined.
|
|
5373
|
+
*
|
|
5374
|
+
* @example
|
|
5375
|
+
* ```ts
|
|
5376
|
+
* // vitest.setup.ts
|
|
5377
|
+
* reportAllStartupErrors()
|
|
5378
|
+
* ```
|
|
5379
|
+
*
|
|
5380
|
+
* @group Testing
|
|
5381
|
+
* @category Module
|
|
5382
|
+
* @see {@link MeoCordTestingModule}
|
|
5383
|
+
* @see {@link https://meocord.dev/docs/4.2/configuration | Configuration}
|
|
5384
|
+
*/ function reportAllStartupErrors() {
|
|
5385
|
+
controller_decorator.setStartupErrorsMode('all');
|
|
5386
|
+
}
|
|
5387
|
+
|
|
3780
5388
|
/** The options `@MeoCord` declares on an application class. */ function appOptionsOf(app) {
|
|
3781
5389
|
const options = Reflect.getMetadata(controller_decorator.META.appOptions, app);
|
|
3782
5390
|
if (!options) throw new TypeError(`${app.name || 'The given class'} is not decorated with @MeoCord().`);
|
|
@@ -3820,7 +5428,7 @@ providers.adviseInPlaceOfInjecting(TestingModule, 'make it with MeoCordTestingMo
|
|
|
3820
5428
|
* @group Testing
|
|
3821
5429
|
* @category Inspection
|
|
3822
5430
|
* @see {@link findRouteConflicts}
|
|
3823
|
-
* @see {@link https://meocord.dev/docs/4.
|
|
5431
|
+
* @see {@link https://meocord.dev/docs/4.2/components | Buttons, selects and modals}
|
|
3824
5432
|
*/ function resolveRoute(app, input) {
|
|
3825
5433
|
if ('content' in input) {
|
|
3826
5434
|
const { messages = {} } = appOptionsOf(app);
|
|
@@ -3846,7 +5454,10 @@ providers.adviseInPlaceOfInjecting(TestingModule, 'make it with MeoCordTestingMo
|
|
|
3846
5454
|
const { route, params, text } = matched;
|
|
3847
5455
|
const method = route.meta.methodName;
|
|
3848
5456
|
const typed = Object.keys(route.types).length > 0;
|
|
3849
|
-
|
|
5457
|
+
// The routes ranked behind it that the customId matches too, which dispatch never reaches for it
|
|
5458
|
+
const behind = routes.slice(routes.indexOf(route) + 1);
|
|
5459
|
+
const alsoMatches = behind.filter((other)=>other.meta.type === input.type && startupRoots.readCustomId(other.pattern, other.meta.regex, input.customId)).map((other)=>other.pattern);
|
|
5460
|
+
const resolved = {
|
|
3850
5461
|
controller: route.controllerClass,
|
|
3851
5462
|
method,
|
|
3852
5463
|
handler: route.controllerClass.prototype[method],
|
|
@@ -3855,28 +5466,34 @@ providers.adviseInPlaceOfInjecting(TestingModule, 'make it with MeoCordTestingMo
|
|
|
3855
5466
|
values: params
|
|
3856
5467
|
}
|
|
3857
5468
|
};
|
|
5469
|
+
return alsoMatches.length > 0 ? readableOnly(resolved, {
|
|
5470
|
+
alsoMatches
|
|
5471
|
+
}) : resolved;
|
|
3858
5472
|
}
|
|
3859
5473
|
/**
|
|
3860
|
-
* Finds component patterns that can match the same `customId`, which MeoCord otherwise only warns
|
|
5474
|
+
* Finds component patterns that rank equally and can match the same `customId`, which MeoCord otherwise only warns
|
|
5475
|
+
* about at startup.
|
|
3861
5476
|
*
|
|
3862
|
-
* Use it in a test to keep the warning from reaching production
|
|
3863
|
-
*
|
|
5477
|
+
* Use it in a test to keep the warning from reaching production. Patterns rank segment by segment, a literal before a
|
|
5478
|
+
* param and then the narrower type, so two collide only with the same literals and equally narrow params at each
|
|
5479
|
+
* position, such as `{action:close|reopen}` and `{step:close|confirm}`, which both take `close`; the one listed
|
|
5480
|
+
* first runs. Patterns are compared within a component type.
|
|
3864
5481
|
*
|
|
3865
5482
|
* @param app - The application class decorated with `@MeoCord`.
|
|
3866
|
-
* @returns Each pair of patterns that can match the same `customId`, with its component type.
|
|
5483
|
+
* @returns Each pair of patterns that rank equally and can match the same `customId`, with its component type.
|
|
3867
5484
|
*
|
|
3868
5485
|
* @example
|
|
3869
5486
|
* ```ts
|
|
3870
5487
|
* import { expect } from 'vitest'
|
|
3871
5488
|
*
|
|
3872
5489
|
* @Controller()
|
|
3873
|
-
* class
|
|
3874
|
-
* @Command('
|
|
3875
|
-
* async
|
|
3876
|
-
* @Command('
|
|
3877
|
-
* async
|
|
5490
|
+
* class TicketController {
|
|
5491
|
+
* @Command('ticket/{action:close|reopen}', CommandType.BUTTON)
|
|
5492
|
+
* async act(interaction: ButtonInteraction) { await respond(interaction).send('Done.') }
|
|
5493
|
+
* @Command('ticket/{step:close|confirm}', CommandType.BUTTON)
|
|
5494
|
+
* async step(interaction: ButtonInteraction) { await respond(interaction).send('Next.') }
|
|
3878
5495
|
* }
|
|
3879
|
-
* @MeoCord({ controllers: [
|
|
5496
|
+
* @MeoCord({ controllers: [TicketController], clientOptions: { intents: [] } })
|
|
3880
5497
|
* class App {}
|
|
3881
5498
|
* expect(findRouteConflicts(App)).toHaveLength(1)
|
|
3882
5499
|
* ```
|
|
@@ -3885,7 +5502,23 @@ providers.adviseInPlaceOfInjecting(TestingModule, 'make it with MeoCordTestingMo
|
|
|
3885
5502
|
* @category Inspection
|
|
3886
5503
|
* @see {@link resolveRoute}
|
|
3887
5504
|
*/ function findRouteConflicts(app) {
|
|
3888
|
-
return startupRoots.findComponentRouteConflicts(startupRoots.buildComponentRoutes(controllersOf(app)))
|
|
5505
|
+
return startupRoots.findComponentRouteConflicts(startupRoots.buildComponentRoutes(controllersOf(app))).map(({ type, patterns, runs })=>readableOnly({
|
|
5506
|
+
type: type,
|
|
5507
|
+
patterns
|
|
5508
|
+
}, {
|
|
5509
|
+
runs
|
|
5510
|
+
}));
|
|
5511
|
+
}
|
|
5512
|
+
/**
|
|
5513
|
+
* `target` with `fields` added as properties a test reads but `toEqual` and its kin, which compare enumerable keys,
|
|
5514
|
+
* don't see, so an assertion written for the fields it had before still holds.
|
|
5515
|
+
*/ function readableOnly(target, fields) {
|
|
5516
|
+
for (const [key, value] of Object.entries(fields))Object.defineProperty(target, key, {
|
|
5517
|
+
value,
|
|
5518
|
+
enumerable: false,
|
|
5519
|
+
configurable: true
|
|
5520
|
+
});
|
|
5521
|
+
return target;
|
|
3889
5522
|
}
|
|
3890
5523
|
|
|
3891
5524
|
/**
|
|
@@ -3954,7 +5587,15 @@ providers.adviseInPlaceOfInjecting(TestingModule, 'make it with MeoCordTestingMo
|
|
|
3954
5587
|
return new Fields(components);
|
|
3955
5588
|
}
|
|
3956
5589
|
|
|
3957
|
-
const
|
|
5590
|
+
const PLURAL_CATEGORIES = new Set([
|
|
5591
|
+
'zero',
|
|
5592
|
+
'one',
|
|
5593
|
+
'two',
|
|
5594
|
+
'few',
|
|
5595
|
+
'many',
|
|
5596
|
+
'other'
|
|
5597
|
+
]);
|
|
5598
|
+
/** A plural: an object whose every key is a plural category, as the translator's types read one; any other is a group. */ const isPlural = (value)=>typeof value === 'object' && value !== null && Object.keys(value).length > 0 && Object.keys(value).every((key)=>PLURAL_CATEGORIES.has(key));
|
|
3958
5599
|
/** Every message key of a catalog, and whether it is a plural. */ function leaves(catalog, prefix = '') {
|
|
3959
5600
|
return Object.entries(catalog).flatMap(([name, value])=>{
|
|
3960
5601
|
const key = `${prefix}${name}`;
|
|
@@ -3973,8 +5614,9 @@ const isPlural = (value)=>typeof value === 'object' && value !== null && typeof
|
|
|
3973
5614
|
return leaves(value, `${key}.`);
|
|
3974
5615
|
});
|
|
3975
5616
|
}
|
|
3976
|
-
|
|
3977
|
-
|
|
5617
|
+
/** A translation's text, which an empty string isn't: the translator reads `''` as missing, as tools export one untranslated. */ const isText = (value)=>typeof value === 'string' && value !== '';
|
|
5618
|
+
/** The value at a key's path, through own properties only, so `constructor` or `toString` is not read off a prototype. */ function at(catalog, key) {
|
|
5619
|
+
return key.split('.').reduce((current, part)=>typeof current === 'object' && current !== null && Object.hasOwn(current, part) ? current[part] : undefined, catalog);
|
|
3978
5620
|
}
|
|
3979
5621
|
const MEOCORD_KEYS = new Set(leaves(controller_decorator.MEOCORD_MESSAGES).map(({ key })=>key));
|
|
3980
5622
|
const isMeoCordKey = (key)=>key.startsWith('meocord.');
|
|
@@ -3997,9 +5639,11 @@ const isMeoCordKey = (key)=>key.startsWith('meocord.');
|
|
|
3997
5639
|
*
|
|
3998
5640
|
* Use it in a test for a team that wants no fallback in production. It reports the messages a locale lacks, the
|
|
3999
5641
|
* messages the default catalog doesn't have, the plural forms a language needs but a plural lacks, such as `few` for
|
|
4000
|
-
* Russian,
|
|
4001
|
-
*
|
|
4002
|
-
*
|
|
5642
|
+
* Russian, a translation of another shape than the default's, a text for a plural or a plural for a text, and each
|
|
5643
|
+
* `{param}` a translation uses that the message it translates doesn't take. An empty translation, `''`, counts as
|
|
5644
|
+
* lacking, as the translator reads it as missing. It reads the catalogs' own strings, so it checks the params of a
|
|
5645
|
+
* catalog from a plain variable or a JSON file too, which the compiler can't. An object counts as a plural only when
|
|
5646
|
+
* its every key is a plural category, so a group with an `other` key is a group.
|
|
4003
5647
|
*
|
|
4004
5648
|
* @remarks
|
|
4005
5649
|
* MeoCord's own texts, a catalog's `meocord` group, fall back to English by design, so only a key MeoCord lacks, and
|
|
@@ -4032,7 +5676,7 @@ const isMeoCordKey = (key)=>key.startsWith('meocord.');
|
|
|
4032
5676
|
* @group Testing
|
|
4033
5677
|
* @category Inspection
|
|
4034
5678
|
* @see {@link createTranslator}
|
|
4035
|
-
* @see {@link https://meocord.dev/docs/4.
|
|
5679
|
+
* @see {@link https://meocord.dev/docs/4.2/localisation | Localisation}
|
|
4036
5680
|
*/ function expectCompleteCatalog(translator, options = {}) {
|
|
4037
5681
|
const catalogs = translator[controller_decorator.CATALOGS];
|
|
4038
5682
|
if (!catalogs) throw new Error('expectCompleteCatalog takes a translator made by createTranslator.');
|
|
@@ -4046,12 +5690,17 @@ const isMeoCordKey = (key)=>key.startsWith('meocord.');
|
|
|
4046
5690
|
const categories = new Intl.PluralRules(locale).resolvedOptions().pluralCategories;
|
|
4047
5691
|
for (const { key, plural } of reference){
|
|
4048
5692
|
const message = at(catalog, key);
|
|
4049
|
-
if (message === undefined) {
|
|
5693
|
+
if (message === undefined || message === '') {
|
|
4050
5694
|
problems.push(`missing ${key}`);
|
|
4051
5695
|
continue;
|
|
4052
5696
|
}
|
|
4053
|
-
|
|
4054
|
-
|
|
5697
|
+
// A translation of another shape than the default's can't be read as it: a text for a plural, or the reverse
|
|
5698
|
+
if (plural !== isPlural(message) || !plural && typeof message !== 'string') {
|
|
5699
|
+
problems.push(`${key} should be a ${plural ? 'plural' : 'text'}, as the default is`);
|
|
5700
|
+
continue;
|
|
5701
|
+
}
|
|
5702
|
+
if (plural) {
|
|
5703
|
+
const missing = categories.filter((category)=>!isText(message[category]));
|
|
4055
5704
|
if (missing.length > 0) problems.push(`${key} lacks ${missing.join(', ')}`);
|
|
4056
5705
|
}
|
|
4057
5706
|
const original = at(defaultCatalog, key);
|
|
@@ -4059,7 +5708,7 @@ const isMeoCordKey = (key)=>key.startsWith('meocord.');
|
|
|
4059
5708
|
for (const name of stray)problems.push(`${key} takes no {${name}}; the default is "${shown(original)}"`);
|
|
4060
5709
|
}
|
|
4061
5710
|
if (options.meocord && !locale.startsWith('en-')) {
|
|
4062
|
-
for (const key of MEOCORD_KEYS)if (at(catalog, key) === undefined) problems.push(`missing ${key}`);
|
|
5711
|
+
for (const key of MEOCORD_KEYS)if (at(catalog, key) === undefined || at(catalog, key) === '') problems.push(`missing ${key}`);
|
|
4063
5712
|
}
|
|
4064
5713
|
for (const { key } of leaves(catalog)){
|
|
4065
5714
|
if (isMeoCordKey(key)) {
|
|
@@ -4078,6 +5727,7 @@ const isMeoCordKey = (key)=>key.startsWith('meocord.');
|
|
|
4078
5727
|
|
|
4079
5728
|
/** Long enough for a store over the network to answer each case, short enough that the suite takes seconds. */ const CASE_TIMEOUT_MS = 10000;
|
|
4080
5729
|
const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
|
|
5730
|
+
/** How far a store's clock may run from this process's, as a store's server keeps its own time. */ const CLOCK_SLACK_MS = 50;
|
|
4081
5731
|
/**
|
|
4082
5732
|
* Checks that a `CooldownStore` counts calls as `MemoryCooldownStore` does, as a suite of test cases.
|
|
4083
5733
|
*
|
|
@@ -4086,9 +5736,11 @@ const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
|
|
|
4086
5736
|
*
|
|
4087
5737
|
* @remarks
|
|
4088
5738
|
* The cases cover what a shared store most often gets wrong: a sliding window rather than fixed buckets,
|
|
4089
|
-
* `retryAfterMs` from the oldest
|
|
4090
|
-
* calls at the limit where exactly one may pass
|
|
4091
|
-
*
|
|
5739
|
+
* `retryAfterMs` until a use frees up, from the oldest of the newest `uses` calls in the window, calls in the same
|
|
5740
|
+
* millisecond kept apart, concurrent calls at the limit where exactly one may pass, a batch refusal that names the
|
|
5741
|
+
* longest wait, and a release that frees only the call it gave back. They use real time, with windows short enough that the suite takes
|
|
5742
|
+
* a few seconds and margins wide enough that a correct store passes when timers fire late, as on a busy CI runner,
|
|
5743
|
+
* and each case counts under keys of its own. Each window is a whole number of milliseconds, as
|
|
4092
5744
|
* `@Cooldown` gives a store: it rounds its `seconds` to the millisecond.
|
|
4093
5745
|
*
|
|
4094
5746
|
* @param name - What the store is called in the report.
|
|
@@ -4105,7 +5757,7 @@ const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
|
|
|
4105
5757
|
* @group Testing
|
|
4106
5758
|
* @category Inspection
|
|
4107
5759
|
* @see {@link CooldownStore}
|
|
4108
|
-
* @see {@link https://meocord.dev/docs/4.
|
|
5760
|
+
* @see {@link https://meocord.dev/docs/4.2/recipes/cooldown-stores | Cooldown stores}
|
|
4109
5761
|
*/ function testCooldownStore(name, factory, { describe, it, expect }) {
|
|
4110
5762
|
const run = node_crypto.randomUUID();
|
|
4111
5763
|
const key = (label)=>`meocord-suite:${run}:${label}`;
|
|
@@ -4128,32 +5780,62 @@ const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
|
|
|
4128
5780
|
expect(refused.retryAfterMs).toBeLessThanOrEqual(limit.windowMs);
|
|
4129
5781
|
});
|
|
4130
5782
|
test('slides its window: a call is allowed once the oldest leaves it, not when a bucket resets', async (store)=>{
|
|
5783
|
+
// A second between calls, in a 2 s window, leaves room for a timer that fires late on a busy runner
|
|
4131
5784
|
const limit = {
|
|
4132
5785
|
uses: 2,
|
|
4133
|
-
windowMs:
|
|
5786
|
+
windowMs: 2000
|
|
4134
5787
|
};
|
|
4135
5788
|
await store.consume(key('sliding'), limit);
|
|
4136
|
-
await sleep(
|
|
5789
|
+
await sleep(1000);
|
|
4137
5790
|
await store.consume(key('sliding'), limit);
|
|
4138
5791
|
const refused = await store.consume(key('sliding'), limit);
|
|
4139
5792
|
expect(refused.allowed).toBe(false);
|
|
4140
5793
|
await sleep(refused.retryAfterMs + 40);
|
|
4141
|
-
// The first call has left the window; the second,
|
|
5794
|
+
// The first call has left the window; the second, a second younger, still holds its use.
|
|
4142
5795
|
expect((await store.consume(key('sliding'), limit)).allowed).toBe(true);
|
|
4143
5796
|
expect((await store.consume(key('sliding'), limit)).allowed).toBe(false);
|
|
4144
5797
|
});
|
|
4145
5798
|
test('counts retryAfterMs from the oldest call still in the window', async (store)=>{
|
|
4146
5799
|
const limit = {
|
|
4147
5800
|
uses: 2,
|
|
4148
|
-
windowMs:
|
|
5801
|
+
windowMs: 2000
|
|
4149
5802
|
};
|
|
5803
|
+
const firstSent = Date.now();
|
|
4150
5804
|
await store.consume(key('retry'), limit);
|
|
4151
|
-
|
|
5805
|
+
const firstDone = Date.now();
|
|
5806
|
+
await sleep(1000);
|
|
5807
|
+
const secondSent = Date.now();
|
|
4152
5808
|
await store.consume(key('retry'), limit);
|
|
4153
|
-
// From the oldest call, about 700ms remain; from the newest, about 1000ms would.
|
|
4154
5809
|
const { retryAfterMs } = await store.consume(key('retry'), limit);
|
|
4155
|
-
|
|
4156
|
-
|
|
5810
|
+
const thirdDone = Date.now();
|
|
5811
|
+
// What is left of the oldest call's window, by the times measured around each call, as timers may fire late;
|
|
5812
|
+
// counted from the newest call, nearly the whole window would be left instead
|
|
5813
|
+
expect(retryAfterMs).toBeLessThanOrEqual(limit.windowMs - (secondSent - firstDone) + CLOCK_SLACK_MS);
|
|
5814
|
+
expect(retryAfterMs).toBeGreaterThanOrEqual(limit.windowMs - (thirdDone - firstSent) - CLOCK_SLACK_MS);
|
|
5815
|
+
});
|
|
5816
|
+
// @Cooldown keeps the calls counted so far when a limit is lowered, so a key can hold more calls than `uses`
|
|
5817
|
+
test('counts retryAfterMs from the oldest of the newest `uses` calls when a lowered limit leaves more in the window', async (store)=>{
|
|
5818
|
+
const windowMs = 2000;
|
|
5819
|
+
for(let call = 0; call < 3; call += 1)await store.consume(key('lowered'), {
|
|
5820
|
+
uses: 5,
|
|
5821
|
+
windowMs
|
|
5822
|
+
});
|
|
5823
|
+
await sleep(1000);
|
|
5824
|
+
const newerSent = Date.now();
|
|
5825
|
+
for(let call = 0; call < 2; call += 1)await store.consume(key('lowered'), {
|
|
5826
|
+
uses: 5,
|
|
5827
|
+
windowMs
|
|
5828
|
+
});
|
|
5829
|
+
const { allowed, retryAfterMs } = await store.consume(key('lowered'), {
|
|
5830
|
+
uses: 2,
|
|
5831
|
+
windowMs
|
|
5832
|
+
});
|
|
5833
|
+
const refusedDone = Date.now();
|
|
5834
|
+
expect(allowed).toBe(false);
|
|
5835
|
+
// A use frees up when the older of the two newest calls leaves the window; counted from the oldest call, it would
|
|
5836
|
+
// be a second sooner
|
|
5837
|
+
expect(retryAfterMs).toBeGreaterThanOrEqual(windowMs - (refusedDone - newerSent) - CLOCK_SLACK_MS);
|
|
5838
|
+
expect(retryAfterMs).toBeLessThanOrEqual(windowMs + CLOCK_SLACK_MS);
|
|
4157
5839
|
});
|
|
4158
5840
|
test('counts each key on its own', async (store)=>{
|
|
4159
5841
|
const limit = {
|
|
@@ -4216,6 +5898,41 @@ const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
|
|
|
4216
5898
|
expect(refused.retryAfterMs).toBeGreaterThan(short.windowMs);
|
|
4217
5899
|
expect(refused.retryAfterMs).toBeLessThanOrEqual(long.windowMs);
|
|
4218
5900
|
});
|
|
5901
|
+
// A refusal names the wait until every cooldown of the batch allows the call; the default consumeMany stops at the first
|
|
5902
|
+
test('names the longest wait when several cooldowns refuse a batch, or with the default consumeMany, the first', async (store)=>{
|
|
5903
|
+
const short = {
|
|
5904
|
+
uses: 1,
|
|
5905
|
+
windowMs: 1000
|
|
5906
|
+
};
|
|
5907
|
+
const long = {
|
|
5908
|
+
uses: 1,
|
|
5909
|
+
windowMs: 2000
|
|
5910
|
+
};
|
|
5911
|
+
const batch = [
|
|
5912
|
+
{
|
|
5913
|
+
key: key('both-short'),
|
|
5914
|
+
limit: short
|
|
5915
|
+
},
|
|
5916
|
+
{
|
|
5917
|
+
key: key('both-long'),
|
|
5918
|
+
limit: long
|
|
5919
|
+
}
|
|
5920
|
+
];
|
|
5921
|
+
await store.consume(batch[0].key, short);
|
|
5922
|
+
await store.consume(batch[1].key, long);
|
|
5923
|
+
const refused = await store.consumeMany(batch);
|
|
5924
|
+
expect(refused.allowed).toBe(false);
|
|
5925
|
+
const [blocked, limit] = overridesConsumeMany(store) ? [
|
|
5926
|
+
1,
|
|
5927
|
+
long
|
|
5928
|
+
] : [
|
|
5929
|
+
0,
|
|
5930
|
+
short
|
|
5931
|
+
];
|
|
5932
|
+
expect(refused.blocked).toBe(blocked);
|
|
5933
|
+
expect(refused.retryAfterMs).toBeGreaterThan(limit.windowMs - 1000);
|
|
5934
|
+
expect(refused.retryAfterMs).toBeLessThanOrEqual(limit.windowMs);
|
|
5935
|
+
});
|
|
4219
5936
|
// The default consumeMany counts in order and stops at the first refusal, so the entries before it count
|
|
4220
5937
|
test('records nothing when one cooldown of a batch refuses it, or with the default consumeMany, counts those before it', async (store)=>{
|
|
4221
5938
|
const limit = {
|
|
@@ -4258,10 +5975,11 @@ const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
|
|
|
4258
5975
|
}, ()=>store.consumeMany(batch)));
|
|
4259
5976
|
expect(verdicts.filter((verdict)=>verdict.allowed).length).toBe(1);
|
|
4260
5977
|
});
|
|
4261
|
-
// A store without release keeps a counted call counted, as the default consumeMany does
|
|
4262
|
-
|
|
5978
|
+
// A store without release keeps a counted call counted, as the default consumeMany does. Two uses tell a release of
|
|
5979
|
+
// the one call apart from one that frees the key's other calls too
|
|
5980
|
+
test('releases only the call it counted when the verdict gives release, or keeps it counted without one', async (store)=>{
|
|
4263
5981
|
const limit = {
|
|
4264
|
-
uses:
|
|
5982
|
+
uses: 2,
|
|
4265
5983
|
windowMs: 2000
|
|
4266
5984
|
};
|
|
4267
5985
|
const entries = [
|
|
@@ -4274,6 +5992,7 @@ const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
|
|
|
4274
5992
|
limit
|
|
4275
5993
|
}
|
|
4276
5994
|
];
|
|
5995
|
+
expect((await store.consumeMany(entries)).allowed).toBe(true);
|
|
4277
5996
|
const counted = await store.consumeMany(entries);
|
|
4278
5997
|
expect(counted.allowed).toBe(true);
|
|
4279
5998
|
if (!counted.release) {
|
|
@@ -4501,7 +6220,7 @@ const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
|
|
|
4501
6220
|
* @group Testing
|
|
4502
6221
|
* @category Inspection
|
|
4503
6222
|
* @see {@link createExecutionContext}
|
|
4504
|
-
* @see {@link https://meocord.dev/docs/4.
|
|
6223
|
+
* @see {@link https://meocord.dev/docs/4.2/testing-recipes | Testing recipes}
|
|
4505
6224
|
*/ function inspectHandler(controller, methodName, options = {}) {
|
|
4506
6225
|
const context = new controller_decorator.HandlerExecutionContext({
|
|
4507
6226
|
controller,
|
|
@@ -4510,7 +6229,7 @@ const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
|
|
|
4510
6229
|
});
|
|
4511
6230
|
const globals = options.app ? providers.appStages(options.app) : undefined;
|
|
4512
6231
|
const { guards, interceptors, filters } = providers.handlerStages(controller.prototype, methodName, globals);
|
|
4513
|
-
|
|
6232
|
+
const inspection = {
|
|
4514
6233
|
controller,
|
|
4515
6234
|
methodName,
|
|
4516
6235
|
guards: Object.freeze([
|
|
@@ -4532,6 +6251,23 @@ const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
|
|
|
4532
6251
|
get: (metadata)=>context.get(metadata),
|
|
4533
6252
|
getAll: (metadata)=>context.getAll(metadata)
|
|
4534
6253
|
};
|
|
6254
|
+
// Read by a test but not compared by toEqual, so an assertion on the result's other fields holds
|
|
6255
|
+
Object.defineProperty(inspection, 'inheritedRoutes', {
|
|
6256
|
+
value: Object.freeze(inheritedRoutesOf(controller.prototype, methodName)),
|
|
6257
|
+
enumerable: false
|
|
6258
|
+
});
|
|
6259
|
+
return inspection;
|
|
6260
|
+
}
|
|
6261
|
+
/** The routes `methodName` answers that the class declaring it last doesn't declare for it: those it inherits. */ function inheritedRoutesOf(prototype, methodName) {
|
|
6262
|
+
const answered = [
|
|
6263
|
+
...new Set(controller_decorator.getHandlerRoutes(prototype).filter((route)=>route.method === methodName).map((route)=>route.label))
|
|
6264
|
+
];
|
|
6265
|
+
// The nearest class with its own decorators on the method; a class that only overrides it declares none
|
|
6266
|
+
for(let at = prototype; at && at !== Object.prototype; at = Object.getPrototypeOf(at)){
|
|
6267
|
+
const own = controller_decorator.getDeclaredRoutes(at).filter((route)=>route.method === methodName).map((route)=>route.label);
|
|
6268
|
+
if (own.length > 0) return at === prototype ? answered.filter((label)=>!own.includes(label)) : answered;
|
|
6269
|
+
}
|
|
6270
|
+
return [];
|
|
4535
6271
|
}
|
|
4536
6272
|
|
|
4537
6273
|
// Themes createMockTheme made, which withTheme uses as they are
|
|
@@ -4568,7 +6304,7 @@ function mockTheme(overrides, where) {
|
|
|
4568
6304
|
* @group Testing
|
|
4569
6305
|
* @category Mocks
|
|
4570
6306
|
* @see {@link withTheme}
|
|
4571
|
-
* @see {@link https://meocord.dev/docs/4.
|
|
6307
|
+
* @see {@link https://meocord.dev/docs/4.2/theming | Theming}
|
|
4572
6308
|
*/ function createMockTheme(...[overrides]) {
|
|
4573
6309
|
return mockTheme(overrides, 'createMockTheme');
|
|
4574
6310
|
}
|
|
@@ -4615,6 +6351,7 @@ exports.createMockGuild = createMockGuild;
|
|
|
4615
6351
|
exports.createMockInteraction = createMockInteraction;
|
|
4616
6352
|
exports.createMockMember = createMockMember;
|
|
4617
6353
|
exports.createMockMessage = createMockMessage;
|
|
6354
|
+
exports.createMockRawMember = createMockRawMember;
|
|
4618
6355
|
exports.createMockTheme = createMockTheme;
|
|
4619
6356
|
exports.createMockUser = createMockUser;
|
|
4620
6357
|
exports.createModalFields = createModalFields;
|
|
@@ -4623,7 +6360,10 @@ exports.findRouteConflicts = findRouteConflicts;
|
|
|
4623
6360
|
exports.getResponse = getResponse;
|
|
4624
6361
|
exports.inspectHandler = inspectHandler;
|
|
4625
6362
|
exports.isMockFunction = isMockFunction;
|
|
6363
|
+
exports.reportAllStartupErrors = reportAllStartupErrors;
|
|
4626
6364
|
exports.resetAllMocks = resetAllMocks;
|
|
4627
6365
|
exports.resolveRoute = resolveRoute;
|
|
4628
6366
|
exports.testCooldownStore = testCooldownStore;
|
|
6367
|
+
exports.useMockFn = useMockFn;
|
|
6368
|
+
exports.useStrictMocks = useStrictMocks;
|
|
4629
6369
|
exports.withTheme = withTheme;
|