meocord 4.1.2 → 4.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +362 -0
- package/THIRD_PARTY_NOTICES.md +14 -13
- package/dist/cjs/_shared/{controller.decorator-BywwkU9O.cjs → controller.decorator-XKMlcUZz.cjs} +669 -185
- package/dist/cjs/_shared/{providers-BMMLp2SN.cjs → providers-BZgJyfZS.cjs} +218 -75
- package/dist/cjs/_shared/{sharded-cooldown-store-B65iaiAy.cjs → sharded-cooldown-store-CsJJyCZd.cjs} +10 -7
- package/dist/cjs/_shared/{startup-roots-lFc69AxC.cjs → startup-roots-CgfPQrNl.cjs} +374 -132
- package/dist/cjs/_shared/{translate-error-BnM8FbSH.cjs → translate-error-BUAR1WYG.cjs} +21 -7
- package/dist/cjs/common/index.cjs +4 -4
- package/dist/cjs/core/index.cjs +365 -310
- package/dist/cjs/decorator/index.cjs +85 -61
- package/dist/cjs/testing/index.cjs +2236 -496
- package/dist/cli.json +1 -1
- package/dist/esm/bin/app-template/README.md.template +3 -1
- package/dist/esm/bin/app-template/src/controllers/button/sample.button.controller.spec.ts.template +8 -4
- package/dist/esm/bin/app-template/src/controllers/button/sample.button.controller.ts.template +2 -2
- package/dist/esm/bin/app-template/src/guards/owner.guard.spec.ts.template +8 -4
- package/dist/esm/bin/app-template/src/guards/owner.guard.ts.template +1 -1
- package/dist/esm/bin/app-template/vitest.config.ts.template +1 -1
- package/dist/esm/bin/app-template/vitest.setup.ts.template +7 -3
- package/dist/esm/bin/generator.js +4 -5
- package/dist/esm/bin/helper/filter-generator.helper.js +4 -5
- package/dist/esm/bin/helper/guard-generator.helper.js +4 -5
- package/dist/esm/bin/helper/interceptor-generator.helper.js +4 -5
- package/dist/esm/bin/helper/observer-generator.helper.js +4 -5
- package/dist/esm/bin/helper/pipe-generator.helper.js +4 -5
- package/dist/esm/bin/helper/service-generator.helper.js +4 -5
- package/dist/esm/bin/meocord.js +84 -25
- package/dist/esm/build/load-config.pre-entry.js +17 -4
- package/dist/esm/build/rsbuild-config.js +16 -12
- package/dist/esm/build/typed-externals.js +36 -0
- package/dist/esm/common/cooldown-store.js +10 -2
- package/dist/esm/common/decorator.js +6 -6
- package/dist/esm/common/deprecation.js +2 -2
- package/dist/esm/common/errors.js +27 -2
- package/dist/esm/common/execution-context.js +1 -1
- package/dist/esm/common/meocord-text.js +11 -8
- package/dist/esm/common/metadata.js +1 -1
- package/dist/esm/common/response/call-order.js +9 -4
- package/dist/esm/common/response/presenter.js +33 -6
- package/dist/esm/common/response/response-state.js +84 -10
- package/dist/esm/common/route.js +21 -5
- package/dist/esm/common/sharded-cooldown-store.js +1 -1
- package/dist/esm/common/translate-error.js +1 -1
- package/dist/esm/common/translator.js +37 -6
- package/dist/esm/core/command-conflicts.js +34 -34
- package/dist/esm/core/command-registration.js +93 -24
- package/dist/esm/core/command-routes.js +2 -2
- package/dist/esm/core/component-routes.js +74 -31
- package/dist/esm/core/cooldown-runner.js +29 -8
- package/dist/esm/core/dispatcher.js +51 -7
- package/dist/esm/core/fallback.js +20 -11
- package/dist/esm/core/filter-runner.js +2 -2
- package/dist/esm/core/guard-runner.js +16 -3
- package/dist/esm/core/handler-input.js +4 -3
- package/dist/esm/core/handler-pipeline.js +27 -14
- package/dist/esm/core/handler-registry.js +1 -1
- package/dist/esm/core/interceptor-runner.js +2 -2
- package/dist/esm/core/lifecycle-hooks.js +134 -47
- package/dist/esm/core/meocord-factory.js +241 -179
- package/dist/esm/core/meocord.app.js +116 -75
- package/dist/esm/core/message-routes.js +4 -3
- package/dist/esm/core/observer-runner.js +4 -3
- package/dist/esm/core/providers.js +45 -23
- package/dist/esm/core/route-specificity.js +4 -4
- package/dist/esm/core/running-calls.js +43 -0
- package/dist/esm/core/scalar-types.js +15 -3
- package/dist/esm/core/shard-context.js +6 -7
- package/dist/esm/core/shard-manager.js +16 -10
- package/dist/esm/core/startup-roots.js +23 -4
- package/dist/esm/core/theme-resolvers.js +4 -1
- package/dist/esm/core/theme-runtime.js +2 -2
- package/dist/esm/core/theme-scope.js +3 -1
- package/dist/esm/core/theme-validation.js +17 -10
- package/dist/esm/decorator/app.decorator.js +12 -7
- package/dist/esm/decorator/command-builder.decorator.js +4 -4
- package/dist/esm/decorator/controller-class.decorator.js +9 -4
- package/dist/esm/decorator/controller.decorator.js +162 -83
- package/dist/esm/decorator/cooldown.decorator.js +20 -3
- package/dist/esm/decorator/defer.decorator.js +4 -4
- package/dist/esm/decorator/event.decorator.js +5 -5
- package/dist/esm/decorator/filter.decorator.js +7 -7
- package/dist/esm/decorator/guard.decorator.js +11 -10
- package/dist/esm/decorator/inject.decorator.js +4 -4
- package/dist/esm/decorator/interceptor.decorator.js +7 -7
- package/dist/esm/decorator/observer.decorator.js +4 -4
- package/dist/esm/decorator/service.decorator.js +4 -4
- package/dist/esm/decorator/theme.decorator.js +3 -3
- package/dist/esm/decorator/validation.decorator.js +10 -10
- package/dist/esm/package.json.js +1 -1
- package/dist/esm/testing/catalog.js +27 -11
- package/dist/esm/testing/cooldown-store-suite.js +85 -15
- package/dist/esm/testing/discord-defaults.js +249 -21
- package/dist/esm/testing/index.js +4 -2
- package/dist/esm/testing/inspect-handler.js +20 -3
- package/dist/esm/testing/meocord-testing-module.js +352 -205
- package/dist/esm/testing/mock-fn.js +173 -22
- package/dist/esm/testing/mock-interaction.js +1214 -209
- package/dist/esm/testing/response.js +1 -1
- package/dist/esm/testing/routing.js +39 -14
- package/dist/esm/testing/snowflake.js +9 -2
- package/dist/esm/testing/startup-errors.js +26 -0
- package/dist/esm/testing/strict-mocks.js +65 -0
- package/dist/esm/testing/theme.js +1 -1
- package/dist/esm/util/bundle-entry.util.js +2 -1
- package/dist/esm/util/common.util.js +47 -19
- package/dist/esm/util/dev-runner.util.js +8 -8
- package/dist/esm/util/generator-cli.util.js +4 -5
- package/dist/esm/util/interaction.util.js +3 -3
- package/dist/esm/util/meocord-config-loader.util.js +10 -2
- package/dist/esm/util/meocord-config-validation.util.js +4 -5
- package/dist/esm/util/meocord-source-config.util.js +65 -20
- package/dist/esm/util/metadata-keys.js +1 -0
- package/dist/esm/util/method-wrapper.util.js +16 -0
- package/dist/esm/util/platform.util.js +10 -10
- package/dist/esm/util/refusal.util.js +148 -8
- package/dist/esm/util/shard-process.util.js +4 -1
- package/dist/esm/util/sharding-mode.util.js +5 -7
- package/dist/esm/util/shared-state.util.js +21 -0
- package/dist/esm/util/tsconfig-paths.util.js +29 -0
- package/dist/esm/util/tsconfig.util.js +10 -10
- package/dist/esm/util/value.util.js +12 -1
- package/dist/types/common/index.d.cts +8 -8
- package/dist/types/common/index.d.ts +8 -8
- package/dist/types/core/index.d.cts +10 -7
- package/dist/types/core/index.d.ts +10 -7
- package/dist/types/decorator/index.d.cts +31 -29
- package/dist/types/decorator/index.d.ts +31 -29
- package/dist/types/{index-D7n0FBMm.d.ts → index-DKJclIi-.d.ts} +107 -36
- package/dist/types/{index-DEP0NYrU.d.cts → index-Dlf0ZZtS.d.cts} +107 -36
- package/dist/types/interface/index.d.cts +1 -1
- package/dist/types/interface/index.d.ts +1 -1
- package/dist/types/{route-C-aMI7Sl.d.cts → route-Bh1U13yy.d.cts} +8 -4
- package/dist/types/{route-C-aMI7Sl.d.ts → route-Bh1U13yy.d.ts} +8 -4
- package/dist/types/testing/index.d.cts +406 -70
- package/dist/types/testing/index.d.ts +406 -70
- package/dist/types/{theme-resolvers-COHdX5g6.d.cts → theme-resolvers-CyEz9pnv.d.cts} +1 -1
- package/dist/types/{theme-resolvers-DedxyscT.d.ts → theme-resolvers-DA204O-q.d.ts} +1 -1
- package/dist/types/{translator-J6tIUfdp.d.ts → translator-Bg7hwxDF.d.cts} +21 -8
- package/dist/types/{translator-B4mc1lzJ.d.cts → translator-CeP4bCzp.d.ts} +21 -8
- package/package.json +4 -3
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import 'reflect-metadata';
|
|
2
|
-
import { createMockFn } from './mock-fn.js';
|
|
3
|
-
import { DataManager, RoleManager, Role, GuildChannel, GuildMember, User, BaseGuild, MessageMentions,
|
|
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, 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 { warnPlaceholder } from '../common/deprecation.js';
|
|
8
|
+
import { warnOnce, warnPlaceholder } 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 { strictMocks, noteMockMade } from './strict-mocks.js';
|
|
12
|
+
import { sharedName } from '../util/shared-state.util.js';
|
|
11
13
|
|
|
12
14
|
// ---------------------------------------------------------------------------
|
|
13
15
|
// stubDeep — Proxy that answers each property it lacks: a mock fn for a method, a default, or a nested stub
|
|
@@ -19,8 +21,15 @@ const SKIP = new Set([
|
|
|
19
21
|
'toJSON',
|
|
20
22
|
'then'
|
|
21
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();
|
|
22
28
|
function stubDeep(instance, externalStubs, onSet) {
|
|
29
|
+
noteMockMade();
|
|
23
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();
|
|
24
33
|
const proxy = new Proxy(instance, {
|
|
25
34
|
get (target, prop) {
|
|
26
35
|
// Always pass through symbols
|
|
@@ -34,12 +43,17 @@ function stubDeep(instance, externalStubs, onSet) {
|
|
|
34
43
|
if (Object.prototype.hasOwnProperty.call(target, key)) {
|
|
35
44
|
return Reflect.get(target, prop, target);
|
|
36
45
|
}
|
|
46
|
+
stubReadWarnings.get(target)?.get(key)?.();
|
|
37
47
|
// Skip passthrough props — return prototype value as-is
|
|
38
48
|
if (SKIP.has(key)) {
|
|
39
49
|
return Reflect.get(target, prop, target);
|
|
40
50
|
}
|
|
41
51
|
// Return cached stub
|
|
42
|
-
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
|
+
}
|
|
43
57
|
// A value discord.js always gives, in its shape, rather than a stub: kept once read, or its getter run live
|
|
44
58
|
const known = discordDefault(target, key);
|
|
45
59
|
if (known === REAL_GETTER) {
|
|
@@ -53,6 +67,12 @@ function stubDeep(instance, externalStubs, onSet) {
|
|
|
53
67
|
stubs.set(key, value);
|
|
54
68
|
return value;
|
|
55
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
|
+
}
|
|
56
76
|
// Walk the prototype chain to check if it's a function
|
|
57
77
|
let proto = Object.getPrototypeOf(target);
|
|
58
78
|
let protoValue;
|
|
@@ -60,14 +80,18 @@ function stubDeep(instance, externalStubs, onSet) {
|
|
|
60
80
|
const desc = Object.getOwnPropertyDescriptor(proto, key);
|
|
61
81
|
if (desc !== undefined) {
|
|
62
82
|
protoValue = desc.value;
|
|
63
|
-
if (desc.get &&
|
|
83
|
+
if (desc.get && isPlaceholder(proto, key)) {
|
|
84
|
+
// Strict mocks run discord.js's getter, read live; otherwise the placeholder, with a warning once
|
|
85
|
+
if (strictMocks()) return Reflect.get(target, prop, proxy);
|
|
86
|
+
warnPlaceholderRead(target, key, 'false');
|
|
87
|
+
}
|
|
64
88
|
break;
|
|
65
89
|
}
|
|
66
90
|
proto = Object.getPrototypeOf(proto);
|
|
67
91
|
}
|
|
68
92
|
// A client the mock's structures share: its guild's, or one of its own
|
|
69
93
|
if (key === 'client' && target instanceof Base) return clientOf(target);
|
|
70
|
-
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({});
|
|
71
95
|
stubs.set(key, stub);
|
|
72
96
|
return stub;
|
|
73
97
|
},
|
|
@@ -76,7 +100,10 @@ function stubDeep(instance, externalStubs, onSet) {
|
|
|
76
100
|
// and a plain write against one of those throws. Defining an own
|
|
77
101
|
// data property shadows the accessor, which is what test setup means.
|
|
78
102
|
set (target, prop, value) {
|
|
79
|
-
|
|
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, {
|
|
80
107
|
value,
|
|
81
108
|
writable: true,
|
|
82
109
|
enumerable: true,
|
|
@@ -86,6 +113,7 @@ function stubDeep(instance, externalStubs, onSet) {
|
|
|
86
113
|
return true;
|
|
87
114
|
}
|
|
88
115
|
});
|
|
116
|
+
mockTargets.set(proxy, instance);
|
|
89
117
|
return proxy;
|
|
90
118
|
}
|
|
91
119
|
// discord.js methods that read only what the mock holds, so they run for real: looking items up in a manager's cache,
|
|
@@ -150,9 +178,18 @@ const REAL_METHODS = new Map([
|
|
|
150
178
|
])
|
|
151
179
|
]
|
|
152
180
|
]);
|
|
181
|
+
// What strict mocks also run for real: a thread's permissions are its parent channel's, as discord.js reads them
|
|
182
|
+
const STRICT_REAL_METHODS = new Map([
|
|
183
|
+
[
|
|
184
|
+
ThreadChannel.prototype,
|
|
185
|
+
new Set([
|
|
186
|
+
'permissionsFor'
|
|
187
|
+
])
|
|
188
|
+
]
|
|
189
|
+
]);
|
|
153
190
|
function isRealMethod(target, key) {
|
|
154
191
|
for(let proto = Object.getPrototypeOf(target); proto !== null; proto = Object.getPrototypeOf(proto)){
|
|
155
|
-
if (REAL_METHODS.get(proto)?.has(key)) return true;
|
|
192
|
+
if (REAL_METHODS.get(proto)?.has(key) || strictMocks() && STRICT_REAL_METHODS.get(proto)?.has(key)) return true;
|
|
156
193
|
}
|
|
157
194
|
return false;
|
|
158
195
|
}
|
|
@@ -209,7 +246,14 @@ const PLACEHOLDER_BOOLEANS = new Map([
|
|
|
209
246
|
[
|
|
210
247
|
BaseGuildVoiceChannel.prototype,
|
|
211
248
|
new Set([
|
|
212
|
-
'joinable'
|
|
249
|
+
'joinable',
|
|
250
|
+
'full'
|
|
251
|
+
])
|
|
252
|
+
],
|
|
253
|
+
[
|
|
254
|
+
VoiceChannel.prototype,
|
|
255
|
+
new Set([
|
|
256
|
+
'speakable'
|
|
213
257
|
])
|
|
214
258
|
],
|
|
215
259
|
[
|
|
@@ -231,11 +275,49 @@ const PLACEHOLDER_BOOLEANS = new Map([
|
|
|
231
275
|
])
|
|
232
276
|
]
|
|
233
277
|
]);
|
|
278
|
+
/** Whether `proto`'s getter for `key` is one of those, or a subclass's override of one, such as a voice channel's `joinable`. */ function isPlaceholder(proto, key) {
|
|
279
|
+
for(; proto !== null; proto = Object.getPrototypeOf(proto)){
|
|
280
|
+
if (PLACEHOLDER_BOOLEANS.get(proto)?.has(key)) return true;
|
|
281
|
+
}
|
|
282
|
+
return false;
|
|
283
|
+
}
|
|
234
284
|
const mockLogger = new Logger('Mocks');
|
|
235
|
-
|
|
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;
|
|
236
312
|
const name = Object.getPrototypeOf(target).constructor.name;
|
|
237
|
-
|
|
238
|
-
|
|
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
|
+
}
|
|
318
|
+
// The class name in camel case, a leading acronym included: dmChannel, guildMember
|
|
319
|
+
const variable = name.replace(/^[A-Z]+(?=[A-Z][a-z])|^[A-Z]/, (start)=>start.toLowerCase());
|
|
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.`);
|
|
239
321
|
}
|
|
240
322
|
// The client of a structure made without one: the one its guild has, else one of its own, made once
|
|
241
323
|
const ownClients = new WeakMap();
|
|
@@ -343,25 +425,37 @@ const MANAGER_ITEMS = [
|
|
|
343
425
|
],
|
|
344
426
|
[
|
|
345
427
|
RoleManager,
|
|
346
|
-
(id)=>createMockInteraction(Role,
|
|
347
|
-
id
|
|
348
|
-
|
|
428
|
+
(id, manager)=>createMockInteraction(Role, {
|
|
429
|
+
...id ? {
|
|
430
|
+
id
|
|
431
|
+
} : {},
|
|
432
|
+
...guildOf(manager) ? {
|
|
433
|
+
guild: guildOf(manager)
|
|
434
|
+
} : {}
|
|
435
|
+
})
|
|
349
436
|
],
|
|
350
437
|
[
|
|
351
438
|
GuildBanManager,
|
|
352
439
|
()=>createMockInteraction(GuildBan)
|
|
353
440
|
],
|
|
441
|
+
// A message fetched from a channel is in it, and another user's
|
|
354
442
|
[
|
|
355
443
|
GuildMessageManager,
|
|
356
|
-
(id)=>createMockMessage(
|
|
357
|
-
id
|
|
358
|
-
|
|
444
|
+
(id, manager)=>createMockMessage({
|
|
445
|
+
...id ? {
|
|
446
|
+
id
|
|
447
|
+
} : {},
|
|
448
|
+
...channelOfManager(manager)
|
|
449
|
+
})
|
|
359
450
|
],
|
|
360
451
|
[
|
|
361
452
|
DMMessageManager,
|
|
362
|
-
(id)=>createMockMessage(
|
|
363
|
-
id
|
|
364
|
-
|
|
453
|
+
(id, manager)=>createMockMessage({
|
|
454
|
+
...id ? {
|
|
455
|
+
id
|
|
456
|
+
} : {},
|
|
457
|
+
...channelOfManager(manager)
|
|
458
|
+
})
|
|
365
459
|
],
|
|
366
460
|
[
|
|
367
461
|
GuildTextThreadManager,
|
|
@@ -379,6 +473,12 @@ const MANAGER_ITEMS = [
|
|
|
379
473
|
ThreadMemberManager,
|
|
380
474
|
()=>createMockInteraction(ThreadMember)
|
|
381
475
|
],
|
|
476
|
+
[
|
|
477
|
+
ReactionUserManager,
|
|
478
|
+
(id)=>createMockUser(id ? {
|
|
479
|
+
id
|
|
480
|
+
} : {})
|
|
481
|
+
],
|
|
382
482
|
[
|
|
383
483
|
ApplicationCommandManager,
|
|
384
484
|
(id)=>createMockInteraction(ApplicationCommand, id ? {
|
|
@@ -406,6 +506,12 @@ const MANAGER_ITEMS = [
|
|
|
406
506
|
const returnsPromise = (key, method)=>method.constructor.name === 'AsyncFunction' || PROMISE_METHODS.has(key) || /^set[A-Z]/.test(key) && !SYNC_SETTERS.has(key);
|
|
407
507
|
// The ids a fetch asks for: an id, a discord.js object, or options naming one or several, such as `{ user: id }` or
|
|
408
508
|
// `{ user: [ids] }`; undefined for a fetch of a list
|
|
509
|
+
/** The channel a message manager belongs to, as `createMockMessage` takes it. */ const channelOfManager = (manager)=>{
|
|
510
|
+
const channel = manager.channel;
|
|
511
|
+
return channel instanceof BaseChannel ? {
|
|
512
|
+
channel: channel
|
|
513
|
+
} : {};
|
|
514
|
+
};
|
|
409
515
|
function fetchedIds(args) {
|
|
410
516
|
const idOf = (value)=>typeof value === 'string' ? value : value instanceof Base ? value.id : undefined;
|
|
411
517
|
const [first] = args;
|
|
@@ -441,6 +547,10 @@ function fetchedIds(args) {
|
|
|
441
547
|
if (key === 'send' && target instanceof User) {
|
|
442
548
|
return createMockFn(async (...args)=>(await receiver().createDM()).send(...args));
|
|
443
549
|
}
|
|
550
|
+
// A channel's send resolves to the message it sent there
|
|
551
|
+
if (key === 'send' && target instanceof BaseChannel) {
|
|
552
|
+
return createMockFn(async (options)=>sentMessage(sent(options, false), receiver()));
|
|
553
|
+
}
|
|
444
554
|
if (MESSAGE_METHODS.has(key)) return createMockFn(async ()=>createMockMessage());
|
|
445
555
|
if (target instanceof BaseManager) {
|
|
446
556
|
const item = MANAGER_ITEMS.find(([Manager])=>Manager.prototype.isPrototypeOf(target))?.[1];
|
|
@@ -457,7 +567,24 @@ function fetchedIds(args) {
|
|
|
457
567
|
return ids === undefined ? new Collection() : one(ids);
|
|
458
568
|
});
|
|
459
569
|
}
|
|
460
|
-
if (item &&
|
|
570
|
+
if (item && key === 'edit') return createMockFn(async ()=>item(undefined, receiver()));
|
|
571
|
+
// A ban resolves to the member, user or id it was given, as discord.js's does, and caches nothing
|
|
572
|
+
if (key === 'create' && target instanceof GuildBanManager) {
|
|
573
|
+
return createMockFn(async (user)=>{
|
|
574
|
+
if (user instanceof GuildMember) return user;
|
|
575
|
+
if (user instanceof User) return receiver().guild.members.resolve(user) ?? user;
|
|
576
|
+
return typeof user === 'string' ? user : user?.id;
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
// Every other manager here caches what its create() makes, as discord.js's does; check a manager's create when
|
|
580
|
+
// adding it to MANAGER_ITEMS
|
|
581
|
+
if (item && key === 'create') {
|
|
582
|
+
return createMockFn(async ()=>{
|
|
583
|
+
const made = item(undefined, receiver());
|
|
584
|
+
if (typeof made.id === 'string') cacheOf(receiver())?.set(made.id, made);
|
|
585
|
+
return made;
|
|
586
|
+
});
|
|
587
|
+
}
|
|
461
588
|
} else if (target instanceof Base && (SELF_METHODS.has(key) || /^set[A-Z]/.test(key))) {
|
|
462
589
|
return createMockFn(async ()=>receiver());
|
|
463
590
|
}
|
|
@@ -617,23 +744,46 @@ const isSnowflake = (id)=>typeof id === 'string' && /^\d{1,20}$/.test(id);
|
|
|
617
744
|
});
|
|
618
745
|
}
|
|
619
746
|
}
|
|
620
|
-
/** Where a mock interaction keeps every answer it got, through respond() or discord.js directly, in order. */ const RESPONSE_LOG = Symbol('response log');
|
|
621
|
-
const ANSWER_METHODS = [
|
|
622
|
-
'reply',
|
|
623
|
-
'deferReply',
|
|
624
|
-
'editReply',
|
|
625
|
-
'followUp',
|
|
626
|
-
'deleteReply',
|
|
627
|
-
'update',
|
|
628
|
-
'deferUpdate',
|
|
629
|
-
'showModal'
|
|
630
|
-
];
|
|
631
747
|
/**
|
|
632
|
-
*
|
|
633
|
-
*
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
748
|
+
* Gives a user, server or channel mock its creation time. Under strict mocks, a generated id gives the time the mock
|
|
749
|
+
* 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.
|
|
750
|
+
* An id the test gives decides it in both modes, and a value the test sets replaces it.
|
|
751
|
+
*/ function defineMadeTime(mock, generatedId) {
|
|
752
|
+
if (strictMocks()) return defineCreatedTime(mock, generatedId);
|
|
753
|
+
const own = (key)=>Object.prototype.hasOwnProperty.call(mock, key);
|
|
754
|
+
const name = Object.getPrototypeOf(mock).constructor.name;
|
|
755
|
+
if (!own('createdTimestamp')) {
|
|
756
|
+
Object.defineProperty(mock, 'createdTimestamp', {
|
|
757
|
+
get () {
|
|
758
|
+
if (this.id === generatedId) {
|
|
759
|
+
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.');
|
|
760
|
+
}
|
|
761
|
+
return isSnowflake(this.id) ? SnowflakeUtil.timestampFrom(this.id) : null;
|
|
762
|
+
},
|
|
763
|
+
configurable: true
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
if (!own('createdAt')) {
|
|
767
|
+
Object.defineProperty(mock, 'createdAt', {
|
|
768
|
+
get () {
|
|
769
|
+
return this.createdTimestamp === null ? null : new Date(this.createdTimestamp);
|
|
770
|
+
},
|
|
771
|
+
configurable: true
|
|
772
|
+
});
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
/**
|
|
776
|
+
* Where a mock interaction keeps every answer it got, through respond() or discord.js directly, in order, under a key
|
|
777
|
+
* both builds of this meocord version read.
|
|
778
|
+
*/ const RESPONSE_LOG = Symbol.for(sharedName('responseLog'));
|
|
779
|
+
/**
|
|
780
|
+
* A mock of an answer method, `impl` its behaviour, that records each call in `log` as it is made, with what it sent
|
|
781
|
+
* and, once it settles, what it rejected with. The record wraps every behaviour the mock is given, its own and any a
|
|
782
|
+
* test sets, so the mock stays the plain mock function a runner's matchers read, bun's included, and `gate` keeps the
|
|
783
|
+
* reply state whichever behaviour runs. `withResponse` is how a call asks discord.js for the message back, not part of
|
|
784
|
+
* what it sends, so it is left out.
|
|
785
|
+
*/ function recordedMock(method, impl, log, gate) {
|
|
786
|
+
const record = (behaviour, byTest = true)=>function(...args) {
|
|
637
787
|
const [payload] = args;
|
|
638
788
|
const sent = payload && typeof payload === 'object' && 'withResponse' in payload ? Object.fromEntries(Object.entries(payload).filter(([key])=>key !== 'withResponse')) : payload;
|
|
639
789
|
const call = {
|
|
@@ -642,34 +792,81 @@ const ANSWER_METHODS = [
|
|
|
642
792
|
};
|
|
643
793
|
stampCall(call);
|
|
644
794
|
log.push(call);
|
|
795
|
+
let undo;
|
|
645
796
|
try {
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
if (result instanceof Promise) {
|
|
649
|
-
return result.then(undefined, (error)=>{
|
|
650
|
-
call.error = error;
|
|
651
|
-
throw error;
|
|
652
|
-
});
|
|
653
|
-
}
|
|
654
|
-
return result;
|
|
797
|
+
// Before the behaviour, as discord.js changes the state at once: a second answer made without awaiting is refused
|
|
798
|
+
if (gate) undo = gate(args, byTest);
|
|
655
799
|
} catch (error) {
|
|
800
|
+
call.error = error;
|
|
801
|
+
return Promise.reject(error);
|
|
802
|
+
}
|
|
803
|
+
const failed = (error)=>{
|
|
804
|
+
undo?.();
|
|
656
805
|
call.error = error;
|
|
657
806
|
throw error;
|
|
807
|
+
};
|
|
808
|
+
try {
|
|
809
|
+
const result = Reflect.apply(behaviour, this, args);
|
|
810
|
+
// Recorded and passed on, so a rejection nobody awaits is unhandled in the test, as it is on a bot
|
|
811
|
+
if (result instanceof Promise) return result.then(undefined, failed);
|
|
812
|
+
return result;
|
|
813
|
+
} catch (error) {
|
|
814
|
+
return failed(error);
|
|
658
815
|
}
|
|
659
|
-
}
|
|
660
|
-
|
|
816
|
+
};
|
|
817
|
+
const mock = createMockFn(record(impl, false));
|
|
818
|
+
const always = mock.mockImplementation.bind(mock);
|
|
819
|
+
const once = mock.mockImplementationOnce.bind(mock);
|
|
820
|
+
// Each way a test sets a behaviour goes through the record, a runner's own extras included where it has them
|
|
821
|
+
const setters = {
|
|
822
|
+
mockImplementation: (fn)=>fn,
|
|
823
|
+
mockReturnValue: (value)=>()=>value,
|
|
824
|
+
mockResolvedValue: (value)=>()=>Promise.resolve(value),
|
|
825
|
+
mockRejectedValue: (value)=>()=>Promise.reject(value)
|
|
826
|
+
};
|
|
827
|
+
// In place of the mock's own, keeping whether each is among its keys
|
|
828
|
+
const define = (name, value)=>Object.defineProperty(mock, name, {
|
|
829
|
+
value,
|
|
830
|
+
writable: true,
|
|
831
|
+
enumerable: Object.getOwnPropertyDescriptor(mock, name)?.enumerable ?? false,
|
|
832
|
+
configurable: true
|
|
833
|
+
});
|
|
834
|
+
for (const [name, behaviourOf] of Object.entries(setters)){
|
|
835
|
+
define(name, (value)=>(always(record(behaviourOf(value))), mock));
|
|
836
|
+
define(`${name}Once`, (value)=>(once(record(behaviourOf(value))), mock));
|
|
837
|
+
}
|
|
838
|
+
if (typeof mock.mockReturnThis === 'function') {
|
|
839
|
+
define('mockReturnThis', ()=>(always(record(function() {
|
|
840
|
+
return this;
|
|
841
|
+
})), mock));
|
|
842
|
+
}
|
|
843
|
+
const withImplementation = mock.withImplementation;
|
|
844
|
+
if (typeof withImplementation === 'function') {
|
|
845
|
+
define('withImplementation', (fn, callback)=>withImplementation.call(mock, record(fn), callback));
|
|
846
|
+
}
|
|
847
|
+
return mock;
|
|
661
848
|
}
|
|
662
849
|
/** The interaction a mock options resolver was given to, for the server a user option's member is in. */ const optionOwners = new WeakMap();
|
|
850
|
+
/** 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) {
|
|
851
|
+
const error = new DiscordjsError(code);
|
|
852
|
+
if (message !== undefined) error.message = message;
|
|
853
|
+
return error;
|
|
854
|
+
}
|
|
663
855
|
/** 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);
|
|
664
856
|
// In discord.js's error codes, though missing from its typings
|
|
665
857
|
const INVALID_CHANNEL_TYPE = 'CommandInteractionOptionInvalidChannelType';
|
|
666
858
|
/** Records the interaction a mock options resolver belongs to. */ const ownOptions = (interaction, options)=>{
|
|
667
859
|
if (typeof options === 'object' && options !== null) optionOwners.set(options, interaction);
|
|
668
860
|
};
|
|
669
|
-
/** A mock user that is a person, with an id of its own unless given one. */ const mockUser = (id
|
|
670
|
-
|
|
861
|
+
/** A mock user that is a person, with an id of its own unless given one. */ const mockUser = (id)=>{
|
|
862
|
+
const generatedId = id === undefined ? nextSnowflake() : undefined;
|
|
863
|
+
const user = Object.assign(Object.create(User.prototype), {
|
|
864
|
+
id: id ?? generatedId,
|
|
671
865
|
bot: false
|
|
672
|
-
})
|
|
866
|
+
});
|
|
867
|
+
defineMadeTime(user, generatedId);
|
|
868
|
+
return stubDeep(user);
|
|
869
|
+
};
|
|
673
870
|
function createMockInteraction(Class, props) {
|
|
674
871
|
const instance = Object.create(Class.prototype);
|
|
675
872
|
const stubs = new Map();
|
|
@@ -689,14 +886,36 @@ function createMockInteraction(Class, props) {
|
|
|
689
886
|
// mock lacks: a guildId is a guild, a guild object with it a cached one, neither a DM. A member
|
|
690
887
|
// not given is the user's on an interaction, and the auto-stub elsewhere, so only one set to null or undefined fails.
|
|
691
888
|
const own = (key)=>Object.prototype.hasOwnProperty.call(instance, key) ? instance[key] : undefined;
|
|
889
|
+
let strictPlace;
|
|
692
890
|
const hasMember = ()=>!Object.prototype.hasOwnProperty.call(instance, 'member') || Boolean(instance.member);
|
|
693
891
|
const guildChecks = {
|
|
694
892
|
inGuild: ()=>Boolean(own('guildId') && hasMember()),
|
|
695
893
|
inCachedGuild: ()=>Boolean(own('guildId') && own('guild') && hasMember()),
|
|
696
894
|
inRawGuild: ()=>Boolean(own('guildId') && !own('guild') && hasMember())
|
|
697
895
|
};
|
|
896
|
+
// What each answers where strict mocks place the mock: the place's guildId and guild for those the test left out, and
|
|
897
|
+
// a member wherever there is a guildId, unless the test gave one
|
|
898
|
+
const given = new Set(Object.keys(props ?? {}));
|
|
899
|
+
const strictChecks = (place)=>{
|
|
900
|
+
const pick = (key, placed)=>given.has(key) || placed === undefined ? own(key) : placed;
|
|
901
|
+
const guildId = pick('guildId', place.guildId);
|
|
902
|
+
const guild = place.raw ? null : pick('guild', place.guild);
|
|
903
|
+
const member = given.has('member') ? Boolean(own('member')) : Boolean(guildId);
|
|
904
|
+
return {
|
|
905
|
+
inGuild: Boolean(guildId && member),
|
|
906
|
+
inCachedGuild: Boolean(guildId && guild && member),
|
|
907
|
+
inRawGuild: Boolean(guildId && !guild && member)
|
|
908
|
+
};
|
|
909
|
+
};
|
|
910
|
+
// Each reads where the mock is as its own, and in default mode warns once, by its own name, where strict mocks answer
|
|
911
|
+
// otherwise or refuse the mock
|
|
698
912
|
for (const [name, check] of Object.entries(guildChecks)){
|
|
699
|
-
if (findPrototypeMethod(instance, name)
|
|
913
|
+
if (findPrototypeMethod(instance, name) === null) continue;
|
|
914
|
+
stubs.set(name, createMockFn(()=>{
|
|
915
|
+
const answer = readPlaceQuietly(check);
|
|
916
|
+
if (strictPlace && !strictMocks() && (strictPlace.refused || strictChecks(strictPlace)[name] !== answer)) placeWarners.get(instance)?.warn(`${name}()`);
|
|
917
|
+
return answer;
|
|
918
|
+
}));
|
|
700
919
|
}
|
|
701
920
|
// Set up reply state machine for repliable interactions
|
|
702
921
|
const isRepliableMethod = findPrototypeMethod(instance, 'isRepliable');
|
|
@@ -705,86 +924,204 @@ function createMockInteraction(Class, props) {
|
|
|
705
924
|
instance.replied = false;
|
|
706
925
|
instance.deferred = false;
|
|
707
926
|
instance.ephemeral = false;
|
|
708
|
-
|
|
709
|
-
const
|
|
927
|
+
// discord.js's errors, with their codes; a call before any answer keeps the message the mock has always given
|
|
928
|
+
const alreadyReplied = ()=>discordjsError(DiscordjsErrorCodes.InteractionAlreadyReplied);
|
|
929
|
+
const notYetReplied = (method)=>discordjsError(DiscordjsErrorCodes.InteractionNotReplied, `Cannot call ${method}() before replying or deferring.`);
|
|
710
930
|
// Only `flags` is read: discord.js deprecates the `ephemeral: true` reply option, and honouring it here would let
|
|
711
|
-
// a test pass against a deprecated call.
|
|
931
|
+
// a test pass against a deprecated call. Read as discord.js reads them, so a flag it refuses throws here too.
|
|
712
932
|
const hasEphemeralFlag = (options)=>{
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
if (typeof flags === 'bigint') return (flags & 64n) !== 0n;
|
|
717
|
-
return false;
|
|
933
|
+
const flags = options?.flags;
|
|
934
|
+
if (typeof options !== 'object' || flags === null || flags === undefined) return false;
|
|
935
|
+
return new MessageFlagsBitField(flags).has(MessageFlags.Ephemeral);
|
|
718
936
|
};
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
if (instance.deferred || instance.replied) throw alreadyReplied();
|
|
726
|
-
instance.deferred = true;
|
|
727
|
-
if (hasEphemeralFlag(args[0])) instance.ephemeral = true;
|
|
728
|
-
}));
|
|
729
|
-
stubs.set('followUp', createMockFn(async ()=>{
|
|
730
|
-
if (!instance.deferred && !instance.replied) throw notYetReplied('followUp');
|
|
731
|
-
instance.replied = true;
|
|
732
|
-
return createMockMessage();
|
|
733
|
-
}));
|
|
734
|
-
// The interaction's own message as Discord holds it: the message a component is on, or the original response.
|
|
735
|
-
// An edit replaces it in the shape Discord stores, with ids and resolved media, stamped with when it was edited,
|
|
736
|
-
// and fetchReply() reads that back, so code comparing the two sees what it would against Discord.
|
|
737
|
-
let held;
|
|
738
|
-
const current = ()=>held ?? (held = heldFrom(instance.message));
|
|
937
|
+
const built = (held)=>typeof held === 'function' ? held() : held;
|
|
938
|
+
let original;
|
|
939
|
+
let originalGone = false;
|
|
940
|
+
const followUps = new Map();
|
|
941
|
+
// Whether the last answer ran a behaviour a test set, after which a second answer runs with a warning by default
|
|
942
|
+
let answeredByTest = false;
|
|
739
943
|
// A command answered with a modal has no original response, and Discord refuses one asked for
|
|
740
944
|
let modalAnswered = false;
|
|
945
|
+
const isComponent = instance.type === InteractionType.MessageComponent;
|
|
946
|
+
// The original response of a component, or of a modal submitted from one, is the message it's on
|
|
947
|
+
const componentMessage = ()=>heldFrom(instance.message);
|
|
948
|
+
const snapshot = ()=>{
|
|
949
|
+
const saved = {
|
|
950
|
+
replied: instance.replied,
|
|
951
|
+
deferred: instance.deferred,
|
|
952
|
+
ephemeral: instance.ephemeral,
|
|
953
|
+
original,
|
|
954
|
+
originalGone,
|
|
955
|
+
followUps: new Map(followUps),
|
|
956
|
+
answeredByTest,
|
|
957
|
+
modalAnswered
|
|
958
|
+
};
|
|
959
|
+
return ()=>{
|
|
960
|
+
Object.assign(instance, {
|
|
961
|
+
replied: saved.replied,
|
|
962
|
+
deferred: saved.deferred,
|
|
963
|
+
ephemeral: saved.ephemeral
|
|
964
|
+
});
|
|
965
|
+
({ original, originalGone, answeredByTest, modalAnswered } = saved);
|
|
966
|
+
followUps.clear();
|
|
967
|
+
for (const [id, held] of saved.followUps)followUps.set(id, held);
|
|
968
|
+
};
|
|
969
|
+
};
|
|
970
|
+
/**
|
|
971
|
+
* Refuses an answer as discord.js does. Where the refusal turns on a behaviour a test set, this answer's or the
|
|
972
|
+
* one before it, which ran without one, default mode warns and lets it run; strict mocks refuse it.
|
|
973
|
+
*/ const refuse = (method, error, byTest)=>{
|
|
974
|
+
if (!(byTest || answeredByTest) || strictMocks()) throw error;
|
|
975
|
+
warnOnce(mockLogger, `${method}() here is refused by discord.js with "${error.message}", but the mock runs it, as a test set this ` + 'answer or the one before it. In the next major version (5.0) the mock refuses it; call useStrictMocks() to ' + 'have it refused now.');
|
|
976
|
+
};
|
|
977
|
+
const answered = ()=>instance.deferred || instance.replied;
|
|
978
|
+
/**
|
|
979
|
+
* What an answer sends, built now, as discord.js builds it when sending. One it refuses to build is refused under
|
|
980
|
+
* strict mocks; default mode warns and holds it to build when first read.
|
|
981
|
+
*/ const hold = (method, build)=>{
|
|
982
|
+
try {
|
|
983
|
+
return build();
|
|
984
|
+
} catch (error) {
|
|
985
|
+
if (strictMocks()) throw error;
|
|
986
|
+
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.');
|
|
987
|
+
return build;
|
|
988
|
+
}
|
|
989
|
+
};
|
|
990
|
+
/** A first answer: refused once the interaction has one, else it replies or defers. */ const first = (method, kind, then)=>(args, byTest)=>{
|
|
991
|
+
if (answered()) refuse(method, alreadyReplied(), byTest);
|
|
992
|
+
// Read first: flags discord.js refuses throw before anything changes
|
|
993
|
+
const ephemeral = method === 'reply' || method === 'deferReply' ? hasEphemeralFlag(args[0]) : undefined;
|
|
994
|
+
const undo = snapshot();
|
|
995
|
+
instance[kind] = true;
|
|
996
|
+
if (ephemeral) instance.ephemeral = true;
|
|
997
|
+
answeredByTest = byTest;
|
|
998
|
+
try {
|
|
999
|
+
then(args);
|
|
1000
|
+
} catch (error) {
|
|
1001
|
+
undo();
|
|
1002
|
+
throw error;
|
|
1003
|
+
}
|
|
1004
|
+
return undo;
|
|
1005
|
+
};
|
|
1006
|
+
/** A later answer: refused before the interaction has one. */ const later = (method, then)=>(args, byTest)=>{
|
|
1007
|
+
if (!answered()) refuse(method, notYetReplied(method), byTest);
|
|
1008
|
+
const undo = snapshot();
|
|
1009
|
+
try {
|
|
1010
|
+
then(args);
|
|
1011
|
+
} catch (error) {
|
|
1012
|
+
undo();
|
|
1013
|
+
throw error;
|
|
1014
|
+
}
|
|
1015
|
+
return undo;
|
|
1016
|
+
};
|
|
1017
|
+
/** The id an edit, fetch or delete names, `undefined` for the original response. */ const targetId = (message)=>{
|
|
1018
|
+
if (message === undefined || message === '@original') return undefined;
|
|
1019
|
+
return typeof message === 'string' ? message : message.id ?? undefined;
|
|
1020
|
+
};
|
|
1021
|
+
/** The message an edit, fetch or delete reaches, or why Discord refuses it: it is gone or never was. */ const target = (id)=>{
|
|
1022
|
+
if (id !== undefined) {
|
|
1023
|
+
const held = followUps.get(id);
|
|
1024
|
+
if (held) followUps.set(id, built(held));
|
|
1025
|
+
return held && built(held);
|
|
1026
|
+
}
|
|
1027
|
+
if (originalGone || modalAnswered) return undefined;
|
|
1028
|
+
// Answered without one of the mock's calls, as after a 40060 or a test setting `replied`: the original is there
|
|
1029
|
+
if (!original && answered()) original = componentMessage();
|
|
1030
|
+
if (original) original = built(original);
|
|
1031
|
+
// An answer's message gets its id when first read, as it would be built then without strict mocks
|
|
1032
|
+
if (original?.id === '') original = {
|
|
1033
|
+
...original,
|
|
1034
|
+
id: nextSnowflake()
|
|
1035
|
+
};
|
|
1036
|
+
return original;
|
|
1037
|
+
};
|
|
741
1038
|
const unknownMessage = ()=>createDiscordError(10008, 'Unknown Message');
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
1039
|
+
// Where its answers are sent, read as the mock's own, so a placement warning stays the test's
|
|
1040
|
+
const answeredIn = ()=>readPlaceQuietly(()=>[
|
|
1041
|
+
instance.channel,
|
|
1042
|
+
instance.client
|
|
1043
|
+
]);
|
|
1044
|
+
// What each answer's message made for itself, by its id, so every read of it has the same author and place
|
|
1045
|
+
const madePlaces = new Map();
|
|
1046
|
+
const madeFor = (id)=>madePlaces.get(id) ?? madePlaces.set(id, {}).get(id);
|
|
1047
|
+
// Each call's message, read by its behaviour right after the gate made it
|
|
1048
|
+
let made;
|
|
1049
|
+
let followUpMade;
|
|
1050
|
+
// Every answer the interaction gets, through respond() or discord.js directly, in order, for getResponse
|
|
1051
|
+
const log = [];
|
|
1052
|
+
Object.defineProperty(instance, RESPONSE_LOG, {
|
|
1053
|
+
value: log
|
|
1054
|
+
});
|
|
1055
|
+
const answer = (method, impl, gate)=>stubs.set(method, recordedMock(method, impl, log, gate));
|
|
1056
|
+
answer('reply', async ()=>undefined, first('reply', 'replied', ([options])=>original = hold('reply', ()=>sent(options, false))));
|
|
1057
|
+
answer('deferReply', async ()=>undefined, first('deferReply', 'deferred', ()=>original = sent(undefined)));
|
|
1058
|
+
answer('followUp', // The follow-up as sent, which an edit, fetch or delete reaches by its id
|
|
1059
|
+
async ()=>{
|
|
1060
|
+
const held = followUps.get(followUpMade);
|
|
1061
|
+
// One held to build when read resolves to its id alone, as building it now would refuse it
|
|
1062
|
+
return typeof held === 'function' ? createMockMessage({
|
|
1063
|
+
id: followUpMade
|
|
1064
|
+
}) : messageFrom(held, madeFor(held.id), ...answeredIn());
|
|
1065
|
+
}, later('followUp', ([options])=>{
|
|
745
1066
|
instance.replied = true;
|
|
746
|
-
|
|
747
|
-
//
|
|
748
|
-
|
|
1067
|
+
// Held as it is when sent, as Discord stores it, so a later change to what was passed isn't seen; its id taken
|
|
1068
|
+
// once built, so one that fails to build under strict mocks takes none
|
|
1069
|
+
const held = hold('followUp', ()=>sent(options, false));
|
|
1070
|
+
const id = nextSnowflake();
|
|
1071
|
+
made = undefined;
|
|
1072
|
+
followUps.set(id, typeof held === 'function' ? ()=>({
|
|
1073
|
+
...held(),
|
|
1074
|
+
id
|
|
1075
|
+
}) : {
|
|
749
1076
|
...held,
|
|
750
|
-
|
|
1077
|
+
id
|
|
751
1078
|
});
|
|
1079
|
+
followUpMade = id;
|
|
1080
|
+
}));
|
|
1081
|
+
// An edit replaces the message in the shape Discord stores, with ids and resolved media, stamped with when it was
|
|
1082
|
+
// edited, and fetchReply() reads that back, so code comparing the two sees what it would against Discord
|
|
1083
|
+
answer('editReply', async ()=>{
|
|
1084
|
+
if (!made) throw unknownMessage();
|
|
1085
|
+
// The response has the message as Discord answers the edit, before its uploaded files have loaded again
|
|
1086
|
+
return messageFrom({
|
|
1087
|
+
...made,
|
|
1088
|
+
components: made.returned ?? made.components
|
|
1089
|
+
}, madeFor(made.id), ...answeredIn());
|
|
1090
|
+
}, later('editReply', ([options])=>{
|
|
1091
|
+
const id = targetId(options?.message);
|
|
1092
|
+
const held = target(id);
|
|
1093
|
+
made = held && edited(held, options);
|
|
1094
|
+
if (!made) return;
|
|
1095
|
+
if (id === undefined) {
|
|
1096
|
+
original = made;
|
|
1097
|
+
instance.replied = true;
|
|
1098
|
+
} else followUps.set(id, made);
|
|
752
1099
|
}));
|
|
753
|
-
stubs.set('fetchReply', createMockFn(async ()=>{
|
|
754
|
-
|
|
755
|
-
|
|
1100
|
+
stubs.set('fetchReply', createMockFn(async (message)=>{
|
|
1101
|
+
const held = target(targetId(message));
|
|
1102
|
+
if (!held) throw unknownMessage();
|
|
1103
|
+
return messageFrom(held, madeFor(held.id), ...answeredIn());
|
|
756
1104
|
}));
|
|
757
|
-
|
|
758
|
-
if (!
|
|
759
|
-
|
|
1105
|
+
answer('deleteReply', async ()=>{
|
|
1106
|
+
if (!made) throw unknownMessage();
|
|
1107
|
+
}, later('deleteReply', ([message])=>{
|
|
1108
|
+
const id = targetId(message);
|
|
1109
|
+
made = target(id);
|
|
1110
|
+
if (!made) return;
|
|
1111
|
+
if (id === undefined) originalGone = true;
|
|
1112
|
+
else followUps.delete(id);
|
|
760
1113
|
}));
|
|
761
1114
|
// showModal — the first response of a command or a component, like reply()
|
|
762
|
-
if (instance.type === InteractionType.ApplicationCommand ||
|
|
763
|
-
|
|
764
|
-
if (instance.deferred || instance.replied) throw alreadyReplied();
|
|
765
|
-
instance.replied = true;
|
|
1115
|
+
if (instance.type === InteractionType.ApplicationCommand || isComponent) {
|
|
1116
|
+
answer('showModal', async ()=>undefined, first('showModal', 'replied', ()=>{
|
|
766
1117
|
modalAnswered = instance.type === InteractionType.ApplicationCommand;
|
|
1118
|
+
if (isComponent) original = componentMessage();
|
|
767
1119
|
}));
|
|
768
1120
|
}
|
|
769
1121
|
// deferUpdate / update — components, and modals submitted from a message's component
|
|
770
|
-
if (
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
instance.replied = true;
|
|
774
|
-
}));
|
|
775
|
-
stubs.set('deferUpdate', createMockFn(async ()=>{
|
|
776
|
-
if (instance.deferred || instance.replied) throw alreadyReplied();
|
|
777
|
-
instance.deferred = true;
|
|
778
|
-
}));
|
|
779
|
-
}
|
|
780
|
-
// Every answer the interaction gets, through respond() or discord.js directly, in order, for getResponse
|
|
781
|
-
const log = [];
|
|
782
|
-
Object.defineProperty(instance, RESPONSE_LOG, {
|
|
783
|
-
value: log
|
|
784
|
-
});
|
|
785
|
-
for (const method of ANSWER_METHODS){
|
|
786
|
-
const stub = stubs.get(method);
|
|
787
|
-
if (stub) stubs.set(method, recorded(method, stub, log));
|
|
1122
|
+
if (isComponent || instance.type === InteractionType.ModalSubmit) {
|
|
1123
|
+
answer('update', async ()=>undefined, first('update', 'replied', ([options])=>original = hold('update', ()=>edited(heldFrom(instance.message, false), options))));
|
|
1124
|
+
answer('deferUpdate', async ()=>undefined, first('deferUpdate', 'deferred', ()=>original = componentMessage()));
|
|
788
1125
|
}
|
|
789
1126
|
}
|
|
790
1127
|
// Autocomplete is not repliable, but it has a response of its own: Discord accepts
|
|
@@ -794,7 +1131,7 @@ function createMockInteraction(Class, props) {
|
|
|
794
1131
|
if (instance.type === InteractionType.ApplicationCommandAutocomplete) {
|
|
795
1132
|
instance.responded = false;
|
|
796
1133
|
stubs.set('respond', createMockFn(async (choices)=>{
|
|
797
|
-
if (instance.responded) throw
|
|
1134
|
+
if (instance.responded) throw discordjsError(DiscordjsErrorCodes.InteractionAlreadyReplied);
|
|
798
1135
|
// Discord refuses a list longer than its limit, and the menu shows nothing
|
|
799
1136
|
if (Array.isArray(choices) && choices.length > MAX_AUTOCOMPLETE_CHOICES) {
|
|
800
1137
|
throw createDiscordError(50035, `Invalid Form Body\ndata.choices[BASE_TYPE_MAX_LENGTH]: Must be ${MAX_AUTOCOMPLETE_CHOICES} or fewer in length.`);
|
|
@@ -823,6 +1160,71 @@ function createMockInteraction(Class, props) {
|
|
|
823
1160
|
const unset = (key)=>!Object.prototype.hasOwnProperty.call(instance, key);
|
|
824
1161
|
const generatedId = unset('id') ? instance.id = nextSnowflake() : undefined;
|
|
825
1162
|
defineCreatedTime(instance, generatedId);
|
|
1163
|
+
// Strict mocks place it where its message, guild or member is, and a guildId alone in a server the bot isn't in
|
|
1164
|
+
// Read as the mock's own, so a message's placement warning stays the test's
|
|
1165
|
+
strictPlace = readPlaceQuietly(()=>strictPlaceOf(own));
|
|
1166
|
+
if (strictPlace && strictMocks()) {
|
|
1167
|
+
if (strictPlace.refused) throw new Error(strictPlace.refused);
|
|
1168
|
+
const set = (key, value)=>Object.defineProperty(instance, key, {
|
|
1169
|
+
value,
|
|
1170
|
+
writable: true,
|
|
1171
|
+
enumerable: true,
|
|
1172
|
+
configurable: true
|
|
1173
|
+
});
|
|
1174
|
+
if (strictPlace.channel && unset('channel')) set('channel', strictPlace.channel);
|
|
1175
|
+
if (strictPlace.guildId !== undefined && unset('guildId')) set('guildId', strictPlace.guildId);
|
|
1176
|
+
if (strictPlace.guild !== undefined && unset('guild')) set('guild', strictPlace.guild);
|
|
1177
|
+
if (strictPlace.raw) {
|
|
1178
|
+
const user = own('user');
|
|
1179
|
+
const rawUser = user && {
|
|
1180
|
+
id: user.id,
|
|
1181
|
+
username: user.username,
|
|
1182
|
+
global_name: user.globalName ?? null,
|
|
1183
|
+
discriminator: user.discriminator,
|
|
1184
|
+
avatar: user.avatar ?? null
|
|
1185
|
+
};
|
|
1186
|
+
set('member', createMockRawMember(rawUser ? {
|
|
1187
|
+
user: rawUser
|
|
1188
|
+
} : {}));
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
// 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
|
|
1192
|
+
const raw = own('member');
|
|
1193
|
+
// Given with a guild, a raw-shaped member is kept as the cached server's member it was before raw members
|
|
1194
|
+
if (isRawMember(raw) && own('guild')) {
|
|
1195
|
+
warnOnce(mockLogger, "The member given is a raw member, from a server the bot isn't in, but the mock was given a guild, so it reads as a " + 'cached server: leave the guild out for a raw member, or give a GuildMember.');
|
|
1196
|
+
} else if (isRawMember(raw)) {
|
|
1197
|
+
const rawOnly = "The member given is a raw member, from a server the bot isn't in";
|
|
1198
|
+
if (!own('guildId')) throw new Error(`${rawOnly}, so the mock needs that server's guildId.`);
|
|
1199
|
+
if (own('channel') !== undefined) throw new Error(`${rawOnly}, where discord.js caches no channel, but the mock was given a channel: leave the channel out.`);
|
|
1200
|
+
const givenUser = own('user');
|
|
1201
|
+
if (givenUser && givenUser.id !== raw.user.id) {
|
|
1202
|
+
throw new Error(`The member given is user ${raw.user.id}'s, but the mock's user is ${givenUser.id}: give the same user, or leave one of them out.`);
|
|
1203
|
+
}
|
|
1204
|
+
if (!givenUser) {
|
|
1205
|
+
const { id, username, global_name: globalName, discriminator, avatar } = raw.user;
|
|
1206
|
+
instance.user = createMockUser({
|
|
1207
|
+
id,
|
|
1208
|
+
username,
|
|
1209
|
+
globalName: globalName ?? null,
|
|
1210
|
+
discriminator,
|
|
1211
|
+
avatar: avatar ?? null
|
|
1212
|
+
});
|
|
1213
|
+
}
|
|
1214
|
+
Object.defineProperty(instance, 'guild', {
|
|
1215
|
+
value: null,
|
|
1216
|
+
writable: true,
|
|
1217
|
+
enumerable: true,
|
|
1218
|
+
configurable: true
|
|
1219
|
+
});
|
|
1220
|
+
// Only its channelId: discord.js caches no channel from a server the bot isn't in
|
|
1221
|
+
Object.defineProperty(instance, 'channel', {
|
|
1222
|
+
value: null,
|
|
1223
|
+
writable: true,
|
|
1224
|
+
enumerable: true,
|
|
1225
|
+
configurable: true
|
|
1226
|
+
});
|
|
1227
|
+
}
|
|
826
1228
|
const userGiven = !unset('user');
|
|
827
1229
|
if (!userGiven) instance.user = mockUser();
|
|
828
1230
|
// Assigned once by discord.js's Base constructor, as on a message, and the gateway caches the user on it
|
|
@@ -868,12 +1270,13 @@ function createMockInteraction(Class, props) {
|
|
|
868
1270
|
configurable: true
|
|
869
1271
|
});
|
|
870
1272
|
}
|
|
871
|
-
// The channel it came from, as the gateway caches it for an interaction:
|
|
1273
|
+
// The channel it came from, as the gateway caches it for an interaction: its server's cached one, looked up on each
|
|
1274
|
+
// read so a channel the test caches later is the one read, or the user's DM
|
|
872
1275
|
let channel;
|
|
873
1276
|
if (unset('channel')) {
|
|
874
1277
|
Object.defineProperty(instance, 'channel', {
|
|
875
1278
|
get: ()=>{
|
|
876
|
-
if (channel === undefined) {
|
|
1279
|
+
if (channel === undefined || own('guildId') && own('guild') instanceof Guild) {
|
|
877
1280
|
channel = channelFor(own('guild'), own('guildId'), own('channelId'), own('user'));
|
|
878
1281
|
cacheChannel(channel, own('guild'), own('client'));
|
|
879
1282
|
}
|
|
@@ -928,7 +1331,73 @@ function createMockInteraction(Class, props) {
|
|
|
928
1331
|
live('targetMember', ()=>own('guildId') ? memberFor(own('guild'), targetUser(), true, own('guildId')) : null);
|
|
929
1332
|
}
|
|
930
1333
|
}
|
|
1334
|
+
// A message context menu's target: the message given, else one made for its targetId. The message is the one
|
|
1335
|
+
// source: targetId reads its id, and setting either one moves the other with it
|
|
1336
|
+
if (MessageContextMenuCommandInteraction.prototype.isPrototypeOf(instance)) {
|
|
1337
|
+
let given = own('targetMessage');
|
|
1338
|
+
let explicitId = own('targetId');
|
|
1339
|
+
if (given && given.id === undefined) given.id = nextSnowflake();
|
|
1340
|
+
if (given && explicitId !== undefined && explicitId !== given.id) {
|
|
1341
|
+
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.`;
|
|
1342
|
+
if (strictMocks()) throw new Error(conflict);
|
|
1343
|
+
warnOnce(mockLogger, `${conflict} The mock reads targetMessage's id; under useStrictMocks(), as in the next major version (5.0), it refuses the pair.`);
|
|
1344
|
+
explicitId = undefined;
|
|
1345
|
+
}
|
|
1346
|
+
const generated = given || explicitId !== undefined ? undefined : nextSnowflake();
|
|
1347
|
+
let made;
|
|
1348
|
+
const accessor = (key, get, set)=>Object.defineProperty(instance, key, {
|
|
1349
|
+
get,
|
|
1350
|
+
set,
|
|
1351
|
+
enumerable: true,
|
|
1352
|
+
configurable: true
|
|
1353
|
+
});
|
|
1354
|
+
const targetId = ()=>explicitId ?? given?.id ?? generated;
|
|
1355
|
+
accessor('targetId', targetId, (value)=>{
|
|
1356
|
+
explicitId = value;
|
|
1357
|
+
given = undefined;
|
|
1358
|
+
});
|
|
1359
|
+
// In the channel the menu was used in, and its server, as the message a menu is used on is: its channel is the
|
|
1360
|
+
// menu's, read live, so making the message makes and caches no channel. Read as the mock's own, so a placement
|
|
1361
|
+
// warning stays the test's
|
|
1362
|
+
const makeTarget = ()=>readPlaceQuietly(()=>{
|
|
1363
|
+
const [guild, guildId] = [
|
|
1364
|
+
own('guild'),
|
|
1365
|
+
own('guildId') ?? null
|
|
1366
|
+
];
|
|
1367
|
+
const message = messageMock({
|
|
1368
|
+
id: targetId(),
|
|
1369
|
+
guild: guildId && guild instanceof Guild ? guild : null,
|
|
1370
|
+
client: instance.client
|
|
1371
|
+
}, undefined, {}, {
|
|
1372
|
+
channelId: own('channelId'),
|
|
1373
|
+
guildId,
|
|
1374
|
+
channel: ()=>readPlaceQuietly(()=>instance.channel)
|
|
1375
|
+
});
|
|
1376
|
+
// Where strict mocks place the menu elsewhere, its target's place warns as the menu's does, by the target's name
|
|
1377
|
+
const warner = placeWarners.get(instance);
|
|
1378
|
+
const target = mockTargets.get(message) ?? message;
|
|
1379
|
+
for (const key of warner?.keys ?? [])warnOnRead(target, key, ()=>readingPlace > 0 ? void placeReadsSeen?.add(key) : warner.warn(`targetMessage.${key}`));
|
|
1380
|
+
return message;
|
|
1381
|
+
});
|
|
1382
|
+
accessor('targetMessage', ()=>given ?? (made?.id === targetId() ? made : made = makeTarget()), (value)=>{
|
|
1383
|
+
given = value;
|
|
1384
|
+
if (given && given.id === undefined) given.id = nextSnowflake();
|
|
1385
|
+
explicitId = undefined;
|
|
1386
|
+
});
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
// A user has an id of its own, as Discord sends one, and was created when the mock was made under strict mocks
|
|
1390
|
+
if (User.prototype.isPrototypeOf(instance) && !Object.prototype.hasOwnProperty.call(instance, 'id')) {
|
|
1391
|
+
const generatedId = instance.id = nextSnowflake();
|
|
1392
|
+
defineMadeTime(instance, generatedId);
|
|
931
1393
|
}
|
|
1394
|
+
// A role too, created when the mock was made in both modes
|
|
1395
|
+
if (Role.prototype.isPrototypeOf(instance) && !Object.prototype.hasOwnProperty.call(instance, 'id')) {
|
|
1396
|
+
const generatedId = instance.id = nextSnowflake();
|
|
1397
|
+
defineCreatedTime(instance, generatedId);
|
|
1398
|
+
}
|
|
1399
|
+
// An attachment too, from the same count as every mock's
|
|
1400
|
+
if (Attachment.prototype.isPrototypeOf(instance) && !Object.prototype.hasOwnProperty.call(instance, 'id')) instance.id = nextSnowflake();
|
|
932
1401
|
// Discord sends the user's locale with every interaction, and the server's preferred one with one made in a server
|
|
933
1402
|
if (BaseInteraction.prototype.isPrototypeOf(instance)) {
|
|
934
1403
|
if (own('locale') === undefined) instance.locale = Locale.EnglishUS;
|
|
@@ -952,6 +1421,32 @@ function createMockInteraction(Class, props) {
|
|
|
952
1421
|
}
|
|
953
1422
|
for (const kind of choices)if (!Object.prototype.hasOwnProperty.call(instance, kind)) instance[kind] = new Collection();
|
|
954
1423
|
}
|
|
1424
|
+
// In default mode, where strict mocks place it elsewhere, reading where it is warns
|
|
1425
|
+
if (strictPlace && !strictMocks()) warnOnPlaceReads(instance, strictPlace, new Set(Object.keys(props ?? {})));
|
|
1426
|
+
// A message whose place the mock made moves to where the test placed the interaction under strict mocks, and is then
|
|
1427
|
+
// one the test placed; in default mode it stays, and reading its place warns
|
|
1428
|
+
const message = own('message');
|
|
1429
|
+
const placed = [
|
|
1430
|
+
'channel',
|
|
1431
|
+
'guild',
|
|
1432
|
+
'guildId'
|
|
1433
|
+
].some((key)=>Object.keys(props ?? {}).includes(key));
|
|
1434
|
+
if (BaseInteraction.prototype.isPrototypeOf(instance) && placed && typeof message === 'object' && message !== null && placeWasMade(message)) {
|
|
1435
|
+
if (strictMocks()) {
|
|
1436
|
+
const guild = own('guild');
|
|
1437
|
+
const channel = instance.channel;
|
|
1438
|
+
Object.assign(message, {
|
|
1439
|
+
guild: guild ?? null,
|
|
1440
|
+
guildId: own('guildId') ?? null,
|
|
1441
|
+
...channel && {
|
|
1442
|
+
channel,
|
|
1443
|
+
channelId: channel.id
|
|
1444
|
+
}
|
|
1445
|
+
});
|
|
1446
|
+
if (guild) memberFor(guild, message.author, true);
|
|
1447
|
+
placesMade.delete(mockTargets.get(message) ?? message);
|
|
1448
|
+
} else warnOnMadePlaceReads(mockTargets.get(message) ?? message);
|
|
1449
|
+
}
|
|
955
1450
|
// Options assigned after creation, directly or through Object.assign, belong to it as those given do
|
|
956
1451
|
return stubDeep(instance, stubs, (prop, value)=>{
|
|
957
1452
|
if (prop === 'options') ownOptions(instance, value);
|
|
@@ -963,31 +1458,36 @@ function createMockInteraction(Class, props) {
|
|
|
963
1458
|
/**
|
|
964
1459
|
* A mock function that answers property access with another one, so `cache.store.flush()` works
|
|
965
1460
|
* on a double whose shape is an interface with nothing at runtime to read it from.
|
|
966
|
-
*/ function stubCallable() {
|
|
1461
|
+
*/ function stubCallable(member) {
|
|
967
1462
|
const fn = createMockFn();
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
1463
|
+
// jest's mock reads `_protoImpl` from itself whenever it's called, so the prototype below must not answer it
|
|
1464
|
+
if (usesRunnerMockFn()) Object.defineProperty(fn, '_protoImpl', {
|
|
1465
|
+
value: undefined,
|
|
1466
|
+
writable: true,
|
|
1467
|
+
enumerable: false,
|
|
1468
|
+
configurable: true
|
|
1469
|
+
});
|
|
1470
|
+
// Any property beyond the mock's own API is a nested mock, made on first read and kept on the function, out of its
|
|
1471
|
+
// keys. Its prototype makes them rather than a Proxy around it, so it stays the plain mock function matchers read
|
|
1472
|
+
const inherited = Object.getPrototypeOf(fn);
|
|
1473
|
+
Object.setPrototypeOf(fn, new Proxy(inherited, {
|
|
1474
|
+
get (proto, prop, receiver) {
|
|
1475
|
+
if (typeof prop === 'symbol' || prop in proto) return Reflect.get(proto, prop, receiver);
|
|
973
1476
|
// Never thenable — otherwise awaiting a mock hangs on itself
|
|
974
|
-
if (
|
|
975
|
-
//
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
},
|
|
981
|
-
set (target, prop, value) {
|
|
982
|
-
Object.defineProperty(target, prop, {
|
|
983
|
-
value,
|
|
1477
|
+
if (prop === 'then') return undefined;
|
|
1478
|
+
// jest's matchers take a function whose `calls` has `all` and `count` for a jasmine spy, read before `mock.calls`
|
|
1479
|
+
if (member === 'calls' && (prop === 'all' || prop === 'count')) return undefined;
|
|
1480
|
+
const nested = stubCallable(prop);
|
|
1481
|
+
Object.defineProperty(receiver, prop, {
|
|
1482
|
+
value: nested,
|
|
984
1483
|
writable: true,
|
|
985
|
-
enumerable:
|
|
1484
|
+
enumerable: false,
|
|
986
1485
|
configurable: true
|
|
987
1486
|
});
|
|
988
|
-
return
|
|
1487
|
+
return nested;
|
|
989
1488
|
}
|
|
990
|
-
});
|
|
1489
|
+
}));
|
|
1490
|
+
return fn;
|
|
991
1491
|
}
|
|
992
1492
|
/**
|
|
993
1493
|
* Creates a mock of any type, with no class needed, for service doubles and interfaces.
|
|
@@ -1042,6 +1542,7 @@ function createMockInteraction(Class, props) {
|
|
|
1042
1542
|
if (key === 'then') return undefined;
|
|
1043
1543
|
// An explicitly supplied prop wins over the auto-stub.
|
|
1044
1544
|
if (Object.prototype.hasOwnProperty.call(instance, key)) return Reflect.get(instance, prop, instance);
|
|
1545
|
+
// The double itself is no function a matcher reads, so a `calls` member here keeps its `all` and `count`
|
|
1045
1546
|
if (!stubs.has(key)) stubs.set(key, stubCallable());
|
|
1046
1547
|
return stubs.get(key);
|
|
1047
1548
|
},
|
|
@@ -1081,11 +1582,12 @@ function createMockInteraction(Class, props) {
|
|
|
1081
1582
|
* @group Testing
|
|
1082
1583
|
* @category Mocks
|
|
1083
1584
|
* @see {@link createMockInteraction}
|
|
1084
|
-
*/
|
|
1085
|
-
|
|
1585
|
+
*/ function createMockUser(props = {}) {
|
|
1586
|
+
return createMockInteraction(User, {
|
|
1086
1587
|
bot: false,
|
|
1087
1588
|
...props
|
|
1088
1589
|
});
|
|
1590
|
+
}
|
|
1089
1591
|
/**
|
|
1090
1592
|
* Creates a mock {@link Client}, with `users`, `channels`, `guilds` and `application.commands` ready to stub.
|
|
1091
1593
|
*
|
|
@@ -1199,6 +1701,18 @@ const HOLDS = [
|
|
|
1199
1701
|
[
|
|
1200
1702
|
PermissionOverwriteManager.prototype,
|
|
1201
1703
|
PermissionOverwrites
|
|
1704
|
+
],
|
|
1705
|
+
[
|
|
1706
|
+
ReactionManager.prototype,
|
|
1707
|
+
MessageReaction
|
|
1708
|
+
],
|
|
1709
|
+
[
|
|
1710
|
+
ReactionUserManager.prototype,
|
|
1711
|
+
User
|
|
1712
|
+
],
|
|
1713
|
+
[
|
|
1714
|
+
PresenceManager.prototype,
|
|
1715
|
+
Presence
|
|
1202
1716
|
]
|
|
1203
1717
|
];
|
|
1204
1718
|
/**
|
|
@@ -1214,6 +1728,11 @@ const HOLDS = [
|
|
|
1214
1728
|
value: cache,
|
|
1215
1729
|
writable: true
|
|
1216
1730
|
});
|
|
1731
|
+
// The same collection where discord.js's own methods read it past a subclass's `cache`, as `super.cache` does
|
|
1732
|
+
Object.defineProperty(manager, '_cache', {
|
|
1733
|
+
value: cache,
|
|
1734
|
+
writable: true
|
|
1735
|
+
});
|
|
1217
1736
|
const holds = HOLDS.find(([Manager])=>Manager === prototype || Object.prototype.isPrototypeOf.call(Manager, prototype))?.[1];
|
|
1218
1737
|
if (holds) Object.defineProperty(manager, 'holds', {
|
|
1219
1738
|
value: holds
|
|
@@ -1221,14 +1740,25 @@ const HOLDS = [
|
|
|
1221
1740
|
let own;
|
|
1222
1741
|
Object.defineProperty(manager, 'client', {
|
|
1223
1742
|
get: ()=>{
|
|
1224
|
-
const owner = manager.guild ?? manager.channel ?? manager.thread;
|
|
1743
|
+
const owner = manager.guild ?? manager.channel ?? manager.thread ?? manager.message;
|
|
1225
1744
|
return owner instanceof Base ? owner.client : own ?? (own = createMockClient());
|
|
1226
1745
|
},
|
|
1227
|
-
|
|
1746
|
+
// A client the test sets is the one read from then on, as on any other mock
|
|
1747
|
+
set: (value)=>Object.defineProperty(manager, 'client', {
|
|
1748
|
+
value,
|
|
1749
|
+
writable: true,
|
|
1750
|
+
enumerable: true,
|
|
1751
|
+
configurable: true
|
|
1752
|
+
}),
|
|
1228
1753
|
configurable: true
|
|
1229
1754
|
});
|
|
1230
1755
|
return stubDeep(manager);
|
|
1231
1756
|
}
|
|
1757
|
+
/** An empty manager of `prototype` that `owner` names its own, such as `{ message }` for a message's reactions. */ function ownedManager(prototype, owner) {
|
|
1758
|
+
const manager = managerWith(prototype, undefined);
|
|
1759
|
+
Object.assign(manager, owner);
|
|
1760
|
+
return manager;
|
|
1761
|
+
}
|
|
1232
1762
|
/**
|
|
1233
1763
|
* Creates a mock {@link Guild}, with the `members`, `channels`, `roles` and `bans` managers ready to stub.
|
|
1234
1764
|
*
|
|
@@ -1265,8 +1795,9 @@ const HOLDS = [
|
|
|
1265
1795
|
* @see {@link createMockMessage}
|
|
1266
1796
|
*/ function createMockGuild(overrides = {}) {
|
|
1267
1797
|
const instance = Object.create(Guild.prototype);
|
|
1268
|
-
|
|
1269
|
-
|
|
1798
|
+
const generatedId = overrides.id === undefined ? nextSnowflake() : undefined;
|
|
1799
|
+
instance.id = overrides.id ?? generatedId;
|
|
1800
|
+
defineMadeTime(instance, generatedId);
|
|
1270
1801
|
if (overrides.name !== undefined) instance.name = overrides.name;
|
|
1271
1802
|
if (overrides.preferredLocale !== undefined) instance.preferredLocale = overrides.preferredLocale;
|
|
1272
1803
|
instance.members = managerWith(GuildMemberManager.prototype, overrides.members);
|
|
@@ -1311,7 +1842,8 @@ const HOLDS = [
|
|
|
1311
1842
|
* @see {@link createMockGuild}
|
|
1312
1843
|
*/ function createMockChannel(Class, props = {}) {
|
|
1313
1844
|
const instance = Object.create(Class.prototype);
|
|
1314
|
-
|
|
1845
|
+
const generatedId = props.id === undefined ? nextSnowflake() : undefined;
|
|
1846
|
+
instance.id = generatedId;
|
|
1315
1847
|
const is = (Base)=>Base.prototype.isPrototypeOf(Class.prototype) || Class === Base;
|
|
1316
1848
|
// Text and announcement channels: messages, and threads made in the channel
|
|
1317
1849
|
if (is(TextChannel) || is(NewsChannel)) {
|
|
@@ -1339,6 +1871,7 @@ const HOLDS = [
|
|
|
1339
1871
|
// A server's channel has its overwrites, none until a test puts some in the cache, which its permissions read
|
|
1340
1872
|
if (is(GuildChannel) && !is(ThreadChannel)) instance.permissionOverwrites = managerWith(PermissionOverwriteManager.prototype, undefined);
|
|
1341
1873
|
Object.assign(instance, props);
|
|
1874
|
+
defineMadeTime(instance, generatedId);
|
|
1342
1875
|
const stubs = new Map();
|
|
1343
1876
|
const channel = stubDeep(instance, stubs);
|
|
1344
1877
|
// Each manager knows its channel, as discord.js's do, and a thread's members their thread
|
|
@@ -1463,11 +1996,25 @@ const CHANNEL_TYPE_GUARDS = [
|
|
|
1463
1996
|
});
|
|
1464
1997
|
return stubDeep(manager);
|
|
1465
1998
|
}
|
|
1466
|
-
/**
|
|
1999
|
+
/** The permissions Discord gives a server's @everyone role in a server made today. */ const NEW_SERVER_EVERYONE_PERMISSIONS = 2248473465835073n;
|
|
2000
|
+
// The @everyone roles default mode made with no permissions, where strict mocks give a new server's
|
|
2001
|
+
const madeEveryones = new WeakSet();
|
|
2002
|
+
/** 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');
|
|
2003
|
+
// A server's @everyone role, which has the server's id and ranks lowest, at position 0; with strict mocks, the
|
|
2004
|
+
// permissions Discord gives it in a new server
|
|
2005
|
+
function everyoneRole(guildId) {
|
|
2006
|
+
const strict = strictMocks();
|
|
2007
|
+
const role = createMockInteraction(Role, {
|
|
1467
2008
|
id: guildId,
|
|
1468
2009
|
name: '@everyone',
|
|
1469
|
-
position: 0
|
|
2010
|
+
position: 0,
|
|
2011
|
+
...strict ? {
|
|
2012
|
+
permissions: new PermissionsBitField(NEW_SERVER_EVERYONE_PERMISSIONS).freeze()
|
|
2013
|
+
} : {}
|
|
1470
2014
|
});
|
|
2015
|
+
if (!strict) madeEveryones.add(role);
|
|
2016
|
+
return role;
|
|
2017
|
+
}
|
|
1471
2018
|
/**
|
|
1472
2019
|
* A guild's role manager with `roles` in its cache, and the guild's @everyone role: the one given with the guild's id,
|
|
1473
2020
|
* or a role of its own at position 0, as every server has one.
|
|
@@ -1494,6 +2041,68 @@ const CHANNEL_TYPE_GUARDS = [
|
|
|
1494
2041
|
});
|
|
1495
2042
|
return manager;
|
|
1496
2043
|
}
|
|
2044
|
+
/**
|
|
2045
|
+
* Creates the member Discord sends with an interaction from a server the bot isn't in, as discord.js keeps it.
|
|
2046
|
+
*
|
|
2047
|
+
* Give it as an interaction's `member`, with the server's `guildId` and no `guild`, to test a user-installed command
|
|
2048
|
+
* run there: the interaction reads `inRawGuild()` true, `guild` and `channel` null, `user` the member's user and
|
|
2049
|
+
* `memberPermissions` the member's, and a user option's member is the plain member Discord resolves too.
|
|
2050
|
+
*
|
|
2051
|
+
* @remarks
|
|
2052
|
+
* It is plain data, not a `GuildMember`: `roles` are role ids, `permissions` is the permission bitfield as a string,
|
|
2053
|
+
* and the rest are as Discord sends them, `null` for what may be absent and `false` for a flag. Code that reads
|
|
2054
|
+
* `member.roles.cache` throws on it, as it does in Discord.
|
|
2055
|
+
*
|
|
2056
|
+
* @param overrides - Fields for the member; see {@link MockRawMemberOverrides}.
|
|
2057
|
+
* @returns The member, as an `APIInteractionGuildMember`.
|
|
2058
|
+
*
|
|
2059
|
+
* @example
|
|
2060
|
+
* ```ts
|
|
2061
|
+
* import { expect } from 'vitest'
|
|
2062
|
+
*
|
|
2063
|
+
* const member = createMockRawMember({ roles: ['300000000000000001'], permissions: [PermissionFlagsBits.SendMessages] })
|
|
2064
|
+
* const command = createMockInteraction(ChatInputCommandInteraction, { guildId: '100000000000000001', member })
|
|
2065
|
+
* expect(command.inRawGuild()).toBe(true)
|
|
2066
|
+
* ```
|
|
2067
|
+
*
|
|
2068
|
+
* @group Testing
|
|
2069
|
+
* @category Mocks
|
|
2070
|
+
* @see {@link createMockMember}
|
|
2071
|
+
*/ function createMockRawMember(overrides = {}) {
|
|
2072
|
+
const { permissions, user, ...given } = overrides;
|
|
2073
|
+
return {
|
|
2074
|
+
user: {
|
|
2075
|
+
id: nextSnowflake(),
|
|
2076
|
+
username: 'user',
|
|
2077
|
+
discriminator: '0',
|
|
2078
|
+
global_name: null,
|
|
2079
|
+
avatar: null,
|
|
2080
|
+
...user
|
|
2081
|
+
},
|
|
2082
|
+
roles: [],
|
|
2083
|
+
nick: null,
|
|
2084
|
+
avatar: null,
|
|
2085
|
+
banner: null,
|
|
2086
|
+
premium_since: null,
|
|
2087
|
+
communication_disabled_until: null,
|
|
2088
|
+
joined_at: new Date().toISOString(),
|
|
2089
|
+
deaf: false,
|
|
2090
|
+
mute: false,
|
|
2091
|
+
pending: false,
|
|
2092
|
+
flags: 0,
|
|
2093
|
+
...given,
|
|
2094
|
+
permissions: String(new PermissionsBitField(permissions ?? NEW_SERVER_EVERYONE_PERMISSIONS).bitfield)
|
|
2095
|
+
};
|
|
2096
|
+
}
|
|
2097
|
+
/** Whether `value` is a member Discord sends from a server the bot isn't in: plain data, not a `GuildMember`. */ function isRawMember(value) {
|
|
2098
|
+
if (typeof value !== 'object' || value === null || value instanceof GuildMember) return false;
|
|
2099
|
+
const { roles, permissions, user } = value;
|
|
2100
|
+
return Array.isArray(roles) && typeof permissions === 'string' && typeof user === 'object' && user !== null;
|
|
2101
|
+
}
|
|
2102
|
+
/** A user option's member as Discord resolves it with an interaction from a server the bot isn't in. */ function rawResolvedMember() {
|
|
2103
|
+
const { user: _user, deaf: _deaf, mute: _mute, ...resolved } = createMockRawMember();
|
|
2104
|
+
return resolved;
|
|
2105
|
+
}
|
|
1497
2106
|
/**
|
|
1498
2107
|
* Creates a mock {@link GuildMember}: a user in a server, with the roles given.
|
|
1499
2108
|
*
|
|
@@ -1505,8 +2114,9 @@ const CHANNEL_TYPE_GUARDS = [
|
|
|
1505
2114
|
* highest, by position, then the lower id. `permissions` are computed as discord.js computes them: every permission for
|
|
1506
2115
|
* the server's owner, and otherwise its roles' permissions combined, @everyone's included, so a member with no roles has
|
|
1507
2116
|
* @everyone's alone. The member is put in its guild's member cache, so an interaction or a message from its user in that
|
|
1508
|
-
* server has it as its `member`, and `memberPermissions` are its permissions
|
|
1509
|
-
* user's `send()` and the user's
|
|
2117
|
+
* server has it as its `member`, and `memberPermissions` are its permissions, with the interaction's channel's
|
|
2118
|
+
* overwrites applied under `useStrictMocks()`. A DM sent to the member goes through its user's `send()` and the user's
|
|
2119
|
+
* one DM channel.
|
|
1510
2120
|
*
|
|
1511
2121
|
* @param overrides - The member's user, server, roles and nickname; see {@link MockMemberOverrides}.
|
|
1512
2122
|
* @returns The mock member.
|
|
@@ -1553,6 +2163,218 @@ const CHANNEL_TYPE_GUARDS = [
|
|
|
1553
2163
|
return guild ? cached(cacheOf(guild.channels), channelId, make) : make();
|
|
1554
2164
|
}
|
|
1555
2165
|
/** A server's id as a refusal names it, or a DM. */ const placeName = (guildId)=>guildId === null ? 'a DM' : `server ${guildId}`;
|
|
2166
|
+
/**
|
|
2167
|
+
* Where strict mocks place an interaction: in its message's channel and server, in a guild's or a member's server, and
|
|
2168
|
+
* with a guildId alone in a server the bot isn't in. Undefined where a channel given, or nothing, places it.
|
|
2169
|
+
*/ // Mock messages whose channel and server the mock made, as neither was given
|
|
2170
|
+
const placesMade = new WeakSet();
|
|
2171
|
+
// Messages whose place reads already warn, so a message reused for several interactions is wrapped once
|
|
2172
|
+
const madePlaceWarned = new WeakSet();
|
|
2173
|
+
/**
|
|
2174
|
+
* Has a message whose place the mock made warn once per key when its place is read, in default mode, where strict mocks
|
|
2175
|
+
* move it to the interaction it was given to. A read made while reading another of these, or by the mock, stays quiet.
|
|
2176
|
+
*/ function warnOnMadePlaceReads(message) {
|
|
2177
|
+
if (madePlaceWarned.has(message)) return;
|
|
2178
|
+
madePlaceWarned.add(message);
|
|
2179
|
+
for (const key of [
|
|
2180
|
+
'guild',
|
|
2181
|
+
'guildId',
|
|
2182
|
+
'channel',
|
|
2183
|
+
'channelId',
|
|
2184
|
+
'member'
|
|
2185
|
+
]){
|
|
2186
|
+
const descriptor = Object.getOwnPropertyDescriptor(message, key);
|
|
2187
|
+
if (!descriptor) continue;
|
|
2188
|
+
let value = descriptor.value;
|
|
2189
|
+
Object.defineProperty(message, key, {
|
|
2190
|
+
get: ()=>{
|
|
2191
|
+
if (readingPlace === 0) {
|
|
2192
|
+
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.');
|
|
2193
|
+
}
|
|
2194
|
+
readingPlace++;
|
|
2195
|
+
try {
|
|
2196
|
+
return descriptor.get ? descriptor.get.call(message) : value;
|
|
2197
|
+
} finally{
|
|
2198
|
+
readingPlace--;
|
|
2199
|
+
}
|
|
2200
|
+
},
|
|
2201
|
+
set: (next)=>{
|
|
2202
|
+
if (descriptor.set) descriptor.set.call(message, next);
|
|
2203
|
+
else value = next;
|
|
2204
|
+
},
|
|
2205
|
+
enumerable: true,
|
|
2206
|
+
configurable: true
|
|
2207
|
+
});
|
|
2208
|
+
}
|
|
2209
|
+
}
|
|
2210
|
+
// The DM channel each message the bot sent in a DM in default mode was sent in, where strict mocks place it
|
|
2211
|
+
const sentInDm = new WeakMap();
|
|
2212
|
+
/** 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);
|
|
2213
|
+
function strictPlaceOf(own) {
|
|
2214
|
+
const message = own('message');
|
|
2215
|
+
const channel = own('channel');
|
|
2216
|
+
const guild = own('guild');
|
|
2217
|
+
const guildId = own('guildId');
|
|
2218
|
+
const member = own('member');
|
|
2219
|
+
// A message whose place the mock made has none a test meant, so where the test placed the interaction wins
|
|
2220
|
+
const placed = channel !== undefined || guild !== undefined || guildId !== undefined;
|
|
2221
|
+
if (typeof message === 'object' && message !== null && typeof message.channelId === 'string' && !(placed && placeWasMade(message))) {
|
|
2222
|
+
// One the bot sent in a DM is in that DM, as strict mocks place it, though default mode reads a server for it
|
|
2223
|
+
const dm = sentInDm.get(mockTargets.get(message) ?? message);
|
|
2224
|
+
const channelId = dm ? dm.id : message.channelId;
|
|
2225
|
+
const at = dm ? null : message.guildId ?? null;
|
|
2226
|
+
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;
|
|
2227
|
+
return {
|
|
2228
|
+
given: 'message',
|
|
2229
|
+
channel: dm ?? message.channel,
|
|
2230
|
+
guildId: at,
|
|
2231
|
+
guild: dm ? null : message.guild ?? null,
|
|
2232
|
+
refused
|
|
2233
|
+
};
|
|
2234
|
+
}
|
|
2235
|
+
if (guild instanceof Guild && guildId === undefined) return {
|
|
2236
|
+
given: 'guild',
|
|
2237
|
+
guildId: guild.id
|
|
2238
|
+
};
|
|
2239
|
+
if (member instanceof GuildMember && member.guild instanceof Guild && guild === undefined && guildId === undefined) {
|
|
2240
|
+
return {
|
|
2241
|
+
given: 'member',
|
|
2242
|
+
guild: member.guild,
|
|
2243
|
+
guildId: member.guild.id
|
|
2244
|
+
};
|
|
2245
|
+
}
|
|
2246
|
+
if (typeof guildId === 'string' && guild === undefined && channel === undefined && !(member instanceof GuildMember) && !isRawMember(member)) {
|
|
2247
|
+
return {
|
|
2248
|
+
given: 'guildId',
|
|
2249
|
+
raw: true
|
|
2250
|
+
};
|
|
2251
|
+
}
|
|
2252
|
+
return undefined;
|
|
2253
|
+
}
|
|
2254
|
+
// Set while one of the reads below runs, so what it reads in turn doesn't warn again
|
|
2255
|
+
let readingPlace = 0;
|
|
2256
|
+
/** Runs `read` as the mock's own read of where a mock is, which warns about no placement. */ function readPlaceQuietly(read) {
|
|
2257
|
+
readingPlace++;
|
|
2258
|
+
try {
|
|
2259
|
+
return read();
|
|
2260
|
+
} finally{
|
|
2261
|
+
readingPlace--;
|
|
2262
|
+
}
|
|
2263
|
+
}
|
|
2264
|
+
// 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
|
|
2265
|
+
const placeWarners = new WeakMap();
|
|
2266
|
+
// 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
|
|
2267
|
+
let placeReadsSeen;
|
|
2268
|
+
/**
|
|
2269
|
+
* Runs `read`, a value of `mock`'s own that reads where the mock is, quietly; in default mode it warns once, as `key`,
|
|
2270
|
+
* where one of those reads would warn, since strict mocks place the mock otherwise, unless `differs` says the value is
|
|
2271
|
+
* the same in that place.
|
|
2272
|
+
*/ function readPlaceAs(mock, key, read, differs = ()=>true) {
|
|
2273
|
+
const outer = placeReadsSeen;
|
|
2274
|
+
const seen = placeReadsSeen = new Set();
|
|
2275
|
+
try {
|
|
2276
|
+
return readPlaceQuietly(read);
|
|
2277
|
+
} finally{
|
|
2278
|
+
placeReadsSeen = outer;
|
|
2279
|
+
const warner = placeWarners.get(mockTargets.get(mock) ?? mock);
|
|
2280
|
+
if (seen.size > 0 && warner && differs(warner.place)) warner.warn(key);
|
|
2281
|
+
}
|
|
2282
|
+
}
|
|
2283
|
+
/** What reading `key`, where strict mocks place an interaction otherwise, warns: what it reads now, and in 5.0. */ function placeWarning(name, key, place) {
|
|
2284
|
+
if (place.refused) {
|
|
2285
|
+
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}`;
|
|
2286
|
+
}
|
|
2287
|
+
const why = {
|
|
2288
|
+
message: [
|
|
2289
|
+
'a place other than its message\'s',
|
|
2290
|
+
"the mock is in its message's channel and server",
|
|
2291
|
+
"Give it the message's channel, or call useStrictMocks() to place it there now."
|
|
2292
|
+
],
|
|
2293
|
+
guild: [
|
|
2294
|
+
"a DM's value, though the mock was given a guild",
|
|
2295
|
+
'the mock is in that guild',
|
|
2296
|
+
'Give its guildId or one of its channels too, or call useStrictMocks() to place it there now.'
|
|
2297
|
+
],
|
|
2298
|
+
member: [
|
|
2299
|
+
"a DM's value, though the mock was given a member of a server",
|
|
2300
|
+
"the mock is in the member's server",
|
|
2301
|
+
"Give the member's guild too, or call useStrictMocks() to place it there now."
|
|
2302
|
+
],
|
|
2303
|
+
guildId: [
|
|
2304
|
+
'a server the bot is in, though the mock was given only a guildId',
|
|
2305
|
+
"a guildId alone is a server the bot isn't in: guild and channel are null, and the member is a raw one",
|
|
2306
|
+
'Give a guild too, or call useStrictMocks() to read it so now.'
|
|
2307
|
+
]
|
|
2308
|
+
}[place.given];
|
|
2309
|
+
return `${name}.${key} reads ${why[0]} here; in the next major version (5.0) ${why[1]}. ${why[2]}`;
|
|
2310
|
+
}
|
|
2311
|
+
/** Has a read of `instance`'s `key` call `warn` first, and what that read reads in turn stay quiet. */ function warnOnRead(instance, key, warn) {
|
|
2312
|
+
const descriptor = Object.getOwnPropertyDescriptor(instance, key);
|
|
2313
|
+
// One the mock answers with a stub stays unset, as the mock's own checks read it, and warns as the stub is read
|
|
2314
|
+
if (!descriptor) {
|
|
2315
|
+
if (!stubReadWarnings.has(instance)) stubReadWarnings.set(instance, new Map());
|
|
2316
|
+
stubReadWarnings.get(instance).set(key, warn);
|
|
2317
|
+
return;
|
|
2318
|
+
}
|
|
2319
|
+
const read = ()=>descriptor.get ? descriptor.get.call(instance) : descriptor.value;
|
|
2320
|
+
Object.defineProperty(instance, key, {
|
|
2321
|
+
get: ()=>{
|
|
2322
|
+
warn();
|
|
2323
|
+
readingPlace++;
|
|
2324
|
+
try {
|
|
2325
|
+
return read();
|
|
2326
|
+
} finally{
|
|
2327
|
+
readingPlace--;
|
|
2328
|
+
}
|
|
2329
|
+
},
|
|
2330
|
+
set: (next)=>Object.defineProperty(instance, key, {
|
|
2331
|
+
value: next,
|
|
2332
|
+
writable: true,
|
|
2333
|
+
enumerable: true,
|
|
2334
|
+
configurable: true
|
|
2335
|
+
}),
|
|
2336
|
+
enumerable: true,
|
|
2337
|
+
configurable: true
|
|
2338
|
+
});
|
|
2339
|
+
}
|
|
2340
|
+
/**
|
|
2341
|
+
* Has each of an interaction's `guildId`, `guild`, `channelId`, `channel` and `member` that strict mocks read otherwise
|
|
2342
|
+
* warn once when read, unless the test gave it.
|
|
2343
|
+
*/ function warnOnPlaceReads(instance, place, given) {
|
|
2344
|
+
const value = (key)=>{
|
|
2345
|
+
const descriptor = Object.getOwnPropertyDescriptor(instance, key);
|
|
2346
|
+
return descriptor?.get ? undefined : descriptor?.value;
|
|
2347
|
+
};
|
|
2348
|
+
const keys = place.refused ? [
|
|
2349
|
+
'guildId',
|
|
2350
|
+
'guild',
|
|
2351
|
+
'channelId',
|
|
2352
|
+
'channel'
|
|
2353
|
+
] : place.raw ? [
|
|
2354
|
+
'guild',
|
|
2355
|
+
'channel',
|
|
2356
|
+
'member'
|
|
2357
|
+
] : place.guildId !== value('guildId') ? [
|
|
2358
|
+
'guildId',
|
|
2359
|
+
'guild',
|
|
2360
|
+
'channelId',
|
|
2361
|
+
'channel',
|
|
2362
|
+
'member'
|
|
2363
|
+
] : place.channel && place.channel.id !== value('channelId') ? [
|
|
2364
|
+
'channelId',
|
|
2365
|
+
'channel'
|
|
2366
|
+
] : [];
|
|
2367
|
+
const name = Object.getPrototypeOf(instance).constructor.name;
|
|
2368
|
+
placeWarners.set(instance, {
|
|
2369
|
+
place,
|
|
2370
|
+
keys,
|
|
2371
|
+
warn: (read)=>warnOnce(mockLogger, placeWarning(name, read, place))
|
|
2372
|
+
});
|
|
2373
|
+
for (const key of keys.filter((each)=>!given.has(each))){
|
|
2374
|
+
// A read the mock makes stays quiet, noted for a read of the mock's own to warn by its name
|
|
2375
|
+
warnOnRead(instance, key, ()=>readingPlace > 0 ? void placeReadsSeen?.add(key) : warnOnce(mockLogger, placeWarning(name, key, place)));
|
|
2376
|
+
}
|
|
2377
|
+
}
|
|
1556
2378
|
/**
|
|
1557
2379
|
* 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
|
|
1558
2380
|
* DM channel, else the server the channel names. A server channel that names none is put in `fallback`'s, and named
|
|
@@ -1597,9 +2419,11 @@ const CHANNEL_TYPE_GUARDS = [
|
|
|
1597
2419
|
cached(cacheOf(manager), id, ()=>channel);
|
|
1598
2420
|
}
|
|
1599
2421
|
}
|
|
1600
|
-
/** The guild a mock message carries: a guild with the same stubbed managers as createMockGuild. */ function createMockGuildForMessage(id
|
|
2422
|
+
/** The guild a mock message carries: a guild with the same stubbed managers as createMockGuild. */ function createMockGuildForMessage(id) {
|
|
1601
2423
|
const guild = Object.create(Guild.prototype);
|
|
1602
|
-
|
|
2424
|
+
const generatedId = id === undefined ? nextSnowflake() : undefined;
|
|
2425
|
+
guild.id = id ?? generatedId;
|
|
2426
|
+
defineMadeTime(guild, generatedId);
|
|
1603
2427
|
guild.members = managerWith(GuildMemberManager.prototype, undefined);
|
|
1604
2428
|
guild.channels = managerWith(GuildChannelManager.prototype, undefined);
|
|
1605
2429
|
guild.roles = guildRoleManager(guild.id, undefined);
|
|
@@ -1608,6 +2432,23 @@ const CHANNEL_TYPE_GUARDS = [
|
|
|
1608
2432
|
settleGuild(guild, proxy);
|
|
1609
2433
|
return proxy;
|
|
1610
2434
|
}
|
|
2435
|
+
// Each settled guild's bot member as `members.me` reads it, without caching one made for the read
|
|
2436
|
+
const botMembers = new WeakMap();
|
|
2437
|
+
/** 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) {
|
|
2438
|
+
return botMembers.get(guild)?.() ?? guild.members.me;
|
|
2439
|
+
}
|
|
2440
|
+
/**
|
|
2441
|
+
* The channel an interaction's `channel` reads, for the mock's own reads: `null` in a DM, else one set on it or its
|
|
2442
|
+
* server's cached one, without making or caching one. Undefined where `channel` would make one, which has no overwrites.
|
|
2443
|
+
*/ function heldChannelOf(interaction, guild) {
|
|
2444
|
+
const target = mockTargets.get(interaction) ?? interaction;
|
|
2445
|
+
return readPlaceQuietly(()=>{
|
|
2446
|
+
const set = Object.getOwnPropertyDescriptor(target, 'channel');
|
|
2447
|
+
if (set && 'value' in set) return set.value;
|
|
2448
|
+
const { guildId, channelId } = target;
|
|
2449
|
+
return guildId ? cacheOf(guild.channels)?.get(channelId) : null;
|
|
2450
|
+
});
|
|
2451
|
+
}
|
|
1611
2452
|
/** 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) {
|
|
1612
2453
|
for (const key of [
|
|
1613
2454
|
'members',
|
|
@@ -1629,11 +2470,13 @@ const CHANNEL_TYPE_GUARDS = [
|
|
|
1629
2470
|
configurable: true
|
|
1630
2471
|
});
|
|
1631
2472
|
const members = instance.members;
|
|
2473
|
+
const botMember = (keep)=>{
|
|
2474
|
+
const { user } = guild.client;
|
|
2475
|
+
return keep ? cached(cacheOf(members), user.id, ()=>memberIn(guild, user.id, user)) : cacheOf(members)?.get(user.id) ?? memberIn(guild, user.id, user);
|
|
2476
|
+
};
|
|
2477
|
+
botMembers.set(guild, ()=>botMember(false));
|
|
1632
2478
|
Object.defineProperty(members, 'me', {
|
|
1633
|
-
get: ()=>
|
|
1634
|
-
const { user } = guild.client;
|
|
1635
|
-
return cached(cacheOf(members), user.id, ()=>memberIn(guild, user.id, user));
|
|
1636
|
-
},
|
|
2479
|
+
get: ()=>botMember(true),
|
|
1637
2480
|
configurable: true
|
|
1638
2481
|
});
|
|
1639
2482
|
for (const key of [
|
|
@@ -1644,6 +2487,8 @@ const CHANNEL_TYPE_GUARDS = [
|
|
|
1644
2487
|
if (!Object.prototype.hasOwnProperty.call(item, 'guild')) item.guild = guild;
|
|
1645
2488
|
}
|
|
1646
2489
|
}
|
|
2490
|
+
// Strict mocks cache the bot's member from the start, as the gateway does when the bot joins, so lookups find it
|
|
2491
|
+
if (strictMocks()) void members.me;
|
|
1647
2492
|
}
|
|
1648
2493
|
/** The ids each kind of mention in `content` names, in order and once each. */ function mentionedIds(content) {
|
|
1649
2494
|
const ids = (pattern)=>[
|
|
@@ -1705,8 +2550,11 @@ const CHANNEL_TYPE_GUARDS = [
|
|
|
1705
2550
|
};
|
|
1706
2551
|
// Own values, so the prototype's getters, which read the raw mention data, are not reached
|
|
1707
2552
|
const instance = Object.create(MessageMentions.prototype);
|
|
2553
|
+
const parsedUsers = new Collection(mentioned.users);
|
|
1708
2554
|
for (const [key, value] of Object.entries({
|
|
1709
2555
|
...mentioned,
|
|
2556
|
+
parsedUsers,
|
|
2557
|
+
crosspostedChannels: new Collection(),
|
|
1710
2558
|
everyone: false,
|
|
1711
2559
|
client,
|
|
1712
2560
|
guild: guild ?? null
|
|
@@ -1719,11 +2567,14 @@ const CHANNEL_TYPE_GUARDS = [
|
|
|
1719
2567
|
return stubDeep(instance);
|
|
1720
2568
|
}
|
|
1721
2569
|
/** 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;
|
|
1722
|
-
/**
|
|
2570
|
+
/**
|
|
2571
|
+
* The message a reply method starts from: the one a component is on, as it was, or an empty original response. Without
|
|
2572
|
+
* `allocate`, one with no id of its own gets `''`, for its holder to give it one when first read.
|
|
2573
|
+
*/ function heldFrom(message, allocate = true) {
|
|
1723
2574
|
// Read only what the message really has: a mock's unset fields are stubs
|
|
1724
2575
|
const from = message ?? {};
|
|
1725
2576
|
return {
|
|
1726
|
-
id: typeof from.id === 'string' ? from.id : nextSnowflake(),
|
|
2577
|
+
id: typeof from.id === 'string' ? from.id : allocate ? nextSnowflake() : '',
|
|
1727
2578
|
content: typeof from.content === 'string' ? from.content : undefined,
|
|
1728
2579
|
components: Array.isArray(from.components) ? from.components.map(jsonOf) : [],
|
|
1729
2580
|
embeds: Array.isArray(from.embeds) ? from.embeds.map(jsonOf) : [],
|
|
@@ -1731,6 +2582,15 @@ const CHANNEL_TYPE_GUARDS = [
|
|
|
1731
2582
|
editedTimestamp: typeof from.editedTimestamp === 'number' ? from.editedTimestamp : null
|
|
1732
2583
|
};
|
|
1733
2584
|
}
|
|
2585
|
+
/** A message as an answer sends it: a new one, with what the answer gave, never edited. */ const sent = (options, allocate = true)=>{
|
|
2586
|
+
// Its id taken only once it builds, so a payload that fails to build takes none
|
|
2587
|
+
const held = edited(heldFrom(undefined, false), options);
|
|
2588
|
+
return {
|
|
2589
|
+
...held,
|
|
2590
|
+
id: allocate ? nextSnowflake() : '',
|
|
2591
|
+
editedTimestamp: null
|
|
2592
|
+
};
|
|
2593
|
+
};
|
|
1734
2594
|
/** A held message after an edit: what the edit sets replaced in Discord's shape, and a later edit stamp. */ function edited(held, options) {
|
|
1735
2595
|
const edit = typeof options === 'string' ? {
|
|
1736
2596
|
content: options
|
|
@@ -1759,29 +2619,28 @@ const CHANNEL_TYPE_GUARDS = [
|
|
|
1759
2619
|
editedTimestamp: Math.max(Date.now(), (held.editedTimestamp ?? 0) + 1)
|
|
1760
2620
|
};
|
|
1761
2621
|
}
|
|
1762
|
-
/** A mock message showing a held message. */ function messageFrom(held) {
|
|
1763
|
-
return
|
|
2622
|
+
/** 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) {
|
|
2623
|
+
return sentMessage(held, channel, client, {
|
|
1764
2624
|
id: held.id,
|
|
1765
|
-
...held.content !== undefined && {
|
|
1766
|
-
content: held.content
|
|
1767
|
-
},
|
|
1768
|
-
components: held.components,
|
|
1769
|
-
embeds: held.embeds,
|
|
1770
|
-
...held.flags !== undefined && {
|
|
1771
|
-
flags: held.flags
|
|
1772
|
-
},
|
|
1773
2625
|
editedTimestamp: held.editedTimestamp
|
|
1774
|
-
});
|
|
2626
|
+
}, made);
|
|
1775
2627
|
}
|
|
1776
2628
|
/**
|
|
1777
|
-
* A component or embed as a message holds it: a discord.js instance as it is
|
|
1778
|
-
* its API JSON at the time of the call,
|
|
1779
|
-
*/ function asHeld(value) {
|
|
2629
|
+
* A component or embed as a message holds it: a discord.js instance as it is, anything else as the discord.js class
|
|
2630
|
+
* built from its API JSON at the time of the call, as discord.js builds it, an unknown type as a plain `Component`.
|
|
2631
|
+
*/ function asHeld(value, kind) {
|
|
1780
2632
|
if (value instanceof Component || value instanceof Embed) return value;
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
2633
|
+
// A copy, since discord.js's classes keep what they are given, one level deep
|
|
2634
|
+
const json = structuredClone(jsonOf(value));
|
|
2635
|
+
if (kind === 'embed') return new Embed(json);
|
|
2636
|
+
// An action row builds each child with discord.js's own createComponent, which it doesn't export
|
|
2637
|
+
const row = new ActionRow({
|
|
2638
|
+
type: ComponentType.ActionRow,
|
|
2639
|
+
components: [
|
|
2640
|
+
json
|
|
2641
|
+
]
|
|
2642
|
+
});
|
|
2643
|
+
return row.components[0];
|
|
1785
2644
|
}
|
|
1786
2645
|
/**
|
|
1787
2646
|
* Creates a mock {@link Message} that tracks whether it has been deleted.
|
|
@@ -1790,12 +2649,14 @@ const CHANNEL_TYPE_GUARDS = [
|
|
|
1790
2649
|
* `@MessageHandler` does not skip it.
|
|
1791
2650
|
*
|
|
1792
2651
|
* @remarks
|
|
1793
|
-
* `delete()`, `edit()`, `reply()`, `react()`, `pin()` and `unpin()` throw once the message is deleted
|
|
1794
|
-
* and `reply()`
|
|
1795
|
-
*
|
|
1796
|
-
* one
|
|
1797
|
-
*
|
|
1798
|
-
*
|
|
2652
|
+
* `delete()`, `edit()`, `reply()`, `react()`, `pin()` and `unpin()` throw once the message is deleted. `edit()` changes
|
|
2653
|
+
* this message and resolves to it, and `reply()` resolves to the reply, sent in this message's channel and holding what
|
|
2654
|
+
* was sent, as a channel's `send()` does. Under `useStrictMocks()` a message the bot sends is its `client.user`'s, and
|
|
2655
|
+
* 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
|
|
2656
|
+
* `member`; with `guild: null` it is a direct message. Give `author` to send several messages as one user, such as to
|
|
2657
|
+
* reach a per-user cooldown. An author given, and the users, roles and channels the content mentions, are cached as the
|
|
2658
|
+
* gateway delivers them. It is typed as discord.js emits a message, never in a group DM, so it fits `messageCreate` and
|
|
2659
|
+
* the other message events.
|
|
1799
2660
|
*
|
|
1800
2661
|
* @param overrides - The message's content, components and the rest; see {@link MockMessageOverrides}.
|
|
1801
2662
|
*
|
|
@@ -1818,11 +2679,18 @@ const CHANNEL_TYPE_GUARDS = [
|
|
|
1818
2679
|
* @see {@link MockMessageOverrides}
|
|
1819
2680
|
* @see {@link createMockGuild}
|
|
1820
2681
|
*/ function createMockMessage(overrides = {}) {
|
|
2682
|
+
return messageMock(overrides);
|
|
2683
|
+
}
|
|
2684
|
+
function messageMock(overrides = {}, sentBy, made = {}, at) {
|
|
2685
|
+
var _made, _made1, _made2;
|
|
1821
2686
|
const instance = Object.create(Message.prototype);
|
|
1822
2687
|
const stubs = new Map();
|
|
2688
|
+
const unplaced = overrides.channel === undefined && overrides.guild === undefined;
|
|
2689
|
+
// A message the bot sent is placed where it was sent, even where default mode reads a server of its own for a DM
|
|
2690
|
+
if (unplaced && !sentBy) placesMade.add(instance);
|
|
1823
2691
|
instance.deleted = false;
|
|
1824
2692
|
// Constructor-assigned — set as prototype-based stubs; a user rather than a bot, as dispatch handles only those
|
|
1825
|
-
instance.author = overrides.author ?? mockUser();
|
|
2693
|
+
instance.author = overrides.author ?? (_made = made).author ?? (_made.author = mockUser());
|
|
1826
2694
|
// Getters on the prototype, which the proxy would answer with a stub object: own properties shadow them
|
|
1827
2695
|
// A channel given says where the message was sent, unless its guild is given too
|
|
1828
2696
|
const place = overrides.channel ? placeOf(overrides.channel, overrides.guild === undefined ? undefined : overrides.guild?.id ?? null, ()=>{
|
|
@@ -1832,22 +2700,37 @@ const CHANNEL_TYPE_GUARDS = [
|
|
|
1832
2700
|
guild
|
|
1833
2701
|
};
|
|
1834
2702
|
}) : undefined;
|
|
1835
|
-
const guild = overrides.guild !== undefined ? overrides.guild : place?.guildId !== undefined ? place.guildId === null ? null : place.guild ?? createMockGuildForMessage(place.guildId) : createMockGuildForMessage();
|
|
1836
|
-
const
|
|
1837
|
-
|
|
2703
|
+
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();
|
|
2704
|
+
const madeChannel = ()=>channelFor(guild, guild?.id ?? null, nextSnowflake(), instance.author);
|
|
2705
|
+
const channel = at ? undefined : overrides.channel ?? (unplaced ? (_made2 = made).channel ?? (_made2.channel = madeChannel()) : madeChannel());
|
|
2706
|
+
if (channel) Object.defineProperty(instance, 'channel', {
|
|
1838
2707
|
value: channel,
|
|
1839
|
-
writable: true
|
|
2708
|
+
writable: true,
|
|
2709
|
+
configurable: true
|
|
1840
2710
|
});
|
|
2711
|
+
else {
|
|
2712
|
+
Object.defineProperty(instance, 'channel', {
|
|
2713
|
+
get: at.channel,
|
|
2714
|
+
set: (value)=>Object.defineProperty(instance, 'channel', {
|
|
2715
|
+
value,
|
|
2716
|
+
writable: true,
|
|
2717
|
+
configurable: true
|
|
2718
|
+
}),
|
|
2719
|
+
configurable: true
|
|
2720
|
+
});
|
|
2721
|
+
}
|
|
1841
2722
|
Object.defineProperty(instance, 'guild', {
|
|
1842
2723
|
value: guild,
|
|
1843
|
-
writable: true
|
|
2724
|
+
writable: true,
|
|
2725
|
+
configurable: true
|
|
1844
2726
|
});
|
|
1845
2727
|
// The author as a member of the message's server; a direct message has none. A given author's member is the one
|
|
1846
2728
|
// the server caches, as the gateway resolves it, so every message from that author has the same member
|
|
1847
|
-
// The author's member, cached as the gateway caches it with the message; `member` reads the cache, as discord.js
|
|
2729
|
+
// The author's member, cached as the gateway caches it with the message; `member` reads the cache, as discord.js
|
|
2730
|
+
// does, without calling the manager's resolve a test may stub
|
|
1848
2731
|
if (guild) memberFor(guild, instance.author, true);
|
|
1849
2732
|
Object.defineProperty(instance, 'member', {
|
|
1850
|
-
get: ()=>guild ? guild.members
|
|
2733
|
+
get: ()=>instance.guild ? cacheOf(instance.guild.members)?.get(instance.author.id) ?? null : null,
|
|
1851
2734
|
set: (value)=>Object.defineProperty(instance, 'member', {
|
|
1852
2735
|
value,
|
|
1853
2736
|
writable: true,
|
|
@@ -1858,15 +2741,16 @@ const CHANNEL_TYPE_GUARDS = [
|
|
|
1858
2741
|
configurable: true
|
|
1859
2742
|
});
|
|
1860
2743
|
// In a server's text channel, the ids matching the objects; with no guild, a DM
|
|
1861
|
-
instance.channelId = channel.
|
|
1862
|
-
instance.guildId = guild?.id ?? null;
|
|
2744
|
+
instance.channelId = channel?.id ?? at.channelId;
|
|
2745
|
+
instance.guildId = at ? at.guildId : guild?.id ?? null;
|
|
1863
2746
|
// The thread its channel caches under its id, as discord.js reads it; until 5.0, a placeholder thread otherwise
|
|
1864
2747
|
let placeholderThread;
|
|
1865
2748
|
Object.defineProperty(instance, 'thread', {
|
|
1866
2749
|
get: ()=>{
|
|
1867
2750
|
const found = cacheOf(instance.channel.threads)?.get(instance.id);
|
|
1868
2751
|
if (found) return found;
|
|
1869
|
-
|
|
2752
|
+
if (strictMocks()) return null;
|
|
2753
|
+
warnPlaceholder(mockLogger, 'Message.thread', 'it is null unless the channel caches a thread under the message id, as discord.js reads it', 'Cache one with message.channel.threads.cache.set(message.id, thread), or call useStrictMocks() to have it read null now.');
|
|
1870
2754
|
return placeholderThread ?? (placeholderThread = stubDeep(Object.create(ThreadChannel.prototype)));
|
|
1871
2755
|
},
|
|
1872
2756
|
set: (value)=>Object.defineProperty(instance, 'thread', {
|
|
@@ -1888,7 +2772,7 @@ const CHANNEL_TYPE_GUARDS = [
|
|
|
1888
2772
|
configurable: true
|
|
1889
2773
|
});
|
|
1890
2774
|
if (guild instanceof Guild) cached(cacheOf(client.guilds), guild.id, ()=>guild);
|
|
1891
|
-
cacheChannel(channel, guild, client);
|
|
2775
|
+
if (channel) cacheChannel(channel, guild, client);
|
|
1892
2776
|
const userCache = cacheOf(client.users);
|
|
1893
2777
|
for (const user of [
|
|
1894
2778
|
...overrides.users ?? [],
|
|
@@ -1898,8 +2782,8 @@ const CHANNEL_TYPE_GUARDS = [
|
|
|
1898
2782
|
instance.mentions = mentionsOf(overrides.content, client, guild);
|
|
1899
2783
|
// Data a message always has, real rather than stubbed, so code reading it sees an empty message
|
|
1900
2784
|
instance.flags = new MessageFlagsBitField(overrides.flags);
|
|
1901
|
-
instance.components = (overrides.components ?? []).map(asHeld);
|
|
1902
|
-
instance.embeds = (overrides.embeds ?? []).map(asHeld);
|
|
2785
|
+
instance.components = (overrides.components ?? []).map((component)=>asHeld(component, 'component'));
|
|
2786
|
+
instance.embeds = (overrides.embeds ?? []).map((embed)=>asHeld(embed, 'embed'));
|
|
1903
2787
|
instance.attachments = new Collection();
|
|
1904
2788
|
instance.editedTimestamp = overrides.editedTimestamp ?? null;
|
|
1905
2789
|
const generatedId = overrides.id === undefined ? nextSnowflake() : undefined;
|
|
@@ -1912,14 +2796,27 @@ const CHANNEL_TYPE_GUARDS = [
|
|
|
1912
2796
|
if (instance.deleted) throw alreadyDeleted();
|
|
1913
2797
|
instance.deleted = true;
|
|
1914
2798
|
}));
|
|
1915
|
-
|
|
2799
|
+
// An edit changes this message, as Discord edits it in place, and resolves to it
|
|
2800
|
+
stubs.set('edit', createMockFn(async (options)=>{
|
|
1916
2801
|
if (instance.deleted) throw alreadyDeleted();
|
|
1917
|
-
|
|
2802
|
+
const held = edited(heldFrom(undefined, false), options);
|
|
2803
|
+
const edit = typeof options === 'string' ? {
|
|
2804
|
+
content: options
|
|
2805
|
+
} : options ?? {};
|
|
2806
|
+
if (edit.content !== undefined) instance.content = held.content;
|
|
2807
|
+
if (edit.components !== undefined) instance.components = held.components.map((component)=>asHeld(component, 'component'));
|
|
2808
|
+
if (edit.embeds !== undefined) instance.embeds = held.embeds.map((embed)=>asHeld(embed, 'embed'));
|
|
2809
|
+
if (edit.flags !== undefined) instance.flags = new MessageFlagsBitField(held.flags);
|
|
2810
|
+
instance.editedTimestamp = Math.max(Date.now(), (instance.editedTimestamp ?? 0) + 1);
|
|
2811
|
+
return message;
|
|
1918
2812
|
}));
|
|
1919
|
-
stubs.set('reply', createMockFn(async ()=>{
|
|
2813
|
+
stubs.set('reply', createMockFn(async (options)=>{
|
|
1920
2814
|
if (instance.deleted) throw alreadyDeleted();
|
|
1921
|
-
return
|
|
2815
|
+
return sentMessage(sent(options, false), instance.channel, instance.client);
|
|
1922
2816
|
}));
|
|
2817
|
+
// Crossposting publishes this message, and forwarding sends one in the channel it is forwarded to
|
|
2818
|
+
stubs.set('crosspost', createMockFn(async ()=>message));
|
|
2819
|
+
stubs.set('forward', createMockFn(async (channel)=>sentMessage(sent(undefined, false), channel, instance.client)));
|
|
1923
2820
|
stubs.set('react', createMockFn(async ()=>{
|
|
1924
2821
|
if (instance.deleted) throw alreadyDeleted();
|
|
1925
2822
|
}));
|
|
@@ -1929,7 +2826,85 @@ const CHANNEL_TYPE_GUARDS = [
|
|
|
1929
2826
|
stubs.set('unpin', createMockFn(async ()=>{
|
|
1930
2827
|
if (instance.deleted) throw alreadyDeleted();
|
|
1931
2828
|
}));
|
|
1932
|
-
|
|
2829
|
+
if (sentBy) warnOnSentReads(instance, stubs, sentBy);
|
|
2830
|
+
const message = stubDeep(instance, stubs);
|
|
2831
|
+
return message;
|
|
2832
|
+
}
|
|
2833
|
+
/**
|
|
2834
|
+
* Has a message the bot sent warn once where strict mocks read it otherwise: its author, the bot's `client.user` under
|
|
2835
|
+
* strict mocks, and, for one sent in a DM, the server it reads as being in.
|
|
2836
|
+
*/ function warnOnSentReads(instance, stubs, sentBy) {
|
|
2837
|
+
// A read made while reading another of these, as `member` reads `author`, warns only for the outer one
|
|
2838
|
+
let reading = 0;
|
|
2839
|
+
const read = (key, warning)=>{
|
|
2840
|
+
const descriptor = Object.getOwnPropertyDescriptor(instance, key);
|
|
2841
|
+
Object.defineProperty(instance, key, {
|
|
2842
|
+
get: ()=>{
|
|
2843
|
+
if (reading === 0) warnOnce(mockLogger, warning);
|
|
2844
|
+
reading++;
|
|
2845
|
+
try {
|
|
2846
|
+
return descriptor?.get ? descriptor.get.call(instance) : descriptor?.value;
|
|
2847
|
+
} finally{
|
|
2848
|
+
reading--;
|
|
2849
|
+
}
|
|
2850
|
+
},
|
|
2851
|
+
set: (next)=>Object.defineProperty(instance, key, {
|
|
2852
|
+
value: next,
|
|
2853
|
+
writable: true,
|
|
2854
|
+
enumerable: true,
|
|
2855
|
+
configurable: true
|
|
2856
|
+
}),
|
|
2857
|
+
enumerable: true,
|
|
2858
|
+
configurable: true
|
|
2859
|
+
});
|
|
2860
|
+
};
|
|
2861
|
+
if (sentBy.bot) {
|
|
2862
|
+
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.");
|
|
2863
|
+
}
|
|
2864
|
+
if (!sentBy.dm) return;
|
|
2865
|
+
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.";
|
|
2866
|
+
const inGuild = Boolean(instance.guildId);
|
|
2867
|
+
for (const key of [
|
|
2868
|
+
'guild',
|
|
2869
|
+
'guildId',
|
|
2870
|
+
'member'
|
|
2871
|
+
])read(key, inDm(key));
|
|
2872
|
+
stubs.set('inGuild', createMockFn(()=>(warnOnce(mockLogger, inDm('inGuild()')), inGuild)));
|
|
2873
|
+
}
|
|
2874
|
+
/**
|
|
2875
|
+
* A message the bot sent, as Discord returns it: in the channel it was sent in and that channel's server, holding what
|
|
2876
|
+
* was sent. Under strict mocks it is the bot's `client.user`'s, and one sent in a DM is in that DM; default mode places
|
|
2877
|
+
* a DM send in a server of its own and keeps another author, with a warning when read.
|
|
2878
|
+
*/ function sentMessage(held, channel, client, fields = {}, made) {
|
|
2879
|
+
const by = client ?? channel?.client;
|
|
2880
|
+
const inChannel = channel instanceof BaseChannel;
|
|
2881
|
+
const dm = inChannel && channel.isDMBased();
|
|
2882
|
+
const strict = strictMocks();
|
|
2883
|
+
const message = messageMock({
|
|
2884
|
+
...held.content !== undefined && {
|
|
2885
|
+
content: held.content
|
|
2886
|
+
},
|
|
2887
|
+
components: held.components,
|
|
2888
|
+
embeds: held.embeds,
|
|
2889
|
+
...held.flags !== undefined && {
|
|
2890
|
+
flags: held.flags
|
|
2891
|
+
},
|
|
2892
|
+
...inChannel && (!dm || strict) && {
|
|
2893
|
+
channel: channel
|
|
2894
|
+
},
|
|
2895
|
+
...by && {
|
|
2896
|
+
client: by
|
|
2897
|
+
},
|
|
2898
|
+
...strict && by?.user && {
|
|
2899
|
+
author: by.user
|
|
2900
|
+
},
|
|
2901
|
+
...fields
|
|
2902
|
+
}, strict ? undefined : {
|
|
2903
|
+
bot: Boolean(by?.user),
|
|
2904
|
+
dm
|
|
2905
|
+
}, made);
|
|
2906
|
+
if (dm && !strict) sentInDm.set(mockTargets.get(message) ?? message, channel);
|
|
2907
|
+
return message;
|
|
1933
2908
|
}
|
|
1934
2909
|
/** The option type Discord would have sent for a given JavaScript value. */ function optionTypeOf(value) {
|
|
1935
2910
|
if (typeof value === 'boolean') return ApplicationCommandOptionType.Boolean;
|
|
@@ -2018,11 +2993,32 @@ const CHANNEL_TYPE_GUARDS = [
|
|
|
2018
2993
|
else if (isEntity) option.user = value;
|
|
2019
2994
|
return option;
|
|
2020
2995
|
}
|
|
2996
|
+
/**
|
|
2997
|
+
* The option an autocomplete's user is typing, as Discord sends it: its value a string, `''` before anything is typed.
|
|
2998
|
+
* A number given for it is its digits under strict mocks; otherwise it stays a number, with a warning when read.
|
|
2999
|
+
*/ function focusedOptionData(name, value) {
|
|
3000
|
+
const option = toOptionData(name, value ?? '');
|
|
3001
|
+
option.focused = true;
|
|
3002
|
+
if (typeof value === 'number') {
|
|
3003
|
+
if (strictMocks()) option.value = String(value);
|
|
3004
|
+
else {
|
|
3005
|
+
Object.defineProperty(option, 'value', {
|
|
3006
|
+
get: ()=>{
|
|
3007
|
+
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.`);
|
|
3008
|
+
return value;
|
|
3009
|
+
},
|
|
3010
|
+
enumerable: true
|
|
3011
|
+
});
|
|
3012
|
+
}
|
|
3013
|
+
}
|
|
3014
|
+
return option;
|
|
3015
|
+
}
|
|
2021
3016
|
/**
|
|
2022
3017
|
* Nests the supplied options under the subcommand path they were invoked through,
|
|
2023
3018
|
* matching the shape Discord sends rather than a flat list.
|
|
2024
|
-
*/ function buildOptionData(subcommandGroup, subcommand, values, memberOf) {
|
|
2025
|
-
const leaves = Object.entries(values).map(([name, value])=>toOptionData(name, value, memberOf));
|
|
3019
|
+
*/ function buildOptionData(subcommandGroup, subcommand, values, memberOf, focused) {
|
|
3020
|
+
const leaves = Object.entries(values).map(([name, value])=>name === focused ? focusedOptionData(name, value) : toOptionData(name, value, memberOf));
|
|
3021
|
+
if (focused !== null && !Object.hasOwn(values, focused)) leaves.push(focusedOptionData(focused, null));
|
|
2026
3022
|
if (subcommand === null) return leaves;
|
|
2027
3023
|
const sub = {
|
|
2028
3024
|
name: subcommand,
|
|
@@ -2062,6 +3058,10 @@ const CHANNEL_TYPE_GUARDS = [
|
|
|
2062
3058
|
* A whole number is an Integer option and a fraction a Number one, so
|
|
2063
3059
|
* `getInteger()` reads only a whole number, and `getNumber()` reads either.
|
|
2064
3060
|
*
|
|
3061
|
+
* The `focused` option reads as Discord sends it, through `getFocused()`, `getFocused(true)` and `data` alike: a
|
|
3062
|
+
* string, `''` when no value is given, and marked `focused` in `data`. A number given for it is its digits under
|
|
3063
|
+
* `useStrictMocks()`; otherwise it stays the number, with a warning when read.
|
|
3064
|
+
*
|
|
2065
3065
|
* @typeParam Cached - `any` by default, to match `createMockInteraction(ChatInputCommandInteraction)`; pass it when the
|
|
2066
3066
|
* interaction under test is cache-pinned.
|
|
2067
3067
|
*
|
|
@@ -2135,7 +3135,8 @@ const CHANNEL_TYPE_GUARDS = [
|
|
|
2135
3135
|
if (!unowned.has(name)) unowned.set(name, memberIn(undefined, user.id, user));
|
|
2136
3136
|
return unowned.get(name);
|
|
2137
3137
|
}
|
|
2138
|
-
|
|
3138
|
+
const rawOwner = !owner.guild && isRawMember(Object.getOwnPropertyDescriptor(owner, 'member')?.value);
|
|
3139
|
+
if (!members.has(name)) members.set(name, owner.guildId ? rawOwner ? rawResolvedMember() : memberFor(owner.guild, user, true) : null);
|
|
2139
3140
|
return members.get(name) ?? null;
|
|
2140
3141
|
};
|
|
2141
3142
|
base.getUser = createMockFn((name, required)=>{
|
|
@@ -2181,17 +3182,21 @@ const CHANNEL_TYPE_GUARDS = [
|
|
|
2181
3182
|
], ()=>false, required));
|
|
2182
3183
|
base.getFocused = createMockFn((getFull)=>{
|
|
2183
3184
|
if (focused === null) throw optionError(DiscordjsErrorCodes.AutocompleteInteractionOptionNoFocusedOption);
|
|
2184
|
-
|
|
3185
|
+
// The one options.data holds, so the two agree
|
|
3186
|
+
const option = base._hoistedOptions.find((each)=>each.focused);
|
|
2185
3187
|
return getFull === true ? {
|
|
2186
|
-
...option
|
|
2187
|
-
focused: true
|
|
3188
|
+
...option
|
|
2188
3189
|
} : option.value;
|
|
2189
3190
|
});
|
|
2190
3191
|
// `data` is what the framework reads to build a handler's params, so it is materialised here rather than
|
|
2191
3192
|
// auto-stubbed, or every params assertion would see an empty record.
|
|
2192
|
-
base.data = buildOptionData(subcommandGroup, subcommand, values, memberOf);
|
|
3193
|
+
base.data = buildOptionData(subcommandGroup, subcommand, values, memberOf, focused);
|
|
3194
|
+
// The fields discord.js's constructor sets from it, which its toString() reads
|
|
3195
|
+
base._group = subcommandGroup;
|
|
3196
|
+
base._subcommand = subcommand;
|
|
3197
|
+
base._hoistedOptions = subcommand === null ? base.data : (subcommandGroup === null ? base.data[0] : base.data[0].options[0]).options;
|
|
2193
3198
|
const resolver = stubDeep(base);
|
|
2194
3199
|
return resolver;
|
|
2195
3200
|
}
|
|
2196
3201
|
|
|
2197
|
-
export { RESPONSE_LOG, createChatInputOptions, createMock, createMockChannel, createMockClient, createMockGuild, createMockInteraction, createMockMember, createMockMessage, createMockUser, memberRoles };
|
|
3202
|
+
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 };
|