meocord 4.2.0 → 4.2.2
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 +322 -0
- package/THIRD_PARTY_NOTICES.md +14 -13
- package/dist/cjs/_shared/{controller.decorator-CRn9kWob.cjs → controller.decorator-U2NBWcoU.cjs} +416 -108
- package/dist/cjs/_shared/{providers-ChvMqnLd.cjs → providers-B4MOFBrW.cjs} +202 -50
- package/dist/cjs/_shared/{sharded-cooldown-store-BdAUTdYs.cjs → sharded-cooldown-store-HTUb3Ii_.cjs} +10 -7
- package/dist/cjs/_shared/{startup-roots-CQo8mSP8.cjs → startup-roots-lA5xkBFq.cjs} +228 -71
- package/dist/cjs/_shared/{translate-error-DhDVbT5M.cjs → translate-error-CbBHUu5P.cjs} +10 -5
- package/dist/cjs/common/index.cjs +3 -3
- package/dist/cjs/core/index.cjs +188 -169
- package/dist/cjs/decorator/index.cjs +52 -7
- package/dist/cjs/testing/index.cjs +1854 -311
- package/dist/cli.json +1 -1
- 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 +13 -3
- 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/errors.js +25 -0
- package/dist/esm/common/meocord-text.js +11 -8
- 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 +89 -15
- package/dist/esm/common/route.js +11 -4
- package/dist/esm/common/sharded-cooldown-store.js +1 -1
- package/dist/esm/common/translator.js +52 -11
- package/dist/esm/core/command-registration.js +93 -24
- package/dist/esm/core/command-routes.js +2 -2
- package/dist/esm/core/cooldown-runner.js +29 -8
- package/dist/esm/core/dispatcher.js +9 -8
- 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 +10 -4
- package/dist/esm/core/lifecycle-hooks.js +134 -47
- package/dist/esm/core/meocord-factory.js +59 -34
- package/dist/esm/core/meocord.app.js +116 -75
- package/dist/esm/core/message-help.js +32 -6
- package/dist/esm/core/observer-runner.js +2 -1
- package/dist/esm/core/providers.js +19 -4
- package/dist/esm/core/running-calls.js +43 -0
- package/dist/esm/core/shard-context.js +5 -6
- package/dist/esm/core/shard-manager.js +16 -10
- package/dist/esm/core/stage-scope.js +2 -1
- package/dist/esm/core/startup-roots.js +21 -1
- package/dist/esm/core/theme-resolvers.js +4 -1
- package/dist/esm/core/theme-scope.js +7 -2
- package/dist/esm/core/theme-validation.js +17 -10
- package/dist/esm/decorator/app.decorator.js +31 -0
- package/dist/esm/decorator/controller.decorator.js +56 -20
- package/dist/esm/decorator/cooldown.decorator.js +17 -0
- package/dist/esm/decorator/filter.decorator.js +2 -2
- package/dist/esm/decorator/guard.decorator.js +3 -2
- package/dist/esm/package.json.js +1 -1
- package/dist/esm/testing/catalog.js +20 -13
- package/dist/esm/testing/cooldown-store-suite.js +67 -5
- package/dist/esm/testing/discord-defaults.js +225 -30
- package/dist/esm/testing/meocord-testing-module.js +158 -44
- package/dist/esm/testing/mock-fn.js +107 -26
- package/dist/esm/testing/mock-interaction.js +1250 -182
- package/dist/esm/testing/snowflake.js +9 -2
- package/dist/esm/testing/strict-mocks.js +40 -17
- 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 +2 -2
- package/dist/esm/util/meocord-config-loader.util.js +10 -2
- package/dist/esm/util/meocord-config-validation.util.js +3 -5
- package/dist/esm/util/meocord-source-config.util.js +65 -20
- package/dist/esm/util/method-wrapper.util.js +16 -0
- package/dist/esm/util/platform.util.js +10 -10
- 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 +30 -2
- package/dist/types/common/index.d.cts +10 -7
- package/dist/types/common/index.d.ts +10 -7
- package/dist/types/core/index.d.cts +3 -2
- package/dist/types/core/index.d.ts +3 -2
- package/dist/types/decorator/index.d.cts +1 -0
- package/dist/types/decorator/index.d.ts +1 -0
- package/dist/types/{index-CdXsFgjE.d.ts → index-DKJclIi-.d.ts} +45 -21
- package/dist/types/{index-nn5p7OoX.d.cts → index-Dlf0ZZtS.d.cts} +45 -21
- package/dist/types/interface/index.d.cts +1 -1
- package/dist/types/interface/index.d.ts +1 -1
- package/dist/types/{route-BZgFoNeB.d.cts → route-Bh1U13yy.d.cts} +3 -2
- package/dist/types/{route-BZgFoNeB.d.ts → route-Bh1U13yy.d.ts} +3 -2
- package/dist/types/testing/index.d.cts +198 -62
- package/dist/types/testing/index.d.ts +198 -62
- package/dist/types/{theme-resolvers-UKr3-6Ys.d.cts → theme-resolvers-CyEz9pnv.d.cts} +1 -1
- package/dist/types/{theme-resolvers-BcOJVzR9.d.ts → theme-resolvers-DA204O-q.d.ts} +1 -1
- package/dist/types/{translator-DHMkkCpg.d.cts → translator-Bg7hwxDF.d.cts} +21 -8
- package/dist/types/{translator-BViWKtLJ.d.ts → translator-CeP4bCzp.d.ts} +21 -8
- package/package.json +2 -2
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
require('reflect-metadata');
|
|
4
4
|
var inversify = require('inversify');
|
|
5
|
-
var providers = require('../_shared/providers-
|
|
5
|
+
var providers = require('../_shared/providers-B4MOFBrW.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-U2NBWcoU.cjs');
|
|
9
|
+
var startupRoots = require('../_shared/startup-roots-lA5xkBFq.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-CbBHUu5P.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,13 +47,20 @@ require('../_shared/translate-error-DhDVbT5M.cjs');
|
|
|
49
47
|
*/ function isMockFunction(fn) {
|
|
50
48
|
return typeof fn === 'function' && '_isMockFunction' in fn && fn._isMockFunction === true;
|
|
51
49
|
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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));
|
|
59
64
|
/**
|
|
60
65
|
* Makes every mock `meocord/testing` creates with the test runner's own mock function, such as Vitest's `vi.fn`.
|
|
61
66
|
*
|
|
@@ -86,26 +91,36 @@ const restoreImpl = new WeakMap();
|
|
|
86
91
|
* @category Mocks
|
|
87
92
|
* @see {@link createMockFn}
|
|
88
93
|
*/ function useMockFn(factory) {
|
|
89
|
-
if (factory === runnerFactory) return;
|
|
94
|
+
if (factory === mocks$1.runnerFactory) return;
|
|
90
95
|
if ([
|
|
91
|
-
...created
|
|
96
|
+
...mocks$1.created
|
|
92
97
|
].some((ref)=>ref.deref() !== undefined)) {
|
|
93
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.");
|
|
94
99
|
}
|
|
95
|
-
const
|
|
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
|
+
}
|
|
96
110
|
const api = [
|
|
97
111
|
'mockImplementation',
|
|
98
112
|
'mockClear',
|
|
99
113
|
'mockReset'
|
|
100
114
|
];
|
|
101
115
|
if (!isMockFunction(probe) || !Array.isArray(probe.mock?.calls) || api.some((name)=>typeof probe[name] !== 'function')) {
|
|
102
|
-
throw
|
|
116
|
+
throw refusal();
|
|
103
117
|
}
|
|
104
|
-
runnerFactory = factory;
|
|
118
|
+
mocks$1.runnerFactory = factory;
|
|
105
119
|
}
|
|
120
|
+
/** Whether mocks are made with a runner's function, which {@link useMockFn} registered. */ const usesRunnerMockFn = ()=>mocks$1.runnerFactory !== undefined;
|
|
106
121
|
/** Tracks a mock for clearAllMocks and resetAllMocks. */ function track(mockFn) {
|
|
107
122
|
const ref = new WeakRef(mockFn);
|
|
108
|
-
created.add(ref);
|
|
123
|
+
mocks$1.created.add(ref);
|
|
109
124
|
collected.register(mockFn, ref);
|
|
110
125
|
return mockFn;
|
|
111
126
|
}
|
|
@@ -128,7 +143,7 @@ const restoreImpl = new WeakMap();
|
|
|
128
143
|
* @category Mocks
|
|
129
144
|
* @see {@link resetAllMocks}
|
|
130
145
|
*/ function clearAllMocks() {
|
|
131
|
-
for (const ref of created)ref.deref()?.mockClear();
|
|
146
|
+
for (const ref of mocks$1.created)ref.deref()?.mockClear();
|
|
132
147
|
}
|
|
133
148
|
/**
|
|
134
149
|
* Clears every mock from `meocord/testing`, and puts each back to the implementation it was created with.
|
|
@@ -151,12 +166,12 @@ const restoreImpl = new WeakMap();
|
|
|
151
166
|
* @category Mocks
|
|
152
167
|
* @see {@link clearAllMocks}
|
|
153
168
|
*/ function resetAllMocks() {
|
|
154
|
-
for (const ref of created){
|
|
169
|
+
for (const ref of mocks$1.created){
|
|
155
170
|
const mockFn = ref.deref();
|
|
156
171
|
if (!mockFn) continue;
|
|
157
172
|
mockFn.mockReset();
|
|
158
173
|
// A runner whose reset drops the implementation the mock was created with, as jest's and bun's do, gets it back
|
|
159
|
-
restoreImpl.get(mockFn)?.();
|
|
174
|
+
mocks$1.restoreImpl.get(mockFn)?.();
|
|
160
175
|
}
|
|
161
176
|
}
|
|
162
177
|
/**
|
|
@@ -170,6 +185,14 @@ const restoreImpl = new WeakMap();
|
|
|
170
185
|
* `mockReturnValue`, `mockResolvedValue`, `mockRejectedValue` and `mockImplementation` share one implementation, so
|
|
171
186
|
* the last one set wins. The `*Once` variants queue, and run first, in the order they were set.
|
|
172
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
|
+
*
|
|
173
196
|
* @param impl - The implementation to run until another is set.
|
|
174
197
|
*
|
|
175
198
|
* @example
|
|
@@ -187,11 +210,12 @@ const restoreImpl = new WeakMap();
|
|
|
187
210
|
* @category Mocks
|
|
188
211
|
* @see {@link MockInstance}
|
|
189
212
|
*/ function createMockFn(impl) {
|
|
213
|
+
const { runnerFactory } = mocks$1;
|
|
190
214
|
if (runnerFactory) {
|
|
191
215
|
const runnerMock = runnerFactory(impl);
|
|
192
216
|
if (impl) {
|
|
193
217
|
const setImpl = runnerMock.mockImplementation.bind(runnerMock);
|
|
194
|
-
restoreImpl.set(runnerMock, ()=>setImpl(impl));
|
|
218
|
+
mocks$1.restoreImpl.set(runnerMock, ()=>setImpl(impl));
|
|
195
219
|
}
|
|
196
220
|
return track(runnerMock);
|
|
197
221
|
}
|
|
@@ -204,9 +228,20 @@ const restoreImpl = new WeakMap();
|
|
|
204
228
|
const calls = [];
|
|
205
229
|
const results = [];
|
|
206
230
|
const instances = [];
|
|
231
|
+
const settledResults = [];
|
|
232
|
+
const invocationCallOrder = [];
|
|
233
|
+
const contexts = [];
|
|
207
234
|
const mockFn = function(...args) {
|
|
208
235
|
calls.push(args);
|
|
209
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);
|
|
210
245
|
let type = 'return';
|
|
211
246
|
let value;
|
|
212
247
|
try {
|
|
@@ -217,10 +252,36 @@ const restoreImpl = new WeakMap();
|
|
|
217
252
|
} else {
|
|
218
253
|
value = undefined;
|
|
219
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
|
+
}
|
|
220
277
|
return value;
|
|
221
278
|
} catch (err) {
|
|
222
279
|
type = 'throw';
|
|
223
280
|
value = err;
|
|
281
|
+
Object.assign(settled, {
|
|
282
|
+
type: 'rejected',
|
|
283
|
+
value: err
|
|
284
|
+
});
|
|
224
285
|
throw err;
|
|
225
286
|
} finally{
|
|
226
287
|
results.push({
|
|
@@ -248,6 +309,18 @@ const restoreImpl = new WeakMap();
|
|
|
248
309
|
return calls.length > 0 ? calls[calls.length - 1] : undefined;
|
|
249
310
|
}
|
|
250
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
|
+
});
|
|
251
324
|
Object.defineProperty(mockFn, 'mock', {
|
|
252
325
|
value: mock,
|
|
253
326
|
enumerable: false
|
|
@@ -284,16 +357,22 @@ const restoreImpl = new WeakMap();
|
|
|
284
357
|
onceQueue.push(fn);
|
|
285
358
|
return mockFn;
|
|
286
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
|
+
};
|
|
287
370
|
mockFn.mockClear = ()=>{
|
|
288
|
-
|
|
289
|
-
results.length = 0;
|
|
290
|
-
instances.length = 0;
|
|
371
|
+
forgetCalls();
|
|
291
372
|
return mockFn;
|
|
292
373
|
};
|
|
293
374
|
mockFn.mockReset = ()=>{
|
|
294
|
-
|
|
295
|
-
results.length = 0;
|
|
296
|
-
instances.length = 0;
|
|
375
|
+
forgetCalls();
|
|
297
376
|
onceQueue = [];
|
|
298
377
|
currentImpl = impl;
|
|
299
378
|
return mockFn;
|
|
@@ -407,29 +486,62 @@ const VISIBLE = new Set([
|
|
|
407
486
|
}, code, 400, 'POST', 'https://discord.com/api/v10/interactions', {});
|
|
408
487
|
}
|
|
409
488
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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;
|
|
414
507
|
/** Notes that a mock was made, which useStrictMocks() must come before. */ function noteMockMade() {
|
|
415
|
-
mockMade = true;
|
|
508
|
+
settings.mockMade = true;
|
|
416
509
|
}
|
|
417
510
|
/**
|
|
418
511
|
* Has every mock from `meocord/testing` compute the values discord.js computes, where it reads a placeholder otherwise.
|
|
419
512
|
*
|
|
420
513
|
* Call it once in the test runner's setup file. A message's `editable`, `deletable`, `pinnable`, `crosspostable`,
|
|
421
514
|
* `bulkDeletable`, `hasThread` and `partial`, a member's `manageable`, `kickable`, `bannable` and `moderatable`, a
|
|
422
|
-
* role's `editable`, a channel's and a thread's `viewable`, `manageable`, `deletable`, `joinable` and the rest,
|
|
423
|
-
* `partial` on users, channels and reactions then run discord.js's own getters against the mock,
|
|
424
|
-
* is `null` when the channel caches no thread under the message's id.
|
|
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`, an interaction's `ephemeral` is `null` until `reply()` or
|
|
521
|
+
* `deferReply()` decides it, and a reaction has a whole message of its own, so MeoCord's dispatcher fetches neither. No
|
|
522
|
+
* placeholder warning is logged.
|
|
425
523
|
*
|
|
426
524
|
* @remarks
|
|
427
|
-
* Those getters read what a server holds, so strict mocks also have it as Discord sends it: the bot's member is in
|
|
428
|
-
*
|
|
429
|
-
*
|
|
430
|
-
*
|
|
431
|
-
* a
|
|
432
|
-
*
|
|
525
|
+
* Those getters read what a server holds, so strict mocks also have it as Discord sends it: the bot's member is in the
|
|
526
|
+
* server's member cache from the start, @everyone has the permissions Discord gives it in a new server, and a channel
|
|
527
|
+
* or thread made without a server has one of its own, a thread with a text channel as its parent. A message has empty
|
|
528
|
+
* `content`, a thread is neither archived nor locked, and a reaction's `count` is 1. A user, server or channel made
|
|
529
|
+
* with a generated id was created when the mock was made, as a message is. An interaction's `memberPermissions` apply
|
|
530
|
+
* its channel's overwrites, a thread's parent's for a thread, to its member's permissions. An interaction is in its
|
|
531
|
+
* 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.
|
|
532
|
+
* 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
|
|
533
|
+
* 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
|
|
534
|
+
* one. An interaction's answer that discord.js would refuse, such as a second `reply()` after one a test set with
|
|
535
|
+
* `mockResolvedValue`, is refused too, where without it the mock runs it and warns. An answer marks the interaction
|
|
536
|
+
* `replied`, `deferred` or `responded` once it resolves, as discord.js does, so code that doesn't await one fails as it
|
|
537
|
+
* would on a bot, within one run of the test's code. The next major version (5.0) computes these values, and refuses
|
|
538
|
+
* those answers, without the call.
|
|
539
|
+
*
|
|
540
|
+
* `invoke` also refuses a call that gives a handler fewer arguments than it declares where it builds none, such as a
|
|
541
|
+
* reaction handler given its reaction without its `ReactionEvent`; without strict mocks, it warns once and calls it.
|
|
542
|
+
*
|
|
543
|
+
* `compile()` also refuses an `overrideGuard`, `overrideInterceptor` or `overrideFilter` stub without the stage's
|
|
544
|
+
* method, `canActivate`, `intercept` or `catch`; without strict mocks, it warns and builds the module.
|
|
433
545
|
*
|
|
434
546
|
* @throws Error when a mock already exists, which was made without it.
|
|
435
547
|
*
|
|
@@ -444,16 +556,69 @@ let mockMade = false;
|
|
|
444
556
|
* @see {@link useMockFn}
|
|
445
557
|
* @see {@link https://meocord.dev/docs/4.2/mocks | Mocks}
|
|
446
558
|
*/ function useStrictMocks() {
|
|
447
|
-
if (strict) return;
|
|
448
|
-
if (mockMade) {
|
|
559
|
+
if (settings.strict) return;
|
|
560
|
+
if (settings.mockMade) {
|
|
449
561
|
throw new Error('useStrictMocks() goes before any mock is made: a mock made before it reads placeholders, and the two kinds would ' + "mix. Call it once, in the test runner's setup file.");
|
|
450
562
|
}
|
|
451
|
-
strict = true;
|
|
563
|
+
settings.strict = true;
|
|
452
564
|
}
|
|
453
565
|
|
|
566
|
+
const mockLogger$1 = new controller_decorator.Logger('Mocks');
|
|
567
|
+
/**
|
|
568
|
+
* A member's permissions where an interaction was made, as Discord computes them: under strict mocks, every permission
|
|
569
|
+
* for an Administrator or the owner, else the channel's overwrites on top of `member.permissions`, so a test that sets
|
|
570
|
+
* those keeps the base; otherwise `member.permissions`, with a warning once where strict mocks would read otherwise.
|
|
571
|
+
*/ function inChannel(interaction, member) {
|
|
572
|
+
const base = member.permissions;
|
|
573
|
+
// Only a server the mock caches has the overwrites to apply
|
|
574
|
+
const { guild } = interaction;
|
|
575
|
+
if (!(guild instanceof discord_js.Guild)) return base;
|
|
576
|
+
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);
|
|
577
|
+
if (strictMocks()) return computed;
|
|
578
|
+
if (!computed.equals(base)) {
|
|
579
|
+
const lost = new discord_js.PermissionsBitField(base).remove(computed).toArray();
|
|
580
|
+
const gained = new discord_js.PermissionsBitField(computed).remove(base).toArray();
|
|
581
|
+
const change = lost.length > 0 ? [
|
|
582
|
+
`without ${lost.join(', ')}`
|
|
583
|
+
] : [];
|
|
584
|
+
if (gained.length > 0) change.push(`with ${gained.join(', ')}`);
|
|
585
|
+
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.');
|
|
586
|
+
}
|
|
587
|
+
// Unless the test set them, a member's permissions come from its roles, @everyone's included
|
|
588
|
+
if (!Object.prototype.hasOwnProperty.call(member, 'permissions')) warnOnUnsetEveryone(interaction, guild, 'memberPermissions', base);
|
|
589
|
+
return base;
|
|
590
|
+
}
|
|
591
|
+
/**
|
|
592
|
+
* Warns once, as `key`, where `permissions` read a server's @everyone role default mode made with none, and lack what
|
|
593
|
+
* strict mocks give @everyone in a new server.
|
|
594
|
+
*/ function warnOnUnsetEveryone(interaction, guild, key, permissions) {
|
|
595
|
+
const everyone = guild.roles.everyone;
|
|
596
|
+
if (!everyone || !isUnsetEveryone(everyone) || permissions.has(NEW_SERVER_EVERYONE_PERMISSIONS)) return;
|
|
597
|
+
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.');
|
|
598
|
+
}
|
|
599
|
+
/**
|
|
600
|
+
* The channel whose overwrites apply where an interaction was made, read without making or caching one: a thread's
|
|
601
|
+
* parent, or undefined where the channel, or a thread's parent, is one the mock would make, which has none.
|
|
602
|
+
*/ function overwritingChannel(interaction, guild) {
|
|
603
|
+
const channel = heldChannelOf(interaction, guild);
|
|
604
|
+
// 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
|
|
605
|
+
const parentId = (thread)=>Object.prototype.hasOwnProperty.call(thread, 'parentId') ? thread.parentId : madeParentIds.get(thread);
|
|
606
|
+
const at = channel instanceof discord_js.ThreadChannel ? guild.channels.cache.get(parentId(channel) ?? '') : channel;
|
|
607
|
+
return at instanceof discord_js.GuildChannel ? at : undefined;
|
|
608
|
+
}
|
|
609
|
+
// The parent id strict mocks made for each thread made without one, once read
|
|
610
|
+
const madeParentIds = new WeakMap();
|
|
611
|
+
// Private in discord.js's typings, though they are what its own permissionsFor computes with; called on the prototype
|
|
612
|
+
// so a test's stub on the channel stays for its caller
|
|
613
|
+
const channelPermissions = discord_js.GuildChannel.prototype;
|
|
614
|
+
/** `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) {
|
|
615
|
+
if (!channel) return base;
|
|
616
|
+
// Given the member and its roles as verified, as discord.js's memberPermissions does, so it resolves nothing again
|
|
617
|
+
const overwrites = channelPermissions.overwritesFor.call(channel, member, true, member.roles.cache);
|
|
618
|
+
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();
|
|
619
|
+
}
|
|
454
620
|
/** A permission set with none, frozen as discord.js freezes the ones it gives. */ const noPermissions = ()=>new discord_js.PermissionsBitField().freeze();
|
|
455
621
|
/** A property discord.js computes from the others: its own getter runs, reading the mock's values. */ const REAL_GETTER = Symbol('real getter');
|
|
456
|
-
const snowflake = ()=>discord_js.SnowflakeUtil.generate().toString();
|
|
457
622
|
/** A class and its defaults, typed together so a default that stops fitting its property fails to compile. */ const defaultsOf = (Class, defaults)=>[
|
|
458
623
|
Class.prototype,
|
|
459
624
|
defaults
|
|
@@ -466,7 +631,7 @@ const snowflake = ()=>discord_js.SnowflakeUtil.generate().toString();
|
|
|
466
631
|
defaultsOf(discord_js.Guild, {
|
|
467
632
|
name: ()=>'Guild',
|
|
468
633
|
preferredLocale: ()=>discord_js.Locale.EnglishUS,
|
|
469
|
-
ownerId:
|
|
634
|
+
ownerId: nextSnowflake,
|
|
470
635
|
memberCount: ()=>1,
|
|
471
636
|
available: ()=>true,
|
|
472
637
|
large: ()=>false,
|
|
@@ -479,6 +644,9 @@ const snowflake = ()=>discord_js.SnowflakeUtil.generate().toString();
|
|
|
479
644
|
shardId: ()=>0,
|
|
480
645
|
nsfwLevel: ()=>discord_js.GuildNSFWLevel.Default,
|
|
481
646
|
verificationLevel: ()=>discord_js.GuildVerificationLevel.None,
|
|
647
|
+
presences: (guild)=>ownedManager(discord_js.PresenceManager.prototype, {
|
|
648
|
+
guild
|
|
649
|
+
}),
|
|
482
650
|
premiumTier: ()=>discord_js.GuildPremiumTier.None,
|
|
483
651
|
createdTimestamp: REAL_GETTER,
|
|
484
652
|
createdAt: REAL_GETTER
|
|
@@ -493,6 +661,7 @@ const snowflake = ()=>discord_js.SnowflakeUtil.generate().toString();
|
|
|
493
661
|
discriminator: ()=>'0',
|
|
494
662
|
avatar: ()=>null,
|
|
495
663
|
system: ()=>false,
|
|
664
|
+
flags: ()=>new discord_js.UserFlagsBitField(),
|
|
496
665
|
tag: REAL_GETTER,
|
|
497
666
|
displayName: REAL_GETTER,
|
|
498
667
|
defaultAvatarURL: REAL_GETTER,
|
|
@@ -513,12 +682,32 @@ const snowflake = ()=>discord_js.SnowflakeUtil.generate().toString();
|
|
|
513
682
|
premiumSinceTimestamp: ()=>null,
|
|
514
683
|
communicationDisabledUntilTimestamp: ()=>null,
|
|
515
684
|
pending: ()=>false,
|
|
685
|
+
flags: ()=>new discord_js.GuildMemberFlagsBitField().freeze(),
|
|
516
686
|
displayName: REAL_GETTER,
|
|
517
687
|
roles: (member)=>memberRoles(member, []),
|
|
518
688
|
permissions: REAL_GETTER
|
|
519
689
|
}),
|
|
690
|
+
// A server's emoji, sticker, scheduled event and AutoMod rule are named, and what discord.js formats from them
|
|
691
|
+
// computes: an emoji's mention and identifier, an event's and an invite's link
|
|
692
|
+
defaultsOf(discord_js.GuildEmoji, {
|
|
693
|
+
name: ()=>'emoji',
|
|
694
|
+
animated: ()=>false,
|
|
695
|
+
identifier: REAL_GETTER
|
|
696
|
+
}),
|
|
697
|
+
defaultsOf(discord_js.Sticker, {
|
|
698
|
+
name: ()=>'sticker'
|
|
699
|
+
}),
|
|
700
|
+
defaultsOf(discord_js.GuildScheduledEvent, {
|
|
701
|
+
name: ()=>'event',
|
|
702
|
+
url: REAL_GETTER
|
|
703
|
+
}),
|
|
704
|
+
defaultsOf(discord_js.AutoModerationRule, {
|
|
705
|
+
name: ()=>'rule'
|
|
706
|
+
}),
|
|
707
|
+
defaultsOf(discord_js.Invite, {
|
|
708
|
+
url: REAL_GETTER
|
|
709
|
+
}),
|
|
520
710
|
defaultsOf(discord_js.Role, {
|
|
521
|
-
id: snowflake,
|
|
522
711
|
name: ()=>'role',
|
|
523
712
|
// Above @everyone, which a server's role manager makes at position 0
|
|
524
713
|
position: ()=>1,
|
|
@@ -556,37 +745,71 @@ const snowflake = ()=>discord_js.SnowflakeUtil.generate().toString();
|
|
|
556
745
|
poll: ()=>null,
|
|
557
746
|
interactionMetadata: ()=>null,
|
|
558
747
|
stickers: ()=>new discord_js.Collection(),
|
|
748
|
+
messageSnapshots: ()=>new discord_js.Collection(),
|
|
749
|
+
reactions: (message)=>ownedManager(discord_js.ReactionManager.prototype, {
|
|
750
|
+
message
|
|
751
|
+
}),
|
|
559
752
|
url: REAL_GETTER
|
|
560
753
|
}),
|
|
561
754
|
// A command's name and a component's customId say which handler a call is for, so they stay the test's to give
|
|
755
|
+
defaultsOf(discord_js.ChatInputCommandInteraction, {
|
|
756
|
+
options: ()=>createChatInputOptions()
|
|
757
|
+
}),
|
|
562
758
|
defaultsOf(discord_js.CommandInteraction, {
|
|
563
|
-
commandId:
|
|
759
|
+
commandId: nextSnowflake,
|
|
564
760
|
commandGuildId: ()=>null
|
|
565
761
|
}),
|
|
566
762
|
defaultsOf(discord_js.AutocompleteInteraction, {
|
|
567
|
-
commandId:
|
|
568
|
-
commandGuildId: ()=>null
|
|
763
|
+
commandId: nextSnowflake,
|
|
764
|
+
commandGuildId: ()=>null,
|
|
765
|
+
options: ()=>createChatInputOptions()
|
|
766
|
+
}),
|
|
767
|
+
defaultsOf(discord_js.MessageReaction, {
|
|
768
|
+
users: (reaction)=>ownedManager(discord_js.ReactionUserManager.prototype, {
|
|
769
|
+
reaction
|
|
770
|
+
})
|
|
569
771
|
}),
|
|
570
772
|
defaultsOf(discord_js.BaseInteraction, {
|
|
571
|
-
applicationId:
|
|
773
|
+
applicationId: nextSnowflake,
|
|
572
774
|
token: ()=>'mock-interaction-token',
|
|
573
775
|
version: ()=>1,
|
|
574
776
|
context: ()=>null,
|
|
777
|
+
entitlements: ()=>new discord_js.Collection(),
|
|
575
778
|
// What Discord sends with an interaction in a server, the member's permissions there; typed for a cached one
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
779
|
+
// Each reads where the interaction is as its own, and warns by its own name where strict mocks place it otherwise
|
|
780
|
+
memberPermissions: (interaction)=>readPlaceAs(interaction, 'memberPermissions', ()=>{
|
|
781
|
+
const { guildId } = interaction;
|
|
782
|
+
const member = interaction.member;
|
|
783
|
+
if (!guildId) return null;
|
|
784
|
+
if (member instanceof discord_js.GuildMember) return inChannel(interaction, member);
|
|
785
|
+
// A raw member's permissions are the string Discord sends, read as discord.js reads them
|
|
786
|
+
return isRawMember(member) && !interaction.guild ? new discord_js.PermissionsBitField(BigInt(member.permissions)).freeze() : null;
|
|
787
|
+
}),
|
|
788
|
+
// The bot's permissions where the interaction was made: its member's in the channel, or in a thread its parent, as
|
|
789
|
+
// discord.js computes them; none outside a server
|
|
790
|
+
appPermissions: (interaction)=>readPlaceAs(interaction, 'appPermissions', ()=>{
|
|
791
|
+
const { guild } = interaction;
|
|
792
|
+
const me = guild instanceof discord_js.Guild ? botMemberOf(guild) : null;
|
|
793
|
+
if (!(me instanceof discord_js.GuildMember) || heldChannelOf(interaction, guild) === null) return noPermissions();
|
|
794
|
+
// discord.js's own computation, given its own overwritesFor over a test's stub on the channel; a channel the
|
|
795
|
+
// mock would make has no overwrites
|
|
796
|
+
const at = overwritingChannel(interaction, guild) ?? {
|
|
797
|
+
guild,
|
|
798
|
+
permissionOverwrites: {
|
|
799
|
+
cache: new discord_js.Collection()
|
|
800
|
+
}
|
|
801
|
+
};
|
|
802
|
+
const channel = Object.create(at, {
|
|
803
|
+
overwritesFor: {
|
|
804
|
+
value: channelPermissions.overwritesFor
|
|
805
|
+
}
|
|
806
|
+
});
|
|
807
|
+
const permissions = channelPermissions.memberPermissions.call(channel, me, true);
|
|
808
|
+
// The bot's member has @everyone's too
|
|
809
|
+
warnOnUnsetEveryone(interaction, guild, 'appPermissions', permissions);
|
|
810
|
+
return permissions;
|
|
811
|
+
}, // A server the bot isn't in has no guild in either mode, so no permissions in both
|
|
812
|
+
(place)=>!place.raw)
|
|
590
813
|
}),
|
|
591
814
|
defaultsOf(discord_js.TextChannel, {
|
|
592
815
|
type: ()=>discord_js.ChannelType.GuildText,
|
|
@@ -622,7 +845,7 @@ const snowflake = ()=>discord_js.SnowflakeUtil.generate().toString();
|
|
|
622
845
|
}),
|
|
623
846
|
defaultsOf(discord_js.GuildChannel, {
|
|
624
847
|
name: ()=>'channel',
|
|
625
|
-
guildId: (channel)=>typeof channel.guild?.id === 'string' ? channel.guild.id :
|
|
848
|
+
guildId: (channel)=>typeof channel.guild?.id === 'string' ? channel.guild.id : nextSnowflake(),
|
|
626
849
|
position: ()=>0,
|
|
627
850
|
rawPosition: ()=>0,
|
|
628
851
|
parentId: ()=>null
|
|
@@ -646,11 +869,26 @@ const snowflake = ()=>discord_js.SnowflakeUtil.generate().toString();
|
|
|
646
869
|
* thread made without one, a thread's parent text channel in it, and data that is otherwise a placeholder.
|
|
647
870
|
*/ const STRICT_DEFAULTS = new Map([
|
|
648
871
|
defaultsOf(discord_js.Message, {
|
|
649
|
-
content: ()=>''
|
|
872
|
+
content: ()=>'',
|
|
873
|
+
editedTimestamp: ()=>null,
|
|
874
|
+
editedAt: REAL_GETTER
|
|
875
|
+
}),
|
|
876
|
+
defaultsOf(discord_js.MessageMentions, {
|
|
877
|
+
repliedUser: ()=>null
|
|
650
878
|
}),
|
|
651
879
|
defaultsOf(discord_js.Guild, {
|
|
652
880
|
rulesChannelId: ()=>null,
|
|
653
|
-
publicUpdatesChannelId: ()=>null
|
|
881
|
+
publicUpdatesChannelId: ()=>null,
|
|
882
|
+
systemChannel: REAL_GETTER
|
|
883
|
+
}),
|
|
884
|
+
defaultsOf(discord_js.BaseGuild, {
|
|
885
|
+
verified: REAL_GETTER
|
|
886
|
+
}),
|
|
887
|
+
defaultsOf(discord_js.GuildMember, {
|
|
888
|
+
presence: REAL_GETTER
|
|
889
|
+
}),
|
|
890
|
+
defaultsOf(discord_js.ModalSubmitInteraction, {
|
|
891
|
+
message: ()=>null
|
|
654
892
|
}),
|
|
655
893
|
defaultsOf(discord_js.ThreadChannel, {
|
|
656
894
|
guild: ()=>createMockGuild(),
|
|
@@ -660,25 +898,91 @@ const snowflake = ()=>discord_js.SnowflakeUtil.generate().toString();
|
|
|
660
898
|
guild: thread.guild
|
|
661
899
|
});
|
|
662
900
|
thread.guild.channels.cache.set(parent.id, parent);
|
|
901
|
+
madeParentIds.set(thread, parent.id);
|
|
663
902
|
return parent.id;
|
|
664
903
|
},
|
|
665
904
|
parent: REAL_GETTER,
|
|
666
|
-
ownerId:
|
|
905
|
+
ownerId: nextSnowflake,
|
|
667
906
|
archived: ()=>false,
|
|
668
907
|
locked: ()=>false
|
|
669
908
|
}),
|
|
670
909
|
defaultsOf(discord_js.GuildChannel, {
|
|
671
|
-
guild: ()=>createMockGuild()
|
|
910
|
+
guild: ()=>createMockGuild(),
|
|
911
|
+
parent: REAL_GETTER
|
|
672
912
|
}),
|
|
673
913
|
// Full once its members reach a user limit, which a voice channel has none of unless given one
|
|
674
914
|
defaultsOf(discord_js.BaseGuildVoiceChannel, {
|
|
675
915
|
userLimit: ()=>0,
|
|
676
916
|
full: REAL_GETTER
|
|
677
917
|
}),
|
|
918
|
+
// A reaction's message, as discord.js always has one, is a whole one
|
|
678
919
|
defaultsOf(discord_js.MessageReaction, {
|
|
679
|
-
count: ()=>1
|
|
920
|
+
count: ()=>1,
|
|
921
|
+
me: ()=>false,
|
|
922
|
+
message: ()=>createMockMessage()
|
|
680
923
|
})
|
|
681
924
|
]);
|
|
925
|
+
/**
|
|
926
|
+
* What a mock reads as a placeholder unless strict mocks read it as discord.js gives it, by the value a test sets for
|
|
927
|
+
* that, which the warning on reading one names.
|
|
928
|
+
*/ const PLACEHOLDER_VALUES = new Map([
|
|
929
|
+
[
|
|
930
|
+
discord_js.MessageReaction.prototype,
|
|
931
|
+
{
|
|
932
|
+
me: 'false',
|
|
933
|
+
message: 'createMockMessage()'
|
|
934
|
+
}
|
|
935
|
+
],
|
|
936
|
+
[
|
|
937
|
+
discord_js.MessageMentions.prototype,
|
|
938
|
+
{
|
|
939
|
+
repliedUser: 'null'
|
|
940
|
+
}
|
|
941
|
+
],
|
|
942
|
+
[
|
|
943
|
+
discord_js.Message.prototype,
|
|
944
|
+
{
|
|
945
|
+
editedAt: 'null'
|
|
946
|
+
}
|
|
947
|
+
],
|
|
948
|
+
[
|
|
949
|
+
discord_js.GuildMember.prototype,
|
|
950
|
+
{
|
|
951
|
+
presence: 'null'
|
|
952
|
+
}
|
|
953
|
+
],
|
|
954
|
+
[
|
|
955
|
+
discord_js.BaseGuild.prototype,
|
|
956
|
+
{
|
|
957
|
+
verified: 'false'
|
|
958
|
+
}
|
|
959
|
+
],
|
|
960
|
+
[
|
|
961
|
+
discord_js.Guild.prototype,
|
|
962
|
+
{
|
|
963
|
+
systemChannel: 'null'
|
|
964
|
+
}
|
|
965
|
+
],
|
|
966
|
+
[
|
|
967
|
+
discord_js.GuildChannel.prototype,
|
|
968
|
+
{
|
|
969
|
+
parent: 'null'
|
|
970
|
+
}
|
|
971
|
+
],
|
|
972
|
+
[
|
|
973
|
+
discord_js.ModalSubmitInteraction.prototype,
|
|
974
|
+
{
|
|
975
|
+
message: 'null'
|
|
976
|
+
}
|
|
977
|
+
]
|
|
978
|
+
]);
|
|
979
|
+
/** 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) {
|
|
980
|
+
for(let proto = Object.getPrototypeOf(target); proto !== null; proto = Object.getPrototypeOf(proto)){
|
|
981
|
+
const value = PLACEHOLDER_VALUES.get(proto)?.[key];
|
|
982
|
+
if (value !== undefined) return value;
|
|
983
|
+
}
|
|
984
|
+
return undefined;
|
|
985
|
+
}
|
|
682
986
|
/** The default a mock of `target`'s class has for `key`, read from the nearest class that has one, strict ones first. */ function discordDefault(target, key) {
|
|
683
987
|
for (const defaults of strictMocks() ? [
|
|
684
988
|
STRICT_DEFAULTS,
|
|
@@ -694,13 +998,6 @@ const snowflake = ()=>discord_js.SnowflakeUtil.generate().toString();
|
|
|
694
998
|
return undefined;
|
|
695
999
|
}
|
|
696
1000
|
|
|
697
|
-
/**
|
|
698
|
-
* The id of the bot every mock client is logged in as, so a test can mention it. Fixed, and below the
|
|
699
|
-
* ids other mocks take, so it is the same on every run and in any test order, and no mock shares it.
|
|
700
|
-
*/ const MOCK_BOT_ID = '1300000000000000000';
|
|
701
|
-
/** The last id a mock was given; ids count up from a real snowflake, so each mock's is its own. */ let lastSnowflake = 1400000000000000000n;
|
|
702
|
-
/** 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);
|
|
703
|
-
|
|
704
1001
|
/** Where a mock resolves unfurled media to, as Discord's media proxy would. */ const PROXY = 'https://images-ext-1.discordapp.net/external/mock';
|
|
705
1002
|
/** Unfurled media as Discord stores it: the url given, resolved to a proxied image of a fixed size. */ function resolvedMedia(media) {
|
|
706
1003
|
return {
|
|
@@ -830,9 +1127,15 @@ const SKIP = new Set([
|
|
|
830
1127
|
'toJSON',
|
|
831
1128
|
'then'
|
|
832
1129
|
]);
|
|
1130
|
+
// Each mock's own instance, which the proxy's traps receive as their target
|
|
1131
|
+
const mockTargets = new WeakMap();
|
|
1132
|
+
// Warnings a read answered by the stub logs first, by the mock's instance and the property
|
|
1133
|
+
const stubReadWarnings = new WeakMap();
|
|
833
1134
|
function stubDeep(instance, externalStubs, onSet) {
|
|
834
1135
|
noteMockMade();
|
|
835
1136
|
const stubs = externalStubs ?? new Map();
|
|
1137
|
+
// Placeholders read so far, by the value strict mocks give: a read of a kept one still warns, once
|
|
1138
|
+
const placeholders = new Map();
|
|
836
1139
|
const proxy = new Proxy(instance, {
|
|
837
1140
|
get (target, prop) {
|
|
838
1141
|
// Always pass through symbols
|
|
@@ -846,12 +1149,17 @@ function stubDeep(instance, externalStubs, onSet) {
|
|
|
846
1149
|
if (Object.prototype.hasOwnProperty.call(target, key)) {
|
|
847
1150
|
return Reflect.get(target, prop, target);
|
|
848
1151
|
}
|
|
1152
|
+
stubReadWarnings.get(target)?.get(key)?.();
|
|
849
1153
|
// Skip passthrough props — return prototype value as-is
|
|
850
1154
|
if (SKIP.has(key)) {
|
|
851
1155
|
return Reflect.get(target, prop, target);
|
|
852
1156
|
}
|
|
853
1157
|
// Return cached stub
|
|
854
|
-
if (stubs.has(key))
|
|
1158
|
+
if (stubs.has(key)) {
|
|
1159
|
+
const kept = placeholders.get(key);
|
|
1160
|
+
if (kept !== undefined) warnPlaceholderRead(target, key, kept);
|
|
1161
|
+
return stubs.get(key);
|
|
1162
|
+
}
|
|
855
1163
|
// A value discord.js always gives, in its shape, rather than a stub: kept once read, or its getter run live
|
|
856
1164
|
const known = discordDefault(target, key);
|
|
857
1165
|
if (known === REAL_GETTER) {
|
|
@@ -865,6 +1173,12 @@ function stubDeep(instance, externalStubs, onSet) {
|
|
|
865
1173
|
stubs.set(key, value);
|
|
866
1174
|
return value;
|
|
867
1175
|
}
|
|
1176
|
+
// A value strict mocks read as discord.js gives it, which is a placeholder otherwise
|
|
1177
|
+
const strictValue = strictMocks() ? undefined : placeholderValue(target, key);
|
|
1178
|
+
if (strictValue !== undefined) {
|
|
1179
|
+
placeholders.set(key, strictValue);
|
|
1180
|
+
warnPlaceholderRead(target, key, strictValue);
|
|
1181
|
+
}
|
|
868
1182
|
// Walk the prototype chain to check if it's a function
|
|
869
1183
|
let proto = Object.getPrototypeOf(target);
|
|
870
1184
|
let protoValue;
|
|
@@ -875,7 +1189,7 @@ function stubDeep(instance, externalStubs, onSet) {
|
|
|
875
1189
|
if (desc.get && isPlaceholder(proto, key)) {
|
|
876
1190
|
// Strict mocks run discord.js's getter, read live; otherwise the placeholder, with a warning once
|
|
877
1191
|
if (strictMocks()) return Reflect.get(target, prop, proxy);
|
|
878
|
-
|
|
1192
|
+
warnPlaceholderRead(target, key, 'false');
|
|
879
1193
|
}
|
|
880
1194
|
break;
|
|
881
1195
|
}
|
|
@@ -883,7 +1197,7 @@ function stubDeep(instance, externalStubs, onSet) {
|
|
|
883
1197
|
}
|
|
884
1198
|
// A client the mock's structures share: its guild's, or one of its own
|
|
885
1199
|
if (key === 'client' && target instanceof discord_js.Base) return clientOf(target);
|
|
886
|
-
const stub = typeof protoValue === 'function' ? isRealMethod(target, key) ? createMockFn((...args)=>protoValue.apply(proxy, args)) : methodStub(target, key, protoValue, ()=>proxy) : stubDeep({});
|
|
1200
|
+
const stub = typeof protoValue === 'function' ? isRealMethod(target, key) ? createMockFn((...args)=>quietly(target, key, ()=>protoValue.apply(proxy, args))) : methodStub(target, key, protoValue, ()=>proxy) : stubDeep({});
|
|
887
1201
|
stubs.set(key, stub);
|
|
888
1202
|
return stub;
|
|
889
1203
|
},
|
|
@@ -892,7 +1206,10 @@ function stubDeep(instance, externalStubs, onSet) {
|
|
|
892
1206
|
// and a plain write against one of those throws. Defining an own
|
|
893
1207
|
// data property shadows the accessor, which is what test setup means.
|
|
894
1208
|
set (target, prop, value) {
|
|
895
|
-
|
|
1209
|
+
// An own accessor with a setter, such as a context menu's target, takes the value itself
|
|
1210
|
+
const setter = Object.getOwnPropertyDescriptor(target, prop)?.set;
|
|
1211
|
+
if (setter) setter.call(target, value);
|
|
1212
|
+
else Object.defineProperty(target, prop, {
|
|
896
1213
|
value,
|
|
897
1214
|
writable: true,
|
|
898
1215
|
enumerable: true,
|
|
@@ -902,6 +1219,7 @@ function stubDeep(instance, externalStubs, onSet) {
|
|
|
902
1219
|
return true;
|
|
903
1220
|
}
|
|
904
1221
|
});
|
|
1222
|
+
mockTargets.set(proxy, instance);
|
|
905
1223
|
return proxy;
|
|
906
1224
|
}
|
|
907
1225
|
// discord.js methods that read only what the mock holds, so they run for real: looking items up in a manager's cache,
|
|
@@ -1034,7 +1352,8 @@ const PLACEHOLDER_BOOLEANS = new Map([
|
|
|
1034
1352
|
[
|
|
1035
1353
|
discord_js.BaseGuildVoiceChannel.prototype,
|
|
1036
1354
|
new Set([
|
|
1037
|
-
'joinable'
|
|
1355
|
+
'joinable',
|
|
1356
|
+
'full'
|
|
1038
1357
|
])
|
|
1039
1358
|
],
|
|
1040
1359
|
[
|
|
@@ -1069,11 +1388,42 @@ const PLACEHOLDER_BOOLEANS = new Map([
|
|
|
1069
1388
|
return false;
|
|
1070
1389
|
}
|
|
1071
1390
|
const mockLogger = new controller_decorator.Logger('Mocks');
|
|
1072
|
-
|
|
1391
|
+
// Mock reactions, and their messages, that MeoCord's dispatcher reads `partial` on
|
|
1392
|
+
const dispatchedReactions = new WeakSet();
|
|
1393
|
+
let quiet = 0;
|
|
1394
|
+
/**
|
|
1395
|
+
* Runs a real method, keeping quiet about the placeholders `mentions.has()` reads: a placeholder `repliedUser` matches
|
|
1396
|
+
* no user, so its answer is discord.js's either way.
|
|
1397
|
+
*/ function quietly(target, key, run) {
|
|
1398
|
+
if (!(target instanceof discord_js.MessageMentions && key === 'has')) return run();
|
|
1399
|
+
quiet++;
|
|
1400
|
+
try {
|
|
1401
|
+
return run();
|
|
1402
|
+
} finally{
|
|
1403
|
+
quiet--;
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
/**
|
|
1407
|
+
* Marks a mock reaction and its message as read by MeoCord's dispatcher, so a placeholder `partial` it reads warns
|
|
1408
|
+
* that MeoCord read it, and fetched for it.
|
|
1409
|
+
*/ function noteReactionDispatch(reaction) {
|
|
1410
|
+
const target = mockTargets.get(reaction);
|
|
1411
|
+
if (!target) return;
|
|
1412
|
+
dispatchedReactions.add(target);
|
|
1413
|
+
const message = mockTargets.get(reaction.message ?? {});
|
|
1414
|
+
if (message) dispatchedReactions.add(message);
|
|
1415
|
+
}
|
|
1416
|
+
function warnPlaceholderRead(target, key, strictValue) {
|
|
1417
|
+
if (quiet > 0) return;
|
|
1073
1418
|
const name = Object.getPrototypeOf(target).constructor.name;
|
|
1419
|
+
if (key === 'partial' && dispatchedReactions.has(target)) {
|
|
1420
|
+
const fetched = target instanceof discord_js.MessageReaction ? 'the reaction' : "the reaction's message";
|
|
1421
|
+
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.');
|
|
1422
|
+
return;
|
|
1423
|
+
}
|
|
1074
1424
|
// The class name in camel case, a leading acronym included: dmChannel, guildMember
|
|
1075
1425
|
const variable = name.replace(/^[A-Z]+(?=[A-Z][a-z])|^[A-Z]/, (start)=>start.toLowerCase());
|
|
1076
|
-
controller_decorator.warnPlaceholder(mockLogger, `${name}.${key}`, 'the mock computes it as discord.js does', `Set it on the mock, such as ${variable}.${key} =
|
|
1426
|
+
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.`);
|
|
1077
1427
|
}
|
|
1078
1428
|
// The client of a structure made without one: the one its guild has, else one of its own, made once
|
|
1079
1429
|
const ownClients = new WeakMap();
|
|
@@ -1181,25 +1531,37 @@ const MANAGER_ITEMS = [
|
|
|
1181
1531
|
],
|
|
1182
1532
|
[
|
|
1183
1533
|
discord_js.RoleManager,
|
|
1184
|
-
(id)=>createMockInteraction(discord_js.Role,
|
|
1185
|
-
id
|
|
1186
|
-
|
|
1534
|
+
(id, manager)=>createMockInteraction(discord_js.Role, {
|
|
1535
|
+
...id ? {
|
|
1536
|
+
id
|
|
1537
|
+
} : {},
|
|
1538
|
+
...guildOf(manager) ? {
|
|
1539
|
+
guild: guildOf(manager)
|
|
1540
|
+
} : {}
|
|
1541
|
+
})
|
|
1187
1542
|
],
|
|
1188
1543
|
[
|
|
1189
1544
|
discord_js.GuildBanManager,
|
|
1190
1545
|
()=>createMockInteraction(discord_js.GuildBan)
|
|
1191
1546
|
],
|
|
1547
|
+
// A message fetched from a channel is in it, and another user's
|
|
1192
1548
|
[
|
|
1193
1549
|
discord_js.GuildMessageManager,
|
|
1194
|
-
(id)=>createMockMessage(
|
|
1195
|
-
id
|
|
1196
|
-
|
|
1550
|
+
(id, manager)=>createMockMessage({
|
|
1551
|
+
...id ? {
|
|
1552
|
+
id
|
|
1553
|
+
} : {},
|
|
1554
|
+
...channelOfManager(manager)
|
|
1555
|
+
})
|
|
1197
1556
|
],
|
|
1198
1557
|
[
|
|
1199
1558
|
discord_js.DMMessageManager,
|
|
1200
|
-
(id)=>createMockMessage(
|
|
1201
|
-
id
|
|
1202
|
-
|
|
1559
|
+
(id, manager)=>createMockMessage({
|
|
1560
|
+
...id ? {
|
|
1561
|
+
id
|
|
1562
|
+
} : {},
|
|
1563
|
+
...channelOfManager(manager)
|
|
1564
|
+
})
|
|
1203
1565
|
],
|
|
1204
1566
|
[
|
|
1205
1567
|
discord_js.GuildTextThreadManager,
|
|
@@ -1217,6 +1579,12 @@ const MANAGER_ITEMS = [
|
|
|
1217
1579
|
discord_js.ThreadMemberManager,
|
|
1218
1580
|
()=>createMockInteraction(discord_js.ThreadMember)
|
|
1219
1581
|
],
|
|
1582
|
+
[
|
|
1583
|
+
discord_js.ReactionUserManager,
|
|
1584
|
+
(id)=>createMockUser(id ? {
|
|
1585
|
+
id
|
|
1586
|
+
} : {})
|
|
1587
|
+
],
|
|
1220
1588
|
[
|
|
1221
1589
|
discord_js.ApplicationCommandManager,
|
|
1222
1590
|
(id)=>createMockInteraction(discord_js.ApplicationCommand, id ? {
|
|
@@ -1239,11 +1607,58 @@ const MANAGER_ITEMS = [
|
|
|
1239
1607
|
guild: guildOf(manager)
|
|
1240
1608
|
} : {}
|
|
1241
1609
|
})
|
|
1610
|
+
],
|
|
1611
|
+
[
|
|
1612
|
+
discord_js.GuildEmojiManager,
|
|
1613
|
+
(id, manager)=>itemInGuild(discord_js.GuildEmoji, {
|
|
1614
|
+
id: id ?? nextSnowflake()
|
|
1615
|
+
}, manager)
|
|
1616
|
+
],
|
|
1617
|
+
[
|
|
1618
|
+
discord_js.GuildStickerManager,
|
|
1619
|
+
(id, manager)=>itemInGuild(discord_js.Sticker, {
|
|
1620
|
+
id: id ?? nextSnowflake()
|
|
1621
|
+
}, manager)
|
|
1622
|
+
],
|
|
1623
|
+
[
|
|
1624
|
+
discord_js.GuildScheduledEventManager,
|
|
1625
|
+
(id, manager)=>itemInGuild(discord_js.GuildScheduledEvent, {
|
|
1626
|
+
id: id ?? nextSnowflake()
|
|
1627
|
+
}, manager)
|
|
1628
|
+
],
|
|
1629
|
+
[
|
|
1630
|
+
discord_js.AutoModerationRuleManager,
|
|
1631
|
+
(id, manager)=>itemInGuild(discord_js.AutoModerationRule, {
|
|
1632
|
+
id: id ?? nextSnowflake()
|
|
1633
|
+
}, manager)
|
|
1634
|
+
],
|
|
1635
|
+
// An invite is keyed by its code, as discord.js caches and fetches one
|
|
1636
|
+
[
|
|
1637
|
+
discord_js.GuildInviteManager,
|
|
1638
|
+
(code, manager)=>itemInGuild(discord_js.Invite, {
|
|
1639
|
+
code: code ?? BigInt(nextSnowflake()).toString(36)
|
|
1640
|
+
}, manager)
|
|
1242
1641
|
]
|
|
1243
1642
|
];
|
|
1643
|
+
/** A mock of `Class` with `props`, in the manager's guild, by the guild and its id, when the manager has one. */ function itemInGuild(Class, props, manager) {
|
|
1644
|
+
const guild = guildOf(manager);
|
|
1645
|
+
return createMockInteraction(Class, {
|
|
1646
|
+
...props,
|
|
1647
|
+
...guild ? {
|
|
1648
|
+
guild,
|
|
1649
|
+
guildId: guild.id
|
|
1650
|
+
} : {}
|
|
1651
|
+
});
|
|
1652
|
+
}
|
|
1244
1653
|
const returnsPromise = (key, method)=>method.constructor.name === 'AsyncFunction' || PROMISE_METHODS.has(key) || /^set[A-Z]/.test(key) && !SYNC_SETTERS.has(key);
|
|
1245
1654
|
// The ids a fetch asks for: an id, a discord.js object, or options naming one or several, such as `{ user: id }` or
|
|
1246
1655
|
// `{ user: [ids] }`; undefined for a fetch of a list
|
|
1656
|
+
/** The channel a message manager belongs to, as `createMockMessage` takes it. */ const channelOfManager = (manager)=>{
|
|
1657
|
+
const channel = manager.channel;
|
|
1658
|
+
return channel instanceof discord_js.BaseChannel ? {
|
|
1659
|
+
channel: channel
|
|
1660
|
+
} : {};
|
|
1661
|
+
};
|
|
1247
1662
|
function fetchedIds(args) {
|
|
1248
1663
|
const idOf = (value)=>typeof value === 'string' ? value : value instanceof discord_js.Base ? value.id : undefined;
|
|
1249
1664
|
const [first] = args;
|
|
@@ -1279,6 +1694,10 @@ function fetchedIds(args) {
|
|
|
1279
1694
|
if (key === 'send' && target instanceof discord_js.User) {
|
|
1280
1695
|
return createMockFn(async (...args)=>(await receiver().createDM()).send(...args));
|
|
1281
1696
|
}
|
|
1697
|
+
// A channel's send resolves to the message it sent there
|
|
1698
|
+
if (key === 'send' && target instanceof discord_js.BaseChannel) {
|
|
1699
|
+
return createMockFn(async (options)=>sentMessage(sent(options, false), receiver()));
|
|
1700
|
+
}
|
|
1282
1701
|
if (MESSAGE_METHODS.has(key)) return createMockFn(async ()=>createMockMessage());
|
|
1283
1702
|
if (target instanceof discord_js.BaseManager) {
|
|
1284
1703
|
const item = MANAGER_ITEMS.find(([Manager])=>Manager.prototype.isPrototypeOf(target))?.[1];
|
|
@@ -1295,7 +1714,36 @@ function fetchedIds(args) {
|
|
|
1295
1714
|
return ids === undefined ? new discord_js.Collection() : one(ids);
|
|
1296
1715
|
});
|
|
1297
1716
|
}
|
|
1298
|
-
if (item &&
|
|
1717
|
+
if (item && key === 'edit') return createMockFn(async ()=>item(undefined, receiver()));
|
|
1718
|
+
// A ban resolves to the member, user or id it was given, as discord.js's does, and caches nothing
|
|
1719
|
+
if (key === 'create' && target instanceof discord_js.GuildBanManager) {
|
|
1720
|
+
return createMockFn(async (user)=>{
|
|
1721
|
+
if (user instanceof discord_js.GuildMember) return user;
|
|
1722
|
+
if (user instanceof discord_js.User) return receiver().guild.members.resolve(user) ?? user;
|
|
1723
|
+
return typeof user === 'string' ? user : user?.id;
|
|
1724
|
+
});
|
|
1725
|
+
}
|
|
1726
|
+
// An invite is made for the channel given, in its guild, and cached nowhere, as discord.js's create does
|
|
1727
|
+
if (item && key === 'create' && target instanceof discord_js.GuildInviteManager) {
|
|
1728
|
+
return createMockFn(async (channel)=>{
|
|
1729
|
+
const invite = item(undefined, receiver());
|
|
1730
|
+
const channelId = typeof channel === 'string' ? channel : channel?.id;
|
|
1731
|
+
const found = channel instanceof discord_js.BaseChannel ? channel : channelId ? cacheOf(guildOf(receiver())?.channels)?.get(channelId) ?? null : null;
|
|
1732
|
+
return Object.assign(invite, {
|
|
1733
|
+
channel: found,
|
|
1734
|
+
channelId: channelId ?? null
|
|
1735
|
+
});
|
|
1736
|
+
});
|
|
1737
|
+
}
|
|
1738
|
+
// Every other manager here caches what its create() makes, as discord.js's does; check a manager's create when
|
|
1739
|
+
// adding it to MANAGER_ITEMS
|
|
1740
|
+
if (item && key === 'create') {
|
|
1741
|
+
return createMockFn(async ()=>{
|
|
1742
|
+
const made = item(undefined, receiver());
|
|
1743
|
+
if (typeof made.id === 'string') cacheOf(receiver())?.set(made.id, made);
|
|
1744
|
+
return made;
|
|
1745
|
+
});
|
|
1746
|
+
}
|
|
1299
1747
|
} else if (target instanceof discord_js.Base && (SELF_METHODS.has(key) || /^set[A-Z]/.test(key))) {
|
|
1300
1748
|
return createMockFn(async ()=>receiver());
|
|
1301
1749
|
}
|
|
@@ -1455,14 +1903,46 @@ const isSnowflake = (id)=>typeof id === 'string' && /^\d{1,20}$/.test(id);
|
|
|
1455
1903
|
});
|
|
1456
1904
|
}
|
|
1457
1905
|
}
|
|
1458
|
-
/**
|
|
1906
|
+
/**
|
|
1907
|
+
* Gives a user, server or channel mock its creation time. Under strict mocks, a generated id gives the time the mock
|
|
1908
|
+
* 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.
|
|
1909
|
+
* An id the test gives decides it in both modes, and a value the test sets replaces it.
|
|
1910
|
+
*/ function defineMadeTime(mock, generatedId) {
|
|
1911
|
+
if (strictMocks()) return defineCreatedTime(mock, generatedId);
|
|
1912
|
+
const own = (key)=>Object.prototype.hasOwnProperty.call(mock, key);
|
|
1913
|
+
const name = Object.getPrototypeOf(mock).constructor.name;
|
|
1914
|
+
if (!own('createdTimestamp')) {
|
|
1915
|
+
Object.defineProperty(mock, 'createdTimestamp', {
|
|
1916
|
+
get () {
|
|
1917
|
+
if (this.id === generatedId) {
|
|
1918
|
+
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.');
|
|
1919
|
+
}
|
|
1920
|
+
return isSnowflake(this.id) ? discord_js.SnowflakeUtil.timestampFrom(this.id) : null;
|
|
1921
|
+
},
|
|
1922
|
+
configurable: true
|
|
1923
|
+
});
|
|
1924
|
+
}
|
|
1925
|
+
if (!own('createdAt')) {
|
|
1926
|
+
Object.defineProperty(mock, 'createdAt', {
|
|
1927
|
+
get () {
|
|
1928
|
+
return this.createdTimestamp === null ? null : new Date(this.createdTimestamp);
|
|
1929
|
+
},
|
|
1930
|
+
configurable: true
|
|
1931
|
+
});
|
|
1932
|
+
}
|
|
1933
|
+
}
|
|
1934
|
+
/**
|
|
1935
|
+
* Where a mock interaction keeps every answer it got, through respond() or discord.js directly, in order, under a key
|
|
1936
|
+
* both builds of this meocord version read.
|
|
1937
|
+
*/ const RESPONSE_LOG = Symbol.for(controller_decorator.sharedName('responseLog'));
|
|
1459
1938
|
/**
|
|
1460
1939
|
* A mock of an answer method, `impl` its behaviour, that records each call in `log` as it is made, with what it sent
|
|
1461
1940
|
* and, once it settles, what it rejected with. The record wraps every behaviour the mock is given, its own and any a
|
|
1462
|
-
* test sets, so the mock stays the plain mock function a runner's matchers read, bun's included
|
|
1463
|
-
* a call asks discord.js for the message back, not part of
|
|
1464
|
-
|
|
1465
|
-
|
|
1941
|
+
* test sets, so the mock stays the plain mock function a runner's matchers read, bun's included, and `gate` keeps the
|
|
1942
|
+
* reply state whichever behaviour runs. `withResponse` is how a call asks discord.js for the message back, not part of
|
|
1943
|
+
* what it sends, so it is left out.
|
|
1944
|
+
*/ function recordedMock(method, impl, log, gate) {
|
|
1945
|
+
const record = (behaviour, byTest = true)=>function(...args) {
|
|
1466
1946
|
const [payload] = args;
|
|
1467
1947
|
const sent = payload && typeof payload === 'object' && 'withResponse' in payload ? Object.fromEntries(Object.entries(payload).filter(([key])=>key !== 'withResponse')) : payload;
|
|
1468
1948
|
const call = {
|
|
@@ -1471,22 +1951,41 @@ const isSnowflake = (id)=>typeof id === 'string' && /^\d{1,20}$/.test(id);
|
|
|
1471
1951
|
};
|
|
1472
1952
|
controller_decorator.stampCall(call);
|
|
1473
1953
|
log.push(call);
|
|
1954
|
+
let gated;
|
|
1474
1955
|
try {
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
if (result instanceof Promise) {
|
|
1478
|
-
return result.then(undefined, (error)=>{
|
|
1479
|
-
call.error = error;
|
|
1480
|
-
throw error;
|
|
1481
|
-
});
|
|
1482
|
-
}
|
|
1483
|
-
return result;
|
|
1956
|
+
// Before the behaviour, as discord.js checks the state before it sends an answer
|
|
1957
|
+
if (gate) gated = gate(args, byTest);
|
|
1484
1958
|
} catch (error) {
|
|
1959
|
+
call.error = error;
|
|
1960
|
+
return Promise.reject(error);
|
|
1961
|
+
}
|
|
1962
|
+
const failed = (error)=>{
|
|
1963
|
+
gated?.settle?.();
|
|
1964
|
+
gated?.undo?.();
|
|
1485
1965
|
call.error = error;
|
|
1486
1966
|
throw error;
|
|
1967
|
+
};
|
|
1968
|
+
// Once the behaviour resolves, as discord.js changes the state once Discord has taken the answer
|
|
1969
|
+
const landed = (value)=>{
|
|
1970
|
+
gated?.settle?.();
|
|
1971
|
+
try {
|
|
1972
|
+
gated?.land?.();
|
|
1973
|
+
} catch (error) {
|
|
1974
|
+
call.error = error;
|
|
1975
|
+
throw error;
|
|
1976
|
+
}
|
|
1977
|
+
return value;
|
|
1978
|
+
};
|
|
1979
|
+
try {
|
|
1980
|
+
const result = Reflect.apply(behaviour, this, args);
|
|
1981
|
+
// Recorded and passed on, so a rejection nobody awaits is unhandled in the test, as it is on a bot
|
|
1982
|
+
if (result instanceof Promise) return result.then(landed, failed);
|
|
1983
|
+
return landed(result);
|
|
1984
|
+
} catch (error) {
|
|
1985
|
+
return failed(error);
|
|
1487
1986
|
}
|
|
1488
1987
|
};
|
|
1489
|
-
const mock = createMockFn(record(impl));
|
|
1988
|
+
const mock = createMockFn(record(impl, false));
|
|
1490
1989
|
const always = mock.mockImplementation.bind(mock);
|
|
1491
1990
|
const once = mock.mockImplementationOnce.bind(mock);
|
|
1492
1991
|
// Each way a test sets a behaviour goes through the record, a runner's own extras included where it has them
|
|
@@ -1519,16 +2018,26 @@ const isSnowflake = (id)=>typeof id === 'string' && /^\d{1,20}$/.test(id);
|
|
|
1519
2018
|
return mock;
|
|
1520
2019
|
}
|
|
1521
2020
|
/** The interaction a mock options resolver was given to, for the server a user option's member is in. */ const optionOwners = new WeakMap();
|
|
2021
|
+
/** 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) {
|
|
2022
|
+
const error = new discord_js.DiscordjsError(code);
|
|
2023
|
+
if (message !== undefined) error.message = message;
|
|
2024
|
+
return error;
|
|
2025
|
+
}
|
|
1522
2026
|
/** 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);
|
|
1523
2027
|
// In discord.js's error codes, though missing from its typings
|
|
1524
2028
|
const INVALID_CHANNEL_TYPE = 'CommandInteractionOptionInvalidChannelType';
|
|
1525
2029
|
/** Records the interaction a mock options resolver belongs to. */ const ownOptions = (interaction, options)=>{
|
|
1526
2030
|
if (typeof options === 'object' && options !== null) optionOwners.set(options, interaction);
|
|
1527
2031
|
};
|
|
1528
|
-
/** A mock user that is a person, with an id of its own unless given one. */ const mockUser = (id
|
|
1529
|
-
|
|
2032
|
+
/** A mock user that is a person, with an id of its own unless given one. */ const mockUser = (id)=>{
|
|
2033
|
+
const generatedId = id === undefined ? nextSnowflake() : undefined;
|
|
2034
|
+
const user = Object.assign(Object.create(discord_js.User.prototype), {
|
|
2035
|
+
id: id ?? generatedId,
|
|
1530
2036
|
bot: false
|
|
1531
|
-
})
|
|
2037
|
+
});
|
|
2038
|
+
defineMadeTime(user, generatedId);
|
|
2039
|
+
return stubDeep(user);
|
|
2040
|
+
};
|
|
1532
2041
|
function createMockInteraction(Class, props) {
|
|
1533
2042
|
const instance = Object.create(Class.prototype);
|
|
1534
2043
|
const stubs = new Map();
|
|
@@ -1548,14 +2057,36 @@ function createMockInteraction(Class, props) {
|
|
|
1548
2057
|
// mock lacks: a guildId is a guild, a guild object with it a cached one, neither a DM. A member
|
|
1549
2058
|
// not given is the user's on an interaction, and the auto-stub elsewhere, so only one set to null or undefined fails.
|
|
1550
2059
|
const own = (key)=>Object.prototype.hasOwnProperty.call(instance, key) ? instance[key] : undefined;
|
|
2060
|
+
let strictPlace;
|
|
1551
2061
|
const hasMember = ()=>!Object.prototype.hasOwnProperty.call(instance, 'member') || Boolean(instance.member);
|
|
1552
2062
|
const guildChecks = {
|
|
1553
2063
|
inGuild: ()=>Boolean(own('guildId') && hasMember()),
|
|
1554
2064
|
inCachedGuild: ()=>Boolean(own('guildId') && own('guild') && hasMember()),
|
|
1555
2065
|
inRawGuild: ()=>Boolean(own('guildId') && !own('guild') && hasMember())
|
|
1556
2066
|
};
|
|
2067
|
+
// What each answers where strict mocks place the mock: the place's guildId and guild for those the test left out, and
|
|
2068
|
+
// a member wherever there is a guildId, unless the test gave one
|
|
2069
|
+
const given = new Set(Object.keys(props ?? {}));
|
|
2070
|
+
const strictChecks = (place)=>{
|
|
2071
|
+
const pick = (key, placed)=>given.has(key) || placed === undefined ? own(key) : placed;
|
|
2072
|
+
const guildId = pick('guildId', place.guildId);
|
|
2073
|
+
const guild = place.raw ? null : pick('guild', place.guild);
|
|
2074
|
+
const member = given.has('member') ? Boolean(own('member')) : Boolean(guildId);
|
|
2075
|
+
return {
|
|
2076
|
+
inGuild: Boolean(guildId && member),
|
|
2077
|
+
inCachedGuild: Boolean(guildId && guild && member),
|
|
2078
|
+
inRawGuild: Boolean(guildId && !guild && member)
|
|
2079
|
+
};
|
|
2080
|
+
};
|
|
2081
|
+
// Each reads where the mock is as its own, and in default mode warns once, by its own name, where strict mocks answer
|
|
2082
|
+
// otherwise or refuse the mock
|
|
1557
2083
|
for (const [name, check] of Object.entries(guildChecks)){
|
|
1558
|
-
if (findPrototypeMethod(instance, name)
|
|
2084
|
+
if (findPrototypeMethod(instance, name) === null) continue;
|
|
2085
|
+
stubs.set(name, createMockFn(()=>{
|
|
2086
|
+
const answer = readPlaceQuietly(check);
|
|
2087
|
+
if (strictPlace && !strictMocks() && (strictPlace.refused || strictChecks(strictPlace)[name] !== answer)) placeWarners.get(instance)?.warn(`${name}()`);
|
|
2088
|
+
return answer;
|
|
2089
|
+
}));
|
|
1559
2090
|
}
|
|
1560
2091
|
// Set up reply state machine for repliable interactions
|
|
1561
2092
|
const isRepliableMethod = findPrototypeMethod(instance, 'isRepliable');
|
|
@@ -1563,84 +2094,285 @@ function createMockInteraction(Class, props) {
|
|
|
1563
2094
|
if (repliable) {
|
|
1564
2095
|
instance.replied = false;
|
|
1565
2096
|
instance.deferred = false;
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
const
|
|
2097
|
+
// Null until reply() or deferReply() decides it, as discord.js has it; default mode reads false until then, and a
|
|
2098
|
+
// test reading it there is told once. A value the test sets is read as set
|
|
2099
|
+
const strict = strictMocks();
|
|
2100
|
+
let ephemeral = strict ? null : false;
|
|
2101
|
+
let ephemeralDecided = false;
|
|
2102
|
+
// Whether one of the mock's own first answers ran; flags a test sets without one leave ephemeral as discord.js would
|
|
2103
|
+
let answeredByMock = false;
|
|
2104
|
+
const answeredOutside = ()=>Boolean(instance.replied || instance.deferred) && !answeredByMock;
|
|
2105
|
+
const decideEphemeral = (value, decided)=>{
|
|
2106
|
+
ephemeral = value;
|
|
2107
|
+
ephemeralDecided = decided;
|
|
2108
|
+
};
|
|
2109
|
+
Object.defineProperty(instance, 'ephemeral', {
|
|
2110
|
+
get: ()=>{
|
|
2111
|
+
// Quiet once a test answered the interaction itself: MeoCord then reads ephemeral only for truthiness, where false
|
|
2112
|
+
// and null act alike
|
|
2113
|
+
if (!ephemeralDecided && !strict && !answeredOutside()) {
|
|
2114
|
+
controller_decorator.warnPlaceholder(mockLogger, `${Object.getPrototypeOf(instance).constructor.name}.ephemeral`, 'it is null until reply() or deferReply() decides it, as discord.js has it', 'Reply or defer first, or call useStrictMocks() to read null now.');
|
|
2115
|
+
}
|
|
2116
|
+
return ephemeral;
|
|
2117
|
+
},
|
|
2118
|
+
set: (value)=>decideEphemeral(value, true),
|
|
2119
|
+
enumerable: true,
|
|
2120
|
+
configurable: true
|
|
2121
|
+
});
|
|
2122
|
+
// discord.js's errors, with their codes; a call before any answer keeps the message the mock has always given
|
|
2123
|
+
const alreadyReplied = ()=>discordjsError(discord_js.DiscordjsErrorCodes.InteractionAlreadyReplied);
|
|
2124
|
+
const notYetReplied = (method)=>discordjsError(discord_js.DiscordjsErrorCodes.InteractionNotReplied, `Cannot call ${method}() before replying or deferring.`);
|
|
1569
2125
|
// Only `flags` is read: discord.js deprecates the `ephemeral: true` reply option, and honouring it here would let
|
|
1570
|
-
// a test pass against a deprecated call.
|
|
2126
|
+
// a test pass against a deprecated call. Read as discord.js reads them, so a flag it refuses throws here too.
|
|
1571
2127
|
const hasEphemeralFlag = (options)=>{
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
2128
|
+
const flags = options?.flags;
|
|
2129
|
+
if (typeof options !== 'object' || flags === null || flags === undefined) return false;
|
|
2130
|
+
return new discord_js.MessageFlagsBitField(flags).has(discord_js.MessageFlags.Ephemeral);
|
|
2131
|
+
};
|
|
2132
|
+
const built = (held)=>typeof held === 'function' ? held() : held;
|
|
2133
|
+
let original;
|
|
2134
|
+
let originalGone = false;
|
|
2135
|
+
const followUps = new Map();
|
|
2136
|
+
// Whether the last answer ran a behaviour a test set, after which a second answer runs with a warning by default
|
|
2137
|
+
let answeredByTest = false;
|
|
2138
|
+
// A command answered with a modal has no original response, and Discord refuses one asked for
|
|
2139
|
+
let modalAnswered = false;
|
|
2140
|
+
const isComponent = instance.type === discord_js.InteractionType.MessageComponent;
|
|
2141
|
+
// The original response of a component, or of a modal submitted from one, is the message it's on
|
|
2142
|
+
const componentMessage = ()=>heldFrom(instance.message);
|
|
2143
|
+
const snapshot = ()=>{
|
|
2144
|
+
const saved = {
|
|
2145
|
+
replied: instance.replied,
|
|
2146
|
+
deferred: instance.deferred,
|
|
2147
|
+
ephemeral,
|
|
2148
|
+
ephemeralDecided,
|
|
2149
|
+
answeredByMock,
|
|
2150
|
+
original,
|
|
2151
|
+
originalGone,
|
|
2152
|
+
followUps: new Map(followUps),
|
|
2153
|
+
answeredByTest,
|
|
2154
|
+
modalAnswered
|
|
2155
|
+
};
|
|
2156
|
+
return ()=>{
|
|
2157
|
+
Object.assign(instance, {
|
|
2158
|
+
replied: saved.replied,
|
|
2159
|
+
deferred: saved.deferred
|
|
2160
|
+
});
|
|
2161
|
+
decideEphemeral(saved.ephemeral, saved.ephemeralDecided);
|
|
2162
|
+
({ original, originalGone, answeredByTest, answeredByMock, modalAnswered } = saved);
|
|
2163
|
+
followUps.clear();
|
|
2164
|
+
for (const [id, held] of saved.followUps)followUps.set(id, held);
|
|
2165
|
+
};
|
|
2166
|
+
};
|
|
2167
|
+
/**
|
|
2168
|
+
* Refuses an answer as discord.js does. Where the refusal turns on a behaviour a test set, this answer's or the
|
|
2169
|
+
* one before it, which ran without one, default mode warns and lets it run; strict mocks refuse it.
|
|
2170
|
+
*/ const refuse = (method, error, byTest)=>{
|
|
2171
|
+
if (!(byTest || answeredByTest) || strictMocks()) throw error;
|
|
2172
|
+
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.');
|
|
2173
|
+
};
|
|
2174
|
+
const answered = ()=>instance.deferred || instance.replied;
|
|
2175
|
+
/**
|
|
2176
|
+
* What an answer sends, built now, as discord.js builds it when sending. One it refuses to build is refused under
|
|
2177
|
+
* strict mocks; default mode warns and holds it to build when first read.
|
|
2178
|
+
*/ const hold = (method, build)=>{
|
|
2179
|
+
try {
|
|
2180
|
+
return build();
|
|
2181
|
+
} catch (error) {
|
|
2182
|
+
if (strictMocks()) throw error;
|
|
2183
|
+
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.');
|
|
2184
|
+
return build;
|
|
2185
|
+
}
|
|
2186
|
+
};
|
|
2187
|
+
// The changes answers make to the reply state, in order. One whose answer fails is undone once every change after
|
|
2188
|
+
// it is undone too, so a later answer's change stands while it does, as on a bot
|
|
2189
|
+
const changes = [];
|
|
2190
|
+
const undoneInTurn = (undo)=>{
|
|
2191
|
+
const change = {
|
|
2192
|
+
undo,
|
|
2193
|
+
failed: false
|
|
2194
|
+
};
|
|
2195
|
+
changes.push(change);
|
|
2196
|
+
return ()=>{
|
|
2197
|
+
change.failed = true;
|
|
2198
|
+
while(changes.at(-1)?.failed)changes.pop().undo();
|
|
2199
|
+
};
|
|
1577
2200
|
};
|
|
2201
|
+
// The first answers called and not yet settled, oldest first, for default mode to warn where a bot would differ
|
|
2202
|
+
const unsettled = [];
|
|
2203
|
+
// Default mode warns once per interaction where code that doesn't await an answer would fail on a bot
|
|
2204
|
+
let inFlightWarned = false;
|
|
2205
|
+
const warnInFlight = (text)=>{
|
|
2206
|
+
if (inFlightWarned) return;
|
|
2207
|
+
inFlightWarned = true;
|
|
2208
|
+
mockLogger.warn(text);
|
|
2209
|
+
};
|
|
2210
|
+
/**
|
|
2211
|
+
* A first answer: refused once the interaction has one, else it replies or defers. Under strict mocks the change
|
|
2212
|
+
* waits for the answer to land, as discord.js makes it once the request resolves, so one sent while another is in
|
|
2213
|
+
* flight passes discord.js's check and Discord refuses it with 40060 if the other landed first. Default mode makes
|
|
2214
|
+
* the change at once.
|
|
2215
|
+
*/ const first = (method, kind, then)=>(args, byTest)=>{
|
|
2216
|
+
if (answered()) {
|
|
2217
|
+
if (unsettled.length > 0) {
|
|
2218
|
+
warnInFlight(`${method}() was called while ${unsettled[0]}() had not resolved; on a bot discord.js sends it, and Discord ` + `refuses it with 40060 ("Interaction has already been acknowledged") once the first lands. Await ` + `${unsettled[0]}() before answering again, or call useStrictMocks() to have the mock answer as Discord does.`);
|
|
2219
|
+
}
|
|
2220
|
+
refuse(method, alreadyReplied(), byTest);
|
|
2221
|
+
}
|
|
2222
|
+
// Read first: flags discord.js refuses throw before anything changes; reply() and deferReply() decide
|
|
2223
|
+
// ephemeral, as discord.js's do, and update() and deferUpdate() leave it
|
|
2224
|
+
const flagged = method === 'reply' || method === 'deferReply' ? hasEphemeralFlag(args[0]) : undefined;
|
|
2225
|
+
const undo = snapshot();
|
|
2226
|
+
instance[kind] = true;
|
|
2227
|
+
if (flagged !== undefined) decideEphemeral(flagged, true);
|
|
2228
|
+
answeredByTest = byTest;
|
|
2229
|
+
answeredByMock = true;
|
|
2230
|
+
try {
|
|
2231
|
+
then(args);
|
|
2232
|
+
} catch (error) {
|
|
2233
|
+
undo();
|
|
2234
|
+
throw error;
|
|
2235
|
+
}
|
|
2236
|
+
if (strict) {
|
|
2237
|
+
const land = snapshot();
|
|
2238
|
+
undo();
|
|
2239
|
+
return {
|
|
2240
|
+
land: ()=>{
|
|
2241
|
+
if (answered()) throw createDiscordError(40060, 'Interaction has already been acknowledged.');
|
|
2242
|
+
land();
|
|
2243
|
+
}
|
|
2244
|
+
};
|
|
2245
|
+
}
|
|
2246
|
+
unsettled.push(method);
|
|
2247
|
+
return {
|
|
2248
|
+
undo: undoneInTurn(undo),
|
|
2249
|
+
settle: ()=>void unsettled.splice(unsettled.indexOf(method), 1)
|
|
2250
|
+
};
|
|
2251
|
+
};
|
|
2252
|
+
/** A later answer: refused before the interaction has one. */ const later = (method, then)=>(args, byTest)=>{
|
|
2253
|
+
if (!answered()) refuse(method, notYetReplied(method), byTest);
|
|
2254
|
+
if (unsettled.length > 0) {
|
|
2255
|
+
warnInFlight(`${unsettled[0]}() had not resolved when ${method}() was called; on a bot this throws InteractionNotReplied. ` + `Await ${unsettled[0]}() first, or call useStrictMocks() to have the mock refuse it now.`);
|
|
2256
|
+
}
|
|
2257
|
+
const undo = snapshot();
|
|
2258
|
+
try {
|
|
2259
|
+
then(args);
|
|
2260
|
+
} catch (error) {
|
|
2261
|
+
undo();
|
|
2262
|
+
throw error;
|
|
2263
|
+
}
|
|
2264
|
+
return {
|
|
2265
|
+
undo: undoneInTurn(undo)
|
|
2266
|
+
};
|
|
2267
|
+
};
|
|
2268
|
+
/** The id an edit, fetch or delete names, `undefined` for the original response. */ const targetId = (message)=>{
|
|
2269
|
+
if (message === undefined || message === '@original') return undefined;
|
|
2270
|
+
return typeof message === 'string' ? message : message.id ?? undefined;
|
|
2271
|
+
};
|
|
2272
|
+
/** The message an edit, fetch or delete reaches, or why Discord refuses it: it is gone or never was. */ const target = (id)=>{
|
|
2273
|
+
if (id !== undefined) {
|
|
2274
|
+
const held = followUps.get(id);
|
|
2275
|
+
if (held) followUps.set(id, built(held));
|
|
2276
|
+
return held && built(held);
|
|
2277
|
+
}
|
|
2278
|
+
if (originalGone || modalAnswered) return undefined;
|
|
2279
|
+
// Answered without one of the mock's calls, as after a 40060 or a test setting `replied`: the original is there
|
|
2280
|
+
if (!original && answered()) original = componentMessage();
|
|
2281
|
+
if (original) original = built(original);
|
|
2282
|
+
// An answer's message gets its id when first read, as it would be built then without strict mocks
|
|
2283
|
+
if (original?.id === '') original = {
|
|
2284
|
+
...original,
|
|
2285
|
+
id: nextSnowflake()
|
|
2286
|
+
};
|
|
2287
|
+
return original;
|
|
2288
|
+
};
|
|
2289
|
+
const unknownMessage = ()=>createDiscordError(10008, 'Unknown Message');
|
|
2290
|
+
// Where its answers are sent, read as the mock's own, so a placement warning stays the test's
|
|
2291
|
+
const answeredIn = ()=>readPlaceQuietly(()=>[
|
|
2292
|
+
instance.channel,
|
|
2293
|
+
instance.client
|
|
2294
|
+
]);
|
|
2295
|
+
// What each answer's message made for itself, by its id, so every read of it has the same author and place
|
|
2296
|
+
const madePlaces = new Map();
|
|
2297
|
+
const madeFor = (id)=>madePlaces.get(id) ?? madePlaces.set(id, {}).get(id);
|
|
2298
|
+
// Each call's message, read by its behaviour right after the gate made it
|
|
2299
|
+
let made;
|
|
2300
|
+
let followUpMade;
|
|
1578
2301
|
// Every answer the interaction gets, through respond() or discord.js directly, in order, for getResponse
|
|
1579
2302
|
const log = [];
|
|
1580
2303
|
Object.defineProperty(instance, RESPONSE_LOG, {
|
|
1581
2304
|
value: log
|
|
1582
2305
|
});
|
|
1583
|
-
const answer = (method, impl)=>stubs.set(method, recordedMock(method, impl, log));
|
|
1584
|
-
answer('reply', async (
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
})
|
|
1594
|
-
answer('followUp', async ()=>{
|
|
1595
|
-
if (!instance.deferred && !instance.replied) throw notYetReplied('followUp');
|
|
1596
|
-
instance.replied = true;
|
|
1597
|
-
return createMockMessage();
|
|
1598
|
-
});
|
|
1599
|
-
// The interaction's own message as Discord holds it: the message a component is on, or the original response.
|
|
1600
|
-
// An edit replaces it in the shape Discord stores, with ids and resolved media, stamped with when it was edited,
|
|
1601
|
-
// and fetchReply() reads that back, so code comparing the two sees what it would against Discord.
|
|
1602
|
-
let held;
|
|
1603
|
-
const current = ()=>held ?? (held = heldFrom(instance.message));
|
|
1604
|
-
// A command answered with a modal has no original response, and Discord refuses one asked for
|
|
1605
|
-
let modalAnswered = false;
|
|
1606
|
-
const unknownMessage = ()=>createDiscordError(10008, 'Unknown Message');
|
|
1607
|
-
answer('editReply', async (options)=>{
|
|
1608
|
-
if (!instance.deferred && !instance.replied) throw notYetReplied('editReply');
|
|
1609
|
-
if (modalAnswered) throw unknownMessage();
|
|
2306
|
+
const answer = (method, impl, gate)=>stubs.set(method, recordedMock(method, impl, log, gate));
|
|
2307
|
+
answer('reply', async ()=>undefined, first('reply', 'replied', ([options])=>original = hold('reply', ()=>sent(options, false))));
|
|
2308
|
+
answer('deferReply', async ()=>undefined, first('deferReply', 'deferred', ()=>original = sent(undefined)));
|
|
2309
|
+
answer('followUp', // The follow-up as sent, which an edit, fetch or delete reaches by its id
|
|
2310
|
+
async ()=>{
|
|
2311
|
+
const held = followUps.get(followUpMade);
|
|
2312
|
+
// One held to build when read resolves to its id alone, as building it now would refuse it
|
|
2313
|
+
return typeof held === 'function' ? createMockMessage({
|
|
2314
|
+
id: followUpMade
|
|
2315
|
+
}) : messageFrom(held, madeFor(held.id), ...answeredIn());
|
|
2316
|
+
}, later('followUp', ([options])=>{
|
|
1610
2317
|
instance.replied = true;
|
|
1611
|
-
|
|
1612
|
-
//
|
|
1613
|
-
|
|
2318
|
+
// Held as it is when sent, as Discord stores it, so a later change to what was passed isn't seen; its id taken
|
|
2319
|
+
// once built, so one that fails to build under strict mocks takes none
|
|
2320
|
+
const held = hold('followUp', ()=>sent(options, false));
|
|
2321
|
+
const id = nextSnowflake();
|
|
2322
|
+
made = undefined;
|
|
2323
|
+
followUps.set(id, typeof held === 'function' ? ()=>({
|
|
2324
|
+
...held(),
|
|
2325
|
+
id
|
|
2326
|
+
}) : {
|
|
1614
2327
|
...held,
|
|
1615
|
-
|
|
2328
|
+
id
|
|
1616
2329
|
});
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
2330
|
+
followUpMade = id;
|
|
2331
|
+
}));
|
|
2332
|
+
// An edit replaces the message in the shape Discord stores, with ids and resolved media, stamped with when it was
|
|
2333
|
+
// edited, and fetchReply() reads that back, so code comparing the two sees what it would against Discord
|
|
2334
|
+
answer('editReply', async ()=>{
|
|
2335
|
+
if (!made) throw unknownMessage();
|
|
2336
|
+
// The response has the message as Discord answers the edit, before its uploaded files have loaded again
|
|
2337
|
+
return messageFrom({
|
|
2338
|
+
...made,
|
|
2339
|
+
components: made.returned ?? made.components
|
|
2340
|
+
}, madeFor(made.id), ...answeredIn());
|
|
2341
|
+
}, later('editReply', ([options])=>{
|
|
2342
|
+
const id = targetId(options?.message);
|
|
2343
|
+
const held = target(id);
|
|
2344
|
+
made = held && edited(held, options);
|
|
2345
|
+
if (!made) return;
|
|
2346
|
+
if (id === undefined) {
|
|
2347
|
+
original = made;
|
|
2348
|
+
instance.replied = true;
|
|
2349
|
+
} else followUps.set(id, made);
|
|
2350
|
+
}));
|
|
2351
|
+
stubs.set('fetchReply', createMockFn(async (message)=>{
|
|
2352
|
+
const held = target(targetId(message));
|
|
2353
|
+
if (!held) throw unknownMessage();
|
|
2354
|
+
return messageFrom(held, madeFor(held.id), ...answeredIn());
|
|
1621
2355
|
}));
|
|
1622
2356
|
answer('deleteReply', async ()=>{
|
|
1623
|
-
if (!
|
|
1624
|
-
|
|
1625
|
-
|
|
2357
|
+
if (!made) throw unknownMessage();
|
|
2358
|
+
}, later('deleteReply', ([message])=>{
|
|
2359
|
+
const id = targetId(message);
|
|
2360
|
+
made = target(id);
|
|
2361
|
+
if (!made) return;
|
|
2362
|
+
if (id === undefined) originalGone = true;
|
|
2363
|
+
else followUps.delete(id);
|
|
2364
|
+
}));
|
|
1626
2365
|
// showModal — the first response of a command or a component, like reply()
|
|
1627
|
-
if (instance.type === discord_js.InteractionType.ApplicationCommand ||
|
|
1628
|
-
answer('showModal', async ()=>{
|
|
1629
|
-
if (instance.deferred || instance.replied) throw alreadyReplied();
|
|
1630
|
-
instance.replied = true;
|
|
2366
|
+
if (instance.type === discord_js.InteractionType.ApplicationCommand || isComponent) {
|
|
2367
|
+
answer('showModal', async ()=>undefined, first('showModal', 'replied', ()=>{
|
|
1631
2368
|
modalAnswered = instance.type === discord_js.InteractionType.ApplicationCommand;
|
|
1632
|
-
|
|
2369
|
+
if (isComponent) original = componentMessage();
|
|
2370
|
+
}));
|
|
1633
2371
|
}
|
|
1634
2372
|
// deferUpdate / update — components, and modals submitted from a message's component
|
|
1635
|
-
if (
|
|
1636
|
-
answer('update', async ()=>
|
|
1637
|
-
|
|
1638
|
-
instance.replied = true;
|
|
1639
|
-
});
|
|
1640
|
-
answer('deferUpdate', async ()=>{
|
|
1641
|
-
if (instance.deferred || instance.replied) throw alreadyReplied();
|
|
1642
|
-
instance.deferred = true;
|
|
1643
|
-
});
|
|
2373
|
+
if (isComponent || instance.type === discord_js.InteractionType.ModalSubmit) {
|
|
2374
|
+
answer('update', async ()=>undefined, first('update', 'replied', ([options])=>original = hold('update', ()=>edited(heldFrom(instance.message, false), options))));
|
|
2375
|
+
answer('deferUpdate', async ()=>undefined, first('deferUpdate', 'deferred', ()=>original = componentMessage()));
|
|
1644
2376
|
}
|
|
1645
2377
|
}
|
|
1646
2378
|
// Autocomplete is not repliable, but it has a response of its own: Discord accepts
|
|
@@ -1649,12 +2381,31 @@ function createMockInteraction(Class, props) {
|
|
|
1649
2381
|
// it before answering would decide the window was already closed.
|
|
1650
2382
|
if (instance.type === discord_js.InteractionType.ApplicationCommandAutocomplete) {
|
|
1651
2383
|
instance.responded = false;
|
|
2384
|
+
// Under strict mocks responded is set once respond() lands, as discord.js sets it once the request resolves, and a
|
|
2385
|
+
// second one sent while the first is in flight is refused by Discord with 40060; default mode sets it at once
|
|
2386
|
+
let respondsInFlight = 0;
|
|
2387
|
+
let respondWarned = false;
|
|
1652
2388
|
stubs.set('respond', createMockFn(async (choices)=>{
|
|
1653
|
-
if (instance.responded)
|
|
2389
|
+
if (instance.responded) {
|
|
2390
|
+
if (respondsInFlight > 0 && !respondWarned) {
|
|
2391
|
+
respondWarned = true;
|
|
2392
|
+
mockLogger.warn('respond() was called while respond() had not resolved; on a bot discord.js sends it, and Discord refuses ' + 'it with 40060 ("Interaction has already been acknowledged") once the first lands. Await respond() ' + 'before responding again, or call useStrictMocks() to have the mock respond as Discord does.');
|
|
2393
|
+
}
|
|
2394
|
+
throw discordjsError(discord_js.DiscordjsErrorCodes.InteractionAlreadyReplied);
|
|
2395
|
+
}
|
|
1654
2396
|
// Discord refuses a list longer than its limit, and the menu shows nothing
|
|
1655
2397
|
if (Array.isArray(choices) && choices.length > MAX_AUTOCOMPLETE_CHOICES) {
|
|
1656
2398
|
throw createDiscordError(50035, `Invalid Form Body\ndata.choices[BASE_TYPE_MAX_LENGTH]: Must be ${MAX_AUTOCOMPLETE_CHOICES} or fewer in length.`);
|
|
1657
2399
|
}
|
|
2400
|
+
respondsInFlight++;
|
|
2401
|
+
if (!strictMocks()) instance.responded = true;
|
|
2402
|
+
try {
|
|
2403
|
+
await undefined;
|
|
2404
|
+
} finally{
|
|
2405
|
+
respondsInFlight--;
|
|
2406
|
+
}
|
|
2407
|
+
if (!strictMocks()) return;
|
|
2408
|
+
if (instance.responded) throw createDiscordError(40060, 'Interaction has already been acknowledged.');
|
|
1658
2409
|
instance.responded = true;
|
|
1659
2410
|
}));
|
|
1660
2411
|
}
|
|
@@ -1679,6 +2430,34 @@ function createMockInteraction(Class, props) {
|
|
|
1679
2430
|
const unset = (key)=>!Object.prototype.hasOwnProperty.call(instance, key);
|
|
1680
2431
|
const generatedId = unset('id') ? instance.id = nextSnowflake() : undefined;
|
|
1681
2432
|
defineCreatedTime(instance, generatedId);
|
|
2433
|
+
// Strict mocks place it where its message, guild or member is, and a guildId alone in a server the bot isn't in
|
|
2434
|
+
// Read as the mock's own, so a message's placement warning stays the test's
|
|
2435
|
+
strictPlace = readPlaceQuietly(()=>strictPlaceOf(own));
|
|
2436
|
+
if (strictPlace && strictMocks()) {
|
|
2437
|
+
if (strictPlace.refused) throw new Error(strictPlace.refused);
|
|
2438
|
+
const set = (key, value)=>Object.defineProperty(instance, key, {
|
|
2439
|
+
value,
|
|
2440
|
+
writable: true,
|
|
2441
|
+
enumerable: true,
|
|
2442
|
+
configurable: true
|
|
2443
|
+
});
|
|
2444
|
+
if (strictPlace.channel && unset('channel')) set('channel', strictPlace.channel);
|
|
2445
|
+
if (strictPlace.guildId !== undefined && unset('guildId')) set('guildId', strictPlace.guildId);
|
|
2446
|
+
if (strictPlace.guild !== undefined && unset('guild')) set('guild', strictPlace.guild);
|
|
2447
|
+
if (strictPlace.raw) {
|
|
2448
|
+
const user = own('user');
|
|
2449
|
+
const rawUser = user && {
|
|
2450
|
+
id: user.id,
|
|
2451
|
+
username: user.username,
|
|
2452
|
+
global_name: user.globalName ?? null,
|
|
2453
|
+
discriminator: user.discriminator,
|
|
2454
|
+
avatar: user.avatar ?? null
|
|
2455
|
+
};
|
|
2456
|
+
set('member', createMockRawMember(rawUser ? {
|
|
2457
|
+
user: rawUser
|
|
2458
|
+
} : {}));
|
|
2459
|
+
}
|
|
2460
|
+
}
|
|
1682
2461
|
// A raw member is from a server the bot isn't in: no cached guild, and its user the interaction's, as discord.js reads it
|
|
1683
2462
|
const raw = own('member');
|
|
1684
2463
|
// Given with a guild, a raw-shaped member is kept as the cached server's member it was before raw members
|
|
@@ -1761,12 +2540,13 @@ function createMockInteraction(Class, props) {
|
|
|
1761
2540
|
configurable: true
|
|
1762
2541
|
});
|
|
1763
2542
|
}
|
|
1764
|
-
// The channel it came from, as the gateway caches it for an interaction:
|
|
2543
|
+
// The channel it came from, as the gateway caches it for an interaction: its server's cached one, looked up on each
|
|
2544
|
+
// read so a channel the test caches later is the one read, or the user's DM
|
|
1765
2545
|
let channel;
|
|
1766
2546
|
if (unset('channel')) {
|
|
1767
2547
|
Object.defineProperty(instance, 'channel', {
|
|
1768
2548
|
get: ()=>{
|
|
1769
|
-
if (channel === undefined) {
|
|
2549
|
+
if (channel === undefined || own('guildId') && own('guild') instanceof discord_js.Guild) {
|
|
1770
2550
|
channel = channelFor(own('guild'), own('guildId'), own('channelId'), own('user'));
|
|
1771
2551
|
cacheChannel(channel, own('guild'), own('client'));
|
|
1772
2552
|
}
|
|
@@ -1821,7 +2601,73 @@ function createMockInteraction(Class, props) {
|
|
|
1821
2601
|
live('targetMember', ()=>own('guildId') ? memberFor(own('guild'), targetUser(), true, own('guildId')) : null);
|
|
1822
2602
|
}
|
|
1823
2603
|
}
|
|
2604
|
+
// A message context menu's target: the message given, else one made for its targetId. The message is the one
|
|
2605
|
+
// source: targetId reads its id, and setting either one moves the other with it
|
|
2606
|
+
if (discord_js.MessageContextMenuCommandInteraction.prototype.isPrototypeOf(instance)) {
|
|
2607
|
+
let given = own('targetMessage');
|
|
2608
|
+
let explicitId = own('targetId');
|
|
2609
|
+
if (given && given.id === undefined) given.id = nextSnowflake();
|
|
2610
|
+
if (given && explicitId !== undefined && explicitId !== given.id) {
|
|
2611
|
+
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.`;
|
|
2612
|
+
if (strictMocks()) throw new Error(conflict);
|
|
2613
|
+
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.`);
|
|
2614
|
+
explicitId = undefined;
|
|
2615
|
+
}
|
|
2616
|
+
const generated = given || explicitId !== undefined ? undefined : nextSnowflake();
|
|
2617
|
+
let made;
|
|
2618
|
+
const accessor = (key, get, set)=>Object.defineProperty(instance, key, {
|
|
2619
|
+
get,
|
|
2620
|
+
set,
|
|
2621
|
+
enumerable: true,
|
|
2622
|
+
configurable: true
|
|
2623
|
+
});
|
|
2624
|
+
const targetId = ()=>explicitId ?? given?.id ?? generated;
|
|
2625
|
+
accessor('targetId', targetId, (value)=>{
|
|
2626
|
+
explicitId = value;
|
|
2627
|
+
given = undefined;
|
|
2628
|
+
});
|
|
2629
|
+
// In the channel the menu was used in, and its server, as the message a menu is used on is: its channel is the
|
|
2630
|
+
// menu's, read live, so making the message makes and caches no channel. Read as the mock's own, so a placement
|
|
2631
|
+
// warning stays the test's
|
|
2632
|
+
const makeTarget = ()=>readPlaceQuietly(()=>{
|
|
2633
|
+
const [guild, guildId] = [
|
|
2634
|
+
own('guild'),
|
|
2635
|
+
own('guildId') ?? null
|
|
2636
|
+
];
|
|
2637
|
+
const message = messageMock({
|
|
2638
|
+
id: targetId(),
|
|
2639
|
+
guild: guildId && guild instanceof discord_js.Guild ? guild : null,
|
|
2640
|
+
client: instance.client
|
|
2641
|
+
}, undefined, {}, {
|
|
2642
|
+
channelId: own('channelId'),
|
|
2643
|
+
guildId,
|
|
2644
|
+
channel: ()=>readPlaceQuietly(()=>instance.channel)
|
|
2645
|
+
});
|
|
2646
|
+
// Where strict mocks place the menu elsewhere, its target's place warns as the menu's does, by the target's name
|
|
2647
|
+
const warner = placeWarners.get(instance);
|
|
2648
|
+
const target = mockTargets.get(message) ?? message;
|
|
2649
|
+
for (const key of warner?.keys ?? [])warnOnRead(target, key, ()=>readingPlace > 0 ? void placeReadsSeen?.add(key) : warner.warn(`targetMessage.${key}`));
|
|
2650
|
+
return message;
|
|
2651
|
+
});
|
|
2652
|
+
accessor('targetMessage', ()=>given ?? (made?.id === targetId() ? made : made = makeTarget()), (value)=>{
|
|
2653
|
+
given = value;
|
|
2654
|
+
if (given && given.id === undefined) given.id = nextSnowflake();
|
|
2655
|
+
explicitId = undefined;
|
|
2656
|
+
});
|
|
2657
|
+
}
|
|
1824
2658
|
}
|
|
2659
|
+
// A user has an id of its own, as Discord sends one, and was created when the mock was made under strict mocks
|
|
2660
|
+
if (discord_js.User.prototype.isPrototypeOf(instance) && !Object.prototype.hasOwnProperty.call(instance, 'id')) {
|
|
2661
|
+
const generatedId = instance.id = nextSnowflake();
|
|
2662
|
+
defineMadeTime(instance, generatedId);
|
|
2663
|
+
}
|
|
2664
|
+
// A role too, created when the mock was made in both modes
|
|
2665
|
+
if (discord_js.Role.prototype.isPrototypeOf(instance) && !Object.prototype.hasOwnProperty.call(instance, 'id')) {
|
|
2666
|
+
const generatedId = instance.id = nextSnowflake();
|
|
2667
|
+
defineCreatedTime(instance, generatedId);
|
|
2668
|
+
}
|
|
2669
|
+
// An attachment too, from the same count as every mock's
|
|
2670
|
+
if (discord_js.Attachment.prototype.isPrototypeOf(instance) && !Object.prototype.hasOwnProperty.call(instance, 'id')) instance.id = nextSnowflake();
|
|
1825
2671
|
// Discord sends the user's locale with every interaction, and the server's preferred one with one made in a server
|
|
1826
2672
|
if (discord_js.BaseInteraction.prototype.isPrototypeOf(instance)) {
|
|
1827
2673
|
if (own('locale') === undefined) instance.locale = discord_js.Locale.EnglishUS;
|
|
@@ -1845,6 +2691,32 @@ function createMockInteraction(Class, props) {
|
|
|
1845
2691
|
}
|
|
1846
2692
|
for (const kind of choices)if (!Object.prototype.hasOwnProperty.call(instance, kind)) instance[kind] = new discord_js.Collection();
|
|
1847
2693
|
}
|
|
2694
|
+
// In default mode, where strict mocks place it elsewhere, reading where it is warns
|
|
2695
|
+
if (strictPlace && !strictMocks()) warnOnPlaceReads(instance, strictPlace, new Set(Object.keys(props ?? {})));
|
|
2696
|
+
// A message whose place the mock made moves to where the test placed the interaction under strict mocks, and is then
|
|
2697
|
+
// one the test placed; in default mode it stays, and reading its place warns
|
|
2698
|
+
const message = own('message');
|
|
2699
|
+
const placed = [
|
|
2700
|
+
'channel',
|
|
2701
|
+
'guild',
|
|
2702
|
+
'guildId'
|
|
2703
|
+
].some((key)=>Object.keys(props ?? {}).includes(key));
|
|
2704
|
+
if (discord_js.BaseInteraction.prototype.isPrototypeOf(instance) && placed && typeof message === 'object' && message !== null && placeWasMade(message)) {
|
|
2705
|
+
if (strictMocks()) {
|
|
2706
|
+
const guild = own('guild');
|
|
2707
|
+
const channel = instance.channel;
|
|
2708
|
+
Object.assign(message, {
|
|
2709
|
+
guild: guild ?? null,
|
|
2710
|
+
guildId: own('guildId') ?? null,
|
|
2711
|
+
...channel && {
|
|
2712
|
+
channel,
|
|
2713
|
+
channelId: channel.id
|
|
2714
|
+
}
|
|
2715
|
+
});
|
|
2716
|
+
if (guild) memberFor(guild, message.author, true);
|
|
2717
|
+
placesMade.delete(mockTargets.get(message) ?? message);
|
|
2718
|
+
} else warnOnMadePlaceReads(mockTargets.get(message) ?? message);
|
|
2719
|
+
}
|
|
1848
2720
|
// Options assigned after creation, directly or through Object.assign, belong to it as those given do
|
|
1849
2721
|
return stubDeep(instance, stubs, (prop, value)=>{
|
|
1850
2722
|
if (prop === 'options') ownOptions(instance, value);
|
|
@@ -1856,8 +2728,15 @@ function createMockInteraction(Class, props) {
|
|
|
1856
2728
|
/**
|
|
1857
2729
|
* A mock function that answers property access with another one, so `cache.store.flush()` works
|
|
1858
2730
|
* on a double whose shape is an interface with nothing at runtime to read it from.
|
|
1859
|
-
*/ function stubCallable() {
|
|
2731
|
+
*/ function stubCallable(member) {
|
|
1860
2732
|
const fn = createMockFn();
|
|
2733
|
+
// jest's mock reads `_protoImpl` from itself whenever it's called, so the prototype below must not answer it
|
|
2734
|
+
if (usesRunnerMockFn()) Object.defineProperty(fn, '_protoImpl', {
|
|
2735
|
+
value: undefined,
|
|
2736
|
+
writable: true,
|
|
2737
|
+
enumerable: false,
|
|
2738
|
+
configurable: true
|
|
2739
|
+
});
|
|
1861
2740
|
// Any property beyond the mock's own API is a nested mock, made on first read and kept on the function, out of its
|
|
1862
2741
|
// keys. Its prototype makes them rather than a Proxy around it, so it stays the plain mock function matchers read
|
|
1863
2742
|
const inherited = Object.getPrototypeOf(fn);
|
|
@@ -1866,7 +2745,9 @@ function createMockInteraction(Class, props) {
|
|
|
1866
2745
|
if (typeof prop === 'symbol' || prop in proto) return Reflect.get(proto, prop, receiver);
|
|
1867
2746
|
// Never thenable — otherwise awaiting a mock hangs on itself
|
|
1868
2747
|
if (prop === 'then') return undefined;
|
|
1869
|
-
|
|
2748
|
+
// jest's matchers take a function whose `calls` has `all` and `count` for a jasmine spy, read before `mock.calls`
|
|
2749
|
+
if (member === 'calls' && (prop === 'all' || prop === 'count')) return undefined;
|
|
2750
|
+
const nested = stubCallable(prop);
|
|
1870
2751
|
Object.defineProperty(receiver, prop, {
|
|
1871
2752
|
value: nested,
|
|
1872
2753
|
writable: true,
|
|
@@ -1931,6 +2812,7 @@ function createMockInteraction(Class, props) {
|
|
|
1931
2812
|
if (key === 'then') return undefined;
|
|
1932
2813
|
// An explicitly supplied prop wins over the auto-stub.
|
|
1933
2814
|
if (Object.prototype.hasOwnProperty.call(instance, key)) return Reflect.get(instance, prop, instance);
|
|
2815
|
+
// The double itself is no function a matcher reads, so a `calls` member here keeps its `all` and `count`
|
|
1934
2816
|
if (!stubs.has(key)) stubs.set(key, stubCallable());
|
|
1935
2817
|
return stubs.get(key);
|
|
1936
2818
|
},
|
|
@@ -1970,11 +2852,12 @@ function createMockInteraction(Class, props) {
|
|
|
1970
2852
|
* @group Testing
|
|
1971
2853
|
* @category Mocks
|
|
1972
2854
|
* @see {@link createMockInteraction}
|
|
1973
|
-
*/
|
|
1974
|
-
|
|
2855
|
+
*/ function createMockUser(props = {}) {
|
|
2856
|
+
return createMockInteraction(discord_js.User, {
|
|
1975
2857
|
bot: false,
|
|
1976
2858
|
...props
|
|
1977
2859
|
});
|
|
2860
|
+
}
|
|
1978
2861
|
/**
|
|
1979
2862
|
* Creates a mock {@link Client}, with `users`, `channels`, `guilds` and `application.commands` ready to stub.
|
|
1980
2863
|
*
|
|
@@ -2088,6 +2971,38 @@ const HOLDS = [
|
|
|
2088
2971
|
[
|
|
2089
2972
|
discord_js.PermissionOverwriteManager.prototype,
|
|
2090
2973
|
discord_js.PermissionOverwrites
|
|
2974
|
+
],
|
|
2975
|
+
[
|
|
2976
|
+
discord_js.ReactionManager.prototype,
|
|
2977
|
+
discord_js.MessageReaction
|
|
2978
|
+
],
|
|
2979
|
+
[
|
|
2980
|
+
discord_js.ReactionUserManager.prototype,
|
|
2981
|
+
discord_js.User
|
|
2982
|
+
],
|
|
2983
|
+
[
|
|
2984
|
+
discord_js.PresenceManager.prototype,
|
|
2985
|
+
discord_js.Presence
|
|
2986
|
+
],
|
|
2987
|
+
[
|
|
2988
|
+
discord_js.GuildEmojiManager.prototype,
|
|
2989
|
+
discord_js.GuildEmoji
|
|
2990
|
+
],
|
|
2991
|
+
[
|
|
2992
|
+
discord_js.GuildStickerManager.prototype,
|
|
2993
|
+
discord_js.Sticker
|
|
2994
|
+
],
|
|
2995
|
+
[
|
|
2996
|
+
discord_js.GuildScheduledEventManager.prototype,
|
|
2997
|
+
discord_js.GuildScheduledEvent
|
|
2998
|
+
],
|
|
2999
|
+
[
|
|
3000
|
+
discord_js.AutoModerationRuleManager.prototype,
|
|
3001
|
+
discord_js.AutoModerationRule
|
|
3002
|
+
],
|
|
3003
|
+
[
|
|
3004
|
+
discord_js.GuildInviteManager.prototype,
|
|
3005
|
+
discord_js.Invite
|
|
2091
3006
|
]
|
|
2092
3007
|
];
|
|
2093
3008
|
/**
|
|
@@ -2103,6 +3018,11 @@ const HOLDS = [
|
|
|
2103
3018
|
value: cache,
|
|
2104
3019
|
writable: true
|
|
2105
3020
|
});
|
|
3021
|
+
// The same collection where discord.js's own methods read it past a subclass's `cache`, as `super.cache` does
|
|
3022
|
+
Object.defineProperty(manager, '_cache', {
|
|
3023
|
+
value: cache,
|
|
3024
|
+
writable: true
|
|
3025
|
+
});
|
|
2106
3026
|
const holds = HOLDS.find(([Manager])=>Manager === prototype || Object.prototype.isPrototypeOf.call(Manager, prototype))?.[1];
|
|
2107
3027
|
if (holds) Object.defineProperty(manager, 'holds', {
|
|
2108
3028
|
value: holds
|
|
@@ -2110,16 +3030,28 @@ const HOLDS = [
|
|
|
2110
3030
|
let own;
|
|
2111
3031
|
Object.defineProperty(manager, 'client', {
|
|
2112
3032
|
get: ()=>{
|
|
2113
|
-
const owner = manager.guild ?? manager.channel ?? manager.thread;
|
|
3033
|
+
const owner = manager.guild ?? manager.channel ?? manager.thread ?? manager.message;
|
|
2114
3034
|
return owner instanceof discord_js.Base ? owner.client : own ?? (own = createMockClient());
|
|
2115
3035
|
},
|
|
2116
|
-
|
|
3036
|
+
// A client the test sets is the one read from then on, as on any other mock
|
|
3037
|
+
set: (value)=>Object.defineProperty(manager, 'client', {
|
|
3038
|
+
value,
|
|
3039
|
+
writable: true,
|
|
3040
|
+
enumerable: true,
|
|
3041
|
+
configurable: true
|
|
3042
|
+
}),
|
|
2117
3043
|
configurable: true
|
|
2118
3044
|
});
|
|
2119
3045
|
return stubDeep(manager);
|
|
2120
3046
|
}
|
|
3047
|
+
/** An empty manager of `prototype` that `owner` names its own, such as `{ message }` for a message's reactions. */ function ownedManager(prototype, owner) {
|
|
3048
|
+
const manager = managerWith(prototype, undefined);
|
|
3049
|
+
Object.assign(manager, owner);
|
|
3050
|
+
return manager;
|
|
3051
|
+
}
|
|
2121
3052
|
/**
|
|
2122
|
-
* Creates a mock {@link Guild}, with
|
|
3053
|
+
* Creates a mock {@link Guild}, with its `members`, `channels`, `roles`, `bans`, `emojis`, `stickers`, `scheduledEvents`,
|
|
3054
|
+
* `autoModerationRules` and `invites` managers ready to stub.
|
|
2123
3055
|
*
|
|
2124
3056
|
* Use it for the server a message or an interaction came from, with the members, roles and channels a handler looks
|
|
2125
3057
|
* up in it.
|
|
@@ -2129,9 +3061,11 @@ const HOLDS = [
|
|
|
2129
3061
|
*
|
|
2130
3062
|
* @remarks
|
|
2131
3063
|
* A manager's `fetch(id)` resolves to its cached item with that id, as discord.js looks there first, or to a new one it
|
|
2132
|
-
* caches under that id: a member or
|
|
2133
|
-
*
|
|
2134
|
-
*
|
|
3064
|
+
* caches under that id: a member, channel, emoji, sticker, scheduled event or AutoMod rule in this guild, a role with
|
|
3065
|
+
* that id, a ban, or an invite with that code. `members.fetch({ user: ids })` resolves to a collection of each of those
|
|
3066
|
+
* members, found or made the same way; `create()` and `edit()` resolve to a mock of its item, which `create()` caches
|
|
3067
|
+
* as discord.js does, but for an invite, made for the channel given and cached nowhere; a list fetch resolves to an
|
|
3068
|
+
* empty collection. Members, roles and channels given are put in
|
|
2135
3069
|
* their managers' caches, where dispatch looks first when it resolves a message's typed params; a member
|
|
2136
3070
|
* {@link createMockMember} made without a server is in this one. Its `roles.everyone` is the role given with the
|
|
2137
3071
|
* guild's id, or else an @everyone role of its own at position 0 with no permissions, in `roles.cache` as Discord has
|
|
@@ -2154,14 +3088,15 @@ const HOLDS = [
|
|
|
2154
3088
|
* @see {@link createMockMessage}
|
|
2155
3089
|
*/ function createMockGuild(overrides = {}) {
|
|
2156
3090
|
const instance = Object.create(discord_js.Guild.prototype);
|
|
2157
|
-
|
|
2158
|
-
|
|
3091
|
+
const generatedId = overrides.id === undefined ? nextSnowflake() : undefined;
|
|
3092
|
+
instance.id = overrides.id ?? generatedId;
|
|
3093
|
+
defineMadeTime(instance, generatedId);
|
|
2159
3094
|
if (overrides.name !== undefined) instance.name = overrides.name;
|
|
2160
3095
|
if (overrides.preferredLocale !== undefined) instance.preferredLocale = overrides.preferredLocale;
|
|
2161
3096
|
instance.members = managerWith(discord_js.GuildMemberManager.prototype, overrides.members);
|
|
2162
3097
|
instance.channels = managerWith(discord_js.GuildChannelManager.prototype, overrides.channels);
|
|
2163
3098
|
instance.roles = guildRoleManager(instance.id, overrides.roles);
|
|
2164
|
-
instance
|
|
3099
|
+
for (const [key, Manager] of GUILD_MANAGERS)instance[key] = managerWith(Manager.prototype, undefined);
|
|
2165
3100
|
const guild = stubDeep(instance);
|
|
2166
3101
|
settleGuild(instance, guild);
|
|
2167
3102
|
// A member made without a server of its own is in this one
|
|
@@ -2200,7 +3135,8 @@ const HOLDS = [
|
|
|
2200
3135
|
* @see {@link createMockGuild}
|
|
2201
3136
|
*/ function createMockChannel(Class, props = {}) {
|
|
2202
3137
|
const instance = Object.create(Class.prototype);
|
|
2203
|
-
|
|
3138
|
+
const generatedId = props.id === undefined ? nextSnowflake() : undefined;
|
|
3139
|
+
instance.id = generatedId;
|
|
2204
3140
|
const is = (Base)=>Base.prototype.isPrototypeOf(Class.prototype) || Class === Base;
|
|
2205
3141
|
// Text and announcement channels: messages, and threads made in the channel
|
|
2206
3142
|
if (is(discord_js.TextChannel) || is(discord_js.NewsChannel)) {
|
|
@@ -2228,6 +3164,7 @@ const HOLDS = [
|
|
|
2228
3164
|
// A server's channel has its overwrites, none until a test puts some in the cache, which its permissions read
|
|
2229
3165
|
if (is(discord_js.GuildChannel) && !is(discord_js.ThreadChannel)) instance.permissionOverwrites = managerWith(discord_js.PermissionOverwriteManager.prototype, undefined);
|
|
2230
3166
|
Object.assign(instance, props);
|
|
3167
|
+
defineMadeTime(instance, generatedId);
|
|
2231
3168
|
const stubs = new Map();
|
|
2232
3169
|
const channel = stubDeep(instance, stubs);
|
|
2233
3170
|
// Each manager knows its channel, as discord.js's do, and a thread's members their thread
|
|
@@ -2353,16 +3290,24 @@ const CHANNEL_TYPE_GUARDS = [
|
|
|
2353
3290
|
return stubDeep(manager);
|
|
2354
3291
|
}
|
|
2355
3292
|
/** The permissions Discord gives a server's @everyone role in a server made today. */ const NEW_SERVER_EVERYONE_PERMISSIONS = 2248473465835073n;
|
|
3293
|
+
// The @everyone roles default mode made with no permissions, where strict mocks give a new server's
|
|
3294
|
+
const madeEveryones = new WeakSet();
|
|
3295
|
+
/** 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');
|
|
2356
3296
|
// A server's @everyone role, which has the server's id and ranks lowest, at position 0; with strict mocks, the
|
|
2357
3297
|
// permissions Discord gives it in a new server
|
|
2358
|
-
|
|
3298
|
+
function everyoneRole(guildId) {
|
|
3299
|
+
const strict = strictMocks();
|
|
3300
|
+
const role = createMockInteraction(discord_js.Role, {
|
|
2359
3301
|
id: guildId,
|
|
2360
3302
|
name: '@everyone',
|
|
2361
3303
|
position: 0,
|
|
2362
|
-
...
|
|
3304
|
+
...strict ? {
|
|
2363
3305
|
permissions: new discord_js.PermissionsBitField(NEW_SERVER_EVERYONE_PERMISSIONS).freeze()
|
|
2364
3306
|
} : {}
|
|
2365
3307
|
});
|
|
3308
|
+
if (!strict) madeEveryones.add(role);
|
|
3309
|
+
return role;
|
|
3310
|
+
}
|
|
2366
3311
|
/**
|
|
2367
3312
|
* A guild's role manager with `roles` in its cache, and the guild's @everyone role: the one given with the guild's id,
|
|
2368
3313
|
* or a role of its own at position 0, as every server has one.
|
|
@@ -2462,8 +3407,9 @@ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
|
|
|
2462
3407
|
* highest, by position, then the lower id. `permissions` are computed as discord.js computes them: every permission for
|
|
2463
3408
|
* the server's owner, and otherwise its roles' permissions combined, @everyone's included, so a member with no roles has
|
|
2464
3409
|
* @everyone's alone. The member is put in its guild's member cache, so an interaction or a message from its user in that
|
|
2465
|
-
* server has it as its `member`, and `memberPermissions` are its permissions
|
|
2466
|
-
* user's `send()` and the user's
|
|
3410
|
+
* server has it as its `member`, and `memberPermissions` are its permissions, with the interaction's channel's
|
|
3411
|
+
* overwrites applied under `useStrictMocks()`. A DM sent to the member goes through its user's `send()` and the user's
|
|
3412
|
+
* one DM channel.
|
|
2467
3413
|
*
|
|
2468
3414
|
* @param overrides - The member's user, server, roles and nickname; see {@link MockMemberOverrides}.
|
|
2469
3415
|
* @returns The mock member.
|
|
@@ -2510,6 +3456,218 @@ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
|
|
|
2510
3456
|
return guild ? cached(cacheOf(guild.channels), channelId, make) : make();
|
|
2511
3457
|
}
|
|
2512
3458
|
/** A server's id as a refusal names it, or a DM. */ const placeName = (guildId)=>guildId === null ? 'a DM' : `server ${guildId}`;
|
|
3459
|
+
/**
|
|
3460
|
+
* Where strict mocks place an interaction: in its message's channel and server, in a guild's or a member's server, and
|
|
3461
|
+
* with a guildId alone in a server the bot isn't in. Undefined where a channel given, or nothing, places it.
|
|
3462
|
+
*/ // Mock messages whose channel and server the mock made, as neither was given
|
|
3463
|
+
const placesMade = new WeakSet();
|
|
3464
|
+
// Messages whose place reads already warn, so a message reused for several interactions is wrapped once
|
|
3465
|
+
const madePlaceWarned = new WeakSet();
|
|
3466
|
+
/**
|
|
3467
|
+
* Has a message whose place the mock made warn once per key when its place is read, in default mode, where strict mocks
|
|
3468
|
+
* move it to the interaction it was given to. A read made while reading another of these, or by the mock, stays quiet.
|
|
3469
|
+
*/ function warnOnMadePlaceReads(message) {
|
|
3470
|
+
if (madePlaceWarned.has(message)) return;
|
|
3471
|
+
madePlaceWarned.add(message);
|
|
3472
|
+
for (const key of [
|
|
3473
|
+
'guild',
|
|
3474
|
+
'guildId',
|
|
3475
|
+
'channel',
|
|
3476
|
+
'channelId',
|
|
3477
|
+
'member'
|
|
3478
|
+
]){
|
|
3479
|
+
const descriptor = Object.getOwnPropertyDescriptor(message, key);
|
|
3480
|
+
if (!descriptor) continue;
|
|
3481
|
+
let value = descriptor.value;
|
|
3482
|
+
Object.defineProperty(message, key, {
|
|
3483
|
+
get: ()=>{
|
|
3484
|
+
if (readingPlace === 0) {
|
|
3485
|
+
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.');
|
|
3486
|
+
}
|
|
3487
|
+
readingPlace++;
|
|
3488
|
+
try {
|
|
3489
|
+
return descriptor.get ? descriptor.get.call(message) : value;
|
|
3490
|
+
} finally{
|
|
3491
|
+
readingPlace--;
|
|
3492
|
+
}
|
|
3493
|
+
},
|
|
3494
|
+
set: (next)=>{
|
|
3495
|
+
if (descriptor.set) descriptor.set.call(message, next);
|
|
3496
|
+
else value = next;
|
|
3497
|
+
},
|
|
3498
|
+
enumerable: true,
|
|
3499
|
+
configurable: true
|
|
3500
|
+
});
|
|
3501
|
+
}
|
|
3502
|
+
}
|
|
3503
|
+
// The DM channel each message the bot sent in a DM in default mode was sent in, where strict mocks place it
|
|
3504
|
+
const sentInDm = new WeakMap();
|
|
3505
|
+
/** 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);
|
|
3506
|
+
function strictPlaceOf(own) {
|
|
3507
|
+
const message = own('message');
|
|
3508
|
+
const channel = own('channel');
|
|
3509
|
+
const guild = own('guild');
|
|
3510
|
+
const guildId = own('guildId');
|
|
3511
|
+
const member = own('member');
|
|
3512
|
+
// A message whose place the mock made has none a test meant, so where the test placed the interaction wins
|
|
3513
|
+
const placed = channel !== undefined || guild !== undefined || guildId !== undefined;
|
|
3514
|
+
if (typeof message === 'object' && message !== null && typeof message.channelId === 'string' && !(placed && placeWasMade(message))) {
|
|
3515
|
+
// One the bot sent in a DM is in that DM, as strict mocks place it, though default mode reads a server for it
|
|
3516
|
+
const dm = sentInDm.get(mockTargets.get(message) ?? message);
|
|
3517
|
+
const channelId = dm ? dm.id : message.channelId;
|
|
3518
|
+
const at = dm ? null : message.guildId ?? null;
|
|
3519
|
+
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;
|
|
3520
|
+
return {
|
|
3521
|
+
given: 'message',
|
|
3522
|
+
channel: dm ?? message.channel,
|
|
3523
|
+
guildId: at,
|
|
3524
|
+
guild: dm ? null : message.guild ?? null,
|
|
3525
|
+
refused
|
|
3526
|
+
};
|
|
3527
|
+
}
|
|
3528
|
+
if (guild instanceof discord_js.Guild && guildId === undefined) return {
|
|
3529
|
+
given: 'guild',
|
|
3530
|
+
guildId: guild.id
|
|
3531
|
+
};
|
|
3532
|
+
if (member instanceof discord_js.GuildMember && member.guild instanceof discord_js.Guild && guild === undefined && guildId === undefined) {
|
|
3533
|
+
return {
|
|
3534
|
+
given: 'member',
|
|
3535
|
+
guild: member.guild,
|
|
3536
|
+
guildId: member.guild.id
|
|
3537
|
+
};
|
|
3538
|
+
}
|
|
3539
|
+
if (typeof guildId === 'string' && guild === undefined && channel === undefined && !(member instanceof discord_js.GuildMember) && !isRawMember(member)) {
|
|
3540
|
+
return {
|
|
3541
|
+
given: 'guildId',
|
|
3542
|
+
raw: true
|
|
3543
|
+
};
|
|
3544
|
+
}
|
|
3545
|
+
return undefined;
|
|
3546
|
+
}
|
|
3547
|
+
// Set while one of the reads below runs, so what it reads in turn doesn't warn again
|
|
3548
|
+
let readingPlace = 0;
|
|
3549
|
+
/** Runs `read` as the mock's own read of where a mock is, which warns about no placement. */ function readPlaceQuietly(read) {
|
|
3550
|
+
readingPlace++;
|
|
3551
|
+
try {
|
|
3552
|
+
return read();
|
|
3553
|
+
} finally{
|
|
3554
|
+
readingPlace--;
|
|
3555
|
+
}
|
|
3556
|
+
}
|
|
3557
|
+
// 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
|
|
3558
|
+
const placeWarners = new WeakMap();
|
|
3559
|
+
// 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
|
|
3560
|
+
let placeReadsSeen;
|
|
3561
|
+
/**
|
|
3562
|
+
* Runs `read`, a value of `mock`'s own that reads where the mock is, quietly; in default mode it warns once, as `key`,
|
|
3563
|
+
* where one of those reads would warn, since strict mocks place the mock otherwise, unless `differs` says the value is
|
|
3564
|
+
* the same in that place.
|
|
3565
|
+
*/ function readPlaceAs(mock, key, read, differs = ()=>true) {
|
|
3566
|
+
const outer = placeReadsSeen;
|
|
3567
|
+
const seen = placeReadsSeen = new Set();
|
|
3568
|
+
try {
|
|
3569
|
+
return readPlaceQuietly(read);
|
|
3570
|
+
} finally{
|
|
3571
|
+
placeReadsSeen = outer;
|
|
3572
|
+
const warner = placeWarners.get(mockTargets.get(mock) ?? mock);
|
|
3573
|
+
if (seen.size > 0 && warner && differs(warner.place)) warner.warn(key);
|
|
3574
|
+
}
|
|
3575
|
+
}
|
|
3576
|
+
/** What reading `key`, where strict mocks place an interaction otherwise, warns: what it reads now, and in 5.0. */ function placeWarning(name, key, place) {
|
|
3577
|
+
if (place.refused) {
|
|
3578
|
+
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}`;
|
|
3579
|
+
}
|
|
3580
|
+
const why = {
|
|
3581
|
+
message: [
|
|
3582
|
+
'a place other than its message\'s',
|
|
3583
|
+
"the mock is in its message's channel and server",
|
|
3584
|
+
"Give it the message's channel, or call useStrictMocks() to place it there now."
|
|
3585
|
+
],
|
|
3586
|
+
guild: [
|
|
3587
|
+
"a DM's value, though the mock was given a guild",
|
|
3588
|
+
'the mock is in that guild',
|
|
3589
|
+
'Give its guildId or one of its channels too, or call useStrictMocks() to place it there now.'
|
|
3590
|
+
],
|
|
3591
|
+
member: [
|
|
3592
|
+
"a DM's value, though the mock was given a member of a server",
|
|
3593
|
+
"the mock is in the member's server",
|
|
3594
|
+
"Give the member's guild too, or call useStrictMocks() to place it there now."
|
|
3595
|
+
],
|
|
3596
|
+
guildId: [
|
|
3597
|
+
'a server the bot is in, though the mock was given only a guildId',
|
|
3598
|
+
"a guildId alone is a server the bot isn't in: guild and channel are null, and the member is a raw one",
|
|
3599
|
+
'Give a guild too, or call useStrictMocks() to read it so now.'
|
|
3600
|
+
]
|
|
3601
|
+
}[place.given];
|
|
3602
|
+
return `${name}.${key} reads ${why[0]} here; in the next major version (5.0) ${why[1]}. ${why[2]}`;
|
|
3603
|
+
}
|
|
3604
|
+
/** Has a read of `instance`'s `key` call `warn` first, and what that read reads in turn stay quiet. */ function warnOnRead(instance, key, warn) {
|
|
3605
|
+
const descriptor = Object.getOwnPropertyDescriptor(instance, key);
|
|
3606
|
+
// One the mock answers with a stub stays unset, as the mock's own checks read it, and warns as the stub is read
|
|
3607
|
+
if (!descriptor) {
|
|
3608
|
+
if (!stubReadWarnings.has(instance)) stubReadWarnings.set(instance, new Map());
|
|
3609
|
+
stubReadWarnings.get(instance).set(key, warn);
|
|
3610
|
+
return;
|
|
3611
|
+
}
|
|
3612
|
+
const read = ()=>descriptor.get ? descriptor.get.call(instance) : descriptor.value;
|
|
3613
|
+
Object.defineProperty(instance, key, {
|
|
3614
|
+
get: ()=>{
|
|
3615
|
+
warn();
|
|
3616
|
+
readingPlace++;
|
|
3617
|
+
try {
|
|
3618
|
+
return read();
|
|
3619
|
+
} finally{
|
|
3620
|
+
readingPlace--;
|
|
3621
|
+
}
|
|
3622
|
+
},
|
|
3623
|
+
set: (next)=>Object.defineProperty(instance, key, {
|
|
3624
|
+
value: next,
|
|
3625
|
+
writable: true,
|
|
3626
|
+
enumerable: true,
|
|
3627
|
+
configurable: true
|
|
3628
|
+
}),
|
|
3629
|
+
enumerable: true,
|
|
3630
|
+
configurable: true
|
|
3631
|
+
});
|
|
3632
|
+
}
|
|
3633
|
+
/**
|
|
3634
|
+
* Has each of an interaction's `guildId`, `guild`, `channelId`, `channel` and `member` that strict mocks read otherwise
|
|
3635
|
+
* warn once when read, unless the test gave it.
|
|
3636
|
+
*/ function warnOnPlaceReads(instance, place, given) {
|
|
3637
|
+
const value = (key)=>{
|
|
3638
|
+
const descriptor = Object.getOwnPropertyDescriptor(instance, key);
|
|
3639
|
+
return descriptor?.get ? undefined : descriptor?.value;
|
|
3640
|
+
};
|
|
3641
|
+
const keys = place.refused ? [
|
|
3642
|
+
'guildId',
|
|
3643
|
+
'guild',
|
|
3644
|
+
'channelId',
|
|
3645
|
+
'channel'
|
|
3646
|
+
] : place.raw ? [
|
|
3647
|
+
'guild',
|
|
3648
|
+
'channel',
|
|
3649
|
+
'member'
|
|
3650
|
+
] : place.guildId !== value('guildId') ? [
|
|
3651
|
+
'guildId',
|
|
3652
|
+
'guild',
|
|
3653
|
+
'channelId',
|
|
3654
|
+
'channel',
|
|
3655
|
+
'member'
|
|
3656
|
+
] : place.channel && place.channel.id !== value('channelId') ? [
|
|
3657
|
+
'channelId',
|
|
3658
|
+
'channel'
|
|
3659
|
+
] : [];
|
|
3660
|
+
const name = Object.getPrototypeOf(instance).constructor.name;
|
|
3661
|
+
placeWarners.set(instance, {
|
|
3662
|
+
place,
|
|
3663
|
+
keys,
|
|
3664
|
+
warn: (read)=>controller_decorator.warnOnce(mockLogger, placeWarning(name, read, place))
|
|
3665
|
+
});
|
|
3666
|
+
for (const key of keys.filter((each)=>!given.has(each))){
|
|
3667
|
+
// A read the mock makes stays quiet, noted for a read of the mock's own to warn by its name
|
|
3668
|
+
warnOnRead(instance, key, ()=>readingPlace > 0 ? void placeReadsSeen?.add(key) : controller_decorator.warnOnce(mockLogger, placeWarning(name, key, place)));
|
|
3669
|
+
}
|
|
3670
|
+
}
|
|
2513
3671
|
/**
|
|
2514
3672
|
* 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
|
|
2515
3673
|
* DM channel, else the server the channel names. A server channel that names none is put in `fallback`'s, and named
|
|
@@ -2554,23 +3712,69 @@ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
|
|
|
2554
3712
|
cached(cacheOf(manager), id, ()=>channel);
|
|
2555
3713
|
}
|
|
2556
3714
|
}
|
|
2557
|
-
/** The guild a mock message carries: a guild with the same stubbed managers as createMockGuild. */ function createMockGuildForMessage(id
|
|
3715
|
+
/** The guild a mock message carries: a guild with the same stubbed managers as createMockGuild. */ function createMockGuildForMessage(id) {
|
|
2558
3716
|
const guild = Object.create(discord_js.Guild.prototype);
|
|
2559
|
-
|
|
3717
|
+
const generatedId = id === undefined ? nextSnowflake() : undefined;
|
|
3718
|
+
guild.id = id ?? generatedId;
|
|
3719
|
+
defineMadeTime(guild, generatedId);
|
|
2560
3720
|
guild.members = managerWith(discord_js.GuildMemberManager.prototype, undefined);
|
|
2561
3721
|
guild.channels = managerWith(discord_js.GuildChannelManager.prototype, undefined);
|
|
2562
3722
|
guild.roles = guildRoleManager(guild.id, undefined);
|
|
2563
|
-
guild
|
|
3723
|
+
for (const [key, Manager] of GUILD_MANAGERS)guild[key] = managerWith(Manager.prototype, undefined);
|
|
2564
3724
|
const proxy = stubDeep(guild);
|
|
2565
3725
|
settleGuild(guild, proxy);
|
|
2566
3726
|
return proxy;
|
|
2567
3727
|
}
|
|
3728
|
+
// Each settled guild's bot member as `members.me` reads it, without caching one made for the read
|
|
3729
|
+
const botMembers = new WeakMap();
|
|
3730
|
+
/** 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) {
|
|
3731
|
+
return botMembers.get(guild)?.() ?? guild.members.me;
|
|
3732
|
+
}
|
|
3733
|
+
/**
|
|
3734
|
+
* The channel an interaction's `channel` reads, for the mock's own reads: `null` in a DM, else one set on it or its
|
|
3735
|
+
* server's cached one, without making or caching one. Undefined where `channel` would make one, which has no overwrites.
|
|
3736
|
+
*/ function heldChannelOf(interaction, guild) {
|
|
3737
|
+
const target = mockTargets.get(interaction) ?? interaction;
|
|
3738
|
+
return readPlaceQuietly(()=>{
|
|
3739
|
+
const set = Object.getOwnPropertyDescriptor(target, 'channel');
|
|
3740
|
+
if (set && 'value' in set) return set.value;
|
|
3741
|
+
const { guildId, channelId } = target;
|
|
3742
|
+
return guildId ? cacheOf(guild.channels)?.get(channelId) : null;
|
|
3743
|
+
});
|
|
3744
|
+
}
|
|
3745
|
+
// A guild's managers beside members, channels and roles, each empty, of its discord.js class
|
|
3746
|
+
const GUILD_MANAGERS = [
|
|
3747
|
+
[
|
|
3748
|
+
'bans',
|
|
3749
|
+
discord_js.GuildBanManager
|
|
3750
|
+
],
|
|
3751
|
+
[
|
|
3752
|
+
'emojis',
|
|
3753
|
+
discord_js.GuildEmojiManager
|
|
3754
|
+
],
|
|
3755
|
+
[
|
|
3756
|
+
'stickers',
|
|
3757
|
+
discord_js.GuildStickerManager
|
|
3758
|
+
],
|
|
3759
|
+
[
|
|
3760
|
+
'scheduledEvents',
|
|
3761
|
+
discord_js.GuildScheduledEventManager
|
|
3762
|
+
],
|
|
3763
|
+
[
|
|
3764
|
+
'autoModerationRules',
|
|
3765
|
+
discord_js.AutoModerationRuleManager
|
|
3766
|
+
],
|
|
3767
|
+
[
|
|
3768
|
+
'invites',
|
|
3769
|
+
discord_js.GuildInviteManager
|
|
3770
|
+
]
|
|
3771
|
+
];
|
|
2568
3772
|
/** 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) {
|
|
2569
3773
|
for (const key of [
|
|
2570
3774
|
'members',
|
|
2571
3775
|
'channels',
|
|
2572
3776
|
'roles',
|
|
2573
|
-
|
|
3777
|
+
...GUILD_MANAGERS.map(([key])=>key)
|
|
2574
3778
|
])instance[key].guild = guild;
|
|
2575
3779
|
}
|
|
2576
3780
|
/**
|
|
@@ -2586,11 +3790,13 @@ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
|
|
|
2586
3790
|
configurable: true
|
|
2587
3791
|
});
|
|
2588
3792
|
const members = instance.members;
|
|
3793
|
+
const botMember = (keep)=>{
|
|
3794
|
+
const { user } = guild.client;
|
|
3795
|
+
return keep ? cached(cacheOf(members), user.id, ()=>memberIn(guild, user.id, user)) : cacheOf(members)?.get(user.id) ?? memberIn(guild, user.id, user);
|
|
3796
|
+
};
|
|
3797
|
+
botMembers.set(guild, ()=>botMember(false));
|
|
2589
3798
|
Object.defineProperty(members, 'me', {
|
|
2590
|
-
get: ()=>
|
|
2591
|
-
const { user } = guild.client;
|
|
2592
|
-
return cached(cacheOf(members), user.id, ()=>memberIn(guild, user.id, user));
|
|
2593
|
-
},
|
|
3799
|
+
get: ()=>botMember(true),
|
|
2594
3800
|
configurable: true
|
|
2595
3801
|
});
|
|
2596
3802
|
for (const key of [
|
|
@@ -2664,8 +3870,11 @@ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
|
|
|
2664
3870
|
};
|
|
2665
3871
|
// Own values, so the prototype's getters, which read the raw mention data, are not reached
|
|
2666
3872
|
const instance = Object.create(discord_js.MessageMentions.prototype);
|
|
3873
|
+
const parsedUsers = new discord_js.Collection(mentioned.users);
|
|
2667
3874
|
for (const [key, value] of Object.entries({
|
|
2668
3875
|
...mentioned,
|
|
3876
|
+
parsedUsers,
|
|
3877
|
+
crosspostedChannels: new discord_js.Collection(),
|
|
2669
3878
|
everyone: false,
|
|
2670
3879
|
client,
|
|
2671
3880
|
guild: guild ?? null
|
|
@@ -2678,11 +3887,14 @@ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
|
|
|
2678
3887
|
return stubDeep(instance);
|
|
2679
3888
|
}
|
|
2680
3889
|
/** 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;
|
|
2681
|
-
/**
|
|
3890
|
+
/**
|
|
3891
|
+
* The message a reply method starts from: the one a component is on, as it was, or an empty original response. Without
|
|
3892
|
+
* `allocate`, one with no id of its own gets `''`, for its holder to give it one when first read.
|
|
3893
|
+
*/ function heldFrom(message, allocate = true) {
|
|
2682
3894
|
// Read only what the message really has: a mock's unset fields are stubs
|
|
2683
3895
|
const from = message ?? {};
|
|
2684
3896
|
return {
|
|
2685
|
-
id: typeof from.id === 'string' ? from.id : nextSnowflake(),
|
|
3897
|
+
id: typeof from.id === 'string' ? from.id : allocate ? nextSnowflake() : '',
|
|
2686
3898
|
content: typeof from.content === 'string' ? from.content : undefined,
|
|
2687
3899
|
components: Array.isArray(from.components) ? from.components.map(jsonOf) : [],
|
|
2688
3900
|
embeds: Array.isArray(from.embeds) ? from.embeds.map(jsonOf) : [],
|
|
@@ -2690,6 +3902,15 @@ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
|
|
|
2690
3902
|
editedTimestamp: typeof from.editedTimestamp === 'number' ? from.editedTimestamp : null
|
|
2691
3903
|
};
|
|
2692
3904
|
}
|
|
3905
|
+
/** A message as an answer sends it: a new one, with what the answer gave, never edited. */ const sent = (options, allocate = true)=>{
|
|
3906
|
+
// Its id taken only once it builds, so a payload that fails to build takes none
|
|
3907
|
+
const held = edited(heldFrom(undefined, false), options);
|
|
3908
|
+
return {
|
|
3909
|
+
...held,
|
|
3910
|
+
id: allocate ? nextSnowflake() : '',
|
|
3911
|
+
editedTimestamp: null
|
|
3912
|
+
};
|
|
3913
|
+
};
|
|
2693
3914
|
/** A held message after an edit: what the edit sets replaced in Discord's shape, and a later edit stamp. */ function edited(held, options) {
|
|
2694
3915
|
const edit = typeof options === 'string' ? {
|
|
2695
3916
|
content: options
|
|
@@ -2718,29 +3939,28 @@ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
|
|
|
2718
3939
|
editedTimestamp: Math.max(Date.now(), (held.editedTimestamp ?? 0) + 1)
|
|
2719
3940
|
};
|
|
2720
3941
|
}
|
|
2721
|
-
/** A mock message showing a held message. */ function messageFrom(held) {
|
|
2722
|
-
return
|
|
3942
|
+
/** 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) {
|
|
3943
|
+
return sentMessage(held, channel, client, {
|
|
2723
3944
|
id: held.id,
|
|
2724
|
-
...held.content !== undefined && {
|
|
2725
|
-
content: held.content
|
|
2726
|
-
},
|
|
2727
|
-
components: held.components,
|
|
2728
|
-
embeds: held.embeds,
|
|
2729
|
-
...held.flags !== undefined && {
|
|
2730
|
-
flags: held.flags
|
|
2731
|
-
},
|
|
2732
3945
|
editedTimestamp: held.editedTimestamp
|
|
2733
|
-
});
|
|
3946
|
+
}, made);
|
|
2734
3947
|
}
|
|
2735
3948
|
/**
|
|
2736
|
-
* A component or embed as a message holds it: a discord.js instance as it is
|
|
2737
|
-
* its API JSON at the time of the call,
|
|
2738
|
-
*/ function asHeld(value) {
|
|
3949
|
+
* A component or embed as a message holds it: a discord.js instance as it is, anything else as the discord.js class
|
|
3950
|
+
* built from its API JSON at the time of the call, as discord.js builds it, an unknown type as a plain `Component`.
|
|
3951
|
+
*/ function asHeld(value, kind) {
|
|
2739
3952
|
if (value instanceof discord_js.Component || value instanceof discord_js.Embed) return value;
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
|
|
3953
|
+
// A copy, since discord.js's classes keep what they are given, one level deep
|
|
3954
|
+
const json = structuredClone(jsonOf(value));
|
|
3955
|
+
if (kind === 'embed') return new discord_js.Embed(json);
|
|
3956
|
+
// An action row builds each child with discord.js's own createComponent, which it doesn't export
|
|
3957
|
+
const row = new discord_js.ActionRow({
|
|
3958
|
+
type: discord_js.ComponentType.ActionRow,
|
|
3959
|
+
components: [
|
|
3960
|
+
json
|
|
3961
|
+
]
|
|
3962
|
+
});
|
|
3963
|
+
return row.components[0];
|
|
2744
3964
|
}
|
|
2745
3965
|
/**
|
|
2746
3966
|
* Creates a mock {@link Message} that tracks whether it has been deleted.
|
|
@@ -2749,12 +3969,14 @@ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
|
|
|
2749
3969
|
* `@MessageHandler` does not skip it.
|
|
2750
3970
|
*
|
|
2751
3971
|
* @remarks
|
|
2752
|
-
* `delete()`, `edit()`, `reply()`, `react()`, `pin()` and `unpin()` throw once the message is deleted
|
|
2753
|
-
* and `reply()`
|
|
2754
|
-
*
|
|
2755
|
-
* one
|
|
2756
|
-
*
|
|
2757
|
-
*
|
|
3972
|
+
* `delete()`, `edit()`, `reply()`, `react()`, `pin()` and `unpin()` throw once the message is deleted. `edit()` changes
|
|
3973
|
+
* this message and resolves to it, and `reply()` resolves to the reply, sent in this message's channel and holding what
|
|
3974
|
+
* was sent, as a channel's `send()` does. Under `useStrictMocks()` a message the bot sends is its `client.user`'s, and
|
|
3975
|
+
* 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
|
|
3976
|
+
* `member`; with `guild: null` it is a direct message. Give `author` to send several messages as one user, such as to
|
|
3977
|
+
* reach a per-user cooldown. An author given, and the users, roles and channels the content mentions, are cached as the
|
|
3978
|
+
* gateway delivers them. It is typed as discord.js emits a message, never in a group DM, so it fits `messageCreate` and
|
|
3979
|
+
* the other message events.
|
|
2758
3980
|
*
|
|
2759
3981
|
* @param overrides - The message's content, components and the rest; see {@link MockMessageOverrides}.
|
|
2760
3982
|
*
|
|
@@ -2777,11 +3999,18 @@ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
|
|
|
2777
3999
|
* @see {@link MockMessageOverrides}
|
|
2778
4000
|
* @see {@link createMockGuild}
|
|
2779
4001
|
*/ function createMockMessage(overrides = {}) {
|
|
4002
|
+
return messageMock(overrides);
|
|
4003
|
+
}
|
|
4004
|
+
function messageMock(overrides = {}, sentBy, made = {}, at) {
|
|
4005
|
+
var _made, _made1, _made2;
|
|
2780
4006
|
const instance = Object.create(discord_js.Message.prototype);
|
|
2781
4007
|
const stubs = new Map();
|
|
4008
|
+
const unplaced = overrides.channel === undefined && overrides.guild === undefined;
|
|
4009
|
+
// A message the bot sent is placed where it was sent, even where default mode reads a server of its own for a DM
|
|
4010
|
+
if (unplaced && !sentBy) placesMade.add(instance);
|
|
2782
4011
|
instance.deleted = false;
|
|
2783
4012
|
// Constructor-assigned — set as prototype-based stubs; a user rather than a bot, as dispatch handles only those
|
|
2784
|
-
instance.author = overrides.author ?? mockUser();
|
|
4013
|
+
instance.author = overrides.author ?? (_made = made).author ?? (_made.author = mockUser());
|
|
2785
4014
|
// Getters on the prototype, which the proxy would answer with a stub object: own properties shadow them
|
|
2786
4015
|
// A channel given says where the message was sent, unless its guild is given too
|
|
2787
4016
|
const place = overrides.channel ? placeOf(overrides.channel, overrides.guild === undefined ? undefined : overrides.guild?.id ?? null, ()=>{
|
|
@@ -2791,22 +4020,37 @@ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
|
|
|
2791
4020
|
guild
|
|
2792
4021
|
};
|
|
2793
4022
|
}) : undefined;
|
|
2794
|
-
const guild = overrides.guild !== undefined ? overrides.guild : place?.guildId !== undefined ? place.guildId === null ? null : place.guild ?? createMockGuildForMessage(place.guildId) : createMockGuildForMessage();
|
|
2795
|
-
const
|
|
2796
|
-
|
|
4023
|
+
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();
|
|
4024
|
+
const madeChannel = ()=>channelFor(guild, guild?.id ?? null, nextSnowflake(), instance.author);
|
|
4025
|
+
const channel = at ? undefined : overrides.channel ?? (unplaced ? (_made2 = made).channel ?? (_made2.channel = madeChannel()) : madeChannel());
|
|
4026
|
+
if (channel) Object.defineProperty(instance, 'channel', {
|
|
2797
4027
|
value: channel,
|
|
2798
|
-
writable: true
|
|
4028
|
+
writable: true,
|
|
4029
|
+
configurable: true
|
|
2799
4030
|
});
|
|
4031
|
+
else {
|
|
4032
|
+
Object.defineProperty(instance, 'channel', {
|
|
4033
|
+
get: at.channel,
|
|
4034
|
+
set: (value)=>Object.defineProperty(instance, 'channel', {
|
|
4035
|
+
value,
|
|
4036
|
+
writable: true,
|
|
4037
|
+
configurable: true
|
|
4038
|
+
}),
|
|
4039
|
+
configurable: true
|
|
4040
|
+
});
|
|
4041
|
+
}
|
|
2800
4042
|
Object.defineProperty(instance, 'guild', {
|
|
2801
4043
|
value: guild,
|
|
2802
|
-
writable: true
|
|
4044
|
+
writable: true,
|
|
4045
|
+
configurable: true
|
|
2803
4046
|
});
|
|
2804
4047
|
// The author as a member of the message's server; a direct message has none. A given author's member is the one
|
|
2805
4048
|
// the server caches, as the gateway resolves it, so every message from that author has the same member
|
|
2806
|
-
// The author's member, cached as the gateway caches it with the message; `member` reads the cache, as discord.js
|
|
4049
|
+
// The author's member, cached as the gateway caches it with the message; `member` reads the cache, as discord.js
|
|
4050
|
+
// does, without calling the manager's resolve a test may stub
|
|
2807
4051
|
if (guild) memberFor(guild, instance.author, true);
|
|
2808
4052
|
Object.defineProperty(instance, 'member', {
|
|
2809
|
-
get: ()=>guild ? guild.members
|
|
4053
|
+
get: ()=>instance.guild ? cacheOf(instance.guild.members)?.get(instance.author.id) ?? null : null,
|
|
2810
4054
|
set: (value)=>Object.defineProperty(instance, 'member', {
|
|
2811
4055
|
value,
|
|
2812
4056
|
writable: true,
|
|
@@ -2817,8 +4061,8 @@ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
|
|
|
2817
4061
|
configurable: true
|
|
2818
4062
|
});
|
|
2819
4063
|
// In a server's text channel, the ids matching the objects; with no guild, a DM
|
|
2820
|
-
instance.channelId = channel.
|
|
2821
|
-
instance.guildId = guild?.id ?? null;
|
|
4064
|
+
instance.channelId = channel?.id ?? at.channelId;
|
|
4065
|
+
instance.guildId = at ? at.guildId : guild?.id ?? null;
|
|
2822
4066
|
// The thread its channel caches under its id, as discord.js reads it; until 5.0, a placeholder thread otherwise
|
|
2823
4067
|
let placeholderThread;
|
|
2824
4068
|
Object.defineProperty(instance, 'thread', {
|
|
@@ -2848,7 +4092,7 @@ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
|
|
|
2848
4092
|
configurable: true
|
|
2849
4093
|
});
|
|
2850
4094
|
if (guild instanceof discord_js.Guild) cached(cacheOf(client.guilds), guild.id, ()=>guild);
|
|
2851
|
-
cacheChannel(channel, guild, client);
|
|
4095
|
+
if (channel) cacheChannel(channel, guild, client);
|
|
2852
4096
|
const userCache = cacheOf(client.users);
|
|
2853
4097
|
for (const user of [
|
|
2854
4098
|
...overrides.users ?? [],
|
|
@@ -2858,8 +4102,8 @@ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
|
|
|
2858
4102
|
instance.mentions = mentionsOf(overrides.content, client, guild);
|
|
2859
4103
|
// Data a message always has, real rather than stubbed, so code reading it sees an empty message
|
|
2860
4104
|
instance.flags = new discord_js.MessageFlagsBitField(overrides.flags);
|
|
2861
|
-
instance.components = (overrides.components ?? []).map(asHeld);
|
|
2862
|
-
instance.embeds = (overrides.embeds ?? []).map(asHeld);
|
|
4105
|
+
instance.components = (overrides.components ?? []).map((component)=>asHeld(component, 'component'));
|
|
4106
|
+
instance.embeds = (overrides.embeds ?? []).map((embed)=>asHeld(embed, 'embed'));
|
|
2863
4107
|
instance.attachments = new discord_js.Collection();
|
|
2864
4108
|
instance.editedTimestamp = overrides.editedTimestamp ?? null;
|
|
2865
4109
|
const generatedId = overrides.id === undefined ? nextSnowflake() : undefined;
|
|
@@ -2872,14 +4116,27 @@ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
|
|
|
2872
4116
|
if (instance.deleted) throw alreadyDeleted();
|
|
2873
4117
|
instance.deleted = true;
|
|
2874
4118
|
}));
|
|
2875
|
-
|
|
4119
|
+
// An edit changes this message, as Discord edits it in place, and resolves to it
|
|
4120
|
+
stubs.set('edit', createMockFn(async (options)=>{
|
|
2876
4121
|
if (instance.deleted) throw alreadyDeleted();
|
|
2877
|
-
|
|
4122
|
+
const held = edited(heldFrom(undefined, false), options);
|
|
4123
|
+
const edit = typeof options === 'string' ? {
|
|
4124
|
+
content: options
|
|
4125
|
+
} : options ?? {};
|
|
4126
|
+
if (edit.content !== undefined) instance.content = held.content;
|
|
4127
|
+
if (edit.components !== undefined) instance.components = held.components.map((component)=>asHeld(component, 'component'));
|
|
4128
|
+
if (edit.embeds !== undefined) instance.embeds = held.embeds.map((embed)=>asHeld(embed, 'embed'));
|
|
4129
|
+
if (edit.flags !== undefined) instance.flags = new discord_js.MessageFlagsBitField(held.flags);
|
|
4130
|
+
instance.editedTimestamp = Math.max(Date.now(), (instance.editedTimestamp ?? 0) + 1);
|
|
4131
|
+
return message;
|
|
2878
4132
|
}));
|
|
2879
|
-
stubs.set('reply', createMockFn(async ()=>{
|
|
4133
|
+
stubs.set('reply', createMockFn(async (options)=>{
|
|
2880
4134
|
if (instance.deleted) throw alreadyDeleted();
|
|
2881
|
-
return
|
|
4135
|
+
return sentMessage(sent(options, false), instance.channel, instance.client);
|
|
2882
4136
|
}));
|
|
4137
|
+
// Crossposting publishes this message, and forwarding sends one in the channel it is forwarded to
|
|
4138
|
+
stubs.set('crosspost', createMockFn(async ()=>message));
|
|
4139
|
+
stubs.set('forward', createMockFn(async (channel)=>sentMessage(sent(undefined, false), channel, instance.client)));
|
|
2883
4140
|
stubs.set('react', createMockFn(async ()=>{
|
|
2884
4141
|
if (instance.deleted) throw alreadyDeleted();
|
|
2885
4142
|
}));
|
|
@@ -2889,7 +4146,85 @@ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
|
|
|
2889
4146
|
stubs.set('unpin', createMockFn(async ()=>{
|
|
2890
4147
|
if (instance.deleted) throw alreadyDeleted();
|
|
2891
4148
|
}));
|
|
2892
|
-
|
|
4149
|
+
if (sentBy) warnOnSentReads(instance, stubs, sentBy);
|
|
4150
|
+
const message = stubDeep(instance, stubs);
|
|
4151
|
+
return message;
|
|
4152
|
+
}
|
|
4153
|
+
/**
|
|
4154
|
+
* Has a message the bot sent warn once where strict mocks read it otherwise: its author, the bot's `client.user` under
|
|
4155
|
+
* strict mocks, and, for one sent in a DM, the server it reads as being in.
|
|
4156
|
+
*/ function warnOnSentReads(instance, stubs, sentBy) {
|
|
4157
|
+
// A read made while reading another of these, as `member` reads `author`, warns only for the outer one
|
|
4158
|
+
let reading = 0;
|
|
4159
|
+
const read = (key, warning)=>{
|
|
4160
|
+
const descriptor = Object.getOwnPropertyDescriptor(instance, key);
|
|
4161
|
+
Object.defineProperty(instance, key, {
|
|
4162
|
+
get: ()=>{
|
|
4163
|
+
if (reading === 0) controller_decorator.warnOnce(mockLogger, warning);
|
|
4164
|
+
reading++;
|
|
4165
|
+
try {
|
|
4166
|
+
return descriptor?.get ? descriptor.get.call(instance) : descriptor?.value;
|
|
4167
|
+
} finally{
|
|
4168
|
+
reading--;
|
|
4169
|
+
}
|
|
4170
|
+
},
|
|
4171
|
+
set: (next)=>Object.defineProperty(instance, key, {
|
|
4172
|
+
value: next,
|
|
4173
|
+
writable: true,
|
|
4174
|
+
enumerable: true,
|
|
4175
|
+
configurable: true
|
|
4176
|
+
}),
|
|
4177
|
+
enumerable: true,
|
|
4178
|
+
configurable: true
|
|
4179
|
+
});
|
|
4180
|
+
};
|
|
4181
|
+
if (sentBy.bot) {
|
|
4182
|
+
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.");
|
|
4183
|
+
}
|
|
4184
|
+
if (!sentBy.dm) return;
|
|
4185
|
+
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.";
|
|
4186
|
+
const inGuild = Boolean(instance.guildId);
|
|
4187
|
+
for (const key of [
|
|
4188
|
+
'guild',
|
|
4189
|
+
'guildId',
|
|
4190
|
+
'member'
|
|
4191
|
+
])read(key, inDm(key));
|
|
4192
|
+
stubs.set('inGuild', createMockFn(()=>(controller_decorator.warnOnce(mockLogger, inDm('inGuild()')), inGuild)));
|
|
4193
|
+
}
|
|
4194
|
+
/**
|
|
4195
|
+
* A message the bot sent, as Discord returns it: in the channel it was sent in and that channel's server, holding what
|
|
4196
|
+
* 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
|
|
4197
|
+
* a DM send in a server of its own and keeps another author, with a warning when read.
|
|
4198
|
+
*/ function sentMessage(held, channel, client, fields = {}, made) {
|
|
4199
|
+
const by = client ?? channel?.client;
|
|
4200
|
+
const inChannel = channel instanceof discord_js.BaseChannel;
|
|
4201
|
+
const dm = inChannel && channel.isDMBased();
|
|
4202
|
+
const strict = strictMocks();
|
|
4203
|
+
const message = messageMock({
|
|
4204
|
+
...held.content !== undefined && {
|
|
4205
|
+
content: held.content
|
|
4206
|
+
},
|
|
4207
|
+
components: held.components,
|
|
4208
|
+
embeds: held.embeds,
|
|
4209
|
+
...held.flags !== undefined && {
|
|
4210
|
+
flags: held.flags
|
|
4211
|
+
},
|
|
4212
|
+
...inChannel && (!dm || strict) && {
|
|
4213
|
+
channel: channel
|
|
4214
|
+
},
|
|
4215
|
+
...by && {
|
|
4216
|
+
client: by
|
|
4217
|
+
},
|
|
4218
|
+
...strict && by?.user && {
|
|
4219
|
+
author: by.user
|
|
4220
|
+
},
|
|
4221
|
+
...fields
|
|
4222
|
+
}, strict ? undefined : {
|
|
4223
|
+
bot: Boolean(by?.user),
|
|
4224
|
+
dm
|
|
4225
|
+
}, made);
|
|
4226
|
+
if (dm && !strict) sentInDm.set(mockTargets.get(message) ?? message, channel);
|
|
4227
|
+
return message;
|
|
2893
4228
|
}
|
|
2894
4229
|
/** The option type Discord would have sent for a given JavaScript value. */ function optionTypeOf(value) {
|
|
2895
4230
|
if (typeof value === 'boolean') return discord_js.ApplicationCommandOptionType.Boolean;
|
|
@@ -2978,11 +4313,32 @@ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
|
|
|
2978
4313
|
else if (isEntity) option.user = value;
|
|
2979
4314
|
return option;
|
|
2980
4315
|
}
|
|
4316
|
+
/**
|
|
4317
|
+
* The option an autocomplete's user is typing, as Discord sends it: its value a string, `''` before anything is typed.
|
|
4318
|
+
* A number given for it is its digits under strict mocks; otherwise it stays a number, with a warning when read.
|
|
4319
|
+
*/ function focusedOptionData(name, value) {
|
|
4320
|
+
const option = toOptionData(name, value ?? '');
|
|
4321
|
+
option.focused = true;
|
|
4322
|
+
if (typeof value === 'number') {
|
|
4323
|
+
if (strictMocks()) option.value = String(value);
|
|
4324
|
+
else {
|
|
4325
|
+
Object.defineProperty(option, 'value', {
|
|
4326
|
+
get: ()=>{
|
|
4327
|
+
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.`);
|
|
4328
|
+
return value;
|
|
4329
|
+
},
|
|
4330
|
+
enumerable: true
|
|
4331
|
+
});
|
|
4332
|
+
}
|
|
4333
|
+
}
|
|
4334
|
+
return option;
|
|
4335
|
+
}
|
|
2981
4336
|
/**
|
|
2982
4337
|
* Nests the supplied options under the subcommand path they were invoked through,
|
|
2983
4338
|
* matching the shape Discord sends rather than a flat list.
|
|
2984
|
-
*/ function buildOptionData(subcommandGroup, subcommand, values, memberOf) {
|
|
2985
|
-
const leaves = Object.entries(values).map(([name, value])=>toOptionData(name, value, memberOf));
|
|
4339
|
+
*/ function buildOptionData(subcommandGroup, subcommand, values, memberOf, focused) {
|
|
4340
|
+
const leaves = Object.entries(values).map(([name, value])=>name === focused ? focusedOptionData(name, value) : toOptionData(name, value, memberOf));
|
|
4341
|
+
if (focused !== null && !Object.hasOwn(values, focused)) leaves.push(focusedOptionData(focused, null));
|
|
2986
4342
|
if (subcommand === null) return leaves;
|
|
2987
4343
|
const sub = {
|
|
2988
4344
|
name: subcommand,
|
|
@@ -3022,6 +4378,10 @@ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
|
|
|
3022
4378
|
* A whole number is an Integer option and a fraction a Number one, so
|
|
3023
4379
|
* `getInteger()` reads only a whole number, and `getNumber()` reads either.
|
|
3024
4380
|
*
|
|
4381
|
+
* The `focused` option reads as Discord sends it, through `getFocused()`, `getFocused(true)` and `data` alike: a
|
|
4382
|
+
* string, `''` when no value is given, and marked `focused` in `data`. A number given for it is its digits under
|
|
4383
|
+
* `useStrictMocks()`; otherwise it stays the number, with a warning when read.
|
|
4384
|
+
*
|
|
3025
4385
|
* @typeParam Cached - `any` by default, to match `createMockInteraction(ChatInputCommandInteraction)`; pass it when the
|
|
3026
4386
|
* interaction under test is cache-pinned.
|
|
3027
4387
|
*
|
|
@@ -3142,15 +4502,19 @@ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
|
|
|
3142
4502
|
], ()=>false, required));
|
|
3143
4503
|
base.getFocused = createMockFn((getFull)=>{
|
|
3144
4504
|
if (focused === null) throw optionError(discord_js.DiscordjsErrorCodes.AutocompleteInteractionOptionNoFocusedOption);
|
|
3145
|
-
|
|
4505
|
+
// The one options.data holds, so the two agree
|
|
4506
|
+
const option = base._hoistedOptions.find((each)=>each.focused);
|
|
3146
4507
|
return getFull === true ? {
|
|
3147
|
-
...option
|
|
3148
|
-
focused: true
|
|
4508
|
+
...option
|
|
3149
4509
|
} : option.value;
|
|
3150
4510
|
});
|
|
3151
4511
|
// `data` is what the framework reads to build a handler's params, so it is materialised here rather than
|
|
3152
4512
|
// auto-stubbed, or every params assertion would see an empty record.
|
|
3153
|
-
base.data = buildOptionData(subcommandGroup, subcommand, values, memberOf);
|
|
4513
|
+
base.data = buildOptionData(subcommandGroup, subcommand, values, memberOf, focused);
|
|
4514
|
+
// The fields discord.js's constructor sets from it, which its toString() reads
|
|
4515
|
+
base._group = subcommandGroup;
|
|
4516
|
+
base._subcommand = subcommand;
|
|
4517
|
+
base._hoistedOptions = subcommand === null ? base.data : (subcommandGroup === null ? base.data[0] : base.data[0].options[0]).options;
|
|
3154
4518
|
const resolver = stubDeep(base);
|
|
3155
4519
|
return resolver;
|
|
3156
4520
|
}
|
|
@@ -3224,7 +4588,7 @@ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
|
|
|
3224
4588
|
await this.resolving;
|
|
3225
4589
|
if (options.ready) {
|
|
3226
4590
|
const { client = createMockClient(), primary = true } = options.ready === true ? {} : options.ready;
|
|
3227
|
-
this.readying ?? (this.readying = this.runReady(client, primary));
|
|
4591
|
+
this.readying ?? (this.readying = this.readyPass.run(()=>this.runReady(client, primary)));
|
|
3228
4592
|
await this.readying;
|
|
3229
4593
|
}
|
|
3230
4594
|
return this;
|
|
@@ -3239,12 +4603,21 @@ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
|
|
|
3239
4603
|
* It first gives up the theme read outside calls, if `init({ ready: true })` made it this module's;
|
|
3240
4604
|
* reads outside calls then return MeoCord's defaults until another module or app is ready.
|
|
3241
4605
|
*
|
|
4606
|
+
* A stopped bot runs nothing more. Once `close()` is called, a module still runs what `dispatch`, `invoke` and `emit`
|
|
4607
|
+
* are given, against services shut down, or shutting down, and warns once that it is closed, so a `close()` in the
|
|
4608
|
+
* wrong place shows.
|
|
4609
|
+
*
|
|
3242
4610
|
* The cooldown store and what it injects shut down last, in the same sequence as the bot's. When any of them has an
|
|
3243
4611
|
* `onShutdown`, the calls `invoke`, `dispatch` and `emit` have under way finish first, then the store operations
|
|
3244
4612
|
* they started, so the store's last writes still reach it. The module stops waiting for the whole sequence after
|
|
3245
4613
|
* its `shutdownTimeout`, 10 seconds unless set, and logs that it did. Give a test whose fake store never answers, or
|
|
3246
4614
|
* whose `onShutdown` never settles, a short `shutdownTimeout`.
|
|
3247
4615
|
*
|
|
4616
|
+
* A `close()` while `init({ ready: true })` runs the `onReady` hooks waits for them first, within the same
|
|
4617
|
+
* `shutdownTimeout`. An `onReady` still running once only the hooks' share of it is left is named in a warning, and
|
|
4618
|
+
* its class is shut down all the same, as everything the module constructed is. One that calls `close()` itself isn't
|
|
4619
|
+
* waited for.
|
|
4620
|
+
*
|
|
3248
4621
|
* @returns Once every hook has run. Rejects with the error of a hook that failed, or an
|
|
3249
4622
|
* `AggregateError` naming each when several did.
|
|
3250
4623
|
*
|
|
@@ -3259,14 +4632,18 @@ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
|
|
|
3259
4632
|
* expect(module.get(POOL).ended).toBe(true)
|
|
3260
4633
|
* ```
|
|
3261
4634
|
*/ async close() {
|
|
4635
|
+
// An onReady awaiting close() would otherwise hold up the close that waits for it
|
|
4636
|
+
this.readyPass.excuseCurrent();
|
|
3262
4637
|
this.closing ?? (this.closing = (async ()=>{
|
|
3263
|
-
// A close during init waits for the
|
|
3264
|
-
|
|
3265
|
-
|
|
3266
|
-
.
|
|
4638
|
+
// A close during init waits for the providers it started, and the sequence for its onReady hooks, so it shuts
|
|
4639
|
+
// down whatever they constructed
|
|
4640
|
+
await this.resolving?.catch(()=>undefined);
|
|
4641
|
+
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
|
|
4642
|
+
.map((unit)=>startupRoots.lifecycleEntry(unit, this.container.get(unit.token) ?? {}));
|
|
3267
4643
|
const failures = [];
|
|
3268
4644
|
await startupRoots.runShutdownSequence(this.container, entries, {
|
|
3269
4645
|
drainCalls: ()=>startupRoots.callsSettled(this.calls),
|
|
4646
|
+
readyPass: this.readyPass,
|
|
3270
4647
|
timeoutMs: this.shutdownTimeout,
|
|
3271
4648
|
hookFailed: (name, error)=>failures.push({
|
|
3272
4649
|
name,
|
|
@@ -3280,6 +4657,11 @@ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
|
|
|
3280
4657
|
})());
|
|
3281
4658
|
await this.closing;
|
|
3282
4659
|
}
|
|
4660
|
+
/** Warns once, after `close()`, that a call runs against services already shut down. */ warnIfClosed() {
|
|
4661
|
+
if (this.closing === undefined || this.warnedClosed) return;
|
|
4662
|
+
this.warnedClosed = true;
|
|
4663
|
+
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.');
|
|
4664
|
+
}
|
|
3283
4665
|
async runReady(client, primary) {
|
|
3284
4666
|
// As the bot claims it before it comes online, so onReady reads the app's theme
|
|
3285
4667
|
providers.claimAmbientAppTheme(this.container);
|
|
@@ -3295,6 +4677,8 @@ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
|
|
|
3295
4677
|
}, [], {
|
|
3296
4678
|
resolveFailed: failed,
|
|
3297
4679
|
hookFailed: failed
|
|
4680
|
+
}, {
|
|
4681
|
+
runOnReady: this.readyPass.runOnReady
|
|
3298
4682
|
});
|
|
3299
4683
|
throwFailures('onReady', failures);
|
|
3300
4684
|
}
|
|
@@ -3366,7 +4750,9 @@ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
|
|
|
3366
4750
|
* @returns Whether the handler ran, and the error a filter handled, if any. Rejects with an error no
|
|
3367
4751
|
* filter handles, or with the error a filter throws: the built-in fallback, which answers such
|
|
3368
4752
|
* errors in the bot, does not run here. Rejects before running anything with an interaction or a
|
|
3369
|
-
* message the handler's route does not match, naming both.
|
|
4753
|
+
* message the handler's route does not match, naming both. Given fewer arguments than the handler
|
|
4754
|
+
* declares, with a first that is neither an interaction nor a message, such as a reaction without its
|
|
4755
|
+
* `ReactionEvent`, it rejects under `useStrictMocks()`, and otherwise warns once and runs the handler.
|
|
3370
4756
|
*
|
|
3371
4757
|
* @example
|
|
3372
4758
|
* ```ts
|
|
@@ -3388,6 +4774,7 @@ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
|
|
|
3388
4774
|
* expect(outcome.error).toBeInstanceOf(GuardDeniedError)
|
|
3389
4775
|
* ```
|
|
3390
4776
|
*/ async invoke(controller, methodName, ...args) {
|
|
4777
|
+
this.warnIfClosed();
|
|
3391
4778
|
await this.init();
|
|
3392
4779
|
if (!this.controllers.includes(controller)) {
|
|
3393
4780
|
throw new Error(`${controller.name} is not a controller of this testing module. Add it to \`controllers\`.`);
|
|
@@ -3417,6 +4804,7 @@ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
|
|
|
3417
4804
|
hooks = providers.messageCommandHooks(route, params, first, start, given, this.messageOptions.types);
|
|
3418
4805
|
}
|
|
3419
4806
|
}
|
|
4807
|
+
this.checkArgumentCount(controller, methodName, instance[methodName], first, callArgs.length);
|
|
3420
4808
|
const presenter = providers.appPresenterOf(this.container);
|
|
3421
4809
|
// A message command's errors are drawn by the app's presenter too, as an interaction's are
|
|
3422
4810
|
const client = first instanceof discord_js.BaseInteraction || first instanceof discord_js.Message ? first.client : undefined;
|
|
@@ -3433,6 +4821,19 @@ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
|
|
|
3433
4821
|
error
|
|
3434
4822
|
};
|
|
3435
4823
|
}
|
|
4824
|
+
/**
|
|
4825
|
+
* Refuses under `useStrictMocks()`, or warns once otherwise, a call that gives a handler fewer arguments than it
|
|
4826
|
+
* declares when `invoke` builds none for it, as for a reaction given without its `ReactionEvent`.
|
|
4827
|
+
*/ checkArgumentCount(controller, methodName, method, first, given) {
|
|
4828
|
+
if (first instanceof discord_js.BaseInteraction || first instanceof discord_js.Message || method.length <= given) return;
|
|
4829
|
+
const reaction = controller_decorator.getReactionHandlers(controller.prototype).some((handler)=>handler.method === methodName);
|
|
4830
|
+
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.');
|
|
4831
|
+
if (strictMocks()) throw new Error(problem);
|
|
4832
|
+
const key = `${controller.name}.${methodName}`;
|
|
4833
|
+
if (this.warnedArgumentCounts.has(key)) return;
|
|
4834
|
+
this.warnedArgumentCounts.add(key);
|
|
4835
|
+
new controller_decorator.Logger('TestingModule').warn(problem);
|
|
4836
|
+
}
|
|
3436
4837
|
/** Counts `call` as under way until it settles. */ track(call) {
|
|
3437
4838
|
this.calls.add(call);
|
|
3438
4839
|
void call.then(()=>this.calls.delete(call), ()=>this.calls.delete(call));
|
|
@@ -3478,6 +4879,7 @@ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
|
|
|
3478
4879
|
return this.dispatcher;
|
|
3479
4880
|
}
|
|
3480
4881
|
async dispatch(input, options) {
|
|
4882
|
+
this.warnIfClosed();
|
|
3481
4883
|
await this.init();
|
|
3482
4884
|
this.registerClient(input);
|
|
3483
4885
|
const dispatcher = this.dispatcherOf();
|
|
@@ -3495,6 +4897,7 @@ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
|
|
|
3495
4897
|
unhandled: (error)=>unhandled.push(error)
|
|
3496
4898
|
};
|
|
3497
4899
|
if (options) {
|
|
4900
|
+
noteReactionDispatch(input);
|
|
3498
4901
|
await this.track(dispatcher.reaction(input, {
|
|
3499
4902
|
user: options.user,
|
|
3500
4903
|
action: options.action ?? enum_index.ReactionHandlerAction.ADD
|
|
@@ -3524,15 +4927,20 @@ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
|
|
|
3524
4927
|
};
|
|
3525
4928
|
}
|
|
3526
4929
|
/**
|
|
3527
|
-
* Emits a client event to the module's `@On` and `@Once` handlers, through the
|
|
3528
|
-
*
|
|
3529
|
-
* module's controllers, class providers and their dependencies all receive it. A
|
|
3530
|
-
* handles only the first event, as it would on a client.
|
|
4930
|
+
* Emits a client event to the module's `@On` and `@Once` handlers, through the pipeline the app runs
|
|
4931
|
+
* them in up to its event fallback: the global guards of the module's `app`, then each handler's own.
|
|
4932
|
+
* Handlers on the module's controllers, class providers and their dependencies all receive it. A
|
|
4933
|
+
* `@Once` handler handles only the first event, as it would on a client.
|
|
4934
|
+
*
|
|
4935
|
+
* The bot's event fallback doesn't run here. On a bot, a guard's `GuardDeniedError` is logged and the
|
|
4936
|
+
* event skipped, and a handler's `UserError` is logged and, when the event carries a message, answered
|
|
4937
|
+
* as a reply to it; here both reject `emit` as any error does, so a test checks them with `rejects`.
|
|
3531
4938
|
*
|
|
3532
4939
|
* @param event - The client event, such as `'guildMemberAdd'`.
|
|
3533
4940
|
* @param args - The event's arguments, typed from discord.js's `ClientEvents`.
|
|
3534
|
-
* @returns How many handlers ran. Rejects once every handler has settled if any threw
|
|
3535
|
-
*
|
|
4941
|
+
* @returns How many handlers ran. Rejects once every handler has settled if any threw, a guard's
|
|
4942
|
+
* `GuardDeniedError` and a handler's `UserError` included: with that error when one handler failed,
|
|
4943
|
+
* or an `AggregateError` of them when several did.
|
|
3536
4944
|
*
|
|
3537
4945
|
* @example
|
|
3538
4946
|
* ```ts
|
|
@@ -3544,6 +4952,7 @@ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
|
|
|
3544
4952
|
* expect(ran).toBe(1)
|
|
3545
4953
|
* ```
|
|
3546
4954
|
*/ async emit(event, ...args) {
|
|
4955
|
+
this.warnIfClosed();
|
|
3547
4956
|
await this.init();
|
|
3548
4957
|
const calls = [];
|
|
3549
4958
|
for (const cls of this.eventClasses){
|
|
@@ -3588,7 +4997,10 @@ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
|
|
|
3588
4997
|
this.appWarnUnanswered = appWarnUnanswered;
|
|
3589
4998
|
this.services = services;
|
|
3590
4999
|
this.shutdownTimeout = shutdownTimeout;
|
|
5000
|
+
/** The `onReady` hooks' pass, which `close()` waits for, less one whose `onReady` called `close()`. */ this.readyPass = new startupRoots.ReadyPass();
|
|
5001
|
+
this.warnedClosed = false;
|
|
3591
5002
|
/** The `@Once` handlers that have already handled their event, as a client forgets its once listeners. */ this.firedOnce = new Set();
|
|
5003
|
+
/** The handlers already warned about for being given fewer arguments than they take, so each is named once. */ this.warnedArgumentCounts = new Set();
|
|
3592
5004
|
/** The calls under way, each removed once it settles: what `close()` lets finish before the cooldown store stops. */ this.calls = new Set();
|
|
3593
5005
|
}
|
|
3594
5006
|
}
|
|
@@ -3653,6 +5065,10 @@ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
|
|
|
3653
5065
|
/**
|
|
3654
5066
|
* Replaces a guard with a stub wherever it applies, globally or on a controller or handler.
|
|
3655
5067
|
*
|
|
5068
|
+
* It stands in for the guard wherever the guard is bound, a provider of the same token included, and wins over an
|
|
5069
|
+
* `overrideProvider` of it. The stub needs a `canActivate`, its own or its prototype's: `compile()` refuses one without
|
|
5070
|
+
* it under `useStrictMocks()`, and otherwise warns.
|
|
5071
|
+
*
|
|
3656
5072
|
* @param guard - The guard class to replace.
|
|
3657
5073
|
* @example
|
|
3658
5074
|
* ```ts
|
|
@@ -3670,6 +5086,10 @@ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
|
|
|
3670
5086
|
* Replaces an interceptor with a stub wherever it applies, globally or on a controller or handler.
|
|
3671
5087
|
* The stub's `intercept` receives the context and `next`; call `next.handle()` to run the handler.
|
|
3672
5088
|
*
|
|
5089
|
+
* It stands in for the interceptor wherever it is bound, a provider of the same token included, and wins over an
|
|
5090
|
+
* `overrideProvider` of it. A stub without `intercept` is refused by `compile()` under `useStrictMocks()`, and
|
|
5091
|
+
* otherwise warned about.
|
|
5092
|
+
*
|
|
3673
5093
|
* @param interceptor - The interceptor class to replace.
|
|
3674
5094
|
* @example
|
|
3675
5095
|
* ```ts
|
|
@@ -3687,6 +5107,10 @@ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
|
|
|
3687
5107
|
* Replaces an exception filter with a stub wherever it applies. The filter's `@Catch` still decides
|
|
3688
5108
|
* which errors reach the stub.
|
|
3689
5109
|
*
|
|
5110
|
+
* It stands in for the filter wherever it is bound, a provider of the same token included, and wins over an
|
|
5111
|
+
* `overrideProvider` of it. A stub without `catch` is refused by `compile()` under `useStrictMocks()`, and otherwise
|
|
5112
|
+
* warned about.
|
|
5113
|
+
*
|
|
3690
5114
|
* @param filter - The filter class to replace.
|
|
3691
5115
|
* @example
|
|
3692
5116
|
* ```ts
|
|
@@ -3819,9 +5243,52 @@ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
|
|
|
3819
5243
|
const providers$1 = providers.providerMap(this.wiring?.providers ?? [], '@MeoCord({ providers })');
|
|
3820
5244
|
for (const [token, provider] of providers.providerMap(this.options.providers ?? [], "the testing module's providers"))providers$1.set(token, provider);
|
|
3821
5245
|
for (const [token, override] of this.overrides)providers$1.set(token, override);
|
|
5246
|
+
// A stage override stands in for its class wherever the class is bound. A stage also provided takes the stub in
|
|
5247
|
+
// place of its provider, one binding per token, and runs lifecycle hooks as that provider would; any other is
|
|
5248
|
+
// bound on its own below, as the bot never makes a lifecycle unit of it. A stub that can't run is named here
|
|
5249
|
+
const stageStubs = new Map();
|
|
5250
|
+
for (const [method, overrides, kind] of [
|
|
5251
|
+
[
|
|
5252
|
+
'canActivate',
|
|
5253
|
+
this.guardOverrides,
|
|
5254
|
+
'overrideGuard'
|
|
5255
|
+
],
|
|
5256
|
+
[
|
|
5257
|
+
'intercept',
|
|
5258
|
+
this.interceptorOverrides,
|
|
5259
|
+
'overrideInterceptor'
|
|
5260
|
+
],
|
|
5261
|
+
[
|
|
5262
|
+
'catch',
|
|
5263
|
+
this.filterOverrides,
|
|
5264
|
+
'overrideFilter'
|
|
5265
|
+
]
|
|
5266
|
+
]){
|
|
5267
|
+
for (const [stage, stub] of overrides){
|
|
5268
|
+
if (providers$1.has(stage)) providers$1.set(stage, {
|
|
5269
|
+
provide: stage,
|
|
5270
|
+
useValue: stub
|
|
5271
|
+
});
|
|
5272
|
+
else stageStubs.set(stage, stub);
|
|
5273
|
+
if (typeof stub[method] === 'function') continue;
|
|
5274
|
+
const problem = `${kind}(${stage.name}).useValue(…) has no ${method} method.`;
|
|
5275
|
+
if (strictMocks()) controller_decorator.startupError(new Error(problem));
|
|
5276
|
+
else controller_decorator.warnOnce(new controller_decorator.Logger('TestingModule'), `${problem} A call that reaches ${stage.name} fails; useStrictMocks() refuses it here.`);
|
|
5277
|
+
}
|
|
5278
|
+
}
|
|
3822
5279
|
// Each pass of checks ends before the next step that is no check, so a module with one error stops where it always did
|
|
3823
5280
|
controller_decorator.stopOnStartupErrors();
|
|
5281
|
+
// What the walk below takes as classes, reported with what the walk finds; an entry refused here is left out of it
|
|
3824
5282
|
const services = this.wiring?.services ?? [];
|
|
5283
|
+
if (this.options.app) providers.assertProvidedServices(this.options.app.name, services, providers$1, "@MeoCord({ providers }) or the testing module's providers");
|
|
5284
|
+
for (const controller of this.options.controllers ?? []){
|
|
5285
|
+
if (!controller_decorator.isConstructor(controller)) controller_decorator.startupError(new Error(`the testing module's controllers takes classes, not ${controller_decorator.describeValue(controller)}.`));
|
|
5286
|
+
}
|
|
5287
|
+
const observers = [
|
|
5288
|
+
...this.options.app ? providers.appObservers(this.options.app) : [],
|
|
5289
|
+
...this.options.observers ?? []
|
|
5290
|
+
];
|
|
5291
|
+
providers.assertObservers("the testing module's observers", observers);
|
|
3825
5292
|
const appOptions = this.options.app ? Reflect.getMetadata(controller_decorator.META.appOptions, this.options.app) : undefined;
|
|
3826
5293
|
// The app's own store, as the bot binds it, whether the module is given the app or built from it; none when the
|
|
3827
5294
|
// test provides the CooldownStore, so nothing builds the app's or asks for what it injects
|
|
@@ -3830,18 +5297,16 @@ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
|
|
|
3830
5297
|
const themeResolver = controller_decorator.themeResolverClass(stages?.themeFor?.resolvers);
|
|
3831
5298
|
// Every class the module runs, as the app finds them, except stage classes an override stands in for
|
|
3832
5299
|
const i18n = this.options.app && Reflect.getMetadata(controller_decorator.META.appOptions, this.options.app)?.i18n;
|
|
5300
|
+
const appPresenterClass = this.options.app && Reflect.getMetadata(controller_decorator.META.appOptions, this.options.app)?.presenter;
|
|
3833
5301
|
const startup = startupRoots.startupClasses({
|
|
3834
|
-
controllers: this.options.controllers ?? [],
|
|
5302
|
+
controllers: (this.options.controllers ?? []).filter(controller_decorator.isConstructor),
|
|
3835
5303
|
services,
|
|
3836
5304
|
providers: providers$1,
|
|
3837
5305
|
stages,
|
|
3838
|
-
presenter:
|
|
5306
|
+
presenter: appPresenterClass,
|
|
3839
5307
|
cooldownStore: store,
|
|
3840
5308
|
themeFor: stages?.themeFor?.resolvers,
|
|
3841
|
-
observers
|
|
3842
|
-
...this.options.app ? providers.appObservers(this.options.app) : [],
|
|
3843
|
-
...this.options.observers ?? []
|
|
3844
|
-
],
|
|
5309
|
+
observers,
|
|
3845
5310
|
stubs: [
|
|
3846
5311
|
...this.guardOverrides.keys(),
|
|
3847
5312
|
...this.interceptorOverrides.keys(),
|
|
@@ -3866,16 +5331,8 @@ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
|
|
|
3866
5331
|
throw new Error(`${needClient.map((cls)=>cls.name).join(', ')} ${needClient.length === 1 ? 'injects' : 'inject'} the Discord Client, ` + 'which a testing module does not make: give one in its providers, such as ' + '{ provide: Client, useValue: createMockClient() }.');
|
|
3867
5332
|
});
|
|
3868
5333
|
}
|
|
3869
|
-
//
|
|
3870
|
-
for (const [
|
|
3871
|
-
container.bind(guardClass).toConstantValue(stub);
|
|
3872
|
-
}
|
|
3873
|
-
for (const [filterClass, stub] of this.filterOverrides){
|
|
3874
|
-
container.bind(filterClass).toConstantValue(stub);
|
|
3875
|
-
}
|
|
3876
|
-
for (const [interceptorClass, stub] of this.interceptorOverrides){
|
|
3877
|
-
container.bind(interceptorClass).toConstantValue(stub);
|
|
3878
|
-
}
|
|
5334
|
+
// A stage override nothing provides, bound before the classes so none of them binds the stage itself
|
|
5335
|
+
for (const [stage, stub] of stageStubs)container.bind(stage).toConstantValue(stub);
|
|
3879
5336
|
// The app's cooldown policy, so a test of a failing store sees what the bot would do
|
|
3880
5337
|
if (appOptions && !container.isBound(providers.COOLDOWN_POLICY)) container.bind(providers.COOLDOWN_POLICY).toConstantValue(providers.cooldownPolicyFrom(appOptions));
|
|
3881
5338
|
// The app's translator, unless a provider stands in for it
|
|
@@ -3939,16 +5396,12 @@ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
|
|
|
3939
5396
|
// As the app does when it is created, so a test sees the refusal the bot would give
|
|
3940
5397
|
startupRoots.assertDistinctCommands(this.options.controllers ?? []);
|
|
3941
5398
|
if (this.options.app) providers.prepareAppPresenter(container, this.options.app);
|
|
3942
|
-
const observers = [
|
|
3943
|
-
...this.options.app ? providers.appObservers(this.options.app) : [],
|
|
3944
|
-
...this.options.observers ?? []
|
|
3945
|
-
];
|
|
3946
|
-
providers.assertObservers("the testing module's observers", observers);
|
|
3947
|
-
// Only @Observer classes are bound as observers
|
|
3948
|
-
controller_decorator.stopOnStartupErrors();
|
|
3949
5399
|
providers.bindObservers(container, observers);
|
|
3950
5400
|
// The store calls ask: the test's own when it provides CooldownStore, else the app's
|
|
3951
5401
|
const boundStore = store ?? (providers$1.has(controller_decorator.CooldownStore) ? controller_decorator.CooldownStore : undefined);
|
|
5402
|
+
// What the stages and presenter inject, as the bot makes it; a stage the test replaced contributes nothing
|
|
5403
|
+
const stageServices = startupRoots.stageDependencies(startup, providers$1, appPresenterClass);
|
|
5404
|
+
for (const service of stageServices)bindClass(service);
|
|
3952
5405
|
// The order the app runs lifecycle hooks in: its cooldown store, then providers, controllers and observers, each
|
|
3953
5406
|
// after what it injects
|
|
3954
5407
|
const lifecycle = providers.resolutionOrder(container, providers$1, [
|
|
@@ -3957,6 +5410,7 @@ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
|
|
|
3957
5410
|
] : [],
|
|
3958
5411
|
...providers$1.keys(),
|
|
3959
5412
|
...services,
|
|
5413
|
+
...stageServices,
|
|
3960
5414
|
...themeResolver ? [
|
|
3961
5415
|
themeResolver
|
|
3962
5416
|
] : [],
|
|
@@ -3977,6 +5431,11 @@ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
|
|
|
3977
5431
|
...this.options.controllers ?? [],
|
|
3978
5432
|
...startup.classes
|
|
3979
5433
|
])Reflect.defineMetadata(controller_decorator.META.container, container, cls);
|
|
5434
|
+
// And each instance with the module that made it, as another module made from the same classes stamps them again
|
|
5435
|
+
providers.recordInstanceContainers(container, [
|
|
5436
|
+
...this.options.controllers ?? [],
|
|
5437
|
+
...startup.classes
|
|
5438
|
+
]);
|
|
3980
5439
|
providers.prepareThemes(container, appClasses);
|
|
3981
5440
|
// A handler with no builder is how a fixture is written, so only what is always a mistake is named
|
|
3982
5441
|
startupRoots.warnUnregisteredCommands(this.options.controllers ?? [], {
|
|
@@ -4050,7 +5509,15 @@ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
|
|
|
4050
5509
|
* @param options - The controllers, providers, observers and app the module is built from.
|
|
4051
5510
|
* @returns The builder, to override what the test replaces before `compile()`.
|
|
4052
5511
|
*/ static create(options) {
|
|
4053
|
-
|
|
5512
|
+
// A controller listed twice is one controller, as the bot and fromApp take it
|
|
5513
|
+
return new TestingModuleBuilder({
|
|
5514
|
+
...options,
|
|
5515
|
+
...options.controllers && {
|
|
5516
|
+
controllers: [
|
|
5517
|
+
...new Set(options.controllers)
|
|
5518
|
+
]
|
|
5519
|
+
}
|
|
5520
|
+
});
|
|
4054
5521
|
}
|
|
4055
5522
|
/**
|
|
4056
5523
|
* Starts building a testing module from a whole `@MeoCord` app, wired as the bot wires it.
|
|
@@ -4068,7 +5535,8 @@ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
|
|
|
4068
5535
|
* @param app - The class `@MeoCord` decorates.
|
|
4069
5536
|
* @param options - Providers that replace the app's by token, extra controllers, and observers.
|
|
4070
5537
|
* @returns The builder, whose `override*` methods still apply, for `compile()`.
|
|
4071
|
-
* @throws TypeError when `app` is not a `@MeoCord` class.
|
|
5538
|
+
* @throws TypeError when `app` is not a `@MeoCord` class. After {@link reportAllStartupErrors}, an app whose
|
|
5539
|
+
* `@MeoCord` was refused throws the error it was refused for instead.
|
|
4072
5540
|
*
|
|
4073
5541
|
* @example
|
|
4074
5542
|
* ```ts
|
|
@@ -4109,7 +5577,13 @@ const everyoneRole = (guildId)=>createMockInteraction(discord_js.Role, {
|
|
|
4109
5577
|
* ```
|
|
4110
5578
|
*/ static fromApp(app, options = {}) {
|
|
4111
5579
|
const appOptions = Reflect.getMetadata(controller_decorator.META.appOptions, app);
|
|
4112
|
-
if (!appOptions)
|
|
5580
|
+
if (!appOptions) {
|
|
5581
|
+
// A refused @MeoCord stores no options, so its own errors, kept with startupErrors: 'all', say why
|
|
5582
|
+
controller_decorator.collectStartupErrors(()=>controller_decorator.declaredErrorsOf([
|
|
5583
|
+
app
|
|
5584
|
+
]).forEach(controller_decorator.startupError));
|
|
5585
|
+
throw new TypeError(`${app?.name ?? String(app)} is not a @MeoCord app: fromApp takes the class @MeoCord decorates.`);
|
|
5586
|
+
}
|
|
4113
5587
|
const controllers = [
|
|
4114
5588
|
...new Set([
|
|
4115
5589
|
...appOptions.controllers,
|
|
@@ -4357,7 +5831,6 @@ providers.adviseInPlaceOfInjecting(TestingModule, 'make it with MeoCordTestingMo
|
|
|
4357
5831
|
return new Fields(components);
|
|
4358
5832
|
}
|
|
4359
5833
|
|
|
4360
|
-
const isPlural = (value)=>typeof value === 'object' && value !== null && typeof value.other === 'string';
|
|
4361
5834
|
/** Every message key of a catalog, and whether it is a plural. */ function leaves(catalog, prefix = '') {
|
|
4362
5835
|
return Object.entries(catalog).flatMap(([name, value])=>{
|
|
4363
5836
|
const key = `${prefix}${name}`;
|
|
@@ -4367,7 +5840,7 @@ const isPlural = (value)=>typeof value === 'object' && value !== null && typeof
|
|
|
4367
5840
|
plural: false
|
|
4368
5841
|
}
|
|
4369
5842
|
];
|
|
4370
|
-
if (
|
|
5843
|
+
if (controller_decorator.isPluralShape(value)) return [
|
|
4371
5844
|
{
|
|
4372
5845
|
key,
|
|
4373
5846
|
plural: true
|
|
@@ -4376,8 +5849,9 @@ const isPlural = (value)=>typeof value === 'object' && value !== null && typeof
|
|
|
4376
5849
|
return leaves(value, `${key}.`);
|
|
4377
5850
|
});
|
|
4378
5851
|
}
|
|
4379
|
-
|
|
4380
|
-
|
|
5852
|
+
/** 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 !== '';
|
|
5853
|
+
/** 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) {
|
|
5854
|
+
return key.split('.').reduce((current, part)=>typeof current === 'object' && current !== null && Object.hasOwn(current, part) ? current[part] : undefined, catalog);
|
|
4381
5855
|
}
|
|
4382
5856
|
const MEOCORD_KEYS = new Set(leaves(controller_decorator.MEOCORD_MESSAGES).map(({ key })=>key));
|
|
4383
5857
|
const isMeoCordKey = (key)=>key.startsWith('meocord.');
|
|
@@ -4390,7 +5864,7 @@ const isMeoCordKey = (key)=>key.startsWith('meocord.');
|
|
|
4390
5864
|
/** A message as a report shows it: the text, or a plural's `other` form. */ const shown = (message)=>typeof message === 'string' ? message : String(message.other);
|
|
4391
5865
|
/** The `{params}` a translation uses that the message it translates doesn't take; a plural's `{count}` is taken. */ function strayParams(translation, original) {
|
|
4392
5866
|
const taken = placeholders(original);
|
|
4393
|
-
if (
|
|
5867
|
+
if (controller_decorator.isPluralShape(original)) taken.add('count');
|
|
4394
5868
|
return [
|
|
4395
5869
|
...placeholders(translation)
|
|
4396
5870
|
].filter((name)=>!taken.has(name));
|
|
@@ -4400,9 +5874,11 @@ const isMeoCordKey = (key)=>key.startsWith('meocord.');
|
|
|
4400
5874
|
*
|
|
4401
5875
|
* Use it in a test for a team that wants no fallback in production. It reports the messages a locale lacks, the
|
|
4402
5876
|
* messages the default catalog doesn't have, the plural forms a language needs but a plural lacks, such as `few` for
|
|
4403
|
-
* Russian,
|
|
4404
|
-
*
|
|
4405
|
-
*
|
|
5877
|
+
* Russian, a translation of another shape than the default's, a text for a plural or a plural for a text, and each
|
|
5878
|
+
* `{param}` a translation uses that the message it translates doesn't take. An empty translation, `''`, counts as
|
|
5879
|
+
* lacking, as the translator reads it as missing. It reads the catalogs' own strings, so it checks the params of a
|
|
5880
|
+
* catalog from a plain variable or a JSON file too, which the compiler can't. An object counts as a plural only when
|
|
5881
|
+
* its every key is a plural category, so a group with an `other` key is a group.
|
|
4406
5882
|
*
|
|
4407
5883
|
* @remarks
|
|
4408
5884
|
* MeoCord's own texts, a catalog's `meocord` group, fall back to English by design, so only a key MeoCord lacks, and
|
|
@@ -4449,12 +5925,17 @@ const isMeoCordKey = (key)=>key.startsWith('meocord.');
|
|
|
4449
5925
|
const categories = new Intl.PluralRules(locale).resolvedOptions().pluralCategories;
|
|
4450
5926
|
for (const { key, plural } of reference){
|
|
4451
5927
|
const message = at(catalog, key);
|
|
4452
|
-
if (message === undefined) {
|
|
5928
|
+
if (message === undefined || message === '') {
|
|
4453
5929
|
problems.push(`missing ${key}`);
|
|
4454
5930
|
continue;
|
|
4455
5931
|
}
|
|
4456
|
-
|
|
4457
|
-
|
|
5932
|
+
// A translation of another shape than the default's can't be read as it: a text for a plural, or the reverse
|
|
5933
|
+
if (plural !== controller_decorator.isPluralShape(message) || !plural && typeof message !== 'string') {
|
|
5934
|
+
problems.push(`${key} should be a ${plural ? 'plural' : 'text'}, as the default is`);
|
|
5935
|
+
continue;
|
|
5936
|
+
}
|
|
5937
|
+
if (plural) {
|
|
5938
|
+
const missing = categories.filter((category)=>!isText(message[category]));
|
|
4458
5939
|
if (missing.length > 0) problems.push(`${key} lacks ${missing.join(', ')}`);
|
|
4459
5940
|
}
|
|
4460
5941
|
const original = at(defaultCatalog, key);
|
|
@@ -4462,7 +5943,7 @@ const isMeoCordKey = (key)=>key.startsWith('meocord.');
|
|
|
4462
5943
|
for (const name of stray)problems.push(`${key} takes no {${name}}; the default is "${shown(original)}"`);
|
|
4463
5944
|
}
|
|
4464
5945
|
if (options.meocord && !locale.startsWith('en-')) {
|
|
4465
|
-
for (const key of MEOCORD_KEYS)if (at(catalog, key) === undefined) problems.push(`missing ${key}`);
|
|
5946
|
+
for (const key of MEOCORD_KEYS)if (at(catalog, key) === undefined || at(catalog, key) === '') problems.push(`missing ${key}`);
|
|
4466
5947
|
}
|
|
4467
5948
|
for (const { key } of leaves(catalog)){
|
|
4468
5949
|
if (isMeoCordKey(key)) {
|
|
@@ -4490,8 +5971,9 @@ const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
|
|
|
4490
5971
|
*
|
|
4491
5972
|
* @remarks
|
|
4492
5973
|
* The cases cover what a shared store most often gets wrong: a sliding window rather than fixed buckets,
|
|
4493
|
-
* `retryAfterMs` from the oldest
|
|
4494
|
-
* calls at the limit where exactly one may pass
|
|
5974
|
+
* `retryAfterMs` until a use frees up, from the oldest of the newest `uses` calls in the window, calls in the same
|
|
5975
|
+
* millisecond kept apart, concurrent calls at the limit where exactly one may pass, a batch refusal that names the
|
|
5976
|
+
* 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
|
|
4495
5977
|
* a few seconds and margins wide enough that a correct store passes when timers fire late, as on a busy CI runner,
|
|
4496
5978
|
* and each case counts under keys of its own. Each window is a whole number of milliseconds, as
|
|
4497
5979
|
* `@Cooldown` gives a store: it rounds its `seconds` to the millisecond.
|
|
@@ -4566,6 +6048,30 @@ const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
|
|
|
4566
6048
|
expect(retryAfterMs).toBeLessThanOrEqual(limit.windowMs - (secondSent - firstDone) + CLOCK_SLACK_MS);
|
|
4567
6049
|
expect(retryAfterMs).toBeGreaterThanOrEqual(limit.windowMs - (thirdDone - firstSent) - CLOCK_SLACK_MS);
|
|
4568
6050
|
});
|
|
6051
|
+
// @Cooldown keeps the calls counted so far when a limit is lowered, so a key can hold more calls than `uses`
|
|
6052
|
+
test('counts retryAfterMs from the oldest of the newest `uses` calls when a lowered limit leaves more in the window', async (store)=>{
|
|
6053
|
+
const windowMs = 2000;
|
|
6054
|
+
for(let call = 0; call < 3; call += 1)await store.consume(key('lowered'), {
|
|
6055
|
+
uses: 5,
|
|
6056
|
+
windowMs
|
|
6057
|
+
});
|
|
6058
|
+
await sleep(1000);
|
|
6059
|
+
const newerSent = Date.now();
|
|
6060
|
+
for(let call = 0; call < 2; call += 1)await store.consume(key('lowered'), {
|
|
6061
|
+
uses: 5,
|
|
6062
|
+
windowMs
|
|
6063
|
+
});
|
|
6064
|
+
const { allowed, retryAfterMs } = await store.consume(key('lowered'), {
|
|
6065
|
+
uses: 2,
|
|
6066
|
+
windowMs
|
|
6067
|
+
});
|
|
6068
|
+
const refusedDone = Date.now();
|
|
6069
|
+
expect(allowed).toBe(false);
|
|
6070
|
+
// A use frees up when the older of the two newest calls leaves the window; counted from the oldest call, it would
|
|
6071
|
+
// be a second sooner
|
|
6072
|
+
expect(retryAfterMs).toBeGreaterThanOrEqual(windowMs - (refusedDone - newerSent) - CLOCK_SLACK_MS);
|
|
6073
|
+
expect(retryAfterMs).toBeLessThanOrEqual(windowMs + CLOCK_SLACK_MS);
|
|
6074
|
+
});
|
|
4569
6075
|
test('counts each key on its own', async (store)=>{
|
|
4570
6076
|
const limit = {
|
|
4571
6077
|
uses: 1,
|
|
@@ -4627,6 +6133,41 @@ const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
|
|
|
4627
6133
|
expect(refused.retryAfterMs).toBeGreaterThan(short.windowMs);
|
|
4628
6134
|
expect(refused.retryAfterMs).toBeLessThanOrEqual(long.windowMs);
|
|
4629
6135
|
});
|
|
6136
|
+
// A refusal names the wait until every cooldown of the batch allows the call; the default consumeMany stops at the first
|
|
6137
|
+
test('names the longest wait when several cooldowns refuse a batch, or with the default consumeMany, the first', async (store)=>{
|
|
6138
|
+
const short = {
|
|
6139
|
+
uses: 1,
|
|
6140
|
+
windowMs: 1000
|
|
6141
|
+
};
|
|
6142
|
+
const long = {
|
|
6143
|
+
uses: 1,
|
|
6144
|
+
windowMs: 2000
|
|
6145
|
+
};
|
|
6146
|
+
const batch = [
|
|
6147
|
+
{
|
|
6148
|
+
key: key('both-short'),
|
|
6149
|
+
limit: short
|
|
6150
|
+
},
|
|
6151
|
+
{
|
|
6152
|
+
key: key('both-long'),
|
|
6153
|
+
limit: long
|
|
6154
|
+
}
|
|
6155
|
+
];
|
|
6156
|
+
await store.consume(batch[0].key, short);
|
|
6157
|
+
await store.consume(batch[1].key, long);
|
|
6158
|
+
const refused = await store.consumeMany(batch);
|
|
6159
|
+
expect(refused.allowed).toBe(false);
|
|
6160
|
+
const [blocked, limit] = overridesConsumeMany(store) ? [
|
|
6161
|
+
1,
|
|
6162
|
+
long
|
|
6163
|
+
] : [
|
|
6164
|
+
0,
|
|
6165
|
+
short
|
|
6166
|
+
];
|
|
6167
|
+
expect(refused.blocked).toBe(blocked);
|
|
6168
|
+
expect(refused.retryAfterMs).toBeGreaterThan(limit.windowMs - 1000);
|
|
6169
|
+
expect(refused.retryAfterMs).toBeLessThanOrEqual(limit.windowMs);
|
|
6170
|
+
});
|
|
4630
6171
|
// The default consumeMany counts in order and stops at the first refusal, so the entries before it count
|
|
4631
6172
|
test('records nothing when one cooldown of a batch refuses it, or with the default consumeMany, counts those before it', async (store)=>{
|
|
4632
6173
|
const limit = {
|
|
@@ -4669,10 +6210,11 @@ const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
|
|
|
4669
6210
|
}, ()=>store.consumeMany(batch)));
|
|
4670
6211
|
expect(verdicts.filter((verdict)=>verdict.allowed).length).toBe(1);
|
|
4671
6212
|
});
|
|
4672
|
-
// A store without release keeps a counted call counted, as the default consumeMany does
|
|
4673
|
-
|
|
6213
|
+
// A store without release keeps a counted call counted, as the default consumeMany does. Two uses tell a release of
|
|
6214
|
+
// the one call apart from one that frees the key's other calls too
|
|
6215
|
+
test('releases only the call it counted when the verdict gives release, or keeps it counted without one', async (store)=>{
|
|
4674
6216
|
const limit = {
|
|
4675
|
-
uses:
|
|
6217
|
+
uses: 2,
|
|
4676
6218
|
windowMs: 2000
|
|
4677
6219
|
};
|
|
4678
6220
|
const entries = [
|
|
@@ -4685,6 +6227,7 @@ const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
|
|
|
4685
6227
|
limit
|
|
4686
6228
|
}
|
|
4687
6229
|
];
|
|
6230
|
+
expect((await store.consumeMany(entries)).allowed).toBe(true);
|
|
4688
6231
|
const counted = await store.consumeMany(entries);
|
|
4689
6232
|
expect(counted.allowed).toBe(true);
|
|
4690
6233
|
if (!counted.release) {
|