meocord 4.2.1 → 4.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +58 -0
- package/dist/cjs/_shared/{controller.decorator-XKMlcUZz.cjs → controller.decorator-U2NBWcoU.cjs} +45 -13
- package/dist/cjs/_shared/{providers-BZgJyfZS.cjs → providers-B4MOFBrW.cjs} +58 -15
- package/dist/cjs/_shared/{sharded-cooldown-store-CsJJyCZd.cjs → sharded-cooldown-store-HTUb3Ii_.cjs} +1 -1
- package/dist/cjs/_shared/{startup-roots-CgfPQrNl.cjs → startup-roots-lA5xkBFq.cjs} +12 -11
- package/dist/cjs/_shared/{translate-error-BUAR1WYG.cjs → translate-error-CbBHUu5P.cjs} +1 -1
- package/dist/cjs/common/index.cjs +3 -3
- package/dist/cjs/core/index.cjs +8 -6
- package/dist/cjs/decorator/index.cjs +33 -3
- package/dist/cjs/testing/index.cjs +295 -60
- package/dist/cli.json +1 -1
- package/dist/esm/common/response/response-state.js +5 -5
- package/dist/esm/common/translator.js +15 -5
- package/dist/esm/core/dispatcher.js +9 -8
- package/dist/esm/core/filter-runner.js +2 -2
- package/dist/esm/core/handler-pipeline.js +10 -4
- package/dist/esm/core/meocord-factory.js +4 -2
- package/dist/esm/core/message-help.js +32 -6
- package/dist/esm/core/observer-runner.js +2 -1
- package/dist/esm/core/providers.js +12 -2
- package/dist/esm/core/stage-scope.js +2 -1
- package/dist/esm/core/theme-scope.js +4 -1
- package/dist/esm/decorator/app.decorator.js +31 -0
- package/dist/esm/decorator/filter.decorator.js +2 -2
- package/dist/esm/package.json.js +1 -1
- package/dist/esm/testing/catalog.js +4 -13
- package/dist/esm/testing/discord-defaults.js +21 -1
- package/dist/esm/testing/meocord-testing-module.js +24 -16
- package/dist/esm/testing/mock-interaction.js +242 -28
- package/dist/esm/testing/strict-mocks.js +7 -4
- package/dist/esm/util/value.util.js +19 -2
- package/dist/types/common/index.d.cts +4 -1
- package/dist/types/common/index.d.ts +4 -1
- package/dist/types/testing/index.d.cts +28 -14
- package/dist/types/testing/index.d.ts +28 -14
- package/package.json +1 -1
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
require('reflect-metadata');
|
|
4
4
|
var inversify = require('inversify');
|
|
5
|
-
var providers = require('../_shared/providers-
|
|
5
|
+
var providers = require('../_shared/providers-B4MOFBrW.cjs');
|
|
6
6
|
var discord_js = require('discord.js');
|
|
7
7
|
var enum_index = require('../enum/index.cjs');
|
|
8
|
-
var controller_decorator = require('../_shared/controller.decorator-
|
|
9
|
-
var startupRoots = require('../_shared/startup-roots-
|
|
8
|
+
var controller_decorator = require('../_shared/controller.decorator-U2NBWcoU.cjs');
|
|
9
|
+
var startupRoots = require('../_shared/startup-roots-lA5xkBFq.cjs');
|
|
10
10
|
var node_crypto = require('node:crypto');
|
|
11
11
|
require('node:async_hooks');
|
|
12
12
|
require('crypto');
|
|
@@ -21,7 +21,7 @@ require('dayjs/plugin/utc.js');
|
|
|
21
21
|
require('dayjs/plugin/timezone.js');
|
|
22
22
|
require('module');
|
|
23
23
|
require('chalk');
|
|
24
|
-
require('../_shared/translate-error-
|
|
24
|
+
require('../_shared/translate-error-CbBHUu5P.cjs');
|
|
25
25
|
|
|
26
26
|
// Framework-agnostic mock function, so `meocord/testing` depends on neither jest nor vitest.
|
|
27
27
|
// Both detect it through `_isMockFunction` and read `fn.mock.calls` in their assertions.
|
|
@@ -517,8 +517,9 @@ const settings = controller_decorator.shared('strictMocks', ()=>({
|
|
|
517
517
|
* and `message.thread` is `null` when the channel caches no thread under the message's id. So do a message's
|
|
518
518
|
* `editedAt`, a member's `presence`, a server's `verified` and `systemChannel` and a channel's `parent`, each `null` or
|
|
519
519
|
* `false` until the test gives what it is computed from. A reaction's `me` is `false`, a message's
|
|
520
|
-
* `mentions.repliedUser` and a modal's `message` are `null`,
|
|
521
|
-
* MeoCord's dispatcher fetches neither. No
|
|
520
|
+
* `mentions.repliedUser` and a modal's `message` are `null`, an interaction's `ephemeral` is `null` until `reply()` or
|
|
521
|
+
* `deferReply()` decides it, and a reaction has a whole message of its own, so MeoCord's dispatcher fetches neither. No
|
|
522
|
+
* placeholder warning is logged.
|
|
522
523
|
*
|
|
523
524
|
* @remarks
|
|
524
525
|
* Those getters read what a server holds, so strict mocks also have it as Discord sends it: the bot's member is in the
|
|
@@ -531,8 +532,10 @@ const settings = controller_decorator.shared('strictMocks', ()=>({
|
|
|
531
532
|
* A message the bot sends is its `client.user`'s, and one it sends in a DM is in that DM. Give the bot's member a role
|
|
532
533
|
* to let it do more, such as kick a member its role ranks above. A value the test sets on a mock wins over the computed
|
|
533
534
|
* one. An interaction's answer that discord.js would refuse, such as a second `reply()` after one a test set with
|
|
534
|
-
* `mockResolvedValue`, is refused too, where without it the mock runs it and warns.
|
|
535
|
-
*
|
|
535
|
+
* `mockResolvedValue`, is refused too, where without it the mock runs it and warns. An answer marks the interaction
|
|
536
|
+
* `replied`, `deferred` or `responded` once it resolves, as discord.js does, so code that doesn't await one fails as it
|
|
537
|
+
* would on a bot, within one run of the test's code. The next major version (5.0) computes these values, and refuses
|
|
538
|
+
* those answers, without the call.
|
|
536
539
|
*
|
|
537
540
|
* `invoke` also refuses a call that gives a handler fewer arguments than it declares where it builds none, such as a
|
|
538
541
|
* reaction handler given its reaction without its `ReactionEvent`; without strict mocks, it warns once and calls it.
|
|
@@ -684,6 +687,26 @@ const channelPermissions = discord_js.GuildChannel.prototype;
|
|
|
684
687
|
roles: (member)=>memberRoles(member, []),
|
|
685
688
|
permissions: REAL_GETTER
|
|
686
689
|
}),
|
|
690
|
+
// A server's emoji, sticker, scheduled event and AutoMod rule are named, and what discord.js formats from them
|
|
691
|
+
// computes: an emoji's mention and identifier, an event's and an invite's link
|
|
692
|
+
defaultsOf(discord_js.GuildEmoji, {
|
|
693
|
+
name: ()=>'emoji',
|
|
694
|
+
animated: ()=>false,
|
|
695
|
+
identifier: REAL_GETTER
|
|
696
|
+
}),
|
|
697
|
+
defaultsOf(discord_js.Sticker, {
|
|
698
|
+
name: ()=>'sticker'
|
|
699
|
+
}),
|
|
700
|
+
defaultsOf(discord_js.GuildScheduledEvent, {
|
|
701
|
+
name: ()=>'event',
|
|
702
|
+
url: REAL_GETTER
|
|
703
|
+
}),
|
|
704
|
+
defaultsOf(discord_js.AutoModerationRule, {
|
|
705
|
+
name: ()=>'rule'
|
|
706
|
+
}),
|
|
707
|
+
defaultsOf(discord_js.Invite, {
|
|
708
|
+
url: REAL_GETTER
|
|
709
|
+
}),
|
|
687
710
|
defaultsOf(discord_js.Role, {
|
|
688
711
|
name: ()=>'role',
|
|
689
712
|
// Above @everyone, which a server's role manager makes at position 0
|
|
@@ -1584,8 +1607,49 @@ const MANAGER_ITEMS = [
|
|
|
1584
1607
|
guild: guildOf(manager)
|
|
1585
1608
|
} : {}
|
|
1586
1609
|
})
|
|
1610
|
+
],
|
|
1611
|
+
[
|
|
1612
|
+
discord_js.GuildEmojiManager,
|
|
1613
|
+
(id, manager)=>itemInGuild(discord_js.GuildEmoji, {
|
|
1614
|
+
id: id ?? nextSnowflake()
|
|
1615
|
+
}, manager)
|
|
1616
|
+
],
|
|
1617
|
+
[
|
|
1618
|
+
discord_js.GuildStickerManager,
|
|
1619
|
+
(id, manager)=>itemInGuild(discord_js.Sticker, {
|
|
1620
|
+
id: id ?? nextSnowflake()
|
|
1621
|
+
}, manager)
|
|
1622
|
+
],
|
|
1623
|
+
[
|
|
1624
|
+
discord_js.GuildScheduledEventManager,
|
|
1625
|
+
(id, manager)=>itemInGuild(discord_js.GuildScheduledEvent, {
|
|
1626
|
+
id: id ?? nextSnowflake()
|
|
1627
|
+
}, manager)
|
|
1628
|
+
],
|
|
1629
|
+
[
|
|
1630
|
+
discord_js.AutoModerationRuleManager,
|
|
1631
|
+
(id, manager)=>itemInGuild(discord_js.AutoModerationRule, {
|
|
1632
|
+
id: id ?? nextSnowflake()
|
|
1633
|
+
}, manager)
|
|
1634
|
+
],
|
|
1635
|
+
// An invite is keyed by its code, as discord.js caches and fetches one
|
|
1636
|
+
[
|
|
1637
|
+
discord_js.GuildInviteManager,
|
|
1638
|
+
(code, manager)=>itemInGuild(discord_js.Invite, {
|
|
1639
|
+
code: code ?? BigInt(nextSnowflake()).toString(36)
|
|
1640
|
+
}, manager)
|
|
1587
1641
|
]
|
|
1588
1642
|
];
|
|
1643
|
+
/** A mock of `Class` with `props`, in the manager's guild, by the guild and its id, when the manager has one. */ function itemInGuild(Class, props, manager) {
|
|
1644
|
+
const guild = guildOf(manager);
|
|
1645
|
+
return createMockInteraction(Class, {
|
|
1646
|
+
...props,
|
|
1647
|
+
...guild ? {
|
|
1648
|
+
guild,
|
|
1649
|
+
guildId: guild.id
|
|
1650
|
+
} : {}
|
|
1651
|
+
});
|
|
1652
|
+
}
|
|
1589
1653
|
const returnsPromise = (key, method)=>method.constructor.name === 'AsyncFunction' || PROMISE_METHODS.has(key) || /^set[A-Z]/.test(key) && !SYNC_SETTERS.has(key);
|
|
1590
1654
|
// The ids a fetch asks for: an id, a discord.js object, or options naming one or several, such as `{ user: id }` or
|
|
1591
1655
|
// `{ user: [ids] }`; undefined for a fetch of a list
|
|
@@ -1659,6 +1723,18 @@ function fetchedIds(args) {
|
|
|
1659
1723
|
return typeof user === 'string' ? user : user?.id;
|
|
1660
1724
|
});
|
|
1661
1725
|
}
|
|
1726
|
+
// An invite is made for the channel given, in its guild, and cached nowhere, as discord.js's create does
|
|
1727
|
+
if (item && key === 'create' && target instanceof discord_js.GuildInviteManager) {
|
|
1728
|
+
return createMockFn(async (channel)=>{
|
|
1729
|
+
const invite = item(undefined, receiver());
|
|
1730
|
+
const channelId = typeof channel === 'string' ? channel : channel?.id;
|
|
1731
|
+
const found = channel instanceof discord_js.BaseChannel ? channel : channelId ? cacheOf(guildOf(receiver())?.channels)?.get(channelId) ?? null : null;
|
|
1732
|
+
return Object.assign(invite, {
|
|
1733
|
+
channel: found,
|
|
1734
|
+
channelId: channelId ?? null
|
|
1735
|
+
});
|
|
1736
|
+
});
|
|
1737
|
+
}
|
|
1662
1738
|
// Every other manager here caches what its create() makes, as discord.js's does; check a manager's create when
|
|
1663
1739
|
// adding it to MANAGER_ITEMS
|
|
1664
1740
|
if (item && key === 'create') {
|
|
@@ -1875,24 +1951,36 @@ const isSnowflake = (id)=>typeof id === 'string' && /^\d{1,20}$/.test(id);
|
|
|
1875
1951
|
};
|
|
1876
1952
|
controller_decorator.stampCall(call);
|
|
1877
1953
|
log.push(call);
|
|
1878
|
-
let
|
|
1954
|
+
let gated;
|
|
1879
1955
|
try {
|
|
1880
|
-
// Before the behaviour, as discord.js
|
|
1881
|
-
if (gate)
|
|
1956
|
+
// Before the behaviour, as discord.js checks the state before it sends an answer
|
|
1957
|
+
if (gate) gated = gate(args, byTest);
|
|
1882
1958
|
} catch (error) {
|
|
1883
1959
|
call.error = error;
|
|
1884
1960
|
return Promise.reject(error);
|
|
1885
1961
|
}
|
|
1886
1962
|
const failed = (error)=>{
|
|
1887
|
-
|
|
1963
|
+
gated?.settle?.();
|
|
1964
|
+
gated?.undo?.();
|
|
1888
1965
|
call.error = error;
|
|
1889
1966
|
throw error;
|
|
1890
1967
|
};
|
|
1968
|
+
// Once the behaviour resolves, as discord.js changes the state once Discord has taken the answer
|
|
1969
|
+
const landed = (value)=>{
|
|
1970
|
+
gated?.settle?.();
|
|
1971
|
+
try {
|
|
1972
|
+
gated?.land?.();
|
|
1973
|
+
} catch (error) {
|
|
1974
|
+
call.error = error;
|
|
1975
|
+
throw error;
|
|
1976
|
+
}
|
|
1977
|
+
return value;
|
|
1978
|
+
};
|
|
1891
1979
|
try {
|
|
1892
1980
|
const result = Reflect.apply(behaviour, this, args);
|
|
1893
1981
|
// Recorded and passed on, so a rejection nobody awaits is unhandled in the test, as it is on a bot
|
|
1894
|
-
if (result instanceof Promise) return result.then(
|
|
1895
|
-
return result;
|
|
1982
|
+
if (result instanceof Promise) return result.then(landed, failed);
|
|
1983
|
+
return landed(result);
|
|
1896
1984
|
} catch (error) {
|
|
1897
1985
|
return failed(error);
|
|
1898
1986
|
}
|
|
@@ -2006,7 +2094,31 @@ function createMockInteraction(Class, props) {
|
|
|
2006
2094
|
if (repliable) {
|
|
2007
2095
|
instance.replied = false;
|
|
2008
2096
|
instance.deferred = false;
|
|
2009
|
-
|
|
2097
|
+
// Null until reply() or deferReply() decides it, as discord.js has it; default mode reads false until then, and a
|
|
2098
|
+
// test reading it there is told once. A value the test sets is read as set
|
|
2099
|
+
const strict = strictMocks();
|
|
2100
|
+
let ephemeral = strict ? null : false;
|
|
2101
|
+
let ephemeralDecided = false;
|
|
2102
|
+
// Whether one of the mock's own first answers ran; flags a test sets without one leave ephemeral as discord.js would
|
|
2103
|
+
let answeredByMock = false;
|
|
2104
|
+
const answeredOutside = ()=>Boolean(instance.replied || instance.deferred) && !answeredByMock;
|
|
2105
|
+
const decideEphemeral = (value, decided)=>{
|
|
2106
|
+
ephemeral = value;
|
|
2107
|
+
ephemeralDecided = decided;
|
|
2108
|
+
};
|
|
2109
|
+
Object.defineProperty(instance, 'ephemeral', {
|
|
2110
|
+
get: ()=>{
|
|
2111
|
+
// Quiet once a test answered the interaction itself: MeoCord then reads ephemeral only for truthiness, where false
|
|
2112
|
+
// and null act alike
|
|
2113
|
+
if (!ephemeralDecided && !strict && !answeredOutside()) {
|
|
2114
|
+
controller_decorator.warnPlaceholder(mockLogger, `${Object.getPrototypeOf(instance).constructor.name}.ephemeral`, 'it is null until reply() or deferReply() decides it, as discord.js has it', 'Reply or defer first, or call useStrictMocks() to read null now.');
|
|
2115
|
+
}
|
|
2116
|
+
return ephemeral;
|
|
2117
|
+
},
|
|
2118
|
+
set: (value)=>decideEphemeral(value, true),
|
|
2119
|
+
enumerable: true,
|
|
2120
|
+
configurable: true
|
|
2121
|
+
});
|
|
2010
2122
|
// discord.js's errors, with their codes; a call before any answer keeps the message the mock has always given
|
|
2011
2123
|
const alreadyReplied = ()=>discordjsError(discord_js.DiscordjsErrorCodes.InteractionAlreadyReplied);
|
|
2012
2124
|
const notYetReplied = (method)=>discordjsError(discord_js.DiscordjsErrorCodes.InteractionNotReplied, `Cannot call ${method}() before replying or deferring.`);
|
|
@@ -2032,7 +2144,9 @@ function createMockInteraction(Class, props) {
|
|
|
2032
2144
|
const saved = {
|
|
2033
2145
|
replied: instance.replied,
|
|
2034
2146
|
deferred: instance.deferred,
|
|
2035
|
-
ephemeral
|
|
2147
|
+
ephemeral,
|
|
2148
|
+
ephemeralDecided,
|
|
2149
|
+
answeredByMock,
|
|
2036
2150
|
original,
|
|
2037
2151
|
originalGone,
|
|
2038
2152
|
followUps: new Map(followUps),
|
|
@@ -2042,10 +2156,10 @@ function createMockInteraction(Class, props) {
|
|
|
2042
2156
|
return ()=>{
|
|
2043
2157
|
Object.assign(instance, {
|
|
2044
2158
|
replied: saved.replied,
|
|
2045
|
-
deferred: saved.deferred
|
|
2046
|
-
ephemeral: saved.ephemeral
|
|
2159
|
+
deferred: saved.deferred
|
|
2047
2160
|
});
|
|
2048
|
-
(
|
|
2161
|
+
decideEphemeral(saved.ephemeral, saved.ephemeralDecided);
|
|
2162
|
+
({ original, originalGone, answeredByTest, answeredByMock, modalAnswered } = saved);
|
|
2049
2163
|
followUps.clear();
|
|
2050
2164
|
for (const [id, held] of saved.followUps)followUps.set(id, held);
|
|
2051
2165
|
};
|
|
@@ -2070,24 +2184,76 @@ function createMockInteraction(Class, props) {
|
|
|
2070
2184
|
return build;
|
|
2071
2185
|
}
|
|
2072
2186
|
};
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2187
|
+
// The changes answers make to the reply state, in order. One whose answer fails is undone once every change after
|
|
2188
|
+
// it is undone too, so a later answer's change stands while it does, as on a bot
|
|
2189
|
+
const changes = [];
|
|
2190
|
+
const undoneInTurn = (undo)=>{
|
|
2191
|
+
const change = {
|
|
2192
|
+
undo,
|
|
2193
|
+
failed: false
|
|
2194
|
+
};
|
|
2195
|
+
changes.push(change);
|
|
2196
|
+
return ()=>{
|
|
2197
|
+
change.failed = true;
|
|
2198
|
+
while(changes.at(-1)?.failed)changes.pop().undo();
|
|
2199
|
+
};
|
|
2200
|
+
};
|
|
2201
|
+
// The first answers called and not yet settled, oldest first, for default mode to warn where a bot would differ
|
|
2202
|
+
const unsettled = [];
|
|
2203
|
+
// Default mode warns once per interaction where code that doesn't await an answer would fail on a bot
|
|
2204
|
+
let inFlightWarned = false;
|
|
2205
|
+
const warnInFlight = (text)=>{
|
|
2206
|
+
if (inFlightWarned) return;
|
|
2207
|
+
inFlightWarned = true;
|
|
2208
|
+
mockLogger.warn(text);
|
|
2209
|
+
};
|
|
2210
|
+
/**
|
|
2211
|
+
* A first answer: refused once the interaction has one, else it replies or defers. Under strict mocks the change
|
|
2212
|
+
* waits for the answer to land, as discord.js makes it once the request resolves, so one sent while another is in
|
|
2213
|
+
* flight passes discord.js's check and Discord refuses it with 40060 if the other landed first. Default mode makes
|
|
2214
|
+
* the change at once.
|
|
2215
|
+
*/ const first = (method, kind, then)=>(args, byTest)=>{
|
|
2216
|
+
if (answered()) {
|
|
2217
|
+
if (unsettled.length > 0) {
|
|
2218
|
+
warnInFlight(`${method}() was called while ${unsettled[0]}() had not resolved; on a bot discord.js sends it, and Discord ` + `refuses it with 40060 ("Interaction has already been acknowledged") once the first lands. Await ` + `${unsettled[0]}() before answering again, or call useStrictMocks() to have the mock answer as Discord does.`);
|
|
2219
|
+
}
|
|
2220
|
+
refuse(method, alreadyReplied(), byTest);
|
|
2221
|
+
}
|
|
2222
|
+
// Read first: flags discord.js refuses throw before anything changes; reply() and deferReply() decide
|
|
2223
|
+
// ephemeral, as discord.js's do, and update() and deferUpdate() leave it
|
|
2224
|
+
const flagged = method === 'reply' || method === 'deferReply' ? hasEphemeralFlag(args[0]) : undefined;
|
|
2077
2225
|
const undo = snapshot();
|
|
2078
2226
|
instance[kind] = true;
|
|
2079
|
-
if (
|
|
2227
|
+
if (flagged !== undefined) decideEphemeral(flagged, true);
|
|
2080
2228
|
answeredByTest = byTest;
|
|
2229
|
+
answeredByMock = true;
|
|
2081
2230
|
try {
|
|
2082
2231
|
then(args);
|
|
2083
2232
|
} catch (error) {
|
|
2084
2233
|
undo();
|
|
2085
2234
|
throw error;
|
|
2086
2235
|
}
|
|
2087
|
-
|
|
2236
|
+
if (strict) {
|
|
2237
|
+
const land = snapshot();
|
|
2238
|
+
undo();
|
|
2239
|
+
return {
|
|
2240
|
+
land: ()=>{
|
|
2241
|
+
if (answered()) throw createDiscordError(40060, 'Interaction has already been acknowledged.');
|
|
2242
|
+
land();
|
|
2243
|
+
}
|
|
2244
|
+
};
|
|
2245
|
+
}
|
|
2246
|
+
unsettled.push(method);
|
|
2247
|
+
return {
|
|
2248
|
+
undo: undoneInTurn(undo),
|
|
2249
|
+
settle: ()=>void unsettled.splice(unsettled.indexOf(method), 1)
|
|
2250
|
+
};
|
|
2088
2251
|
};
|
|
2089
2252
|
/** A later answer: refused before the interaction has one. */ const later = (method, then)=>(args, byTest)=>{
|
|
2090
2253
|
if (!answered()) refuse(method, notYetReplied(method), byTest);
|
|
2254
|
+
if (unsettled.length > 0) {
|
|
2255
|
+
warnInFlight(`${unsettled[0]}() had not resolved when ${method}() was called; on a bot this throws InteractionNotReplied. ` + `Await ${unsettled[0]}() first, or call useStrictMocks() to have the mock refuse it now.`);
|
|
2256
|
+
}
|
|
2091
2257
|
const undo = snapshot();
|
|
2092
2258
|
try {
|
|
2093
2259
|
then(args);
|
|
@@ -2095,7 +2261,9 @@ function createMockInteraction(Class, props) {
|
|
|
2095
2261
|
undo();
|
|
2096
2262
|
throw error;
|
|
2097
2263
|
}
|
|
2098
|
-
return
|
|
2264
|
+
return {
|
|
2265
|
+
undo: undoneInTurn(undo)
|
|
2266
|
+
};
|
|
2099
2267
|
};
|
|
2100
2268
|
/** The id an edit, fetch or delete names, `undefined` for the original response. */ const targetId = (message)=>{
|
|
2101
2269
|
if (message === undefined || message === '@original') return undefined;
|
|
@@ -2213,12 +2381,31 @@ function createMockInteraction(Class, props) {
|
|
|
2213
2381
|
// it before answering would decide the window was already closed.
|
|
2214
2382
|
if (instance.type === discord_js.InteractionType.ApplicationCommandAutocomplete) {
|
|
2215
2383
|
instance.responded = false;
|
|
2384
|
+
// Under strict mocks responded is set once respond() lands, as discord.js sets it once the request resolves, and a
|
|
2385
|
+
// second one sent while the first is in flight is refused by Discord with 40060; default mode sets it at once
|
|
2386
|
+
let respondsInFlight = 0;
|
|
2387
|
+
let respondWarned = false;
|
|
2216
2388
|
stubs.set('respond', createMockFn(async (choices)=>{
|
|
2217
|
-
if (instance.responded)
|
|
2389
|
+
if (instance.responded) {
|
|
2390
|
+
if (respondsInFlight > 0 && !respondWarned) {
|
|
2391
|
+
respondWarned = true;
|
|
2392
|
+
mockLogger.warn('respond() was called while respond() had not resolved; on a bot discord.js sends it, and Discord refuses ' + 'it with 40060 ("Interaction has already been acknowledged") once the first lands. Await respond() ' + 'before responding again, or call useStrictMocks() to have the mock respond as Discord does.');
|
|
2393
|
+
}
|
|
2394
|
+
throw discordjsError(discord_js.DiscordjsErrorCodes.InteractionAlreadyReplied);
|
|
2395
|
+
}
|
|
2218
2396
|
// Discord refuses a list longer than its limit, and the menu shows nothing
|
|
2219
2397
|
if (Array.isArray(choices) && choices.length > MAX_AUTOCOMPLETE_CHOICES) {
|
|
2220
2398
|
throw createDiscordError(50035, `Invalid Form Body\ndata.choices[BASE_TYPE_MAX_LENGTH]: Must be ${MAX_AUTOCOMPLETE_CHOICES} or fewer in length.`);
|
|
2221
2399
|
}
|
|
2400
|
+
respondsInFlight++;
|
|
2401
|
+
if (!strictMocks()) instance.responded = true;
|
|
2402
|
+
try {
|
|
2403
|
+
await undefined;
|
|
2404
|
+
} finally{
|
|
2405
|
+
respondsInFlight--;
|
|
2406
|
+
}
|
|
2407
|
+
if (!strictMocks()) return;
|
|
2408
|
+
if (instance.responded) throw createDiscordError(40060, 'Interaction has already been acknowledged.');
|
|
2222
2409
|
instance.responded = true;
|
|
2223
2410
|
}));
|
|
2224
2411
|
}
|
|
@@ -2796,6 +2983,26 @@ const HOLDS = [
|
|
|
2796
2983
|
[
|
|
2797
2984
|
discord_js.PresenceManager.prototype,
|
|
2798
2985
|
discord_js.Presence
|
|
2986
|
+
],
|
|
2987
|
+
[
|
|
2988
|
+
discord_js.GuildEmojiManager.prototype,
|
|
2989
|
+
discord_js.GuildEmoji
|
|
2990
|
+
],
|
|
2991
|
+
[
|
|
2992
|
+
discord_js.GuildStickerManager.prototype,
|
|
2993
|
+
discord_js.Sticker
|
|
2994
|
+
],
|
|
2995
|
+
[
|
|
2996
|
+
discord_js.GuildScheduledEventManager.prototype,
|
|
2997
|
+
discord_js.GuildScheduledEvent
|
|
2998
|
+
],
|
|
2999
|
+
[
|
|
3000
|
+
discord_js.AutoModerationRuleManager.prototype,
|
|
3001
|
+
discord_js.AutoModerationRule
|
|
3002
|
+
],
|
|
3003
|
+
[
|
|
3004
|
+
discord_js.GuildInviteManager.prototype,
|
|
3005
|
+
discord_js.Invite
|
|
2799
3006
|
]
|
|
2800
3007
|
];
|
|
2801
3008
|
/**
|
|
@@ -2843,7 +3050,8 @@ const HOLDS = [
|
|
|
2843
3050
|
return manager;
|
|
2844
3051
|
}
|
|
2845
3052
|
/**
|
|
2846
|
-
* Creates a mock {@link Guild}, with
|
|
3053
|
+
* Creates a mock {@link Guild}, with its `members`, `channels`, `roles`, `bans`, `emojis`, `stickers`, `scheduledEvents`,
|
|
3054
|
+
* `autoModerationRules` and `invites` managers ready to stub.
|
|
2847
3055
|
*
|
|
2848
3056
|
* Use it for the server a message or an interaction came from, with the members, roles and channels a handler looks
|
|
2849
3057
|
* up in it.
|
|
@@ -2853,9 +3061,11 @@ const HOLDS = [
|
|
|
2853
3061
|
*
|
|
2854
3062
|
* @remarks
|
|
2855
3063
|
* A manager's `fetch(id)` resolves to its cached item with that id, as discord.js looks there first, or to a new one it
|
|
2856
|
-
* caches under that id: a member or
|
|
2857
|
-
*
|
|
2858
|
-
*
|
|
3064
|
+
* caches under that id: a member, channel, emoji, sticker, scheduled event or AutoMod rule in this guild, a role with
|
|
3065
|
+
* that id, a ban, or an invite with that code. `members.fetch({ user: ids })` resolves to a collection of each of those
|
|
3066
|
+
* members, found or made the same way; `create()` and `edit()` resolve to a mock of its item, which `create()` caches
|
|
3067
|
+
* as discord.js does, but for an invite, made for the channel given and cached nowhere; a list fetch resolves to an
|
|
3068
|
+
* empty collection. Members, roles and channels given are put in
|
|
2859
3069
|
* their managers' caches, where dispatch looks first when it resolves a message's typed params; a member
|
|
2860
3070
|
* {@link createMockMember} made without a server is in this one. Its `roles.everyone` is the role given with the
|
|
2861
3071
|
* guild's id, or else an @everyone role of its own at position 0 with no permissions, in `roles.cache` as Discord has
|
|
@@ -2886,7 +3096,7 @@ const HOLDS = [
|
|
|
2886
3096
|
instance.members = managerWith(discord_js.GuildMemberManager.prototype, overrides.members);
|
|
2887
3097
|
instance.channels = managerWith(discord_js.GuildChannelManager.prototype, overrides.channels);
|
|
2888
3098
|
instance.roles = guildRoleManager(instance.id, overrides.roles);
|
|
2889
|
-
instance
|
|
3099
|
+
for (const [key, Manager] of GUILD_MANAGERS)instance[key] = managerWith(Manager.prototype, undefined);
|
|
2890
3100
|
const guild = stubDeep(instance);
|
|
2891
3101
|
settleGuild(instance, guild);
|
|
2892
3102
|
// A member made without a server of its own is in this one
|
|
@@ -3510,7 +3720,7 @@ let placeReadsSeen;
|
|
|
3510
3720
|
guild.members = managerWith(discord_js.GuildMemberManager.prototype, undefined);
|
|
3511
3721
|
guild.channels = managerWith(discord_js.GuildChannelManager.prototype, undefined);
|
|
3512
3722
|
guild.roles = guildRoleManager(guild.id, undefined);
|
|
3513
|
-
guild
|
|
3723
|
+
for (const [key, Manager] of GUILD_MANAGERS)guild[key] = managerWith(Manager.prototype, undefined);
|
|
3514
3724
|
const proxy = stubDeep(guild);
|
|
3515
3725
|
settleGuild(guild, proxy);
|
|
3516
3726
|
return proxy;
|
|
@@ -3532,12 +3742,39 @@ const botMembers = new WeakMap();
|
|
|
3532
3742
|
return guildId ? cacheOf(guild.channels)?.get(channelId) : null;
|
|
3533
3743
|
});
|
|
3534
3744
|
}
|
|
3745
|
+
// A guild's managers beside members, channels and roles, each empty, of its discord.js class
|
|
3746
|
+
const GUILD_MANAGERS = [
|
|
3747
|
+
[
|
|
3748
|
+
'bans',
|
|
3749
|
+
discord_js.GuildBanManager
|
|
3750
|
+
],
|
|
3751
|
+
[
|
|
3752
|
+
'emojis',
|
|
3753
|
+
discord_js.GuildEmojiManager
|
|
3754
|
+
],
|
|
3755
|
+
[
|
|
3756
|
+
'stickers',
|
|
3757
|
+
discord_js.GuildStickerManager
|
|
3758
|
+
],
|
|
3759
|
+
[
|
|
3760
|
+
'scheduledEvents',
|
|
3761
|
+
discord_js.GuildScheduledEventManager
|
|
3762
|
+
],
|
|
3763
|
+
[
|
|
3764
|
+
'autoModerationRules',
|
|
3765
|
+
discord_js.AutoModerationRuleManager
|
|
3766
|
+
],
|
|
3767
|
+
[
|
|
3768
|
+
'invites',
|
|
3769
|
+
discord_js.GuildInviteManager
|
|
3770
|
+
]
|
|
3771
|
+
];
|
|
3535
3772
|
/** Gives a guild's managers the guild, as discord.js's do, so what they fetch or make is in it. */ function homeManagers(instance, guild) {
|
|
3536
3773
|
for (const key of [
|
|
3537
3774
|
'members',
|
|
3538
3775
|
'channels',
|
|
3539
3776
|
'roles',
|
|
3540
|
-
|
|
3777
|
+
...GUILD_MANAGERS.map(([key])=>key)
|
|
3541
3778
|
])instance[key].guild = guild;
|
|
3542
3779
|
}
|
|
3543
3780
|
/**
|
|
@@ -5041,7 +5278,17 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
|
|
|
5041
5278
|
}
|
|
5042
5279
|
// Each pass of checks ends before the next step that is no check, so a module with one error stops where it always did
|
|
5043
5280
|
controller_decorator.stopOnStartupErrors();
|
|
5281
|
+
// What the walk below takes as classes, reported with what the walk finds; an entry refused here is left out of it
|
|
5044
5282
|
const services = this.wiring?.services ?? [];
|
|
5283
|
+
if (this.options.app) providers.assertProvidedServices(this.options.app.name, services, providers$1, "@MeoCord({ providers }) or the testing module's providers");
|
|
5284
|
+
for (const controller of this.options.controllers ?? []){
|
|
5285
|
+
if (!controller_decorator.isConstructor(controller)) controller_decorator.startupError(new Error(`the testing module's controllers takes classes, not ${controller_decorator.describeValue(controller)}.`));
|
|
5286
|
+
}
|
|
5287
|
+
const observers = [
|
|
5288
|
+
...this.options.app ? providers.appObservers(this.options.app) : [],
|
|
5289
|
+
...this.options.observers ?? []
|
|
5290
|
+
];
|
|
5291
|
+
providers.assertObservers("the testing module's observers", observers);
|
|
5045
5292
|
const appOptions = this.options.app ? Reflect.getMetadata(controller_decorator.META.appOptions, this.options.app) : undefined;
|
|
5046
5293
|
// The app's own store, as the bot binds it, whether the module is given the app or built from it; none when the
|
|
5047
5294
|
// test provides the CooldownStore, so nothing builds the app's or asks for what it injects
|
|
@@ -5052,17 +5299,14 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
|
|
|
5052
5299
|
const i18n = this.options.app && Reflect.getMetadata(controller_decorator.META.appOptions, this.options.app)?.i18n;
|
|
5053
5300
|
const appPresenterClass = this.options.app && Reflect.getMetadata(controller_decorator.META.appOptions, this.options.app)?.presenter;
|
|
5054
5301
|
const startup = startupRoots.startupClasses({
|
|
5055
|
-
controllers: this.options.controllers ?? [],
|
|
5302
|
+
controllers: (this.options.controllers ?? []).filter(controller_decorator.isConstructor),
|
|
5056
5303
|
services,
|
|
5057
5304
|
providers: providers$1,
|
|
5058
5305
|
stages,
|
|
5059
5306
|
presenter: appPresenterClass,
|
|
5060
5307
|
cooldownStore: store,
|
|
5061
5308
|
themeFor: stages?.themeFor?.resolvers,
|
|
5062
|
-
observers
|
|
5063
|
-
...this.options.app ? providers.appObservers(this.options.app) : [],
|
|
5064
|
-
...this.options.observers ?? []
|
|
5065
|
-
],
|
|
5309
|
+
observers,
|
|
5066
5310
|
stubs: [
|
|
5067
5311
|
...this.guardOverrides.keys(),
|
|
5068
5312
|
...this.interceptorOverrides.keys(),
|
|
@@ -5152,13 +5396,6 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
|
|
|
5152
5396
|
// As the app does when it is created, so a test sees the refusal the bot would give
|
|
5153
5397
|
startupRoots.assertDistinctCommands(this.options.controllers ?? []);
|
|
5154
5398
|
if (this.options.app) providers.prepareAppPresenter(container, this.options.app);
|
|
5155
|
-
const observers = [
|
|
5156
|
-
...this.options.app ? providers.appObservers(this.options.app) : [],
|
|
5157
|
-
...this.options.observers ?? []
|
|
5158
|
-
];
|
|
5159
|
-
providers.assertObservers("the testing module's observers", observers);
|
|
5160
|
-
// Only @Observer classes are bound as observers
|
|
5161
|
-
controller_decorator.stopOnStartupErrors();
|
|
5162
5399
|
providers.bindObservers(container, observers);
|
|
5163
5400
|
// The store calls ask: the test's own when it provides CooldownStore, else the app's
|
|
5164
5401
|
const boundStore = store ?? (providers$1.has(controller_decorator.CooldownStore) ? controller_decorator.CooldownStore : undefined);
|
|
@@ -5298,7 +5535,8 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
|
|
|
5298
5535
|
* @param app - The class `@MeoCord` decorates.
|
|
5299
5536
|
* @param options - Providers that replace the app's by token, extra controllers, and observers.
|
|
5300
5537
|
* @returns The builder, whose `override*` methods still apply, for `compile()`.
|
|
5301
|
-
* @throws TypeError when `app` is not a `@MeoCord` class.
|
|
5538
|
+
* @throws TypeError when `app` is not a `@MeoCord` class. After {@link reportAllStartupErrors}, an app whose
|
|
5539
|
+
* `@MeoCord` was refused throws the error it was refused for instead.
|
|
5302
5540
|
*
|
|
5303
5541
|
* @example
|
|
5304
5542
|
* ```ts
|
|
@@ -5339,7 +5577,13 @@ function messageMock(overrides = {}, sentBy, made = {}, at) {
|
|
|
5339
5577
|
* ```
|
|
5340
5578
|
*/ static fromApp(app, options = {}) {
|
|
5341
5579
|
const appOptions = Reflect.getMetadata(controller_decorator.META.appOptions, app);
|
|
5342
|
-
if (!appOptions)
|
|
5580
|
+
if (!appOptions) {
|
|
5581
|
+
// A refused @MeoCord stores no options, so its own errors, kept with startupErrors: 'all', say why
|
|
5582
|
+
controller_decorator.collectStartupErrors(()=>controller_decorator.declaredErrorsOf([
|
|
5583
|
+
app
|
|
5584
|
+
]).forEach(controller_decorator.startupError));
|
|
5585
|
+
throw new TypeError(`${app?.name ?? String(app)} is not a @MeoCord app: fromApp takes the class @MeoCord decorates.`);
|
|
5586
|
+
}
|
|
5343
5587
|
const controllers = [
|
|
5344
5588
|
...new Set([
|
|
5345
5589
|
...appOptions.controllers,
|
|
@@ -5587,15 +5831,6 @@ providers.adviseInPlaceOfInjecting(TestingModule, 'make it with MeoCordTestingMo
|
|
|
5587
5831
|
return new Fields(components);
|
|
5588
5832
|
}
|
|
5589
5833
|
|
|
5590
|
-
const PLURAL_CATEGORIES = new Set([
|
|
5591
|
-
'zero',
|
|
5592
|
-
'one',
|
|
5593
|
-
'two',
|
|
5594
|
-
'few',
|
|
5595
|
-
'many',
|
|
5596
|
-
'other'
|
|
5597
|
-
]);
|
|
5598
|
-
/** A plural: an object whose every key is a plural category, as the translator's types read one; any other is a group. */ const isPlural = (value)=>typeof value === 'object' && value !== null && Object.keys(value).length > 0 && Object.keys(value).every((key)=>PLURAL_CATEGORIES.has(key));
|
|
5599
5834
|
/** Every message key of a catalog, and whether it is a plural. */ function leaves(catalog, prefix = '') {
|
|
5600
5835
|
return Object.entries(catalog).flatMap(([name, value])=>{
|
|
5601
5836
|
const key = `${prefix}${name}`;
|
|
@@ -5605,7 +5840,7 @@ const PLURAL_CATEGORIES = new Set([
|
|
|
5605
5840
|
plural: false
|
|
5606
5841
|
}
|
|
5607
5842
|
];
|
|
5608
|
-
if (
|
|
5843
|
+
if (controller_decorator.isPluralShape(value)) return [
|
|
5609
5844
|
{
|
|
5610
5845
|
key,
|
|
5611
5846
|
plural: true
|
|
@@ -5629,7 +5864,7 @@ const isMeoCordKey = (key)=>key.startsWith('meocord.');
|
|
|
5629
5864
|
/** A message as a report shows it: the text, or a plural's `other` form. */ const shown = (message)=>typeof message === 'string' ? message : String(message.other);
|
|
5630
5865
|
/** The `{params}` a translation uses that the message it translates doesn't take; a plural's `{count}` is taken. */ function strayParams(translation, original) {
|
|
5631
5866
|
const taken = placeholders(original);
|
|
5632
|
-
if (
|
|
5867
|
+
if (controller_decorator.isPluralShape(original)) taken.add('count');
|
|
5633
5868
|
return [
|
|
5634
5869
|
...placeholders(translation)
|
|
5635
5870
|
].filter((name)=>!taken.has(name));
|
|
@@ -5695,7 +5930,7 @@ const isMeoCordKey = (key)=>key.startsWith('meocord.');
|
|
|
5695
5930
|
continue;
|
|
5696
5931
|
}
|
|
5697
5932
|
// A translation of another shape than the default's can't be read as it: a text for a plural, or the reverse
|
|
5698
|
-
if (plural !==
|
|
5933
|
+
if (plural !== controller_decorator.isPluralShape(message) || !plural && typeof message !== 'string') {
|
|
5699
5934
|
problems.push(`${key} should be a ${plural ? 'plural' : 'text'}, as the default is`);
|
|
5700
5935
|
continue;
|
|
5701
5936
|
}
|
package/dist/cli.json
CHANGED
|
@@ -414,15 +414,15 @@ function toJson(value) {
|
|
|
414
414
|
});
|
|
415
415
|
}
|
|
416
416
|
/**
|
|
417
|
-
*
|
|
418
|
-
*
|
|
419
|
-
* nothing. Never throws.
|
|
417
|
+
* Ends a call a guard denied silently. A component still unanswered is acknowledged invisibly, with or without
|
|
418
|
+
* `@Defer`, since Discord tells the user an unanswered call failed; so is a command `@Defer`'s 'auto' timer was
|
|
419
|
+
* waiting on, whose deferred reply is then deleted, as `@Defer`'s is while nothing was sent into it. Never throws.
|
|
420
420
|
*/ abandon() {
|
|
421
421
|
const waiting = this.timer !== undefined;
|
|
422
422
|
this.cancelScheduled();
|
|
423
423
|
return this.queued(async ()=>{
|
|
424
|
-
//
|
|
425
|
-
if (waiting) await this.acknowledgeUnanswered({
|
|
424
|
+
// A command's acknowledgement shows "thinking…", so one is made only for the timer that would have made it
|
|
425
|
+
if (waiting || !answersWithOwnMessage(this.interaction)) await this.acknowledgeUnanswered({
|
|
426
426
|
ephemeral: true
|
|
427
427
|
});
|
|
428
428
|
this.sync();
|