meocord 4.2.0 → 4.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +322 -0
- package/THIRD_PARTY_NOTICES.md +14 -13
- package/dist/cjs/_shared/{controller.decorator-CRn9kWob.cjs → controller.decorator-U2NBWcoU.cjs} +416 -108
- package/dist/cjs/_shared/{providers-ChvMqnLd.cjs → providers-B4MOFBrW.cjs} +202 -50
- package/dist/cjs/_shared/{sharded-cooldown-store-BdAUTdYs.cjs → sharded-cooldown-store-HTUb3Ii_.cjs} +10 -7
- package/dist/cjs/_shared/{startup-roots-CQo8mSP8.cjs → startup-roots-lA5xkBFq.cjs} +228 -71
- package/dist/cjs/_shared/{translate-error-DhDVbT5M.cjs → translate-error-CbBHUu5P.cjs} +10 -5
- package/dist/cjs/common/index.cjs +3 -3
- package/dist/cjs/core/index.cjs +188 -169
- package/dist/cjs/decorator/index.cjs +52 -7
- package/dist/cjs/testing/index.cjs +1854 -311
- package/dist/cli.json +1 -1
- package/dist/esm/bin/generator.js +4 -5
- package/dist/esm/bin/helper/filter-generator.helper.js +4 -5
- package/dist/esm/bin/helper/guard-generator.helper.js +4 -5
- package/dist/esm/bin/helper/interceptor-generator.helper.js +4 -5
- package/dist/esm/bin/helper/observer-generator.helper.js +4 -5
- package/dist/esm/bin/helper/pipe-generator.helper.js +4 -5
- package/dist/esm/bin/helper/service-generator.helper.js +4 -5
- package/dist/esm/bin/meocord.js +84 -25
- package/dist/esm/build/load-config.pre-entry.js +13 -3
- package/dist/esm/build/rsbuild-config.js +16 -12
- package/dist/esm/build/typed-externals.js +36 -0
- package/dist/esm/common/cooldown-store.js +10 -2
- package/dist/esm/common/errors.js +25 -0
- package/dist/esm/common/meocord-text.js +11 -8
- package/dist/esm/common/response/call-order.js +9 -4
- package/dist/esm/common/response/presenter.js +33 -6
- package/dist/esm/common/response/response-state.js +89 -15
- package/dist/esm/common/route.js +11 -4
- package/dist/esm/common/sharded-cooldown-store.js +1 -1
- package/dist/esm/common/translator.js +52 -11
- package/dist/esm/core/command-registration.js +93 -24
- package/dist/esm/core/command-routes.js +2 -2
- package/dist/esm/core/cooldown-runner.js +29 -8
- package/dist/esm/core/dispatcher.js +9 -8
- package/dist/esm/core/fallback.js +20 -11
- package/dist/esm/core/filter-runner.js +2 -2
- package/dist/esm/core/guard-runner.js +16 -3
- package/dist/esm/core/handler-input.js +4 -3
- package/dist/esm/core/handler-pipeline.js +10 -4
- package/dist/esm/core/lifecycle-hooks.js +134 -47
- package/dist/esm/core/meocord-factory.js +59 -34
- package/dist/esm/core/meocord.app.js +116 -75
- package/dist/esm/core/message-help.js +32 -6
- package/dist/esm/core/observer-runner.js +2 -1
- package/dist/esm/core/providers.js +19 -4
- package/dist/esm/core/running-calls.js +43 -0
- package/dist/esm/core/shard-context.js +5 -6
- package/dist/esm/core/shard-manager.js +16 -10
- package/dist/esm/core/stage-scope.js +2 -1
- package/dist/esm/core/startup-roots.js +21 -1
- package/dist/esm/core/theme-resolvers.js +4 -1
- package/dist/esm/core/theme-scope.js +7 -2
- package/dist/esm/core/theme-validation.js +17 -10
- package/dist/esm/decorator/app.decorator.js +31 -0
- package/dist/esm/decorator/controller.decorator.js +56 -20
- package/dist/esm/decorator/cooldown.decorator.js +17 -0
- package/dist/esm/decorator/filter.decorator.js +2 -2
- package/dist/esm/decorator/guard.decorator.js +3 -2
- package/dist/esm/package.json.js +1 -1
- package/dist/esm/testing/catalog.js +20 -13
- package/dist/esm/testing/cooldown-store-suite.js +67 -5
- package/dist/esm/testing/discord-defaults.js +225 -30
- package/dist/esm/testing/meocord-testing-module.js +158 -44
- package/dist/esm/testing/mock-fn.js +107 -26
- package/dist/esm/testing/mock-interaction.js +1250 -182
- package/dist/esm/testing/snowflake.js +9 -2
- package/dist/esm/testing/strict-mocks.js +40 -17
- package/dist/esm/util/common.util.js +47 -19
- package/dist/esm/util/dev-runner.util.js +8 -8
- package/dist/esm/util/generator-cli.util.js +4 -5
- package/dist/esm/util/interaction.util.js +2 -2
- package/dist/esm/util/meocord-config-loader.util.js +10 -2
- package/dist/esm/util/meocord-config-validation.util.js +3 -5
- package/dist/esm/util/meocord-source-config.util.js +65 -20
- package/dist/esm/util/method-wrapper.util.js +16 -0
- package/dist/esm/util/platform.util.js +10 -10
- package/dist/esm/util/shard-process.util.js +4 -1
- package/dist/esm/util/sharding-mode.util.js +5 -7
- package/dist/esm/util/shared-state.util.js +21 -0
- package/dist/esm/util/tsconfig-paths.util.js +29 -0
- package/dist/esm/util/tsconfig.util.js +10 -10
- package/dist/esm/util/value.util.js +30 -2
- package/dist/types/common/index.d.cts +10 -7
- package/dist/types/common/index.d.ts +10 -7
- package/dist/types/core/index.d.cts +3 -2
- package/dist/types/core/index.d.ts +3 -2
- package/dist/types/decorator/index.d.cts +1 -0
- package/dist/types/decorator/index.d.ts +1 -0
- package/dist/types/{index-CdXsFgjE.d.ts → index-DKJclIi-.d.ts} +45 -21
- package/dist/types/{index-nn5p7OoX.d.cts → index-Dlf0ZZtS.d.cts} +45 -21
- package/dist/types/interface/index.d.cts +1 -1
- package/dist/types/interface/index.d.ts +1 -1
- package/dist/types/{route-BZgFoNeB.d.cts → route-Bh1U13yy.d.cts} +3 -2
- package/dist/types/{route-BZgFoNeB.d.ts → route-Bh1U13yy.d.ts} +3 -2
- package/dist/types/testing/index.d.cts +198 -62
- package/dist/types/testing/index.d.ts +198 -62
- package/dist/types/{theme-resolvers-UKr3-6Ys.d.cts → theme-resolvers-CyEz9pnv.d.cts} +1 -1
- package/dist/types/{theme-resolvers-BcOJVzR9.d.ts → theme-resolvers-DA204O-q.d.ts} +1 -1
- package/dist/types/{translator-DHMkkCpg.d.cts → translator-Bg7hwxDF.d.cts} +21 -8
- package/dist/types/{translator-BViWKtLJ.d.ts → translator-CeP4bCzp.d.ts} +21 -8
- package/package.json +2 -2
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
import 'reflect-metadata';
|
|
2
|
-
import { createMockFn } 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, CommandInteractionOptionResolver, ApplicationCommandOptionType, Client, ApplicationCommandManager, ClientUser, CDN, AuthorizingIntegrationOwners, BaseInteraction, UserContextMenuCommandInteraction, Locale, StringSelectMenuInteraction, UserSelectMenuInteraction, RoleSelectMenuInteraction, ChannelSelectMenuInteraction, MentionableSelectMenuInteraction, Collection,
|
|
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, GuildEmojiManager, GuildEmoji, GuildStickerManager, Sticker, GuildScheduledEventManager, GuildScheduledEvent, AutoModerationRuleManager, AutoModerationRule, GuildInviteManager, Invite, CommandInteractionOptionResolver, ApplicationCommandOptionType, Client, ApplicationCommandManager, ClientUser, CDN, DiscordjsErrorCodes, AuthorizingIntegrationOwners, BaseInteraction, UserContextMenuCommandInteraction, MessageContextMenuCommandInteraction, Attachment, Locale, StringSelectMenuInteraction, UserSelectMenuInteraction, RoleSelectMenuInteraction, ChannelSelectMenuInteraction, MentionableSelectMenuInteraction, Collection, PermissionsBitField, Base, SnowflakeUtil, DiscordjsError, DMChannel, GuildMemberRoleManager, MessageFlagsBitField, BaseManager, TextChannel, Component, Embed, ActionRow, ApplicationCommand, NewsChannel, ForumChannel, MediaChannel, DiscordjsTypeError, MessageFlags } from 'discord.js';
|
|
4
4
|
import { createDiscordError } from './response.js';
|
|
5
5
|
import { stampCall } from '../common/response/call-order.js';
|
|
6
|
-
import { discordDefault, REAL_GETTER } from './discord-defaults.js';
|
|
6
|
+
import { discordDefault, 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
|
-
import {
|
|
11
|
+
import { strictMocks, noteMockMade } from './strict-mocks.js';
|
|
12
|
+
import { sharedName } from '../util/shared-state.util.js';
|
|
12
13
|
|
|
13
14
|
// ---------------------------------------------------------------------------
|
|
14
15
|
// stubDeep — Proxy that answers each property it lacks: a mock fn for a method, a default, or a nested stub
|
|
@@ -20,9 +21,15 @@ const SKIP = new Set([
|
|
|
20
21
|
'toJSON',
|
|
21
22
|
'then'
|
|
22
23
|
]);
|
|
24
|
+
// Each mock's own instance, which the proxy's traps receive as their target
|
|
25
|
+
const mockTargets = new WeakMap();
|
|
26
|
+
// Warnings a read answered by the stub logs first, by the mock's instance and the property
|
|
27
|
+
const stubReadWarnings = new WeakMap();
|
|
23
28
|
function stubDeep(instance, externalStubs, onSet) {
|
|
24
29
|
noteMockMade();
|
|
25
30
|
const stubs = externalStubs ?? new Map();
|
|
31
|
+
// Placeholders read so far, by the value strict mocks give: a read of a kept one still warns, once
|
|
32
|
+
const placeholders = new Map();
|
|
26
33
|
const proxy = new Proxy(instance, {
|
|
27
34
|
get (target, prop) {
|
|
28
35
|
// Always pass through symbols
|
|
@@ -36,12 +43,17 @@ function stubDeep(instance, externalStubs, onSet) {
|
|
|
36
43
|
if (Object.prototype.hasOwnProperty.call(target, key)) {
|
|
37
44
|
return Reflect.get(target, prop, target);
|
|
38
45
|
}
|
|
46
|
+
stubReadWarnings.get(target)?.get(key)?.();
|
|
39
47
|
// Skip passthrough props — return prototype value as-is
|
|
40
48
|
if (SKIP.has(key)) {
|
|
41
49
|
return Reflect.get(target, prop, target);
|
|
42
50
|
}
|
|
43
51
|
// Return cached stub
|
|
44
|
-
if (stubs.has(key))
|
|
52
|
+
if (stubs.has(key)) {
|
|
53
|
+
const kept = placeholders.get(key);
|
|
54
|
+
if (kept !== undefined) warnPlaceholderRead(target, key, kept);
|
|
55
|
+
return stubs.get(key);
|
|
56
|
+
}
|
|
45
57
|
// A value discord.js always gives, in its shape, rather than a stub: kept once read, or its getter run live
|
|
46
58
|
const known = discordDefault(target, key);
|
|
47
59
|
if (known === REAL_GETTER) {
|
|
@@ -55,6 +67,12 @@ function stubDeep(instance, externalStubs, onSet) {
|
|
|
55
67
|
stubs.set(key, value);
|
|
56
68
|
return value;
|
|
57
69
|
}
|
|
70
|
+
// A value strict mocks read as discord.js gives it, which is a placeholder otherwise
|
|
71
|
+
const strictValue = strictMocks() ? undefined : placeholderValue(target, key);
|
|
72
|
+
if (strictValue !== undefined) {
|
|
73
|
+
placeholders.set(key, strictValue);
|
|
74
|
+
warnPlaceholderRead(target, key, strictValue);
|
|
75
|
+
}
|
|
58
76
|
// Walk the prototype chain to check if it's a function
|
|
59
77
|
let proto = Object.getPrototypeOf(target);
|
|
60
78
|
let protoValue;
|
|
@@ -65,7 +83,7 @@ function stubDeep(instance, externalStubs, onSet) {
|
|
|
65
83
|
if (desc.get && isPlaceholder(proto, key)) {
|
|
66
84
|
// Strict mocks run discord.js's getter, read live; otherwise the placeholder, with a warning once
|
|
67
85
|
if (strictMocks()) return Reflect.get(target, prop, proxy);
|
|
68
|
-
|
|
86
|
+
warnPlaceholderRead(target, key, 'false');
|
|
69
87
|
}
|
|
70
88
|
break;
|
|
71
89
|
}
|
|
@@ -73,7 +91,7 @@ function stubDeep(instance, externalStubs, onSet) {
|
|
|
73
91
|
}
|
|
74
92
|
// A client the mock's structures share: its guild's, or one of its own
|
|
75
93
|
if (key === 'client' && target instanceof Base) return clientOf(target);
|
|
76
|
-
const stub = typeof protoValue === 'function' ? isRealMethod(target, key) ? createMockFn((...args)=>protoValue.apply(proxy, args)) : methodStub(target, key, protoValue, ()=>proxy) : stubDeep({});
|
|
94
|
+
const stub = typeof protoValue === 'function' ? isRealMethod(target, key) ? createMockFn((...args)=>quietly(target, key, ()=>protoValue.apply(proxy, args))) : methodStub(target, key, protoValue, ()=>proxy) : stubDeep({});
|
|
77
95
|
stubs.set(key, stub);
|
|
78
96
|
return stub;
|
|
79
97
|
},
|
|
@@ -82,7 +100,10 @@ function stubDeep(instance, externalStubs, onSet) {
|
|
|
82
100
|
// and a plain write against one of those throws. Defining an own
|
|
83
101
|
// data property shadows the accessor, which is what test setup means.
|
|
84
102
|
set (target, prop, value) {
|
|
85
|
-
|
|
103
|
+
// An own accessor with a setter, such as a context menu's target, takes the value itself
|
|
104
|
+
const setter = Object.getOwnPropertyDescriptor(target, prop)?.set;
|
|
105
|
+
if (setter) setter.call(target, value);
|
|
106
|
+
else Object.defineProperty(target, prop, {
|
|
86
107
|
value,
|
|
87
108
|
writable: true,
|
|
88
109
|
enumerable: true,
|
|
@@ -92,6 +113,7 @@ function stubDeep(instance, externalStubs, onSet) {
|
|
|
92
113
|
return true;
|
|
93
114
|
}
|
|
94
115
|
});
|
|
116
|
+
mockTargets.set(proxy, instance);
|
|
95
117
|
return proxy;
|
|
96
118
|
}
|
|
97
119
|
// discord.js methods that read only what the mock holds, so they run for real: looking items up in a manager's cache,
|
|
@@ -224,7 +246,8 @@ const PLACEHOLDER_BOOLEANS = new Map([
|
|
|
224
246
|
[
|
|
225
247
|
BaseGuildVoiceChannel.prototype,
|
|
226
248
|
new Set([
|
|
227
|
-
'joinable'
|
|
249
|
+
'joinable',
|
|
250
|
+
'full'
|
|
228
251
|
])
|
|
229
252
|
],
|
|
230
253
|
[
|
|
@@ -259,11 +282,42 @@ const PLACEHOLDER_BOOLEANS = new Map([
|
|
|
259
282
|
return false;
|
|
260
283
|
}
|
|
261
284
|
const mockLogger = new Logger('Mocks');
|
|
262
|
-
|
|
285
|
+
// Mock reactions, and their messages, that MeoCord's dispatcher reads `partial` on
|
|
286
|
+
const dispatchedReactions = new WeakSet();
|
|
287
|
+
let quiet = 0;
|
|
288
|
+
/**
|
|
289
|
+
* Runs a real method, keeping quiet about the placeholders `mentions.has()` reads: a placeholder `repliedUser` matches
|
|
290
|
+
* no user, so its answer is discord.js's either way.
|
|
291
|
+
*/ function quietly(target, key, run) {
|
|
292
|
+
if (!(target instanceof MessageMentions && key === 'has')) return run();
|
|
293
|
+
quiet++;
|
|
294
|
+
try {
|
|
295
|
+
return run();
|
|
296
|
+
} finally{
|
|
297
|
+
quiet--;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Marks a mock reaction and its message as read by MeoCord's dispatcher, so a placeholder `partial` it reads warns
|
|
302
|
+
* that MeoCord read it, and fetched for it.
|
|
303
|
+
*/ function noteReactionDispatch(reaction) {
|
|
304
|
+
const target = mockTargets.get(reaction);
|
|
305
|
+
if (!target) return;
|
|
306
|
+
dispatchedReactions.add(target);
|
|
307
|
+
const message = mockTargets.get(reaction.message ?? {});
|
|
308
|
+
if (message) dispatchedReactions.add(message);
|
|
309
|
+
}
|
|
310
|
+
function warnPlaceholderRead(target, key, strictValue) {
|
|
311
|
+
if (quiet > 0) return;
|
|
263
312
|
const name = Object.getPrototypeOf(target).constructor.name;
|
|
313
|
+
if (key === 'partial' && dispatchedReactions.has(target)) {
|
|
314
|
+
const fetched = target instanceof MessageReaction ? 'the reaction' : "the reaction's message";
|
|
315
|
+
warnPlaceholder(mockLogger, `${name}.${key}`, 'the mock computes it as discord.js does', `MeoCord's dispatcher read it while dispatching the reaction, and fetched ${fetched} since the placeholder is truthy. ` + 'Set reaction.partial = false and reaction.message.partial = false on the mock, or call useStrictMocks() to have the mock compute them now.');
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
264
318
|
// The class name in camel case, a leading acronym included: dmChannel, guildMember
|
|
265
319
|
const variable = name.replace(/^[A-Z]+(?=[A-Z][a-z])|^[A-Z]/, (start)=>start.toLowerCase());
|
|
266
|
-
warnPlaceholder(mockLogger, `${name}.${key}`, 'the mock computes it as discord.js does', `Set it on the mock, such as ${variable}.${key} =
|
|
320
|
+
warnPlaceholder(mockLogger, `${name}.${key}`, 'the mock computes it as discord.js does', `Set it on the mock, such as ${variable}.${key} = ${strictValue}, to test either way, or call useStrictMocks() to have the mock compute it now.`);
|
|
267
321
|
}
|
|
268
322
|
// The client of a structure made without one: the one its guild has, else one of its own, made once
|
|
269
323
|
const ownClients = new WeakMap();
|
|
@@ -371,25 +425,37 @@ const MANAGER_ITEMS = [
|
|
|
371
425
|
],
|
|
372
426
|
[
|
|
373
427
|
RoleManager,
|
|
374
|
-
(id)=>createMockInteraction(Role,
|
|
375
|
-
id
|
|
376
|
-
|
|
428
|
+
(id, manager)=>createMockInteraction(Role, {
|
|
429
|
+
...id ? {
|
|
430
|
+
id
|
|
431
|
+
} : {},
|
|
432
|
+
...guildOf(manager) ? {
|
|
433
|
+
guild: guildOf(manager)
|
|
434
|
+
} : {}
|
|
435
|
+
})
|
|
377
436
|
],
|
|
378
437
|
[
|
|
379
438
|
GuildBanManager,
|
|
380
439
|
()=>createMockInteraction(GuildBan)
|
|
381
440
|
],
|
|
441
|
+
// A message fetched from a channel is in it, and another user's
|
|
382
442
|
[
|
|
383
443
|
GuildMessageManager,
|
|
384
|
-
(id)=>createMockMessage(
|
|
385
|
-
id
|
|
386
|
-
|
|
444
|
+
(id, manager)=>createMockMessage({
|
|
445
|
+
...id ? {
|
|
446
|
+
id
|
|
447
|
+
} : {},
|
|
448
|
+
...channelOfManager(manager)
|
|
449
|
+
})
|
|
387
450
|
],
|
|
388
451
|
[
|
|
389
452
|
DMMessageManager,
|
|
390
|
-
(id)=>createMockMessage(
|
|
391
|
-
id
|
|
392
|
-
|
|
453
|
+
(id, manager)=>createMockMessage({
|
|
454
|
+
...id ? {
|
|
455
|
+
id
|
|
456
|
+
} : {},
|
|
457
|
+
...channelOfManager(manager)
|
|
458
|
+
})
|
|
393
459
|
],
|
|
394
460
|
[
|
|
395
461
|
GuildTextThreadManager,
|
|
@@ -407,6 +473,12 @@ const MANAGER_ITEMS = [
|
|
|
407
473
|
ThreadMemberManager,
|
|
408
474
|
()=>createMockInteraction(ThreadMember)
|
|
409
475
|
],
|
|
476
|
+
[
|
|
477
|
+
ReactionUserManager,
|
|
478
|
+
(id)=>createMockUser(id ? {
|
|
479
|
+
id
|
|
480
|
+
} : {})
|
|
481
|
+
],
|
|
410
482
|
[
|
|
411
483
|
ApplicationCommandManager,
|
|
412
484
|
(id)=>createMockInteraction(ApplicationCommand, id ? {
|
|
@@ -429,11 +501,58 @@ const MANAGER_ITEMS = [
|
|
|
429
501
|
guild: guildOf(manager)
|
|
430
502
|
} : {}
|
|
431
503
|
})
|
|
504
|
+
],
|
|
505
|
+
[
|
|
506
|
+
GuildEmojiManager,
|
|
507
|
+
(id, manager)=>itemInGuild(GuildEmoji, {
|
|
508
|
+
id: id ?? nextSnowflake()
|
|
509
|
+
}, manager)
|
|
510
|
+
],
|
|
511
|
+
[
|
|
512
|
+
GuildStickerManager,
|
|
513
|
+
(id, manager)=>itemInGuild(Sticker, {
|
|
514
|
+
id: id ?? nextSnowflake()
|
|
515
|
+
}, manager)
|
|
516
|
+
],
|
|
517
|
+
[
|
|
518
|
+
GuildScheduledEventManager,
|
|
519
|
+
(id, manager)=>itemInGuild(GuildScheduledEvent, {
|
|
520
|
+
id: id ?? nextSnowflake()
|
|
521
|
+
}, manager)
|
|
522
|
+
],
|
|
523
|
+
[
|
|
524
|
+
AutoModerationRuleManager,
|
|
525
|
+
(id, manager)=>itemInGuild(AutoModerationRule, {
|
|
526
|
+
id: id ?? nextSnowflake()
|
|
527
|
+
}, manager)
|
|
528
|
+
],
|
|
529
|
+
// An invite is keyed by its code, as discord.js caches and fetches one
|
|
530
|
+
[
|
|
531
|
+
GuildInviteManager,
|
|
532
|
+
(code, manager)=>itemInGuild(Invite, {
|
|
533
|
+
code: code ?? BigInt(nextSnowflake()).toString(36)
|
|
534
|
+
}, manager)
|
|
432
535
|
]
|
|
433
536
|
];
|
|
537
|
+
/** 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) {
|
|
538
|
+
const guild = guildOf(manager);
|
|
539
|
+
return createMockInteraction(Class, {
|
|
540
|
+
...props,
|
|
541
|
+
...guild ? {
|
|
542
|
+
guild,
|
|
543
|
+
guildId: guild.id
|
|
544
|
+
} : {}
|
|
545
|
+
});
|
|
546
|
+
}
|
|
434
547
|
const returnsPromise = (key, method)=>method.constructor.name === 'AsyncFunction' || PROMISE_METHODS.has(key) || /^set[A-Z]/.test(key) && !SYNC_SETTERS.has(key);
|
|
435
548
|
// The ids a fetch asks for: an id, a discord.js object, or options naming one or several, such as `{ user: id }` or
|
|
436
549
|
// `{ user: [ids] }`; undefined for a fetch of a list
|
|
550
|
+
/** The channel a message manager belongs to, as `createMockMessage` takes it. */ const channelOfManager = (manager)=>{
|
|
551
|
+
const channel = manager.channel;
|
|
552
|
+
return channel instanceof BaseChannel ? {
|
|
553
|
+
channel: channel
|
|
554
|
+
} : {};
|
|
555
|
+
};
|
|
437
556
|
function fetchedIds(args) {
|
|
438
557
|
const idOf = (value)=>typeof value === 'string' ? value : value instanceof Base ? value.id : undefined;
|
|
439
558
|
const [first] = args;
|
|
@@ -469,6 +588,10 @@ function fetchedIds(args) {
|
|
|
469
588
|
if (key === 'send' && target instanceof User) {
|
|
470
589
|
return createMockFn(async (...args)=>(await receiver().createDM()).send(...args));
|
|
471
590
|
}
|
|
591
|
+
// A channel's send resolves to the message it sent there
|
|
592
|
+
if (key === 'send' && target instanceof BaseChannel) {
|
|
593
|
+
return createMockFn(async (options)=>sentMessage(sent(options, false), receiver()));
|
|
594
|
+
}
|
|
472
595
|
if (MESSAGE_METHODS.has(key)) return createMockFn(async ()=>createMockMessage());
|
|
473
596
|
if (target instanceof BaseManager) {
|
|
474
597
|
const item = MANAGER_ITEMS.find(([Manager])=>Manager.prototype.isPrototypeOf(target))?.[1];
|
|
@@ -485,7 +608,36 @@ function fetchedIds(args) {
|
|
|
485
608
|
return ids === undefined ? new Collection() : one(ids);
|
|
486
609
|
});
|
|
487
610
|
}
|
|
488
|
-
if (item &&
|
|
611
|
+
if (item && key === 'edit') return createMockFn(async ()=>item(undefined, receiver()));
|
|
612
|
+
// A ban resolves to the member, user or id it was given, as discord.js's does, and caches nothing
|
|
613
|
+
if (key === 'create' && target instanceof GuildBanManager) {
|
|
614
|
+
return createMockFn(async (user)=>{
|
|
615
|
+
if (user instanceof GuildMember) return user;
|
|
616
|
+
if (user instanceof User) return receiver().guild.members.resolve(user) ?? user;
|
|
617
|
+
return typeof user === 'string' ? user : user?.id;
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
// An invite is made for the channel given, in its guild, and cached nowhere, as discord.js's create does
|
|
621
|
+
if (item && key === 'create' && target instanceof GuildInviteManager) {
|
|
622
|
+
return createMockFn(async (channel)=>{
|
|
623
|
+
const invite = item(undefined, receiver());
|
|
624
|
+
const channelId = typeof channel === 'string' ? channel : channel?.id;
|
|
625
|
+
const found = channel instanceof BaseChannel ? channel : channelId ? cacheOf(guildOf(receiver())?.channels)?.get(channelId) ?? null : null;
|
|
626
|
+
return Object.assign(invite, {
|
|
627
|
+
channel: found,
|
|
628
|
+
channelId: channelId ?? null
|
|
629
|
+
});
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
// Every other manager here caches what its create() makes, as discord.js's does; check a manager's create when
|
|
633
|
+
// adding it to MANAGER_ITEMS
|
|
634
|
+
if (item && key === 'create') {
|
|
635
|
+
return createMockFn(async ()=>{
|
|
636
|
+
const made = item(undefined, receiver());
|
|
637
|
+
if (typeof made.id === 'string') cacheOf(receiver())?.set(made.id, made);
|
|
638
|
+
return made;
|
|
639
|
+
});
|
|
640
|
+
}
|
|
489
641
|
} else if (target instanceof Base && (SELF_METHODS.has(key) || /^set[A-Z]/.test(key))) {
|
|
490
642
|
return createMockFn(async ()=>receiver());
|
|
491
643
|
}
|
|
@@ -645,14 +797,46 @@ const isSnowflake = (id)=>typeof id === 'string' && /^\d{1,20}$/.test(id);
|
|
|
645
797
|
});
|
|
646
798
|
}
|
|
647
799
|
}
|
|
648
|
-
/**
|
|
800
|
+
/**
|
|
801
|
+
* Gives a user, server or channel mock its creation time. Under strict mocks, a generated id gives the time the mock
|
|
802
|
+
* was made, as a message's. Otherwise it gives that id's own time, a fixed day in 2025, with a warning once when read.
|
|
803
|
+
* An id the test gives decides it in both modes, and a value the test sets replaces it.
|
|
804
|
+
*/ function defineMadeTime(mock, generatedId) {
|
|
805
|
+
if (strictMocks()) return defineCreatedTime(mock, generatedId);
|
|
806
|
+
const own = (key)=>Object.prototype.hasOwnProperty.call(mock, key);
|
|
807
|
+
const name = Object.getPrototypeOf(mock).constructor.name;
|
|
808
|
+
if (!own('createdTimestamp')) {
|
|
809
|
+
Object.defineProperty(mock, 'createdTimestamp', {
|
|
810
|
+
get () {
|
|
811
|
+
if (this.id === generatedId) {
|
|
812
|
+
warnOnce(mockLogger, `${name}.createdTimestamp reads the time of its generated id here, a fixed day in 2025; in the next major version (5.0) ` + 'it is the time the mock was made. Give the mock an id or a createdTimestamp, or call useStrictMocks() to have it read the time the mock was made now.');
|
|
813
|
+
}
|
|
814
|
+
return isSnowflake(this.id) ? SnowflakeUtil.timestampFrom(this.id) : null;
|
|
815
|
+
},
|
|
816
|
+
configurable: true
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
if (!own('createdAt')) {
|
|
820
|
+
Object.defineProperty(mock, 'createdAt', {
|
|
821
|
+
get () {
|
|
822
|
+
return this.createdTimestamp === null ? null : new Date(this.createdTimestamp);
|
|
823
|
+
},
|
|
824
|
+
configurable: true
|
|
825
|
+
});
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
/**
|
|
829
|
+
* Where a mock interaction keeps every answer it got, through respond() or discord.js directly, in order, under a key
|
|
830
|
+
* both builds of this meocord version read.
|
|
831
|
+
*/ const RESPONSE_LOG = Symbol.for(sharedName('responseLog'));
|
|
649
832
|
/**
|
|
650
833
|
* A mock of an answer method, `impl` its behaviour, that records each call in `log` as it is made, with what it sent
|
|
651
834
|
* and, once it settles, what it rejected with. The record wraps every behaviour the mock is given, its own and any a
|
|
652
|
-
* test sets, so the mock stays the plain mock function a runner's matchers read, bun's included
|
|
653
|
-
* a call asks discord.js for the message back, not part of
|
|
654
|
-
|
|
655
|
-
|
|
835
|
+
* test sets, so the mock stays the plain mock function a runner's matchers read, bun's included, and `gate` keeps the
|
|
836
|
+
* reply state whichever behaviour runs. `withResponse` is how a call asks discord.js for the message back, not part of
|
|
837
|
+
* what it sends, so it is left out.
|
|
838
|
+
*/ function recordedMock(method, impl, log, gate) {
|
|
839
|
+
const record = (behaviour, byTest = true)=>function(...args) {
|
|
656
840
|
const [payload] = args;
|
|
657
841
|
const sent = payload && typeof payload === 'object' && 'withResponse' in payload ? Object.fromEntries(Object.entries(payload).filter(([key])=>key !== 'withResponse')) : payload;
|
|
658
842
|
const call = {
|
|
@@ -661,22 +845,41 @@ const isSnowflake = (id)=>typeof id === 'string' && /^\d{1,20}$/.test(id);
|
|
|
661
845
|
};
|
|
662
846
|
stampCall(call);
|
|
663
847
|
log.push(call);
|
|
848
|
+
let gated;
|
|
664
849
|
try {
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
if (result instanceof Promise) {
|
|
668
|
-
return result.then(undefined, (error)=>{
|
|
669
|
-
call.error = error;
|
|
670
|
-
throw error;
|
|
671
|
-
});
|
|
672
|
-
}
|
|
673
|
-
return result;
|
|
850
|
+
// Before the behaviour, as discord.js checks the state before it sends an answer
|
|
851
|
+
if (gate) gated = gate(args, byTest);
|
|
674
852
|
} catch (error) {
|
|
853
|
+
call.error = error;
|
|
854
|
+
return Promise.reject(error);
|
|
855
|
+
}
|
|
856
|
+
const failed = (error)=>{
|
|
857
|
+
gated?.settle?.();
|
|
858
|
+
gated?.undo?.();
|
|
675
859
|
call.error = error;
|
|
676
860
|
throw error;
|
|
861
|
+
};
|
|
862
|
+
// Once the behaviour resolves, as discord.js changes the state once Discord has taken the answer
|
|
863
|
+
const landed = (value)=>{
|
|
864
|
+
gated?.settle?.();
|
|
865
|
+
try {
|
|
866
|
+
gated?.land?.();
|
|
867
|
+
} catch (error) {
|
|
868
|
+
call.error = error;
|
|
869
|
+
throw error;
|
|
870
|
+
}
|
|
871
|
+
return value;
|
|
872
|
+
};
|
|
873
|
+
try {
|
|
874
|
+
const result = Reflect.apply(behaviour, this, args);
|
|
875
|
+
// Recorded and passed on, so a rejection nobody awaits is unhandled in the test, as it is on a bot
|
|
876
|
+
if (result instanceof Promise) return result.then(landed, failed);
|
|
877
|
+
return landed(result);
|
|
878
|
+
} catch (error) {
|
|
879
|
+
return failed(error);
|
|
677
880
|
}
|
|
678
881
|
};
|
|
679
|
-
const mock = createMockFn(record(impl));
|
|
882
|
+
const mock = createMockFn(record(impl, false));
|
|
680
883
|
const always = mock.mockImplementation.bind(mock);
|
|
681
884
|
const once = mock.mockImplementationOnce.bind(mock);
|
|
682
885
|
// Each way a test sets a behaviour goes through the record, a runner's own extras included where it has them
|
|
@@ -709,16 +912,26 @@ const isSnowflake = (id)=>typeof id === 'string' && /^\d{1,20}$/.test(id);
|
|
|
709
912
|
return mock;
|
|
710
913
|
}
|
|
711
914
|
/** The interaction a mock options resolver was given to, for the server a user option's member is in. */ const optionOwners = new WeakMap();
|
|
915
|
+
/** An error as discord.js throws it, with its code, and with `message` in place of discord.js's own text when given. */ function discordjsError(code, message) {
|
|
916
|
+
const error = new DiscordjsError(code);
|
|
917
|
+
if (message !== undefined) error.message = message;
|
|
918
|
+
return error;
|
|
919
|
+
}
|
|
712
920
|
/** 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);
|
|
713
921
|
// In discord.js's error codes, though missing from its typings
|
|
714
922
|
const INVALID_CHANNEL_TYPE = 'CommandInteractionOptionInvalidChannelType';
|
|
715
923
|
/** Records the interaction a mock options resolver belongs to. */ const ownOptions = (interaction, options)=>{
|
|
716
924
|
if (typeof options === 'object' && options !== null) optionOwners.set(options, interaction);
|
|
717
925
|
};
|
|
718
|
-
/** A mock user that is a person, with an id of its own unless given one. */ const mockUser = (id
|
|
719
|
-
|
|
926
|
+
/** A mock user that is a person, with an id of its own unless given one. */ const mockUser = (id)=>{
|
|
927
|
+
const generatedId = id === undefined ? nextSnowflake() : undefined;
|
|
928
|
+
const user = Object.assign(Object.create(User.prototype), {
|
|
929
|
+
id: id ?? generatedId,
|
|
720
930
|
bot: false
|
|
721
|
-
})
|
|
931
|
+
});
|
|
932
|
+
defineMadeTime(user, generatedId);
|
|
933
|
+
return stubDeep(user);
|
|
934
|
+
};
|
|
722
935
|
function createMockInteraction(Class, props) {
|
|
723
936
|
const instance = Object.create(Class.prototype);
|
|
724
937
|
const stubs = new Map();
|
|
@@ -738,14 +951,36 @@ function createMockInteraction(Class, props) {
|
|
|
738
951
|
// mock lacks: a guildId is a guild, a guild object with it a cached one, neither a DM. A member
|
|
739
952
|
// not given is the user's on an interaction, and the auto-stub elsewhere, so only one set to null or undefined fails.
|
|
740
953
|
const own = (key)=>Object.prototype.hasOwnProperty.call(instance, key) ? instance[key] : undefined;
|
|
954
|
+
let strictPlace;
|
|
741
955
|
const hasMember = ()=>!Object.prototype.hasOwnProperty.call(instance, 'member') || Boolean(instance.member);
|
|
742
956
|
const guildChecks = {
|
|
743
957
|
inGuild: ()=>Boolean(own('guildId') && hasMember()),
|
|
744
958
|
inCachedGuild: ()=>Boolean(own('guildId') && own('guild') && hasMember()),
|
|
745
959
|
inRawGuild: ()=>Boolean(own('guildId') && !own('guild') && hasMember())
|
|
746
960
|
};
|
|
961
|
+
// What each answers where strict mocks place the mock: the place's guildId and guild for those the test left out, and
|
|
962
|
+
// a member wherever there is a guildId, unless the test gave one
|
|
963
|
+
const given = new Set(Object.keys(props ?? {}));
|
|
964
|
+
const strictChecks = (place)=>{
|
|
965
|
+
const pick = (key, placed)=>given.has(key) || placed === undefined ? own(key) : placed;
|
|
966
|
+
const guildId = pick('guildId', place.guildId);
|
|
967
|
+
const guild = place.raw ? null : pick('guild', place.guild);
|
|
968
|
+
const member = given.has('member') ? Boolean(own('member')) : Boolean(guildId);
|
|
969
|
+
return {
|
|
970
|
+
inGuild: Boolean(guildId && member),
|
|
971
|
+
inCachedGuild: Boolean(guildId && guild && member),
|
|
972
|
+
inRawGuild: Boolean(guildId && !guild && member)
|
|
973
|
+
};
|
|
974
|
+
};
|
|
975
|
+
// Each reads where the mock is as its own, and in default mode warns once, by its own name, where strict mocks answer
|
|
976
|
+
// otherwise or refuse the mock
|
|
747
977
|
for (const [name, check] of Object.entries(guildChecks)){
|
|
748
|
-
if (findPrototypeMethod(instance, name)
|
|
978
|
+
if (findPrototypeMethod(instance, name) === null) continue;
|
|
979
|
+
stubs.set(name, createMockFn(()=>{
|
|
980
|
+
const answer = readPlaceQuietly(check);
|
|
981
|
+
if (strictPlace && !strictMocks() && (strictPlace.refused || strictChecks(strictPlace)[name] !== answer)) placeWarners.get(instance)?.warn(`${name}()`);
|
|
982
|
+
return answer;
|
|
983
|
+
}));
|
|
749
984
|
}
|
|
750
985
|
// Set up reply state machine for repliable interactions
|
|
751
986
|
const isRepliableMethod = findPrototypeMethod(instance, 'isRepliable');
|
|
@@ -753,84 +988,285 @@ function createMockInteraction(Class, props) {
|
|
|
753
988
|
if (repliable) {
|
|
754
989
|
instance.replied = false;
|
|
755
990
|
instance.deferred = false;
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
const
|
|
991
|
+
// Null until reply() or deferReply() decides it, as discord.js has it; default mode reads false until then, and a
|
|
992
|
+
// test reading it there is told once. A value the test sets is read as set
|
|
993
|
+
const strict = strictMocks();
|
|
994
|
+
let ephemeral = strict ? null : false;
|
|
995
|
+
let ephemeralDecided = false;
|
|
996
|
+
// Whether one of the mock's own first answers ran; flags a test sets without one leave ephemeral as discord.js would
|
|
997
|
+
let answeredByMock = false;
|
|
998
|
+
const answeredOutside = ()=>Boolean(instance.replied || instance.deferred) && !answeredByMock;
|
|
999
|
+
const decideEphemeral = (value, decided)=>{
|
|
1000
|
+
ephemeral = value;
|
|
1001
|
+
ephemeralDecided = decided;
|
|
1002
|
+
};
|
|
1003
|
+
Object.defineProperty(instance, 'ephemeral', {
|
|
1004
|
+
get: ()=>{
|
|
1005
|
+
// Quiet once a test answered the interaction itself: MeoCord then reads ephemeral only for truthiness, where false
|
|
1006
|
+
// and null act alike
|
|
1007
|
+
if (!ephemeralDecided && !strict && !answeredOutside()) {
|
|
1008
|
+
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.');
|
|
1009
|
+
}
|
|
1010
|
+
return ephemeral;
|
|
1011
|
+
},
|
|
1012
|
+
set: (value)=>decideEphemeral(value, true),
|
|
1013
|
+
enumerable: true,
|
|
1014
|
+
configurable: true
|
|
1015
|
+
});
|
|
1016
|
+
// discord.js's errors, with their codes; a call before any answer keeps the message the mock has always given
|
|
1017
|
+
const alreadyReplied = ()=>discordjsError(DiscordjsErrorCodes.InteractionAlreadyReplied);
|
|
1018
|
+
const notYetReplied = (method)=>discordjsError(DiscordjsErrorCodes.InteractionNotReplied, `Cannot call ${method}() before replying or deferring.`);
|
|
759
1019
|
// Only `flags` is read: discord.js deprecates the `ephemeral: true` reply option, and honouring it here would let
|
|
760
|
-
// a test pass against a deprecated call.
|
|
1020
|
+
// a test pass against a deprecated call. Read as discord.js reads them, so a flag it refuses throws here too.
|
|
761
1021
|
const hasEphemeralFlag = (options)=>{
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
if (typeof flags === 'bigint') return (flags & 64n) !== 0n;
|
|
766
|
-
return false;
|
|
1022
|
+
const flags = options?.flags;
|
|
1023
|
+
if (typeof options !== 'object' || flags === null || flags === undefined) return false;
|
|
1024
|
+
return new MessageFlagsBitField(flags).has(MessageFlags.Ephemeral);
|
|
767
1025
|
};
|
|
1026
|
+
const built = (held)=>typeof held === 'function' ? held() : held;
|
|
1027
|
+
let original;
|
|
1028
|
+
let originalGone = false;
|
|
1029
|
+
const followUps = new Map();
|
|
1030
|
+
// Whether the last answer ran a behaviour a test set, after which a second answer runs with a warning by default
|
|
1031
|
+
let answeredByTest = false;
|
|
1032
|
+
// A command answered with a modal has no original response, and Discord refuses one asked for
|
|
1033
|
+
let modalAnswered = false;
|
|
1034
|
+
const isComponent = instance.type === InteractionType.MessageComponent;
|
|
1035
|
+
// The original response of a component, or of a modal submitted from one, is the message it's on
|
|
1036
|
+
const componentMessage = ()=>heldFrom(instance.message);
|
|
1037
|
+
const snapshot = ()=>{
|
|
1038
|
+
const saved = {
|
|
1039
|
+
replied: instance.replied,
|
|
1040
|
+
deferred: instance.deferred,
|
|
1041
|
+
ephemeral,
|
|
1042
|
+
ephemeralDecided,
|
|
1043
|
+
answeredByMock,
|
|
1044
|
+
original,
|
|
1045
|
+
originalGone,
|
|
1046
|
+
followUps: new Map(followUps),
|
|
1047
|
+
answeredByTest,
|
|
1048
|
+
modalAnswered
|
|
1049
|
+
};
|
|
1050
|
+
return ()=>{
|
|
1051
|
+
Object.assign(instance, {
|
|
1052
|
+
replied: saved.replied,
|
|
1053
|
+
deferred: saved.deferred
|
|
1054
|
+
});
|
|
1055
|
+
decideEphemeral(saved.ephemeral, saved.ephemeralDecided);
|
|
1056
|
+
({ original, originalGone, answeredByTest, answeredByMock, modalAnswered } = saved);
|
|
1057
|
+
followUps.clear();
|
|
1058
|
+
for (const [id, held] of saved.followUps)followUps.set(id, held);
|
|
1059
|
+
};
|
|
1060
|
+
};
|
|
1061
|
+
/**
|
|
1062
|
+
* Refuses an answer as discord.js does. Where the refusal turns on a behaviour a test set, this answer's or the
|
|
1063
|
+
* one before it, which ran without one, default mode warns and lets it run; strict mocks refuse it.
|
|
1064
|
+
*/ const refuse = (method, error, byTest)=>{
|
|
1065
|
+
if (!(byTest || answeredByTest) || strictMocks()) throw error;
|
|
1066
|
+
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.');
|
|
1067
|
+
};
|
|
1068
|
+
const answered = ()=>instance.deferred || instance.replied;
|
|
1069
|
+
/**
|
|
1070
|
+
* What an answer sends, built now, as discord.js builds it when sending. One it refuses to build is refused under
|
|
1071
|
+
* strict mocks; default mode warns and holds it to build when first read.
|
|
1072
|
+
*/ const hold = (method, build)=>{
|
|
1073
|
+
try {
|
|
1074
|
+
return build();
|
|
1075
|
+
} catch (error) {
|
|
1076
|
+
if (strictMocks()) throw error;
|
|
1077
|
+
warnOnce(mockLogger, `${method}() here sends what discord.js refuses to build: "${error.message}". The mock sends it and ` + 'builds it when read; in the next major version (5.0) it rejects, as discord.js does. Call useStrictMocks() to ' + 'have it rejected now.');
|
|
1078
|
+
return build;
|
|
1079
|
+
}
|
|
1080
|
+
};
|
|
1081
|
+
// The changes answers make to the reply state, in order. One whose answer fails is undone once every change after
|
|
1082
|
+
// it is undone too, so a later answer's change stands while it does, as on a bot
|
|
1083
|
+
const changes = [];
|
|
1084
|
+
const undoneInTurn = (undo)=>{
|
|
1085
|
+
const change = {
|
|
1086
|
+
undo,
|
|
1087
|
+
failed: false
|
|
1088
|
+
};
|
|
1089
|
+
changes.push(change);
|
|
1090
|
+
return ()=>{
|
|
1091
|
+
change.failed = true;
|
|
1092
|
+
while(changes.at(-1)?.failed)changes.pop().undo();
|
|
1093
|
+
};
|
|
1094
|
+
};
|
|
1095
|
+
// The first answers called and not yet settled, oldest first, for default mode to warn where a bot would differ
|
|
1096
|
+
const unsettled = [];
|
|
1097
|
+
// Default mode warns once per interaction where code that doesn't await an answer would fail on a bot
|
|
1098
|
+
let inFlightWarned = false;
|
|
1099
|
+
const warnInFlight = (text)=>{
|
|
1100
|
+
if (inFlightWarned) return;
|
|
1101
|
+
inFlightWarned = true;
|
|
1102
|
+
mockLogger.warn(text);
|
|
1103
|
+
};
|
|
1104
|
+
/**
|
|
1105
|
+
* A first answer: refused once the interaction has one, else it replies or defers. Under strict mocks the change
|
|
1106
|
+
* waits for the answer to land, as discord.js makes it once the request resolves, so one sent while another is in
|
|
1107
|
+
* flight passes discord.js's check and Discord refuses it with 40060 if the other landed first. Default mode makes
|
|
1108
|
+
* the change at once.
|
|
1109
|
+
*/ const first = (method, kind, then)=>(args, byTest)=>{
|
|
1110
|
+
if (answered()) {
|
|
1111
|
+
if (unsettled.length > 0) {
|
|
1112
|
+
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.`);
|
|
1113
|
+
}
|
|
1114
|
+
refuse(method, alreadyReplied(), byTest);
|
|
1115
|
+
}
|
|
1116
|
+
// Read first: flags discord.js refuses throw before anything changes; reply() and deferReply() decide
|
|
1117
|
+
// ephemeral, as discord.js's do, and update() and deferUpdate() leave it
|
|
1118
|
+
const flagged = method === 'reply' || method === 'deferReply' ? hasEphemeralFlag(args[0]) : undefined;
|
|
1119
|
+
const undo = snapshot();
|
|
1120
|
+
instance[kind] = true;
|
|
1121
|
+
if (flagged !== undefined) decideEphemeral(flagged, true);
|
|
1122
|
+
answeredByTest = byTest;
|
|
1123
|
+
answeredByMock = true;
|
|
1124
|
+
try {
|
|
1125
|
+
then(args);
|
|
1126
|
+
} catch (error) {
|
|
1127
|
+
undo();
|
|
1128
|
+
throw error;
|
|
1129
|
+
}
|
|
1130
|
+
if (strict) {
|
|
1131
|
+
const land = snapshot();
|
|
1132
|
+
undo();
|
|
1133
|
+
return {
|
|
1134
|
+
land: ()=>{
|
|
1135
|
+
if (answered()) throw createDiscordError(40060, 'Interaction has already been acknowledged.');
|
|
1136
|
+
land();
|
|
1137
|
+
}
|
|
1138
|
+
};
|
|
1139
|
+
}
|
|
1140
|
+
unsettled.push(method);
|
|
1141
|
+
return {
|
|
1142
|
+
undo: undoneInTurn(undo),
|
|
1143
|
+
settle: ()=>void unsettled.splice(unsettled.indexOf(method), 1)
|
|
1144
|
+
};
|
|
1145
|
+
};
|
|
1146
|
+
/** A later answer: refused before the interaction has one. */ const later = (method, then)=>(args, byTest)=>{
|
|
1147
|
+
if (!answered()) refuse(method, notYetReplied(method), byTest);
|
|
1148
|
+
if (unsettled.length > 0) {
|
|
1149
|
+
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.`);
|
|
1150
|
+
}
|
|
1151
|
+
const undo = snapshot();
|
|
1152
|
+
try {
|
|
1153
|
+
then(args);
|
|
1154
|
+
} catch (error) {
|
|
1155
|
+
undo();
|
|
1156
|
+
throw error;
|
|
1157
|
+
}
|
|
1158
|
+
return {
|
|
1159
|
+
undo: undoneInTurn(undo)
|
|
1160
|
+
};
|
|
1161
|
+
};
|
|
1162
|
+
/** The id an edit, fetch or delete names, `undefined` for the original response. */ const targetId = (message)=>{
|
|
1163
|
+
if (message === undefined || message === '@original') return undefined;
|
|
1164
|
+
return typeof message === 'string' ? message : message.id ?? undefined;
|
|
1165
|
+
};
|
|
1166
|
+
/** The message an edit, fetch or delete reaches, or why Discord refuses it: it is gone or never was. */ const target = (id)=>{
|
|
1167
|
+
if (id !== undefined) {
|
|
1168
|
+
const held = followUps.get(id);
|
|
1169
|
+
if (held) followUps.set(id, built(held));
|
|
1170
|
+
return held && built(held);
|
|
1171
|
+
}
|
|
1172
|
+
if (originalGone || modalAnswered) return undefined;
|
|
1173
|
+
// Answered without one of the mock's calls, as after a 40060 or a test setting `replied`: the original is there
|
|
1174
|
+
if (!original && answered()) original = componentMessage();
|
|
1175
|
+
if (original) original = built(original);
|
|
1176
|
+
// An answer's message gets its id when first read, as it would be built then without strict mocks
|
|
1177
|
+
if (original?.id === '') original = {
|
|
1178
|
+
...original,
|
|
1179
|
+
id: nextSnowflake()
|
|
1180
|
+
};
|
|
1181
|
+
return original;
|
|
1182
|
+
};
|
|
1183
|
+
const unknownMessage = ()=>createDiscordError(10008, 'Unknown Message');
|
|
1184
|
+
// Where its answers are sent, read as the mock's own, so a placement warning stays the test's
|
|
1185
|
+
const answeredIn = ()=>readPlaceQuietly(()=>[
|
|
1186
|
+
instance.channel,
|
|
1187
|
+
instance.client
|
|
1188
|
+
]);
|
|
1189
|
+
// What each answer's message made for itself, by its id, so every read of it has the same author and place
|
|
1190
|
+
const madePlaces = new Map();
|
|
1191
|
+
const madeFor = (id)=>madePlaces.get(id) ?? madePlaces.set(id, {}).get(id);
|
|
1192
|
+
// Each call's message, read by its behaviour right after the gate made it
|
|
1193
|
+
let made;
|
|
1194
|
+
let followUpMade;
|
|
768
1195
|
// Every answer the interaction gets, through respond() or discord.js directly, in order, for getResponse
|
|
769
1196
|
const log = [];
|
|
770
1197
|
Object.defineProperty(instance, RESPONSE_LOG, {
|
|
771
1198
|
value: log
|
|
772
1199
|
});
|
|
773
|
-
const answer = (method, impl)=>stubs.set(method, recordedMock(method, impl, log));
|
|
774
|
-
answer('reply', async (
|
|
775
|
-
|
|
1200
|
+
const answer = (method, impl, gate)=>stubs.set(method, recordedMock(method, impl, log, gate));
|
|
1201
|
+
answer('reply', async ()=>undefined, first('reply', 'replied', ([options])=>original = hold('reply', ()=>sent(options, false))));
|
|
1202
|
+
answer('deferReply', async ()=>undefined, first('deferReply', 'deferred', ()=>original = sent(undefined)));
|
|
1203
|
+
answer('followUp', // The follow-up as sent, which an edit, fetch or delete reaches by its id
|
|
1204
|
+
async ()=>{
|
|
1205
|
+
const held = followUps.get(followUpMade);
|
|
1206
|
+
// One held to build when read resolves to its id alone, as building it now would refuse it
|
|
1207
|
+
return typeof held === 'function' ? createMockMessage({
|
|
1208
|
+
id: followUpMade
|
|
1209
|
+
}) : messageFrom(held, madeFor(held.id), ...answeredIn());
|
|
1210
|
+
}, later('followUp', ([options])=>{
|
|
776
1211
|
instance.replied = true;
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
instance.replied = true;
|
|
787
|
-
return createMockMessage();
|
|
788
|
-
});
|
|
789
|
-
// The interaction's own message as Discord holds it: the message a component is on, or the original response.
|
|
790
|
-
// An edit replaces it in the shape Discord stores, with ids and resolved media, stamped with when it was edited,
|
|
791
|
-
// and fetchReply() reads that back, so code comparing the two sees what it would against Discord.
|
|
792
|
-
let held;
|
|
793
|
-
const current = ()=>held ?? (held = heldFrom(instance.message));
|
|
794
|
-
// A command answered with a modal has no original response, and Discord refuses one asked for
|
|
795
|
-
let modalAnswered = false;
|
|
796
|
-
const unknownMessage = ()=>createDiscordError(10008, 'Unknown Message');
|
|
797
|
-
answer('editReply', async (options)=>{
|
|
798
|
-
if (!instance.deferred && !instance.replied) throw notYetReplied('editReply');
|
|
799
|
-
if (modalAnswered) throw unknownMessage();
|
|
800
|
-
instance.replied = true;
|
|
801
|
-
held = edited(current(), options);
|
|
802
|
-
// The response has the message as Discord answers the edit, before its uploaded files have loaded again
|
|
803
|
-
return messageFrom({
|
|
1212
|
+
// Held as it is when sent, as Discord stores it, so a later change to what was passed isn't seen; its id taken
|
|
1213
|
+
// once built, so one that fails to build under strict mocks takes none
|
|
1214
|
+
const held = hold('followUp', ()=>sent(options, false));
|
|
1215
|
+
const id = nextSnowflake();
|
|
1216
|
+
made = undefined;
|
|
1217
|
+
followUps.set(id, typeof held === 'function' ? ()=>({
|
|
1218
|
+
...held(),
|
|
1219
|
+
id
|
|
1220
|
+
}) : {
|
|
804
1221
|
...held,
|
|
805
|
-
|
|
1222
|
+
id
|
|
806
1223
|
});
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
1224
|
+
followUpMade = id;
|
|
1225
|
+
}));
|
|
1226
|
+
// An edit replaces the message in the shape Discord stores, with ids and resolved media, stamped with when it was
|
|
1227
|
+
// edited, and fetchReply() reads that back, so code comparing the two sees what it would against Discord
|
|
1228
|
+
answer('editReply', async ()=>{
|
|
1229
|
+
if (!made) throw unknownMessage();
|
|
1230
|
+
// The response has the message as Discord answers the edit, before its uploaded files have loaded again
|
|
1231
|
+
return messageFrom({
|
|
1232
|
+
...made,
|
|
1233
|
+
components: made.returned ?? made.components
|
|
1234
|
+
}, madeFor(made.id), ...answeredIn());
|
|
1235
|
+
}, later('editReply', ([options])=>{
|
|
1236
|
+
const id = targetId(options?.message);
|
|
1237
|
+
const held = target(id);
|
|
1238
|
+
made = held && edited(held, options);
|
|
1239
|
+
if (!made) return;
|
|
1240
|
+
if (id === undefined) {
|
|
1241
|
+
original = made;
|
|
1242
|
+
instance.replied = true;
|
|
1243
|
+
} else followUps.set(id, made);
|
|
1244
|
+
}));
|
|
1245
|
+
stubs.set('fetchReply', createMockFn(async (message)=>{
|
|
1246
|
+
const held = target(targetId(message));
|
|
1247
|
+
if (!held) throw unknownMessage();
|
|
1248
|
+
return messageFrom(held, madeFor(held.id), ...answeredIn());
|
|
811
1249
|
}));
|
|
812
1250
|
answer('deleteReply', async ()=>{
|
|
813
|
-
if (!
|
|
814
|
-
|
|
815
|
-
|
|
1251
|
+
if (!made) throw unknownMessage();
|
|
1252
|
+
}, later('deleteReply', ([message])=>{
|
|
1253
|
+
const id = targetId(message);
|
|
1254
|
+
made = target(id);
|
|
1255
|
+
if (!made) return;
|
|
1256
|
+
if (id === undefined) originalGone = true;
|
|
1257
|
+
else followUps.delete(id);
|
|
1258
|
+
}));
|
|
816
1259
|
// showModal — the first response of a command or a component, like reply()
|
|
817
|
-
if (instance.type === InteractionType.ApplicationCommand ||
|
|
818
|
-
answer('showModal', async ()=>{
|
|
819
|
-
if (instance.deferred || instance.replied) throw alreadyReplied();
|
|
820
|
-
instance.replied = true;
|
|
1260
|
+
if (instance.type === InteractionType.ApplicationCommand || isComponent) {
|
|
1261
|
+
answer('showModal', async ()=>undefined, first('showModal', 'replied', ()=>{
|
|
821
1262
|
modalAnswered = instance.type === InteractionType.ApplicationCommand;
|
|
822
|
-
|
|
1263
|
+
if (isComponent) original = componentMessage();
|
|
1264
|
+
}));
|
|
823
1265
|
}
|
|
824
1266
|
// deferUpdate / update — components, and modals submitted from a message's component
|
|
825
|
-
if (
|
|
826
|
-
answer('update', async ()=>
|
|
827
|
-
|
|
828
|
-
instance.replied = true;
|
|
829
|
-
});
|
|
830
|
-
answer('deferUpdate', async ()=>{
|
|
831
|
-
if (instance.deferred || instance.replied) throw alreadyReplied();
|
|
832
|
-
instance.deferred = true;
|
|
833
|
-
});
|
|
1267
|
+
if (isComponent || instance.type === InteractionType.ModalSubmit) {
|
|
1268
|
+
answer('update', async ()=>undefined, first('update', 'replied', ([options])=>original = hold('update', ()=>edited(heldFrom(instance.message, false), options))));
|
|
1269
|
+
answer('deferUpdate', async ()=>undefined, first('deferUpdate', 'deferred', ()=>original = componentMessage()));
|
|
834
1270
|
}
|
|
835
1271
|
}
|
|
836
1272
|
// Autocomplete is not repliable, but it has a response of its own: Discord accepts
|
|
@@ -839,12 +1275,31 @@ function createMockInteraction(Class, props) {
|
|
|
839
1275
|
// it before answering would decide the window was already closed.
|
|
840
1276
|
if (instance.type === InteractionType.ApplicationCommandAutocomplete) {
|
|
841
1277
|
instance.responded = false;
|
|
1278
|
+
// Under strict mocks responded is set once respond() lands, as discord.js sets it once the request resolves, and a
|
|
1279
|
+
// second one sent while the first is in flight is refused by Discord with 40060; default mode sets it at once
|
|
1280
|
+
let respondsInFlight = 0;
|
|
1281
|
+
let respondWarned = false;
|
|
842
1282
|
stubs.set('respond', createMockFn(async (choices)=>{
|
|
843
|
-
if (instance.responded)
|
|
1283
|
+
if (instance.responded) {
|
|
1284
|
+
if (respondsInFlight > 0 && !respondWarned) {
|
|
1285
|
+
respondWarned = true;
|
|
1286
|
+
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.');
|
|
1287
|
+
}
|
|
1288
|
+
throw discordjsError(DiscordjsErrorCodes.InteractionAlreadyReplied);
|
|
1289
|
+
}
|
|
844
1290
|
// Discord refuses a list longer than its limit, and the menu shows nothing
|
|
845
1291
|
if (Array.isArray(choices) && choices.length > MAX_AUTOCOMPLETE_CHOICES) {
|
|
846
1292
|
throw createDiscordError(50035, `Invalid Form Body\ndata.choices[BASE_TYPE_MAX_LENGTH]: Must be ${MAX_AUTOCOMPLETE_CHOICES} or fewer in length.`);
|
|
847
1293
|
}
|
|
1294
|
+
respondsInFlight++;
|
|
1295
|
+
if (!strictMocks()) instance.responded = true;
|
|
1296
|
+
try {
|
|
1297
|
+
await undefined;
|
|
1298
|
+
} finally{
|
|
1299
|
+
respondsInFlight--;
|
|
1300
|
+
}
|
|
1301
|
+
if (!strictMocks()) return;
|
|
1302
|
+
if (instance.responded) throw createDiscordError(40060, 'Interaction has already been acknowledged.');
|
|
848
1303
|
instance.responded = true;
|
|
849
1304
|
}));
|
|
850
1305
|
}
|
|
@@ -869,6 +1324,34 @@ function createMockInteraction(Class, props) {
|
|
|
869
1324
|
const unset = (key)=>!Object.prototype.hasOwnProperty.call(instance, key);
|
|
870
1325
|
const generatedId = unset('id') ? instance.id = nextSnowflake() : undefined;
|
|
871
1326
|
defineCreatedTime(instance, generatedId);
|
|
1327
|
+
// Strict mocks place it where its message, guild or member is, and a guildId alone in a server the bot isn't in
|
|
1328
|
+
// Read as the mock's own, so a message's placement warning stays the test's
|
|
1329
|
+
strictPlace = readPlaceQuietly(()=>strictPlaceOf(own));
|
|
1330
|
+
if (strictPlace && strictMocks()) {
|
|
1331
|
+
if (strictPlace.refused) throw new Error(strictPlace.refused);
|
|
1332
|
+
const set = (key, value)=>Object.defineProperty(instance, key, {
|
|
1333
|
+
value,
|
|
1334
|
+
writable: true,
|
|
1335
|
+
enumerable: true,
|
|
1336
|
+
configurable: true
|
|
1337
|
+
});
|
|
1338
|
+
if (strictPlace.channel && unset('channel')) set('channel', strictPlace.channel);
|
|
1339
|
+
if (strictPlace.guildId !== undefined && unset('guildId')) set('guildId', strictPlace.guildId);
|
|
1340
|
+
if (strictPlace.guild !== undefined && unset('guild')) set('guild', strictPlace.guild);
|
|
1341
|
+
if (strictPlace.raw) {
|
|
1342
|
+
const user = own('user');
|
|
1343
|
+
const rawUser = user && {
|
|
1344
|
+
id: user.id,
|
|
1345
|
+
username: user.username,
|
|
1346
|
+
global_name: user.globalName ?? null,
|
|
1347
|
+
discriminator: user.discriminator,
|
|
1348
|
+
avatar: user.avatar ?? null
|
|
1349
|
+
};
|
|
1350
|
+
set('member', createMockRawMember(rawUser ? {
|
|
1351
|
+
user: rawUser
|
|
1352
|
+
} : {}));
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
872
1355
|
// A raw member is from a server the bot isn't in: no cached guild, and its user the interaction's, as discord.js reads it
|
|
873
1356
|
const raw = own('member');
|
|
874
1357
|
// Given with a guild, a raw-shaped member is kept as the cached server's member it was before raw members
|
|
@@ -951,12 +1434,13 @@ function createMockInteraction(Class, props) {
|
|
|
951
1434
|
configurable: true
|
|
952
1435
|
});
|
|
953
1436
|
}
|
|
954
|
-
// The channel it came from, as the gateway caches it for an interaction:
|
|
1437
|
+
// The channel it came from, as the gateway caches it for an interaction: its server's cached one, looked up on each
|
|
1438
|
+
// read so a channel the test caches later is the one read, or the user's DM
|
|
955
1439
|
let channel;
|
|
956
1440
|
if (unset('channel')) {
|
|
957
1441
|
Object.defineProperty(instance, 'channel', {
|
|
958
1442
|
get: ()=>{
|
|
959
|
-
if (channel === undefined) {
|
|
1443
|
+
if (channel === undefined || own('guildId') && own('guild') instanceof Guild) {
|
|
960
1444
|
channel = channelFor(own('guild'), own('guildId'), own('channelId'), own('user'));
|
|
961
1445
|
cacheChannel(channel, own('guild'), own('client'));
|
|
962
1446
|
}
|
|
@@ -1011,7 +1495,73 @@ function createMockInteraction(Class, props) {
|
|
|
1011
1495
|
live('targetMember', ()=>own('guildId') ? memberFor(own('guild'), targetUser(), true, own('guildId')) : null);
|
|
1012
1496
|
}
|
|
1013
1497
|
}
|
|
1498
|
+
// A message context menu's target: the message given, else one made for its targetId. The message is the one
|
|
1499
|
+
// source: targetId reads its id, and setting either one moves the other with it
|
|
1500
|
+
if (MessageContextMenuCommandInteraction.prototype.isPrototypeOf(instance)) {
|
|
1501
|
+
let given = own('targetMessage');
|
|
1502
|
+
let explicitId = own('targetId');
|
|
1503
|
+
if (given && given.id === undefined) given.id = nextSnowflake();
|
|
1504
|
+
if (given && explicitId !== undefined && explicitId !== given.id) {
|
|
1505
|
+
const conflict = `The targetMessage given is message ${given.id}, but the mock's targetId is ${explicitId}: give the same id, or leave one of them out.`;
|
|
1506
|
+
if (strictMocks()) throw new Error(conflict);
|
|
1507
|
+
warnOnce(mockLogger, `${conflict} The mock reads targetMessage's id; under useStrictMocks(), as in the next major version (5.0), it refuses the pair.`);
|
|
1508
|
+
explicitId = undefined;
|
|
1509
|
+
}
|
|
1510
|
+
const generated = given || explicitId !== undefined ? undefined : nextSnowflake();
|
|
1511
|
+
let made;
|
|
1512
|
+
const accessor = (key, get, set)=>Object.defineProperty(instance, key, {
|
|
1513
|
+
get,
|
|
1514
|
+
set,
|
|
1515
|
+
enumerable: true,
|
|
1516
|
+
configurable: true
|
|
1517
|
+
});
|
|
1518
|
+
const targetId = ()=>explicitId ?? given?.id ?? generated;
|
|
1519
|
+
accessor('targetId', targetId, (value)=>{
|
|
1520
|
+
explicitId = value;
|
|
1521
|
+
given = undefined;
|
|
1522
|
+
});
|
|
1523
|
+
// In the channel the menu was used in, and its server, as the message a menu is used on is: its channel is the
|
|
1524
|
+
// menu's, read live, so making the message makes and caches no channel. Read as the mock's own, so a placement
|
|
1525
|
+
// warning stays the test's
|
|
1526
|
+
const makeTarget = ()=>readPlaceQuietly(()=>{
|
|
1527
|
+
const [guild, guildId] = [
|
|
1528
|
+
own('guild'),
|
|
1529
|
+
own('guildId') ?? null
|
|
1530
|
+
];
|
|
1531
|
+
const message = messageMock({
|
|
1532
|
+
id: targetId(),
|
|
1533
|
+
guild: guildId && guild instanceof Guild ? guild : null,
|
|
1534
|
+
client: instance.client
|
|
1535
|
+
}, undefined, {}, {
|
|
1536
|
+
channelId: own('channelId'),
|
|
1537
|
+
guildId,
|
|
1538
|
+
channel: ()=>readPlaceQuietly(()=>instance.channel)
|
|
1539
|
+
});
|
|
1540
|
+
// Where strict mocks place the menu elsewhere, its target's place warns as the menu's does, by the target's name
|
|
1541
|
+
const warner = placeWarners.get(instance);
|
|
1542
|
+
const target = mockTargets.get(message) ?? message;
|
|
1543
|
+
for (const key of warner?.keys ?? [])warnOnRead(target, key, ()=>readingPlace > 0 ? void placeReadsSeen?.add(key) : warner.warn(`targetMessage.${key}`));
|
|
1544
|
+
return message;
|
|
1545
|
+
});
|
|
1546
|
+
accessor('targetMessage', ()=>given ?? (made?.id === targetId() ? made : made = makeTarget()), (value)=>{
|
|
1547
|
+
given = value;
|
|
1548
|
+
if (given && given.id === undefined) given.id = nextSnowflake();
|
|
1549
|
+
explicitId = undefined;
|
|
1550
|
+
});
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
// A user has an id of its own, as Discord sends one, and was created when the mock was made under strict mocks
|
|
1554
|
+
if (User.prototype.isPrototypeOf(instance) && !Object.prototype.hasOwnProperty.call(instance, 'id')) {
|
|
1555
|
+
const generatedId = instance.id = nextSnowflake();
|
|
1556
|
+
defineMadeTime(instance, generatedId);
|
|
1557
|
+
}
|
|
1558
|
+
// A role too, created when the mock was made in both modes
|
|
1559
|
+
if (Role.prototype.isPrototypeOf(instance) && !Object.prototype.hasOwnProperty.call(instance, 'id')) {
|
|
1560
|
+
const generatedId = instance.id = nextSnowflake();
|
|
1561
|
+
defineCreatedTime(instance, generatedId);
|
|
1014
1562
|
}
|
|
1563
|
+
// An attachment too, from the same count as every mock's
|
|
1564
|
+
if (Attachment.prototype.isPrototypeOf(instance) && !Object.prototype.hasOwnProperty.call(instance, 'id')) instance.id = nextSnowflake();
|
|
1015
1565
|
// Discord sends the user's locale with every interaction, and the server's preferred one with one made in a server
|
|
1016
1566
|
if (BaseInteraction.prototype.isPrototypeOf(instance)) {
|
|
1017
1567
|
if (own('locale') === undefined) instance.locale = Locale.EnglishUS;
|
|
@@ -1035,6 +1585,32 @@ function createMockInteraction(Class, props) {
|
|
|
1035
1585
|
}
|
|
1036
1586
|
for (const kind of choices)if (!Object.prototype.hasOwnProperty.call(instance, kind)) instance[kind] = new Collection();
|
|
1037
1587
|
}
|
|
1588
|
+
// In default mode, where strict mocks place it elsewhere, reading where it is warns
|
|
1589
|
+
if (strictPlace && !strictMocks()) warnOnPlaceReads(instance, strictPlace, new Set(Object.keys(props ?? {})));
|
|
1590
|
+
// A message whose place the mock made moves to where the test placed the interaction under strict mocks, and is then
|
|
1591
|
+
// one the test placed; in default mode it stays, and reading its place warns
|
|
1592
|
+
const message = own('message');
|
|
1593
|
+
const placed = [
|
|
1594
|
+
'channel',
|
|
1595
|
+
'guild',
|
|
1596
|
+
'guildId'
|
|
1597
|
+
].some((key)=>Object.keys(props ?? {}).includes(key));
|
|
1598
|
+
if (BaseInteraction.prototype.isPrototypeOf(instance) && placed && typeof message === 'object' && message !== null && placeWasMade(message)) {
|
|
1599
|
+
if (strictMocks()) {
|
|
1600
|
+
const guild = own('guild');
|
|
1601
|
+
const channel = instance.channel;
|
|
1602
|
+
Object.assign(message, {
|
|
1603
|
+
guild: guild ?? null,
|
|
1604
|
+
guildId: own('guildId') ?? null,
|
|
1605
|
+
...channel && {
|
|
1606
|
+
channel,
|
|
1607
|
+
channelId: channel.id
|
|
1608
|
+
}
|
|
1609
|
+
});
|
|
1610
|
+
if (guild) memberFor(guild, message.author, true);
|
|
1611
|
+
placesMade.delete(mockTargets.get(message) ?? message);
|
|
1612
|
+
} else warnOnMadePlaceReads(mockTargets.get(message) ?? message);
|
|
1613
|
+
}
|
|
1038
1614
|
// Options assigned after creation, directly or through Object.assign, belong to it as those given do
|
|
1039
1615
|
return stubDeep(instance, stubs, (prop, value)=>{
|
|
1040
1616
|
if (prop === 'options') ownOptions(instance, value);
|
|
@@ -1046,8 +1622,15 @@ function createMockInteraction(Class, props) {
|
|
|
1046
1622
|
/**
|
|
1047
1623
|
* A mock function that answers property access with another one, so `cache.store.flush()` works
|
|
1048
1624
|
* on a double whose shape is an interface with nothing at runtime to read it from.
|
|
1049
|
-
*/ function stubCallable() {
|
|
1625
|
+
*/ function stubCallable(member) {
|
|
1050
1626
|
const fn = createMockFn();
|
|
1627
|
+
// jest's mock reads `_protoImpl` from itself whenever it's called, so the prototype below must not answer it
|
|
1628
|
+
if (usesRunnerMockFn()) Object.defineProperty(fn, '_protoImpl', {
|
|
1629
|
+
value: undefined,
|
|
1630
|
+
writable: true,
|
|
1631
|
+
enumerable: false,
|
|
1632
|
+
configurable: true
|
|
1633
|
+
});
|
|
1051
1634
|
// Any property beyond the mock's own API is a nested mock, made on first read and kept on the function, out of its
|
|
1052
1635
|
// keys. Its prototype makes them rather than a Proxy around it, so it stays the plain mock function matchers read
|
|
1053
1636
|
const inherited = Object.getPrototypeOf(fn);
|
|
@@ -1056,7 +1639,9 @@ function createMockInteraction(Class, props) {
|
|
|
1056
1639
|
if (typeof prop === 'symbol' || prop in proto) return Reflect.get(proto, prop, receiver);
|
|
1057
1640
|
// Never thenable — otherwise awaiting a mock hangs on itself
|
|
1058
1641
|
if (prop === 'then') return undefined;
|
|
1059
|
-
|
|
1642
|
+
// jest's matchers take a function whose `calls` has `all` and `count` for a jasmine spy, read before `mock.calls`
|
|
1643
|
+
if (member === 'calls' && (prop === 'all' || prop === 'count')) return undefined;
|
|
1644
|
+
const nested = stubCallable(prop);
|
|
1060
1645
|
Object.defineProperty(receiver, prop, {
|
|
1061
1646
|
value: nested,
|
|
1062
1647
|
writable: true,
|
|
@@ -1121,6 +1706,7 @@ function createMockInteraction(Class, props) {
|
|
|
1121
1706
|
if (key === 'then') return undefined;
|
|
1122
1707
|
// An explicitly supplied prop wins over the auto-stub.
|
|
1123
1708
|
if (Object.prototype.hasOwnProperty.call(instance, key)) return Reflect.get(instance, prop, instance);
|
|
1709
|
+
// The double itself is no function a matcher reads, so a `calls` member here keeps its `all` and `count`
|
|
1124
1710
|
if (!stubs.has(key)) stubs.set(key, stubCallable());
|
|
1125
1711
|
return stubs.get(key);
|
|
1126
1712
|
},
|
|
@@ -1160,11 +1746,12 @@ function createMockInteraction(Class, props) {
|
|
|
1160
1746
|
* @group Testing
|
|
1161
1747
|
* @category Mocks
|
|
1162
1748
|
* @see {@link createMockInteraction}
|
|
1163
|
-
*/
|
|
1164
|
-
|
|
1749
|
+
*/ function createMockUser(props = {}) {
|
|
1750
|
+
return createMockInteraction(User, {
|
|
1165
1751
|
bot: false,
|
|
1166
1752
|
...props
|
|
1167
1753
|
});
|
|
1754
|
+
}
|
|
1168
1755
|
/**
|
|
1169
1756
|
* Creates a mock {@link Client}, with `users`, `channels`, `guilds` and `application.commands` ready to stub.
|
|
1170
1757
|
*
|
|
@@ -1278,6 +1865,38 @@ const HOLDS = [
|
|
|
1278
1865
|
[
|
|
1279
1866
|
PermissionOverwriteManager.prototype,
|
|
1280
1867
|
PermissionOverwrites
|
|
1868
|
+
],
|
|
1869
|
+
[
|
|
1870
|
+
ReactionManager.prototype,
|
|
1871
|
+
MessageReaction
|
|
1872
|
+
],
|
|
1873
|
+
[
|
|
1874
|
+
ReactionUserManager.prototype,
|
|
1875
|
+
User
|
|
1876
|
+
],
|
|
1877
|
+
[
|
|
1878
|
+
PresenceManager.prototype,
|
|
1879
|
+
Presence
|
|
1880
|
+
],
|
|
1881
|
+
[
|
|
1882
|
+
GuildEmojiManager.prototype,
|
|
1883
|
+
GuildEmoji
|
|
1884
|
+
],
|
|
1885
|
+
[
|
|
1886
|
+
GuildStickerManager.prototype,
|
|
1887
|
+
Sticker
|
|
1888
|
+
],
|
|
1889
|
+
[
|
|
1890
|
+
GuildScheduledEventManager.prototype,
|
|
1891
|
+
GuildScheduledEvent
|
|
1892
|
+
],
|
|
1893
|
+
[
|
|
1894
|
+
AutoModerationRuleManager.prototype,
|
|
1895
|
+
AutoModerationRule
|
|
1896
|
+
],
|
|
1897
|
+
[
|
|
1898
|
+
GuildInviteManager.prototype,
|
|
1899
|
+
Invite
|
|
1281
1900
|
]
|
|
1282
1901
|
];
|
|
1283
1902
|
/**
|
|
@@ -1293,6 +1912,11 @@ const HOLDS = [
|
|
|
1293
1912
|
value: cache,
|
|
1294
1913
|
writable: true
|
|
1295
1914
|
});
|
|
1915
|
+
// The same collection where discord.js's own methods read it past a subclass's `cache`, as `super.cache` does
|
|
1916
|
+
Object.defineProperty(manager, '_cache', {
|
|
1917
|
+
value: cache,
|
|
1918
|
+
writable: true
|
|
1919
|
+
});
|
|
1296
1920
|
const holds = HOLDS.find(([Manager])=>Manager === prototype || Object.prototype.isPrototypeOf.call(Manager, prototype))?.[1];
|
|
1297
1921
|
if (holds) Object.defineProperty(manager, 'holds', {
|
|
1298
1922
|
value: holds
|
|
@@ -1300,16 +1924,28 @@ const HOLDS = [
|
|
|
1300
1924
|
let own;
|
|
1301
1925
|
Object.defineProperty(manager, 'client', {
|
|
1302
1926
|
get: ()=>{
|
|
1303
|
-
const owner = manager.guild ?? manager.channel ?? manager.thread;
|
|
1927
|
+
const owner = manager.guild ?? manager.channel ?? manager.thread ?? manager.message;
|
|
1304
1928
|
return owner instanceof Base ? owner.client : own ?? (own = createMockClient());
|
|
1305
1929
|
},
|
|
1306
|
-
|
|
1930
|
+
// A client the test sets is the one read from then on, as on any other mock
|
|
1931
|
+
set: (value)=>Object.defineProperty(manager, 'client', {
|
|
1932
|
+
value,
|
|
1933
|
+
writable: true,
|
|
1934
|
+
enumerable: true,
|
|
1935
|
+
configurable: true
|
|
1936
|
+
}),
|
|
1307
1937
|
configurable: true
|
|
1308
1938
|
});
|
|
1309
1939
|
return stubDeep(manager);
|
|
1310
1940
|
}
|
|
1941
|
+
/** An empty manager of `prototype` that `owner` names its own, such as `{ message }` for a message's reactions. */ function ownedManager(prototype, owner) {
|
|
1942
|
+
const manager = managerWith(prototype, undefined);
|
|
1943
|
+
Object.assign(manager, owner);
|
|
1944
|
+
return manager;
|
|
1945
|
+
}
|
|
1311
1946
|
/**
|
|
1312
|
-
* Creates a mock {@link Guild}, with
|
|
1947
|
+
* Creates a mock {@link Guild}, with its `members`, `channels`, `roles`, `bans`, `emojis`, `stickers`, `scheduledEvents`,
|
|
1948
|
+
* `autoModerationRules` and `invites` managers ready to stub.
|
|
1313
1949
|
*
|
|
1314
1950
|
* Use it for the server a message or an interaction came from, with the members, roles and channels a handler looks
|
|
1315
1951
|
* up in it.
|
|
@@ -1319,9 +1955,11 @@ const HOLDS = [
|
|
|
1319
1955
|
*
|
|
1320
1956
|
* @remarks
|
|
1321
1957
|
* 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
|
|
1322
|
-
* caches under that id: a member or
|
|
1323
|
-
*
|
|
1324
|
-
*
|
|
1958
|
+
* caches under that id: a member, channel, emoji, sticker, scheduled event or AutoMod rule in this guild, a role with
|
|
1959
|
+
* that id, a ban, or an invite with that code. `members.fetch({ user: ids })` resolves to a collection of each of those
|
|
1960
|
+
* members, found or made the same way; `create()` and `edit()` resolve to a mock of its item, which `create()` caches
|
|
1961
|
+
* as discord.js does, but for an invite, made for the channel given and cached nowhere; a list fetch resolves to an
|
|
1962
|
+
* empty collection. Members, roles and channels given are put in
|
|
1325
1963
|
* their managers' caches, where dispatch looks first when it resolves a message's typed params; a member
|
|
1326
1964
|
* {@link createMockMember} made without a server is in this one. Its `roles.everyone` is the role given with the
|
|
1327
1965
|
* guild's id, or else an @everyone role of its own at position 0 with no permissions, in `roles.cache` as Discord has
|
|
@@ -1344,14 +1982,15 @@ const HOLDS = [
|
|
|
1344
1982
|
* @see {@link createMockMessage}
|
|
1345
1983
|
*/ function createMockGuild(overrides = {}) {
|
|
1346
1984
|
const instance = Object.create(Guild.prototype);
|
|
1347
|
-
|
|
1348
|
-
|
|
1985
|
+
const generatedId = overrides.id === undefined ? nextSnowflake() : undefined;
|
|
1986
|
+
instance.id = overrides.id ?? generatedId;
|
|
1987
|
+
defineMadeTime(instance, generatedId);
|
|
1349
1988
|
if (overrides.name !== undefined) instance.name = overrides.name;
|
|
1350
1989
|
if (overrides.preferredLocale !== undefined) instance.preferredLocale = overrides.preferredLocale;
|
|
1351
1990
|
instance.members = managerWith(GuildMemberManager.prototype, overrides.members);
|
|
1352
1991
|
instance.channels = managerWith(GuildChannelManager.prototype, overrides.channels);
|
|
1353
1992
|
instance.roles = guildRoleManager(instance.id, overrides.roles);
|
|
1354
|
-
instance
|
|
1993
|
+
for (const [key, Manager] of GUILD_MANAGERS)instance[key] = managerWith(Manager.prototype, undefined);
|
|
1355
1994
|
const guild = stubDeep(instance);
|
|
1356
1995
|
settleGuild(instance, guild);
|
|
1357
1996
|
// A member made without a server of its own is in this one
|
|
@@ -1390,7 +2029,8 @@ const HOLDS = [
|
|
|
1390
2029
|
* @see {@link createMockGuild}
|
|
1391
2030
|
*/ function createMockChannel(Class, props = {}) {
|
|
1392
2031
|
const instance = Object.create(Class.prototype);
|
|
1393
|
-
|
|
2032
|
+
const generatedId = props.id === undefined ? nextSnowflake() : undefined;
|
|
2033
|
+
instance.id = generatedId;
|
|
1394
2034
|
const is = (Base)=>Base.prototype.isPrototypeOf(Class.prototype) || Class === Base;
|
|
1395
2035
|
// Text and announcement channels: messages, and threads made in the channel
|
|
1396
2036
|
if (is(TextChannel) || is(NewsChannel)) {
|
|
@@ -1418,6 +2058,7 @@ const HOLDS = [
|
|
|
1418
2058
|
// A server's channel has its overwrites, none until a test puts some in the cache, which its permissions read
|
|
1419
2059
|
if (is(GuildChannel) && !is(ThreadChannel)) instance.permissionOverwrites = managerWith(PermissionOverwriteManager.prototype, undefined);
|
|
1420
2060
|
Object.assign(instance, props);
|
|
2061
|
+
defineMadeTime(instance, generatedId);
|
|
1421
2062
|
const stubs = new Map();
|
|
1422
2063
|
const channel = stubDeep(instance, stubs);
|
|
1423
2064
|
// Each manager knows its channel, as discord.js's do, and a thread's members their thread
|
|
@@ -1543,16 +2184,24 @@ const CHANNEL_TYPE_GUARDS = [
|
|
|
1543
2184
|
return stubDeep(manager);
|
|
1544
2185
|
}
|
|
1545
2186
|
/** The permissions Discord gives a server's @everyone role in a server made today. */ const NEW_SERVER_EVERYONE_PERMISSIONS = 2248473465835073n;
|
|
2187
|
+
// The @everyone roles default mode made with no permissions, where strict mocks give a new server's
|
|
2188
|
+
const madeEveryones = new WeakSet();
|
|
2189
|
+
/** Whether `role` is an @everyone role default mode made, whose permissions the test hasn't set. */ const isUnsetEveryone = (role)=>madeEveryones.has(role) && !Object.prototype.hasOwnProperty.call(role, 'permissions');
|
|
1546
2190
|
// A server's @everyone role, which has the server's id and ranks lowest, at position 0; with strict mocks, the
|
|
1547
2191
|
// permissions Discord gives it in a new server
|
|
1548
|
-
|
|
2192
|
+
function everyoneRole(guildId) {
|
|
2193
|
+
const strict = strictMocks();
|
|
2194
|
+
const role = createMockInteraction(Role, {
|
|
1549
2195
|
id: guildId,
|
|
1550
2196
|
name: '@everyone',
|
|
1551
2197
|
position: 0,
|
|
1552
|
-
...
|
|
2198
|
+
...strict ? {
|
|
1553
2199
|
permissions: new PermissionsBitField(NEW_SERVER_EVERYONE_PERMISSIONS).freeze()
|
|
1554
2200
|
} : {}
|
|
1555
2201
|
});
|
|
2202
|
+
if (!strict) madeEveryones.add(role);
|
|
2203
|
+
return role;
|
|
2204
|
+
}
|
|
1556
2205
|
/**
|
|
1557
2206
|
* A guild's role manager with `roles` in its cache, and the guild's @everyone role: the one given with the guild's id,
|
|
1558
2207
|
* or a role of its own at position 0, as every server has one.
|
|
@@ -1652,8 +2301,9 @@ const everyoneRole = (guildId)=>createMockInteraction(Role, {
|
|
|
1652
2301
|
* highest, by position, then the lower id. `permissions` are computed as discord.js computes them: every permission for
|
|
1653
2302
|
* the server's owner, and otherwise its roles' permissions combined, @everyone's included, so a member with no roles has
|
|
1654
2303
|
* @everyone's alone. The member is put in its guild's member cache, so an interaction or a message from its user in that
|
|
1655
|
-
* server has it as its `member`, and `memberPermissions` are its permissions
|
|
1656
|
-
* user's `send()` and the user's
|
|
2304
|
+
* server has it as its `member`, and `memberPermissions` are its permissions, with the interaction's channel's
|
|
2305
|
+
* overwrites applied under `useStrictMocks()`. A DM sent to the member goes through its user's `send()` and the user's
|
|
2306
|
+
* one DM channel.
|
|
1657
2307
|
*
|
|
1658
2308
|
* @param overrides - The member's user, server, roles and nickname; see {@link MockMemberOverrides}.
|
|
1659
2309
|
* @returns The mock member.
|
|
@@ -1700,6 +2350,218 @@ const everyoneRole = (guildId)=>createMockInteraction(Role, {
|
|
|
1700
2350
|
return guild ? cached(cacheOf(guild.channels), channelId, make) : make();
|
|
1701
2351
|
}
|
|
1702
2352
|
/** A server's id as a refusal names it, or a DM. */ const placeName = (guildId)=>guildId === null ? 'a DM' : `server ${guildId}`;
|
|
2353
|
+
/**
|
|
2354
|
+
* Where strict mocks place an interaction: in its message's channel and server, in a guild's or a member's server, and
|
|
2355
|
+
* with a guildId alone in a server the bot isn't in. Undefined where a channel given, or nothing, places it.
|
|
2356
|
+
*/ // Mock messages whose channel and server the mock made, as neither was given
|
|
2357
|
+
const placesMade = new WeakSet();
|
|
2358
|
+
// Messages whose place reads already warn, so a message reused for several interactions is wrapped once
|
|
2359
|
+
const madePlaceWarned = new WeakSet();
|
|
2360
|
+
/**
|
|
2361
|
+
* Has a message whose place the mock made warn once per key when its place is read, in default mode, where strict mocks
|
|
2362
|
+
* move it to the interaction it was given to. A read made while reading another of these, or by the mock, stays quiet.
|
|
2363
|
+
*/ function warnOnMadePlaceReads(message) {
|
|
2364
|
+
if (madePlaceWarned.has(message)) return;
|
|
2365
|
+
madePlaceWarned.add(message);
|
|
2366
|
+
for (const key of [
|
|
2367
|
+
'guild',
|
|
2368
|
+
'guildId',
|
|
2369
|
+
'channel',
|
|
2370
|
+
'channelId',
|
|
2371
|
+
'member'
|
|
2372
|
+
]){
|
|
2373
|
+
const descriptor = Object.getOwnPropertyDescriptor(message, key);
|
|
2374
|
+
if (!descriptor) continue;
|
|
2375
|
+
let value = descriptor.value;
|
|
2376
|
+
Object.defineProperty(message, key, {
|
|
2377
|
+
get: ()=>{
|
|
2378
|
+
if (readingPlace === 0) {
|
|
2379
|
+
warnOnce(mockLogger, `Message.${key} reads the place createMockMessage() made for this message here, though the interaction it was ` + "given to is placed elsewhere; in the next major version (5.0) the message moves to the interaction's place. " + 'Give the message a channel or guild, or call useStrictMocks() to move it now.');
|
|
2380
|
+
}
|
|
2381
|
+
readingPlace++;
|
|
2382
|
+
try {
|
|
2383
|
+
return descriptor.get ? descriptor.get.call(message) : value;
|
|
2384
|
+
} finally{
|
|
2385
|
+
readingPlace--;
|
|
2386
|
+
}
|
|
2387
|
+
},
|
|
2388
|
+
set: (next)=>{
|
|
2389
|
+
if (descriptor.set) descriptor.set.call(message, next);
|
|
2390
|
+
else value = next;
|
|
2391
|
+
},
|
|
2392
|
+
enumerable: true,
|
|
2393
|
+
configurable: true
|
|
2394
|
+
});
|
|
2395
|
+
}
|
|
2396
|
+
}
|
|
2397
|
+
// The DM channel each message the bot sent in a DM in default mode was sent in, where strict mocks place it
|
|
2398
|
+
const sentInDm = new WeakMap();
|
|
2399
|
+
/** Whether a message is one whose channel and server the mock made, which a test placing an interaction overrides. */ const placeWasMade = (message)=>placesMade.has(mockTargets.get(message) ?? message);
|
|
2400
|
+
function strictPlaceOf(own) {
|
|
2401
|
+
const message = own('message');
|
|
2402
|
+
const channel = own('channel');
|
|
2403
|
+
const guild = own('guild');
|
|
2404
|
+
const guildId = own('guildId');
|
|
2405
|
+
const member = own('member');
|
|
2406
|
+
// A message whose place the mock made has none a test meant, so where the test placed the interaction wins
|
|
2407
|
+
const placed = channel !== undefined || guild !== undefined || guildId !== undefined;
|
|
2408
|
+
if (typeof message === 'object' && message !== null && typeof message.channelId === 'string' && !(placed && placeWasMade(message))) {
|
|
2409
|
+
// One the bot sent in a DM is in that DM, as strict mocks place it, though default mode reads a server for it
|
|
2410
|
+
const dm = sentInDm.get(mockTargets.get(message) ?? message);
|
|
2411
|
+
const channelId = dm ? dm.id : message.channelId;
|
|
2412
|
+
const at = dm ? null : message.guildId ?? null;
|
|
2413
|
+
const refused = channel && channel.id !== channelId ? `The message given is in channel ${channelId}, but the mock's channel is ${channel.id}: give the message's channel, or leave one of them out.` : guild !== undefined && (guild?.id ?? null) !== at ? `The message given is in ${placeName(at)}, but the mock's guild is ${placeName(guild?.id ?? null)}: give the message's guild, or leave one of them out.` : guildId !== undefined && guildId !== at ? `The message given is in ${placeName(at)}, but the mock's guildId is ${placeName(guildId)}: give the message's guildId, or leave one of them out.` : undefined;
|
|
2414
|
+
return {
|
|
2415
|
+
given: 'message',
|
|
2416
|
+
channel: dm ?? message.channel,
|
|
2417
|
+
guildId: at,
|
|
2418
|
+
guild: dm ? null : message.guild ?? null,
|
|
2419
|
+
refused
|
|
2420
|
+
};
|
|
2421
|
+
}
|
|
2422
|
+
if (guild instanceof Guild && guildId === undefined) return {
|
|
2423
|
+
given: 'guild',
|
|
2424
|
+
guildId: guild.id
|
|
2425
|
+
};
|
|
2426
|
+
if (member instanceof GuildMember && member.guild instanceof Guild && guild === undefined && guildId === undefined) {
|
|
2427
|
+
return {
|
|
2428
|
+
given: 'member',
|
|
2429
|
+
guild: member.guild,
|
|
2430
|
+
guildId: member.guild.id
|
|
2431
|
+
};
|
|
2432
|
+
}
|
|
2433
|
+
if (typeof guildId === 'string' && guild === undefined && channel === undefined && !(member instanceof GuildMember) && !isRawMember(member)) {
|
|
2434
|
+
return {
|
|
2435
|
+
given: 'guildId',
|
|
2436
|
+
raw: true
|
|
2437
|
+
};
|
|
2438
|
+
}
|
|
2439
|
+
return undefined;
|
|
2440
|
+
}
|
|
2441
|
+
// Set while one of the reads below runs, so what it reads in turn doesn't warn again
|
|
2442
|
+
let readingPlace = 0;
|
|
2443
|
+
/** Runs `read` as the mock's own read of where a mock is, which warns about no placement. */ function readPlaceQuietly(read) {
|
|
2444
|
+
readingPlace++;
|
|
2445
|
+
try {
|
|
2446
|
+
return read();
|
|
2447
|
+
} finally{
|
|
2448
|
+
readingPlace--;
|
|
2449
|
+
}
|
|
2450
|
+
}
|
|
2451
|
+
// Each interaction's strict place and its warning, by the read it names, for the mock's own reads that warn as the test's
|
|
2452
|
+
const placeWarners = new WeakMap();
|
|
2453
|
+
// The place keys a read of the mock's own read while it runs, so it warns by its own name where one would warn
|
|
2454
|
+
let placeReadsSeen;
|
|
2455
|
+
/**
|
|
2456
|
+
* Runs `read`, a value of `mock`'s own that reads where the mock is, quietly; in default mode it warns once, as `key`,
|
|
2457
|
+
* where one of those reads would warn, since strict mocks place the mock otherwise, unless `differs` says the value is
|
|
2458
|
+
* the same in that place.
|
|
2459
|
+
*/ function readPlaceAs(mock, key, read, differs = ()=>true) {
|
|
2460
|
+
const outer = placeReadsSeen;
|
|
2461
|
+
const seen = placeReadsSeen = new Set();
|
|
2462
|
+
try {
|
|
2463
|
+
return readPlaceQuietly(read);
|
|
2464
|
+
} finally{
|
|
2465
|
+
placeReadsSeen = outer;
|
|
2466
|
+
const warner = placeWarners.get(mockTargets.get(mock) ?? mock);
|
|
2467
|
+
if (seen.size > 0 && warner && differs(warner.place)) warner.warn(key);
|
|
2468
|
+
}
|
|
2469
|
+
}
|
|
2470
|
+
/** What reading `key`, where strict mocks place an interaction otherwise, warns: what it reads now, and in 5.0. */ function placeWarning(name, key, place) {
|
|
2471
|
+
if (place.refused) {
|
|
2472
|
+
return `${name}.${key} reads where the mock's channel, guild or guildId put it, not where its message is; under useStrictMocks(), as in the next major version (5.0), the mock is refused: ${place.refused}`;
|
|
2473
|
+
}
|
|
2474
|
+
const why = {
|
|
2475
|
+
message: [
|
|
2476
|
+
'a place other than its message\'s',
|
|
2477
|
+
"the mock is in its message's channel and server",
|
|
2478
|
+
"Give it the message's channel, or call useStrictMocks() to place it there now."
|
|
2479
|
+
],
|
|
2480
|
+
guild: [
|
|
2481
|
+
"a DM's value, though the mock was given a guild",
|
|
2482
|
+
'the mock is in that guild',
|
|
2483
|
+
'Give its guildId or one of its channels too, or call useStrictMocks() to place it there now.'
|
|
2484
|
+
],
|
|
2485
|
+
member: [
|
|
2486
|
+
"a DM's value, though the mock was given a member of a server",
|
|
2487
|
+
"the mock is in the member's server",
|
|
2488
|
+
"Give the member's guild too, or call useStrictMocks() to place it there now."
|
|
2489
|
+
],
|
|
2490
|
+
guildId: [
|
|
2491
|
+
'a server the bot is in, though the mock was given only a guildId',
|
|
2492
|
+
"a guildId alone is a server the bot isn't in: guild and channel are null, and the member is a raw one",
|
|
2493
|
+
'Give a guild too, or call useStrictMocks() to read it so now.'
|
|
2494
|
+
]
|
|
2495
|
+
}[place.given];
|
|
2496
|
+
return `${name}.${key} reads ${why[0]} here; in the next major version (5.0) ${why[1]}. ${why[2]}`;
|
|
2497
|
+
}
|
|
2498
|
+
/** Has a read of `instance`'s `key` call `warn` first, and what that read reads in turn stay quiet. */ function warnOnRead(instance, key, warn) {
|
|
2499
|
+
const descriptor = Object.getOwnPropertyDescriptor(instance, key);
|
|
2500
|
+
// One the mock answers with a stub stays unset, as the mock's own checks read it, and warns as the stub is read
|
|
2501
|
+
if (!descriptor) {
|
|
2502
|
+
if (!stubReadWarnings.has(instance)) stubReadWarnings.set(instance, new Map());
|
|
2503
|
+
stubReadWarnings.get(instance).set(key, warn);
|
|
2504
|
+
return;
|
|
2505
|
+
}
|
|
2506
|
+
const read = ()=>descriptor.get ? descriptor.get.call(instance) : descriptor.value;
|
|
2507
|
+
Object.defineProperty(instance, key, {
|
|
2508
|
+
get: ()=>{
|
|
2509
|
+
warn();
|
|
2510
|
+
readingPlace++;
|
|
2511
|
+
try {
|
|
2512
|
+
return read();
|
|
2513
|
+
} finally{
|
|
2514
|
+
readingPlace--;
|
|
2515
|
+
}
|
|
2516
|
+
},
|
|
2517
|
+
set: (next)=>Object.defineProperty(instance, key, {
|
|
2518
|
+
value: next,
|
|
2519
|
+
writable: true,
|
|
2520
|
+
enumerable: true,
|
|
2521
|
+
configurable: true
|
|
2522
|
+
}),
|
|
2523
|
+
enumerable: true,
|
|
2524
|
+
configurable: true
|
|
2525
|
+
});
|
|
2526
|
+
}
|
|
2527
|
+
/**
|
|
2528
|
+
* Has each of an interaction's `guildId`, `guild`, `channelId`, `channel` and `member` that strict mocks read otherwise
|
|
2529
|
+
* warn once when read, unless the test gave it.
|
|
2530
|
+
*/ function warnOnPlaceReads(instance, place, given) {
|
|
2531
|
+
const value = (key)=>{
|
|
2532
|
+
const descriptor = Object.getOwnPropertyDescriptor(instance, key);
|
|
2533
|
+
return descriptor?.get ? undefined : descriptor?.value;
|
|
2534
|
+
};
|
|
2535
|
+
const keys = place.refused ? [
|
|
2536
|
+
'guildId',
|
|
2537
|
+
'guild',
|
|
2538
|
+
'channelId',
|
|
2539
|
+
'channel'
|
|
2540
|
+
] : place.raw ? [
|
|
2541
|
+
'guild',
|
|
2542
|
+
'channel',
|
|
2543
|
+
'member'
|
|
2544
|
+
] : place.guildId !== value('guildId') ? [
|
|
2545
|
+
'guildId',
|
|
2546
|
+
'guild',
|
|
2547
|
+
'channelId',
|
|
2548
|
+
'channel',
|
|
2549
|
+
'member'
|
|
2550
|
+
] : place.channel && place.channel.id !== value('channelId') ? [
|
|
2551
|
+
'channelId',
|
|
2552
|
+
'channel'
|
|
2553
|
+
] : [];
|
|
2554
|
+
const name = Object.getPrototypeOf(instance).constructor.name;
|
|
2555
|
+
placeWarners.set(instance, {
|
|
2556
|
+
place,
|
|
2557
|
+
keys,
|
|
2558
|
+
warn: (read)=>warnOnce(mockLogger, placeWarning(name, read, place))
|
|
2559
|
+
});
|
|
2560
|
+
for (const key of keys.filter((each)=>!given.has(each))){
|
|
2561
|
+
// A read the mock makes stays quiet, noted for a read of the mock's own to warn by its name
|
|
2562
|
+
warnOnRead(instance, key, ()=>readingPlace > 0 ? void placeReadsSeen?.add(key) : warnOnce(mockLogger, placeWarning(name, key, place)));
|
|
2563
|
+
}
|
|
2564
|
+
}
|
|
1703
2565
|
/**
|
|
1704
2566
|
* Where a channel a test gives puts the mock made in it, read from the channel as discord.js reads it: no server for a
|
|
1705
2567
|
* DM channel, else the server the channel names. A server channel that names none is put in `fallback`'s, and named
|
|
@@ -1744,23 +2606,69 @@ const everyoneRole = (guildId)=>createMockInteraction(Role, {
|
|
|
1744
2606
|
cached(cacheOf(manager), id, ()=>channel);
|
|
1745
2607
|
}
|
|
1746
2608
|
}
|
|
1747
|
-
/** The guild a mock message carries: a guild with the same stubbed managers as createMockGuild. */ function createMockGuildForMessage(id
|
|
2609
|
+
/** The guild a mock message carries: a guild with the same stubbed managers as createMockGuild. */ function createMockGuildForMessage(id) {
|
|
1748
2610
|
const guild = Object.create(Guild.prototype);
|
|
1749
|
-
|
|
2611
|
+
const generatedId = id === undefined ? nextSnowflake() : undefined;
|
|
2612
|
+
guild.id = id ?? generatedId;
|
|
2613
|
+
defineMadeTime(guild, generatedId);
|
|
1750
2614
|
guild.members = managerWith(GuildMemberManager.prototype, undefined);
|
|
1751
2615
|
guild.channels = managerWith(GuildChannelManager.prototype, undefined);
|
|
1752
2616
|
guild.roles = guildRoleManager(guild.id, undefined);
|
|
1753
|
-
guild
|
|
2617
|
+
for (const [key, Manager] of GUILD_MANAGERS)guild[key] = managerWith(Manager.prototype, undefined);
|
|
1754
2618
|
const proxy = stubDeep(guild);
|
|
1755
2619
|
settleGuild(guild, proxy);
|
|
1756
2620
|
return proxy;
|
|
1757
2621
|
}
|
|
2622
|
+
// Each settled guild's bot member as `members.me` reads it, without caching one made for the read
|
|
2623
|
+
const botMembers = new WeakMap();
|
|
2624
|
+
/** The bot's member in `guild` for the mock's own reads: the one `members.me` reads, made without caching it there. */ function botMemberOf(guild) {
|
|
2625
|
+
return botMembers.get(guild)?.() ?? guild.members.me;
|
|
2626
|
+
}
|
|
2627
|
+
/**
|
|
2628
|
+
* The channel an interaction's `channel` reads, for the mock's own reads: `null` in a DM, else one set on it or its
|
|
2629
|
+
* server's cached one, without making or caching one. Undefined where `channel` would make one, which has no overwrites.
|
|
2630
|
+
*/ function heldChannelOf(interaction, guild) {
|
|
2631
|
+
const target = mockTargets.get(interaction) ?? interaction;
|
|
2632
|
+
return readPlaceQuietly(()=>{
|
|
2633
|
+
const set = Object.getOwnPropertyDescriptor(target, 'channel');
|
|
2634
|
+
if (set && 'value' in set) return set.value;
|
|
2635
|
+
const { guildId, channelId } = target;
|
|
2636
|
+
return guildId ? cacheOf(guild.channels)?.get(channelId) : null;
|
|
2637
|
+
});
|
|
2638
|
+
}
|
|
2639
|
+
// A guild's managers beside members, channels and roles, each empty, of its discord.js class
|
|
2640
|
+
const GUILD_MANAGERS = [
|
|
2641
|
+
[
|
|
2642
|
+
'bans',
|
|
2643
|
+
GuildBanManager
|
|
2644
|
+
],
|
|
2645
|
+
[
|
|
2646
|
+
'emojis',
|
|
2647
|
+
GuildEmojiManager
|
|
2648
|
+
],
|
|
2649
|
+
[
|
|
2650
|
+
'stickers',
|
|
2651
|
+
GuildStickerManager
|
|
2652
|
+
],
|
|
2653
|
+
[
|
|
2654
|
+
'scheduledEvents',
|
|
2655
|
+
GuildScheduledEventManager
|
|
2656
|
+
],
|
|
2657
|
+
[
|
|
2658
|
+
'autoModerationRules',
|
|
2659
|
+
AutoModerationRuleManager
|
|
2660
|
+
],
|
|
2661
|
+
[
|
|
2662
|
+
'invites',
|
|
2663
|
+
GuildInviteManager
|
|
2664
|
+
]
|
|
2665
|
+
];
|
|
1758
2666
|
/** 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) {
|
|
1759
2667
|
for (const key of [
|
|
1760
2668
|
'members',
|
|
1761
2669
|
'channels',
|
|
1762
2670
|
'roles',
|
|
1763
|
-
|
|
2671
|
+
...GUILD_MANAGERS.map(([key])=>key)
|
|
1764
2672
|
])instance[key].guild = guild;
|
|
1765
2673
|
}
|
|
1766
2674
|
/**
|
|
@@ -1776,11 +2684,13 @@ const everyoneRole = (guildId)=>createMockInteraction(Role, {
|
|
|
1776
2684
|
configurable: true
|
|
1777
2685
|
});
|
|
1778
2686
|
const members = instance.members;
|
|
2687
|
+
const botMember = (keep)=>{
|
|
2688
|
+
const { user } = guild.client;
|
|
2689
|
+
return keep ? cached(cacheOf(members), user.id, ()=>memberIn(guild, user.id, user)) : cacheOf(members)?.get(user.id) ?? memberIn(guild, user.id, user);
|
|
2690
|
+
};
|
|
2691
|
+
botMembers.set(guild, ()=>botMember(false));
|
|
1779
2692
|
Object.defineProperty(members, 'me', {
|
|
1780
|
-
get: ()=>
|
|
1781
|
-
const { user } = guild.client;
|
|
1782
|
-
return cached(cacheOf(members), user.id, ()=>memberIn(guild, user.id, user));
|
|
1783
|
-
},
|
|
2693
|
+
get: ()=>botMember(true),
|
|
1784
2694
|
configurable: true
|
|
1785
2695
|
});
|
|
1786
2696
|
for (const key of [
|
|
@@ -1854,8 +2764,11 @@ const everyoneRole = (guildId)=>createMockInteraction(Role, {
|
|
|
1854
2764
|
};
|
|
1855
2765
|
// Own values, so the prototype's getters, which read the raw mention data, are not reached
|
|
1856
2766
|
const instance = Object.create(MessageMentions.prototype);
|
|
2767
|
+
const parsedUsers = new Collection(mentioned.users);
|
|
1857
2768
|
for (const [key, value] of Object.entries({
|
|
1858
2769
|
...mentioned,
|
|
2770
|
+
parsedUsers,
|
|
2771
|
+
crosspostedChannels: new Collection(),
|
|
1859
2772
|
everyone: false,
|
|
1860
2773
|
client,
|
|
1861
2774
|
guild: guild ?? null
|
|
@@ -1868,11 +2781,14 @@ const everyoneRole = (guildId)=>createMockInteraction(Role, {
|
|
|
1868
2781
|
return stubDeep(instance);
|
|
1869
2782
|
}
|
|
1870
2783
|
/** A value as its API JSON: a builder or discord.js structure through `toJSON()`, anything else as it is. */ const jsonOf = (value)=>value && typeof value.toJSON === 'function' ? value.toJSON() : value;
|
|
1871
|
-
/**
|
|
2784
|
+
/**
|
|
2785
|
+
* The message a reply method starts from: the one a component is on, as it was, or an empty original response. Without
|
|
2786
|
+
* `allocate`, one with no id of its own gets `''`, for its holder to give it one when first read.
|
|
2787
|
+
*/ function heldFrom(message, allocate = true) {
|
|
1872
2788
|
// Read only what the message really has: a mock's unset fields are stubs
|
|
1873
2789
|
const from = message ?? {};
|
|
1874
2790
|
return {
|
|
1875
|
-
id: typeof from.id === 'string' ? from.id : nextSnowflake(),
|
|
2791
|
+
id: typeof from.id === 'string' ? from.id : allocate ? nextSnowflake() : '',
|
|
1876
2792
|
content: typeof from.content === 'string' ? from.content : undefined,
|
|
1877
2793
|
components: Array.isArray(from.components) ? from.components.map(jsonOf) : [],
|
|
1878
2794
|
embeds: Array.isArray(from.embeds) ? from.embeds.map(jsonOf) : [],
|
|
@@ -1880,6 +2796,15 @@ const everyoneRole = (guildId)=>createMockInteraction(Role, {
|
|
|
1880
2796
|
editedTimestamp: typeof from.editedTimestamp === 'number' ? from.editedTimestamp : null
|
|
1881
2797
|
};
|
|
1882
2798
|
}
|
|
2799
|
+
/** A message as an answer sends it: a new one, with what the answer gave, never edited. */ const sent = (options, allocate = true)=>{
|
|
2800
|
+
// Its id taken only once it builds, so a payload that fails to build takes none
|
|
2801
|
+
const held = edited(heldFrom(undefined, false), options);
|
|
2802
|
+
return {
|
|
2803
|
+
...held,
|
|
2804
|
+
id: allocate ? nextSnowflake() : '',
|
|
2805
|
+
editedTimestamp: null
|
|
2806
|
+
};
|
|
2807
|
+
};
|
|
1883
2808
|
/** A held message after an edit: what the edit sets replaced in Discord's shape, and a later edit stamp. */ function edited(held, options) {
|
|
1884
2809
|
const edit = typeof options === 'string' ? {
|
|
1885
2810
|
content: options
|
|
@@ -1908,29 +2833,28 @@ const everyoneRole = (guildId)=>createMockInteraction(Role, {
|
|
|
1908
2833
|
editedTimestamp: Math.max(Date.now(), (held.editedTimestamp ?? 0) + 1)
|
|
1909
2834
|
};
|
|
1910
2835
|
}
|
|
1911
|
-
/** A mock message showing a held message. */ function messageFrom(held) {
|
|
1912
|
-
return
|
|
2836
|
+
/** A mock message showing a held message, one the bot sent in `channel` by `client`, with what `made` keeps. */ function messageFrom(held, made, channel, client) {
|
|
2837
|
+
return sentMessage(held, channel, client, {
|
|
1913
2838
|
id: held.id,
|
|
1914
|
-
...held.content !== undefined && {
|
|
1915
|
-
content: held.content
|
|
1916
|
-
},
|
|
1917
|
-
components: held.components,
|
|
1918
|
-
embeds: held.embeds,
|
|
1919
|
-
...held.flags !== undefined && {
|
|
1920
|
-
flags: held.flags
|
|
1921
|
-
},
|
|
1922
2839
|
editedTimestamp: held.editedTimestamp
|
|
1923
|
-
});
|
|
2840
|
+
}, made);
|
|
1924
2841
|
}
|
|
1925
2842
|
/**
|
|
1926
|
-
* A component or embed as a message holds it: a discord.js instance as it is
|
|
1927
|
-
* its API JSON at the time of the call,
|
|
1928
|
-
*/ function asHeld(value) {
|
|
2843
|
+
* A component or embed as a message holds it: a discord.js instance as it is, anything else as the discord.js class
|
|
2844
|
+
* built from its API JSON at the time of the call, as discord.js builds it, an unknown type as a plain `Component`.
|
|
2845
|
+
*/ function asHeld(value, kind) {
|
|
1929
2846
|
if (value instanceof Component || value instanceof Embed) return value;
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
2847
|
+
// A copy, since discord.js's classes keep what they are given, one level deep
|
|
2848
|
+
const json = structuredClone(jsonOf(value));
|
|
2849
|
+
if (kind === 'embed') return new Embed(json);
|
|
2850
|
+
// An action row builds each child with discord.js's own createComponent, which it doesn't export
|
|
2851
|
+
const row = new ActionRow({
|
|
2852
|
+
type: ComponentType.ActionRow,
|
|
2853
|
+
components: [
|
|
2854
|
+
json
|
|
2855
|
+
]
|
|
2856
|
+
});
|
|
2857
|
+
return row.components[0];
|
|
1934
2858
|
}
|
|
1935
2859
|
/**
|
|
1936
2860
|
* Creates a mock {@link Message} that tracks whether it has been deleted.
|
|
@@ -1939,12 +2863,14 @@ const everyoneRole = (guildId)=>createMockInteraction(Role, {
|
|
|
1939
2863
|
* `@MessageHandler` does not skip it.
|
|
1940
2864
|
*
|
|
1941
2865
|
* @remarks
|
|
1942
|
-
* `delete()`, `edit()`, `reply()`, `react()`, `pin()` and `unpin()` throw once the message is deleted
|
|
1943
|
-
* and `reply()`
|
|
1944
|
-
*
|
|
1945
|
-
* one
|
|
1946
|
-
*
|
|
1947
|
-
*
|
|
2866
|
+
* `delete()`, `edit()`, `reply()`, `react()`, `pin()` and `unpin()` throw once the message is deleted. `edit()` changes
|
|
2867
|
+
* this message and resolves to it, and `reply()` resolves to the reply, sent in this message's channel and holding what
|
|
2868
|
+
* was sent, as a channel's `send()` does. Under `useStrictMocks()` a message the bot sends is its `client.user`'s, and
|
|
2869
|
+
* one sent in a DM is in that DM. Without overrides the message is empty, in a server, from a new person who is its
|
|
2870
|
+
* `member`; with `guild: null` it is a direct message. Give `author` to send several messages as one user, such as to
|
|
2871
|
+
* reach a per-user cooldown. An author given, and the users, roles and channels the content mentions, are cached as the
|
|
2872
|
+
* gateway delivers them. It is typed as discord.js emits a message, never in a group DM, so it fits `messageCreate` and
|
|
2873
|
+
* the other message events.
|
|
1948
2874
|
*
|
|
1949
2875
|
* @param overrides - The message's content, components and the rest; see {@link MockMessageOverrides}.
|
|
1950
2876
|
*
|
|
@@ -1967,11 +2893,18 @@ const everyoneRole = (guildId)=>createMockInteraction(Role, {
|
|
|
1967
2893
|
* @see {@link MockMessageOverrides}
|
|
1968
2894
|
* @see {@link createMockGuild}
|
|
1969
2895
|
*/ function createMockMessage(overrides = {}) {
|
|
2896
|
+
return messageMock(overrides);
|
|
2897
|
+
}
|
|
2898
|
+
function messageMock(overrides = {}, sentBy, made = {}, at) {
|
|
2899
|
+
var _made, _made1, _made2;
|
|
1970
2900
|
const instance = Object.create(Message.prototype);
|
|
1971
2901
|
const stubs = new Map();
|
|
2902
|
+
const unplaced = overrides.channel === undefined && overrides.guild === undefined;
|
|
2903
|
+
// A message the bot sent is placed where it was sent, even where default mode reads a server of its own for a DM
|
|
2904
|
+
if (unplaced && !sentBy) placesMade.add(instance);
|
|
1972
2905
|
instance.deleted = false;
|
|
1973
2906
|
// Constructor-assigned — set as prototype-based stubs; a user rather than a bot, as dispatch handles only those
|
|
1974
|
-
instance.author = overrides.author ?? mockUser();
|
|
2907
|
+
instance.author = overrides.author ?? (_made = made).author ?? (_made.author = mockUser());
|
|
1975
2908
|
// Getters on the prototype, which the proxy would answer with a stub object: own properties shadow them
|
|
1976
2909
|
// A channel given says where the message was sent, unless its guild is given too
|
|
1977
2910
|
const place = overrides.channel ? placeOf(overrides.channel, overrides.guild === undefined ? undefined : overrides.guild?.id ?? null, ()=>{
|
|
@@ -1981,22 +2914,37 @@ const everyoneRole = (guildId)=>createMockInteraction(Role, {
|
|
|
1981
2914
|
guild
|
|
1982
2915
|
};
|
|
1983
2916
|
}) : undefined;
|
|
1984
|
-
const guild = overrides.guild !== undefined ? overrides.guild : place?.guildId !== undefined ? place.guildId === null ? null : place.guild ?? createMockGuildForMessage(place.guildId) : createMockGuildForMessage();
|
|
1985
|
-
const
|
|
1986
|
-
|
|
2917
|
+
const guild = overrides.guild !== undefined ? overrides.guild : place?.guildId !== undefined ? place.guildId === null ? null : place.guild ?? createMockGuildForMessage(place.guildId) : unplaced ? (_made1 = made).guild ?? (_made1.guild = createMockGuildForMessage()) : createMockGuildForMessage();
|
|
2918
|
+
const madeChannel = ()=>channelFor(guild, guild?.id ?? null, nextSnowflake(), instance.author);
|
|
2919
|
+
const channel = at ? undefined : overrides.channel ?? (unplaced ? (_made2 = made).channel ?? (_made2.channel = madeChannel()) : madeChannel());
|
|
2920
|
+
if (channel) Object.defineProperty(instance, 'channel', {
|
|
1987
2921
|
value: channel,
|
|
1988
|
-
writable: true
|
|
2922
|
+
writable: true,
|
|
2923
|
+
configurable: true
|
|
1989
2924
|
});
|
|
2925
|
+
else {
|
|
2926
|
+
Object.defineProperty(instance, 'channel', {
|
|
2927
|
+
get: at.channel,
|
|
2928
|
+
set: (value)=>Object.defineProperty(instance, 'channel', {
|
|
2929
|
+
value,
|
|
2930
|
+
writable: true,
|
|
2931
|
+
configurable: true
|
|
2932
|
+
}),
|
|
2933
|
+
configurable: true
|
|
2934
|
+
});
|
|
2935
|
+
}
|
|
1990
2936
|
Object.defineProperty(instance, 'guild', {
|
|
1991
2937
|
value: guild,
|
|
1992
|
-
writable: true
|
|
2938
|
+
writable: true,
|
|
2939
|
+
configurable: true
|
|
1993
2940
|
});
|
|
1994
2941
|
// The author as a member of the message's server; a direct message has none. A given author's member is the one
|
|
1995
2942
|
// the server caches, as the gateway resolves it, so every message from that author has the same member
|
|
1996
|
-
// The author's member, cached as the gateway caches it with the message; `member` reads the cache, as discord.js
|
|
2943
|
+
// The author's member, cached as the gateway caches it with the message; `member` reads the cache, as discord.js
|
|
2944
|
+
// does, without calling the manager's resolve a test may stub
|
|
1997
2945
|
if (guild) memberFor(guild, instance.author, true);
|
|
1998
2946
|
Object.defineProperty(instance, 'member', {
|
|
1999
|
-
get: ()=>guild ? guild.members
|
|
2947
|
+
get: ()=>instance.guild ? cacheOf(instance.guild.members)?.get(instance.author.id) ?? null : null,
|
|
2000
2948
|
set: (value)=>Object.defineProperty(instance, 'member', {
|
|
2001
2949
|
value,
|
|
2002
2950
|
writable: true,
|
|
@@ -2007,8 +2955,8 @@ const everyoneRole = (guildId)=>createMockInteraction(Role, {
|
|
|
2007
2955
|
configurable: true
|
|
2008
2956
|
});
|
|
2009
2957
|
// In a server's text channel, the ids matching the objects; with no guild, a DM
|
|
2010
|
-
instance.channelId = channel.
|
|
2011
|
-
instance.guildId = guild?.id ?? null;
|
|
2958
|
+
instance.channelId = channel?.id ?? at.channelId;
|
|
2959
|
+
instance.guildId = at ? at.guildId : guild?.id ?? null;
|
|
2012
2960
|
// The thread its channel caches under its id, as discord.js reads it; until 5.0, a placeholder thread otherwise
|
|
2013
2961
|
let placeholderThread;
|
|
2014
2962
|
Object.defineProperty(instance, 'thread', {
|
|
@@ -2038,7 +2986,7 @@ const everyoneRole = (guildId)=>createMockInteraction(Role, {
|
|
|
2038
2986
|
configurable: true
|
|
2039
2987
|
});
|
|
2040
2988
|
if (guild instanceof Guild) cached(cacheOf(client.guilds), guild.id, ()=>guild);
|
|
2041
|
-
cacheChannel(channel, guild, client);
|
|
2989
|
+
if (channel) cacheChannel(channel, guild, client);
|
|
2042
2990
|
const userCache = cacheOf(client.users);
|
|
2043
2991
|
for (const user of [
|
|
2044
2992
|
...overrides.users ?? [],
|
|
@@ -2048,8 +2996,8 @@ const everyoneRole = (guildId)=>createMockInteraction(Role, {
|
|
|
2048
2996
|
instance.mentions = mentionsOf(overrides.content, client, guild);
|
|
2049
2997
|
// Data a message always has, real rather than stubbed, so code reading it sees an empty message
|
|
2050
2998
|
instance.flags = new MessageFlagsBitField(overrides.flags);
|
|
2051
|
-
instance.components = (overrides.components ?? []).map(asHeld);
|
|
2052
|
-
instance.embeds = (overrides.embeds ?? []).map(asHeld);
|
|
2999
|
+
instance.components = (overrides.components ?? []).map((component)=>asHeld(component, 'component'));
|
|
3000
|
+
instance.embeds = (overrides.embeds ?? []).map((embed)=>asHeld(embed, 'embed'));
|
|
2053
3001
|
instance.attachments = new Collection();
|
|
2054
3002
|
instance.editedTimestamp = overrides.editedTimestamp ?? null;
|
|
2055
3003
|
const generatedId = overrides.id === undefined ? nextSnowflake() : undefined;
|
|
@@ -2062,14 +3010,27 @@ const everyoneRole = (guildId)=>createMockInteraction(Role, {
|
|
|
2062
3010
|
if (instance.deleted) throw alreadyDeleted();
|
|
2063
3011
|
instance.deleted = true;
|
|
2064
3012
|
}));
|
|
2065
|
-
|
|
3013
|
+
// An edit changes this message, as Discord edits it in place, and resolves to it
|
|
3014
|
+
stubs.set('edit', createMockFn(async (options)=>{
|
|
2066
3015
|
if (instance.deleted) throw alreadyDeleted();
|
|
2067
|
-
|
|
3016
|
+
const held = edited(heldFrom(undefined, false), options);
|
|
3017
|
+
const edit = typeof options === 'string' ? {
|
|
3018
|
+
content: options
|
|
3019
|
+
} : options ?? {};
|
|
3020
|
+
if (edit.content !== undefined) instance.content = held.content;
|
|
3021
|
+
if (edit.components !== undefined) instance.components = held.components.map((component)=>asHeld(component, 'component'));
|
|
3022
|
+
if (edit.embeds !== undefined) instance.embeds = held.embeds.map((embed)=>asHeld(embed, 'embed'));
|
|
3023
|
+
if (edit.flags !== undefined) instance.flags = new MessageFlagsBitField(held.flags);
|
|
3024
|
+
instance.editedTimestamp = Math.max(Date.now(), (instance.editedTimestamp ?? 0) + 1);
|
|
3025
|
+
return message;
|
|
2068
3026
|
}));
|
|
2069
|
-
stubs.set('reply', createMockFn(async ()=>{
|
|
3027
|
+
stubs.set('reply', createMockFn(async (options)=>{
|
|
2070
3028
|
if (instance.deleted) throw alreadyDeleted();
|
|
2071
|
-
return
|
|
3029
|
+
return sentMessage(sent(options, false), instance.channel, instance.client);
|
|
2072
3030
|
}));
|
|
3031
|
+
// Crossposting publishes this message, and forwarding sends one in the channel it is forwarded to
|
|
3032
|
+
stubs.set('crosspost', createMockFn(async ()=>message));
|
|
3033
|
+
stubs.set('forward', createMockFn(async (channel)=>sentMessage(sent(undefined, false), channel, instance.client)));
|
|
2073
3034
|
stubs.set('react', createMockFn(async ()=>{
|
|
2074
3035
|
if (instance.deleted) throw alreadyDeleted();
|
|
2075
3036
|
}));
|
|
@@ -2079,7 +3040,85 @@ const everyoneRole = (guildId)=>createMockInteraction(Role, {
|
|
|
2079
3040
|
stubs.set('unpin', createMockFn(async ()=>{
|
|
2080
3041
|
if (instance.deleted) throw alreadyDeleted();
|
|
2081
3042
|
}));
|
|
2082
|
-
|
|
3043
|
+
if (sentBy) warnOnSentReads(instance, stubs, sentBy);
|
|
3044
|
+
const message = stubDeep(instance, stubs);
|
|
3045
|
+
return message;
|
|
3046
|
+
}
|
|
3047
|
+
/**
|
|
3048
|
+
* Has a message the bot sent warn once where strict mocks read it otherwise: its author, the bot's `client.user` under
|
|
3049
|
+
* strict mocks, and, for one sent in a DM, the server it reads as being in.
|
|
3050
|
+
*/ function warnOnSentReads(instance, stubs, sentBy) {
|
|
3051
|
+
// A read made while reading another of these, as `member` reads `author`, warns only for the outer one
|
|
3052
|
+
let reading = 0;
|
|
3053
|
+
const read = (key, warning)=>{
|
|
3054
|
+
const descriptor = Object.getOwnPropertyDescriptor(instance, key);
|
|
3055
|
+
Object.defineProperty(instance, key, {
|
|
3056
|
+
get: ()=>{
|
|
3057
|
+
if (reading === 0) warnOnce(mockLogger, warning);
|
|
3058
|
+
reading++;
|
|
3059
|
+
try {
|
|
3060
|
+
return descriptor?.get ? descriptor.get.call(instance) : descriptor?.value;
|
|
3061
|
+
} finally{
|
|
3062
|
+
reading--;
|
|
3063
|
+
}
|
|
3064
|
+
},
|
|
3065
|
+
set: (next)=>Object.defineProperty(instance, key, {
|
|
3066
|
+
value: next,
|
|
3067
|
+
writable: true,
|
|
3068
|
+
enumerable: true,
|
|
3069
|
+
configurable: true
|
|
3070
|
+
}),
|
|
3071
|
+
enumerable: true,
|
|
3072
|
+
configurable: true
|
|
3073
|
+
});
|
|
3074
|
+
};
|
|
3075
|
+
if (sentBy.bot) {
|
|
3076
|
+
read('author', "Message.author reads a user here, though the bot sent this message; in the next major version (5.0) it is the bot's " + "client.user, as Discord sends it. Call useStrictMocks() to read client.user now.");
|
|
3077
|
+
}
|
|
3078
|
+
if (!sentBy.dm) return;
|
|
3079
|
+
const inDm = (what)=>`Message.${what} reads a server here, though this message was sent in a DM; in the next major version (5.0) it reads ` + "as a DM's, with no server. Call useStrictMocks() to read it so now.";
|
|
3080
|
+
const inGuild = Boolean(instance.guildId);
|
|
3081
|
+
for (const key of [
|
|
3082
|
+
'guild',
|
|
3083
|
+
'guildId',
|
|
3084
|
+
'member'
|
|
3085
|
+
])read(key, inDm(key));
|
|
3086
|
+
stubs.set('inGuild', createMockFn(()=>(warnOnce(mockLogger, inDm('inGuild()')), inGuild)));
|
|
3087
|
+
}
|
|
3088
|
+
/**
|
|
3089
|
+
* A message the bot sent, as Discord returns it: in the channel it was sent in and that channel's server, holding what
|
|
3090
|
+
* 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
|
|
3091
|
+
* a DM send in a server of its own and keeps another author, with a warning when read.
|
|
3092
|
+
*/ function sentMessage(held, channel, client, fields = {}, made) {
|
|
3093
|
+
const by = client ?? channel?.client;
|
|
3094
|
+
const inChannel = channel instanceof BaseChannel;
|
|
3095
|
+
const dm = inChannel && channel.isDMBased();
|
|
3096
|
+
const strict = strictMocks();
|
|
3097
|
+
const message = messageMock({
|
|
3098
|
+
...held.content !== undefined && {
|
|
3099
|
+
content: held.content
|
|
3100
|
+
},
|
|
3101
|
+
components: held.components,
|
|
3102
|
+
embeds: held.embeds,
|
|
3103
|
+
...held.flags !== undefined && {
|
|
3104
|
+
flags: held.flags
|
|
3105
|
+
},
|
|
3106
|
+
...inChannel && (!dm || strict) && {
|
|
3107
|
+
channel: channel
|
|
3108
|
+
},
|
|
3109
|
+
...by && {
|
|
3110
|
+
client: by
|
|
3111
|
+
},
|
|
3112
|
+
...strict && by?.user && {
|
|
3113
|
+
author: by.user
|
|
3114
|
+
},
|
|
3115
|
+
...fields
|
|
3116
|
+
}, strict ? undefined : {
|
|
3117
|
+
bot: Boolean(by?.user),
|
|
3118
|
+
dm
|
|
3119
|
+
}, made);
|
|
3120
|
+
if (dm && !strict) sentInDm.set(mockTargets.get(message) ?? message, channel);
|
|
3121
|
+
return message;
|
|
2083
3122
|
}
|
|
2084
3123
|
/** The option type Discord would have sent for a given JavaScript value. */ function optionTypeOf(value) {
|
|
2085
3124
|
if (typeof value === 'boolean') return ApplicationCommandOptionType.Boolean;
|
|
@@ -2168,11 +3207,32 @@ const everyoneRole = (guildId)=>createMockInteraction(Role, {
|
|
|
2168
3207
|
else if (isEntity) option.user = value;
|
|
2169
3208
|
return option;
|
|
2170
3209
|
}
|
|
3210
|
+
/**
|
|
3211
|
+
* The option an autocomplete's user is typing, as Discord sends it: its value a string, `''` before anything is typed.
|
|
3212
|
+
* A number given for it is its digits under strict mocks; otherwise it stays a number, with a warning when read.
|
|
3213
|
+
*/ function focusedOptionData(name, value) {
|
|
3214
|
+
const option = toOptionData(name, value ?? '');
|
|
3215
|
+
option.focused = true;
|
|
3216
|
+
if (typeof value === 'number') {
|
|
3217
|
+
if (strictMocks()) option.value = String(value);
|
|
3218
|
+
else {
|
|
3219
|
+
Object.defineProperty(option, 'value', {
|
|
3220
|
+
get: ()=>{
|
|
3221
|
+
warnOnce(mockLogger, `The focused option "${name}" reads the number ${value} here, where Discord sends a string; in the next major version (5.0) ` + `the mock gives it as one. Give it as a string, such as '${value}', or call useStrictMocks() to have the mock give it now.`);
|
|
3222
|
+
return value;
|
|
3223
|
+
},
|
|
3224
|
+
enumerable: true
|
|
3225
|
+
});
|
|
3226
|
+
}
|
|
3227
|
+
}
|
|
3228
|
+
return option;
|
|
3229
|
+
}
|
|
2171
3230
|
/**
|
|
2172
3231
|
* Nests the supplied options under the subcommand path they were invoked through,
|
|
2173
3232
|
* matching the shape Discord sends rather than a flat list.
|
|
2174
|
-
*/ function buildOptionData(subcommandGroup, subcommand, values, memberOf) {
|
|
2175
|
-
const leaves = Object.entries(values).map(([name, value])=>toOptionData(name, value, memberOf));
|
|
3233
|
+
*/ function buildOptionData(subcommandGroup, subcommand, values, memberOf, focused) {
|
|
3234
|
+
const leaves = Object.entries(values).map(([name, value])=>name === focused ? focusedOptionData(name, value) : toOptionData(name, value, memberOf));
|
|
3235
|
+
if (focused !== null && !Object.hasOwn(values, focused)) leaves.push(focusedOptionData(focused, null));
|
|
2176
3236
|
if (subcommand === null) return leaves;
|
|
2177
3237
|
const sub = {
|
|
2178
3238
|
name: subcommand,
|
|
@@ -2212,6 +3272,10 @@ const everyoneRole = (guildId)=>createMockInteraction(Role, {
|
|
|
2212
3272
|
* A whole number is an Integer option and a fraction a Number one, so
|
|
2213
3273
|
* `getInteger()` reads only a whole number, and `getNumber()` reads either.
|
|
2214
3274
|
*
|
|
3275
|
+
* The `focused` option reads as Discord sends it, through `getFocused()`, `getFocused(true)` and `data` alike: a
|
|
3276
|
+
* string, `''` when no value is given, and marked `focused` in `data`. A number given for it is its digits under
|
|
3277
|
+
* `useStrictMocks()`; otherwise it stays the number, with a warning when read.
|
|
3278
|
+
*
|
|
2215
3279
|
* @typeParam Cached - `any` by default, to match `createMockInteraction(ChatInputCommandInteraction)`; pass it when the
|
|
2216
3280
|
* interaction under test is cache-pinned.
|
|
2217
3281
|
*
|
|
@@ -2332,17 +3396,21 @@ const everyoneRole = (guildId)=>createMockInteraction(Role, {
|
|
|
2332
3396
|
], ()=>false, required));
|
|
2333
3397
|
base.getFocused = createMockFn((getFull)=>{
|
|
2334
3398
|
if (focused === null) throw optionError(DiscordjsErrorCodes.AutocompleteInteractionOptionNoFocusedOption);
|
|
2335
|
-
|
|
3399
|
+
// The one options.data holds, so the two agree
|
|
3400
|
+
const option = base._hoistedOptions.find((each)=>each.focused);
|
|
2336
3401
|
return getFull === true ? {
|
|
2337
|
-
...option
|
|
2338
|
-
focused: true
|
|
3402
|
+
...option
|
|
2339
3403
|
} : option.value;
|
|
2340
3404
|
});
|
|
2341
3405
|
// `data` is what the framework reads to build a handler's params, so it is materialised here rather than
|
|
2342
3406
|
// auto-stubbed, or every params assertion would see an empty record.
|
|
2343
|
-
base.data = buildOptionData(subcommandGroup, subcommand, values, memberOf);
|
|
3407
|
+
base.data = buildOptionData(subcommandGroup, subcommand, values, memberOf, focused);
|
|
3408
|
+
// The fields discord.js's constructor sets from it, which its toString() reads
|
|
3409
|
+
base._group = subcommandGroup;
|
|
3410
|
+
base._subcommand = subcommand;
|
|
3411
|
+
base._hoistedOptions = subcommand === null ? base.data : (subcommandGroup === null ? base.data[0] : base.data[0].options[0]).options;
|
|
2344
3412
|
const resolver = stubDeep(base);
|
|
2345
3413
|
return resolver;
|
|
2346
3414
|
}
|
|
2347
3415
|
|
|
2348
|
-
export { RESPONSE_LOG, createChatInputOptions, createMock, createMockChannel, createMockClient, createMockGuild, createMockInteraction, createMockMember, createMockMessage, createMockRawMember, createMockUser, isRawMember, memberRoles };
|
|
3416
|
+
export { NEW_SERVER_EVERYONE_PERMISSIONS, RESPONSE_LOG, botMemberOf, createChatInputOptions, createMock, createMockChannel, createMockClient, createMockGuild, createMockInteraction, createMockMember, createMockMessage, createMockRawMember, createMockUser, heldChannelOf, isRawMember, isUnsetEveryone, memberRoles, noteReactionDispatch, ownedManager, readPlaceAs };
|