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
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
import 'reflect-metadata';
|
|
2
2
|
import { createMockFn, usesRunnerMockFn } from './mock-fn.js';
|
|
3
|
-
import { DataManager, RoleManager, Role, GuildChannel, GuildMember, User, BaseGuild, MessageMentions, ThreadChannel, Message, BaseGuildVoiceChannel, VoiceChannel, BaseChannel, MessageReaction, InteractionType, ComponentType, ApplicationCommandType, GuildMemberManager, GuildChannelManager, UserManager, ChannelManager, GuildManager, Guild, GuildBanManager, GuildBan, ThreadMemberManager, ThreadMember, GuildMessageManager, DMMessageManager, GuildTextThreadManager, GuildForumThreadManager, PermissionOverwriteManager, PermissionOverwrites, ReactionManager, ReactionUserManager, PresenceManager, Presence,
|
|
3
|
+
import { DataManager, RoleManager, Role, GuildChannel, GuildMember, User, BaseGuild, MessageMentions, ThreadChannel, Message, BaseGuildVoiceChannel, VoiceChannel, BaseChannel, MessageReaction, ChannelType, MediaChannel, ForumChannel, DirectoryChannel, StageChannel, NewsChannel, CategoryChannel, TextChannel, InteractionType, ComponentType, ApplicationCommandType, GuildMemberManager, GuildMemberRoleManager, GuildChannelManager, UserManager, ChannelManager, GuildManager, Guild, GuildBanManager, GuildBan, ThreadMemberManager, ThreadMember, GuildMessageManager, DMMessageManager, GuildTextThreadManager, GuildForumThreadManager, PermissionOverwriteManager, PermissionOverwrites, ReactionManager, ReactionUserManager, PresenceManager, Presence, GuildEmojiManager, GuildEmoji, GuildStickerManager, Sticker, GuildScheduledEventManager, GuildScheduledEvent, AutoModerationRuleManager, AutoModerationRule, GuildInviteManager, Invite, ApplicationCommandOptionType, CommandInteractionOptionResolver, Client, ApplicationCommandManager, ClientUser, CDN, AuthorizingIntegrationOwners, BaseInteraction, UserContextMenuCommandInteraction, MessageContextMenuCommandInteraction, Attachment, Locale, StringSelectMenuInteraction, UserSelectMenuInteraction, RoleSelectMenuInteraction, ChannelSelectMenuInteraction, MentionableSelectMenuInteraction, Collection, PermissionsBitField, Base, SnowflakeUtil, DMChannel, MessageFlagsBitField, BaseManager, Component, Embed, ActionRow, GuildEmojiRoleManager, ApplicationCommand, DiscordjsErrorCodes, DiscordjsTypeError, MessageFlags, DiscordjsError } from 'discord.js';
|
|
4
4
|
import { createDiscordError } from './response.js';
|
|
5
5
|
import { stampCall } from '../common/response/call-order.js';
|
|
6
|
-
import {
|
|
6
|
+
import { discordDefaults, REAL_GETTER, placeholderValue } from './discord-defaults.js';
|
|
7
7
|
import { Logger } from '../common/logger.js';
|
|
8
|
-
import {
|
|
8
|
+
import { warnPlaceholder, warnOnce } from '../common/deprecation.js';
|
|
9
9
|
import { embedsAsDiscordStores, asDiscordStores } from './discord-shape.js';
|
|
10
10
|
import { nextSnowflake, MOCK_BOT_ID } from './snowflake.js';
|
|
11
11
|
import { strictMocks, noteMockMade } from './strict-mocks.js';
|
|
12
|
-
import {
|
|
12
|
+
import { RESPONSE_LOG } from './response-log.js';
|
|
13
13
|
|
|
14
14
|
// ---------------------------------------------------------------------------
|
|
15
15
|
// stubDeep — Proxy that answers each property it lacks: a mock fn for a method, a default, or a nested stub
|
|
@@ -25,8 +25,12 @@ const SKIP = new Set([
|
|
|
25
25
|
const mockTargets = new WeakMap();
|
|
26
26
|
// Warnings a read answered by the stub logs first, by the mock's instance and the property
|
|
27
27
|
const stubReadWarnings = new WeakMap();
|
|
28
|
+
// When each mock was made, which what Discord stamps when a structure arrives, such as a member's join, reads
|
|
29
|
+
const madeTimes = new WeakMap();
|
|
30
|
+
/** When `mock` was made: the time a value Discord stamps as it sends the structure reads. */ const madeAt = (mock)=>madeTimes.get(mock) ?? Date.now();
|
|
28
31
|
function stubDeep(instance, externalStubs, onSet) {
|
|
29
32
|
noteMockMade();
|
|
33
|
+
const made = Date.now();
|
|
30
34
|
const stubs = externalStubs ?? new Map();
|
|
31
35
|
// Placeholders read so far, by the value strict mocks give: a read of a kept one still warns, once
|
|
32
36
|
const placeholders = new Map();
|
|
@@ -91,7 +95,7 @@ function stubDeep(instance, externalStubs, onSet) {
|
|
|
91
95
|
}
|
|
92
96
|
// A client the mock's structures share: its guild's, or one of its own
|
|
93
97
|
if (key === 'client' && target instanceof Base) return clientOf(target);
|
|
94
|
-
const stub = typeof protoValue === 'function' ? isRealMethod(target, key) ? createMockFn((...args)=>quietly(target, key, ()=>protoValue.apply(proxy, args))) : methodStub(target, key, protoValue, ()=>proxy) :
|
|
98
|
+
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();
|
|
95
99
|
stubs.set(key, stub);
|
|
96
100
|
return stub;
|
|
97
101
|
},
|
|
@@ -114,6 +118,7 @@ function stubDeep(instance, externalStubs, onSet) {
|
|
|
114
118
|
}
|
|
115
119
|
});
|
|
116
120
|
mockTargets.set(proxy, instance);
|
|
121
|
+
madeTimes.set(proxy, made);
|
|
117
122
|
return proxy;
|
|
118
123
|
}
|
|
119
124
|
// discord.js methods that read only what the mock holds, so they run for real: looking items up in a manager's cache,
|
|
@@ -187,6 +192,32 @@ const STRICT_REAL_METHODS = new Map([
|
|
|
187
192
|
])
|
|
188
193
|
]
|
|
189
194
|
]);
|
|
195
|
+
// A channel's permission methods, each of which reads the channel's server
|
|
196
|
+
const SERVER_PERMISSION_METHODS = new Set([
|
|
197
|
+
'permissionsFor',
|
|
198
|
+
'overwritesFor',
|
|
199
|
+
'memberPermissions',
|
|
200
|
+
'rolePermissions'
|
|
201
|
+
]);
|
|
202
|
+
// The stubs a mock makes for what a test never gave it, as opposed to anything a test passes or sets
|
|
203
|
+
const autoStubs = new WeakSet();
|
|
204
|
+
function autoStub() {
|
|
205
|
+
const stub = stubDeep({});
|
|
206
|
+
autoStubs.add(stub);
|
|
207
|
+
return stub;
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Whether a channel's permission method has no server to read: in default mode a channel made without one holds the
|
|
211
|
+
* mock's own stub for it, so the method returns `undefined`, as a thread's does, and says so once. A server the test
|
|
212
|
+
* gave, of whatever class, is read as it is. Strict mocks give the channel one.
|
|
213
|
+
*/ function lacksServer(target, key, channel) {
|
|
214
|
+
if (strictMocks() || !(target instanceof GuildChannel) || !SERVER_PERMISSION_METHODS.has(key)) return false;
|
|
215
|
+
const guild = readPlaceQuietly(()=>channel.guild);
|
|
216
|
+
if (typeof guild !== 'object' || guild === null || !autoStubs.has(guild)) return false;
|
|
217
|
+
const name = Object.getPrototypeOf(target).constructor.name;
|
|
218
|
+
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.`);
|
|
219
|
+
return true;
|
|
220
|
+
}
|
|
190
221
|
function isRealMethod(target, key) {
|
|
191
222
|
for(let proto = Object.getPrototypeOf(target); proto !== null; proto = Object.getPrototypeOf(proto)){
|
|
192
223
|
if (REAL_METHODS.get(proto)?.has(key) || strictMocks() && STRICT_REAL_METHODS.get(proto)?.has(key)) return true;
|
|
@@ -331,28 +362,68 @@ function clientOf(target) {
|
|
|
331
362
|
// ---------------------------------------------------------------------------
|
|
332
363
|
// methodStub — what an auto-stubbed discord.js method returns
|
|
333
364
|
// ---------------------------------------------------------------------------
|
|
334
|
-
// discord.js methods not declared `async` that still return a promise; `set*` setters are matched by name
|
|
365
|
+
// discord.js methods not declared `async` that still return a promise; `set*` setters are matched by name.
|
|
366
|
+
// mock-promise-methods.spec checks this list against discord.js's typings
|
|
335
367
|
const PROMISE_METHODS = new Set([
|
|
368
|
+
'awaitMessageComponent',
|
|
369
|
+
'awaitMessages',
|
|
370
|
+
'awaitReactions',
|
|
336
371
|
'ban',
|
|
372
|
+
'bulkBan',
|
|
337
373
|
'clone',
|
|
374
|
+
'create',
|
|
338
375
|
'createDM',
|
|
339
376
|
'createInvite',
|
|
377
|
+
'createStageInstance',
|
|
378
|
+
'createWebhook',
|
|
379
|
+
'delete',
|
|
380
|
+
'deleteDM',
|
|
340
381
|
'disableCommunicationUntil',
|
|
382
|
+
'disconnect',
|
|
341
383
|
'edit',
|
|
342
384
|
'fetch',
|
|
385
|
+
'fetchAuthor',
|
|
343
386
|
'fetchFlags',
|
|
344
387
|
'fetchInvites',
|
|
345
388
|
'fetchMe',
|
|
346
389
|
'fetchReply',
|
|
390
|
+
'fetchSubscribers',
|
|
391
|
+
'fetchUser',
|
|
392
|
+
'fetchVoters',
|
|
347
393
|
'fetchWebhooks',
|
|
348
394
|
'fetchWidget',
|
|
349
395
|
'forward',
|
|
396
|
+
'kick',
|
|
350
397
|
'pin',
|
|
398
|
+
'rawFetchGuildActiveThreads',
|
|
351
399
|
'removeAttachments',
|
|
352
400
|
'suppressEmbeds',
|
|
353
401
|
'timeout',
|
|
402
|
+
'unban',
|
|
354
403
|
'unpin'
|
|
355
404
|
]);
|
|
405
|
+
// Methods that return a promise on one class only, as their name is synchronous on another: a role manager's set, and
|
|
406
|
+
// a scheduled event's setStatus, where a client user's setStatus applies at once
|
|
407
|
+
const PROMISE_METHODS_OF = [
|
|
408
|
+
[
|
|
409
|
+
GuildEmojiRoleManager,
|
|
410
|
+
new Set([
|
|
411
|
+
'set'
|
|
412
|
+
])
|
|
413
|
+
],
|
|
414
|
+
[
|
|
415
|
+
GuildMemberRoleManager,
|
|
416
|
+
new Set([
|
|
417
|
+
'set'
|
|
418
|
+
])
|
|
419
|
+
],
|
|
420
|
+
[
|
|
421
|
+
GuildScheduledEvent,
|
|
422
|
+
new Set([
|
|
423
|
+
'setStatus'
|
|
424
|
+
])
|
|
425
|
+
]
|
|
426
|
+
];
|
|
356
427
|
// The client user's presence setters, which apply at once and return the presence
|
|
357
428
|
const SYNC_SETTERS = new Set([
|
|
358
429
|
'setActivity',
|
|
@@ -377,27 +448,129 @@ const SELF_METHODS = new Set([
|
|
|
377
448
|
'disableCommunicationUntil',
|
|
378
449
|
'edit',
|
|
379
450
|
'fetch',
|
|
451
|
+
'kick',
|
|
380
452
|
'pin',
|
|
381
453
|
'timeout',
|
|
382
454
|
'unpin'
|
|
383
455
|
]);
|
|
456
|
+
// Collectors that resolve to what they collected: nothing, for a mock no one answers
|
|
457
|
+
const COLLECTING_METHODS = new Set([
|
|
458
|
+
'awaitMessages',
|
|
459
|
+
'awaitReactions'
|
|
460
|
+
]);
|
|
384
461
|
// The DM channel of each user, made once, as Discord keeps one per user
|
|
385
462
|
const dmChannels = new WeakMap();
|
|
386
|
-
|
|
463
|
+
/**
|
|
464
|
+
* A user's one DM channel: its recipient is the user, and it is the user's `dmChannel`. Under strict mocks its client
|
|
465
|
+
* 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.
|
|
466
|
+
*/ function dmChannelOf(user) {
|
|
387
467
|
let channel = dmChannels.get(user);
|
|
388
468
|
if (!channel) {
|
|
389
|
-
|
|
390
|
-
|
|
469
|
+
const made = createMockChannel(DMChannel);
|
|
470
|
+
// Getters in discord.js, so set through the mock, which defines them
|
|
471
|
+
made.recipientId = user.id;
|
|
472
|
+
if (user instanceof User) {
|
|
473
|
+
// Not enumerated, so a DM channel's own keys, and a user's, stay those a test may compare
|
|
474
|
+
const place = (target, key, value)=>Object.defineProperty(target, key, {
|
|
475
|
+
value,
|
|
476
|
+
writable: true,
|
|
477
|
+
configurable: true
|
|
478
|
+
});
|
|
479
|
+
place(made, 'recipient', user);
|
|
480
|
+
place(user, 'dmChannel', made);
|
|
481
|
+
if (user.client instanceof Client) {
|
|
482
|
+
if (strictMocks()) place(made, 'client', user.client);
|
|
483
|
+
else warnOnClientRead(made, user.client);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
channel = made;
|
|
391
487
|
dmChannels.set(user, channel);
|
|
392
488
|
}
|
|
393
489
|
return channel;
|
|
394
490
|
}
|
|
491
|
+
/**
|
|
492
|
+
* 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
|
|
493
|
+
* that reads it hears of the difference and the mock's own reads, as a send through the channel makes, stay quiet.
|
|
494
|
+
*/ function warnOnClientRead(channel, userClient) {
|
|
495
|
+
if (readPlaceQuietly(()=>channel.client) === userClient) return;
|
|
496
|
+
// The read is answered by the channel's stub, which warns by the mock's own instance
|
|
497
|
+
warnOnRead(mockTargets.get(channel) ?? channel, 'client', ()=>{
|
|
498
|
+
if (readingPlace > 0) return;
|
|
499
|
+
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.');
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
// What default mode's user manager DMs do apart from discord.js's, warned of once each
|
|
503
|
+
const USER_MANAGER_DM_WARNINGS = {
|
|
504
|
+
createDM: 'UserManager.createDM() here gives every user the same DM channel; under useStrictMocks() each user has their own, ' + 'as Discord keeps one per user.',
|
|
505
|
+
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."
|
|
506
|
+
};
|
|
507
|
+
/** A user made for a manager, on the manager's client, as discord.js makes one. */ function userOf(manager, id) {
|
|
508
|
+
const user = createMockUser(id ? {
|
|
509
|
+
id
|
|
510
|
+
} : {});
|
|
511
|
+
const client = manager.client;
|
|
512
|
+
if (client instanceof Client) Object.defineProperty(user, 'client', {
|
|
513
|
+
value: client,
|
|
514
|
+
writable: true,
|
|
515
|
+
configurable: true
|
|
516
|
+
});
|
|
517
|
+
return user;
|
|
518
|
+
}
|
|
395
519
|
/** The guild a manager belongs to, when it is a mock guild's. */ const guildOf = (manager)=>{
|
|
396
520
|
const guild = manager.guild;
|
|
397
521
|
return guild instanceof Guild ? guild : undefined;
|
|
398
522
|
};
|
|
399
|
-
// The
|
|
400
|
-
|
|
523
|
+
// The class discord.js makes for each type of channel a server's channel manager creates; a text channel by default
|
|
524
|
+
const CHANNEL_CLASSES = {
|
|
525
|
+
[ChannelType.GuildText]: TextChannel,
|
|
526
|
+
[ChannelType.GuildVoice]: VoiceChannel,
|
|
527
|
+
[ChannelType.GuildCategory]: CategoryChannel,
|
|
528
|
+
[ChannelType.GuildAnnouncement]: NewsChannel,
|
|
529
|
+
[ChannelType.GuildStageVoice]: StageChannel,
|
|
530
|
+
[ChannelType.GuildDirectory]: DirectoryChannel,
|
|
531
|
+
[ChannelType.GuildForum]: ForumChannel,
|
|
532
|
+
[ChannelType.GuildMedia]: MediaChannel
|
|
533
|
+
};
|
|
534
|
+
/**
|
|
535
|
+
* The class of a channel a server's channel manager makes for `type`: under strict mocks the one discord.js makes,
|
|
536
|
+
* while default mode keeps a text channel and warns once for another type.
|
|
537
|
+
*/ function createdChannelClass(type) {
|
|
538
|
+
const Class = CHANNEL_CLASSES[type ?? ChannelType.GuildText] ?? TextChannel;
|
|
539
|
+
if (Class === TextChannel || strictMocks()) return Class;
|
|
540
|
+
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.');
|
|
541
|
+
return TextChannel;
|
|
542
|
+
}
|
|
543
|
+
/** A thread a channel's thread manager makes: in that channel, as its parent, and in its server. */ function threadIn(id, manager) {
|
|
544
|
+
const channel = manager.channel;
|
|
545
|
+
if (!(channel instanceof GuildChannel)) return createMockChannel(ThreadChannel, id ? {
|
|
546
|
+
id
|
|
547
|
+
} : {});
|
|
548
|
+
const guild = channel.guild;
|
|
549
|
+
const thread = createMockChannel(ThreadChannel, id ? {
|
|
550
|
+
id
|
|
551
|
+
} : {});
|
|
552
|
+
// Not enumerated, so a mock thread's own keys stay those of one made alone, as a test may compare
|
|
553
|
+
const place = (key, value)=>Object.defineProperty(thread, key, {
|
|
554
|
+
value,
|
|
555
|
+
writable: true,
|
|
556
|
+
configurable: true
|
|
557
|
+
});
|
|
558
|
+
place('parentId', channel.id);
|
|
559
|
+
if (!(guild instanceof Guild)) return thread;
|
|
560
|
+
place('guild', guild);
|
|
561
|
+
place('guildId', guild.id);
|
|
562
|
+
// The parent is found through its server's channels, as discord.js finds it, by the thread's parentId. Strict mocks
|
|
563
|
+
// cache the channel there; default mode leaves the server's cache as it was and reads the channel itself
|
|
564
|
+
if (strictMocks()) cached(cacheOf(guild.channels), channel.id, ()=>channel);
|
|
565
|
+
Object.defineProperty(thread, 'parent', {
|
|
566
|
+
get: ()=>{
|
|
567
|
+
const parentId = thread.parentId ?? '';
|
|
568
|
+
return cacheOf(guild.channels)?.get(parentId) ?? (parentId === channel.id ? channel : null);
|
|
569
|
+
},
|
|
570
|
+
configurable: true
|
|
571
|
+
});
|
|
572
|
+
return thread;
|
|
573
|
+
}
|
|
401
574
|
const MANAGER_ITEMS = [
|
|
402
575
|
[
|
|
403
576
|
UserManager,
|
|
@@ -439,9 +612,10 @@ const MANAGER_ITEMS = [
|
|
|
439
612
|
()=>createMockInteraction(GuildBan)
|
|
440
613
|
],
|
|
441
614
|
// A message fetched from a channel is in it, and another user's
|
|
615
|
+
// A message made in the channel is the one fetched by its id, as Discord has it
|
|
442
616
|
[
|
|
443
617
|
GuildMessageManager,
|
|
444
|
-
(id, manager)=>createMockMessage({
|
|
618
|
+
(id, manager)=>madeIn(manager, id) ?? createMockMessage({
|
|
445
619
|
...id ? {
|
|
446
620
|
id
|
|
447
621
|
} : {},
|
|
@@ -450,7 +624,7 @@ const MANAGER_ITEMS = [
|
|
|
450
624
|
],
|
|
451
625
|
[
|
|
452
626
|
DMMessageManager,
|
|
453
|
-
(id, manager)=>createMockMessage({
|
|
627
|
+
(id, manager)=>madeIn(manager, id) ?? createMockMessage({
|
|
454
628
|
...id ? {
|
|
455
629
|
id
|
|
456
630
|
} : {},
|
|
@@ -459,15 +633,11 @@ const MANAGER_ITEMS = [
|
|
|
459
633
|
],
|
|
460
634
|
[
|
|
461
635
|
GuildTextThreadManager,
|
|
462
|
-
|
|
463
|
-
id
|
|
464
|
-
} : {})
|
|
636
|
+
threadIn
|
|
465
637
|
],
|
|
466
638
|
[
|
|
467
639
|
GuildForumThreadManager,
|
|
468
|
-
|
|
469
|
-
id
|
|
470
|
-
} : {})
|
|
640
|
+
threadIn
|
|
471
641
|
],
|
|
472
642
|
[
|
|
473
643
|
ThreadMemberManager,
|
|
@@ -493,7 +663,7 @@ const MANAGER_ITEMS = [
|
|
|
493
663
|
],
|
|
494
664
|
[
|
|
495
665
|
GuildChannelManager,
|
|
496
|
-
(id, manager)=>createMockChannel(
|
|
666
|
+
(id, manager, options)=>createMockChannel(createdChannelClass(options?.type), {
|
|
497
667
|
...id ? {
|
|
498
668
|
id
|
|
499
669
|
} : {},
|
|
@@ -501,9 +671,65 @@ const MANAGER_ITEMS = [
|
|
|
501
671
|
guild: guildOf(manager)
|
|
502
672
|
} : {}
|
|
503
673
|
})
|
|
674
|
+
],
|
|
675
|
+
[
|
|
676
|
+
GuildEmojiManager,
|
|
677
|
+
(id, manager)=>itemInGuild(GuildEmoji, {
|
|
678
|
+
id: id ?? nextSnowflake()
|
|
679
|
+
}, manager)
|
|
680
|
+
],
|
|
681
|
+
[
|
|
682
|
+
GuildStickerManager,
|
|
683
|
+
(id, manager)=>itemInGuild(Sticker, {
|
|
684
|
+
id: id ?? nextSnowflake()
|
|
685
|
+
}, manager)
|
|
686
|
+
],
|
|
687
|
+
[
|
|
688
|
+
GuildScheduledEventManager,
|
|
689
|
+
(id, manager)=>itemInGuild(GuildScheduledEvent, {
|
|
690
|
+
id: id ?? nextSnowflake()
|
|
691
|
+
}, manager)
|
|
692
|
+
],
|
|
693
|
+
[
|
|
694
|
+
AutoModerationRuleManager,
|
|
695
|
+
(id, manager)=>itemInGuild(AutoModerationRule, {
|
|
696
|
+
id: id ?? nextSnowflake()
|
|
697
|
+
}, manager)
|
|
698
|
+
],
|
|
699
|
+
// An invite is keyed by its code, as discord.js caches and fetches one
|
|
700
|
+
[
|
|
701
|
+
GuildInviteManager,
|
|
702
|
+
(code, manager)=>itemInGuild(Invite, {
|
|
703
|
+
code: code ?? BigInt(nextSnowflake()).toString(36)
|
|
704
|
+
}, manager)
|
|
504
705
|
]
|
|
505
706
|
];
|
|
506
|
-
|
|
707
|
+
/** 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) {
|
|
708
|
+
const guild = guildOf(manager);
|
|
709
|
+
return createMockInteraction(Class, {
|
|
710
|
+
...props,
|
|
711
|
+
...guild ? {
|
|
712
|
+
guild,
|
|
713
|
+
guildId: guild.id
|
|
714
|
+
} : {}
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
/**
|
|
718
|
+
* Names what a manager's create() or edit() made the `name` its options give, as discord.js does, under strict mocks;
|
|
719
|
+
* default mode keeps the name it has, with a warning once.
|
|
720
|
+
*/ function withName(manager, method, item, options) {
|
|
721
|
+
const name = options?.name;
|
|
722
|
+
const current = item.name;
|
|
723
|
+
if (typeof name !== 'string' || current === name) return item;
|
|
724
|
+
if (strictMocks()) {
|
|
725
|
+
item.name = name;
|
|
726
|
+
return item;
|
|
727
|
+
}
|
|
728
|
+
const Manager = Object.getPrototypeOf(manager).constructor.name;
|
|
729
|
+
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.');
|
|
730
|
+
return item;
|
|
731
|
+
}
|
|
732
|
+
/** 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);
|
|
507
733
|
// The ids a fetch asks for: an id, a discord.js object, or options naming one or several, such as `{ user: id }` or
|
|
508
734
|
// `{ user: [ids] }`; undefined for a fetch of a list
|
|
509
735
|
/** The channel a message manager belongs to, as `createMockMessage` takes it. */ const channelOfManager = (manager)=>{
|
|
@@ -531,18 +757,58 @@ function fetchedIds(args) {
|
|
|
531
757
|
}
|
|
532
758
|
return undefined;
|
|
533
759
|
}
|
|
760
|
+
// How many messages discord.js's MessageManager.fetch() asks Discord for when its options give no limit
|
|
761
|
+
const DEFAULT_FETCH_LIMIT = 50;
|
|
762
|
+
/** Which of two messages is newer, by snowflake id, else by when the mock was made: negative when `a` is. */ function newerFirst(a, b) {
|
|
763
|
+
if (isSnowflake(a.id) && isSnowflake(b.id) && a.id !== b.id) return BigInt(b.id) > BigInt(a.id) ? 1 : -1;
|
|
764
|
+
return b.createdTimestamp - a.createdTimestamp;
|
|
765
|
+
}
|
|
766
|
+
/**
|
|
767
|
+
* A channel's messages as `messages.fetch(options)` lists them under strict mocks: those its cache holds, newest first,
|
|
768
|
+
* before, after or around the message `options` names, at most `limit` of them.
|
|
769
|
+
*/ function recentMessages(manager, options) {
|
|
770
|
+
const { limit = DEFAULT_FETCH_LIMIT, before, after, around } = options ?? {};
|
|
771
|
+
const all = [
|
|
772
|
+
...cacheOf(manager)?.values() ?? []
|
|
773
|
+
];
|
|
774
|
+
const sorted = all.sort(newerFirst);
|
|
775
|
+
const at = (id)=>sorted.findIndex((message)=>message.id === id);
|
|
776
|
+
let window = sorted;
|
|
777
|
+
if (before !== undefined) window = at(before) === -1 ? [] : sorted.slice(at(before) + 1);
|
|
778
|
+
else if (after !== undefined) window = at(after) === -1 ? [] : sorted.slice(0, at(after)).slice(-limit);
|
|
779
|
+
else if (around !== undefined) window = at(around) === -1 ? [] : sorted.slice(Math.max(0, at(around) - Math.floor(limit / 2)));
|
|
780
|
+
return new Collection(window.slice(0, limit).map((message)=>[
|
|
781
|
+
message.id,
|
|
782
|
+
message
|
|
783
|
+
]));
|
|
784
|
+
}
|
|
534
785
|
/**
|
|
535
786
|
* The mock function for a method found on a discord.js prototype. One that returns a promise in discord.js resolves:
|
|
536
|
-
* `createDM` to the user's DM channel, `send` and its kin to a message,
|
|
537
|
-
* item (a collection of each for a fetch of several,
|
|
538
|
-
* `delete`, `ban`, `pin`, `unpin`, `timeout`,
|
|
539
|
-
* `undefined`. Any other method returns
|
|
787
|
+
* `createDM` to the user's DM channel, `send` and its kin to a message, `awaitMessages` and `awaitReactions` to an
|
|
788
|
+
* empty collection, a manager's `fetch`, `create` and `edit` to its item (a collection of each for a fetch of several,
|
|
789
|
+
* an empty one for a list fetch), a structure's own `edit`, `fetch`, `delete`, `ban`, `kick`, `pin`, `unpin`, `timeout`,
|
|
790
|
+
* `disableCommunicationUntil` and setters to the structure, and the rest to `undefined`. Any other method returns
|
|
791
|
+
* `undefined`.
|
|
540
792
|
*/ function methodStub(target, key, method, receiver) {
|
|
541
|
-
if (!returnsPromise(key, method)) return createMockFn();
|
|
793
|
+
if (!returnsPromise(target, key, method)) return createMockFn();
|
|
542
794
|
// A member's DMs go through its user, and a user's through its one DM channel, as discord.js sends them
|
|
543
795
|
if (target instanceof GuildMember && (key === 'createDM' || key === 'send')) {
|
|
544
796
|
return createMockFn(async (...args)=>receiver().user[key](...args));
|
|
545
797
|
}
|
|
798
|
+
// Under strict mocks a user manager's DMs go to the user named, its cached one or one made and cached, as discord.js
|
|
799
|
+
// resolves it; default mode keeps one DM channel for the manager and a send that resolves to an empty message
|
|
800
|
+
if (target instanceof UserManager && (key === 'createDM' || key === 'send')) {
|
|
801
|
+
return createMockFn(async (user, ...args)=>{
|
|
802
|
+
if (!strictMocks()) {
|
|
803
|
+
warnOnce(mockLogger, USER_MANAGER_DM_WARNINGS[key]);
|
|
804
|
+
return key === 'createDM' ? dmChannelOf(receiver()) : createMockMessage();
|
|
805
|
+
}
|
|
806
|
+
const named = user instanceof GuildMember ? user.user : user;
|
|
807
|
+
const id = typeof named === 'string' ? named : named?.id;
|
|
808
|
+
const resolved = named instanceof User ? named : cached(cacheOf(receiver()), id ?? nextSnowflake(), ()=>userOf(receiver(), id));
|
|
809
|
+
return key === 'createDM' ? resolved.createDM() : resolved.send(...args);
|
|
810
|
+
});
|
|
811
|
+
}
|
|
546
812
|
if (key === 'createDM') return createMockFn(async ()=>dmChannelOf(receiver()));
|
|
547
813
|
if (key === 'send' && target instanceof User) {
|
|
548
814
|
return createMockFn(async (...args)=>(await receiver().createDM()).send(...args));
|
|
@@ -552,6 +818,7 @@ function fetchedIds(args) {
|
|
|
552
818
|
return createMockFn(async (options)=>sentMessage(sent(options, false), receiver()));
|
|
553
819
|
}
|
|
554
820
|
if (MESSAGE_METHODS.has(key)) return createMockFn(async ()=>createMockMessage());
|
|
821
|
+
if (COLLECTING_METHODS.has(key)) return createMockFn(async ()=>new Collection());
|
|
555
822
|
if (target instanceof BaseManager) {
|
|
556
823
|
const item = MANAGER_ITEMS.find(([Manager])=>Manager.prototype.isPrototypeOf(target))?.[1];
|
|
557
824
|
// A fetch of one item finds it in the cache first, as discord.js does, and caches one it makes; a fetch of
|
|
@@ -564,10 +831,27 @@ function fetchedIds(args) {
|
|
|
564
831
|
id,
|
|
565
832
|
one(id)
|
|
566
833
|
]));
|
|
567
|
-
|
|
834
|
+
if (ids !== undefined) return one(ids);
|
|
835
|
+
// Under strict mocks a channel's fetch() lists the messages it holds, as discord.js lists its recent ones;
|
|
836
|
+
// default mode resolves to none, and warns once for a channel that strict mocks would list messages of
|
|
837
|
+
const messages = target instanceof GuildMessageManager || target instanceof DMMessageManager;
|
|
838
|
+
if (messages && strictMocks()) return recentMessages(receiver(), args[0]);
|
|
839
|
+
if (messages) warnUnlisted(receiver());
|
|
840
|
+
return new Collection();
|
|
841
|
+
});
|
|
842
|
+
}
|
|
843
|
+
// Under strict mocks an edit reaches the item named, the cached one or one made and cached under its id, as
|
|
844
|
+
// discord.js patches it; default mode resolves to a new item and warns once
|
|
845
|
+
if (item && key === 'edit') {
|
|
846
|
+
return createMockFn(async (named, options)=>{
|
|
847
|
+
const id = typeof named === 'string' ? named : named?.id;
|
|
848
|
+
if (id !== undefined && !strictMocks()) {
|
|
849
|
+
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.');
|
|
850
|
+
}
|
|
851
|
+
const edited = id === undefined || !strictMocks() ? item(undefined, receiver()) : cached(cacheOf(receiver()), id, ()=>item(id, receiver()));
|
|
852
|
+
return withName(receiver(), 'edit', edited, options);
|
|
568
853
|
});
|
|
569
854
|
}
|
|
570
|
-
if (item && key === 'edit') return createMockFn(async ()=>item(undefined, receiver()));
|
|
571
855
|
// A ban resolves to the member, user or id it was given, as discord.js's does, and caches nothing
|
|
572
856
|
if (key === 'create' && target instanceof GuildBanManager) {
|
|
573
857
|
return createMockFn(async (user)=>{
|
|
@@ -576,11 +860,23 @@ function fetchedIds(args) {
|
|
|
576
860
|
return typeof user === 'string' ? user : user?.id;
|
|
577
861
|
});
|
|
578
862
|
}
|
|
863
|
+
// An invite is made for the channel given, in its guild, and cached nowhere, as discord.js's create does
|
|
864
|
+
if (item && key === 'create' && target instanceof GuildInviteManager) {
|
|
865
|
+
return createMockFn(async (channel)=>{
|
|
866
|
+
const invite = item(undefined, receiver());
|
|
867
|
+
const channelId = typeof channel === 'string' ? channel : channel?.id;
|
|
868
|
+
const found = channel instanceof BaseChannel ? channel : channelId ? cacheOf(guildOf(receiver())?.channels)?.get(channelId) ?? null : null;
|
|
869
|
+
return Object.assign(invite, {
|
|
870
|
+
channel: found,
|
|
871
|
+
channelId: channelId ?? null
|
|
872
|
+
});
|
|
873
|
+
});
|
|
874
|
+
}
|
|
579
875
|
// Every other manager here caches what its create() makes, as discord.js's does; check a manager's create when
|
|
580
876
|
// adding it to MANAGER_ITEMS
|
|
581
877
|
if (item && key === 'create') {
|
|
582
|
-
return createMockFn(async ()=>{
|
|
583
|
-
const made = item(undefined, receiver());
|
|
878
|
+
return createMockFn(async (options)=>{
|
|
879
|
+
const made = withName(receiver(), 'create', item(undefined, receiver(), options), options);
|
|
584
880
|
if (typeof made.id === 'string') cacheOf(receiver())?.set(made.id, made);
|
|
585
881
|
return made;
|
|
586
882
|
});
|
|
@@ -772,10 +1068,6 @@ const isSnowflake = (id)=>typeof id === 'string' && /^\d{1,20}$/.test(id);
|
|
|
772
1068
|
});
|
|
773
1069
|
}
|
|
774
1070
|
}
|
|
775
|
-
/**
|
|
776
|
-
* Where a mock interaction keeps every answer it got, through respond() or discord.js directly, in order, under a key
|
|
777
|
-
* both builds of this meocord version read.
|
|
778
|
-
*/ const RESPONSE_LOG = Symbol.for(sharedName('responseLog'));
|
|
779
1071
|
/**
|
|
780
1072
|
* A mock of an answer method, `impl` its behaviour, that records each call in `log` as it is made, with what it sent
|
|
781
1073
|
* and, once it settles, what it rejected with. The record wraps every behaviour the mock is given, its own and any a
|
|
@@ -792,24 +1084,36 @@ const isSnowflake = (id)=>typeof id === 'string' && /^\d{1,20}$/.test(id);
|
|
|
792
1084
|
};
|
|
793
1085
|
stampCall(call);
|
|
794
1086
|
log.push(call);
|
|
795
|
-
let
|
|
1087
|
+
let gated;
|
|
796
1088
|
try {
|
|
797
|
-
// Before the behaviour, as discord.js
|
|
798
|
-
if (gate)
|
|
1089
|
+
// Before the behaviour, as discord.js checks the state before it sends an answer
|
|
1090
|
+
if (gate) gated = gate(args, byTest);
|
|
799
1091
|
} catch (error) {
|
|
800
1092
|
call.error = error;
|
|
801
1093
|
return Promise.reject(error);
|
|
802
1094
|
}
|
|
803
1095
|
const failed = (error)=>{
|
|
804
|
-
|
|
1096
|
+
gated?.settle?.();
|
|
1097
|
+
gated?.undo?.();
|
|
805
1098
|
call.error = error;
|
|
806
1099
|
throw error;
|
|
807
1100
|
};
|
|
1101
|
+
// Once the behaviour resolves, as discord.js changes the state once Discord has taken the answer
|
|
1102
|
+
const landed = (value)=>{
|
|
1103
|
+
gated?.settle?.();
|
|
1104
|
+
try {
|
|
1105
|
+
gated?.land?.();
|
|
1106
|
+
} catch (error) {
|
|
1107
|
+
call.error = error;
|
|
1108
|
+
throw error;
|
|
1109
|
+
}
|
|
1110
|
+
return value;
|
|
1111
|
+
};
|
|
808
1112
|
try {
|
|
809
1113
|
const result = Reflect.apply(behaviour, this, args);
|
|
810
1114
|
// Recorded and passed on, so a rejection nobody awaits is unhandled in the test, as it is on a bot
|
|
811
|
-
if (result instanceof Promise) return result.then(
|
|
812
|
-
return result;
|
|
1115
|
+
if (result instanceof Promise) return result.then(landed, failed);
|
|
1116
|
+
return landed(result);
|
|
813
1117
|
} catch (error) {
|
|
814
1118
|
return failed(error);
|
|
815
1119
|
}
|
|
@@ -852,11 +1156,33 @@ const isSnowflake = (id)=>typeof id === 'string' && /^\d{1,20}$/.test(id);
|
|
|
852
1156
|
if (message !== undefined) error.message = message;
|
|
853
1157
|
return error;
|
|
854
1158
|
}
|
|
1159
|
+
// Why default mode runs an answer discord.js refuses, as the warning words it
|
|
1160
|
+
const SET_BY_TEST = 'a test set this answer or the one before it';
|
|
1161
|
+
/**
|
|
1162
|
+
* Refuses an answer as discord.js does. Where `lenient` says why the mock ran it so far, such as a behaviour a test
|
|
1163
|
+
* set, default mode warns and lets it run; strict mocks refuse it.
|
|
1164
|
+
*/ function refuseAnswer(method, error, lenient) {
|
|
1165
|
+
if (lenient === undefined || strictMocks()) throw error;
|
|
1166
|
+
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.');
|
|
1167
|
+
}
|
|
855
1168
|
/** An error as discord.js's options resolver throws it: a `TypeError` with discord.js's code and message. */ const optionError = (code, ...args)=>new DiscordjsTypeError(code, ...args);
|
|
856
1169
|
// In discord.js's error codes, though missing from its typings
|
|
857
1170
|
const INVALID_CHANNEL_TYPE = 'CommandInteractionOptionInvalidChannelType';
|
|
858
|
-
/**
|
|
859
|
-
|
|
1171
|
+
/** The names of a mock options resolver's entity options, for one given to an autocomplete. */ const optionEntities = new WeakMap();
|
|
1172
|
+
/** The resolvers whose entity options a warning has named. */ const warnedEntities = new WeakSet();
|
|
1173
|
+
/** Whether a mock options resolver belongs to an autocomplete, whose options Discord sends unresolved. */ const ownedByAutocomplete = (options)=>optionOwners.get(options)?.type === InteractionType.ApplicationCommandAutocomplete;
|
|
1174
|
+
/**
|
|
1175
|
+
* Records the interaction a mock options resolver belongs to. Given to an autocomplete, an entity option reads as its
|
|
1176
|
+
* id under strict mocks, as Discord sends it; otherwise it keeps the object given, with a warning once per resolver.
|
|
1177
|
+
*/ const ownOptions = (interaction, options)=>{
|
|
1178
|
+
if (typeof options !== 'object' || options === null) return;
|
|
1179
|
+
optionOwners.set(options, interaction);
|
|
1180
|
+
const entities = optionEntities.get(options);
|
|
1181
|
+
if (!entities?.length || strictMocks() || warnedEntities.has(options) || !ownedByAutocomplete(options)) return;
|
|
1182
|
+
warnedEntities.add(options);
|
|
1183
|
+
const names = entities.map((name)=>`"${name}"`).join(', ');
|
|
1184
|
+
const many = entities.length > 1;
|
|
1185
|
+
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.');
|
|
860
1186
|
};
|
|
861
1187
|
/** A mock user that is a person, with an id of its own unless given one. */ const mockUser = (id)=>{
|
|
862
1188
|
const generatedId = id === undefined ? nextSnowflake() : undefined;
|
|
@@ -923,7 +1249,31 @@ function createMockInteraction(Class, props) {
|
|
|
923
1249
|
if (repliable) {
|
|
924
1250
|
instance.replied = false;
|
|
925
1251
|
instance.deferred = false;
|
|
926
|
-
|
|
1252
|
+
// Null until reply() or deferReply() decides it, as discord.js has it; default mode reads false until then, and a
|
|
1253
|
+
// test reading it there is told once. A value the test sets is read as set
|
|
1254
|
+
const strict = strictMocks();
|
|
1255
|
+
let ephemeral = strict ? null : false;
|
|
1256
|
+
let ephemeralDecided = false;
|
|
1257
|
+
// Whether one of the mock's own first answers ran; flags a test sets without one leave ephemeral as discord.js would
|
|
1258
|
+
let answeredByMock = false;
|
|
1259
|
+
const answeredOutside = ()=>Boolean(instance.replied || instance.deferred) && !answeredByMock;
|
|
1260
|
+
const decideEphemeral = (value, decided)=>{
|
|
1261
|
+
ephemeral = value;
|
|
1262
|
+
ephemeralDecided = decided;
|
|
1263
|
+
};
|
|
1264
|
+
Object.defineProperty(instance, 'ephemeral', {
|
|
1265
|
+
get: ()=>{
|
|
1266
|
+
// Quiet once a test answered the interaction itself: MeoCord then reads ephemeral only for truthiness, where false
|
|
1267
|
+
// and null act alike
|
|
1268
|
+
if (!ephemeralDecided && !strict && !answeredOutside()) {
|
|
1269
|
+
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.');
|
|
1270
|
+
}
|
|
1271
|
+
return ephemeral;
|
|
1272
|
+
},
|
|
1273
|
+
set: (value)=>decideEphemeral(value, true),
|
|
1274
|
+
enumerable: true,
|
|
1275
|
+
configurable: true
|
|
1276
|
+
});
|
|
927
1277
|
// discord.js's errors, with their codes; a call before any answer keeps the message the mock has always given
|
|
928
1278
|
const alreadyReplied = ()=>discordjsError(DiscordjsErrorCodes.InteractionAlreadyReplied);
|
|
929
1279
|
const notYetReplied = (method)=>discordjsError(DiscordjsErrorCodes.InteractionNotReplied, `Cannot call ${method}() before replying or deferring.`);
|
|
@@ -940,41 +1290,57 @@ function createMockInteraction(Class, props) {
|
|
|
940
1290
|
const followUps = new Map();
|
|
941
1291
|
// Whether the last answer ran a behaviour a test set, after which a second answer runs with a warning by default
|
|
942
1292
|
let answeredByTest = false;
|
|
943
|
-
//
|
|
944
|
-
let
|
|
1293
|
+
// Whether default mode ran a launchActivity() or sendPremiumRequired() before any answer, which it doesn't count as one
|
|
1294
|
+
let uncounted = false;
|
|
1295
|
+
// A command answered without a message, as with a modal, has no original response, and Discord refuses one asked for
|
|
1296
|
+
let noOriginal = false;
|
|
945
1297
|
const isComponent = instance.type === InteractionType.MessageComponent;
|
|
946
1298
|
// The original response of a component, or of a modal submitted from one, is the message it's on
|
|
947
1299
|
const componentMessage = ()=>heldFrom(instance.message);
|
|
1300
|
+
// Read when answered, after a test's props are applied: a modal submitted from a command has no message
|
|
1301
|
+
const onMessage = ()=>isComponent || instance.type === InteractionType.ModalSubmit && Boolean(instance.message);
|
|
948
1302
|
const snapshot = ()=>{
|
|
949
1303
|
const saved = {
|
|
950
1304
|
replied: instance.replied,
|
|
951
1305
|
deferred: instance.deferred,
|
|
952
|
-
ephemeral
|
|
1306
|
+
ephemeral,
|
|
1307
|
+
ephemeralDecided,
|
|
1308
|
+
answeredByMock,
|
|
953
1309
|
original,
|
|
954
1310
|
originalGone,
|
|
955
1311
|
followUps: new Map(followUps),
|
|
956
1312
|
answeredByTest,
|
|
957
|
-
|
|
1313
|
+
noOriginal
|
|
958
1314
|
};
|
|
959
1315
|
return ()=>{
|
|
960
1316
|
Object.assign(instance, {
|
|
961
1317
|
replied: saved.replied,
|
|
962
|
-
deferred: saved.deferred
|
|
963
|
-
ephemeral: saved.ephemeral
|
|
1318
|
+
deferred: saved.deferred
|
|
964
1319
|
});
|
|
965
|
-
(
|
|
1320
|
+
decideEphemeral(saved.ephemeral, saved.ephemeralDecided);
|
|
1321
|
+
({ original, originalGone, answeredByTest, answeredByMock, noOriginal } = saved);
|
|
966
1322
|
followUps.clear();
|
|
967
1323
|
for (const [id, held] of saved.followUps)followUps.set(id, held);
|
|
968
1324
|
};
|
|
969
1325
|
};
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
* one before it, which ran without one, default mode warns and lets it run; strict mocks refuse it.
|
|
973
|
-
*/ const refuse = (method, error, byTest)=>{
|
|
974
|
-
if (!(byTest || answeredByTest) || strictMocks()) throw error;
|
|
975
|
-
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.');
|
|
976
|
-
};
|
|
1326
|
+
// Refused as discord.js does, leniently in default mode where this answer or the one before it ran a test's behaviour
|
|
1327
|
+
const refuse = (method, error, byTest)=>refuseAnswer(method, error, byTest || answeredByTest ? SET_BY_TEST : undefined);
|
|
977
1328
|
const answered = ()=>instance.deferred || instance.replied;
|
|
1329
|
+
// Default mode's launchActivity() and sendPremiumRequired(), which leave the interaction as it was, as before
|
|
1330
|
+
const countedOnlyWhenStrict = (method)=>{
|
|
1331
|
+
if (answered()) {
|
|
1332
|
+
warnOnce(mockLogger, `${method}() here runs though the interaction was answered; under useStrictMocks() this is refused with ` + 'InteractionAlreadyReplied, as discord.js refuses it.');
|
|
1333
|
+
return;
|
|
1334
|
+
}
|
|
1335
|
+
uncounted = true;
|
|
1336
|
+
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.');
|
|
1337
|
+
};
|
|
1338
|
+
// What Discord answers here isn't known, so the refusal names the cause rather than a code
|
|
1339
|
+
const refuseModalUpdate = ()=>{
|
|
1340
|
+
const reason = 'update() needs the message a component opened this modal from, and this modal came from a command. Use ' + 'reply() or deferReply() instead.';
|
|
1341
|
+
if (strictMocks()) throw new Error(reason);
|
|
1342
|
+
warnOnce(mockLogger, `${reason} The mock runs it; in the next major version (5.0) it refuses it. Call useStrictMocks() to have it refused now.`);
|
|
1343
|
+
};
|
|
978
1344
|
/**
|
|
979
1345
|
* What an answer sends, built now, as discord.js builds it when sending. One it refuses to build is refused under
|
|
980
1346
|
* strict mocks; default mode warns and holds it to build when first read.
|
|
@@ -987,32 +1353,92 @@ function createMockInteraction(Class, props) {
|
|
|
987
1353
|
return build;
|
|
988
1354
|
}
|
|
989
1355
|
};
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
1356
|
+
// The changes answers make to the reply state, in order. One whose answer fails is undone once every change after
|
|
1357
|
+
// it is undone too, so a later answer's change stands while it does, as on a bot
|
|
1358
|
+
const changes = [];
|
|
1359
|
+
const undoneInTurn = (undo)=>{
|
|
1360
|
+
const change = {
|
|
1361
|
+
undo,
|
|
1362
|
+
failed: false
|
|
1363
|
+
};
|
|
1364
|
+
changes.push(change);
|
|
1365
|
+
return ()=>{
|
|
1366
|
+
change.failed = true;
|
|
1367
|
+
while(changes.at(-1)?.failed)changes.pop().undo();
|
|
1368
|
+
};
|
|
1369
|
+
};
|
|
1370
|
+
// The first answers called and not yet settled, oldest first, for default mode to warn where a bot would differ
|
|
1371
|
+
const unsettled = [];
|
|
1372
|
+
// Default mode warns once per interaction where code that doesn't await an answer would fail on a bot
|
|
1373
|
+
let inFlightWarned = false;
|
|
1374
|
+
const warnInFlight = (text)=>{
|
|
1375
|
+
if (inFlightWarned) return;
|
|
1376
|
+
inFlightWarned = true;
|
|
1377
|
+
mockLogger.warn(text);
|
|
1378
|
+
};
|
|
1379
|
+
/**
|
|
1380
|
+
* A first answer: refused once the interaction has one, else it replies or defers. Under strict mocks the change
|
|
1381
|
+
* waits for the answer to land, as discord.js makes it once the request resolves, so one sent while another is in
|
|
1382
|
+
* flight passes discord.js's check and Discord refuses it with 40060 if the other landed first. Default mode makes
|
|
1383
|
+
* the change at once.
|
|
1384
|
+
*/ const first = (method, kind, then)=>(args, byTest)=>{
|
|
1385
|
+
if (answered()) {
|
|
1386
|
+
if (unsettled.length > 0) {
|
|
1387
|
+
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.`);
|
|
1388
|
+
}
|
|
1389
|
+
refuse(method, alreadyReplied(), byTest);
|
|
1390
|
+
}
|
|
1391
|
+
// Read first: flags discord.js refuses throw before anything changes; reply() and deferReply() decide
|
|
1392
|
+
// ephemeral, as discord.js's do, and update() and deferUpdate() leave it
|
|
1393
|
+
const flagged = method === 'reply' || method === 'deferReply' ? hasEphemeralFlag(args[0]) : undefined;
|
|
994
1394
|
const undo = snapshot();
|
|
995
1395
|
instance[kind] = true;
|
|
996
|
-
if (
|
|
1396
|
+
if (flagged !== undefined) decideEphemeral(flagged, true);
|
|
997
1397
|
answeredByTest = byTest;
|
|
1398
|
+
answeredByMock = true;
|
|
998
1399
|
try {
|
|
999
1400
|
then(args);
|
|
1000
1401
|
} catch (error) {
|
|
1001
1402
|
undo();
|
|
1002
1403
|
throw error;
|
|
1003
1404
|
}
|
|
1004
|
-
|
|
1405
|
+
if (strict) {
|
|
1406
|
+
const land = snapshot();
|
|
1407
|
+
undo();
|
|
1408
|
+
return {
|
|
1409
|
+
land: ()=>{
|
|
1410
|
+
if (answered()) throw createDiscordError(40060, 'Interaction has already been acknowledged.');
|
|
1411
|
+
land();
|
|
1412
|
+
}
|
|
1413
|
+
};
|
|
1414
|
+
}
|
|
1415
|
+
unsettled.push(method);
|
|
1416
|
+
return {
|
|
1417
|
+
undo: undoneInTurn(undo),
|
|
1418
|
+
settle: ()=>void unsettled.splice(unsettled.indexOf(method), 1)
|
|
1419
|
+
};
|
|
1005
1420
|
};
|
|
1006
|
-
/**
|
|
1007
|
-
|
|
1421
|
+
/**
|
|
1422
|
+
* A later answer: refused before the interaction has one. One that `replies` marks the interaction replied, under
|
|
1423
|
+
* strict mocks once it lands, as discord.js does once the request resolves; default mode marks it at once.
|
|
1424
|
+
*/ const later = (method, then, replies = false)=>(args, byTest)=>{
|
|
1425
|
+
// A follow-up after an answer default mode doesn't count is sent, as it is once strict mocks count that answer
|
|
1426
|
+
if (!answered() && !(method === 'followUp' && uncounted)) refuse(method, notYetReplied(method), byTest);
|
|
1427
|
+
if (unsettled.length > 0) {
|
|
1428
|
+
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.`);
|
|
1429
|
+
}
|
|
1008
1430
|
const undo = snapshot();
|
|
1009
1431
|
try {
|
|
1010
1432
|
then(args);
|
|
1433
|
+
if (replies && !strict) instance.replied = true;
|
|
1011
1434
|
} catch (error) {
|
|
1012
1435
|
undo();
|
|
1013
1436
|
throw error;
|
|
1014
1437
|
}
|
|
1015
|
-
return
|
|
1438
|
+
return {
|
|
1439
|
+
undo: undoneInTurn(undo),
|
|
1440
|
+
land: replies && strict ? ()=>void (instance.replied = true) : undefined
|
|
1441
|
+
};
|
|
1016
1442
|
};
|
|
1017
1443
|
/** The id an edit, fetch or delete names, `undefined` for the original response. */ const targetId = (message)=>{
|
|
1018
1444
|
if (message === undefined || message === '@original') return undefined;
|
|
@@ -1024,7 +1450,7 @@ function createMockInteraction(Class, props) {
|
|
|
1024
1450
|
if (held) followUps.set(id, built(held));
|
|
1025
1451
|
return held && built(held);
|
|
1026
1452
|
}
|
|
1027
|
-
if (originalGone ||
|
|
1453
|
+
if (originalGone || noOriginal) return undefined;
|
|
1028
1454
|
// Answered without one of the mock's calls, as after a 40060 or a test setting `replied`: the original is there
|
|
1029
1455
|
if (!original && answered()) original = componentMessage();
|
|
1030
1456
|
if (original) original = built(original);
|
|
@@ -1063,7 +1489,6 @@ function createMockInteraction(Class, props) {
|
|
|
1063
1489
|
id: followUpMade
|
|
1064
1490
|
}) : messageFrom(held, madeFor(held.id), ...answeredIn());
|
|
1065
1491
|
}, later('followUp', ([options])=>{
|
|
1066
|
-
instance.replied = true;
|
|
1067
1492
|
// Held as it is when sent, as Discord stores it, so a later change to what was passed isn't seen; its id taken
|
|
1068
1493
|
// once built, so one that fails to build under strict mocks takes none
|
|
1069
1494
|
const held = hold('followUp', ()=>sent(options, false));
|
|
@@ -1077,7 +1502,7 @@ function createMockInteraction(Class, props) {
|
|
|
1077
1502
|
id
|
|
1078
1503
|
});
|
|
1079
1504
|
followUpMade = id;
|
|
1080
|
-
}));
|
|
1505
|
+
}, true));
|
|
1081
1506
|
// An edit replaces the message in the shape Discord stores, with ids and resolved media, stamped with when it was
|
|
1082
1507
|
// edited, and fetchReply() reads that back, so code comparing the two sees what it would against Discord
|
|
1083
1508
|
answer('editReply', async ()=>{
|
|
@@ -1092,11 +1517,9 @@ function createMockInteraction(Class, props) {
|
|
|
1092
1517
|
const held = target(id);
|
|
1093
1518
|
made = held && edited(held, options);
|
|
1094
1519
|
if (!made) return;
|
|
1095
|
-
if (id === undefined)
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
} else followUps.set(id, made);
|
|
1099
|
-
}));
|
|
1520
|
+
if (id === undefined) original = made;
|
|
1521
|
+
else followUps.set(id, made);
|
|
1522
|
+
}, true));
|
|
1100
1523
|
stubs.set('fetchReply', createMockFn(async (message)=>{
|
|
1101
1524
|
const held = target(targetId(message));
|
|
1102
1525
|
if (!held) throw unknownMessage();
|
|
@@ -1111,16 +1534,31 @@ function createMockInteraction(Class, props) {
|
|
|
1111
1534
|
if (id === undefined) originalGone = true;
|
|
1112
1535
|
else followUps.delete(id);
|
|
1113
1536
|
}));
|
|
1537
|
+
// A first answer that sends no message: the original response stays the message the interaction is on, if any
|
|
1538
|
+
const sendsNoMessage = ()=>{
|
|
1539
|
+
if (onMessage()) original = componentMessage();
|
|
1540
|
+
else noOriginal = true;
|
|
1541
|
+
};
|
|
1114
1542
|
// showModal — the first response of a command or a component, like reply()
|
|
1115
1543
|
if (instance.type === InteractionType.ApplicationCommand || isComponent) {
|
|
1116
|
-
answer('showModal', async ()=>undefined, first('showModal', 'replied',
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1544
|
+
answer('showModal', async ()=>undefined, first('showModal', 'replied', sendsNoMessage));
|
|
1545
|
+
}
|
|
1546
|
+
// An activity launched, or the upgrade button sent, is a first answer under strict mocks, as reply() is, on every
|
|
1547
|
+
// repliable interaction. Default mode leaves the interaction unanswered and unreported, and warns once where they differ
|
|
1548
|
+
for (const method of [
|
|
1549
|
+
'launchActivity',
|
|
1550
|
+
'sendPremiumRequired'
|
|
1551
|
+
]){
|
|
1552
|
+
if (strict) answer(method, async ()=>undefined, first(method, 'replied', sendsNoMessage));
|
|
1553
|
+
else stubs.set(method, recordedMock(method, async ()=>undefined, [], ()=>(countedOnlyWhenStrict(method), {})));
|
|
1120
1554
|
}
|
|
1121
1555
|
// deferUpdate / update — components, and modals submitted from a message's component
|
|
1122
1556
|
if (isComponent || instance.type === InteractionType.ModalSubmit) {
|
|
1123
|
-
answer('update', async ()=>undefined, first('update', 'replied', ([options])=>
|
|
1557
|
+
answer('update', async ()=>undefined, first('update', 'replied', ([options])=>{
|
|
1558
|
+
// Discord updates only a message a component is on; a modal from a command has none
|
|
1559
|
+
if (!onMessage()) refuseModalUpdate();
|
|
1560
|
+
original = hold('update', ()=>edited(heldFrom(instance.message, false), options));
|
|
1561
|
+
}));
|
|
1124
1562
|
answer('deferUpdate', async ()=>undefined, first('deferUpdate', 'deferred', ()=>original = componentMessage()));
|
|
1125
1563
|
}
|
|
1126
1564
|
}
|
|
@@ -1130,14 +1568,58 @@ function createMockInteraction(Class, props) {
|
|
|
1130
1568
|
// it before answering would decide the window was already closed.
|
|
1131
1569
|
if (instance.type === InteractionType.ApplicationCommandAutocomplete) {
|
|
1132
1570
|
instance.responded = false;
|
|
1133
|
-
|
|
1134
|
-
|
|
1571
|
+
const strict = strictMocks();
|
|
1572
|
+
const log = [];
|
|
1573
|
+
// getResponse reports each respond() under strict mocks; default mode reports none, as before
|
|
1574
|
+
if (strict) Object.defineProperty(instance, RESPONSE_LOG, {
|
|
1575
|
+
value: log
|
|
1576
|
+
});
|
|
1577
|
+
// Responds whose change stands, and what `responded` read before the first of them, for a failed one to undo
|
|
1578
|
+
let standing = 0;
|
|
1579
|
+
let before = false;
|
|
1580
|
+
let respondsInFlight = 0;
|
|
1581
|
+
let respondWarned = false;
|
|
1582
|
+
// Under strict mocks responded is set once respond() lands, as discord.js sets it once the request resolves, and a
|
|
1583
|
+
// second one sent while the first is in flight is refused by Discord with 40060; default mode sets it at once
|
|
1584
|
+
const gate = (_args, byTest)=>{
|
|
1585
|
+
// Default mode leaves a behaviour a test set to itself, as before: responded stays as it was
|
|
1586
|
+
if (!strict && byTest) {
|
|
1587
|
+
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.');
|
|
1588
|
+
return {};
|
|
1589
|
+
}
|
|
1590
|
+
if (instance.responded) {
|
|
1591
|
+
if (respondsInFlight > 0 && !respondWarned) {
|
|
1592
|
+
respondWarned = true;
|
|
1593
|
+
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.');
|
|
1594
|
+
}
|
|
1595
|
+
throw discordjsError(DiscordjsErrorCodes.InteractionAlreadyReplied);
|
|
1596
|
+
}
|
|
1597
|
+
respondsInFlight++;
|
|
1598
|
+
const settle = ()=>void respondsInFlight--;
|
|
1599
|
+
if (strict) {
|
|
1600
|
+
return {
|
|
1601
|
+
settle,
|
|
1602
|
+
land: ()=>{
|
|
1603
|
+
if (instance.responded) throw createDiscordError(40060, 'Interaction has already been acknowledged.');
|
|
1604
|
+
instance.responded = true;
|
|
1605
|
+
}
|
|
1606
|
+
};
|
|
1607
|
+
}
|
|
1608
|
+
if (standing++ === 0) before = instance.responded;
|
|
1609
|
+
instance.responded = true;
|
|
1610
|
+
return {
|
|
1611
|
+
settle,
|
|
1612
|
+
undo: ()=>{
|
|
1613
|
+
if (--standing === 0) instance.responded = before;
|
|
1614
|
+
}
|
|
1615
|
+
};
|
|
1616
|
+
};
|
|
1617
|
+
stubs.set('respond', recordedMock('respond', async (choices)=>{
|
|
1135
1618
|
// Discord refuses a list longer than its limit, and the menu shows nothing
|
|
1136
1619
|
if (Array.isArray(choices) && choices.length > MAX_AUTOCOMPLETE_CHOICES) {
|
|
1137
1620
|
throw createDiscordError(50035, `Invalid Form Body\ndata.choices[BASE_TYPE_MAX_LENGTH]: Must be ${MAX_AUTOCOMPLETE_CHOICES} or fewer in length.`);
|
|
1138
1621
|
}
|
|
1139
|
-
|
|
1140
|
-
}));
|
|
1622
|
+
}, log, gate));
|
|
1141
1623
|
}
|
|
1142
1624
|
// Applied last so an explicit prop wins over the type fields and the reply
|
|
1143
1625
|
// state machine. defineProperty rather than assignment for the same reason the
|
|
@@ -1270,6 +1752,9 @@ function createMockInteraction(Class, props) {
|
|
|
1270
1752
|
configurable: true
|
|
1271
1753
|
});
|
|
1272
1754
|
}
|
|
1755
|
+
// Default mode reads a guildId alone as a placeholder server with that id, as its warning says, answered in place of
|
|
1756
|
+
// the mock's own stub, so where the interaction is still reads as given. Strict mocks read null, as discord.js does
|
|
1757
|
+
if (strictPlace?.given === 'guildId' && !strictMocks() && unset('guild')) stubs.set('guild', guildPlaceholder(own('guildId')));
|
|
1273
1758
|
// The channel it came from, as the gateway caches it for an interaction: its server's cached one, looked up on each
|
|
1274
1759
|
// read so a channel the test caches later is the one read, or the user's DM
|
|
1275
1760
|
let channel;
|
|
@@ -1311,6 +1796,11 @@ function createMockInteraction(Class, props) {
|
|
|
1311
1796
|
// and in a server that user's member; each read live, so a targetId set later picks its user, and assignable
|
|
1312
1797
|
if (UserContextMenuCommandInteraction.prototype.isPrototypeOf(instance)) {
|
|
1313
1798
|
if (unset('targetId')) instance.targetId = own('targetUser')?.id ?? nextSnowflake();
|
|
1799
|
+
// Under strict mocks a target user given is the client's cached one, as discord.js resolves it, so its member is
|
|
1800
|
+
// that user's; default mode keeps the member's own user, and a read of targetMember warns once
|
|
1801
|
+
const givenTarget = own('targetUser');
|
|
1802
|
+
const users = cacheOf(instance.client.users);
|
|
1803
|
+
if (strictMocks() && typeof givenTarget === 'object' && givenTarget !== null) users?.set(givenTarget.id, givenTarget);
|
|
1314
1804
|
const targetUser = ()=>{
|
|
1315
1805
|
const id = own('targetId');
|
|
1316
1806
|
return cached(cacheOf(instance.client.users), id, ()=>mockUser(id));
|
|
@@ -1328,7 +1818,15 @@ function createMockInteraction(Class, props) {
|
|
|
1328
1818
|
});
|
|
1329
1819
|
if (unset('targetUser')) live('targetUser', targetUser);
|
|
1330
1820
|
if (unset('targetMember')) {
|
|
1331
|
-
live('targetMember', ()=>
|
|
1821
|
+
live('targetMember', ()=>{
|
|
1822
|
+
if (!own('guildId')) return null;
|
|
1823
|
+
const member = memberFor(own('guild'), targetUser(), true, own('guildId'));
|
|
1824
|
+
// Only the mock reads this for the test, so the warning comes at the test's read
|
|
1825
|
+
if (givenTarget && member.user !== givenTarget) {
|
|
1826
|
+
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.");
|
|
1827
|
+
}
|
|
1828
|
+
return member;
|
|
1829
|
+
});
|
|
1332
1830
|
}
|
|
1333
1831
|
}
|
|
1334
1832
|
// A message context menu's target: the message given, else one made for its targetId. The message is the one
|
|
@@ -1658,6 +2156,10 @@ const HOLDS = [
|
|
|
1658
2156
|
RoleManager.prototype,
|
|
1659
2157
|
Role
|
|
1660
2158
|
],
|
|
2159
|
+
[
|
|
2160
|
+
GuildMemberRoleManager.prototype,
|
|
2161
|
+
Role
|
|
2162
|
+
],
|
|
1661
2163
|
[
|
|
1662
2164
|
GuildChannelManager.prototype,
|
|
1663
2165
|
GuildChannel
|
|
@@ -1713,18 +2215,46 @@ const HOLDS = [
|
|
|
1713
2215
|
[
|
|
1714
2216
|
PresenceManager.prototype,
|
|
1715
2217
|
Presence
|
|
2218
|
+
],
|
|
2219
|
+
[
|
|
2220
|
+
GuildEmojiManager.prototype,
|
|
2221
|
+
GuildEmoji
|
|
2222
|
+
],
|
|
2223
|
+
[
|
|
2224
|
+
GuildStickerManager.prototype,
|
|
2225
|
+
Sticker
|
|
2226
|
+
],
|
|
2227
|
+
[
|
|
2228
|
+
GuildScheduledEventManager.prototype,
|
|
2229
|
+
GuildScheduledEvent
|
|
2230
|
+
],
|
|
2231
|
+
[
|
|
2232
|
+
AutoModerationRuleManager.prototype,
|
|
2233
|
+
AutoModerationRule
|
|
2234
|
+
],
|
|
2235
|
+
[
|
|
2236
|
+
GuildInviteManager.prototype,
|
|
2237
|
+
Invite
|
|
1716
2238
|
]
|
|
1717
2239
|
];
|
|
2240
|
+
/** 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];
|
|
1718
2241
|
/**
|
|
1719
2242
|
* A manager whose `cache` is a real collection of `items`, by id, empty without them, and whose methods are stubs but
|
|
1720
|
-
* for `resolve` and `resolveId`, which read the cache. Its `client` is the one its guild or channel has.
|
|
1721
|
-
|
|
2243
|
+
* for `resolve` and `resolveId`, which read the cache. Its `client` is the one its guild or channel has. `onCacheRead`,
|
|
2244
|
+
* when given, runs on each read of `cache`, before it is returned.
|
|
2245
|
+
*/ function managerWith(prototype, items, onCacheRead) {
|
|
1722
2246
|
const manager = Object.create(prototype);
|
|
1723
2247
|
const cache = new Collection((items ?? []).map((item)=>[
|
|
1724
2248
|
String(item.id ?? item.user?.id),
|
|
1725
2249
|
item
|
|
1726
2250
|
]));
|
|
1727
|
-
|
|
2251
|
+
if (onCacheRead) {
|
|
2252
|
+
let current = cache;
|
|
2253
|
+
Object.defineProperty(manager, 'cache', {
|
|
2254
|
+
get: ()=>(onCacheRead(), current),
|
|
2255
|
+
set: (value)=>void (current = value)
|
|
2256
|
+
});
|
|
2257
|
+
} else Object.defineProperty(manager, 'cache', {
|
|
1728
2258
|
value: cache,
|
|
1729
2259
|
writable: true
|
|
1730
2260
|
});
|
|
@@ -1733,7 +2263,7 @@ const HOLDS = [
|
|
|
1733
2263
|
value: cache,
|
|
1734
2264
|
writable: true
|
|
1735
2265
|
});
|
|
1736
|
-
const holds =
|
|
2266
|
+
const holds = holdsOf(prototype);
|
|
1737
2267
|
if (holds) Object.defineProperty(manager, 'holds', {
|
|
1738
2268
|
value: holds
|
|
1739
2269
|
});
|
|
@@ -1754,13 +2284,59 @@ const HOLDS = [
|
|
|
1754
2284
|
});
|
|
1755
2285
|
return stubDeep(manager);
|
|
1756
2286
|
}
|
|
2287
|
+
// Set while the mock reads a manager's cache itself
|
|
2288
|
+
let readingOwnCache = 0;
|
|
2289
|
+
// The messages made in each channel, by its messages manager, for a fetch to find and strict mocks to cache
|
|
2290
|
+
const messagesMade = new WeakMap();
|
|
2291
|
+
const madeIn = (manager, id)=>id === undefined ? undefined : messagesMade.get(manager)?.get(id);
|
|
2292
|
+
// The message managers whose cache read has warned
|
|
2293
|
+
const uncachedWarned = new WeakSet();
|
|
2294
|
+
/**
|
|
2295
|
+
* A channel's empty messages manager. Default mode caches none of the messages made in the channel, where strict mocks
|
|
2296
|
+
* and discord.js cache each, so a test's first read of the cache warns once while one of them is missing from it.
|
|
2297
|
+
*/ function messageManager(prototype) {
|
|
2298
|
+
const manager = managerWith(prototype, undefined, ()=>{
|
|
2299
|
+
const made = messagesMade.get(manager);
|
|
2300
|
+
if (readingOwnCache > 0 || !made || strictMocks() || uncachedWarned.has(manager)) return;
|
|
2301
|
+
// Read as the mock's own, so this check doesn't call itself
|
|
2302
|
+
const cache = cacheOf(manager);
|
|
2303
|
+
if (![
|
|
2304
|
+
...made.values()
|
|
2305
|
+
].some((each)=>!cache?.has(each.id))) return;
|
|
2306
|
+
uncachedWarned.add(manager);
|
|
2307
|
+
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.');
|
|
2308
|
+
});
|
|
2309
|
+
return manager;
|
|
2310
|
+
}
|
|
2311
|
+
// The message managers whose fetch() with no id has warned
|
|
2312
|
+
const unlistedWarned = new WeakSet();
|
|
2313
|
+
/**
|
|
2314
|
+
* Warns once for a channel whose messages.fetch() with no id resolves to none in default mode, though strict mocks
|
|
2315
|
+
* would list messages there: those its cache holds, read as the mock's own, and those made or sent in it, which they
|
|
2316
|
+
* cache.
|
|
2317
|
+
*/ function warnUnlisted(manager) {
|
|
2318
|
+
if (unlistedWarned.has(manager)) return;
|
|
2319
|
+
if (!cacheOf(manager)?.size && !messagesMade.get(manager)?.size) return;
|
|
2320
|
+
unlistedWarned.add(manager);
|
|
2321
|
+
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.');
|
|
2322
|
+
}
|
|
2323
|
+
/** Notes a message made in a channel, and caches it there under strict mocks, as discord.js caches what arrives. */ function noteMadeIn(channel, message) {
|
|
2324
|
+
if (!(channel instanceof BaseChannel)) return;
|
|
2325
|
+
const manager = Reflect.getOwnPropertyDescriptor(channel, 'messages')?.value;
|
|
2326
|
+
const cache = manager && cacheOf(manager);
|
|
2327
|
+
if (!manager || !cache) return;
|
|
2328
|
+
if (!messagesMade.has(manager)) messagesMade.set(manager, new Map());
|
|
2329
|
+
messagesMade.get(manager).set(message.id, message);
|
|
2330
|
+
if (strictMocks()) cache.set(message.id, message);
|
|
2331
|
+
}
|
|
1757
2332
|
/** An empty manager of `prototype` that `owner` names its own, such as `{ message }` for a message's reactions. */ function ownedManager(prototype, owner) {
|
|
1758
2333
|
const manager = managerWith(prototype, undefined);
|
|
1759
2334
|
Object.assign(manager, owner);
|
|
1760
2335
|
return manager;
|
|
1761
2336
|
}
|
|
1762
2337
|
/**
|
|
1763
|
-
* Creates a mock {@link Guild}, with
|
|
2338
|
+
* Creates a mock {@link Guild}, with its `members`, `channels`, `roles`, `bans`, `emojis`, `stickers`, `scheduledEvents`,
|
|
2339
|
+
* `autoModerationRules` and `invites` managers ready to stub.
|
|
1764
2340
|
*
|
|
1765
2341
|
* Use it for the server a message or an interaction came from, with the members, roles and channels a handler looks
|
|
1766
2342
|
* up in it.
|
|
@@ -1770,14 +2346,18 @@ const HOLDS = [
|
|
|
1770
2346
|
*
|
|
1771
2347
|
* @remarks
|
|
1772
2348
|
* 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
|
|
1773
|
-
* caches under that id: a member or
|
|
1774
|
-
*
|
|
1775
|
-
*
|
|
1776
|
-
*
|
|
1777
|
-
*
|
|
1778
|
-
*
|
|
1779
|
-
*
|
|
1780
|
-
*
|
|
2349
|
+
* caches under that id: a member, channel, emoji, sticker, scheduled event or AutoMod rule in this guild, a role with
|
|
2350
|
+
* that id, a ban, or an invite with that code. `members.fetch({ user: ids })` resolves to a collection of each of those
|
|
2351
|
+
* members, found or made the same way; `create()` resolves to a mock of its item, a channel of the type its options
|
|
2352
|
+
* name under `useStrictMocks()`, which it caches as discord.js does, but for an invite, made for the channel given and
|
|
2353
|
+
* cached nowhere; `edit()` resolves under `useStrictMocks()` to the item with the id given, found or made the same way,
|
|
2354
|
+
* and under `useStrictMocks()` either takes the `name` its options give. A list fetch resolves to an empty collection.
|
|
2355
|
+
* Members, roles and channels given are put in their managers' caches, where dispatch looks first when it resolves a
|
|
2356
|
+
* message's typed params; a member {@link createMockMember} made without a server is in this one. Its `roles.everyone`
|
|
2357
|
+
* is the role given with the guild's id, or else an @everyone role of its own at position 0, in `roles.cache` as
|
|
2358
|
+
* Discord has it. That role has no permissions in default mode; under `useStrictMocks()` it has the ones Discord
|
|
2359
|
+
* gives @everyone in a new server. The guild is named `'Guild'` and its `preferredLocale` is `'en-US'` unless given,
|
|
2360
|
+
* so `t.forGuild(guild)` translates as for a new English server.
|
|
1781
2361
|
*
|
|
1782
2362
|
* 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
|
|
1783
2363
|
* `guild.members.fetch.mockRejectedValue(createDiscordError(10007))`, or resolves a collection without that member.
|
|
@@ -1803,7 +2383,7 @@ const HOLDS = [
|
|
|
1803
2383
|
instance.members = managerWith(GuildMemberManager.prototype, overrides.members);
|
|
1804
2384
|
instance.channels = managerWith(GuildChannelManager.prototype, overrides.channels);
|
|
1805
2385
|
instance.roles = guildRoleManager(instance.id, overrides.roles);
|
|
1806
|
-
instance
|
|
2386
|
+
for (const [key, Manager] of GUILD_MANAGERS)instance[key] = managerWith(Manager.prototype, undefined);
|
|
1807
2387
|
const guild = stubDeep(instance);
|
|
1808
2388
|
settleGuild(instance, guild);
|
|
1809
2389
|
// A member made without a server of its own is in this one
|
|
@@ -1847,7 +2427,7 @@ const HOLDS = [
|
|
|
1847
2427
|
const is = (Base)=>Base.prototype.isPrototypeOf(Class.prototype) || Class === Base;
|
|
1848
2428
|
// Text and announcement channels: messages, and threads made in the channel
|
|
1849
2429
|
if (is(TextChannel) || is(NewsChannel)) {
|
|
1850
|
-
instance.messages =
|
|
2430
|
+
instance.messages = messageManager(GuildMessageManager.prototype);
|
|
1851
2431
|
instance.threads = managerWith(GuildTextThreadManager.prototype, undefined);
|
|
1852
2432
|
}
|
|
1853
2433
|
// Forum and media channels hold posts, each a thread started with its first message
|
|
@@ -1857,15 +2437,15 @@ const HOLDS = [
|
|
|
1857
2437
|
}
|
|
1858
2438
|
// Voice and stage channels carry a text chat of their own, and a bitrate, which discord.js tells them apart by
|
|
1859
2439
|
if (is(BaseGuildVoiceChannel)) {
|
|
1860
|
-
instance.messages =
|
|
2440
|
+
instance.messages = messageManager(GuildMessageManager.prototype);
|
|
1861
2441
|
instance.bitrate = 64000;
|
|
1862
2442
|
instance.userLimit = 0;
|
|
1863
2443
|
}
|
|
1864
2444
|
if (is(DMChannel)) {
|
|
1865
|
-
instance.messages =
|
|
2445
|
+
instance.messages = messageManager(DMMessageManager.prototype);
|
|
1866
2446
|
}
|
|
1867
2447
|
if (is(ThreadChannel)) {
|
|
1868
|
-
instance.messages =
|
|
2448
|
+
instance.messages = messageManager(GuildMessageManager.prototype);
|
|
1869
2449
|
instance.members = managerWith(ThreadMemberManager.prototype, undefined);
|
|
1870
2450
|
}
|
|
1871
2451
|
// A server's channel has its overwrites, none until a test puts some in the cache, which its permissions read
|
|
@@ -1970,6 +2550,7 @@ const CHANNEL_TYPE_GUARDS = [
|
|
|
1970
2550
|
id: entry
|
|
1971
2551
|
}) : entry);
|
|
1972
2552
|
const manager = Object.create(GuildMemberRoleManager.prototype);
|
|
2553
|
+
// Its member's server and client, as discord.js's constructor sets them, and what it holds, so resolve() reads it
|
|
1973
2554
|
Object.defineProperties(manager, {
|
|
1974
2555
|
cache: {
|
|
1975
2556
|
get: cache
|
|
@@ -1977,6 +2558,15 @@ const CHANNEL_TYPE_GUARDS = [
|
|
|
1977
2558
|
member: {
|
|
1978
2559
|
value: member
|
|
1979
2560
|
},
|
|
2561
|
+
guild: {
|
|
2562
|
+
get: ()=>member.guild
|
|
2563
|
+
},
|
|
2564
|
+
client: {
|
|
2565
|
+
get: ()=>member.client
|
|
2566
|
+
},
|
|
2567
|
+
holds: {
|
|
2568
|
+
value: holdsOf(GuildMemberRoleManager.prototype)
|
|
2569
|
+
},
|
|
1980
2570
|
highest: {
|
|
1981
2571
|
get: highest
|
|
1982
2572
|
}
|
|
@@ -2301,7 +2891,7 @@ let placeReadsSeen;
|
|
|
2301
2891
|
"Give the member's guild too, or call useStrictMocks() to place it there now."
|
|
2302
2892
|
],
|
|
2303
2893
|
guildId: [
|
|
2304
|
-
'a server
|
|
2894
|
+
'a placeholder server with that id, though the mock was given only a guildId',
|
|
2305
2895
|
"a guildId alone is a server the bot isn't in: guild and channel are null, and the member is a raw one",
|
|
2306
2896
|
'Give a guild too, or call useStrictMocks() to read it so now.'
|
|
2307
2897
|
]
|
|
@@ -2427,7 +3017,7 @@ let placeReadsSeen;
|
|
|
2427
3017
|
guild.members = managerWith(GuildMemberManager.prototype, undefined);
|
|
2428
3018
|
guild.channels = managerWith(GuildChannelManager.prototype, undefined);
|
|
2429
3019
|
guild.roles = guildRoleManager(guild.id, undefined);
|
|
2430
|
-
guild
|
|
3020
|
+
for (const [key, Manager] of GUILD_MANAGERS)guild[key] = managerWith(Manager.prototype, undefined);
|
|
2431
3021
|
const proxy = stubDeep(guild);
|
|
2432
3022
|
settleGuild(guild, proxy);
|
|
2433
3023
|
return proxy;
|
|
@@ -2449,12 +3039,39 @@ const botMembers = new WeakMap();
|
|
|
2449
3039
|
return guildId ? cacheOf(guild.channels)?.get(channelId) : null;
|
|
2450
3040
|
});
|
|
2451
3041
|
}
|
|
3042
|
+
// A guild's managers beside members, channels and roles, each empty, of its discord.js class
|
|
3043
|
+
const GUILD_MANAGERS = [
|
|
3044
|
+
[
|
|
3045
|
+
'bans',
|
|
3046
|
+
GuildBanManager
|
|
3047
|
+
],
|
|
3048
|
+
[
|
|
3049
|
+
'emojis',
|
|
3050
|
+
GuildEmojiManager
|
|
3051
|
+
],
|
|
3052
|
+
[
|
|
3053
|
+
'stickers',
|
|
3054
|
+
GuildStickerManager
|
|
3055
|
+
],
|
|
3056
|
+
[
|
|
3057
|
+
'scheduledEvents',
|
|
3058
|
+
GuildScheduledEventManager
|
|
3059
|
+
],
|
|
3060
|
+
[
|
|
3061
|
+
'autoModerationRules',
|
|
3062
|
+
AutoModerationRuleManager
|
|
3063
|
+
],
|
|
3064
|
+
[
|
|
3065
|
+
'invites',
|
|
3066
|
+
GuildInviteManager
|
|
3067
|
+
]
|
|
3068
|
+
];
|
|
2452
3069
|
/** 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) {
|
|
2453
3070
|
for (const key of [
|
|
2454
3071
|
'members',
|
|
2455
3072
|
'channels',
|
|
2456
3073
|
'roles',
|
|
2457
|
-
|
|
3074
|
+
...GUILD_MANAGERS.map(([key])=>key)
|
|
2458
3075
|
])instance[key].guild = guild;
|
|
2459
3076
|
}
|
|
2460
3077
|
/**
|
|
@@ -2503,9 +3120,38 @@ const botMembers = new WeakMap();
|
|
|
2503
3120
|
};
|
|
2504
3121
|
}
|
|
2505
3122
|
/** A cache's collection, or undefined for a stub a test put in place of the manager. */ const cacheOf = (manager)=>{
|
|
2506
|
-
|
|
2507
|
-
|
|
3123
|
+
// The mock's own read, which a message cache's warning leaves to the test's reads
|
|
3124
|
+
readingOwnCache++;
|
|
3125
|
+
try {
|
|
3126
|
+
const cache = manager?.cache;
|
|
3127
|
+
return cache instanceof Collection ? cache : undefined;
|
|
3128
|
+
} finally{
|
|
3129
|
+
readingOwnCache--;
|
|
3130
|
+
}
|
|
2508
3131
|
};
|
|
3132
|
+
// The managers of a server whose caches a placeholder one holds empty
|
|
3133
|
+
const PLACEHOLDER_GUILD_MANAGERS = [
|
|
3134
|
+
'members',
|
|
3135
|
+
'roles',
|
|
3136
|
+
'channels',
|
|
3137
|
+
'emojis',
|
|
3138
|
+
'stickers',
|
|
3139
|
+
'bans',
|
|
3140
|
+
'invites',
|
|
3141
|
+
'scheduledEvents',
|
|
3142
|
+
'autoModerationRules'
|
|
3143
|
+
];
|
|
3144
|
+
/** 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) {
|
|
3145
|
+
return stubDeep({
|
|
3146
|
+
id,
|
|
3147
|
+
...Object.fromEntries(PLACEHOLDER_GUILD_MANAGERS.map((key)=>[
|
|
3148
|
+
key,
|
|
3149
|
+
stubDeep({
|
|
3150
|
+
cache: new Collection()
|
|
3151
|
+
})
|
|
3152
|
+
]))
|
|
3153
|
+
});
|
|
3154
|
+
}
|
|
2509
3155
|
/** The cached item under `id`, put there by `make` when the cache lacks it. */ function cached(cache, id, make) {
|
|
2510
3156
|
const existing = cache?.get(id);
|
|
2511
3157
|
if (existing !== undefined) return existing;
|
|
@@ -2533,19 +3179,22 @@ const botMembers = new WeakMap();
|
|
|
2533
3179
|
id,
|
|
2534
3180
|
cached(guildCaches.members, id, ()=>memberIn(guild, id, cached(userCache, id, ()=>mockUser(id))))
|
|
2535
3181
|
])) : null,
|
|
3182
|
+
// Full mocks, as the gateway delivers them: a role of the server, and a channel with its managers and overwrites
|
|
2536
3183
|
roles: new Collection(guildCaches ? roles.map((id)=>[
|
|
2537
3184
|
id,
|
|
2538
|
-
cached(guildCaches.roles, id, ()=>
|
|
3185
|
+
cached(guildCaches.roles, id, ()=>createMockInteraction(Role, {
|
|
2539
3186
|
id,
|
|
2540
3187
|
guild
|
|
2541
|
-
}))
|
|
3188
|
+
}))
|
|
2542
3189
|
]) : []),
|
|
2543
3190
|
channels: new Collection(channels.map((id)=>[
|
|
2544
3191
|
id,
|
|
2545
|
-
cached(guildCaches ? guildCaches.channels : cacheOf(client.channels), id, ()=>
|
|
3192
|
+
cached(guildCaches ? guildCaches.channels : cacheOf(client.channels), id, ()=>createMockChannel(TextChannel, guild ? {
|
|
2546
3193
|
id,
|
|
2547
3194
|
guild
|
|
2548
|
-
}
|
|
3195
|
+
} : {
|
|
3196
|
+
id
|
|
3197
|
+
}))
|
|
2549
3198
|
]))
|
|
2550
3199
|
};
|
|
2551
3200
|
// Own values, so the prototype's getters, which read the raw mention data, are not reached
|
|
@@ -2828,6 +3477,7 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
|
|
|
2828
3477
|
}));
|
|
2829
3478
|
if (sentBy) warnOnSentReads(instance, stubs, sentBy);
|
|
2830
3479
|
const message = stubDeep(instance, stubs);
|
|
3480
|
+
if (channel) noteMadeIn(channel, message);
|
|
2831
3481
|
return message;
|
|
2832
3482
|
}
|
|
2833
3483
|
/**
|
|
@@ -2876,7 +3526,8 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
|
|
|
2876
3526
|
* 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
|
|
2877
3527
|
* a DM send in a server of its own and keeps another author, with a warning when read.
|
|
2878
3528
|
*/ function sentMessage(held, channel, client, fields = {}, made) {
|
|
2879
|
-
|
|
3529
|
+
// The mock's own read of where the message was sent
|
|
3530
|
+
const by = client ?? readPlaceQuietly(()=>channel?.client);
|
|
2880
3531
|
const inChannel = channel instanceof BaseChannel;
|
|
2881
3532
|
const dm = inChannel && channel.isDMBased();
|
|
2882
3533
|
const strict = strictMocks();
|
|
@@ -2917,6 +3568,11 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
|
|
|
2917
3568
|
if (typeof value === 'object' && value !== null) return ApplicationCommandOptionType.Mentionable;
|
|
2918
3569
|
return ApplicationCommandOptionType.String;
|
|
2919
3570
|
}
|
|
3571
|
+
/** The option types a user types into, which an autocomplete's focused option is one of. */ const TYPED = [
|
|
3572
|
+
ApplicationCommandOptionType.String,
|
|
3573
|
+
ApplicationCommandOptionType.Integer,
|
|
3574
|
+
ApplicationCommandOptionType.Number
|
|
3575
|
+
];
|
|
2920
3576
|
/** The discord.js classes an entity option's value is an instance of, each read by its own getters. */ const ENTITY_CLASSES = [
|
|
2921
3577
|
User,
|
|
2922
3578
|
GuildMember,
|
|
@@ -2969,7 +3625,7 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
|
|
|
2969
3625
|
* An entity option arrives as a snowflake in `value` *and* as the resolved object on
|
|
2970
3626
|
* its own field, and code that reads only one of the two is exactly what this lets a
|
|
2971
3627
|
* test catch — so both are set. A user option has its user, and in a server its member too.
|
|
2972
|
-
*/ function toOptionData(name, value, memberOf) {
|
|
3628
|
+
*/ function toOptionData(name, value, memberOf, unresolved) {
|
|
2973
3629
|
const type = optionTypeOf(value);
|
|
2974
3630
|
const isEntity = typeof value === 'object' && value !== null;
|
|
2975
3631
|
const option = {
|
|
@@ -2982,7 +3638,8 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
|
|
|
2982
3638
|
// Read once the options are given to an interaction, which says whether it was made in a server
|
|
2983
3639
|
if (memberOf) Object.defineProperty(option, 'member', {
|
|
2984
3640
|
get: ()=>memberOf(name, value) ?? undefined,
|
|
2985
|
-
enumerable: true
|
|
3641
|
+
enumerable: true,
|
|
3642
|
+
configurable: true
|
|
2986
3643
|
});
|
|
2987
3644
|
} else if (value instanceof GuildMember) {
|
|
2988
3645
|
option.user = value.user;
|
|
@@ -2991,6 +3648,25 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
|
|
|
2991
3648
|
else if (value instanceof BaseChannel) option.channel = value;
|
|
2992
3649
|
else if (value instanceof Attachment) option.attachment = value;
|
|
2993
3650
|
else if (isEntity) option.user = value;
|
|
3651
|
+
// An autocomplete's entity option is its id alone, as Discord sends it, when the options belong to one
|
|
3652
|
+
if (unresolved) {
|
|
3653
|
+
for (const field of [
|
|
3654
|
+
'user',
|
|
3655
|
+
'member',
|
|
3656
|
+
'role',
|
|
3657
|
+
'channel',
|
|
3658
|
+
'attachment'
|
|
3659
|
+
]){
|
|
3660
|
+
const descriptor = Object.getOwnPropertyDescriptor(option, field);
|
|
3661
|
+
if (!descriptor) continue;
|
|
3662
|
+
const read = descriptor.get ?? (()=>descriptor.value);
|
|
3663
|
+
Object.defineProperty(option, field, {
|
|
3664
|
+
get: ()=>unresolved() ? undefined : read(),
|
|
3665
|
+
enumerable: true,
|
|
3666
|
+
configurable: true
|
|
3667
|
+
});
|
|
3668
|
+
}
|
|
3669
|
+
}
|
|
2994
3670
|
return option;
|
|
2995
3671
|
}
|
|
2996
3672
|
/**
|
|
@@ -3016,8 +3692,8 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
|
|
|
3016
3692
|
/**
|
|
3017
3693
|
* Nests the supplied options under the subcommand path they were invoked through,
|
|
3018
3694
|
* matching the shape Discord sends rather than a flat list.
|
|
3019
|
-
*/ function buildOptionData(subcommandGroup, subcommand, values, memberOf, focused) {
|
|
3020
|
-
const leaves = Object.entries(values).map(([name, value])=>name === focused ? focusedOptionData(name, value) : toOptionData(name, value, memberOf));
|
|
3695
|
+
*/ function buildOptionData(subcommandGroup, subcommand, values, memberOf, focused, unresolved) {
|
|
3696
|
+
const leaves = Object.entries(values).map(([name, value])=>name === focused ? focusedOptionData(name, value) : toOptionData(name, value, memberOf, unresolved));
|
|
3021
3697
|
if (focused !== null && !Object.hasOwn(values, focused)) leaves.push(focusedOptionData(focused, null));
|
|
3022
3698
|
if (subcommand === null) return leaves;
|
|
3023
3699
|
const sub = {
|
|
@@ -3052,7 +3728,8 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
|
|
|
3052
3728
|
* entity option carries its id in `value` and the object itself, as the gateway sends it: a user option its `user`,
|
|
3053
3729
|
* and in a server its `member` too. A user option's
|
|
3054
3730
|
* `getMember()` is the user's member in the server of the interaction the options are given to, and `null` in a DM;
|
|
3055
|
-
* a member given resolves `getUser()` to its user.
|
|
3731
|
+
* a member given resolves `getUser()` to its user. Under `useStrictMocks()` a user option's `getMentionable()` is its
|
|
3732
|
+
* 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
|
|
3056
3733
|
* 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,
|
|
3057
3734
|
* is `null`, or that error when required, as the option may be a mentionable one; a plain `{ id }` reads as any entity.
|
|
3058
3735
|
* A whole number is an Integer option and a fraction a Number one, so
|
|
@@ -3060,7 +3737,13 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
|
|
|
3060
3737
|
*
|
|
3061
3738
|
* The `focused` option reads as Discord sends it, through `getFocused()`, `getFocused(true)` and `data` alike: a
|
|
3062
3739
|
* string, `''` when no value is given, and marked `focused` in `data`. A number given for it is its digits under
|
|
3063
|
-
* `useStrictMocks()`; otherwise it stays the number, with a warning when read.
|
|
3740
|
+
* `useStrictMocks()`; otherwise it stays the number, with a warning when read. Under `useStrictMocks()` `get()` and the
|
|
3741
|
+
* typed getters read it by name from that same option, as discord.js does; one given no value takes the string,
|
|
3742
|
+
* integer and number getters alike. Default mode reads what was given, `null` for no value, with a warning once.
|
|
3743
|
+
*
|
|
3744
|
+
* Given to an `AutocompleteInteraction`, a user, member, role, channel, mentionable or attachment option reads as
|
|
3745
|
+
* Discord sends an autocomplete's options under `useStrictMocks()`: its id alone, in `data` and the handler's params,
|
|
3746
|
+
* with the entity getters returning `null`. Otherwise it keeps the object given, with a warning once.
|
|
3064
3747
|
*
|
|
3065
3748
|
* @typeParam Cached - `any` by default, to match `createMockInteraction(ChatInputCommandInteraction)`; pass it when the
|
|
3066
3749
|
* interaction under test is cache-pinned.
|
|
@@ -3082,6 +3765,10 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
|
|
|
3082
3765
|
* @see {@link ChatInputOptions}
|
|
3083
3766
|
*/ function createChatInputOptions(opts = {}) {
|
|
3084
3767
|
const { subcommandGroup = null, subcommand = null, focused = null, ...values } = opts;
|
|
3768
|
+
// The option the user is typing, as Discord sent it among the others, which every getter reads in discord.js
|
|
3769
|
+
const focusedOption = ()=>base._hoistedOptions.find((each)=>each.focused);
|
|
3770
|
+
// Default mode reads the focused option from what the test gave, and says once what discord.js reads instead
|
|
3771
|
+
const warnFocused = (name)=>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.');
|
|
3085
3772
|
function resolveOrThrow(name, value, required) {
|
|
3086
3773
|
{
|
|
3087
3774
|
if (required === true) throw optionError(DiscordjsErrorCodes.CommandInteractionOptionNotFound, name);
|
|
@@ -3098,14 +3785,36 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
|
|
|
3098
3785
|
// As in discord.js, a subcommand is required unless told otherwise, and a group is not
|
|
3099
3786
|
base.getSubcommandGroup = createMockFn((required = false)=>resolveSubEntry(subcommandGroup, DiscordjsErrorCodes.CommandInteractionOptionNoSubcommandGroup, required));
|
|
3100
3787
|
base.getSubcommand = createMockFn((required = true)=>resolveSubEntry(subcommand, DiscordjsErrorCodes.CommandInteractionOptionNoSubcommand, required));
|
|
3788
|
+
// Under strict mocks, options given to an autocomplete hold their entities as ids alone, as Discord sends them
|
|
3789
|
+
const unresolved = ()=>strictMocks() && ownedByAutocomplete(resolver);
|
|
3101
3790
|
// As discord.js reads an option by its type: another type throws its type error, required or not. A value whose
|
|
3102
3791
|
// 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
|
|
3103
3792
|
// take it, and throws when required
|
|
3104
3793
|
const readOption = (name, expected, takes, required)=>{
|
|
3794
|
+
if (name === focused && strictMocks()) {
|
|
3795
|
+
// discord.js reads the focused option as Discord sent it: a string, its own type kept. One given no value may be
|
|
3796
|
+
// any type the user can type into, as the mock doesn't know which it was declared as
|
|
3797
|
+
const option = focusedOption();
|
|
3798
|
+
const types = values[name] === undefined || values[name] === null ? TYPED : [
|
|
3799
|
+
option.type
|
|
3800
|
+
];
|
|
3801
|
+
if (!types.some((type)=>expected.includes(type))) {
|
|
3802
|
+
throw optionError(DiscordjsErrorCodes.CommandInteractionOptionType, name, option.type, expected.join(', '));
|
|
3803
|
+
}
|
|
3804
|
+
return option.value;
|
|
3805
|
+
}
|
|
3105
3806
|
const value = values[name];
|
|
3807
|
+
if (name === focused && (value === undefined || value === null)) warnFocused(name);
|
|
3808
|
+
// Read through the option Discord sent, whose number warns where discord.js reads a string
|
|
3809
|
+
if (name === focused && typeof value === 'number') void focusedOption().value;
|
|
3106
3810
|
if (value === undefined || value === null) return resolveOrThrow(name, null, required);
|
|
3107
3811
|
const typeError = ()=>optionError(DiscordjsErrorCodes.CommandInteractionOptionType, name, optionTypeOf(value), expected.join(', '));
|
|
3108
3812
|
if (!possibleTypesOf(value).some((type)=>expected.includes(type))) throw typeError();
|
|
3813
|
+
// An entity in an autocomplete's options is its id alone, so a getter finds nothing, as in discord.js
|
|
3814
|
+
if (typeof value === 'object' && unresolved()) {
|
|
3815
|
+
if (required === true) throw optionError(DiscordjsErrorCodes.CommandInteractionOptionEmpty, name, optionTypeOf(value));
|
|
3816
|
+
return null;
|
|
3817
|
+
}
|
|
3109
3818
|
if (takes(value)) return value;
|
|
3110
3819
|
if (required === true) throw typeError();
|
|
3111
3820
|
return null;
|
|
@@ -3166,15 +3875,25 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
|
|
|
3166
3875
|
], plainOr(User, GuildMember));
|
|
3167
3876
|
return value instanceof User ? memberOf(name, value) : value;
|
|
3168
3877
|
});
|
|
3169
|
-
|
|
3878
|
+
// A user's member in a server, as discord.js reads `member ?? user`; default mode keeps the user, with a warning
|
|
3879
|
+
base.getMentionable = createMockFn((name, required)=>{
|
|
3880
|
+
const value = readOption(name, [
|
|
3170
3881
|
MENTIONABLE
|
|
3171
|
-
], plainOr(User, GuildMember, Role), required)
|
|
3882
|
+
], plainOr(User, GuildMember, Role), required);
|
|
3883
|
+
if (!(value instanceof User)) return value;
|
|
3884
|
+
const member = memberOf(name, value);
|
|
3885
|
+
if (member === null || strictMocks()) return member ?? value;
|
|
3886
|
+
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.');
|
|
3887
|
+
return value;
|
|
3888
|
+
});
|
|
3172
3889
|
base.getAttachment = createMockFn((name, required)=>readOption(name, [
|
|
3173
3890
|
ATTACHMENT
|
|
3174
3891
|
], plainOr(Attachment), required));
|
|
3175
3892
|
base.get = createMockFn((name, required)=>{
|
|
3893
|
+
if (name === focused && strictMocks()) return focusedOption();
|
|
3894
|
+
if (name === focused) warnFocused(name);
|
|
3176
3895
|
const value = values[name];
|
|
3177
|
-
return value === undefined || value === null ? resolveOrThrow(name, null, required) : toOptionData(name, value, memberOf);
|
|
3896
|
+
return value === undefined || value === null ? resolveOrThrow(name, null, required) : toOptionData(name, value, memberOf, unresolved);
|
|
3178
3897
|
});
|
|
3179
3898
|
// A message option is a message context menu's own, never a slash command's
|
|
3180
3899
|
base.getMessage = createMockFn((name, required)=>readOption(name, [
|
|
@@ -3183,20 +3902,41 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
|
|
|
3183
3902
|
base.getFocused = createMockFn((getFull)=>{
|
|
3184
3903
|
if (focused === null) throw optionError(DiscordjsErrorCodes.AutocompleteInteractionOptionNoFocusedOption);
|
|
3185
3904
|
// The one options.data holds, so the two agree
|
|
3186
|
-
const option =
|
|
3905
|
+
const option = focusedOption();
|
|
3187
3906
|
return getFull === true ? {
|
|
3188
3907
|
...option
|
|
3189
3908
|
} : option.value;
|
|
3190
3909
|
});
|
|
3191
3910
|
// `data` is what the framework reads to build a handler's params, so it is materialised here rather than
|
|
3192
3911
|
// auto-stubbed, or every params assertion would see an empty record.
|
|
3193
|
-
base.data = buildOptionData(subcommandGroup, subcommand, values, memberOf, focused);
|
|
3912
|
+
base.data = buildOptionData(subcommandGroup, subcommand, values, memberOf, focused, unresolved);
|
|
3194
3913
|
// The fields discord.js's constructor sets from it, which its toString() reads
|
|
3195
3914
|
base._group = subcommandGroup;
|
|
3196
3915
|
base._subcommand = subcommand;
|
|
3197
3916
|
base._hoistedOptions = subcommand === null ? base.data : (subcommandGroup === null ? base.data[0] : base.data[0].options[0]).options;
|
|
3198
3917
|
const resolver = stubDeep(base);
|
|
3918
|
+
const entities = Object.entries(values).filter(([name, value])=>name !== focused && typeof value === 'object' && value !== null);
|
|
3919
|
+
optionEntities.set(resolver, entities.map(([name])=>name));
|
|
3199
3920
|
return resolver;
|
|
3200
3921
|
}
|
|
3922
|
+
// What a mock reads for a property the test gave none, made with this module's factories and helpers. Built last, as it
|
|
3923
|
+
// reads them, and only read once a mock is made
|
|
3924
|
+
const discordDefault = discordDefaults({
|
|
3925
|
+
botMemberOf,
|
|
3926
|
+
createChatInputOptions,
|
|
3927
|
+
createMockChannel,
|
|
3928
|
+
createMockGuild,
|
|
3929
|
+
createMockMessage,
|
|
3930
|
+
createMockUser,
|
|
3931
|
+
heldChannelOf,
|
|
3932
|
+
isRawMember,
|
|
3933
|
+
isUnsetEveryone,
|
|
3934
|
+
madeAt,
|
|
3935
|
+
memberRoles,
|
|
3936
|
+
NEW_SERVER_EVERYONE_PERMISSIONS,
|
|
3937
|
+
ownedManager,
|
|
3938
|
+
readPlaceAs,
|
|
3939
|
+
readPlaceQuietly
|
|
3940
|
+
});
|
|
3201
3941
|
|
|
3202
|
-
export { NEW_SERVER_EVERYONE_PERMISSIONS,
|
|
3942
|
+
export { NEW_SERVER_EVERYONE_PERMISSIONS, botMemberOf, createChatInputOptions, createMock, createMockChannel, createMockClient, createMockGuild, createMockInteraction, createMockMember, createMockMessage, createMockRawMember, createMockUser, heldChannelOf, isRawMember, isUnsetEveryone, madeAt, memberRoles, noteReactionDispatch, ownedManager, readPlaceAs, readPlaceQuietly, returnsPromise };
|