meocord 4.2.1 → 4.2.3
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 +188 -0
- package/dist/cjs/_shared/{controller.decorator-XKMlcUZz.cjs → controller.decorator-hp5xdJU4.cjs} +550 -189
- package/dist/cjs/_shared/{startup-roots-CgfPQrNl.cjs → event-sources-DzqTODXg.cjs} +274 -88
- package/dist/cjs/_shared/{providers-BZgJyfZS.cjs → providers-BBvbnmLP.cjs} +837 -295
- package/dist/cjs/_shared/{sharded-cooldown-store-CsJJyCZd.cjs → sharded-cooldown-store-B62g5O88.cjs} +11 -9
- package/dist/cjs/_shared/{translate-error-BUAR1WYG.cjs → translate-error-wsgfsUET.cjs} +1 -1
- package/dist/cjs/common/index.cjs +4 -104
- package/dist/cjs/core/index.cjs +435 -182
- package/dist/cjs/decorator/index.cjs +116 -121
- package/dist/cjs/testing/index.cjs +1923 -808
- package/dist/cli.json +1 -1
- package/dist/esm/bin/app-template/package.json.template +9 -9
- package/dist/esm/bin/app-template/src/main.ts.template +3 -2
- package/dist/esm/bin/generator.js +6 -4
- package/dist/esm/bin/helper/filter-generator.helper.js +6 -4
- package/dist/esm/bin/helper/guard-generator.helper.js +6 -4
- package/dist/esm/bin/helper/interceptor-generator.helper.js +6 -4
- package/dist/esm/bin/helper/observer-generator.helper.js +6 -4
- package/dist/esm/bin/helper/pipe-generator.helper.js +6 -4
- package/dist/esm/bin/helper/service-generator.helper.js +6 -4
- package/dist/esm/bin/meocord.js +106 -48
- package/dist/esm/build/native-addons.js +75 -19
- package/dist/esm/build/rsbuild-config.js +11 -3
- package/dist/esm/build/typed-externals.js +15 -2
- package/dist/esm/common/cooldown-store.js +38 -14
- package/dist/esm/common/deprecation.js +4 -1
- package/dist/esm/common/execution-context.js +3 -1
- package/dist/esm/common/explained-error.js +7 -3
- package/dist/esm/common/log-level.js +13 -9
- package/dist/esm/common/logger.js +4 -2
- package/dist/esm/common/meocord-text.js +11 -6
- package/dist/esm/common/redis-cooldown-store.js +3 -7
- package/dist/esm/common/response/flags.js +17 -1
- package/dist/esm/common/response/install-context.js +27 -16
- package/dist/esm/common/response/presenter.js +7 -3
- package/dist/esm/common/response/response-state.js +32 -10
- package/dist/esm/common/sharded-cooldown-store.js +11 -9
- package/dist/esm/common/theme.js +3 -1
- package/dist/esm/common/translator.js +27 -8
- package/dist/esm/core/autocomplete-routes.js +26 -0
- package/dist/esm/core/command-conflicts.js +15 -7
- package/dist/esm/core/command-registration.js +15 -1
- package/dist/esm/core/cooldown-runner.js +7 -9
- package/dist/esm/core/defer.js +2 -5
- package/dist/esm/core/dispatcher.js +92 -66
- package/dist/esm/core/event-requirements.js +67 -19
- package/dist/esm/core/event-sources.js +100 -0
- package/dist/esm/core/fallback.js +3 -1
- package/dist/esm/core/filter-runner.js +6 -6
- package/dist/esm/core/guard-runner.js +173 -52
- package/dist/esm/core/handler-input.js +24 -3
- package/dist/esm/core/handler-pipeline.js +54 -32
- package/dist/esm/core/handler-registry.js +51 -21
- package/dist/esm/core/input-runner.js +7 -8
- package/dist/esm/core/interceptor-runner.js +4 -5
- package/dist/esm/core/lifecycle-hooks.js +3 -8
- package/dist/esm/core/lifecycle-order.js +1 -15
- package/dist/esm/core/meocord-classes.js +14 -1
- package/dist/esm/core/meocord-factory.js +83 -54
- package/dist/esm/core/meocord.app.js +174 -89
- package/dist/esm/core/message-help.js +37 -15
- package/dist/esm/core/message-params.js +126 -46
- package/dist/esm/core/message-routes.js +225 -45
- package/dist/esm/core/message-words.js +30 -2
- package/dist/esm/core/observer-runner.js +4 -2
- package/dist/esm/core/process-state.js +19 -0
- package/dist/esm/core/providers.js +39 -4
- package/dist/esm/core/scalar-types.js +57 -16
- package/dist/esm/core/shard-context.js +11 -10
- package/dist/esm/core/shard-exit.js +3 -3
- package/dist/esm/core/shard-manager.js +16 -6
- package/dist/esm/core/stage-scope.js +2 -1
- package/dist/esm/core/startup-roots.js +28 -8
- package/dist/esm/core/theme-resolvers.js +26 -11
- package/dist/esm/core/theme-runtime.js +23 -13
- package/dist/esm/core/theme-scope.js +31 -30
- package/dist/esm/core/theme-validation.js +1 -1
- package/dist/esm/decorator/app.decorator.js +37 -1
- package/dist/esm/decorator/command-builder.decorator.js +14 -0
- package/dist/esm/decorator/controller-class.decorator.js +1 -1
- package/dist/esm/decorator/controller.decorator.js +48 -19
- package/dist/esm/decorator/defer.decorator.js +2 -2
- package/dist/esm/decorator/event.decorator.js +4 -5
- package/dist/esm/decorator/filter.decorator.js +4 -4
- package/dist/esm/decorator/guard.decorator.js +4 -61
- package/dist/esm/decorator/inject.decorator.js +4 -1
- package/dist/esm/decorator/interceptor.decorator.js +2 -2
- package/dist/esm/decorator/theme.decorator.js +4 -3
- package/dist/esm/decorator/validation.decorator.js +5 -5
- package/dist/esm/package.json.js +1 -1
- package/dist/esm/testing/catalog.js +32 -25
- package/dist/esm/testing/cooldown-store-suite.js +3 -3
- package/dist/esm/testing/discord-defaults.js +449 -335
- package/dist/esm/testing/inspect-handler.js +91 -10
- package/dist/esm/testing/meocord-testing-module.js +217 -91
- package/dist/esm/testing/mock-fn.js +8 -2
- package/dist/esm/testing/mock-interaction.js +864 -124
- package/dist/esm/testing/response-log.js +8 -0
- package/dist/esm/testing/response.js +7 -5
- package/dist/esm/testing/routing.js +48 -6
- package/dist/esm/testing/strict-mocks.js +8 -4
- package/dist/esm/testing/theme.js +3 -2
- package/dist/esm/util/common.util.js +30 -9
- package/dist/esm/util/generator-cli.util.js +6 -4
- package/dist/esm/util/inherited-env.util.js +72 -13
- package/dist/esm/util/json.util.js +10 -3
- package/dist/esm/util/meocord-cli.util.js +3 -2
- package/dist/esm/util/meocord-config-loader.util.js +14 -1
- package/dist/esm/util/meocord-config-validation.util.js +13 -3
- package/dist/esm/util/meocord-source-config.util.js +13 -3
- package/dist/esm/util/platform.util.js +3 -2
- package/dist/esm/util/refusal.util.js +29 -11
- package/dist/esm/util/registration-mode.util.js +14 -1
- package/dist/esm/util/timer-limit.util.js +4 -1
- package/dist/esm/util/tsconfig-paths.util.js +38 -7
- package/dist/esm/util/tsconfig.util.js +16 -58
- package/dist/esm/util/user-text.util.js +8 -4
- package/dist/esm/util/value.util.js +33 -5
- package/dist/types/common/index.d.cts +17 -10
- package/dist/types/common/index.d.ts +17 -10
- package/dist/types/core/index.d.cts +21 -11
- package/dist/types/core/index.d.ts +21 -11
- package/dist/types/decorator/index.d.cts +63 -63
- package/dist/types/decorator/index.d.ts +63 -63
- package/dist/types/{index-DKJclIi-.d.ts → index-Brkd6QGL.d.ts} +81 -35
- package/dist/types/{index-Dlf0ZZtS.d.cts → index-CqXImwF_.d.cts} +81 -35
- package/dist/types/interface/index.d.cts +1 -1
- package/dist/types/interface/index.d.ts +1 -1
- package/dist/types/{route-Bh1U13yy.d.ts → route-IQrK-MN2.d.cts} +11 -3
- package/dist/types/{route-Bh1U13yy.d.cts → route-bI-okq7g.d.ts} +11 -3
- package/dist/types/testing/index.d.cts +158 -61
- package/dist/types/testing/index.d.ts +158 -61
- package/dist/types/{theme-resolvers-CyEz9pnv.d.cts → theme-resolvers-AtgeaYqF.d.cts} +1 -1
- package/dist/types/{theme-resolvers-DA204O-q.d.ts → theme-resolvers-BgqysV8d.d.ts} +1 -1
- package/dist/types/{translator-Bg7hwxDF.d.cts → translator-D8aydlCE.d.ts} +12 -11
- package/dist/types/{translator-CeP4bCzp.d.ts → translator-iJhDlgwL.d.cts} +12 -11
- package/package.json +5 -2
|
@@ -2,26 +2,26 @@
|
|
|
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-BBvbnmLP.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
|
|
8
|
+
var controller_decorator = require('../_shared/controller.decorator-hp5xdJU4.cjs');
|
|
9
|
+
var eventSources = require('../_shared/event-sources-DzqTODXg.cjs');
|
|
10
10
|
var node_crypto = require('node:crypto');
|
|
11
11
|
require('node:async_hooks');
|
|
12
12
|
require('crypto');
|
|
13
13
|
require('fs');
|
|
14
14
|
require('path');
|
|
15
15
|
require('cli-table3');
|
|
16
|
-
require('node:fs');
|
|
17
|
-
require('node:url');
|
|
18
16
|
require('node:util');
|
|
19
17
|
require('dayjs');
|
|
20
18
|
require('dayjs/plugin/utc.js');
|
|
21
19
|
require('dayjs/plugin/timezone.js');
|
|
22
20
|
require('module');
|
|
21
|
+
require('node:fs');
|
|
23
22
|
require('chalk');
|
|
24
|
-
require('
|
|
23
|
+
require('node:url');
|
|
24
|
+
require('../_shared/translate-error-wsgfsUET.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.
|
|
@@ -236,7 +236,13 @@ const collected = new FinalizationRegistry((ref)=>mocks$1.created.delete(ref));
|
|
|
236
236
|
instances.push(this);
|
|
237
237
|
contexts.push(this);
|
|
238
238
|
invocationCallOrder.push(++mocks$1.invocationOrder);
|
|
239
|
-
//
|
|
239
|
+
// Recorded as the call starts, so each call's results sit at its own index however calls nest, and filled in as it
|
|
240
|
+
// ends: a promise's outcome shows in settledResults once it resolves or rejects
|
|
241
|
+
const result = {
|
|
242
|
+
type: 'incomplete',
|
|
243
|
+
value: undefined
|
|
244
|
+
};
|
|
245
|
+
results.push(result);
|
|
240
246
|
const settled = {
|
|
241
247
|
type: 'incomplete',
|
|
242
248
|
value: undefined
|
|
@@ -284,7 +290,7 @@ const collected = new FinalizationRegistry((ref)=>mocks$1.created.delete(ref));
|
|
|
284
290
|
});
|
|
285
291
|
throw err;
|
|
286
292
|
} finally{
|
|
287
|
-
|
|
293
|
+
Object.assign(result, {
|
|
288
294
|
type,
|
|
289
295
|
value
|
|
290
296
|
});
|
|
@@ -389,6 +395,11 @@ const collected = new FinalizationRegistry((ref)=>mocks$1.created.delete(ref));
|
|
|
389
395
|
return track(mockFn);
|
|
390
396
|
}
|
|
391
397
|
|
|
398
|
+
/**
|
|
399
|
+
* Where a mock interaction keeps every answer it got, through respond() or discord.js directly, in order, under a key
|
|
400
|
+
* both builds of this meocord version read.
|
|
401
|
+
*/ const RESPONSE_LOG = Symbol.for(controller_decorator.sharedName('responseLog'));
|
|
402
|
+
|
|
392
403
|
const VISIBLE = new Set([
|
|
393
404
|
'reply',
|
|
394
405
|
'update',
|
|
@@ -400,9 +411,10 @@ const VISIBLE = new Set([
|
|
|
400
411
|
* Reports how an interaction was answered: where its answer stands, and every answer call it got, in order.
|
|
401
412
|
*
|
|
402
413
|
* Use it after `invoke` or `dispatch` to check what the member sees. A mock interaction records each `reply`,
|
|
403
|
-
* `deferReply`, `editReply`, `followUp`, `deleteReply`, `update`, `deferUpdate` and `showModal`,
|
|
404
|
-
*
|
|
405
|
-
*
|
|
414
|
+
* `deferReply`, `editReply`, `followUp`, `deleteReply`, `update`, `deferUpdate` and `showModal`, and under
|
|
415
|
+
* `useStrictMocks()` each `launchActivity`, `sendPremiumRequired` and autocomplete `respond` too, whether the handler made
|
|
416
|
+
* it through `respond()` or with discord.js directly, so both are reported, each once. For a message or a reaction, read
|
|
417
|
+
* the mock's own methods.
|
|
406
418
|
*
|
|
407
419
|
* @param interaction - The interaction a handler answered.
|
|
408
420
|
* @returns The state, whether anything the member can see was sent, and the calls, in order.
|
|
@@ -447,7 +459,8 @@ const VISIBLE = new Set([
|
|
|
447
459
|
sent,
|
|
448
460
|
calls: calls
|
|
449
461
|
};
|
|
450
|
-
|
|
462
|
+
// An autocomplete's choices are its answer, as observers read it, where its mock records them, under strict mocks
|
|
463
|
+
const answered = interaction.isRepliable() ? interaction.replied : Boolean(log) && interaction.isAutocomplete() && interaction.responded;
|
|
451
464
|
const deferred = interaction.isRepliable() ? interaction.deferred : false;
|
|
452
465
|
return {
|
|
453
466
|
state: answered ? 'replied' : deferred ? 'deferred' : 'unanswered',
|
|
@@ -486,6 +499,72 @@ const VISIBLE = new Set([
|
|
|
486
499
|
}, code, 400, 'POST', 'https://discord.com/api/v10/interactions', {});
|
|
487
500
|
}
|
|
488
501
|
|
|
502
|
+
/**
|
|
503
|
+
* Builds the `fields` of a submitted form, as discord.js does when a user submits one.
|
|
504
|
+
*
|
|
505
|
+
* Use it for a mock `ModalSubmitInteraction`: discord.js keeps the `ModalSubmitFields` constructor private, so a test
|
|
506
|
+
* cannot build one directly.
|
|
507
|
+
*
|
|
508
|
+
* @param values - Each field's value, keyed by its customId: a string for a text input, an array of strings for a
|
|
509
|
+
* select's chosen values, or an array of `Attachment`s for a file upload. An empty array is a select with nothing
|
|
510
|
+
* chosen.
|
|
511
|
+
* @returns Fields that `getTextInputValue`, `getStringSelectValues`, `getUploadedFiles` and a handler's params all
|
|
512
|
+
* read.
|
|
513
|
+
*
|
|
514
|
+
* @example
|
|
515
|
+
* ```ts
|
|
516
|
+
* import { expect } from 'vitest'
|
|
517
|
+
*
|
|
518
|
+
* @Controller()
|
|
519
|
+
* class FeedbackController {
|
|
520
|
+
* @Command('feedback/{topic}', CommandType.MODAL_SUBMIT)
|
|
521
|
+
* async submit(interaction: ModalSubmitInteraction, { topic, body }: { topic: string; body: string }) {
|
|
522
|
+
* await respond(interaction).send(`Thanks: ${topic}, ${body}`)
|
|
523
|
+
* }
|
|
524
|
+
* }
|
|
525
|
+
* const module = MeoCordTestingModule.create({ controllers: [FeedbackController] }).compile()
|
|
526
|
+
* const fields = createModalFields({ body: 'It crashed' })
|
|
527
|
+
* const interaction = createMockInteraction(ModalSubmitInteraction, { customId: 'feedback/bugs', fields })
|
|
528
|
+
* await module.invoke(FeedbackController, 'submit', interaction)
|
|
529
|
+
* expect(getResponse(interaction).calls[0].payload).toMatchObject({ content: 'Thanks: bugs, It crashed' })
|
|
530
|
+
* ```
|
|
531
|
+
*
|
|
532
|
+
* @group Testing
|
|
533
|
+
* @category Mocks
|
|
534
|
+
* @see {@link createMockInteraction}
|
|
535
|
+
*/ function createModalFields(values) {
|
|
536
|
+
const component = (customId, value)=>{
|
|
537
|
+
if (!Array.isArray(value)) return {
|
|
538
|
+
type: discord_js.ComponentType.TextInput,
|
|
539
|
+
customId,
|
|
540
|
+
value
|
|
541
|
+
};
|
|
542
|
+
if (!value.some((item)=>item instanceof discord_js.Attachment)) return {
|
|
543
|
+
type: discord_js.ComponentType.StringSelect,
|
|
544
|
+
customId,
|
|
545
|
+
values: value
|
|
546
|
+
};
|
|
547
|
+
const attachments = new discord_js.Collection(value.map((file)=>[
|
|
548
|
+
file.id,
|
|
549
|
+
file
|
|
550
|
+
]));
|
|
551
|
+
return {
|
|
552
|
+
type: discord_js.ComponentType.FileUpload,
|
|
553
|
+
customId,
|
|
554
|
+
values: [
|
|
555
|
+
...attachments.keys()
|
|
556
|
+
],
|
|
557
|
+
attachments
|
|
558
|
+
};
|
|
559
|
+
};
|
|
560
|
+
const components = Object.entries(values).map(([customId, value])=>({
|
|
561
|
+
type: discord_js.ComponentType.Label,
|
|
562
|
+
component: component(customId, value)
|
|
563
|
+
}));
|
|
564
|
+
const Fields = discord_js.ModalSubmitFields;
|
|
565
|
+
return new Fields(components);
|
|
566
|
+
}
|
|
567
|
+
|
|
489
568
|
/**
|
|
490
569
|
* The id of the bot every mock client is logged in as, so a test can mention it. Fixed, and below the
|
|
491
570
|
* ids other mocks take, so it is the same on every run and in any test order, and no mock shares it.
|
|
@@ -517,8 +596,9 @@ const settings = controller_decorator.shared('strictMocks', ()=>({
|
|
|
517
596
|
* and `message.thread` is `null` when the channel caches no thread under the message's id. So do a message's
|
|
518
597
|
* `editedAt`, a member's `presence`, a server's `verified` and `systemChannel` and a channel's `parent`, each `null` or
|
|
519
598
|
* `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`,
|
|
521
|
-
* MeoCord's dispatcher fetches neither. No
|
|
599
|
+
* `mentions.repliedUser` and a modal's `message` are `null`, an interaction's `ephemeral` is `null` until `reply()` or
|
|
600
|
+
* `deferReply()` decides it, and a reaction has a whole message of its own, so MeoCord's dispatcher fetches neither. No
|
|
601
|
+
* placeholder warning is logged.
|
|
522
602
|
*
|
|
523
603
|
* @remarks
|
|
524
604
|
* Those getters read what a server holds, so strict mocks also have it as Discord sends it: the bot's member is in the
|
|
@@ -531,11 +611,14 @@ const settings = controller_decorator.shared('strictMocks', ()=>({
|
|
|
531
611
|
* A message the bot sends is its `client.user`'s, and one it sends in a DM is in that DM. Give the bot's member a role
|
|
532
612
|
* to let it do more, such as kick a member its role ranks above. A value the test sets on a mock wins over the computed
|
|
533
613
|
* one. An interaction's answer that discord.js would refuse, such as a second `reply()` after one a test set with
|
|
534
|
-
* `mockResolvedValue`, is refused too, where without it the mock runs it and warns.
|
|
535
|
-
*
|
|
614
|
+
* `mockResolvedValue`, is refused too, where without it the mock runs it and warns. An answer marks the interaction
|
|
615
|
+
* `replied`, `deferred` or `responded` once it resolves, as discord.js does, so code that doesn't await one fails as it
|
|
616
|
+
* would on a bot, within one run of the test's code. The next major version (5.0) computes these values, and refuses
|
|
617
|
+
* those answers, without the call.
|
|
536
618
|
*
|
|
537
619
|
* `invoke` also refuses a call that gives a handler fewer arguments than it declares where it builds none, such as a
|
|
538
620
|
* reaction handler given its reaction without its `ReactionEvent`; without strict mocks, it warns once and calls it.
|
|
621
|
+
* It refuses an autocomplete that dispatch gives to another `@Autocomplete` handler, or to none, the same way.
|
|
539
622
|
*
|
|
540
623
|
* `compile()` also refuses an `overrideGuard`, `overrideInterceptor` or `overrideFilter` stub without the stage's
|
|
541
624
|
* method, `canActivate`, `intercept` or `catch`; without strict mocks, it warns and builds the module.
|
|
@@ -561,48 +644,6 @@ const settings = controller_decorator.shared('strictMocks', ()=>({
|
|
|
561
644
|
}
|
|
562
645
|
|
|
563
646
|
const mockLogger$1 = new controller_decorator.Logger('Mocks');
|
|
564
|
-
/**
|
|
565
|
-
* A member's permissions where an interaction was made, as Discord computes them: under strict mocks, every permission
|
|
566
|
-
* for an Administrator or the owner, else the channel's overwrites on top of `member.permissions`, so a test that sets
|
|
567
|
-
* those keeps the base; otherwise `member.permissions`, with a warning once where strict mocks would read otherwise.
|
|
568
|
-
*/ function inChannel(interaction, member) {
|
|
569
|
-
const base = member.permissions;
|
|
570
|
-
// Only a server the mock caches has the overwrites to apply
|
|
571
|
-
const { guild } = interaction;
|
|
572
|
-
if (!(guild instanceof discord_js.Guild)) return base;
|
|
573
|
-
const computed = base.has(discord_js.PermissionFlagsBits.Administrator) || member.id === guild.ownerId ? new discord_js.PermissionsBitField(discord_js.PermissionsBitField.All).freeze() : withOverwrites(base, overwritingChannel(interaction, guild), member);
|
|
574
|
-
if (strictMocks()) return computed;
|
|
575
|
-
if (!computed.equals(base)) {
|
|
576
|
-
const lost = new discord_js.PermissionsBitField(base).remove(computed).toArray();
|
|
577
|
-
const gained = new discord_js.PermissionsBitField(computed).remove(base).toArray();
|
|
578
|
-
const change = lost.length > 0 ? [
|
|
579
|
-
`without ${lost.join(', ')}`
|
|
580
|
-
] : [];
|
|
581
|
-
if (gained.length > 0) change.push(`with ${gained.join(', ')}`);
|
|
582
|
-
controller_decorator.warnOnce(mockLogger$1, `${interaction.constructor.name}.memberPermissions reads the member's own permissions here, without the ` + "channel's overwrites or every permission an Administrator or the owner has; in the next major version (5.0) " + `it reads them as Discord computes them in the channel, ${change.join(' and ')}. Set memberPermissions on the ` + 'mock, or call useStrictMocks() to compute them now.');
|
|
583
|
-
}
|
|
584
|
-
// Unless the test set them, a member's permissions come from its roles, @everyone's included
|
|
585
|
-
if (!Object.prototype.hasOwnProperty.call(member, 'permissions')) warnOnUnsetEveryone(interaction, guild, 'memberPermissions', base);
|
|
586
|
-
return base;
|
|
587
|
-
}
|
|
588
|
-
/**
|
|
589
|
-
* Warns once, as `key`, where `permissions` read a server's @everyone role default mode made with none, and lack what
|
|
590
|
-
* strict mocks give @everyone in a new server.
|
|
591
|
-
*/ function warnOnUnsetEveryone(interaction, guild, key, permissions) {
|
|
592
|
-
const everyone = guild.roles.everyone;
|
|
593
|
-
if (!everyone || !isUnsetEveryone(everyone) || permissions.has(NEW_SERVER_EVERYONE_PERMISSIONS)) return;
|
|
594
|
-
controller_decorator.warnOnce(mockLogger$1, `${interaction.constructor.name}.${key} reads the server's @everyone role here with no permissions; in the next major ` + 'version (5.0) it has the permissions Discord gives @everyone in a new server, as strict mocks read it. Set ' + 'guild.roles.everyone.permissions, or call useStrictMocks() to read it so now.');
|
|
595
|
-
}
|
|
596
|
-
/**
|
|
597
|
-
* The channel whose overwrites apply where an interaction was made, read without making or caching one: a thread's
|
|
598
|
-
* parent, or undefined where the channel, or a thread's parent, is one the mock would make, which has none.
|
|
599
|
-
*/ function overwritingChannel(interaction, guild) {
|
|
600
|
-
const channel = heldChannelOf(interaction, guild);
|
|
601
|
-
// A thread's parent by the id the test gave it or one already read, so strict mocks' parent isn't made for this read
|
|
602
|
-
const parentId = (thread)=>Object.prototype.hasOwnProperty.call(thread, 'parentId') ? thread.parentId : madeParentIds.get(thread);
|
|
603
|
-
const at = channel instanceof discord_js.ThreadChannel ? guild.channels.cache.get(parentId(channel) ?? '') : channel;
|
|
604
|
-
return at instanceof discord_js.GuildChannel ? at : undefined;
|
|
605
|
-
}
|
|
606
647
|
// The parent id strict mocks made for each thread made without one, once read
|
|
607
648
|
const madeParentIds = new WeakMap();
|
|
608
649
|
// Private in discord.js's typings, though they are what its own permissionsFor computes with; called on the prototype
|
|
@@ -620,285 +661,7 @@ const channelPermissions = discord_js.GuildChannel.prototype;
|
|
|
620
661
|
Class.prototype,
|
|
621
662
|
defaults
|
|
622
663
|
];
|
|
623
|
-
/**
|
|
624
|
-
* What a mock reads for a data property discord.js always sets, where the test gave none: a value in Discord's shape,
|
|
625
|
-
* such as `'en-US'` for a locale, `null` for what may be absent and `false` for a flag, or the property's own getter
|
|
626
|
-
* for one discord.js computes. The classes a mock descends from are read nearest first.
|
|
627
|
-
*/ const DEFAULTS = new Map([
|
|
628
|
-
defaultsOf(discord_js.Guild, {
|
|
629
|
-
name: ()=>'Guild',
|
|
630
|
-
preferredLocale: ()=>discord_js.Locale.EnglishUS,
|
|
631
|
-
ownerId: nextSnowflake,
|
|
632
|
-
memberCount: ()=>1,
|
|
633
|
-
available: ()=>true,
|
|
634
|
-
large: ()=>false,
|
|
635
|
-
features: ()=>[],
|
|
636
|
-
description: ()=>null,
|
|
637
|
-
icon: ()=>null,
|
|
638
|
-
banner: ()=>null,
|
|
639
|
-
splash: ()=>null,
|
|
640
|
-
systemChannelId: ()=>null,
|
|
641
|
-
shardId: ()=>0,
|
|
642
|
-
nsfwLevel: ()=>discord_js.GuildNSFWLevel.Default,
|
|
643
|
-
verificationLevel: ()=>discord_js.GuildVerificationLevel.None,
|
|
644
|
-
presences: (guild)=>ownedManager(discord_js.PresenceManager.prototype, {
|
|
645
|
-
guild
|
|
646
|
-
}),
|
|
647
|
-
premiumTier: ()=>discord_js.GuildPremiumTier.None,
|
|
648
|
-
createdTimestamp: REAL_GETTER,
|
|
649
|
-
createdAt: REAL_GETTER
|
|
650
|
-
}),
|
|
651
|
-
defaultsOf(discord_js.ClientUser, {
|
|
652
|
-
username: ()=>'bot'
|
|
653
|
-
}),
|
|
654
|
-
defaultsOf(discord_js.User, {
|
|
655
|
-
username: ()=>'user',
|
|
656
|
-
bot: ()=>false,
|
|
657
|
-
globalName: ()=>null,
|
|
658
|
-
discriminator: ()=>'0',
|
|
659
|
-
avatar: ()=>null,
|
|
660
|
-
system: ()=>false,
|
|
661
|
-
flags: ()=>new discord_js.UserFlagsBitField(),
|
|
662
|
-
tag: REAL_GETTER,
|
|
663
|
-
displayName: REAL_GETTER,
|
|
664
|
-
defaultAvatarURL: REAL_GETTER,
|
|
665
|
-
createdTimestamp: REAL_GETTER,
|
|
666
|
-
createdAt: REAL_GETTER
|
|
667
|
-
}),
|
|
668
|
-
defaultsOf(discord_js.GuildMember, {
|
|
669
|
-
user: ()=>createMockUser(),
|
|
670
|
-
id: REAL_GETTER,
|
|
671
|
-
nickname: ()=>null,
|
|
672
|
-
avatar: ()=>null,
|
|
673
|
-
// In the server since the mock was made, as a member Discord sends always is
|
|
674
|
-
joinedTimestamp: ()=>Date.now(),
|
|
675
|
-
joinedAt: REAL_GETTER,
|
|
676
|
-
partial: REAL_GETTER,
|
|
677
|
-
premiumSince: REAL_GETTER,
|
|
678
|
-
communicationDisabledUntil: REAL_GETTER,
|
|
679
|
-
premiumSinceTimestamp: ()=>null,
|
|
680
|
-
communicationDisabledUntilTimestamp: ()=>null,
|
|
681
|
-
pending: ()=>false,
|
|
682
|
-
flags: ()=>new discord_js.GuildMemberFlagsBitField().freeze(),
|
|
683
|
-
displayName: REAL_GETTER,
|
|
684
|
-
roles: (member)=>memberRoles(member, []),
|
|
685
|
-
permissions: REAL_GETTER
|
|
686
|
-
}),
|
|
687
|
-
defaultsOf(discord_js.Role, {
|
|
688
|
-
name: ()=>'role',
|
|
689
|
-
// Above @everyone, which a server's role manager makes at position 0
|
|
690
|
-
position: ()=>1,
|
|
691
|
-
// Every role has a server; one made without is in one of its own, unless a guild given it takes it in
|
|
692
|
-
guild: ()=>createMockGuild(),
|
|
693
|
-
rawPosition: (role)=>role.position,
|
|
694
|
-
permissions: noPermissions,
|
|
695
|
-
hoist: ()=>false,
|
|
696
|
-
managed: ()=>false,
|
|
697
|
-
mentionable: ()=>false,
|
|
698
|
-
icon: ()=>null,
|
|
699
|
-
unicodeEmoji: ()=>null,
|
|
700
|
-
tags: ()=>null,
|
|
701
|
-
colors: ()=>({
|
|
702
|
-
primaryColor: 0,
|
|
703
|
-
secondaryColor: null,
|
|
704
|
-
tertiaryColor: null
|
|
705
|
-
}),
|
|
706
|
-
hexColor: REAL_GETTER,
|
|
707
|
-
createdTimestamp: REAL_GETTER,
|
|
708
|
-
createdAt: REAL_GETTER
|
|
709
|
-
}),
|
|
710
|
-
// A message's text says which handler it is for, so it stays the test's to give, as a command's name does
|
|
711
|
-
defaultsOf(discord_js.Message, {
|
|
712
|
-
pinned: ()=>false,
|
|
713
|
-
tts: ()=>false,
|
|
714
|
-
type: ()=>discord_js.MessageType.Default,
|
|
715
|
-
system: ()=>false,
|
|
716
|
-
webhookId: ()=>null,
|
|
717
|
-
applicationId: ()=>null,
|
|
718
|
-
nonce: ()=>null,
|
|
719
|
-
position: ()=>null,
|
|
720
|
-
activity: ()=>null,
|
|
721
|
-
reference: ()=>null,
|
|
722
|
-
poll: ()=>null,
|
|
723
|
-
interactionMetadata: ()=>null,
|
|
724
|
-
stickers: ()=>new discord_js.Collection(),
|
|
725
|
-
messageSnapshots: ()=>new discord_js.Collection(),
|
|
726
|
-
reactions: (message)=>ownedManager(discord_js.ReactionManager.prototype, {
|
|
727
|
-
message
|
|
728
|
-
}),
|
|
729
|
-
url: REAL_GETTER
|
|
730
|
-
}),
|
|
731
|
-
// A command's name and a component's customId say which handler a call is for, so they stay the test's to give
|
|
732
|
-
defaultsOf(discord_js.ChatInputCommandInteraction, {
|
|
733
|
-
options: ()=>createChatInputOptions()
|
|
734
|
-
}),
|
|
735
|
-
defaultsOf(discord_js.CommandInteraction, {
|
|
736
|
-
commandId: nextSnowflake,
|
|
737
|
-
commandGuildId: ()=>null
|
|
738
|
-
}),
|
|
739
|
-
defaultsOf(discord_js.AutocompleteInteraction, {
|
|
740
|
-
commandId: nextSnowflake,
|
|
741
|
-
commandGuildId: ()=>null,
|
|
742
|
-
options: ()=>createChatInputOptions()
|
|
743
|
-
}),
|
|
744
|
-
defaultsOf(discord_js.MessageReaction, {
|
|
745
|
-
users: (reaction)=>ownedManager(discord_js.ReactionUserManager.prototype, {
|
|
746
|
-
reaction
|
|
747
|
-
})
|
|
748
|
-
}),
|
|
749
|
-
defaultsOf(discord_js.BaseInteraction, {
|
|
750
|
-
applicationId: nextSnowflake,
|
|
751
|
-
token: ()=>'mock-interaction-token',
|
|
752
|
-
version: ()=>1,
|
|
753
|
-
context: ()=>null,
|
|
754
|
-
entitlements: ()=>new discord_js.Collection(),
|
|
755
|
-
// What Discord sends with an interaction in a server, the member's permissions there; typed for a cached one
|
|
756
|
-
// Each reads where the interaction is as its own, and warns by its own name where strict mocks place it otherwise
|
|
757
|
-
memberPermissions: (interaction)=>readPlaceAs(interaction, 'memberPermissions', ()=>{
|
|
758
|
-
const { guildId } = interaction;
|
|
759
|
-
const member = interaction.member;
|
|
760
|
-
if (!guildId) return null;
|
|
761
|
-
if (member instanceof discord_js.GuildMember) return inChannel(interaction, member);
|
|
762
|
-
// A raw member's permissions are the string Discord sends, read as discord.js reads them
|
|
763
|
-
return isRawMember(member) && !interaction.guild ? new discord_js.PermissionsBitField(BigInt(member.permissions)).freeze() : null;
|
|
764
|
-
}),
|
|
765
|
-
// The bot's permissions where the interaction was made: its member's in the channel, or in a thread its parent, as
|
|
766
|
-
// discord.js computes them; none outside a server
|
|
767
|
-
appPermissions: (interaction)=>readPlaceAs(interaction, 'appPermissions', ()=>{
|
|
768
|
-
const { guild } = interaction;
|
|
769
|
-
const me = guild instanceof discord_js.Guild ? botMemberOf(guild) : null;
|
|
770
|
-
if (!(me instanceof discord_js.GuildMember) || heldChannelOf(interaction, guild) === null) return noPermissions();
|
|
771
|
-
// discord.js's own computation, given its own overwritesFor over a test's stub on the channel; a channel the
|
|
772
|
-
// mock would make has no overwrites
|
|
773
|
-
const at = overwritingChannel(interaction, guild) ?? {
|
|
774
|
-
guild,
|
|
775
|
-
permissionOverwrites: {
|
|
776
|
-
cache: new discord_js.Collection()
|
|
777
|
-
}
|
|
778
|
-
};
|
|
779
|
-
const channel = Object.create(at, {
|
|
780
|
-
overwritesFor: {
|
|
781
|
-
value: channelPermissions.overwritesFor
|
|
782
|
-
}
|
|
783
|
-
});
|
|
784
|
-
const permissions = channelPermissions.memberPermissions.call(channel, me, true);
|
|
785
|
-
// The bot's member has @everyone's too
|
|
786
|
-
warnOnUnsetEveryone(interaction, guild, 'appPermissions', permissions);
|
|
787
|
-
return permissions;
|
|
788
|
-
}, // A server the bot isn't in has no guild in either mode, so no permissions in both
|
|
789
|
-
(place)=>!place.raw)
|
|
790
|
-
}),
|
|
791
|
-
defaultsOf(discord_js.TextChannel, {
|
|
792
|
-
type: ()=>discord_js.ChannelType.GuildText,
|
|
793
|
-
nsfw: ()=>false,
|
|
794
|
-
topic: ()=>null,
|
|
795
|
-
rateLimitPerUser: ()=>0
|
|
796
|
-
}),
|
|
797
|
-
defaultsOf(discord_js.NewsChannel, {
|
|
798
|
-
type: ()=>discord_js.ChannelType.GuildAnnouncement,
|
|
799
|
-
nsfw: ()=>false,
|
|
800
|
-
topic: ()=>null
|
|
801
|
-
}),
|
|
802
|
-
defaultsOf(discord_js.VoiceChannel, {
|
|
803
|
-
type: ()=>discord_js.ChannelType.GuildVoice,
|
|
804
|
-
nsfw: ()=>false
|
|
805
|
-
}),
|
|
806
|
-
defaultsOf(discord_js.ForumChannel, {
|
|
807
|
-
type: ()=>discord_js.ChannelType.GuildForum,
|
|
808
|
-
nsfw: ()=>false,
|
|
809
|
-
topic: ()=>null
|
|
810
|
-
}),
|
|
811
|
-
defaultsOf(discord_js.MediaChannel, {
|
|
812
|
-
type: ()=>discord_js.ChannelType.GuildMedia,
|
|
813
|
-
nsfw: ()=>false,
|
|
814
|
-
topic: ()=>null
|
|
815
|
-
}),
|
|
816
|
-
defaultsOf(discord_js.ThreadChannel, {
|
|
817
|
-
type: ()=>discord_js.ChannelType.PublicThread,
|
|
818
|
-
name: ()=>'thread'
|
|
819
|
-
}),
|
|
820
|
-
defaultsOf(discord_js.DMChannel, {
|
|
821
|
-
type: ()=>discord_js.ChannelType.DM
|
|
822
|
-
}),
|
|
823
|
-
defaultsOf(discord_js.GuildChannel, {
|
|
824
|
-
name: ()=>'channel',
|
|
825
|
-
guildId: (channel)=>typeof channel.guild?.id === 'string' ? channel.guild.id : nextSnowflake(),
|
|
826
|
-
position: ()=>0,
|
|
827
|
-
rawPosition: ()=>0,
|
|
828
|
-
parentId: ()=>null
|
|
829
|
-
}),
|
|
830
|
-
defaultsOf(discord_js.BaseChannel, {
|
|
831
|
-
createdTimestamp: REAL_GETTER,
|
|
832
|
-
createdAt: REAL_GETTER
|
|
833
|
-
}),
|
|
834
|
-
defaultsOf(discord_js.Client, {
|
|
835
|
-
readyTimestamp: ()=>Date.now(),
|
|
836
|
-
readyAt: REAL_GETTER,
|
|
837
|
-
uptime: REAL_GETTER,
|
|
838
|
-
token: ()=>null
|
|
839
|
-
}),
|
|
840
|
-
defaultsOf(discord_js.BaseGuild, {
|
|
841
|
-
nameAcronym: REAL_GETTER
|
|
842
|
-
})
|
|
843
|
-
]);
|
|
844
|
-
/**
|
|
845
|
-
* What strict mocks also read as Discord sends it, for discord.js's getters that read it: a server for a channel or
|
|
846
|
-
* thread made without one, a thread's parent text channel in it, and data that is otherwise a placeholder.
|
|
847
|
-
*/ const STRICT_DEFAULTS = new Map([
|
|
848
|
-
defaultsOf(discord_js.Message, {
|
|
849
|
-
content: ()=>'',
|
|
850
|
-
editedTimestamp: ()=>null,
|
|
851
|
-
editedAt: REAL_GETTER
|
|
852
|
-
}),
|
|
853
|
-
defaultsOf(discord_js.MessageMentions, {
|
|
854
|
-
repliedUser: ()=>null
|
|
855
|
-
}),
|
|
856
|
-
defaultsOf(discord_js.Guild, {
|
|
857
|
-
rulesChannelId: ()=>null,
|
|
858
|
-
publicUpdatesChannelId: ()=>null,
|
|
859
|
-
systemChannel: REAL_GETTER
|
|
860
|
-
}),
|
|
861
|
-
defaultsOf(discord_js.BaseGuild, {
|
|
862
|
-
verified: REAL_GETTER
|
|
863
|
-
}),
|
|
864
|
-
defaultsOf(discord_js.GuildMember, {
|
|
865
|
-
presence: REAL_GETTER
|
|
866
|
-
}),
|
|
867
|
-
defaultsOf(discord_js.ModalSubmitInteraction, {
|
|
868
|
-
message: ()=>null
|
|
869
|
-
}),
|
|
870
|
-
defaultsOf(discord_js.ThreadChannel, {
|
|
871
|
-
guild: ()=>createMockGuild(),
|
|
872
|
-
guildId: (thread)=>thread.guild.id,
|
|
873
|
-
parentId: (thread)=>{
|
|
874
|
-
const parent = createMockChannel(discord_js.TextChannel, {
|
|
875
|
-
guild: thread.guild
|
|
876
|
-
});
|
|
877
|
-
thread.guild.channels.cache.set(parent.id, parent);
|
|
878
|
-
madeParentIds.set(thread, parent.id);
|
|
879
|
-
return parent.id;
|
|
880
|
-
},
|
|
881
|
-
parent: REAL_GETTER,
|
|
882
|
-
ownerId: nextSnowflake,
|
|
883
|
-
archived: ()=>false,
|
|
884
|
-
locked: ()=>false
|
|
885
|
-
}),
|
|
886
|
-
defaultsOf(discord_js.GuildChannel, {
|
|
887
|
-
guild: ()=>createMockGuild(),
|
|
888
|
-
parent: REAL_GETTER
|
|
889
|
-
}),
|
|
890
|
-
// Full once its members reach a user limit, which a voice channel has none of unless given one
|
|
891
|
-
defaultsOf(discord_js.BaseGuildVoiceChannel, {
|
|
892
|
-
userLimit: ()=>0,
|
|
893
|
-
full: REAL_GETTER
|
|
894
|
-
}),
|
|
895
|
-
// A reaction's message, as discord.js always has one, is a whole one
|
|
896
|
-
defaultsOf(discord_js.MessageReaction, {
|
|
897
|
-
count: ()=>1,
|
|
898
|
-
me: ()=>false,
|
|
899
|
-
message: ()=>createMockMessage()
|
|
900
|
-
})
|
|
901
|
-
]);
|
|
664
|
+
/** A channel's or thread's server id, its guild's, as discord.js sets it; one of its own for a guild the mock lacks. */ const guildIdOf = (channel)=>typeof channel.guild?.id === 'string' ? channel.guild.id : nextSnowflake();
|
|
902
665
|
/**
|
|
903
666
|
* What a mock reads as a placeholder unless strict mocks read it as discord.js gives it, by the value a test sets for
|
|
904
667
|
* that, which the warning on reading one names.
|
|
@@ -960,19 +723,453 @@ const channelPermissions = discord_js.GuildChannel.prototype;
|
|
|
960
723
|
}
|
|
961
724
|
return undefined;
|
|
962
725
|
}
|
|
963
|
-
/**
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
726
|
+
/**
|
|
727
|
+
* The defaults a mock reads for a property the test gave none, made with `makers`: for `key` on a mock of `target`'s
|
|
728
|
+
* class, the default of the nearest class that has one, strict ones first.
|
|
729
|
+
*/ function discordDefaults(makers) {
|
|
730
|
+
const { botMemberOf, createChatInputOptions, createMockChannel, createMockGuild, createMockMessage, createMockUser, heldChannelOf, isRawMember, isUnsetEveryone, madeAt, memberRoles, NEW_SERVER_EVERYONE_PERMISSIONS, ownedManager, readPlaceAs, readPlaceQuietly } = makers;
|
|
731
|
+
/**
|
|
732
|
+
* A member's permissions where an interaction was made, as Discord computes them: under strict mocks, every permission
|
|
733
|
+
* for an Administrator or the owner, else the channel's overwrites on top of `member.permissions`, so a test that sets
|
|
734
|
+
* those keeps the base; otherwise `member.permissions`, with a warning once where strict mocks would read otherwise.
|
|
735
|
+
*/ function inChannel(interaction, member) {
|
|
736
|
+
const base = member.permissions;
|
|
737
|
+
// Only a server the mock caches has the overwrites to apply
|
|
738
|
+
const { guild } = interaction;
|
|
739
|
+
if (!(guild instanceof discord_js.Guild)) return base;
|
|
740
|
+
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);
|
|
741
|
+
if (strictMocks()) return computed;
|
|
742
|
+
if (!computed.equals(base)) {
|
|
743
|
+
const lost = new discord_js.PermissionsBitField(base).remove(computed).toArray();
|
|
744
|
+
const gained = new discord_js.PermissionsBitField(computed).remove(base).toArray();
|
|
745
|
+
const change = lost.length > 0 ? [
|
|
746
|
+
`without ${lost.join(', ')}`
|
|
747
|
+
] : [];
|
|
748
|
+
if (gained.length > 0) change.push(`with ${gained.join(', ')}`);
|
|
749
|
+
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.');
|
|
973
750
|
}
|
|
751
|
+
// Unless the test set them, a member's permissions come from its roles, @everyone's included
|
|
752
|
+
if (!Object.prototype.hasOwnProperty.call(member, 'permissions')) warnOnUnsetEveryone(interaction, guild, 'memberPermissions', base);
|
|
753
|
+
return base;
|
|
974
754
|
}
|
|
975
|
-
|
|
755
|
+
/**
|
|
756
|
+
* Warns once, as `key`, where `permissions` read a server's @everyone role default mode made with none, and lack what
|
|
757
|
+
* strict mocks give @everyone in a new server.
|
|
758
|
+
*/ function warnOnUnsetEveryone(interaction, guild, key, permissions) {
|
|
759
|
+
const everyone = guild.roles.everyone;
|
|
760
|
+
if (!everyone || !isUnsetEveryone(everyone) || permissions.has(NEW_SERVER_EVERYONE_PERMISSIONS)) return;
|
|
761
|
+
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.');
|
|
762
|
+
}
|
|
763
|
+
/**
|
|
764
|
+
* The channel whose overwrites apply where an interaction was made, read without making or caching one: a thread's
|
|
765
|
+
* parent, or undefined where the channel, or a thread's parent, is one the mock would make, which has none.
|
|
766
|
+
*/ function overwritingChannel(interaction, guild) {
|
|
767
|
+
const channel = heldChannelOf(interaction, guild);
|
|
768
|
+
// 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
|
|
769
|
+
const parentId = (thread)=>Object.prototype.hasOwnProperty.call(thread, 'parentId') ? thread.parentId : madeParentIds.get(thread);
|
|
770
|
+
const at = channel instanceof discord_js.ThreadChannel ? guild.channels.cache.get(parentId(channel) ?? '') : channel;
|
|
771
|
+
return at instanceof discord_js.GuildChannel ? at : undefined;
|
|
772
|
+
}
|
|
773
|
+
/**
|
|
774
|
+
* A member's join or a client's ready time: under strict mocks when the mock was made, as Discord sends it, so time a
|
|
775
|
+
* test advances afterwards counts. Default mode stamps it at the first read and warns once.
|
|
776
|
+
*/ function stampedAt(mock, name) {
|
|
777
|
+
if (strictMocks()) return madeAt(mock);
|
|
778
|
+
controller_decorator.warnOnce(mockLogger$1, `${name} here is the time it was first read; under useStrictMocks() it is the time the mock was made, so time a ` + 'test advances afterwards counts.');
|
|
779
|
+
return Date.now();
|
|
780
|
+
}
|
|
781
|
+
/**
|
|
782
|
+
* The server of a channel or thread made without one: the one its own guildId names, else one of its own. Its own
|
|
783
|
+
* value is read, not its default, which reads the guild in turn.
|
|
784
|
+
*/ function guildFromOwnId(channel) {
|
|
785
|
+
const id = Object.getOwnPropertyDescriptor(channel, 'guildId')?.value;
|
|
786
|
+
return createMockGuild(typeof id === 'string' ? {
|
|
787
|
+
id
|
|
788
|
+
} : {});
|
|
789
|
+
}
|
|
790
|
+
/** The bot's message a component interaction was used on, in the interaction's channel, holding that component. */ function clickedMessage(interaction) {
|
|
791
|
+
const channel = readPlaceQuietly(()=>interaction.channel);
|
|
792
|
+
const bot = interaction.client?.user;
|
|
793
|
+
const { componentType: type } = interaction;
|
|
794
|
+
// A placeholder customId, for an interaction made without one, has no place in the component's data
|
|
795
|
+
const customId = typeof interaction.customId === 'string' ? interaction.customId : undefined;
|
|
796
|
+
const component = type === discord_js.ComponentType.Button ? {
|
|
797
|
+
type,
|
|
798
|
+
custom_id: customId,
|
|
799
|
+
style: discord_js.ButtonStyle.Primary
|
|
800
|
+
} : type === discord_js.ComponentType.StringSelect ? {
|
|
801
|
+
type,
|
|
802
|
+
custom_id: customId,
|
|
803
|
+
options: []
|
|
804
|
+
} : typeof type === 'number' ? {
|
|
805
|
+
type,
|
|
806
|
+
custom_id: customId
|
|
807
|
+
} : undefined;
|
|
808
|
+
return createMockMessage({
|
|
809
|
+
...channel ? {
|
|
810
|
+
channel: channel
|
|
811
|
+
} : {},
|
|
812
|
+
...bot instanceof discord_js.User ? {
|
|
813
|
+
author: bot
|
|
814
|
+
} : {},
|
|
815
|
+
components: component ? [
|
|
816
|
+
{
|
|
817
|
+
type: discord_js.ComponentType.ActionRow,
|
|
818
|
+
components: [
|
|
819
|
+
component
|
|
820
|
+
]
|
|
821
|
+
}
|
|
822
|
+
] : []
|
|
823
|
+
});
|
|
824
|
+
}
|
|
825
|
+
/**
|
|
826
|
+
* What a mock reads for a data property discord.js always sets, where the test gave none: a value in Discord's shape,
|
|
827
|
+
* such as `'en-US'` for a locale, `null` for what may be absent and `false` for a flag, or the property's own getter
|
|
828
|
+
* for one discord.js computes. The classes a mock descends from are read nearest first.
|
|
829
|
+
*/ const DEFAULTS = new Map([
|
|
830
|
+
defaultsOf(discord_js.Guild, {
|
|
831
|
+
name: ()=>'Guild',
|
|
832
|
+
preferredLocale: ()=>discord_js.Locale.EnglishUS,
|
|
833
|
+
ownerId: nextSnowflake,
|
|
834
|
+
memberCount: ()=>1,
|
|
835
|
+
available: ()=>true,
|
|
836
|
+
large: ()=>false,
|
|
837
|
+
features: ()=>[],
|
|
838
|
+
description: ()=>null,
|
|
839
|
+
icon: ()=>null,
|
|
840
|
+
banner: ()=>null,
|
|
841
|
+
splash: ()=>null,
|
|
842
|
+
systemChannelId: ()=>null,
|
|
843
|
+
shardId: ()=>0,
|
|
844
|
+
nsfwLevel: ()=>discord_js.GuildNSFWLevel.Default,
|
|
845
|
+
verificationLevel: ()=>discord_js.GuildVerificationLevel.None,
|
|
846
|
+
presences: (guild)=>ownedManager(discord_js.PresenceManager.prototype, {
|
|
847
|
+
guild
|
|
848
|
+
}),
|
|
849
|
+
premiumTier: ()=>discord_js.GuildPremiumTier.None,
|
|
850
|
+
createdTimestamp: REAL_GETTER,
|
|
851
|
+
createdAt: REAL_GETTER
|
|
852
|
+
}),
|
|
853
|
+
defaultsOf(discord_js.ClientUser, {
|
|
854
|
+
username: ()=>'bot'
|
|
855
|
+
}),
|
|
856
|
+
defaultsOf(discord_js.User, {
|
|
857
|
+
username: ()=>'user',
|
|
858
|
+
bot: ()=>false,
|
|
859
|
+
globalName: ()=>null,
|
|
860
|
+
discriminator: ()=>'0',
|
|
861
|
+
avatar: ()=>null,
|
|
862
|
+
system: ()=>false,
|
|
863
|
+
flags: ()=>new discord_js.UserFlagsBitField(),
|
|
864
|
+
tag: REAL_GETTER,
|
|
865
|
+
displayName: REAL_GETTER,
|
|
866
|
+
defaultAvatarURL: REAL_GETTER,
|
|
867
|
+
createdTimestamp: REAL_GETTER,
|
|
868
|
+
createdAt: REAL_GETTER
|
|
869
|
+
}),
|
|
870
|
+
defaultsOf(discord_js.GuildMember, {
|
|
871
|
+
user: ()=>createMockUser(),
|
|
872
|
+
id: REAL_GETTER,
|
|
873
|
+
nickname: ()=>null,
|
|
874
|
+
avatar: ()=>null,
|
|
875
|
+
joinedTimestamp: (member)=>stampedAt(member, 'GuildMember.joinedTimestamp'),
|
|
876
|
+
joinedAt: REAL_GETTER,
|
|
877
|
+
partial: REAL_GETTER,
|
|
878
|
+
premiumSince: REAL_GETTER,
|
|
879
|
+
communicationDisabledUntil: REAL_GETTER,
|
|
880
|
+
premiumSinceTimestamp: ()=>null,
|
|
881
|
+
communicationDisabledUntilTimestamp: ()=>null,
|
|
882
|
+
pending: ()=>false,
|
|
883
|
+
flags: ()=>new discord_js.GuildMemberFlagsBitField().freeze(),
|
|
884
|
+
displayName: REAL_GETTER,
|
|
885
|
+
// The colour of its highest coloured role, as discord.js computes it from the roles in its cache
|
|
886
|
+
displayColor: REAL_GETTER,
|
|
887
|
+
displayHexColor: REAL_GETTER,
|
|
888
|
+
roles: (member)=>memberRoles(member, []),
|
|
889
|
+
permissions: REAL_GETTER
|
|
890
|
+
}),
|
|
891
|
+
// A server's emoji, sticker, scheduled event and AutoMod rule are named, and what discord.js formats from them
|
|
892
|
+
// computes: an emoji's mention and identifier, an event's and an invite's link
|
|
893
|
+
defaultsOf(discord_js.GuildEmoji, {
|
|
894
|
+
name: ()=>'emoji',
|
|
895
|
+
animated: ()=>false,
|
|
896
|
+
identifier: REAL_GETTER
|
|
897
|
+
}),
|
|
898
|
+
defaultsOf(discord_js.Sticker, {
|
|
899
|
+
name: ()=>'sticker'
|
|
900
|
+
}),
|
|
901
|
+
defaultsOf(discord_js.GuildScheduledEvent, {
|
|
902
|
+
name: ()=>'event',
|
|
903
|
+
url: REAL_GETTER
|
|
904
|
+
}),
|
|
905
|
+
defaultsOf(discord_js.AutoModerationRule, {
|
|
906
|
+
name: ()=>'rule'
|
|
907
|
+
}),
|
|
908
|
+
defaultsOf(discord_js.Invite, {
|
|
909
|
+
url: REAL_GETTER
|
|
910
|
+
}),
|
|
911
|
+
defaultsOf(discord_js.Role, {
|
|
912
|
+
name: ()=>'role',
|
|
913
|
+
// Above @everyone, which a server's role manager makes at position 0
|
|
914
|
+
position: ()=>1,
|
|
915
|
+
// Every role has a server; one made without is in one of its own, unless a guild given it takes it in
|
|
916
|
+
guild: ()=>createMockGuild(),
|
|
917
|
+
rawPosition: (role)=>role.position,
|
|
918
|
+
permissions: noPermissions,
|
|
919
|
+
hoist: ()=>false,
|
|
920
|
+
managed: ()=>false,
|
|
921
|
+
mentionable: ()=>false,
|
|
922
|
+
icon: ()=>null,
|
|
923
|
+
unicodeEmoji: ()=>null,
|
|
924
|
+
tags: ()=>null,
|
|
925
|
+
colors: ()=>({
|
|
926
|
+
primaryColor: 0,
|
|
927
|
+
secondaryColor: null,
|
|
928
|
+
tertiaryColor: null
|
|
929
|
+
}),
|
|
930
|
+
hexColor: REAL_GETTER,
|
|
931
|
+
createdTimestamp: REAL_GETTER,
|
|
932
|
+
createdAt: REAL_GETTER
|
|
933
|
+
}),
|
|
934
|
+
// A message's text says which handler it is for, so it stays the test's to give, as a command's name does
|
|
935
|
+
defaultsOf(discord_js.Message, {
|
|
936
|
+
pinned: ()=>false,
|
|
937
|
+
tts: ()=>false,
|
|
938
|
+
type: ()=>discord_js.MessageType.Default,
|
|
939
|
+
system: ()=>false,
|
|
940
|
+
webhookId: ()=>null,
|
|
941
|
+
applicationId: ()=>null,
|
|
942
|
+
nonce: ()=>null,
|
|
943
|
+
position: ()=>null,
|
|
944
|
+
activity: ()=>null,
|
|
945
|
+
reference: ()=>null,
|
|
946
|
+
poll: ()=>null,
|
|
947
|
+
interactionMetadata: ()=>null,
|
|
948
|
+
stickers: ()=>new discord_js.Collection(),
|
|
949
|
+
messageSnapshots: ()=>new discord_js.Collection(),
|
|
950
|
+
reactions: (message)=>ownedManager(discord_js.ReactionManager.prototype, {
|
|
951
|
+
message
|
|
952
|
+
}),
|
|
953
|
+
url: REAL_GETTER
|
|
954
|
+
}),
|
|
955
|
+
// A command's name and a component's customId say which handler a call is for, so they stay the test's to give
|
|
956
|
+
defaultsOf(discord_js.ChatInputCommandInteraction, {
|
|
957
|
+
options: ()=>createChatInputOptions()
|
|
958
|
+
}),
|
|
959
|
+
defaultsOf(discord_js.CommandInteraction, {
|
|
960
|
+
commandId: nextSnowflake,
|
|
961
|
+
commandGuildId: ()=>null
|
|
962
|
+
}),
|
|
963
|
+
defaultsOf(discord_js.AutocompleteInteraction, {
|
|
964
|
+
commandId: nextSnowflake,
|
|
965
|
+
commandGuildId: ()=>null,
|
|
966
|
+
options: ()=>createChatInputOptions()
|
|
967
|
+
}),
|
|
968
|
+
defaultsOf(discord_js.MessageReaction, {
|
|
969
|
+
users: (reaction)=>ownedManager(discord_js.ReactionUserManager.prototype, {
|
|
970
|
+
reaction
|
|
971
|
+
})
|
|
972
|
+
}),
|
|
973
|
+
// The bot's message the component was used on, holding it, in the interaction's channel, as Discord always sends it;
|
|
974
|
+
// the component discord.js finds there by its customId
|
|
975
|
+
defaultsOf(discord_js.MessageComponentInteraction, {
|
|
976
|
+
message: clickedMessage,
|
|
977
|
+
component: REAL_GETTER
|
|
978
|
+
}),
|
|
979
|
+
// A modal submitted with no fields given has none
|
|
980
|
+
defaultsOf(discord_js.ModalSubmitInteraction, {
|
|
981
|
+
fields: ()=>createModalFields({}),
|
|
982
|
+
components: ()=>[]
|
|
983
|
+
}),
|
|
984
|
+
defaultsOf(discord_js.BaseInteraction, {
|
|
985
|
+
applicationId: nextSnowflake,
|
|
986
|
+
token: ()=>'mock-interaction-token',
|
|
987
|
+
version: ()=>1,
|
|
988
|
+
context: ()=>null,
|
|
989
|
+
entitlements: ()=>new discord_js.Collection(),
|
|
990
|
+
// What Discord sends with an interaction in a server, the member's permissions there; typed for a cached one
|
|
991
|
+
// Each reads where the interaction is as its own, and warns by its own name where strict mocks place it otherwise
|
|
992
|
+
memberPermissions: (interaction)=>readPlaceAs(interaction, 'memberPermissions', ()=>{
|
|
993
|
+
const { guildId } = interaction;
|
|
994
|
+
const member = interaction.member;
|
|
995
|
+
if (!guildId) return null;
|
|
996
|
+
if (member instanceof discord_js.GuildMember) return inChannel(interaction, member);
|
|
997
|
+
// A raw member's permissions are the string Discord sends, read as discord.js reads them
|
|
998
|
+
return isRawMember(member) && !interaction.guild ? new discord_js.PermissionsBitField(BigInt(member.permissions)).freeze() : null;
|
|
999
|
+
}),
|
|
1000
|
+
// The bot's permissions where the interaction was made: its member's in the channel, or in a thread its parent, as
|
|
1001
|
+
// discord.js computes them; none outside a server
|
|
1002
|
+
appPermissions: (interaction)=>readPlaceAs(interaction, 'appPermissions', ()=>{
|
|
1003
|
+
const { guild } = interaction;
|
|
1004
|
+
const me = guild instanceof discord_js.Guild ? botMemberOf(guild) : null;
|
|
1005
|
+
if (!(me instanceof discord_js.GuildMember) || heldChannelOf(interaction, guild) === null) return noPermissions();
|
|
1006
|
+
// discord.js's own computation, given its own overwritesFor over a test's stub on the channel; a channel the
|
|
1007
|
+
// mock would make has no overwrites
|
|
1008
|
+
const at = overwritingChannel(interaction, guild) ?? {
|
|
1009
|
+
guild,
|
|
1010
|
+
permissionOverwrites: {
|
|
1011
|
+
cache: new discord_js.Collection()
|
|
1012
|
+
}
|
|
1013
|
+
};
|
|
1014
|
+
const channel = Object.create(at, {
|
|
1015
|
+
overwritesFor: {
|
|
1016
|
+
value: channelPermissions.overwritesFor
|
|
1017
|
+
}
|
|
1018
|
+
});
|
|
1019
|
+
const permissions = channelPermissions.memberPermissions.call(channel, me, true);
|
|
1020
|
+
// The bot's member has @everyone's too
|
|
1021
|
+
warnOnUnsetEveryone(interaction, guild, 'appPermissions', permissions);
|
|
1022
|
+
return permissions;
|
|
1023
|
+
}, // A server the bot isn't in has no guild in either mode, so no permissions in both
|
|
1024
|
+
(place)=>!place.raw)
|
|
1025
|
+
}),
|
|
1026
|
+
defaultsOf(discord_js.TextChannel, {
|
|
1027
|
+
type: ()=>discord_js.ChannelType.GuildText,
|
|
1028
|
+
nsfw: ()=>false,
|
|
1029
|
+
topic: ()=>null,
|
|
1030
|
+
rateLimitPerUser: ()=>0
|
|
1031
|
+
}),
|
|
1032
|
+
defaultsOf(discord_js.NewsChannel, {
|
|
1033
|
+
type: ()=>discord_js.ChannelType.GuildAnnouncement,
|
|
1034
|
+
nsfw: ()=>false,
|
|
1035
|
+
topic: ()=>null
|
|
1036
|
+
}),
|
|
1037
|
+
defaultsOf(discord_js.VoiceChannel, {
|
|
1038
|
+
type: ()=>discord_js.ChannelType.GuildVoice,
|
|
1039
|
+
nsfw: ()=>false
|
|
1040
|
+
}),
|
|
1041
|
+
defaultsOf(discord_js.ForumChannel, {
|
|
1042
|
+
type: ()=>discord_js.ChannelType.GuildForum,
|
|
1043
|
+
nsfw: ()=>false,
|
|
1044
|
+
topic: ()=>null
|
|
1045
|
+
}),
|
|
1046
|
+
defaultsOf(discord_js.MediaChannel, {
|
|
1047
|
+
type: ()=>discord_js.ChannelType.GuildMedia,
|
|
1048
|
+
nsfw: ()=>false,
|
|
1049
|
+
topic: ()=>null
|
|
1050
|
+
}),
|
|
1051
|
+
defaultsOf(discord_js.StageChannel, {
|
|
1052
|
+
type: ()=>discord_js.ChannelType.GuildStageVoice,
|
|
1053
|
+
nsfw: ()=>false,
|
|
1054
|
+
topic: ()=>null
|
|
1055
|
+
}),
|
|
1056
|
+
defaultsOf(discord_js.CategoryChannel, {
|
|
1057
|
+
type: ()=>discord_js.ChannelType.GuildCategory
|
|
1058
|
+
}),
|
|
1059
|
+
defaultsOf(discord_js.DirectoryChannel, {
|
|
1060
|
+
type: ()=>discord_js.ChannelType.GuildDirectory
|
|
1061
|
+
}),
|
|
1062
|
+
// A thread's server is its guild's, as discord.js sets it from the thread's guild
|
|
1063
|
+
defaultsOf(discord_js.ThreadChannel, {
|
|
1064
|
+
type: ()=>discord_js.ChannelType.PublicThread,
|
|
1065
|
+
name: ()=>'thread',
|
|
1066
|
+
guildId: guildIdOf
|
|
1067
|
+
}),
|
|
1068
|
+
defaultsOf(discord_js.DMChannel, {
|
|
1069
|
+
type: ()=>discord_js.ChannelType.DM
|
|
1070
|
+
}),
|
|
1071
|
+
defaultsOf(discord_js.PartialGroupDMChannel, {
|
|
1072
|
+
type: ()=>discord_js.ChannelType.GroupDM
|
|
1073
|
+
}),
|
|
1074
|
+
defaultsOf(discord_js.GuildChannel, {
|
|
1075
|
+
name: ()=>'channel',
|
|
1076
|
+
guildId: guildIdOf,
|
|
1077
|
+
position: ()=>0,
|
|
1078
|
+
rawPosition: ()=>0,
|
|
1079
|
+
parentId: ()=>null
|
|
1080
|
+
}),
|
|
1081
|
+
defaultsOf(discord_js.BaseChannel, {
|
|
1082
|
+
createdTimestamp: REAL_GETTER,
|
|
1083
|
+
createdAt: REAL_GETTER
|
|
1084
|
+
}),
|
|
1085
|
+
defaultsOf(discord_js.Client, {
|
|
1086
|
+
readyTimestamp: (client)=>stampedAt(client, 'Client.readyTimestamp'),
|
|
1087
|
+
readyAt: REAL_GETTER,
|
|
1088
|
+
uptime: REAL_GETTER,
|
|
1089
|
+
token: ()=>null
|
|
1090
|
+
}),
|
|
1091
|
+
// What a member's roles show, computed from the roles in the cache as discord.js does
|
|
1092
|
+
defaultsOf(discord_js.GuildMemberRoleManager, {
|
|
1093
|
+
color: REAL_GETTER,
|
|
1094
|
+
hoist: REAL_GETTER,
|
|
1095
|
+
icon: REAL_GETTER
|
|
1096
|
+
}),
|
|
1097
|
+
defaultsOf(discord_js.BaseGuild, {
|
|
1098
|
+
nameAcronym: REAL_GETTER
|
|
1099
|
+
})
|
|
1100
|
+
]);
|
|
1101
|
+
/**
|
|
1102
|
+
* What strict mocks also read as Discord sends it, for discord.js's getters that read it: a server for a channel or
|
|
1103
|
+
* thread made without one, a thread's parent text channel in it, and data that is otherwise a placeholder.
|
|
1104
|
+
*/ const STRICT_DEFAULTS = new Map([
|
|
1105
|
+
defaultsOf(discord_js.Message, {
|
|
1106
|
+
content: ()=>'',
|
|
1107
|
+
editedTimestamp: ()=>null,
|
|
1108
|
+
editedAt: REAL_GETTER
|
|
1109
|
+
}),
|
|
1110
|
+
defaultsOf(discord_js.MessageMentions, {
|
|
1111
|
+
repliedUser: ()=>null
|
|
1112
|
+
}),
|
|
1113
|
+
defaultsOf(discord_js.Guild, {
|
|
1114
|
+
rulesChannelId: ()=>null,
|
|
1115
|
+
publicUpdatesChannelId: ()=>null,
|
|
1116
|
+
systemChannel: REAL_GETTER
|
|
1117
|
+
}),
|
|
1118
|
+
defaultsOf(discord_js.BaseGuild, {
|
|
1119
|
+
verified: REAL_GETTER
|
|
1120
|
+
}),
|
|
1121
|
+
defaultsOf(discord_js.GuildMember, {
|
|
1122
|
+
presence: REAL_GETTER
|
|
1123
|
+
}),
|
|
1124
|
+
defaultsOf(discord_js.ModalSubmitInteraction, {
|
|
1125
|
+
message: ()=>null
|
|
1126
|
+
}),
|
|
1127
|
+
defaultsOf(discord_js.ThreadChannel, {
|
|
1128
|
+
guild: guildFromOwnId,
|
|
1129
|
+
guildId: (thread)=>thread.guild.id,
|
|
1130
|
+
parentId: (thread)=>{
|
|
1131
|
+
const parent = createMockChannel(discord_js.TextChannel, {
|
|
1132
|
+
guild: thread.guild
|
|
1133
|
+
});
|
|
1134
|
+
thread.guild.channels.cache.set(parent.id, parent);
|
|
1135
|
+
madeParentIds.set(thread, parent.id);
|
|
1136
|
+
return parent.id;
|
|
1137
|
+
},
|
|
1138
|
+
parent: REAL_GETTER,
|
|
1139
|
+
ownerId: nextSnowflake,
|
|
1140
|
+
archived: ()=>false,
|
|
1141
|
+
locked: ()=>false
|
|
1142
|
+
}),
|
|
1143
|
+
defaultsOf(discord_js.GuildChannel, {
|
|
1144
|
+
guild: guildFromOwnId,
|
|
1145
|
+
parent: REAL_GETTER
|
|
1146
|
+
}),
|
|
1147
|
+
// Full once its members reach a user limit, which a voice channel has none of unless given one
|
|
1148
|
+
defaultsOf(discord_js.BaseGuildVoiceChannel, {
|
|
1149
|
+
userLimit: ()=>0,
|
|
1150
|
+
full: REAL_GETTER
|
|
1151
|
+
}),
|
|
1152
|
+
// A reaction's message, as discord.js always has one, is a whole one
|
|
1153
|
+
defaultsOf(discord_js.MessageReaction, {
|
|
1154
|
+
count: ()=>1,
|
|
1155
|
+
me: ()=>false,
|
|
1156
|
+
message: ()=>createMockMessage()
|
|
1157
|
+
})
|
|
1158
|
+
]);
|
|
1159
|
+
return (target, key)=>{
|
|
1160
|
+
for (const defaults of strictMocks() ? [
|
|
1161
|
+
STRICT_DEFAULTS,
|
|
1162
|
+
DEFAULTS
|
|
1163
|
+
] : [
|
|
1164
|
+
DEFAULTS
|
|
1165
|
+
]){
|
|
1166
|
+
for(let proto = Object.getPrototypeOf(target); proto !== null; proto = Object.getPrototypeOf(proto)){
|
|
1167
|
+
const found = defaults.get(proto)?.[key];
|
|
1168
|
+
if (found !== undefined) return found;
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
return undefined;
|
|
1172
|
+
};
|
|
976
1173
|
}
|
|
977
1174
|
|
|
978
1175
|
/** Where a mock resolves unfurled media to, as Discord's media proxy would. */ const PROXY = 'https://images-ext-1.discordapp.net/external/mock';
|
|
@@ -1108,8 +1305,12 @@ const SKIP = new Set([
|
|
|
1108
1305
|
const mockTargets = new WeakMap();
|
|
1109
1306
|
// Warnings a read answered by the stub logs first, by the mock's instance and the property
|
|
1110
1307
|
const stubReadWarnings = new WeakMap();
|
|
1308
|
+
// When each mock was made, which what Discord stamps when a structure arrives, such as a member's join, reads
|
|
1309
|
+
const madeTimes = new WeakMap();
|
|
1310
|
+
/** When `mock` was made: the time a value Discord stamps as it sends the structure reads. */ const madeAt = (mock)=>madeTimes.get(mock) ?? Date.now();
|
|
1111
1311
|
function stubDeep(instance, externalStubs, onSet) {
|
|
1112
1312
|
noteMockMade();
|
|
1313
|
+
const made = Date.now();
|
|
1113
1314
|
const stubs = externalStubs ?? new Map();
|
|
1114
1315
|
// Placeholders read so far, by the value strict mocks give: a read of a kept one still warns, once
|
|
1115
1316
|
const placeholders = new Map();
|
|
@@ -1174,7 +1375,7 @@ function stubDeep(instance, externalStubs, onSet) {
|
|
|
1174
1375
|
}
|
|
1175
1376
|
// A client the mock's structures share: its guild's, or one of its own
|
|
1176
1377
|
if (key === 'client' && target instanceof discord_js.Base) return clientOf(target);
|
|
1177
|
-
const stub = typeof protoValue === 'function' ? isRealMethod(target, key) ? createMockFn((...args)=>quietly(target, key, ()=>protoValue.apply(proxy, args))) : methodStub(target, key, protoValue, ()=>proxy) :
|
|
1378
|
+
const stub = typeof protoValue === 'function' ? isRealMethod(target, key) ? createMockFn((...args)=>lacksServer(target, key, proxy) ? undefined : quietly(target, key, ()=>protoValue.apply(proxy, args))) : methodStub(target, key, protoValue, ()=>proxy) : autoStub();
|
|
1178
1379
|
stubs.set(key, stub);
|
|
1179
1380
|
return stub;
|
|
1180
1381
|
},
|
|
@@ -1197,6 +1398,7 @@ function stubDeep(instance, externalStubs, onSet) {
|
|
|
1197
1398
|
}
|
|
1198
1399
|
});
|
|
1199
1400
|
mockTargets.set(proxy, instance);
|
|
1401
|
+
madeTimes.set(proxy, made);
|
|
1200
1402
|
return proxy;
|
|
1201
1403
|
}
|
|
1202
1404
|
// discord.js methods that read only what the mock holds, so they run for real: looking items up in a manager's cache,
|
|
@@ -1270,6 +1472,32 @@ const STRICT_REAL_METHODS = new Map([
|
|
|
1270
1472
|
])
|
|
1271
1473
|
]
|
|
1272
1474
|
]);
|
|
1475
|
+
// A channel's permission methods, each of which reads the channel's server
|
|
1476
|
+
const SERVER_PERMISSION_METHODS = new Set([
|
|
1477
|
+
'permissionsFor',
|
|
1478
|
+
'overwritesFor',
|
|
1479
|
+
'memberPermissions',
|
|
1480
|
+
'rolePermissions'
|
|
1481
|
+
]);
|
|
1482
|
+
// The stubs a mock makes for what a test never gave it, as opposed to anything a test passes or sets
|
|
1483
|
+
const autoStubs = new WeakSet();
|
|
1484
|
+
function autoStub() {
|
|
1485
|
+
const stub = stubDeep({});
|
|
1486
|
+
autoStubs.add(stub);
|
|
1487
|
+
return stub;
|
|
1488
|
+
}
|
|
1489
|
+
/**
|
|
1490
|
+
* Whether a channel's permission method has no server to read: in default mode a channel made without one holds the
|
|
1491
|
+
* mock's own stub for it, so the method returns `undefined`, as a thread's does, and says so once. A server the test
|
|
1492
|
+
* gave, of whatever class, is read as it is. Strict mocks give the channel one.
|
|
1493
|
+
*/ function lacksServer(target, key, channel) {
|
|
1494
|
+
if (strictMocks() || !(target instanceof discord_js.GuildChannel) || !SERVER_PERMISSION_METHODS.has(key)) return false;
|
|
1495
|
+
const guild = readPlaceQuietly(()=>channel.guild);
|
|
1496
|
+
if (typeof guild !== 'object' || guild === null || !autoStubs.has(guild)) return false;
|
|
1497
|
+
const name = Object.getPrototypeOf(target).constructor.name;
|
|
1498
|
+
controller_decorator.warnPlaceholder(mockLogger, `${name}.${key}`, 'the mock gives the channel a server and computes it', `Give the channel a guild, such as createMockChannel(${name}, { guild }), or call useStrictMocks() to have the mock compute it now.`);
|
|
1499
|
+
return true;
|
|
1500
|
+
}
|
|
1273
1501
|
function isRealMethod(target, key) {
|
|
1274
1502
|
for(let proto = Object.getPrototypeOf(target); proto !== null; proto = Object.getPrototypeOf(proto)){
|
|
1275
1503
|
if (REAL_METHODS.get(proto)?.has(key) || strictMocks() && STRICT_REAL_METHODS.get(proto)?.has(key)) return true;
|
|
@@ -1414,28 +1642,68 @@ function clientOf(target) {
|
|
|
1414
1642
|
// ---------------------------------------------------------------------------
|
|
1415
1643
|
// methodStub — what an auto-stubbed discord.js method returns
|
|
1416
1644
|
// ---------------------------------------------------------------------------
|
|
1417
|
-
// discord.js methods not declared `async` that still return a promise; `set*` setters are matched by name
|
|
1645
|
+
// discord.js methods not declared `async` that still return a promise; `set*` setters are matched by name.
|
|
1646
|
+
// mock-promise-methods.spec checks this list against discord.js's typings
|
|
1418
1647
|
const PROMISE_METHODS = new Set([
|
|
1648
|
+
'awaitMessageComponent',
|
|
1649
|
+
'awaitMessages',
|
|
1650
|
+
'awaitReactions',
|
|
1419
1651
|
'ban',
|
|
1652
|
+
'bulkBan',
|
|
1420
1653
|
'clone',
|
|
1654
|
+
'create',
|
|
1421
1655
|
'createDM',
|
|
1422
1656
|
'createInvite',
|
|
1657
|
+
'createStageInstance',
|
|
1658
|
+
'createWebhook',
|
|
1659
|
+
'delete',
|
|
1660
|
+
'deleteDM',
|
|
1423
1661
|
'disableCommunicationUntil',
|
|
1662
|
+
'disconnect',
|
|
1424
1663
|
'edit',
|
|
1425
1664
|
'fetch',
|
|
1665
|
+
'fetchAuthor',
|
|
1426
1666
|
'fetchFlags',
|
|
1427
1667
|
'fetchInvites',
|
|
1428
1668
|
'fetchMe',
|
|
1429
1669
|
'fetchReply',
|
|
1670
|
+
'fetchSubscribers',
|
|
1671
|
+
'fetchUser',
|
|
1672
|
+
'fetchVoters',
|
|
1430
1673
|
'fetchWebhooks',
|
|
1431
1674
|
'fetchWidget',
|
|
1432
1675
|
'forward',
|
|
1676
|
+
'kick',
|
|
1433
1677
|
'pin',
|
|
1678
|
+
'rawFetchGuildActiveThreads',
|
|
1434
1679
|
'removeAttachments',
|
|
1435
1680
|
'suppressEmbeds',
|
|
1436
1681
|
'timeout',
|
|
1682
|
+
'unban',
|
|
1437
1683
|
'unpin'
|
|
1438
1684
|
]);
|
|
1685
|
+
// Methods that return a promise on one class only, as their name is synchronous on another: a role manager's set, and
|
|
1686
|
+
// a scheduled event's setStatus, where a client user's setStatus applies at once
|
|
1687
|
+
const PROMISE_METHODS_OF = [
|
|
1688
|
+
[
|
|
1689
|
+
discord_js.GuildEmojiRoleManager,
|
|
1690
|
+
new Set([
|
|
1691
|
+
'set'
|
|
1692
|
+
])
|
|
1693
|
+
],
|
|
1694
|
+
[
|
|
1695
|
+
discord_js.GuildMemberRoleManager,
|
|
1696
|
+
new Set([
|
|
1697
|
+
'set'
|
|
1698
|
+
])
|
|
1699
|
+
],
|
|
1700
|
+
[
|
|
1701
|
+
discord_js.GuildScheduledEvent,
|
|
1702
|
+
new Set([
|
|
1703
|
+
'setStatus'
|
|
1704
|
+
])
|
|
1705
|
+
]
|
|
1706
|
+
];
|
|
1439
1707
|
// The client user's presence setters, which apply at once and return the presence
|
|
1440
1708
|
const SYNC_SETTERS = new Set([
|
|
1441
1709
|
'setActivity',
|
|
@@ -1460,27 +1728,129 @@ const SELF_METHODS = new Set([
|
|
|
1460
1728
|
'disableCommunicationUntil',
|
|
1461
1729
|
'edit',
|
|
1462
1730
|
'fetch',
|
|
1731
|
+
'kick',
|
|
1463
1732
|
'pin',
|
|
1464
1733
|
'timeout',
|
|
1465
1734
|
'unpin'
|
|
1466
1735
|
]);
|
|
1736
|
+
// Collectors that resolve to what they collected: nothing, for a mock no one answers
|
|
1737
|
+
const COLLECTING_METHODS = new Set([
|
|
1738
|
+
'awaitMessages',
|
|
1739
|
+
'awaitReactions'
|
|
1740
|
+
]);
|
|
1467
1741
|
// The DM channel of each user, made once, as Discord keeps one per user
|
|
1468
1742
|
const dmChannels = new WeakMap();
|
|
1469
|
-
|
|
1743
|
+
/**
|
|
1744
|
+
* A user's one DM channel: its recipient is the user, and it is the user's `dmChannel`. Under strict mocks its client
|
|
1745
|
+
* is the user's; default mode keeps a client of its own, and a read of it warns once where it isn't the user's.
|
|
1746
|
+
*/ function dmChannelOf(user) {
|
|
1470
1747
|
let channel = dmChannels.get(user);
|
|
1471
1748
|
if (!channel) {
|
|
1472
|
-
|
|
1473
|
-
|
|
1749
|
+
const made = createMockChannel(discord_js.DMChannel);
|
|
1750
|
+
// Getters in discord.js, so set through the mock, which defines them
|
|
1751
|
+
made.recipientId = user.id;
|
|
1752
|
+
if (user instanceof discord_js.User) {
|
|
1753
|
+
// Not enumerated, so a DM channel's own keys, and a user's, stay those a test may compare
|
|
1754
|
+
const place = (target, key, value)=>Object.defineProperty(target, key, {
|
|
1755
|
+
value,
|
|
1756
|
+
writable: true,
|
|
1757
|
+
configurable: true
|
|
1758
|
+
});
|
|
1759
|
+
place(made, 'recipient', user);
|
|
1760
|
+
place(user, 'dmChannel', made);
|
|
1761
|
+
if (user.client instanceof discord_js.Client) {
|
|
1762
|
+
if (strictMocks()) place(made, 'client', user.client);
|
|
1763
|
+
else warnOnClientRead(made, user.client);
|
|
1764
|
+
}
|
|
1765
|
+
}
|
|
1766
|
+
channel = made;
|
|
1474
1767
|
dmChannels.set(user, channel);
|
|
1475
1768
|
}
|
|
1476
1769
|
return channel;
|
|
1477
1770
|
}
|
|
1771
|
+
/**
|
|
1772
|
+
* Has a read of a default-mode DM channel's `client`, its own where strict mocks give the user's, warn once, so a test
|
|
1773
|
+
* that reads it hears of the difference and the mock's own reads, as a send through the channel makes, stay quiet.
|
|
1774
|
+
*/ function warnOnClientRead(channel, userClient) {
|
|
1775
|
+
if (readPlaceQuietly(()=>channel.client) === userClient) return;
|
|
1776
|
+
// The read is answered by the channel's stub, which warns by the mock's own instance
|
|
1777
|
+
warnOnRead(mockTargets.get(channel) ?? channel, 'client', ()=>{
|
|
1778
|
+
if (readingPlace > 0) return;
|
|
1779
|
+
controller_decorator.warnOnce(mockLogger, "A mock user's DM channel here has a client of its own, not the user's; under useStrictMocks() it shares the user's " + 'client.');
|
|
1780
|
+
});
|
|
1781
|
+
}
|
|
1782
|
+
// What default mode's user manager DMs do apart from discord.js's, warned of once each
|
|
1783
|
+
const USER_MANAGER_DM_WARNINGS = {
|
|
1784
|
+
createDM: 'UserManager.createDM() here gives every user the same DM channel; under useStrictMocks() each user has their own, ' + 'as Discord keeps one per user.',
|
|
1785
|
+
send: "UserManager.send() here resolves to a message that doesn't hold what was sent; under useStrictMocks() it sends " + "through the user's DM channel, as discord.js does."
|
|
1786
|
+
};
|
|
1787
|
+
/** A user made for a manager, on the manager's client, as discord.js makes one. */ function userOf(manager, id) {
|
|
1788
|
+
const user = createMockUser(id ? {
|
|
1789
|
+
id
|
|
1790
|
+
} : {});
|
|
1791
|
+
const client = manager.client;
|
|
1792
|
+
if (client instanceof discord_js.Client) Object.defineProperty(user, 'client', {
|
|
1793
|
+
value: client,
|
|
1794
|
+
writable: true,
|
|
1795
|
+
configurable: true
|
|
1796
|
+
});
|
|
1797
|
+
return user;
|
|
1798
|
+
}
|
|
1478
1799
|
/** The guild a manager belongs to, when it is a mock guild's. */ const guildOf = (manager)=>{
|
|
1479
1800
|
const guild = manager.guild;
|
|
1480
1801
|
return guild instanceof discord_js.Guild ? guild : undefined;
|
|
1481
1802
|
};
|
|
1482
|
-
// The
|
|
1483
|
-
|
|
1803
|
+
// The class discord.js makes for each type of channel a server's channel manager creates; a text channel by default
|
|
1804
|
+
const CHANNEL_CLASSES = {
|
|
1805
|
+
[discord_js.ChannelType.GuildText]: discord_js.TextChannel,
|
|
1806
|
+
[discord_js.ChannelType.GuildVoice]: discord_js.VoiceChannel,
|
|
1807
|
+
[discord_js.ChannelType.GuildCategory]: discord_js.CategoryChannel,
|
|
1808
|
+
[discord_js.ChannelType.GuildAnnouncement]: discord_js.NewsChannel,
|
|
1809
|
+
[discord_js.ChannelType.GuildStageVoice]: discord_js.StageChannel,
|
|
1810
|
+
[discord_js.ChannelType.GuildDirectory]: discord_js.DirectoryChannel,
|
|
1811
|
+
[discord_js.ChannelType.GuildForum]: discord_js.ForumChannel,
|
|
1812
|
+
[discord_js.ChannelType.GuildMedia]: discord_js.MediaChannel
|
|
1813
|
+
};
|
|
1814
|
+
/**
|
|
1815
|
+
* The class of a channel a server's channel manager makes for `type`: under strict mocks the one discord.js makes,
|
|
1816
|
+
* while default mode keeps a text channel and warns once for another type.
|
|
1817
|
+
*/ function createdChannelClass(type) {
|
|
1818
|
+
const Class = CHANNEL_CLASSES[type ?? discord_js.ChannelType.GuildText] ?? discord_js.TextChannel;
|
|
1819
|
+
if (Class === discord_js.TextChannel || strictMocks()) return Class;
|
|
1820
|
+
controller_decorator.warnOnce(mockLogger, 'GuildChannelManager.create() here makes a text channel whatever type its options name; under useStrictMocks() it ' + 'makes the channel discord.js makes for that type.');
|
|
1821
|
+
return discord_js.TextChannel;
|
|
1822
|
+
}
|
|
1823
|
+
/** A thread a channel's thread manager makes: in that channel, as its parent, and in its server. */ function threadIn(id, manager) {
|
|
1824
|
+
const channel = manager.channel;
|
|
1825
|
+
if (!(channel instanceof discord_js.GuildChannel)) return createMockChannel(discord_js.ThreadChannel, id ? {
|
|
1826
|
+
id
|
|
1827
|
+
} : {});
|
|
1828
|
+
const guild = channel.guild;
|
|
1829
|
+
const thread = createMockChannel(discord_js.ThreadChannel, id ? {
|
|
1830
|
+
id
|
|
1831
|
+
} : {});
|
|
1832
|
+
// Not enumerated, so a mock thread's own keys stay those of one made alone, as a test may compare
|
|
1833
|
+
const place = (key, value)=>Object.defineProperty(thread, key, {
|
|
1834
|
+
value,
|
|
1835
|
+
writable: true,
|
|
1836
|
+
configurable: true
|
|
1837
|
+
});
|
|
1838
|
+
place('parentId', channel.id);
|
|
1839
|
+
if (!(guild instanceof discord_js.Guild)) return thread;
|
|
1840
|
+
place('guild', guild);
|
|
1841
|
+
place('guildId', guild.id);
|
|
1842
|
+
// The parent is found through its server's channels, as discord.js finds it, by the thread's parentId. Strict mocks
|
|
1843
|
+
// cache the channel there; default mode leaves the server's cache as it was and reads the channel itself
|
|
1844
|
+
if (strictMocks()) cached(cacheOf(guild.channels), channel.id, ()=>channel);
|
|
1845
|
+
Object.defineProperty(thread, 'parent', {
|
|
1846
|
+
get: ()=>{
|
|
1847
|
+
const parentId = thread.parentId ?? '';
|
|
1848
|
+
return cacheOf(guild.channels)?.get(parentId) ?? (parentId === channel.id ? channel : null);
|
|
1849
|
+
},
|
|
1850
|
+
configurable: true
|
|
1851
|
+
});
|
|
1852
|
+
return thread;
|
|
1853
|
+
}
|
|
1484
1854
|
const MANAGER_ITEMS = [
|
|
1485
1855
|
[
|
|
1486
1856
|
discord_js.UserManager,
|
|
@@ -1522,9 +1892,10 @@ const MANAGER_ITEMS = [
|
|
|
1522
1892
|
()=>createMockInteraction(discord_js.GuildBan)
|
|
1523
1893
|
],
|
|
1524
1894
|
// A message fetched from a channel is in it, and another user's
|
|
1895
|
+
// A message made in the channel is the one fetched by its id, as Discord has it
|
|
1525
1896
|
[
|
|
1526
1897
|
discord_js.GuildMessageManager,
|
|
1527
|
-
(id, manager)=>createMockMessage({
|
|
1898
|
+
(id, manager)=>madeIn(manager, id) ?? createMockMessage({
|
|
1528
1899
|
...id ? {
|
|
1529
1900
|
id
|
|
1530
1901
|
} : {},
|
|
@@ -1533,7 +1904,7 @@ const MANAGER_ITEMS = [
|
|
|
1533
1904
|
],
|
|
1534
1905
|
[
|
|
1535
1906
|
discord_js.DMMessageManager,
|
|
1536
|
-
(id, manager)=>createMockMessage({
|
|
1907
|
+
(id, manager)=>madeIn(manager, id) ?? createMockMessage({
|
|
1537
1908
|
...id ? {
|
|
1538
1909
|
id
|
|
1539
1910
|
} : {},
|
|
@@ -1542,15 +1913,11 @@ const MANAGER_ITEMS = [
|
|
|
1542
1913
|
],
|
|
1543
1914
|
[
|
|
1544
1915
|
discord_js.GuildTextThreadManager,
|
|
1545
|
-
|
|
1546
|
-
id
|
|
1547
|
-
} : {})
|
|
1916
|
+
threadIn
|
|
1548
1917
|
],
|
|
1549
1918
|
[
|
|
1550
1919
|
discord_js.GuildForumThreadManager,
|
|
1551
|
-
|
|
1552
|
-
id
|
|
1553
|
-
} : {})
|
|
1920
|
+
threadIn
|
|
1554
1921
|
],
|
|
1555
1922
|
[
|
|
1556
1923
|
discord_js.ThreadMemberManager,
|
|
@@ -1576,7 +1943,7 @@ const MANAGER_ITEMS = [
|
|
|
1576
1943
|
],
|
|
1577
1944
|
[
|
|
1578
1945
|
discord_js.GuildChannelManager,
|
|
1579
|
-
(id, manager)=>createMockChannel(
|
|
1946
|
+
(id, manager, options)=>createMockChannel(createdChannelClass(options?.type), {
|
|
1580
1947
|
...id ? {
|
|
1581
1948
|
id
|
|
1582
1949
|
} : {},
|
|
@@ -1584,9 +1951,65 @@ const MANAGER_ITEMS = [
|
|
|
1584
1951
|
guild: guildOf(manager)
|
|
1585
1952
|
} : {}
|
|
1586
1953
|
})
|
|
1954
|
+
],
|
|
1955
|
+
[
|
|
1956
|
+
discord_js.GuildEmojiManager,
|
|
1957
|
+
(id, manager)=>itemInGuild(discord_js.GuildEmoji, {
|
|
1958
|
+
id: id ?? nextSnowflake()
|
|
1959
|
+
}, manager)
|
|
1960
|
+
],
|
|
1961
|
+
[
|
|
1962
|
+
discord_js.GuildStickerManager,
|
|
1963
|
+
(id, manager)=>itemInGuild(discord_js.Sticker, {
|
|
1964
|
+
id: id ?? nextSnowflake()
|
|
1965
|
+
}, manager)
|
|
1966
|
+
],
|
|
1967
|
+
[
|
|
1968
|
+
discord_js.GuildScheduledEventManager,
|
|
1969
|
+
(id, manager)=>itemInGuild(discord_js.GuildScheduledEvent, {
|
|
1970
|
+
id: id ?? nextSnowflake()
|
|
1971
|
+
}, manager)
|
|
1972
|
+
],
|
|
1973
|
+
[
|
|
1974
|
+
discord_js.AutoModerationRuleManager,
|
|
1975
|
+
(id, manager)=>itemInGuild(discord_js.AutoModerationRule, {
|
|
1976
|
+
id: id ?? nextSnowflake()
|
|
1977
|
+
}, manager)
|
|
1978
|
+
],
|
|
1979
|
+
// An invite is keyed by its code, as discord.js caches and fetches one
|
|
1980
|
+
[
|
|
1981
|
+
discord_js.GuildInviteManager,
|
|
1982
|
+
(code, manager)=>itemInGuild(discord_js.Invite, {
|
|
1983
|
+
code: code ?? BigInt(nextSnowflake()).toString(36)
|
|
1984
|
+
}, manager)
|
|
1587
1985
|
]
|
|
1588
1986
|
];
|
|
1589
|
-
|
|
1987
|
+
/** 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) {
|
|
1988
|
+
const guild = guildOf(manager);
|
|
1989
|
+
return createMockInteraction(Class, {
|
|
1990
|
+
...props,
|
|
1991
|
+
...guild ? {
|
|
1992
|
+
guild,
|
|
1993
|
+
guildId: guild.id
|
|
1994
|
+
} : {}
|
|
1995
|
+
});
|
|
1996
|
+
}
|
|
1997
|
+
/**
|
|
1998
|
+
* Names what a manager's create() or edit() made the `name` its options give, as discord.js does, under strict mocks;
|
|
1999
|
+
* default mode keeps the name it has, with a warning once.
|
|
2000
|
+
*/ function withName(manager, method, item, options) {
|
|
2001
|
+
const name = options?.name;
|
|
2002
|
+
const current = item.name;
|
|
2003
|
+
if (typeof name !== 'string' || current === name) return item;
|
|
2004
|
+
if (strictMocks()) {
|
|
2005
|
+
item.name = name;
|
|
2006
|
+
return item;
|
|
2007
|
+
}
|
|
2008
|
+
const Manager = Object.getPrototypeOf(manager).constructor.name;
|
|
2009
|
+
controller_decorator.warnOnce(mockLogger, `${Manager}.${method}() here leaves the item with the mock's name, not the name its options give, as discord.js names it; ` + 'in the next major version (5.0) the mock names it so. Call useStrictMocks() to have it named now.');
|
|
2010
|
+
return item;
|
|
2011
|
+
}
|
|
2012
|
+
/** Whether a discord.js method on `target` returns a promise: an async one, or one the lists above name. */ const returnsPromise = (target, key, method)=>method.constructor.name === 'AsyncFunction' || PROMISE_METHODS.has(key) || PROMISE_METHODS_OF.some(([Class, keys])=>keys.has(key) && Class.prototype.isPrototypeOf(target)) || /^set[A-Z]/.test(key) && !SYNC_SETTERS.has(key);
|
|
1590
2013
|
// The ids a fetch asks for: an id, a discord.js object, or options naming one or several, such as `{ user: id }` or
|
|
1591
2014
|
// `{ user: [ids] }`; undefined for a fetch of a list
|
|
1592
2015
|
/** The channel a message manager belongs to, as `createMockMessage` takes it. */ const channelOfManager = (manager)=>{
|
|
@@ -1614,18 +2037,58 @@ function fetchedIds(args) {
|
|
|
1614
2037
|
}
|
|
1615
2038
|
return undefined;
|
|
1616
2039
|
}
|
|
2040
|
+
// How many messages discord.js's MessageManager.fetch() asks Discord for when its options give no limit
|
|
2041
|
+
const DEFAULT_FETCH_LIMIT = 50;
|
|
2042
|
+
/** Which of two messages is newer, by snowflake id, else by when the mock was made: negative when `a` is. */ function newerFirst(a, b) {
|
|
2043
|
+
if (isSnowflake(a.id) && isSnowflake(b.id) && a.id !== b.id) return BigInt(b.id) > BigInt(a.id) ? 1 : -1;
|
|
2044
|
+
return b.createdTimestamp - a.createdTimestamp;
|
|
2045
|
+
}
|
|
2046
|
+
/**
|
|
2047
|
+
* A channel's messages as `messages.fetch(options)` lists them under strict mocks: those its cache holds, newest first,
|
|
2048
|
+
* before, after or around the message `options` names, at most `limit` of them.
|
|
2049
|
+
*/ function recentMessages(manager, options) {
|
|
2050
|
+
const { limit = DEFAULT_FETCH_LIMIT, before, after, around } = options ?? {};
|
|
2051
|
+
const all = [
|
|
2052
|
+
...cacheOf(manager)?.values() ?? []
|
|
2053
|
+
];
|
|
2054
|
+
const sorted = all.sort(newerFirst);
|
|
2055
|
+
const at = (id)=>sorted.findIndex((message)=>message.id === id);
|
|
2056
|
+
let window = sorted;
|
|
2057
|
+
if (before !== undefined) window = at(before) === -1 ? [] : sorted.slice(at(before) + 1);
|
|
2058
|
+
else if (after !== undefined) window = at(after) === -1 ? [] : sorted.slice(0, at(after)).slice(-limit);
|
|
2059
|
+
else if (around !== undefined) window = at(around) === -1 ? [] : sorted.slice(Math.max(0, at(around) - Math.floor(limit / 2)));
|
|
2060
|
+
return new discord_js.Collection(window.slice(0, limit).map((message)=>[
|
|
2061
|
+
message.id,
|
|
2062
|
+
message
|
|
2063
|
+
]));
|
|
2064
|
+
}
|
|
1617
2065
|
/**
|
|
1618
2066
|
* The mock function for a method found on a discord.js prototype. One that returns a promise in discord.js resolves:
|
|
1619
|
-
* `createDM` to the user's DM channel, `send` and its kin to a message,
|
|
1620
|
-
* item (a collection of each for a fetch of several,
|
|
1621
|
-
* `delete`, `ban`, `pin`, `unpin`, `timeout`,
|
|
1622
|
-
* `undefined`. Any other method returns
|
|
2067
|
+
* `createDM` to the user's DM channel, `send` and its kin to a message, `awaitMessages` and `awaitReactions` to an
|
|
2068
|
+
* empty collection, a manager's `fetch`, `create` and `edit` to its item (a collection of each for a fetch of several,
|
|
2069
|
+
* an empty one for a list fetch), a structure's own `edit`, `fetch`, `delete`, `ban`, `kick`, `pin`, `unpin`, `timeout`,
|
|
2070
|
+
* `disableCommunicationUntil` and setters to the structure, and the rest to `undefined`. Any other method returns
|
|
2071
|
+
* `undefined`.
|
|
1623
2072
|
*/ function methodStub(target, key, method, receiver) {
|
|
1624
|
-
if (!returnsPromise(key, method)) return createMockFn();
|
|
2073
|
+
if (!returnsPromise(target, key, method)) return createMockFn();
|
|
1625
2074
|
// A member's DMs go through its user, and a user's through its one DM channel, as discord.js sends them
|
|
1626
2075
|
if (target instanceof discord_js.GuildMember && (key === 'createDM' || key === 'send')) {
|
|
1627
2076
|
return createMockFn(async (...args)=>receiver().user[key](...args));
|
|
1628
2077
|
}
|
|
2078
|
+
// Under strict mocks a user manager's DMs go to the user named, its cached one or one made and cached, as discord.js
|
|
2079
|
+
// resolves it; default mode keeps one DM channel for the manager and a send that resolves to an empty message
|
|
2080
|
+
if (target instanceof discord_js.UserManager && (key === 'createDM' || key === 'send')) {
|
|
2081
|
+
return createMockFn(async (user, ...args)=>{
|
|
2082
|
+
if (!strictMocks()) {
|
|
2083
|
+
controller_decorator.warnOnce(mockLogger, USER_MANAGER_DM_WARNINGS[key]);
|
|
2084
|
+
return key === 'createDM' ? dmChannelOf(receiver()) : createMockMessage();
|
|
2085
|
+
}
|
|
2086
|
+
const named = user instanceof discord_js.GuildMember ? user.user : user;
|
|
2087
|
+
const id = typeof named === 'string' ? named : named?.id;
|
|
2088
|
+
const resolved = named instanceof discord_js.User ? named : cached(cacheOf(receiver()), id ?? nextSnowflake(), ()=>userOf(receiver(), id));
|
|
2089
|
+
return key === 'createDM' ? resolved.createDM() : resolved.send(...args);
|
|
2090
|
+
});
|
|
2091
|
+
}
|
|
1629
2092
|
if (key === 'createDM') return createMockFn(async ()=>dmChannelOf(receiver()));
|
|
1630
2093
|
if (key === 'send' && target instanceof discord_js.User) {
|
|
1631
2094
|
return createMockFn(async (...args)=>(await receiver().createDM()).send(...args));
|
|
@@ -1635,6 +2098,7 @@ function fetchedIds(args) {
|
|
|
1635
2098
|
return createMockFn(async (options)=>sentMessage(sent(options, false), receiver()));
|
|
1636
2099
|
}
|
|
1637
2100
|
if (MESSAGE_METHODS.has(key)) return createMockFn(async ()=>createMockMessage());
|
|
2101
|
+
if (COLLECTING_METHODS.has(key)) return createMockFn(async ()=>new discord_js.Collection());
|
|
1638
2102
|
if (target instanceof discord_js.BaseManager) {
|
|
1639
2103
|
const item = MANAGER_ITEMS.find(([Manager])=>Manager.prototype.isPrototypeOf(target))?.[1];
|
|
1640
2104
|
// A fetch of one item finds it in the cache first, as discord.js does, and caches one it makes; a fetch of
|
|
@@ -1647,10 +2111,27 @@ function fetchedIds(args) {
|
|
|
1647
2111
|
id,
|
|
1648
2112
|
one(id)
|
|
1649
2113
|
]));
|
|
1650
|
-
|
|
2114
|
+
if (ids !== undefined) return one(ids);
|
|
2115
|
+
// Under strict mocks a channel's fetch() lists the messages it holds, as discord.js lists its recent ones;
|
|
2116
|
+
// default mode resolves to none, and warns once for a channel that strict mocks would list messages of
|
|
2117
|
+
const messages = target instanceof discord_js.GuildMessageManager || target instanceof discord_js.DMMessageManager;
|
|
2118
|
+
if (messages && strictMocks()) return recentMessages(receiver(), args[0]);
|
|
2119
|
+
if (messages) warnUnlisted(receiver());
|
|
2120
|
+
return new discord_js.Collection();
|
|
2121
|
+
});
|
|
2122
|
+
}
|
|
2123
|
+
// Under strict mocks an edit reaches the item named, the cached one or one made and cached under its id, as
|
|
2124
|
+
// discord.js patches it; default mode resolves to a new item and warns once
|
|
2125
|
+
if (item && key === 'edit') {
|
|
2126
|
+
return createMockFn(async (named, options)=>{
|
|
2127
|
+
const id = typeof named === 'string' ? named : named?.id;
|
|
2128
|
+
if (id !== undefined && !strictMocks()) {
|
|
2129
|
+
controller_decorator.warnOnce(mockLogger, `${Object.getPrototypeOf(receiver()).constructor.name}.edit() here ` + 'resolves to a new item with an id of its own, not the item named; under useStrictMocks() it resolves to ' + 'the item with that id, the cached one or one made under it.');
|
|
2130
|
+
}
|
|
2131
|
+
const edited = id === undefined || !strictMocks() ? item(undefined, receiver()) : cached(cacheOf(receiver()), id, ()=>item(id, receiver()));
|
|
2132
|
+
return withName(receiver(), 'edit', edited, options);
|
|
1651
2133
|
});
|
|
1652
2134
|
}
|
|
1653
|
-
if (item && key === 'edit') return createMockFn(async ()=>item(undefined, receiver()));
|
|
1654
2135
|
// A ban resolves to the member, user or id it was given, as discord.js's does, and caches nothing
|
|
1655
2136
|
if (key === 'create' && target instanceof discord_js.GuildBanManager) {
|
|
1656
2137
|
return createMockFn(async (user)=>{
|
|
@@ -1659,11 +2140,23 @@ function fetchedIds(args) {
|
|
|
1659
2140
|
return typeof user === 'string' ? user : user?.id;
|
|
1660
2141
|
});
|
|
1661
2142
|
}
|
|
2143
|
+
// An invite is made for the channel given, in its guild, and cached nowhere, as discord.js's create does
|
|
2144
|
+
if (item && key === 'create' && target instanceof discord_js.GuildInviteManager) {
|
|
2145
|
+
return createMockFn(async (channel)=>{
|
|
2146
|
+
const invite = item(undefined, receiver());
|
|
2147
|
+
const channelId = typeof channel === 'string' ? channel : channel?.id;
|
|
2148
|
+
const found = channel instanceof discord_js.BaseChannel ? channel : channelId ? cacheOf(guildOf(receiver())?.channels)?.get(channelId) ?? null : null;
|
|
2149
|
+
return Object.assign(invite, {
|
|
2150
|
+
channel: found,
|
|
2151
|
+
channelId: channelId ?? null
|
|
2152
|
+
});
|
|
2153
|
+
});
|
|
2154
|
+
}
|
|
1662
2155
|
// Every other manager here caches what its create() makes, as discord.js's does; check a manager's create when
|
|
1663
2156
|
// adding it to MANAGER_ITEMS
|
|
1664
2157
|
if (item && key === 'create') {
|
|
1665
|
-
return createMockFn(async ()=>{
|
|
1666
|
-
const made = item(undefined, receiver());
|
|
2158
|
+
return createMockFn(async (options)=>{
|
|
2159
|
+
const made = withName(receiver(), 'create', item(undefined, receiver(), options), options);
|
|
1667
2160
|
if (typeof made.id === 'string') cacheOf(receiver())?.set(made.id, made);
|
|
1668
2161
|
return made;
|
|
1669
2162
|
});
|
|
@@ -1855,10 +2348,6 @@ const isSnowflake = (id)=>typeof id === 'string' && /^\d{1,20}$/.test(id);
|
|
|
1855
2348
|
});
|
|
1856
2349
|
}
|
|
1857
2350
|
}
|
|
1858
|
-
/**
|
|
1859
|
-
* Where a mock interaction keeps every answer it got, through respond() or discord.js directly, in order, under a key
|
|
1860
|
-
* both builds of this meocord version read.
|
|
1861
|
-
*/ const RESPONSE_LOG = Symbol.for(controller_decorator.sharedName('responseLog'));
|
|
1862
2351
|
/**
|
|
1863
2352
|
* A mock of an answer method, `impl` its behaviour, that records each call in `log` as it is made, with what it sent
|
|
1864
2353
|
* and, once it settles, what it rejected with. The record wraps every behaviour the mock is given, its own and any a
|
|
@@ -1875,24 +2364,36 @@ const isSnowflake = (id)=>typeof id === 'string' && /^\d{1,20}$/.test(id);
|
|
|
1875
2364
|
};
|
|
1876
2365
|
controller_decorator.stampCall(call);
|
|
1877
2366
|
log.push(call);
|
|
1878
|
-
let
|
|
2367
|
+
let gated;
|
|
1879
2368
|
try {
|
|
1880
|
-
// Before the behaviour, as discord.js
|
|
1881
|
-
if (gate)
|
|
2369
|
+
// Before the behaviour, as discord.js checks the state before it sends an answer
|
|
2370
|
+
if (gate) gated = gate(args, byTest);
|
|
1882
2371
|
} catch (error) {
|
|
1883
2372
|
call.error = error;
|
|
1884
2373
|
return Promise.reject(error);
|
|
1885
2374
|
}
|
|
1886
2375
|
const failed = (error)=>{
|
|
1887
|
-
|
|
2376
|
+
gated?.settle?.();
|
|
2377
|
+
gated?.undo?.();
|
|
1888
2378
|
call.error = error;
|
|
1889
2379
|
throw error;
|
|
1890
2380
|
};
|
|
2381
|
+
// Once the behaviour resolves, as discord.js changes the state once Discord has taken the answer
|
|
2382
|
+
const landed = (value)=>{
|
|
2383
|
+
gated?.settle?.();
|
|
2384
|
+
try {
|
|
2385
|
+
gated?.land?.();
|
|
2386
|
+
} catch (error) {
|
|
2387
|
+
call.error = error;
|
|
2388
|
+
throw error;
|
|
2389
|
+
}
|
|
2390
|
+
return value;
|
|
2391
|
+
};
|
|
1891
2392
|
try {
|
|
1892
2393
|
const result = Reflect.apply(behaviour, this, args);
|
|
1893
2394
|
// Recorded and passed on, so a rejection nobody awaits is unhandled in the test, as it is on a bot
|
|
1894
|
-
if (result instanceof Promise) return result.then(
|
|
1895
|
-
return result;
|
|
2395
|
+
if (result instanceof Promise) return result.then(landed, failed);
|
|
2396
|
+
return landed(result);
|
|
1896
2397
|
} catch (error) {
|
|
1897
2398
|
return failed(error);
|
|
1898
2399
|
}
|
|
@@ -1935,11 +2436,33 @@ const isSnowflake = (id)=>typeof id === 'string' && /^\d{1,20}$/.test(id);
|
|
|
1935
2436
|
if (message !== undefined) error.message = message;
|
|
1936
2437
|
return error;
|
|
1937
2438
|
}
|
|
2439
|
+
// Why default mode runs an answer discord.js refuses, as the warning words it
|
|
2440
|
+
const SET_BY_TEST = 'a test set this answer or the one before it';
|
|
2441
|
+
/**
|
|
2442
|
+
* Refuses an answer as discord.js does. Where `lenient` says why the mock ran it so far, such as a behaviour a test
|
|
2443
|
+
* set, default mode warns and lets it run; strict mocks refuse it.
|
|
2444
|
+
*/ function refuseAnswer(method, error, lenient) {
|
|
2445
|
+
if (lenient === undefined || strictMocks()) throw error;
|
|
2446
|
+
controller_decorator.warnOnce(mockLogger, `${method}() here is refused by discord.js with "${error.message}", but the mock runs it, as ${lenient}. In the ` + 'next major version (5.0) the mock refuses it; call useStrictMocks() to have it refused now.');
|
|
2447
|
+
}
|
|
1938
2448
|
/** 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);
|
|
1939
2449
|
// In discord.js's error codes, though missing from its typings
|
|
1940
2450
|
const INVALID_CHANNEL_TYPE = 'CommandInteractionOptionInvalidChannelType';
|
|
1941
|
-
/**
|
|
1942
|
-
|
|
2451
|
+
/** The names of a mock options resolver's entity options, for one given to an autocomplete. */ const optionEntities = new WeakMap();
|
|
2452
|
+
/** The resolvers whose entity options a warning has named. */ const warnedEntities = new WeakSet();
|
|
2453
|
+
/** Whether a mock options resolver belongs to an autocomplete, whose options Discord sends unresolved. */ const ownedByAutocomplete = (options)=>optionOwners.get(options)?.type === discord_js.InteractionType.ApplicationCommandAutocomplete;
|
|
2454
|
+
/**
|
|
2455
|
+
* Records the interaction a mock options resolver belongs to. Given to an autocomplete, an entity option reads as its
|
|
2456
|
+
* id under strict mocks, as Discord sends it; otherwise it keeps the object given, with a warning once per resolver.
|
|
2457
|
+
*/ const ownOptions = (interaction, options)=>{
|
|
2458
|
+
if (typeof options !== 'object' || options === null) return;
|
|
2459
|
+
optionOwners.set(options, interaction);
|
|
2460
|
+
const entities = optionEntities.get(options);
|
|
2461
|
+
if (!entities?.length || strictMocks() || warnedEntities.has(options) || !ownedByAutocomplete(options)) return;
|
|
2462
|
+
warnedEntities.add(options);
|
|
2463
|
+
const names = entities.map((name)=>`"${name}"`).join(', ');
|
|
2464
|
+
const many = entities.length > 1;
|
|
2465
|
+
mockLogger.warn(`The autocomplete option${many ? 's' : ''} ${names} ${many ? 'hold resolved objects' : 'holds a resolved object'} here, where Discord ` + `sends an autocomplete only ${many ? 'their ids' : 'its id'}, so a bot's handler gets the id and getUser() and the like return null. ` + `Call useStrictMocks(), which reads ${many ? 'them as their ids' : 'it as its id'}, as Discord sends ${many ? 'them' : 'it'}. A bare id string reads as ` + 'a string option.');
|
|
1943
2466
|
};
|
|
1944
2467
|
/** A mock user that is a person, with an id of its own unless given one. */ const mockUser = (id)=>{
|
|
1945
2468
|
const generatedId = id === undefined ? nextSnowflake() : undefined;
|
|
@@ -2006,7 +2529,31 @@ function createMockInteraction(Class, props) {
|
|
|
2006
2529
|
if (repliable) {
|
|
2007
2530
|
instance.replied = false;
|
|
2008
2531
|
instance.deferred = false;
|
|
2009
|
-
|
|
2532
|
+
// Null until reply() or deferReply() decides it, as discord.js has it; default mode reads false until then, and a
|
|
2533
|
+
// test reading it there is told once. A value the test sets is read as set
|
|
2534
|
+
const strict = strictMocks();
|
|
2535
|
+
let ephemeral = strict ? null : false;
|
|
2536
|
+
let ephemeralDecided = false;
|
|
2537
|
+
// Whether one of the mock's own first answers ran; flags a test sets without one leave ephemeral as discord.js would
|
|
2538
|
+
let answeredByMock = false;
|
|
2539
|
+
const answeredOutside = ()=>Boolean(instance.replied || instance.deferred) && !answeredByMock;
|
|
2540
|
+
const decideEphemeral = (value, decided)=>{
|
|
2541
|
+
ephemeral = value;
|
|
2542
|
+
ephemeralDecided = decided;
|
|
2543
|
+
};
|
|
2544
|
+
Object.defineProperty(instance, 'ephemeral', {
|
|
2545
|
+
get: ()=>{
|
|
2546
|
+
// Quiet once a test answered the interaction itself: MeoCord then reads ephemeral only for truthiness, where false
|
|
2547
|
+
// and null act alike
|
|
2548
|
+
if (!ephemeralDecided && !strict && !answeredOutside()) {
|
|
2549
|
+
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.');
|
|
2550
|
+
}
|
|
2551
|
+
return ephemeral;
|
|
2552
|
+
},
|
|
2553
|
+
set: (value)=>decideEphemeral(value, true),
|
|
2554
|
+
enumerable: true,
|
|
2555
|
+
configurable: true
|
|
2556
|
+
});
|
|
2010
2557
|
// discord.js's errors, with their codes; a call before any answer keeps the message the mock has always given
|
|
2011
2558
|
const alreadyReplied = ()=>discordjsError(discord_js.DiscordjsErrorCodes.InteractionAlreadyReplied);
|
|
2012
2559
|
const notYetReplied = (method)=>discordjsError(discord_js.DiscordjsErrorCodes.InteractionNotReplied, `Cannot call ${method}() before replying or deferring.`);
|
|
@@ -2023,41 +2570,57 @@ function createMockInteraction(Class, props) {
|
|
|
2023
2570
|
const followUps = new Map();
|
|
2024
2571
|
// Whether the last answer ran a behaviour a test set, after which a second answer runs with a warning by default
|
|
2025
2572
|
let answeredByTest = false;
|
|
2026
|
-
//
|
|
2027
|
-
let
|
|
2573
|
+
// Whether default mode ran a launchActivity() or sendPremiumRequired() before any answer, which it doesn't count as one
|
|
2574
|
+
let uncounted = false;
|
|
2575
|
+
// A command answered without a message, as with a modal, has no original response, and Discord refuses one asked for
|
|
2576
|
+
let noOriginal = false;
|
|
2028
2577
|
const isComponent = instance.type === discord_js.InteractionType.MessageComponent;
|
|
2029
2578
|
// The original response of a component, or of a modal submitted from one, is the message it's on
|
|
2030
2579
|
const componentMessage = ()=>heldFrom(instance.message);
|
|
2580
|
+
// Read when answered, after a test's props are applied: a modal submitted from a command has no message
|
|
2581
|
+
const onMessage = ()=>isComponent || instance.type === discord_js.InteractionType.ModalSubmit && Boolean(instance.message);
|
|
2031
2582
|
const snapshot = ()=>{
|
|
2032
2583
|
const saved = {
|
|
2033
2584
|
replied: instance.replied,
|
|
2034
2585
|
deferred: instance.deferred,
|
|
2035
|
-
ephemeral
|
|
2586
|
+
ephemeral,
|
|
2587
|
+
ephemeralDecided,
|
|
2588
|
+
answeredByMock,
|
|
2036
2589
|
original,
|
|
2037
2590
|
originalGone,
|
|
2038
2591
|
followUps: new Map(followUps),
|
|
2039
2592
|
answeredByTest,
|
|
2040
|
-
|
|
2593
|
+
noOriginal
|
|
2041
2594
|
};
|
|
2042
2595
|
return ()=>{
|
|
2043
2596
|
Object.assign(instance, {
|
|
2044
2597
|
replied: saved.replied,
|
|
2045
|
-
deferred: saved.deferred
|
|
2046
|
-
ephemeral: saved.ephemeral
|
|
2598
|
+
deferred: saved.deferred
|
|
2047
2599
|
});
|
|
2048
|
-
(
|
|
2600
|
+
decideEphemeral(saved.ephemeral, saved.ephemeralDecided);
|
|
2601
|
+
({ original, originalGone, answeredByTest, answeredByMock, noOriginal } = saved);
|
|
2049
2602
|
followUps.clear();
|
|
2050
2603
|
for (const [id, held] of saved.followUps)followUps.set(id, held);
|
|
2051
2604
|
};
|
|
2052
2605
|
};
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
* one before it, which ran without one, default mode warns and lets it run; strict mocks refuse it.
|
|
2056
|
-
*/ const refuse = (method, error, byTest)=>{
|
|
2057
|
-
if (!(byTest || answeredByTest) || strictMocks()) throw error;
|
|
2058
|
-
controller_decorator.warnOnce(mockLogger, `${method}() here is refused by discord.js with "${error.message}", but the mock runs it, as a test set this ` + 'answer or the one before it. In the next major version (5.0) the mock refuses it; call useStrictMocks() to ' + 'have it refused now.');
|
|
2059
|
-
};
|
|
2606
|
+
// Refused as discord.js does, leniently in default mode where this answer or the one before it ran a test's behaviour
|
|
2607
|
+
const refuse = (method, error, byTest)=>refuseAnswer(method, error, byTest || answeredByTest ? SET_BY_TEST : undefined);
|
|
2060
2608
|
const answered = ()=>instance.deferred || instance.replied;
|
|
2609
|
+
// Default mode's launchActivity() and sendPremiumRequired(), which leave the interaction as it was, as before
|
|
2610
|
+
const countedOnlyWhenStrict = (method)=>{
|
|
2611
|
+
if (answered()) {
|
|
2612
|
+
controller_decorator.warnOnce(mockLogger, `${method}() here runs though the interaction was answered; under useStrictMocks() this is refused with ` + 'InteractionAlreadyReplied, as discord.js refuses it.');
|
|
2613
|
+
return;
|
|
2614
|
+
}
|
|
2615
|
+
uncounted = true;
|
|
2616
|
+
controller_decorator.warnOnce(mockLogger, `${method}() here leaves the interaction unanswered, and getResponse() doesn't report it; under useStrictMocks() this ` + "is the interaction's first answer, as in discord.js: it marks the interaction replied, and an answer before or " + 'after it is refused.');
|
|
2617
|
+
};
|
|
2618
|
+
// What Discord answers here isn't known, so the refusal names the cause rather than a code
|
|
2619
|
+
const refuseModalUpdate = ()=>{
|
|
2620
|
+
const reason = 'update() needs the message a component opened this modal from, and this modal came from a command. Use ' + 'reply() or deferReply() instead.';
|
|
2621
|
+
if (strictMocks()) throw new Error(reason);
|
|
2622
|
+
controller_decorator.warnOnce(mockLogger, `${reason} The mock runs it; in the next major version (5.0) it refuses it. Call useStrictMocks() to have it refused now.`);
|
|
2623
|
+
};
|
|
2061
2624
|
/**
|
|
2062
2625
|
* What an answer sends, built now, as discord.js builds it when sending. One it refuses to build is refused under
|
|
2063
2626
|
* strict mocks; default mode warns and holds it to build when first read.
|
|
@@ -2070,32 +2633,92 @@ function createMockInteraction(Class, props) {
|
|
|
2070
2633
|
return build;
|
|
2071
2634
|
}
|
|
2072
2635
|
};
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2636
|
+
// The changes answers make to the reply state, in order. One whose answer fails is undone once every change after
|
|
2637
|
+
// it is undone too, so a later answer's change stands while it does, as on a bot
|
|
2638
|
+
const changes = [];
|
|
2639
|
+
const undoneInTurn = (undo)=>{
|
|
2640
|
+
const change = {
|
|
2641
|
+
undo,
|
|
2642
|
+
failed: false
|
|
2643
|
+
};
|
|
2644
|
+
changes.push(change);
|
|
2645
|
+
return ()=>{
|
|
2646
|
+
change.failed = true;
|
|
2647
|
+
while(changes.at(-1)?.failed)changes.pop().undo();
|
|
2648
|
+
};
|
|
2649
|
+
};
|
|
2650
|
+
// The first answers called and not yet settled, oldest first, for default mode to warn where a bot would differ
|
|
2651
|
+
const unsettled = [];
|
|
2652
|
+
// Default mode warns once per interaction where code that doesn't await an answer would fail on a bot
|
|
2653
|
+
let inFlightWarned = false;
|
|
2654
|
+
const warnInFlight = (text)=>{
|
|
2655
|
+
if (inFlightWarned) return;
|
|
2656
|
+
inFlightWarned = true;
|
|
2657
|
+
mockLogger.warn(text);
|
|
2658
|
+
};
|
|
2659
|
+
/**
|
|
2660
|
+
* A first answer: refused once the interaction has one, else it replies or defers. Under strict mocks the change
|
|
2661
|
+
* waits for the answer to land, as discord.js makes it once the request resolves, so one sent while another is in
|
|
2662
|
+
* flight passes discord.js's check and Discord refuses it with 40060 if the other landed first. Default mode makes
|
|
2663
|
+
* the change at once.
|
|
2664
|
+
*/ const first = (method, kind, then)=>(args, byTest)=>{
|
|
2665
|
+
if (answered()) {
|
|
2666
|
+
if (unsettled.length > 0) {
|
|
2667
|
+
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.`);
|
|
2668
|
+
}
|
|
2669
|
+
refuse(method, alreadyReplied(), byTest);
|
|
2670
|
+
}
|
|
2671
|
+
// Read first: flags discord.js refuses throw before anything changes; reply() and deferReply() decide
|
|
2672
|
+
// ephemeral, as discord.js's do, and update() and deferUpdate() leave it
|
|
2673
|
+
const flagged = method === 'reply' || method === 'deferReply' ? hasEphemeralFlag(args[0]) : undefined;
|
|
2674
|
+
const undo = snapshot();
|
|
2675
|
+
instance[kind] = true;
|
|
2676
|
+
if (flagged !== undefined) decideEphemeral(flagged, true);
|
|
2677
|
+
answeredByTest = byTest;
|
|
2678
|
+
answeredByMock = true;
|
|
2679
|
+
try {
|
|
2680
|
+
then(args);
|
|
2681
|
+
} catch (error) {
|
|
2682
|
+
undo();
|
|
2085
2683
|
throw error;
|
|
2086
2684
|
}
|
|
2087
|
-
|
|
2685
|
+
if (strict) {
|
|
2686
|
+
const land = snapshot();
|
|
2687
|
+
undo();
|
|
2688
|
+
return {
|
|
2689
|
+
land: ()=>{
|
|
2690
|
+
if (answered()) throw createDiscordError(40060, 'Interaction has already been acknowledged.');
|
|
2691
|
+
land();
|
|
2692
|
+
}
|
|
2693
|
+
};
|
|
2694
|
+
}
|
|
2695
|
+
unsettled.push(method);
|
|
2696
|
+
return {
|
|
2697
|
+
undo: undoneInTurn(undo),
|
|
2698
|
+
settle: ()=>void unsettled.splice(unsettled.indexOf(method), 1)
|
|
2699
|
+
};
|
|
2088
2700
|
};
|
|
2089
|
-
/**
|
|
2090
|
-
|
|
2701
|
+
/**
|
|
2702
|
+
* A later answer: refused before the interaction has one. One that `replies` marks the interaction replied, under
|
|
2703
|
+
* strict mocks once it lands, as discord.js does once the request resolves; default mode marks it at once.
|
|
2704
|
+
*/ const later = (method, then, replies = false)=>(args, byTest)=>{
|
|
2705
|
+
// A follow-up after an answer default mode doesn't count is sent, as it is once strict mocks count that answer
|
|
2706
|
+
if (!answered() && !(method === 'followUp' && uncounted)) refuse(method, notYetReplied(method), byTest);
|
|
2707
|
+
if (unsettled.length > 0) {
|
|
2708
|
+
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.`);
|
|
2709
|
+
}
|
|
2091
2710
|
const undo = snapshot();
|
|
2092
2711
|
try {
|
|
2093
2712
|
then(args);
|
|
2713
|
+
if (replies && !strict) instance.replied = true;
|
|
2094
2714
|
} catch (error) {
|
|
2095
2715
|
undo();
|
|
2096
2716
|
throw error;
|
|
2097
2717
|
}
|
|
2098
|
-
return
|
|
2718
|
+
return {
|
|
2719
|
+
undo: undoneInTurn(undo),
|
|
2720
|
+
land: replies && strict ? ()=>void (instance.replied = true) : undefined
|
|
2721
|
+
};
|
|
2099
2722
|
};
|
|
2100
2723
|
/** The id an edit, fetch or delete names, `undefined` for the original response. */ const targetId = (message)=>{
|
|
2101
2724
|
if (message === undefined || message === '@original') return undefined;
|
|
@@ -2107,7 +2730,7 @@ function createMockInteraction(Class, props) {
|
|
|
2107
2730
|
if (held) followUps.set(id, built(held));
|
|
2108
2731
|
return held && built(held);
|
|
2109
2732
|
}
|
|
2110
|
-
if (originalGone ||
|
|
2733
|
+
if (originalGone || noOriginal) return undefined;
|
|
2111
2734
|
// Answered without one of the mock's calls, as after a 40060 or a test setting `replied`: the original is there
|
|
2112
2735
|
if (!original && answered()) original = componentMessage();
|
|
2113
2736
|
if (original) original = built(original);
|
|
@@ -2146,7 +2769,6 @@ function createMockInteraction(Class, props) {
|
|
|
2146
2769
|
id: followUpMade
|
|
2147
2770
|
}) : messageFrom(held, madeFor(held.id), ...answeredIn());
|
|
2148
2771
|
}, later('followUp', ([options])=>{
|
|
2149
|
-
instance.replied = true;
|
|
2150
2772
|
// Held as it is when sent, as Discord stores it, so a later change to what was passed isn't seen; its id taken
|
|
2151
2773
|
// once built, so one that fails to build under strict mocks takes none
|
|
2152
2774
|
const held = hold('followUp', ()=>sent(options, false));
|
|
@@ -2160,7 +2782,7 @@ function createMockInteraction(Class, props) {
|
|
|
2160
2782
|
id
|
|
2161
2783
|
});
|
|
2162
2784
|
followUpMade = id;
|
|
2163
|
-
}));
|
|
2785
|
+
}, true));
|
|
2164
2786
|
// An edit replaces the message in the shape Discord stores, with ids and resolved media, stamped with when it was
|
|
2165
2787
|
// edited, and fetchReply() reads that back, so code comparing the two sees what it would against Discord
|
|
2166
2788
|
answer('editReply', async ()=>{
|
|
@@ -2175,11 +2797,9 @@ function createMockInteraction(Class, props) {
|
|
|
2175
2797
|
const held = target(id);
|
|
2176
2798
|
made = held && edited(held, options);
|
|
2177
2799
|
if (!made) return;
|
|
2178
|
-
if (id === undefined)
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
} else followUps.set(id, made);
|
|
2182
|
-
}));
|
|
2800
|
+
if (id === undefined) original = made;
|
|
2801
|
+
else followUps.set(id, made);
|
|
2802
|
+
}, true));
|
|
2183
2803
|
stubs.set('fetchReply', createMockFn(async (message)=>{
|
|
2184
2804
|
const held = target(targetId(message));
|
|
2185
2805
|
if (!held) throw unknownMessage();
|
|
@@ -2194,16 +2814,31 @@ function createMockInteraction(Class, props) {
|
|
|
2194
2814
|
if (id === undefined) originalGone = true;
|
|
2195
2815
|
else followUps.delete(id);
|
|
2196
2816
|
}));
|
|
2817
|
+
// A first answer that sends no message: the original response stays the message the interaction is on, if any
|
|
2818
|
+
const sendsNoMessage = ()=>{
|
|
2819
|
+
if (onMessage()) original = componentMessage();
|
|
2820
|
+
else noOriginal = true;
|
|
2821
|
+
};
|
|
2197
2822
|
// showModal — the first response of a command or a component, like reply()
|
|
2198
2823
|
if (instance.type === discord_js.InteractionType.ApplicationCommand || isComponent) {
|
|
2199
|
-
answer('showModal', async ()=>undefined, first('showModal', 'replied',
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2824
|
+
answer('showModal', async ()=>undefined, first('showModal', 'replied', sendsNoMessage));
|
|
2825
|
+
}
|
|
2826
|
+
// An activity launched, or the upgrade button sent, is a first answer under strict mocks, as reply() is, on every
|
|
2827
|
+
// repliable interaction. Default mode leaves the interaction unanswered and unreported, and warns once where they differ
|
|
2828
|
+
for (const method of [
|
|
2829
|
+
'launchActivity',
|
|
2830
|
+
'sendPremiumRequired'
|
|
2831
|
+
]){
|
|
2832
|
+
if (strict) answer(method, async ()=>undefined, first(method, 'replied', sendsNoMessage));
|
|
2833
|
+
else stubs.set(method, recordedMock(method, async ()=>undefined, [], ()=>(countedOnlyWhenStrict(method), {})));
|
|
2203
2834
|
}
|
|
2204
2835
|
// deferUpdate / update — components, and modals submitted from a message's component
|
|
2205
2836
|
if (isComponent || instance.type === discord_js.InteractionType.ModalSubmit) {
|
|
2206
|
-
answer('update', async ()=>undefined, first('update', 'replied', ([options])=>
|
|
2837
|
+
answer('update', async ()=>undefined, first('update', 'replied', ([options])=>{
|
|
2838
|
+
// Discord updates only a message a component is on; a modal from a command has none
|
|
2839
|
+
if (!onMessage()) refuseModalUpdate();
|
|
2840
|
+
original = hold('update', ()=>edited(heldFrom(instance.message, false), options));
|
|
2841
|
+
}));
|
|
2207
2842
|
answer('deferUpdate', async ()=>undefined, first('deferUpdate', 'deferred', ()=>original = componentMessage()));
|
|
2208
2843
|
}
|
|
2209
2844
|
}
|
|
@@ -2213,14 +2848,58 @@ function createMockInteraction(Class, props) {
|
|
|
2213
2848
|
// it before answering would decide the window was already closed.
|
|
2214
2849
|
if (instance.type === discord_js.InteractionType.ApplicationCommandAutocomplete) {
|
|
2215
2850
|
instance.responded = false;
|
|
2216
|
-
|
|
2217
|
-
|
|
2851
|
+
const strict = strictMocks();
|
|
2852
|
+
const log = [];
|
|
2853
|
+
// getResponse reports each respond() under strict mocks; default mode reports none, as before
|
|
2854
|
+
if (strict) Object.defineProperty(instance, RESPONSE_LOG, {
|
|
2855
|
+
value: log
|
|
2856
|
+
});
|
|
2857
|
+
// Responds whose change stands, and what `responded` read before the first of them, for a failed one to undo
|
|
2858
|
+
let standing = 0;
|
|
2859
|
+
let before = false;
|
|
2860
|
+
let respondsInFlight = 0;
|
|
2861
|
+
let respondWarned = false;
|
|
2862
|
+
// Under strict mocks responded is set once respond() lands, as discord.js sets it once the request resolves, and a
|
|
2863
|
+
// second one sent while the first is in flight is refused by Discord with 40060; default mode sets it at once
|
|
2864
|
+
const gate = (_args, byTest)=>{
|
|
2865
|
+
// Default mode leaves a behaviour a test set to itself, as before: responded stays as it was
|
|
2866
|
+
if (!strict && byTest) {
|
|
2867
|
+
controller_decorator.warnOnce(mockLogger, 'respond() here runs a behaviour a test set and leaves responded as it was; under useStrictMocks() this marks the ' + 'autocomplete responded once it resolves, as discord.js does, and a second respond() is refused.');
|
|
2868
|
+
return {};
|
|
2869
|
+
}
|
|
2870
|
+
if (instance.responded) {
|
|
2871
|
+
if (respondsInFlight > 0 && !respondWarned) {
|
|
2872
|
+
respondWarned = true;
|
|
2873
|
+
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.');
|
|
2874
|
+
}
|
|
2875
|
+
throw discordjsError(discord_js.DiscordjsErrorCodes.InteractionAlreadyReplied);
|
|
2876
|
+
}
|
|
2877
|
+
respondsInFlight++;
|
|
2878
|
+
const settle = ()=>void respondsInFlight--;
|
|
2879
|
+
if (strict) {
|
|
2880
|
+
return {
|
|
2881
|
+
settle,
|
|
2882
|
+
land: ()=>{
|
|
2883
|
+
if (instance.responded) throw createDiscordError(40060, 'Interaction has already been acknowledged.');
|
|
2884
|
+
instance.responded = true;
|
|
2885
|
+
}
|
|
2886
|
+
};
|
|
2887
|
+
}
|
|
2888
|
+
if (standing++ === 0) before = instance.responded;
|
|
2889
|
+
instance.responded = true;
|
|
2890
|
+
return {
|
|
2891
|
+
settle,
|
|
2892
|
+
undo: ()=>{
|
|
2893
|
+
if (--standing === 0) instance.responded = before;
|
|
2894
|
+
}
|
|
2895
|
+
};
|
|
2896
|
+
};
|
|
2897
|
+
stubs.set('respond', recordedMock('respond', async (choices)=>{
|
|
2218
2898
|
// Discord refuses a list longer than its limit, and the menu shows nothing
|
|
2219
2899
|
if (Array.isArray(choices) && choices.length > MAX_AUTOCOMPLETE_CHOICES) {
|
|
2220
2900
|
throw createDiscordError(50035, `Invalid Form Body\ndata.choices[BASE_TYPE_MAX_LENGTH]: Must be ${MAX_AUTOCOMPLETE_CHOICES} or fewer in length.`);
|
|
2221
2901
|
}
|
|
2222
|
-
|
|
2223
|
-
}));
|
|
2902
|
+
}, log, gate));
|
|
2224
2903
|
}
|
|
2225
2904
|
// Applied last so an explicit prop wins over the type fields and the reply
|
|
2226
2905
|
// state machine. defineProperty rather than assignment for the same reason the
|
|
@@ -2353,6 +3032,9 @@ function createMockInteraction(Class, props) {
|
|
|
2353
3032
|
configurable: true
|
|
2354
3033
|
});
|
|
2355
3034
|
}
|
|
3035
|
+
// Default mode reads a guildId alone as a placeholder server with that id, as its warning says, answered in place of
|
|
3036
|
+
// the mock's own stub, so where the interaction is still reads as given. Strict mocks read null, as discord.js does
|
|
3037
|
+
if (strictPlace?.given === 'guildId' && !strictMocks() && unset('guild')) stubs.set('guild', guildPlaceholder(own('guildId')));
|
|
2356
3038
|
// The channel it came from, as the gateway caches it for an interaction: its server's cached one, looked up on each
|
|
2357
3039
|
// read so a channel the test caches later is the one read, or the user's DM
|
|
2358
3040
|
let channel;
|
|
@@ -2394,6 +3076,11 @@ function createMockInteraction(Class, props) {
|
|
|
2394
3076
|
// and in a server that user's member; each read live, so a targetId set later picks its user, and assignable
|
|
2395
3077
|
if (discord_js.UserContextMenuCommandInteraction.prototype.isPrototypeOf(instance)) {
|
|
2396
3078
|
if (unset('targetId')) instance.targetId = own('targetUser')?.id ?? nextSnowflake();
|
|
3079
|
+
// Under strict mocks a target user given is the client's cached one, as discord.js resolves it, so its member is
|
|
3080
|
+
// that user's; default mode keeps the member's own user, and a read of targetMember warns once
|
|
3081
|
+
const givenTarget = own('targetUser');
|
|
3082
|
+
const users = cacheOf(instance.client.users);
|
|
3083
|
+
if (strictMocks() && typeof givenTarget === 'object' && givenTarget !== null) users?.set(givenTarget.id, givenTarget);
|
|
2397
3084
|
const targetUser = ()=>{
|
|
2398
3085
|
const id = own('targetId');
|
|
2399
3086
|
return cached(cacheOf(instance.client.users), id, ()=>mockUser(id));
|
|
@@ -2411,7 +3098,15 @@ function createMockInteraction(Class, props) {
|
|
|
2411
3098
|
});
|
|
2412
3099
|
if (unset('targetUser')) live('targetUser', targetUser);
|
|
2413
3100
|
if (unset('targetMember')) {
|
|
2414
|
-
live('targetMember', ()=>
|
|
3101
|
+
live('targetMember', ()=>{
|
|
3102
|
+
if (!own('guildId')) return null;
|
|
3103
|
+
const member = memberFor(own('guild'), targetUser(), true, own('guildId'));
|
|
3104
|
+
// Only the mock reads this for the test, so the warning comes at the test's read
|
|
3105
|
+
if (givenTarget && member.user !== givenTarget) {
|
|
3106
|
+
controller_decorator.warnOnce(mockLogger, "UserContextMenuCommandInteraction.targetMember here is the member of another user with the targetUser's " + "id; under useStrictMocks() the targetUser given is cached on the client, so targetMember is that user's member.");
|
|
3107
|
+
}
|
|
3108
|
+
return member;
|
|
3109
|
+
});
|
|
2415
3110
|
}
|
|
2416
3111
|
}
|
|
2417
3112
|
// A message context menu's target: the message given, else one made for its targetId. The message is the one
|
|
@@ -2741,6 +3436,10 @@ const HOLDS = [
|
|
|
2741
3436
|
discord_js.RoleManager.prototype,
|
|
2742
3437
|
discord_js.Role
|
|
2743
3438
|
],
|
|
3439
|
+
[
|
|
3440
|
+
discord_js.GuildMemberRoleManager.prototype,
|
|
3441
|
+
discord_js.Role
|
|
3442
|
+
],
|
|
2744
3443
|
[
|
|
2745
3444
|
discord_js.GuildChannelManager.prototype,
|
|
2746
3445
|
discord_js.GuildChannel
|
|
@@ -2796,18 +3495,46 @@ const HOLDS = [
|
|
|
2796
3495
|
[
|
|
2797
3496
|
discord_js.PresenceManager.prototype,
|
|
2798
3497
|
discord_js.Presence
|
|
3498
|
+
],
|
|
3499
|
+
[
|
|
3500
|
+
discord_js.GuildEmojiManager.prototype,
|
|
3501
|
+
discord_js.GuildEmoji
|
|
3502
|
+
],
|
|
3503
|
+
[
|
|
3504
|
+
discord_js.GuildStickerManager.prototype,
|
|
3505
|
+
discord_js.Sticker
|
|
3506
|
+
],
|
|
3507
|
+
[
|
|
3508
|
+
discord_js.GuildScheduledEventManager.prototype,
|
|
3509
|
+
discord_js.GuildScheduledEvent
|
|
3510
|
+
],
|
|
3511
|
+
[
|
|
3512
|
+
discord_js.AutoModerationRuleManager.prototype,
|
|
3513
|
+
discord_js.AutoModerationRule
|
|
3514
|
+
],
|
|
3515
|
+
[
|
|
3516
|
+
discord_js.GuildInviteManager.prototype,
|
|
3517
|
+
discord_js.Invite
|
|
2799
3518
|
]
|
|
2800
3519
|
];
|
|
3520
|
+
/** What a manager of `prototype` holds, as discord.js's resolve() checks an item against. */ const holdsOf = (prototype)=>HOLDS.find(([Manager])=>Manager === prototype || Object.prototype.isPrototypeOf.call(Manager, prototype))?.[1];
|
|
2801
3521
|
/**
|
|
2802
3522
|
* A manager whose `cache` is a real collection of `items`, by id, empty without them, and whose methods are stubs but
|
|
2803
|
-
* for `resolve` and `resolveId`, which read the cache. Its `client` is the one its guild or channel has.
|
|
2804
|
-
|
|
3523
|
+
* for `resolve` and `resolveId`, which read the cache. Its `client` is the one its guild or channel has. `onCacheRead`,
|
|
3524
|
+
* when given, runs on each read of `cache`, before it is returned.
|
|
3525
|
+
*/ function managerWith(prototype, items, onCacheRead) {
|
|
2805
3526
|
const manager = Object.create(prototype);
|
|
2806
3527
|
const cache = new discord_js.Collection((items ?? []).map((item)=>[
|
|
2807
3528
|
String(item.id ?? item.user?.id),
|
|
2808
3529
|
item
|
|
2809
3530
|
]));
|
|
2810
|
-
|
|
3531
|
+
if (onCacheRead) {
|
|
3532
|
+
let current = cache;
|
|
3533
|
+
Object.defineProperty(manager, 'cache', {
|
|
3534
|
+
get: ()=>(onCacheRead(), current),
|
|
3535
|
+
set: (value)=>void (current = value)
|
|
3536
|
+
});
|
|
3537
|
+
} else Object.defineProperty(manager, 'cache', {
|
|
2811
3538
|
value: cache,
|
|
2812
3539
|
writable: true
|
|
2813
3540
|
});
|
|
@@ -2816,7 +3543,7 @@ const HOLDS = [
|
|
|
2816
3543
|
value: cache,
|
|
2817
3544
|
writable: true
|
|
2818
3545
|
});
|
|
2819
|
-
const holds =
|
|
3546
|
+
const holds = holdsOf(prototype);
|
|
2820
3547
|
if (holds) Object.defineProperty(manager, 'holds', {
|
|
2821
3548
|
value: holds
|
|
2822
3549
|
});
|
|
@@ -2837,13 +3564,59 @@ const HOLDS = [
|
|
|
2837
3564
|
});
|
|
2838
3565
|
return stubDeep(manager);
|
|
2839
3566
|
}
|
|
3567
|
+
// Set while the mock reads a manager's cache itself
|
|
3568
|
+
let readingOwnCache = 0;
|
|
3569
|
+
// The messages made in each channel, by its messages manager, for a fetch to find and strict mocks to cache
|
|
3570
|
+
const messagesMade = new WeakMap();
|
|
3571
|
+
const madeIn = (manager, id)=>id === undefined ? undefined : messagesMade.get(manager)?.get(id);
|
|
3572
|
+
// The message managers whose cache read has warned
|
|
3573
|
+
const uncachedWarned = new WeakSet();
|
|
3574
|
+
/**
|
|
3575
|
+
* A channel's empty messages manager. Default mode caches none of the messages made in the channel, where strict mocks
|
|
3576
|
+
* and discord.js cache each, so a test's first read of the cache warns once while one of them is missing from it.
|
|
3577
|
+
*/ function messageManager(prototype) {
|
|
3578
|
+
const manager = managerWith(prototype, undefined, ()=>{
|
|
3579
|
+
const made = messagesMade.get(manager);
|
|
3580
|
+
if (readingOwnCache > 0 || !made || strictMocks() || uncachedWarned.has(manager)) return;
|
|
3581
|
+
// Read as the mock's own, so this check doesn't call itself
|
|
3582
|
+
const cache = cacheOf(manager);
|
|
3583
|
+
if (![
|
|
3584
|
+
...made.values()
|
|
3585
|
+
].some((each)=>!cache?.has(each.id))) return;
|
|
3586
|
+
uncachedWarned.add(manager);
|
|
3587
|
+
mockLogger.warn(`${prototype.constructor.name}.cache here lacks messages made in its channel, where ` + 'discord.js caches each message as it arrives and each one the bot sends; in the next major version (5.0) the mock caches ' + 'them. Fetch the message, or call useStrictMocks() to have them cached now.');
|
|
3588
|
+
});
|
|
3589
|
+
return manager;
|
|
3590
|
+
}
|
|
3591
|
+
// The message managers whose fetch() with no id has warned
|
|
3592
|
+
const unlistedWarned = new WeakSet();
|
|
3593
|
+
/**
|
|
3594
|
+
* Warns once for a channel whose messages.fetch() with no id resolves to none in default mode, though strict mocks
|
|
3595
|
+
* would list messages there: those its cache holds, read as the mock's own, and those made or sent in it, which they
|
|
3596
|
+
* cache.
|
|
3597
|
+
*/ function warnUnlisted(manager) {
|
|
3598
|
+
if (unlistedWarned.has(manager)) return;
|
|
3599
|
+
if (!cacheOf(manager)?.size && !messagesMade.get(manager)?.size) return;
|
|
3600
|
+
unlistedWarned.add(manager);
|
|
3601
|
+
mockLogger.warn(`${Object.getPrototypeOf(manager).constructor.name}.fetch() here resolves to no ` + "messages, though messages were made, sent or cached in its channel, where discord.js lists a channel's recent " + 'messages; under useStrictMocks() fetch() lists them.');
|
|
3602
|
+
}
|
|
3603
|
+
/** Notes a message made in a channel, and caches it there under strict mocks, as discord.js caches what arrives. */ function noteMadeIn(channel, message) {
|
|
3604
|
+
if (!(channel instanceof discord_js.BaseChannel)) return;
|
|
3605
|
+
const manager = Reflect.getOwnPropertyDescriptor(channel, 'messages')?.value;
|
|
3606
|
+
const cache = manager && cacheOf(manager);
|
|
3607
|
+
if (!manager || !cache) return;
|
|
3608
|
+
if (!messagesMade.has(manager)) messagesMade.set(manager, new Map());
|
|
3609
|
+
messagesMade.get(manager).set(message.id, message);
|
|
3610
|
+
if (strictMocks()) cache.set(message.id, message);
|
|
3611
|
+
}
|
|
2840
3612
|
/** An empty manager of `prototype` that `owner` names its own, such as `{ message }` for a message's reactions. */ function ownedManager(prototype, owner) {
|
|
2841
3613
|
const manager = managerWith(prototype, undefined);
|
|
2842
3614
|
Object.assign(manager, owner);
|
|
2843
3615
|
return manager;
|
|
2844
3616
|
}
|
|
2845
3617
|
/**
|
|
2846
|
-
* Creates a mock {@link Guild}, with
|
|
3618
|
+
* Creates a mock {@link Guild}, with its `members`, `channels`, `roles`, `bans`, `emojis`, `stickers`, `scheduledEvents`,
|
|
3619
|
+
* `autoModerationRules` and `invites` managers ready to stub.
|
|
2847
3620
|
*
|
|
2848
3621
|
* Use it for the server a message or an interaction came from, with the members, roles and channels a handler looks
|
|
2849
3622
|
* up in it.
|
|
@@ -2853,14 +3626,18 @@ const HOLDS = [
|
|
|
2853
3626
|
*
|
|
2854
3627
|
* @remarks
|
|
2855
3628
|
* 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
|
|
2856
|
-
* caches under that id: a member or
|
|
2857
|
-
*
|
|
2858
|
-
*
|
|
2859
|
-
*
|
|
2860
|
-
*
|
|
2861
|
-
*
|
|
2862
|
-
*
|
|
2863
|
-
*
|
|
3629
|
+
* caches under that id: a member, channel, emoji, sticker, scheduled event or AutoMod rule in this guild, a role with
|
|
3630
|
+
* that id, a ban, or an invite with that code. `members.fetch({ user: ids })` resolves to a collection of each of those
|
|
3631
|
+
* members, found or made the same way; `create()` resolves to a mock of its item, a channel of the type its options
|
|
3632
|
+
* name under `useStrictMocks()`, which it caches as discord.js does, but for an invite, made for the channel given and
|
|
3633
|
+
* cached nowhere; `edit()` resolves under `useStrictMocks()` to the item with the id given, found or made the same way,
|
|
3634
|
+
* and under `useStrictMocks()` either takes the `name` its options give. A list fetch resolves to an empty collection.
|
|
3635
|
+
* Members, roles and channels given are put in their managers' caches, where dispatch looks first when it resolves a
|
|
3636
|
+
* message's typed params; a member {@link createMockMember} made without a server is in this one. Its `roles.everyone`
|
|
3637
|
+
* is the role given with the guild's id, or else an @everyone role of its own at position 0, in `roles.cache` as
|
|
3638
|
+
* Discord has it. That role has no permissions in default mode; under `useStrictMocks()` it has the ones Discord
|
|
3639
|
+
* gives @everyone in a new server. The guild is named `'Guild'` and its `preferredLocale` is `'en-US'` unless given,
|
|
3640
|
+
* so `t.forGuild(guild)` translates as for a new English server.
|
|
2864
3641
|
*
|
|
2865
3642
|
* The mock makes a member for any id it's asked for, so a test of an id that isn't a member rejects the fetch, as
|
|
2866
3643
|
* `guild.members.fetch.mockRejectedValue(createDiscordError(10007))`, or resolves a collection without that member.
|
|
@@ -2886,7 +3663,7 @@ const HOLDS = [
|
|
|
2886
3663
|
instance.members = managerWith(discord_js.GuildMemberManager.prototype, overrides.members);
|
|
2887
3664
|
instance.channels = managerWith(discord_js.GuildChannelManager.prototype, overrides.channels);
|
|
2888
3665
|
instance.roles = guildRoleManager(instance.id, overrides.roles);
|
|
2889
|
-
instance
|
|
3666
|
+
for (const [key, Manager] of GUILD_MANAGERS)instance[key] = managerWith(Manager.prototype, undefined);
|
|
2890
3667
|
const guild = stubDeep(instance);
|
|
2891
3668
|
settleGuild(instance, guild);
|
|
2892
3669
|
// A member made without a server of its own is in this one
|
|
@@ -2930,7 +3707,7 @@ const HOLDS = [
|
|
|
2930
3707
|
const is = (Base)=>Base.prototype.isPrototypeOf(Class.prototype) || Class === Base;
|
|
2931
3708
|
// Text and announcement channels: messages, and threads made in the channel
|
|
2932
3709
|
if (is(discord_js.TextChannel) || is(discord_js.NewsChannel)) {
|
|
2933
|
-
instance.messages =
|
|
3710
|
+
instance.messages = messageManager(discord_js.GuildMessageManager.prototype);
|
|
2934
3711
|
instance.threads = managerWith(discord_js.GuildTextThreadManager.prototype, undefined);
|
|
2935
3712
|
}
|
|
2936
3713
|
// Forum and media channels hold posts, each a thread started with its first message
|
|
@@ -2940,15 +3717,15 @@ const HOLDS = [
|
|
|
2940
3717
|
}
|
|
2941
3718
|
// Voice and stage channels carry a text chat of their own, and a bitrate, which discord.js tells them apart by
|
|
2942
3719
|
if (is(discord_js.BaseGuildVoiceChannel)) {
|
|
2943
|
-
instance.messages =
|
|
3720
|
+
instance.messages = messageManager(discord_js.GuildMessageManager.prototype);
|
|
2944
3721
|
instance.bitrate = 64000;
|
|
2945
3722
|
instance.userLimit = 0;
|
|
2946
3723
|
}
|
|
2947
3724
|
if (is(discord_js.DMChannel)) {
|
|
2948
|
-
instance.messages =
|
|
3725
|
+
instance.messages = messageManager(discord_js.DMMessageManager.prototype);
|
|
2949
3726
|
}
|
|
2950
3727
|
if (is(discord_js.ThreadChannel)) {
|
|
2951
|
-
instance.messages =
|
|
3728
|
+
instance.messages = messageManager(discord_js.GuildMessageManager.prototype);
|
|
2952
3729
|
instance.members = managerWith(discord_js.ThreadMemberManager.prototype, undefined);
|
|
2953
3730
|
}
|
|
2954
3731
|
// A server's channel has its overwrites, none until a test puts some in the cache, which its permissions read
|
|
@@ -3053,6 +3830,7 @@ const CHANNEL_TYPE_GUARDS = [
|
|
|
3053
3830
|
id: entry
|
|
3054
3831
|
}) : entry);
|
|
3055
3832
|
const manager = Object.create(discord_js.GuildMemberRoleManager.prototype);
|
|
3833
|
+
// Its member's server and client, as discord.js's constructor sets them, and what it holds, so resolve() reads it
|
|
3056
3834
|
Object.defineProperties(manager, {
|
|
3057
3835
|
cache: {
|
|
3058
3836
|
get: cache
|
|
@@ -3060,6 +3838,15 @@ const CHANNEL_TYPE_GUARDS = [
|
|
|
3060
3838
|
member: {
|
|
3061
3839
|
value: member
|
|
3062
3840
|
},
|
|
3841
|
+
guild: {
|
|
3842
|
+
get: ()=>member.guild
|
|
3843
|
+
},
|
|
3844
|
+
client: {
|
|
3845
|
+
get: ()=>member.client
|
|
3846
|
+
},
|
|
3847
|
+
holds: {
|
|
3848
|
+
value: holdsOf(discord_js.GuildMemberRoleManager.prototype)
|
|
3849
|
+
},
|
|
3063
3850
|
highest: {
|
|
3064
3851
|
get: highest
|
|
3065
3852
|
}
|
|
@@ -3384,7 +4171,7 @@ let placeReadsSeen;
|
|
|
3384
4171
|
"Give the member's guild too, or call useStrictMocks() to place it there now."
|
|
3385
4172
|
],
|
|
3386
4173
|
guildId: [
|
|
3387
|
-
'a server
|
|
4174
|
+
'a placeholder server with that id, though the mock was given only a guildId',
|
|
3388
4175
|
"a guildId alone is a server the bot isn't in: guild and channel are null, and the member is a raw one",
|
|
3389
4176
|
'Give a guild too, or call useStrictMocks() to read it so now.'
|
|
3390
4177
|
]
|
|
@@ -3510,7 +4297,7 @@ let placeReadsSeen;
|
|
|
3510
4297
|
guild.members = managerWith(discord_js.GuildMemberManager.prototype, undefined);
|
|
3511
4298
|
guild.channels = managerWith(discord_js.GuildChannelManager.prototype, undefined);
|
|
3512
4299
|
guild.roles = guildRoleManager(guild.id, undefined);
|
|
3513
|
-
guild
|
|
4300
|
+
for (const [key, Manager] of GUILD_MANAGERS)guild[key] = managerWith(Manager.prototype, undefined);
|
|
3514
4301
|
const proxy = stubDeep(guild);
|
|
3515
4302
|
settleGuild(guild, proxy);
|
|
3516
4303
|
return proxy;
|
|
@@ -3532,12 +4319,39 @@ const botMembers = new WeakMap();
|
|
|
3532
4319
|
return guildId ? cacheOf(guild.channels)?.get(channelId) : null;
|
|
3533
4320
|
});
|
|
3534
4321
|
}
|
|
4322
|
+
// A guild's managers beside members, channels and roles, each empty, of its discord.js class
|
|
4323
|
+
const GUILD_MANAGERS = [
|
|
4324
|
+
[
|
|
4325
|
+
'bans',
|
|
4326
|
+
discord_js.GuildBanManager
|
|
4327
|
+
],
|
|
4328
|
+
[
|
|
4329
|
+
'emojis',
|
|
4330
|
+
discord_js.GuildEmojiManager
|
|
4331
|
+
],
|
|
4332
|
+
[
|
|
4333
|
+
'stickers',
|
|
4334
|
+
discord_js.GuildStickerManager
|
|
4335
|
+
],
|
|
4336
|
+
[
|
|
4337
|
+
'scheduledEvents',
|
|
4338
|
+
discord_js.GuildScheduledEventManager
|
|
4339
|
+
],
|
|
4340
|
+
[
|
|
4341
|
+
'autoModerationRules',
|
|
4342
|
+
discord_js.AutoModerationRuleManager
|
|
4343
|
+
],
|
|
4344
|
+
[
|
|
4345
|
+
'invites',
|
|
4346
|
+
discord_js.GuildInviteManager
|
|
4347
|
+
]
|
|
4348
|
+
];
|
|
3535
4349
|
/** 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) {
|
|
3536
4350
|
for (const key of [
|
|
3537
4351
|
'members',
|
|
3538
4352
|
'channels',
|
|
3539
4353
|
'roles',
|
|
3540
|
-
|
|
4354
|
+
...GUILD_MANAGERS.map(([key])=>key)
|
|
3541
4355
|
])instance[key].guild = guild;
|
|
3542
4356
|
}
|
|
3543
4357
|
/**
|
|
@@ -3586,9 +4400,38 @@ const botMembers = new WeakMap();
|
|
|
3586
4400
|
};
|
|
3587
4401
|
}
|
|
3588
4402
|
/** A cache's collection, or undefined for a stub a test put in place of the manager. */ const cacheOf = (manager)=>{
|
|
3589
|
-
|
|
3590
|
-
|
|
4403
|
+
// The mock's own read, which a message cache's warning leaves to the test's reads
|
|
4404
|
+
readingOwnCache++;
|
|
4405
|
+
try {
|
|
4406
|
+
const cache = manager?.cache;
|
|
4407
|
+
return cache instanceof discord_js.Collection ? cache : undefined;
|
|
4408
|
+
} finally{
|
|
4409
|
+
readingOwnCache--;
|
|
4410
|
+
}
|
|
3591
4411
|
};
|
|
4412
|
+
// The managers of a server whose caches a placeholder one holds empty
|
|
4413
|
+
const PLACEHOLDER_GUILD_MANAGERS = [
|
|
4414
|
+
'members',
|
|
4415
|
+
'roles',
|
|
4416
|
+
'channels',
|
|
4417
|
+
'emojis',
|
|
4418
|
+
'stickers',
|
|
4419
|
+
'bans',
|
|
4420
|
+
'invites',
|
|
4421
|
+
'scheduledEvents',
|
|
4422
|
+
'autoModerationRules'
|
|
4423
|
+
];
|
|
4424
|
+
/** A server default mode reads for a guildId alone: no `Guild`, so it answers no server checks, but with that id and empty caches. */ function guildPlaceholder(id) {
|
|
4425
|
+
return stubDeep({
|
|
4426
|
+
id,
|
|
4427
|
+
...Object.fromEntries(PLACEHOLDER_GUILD_MANAGERS.map((key)=>[
|
|
4428
|
+
key,
|
|
4429
|
+
stubDeep({
|
|
4430
|
+
cache: new discord_js.Collection()
|
|
4431
|
+
})
|
|
4432
|
+
]))
|
|
4433
|
+
});
|
|
4434
|
+
}
|
|
3592
4435
|
/** The cached item under `id`, put there by `make` when the cache lacks it. */ function cached(cache, id, make) {
|
|
3593
4436
|
const existing = cache?.get(id);
|
|
3594
4437
|
if (existing !== undefined) return existing;
|
|
@@ -3616,19 +4459,22 @@ const botMembers = new WeakMap();
|
|
|
3616
4459
|
id,
|
|
3617
4460
|
cached(guildCaches.members, id, ()=>memberIn(guild, id, cached(userCache, id, ()=>mockUser(id))))
|
|
3618
4461
|
])) : null,
|
|
4462
|
+
// Full mocks, as the gateway delivers them: a role of the server, and a channel with its managers and overwrites
|
|
3619
4463
|
roles: new discord_js.Collection(guildCaches ? roles.map((id)=>[
|
|
3620
4464
|
id,
|
|
3621
|
-
cached(guildCaches.roles, id, ()=>
|
|
4465
|
+
cached(guildCaches.roles, id, ()=>createMockInteraction(discord_js.Role, {
|
|
3622
4466
|
id,
|
|
3623
4467
|
guild
|
|
3624
|
-
}))
|
|
4468
|
+
}))
|
|
3625
4469
|
]) : []),
|
|
3626
4470
|
channels: new discord_js.Collection(channels.map((id)=>[
|
|
3627
4471
|
id,
|
|
3628
|
-
cached(guildCaches ? guildCaches.channels : cacheOf(client.channels), id, ()=>
|
|
4472
|
+
cached(guildCaches ? guildCaches.channels : cacheOf(client.channels), id, ()=>createMockChannel(discord_js.TextChannel, guild ? {
|
|
3629
4473
|
id,
|
|
3630
4474
|
guild
|
|
3631
|
-
}
|
|
4475
|
+
} : {
|
|
4476
|
+
id
|
|
4477
|
+
}))
|
|
3632
4478
|
]))
|
|
3633
4479
|
};
|
|
3634
4480
|
// Own values, so the prototype's getters, which read the raw mention data, are not reached
|
|
@@ -3911,6 +4757,7 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
|
|
|
3911
4757
|
}));
|
|
3912
4758
|
if (sentBy) warnOnSentReads(instance, stubs, sentBy);
|
|
3913
4759
|
const message = stubDeep(instance, stubs);
|
|
4760
|
+
if (channel) noteMadeIn(channel, message);
|
|
3914
4761
|
return message;
|
|
3915
4762
|
}
|
|
3916
4763
|
/**
|
|
@@ -3959,7 +4806,8 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
|
|
|
3959
4806
|
* was sent. Under strict mocks it is the bot's `client.user`'s, and one sent in a DM is in that DM; default mode places
|
|
3960
4807
|
* a DM send in a server of its own and keeps another author, with a warning when read.
|
|
3961
4808
|
*/ function sentMessage(held, channel, client, fields = {}, made) {
|
|
3962
|
-
|
|
4809
|
+
// The mock's own read of where the message was sent
|
|
4810
|
+
const by = client ?? readPlaceQuietly(()=>channel?.client);
|
|
3963
4811
|
const inChannel = channel instanceof discord_js.BaseChannel;
|
|
3964
4812
|
const dm = inChannel && channel.isDMBased();
|
|
3965
4813
|
const strict = strictMocks();
|
|
@@ -4000,6 +4848,11 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
|
|
|
4000
4848
|
if (typeof value === 'object' && value !== null) return discord_js.ApplicationCommandOptionType.Mentionable;
|
|
4001
4849
|
return discord_js.ApplicationCommandOptionType.String;
|
|
4002
4850
|
}
|
|
4851
|
+
/** The option types a user types into, which an autocomplete's focused option is one of. */ const TYPED = [
|
|
4852
|
+
discord_js.ApplicationCommandOptionType.String,
|
|
4853
|
+
discord_js.ApplicationCommandOptionType.Integer,
|
|
4854
|
+
discord_js.ApplicationCommandOptionType.Number
|
|
4855
|
+
];
|
|
4003
4856
|
/** The discord.js classes an entity option's value is an instance of, each read by its own getters. */ const ENTITY_CLASSES = [
|
|
4004
4857
|
discord_js.User,
|
|
4005
4858
|
discord_js.GuildMember,
|
|
@@ -4052,7 +4905,7 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
|
|
|
4052
4905
|
* An entity option arrives as a snowflake in `value` *and* as the resolved object on
|
|
4053
4906
|
* its own field, and code that reads only one of the two is exactly what this lets a
|
|
4054
4907
|
* test catch — so both are set. A user option has its user, and in a server its member too.
|
|
4055
|
-
*/ function toOptionData(name, value, memberOf) {
|
|
4908
|
+
*/ function toOptionData(name, value, memberOf, unresolved) {
|
|
4056
4909
|
const type = optionTypeOf(value);
|
|
4057
4910
|
const isEntity = typeof value === 'object' && value !== null;
|
|
4058
4911
|
const option = {
|
|
@@ -4065,7 +4918,8 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
|
|
|
4065
4918
|
// Read once the options are given to an interaction, which says whether it was made in a server
|
|
4066
4919
|
if (memberOf) Object.defineProperty(option, 'member', {
|
|
4067
4920
|
get: ()=>memberOf(name, value) ?? undefined,
|
|
4068
|
-
enumerable: true
|
|
4921
|
+
enumerable: true,
|
|
4922
|
+
configurable: true
|
|
4069
4923
|
});
|
|
4070
4924
|
} else if (value instanceof discord_js.GuildMember) {
|
|
4071
4925
|
option.user = value.user;
|
|
@@ -4074,6 +4928,25 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
|
|
|
4074
4928
|
else if (value instanceof discord_js.BaseChannel) option.channel = value;
|
|
4075
4929
|
else if (value instanceof discord_js.Attachment) option.attachment = value;
|
|
4076
4930
|
else if (isEntity) option.user = value;
|
|
4931
|
+
// An autocomplete's entity option is its id alone, as Discord sends it, when the options belong to one
|
|
4932
|
+
if (unresolved) {
|
|
4933
|
+
for (const field of [
|
|
4934
|
+
'user',
|
|
4935
|
+
'member',
|
|
4936
|
+
'role',
|
|
4937
|
+
'channel',
|
|
4938
|
+
'attachment'
|
|
4939
|
+
]){
|
|
4940
|
+
const descriptor = Object.getOwnPropertyDescriptor(option, field);
|
|
4941
|
+
if (!descriptor) continue;
|
|
4942
|
+
const read = descriptor.get ?? (()=>descriptor.value);
|
|
4943
|
+
Object.defineProperty(option, field, {
|
|
4944
|
+
get: ()=>unresolved() ? undefined : read(),
|
|
4945
|
+
enumerable: true,
|
|
4946
|
+
configurable: true
|
|
4947
|
+
});
|
|
4948
|
+
}
|
|
4949
|
+
}
|
|
4077
4950
|
return option;
|
|
4078
4951
|
}
|
|
4079
4952
|
/**
|
|
@@ -4099,8 +4972,8 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
|
|
|
4099
4972
|
/**
|
|
4100
4973
|
* Nests the supplied options under the subcommand path they were invoked through,
|
|
4101
4974
|
* matching the shape Discord sends rather than a flat list.
|
|
4102
|
-
*/ function buildOptionData(subcommandGroup, subcommand, values, memberOf, focused) {
|
|
4103
|
-
const leaves = Object.entries(values).map(([name, value])=>name === focused ? focusedOptionData(name, value) : toOptionData(name, value, memberOf));
|
|
4975
|
+
*/ function buildOptionData(subcommandGroup, subcommand, values, memberOf, focused, unresolved) {
|
|
4976
|
+
const leaves = Object.entries(values).map(([name, value])=>name === focused ? focusedOptionData(name, value) : toOptionData(name, value, memberOf, unresolved));
|
|
4104
4977
|
if (focused !== null && !Object.hasOwn(values, focused)) leaves.push(focusedOptionData(focused, null));
|
|
4105
4978
|
if (subcommand === null) return leaves;
|
|
4106
4979
|
const sub = {
|
|
@@ -4135,7 +5008,8 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
|
|
|
4135
5008
|
* entity option carries its id in `value` and the object itself, as the gateway sends it: a user option its `user`,
|
|
4136
5009
|
* and in a server its `member` too. A user option's
|
|
4137
5010
|
* `getMember()` is the user's member in the server of the interaction the options are given to, and `null` in a DM;
|
|
4138
|
-
* a member given resolves `getUser()` to its user.
|
|
5011
|
+
* a member given resolves `getUser()` to its user. Under `useStrictMocks()` a user option's `getMentionable()` is its
|
|
5012
|
+
* member too where there is one, as discord.js reads it; default mode returns the user, with a warning once. A getter throws discord.js's type error for an option of another
|
|
4139
5013
|
* type, as discord.js does, required or not. Only a user or member read as a role, or a role read as a user or member,
|
|
4140
5014
|
* is `null`, or that error when required, as the option may be a mentionable one; a plain `{ id }` reads as any entity.
|
|
4141
5015
|
* A whole number is an Integer option and a fraction a Number one, so
|
|
@@ -4143,7 +5017,13 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
|
|
|
4143
5017
|
*
|
|
4144
5018
|
* The `focused` option reads as Discord sends it, through `getFocused()`, `getFocused(true)` and `data` alike: a
|
|
4145
5019
|
* string, `''` when no value is given, and marked `focused` in `data`. A number given for it is its digits under
|
|
4146
|
-
* `useStrictMocks()`; otherwise it stays the number, with a warning when read.
|
|
5020
|
+
* `useStrictMocks()`; otherwise it stays the number, with a warning when read. Under `useStrictMocks()` `get()` and the
|
|
5021
|
+
* typed getters read it by name from that same option, as discord.js does; one given no value takes the string,
|
|
5022
|
+
* integer and number getters alike. Default mode reads what was given, `null` for no value, with a warning once.
|
|
5023
|
+
*
|
|
5024
|
+
* Given to an `AutocompleteInteraction`, a user, member, role, channel, mentionable or attachment option reads as
|
|
5025
|
+
* Discord sends an autocomplete's options under `useStrictMocks()`: its id alone, in `data` and the handler's params,
|
|
5026
|
+
* with the entity getters returning `null`. Otherwise it keeps the object given, with a warning once.
|
|
4147
5027
|
*
|
|
4148
5028
|
* @typeParam Cached - `any` by default, to match `createMockInteraction(ChatInputCommandInteraction)`; pass it when the
|
|
4149
5029
|
* interaction under test is cache-pinned.
|
|
@@ -4165,6 +5045,10 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
|
|
|
4165
5045
|
* @see {@link ChatInputOptions}
|
|
4166
5046
|
*/ function createChatInputOptions(opts = {}) {
|
|
4167
5047
|
const { subcommandGroup = null, subcommand = null, focused = null, ...values } = opts;
|
|
5048
|
+
// The option the user is typing, as Discord sent it among the others, which every getter reads in discord.js
|
|
5049
|
+
const focusedOption = ()=>base._hoistedOptions.find((each)=>each.focused);
|
|
5050
|
+
// Default mode reads the focused option from what the test gave, and says once what discord.js reads instead
|
|
5051
|
+
const warnFocused = (name)=>controller_decorator.warnOnce(mockLogger, `The focused option "${name}" is read here from what the test gave, where discord.js reads the option Discord sent: ` + "marked focused, its value a string ('' before anything is typed). In the next major version (5.0) the mock reads " + 'it so; call useStrictMocks() to have it read so now.');
|
|
4168
5052
|
function resolveOrThrow(name, value, required) {
|
|
4169
5053
|
{
|
|
4170
5054
|
if (required === true) throw optionError(discord_js.DiscordjsErrorCodes.CommandInteractionOptionNotFound, name);
|
|
@@ -4181,14 +5065,36 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
|
|
|
4181
5065
|
// As in discord.js, a subcommand is required unless told otherwise, and a group is not
|
|
4182
5066
|
base.getSubcommandGroup = createMockFn((required = false)=>resolveSubEntry(subcommandGroup, discord_js.DiscordjsErrorCodes.CommandInteractionOptionNoSubcommandGroup, required));
|
|
4183
5067
|
base.getSubcommand = createMockFn((required = true)=>resolveSubEntry(subcommand, discord_js.DiscordjsErrorCodes.CommandInteractionOptionNoSubcommand, required));
|
|
5068
|
+
// Under strict mocks, options given to an autocomplete hold their entities as ids alone, as Discord sends them
|
|
5069
|
+
const unresolved = ()=>strictMocks() && ownedByAutocomplete(resolver);
|
|
4184
5070
|
// As discord.js reads an option by its type: another type throws its type error, required or not. A value whose
|
|
4185
5071
|
// option may be of the getter's type, as a user may be a mentionable option's, reads as null if the getter doesn't
|
|
4186
5072
|
// take it, and throws when required
|
|
4187
5073
|
const readOption = (name, expected, takes, required)=>{
|
|
5074
|
+
if (name === focused && strictMocks()) {
|
|
5075
|
+
// discord.js reads the focused option as Discord sent it: a string, its own type kept. One given no value may be
|
|
5076
|
+
// any type the user can type into, as the mock doesn't know which it was declared as
|
|
5077
|
+
const option = focusedOption();
|
|
5078
|
+
const types = values[name] === undefined || values[name] === null ? TYPED : [
|
|
5079
|
+
option.type
|
|
5080
|
+
];
|
|
5081
|
+
if (!types.some((type)=>expected.includes(type))) {
|
|
5082
|
+
throw optionError(discord_js.DiscordjsErrorCodes.CommandInteractionOptionType, name, option.type, expected.join(', '));
|
|
5083
|
+
}
|
|
5084
|
+
return option.value;
|
|
5085
|
+
}
|
|
4188
5086
|
const value = values[name];
|
|
5087
|
+
if (name === focused && (value === undefined || value === null)) warnFocused(name);
|
|
5088
|
+
// Read through the option Discord sent, whose number warns where discord.js reads a string
|
|
5089
|
+
if (name === focused && typeof value === 'number') void focusedOption().value;
|
|
4189
5090
|
if (value === undefined || value === null) return resolveOrThrow(name, null, required);
|
|
4190
5091
|
const typeError = ()=>optionError(discord_js.DiscordjsErrorCodes.CommandInteractionOptionType, name, optionTypeOf(value), expected.join(', '));
|
|
4191
5092
|
if (!possibleTypesOf(value).some((type)=>expected.includes(type))) throw typeError();
|
|
5093
|
+
// An entity in an autocomplete's options is its id alone, so a getter finds nothing, as in discord.js
|
|
5094
|
+
if (typeof value === 'object' && unresolved()) {
|
|
5095
|
+
if (required === true) throw optionError(discord_js.DiscordjsErrorCodes.CommandInteractionOptionEmpty, name, optionTypeOf(value));
|
|
5096
|
+
return null;
|
|
5097
|
+
}
|
|
4192
5098
|
if (takes(value)) return value;
|
|
4193
5099
|
if (required === true) throw typeError();
|
|
4194
5100
|
return null;
|
|
@@ -4249,15 +5155,25 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
|
|
|
4249
5155
|
], plainOr(discord_js.User, discord_js.GuildMember));
|
|
4250
5156
|
return value instanceof discord_js.User ? memberOf(name, value) : value;
|
|
4251
5157
|
});
|
|
4252
|
-
|
|
5158
|
+
// A user's member in a server, as discord.js reads `member ?? user`; default mode keeps the user, with a warning
|
|
5159
|
+
base.getMentionable = createMockFn((name, required)=>{
|
|
5160
|
+
const value = readOption(name, [
|
|
4253
5161
|
MENTIONABLE
|
|
4254
|
-
], plainOr(discord_js.User, discord_js.GuildMember, discord_js.Role), required)
|
|
5162
|
+
], plainOr(discord_js.User, discord_js.GuildMember, discord_js.Role), required);
|
|
5163
|
+
if (!(value instanceof discord_js.User)) return value;
|
|
5164
|
+
const member = memberOf(name, value);
|
|
5165
|
+
if (member === null || strictMocks()) return member ?? value;
|
|
5166
|
+
controller_decorator.warnOnce(mockLogger, `getMentionable('${name}') returns the user here, where discord.js returns the user's member in a server; in the next ` + 'major version (5.0) the mock returns the member. Call useStrictMocks() to have it returned now.');
|
|
5167
|
+
return value;
|
|
5168
|
+
});
|
|
4255
5169
|
base.getAttachment = createMockFn((name, required)=>readOption(name, [
|
|
4256
5170
|
ATTACHMENT
|
|
4257
5171
|
], plainOr(discord_js.Attachment), required));
|
|
4258
5172
|
base.get = createMockFn((name, required)=>{
|
|
5173
|
+
if (name === focused && strictMocks()) return focusedOption();
|
|
5174
|
+
if (name === focused) warnFocused(name);
|
|
4259
5175
|
const value = values[name];
|
|
4260
|
-
return value === undefined || value === null ? resolveOrThrow(name, null, required) : toOptionData(name, value, memberOf);
|
|
5176
|
+
return value === undefined || value === null ? resolveOrThrow(name, null, required) : toOptionData(name, value, memberOf, unresolved);
|
|
4261
5177
|
});
|
|
4262
5178
|
// A message option is a message context menu's own, never a slash command's
|
|
4263
5179
|
base.getMessage = createMockFn((name, required)=>readOption(name, [
|
|
@@ -4266,21 +5182,216 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
|
|
|
4266
5182
|
base.getFocused = createMockFn((getFull)=>{
|
|
4267
5183
|
if (focused === null) throw optionError(discord_js.DiscordjsErrorCodes.AutocompleteInteractionOptionNoFocusedOption);
|
|
4268
5184
|
// The one options.data holds, so the two agree
|
|
4269
|
-
const option =
|
|
5185
|
+
const option = focusedOption();
|
|
4270
5186
|
return getFull === true ? {
|
|
4271
5187
|
...option
|
|
4272
5188
|
} : option.value;
|
|
4273
5189
|
});
|
|
4274
5190
|
// `data` is what the framework reads to build a handler's params, so it is materialised here rather than
|
|
4275
5191
|
// auto-stubbed, or every params assertion would see an empty record.
|
|
4276
|
-
base.data = buildOptionData(subcommandGroup, subcommand, values, memberOf, focused);
|
|
5192
|
+
base.data = buildOptionData(subcommandGroup, subcommand, values, memberOf, focused, unresolved);
|
|
4277
5193
|
// The fields discord.js's constructor sets from it, which its toString() reads
|
|
4278
5194
|
base._group = subcommandGroup;
|
|
4279
5195
|
base._subcommand = subcommand;
|
|
4280
5196
|
base._hoistedOptions = subcommand === null ? base.data : (subcommandGroup === null ? base.data[0] : base.data[0].options[0]).options;
|
|
4281
5197
|
const resolver = stubDeep(base);
|
|
5198
|
+
const entities = Object.entries(values).filter(([name, value])=>name !== focused && typeof value === 'object' && value !== null);
|
|
5199
|
+
optionEntities.set(resolver, entities.map(([name])=>name));
|
|
4282
5200
|
return resolver;
|
|
4283
5201
|
}
|
|
5202
|
+
// What a mock reads for a property the test gave none, made with this module's factories and helpers. Built last, as it
|
|
5203
|
+
// reads them, and only read once a mock is made
|
|
5204
|
+
const discordDefault = discordDefaults({
|
|
5205
|
+
botMemberOf,
|
|
5206
|
+
createChatInputOptions,
|
|
5207
|
+
createMockChannel,
|
|
5208
|
+
createMockGuild,
|
|
5209
|
+
createMockMessage,
|
|
5210
|
+
createMockUser,
|
|
5211
|
+
heldChannelOf,
|
|
5212
|
+
isRawMember,
|
|
5213
|
+
isUnsetEveryone,
|
|
5214
|
+
madeAt,
|
|
5215
|
+
memberRoles,
|
|
5216
|
+
NEW_SERVER_EVERYONE_PERMISSIONS,
|
|
5217
|
+
ownedManager,
|
|
5218
|
+
readPlaceAs,
|
|
5219
|
+
readPlaceQuietly
|
|
5220
|
+
});
|
|
5221
|
+
|
|
5222
|
+
const logger = new controller_decorator.Logger('resolveRoute');
|
|
5223
|
+
/** Why an app import reads `undefined`, for its refusal. */ const CIRCULAR_IMPORT_HINT = ' An app import that reads undefined is often a circular import.';
|
|
5224
|
+
/** Why an app is an object, for its refusal. */ const MODULE_HINT = ' An object here is often a module namespace, as `import * as App` gives: pass the class it exports.';
|
|
5225
|
+
/** An app that isn't a class, as its refusal names it: the value, and its likely cause where there is one. */ function notAClass(app) {
|
|
5226
|
+
const hint = app === undefined ? CIRCULAR_IMPORT_HINT : typeof app === 'object' && app !== null ? MODULE_HINT : '';
|
|
5227
|
+
return {
|
|
5228
|
+
name: controller_decorator.describeValue(app),
|
|
5229
|
+
hint
|
|
5230
|
+
};
|
|
5231
|
+
}
|
|
5232
|
+
/** Whether a message gives a route's `member`, `role` or `channel` flag, which dispatch refuses in a DM. */ const givesGuildFlag = (route, params)=>route.flags.some((flag)=>flag.type !== undefined && providers.isGuildType(flag.type) && Object.hasOwn(params, flag.flag));
|
|
5233
|
+
/** The options `@MeoCord` declares on an application class. */ function appOptionsOf(app) {
|
|
5234
|
+
// An app that isn't a class at all, as a circular import gives, has no metadata to read
|
|
5235
|
+
if (typeof app !== 'function') {
|
|
5236
|
+
const { name, hint } = notAClass(app);
|
|
5237
|
+
throw new TypeError(`${name} is not decorated with @MeoCord().${hint}`);
|
|
5238
|
+
}
|
|
5239
|
+
const options = Reflect.getMetadata(controller_decorator.META.appOptions, app);
|
|
5240
|
+
if (!options) throw new TypeError(`${app.name || 'The given class'} is not decorated with @MeoCord().`);
|
|
5241
|
+
return options;
|
|
5242
|
+
}
|
|
5243
|
+
/** The controllers `@MeoCord({ controllers })` registers on an application class. */ function controllersOf(app) {
|
|
5244
|
+
return appOptionsOf(app).controllers ?? [];
|
|
5245
|
+
}
|
|
5246
|
+
/**
|
|
5247
|
+
* Resolves which handler a component's `customId` or a message's content reaches, as dispatch routes it.
|
|
5248
|
+
*
|
|
5249
|
+
* Use it to check routing alone, in a plain unit test: it reads decorator metadata, with no client, config or
|
|
5250
|
+
* container, and runs no guard. To run the handler it reaches, use {@link TestingModule.dispatch}.
|
|
5251
|
+
*
|
|
5252
|
+
* @remarks
|
|
5253
|
+
* It routes across every controller the app registers, most specific pattern first: a component within its type, a
|
|
5254
|
+
* message after the app's prefix, and never to a `@MessageHandler()` listener, which runs for every message.
|
|
5255
|
+
*
|
|
5256
|
+
* @param app - The application class decorated with `@MeoCord`.
|
|
5257
|
+
* @param input - The component type and its `customId`, or the message's content with its prefix and the bot's id.
|
|
5258
|
+
* @returns The handler that runs, with the params it captures, or `undefined` when no route handles the input.
|
|
5259
|
+
* @throws TypeError for a message to an app whose prefix is a function, when no `prefix` is given.
|
|
5260
|
+
*
|
|
5261
|
+
* @example
|
|
5262
|
+
* ```ts
|
|
5263
|
+
* import { expect } from 'vitest'
|
|
5264
|
+
*
|
|
5265
|
+
* @Controller()
|
|
5266
|
+
* class ProfileController {
|
|
5267
|
+
* @Command('profile/{uid}', CommandType.BUTTON)
|
|
5268
|
+
* async show(interaction: ButtonInteraction, { uid }: { uid: string }) {
|
|
5269
|
+
* await respond(interaction).send(`Profile ${uid}`)
|
|
5270
|
+
* }
|
|
5271
|
+
* }
|
|
5272
|
+
* @MeoCord({ controllers: [ProfileController], clientOptions: { intents: [] } })
|
|
5273
|
+
* class App {}
|
|
5274
|
+
* const route = resolveRoute(App, { type: CommandType.BUTTON, customId: 'profile/8000' })
|
|
5275
|
+
* expect([route?.handler, route?.params]).toEqual([ProfileController.prototype.show, { uid: '8000' }])
|
|
5276
|
+
* ```
|
|
5277
|
+
*
|
|
5278
|
+
* @group Testing
|
|
5279
|
+
* @category Inspection
|
|
5280
|
+
* @see {@link findRouteConflicts}
|
|
5281
|
+
* @see {@link https://meocord.dev/docs/4.2/components | Buttons, selects and modals}
|
|
5282
|
+
*/ function resolveRoute(app, input) {
|
|
5283
|
+
if ('content' in input) {
|
|
5284
|
+
const { messages = {} } = appOptionsOf(app);
|
|
5285
|
+
const starts = providers.staticMessageStarts(app, messages, input);
|
|
5286
|
+
const routes = providers.buildMessageRoutes(controllersOf(app), messages);
|
|
5287
|
+
let matched = providers.matchMessageRoute(routes, input.content, starts);
|
|
5288
|
+
const named = (route)=>`${route.controllerClass.name}.${String(route.method)}`;
|
|
5289
|
+
// Without `dm`: under strict mocks, read as sent in a server, as dispatch routes such a message whatever the
|
|
5290
|
+
// controllers' order; otherwise as from anywhere, warning where that answers differently
|
|
5291
|
+
if (starts.inGuild === undefined) {
|
|
5292
|
+
const inServer = providers.matchMessageRoute(routes, input.content, {
|
|
5293
|
+
...starts,
|
|
5294
|
+
inGuild: true
|
|
5295
|
+
});
|
|
5296
|
+
if (strictMocks()) matched = inServer && providers.fitsScope(inServer.route.scope, true) ? inServer : undefined;
|
|
5297
|
+
else if (matched && inServer && matched.route !== inServer.route && !providers.fitsScope(matched.route.scope, true)) {
|
|
5298
|
+
controller_decorator.warnOnce(logger, `resolveRoute resolved a message without dm to ${named(matched.route)}, which works in a DM alone, where dispatch runs ` + `${named(inServer.route)} for a message sent in a server. Pass dm: true for a DM. Under useStrictMocks() it resolves to ` + `${named(inServer.route)}.`);
|
|
5299
|
+
} else if (matched && !providers.fitsScope(matched.route.scope, true)) {
|
|
5300
|
+
// Only a handler that works in a DM alone takes it, which dispatch runs in a DM only
|
|
5301
|
+
controller_decorator.warnOnce(logger, `resolveRoute found only ${named(matched.route)}, which works in a DM alone, for a message without dm; dispatch runs it ` + 'only in a DM. Pass dm: true. Under useStrictMocks() this resolves to undefined.');
|
|
5302
|
+
}
|
|
5303
|
+
}
|
|
5304
|
+
if (!matched) return undefined;
|
|
5305
|
+
const { route, params } = matched;
|
|
5306
|
+
// Dispatch never runs a route that does not work in a DM; one sent with a prefix or mention is told it works in a server only
|
|
5307
|
+
if (starts.inGuild === false && (!providers.fitsScope(route.scope, false) || providers.takesGuildParam(route))) return undefined;
|
|
5308
|
+
// Nor one with a required flag only a server has, or given one: refused under strict mocks, warned about otherwise
|
|
5309
|
+
if (starts.inGuild === false && (providers.needsGuild(route) || givesGuildFlag(route, params))) {
|
|
5310
|
+
if (strictMocks()) return undefined;
|
|
5311
|
+
controller_decorator.warnOnce(logger, `resolveRoute resolved a DM to ${named(route)}, which dispatch answers "This command ` + 'works in a server only.": a member, role or channel flag is found only in a server. Under useStrictMocks() this ' + 'resolves to undefined.');
|
|
5312
|
+
}
|
|
5313
|
+
return {
|
|
5314
|
+
controller: route.controllerClass,
|
|
5315
|
+
method: route.method,
|
|
5316
|
+
handler: route.controllerClass.prototype[route.method],
|
|
5317
|
+
params
|
|
5318
|
+
};
|
|
5319
|
+
}
|
|
5320
|
+
if (!controller_decorator.isCustomIdRouted(input.type)) {
|
|
5321
|
+
throw new TypeError(`${input.type} commands are routed by name, not by customId.`);
|
|
5322
|
+
}
|
|
5323
|
+
const routes = eventSources.buildComponentRoutes(controllersOf(app));
|
|
5324
|
+
const matched = eventSources.matchComponentRoute(routes, (type)=>type === input.type, input.customId);
|
|
5325
|
+
if (!matched) return undefined;
|
|
5326
|
+
const { route, params, text } = matched;
|
|
5327
|
+
const method = route.meta.methodName;
|
|
5328
|
+
const typed = Object.keys(route.types).length > 0;
|
|
5329
|
+
// The routes ranked behind it that the customId matches too, which dispatch never reaches for it
|
|
5330
|
+
const behind = routes.slice(routes.indexOf(route) + 1);
|
|
5331
|
+
const alsoMatches = behind.filter((other)=>other.meta.type === input.type && eventSources.readCustomId(other.pattern, other.meta.regex, input.customId)).map((other)=>other.pattern);
|
|
5332
|
+
const resolved = {
|
|
5333
|
+
controller: route.controllerClass,
|
|
5334
|
+
method,
|
|
5335
|
+
handler: route.controllerClass.prototype[method],
|
|
5336
|
+
params: text,
|
|
5337
|
+
...typed && {
|
|
5338
|
+
values: params
|
|
5339
|
+
}
|
|
5340
|
+
};
|
|
5341
|
+
return alsoMatches.length > 0 ? readableOnly(resolved, {
|
|
5342
|
+
alsoMatches
|
|
5343
|
+
}) : resolved;
|
|
5344
|
+
}
|
|
5345
|
+
/**
|
|
5346
|
+
* Finds component patterns that rank equally and can match the same `customId`, which MeoCord otherwise only warns
|
|
5347
|
+
* about at startup.
|
|
5348
|
+
*
|
|
5349
|
+
* Use it in a test to keep the warning from reaching production. Patterns rank segment by segment, a literal before a
|
|
5350
|
+
* param and then the narrower type, so two collide only with the same literals and equally narrow params at each
|
|
5351
|
+
* position, such as `{action:close|reopen}` and `{step:close|confirm}`, which both take `close`; the one listed
|
|
5352
|
+
* first runs. Patterns are compared within a component type.
|
|
5353
|
+
*
|
|
5354
|
+
* @param app - The application class decorated with `@MeoCord`.
|
|
5355
|
+
* @returns Each pair of patterns that rank equally and can match the same `customId`, with its component type.
|
|
5356
|
+
*
|
|
5357
|
+
* @example
|
|
5358
|
+
* ```ts
|
|
5359
|
+
* import { expect } from 'vitest'
|
|
5360
|
+
*
|
|
5361
|
+
* @Controller()
|
|
5362
|
+
* class TicketController {
|
|
5363
|
+
* @Command('ticket/{action:close|reopen}', CommandType.BUTTON)
|
|
5364
|
+
* async act(interaction: ButtonInteraction) { await respond(interaction).send('Done.') }
|
|
5365
|
+
* @Command('ticket/{step:close|confirm}', CommandType.BUTTON)
|
|
5366
|
+
* async step(interaction: ButtonInteraction) { await respond(interaction).send('Next.') }
|
|
5367
|
+
* }
|
|
5368
|
+
* @MeoCord({ controllers: [TicketController], clientOptions: { intents: [] } })
|
|
5369
|
+
* class App {}
|
|
5370
|
+
* expect(findRouteConflicts(App)).toHaveLength(1)
|
|
5371
|
+
* ```
|
|
5372
|
+
*
|
|
5373
|
+
* @group Testing
|
|
5374
|
+
* @category Inspection
|
|
5375
|
+
* @see {@link resolveRoute}
|
|
5376
|
+
*/ function findRouteConflicts(app) {
|
|
5377
|
+
return eventSources.findComponentRouteConflicts(eventSources.buildComponentRoutes(controllersOf(app))).map(({ type, patterns, runs })=>readableOnly({
|
|
5378
|
+
type: type,
|
|
5379
|
+
patterns
|
|
5380
|
+
}, {
|
|
5381
|
+
runs
|
|
5382
|
+
}));
|
|
5383
|
+
}
|
|
5384
|
+
/**
|
|
5385
|
+
* `target` with `fields` added as properties a test reads but `toEqual` and its kin, which compare enumerable keys,
|
|
5386
|
+
* don't see, so an assertion written for the fields it had before still holds.
|
|
5387
|
+
*/ function readableOnly(target, fields) {
|
|
5388
|
+
for (const [key, value] of Object.entries(fields))Object.defineProperty(target, key, {
|
|
5389
|
+
value,
|
|
5390
|
+
enumerable: false,
|
|
5391
|
+
configurable: true
|
|
5392
|
+
});
|
|
5393
|
+
return target;
|
|
5394
|
+
}
|
|
4284
5395
|
|
|
4285
5396
|
/**
|
|
4286
5397
|
* A compiled testing module, which runs handlers as the bot does and resolves the classes it built.
|
|
@@ -4346,7 +5457,13 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
|
|
|
4346
5457
|
* ```
|
|
4347
5458
|
*/ async init(options = {}) {
|
|
4348
5459
|
this.resolving ?? (this.resolving = providers.resolveProviders(this.container, this.providers, this.order).then(()=>{
|
|
5460
|
+
// As the bot reads them, once every provided value is made and before the services that could list them
|
|
5461
|
+
this.sources ?? (this.sources = eventSources.eventSources(this.container, this.providers, this.lifecycle, this.eventClasses, (text)=>new controller_decorator.Logger('TestingModule').warn(text)));
|
|
5462
|
+
providers.listEventSources(this.container.get(providers.HandlerRegistry), this.sources);
|
|
5463
|
+
providers.prepareLateThemes(this.container, this.sources.map(({ cls })=>cls));
|
|
4349
5464
|
for (const service of this.services)this.container.get(service);
|
|
5465
|
+
// After the providers and listed services, as the bot makes it, so a provided value it injects is made
|
|
5466
|
+
if (this.app) providers.bindAppPresenter(this.container, this.app);
|
|
4350
5467
|
}));
|
|
4351
5468
|
await this.resolving;
|
|
4352
5469
|
if (options.ready) {
|
|
@@ -4402,10 +5519,10 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
|
|
|
4402
5519
|
// down whatever they constructed
|
|
4403
5520
|
await this.resolving?.catch(()=>undefined);
|
|
4404
5521
|
const entries = ()=>this.lifecycle.filter((unit)=>this.constructed.has(unit.token))// Already made, so this returns the instance; a provided value may be anything, null included
|
|
4405
|
-
.map((unit)=>
|
|
5522
|
+
.map((unit)=>eventSources.lifecycleEntry(unit, this.container.get(unit.token) ?? {}));
|
|
4406
5523
|
const failures = [];
|
|
4407
|
-
await
|
|
4408
|
-
drainCalls: ()=>
|
|
5524
|
+
await eventSources.runShutdownSequence(this.container, entries, {
|
|
5525
|
+
drainCalls: ()=>eventSources.callsSettled(this.calls),
|
|
4409
5526
|
readyPass: this.readyPass,
|
|
4410
5527
|
timeoutMs: this.shutdownTimeout,
|
|
4411
5528
|
hookFailed: (name, error)=>failures.push({
|
|
@@ -4434,10 +5551,9 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
|
|
|
4434
5551
|
error
|
|
4435
5552
|
});
|
|
4436
5553
|
// No warning for a hook whose dependency failed: the test sees the dependency's own error
|
|
4437
|
-
|
|
4438
|
-
await startupRoots.runReadyHooks(this.container, this.lifecycle, client, {
|
|
5554
|
+
await eventSources.runReadyHooks(this.container, this.lifecycle, client, {
|
|
4439
5555
|
primary
|
|
4440
|
-
},
|
|
5556
|
+
}, {
|
|
4441
5557
|
resolveFailed: failed,
|
|
4442
5558
|
hookFailed: failed
|
|
4443
5559
|
}, {
|
|
@@ -4504,16 +5620,24 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
|
|
|
4504
5620
|
* command's or an autocomplete's options, or the handler's customId params with a modal's fields or
|
|
4505
5621
|
* a select menu's choices.
|
|
4506
5622
|
* An interaction's customId or command name must be one dispatch routes to the handler, ranking
|
|
4507
|
-
* every handler of the module as the bot does; a mock built without one is not checked.
|
|
4508
|
-
*
|
|
4509
|
-
*
|
|
4510
|
-
*
|
|
4511
|
-
* the
|
|
4512
|
-
*
|
|
5623
|
+
* every handler of the module as the bot does; a mock built without one is not checked. An autocomplete is
|
|
5624
|
+
* checked by its command path and its focused option: one dispatch gives to another `@Autocomplete` handler,
|
|
5625
|
+
* or to none, is refused under `useStrictMocks()`, and otherwise warned about once and run.
|
|
5626
|
+
* With a message alone, a patterned `@MessageHandler` gets the params its pattern captures from the content,
|
|
5627
|
+
* after the prefix of the module's `app`, with typed params resolved as dispatch resolves them, from the
|
|
5628
|
+
* message's guild caches first. A message whose `content` isn't a string, as a mock's without
|
|
5629
|
+
* `useStrictMocks()`, gets `{}`. Under strict mocks a message has empty `content`, and a patterned handler
|
|
5630
|
+
* rejects it, as dispatch runs no handler for a message with no text: give the mock the `content` the handler
|
|
5631
|
+
* takes. A word that is not a value of its type, and a prefixed message that names the command but leaves out
|
|
5632
|
+
* a param, go through the handler's filters as a `MessageUsageError`, as dispatch answers them.
|
|
5633
|
+
* An `@On` or `@Once` handler no route reaches runs as {@link emit} runs it, as an event with the arguments given,
|
|
5634
|
+
* under `useStrictMocks()`; without strict mocks it runs as its first argument's type, and `invoke` warns once
|
|
5635
|
+
* where that differs.
|
|
4513
5636
|
* @returns Whether the handler ran, and the error a filter handled, if any. Rejects with an error no
|
|
4514
5637
|
* filter handles, or with the error a filter throws: the built-in fallback, which answers such
|
|
4515
5638
|
* errors in the bot, does not run here. Rejects before running anything with an interaction or a
|
|
4516
|
-
* message the handler's route does not match, naming both
|
|
5639
|
+
* message the handler's route does not match, naming both, and under `useStrictMocks()` with such an
|
|
5640
|
+
* autocomplete. Given fewer arguments than the handler
|
|
4517
5641
|
* declares, with a first that is neither an interaction nor a message, such as a reaction without its
|
|
4518
5642
|
* `ReactionEvent`, it rejects under `useStrictMocks()`, and otherwise warns once and runs the handler.
|
|
4519
5643
|
*
|
|
@@ -4545,15 +5669,29 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
|
|
|
4545
5669
|
const instance = this.container.get(controller);
|
|
4546
5670
|
if (typeof instance[methodName] !== 'function') throw new Error(`${controller.name}.${methodName} is not a method.`);
|
|
4547
5671
|
const [first] = args;
|
|
5672
|
+
const event = eventHandlerOf(controller, methodName);
|
|
5673
|
+
// As emit and the bot run it; without strict mocks it runs as before, as a test that passes that way could fail
|
|
5674
|
+
if (event && strictMocks()) {
|
|
5675
|
+
this.checkArgumentCount(controller, methodName, instance[methodName], first, args.length);
|
|
5676
|
+
this.registerEventClients(args);
|
|
5677
|
+
const { ran, error } = await this.track(this.runEventHandler(instance, methodName, args));
|
|
5678
|
+
return error === undefined ? {
|
|
5679
|
+
ran
|
|
5680
|
+
} : {
|
|
5681
|
+
ran,
|
|
5682
|
+
error
|
|
5683
|
+
};
|
|
5684
|
+
}
|
|
4548
5685
|
const interaction = first instanceof discord_js.BaseInteraction ? first : undefined;
|
|
4549
5686
|
// The handler dispatch gives the customId to, among every handler of the module
|
|
4550
|
-
const component = interaction && controller_decorator.hasCustomId(interaction) ?
|
|
4551
|
-
const mismatch = component && 'mismatch' in component ? component.mismatch : interaction &&
|
|
5687
|
+
const component = interaction && controller_decorator.hasCustomId(interaction) ? eventSources.componentRouteFor(this.componentRoutes(), controller, methodName, interaction) : undefined;
|
|
5688
|
+
const mismatch = component && 'mismatch' in component ? component.mismatch : interaction && eventSources.commandMismatch(this.controllers, controller, methodName, interaction);
|
|
4552
5689
|
if (mismatch) throw new Error(mismatch);
|
|
5690
|
+
if (interaction) this.checkAutocompleteRoute(controller, methodName, interaction);
|
|
4553
5691
|
let hooks = {};
|
|
4554
5692
|
let callArgs = args.length === 1 && interaction ? [
|
|
4555
5693
|
interaction,
|
|
4556
|
-
|
|
5694
|
+
eventSources.handlerInput(interaction, component && 'params' in component ? component.params : {}).params
|
|
4557
5695
|
] : args;
|
|
4558
5696
|
if (args.length === 1 && first instanceof discord_js.Message) {
|
|
4559
5697
|
const input = await providers.messageParamsFor(controller, methodName, first, this.messageOptions, this.controllers);
|
|
@@ -4568,10 +5706,7 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
|
|
|
4568
5706
|
}
|
|
4569
5707
|
}
|
|
4570
5708
|
this.checkArgumentCount(controller, methodName, instance[methodName], first, callArgs.length);
|
|
4571
|
-
|
|
4572
|
-
// A message command's errors are drawn by the app's presenter too, as an interaction's are
|
|
4573
|
-
const client = first instanceof discord_js.BaseInteraction || first instanceof discord_js.Message ? first.client : undefined;
|
|
4574
|
-
if (presenter && client) controller_decorator.setPresenter(client, presenter);
|
|
5709
|
+
if (event) this.warnEventRun(controller, methodName, event, first, callArgs.length !== args.length);
|
|
4575
5710
|
this.registerClient(first);
|
|
4576
5711
|
const { ran, error } = await this.track(providers.runHandler(this.container, instance, methodName, callArgs, {
|
|
4577
5712
|
awaitObservers: true,
|
|
@@ -4585,6 +5720,36 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
|
|
|
4585
5720
|
};
|
|
4586
5721
|
}
|
|
4587
5722
|
/**
|
|
5723
|
+
* Warns once, without strict mocks, that `invoke` runs an event handler other than as the bot does: as the type its
|
|
5724
|
+
* first argument gives, or with params it added. Only `invoke` warns, on the test's own call.
|
|
5725
|
+
*/ warnEventRun(controller, methodName, event, first, paramsAdded) {
|
|
5726
|
+
const type = controller_decorator.inferContextType(first);
|
|
5727
|
+
// invoke adds params only for an interaction or a message, whose type is never 'event'
|
|
5728
|
+
if (type === 'event') return;
|
|
5729
|
+
const key = `${controller.name}.${methodName}`;
|
|
5730
|
+
if (this.warnedEventRuns.has(key)) return;
|
|
5731
|
+
this.warnedEventRuns.add(key);
|
|
5732
|
+
const call = `${controller_decorator.withArticle(type)} call${paramsAdded ? ' with its params added' : ''}`;
|
|
5733
|
+
new controller_decorator.Logger('TestingModule').warn(`invoke runs ${key}, an @${event.once ? 'Once' : 'On'}('${event.event}') handler, as ${call}; the bot runs it as ` + 'an event with its arguments as given, and useStrictMocks() runs it so.');
|
|
5734
|
+
}
|
|
5735
|
+
/** Runs an `@On` or `@Once` handler as the bot and `emit` do: as an event, with its arguments as given. */ runEventHandler(instance, method, args) {
|
|
5736
|
+
return providers.runHandler(this.container, instance, method, args, {
|
|
5737
|
+
type: 'event',
|
|
5738
|
+
awaitObservers: true
|
|
5739
|
+
});
|
|
5740
|
+
}
|
|
5741
|
+
/**
|
|
5742
|
+
* Refuses under strict mocks, and otherwise warns once, an autocomplete dispatch gives to another handler or to none.
|
|
5743
|
+
* Without strict mocks the handler still runs, since a test that passes that way would fail.
|
|
5744
|
+
*/ checkAutocompleteRoute(controller, methodName, interaction) {
|
|
5745
|
+
const mismatch = eventSources.autocompleteMismatch(this.controllers, controller, methodName, interaction);
|
|
5746
|
+
if (!mismatch) return;
|
|
5747
|
+
if (strictMocks()) throw new Error(mismatch);
|
|
5748
|
+
if (this.warnedAutocompleteRoutes.has(mismatch)) return;
|
|
5749
|
+
this.warnedAutocompleteRoutes.add(mismatch);
|
|
5750
|
+
new controller_decorator.Logger('TestingModule').warn(`${mismatch} Under useStrictMocks(), invoke rejects it.`);
|
|
5751
|
+
}
|
|
5752
|
+
/**
|
|
4588
5753
|
* Refuses under `useStrictMocks()`, or warns once otherwise, a call that gives a handler fewer arguments than it
|
|
4589
5754
|
* declares when `invoke` builds none for it, as for a reaction given without its `ReactionEvent`.
|
|
4590
5755
|
*/ checkArgumentCount(controller, methodName, method, first, given) {
|
|
@@ -4603,34 +5768,70 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
|
|
|
4603
5768
|
return call;
|
|
4604
5769
|
}
|
|
4605
5770
|
/**
|
|
4606
|
-
* Makes this module the app of the input's client, as the bot is of its own
|
|
4607
|
-
*
|
|
5771
|
+
* Makes this module the app of the input's client, as the bot is of its own: its answers are drawn by this
|
|
5772
|
+
* module's presenter, in its theme and language, `respond()` outside any call included, as in a collector's callback
|
|
5773
|
+
* on that client. A module without a presenter clears the client's under strict mocks, so the default draws; default
|
|
5774
|
+
* mode leaves another module's in place and warns once.
|
|
4608
5775
|
*/ registerClient(input) {
|
|
4609
5776
|
const client = input?.client;
|
|
4610
5777
|
if (!client || typeof client !== 'object') return;
|
|
5778
|
+
const presenter = providers.appPresenterOf(this.container);
|
|
5779
|
+
if (presenter || strictMocks()) controller_decorator.setPresenter(client, presenter);
|
|
5780
|
+
else if (controller_decorator.clientPresenter(client)) {
|
|
5781
|
+
controller_decorator.warnOnce(new controller_decorator.Logger('TestingModule'), "A module without a presenter here answers with the presenter another module set on the same client; under " + "useStrictMocks() this module's own presenter, or the default, is used.");
|
|
5782
|
+
}
|
|
4611
5783
|
providers.registerClientTheme(client, this.container);
|
|
4612
5784
|
controller_decorator.registerClientTranslator(client, this.container.isBound(controller_decorator.Translator) ? this.container.get(controller_decorator.Translator) : undefined);
|
|
4613
5785
|
}
|
|
5786
|
+
/**
|
|
5787
|
+
* Makes this module the app of each event argument's client, as the bot is of its own, under strict mocks; default
|
|
5788
|
+
* mode leaves the clients as they are and warns once where this module would draw answers otherwise.
|
|
5789
|
+
*/ registerEventClients(args) {
|
|
5790
|
+
const withClient = args.filter((arg)=>typeof arg?.client === 'object');
|
|
5791
|
+
if (withClient.length === 0) return;
|
|
5792
|
+
if (strictMocks()) {
|
|
5793
|
+
for (const arg of withClient)this.registerClient(arg);
|
|
5794
|
+
return;
|
|
5795
|
+
}
|
|
5796
|
+
const ownTheme = providers.appTheme(this.container) !== controller_decorator.defaultTheme();
|
|
5797
|
+
if (!providers.appPresenterOf(this.container) && !this.container.isBound(controller_decorator.Translator) && !ownTheme) return;
|
|
5798
|
+
controller_decorator.warnOnce(new controller_decorator.Logger('TestingModule'), "emit() here leaves the client's presenter, theme and translator as they were, where the bot draws the answers to " + "an event with its app's; in the next major version (5.0) emit sets the module's on the client. Call " + 'useStrictMocks() to have it set them now.');
|
|
5799
|
+
}
|
|
4614
5800
|
/** The module's component routes, ranked as dispatch ranks them; throws, as the bot would, for two of one shape. */ componentRoutes() {
|
|
4615
|
-
return this.builtComponentRoutes ?? (this.builtComponentRoutes =
|
|
5801
|
+
return this.builtComponentRoutes ?? (this.builtComponentRoutes = eventSources.buildComponentRoutes(this.controllers));
|
|
4616
5802
|
}
|
|
4617
5803
|
/** The dispatcher the bot would build from this module's controllers and `app`. */ dispatcherOf() {
|
|
4618
5804
|
if (this.dispatcher) return this.dispatcher;
|
|
4619
5805
|
const logger = new controller_decorator.Logger('TestingModule');
|
|
4620
5806
|
const warnUnanswered = this.appWarnUnanswered ?? process.env.NODE_ENV === 'development';
|
|
4621
|
-
|
|
5807
|
+
const notices = {
|
|
5808
|
+
cooldownNotice: (refusal)=>providers.claimCooldownNotice(this.container, refusal),
|
|
5809
|
+
storeDownNotice: (who)=>providers.claimStoreDownNotice(this.container, who)
|
|
5810
|
+
};
|
|
5811
|
+
// Strict mocks log an answer MeoCord fails to build and end the call in that fault, as a bot does; default mode
|
|
5812
|
+
// rethrows it from the fallback, so observers hear the call's own outcome, and an interaction's again as an error
|
|
5813
|
+
const answer = eventSources.createFallback(logger, ()=>this.messageOptions, notices);
|
|
5814
|
+
const rethrowing = eventSources.createFallback(logger, ()=>this.messageOptions, {
|
|
5815
|
+
...notices,
|
|
5816
|
+
strict: true
|
|
5817
|
+
});
|
|
5818
|
+
const fallback = async (error, context)=>{
|
|
5819
|
+
if (strictMocks()) return answer(error, context);
|
|
5820
|
+
try {
|
|
5821
|
+
return await rethrowing(error, context);
|
|
5822
|
+
} catch (failure) {
|
|
5823
|
+
controller_decorator.warnOnce(logger, "dispatch() here reports a call whose answer MeoCord couldn't build with the call's own outcome, and an " + "interaction's a second time as an error; under useStrictMocks() the call is reported once, as 'error'.");
|
|
5824
|
+
throw failure;
|
|
5825
|
+
}
|
|
5826
|
+
};
|
|
5827
|
+
this.dispatcher = new eventSources.Dispatcher({
|
|
4622
5828
|
container: this.container,
|
|
4623
5829
|
controllerClasses: this.controllers,
|
|
4624
5830
|
messageOptions: this.messageOptions,
|
|
4625
5831
|
logger,
|
|
4626
5832
|
// A test's fake clock can pass 5 s inside a handler, so the slow-handler warning waits to be asked for
|
|
4627
5833
|
warnSlowMessageHandlers: false,
|
|
4628
|
-
|
|
4629
|
-
fallback: startupRoots.createFallback(logger, ()=>this.messageOptions, {
|
|
4630
|
-
strict: true,
|
|
4631
|
-
cooldownNotice: (refusal)=>providers.claimCooldownNotice(this.container, refusal),
|
|
4632
|
-
storeDownNotice: (who)=>providers.claimStoreDownNotice(this.container, who)
|
|
4633
|
-
}),
|
|
5834
|
+
fallback,
|
|
4634
5835
|
// A mock's client is the one bot every mock client is, so a mention of it starts a command
|
|
4635
5836
|
botUserId: (event)=>{
|
|
4636
5837
|
const id = event.client?.user?.id;
|
|
@@ -4648,6 +5849,8 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
|
|
|
4648
5849
|
const dispatcher = this.dispatcherOf();
|
|
4649
5850
|
const handlers = [];
|
|
4650
5851
|
const unhandled = [];
|
|
5852
|
+
// The fault each error's answer failed with, which the call ends in in that error's place
|
|
5853
|
+
const unanswered = new Map();
|
|
4651
5854
|
const record = {
|
|
4652
5855
|
settled: (controller, method, { ran, error })=>handlers.push({
|
|
4653
5856
|
controller,
|
|
@@ -4657,7 +5860,8 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
|
|
|
4657
5860
|
error
|
|
4658
5861
|
}
|
|
4659
5862
|
}),
|
|
4660
|
-
unhandled: (error)=>unhandled.push(error)
|
|
5863
|
+
unhandled: (error)=>unhandled.push(error),
|
|
5864
|
+
unanswered: (error, failure)=>unanswered.set(error, failure)
|
|
4661
5865
|
};
|
|
4662
5866
|
if (options) {
|
|
4663
5867
|
noteReactionDispatch(input);
|
|
@@ -4666,18 +5870,19 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
|
|
|
4666
5870
|
action: options.action ?? enum_index.ReactionHandlerAction.ADD
|
|
4667
5871
|
}, record));
|
|
4668
5872
|
} else if (input instanceof discord_js.BaseInteraction) {
|
|
4669
|
-
const presenter = providers.appPresenterOf(this.container);
|
|
4670
|
-
if (presenter) controller_decorator.setPresenter(input.client, presenter);
|
|
4671
5873
|
await this.track(dispatcher.interaction(input, record));
|
|
4672
5874
|
} else if (input instanceof discord_js.Message) {
|
|
4673
|
-
const presenter = providers.appPresenterOf(this.container);
|
|
4674
|
-
if (presenter) controller_decorator.setPresenter(input.client, presenter);
|
|
4675
5875
|
await this.track(dispatcher.message(input, record));
|
|
4676
5876
|
} else {
|
|
4677
5877
|
throw new TypeError('dispatch takes an interaction, a message, or a reaction with { user }.');
|
|
4678
5878
|
}
|
|
4679
|
-
// What the fallback answers as the user's own outcome, such as a usage reply or a refusal, is an outcome to assert on
|
|
4680
|
-
|
|
5879
|
+
// What the fallback answers as the user's own outcome, such as a usage reply or a refusal, is an outcome to assert on,
|
|
5880
|
+
// unless its answer failed to build, which is the call's fault, as on a bot
|
|
5881
|
+
const failures = unhandled.flatMap((error)=>unanswered.has(error) ? [
|
|
5882
|
+
unanswered.get(error)
|
|
5883
|
+
] : controller_decorator.isUserOutcome(error, options ? undefined : input) ? [] : [
|
|
5884
|
+
error
|
|
5885
|
+
]);
|
|
4681
5886
|
if (failures.length === 1) throw failures[0];
|
|
4682
5887
|
if (failures.length > 1) throw new AggregateError(failures, `${failures.length} errors were left to the fallback.`);
|
|
4683
5888
|
const error = handlers.find((handler)=>handler.error !== undefined)?.error ?? unhandled[0];
|
|
@@ -4692,8 +5897,9 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
|
|
|
4692
5897
|
/**
|
|
4693
5898
|
* Emits a client event to the module's `@On` and `@Once` handlers, through the pipeline the app runs
|
|
4694
5899
|
* them in up to its event fallback: the global guards of the module's `app`, then each handler's own.
|
|
4695
|
-
*
|
|
4696
|
-
*
|
|
5900
|
+
* As on the bot, every instance the module runs lifecycle hooks on, the cooldown store aside, receives
|
|
5901
|
+
* it: the controllers and services, what they and the stages inject, and each provided class or value.
|
|
5902
|
+
* A `@Once` handler handles only the first event, as it would on a client.
|
|
4697
5903
|
*
|
|
4698
5904
|
* The bot's event fallback doesn't run here. On a bot, a guard's `GuardDeniedError` is logged and the
|
|
4699
5905
|
* event skipped, and a handler's `UserError` is logged and, when the event carries a message, answered
|
|
@@ -4717,22 +5923,21 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
|
|
|
4717
5923
|
*/ async emit(event, ...args) {
|
|
4718
5924
|
this.warnIfClosed();
|
|
4719
5925
|
await this.init();
|
|
5926
|
+
this.registerEventClients(args);
|
|
4720
5927
|
const calls = [];
|
|
4721
|
-
for (const
|
|
4722
|
-
for (const handler of
|
|
5928
|
+
for (const [index, source] of (this.sources ?? []).entries()){
|
|
5929
|
+
for (const handler of source.handlers){
|
|
4723
5930
|
if (handler.event !== event) continue;
|
|
4724
5931
|
if (handler.once) {
|
|
4725
|
-
const key = `${
|
|
5932
|
+
const key = `${index}.${handler.method}:${event}`;
|
|
4726
5933
|
if (this.firedOnce.has(key)) continue;
|
|
4727
5934
|
this.firedOnce.add(key);
|
|
4728
5935
|
}
|
|
4729
5936
|
// Resolved inside the call, so a class that cannot be resolved fails as its handler would
|
|
4730
5937
|
const run = async ()=>{
|
|
4731
|
-
const instance =
|
|
4732
|
-
|
|
4733
|
-
|
|
4734
|
-
awaitObservers: true
|
|
4735
|
-
});
|
|
5938
|
+
const instance = source.resolve();
|
|
5939
|
+
if (!instance) return false;
|
|
5940
|
+
const { ran } = await this.runEventHandler(instance, handler.method, args);
|
|
4736
5941
|
return ran;
|
|
4737
5942
|
};
|
|
4738
5943
|
calls.push(this.track(run()));
|
|
@@ -4748,7 +5953,7 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
|
|
|
4748
5953
|
ran: results.filter((result)=>result.status === 'fulfilled' && result.value).length
|
|
4749
5954
|
};
|
|
4750
5955
|
}
|
|
4751
|
-
constructor(container, controllers = [], eventClasses = [], providers = new Map(), order = [], messageOptions = {}, lifecycle = [], /** The lifecycle units the container has constructed, which `close()` shuts down. */ constructed = new Set(), /** The `app`'s `warnUnanswered`, which dispatch follows as the bot does. */ appWarnUnanswered, /** The app's listed services, made at `init()` as the bot makes them before it logs in. */ services = [], /** How long `close()` waits for the `onShutdown` hooks, as the bot's `shutdownTimeout` does. */ shutdownTimeout =
|
|
5956
|
+
constructor(container, controllers = [], eventClasses = [], providers = new Map(), order = [], messageOptions = {}, lifecycle = [], /** The lifecycle units the container has constructed, which `close()` shuts down. */ constructed = new Set(), /** The `app`'s `warnUnanswered`, which dispatch follows as the bot does. */ appWarnUnanswered, /** The app's listed services, made at `init()` as the bot makes them before it logs in. */ services = [], /** How long `close()` waits for the `onShutdown` hooks, as the bot's `shutdownTimeout` does. */ shutdownTimeout = eventSources.DEFAULT_SHUTDOWN_TIMEOUT_MS, /** The `app`, whose presenter `init()` makes after the providers, as the bot does. */ app){
|
|
4752
5957
|
this.container = container;
|
|
4753
5958
|
this.controllers = controllers;
|
|
4754
5959
|
this.eventClasses = eventClasses;
|
|
@@ -4760,10 +5965,13 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
|
|
|
4760
5965
|
this.appWarnUnanswered = appWarnUnanswered;
|
|
4761
5966
|
this.services = services;
|
|
4762
5967
|
this.shutdownTimeout = shutdownTimeout;
|
|
4763
|
-
|
|
5968
|
+
this.app = app;
|
|
5969
|
+
/** The `onReady` hooks' pass, which `close()` waits for, less one whose `onReady` called `close()`. */ this.readyPass = new eventSources.ReadyPass();
|
|
4764
5970
|
this.warnedClosed = false;
|
|
4765
5971
|
/** The `@Once` handlers that have already handled their event, as a client forgets its once listeners. */ this.firedOnce = new Set();
|
|
4766
5972
|
/** The handlers already warned about for being given fewer arguments than they take, so each is named once. */ this.warnedArgumentCounts = new Set();
|
|
5973
|
+
/** The `@On` and `@Once` handlers already warned about for running other than as an event, so each is named once. */ this.warnedEventRuns = new Set();
|
|
5974
|
+
/** The autocomplete mismatches already warned about without strict mocks, so each handler and route warns once. */ this.warnedAutocompleteRoutes = new Set();
|
|
4767
5975
|
/** The calls under way, each removed once it settles: what `close()` lets finish before the cooldown store stops. */ this.calls = new Set();
|
|
4768
5976
|
}
|
|
4769
5977
|
}
|
|
@@ -4773,6 +5981,10 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
|
|
|
4773
5981
|
throw new AggregateError(failures.map(({ error })=>error), `${failures.length} ${hook} hooks threw: ${failures.map(({ name })=>name).join(', ')}.`);
|
|
4774
5982
|
}
|
|
4775
5983
|
}
|
|
5984
|
+
/** A method's `@On` or `@Once` handler, when no route also reaches it: a route's input decides how that one runs. */ function eventHandlerOf(controller, methodName) {
|
|
5985
|
+
if (controller_decorator.getHandlerRoutes(controller.prototype).some((route)=>route.method === methodName)) return undefined;
|
|
5986
|
+
return providers.getEventHandlers(controller.prototype).find((handler)=>handler.method === methodName);
|
|
5987
|
+
}
|
|
4776
5988
|
/** The app's `messages` options, when the testing module is given an app. */ function messagesOf(app) {
|
|
4777
5989
|
return app && Reflect.getMetadata(controller_decorator.META.appOptions, app)?.messages;
|
|
4778
5990
|
}
|
|
@@ -4807,7 +6019,9 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
|
|
|
4807
6019
|
/**
|
|
4808
6020
|
* Replaces a provider with a test double.
|
|
4809
6021
|
*
|
|
4810
|
-
* The double needs only the members the test uses; misspelled member names are still rejected.
|
|
6022
|
+
* The double needs only the members the test uses; misspelled member names are still rejected. A token MeoCord binds
|
|
6023
|
+
* itself, such as `ShardContext` or `HandlerRegistry`, takes the double in place of MeoCord's own, so a test can stub
|
|
6024
|
+
* cross-shard calls.
|
|
4811
6025
|
*
|
|
4812
6026
|
* @param token - The provider to replace.
|
|
4813
6027
|
* @example
|
|
@@ -4970,7 +6184,7 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
|
|
|
4970
6184
|
* @throws Error for the first startup error the checks find.
|
|
4971
6185
|
*/ compile() {
|
|
4972
6186
|
// The bound the bot's config gives it, with the same words
|
|
4973
|
-
const shutdownTimeout = this.options.shutdownTimeout === undefined ? undefined :
|
|
6187
|
+
const shutdownTimeout = this.options.shutdownTimeout === undefined ? undefined : eventSources.shutdownTimeoutProblem(this.options.shutdownTimeout);
|
|
4974
6188
|
if (shutdownTimeout) throw new TypeError(`shutdownTimeout ${shutdownTimeout}.`);
|
|
4975
6189
|
// With startupErrors: 'all', what the decorators kept on the classes this module runs
|
|
4976
6190
|
const declared = controller_decorator.declaredErrorsOf([
|
|
@@ -4991,7 +6205,7 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
|
|
|
4991
6205
|
// Bound first, as in the app, so a class that injects it gets this instance
|
|
4992
6206
|
const appClasses = [];
|
|
4993
6207
|
const translator = ()=>container.isBound(controller_decorator.Translator) ? container.get(controller_decorator.Translator) : undefined;
|
|
4994
|
-
container.bind(providers.HandlerRegistry).toConstantValue(new providers.HandlerRegistry(appClasses, messagesOf(this.options.app), translator));
|
|
6208
|
+
container.bind(providers.HandlerRegistry).toConstantValue(new providers.HandlerRegistry(appClasses, messagesOf(this.options.app), translator, this.options.controllers ?? []));
|
|
4995
6209
|
// A testing module runs as one process, so a cross-shard call runs once, here
|
|
4996
6210
|
container.bind(providers.ShardContext).toConstantValue(new providers.ShardContext(undefined, providers.shardCallHandler(container, ()=>[
|
|
4997
6211
|
...appClasses,
|
|
@@ -5004,6 +6218,17 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
|
|
|
5004
6218
|
// Checked as the app checks its own: the app's providers in their order, then the test's, which replace them
|
|
5005
6219
|
// by token, then the overrides, which win
|
|
5006
6220
|
const providers$1 = providers.providerMap(this.wiring?.providers ?? [], '@MeoCord({ providers })');
|
|
6221
|
+
// The app's own providers checked as the bot checks them. A token the module binds itself above fails at its
|
|
6222
|
+
// first call, so it is refused; another the bot binds before its providers is refused under strict mocks, and
|
|
6223
|
+
// runs here in default mode with a warning
|
|
6224
|
+
const i18n = this.options.app && Reflect.getMetadata(controller_decorator.META.appOptions, this.options.app)?.i18n;
|
|
6225
|
+
const botBinds = providers.boundBeforeProviders(i18n !== undefined);
|
|
6226
|
+
for (const token of providers$1.keys()){
|
|
6227
|
+
const refusal = providers.appProviderRefusal(`${this.options.app?.name}`, token, (own)=>botBinds.includes(own), new controller_decorator.Logger('TestingModule'));
|
|
6228
|
+
if (!refusal) continue;
|
|
6229
|
+
if (container.isBound(token) || strictMocks()) controller_decorator.startupError(refusal);
|
|
6230
|
+
else controller_decorator.warnOnce(new controller_decorator.Logger('TestingModule'), `${refusal.message} The testing module runs it; under useStrictMocks() compile() refuses it, as the bot does.`);
|
|
6231
|
+
}
|
|
5007
6232
|
for (const [token, provider] of providers.providerMap(this.options.providers ?? [], "the testing module's providers"))providers$1.set(token, provider);
|
|
5008
6233
|
for (const [token, override] of this.overrides)providers$1.set(token, override);
|
|
5009
6234
|
// A stage override stands in for its class wherever the class is bound. A stage also provided takes the stub in
|
|
@@ -5041,7 +6266,17 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
|
|
|
5041
6266
|
}
|
|
5042
6267
|
// Each pass of checks ends before the next step that is no check, so a module with one error stops where it always did
|
|
5043
6268
|
controller_decorator.stopOnStartupErrors();
|
|
6269
|
+
// What the walk below takes as classes, reported with what the walk finds; an entry refused here is left out of it
|
|
5044
6270
|
const services = this.wiring?.services ?? [];
|
|
6271
|
+
if (this.options.app) providers.assertProvidedServices(this.options.app.name, services, providers$1, "@MeoCord({ providers }) or the testing module's providers");
|
|
6272
|
+
for (const controller of this.options.controllers ?? []){
|
|
6273
|
+
if (!controller_decorator.isConstructor(controller)) controller_decorator.startupError(new Error(`the testing module's controllers takes classes, not ${controller_decorator.describeValue(controller)}.`));
|
|
6274
|
+
}
|
|
6275
|
+
const observers = [
|
|
6276
|
+
...this.options.app ? providers.appObservers(this.options.app) : [],
|
|
6277
|
+
...this.options.observers ?? []
|
|
6278
|
+
];
|
|
6279
|
+
providers.assertObservers("the testing module's observers", observers);
|
|
5045
6280
|
const appOptions = this.options.app ? Reflect.getMetadata(controller_decorator.META.appOptions, this.options.app) : undefined;
|
|
5046
6281
|
// The app's own store, as the bot binds it, whether the module is given the app or built from it; none when the
|
|
5047
6282
|
// test provides the CooldownStore, so nothing builds the app's or asks for what it injects
|
|
@@ -5049,20 +6284,16 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
|
|
|
5049
6284
|
// The themeFor class this module runs, the app's or overrideThemeFor's, bound on its own as the app binds it
|
|
5050
6285
|
const themeResolver = controller_decorator.themeResolverClass(stages?.themeFor?.resolvers);
|
|
5051
6286
|
// Every class the module runs, as the app finds them, except stage classes an override stands in for
|
|
5052
|
-
const i18n = this.options.app && Reflect.getMetadata(controller_decorator.META.appOptions, this.options.app)?.i18n;
|
|
5053
6287
|
const appPresenterClass = this.options.app && Reflect.getMetadata(controller_decorator.META.appOptions, this.options.app)?.presenter;
|
|
5054
|
-
const startup =
|
|
5055
|
-
controllers: this.options.controllers ?? [],
|
|
6288
|
+
const startup = eventSources.startupClasses({
|
|
6289
|
+
controllers: (this.options.controllers ?? []).filter(controller_decorator.isConstructor),
|
|
5056
6290
|
services,
|
|
5057
6291
|
providers: providers$1,
|
|
5058
6292
|
stages,
|
|
5059
6293
|
presenter: appPresenterClass,
|
|
5060
6294
|
cooldownStore: store,
|
|
5061
6295
|
themeFor: stages?.themeFor?.resolvers,
|
|
5062
|
-
observers
|
|
5063
|
-
...this.options.app ? providers.appObservers(this.options.app) : [],
|
|
5064
|
-
...this.options.observers ?? []
|
|
5065
|
-
],
|
|
6296
|
+
observers,
|
|
5066
6297
|
stubs: [
|
|
5067
6298
|
...this.guardOverrides.keys(),
|
|
5068
6299
|
...this.interceptorOverrides.keys(),
|
|
@@ -5070,7 +6301,7 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
|
|
|
5070
6301
|
]
|
|
5071
6302
|
});
|
|
5072
6303
|
const hasTranslator = i18n !== undefined || providers$1.has(controller_decorator.Translator);
|
|
5073
|
-
|
|
6304
|
+
eventSources.assertStartupClasses(startup, {
|
|
5074
6305
|
translator: hasTranslator
|
|
5075
6306
|
});
|
|
5076
6307
|
// Those of the stages, guards and other classes they reach
|
|
@@ -5079,7 +6310,7 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
|
|
|
5079
6310
|
providers.markStartupChecked(container, [
|
|
5080
6311
|
...startup.classes,
|
|
5081
6312
|
...providers.meocordClasses()
|
|
5082
|
-
],
|
|
6313
|
+
], eventSources.lateGuardCheck(providers$1, hasTranslator));
|
|
5083
6314
|
// The bot binds the Client it logs in with; a test gives its own, and is told so where one is needed
|
|
5084
6315
|
const needClient = startup.classes.filter((cls)=>providers.injectedTokens(cls).includes(discord_js.Client));
|
|
5085
6316
|
if (needClient.length > 0 && !providers$1.has(discord_js.Client)) {
|
|
@@ -5106,8 +6337,7 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
|
|
|
5106
6337
|
container.bind(cls).toSelf().inSingletonScope();
|
|
5107
6338
|
// By constructor type or @inject token, as the app binds them
|
|
5108
6339
|
for (const dep of providers.injectedTokens(cls)){
|
|
5109
|
-
if (
|
|
5110
|
-
else if (providers.isAppClassToken(dep)) bindClass(dep);
|
|
6340
|
+
if (providers.isAppClassToken(dep)) bindClass(dep);
|
|
5111
6341
|
}
|
|
5112
6342
|
};
|
|
5113
6343
|
// The app's own store, resolved like a service, unless the test provides the CooldownStore itself; bound first, as the
|
|
@@ -5121,8 +6351,12 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
|
|
|
5121
6351
|
}
|
|
5122
6352
|
providers.bindsOwnToken(container, controller_decorator.CooldownStore, store);
|
|
5123
6353
|
}
|
|
5124
|
-
// Before the controllers, so a class token that is provided is not also bound as itself
|
|
5125
|
-
|
|
6354
|
+
// Before the controllers, so a class token that is provided is not also bound as itself. A test's provider of a
|
|
6355
|
+
// token the module bound above, as HandlerRegistry or ShardContext, stands in for that binding
|
|
6356
|
+
for (const provider of providers$1.values()){
|
|
6357
|
+
if (container.isBound(provider.provide)) container.unbind(provider.provide);
|
|
6358
|
+
providers.bindProvider(container, provider, bindClass);
|
|
6359
|
+
}
|
|
5126
6360
|
for (const ctrl of this.options.controllers ?? [])bindClass(ctrl);
|
|
5127
6361
|
for (const service of services)bindClass(service);
|
|
5128
6362
|
if (themeResolver) bindClass(themeResolver);
|
|
@@ -5150,20 +6384,13 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
|
|
|
5150
6384
|
// As the app would at startup, refuses a message pattern that cannot be read or two that match the same messages
|
|
5151
6385
|
providers.buildMessageRoutes(this.options.controllers ?? [], messages);
|
|
5152
6386
|
// As the app does when it is created, so a test sees the refusal the bot would give
|
|
5153
|
-
|
|
6387
|
+
eventSources.assertDistinctCommands(this.options.controllers ?? []);
|
|
5154
6388
|
if (this.options.app) providers.prepareAppPresenter(container, this.options.app);
|
|
5155
|
-
const observers = [
|
|
5156
|
-
...this.options.app ? providers.appObservers(this.options.app) : [],
|
|
5157
|
-
...this.options.observers ?? []
|
|
5158
|
-
];
|
|
5159
|
-
providers.assertObservers("the testing module's observers", observers);
|
|
5160
|
-
// Only @Observer classes are bound as observers
|
|
5161
|
-
controller_decorator.stopOnStartupErrors();
|
|
5162
6389
|
providers.bindObservers(container, observers);
|
|
5163
6390
|
// The store calls ask: the test's own when it provides CooldownStore, else the app's
|
|
5164
6391
|
const boundStore = store ?? (providers$1.has(controller_decorator.CooldownStore) ? controller_decorator.CooldownStore : undefined);
|
|
5165
6392
|
// What the stages and presenter inject, as the bot makes it; a stage the test replaced contributes nothing
|
|
5166
|
-
const stageServices =
|
|
6393
|
+
const stageServices = eventSources.stageDependencies(startup, providers$1, appPresenterClass);
|
|
5167
6394
|
for (const service of stageServices)bindClass(service);
|
|
5168
6395
|
// The order the app runs lifecycle hooks in: its cooldown store, then providers, controllers and observers, each
|
|
5169
6396
|
// after what it injects
|
|
@@ -5187,39 +6414,34 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
|
|
|
5187
6414
|
cooldownStore: true
|
|
5188
6415
|
}
|
|
5189
6416
|
}));
|
|
6417
|
+
// Recorded as the container makes each one, before anything can be made, so close() shuts down exactly what exists
|
|
6418
|
+
const constructed = new Set();
|
|
6419
|
+
for (const { token } of lifecycle){
|
|
6420
|
+
container.onActivation(token, (_context, instance)=>{
|
|
6421
|
+
constructed.add(token);
|
|
6422
|
+
return instance;
|
|
6423
|
+
});
|
|
6424
|
+
}
|
|
5190
6425
|
// Every check has run, and touched only the container: from here the module is made, once none found an error
|
|
5191
6426
|
controller_decorator.stopOnStartupErrors();
|
|
5192
6427
|
// Every class the module runs, so @UseGuard resolves guards on a direct call to any of them
|
|
5193
|
-
|
|
5194
|
-
...this.options.controllers ?? [],
|
|
5195
|
-
...startup.classes
|
|
5196
|
-
])Reflect.defineMetadata(controller_decorator.META.container, container, cls);
|
|
5197
|
-
// And each instance with the module that made it, as another module made from the same classes stamps them again
|
|
5198
|
-
providers.recordInstanceContainers(container, [
|
|
6428
|
+
providers.prepareDirectCalls(container, [
|
|
5199
6429
|
...this.options.controllers ?? [],
|
|
5200
6430
|
...startup.classes
|
|
5201
6431
|
]);
|
|
5202
6432
|
providers.prepareThemes(container, appClasses);
|
|
5203
6433
|
// A handler with no builder is how a fixture is written, so only what is always a mistake is named
|
|
5204
|
-
|
|
6434
|
+
eventSources.warnUnregisteredCommands(this.options.controllers ?? [], {
|
|
5205
6435
|
missingBuilders: false
|
|
5206
6436
|
});
|
|
5207
|
-
|
|
5208
|
-
|
|
5209
|
-
|
|
5210
|
-
|
|
5211
|
-
// Recorded as the container makes each one, so close() shuts down exactly what exists
|
|
5212
|
-
const constructed = new Set();
|
|
5213
|
-
for (const { token } of lifecycle){
|
|
5214
|
-
container.onActivation(token, (_context, instance)=>{
|
|
5215
|
-
constructed.add(token);
|
|
5216
|
-
return instance;
|
|
5217
|
-
});
|
|
5218
|
-
}
|
|
6437
|
+
eventSources.warnInheritedRoutes(this.options.controllers ?? []);
|
|
6438
|
+
eventSources.warnOverlappingPatterns(this.options.controllers ?? []);
|
|
6439
|
+
eventSources.warnHandlersOffControllers(this.options.controllers ?? [], startup.classes);
|
|
6440
|
+
for (const warning of eventSources.perCallEventWarnings(startup, providers$1, appPresenterClass))new controller_decorator.Logger('TestingModule').warn(warning);
|
|
5219
6441
|
const warnUnanswered = this.options.app && Reflect.getMetadata(controller_decorator.META.appOptions, this.options.app)?.warnUnanswered;
|
|
5220
6442
|
return new TestingModule(container, [
|
|
5221
6443
|
...this.options.controllers ?? []
|
|
5222
|
-
], appClasses, providers$1, order, messages, lifecycle, constructed, warnUnanswered, services, this.options.shutdownTimeout ??
|
|
6444
|
+
], appClasses, providers$1, order, messages, lifecycle, constructed, warnUnanswered, services, this.options.shutdownTimeout ?? eventSources.DEFAULT_SHUTDOWN_TIMEOUT_MS, this.options.app);
|
|
5223
6445
|
});
|
|
5224
6446
|
} catch (error) {
|
|
5225
6447
|
if (controller_decorator.isRefusal(error) && !controller_decorator.isExplainedError(error)) {
|
|
@@ -5298,7 +6520,8 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
|
|
|
5298
6520
|
* @param app - The class `@MeoCord` decorates.
|
|
5299
6521
|
* @param options - Providers that replace the app's by token, extra controllers, and observers.
|
|
5300
6522
|
* @returns The builder, whose `override*` methods still apply, for `compile()`.
|
|
5301
|
-
* @throws TypeError when `app` is not a `@MeoCord` class.
|
|
6523
|
+
* @throws TypeError when `app` is not a `@MeoCord` class. After {@link reportAllStartupErrors}, an app whose
|
|
6524
|
+
* `@MeoCord` was refused throws the error it was refused for instead.
|
|
5302
6525
|
*
|
|
5303
6526
|
* @example
|
|
5304
6527
|
* ```ts
|
|
@@ -5338,8 +6561,19 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
|
|
|
5338
6561
|
* expect(getResponse(interaction).calls[0].payload).toMatchObject({ content: '1 notes' })
|
|
5339
6562
|
* ```
|
|
5340
6563
|
*/ static fromApp(app, options = {}) {
|
|
6564
|
+
// An app that isn't a class at all, as a circular import gives, has no metadata to read
|
|
6565
|
+
if (typeof app !== 'function') {
|
|
6566
|
+
const { name, hint } = notAClass(app);
|
|
6567
|
+
throw new TypeError(`${name} is not a @MeoCord app: fromApp takes the class @MeoCord decorates.${hint}`);
|
|
6568
|
+
}
|
|
5341
6569
|
const appOptions = Reflect.getMetadata(controller_decorator.META.appOptions, app);
|
|
5342
|
-
if (!appOptions)
|
|
6570
|
+
if (!appOptions) {
|
|
6571
|
+
// A refused @MeoCord stores no options, so its own errors, kept with startupErrors: 'all', say why
|
|
6572
|
+
controller_decorator.collectStartupErrors(()=>controller_decorator.declaredErrorsOf([
|
|
6573
|
+
app
|
|
6574
|
+
]).forEach(controller_decorator.startupError));
|
|
6575
|
+
throw new TypeError(`${app?.name ?? String(app)} is not a @MeoCord app: fromApp takes the class @MeoCord decorates.`);
|
|
6576
|
+
}
|
|
5343
6577
|
const controllers = [
|
|
5344
6578
|
...new Set([
|
|
5345
6579
|
...appOptions.controllers,
|
|
@@ -5385,236 +6619,33 @@ providers.adviseInPlaceOfInjecting(TestingModule, 'make it with MeoCordTestingMo
|
|
|
5385
6619
|
controller_decorator.setStartupErrorsMode('all');
|
|
5386
6620
|
}
|
|
5387
6621
|
|
|
5388
|
-
/** The options `@MeoCord` declares on an application class. */ function appOptionsOf(app) {
|
|
5389
|
-
const options = Reflect.getMetadata(controller_decorator.META.appOptions, app);
|
|
5390
|
-
if (!options) throw new TypeError(`${app.name || 'The given class'} is not decorated with @MeoCord().`);
|
|
5391
|
-
return options;
|
|
5392
|
-
}
|
|
5393
|
-
/** The controllers `@MeoCord({ controllers })` registers on an application class. */ function controllersOf(app) {
|
|
5394
|
-
return appOptionsOf(app).controllers ?? [];
|
|
5395
|
-
}
|
|
5396
|
-
/**
|
|
5397
|
-
* Resolves which handler a component's `customId` or a message's content reaches, as dispatch routes it.
|
|
5398
|
-
*
|
|
5399
|
-
* Use it to check routing alone, in a plain unit test: it reads decorator metadata, with no client, config or
|
|
5400
|
-
* container, and runs no guard. To run the handler it reaches, use {@link TestingModule.dispatch}.
|
|
5401
|
-
*
|
|
5402
|
-
* @remarks
|
|
5403
|
-
* It routes across every controller the app registers, most specific pattern first: a component within its type, a
|
|
5404
|
-
* message after the app's prefix, and never to a `@MessageHandler()` listener, which runs for every message.
|
|
5405
|
-
*
|
|
5406
|
-
* @param app - The application class decorated with `@MeoCord`.
|
|
5407
|
-
* @param input - The component type and its `customId`, or the message's content with its prefix and the bot's id.
|
|
5408
|
-
* @returns The handler that runs, with the params it captures, or `undefined` when no route handles the input.
|
|
5409
|
-
* @throws TypeError for a message to an app whose prefix is a function, when no `prefix` is given.
|
|
5410
|
-
*
|
|
5411
|
-
* @example
|
|
5412
|
-
* ```ts
|
|
5413
|
-
* import { expect } from 'vitest'
|
|
5414
|
-
*
|
|
5415
|
-
* @Controller()
|
|
5416
|
-
* class ProfileController {
|
|
5417
|
-
* @Command('profile/{uid}', CommandType.BUTTON)
|
|
5418
|
-
* async show(interaction: ButtonInteraction, { uid }: { uid: string }) {
|
|
5419
|
-
* await respond(interaction).send(`Profile ${uid}`)
|
|
5420
|
-
* }
|
|
5421
|
-
* }
|
|
5422
|
-
* @MeoCord({ controllers: [ProfileController], clientOptions: { intents: [] } })
|
|
5423
|
-
* class App {}
|
|
5424
|
-
* const route = resolveRoute(App, { type: CommandType.BUTTON, customId: 'profile/8000' })
|
|
5425
|
-
* expect([route?.handler, route?.params]).toEqual([ProfileController.prototype.show, { uid: '8000' }])
|
|
5426
|
-
* ```
|
|
5427
|
-
*
|
|
5428
|
-
* @group Testing
|
|
5429
|
-
* @category Inspection
|
|
5430
|
-
* @see {@link findRouteConflicts}
|
|
5431
|
-
* @see {@link https://meocord.dev/docs/4.2/components | Buttons, selects and modals}
|
|
5432
|
-
*/ function resolveRoute(app, input) {
|
|
5433
|
-
if ('content' in input) {
|
|
5434
|
-
const { messages = {} } = appOptionsOf(app);
|
|
5435
|
-
const starts = providers.staticMessageStarts(app, messages, input);
|
|
5436
|
-
const matched = providers.matchMessageRoute(providers.buildMessageRoutes(controllersOf(app), messages), input.content, starts);
|
|
5437
|
-
if (!matched) return undefined;
|
|
5438
|
-
const { route, params } = matched;
|
|
5439
|
-
// Dispatch answers a route that does not work in a DM with its usage, and never runs it
|
|
5440
|
-
if (starts.inGuild === false && (!providers.fitsScope(route.scope, false) || providers.needsGuild(route))) return undefined;
|
|
5441
|
-
return {
|
|
5442
|
-
controller: route.controllerClass,
|
|
5443
|
-
method: route.method,
|
|
5444
|
-
handler: route.controllerClass.prototype[route.method],
|
|
5445
|
-
params
|
|
5446
|
-
};
|
|
5447
|
-
}
|
|
5448
|
-
if (!controller_decorator.isCustomIdRouted(input.type)) {
|
|
5449
|
-
throw new TypeError(`${input.type} commands are routed by name, not by customId.`);
|
|
5450
|
-
}
|
|
5451
|
-
const routes = startupRoots.buildComponentRoutes(controllersOf(app));
|
|
5452
|
-
const matched = startupRoots.matchComponentRoute(routes, (type)=>type === input.type, input.customId);
|
|
5453
|
-
if (!matched) return undefined;
|
|
5454
|
-
const { route, params, text } = matched;
|
|
5455
|
-
const method = route.meta.methodName;
|
|
5456
|
-
const typed = Object.keys(route.types).length > 0;
|
|
5457
|
-
// The routes ranked behind it that the customId matches too, which dispatch never reaches for it
|
|
5458
|
-
const behind = routes.slice(routes.indexOf(route) + 1);
|
|
5459
|
-
const alsoMatches = behind.filter((other)=>other.meta.type === input.type && startupRoots.readCustomId(other.pattern, other.meta.regex, input.customId)).map((other)=>other.pattern);
|
|
5460
|
-
const resolved = {
|
|
5461
|
-
controller: route.controllerClass,
|
|
5462
|
-
method,
|
|
5463
|
-
handler: route.controllerClass.prototype[method],
|
|
5464
|
-
params: text,
|
|
5465
|
-
...typed && {
|
|
5466
|
-
values: params
|
|
5467
|
-
}
|
|
5468
|
-
};
|
|
5469
|
-
return alsoMatches.length > 0 ? readableOnly(resolved, {
|
|
5470
|
-
alsoMatches
|
|
5471
|
-
}) : resolved;
|
|
5472
|
-
}
|
|
5473
|
-
/**
|
|
5474
|
-
* Finds component patterns that rank equally and can match the same `customId`, which MeoCord otherwise only warns
|
|
5475
|
-
* about at startup.
|
|
5476
|
-
*
|
|
5477
|
-
* Use it in a test to keep the warning from reaching production. Patterns rank segment by segment, a literal before a
|
|
5478
|
-
* param and then the narrower type, so two collide only with the same literals and equally narrow params at each
|
|
5479
|
-
* position, such as `{action:close|reopen}` and `{step:close|confirm}`, which both take `close`; the one listed
|
|
5480
|
-
* first runs. Patterns are compared within a component type.
|
|
5481
|
-
*
|
|
5482
|
-
* @param app - The application class decorated with `@MeoCord`.
|
|
5483
|
-
* @returns Each pair of patterns that rank equally and can match the same `customId`, with its component type.
|
|
5484
|
-
*
|
|
5485
|
-
* @example
|
|
5486
|
-
* ```ts
|
|
5487
|
-
* import { expect } from 'vitest'
|
|
5488
|
-
*
|
|
5489
|
-
* @Controller()
|
|
5490
|
-
* class TicketController {
|
|
5491
|
-
* @Command('ticket/{action:close|reopen}', CommandType.BUTTON)
|
|
5492
|
-
* async act(interaction: ButtonInteraction) { await respond(interaction).send('Done.') }
|
|
5493
|
-
* @Command('ticket/{step:close|confirm}', CommandType.BUTTON)
|
|
5494
|
-
* async step(interaction: ButtonInteraction) { await respond(interaction).send('Next.') }
|
|
5495
|
-
* }
|
|
5496
|
-
* @MeoCord({ controllers: [TicketController], clientOptions: { intents: [] } })
|
|
5497
|
-
* class App {}
|
|
5498
|
-
* expect(findRouteConflicts(App)).toHaveLength(1)
|
|
5499
|
-
* ```
|
|
5500
|
-
*
|
|
5501
|
-
* @group Testing
|
|
5502
|
-
* @category Inspection
|
|
5503
|
-
* @see {@link resolveRoute}
|
|
5504
|
-
*/ function findRouteConflicts(app) {
|
|
5505
|
-
return startupRoots.findComponentRouteConflicts(startupRoots.buildComponentRoutes(controllersOf(app))).map(({ type, patterns, runs })=>readableOnly({
|
|
5506
|
-
type: type,
|
|
5507
|
-
patterns
|
|
5508
|
-
}, {
|
|
5509
|
-
runs
|
|
5510
|
-
}));
|
|
5511
|
-
}
|
|
5512
|
-
/**
|
|
5513
|
-
* `target` with `fields` added as properties a test reads but `toEqual` and its kin, which compare enumerable keys,
|
|
5514
|
-
* don't see, so an assertion written for the fields it had before still holds.
|
|
5515
|
-
*/ function readableOnly(target, fields) {
|
|
5516
|
-
for (const [key, value] of Object.entries(fields))Object.defineProperty(target, key, {
|
|
5517
|
-
value,
|
|
5518
|
-
enumerable: false,
|
|
5519
|
-
configurable: true
|
|
5520
|
-
});
|
|
5521
|
-
return target;
|
|
5522
|
-
}
|
|
5523
|
-
|
|
5524
6622
|
/**
|
|
5525
|
-
*
|
|
5526
|
-
*
|
|
5527
|
-
*
|
|
5528
|
-
|
|
5529
|
-
*
|
|
5530
|
-
* @param values - Each field's value, keyed by its customId: a string for a text input, an array of strings for a
|
|
5531
|
-
* select's chosen values, or an array of `Attachment`s for a file upload. An empty array is a select with nothing
|
|
5532
|
-
* chosen.
|
|
5533
|
-
* @returns Fields that `getTextInputValue`, `getStringSelectValues`, `getUploadedFiles` and a handler's params all
|
|
5534
|
-
* read.
|
|
5535
|
-
*
|
|
5536
|
-
* @example
|
|
5537
|
-
* ```ts
|
|
5538
|
-
* import { expect } from 'vitest'
|
|
5539
|
-
*
|
|
5540
|
-
* @Controller()
|
|
5541
|
-
* class FeedbackController {
|
|
5542
|
-
* @Command('feedback/{topic}', CommandType.MODAL_SUBMIT)
|
|
5543
|
-
* async submit(interaction: ModalSubmitInteraction, { topic, body }: { topic: string; body: string }) {
|
|
5544
|
-
* await respond(interaction).send(`Thanks: ${topic}, ${body}`)
|
|
5545
|
-
* }
|
|
5546
|
-
* }
|
|
5547
|
-
* const module = MeoCordTestingModule.create({ controllers: [FeedbackController] }).compile()
|
|
5548
|
-
* const fields = createModalFields({ body: 'It crashed' })
|
|
5549
|
-
* const interaction = createMockInteraction(ModalSubmitInteraction, { customId: 'feedback/bugs', fields })
|
|
5550
|
-
* await module.invoke(FeedbackController, 'submit', interaction)
|
|
5551
|
-
* expect(getResponse(interaction).calls[0].payload).toMatchObject({ content: 'Thanks: bugs, It crashed' })
|
|
5552
|
-
* ```
|
|
5553
|
-
*
|
|
5554
|
-
* @group Testing
|
|
5555
|
-
* @category Mocks
|
|
5556
|
-
* @see {@link createMockInteraction}
|
|
5557
|
-
*/ function createModalFields(values) {
|
|
5558
|
-
const component = (customId, value)=>{
|
|
5559
|
-
if (!Array.isArray(value)) return {
|
|
5560
|
-
type: discord_js.ComponentType.TextInput,
|
|
5561
|
-
customId,
|
|
5562
|
-
value
|
|
5563
|
-
};
|
|
5564
|
-
if (!value.some((item)=>item instanceof discord_js.Attachment)) return {
|
|
5565
|
-
type: discord_js.ComponentType.StringSelect,
|
|
5566
|
-
customId,
|
|
5567
|
-
values: value
|
|
5568
|
-
};
|
|
5569
|
-
const attachments = new discord_js.Collection(value.map((file)=>[
|
|
5570
|
-
file.id,
|
|
5571
|
-
file
|
|
5572
|
-
]));
|
|
5573
|
-
return {
|
|
5574
|
-
type: discord_js.ComponentType.FileUpload,
|
|
5575
|
-
customId,
|
|
5576
|
-
values: [
|
|
5577
|
-
...attachments.keys()
|
|
5578
|
-
],
|
|
5579
|
-
attachments
|
|
5580
|
-
};
|
|
5581
|
-
};
|
|
5582
|
-
const components = Object.entries(values).map(([customId, value])=>({
|
|
5583
|
-
type: discord_js.ComponentType.Label,
|
|
5584
|
-
component: component(customId, value)
|
|
5585
|
-
}));
|
|
5586
|
-
const Fields = discord_js.ModalSubmitFields;
|
|
5587
|
-
return new Fields(components);
|
|
5588
|
-
}
|
|
5589
|
-
|
|
5590
|
-
const PLURAL_CATEGORIES = new Set([
|
|
5591
|
-
'zero',
|
|
5592
|
-
'one',
|
|
5593
|
-
'two',
|
|
5594
|
-
'few',
|
|
5595
|
-
'many',
|
|
5596
|
-
'other'
|
|
5597
|
-
]);
|
|
5598
|
-
/** A plural: an object whose every key is a plural category, as the translator's types read one; any other is a group. */ const isPlural = (value)=>typeof value === 'object' && value !== null && Object.keys(value).length > 0 && Object.keys(value).every((key)=>PLURAL_CATEGORIES.has(key));
|
|
5599
|
-
/** Every message key of a catalog, and whether it is a plural. */ function leaves(catalog, prefix = '') {
|
|
6623
|
+
* Every message key of a catalog, and whether it is a plural. `reference` decides whether an object is a plural or a
|
|
6624
|
+
* group, as the translator reads a translation by the default catalog's shapes; where it lacks a key, the object's own
|
|
6625
|
+
* keys decide.
|
|
6626
|
+
*/ function leaves(catalog, prefix = '', reference = catalog) {
|
|
5600
6627
|
return Object.entries(catalog).flatMap(([name, value])=>{
|
|
5601
6628
|
const key = `${prefix}${name}`;
|
|
5602
|
-
|
|
6629
|
+
// A null message is one where it stands, which the checks below read as missing
|
|
6630
|
+
if (typeof value === 'string' || value === null) return [
|
|
5603
6631
|
{
|
|
5604
6632
|
key,
|
|
5605
6633
|
plural: false
|
|
5606
6634
|
}
|
|
5607
6635
|
];
|
|
5608
|
-
|
|
6636
|
+
const shape = typeof reference === 'object' && reference !== null && Object.hasOwn(reference, name) ? reference[name] : undefined;
|
|
6637
|
+
const plural = typeof shape === 'object' && shape !== null ? controller_decorator.isPluralShape(shape) : controller_decorator.isPluralShape(value);
|
|
6638
|
+
if (plural) return [
|
|
5609
6639
|
{
|
|
5610
6640
|
key,
|
|
5611
6641
|
plural: true
|
|
5612
6642
|
}
|
|
5613
6643
|
];
|
|
5614
|
-
return leaves(value, `${key}
|
|
6644
|
+
return leaves(value, `${key}.`, shape);
|
|
5615
6645
|
});
|
|
5616
6646
|
}
|
|
5617
6647
|
/** 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 !== '';
|
|
6648
|
+
/** A message the translator reads as missing: none, `null` or `''`, as tools export one untranslated. */ const isMissing = (value)=>value === undefined || value === null || value === '';
|
|
5618
6649
|
/** The value at a key's path, through own properties only, so `constructor` or `toString` is not read off a prototype. */ function at(catalog, key) {
|
|
5619
6650
|
return key.split('.').reduce((current, part)=>typeof current === 'object' && current !== null && Object.hasOwn(current, part) ? current[part] : undefined, catalog);
|
|
5620
6651
|
}
|
|
@@ -5629,7 +6660,7 @@ const isMeoCordKey = (key)=>key.startsWith('meocord.');
|
|
|
5629
6660
|
/** 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);
|
|
5630
6661
|
/** The `{params}` a translation uses that the message it translates doesn't take; a plural's `{count}` is taken. */ function strayParams(translation, original) {
|
|
5631
6662
|
const taken = placeholders(original);
|
|
5632
|
-
if (
|
|
6663
|
+
if (controller_decorator.isPluralShape(original)) taken.add('count');
|
|
5633
6664
|
return [
|
|
5634
6665
|
...placeholders(translation)
|
|
5635
6666
|
].filter((name)=>!taken.has(name));
|
|
@@ -5639,11 +6670,13 @@ const isMeoCordKey = (key)=>key.startsWith('meocord.');
|
|
|
5639
6670
|
*
|
|
5640
6671
|
* Use it in a test for a team that wants no fallback in production. It reports the messages a locale lacks, the
|
|
5641
6672
|
* messages the default catalog doesn't have, the plural forms a language needs but a plural lacks, such as `few` for
|
|
5642
|
-
* Russian, a translation of another shape than the default's, a text for a plural or a plural for a text, and each
|
|
5643
|
-
* `{param}` a translation uses that the message it translates doesn't take. An empty translation, `''`, counts
|
|
5644
|
-
* lacking, as the translator reads it as missing. It reads the catalogs' own strings, so it checks the params of a
|
|
5645
|
-
* catalog from a plain variable or a JSON file too, which the compiler can't. An object
|
|
5646
|
-
* its every key is a plural category, so a group with an `other` key is a group
|
|
6673
|
+
* Russian, a translation of another shape than the default's, a text for a plural or a group, or a plural for a text, and each
|
|
6674
|
+
* `{param}` a translation uses that the message it translates doesn't take. An empty translation, `''` or `null`, counts
|
|
6675
|
+
* as lacking, as the translator reads it as missing. It reads the catalogs' own strings, so it checks the params of a
|
|
6676
|
+
* catalog from a plain variable or a JSON file too, which the compiler can't. An object in the default catalog counts as
|
|
6677
|
+
* a plural only when its every key is a plural category, so a group with an `other` key is a group, and a translation
|
|
6678
|
+
* is read by the default catalog's shapes, as the translator reads it: one that keeps only a group's `other` message
|
|
6679
|
+
* lacks the group's other messages.
|
|
5647
6680
|
*
|
|
5648
6681
|
* @remarks
|
|
5649
6682
|
* MeoCord's own texts, a catalog's `meocord` group, fall back to English by design, so only a key MeoCord lacks, and
|
|
@@ -5690,12 +6723,12 @@ const isMeoCordKey = (key)=>key.startsWith('meocord.');
|
|
|
5690
6723
|
const categories = new Intl.PluralRules(locale).resolvedOptions().pluralCategories;
|
|
5691
6724
|
for (const { key, plural } of reference){
|
|
5692
6725
|
const message = at(catalog, key);
|
|
5693
|
-
if (message
|
|
6726
|
+
if (isMissing(message)) {
|
|
5694
6727
|
problems.push(`missing ${key}`);
|
|
5695
6728
|
continue;
|
|
5696
6729
|
}
|
|
5697
6730
|
// A translation of another shape than the default's can't be read as it: a text for a plural, or the reverse
|
|
5698
|
-
if (plural !==
|
|
6731
|
+
if (plural !== controller_decorator.isPluralShape(message) || !plural && typeof message !== 'string') {
|
|
5699
6732
|
problems.push(`${key} should be a ${plural ? 'plural' : 'text'}, as the default is`);
|
|
5700
6733
|
continue;
|
|
5701
6734
|
}
|
|
@@ -5708,9 +6741,11 @@ const isMeoCordKey = (key)=>key.startsWith('meocord.');
|
|
|
5708
6741
|
for (const name of stray)problems.push(`${key} takes no {${name}}; the default is "${shown(original)}"`);
|
|
5709
6742
|
}
|
|
5710
6743
|
if (options.meocord && !locale.startsWith('en-')) {
|
|
5711
|
-
for (const key of MEOCORD_KEYS)if (
|
|
6744
|
+
for (const key of MEOCORD_KEYS)if (isMissing(at(catalog, key))) problems.push(`missing ${key}`);
|
|
5712
6745
|
}
|
|
5713
|
-
for (const { key } of leaves(catalog)){
|
|
6746
|
+
for (const { key } of leaves(catalog, '', defaultCatalog)){
|
|
6747
|
+
// A message left out, as a null one is, that the default has is reported missing above, and is nothing extra
|
|
6748
|
+
if (isMissing(at(catalog, key)) && at(defaultCatalog, key) !== undefined) continue;
|
|
5714
6749
|
if (isMeoCordKey(key)) {
|
|
5715
6750
|
if (!MEOCORD_KEYS.has(key)) problems.push(`${key} is not one of MeoCord's texts`);
|
|
5716
6751
|
else {
|
|
@@ -5718,7 +6753,11 @@ const isMeoCordKey = (key)=>key.startsWith('meocord.');
|
|
|
5718
6753
|
const stray = strayParams(at(catalog, key), english);
|
|
5719
6754
|
for (const name of stray)problems.push(`${key} takes no {${name}}: MeoCord's English is "${shown(english)}"`);
|
|
5720
6755
|
}
|
|
5721
|
-
} else if (!referenceKeys.has(key))
|
|
6756
|
+
} else if (!referenceKeys.has(key)) {
|
|
6757
|
+
// A text where the default has a group: the key is the default's, in another shape
|
|
6758
|
+
const original = at(defaultCatalog, key);
|
|
6759
|
+
problems.push(typeof original === 'object' && original !== null ? `${key} should be a group, as the default is` : `${key} is not in the default catalog`);
|
|
6760
|
+
}
|
|
5722
6761
|
}
|
|
5723
6762
|
if (problems.length > 0) gaps.push(` ${locale}: ${problems.join('; ')}`);
|
|
5724
6763
|
}
|
|
@@ -6132,10 +7171,10 @@ const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
|
|
|
6132
7171
|
});
|
|
6133
7172
|
}
|
|
6134
7173
|
/** Whether a store overrides `consumeMany`, as a store that counts a batch at once does. */ function overridesConsumeMany(store) {
|
|
6135
|
-
return
|
|
7174
|
+
return !controller_decorator.isDefaultBatchMethod(store.consumeMany);
|
|
6136
7175
|
}
|
|
6137
7176
|
/** Whether a store overrides `peekMany`, as a store that can check a batch without recording it does. */ function overridesPeekMany(store) {
|
|
6138
|
-
return
|
|
7177
|
+
return !controller_decorator.isDefaultBatchMethod(store.peekMany);
|
|
6139
7178
|
}
|
|
6140
7179
|
|
|
6141
7180
|
/**
|
|
@@ -6192,8 +7231,10 @@ const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
|
|
|
6192
7231
|
* Reports what runs when a handler is dispatched, and the metadata declared on it, without running anything.
|
|
6193
7232
|
*
|
|
6194
7233
|
* Use it to check that decorators set a handler up as intended: its guards with their params, its interceptors,
|
|
6195
|
-
* filters and cooldowns, in the order dispatch applies them.
|
|
6196
|
-
*
|
|
7234
|
+
* filters and cooldowns, in the order dispatch applies them. Under `useStrictMocks()`, the guards, interceptors and
|
|
7235
|
+
* observers are those dispatch runs for the handler's context types: a stage declared with `types` is listed only for
|
|
7236
|
+
* a handler of one of them, and an autocomplete lists no interceptors. Default mode lists every one, with a warning
|
|
7237
|
+
* once where dispatch runs fewer. To check what a handler does, run it with {@link TestingModule.invoke}.
|
|
6197
7238
|
*
|
|
6198
7239
|
* @param controller - The controller class declaring the handler.
|
|
6199
7240
|
* @param methodName - The handler method's name.
|
|
@@ -6229,14 +7270,20 @@ const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
|
|
|
6229
7270
|
});
|
|
6230
7271
|
const globals = options.app ? providers.appStages(options.app) : undefined;
|
|
6231
7272
|
const { guards, interceptors, filters } = providers.handlerStages(controller.prototype, methodName, globals);
|
|
7273
|
+
const observers = options.app ? providers.appObservers(options.app) : [];
|
|
7274
|
+
const stages = runStages(controller.prototype, methodName, {
|
|
7275
|
+
guards,
|
|
7276
|
+
interceptors,
|
|
7277
|
+
observers
|
|
7278
|
+
});
|
|
6232
7279
|
const inspection = {
|
|
6233
7280
|
controller,
|
|
6234
7281
|
methodName,
|
|
6235
7282
|
guards: Object.freeze([
|
|
6236
|
-
...guards
|
|
7283
|
+
...stages.guards
|
|
6237
7284
|
]),
|
|
6238
7285
|
interceptors: Object.freeze([
|
|
6239
|
-
...interceptors
|
|
7286
|
+
...stages.interceptors
|
|
6240
7287
|
]),
|
|
6241
7288
|
filters: Object.freeze(filters.flat()),
|
|
6242
7289
|
cooldowns: Object.freeze(providers.handlerCooldowns(controller.prototype, methodName).map(({ seconds, uses, per, bypass, by })=>Object.freeze({
|
|
@@ -6247,31 +7294,99 @@ const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
|
|
|
6247
7294
|
by: by !== undefined
|
|
6248
7295
|
}))),
|
|
6249
7296
|
pattern: controller_decorator.getMessageHandlers(controller.prototype).find((handler)=>handler.method === methodName)?.pattern,
|
|
6250
|
-
observers: Object.freeze(
|
|
7297
|
+
observers: Object.freeze([
|
|
7298
|
+
...stages.observers
|
|
7299
|
+
]),
|
|
6251
7300
|
get: (metadata)=>context.get(metadata),
|
|
6252
7301
|
getAll: (metadata)=>context.getAll(metadata)
|
|
6253
7302
|
};
|
|
6254
7303
|
// Read by a test but not compared by toEqual, so an assertion on the result's other fields holds
|
|
7304
|
+
const routes = Object.freeze(inheritedRoutesOf(controller.prototype, methodName, false));
|
|
7305
|
+
const withEvents = Object.freeze(inheritedRoutesOf(controller.prototype, methodName, true));
|
|
6255
7306
|
Object.defineProperty(inspection, 'inheritedRoutes', {
|
|
6256
|
-
|
|
7307
|
+
get: ()=>{
|
|
7308
|
+
if (strictMocks()) return withEvents;
|
|
7309
|
+
if (withEvents.length > routes.length) {
|
|
7310
|
+
controller_decorator.warnOnce(new controller_decorator.Logger('inspectHandler'), `inspectHandler() here leaves the @On and @Once events ${controller.name}.${methodName} inherits out of ` + 'inheritedRoutes, though the bot answers them; under useStrictMocks() it lists them.');
|
|
7311
|
+
}
|
|
7312
|
+
return routes;
|
|
7313
|
+
},
|
|
6257
7314
|
enumerable: false
|
|
6258
7315
|
});
|
|
6259
7316
|
return inspection;
|
|
6260
7317
|
}
|
|
6261
|
-
/** The
|
|
7318
|
+
/** The context types dispatch calls `methodName` with: one for each kind of route it answers. */ function handlerContextTypes(prototype, methodName) {
|
|
7319
|
+
const answers = [
|
|
7320
|
+
[
|
|
7321
|
+
'interaction',
|
|
7322
|
+
Object.values(controller_decorator.getCommandMap(prototype) ?? {}).some((metas)=>metas.some((meta)=>meta.methodName === methodName))
|
|
7323
|
+
],
|
|
7324
|
+
[
|
|
7325
|
+
'autocomplete',
|
|
7326
|
+
controller_decorator.getAutocompleteHandlers(prototype).some((handler)=>handler.methodName === methodName)
|
|
7327
|
+
],
|
|
7328
|
+
[
|
|
7329
|
+
'message',
|
|
7330
|
+
controller_decorator.getMessageHandlers(prototype).some((handler)=>handler.method === methodName)
|
|
7331
|
+
],
|
|
7332
|
+
[
|
|
7333
|
+
'reaction',
|
|
7334
|
+
controller_decorator.getReactionHandlers(prototype).some((handler)=>handler.method === methodName)
|
|
7335
|
+
],
|
|
7336
|
+
[
|
|
7337
|
+
'event',
|
|
7338
|
+
providers.getEventHandlers(prototype).some((handler)=>handler.method === methodName)
|
|
7339
|
+
]
|
|
7340
|
+
];
|
|
7341
|
+
return answers.filter(([, answered])=>answered).map(([type])=>type);
|
|
7342
|
+
}
|
|
7343
|
+
/**
|
|
7344
|
+
* The guards, interceptors and observers dispatch runs for the handler, by the same `appliesTo` it uses: those for one
|
|
7345
|
+
* of its context types, and no interceptors for an autocomplete. Default mode lists every one, and warns once where
|
|
7346
|
+
* that lists more; a method no route reaches lists every one in both modes.
|
|
7347
|
+
*/ function runStages(prototype, methodName, all) {
|
|
7348
|
+
const types = handlerContextTypes(prototype, methodName);
|
|
7349
|
+
if (types.length === 0) return all;
|
|
7350
|
+
const forTypes = (entries, among)=>entries.filter((entry)=>among.some((type)=>providers.appliesTo(entry, type)));
|
|
7351
|
+
const run = {
|
|
7352
|
+
...all,
|
|
7353
|
+
guards: forTypes(all.guards, types),
|
|
7354
|
+
// Autocomplete answers within three seconds and has no reply to shape, so it runs no interceptors
|
|
7355
|
+
interceptors: forTypes(all.interceptors, types.filter((type)=>type !== 'autocomplete')),
|
|
7356
|
+
observers: forTypes(all.observers, types)
|
|
7357
|
+
};
|
|
7358
|
+
if (strictMocks()) return run;
|
|
7359
|
+
const handlerName = `${prototype.constructor.name}.${methodName}`;
|
|
7360
|
+
const count = (stages)=>stages.guards.length + stages.interceptors.length + stages.observers.length;
|
|
7361
|
+
if (count(run) < count(all)) {
|
|
7362
|
+
controller_decorator.warnOnce(new controller_decorator.Logger('inspectHandler'), `inspectHandler() here lists the guards, interceptors and observers of every context type for ${handlerName}, ` + "where dispatch runs only those of its own; under useStrictMocks() it lists the ones dispatch runs.");
|
|
7363
|
+
}
|
|
7364
|
+
return all;
|
|
7365
|
+
}
|
|
7366
|
+
/**
|
|
7367
|
+
* The routes `methodName` answers that the class declaring it last doesn't declare for it: those it inherits, its
|
|
7368
|
+
* `@On` and `@Once` events among them when `events` is set.
|
|
7369
|
+
*/ function inheritedRoutesOf(prototype, methodName, events) {
|
|
7370
|
+
const labels = (routes)=>routes.filter((route)=>route.method === methodName).map((route)=>route.label);
|
|
6262
7371
|
const answered = [
|
|
6263
|
-
...new Set(
|
|
7372
|
+
...new Set(labels([
|
|
7373
|
+
...controller_decorator.getHandlerRoutes(prototype),
|
|
7374
|
+
...events ? providers.getEventHandlers(prototype).map(controller_decorator.eventRoute) : []
|
|
7375
|
+
]))
|
|
6264
7376
|
];
|
|
6265
7377
|
// The nearest class with its own decorators on the method; a class that only overrides it declares none
|
|
6266
7378
|
for(let at = prototype; at && at !== Object.prototype; at = Object.getPrototypeOf(at)){
|
|
6267
|
-
const own =
|
|
7379
|
+
const own = labels([
|
|
7380
|
+
...controller_decorator.getDeclaredRoutes(at),
|
|
7381
|
+
...events ? controller_decorator.ownEventHandlers(at).map(controller_decorator.eventRoute) : []
|
|
7382
|
+
]);
|
|
6268
7383
|
if (own.length > 0) return at === prototype ? answered.filter((label)=>!own.includes(label)) : answered;
|
|
6269
7384
|
}
|
|
6270
7385
|
return [];
|
|
6271
7386
|
}
|
|
6272
7387
|
|
|
6273
|
-
// Themes createMockTheme made, which withTheme uses as they are
|
|
6274
|
-
const mocks = new WeakSet();
|
|
7388
|
+
// Themes createMockTheme made through either build of this version, which withTheme uses as they are
|
|
7389
|
+
const mocks = controller_decorator.shared('mockThemes', ()=>new WeakSet());
|
|
6275
7390
|
function mockTheme(overrides, where) {
|
|
6276
7391
|
if (overrides === undefined) return controller_decorator.defaultTheme();
|
|
6277
7392
|
controller_decorator.assertValidTheme(overrides, where);
|