chapterjs 0.4.0 → 0.5.0
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/README.md +3 -1
- package/dist/assets/public.js +4 -4
- package/dist/cli/build.d.ts +6 -2
- package/dist/cli/build.js +3 -3
- package/dist/cli/dev.js +8 -5
- package/dist/cli/project.d.ts +31 -2
- package/dist/cli/project.js +166 -34
- package/dist/cli/redis.js +14 -2
- package/dist/cli/start.js +11 -1
- package/dist/cli/test.js +108 -16
- package/dist/commands/command.d.ts +140 -5
- package/dist/commands/command.js +17 -2
- package/dist/commands/declaration.d.ts +17 -4
- package/dist/commands/declaration.js +128 -2
- package/dist/commands/router.js +78 -18
- package/dist/commands/tree.d.ts +25 -4
- package/dist/commands/tree.js +95 -26
- package/dist/commands/twins.d.ts +8 -0
- package/dist/commands/twins.js +24 -6
- package/dist/components/button.d.ts +8 -3
- package/dist/components/declaration.d.ts +2 -2
- package/dist/components/declaration.js +16 -12
- package/dist/components/render.js +5 -0
- package/dist/components/router.js +41 -0
- package/dist/components/select.d.ts +12 -5
- package/dist/core/bot.d.ts +7 -0
- package/dist/core/bot.js +44 -0
- package/dist/discord/api.d.ts +2 -0
- package/dist/discord/api.js +2 -0
- package/dist/discord/permissions.d.ts +4 -4
- package/dist/discord/permissions.js +13 -0
- package/dist/events/registry.d.ts +19 -10
- package/dist/events/registry.js +10 -4
- package/dist/index.d.ts +3 -2
- package/dist/index.js +1 -1
- package/dist/interactions/dispatch.js +4 -2
- package/dist/loader/loader.d.ts +16 -0
- package/dist/loader/loader.js +52 -7
- package/dist/loader/usage.d.ts +15 -0
- package/dist/loader/usage.js +40 -0
- package/dist/loader/watch.d.ts +5 -3
- package/dist/loader/watch.js +88 -17
- package/dist/messages/commands.js +3 -0
- package/dist/messages/messages.d.ts +1 -0
- package/dist/messages/translate.js +5 -1
- package/dist/presence/declaration.d.ts +49 -2
- package/dist/presence/declaration.js +122 -39
- package/dist/presence/presence.d.ts +65 -6
- package/dist/presence/presence.js +11 -0
- package/dist/presence/rotation.d.ts +32 -0
- package/dist/presence/rotation.js +167 -0
- package/dist/rest/errors.d.ts +0 -1
- package/dist/rest/errors.js +15 -1
- package/dist/rest/rest.js +10 -1
- package/dist/store/file-backend.d.ts +0 -1
- package/dist/store/file-backend.js +18 -1
- package/dist/store/scope.d.ts +6 -1
- package/dist/store/scope.js +9 -3
- package/dist/structures/channel.d.ts +13 -0
- package/dist/structures/channel.js +24 -0
- package/dist/structures/context.d.ts +3 -0
- package/dist/structures/entities.js +1 -0
- package/dist/structures/interaction.d.ts +6 -0
- package/dist/structures/interaction.js +20 -0
- package/dist/structures/message.d.ts +17 -0
- package/dist/structures/message.js +33 -0
- package/dist/structures/wait.d.ts +105 -0
- package/dist/structures/wait.js +85 -0
- package/dist/testing/harness.d.ts +48 -8
- package/dist/testing/harness.js +156 -36
- package/dist/testing/index.d.ts +1 -1
- package/dist/testing/smoke.js +112 -1
- package/dist/testing/world.js +51 -2
- package/dist/util/call-site.d.ts +4 -0
- package/dist/util/call-site.js +22 -0
- package/dist/voice/connection.d.ts +2 -0
- package/dist/voice/connection.js +15 -1
- package/dist/voice/usage.d.ts +3 -2
- package/dist/voice/usage.js +7 -19
- package/dist/waiting/usage.d.ts +5 -0
- package/dist/waiting/usage.js +19 -0
- package/dist/waiting/waiters.d.ts +48 -0
- package/dist/waiting/waiters.js +107 -0
- package/package.json +1 -1
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { Snowflake } from '../discord/types/common.js';
|
|
2
|
+
import type { Duration } from '../commands/command.js';
|
|
3
|
+
import type { ButtonDeclaration } from '../components/button.js';
|
|
4
|
+
import type { SelectDeclaration } from '../components/select.js';
|
|
5
|
+
import type { WaitKind } from '../waiting/waiters.js';
|
|
6
|
+
import type { Context } from './context.js';
|
|
7
|
+
import type { ComponentInteraction } from './interaction.js';
|
|
8
|
+
import type { GuildMember } from './member.js';
|
|
9
|
+
import type { Message, ReactionEmoji } from './message.js';
|
|
10
|
+
import type { User } from './user.js';
|
|
11
|
+
/** A person, as a wait takes it: a user, a member, or an id. */
|
|
12
|
+
export type PersonInput = User | GuildMember | Snowflake;
|
|
13
|
+
/** What `channel.waitForMessage()` takes. */
|
|
14
|
+
export interface WaitForMessageOptions {
|
|
15
|
+
/** Only a message of this person. Anyone but a bot by default. */
|
|
16
|
+
from?: PersonInput;
|
|
17
|
+
/**
|
|
18
|
+
* Only a message that says this: a text (the same, whatever the capitals
|
|
19
|
+
* and the spaces around), a regular expression, or a function of the
|
|
20
|
+
* message.
|
|
21
|
+
*/
|
|
22
|
+
matches?: string | RegExp | ((message: Message) => boolean);
|
|
23
|
+
/**
|
|
24
|
+
* How long to wait: `'30s'`, `'2m'`. One minute by default, a second at
|
|
25
|
+
* least, a day at most.
|
|
26
|
+
*/
|
|
27
|
+
within?: Duration;
|
|
28
|
+
}
|
|
29
|
+
/** What `channel.collectMessages()` takes. */
|
|
30
|
+
export interface CollectMessagesOptions extends WaitForMessageOptions {
|
|
31
|
+
/** Stops once this many messages came. No limit by default: until `within`. */
|
|
32
|
+
max?: number;
|
|
33
|
+
}
|
|
34
|
+
/** What `message.waitForClick()` takes. */
|
|
35
|
+
export interface WaitForClickOptions {
|
|
36
|
+
/** Only a click of this person. Anyone by default. */
|
|
37
|
+
from?: PersonInput;
|
|
38
|
+
/** How long to wait: `'30s'`, `'2m'`. One minute by default. */
|
|
39
|
+
within?: Duration;
|
|
40
|
+
}
|
|
41
|
+
/** What `message.waitForReaction()` takes. */
|
|
42
|
+
export interface WaitForReactionOptions {
|
|
43
|
+
/** Only a reaction of this person. Anyone but a bot by default. */
|
|
44
|
+
from?: PersonInput;
|
|
45
|
+
/**
|
|
46
|
+
* Only this emoji: a standard one (`'👍'`), or a custom one by its name,
|
|
47
|
+
* its id or as Discord writes it (`'<:party:123>'`).
|
|
48
|
+
*/
|
|
49
|
+
emoji?: string;
|
|
50
|
+
/** How long to wait: `'30s'`, `'2m'`. One minute by default. */
|
|
51
|
+
within?: Duration;
|
|
52
|
+
}
|
|
53
|
+
/** A click on a button or a menu of a message, as `waitForClick()` gives it. */
|
|
54
|
+
export interface Click {
|
|
55
|
+
/**
|
|
56
|
+
* The click itself: answer it as in the `run` of a button, with
|
|
57
|
+
* `update()`, `reply()` or `deferUpdate()`. Without an answer within 2
|
|
58
|
+
* seconds, the bot changes nothing on screen for you (`deferUpdate()`).
|
|
59
|
+
*/
|
|
60
|
+
readonly interaction: ComponentInteraction;
|
|
61
|
+
/** Who clicked. */
|
|
62
|
+
readonly user: User;
|
|
63
|
+
/** Who clicked, as a member of the server; `null` in a private message. */
|
|
64
|
+
readonly member: GuildMember | null;
|
|
65
|
+
/** The name of the button or menu: the name of its export. */
|
|
66
|
+
readonly name: string;
|
|
67
|
+
/** The data the button or menu carries. */
|
|
68
|
+
readonly data: Readonly<Record<string, string | number | boolean>>;
|
|
69
|
+
/**
|
|
70
|
+
* What was picked in a menu: the values of a menu of texts, the ids of
|
|
71
|
+
* the people, roles or channels of the others. Empty for a button.
|
|
72
|
+
*/
|
|
73
|
+
readonly values: readonly string[];
|
|
74
|
+
/** Whether it is this button or this menu. */
|
|
75
|
+
is(component: ButtonDeclaration<never> | SelectDeclaration<never>): boolean;
|
|
76
|
+
}
|
|
77
|
+
/** A reaction added to a message, as `waitForReaction()` gives it. */
|
|
78
|
+
export interface WaitedReaction {
|
|
79
|
+
/** The emoji. */
|
|
80
|
+
readonly emoji: ReactionEmoji;
|
|
81
|
+
/** Who reacted. */
|
|
82
|
+
readonly user: User;
|
|
83
|
+
/** Who reacted, as a member of the server; `null` in a private message. */
|
|
84
|
+
readonly member: GuildMember | null;
|
|
85
|
+
}
|
|
86
|
+
/** What the bot offers the waits for a click: `components/router.ts` builds it. */
|
|
87
|
+
export interface OfferedClick extends Click {
|
|
88
|
+
readonly messageId: Snowflake;
|
|
89
|
+
}
|
|
90
|
+
/** What the bot offers the waits for a reaction: `core/bot.ts` builds it. */
|
|
91
|
+
export interface OfferedReaction extends WaitedReaction {
|
|
92
|
+
readonly messageId: Snowflake;
|
|
93
|
+
}
|
|
94
|
+
/** The milliseconds of `within`, checked. */
|
|
95
|
+
export declare function readWithin(within: unknown): number;
|
|
96
|
+
/** The id of a person a wait is given, checked. */
|
|
97
|
+
export declare function personId(from: unknown): Snowflake | undefined;
|
|
98
|
+
/** Whether a message says what `matches` asks. */
|
|
99
|
+
export declare function readMatches(matches: unknown): (message: Message) => boolean;
|
|
100
|
+
/** Whether an emoji is the one `emoji` names. */
|
|
101
|
+
export declare function readEmoji(emoji: unknown): (given: ReactionEmoji) => boolean;
|
|
102
|
+
/** The waits of the bot, or why there are none. */
|
|
103
|
+
export declare function waitersOf(structure: object): NonNullable<Context['waiters']>;
|
|
104
|
+
/** Waits for what fits, `max` at most: what was taken, in order. */
|
|
105
|
+
export declare function waitFor<T>(structure: object, kind: WaitKind, match: (offered: T) => boolean, within: number, max?: number): Promise<T[]>;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// What a structure waits for: the options of `channel.waitForMessage()`,
|
|
2
|
+
// `channel.collectMessages()`, `message.waitForClick()` and
|
|
3
|
+
// `message.waitForReaction()`, checked, and turned into the test a wait of
|
|
4
|
+
// `waiting/` runs on what the bot offers it.
|
|
5
|
+
import { DURATION_EXAMPLE, parseDuration } from '../util/duration.js';
|
|
6
|
+
import { ctxOf } from './base.js';
|
|
7
|
+
/** One minute: how long a wait lasts when the file does not say. */
|
|
8
|
+
const DEFAULT_WITHIN = 60_000;
|
|
9
|
+
const MIN_WITHIN = 1_000;
|
|
10
|
+
/** A day: longer would be lost by any restart anyway. */
|
|
11
|
+
const MAX_WITHIN = 86_400_000;
|
|
12
|
+
/** The milliseconds of `within`, checked. */
|
|
13
|
+
export function readWithin(within) {
|
|
14
|
+
if (within === undefined)
|
|
15
|
+
return DEFAULT_WITHIN;
|
|
16
|
+
const ms = typeof within === 'string' ? parseDuration(within) : null;
|
|
17
|
+
if (ms === null) {
|
|
18
|
+
throw new TypeError(`"within" is how long to wait, a duration (${DURATION_EXAMPLE}): got ${JSON.stringify(within) ?? typeof within}.`);
|
|
19
|
+
}
|
|
20
|
+
if (ms < MIN_WITHIN || ms > MAX_WITHIN) {
|
|
21
|
+
throw new RangeError(`"within" is ${JSON.stringify(within)}: a wait lasts between 1 second and 1 day.`);
|
|
22
|
+
}
|
|
23
|
+
return ms;
|
|
24
|
+
}
|
|
25
|
+
/** The id of a person a wait is given, checked. */
|
|
26
|
+
export function personId(from) {
|
|
27
|
+
if (from === undefined)
|
|
28
|
+
return undefined;
|
|
29
|
+
if (typeof from === 'string' && /^\d+$/.test(from))
|
|
30
|
+
return from;
|
|
31
|
+
if (typeof from === 'object' &&
|
|
32
|
+
from !== null &&
|
|
33
|
+
typeof from.id === 'string') {
|
|
34
|
+
return from.id;
|
|
35
|
+
}
|
|
36
|
+
throw new TypeError(`"from" is the person to wait for: a user, a member or their id, got ${JSON.stringify(from) ?? typeof from}.`);
|
|
37
|
+
}
|
|
38
|
+
/** Whether a message says what `matches` asks. */
|
|
39
|
+
export function readMatches(matches) {
|
|
40
|
+
if (matches === undefined)
|
|
41
|
+
return () => true;
|
|
42
|
+
if (typeof matches === 'string') {
|
|
43
|
+
const wanted = matches.trim().toLowerCase();
|
|
44
|
+
return message => message.content.trim().toLowerCase() === wanted;
|
|
45
|
+
}
|
|
46
|
+
if (matches instanceof RegExp) {
|
|
47
|
+
// A global or sticky expression keeps where it stopped: test from the start.
|
|
48
|
+
return message => {
|
|
49
|
+
matches.lastIndex = 0;
|
|
50
|
+
return matches.test(message.content);
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
if (typeof matches === 'function') {
|
|
54
|
+
return message => matches(message) === true;
|
|
55
|
+
}
|
|
56
|
+
throw new TypeError(`"matches" is a text, a regular expression or a function of the message, like matches: 'paris' or matches: /^paris$/i.`);
|
|
57
|
+
}
|
|
58
|
+
/** Whether an emoji is the one `emoji` names. */
|
|
59
|
+
export function readEmoji(emoji) {
|
|
60
|
+
if (emoji === undefined)
|
|
61
|
+
return () => true;
|
|
62
|
+
if (typeof emoji !== 'string' || emoji.trim() === '') {
|
|
63
|
+
throw new TypeError(`"emoji" is the emoji to wait for: '👍', or a custom one by its name, its id or as '<:name:id>'.`);
|
|
64
|
+
}
|
|
65
|
+
const custom = /^<a?:(\w+):(\d+)>$/.exec(emoji.trim());
|
|
66
|
+
const wanted = custom ? custom[2] : emoji.trim();
|
|
67
|
+
return given => given.id === wanted || given.name === wanted;
|
|
68
|
+
}
|
|
69
|
+
/** The waits of the bot, or why there are none. */
|
|
70
|
+
export function waitersOf(structure) {
|
|
71
|
+
const { waiters } = ctxOf(structure);
|
|
72
|
+
if (!waiters) {
|
|
73
|
+
throw new Error('Waiting for what comes next only works in a running bot: in a command, an event, a component or a task.');
|
|
74
|
+
}
|
|
75
|
+
return waiters;
|
|
76
|
+
}
|
|
77
|
+
/** Waits for what fits, `max` at most: what was taken, in order. */
|
|
78
|
+
export function waitFor(structure, kind, match, within, max = 1) {
|
|
79
|
+
return waitersOf(structure).wait({
|
|
80
|
+
kind,
|
|
81
|
+
match: offered => match(offered),
|
|
82
|
+
max,
|
|
83
|
+
within,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import type { PermissionName } from '../discord/permissions.js';
|
|
2
2
|
import type { Locale } from '../discord/types/common.js';
|
|
3
3
|
import { type RawEmbed } from '../discord/types/message.js';
|
|
4
|
-
import type { CommandDeclaration, CommandOption, CommandOptions, CommandWhere } from '../commands/command.js';
|
|
4
|
+
import type { CommandDeclaration, CommandOption, CommandOptions, CommandWhere, MenuDeclaration, MenuType } from '../commands/command.js';
|
|
5
5
|
import type { ButtonDeclaration } from '../components/button.js';
|
|
6
6
|
import { type ButtonComponent, type ModalComponent, type SelectComponent } from '../components/instance.js';
|
|
7
7
|
import type { CheckboxesField, CheckboxField, EntityField, FilesField, ModalDeclaration, ModalField, ModalFields, NoteField, RadioField, SelectField, TextField } from '../components/modal.js';
|
|
8
|
+
import type { DataShape } from '../components/custom-id.js';
|
|
8
9
|
import type { OptionValue } from '../components/options.js';
|
|
9
10
|
import type { SelectDeclaration } from '../components/select.js';
|
|
10
11
|
import type { Bot } from '../core/bot.js';
|
|
@@ -190,8 +191,12 @@ export interface TestReply extends TestAssertions<TestReply> {
|
|
|
190
191
|
isDeferred(): TestReply;
|
|
191
192
|
/** Checks that the bot changed the message of the button or menu. */
|
|
192
193
|
updatesMessage(): TestReply;
|
|
193
|
-
/**
|
|
194
|
-
|
|
194
|
+
/**
|
|
195
|
+
* Checks that the bot opened a form, this one when given: its export
|
|
196
|
+
* (whatever data it carries), the form called with its data, or one found
|
|
197
|
+
* on an answer.
|
|
198
|
+
*/
|
|
199
|
+
opensForm<Fields extends ModalFields, Data extends DataShape>(form?: ModalDeclaration<Fields, Data> | ModalComponent | TestForm): TestReply;
|
|
195
200
|
/** Checks that the bot answered with nothing visible (a deferred update). */
|
|
196
201
|
changesNothing(): TestReply;
|
|
197
202
|
/** The button with this label, to click it. Fails when there is none. */
|
|
@@ -258,6 +263,14 @@ export type SelectInput = string | TestMember | TestRole | TestChannel;
|
|
|
258
263
|
export type ButtonTarget = ButtonDeclaration<never> | ButtonComponent | TestButton;
|
|
259
264
|
export type SelectTarget = SelectDeclaration<never> | SelectComponent | TestMenu;
|
|
260
265
|
export type FormTarget<Fields extends ModalFields = ModalFields> = ModalDeclaration<Fields, never> | ModalComponent | TestForm;
|
|
266
|
+
/**
|
|
267
|
+
* What a menu command is used on in a test: a person for a `'user'` menu,
|
|
268
|
+
* a message for a `'message'` menu.
|
|
269
|
+
*/
|
|
270
|
+
export type TestMenuTarget<Type extends MenuType = MenuType> = {
|
|
271
|
+
/** The person or the message the menu is used on. */
|
|
272
|
+
readonly on: Type extends 'user' ? TestMember : TestMessage;
|
|
273
|
+
};
|
|
261
274
|
/** The arguments of `bot.use()`: the options may be left out when none is required. */
|
|
262
275
|
type UseArgs<Options extends CommandOptions> = {} extends OptionInputsOf<Options> ? [options?: OptionInputsOf<Options>, where?: TestWhere] : [options: OptionInputsOf<Options>, where?: TestWhere];
|
|
263
276
|
/**
|
|
@@ -291,7 +304,13 @@ export interface TestBot {
|
|
|
291
304
|
role(name: string, options?: TestRoleOptions): TestRole;
|
|
292
305
|
/** A channel of the server, made the first time the name is given. `#general` exists. */
|
|
293
306
|
channel(name: string, options?: TestChannelOptions): TestChannel;
|
|
294
|
-
/**
|
|
307
|
+
/**
|
|
308
|
+
* Uses a command, as a person would: a slash command with its options,
|
|
309
|
+
* or a menu command on what it is used on (`{ on: message }`,
|
|
310
|
+
* `{ on: member }`; a message menu is used in the channel of its
|
|
311
|
+
* message). Resolves once the bot answered.
|
|
312
|
+
*/
|
|
313
|
+
use<Type extends MenuType, Where extends CommandWhere>(command: MenuDeclaration<Type, Where>, target: TestMenuTarget<Type>, where?: TestWhere): Promise<TestReply>;
|
|
295
314
|
use<Options extends CommandOptions, Where extends CommandWhere>(command: CommandDeclaration<Options, Where>, ...args: UseArgs<Options>): Promise<TestReply>;
|
|
296
315
|
/** Clicks a button. Resolves once the bot answered. */
|
|
297
316
|
click(button: ButtonTarget, where?: TestWhere): Promise<TestReply>;
|
|
@@ -311,6 +330,12 @@ export interface TestBot {
|
|
|
311
330
|
leave(member: TestMember): Promise<void>;
|
|
312
331
|
/** Runs a task now, as if its time had come. Resolves once it is done. */
|
|
313
332
|
run(task: TaskDeclaration, at?: Date): Promise<void>;
|
|
333
|
+
/**
|
|
334
|
+
* Ends everything the bot waits for (`waitForMessage()`,
|
|
335
|
+
* `waitForClick()`...) as if its time was up: time does not pass in the
|
|
336
|
+
* test server. Resolves once the bot handled it.
|
|
337
|
+
*/
|
|
338
|
+
endWaits(): Promise<void>;
|
|
314
339
|
/** Waits for everything the bot started to be done. */
|
|
315
340
|
settle(): Promise<void>;
|
|
316
341
|
}
|
|
@@ -329,13 +354,26 @@ export declare function captureLog(): {
|
|
|
329
354
|
export declare class Observer {
|
|
330
355
|
#private;
|
|
331
356
|
readonly observe: ProjectObserver;
|
|
332
|
-
/**
|
|
357
|
+
/**
|
|
358
|
+
* Forgets everything of the world before: a new world starts. What the
|
|
359
|
+
* files of the last one still run (a case given up on) is never waited
|
|
360
|
+
* for again.
|
|
361
|
+
*/
|
|
333
362
|
reset(): void;
|
|
334
|
-
/**
|
|
363
|
+
/**
|
|
364
|
+
* Resolves once the files were given the interaction `id`: `settle()`
|
|
365
|
+
* then waits for them to be done with it.
|
|
366
|
+
*/
|
|
335
367
|
interaction(id: string, timeout: number): Promise<void>;
|
|
336
|
-
/**
|
|
368
|
+
/**
|
|
369
|
+
* Resolves once the files were given the next event that matches:
|
|
370
|
+
* `settle()` then waits for them to be done with it.
|
|
371
|
+
*/
|
|
337
372
|
event(match: (event: string, data: unknown) => boolean, timeout: number): Promise<void>;
|
|
338
|
-
/**
|
|
373
|
+
/**
|
|
374
|
+
* Resolves once nothing the files started is still running, but what
|
|
375
|
+
* waits for the test to act (a run waiting for a message, a click...).
|
|
376
|
+
*/
|
|
339
377
|
settle(): Promise<void>;
|
|
340
378
|
}
|
|
341
379
|
/** The bot in its world, with the handles a test works with. */
|
|
@@ -362,6 +400,7 @@ export declare class Session implements TestBot {
|
|
|
362
400
|
member(name: string, options?: TestMemberOptions): TestMember;
|
|
363
401
|
role(name: string, options?: TestRoleOptions): TestRole;
|
|
364
402
|
channel(name: string, options?: TestChannelOptions): TestChannel;
|
|
403
|
+
use<Type extends MenuType, Where extends CommandWhere>(command: MenuDeclaration<Type, Where>, target: TestMenuTarget<Type>, where?: TestWhere): Promise<TestReply>;
|
|
365
404
|
use<Options extends CommandOptions, Where extends CommandWhere>(command: CommandDeclaration<Options, Where>, ...args: UseArgs<Options>): Promise<TestReply>;
|
|
366
405
|
suggest<Options extends CommandOptions, Where extends CommandWhere>(command: CommandDeclaration<Options, Where>, option: keyof Options & string, typed: string, where?: TestWhere): Promise<TestSuggestion[]>;
|
|
367
406
|
click(button: ButtonTarget, where?: TestWhere): Promise<TestReply>;
|
|
@@ -373,6 +412,7 @@ export declare class Session implements TestBot {
|
|
|
373
412
|
leave(member: TestMember): Promise<void>;
|
|
374
413
|
run(task: TaskDeclaration, at?: Date): Promise<void>;
|
|
375
414
|
settle(): Promise<void>;
|
|
415
|
+
endWaits(): Promise<void>;
|
|
376
416
|
/**
|
|
377
417
|
* Sends a gateway event to the bot and waits for the files to be done
|
|
378
418
|
* with it. For the checks of `chapterjs test`, not for scenarios.
|
package/dist/testing/harness.js
CHANGED
|
@@ -10,12 +10,17 @@ import { ComponentType, } from '../discord/types/component.js';
|
|
|
10
10
|
import { InteractionType, } from '../discord/types/interaction.js';
|
|
11
11
|
import { MessageFlags, MessageType, } from '../discord/types/message.js';
|
|
12
12
|
import { PermissionFlags } from '../discord/types/permissions.js';
|
|
13
|
-
import { commandName } from '../commands/tree.js';
|
|
13
|
+
import { capitalize, commandName, describeCommand, } from '../commands/tree.js';
|
|
14
14
|
import { componentStateOf } from '../components/declared.js';
|
|
15
15
|
import { isPiece, renderedOf, resolve, } from '../components/instance.js';
|
|
16
16
|
import { translatorOf } from '../messages/translate.js';
|
|
17
17
|
import { toCamelCase } from '../util/case.js';
|
|
18
18
|
import { expectThat, shown, TestFailure } from './failure.js';
|
|
19
|
+
/** The type Discord gives each kind of menu command. */
|
|
20
|
+
const MENU_COMMAND_TYPES = {
|
|
21
|
+
user: ApplicationCommandType.User,
|
|
22
|
+
message: ApplicationCommandType.Message,
|
|
23
|
+
};
|
|
19
24
|
/** A `Log` that keeps what is written, for the runner to show per check. */
|
|
20
25
|
export function captureLog() {
|
|
21
26
|
let current = { errors: [], warnings: [], infos: [] };
|
|
@@ -37,10 +42,27 @@ export function captureLog() {
|
|
|
37
42
|
/** Watches what the bot does with the files, to wait for it. */
|
|
38
43
|
export class Observer {
|
|
39
44
|
#pending = new Set();
|
|
45
|
+
/**
|
|
46
|
+
* The runs of the files waiting for what comes next: each keeps one
|
|
47
|
+
* promise pending that only the test can end (`bot.say()`...).
|
|
48
|
+
*/
|
|
49
|
+
#parked = new Set();
|
|
50
|
+
/** Resolves the next time a run starts or stops waiting. */
|
|
51
|
+
#parkChanged = () => { };
|
|
40
52
|
#interactions = new Map();
|
|
41
53
|
#interactionWaiters = new Map();
|
|
42
54
|
#eventWaiters = [];
|
|
43
55
|
observe = {
|
|
56
|
+
waits: {
|
|
57
|
+
parked: run => {
|
|
58
|
+
this.#parked.add(run);
|
|
59
|
+
this.#parkChanged();
|
|
60
|
+
},
|
|
61
|
+
resumed: run => {
|
|
62
|
+
this.#parked.delete(run);
|
|
63
|
+
this.#parkChanged();
|
|
64
|
+
},
|
|
65
|
+
},
|
|
44
66
|
interaction: (raw, done) => {
|
|
45
67
|
const settled = done.then(() => { }, () => { });
|
|
46
68
|
this.#track(settled);
|
|
@@ -67,34 +89,47 @@ export class Observer {
|
|
|
67
89
|
this.#pending.add(settled);
|
|
68
90
|
settled.finally(() => this.#pending.delete(settled));
|
|
69
91
|
}
|
|
70
|
-
/**
|
|
92
|
+
/**
|
|
93
|
+
* Forgets everything of the world before: a new world starts. What the
|
|
94
|
+
* files of the last one still run (a case given up on) is never waited
|
|
95
|
+
* for again.
|
|
96
|
+
*/
|
|
71
97
|
reset() {
|
|
72
98
|
this.#interactions.clear();
|
|
99
|
+
this.#pending.clear();
|
|
100
|
+
this.#parked.clear();
|
|
101
|
+
this.#interactionWaiters.clear();
|
|
102
|
+
this.#eventWaiters.length = 0;
|
|
73
103
|
}
|
|
74
|
-
/**
|
|
104
|
+
/**
|
|
105
|
+
* Resolves once the files were given the interaction `id`: `settle()`
|
|
106
|
+
* then waits for them to be done with it.
|
|
107
|
+
*/
|
|
75
108
|
interaction(id, timeout) {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
return known;
|
|
109
|
+
if (this.#interactions.has(id))
|
|
110
|
+
return Promise.resolve();
|
|
79
111
|
return new Promise((resolve, reject) => {
|
|
80
112
|
const timer = setTimeout(() => {
|
|
81
113
|
this.#interactionWaiters.delete(id);
|
|
82
114
|
reject(new TestFailure(`The bot did not receive the interaction within ${timeout / 1000} s.`));
|
|
83
115
|
}, timeout);
|
|
84
|
-
this.#interactionWaiters.set(id,
|
|
116
|
+
this.#interactionWaiters.set(id, () => {
|
|
85
117
|
clearTimeout(timer);
|
|
86
|
-
resolve(
|
|
118
|
+
resolve();
|
|
87
119
|
});
|
|
88
120
|
});
|
|
89
121
|
}
|
|
90
|
-
/**
|
|
122
|
+
/**
|
|
123
|
+
* Resolves once the files were given the next event that matches:
|
|
124
|
+
* `settle()` then waits for them to be done with it.
|
|
125
|
+
*/
|
|
91
126
|
event(match, timeout) {
|
|
92
127
|
return new Promise((resolve, reject) => {
|
|
93
128
|
const waiter = {
|
|
94
129
|
match,
|
|
95
|
-
resolve: (
|
|
130
|
+
resolve: () => {
|
|
96
131
|
clearTimeout(timer);
|
|
97
|
-
resolve(
|
|
132
|
+
resolve();
|
|
98
133
|
},
|
|
99
134
|
};
|
|
100
135
|
const timer = setTimeout(() => {
|
|
@@ -106,13 +141,22 @@ export class Observer {
|
|
|
106
141
|
this.#eventWaiters.push(waiter);
|
|
107
142
|
});
|
|
108
143
|
}
|
|
109
|
-
/**
|
|
144
|
+
/**
|
|
145
|
+
* Resolves once nothing the files started is still running, but what
|
|
146
|
+
* waits for the test to act (a run waiting for a message, a click...).
|
|
147
|
+
*/
|
|
110
148
|
async settle() {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
149
|
+
do {
|
|
150
|
+
while (this.#pending.size > this.#parked.size) {
|
|
151
|
+
const changed = new Promise(resolve => (this.#parkChanged = resolve));
|
|
152
|
+
// Looked at again as soon as one is done: the one left may be a
|
|
153
|
+
// run that waits, which only the test ends.
|
|
154
|
+
await Promise.race([...this.#pending, changed]);
|
|
155
|
+
}
|
|
156
|
+
// Answers sent at the very end have reached the world by now, and a
|
|
157
|
+
// wait given what just happened has started running again.
|
|
158
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
159
|
+
} while (this.#pending.size > this.#parked.size);
|
|
116
160
|
}
|
|
117
161
|
}
|
|
118
162
|
// ── The handles ──────────────────────────────────────────────────────
|
|
@@ -170,13 +214,24 @@ function collectComponents(components) {
|
|
|
170
214
|
walk(components);
|
|
171
215
|
return { buttons, menus };
|
|
172
216
|
}
|
|
173
|
-
|
|
217
|
+
/**
|
|
218
|
+
* The message a button or a menu was found on, kept out of sight: clicking
|
|
219
|
+
* it clicks that message, as a person would.
|
|
220
|
+
*/
|
|
221
|
+
const FOUND_ON = Symbol('found on');
|
|
222
|
+
/** The id of the message a handle was found on, if it was found on one. */
|
|
223
|
+
const foundOn = (target) => typeof target === 'object' && target !== null
|
|
224
|
+
? target[FOUND_ON]
|
|
225
|
+
: undefined;
|
|
226
|
+
/** Keeps the message a handle was found on, without showing it. */
|
|
227
|
+
const onMessage = (handle, messageId) => Object.freeze(Object.defineProperty(handle, FOUND_ON, { value: messageId }));
|
|
228
|
+
const buttonHandle = (raw, messageId) => onMessage({
|
|
174
229
|
label: raw.label ?? null,
|
|
175
230
|
style: BUTTON_STYLES[raw.style] ?? 'secondary',
|
|
176
231
|
disabled: raw.disabled === true,
|
|
177
232
|
customId: raw.custom_id ?? null,
|
|
178
|
-
});
|
|
179
|
-
const menuHandle = (raw) =>
|
|
233
|
+
}, messageId);
|
|
234
|
+
const menuHandle = (raw, messageId) => onMessage({
|
|
180
235
|
type: MENU_TYPES[raw.type] ?? 'string',
|
|
181
236
|
placeholder: raw.placeholder ?? null,
|
|
182
237
|
disabled: raw.disabled === true,
|
|
@@ -187,7 +242,7 @@ const menuHandle = (raw) => Object.freeze({
|
|
|
187
242
|
}))
|
|
188
243
|
: [],
|
|
189
244
|
customId: raw.custom_id,
|
|
190
|
-
});
|
|
245
|
+
}, messageId);
|
|
191
246
|
/** Checks a message or an answer; written once for both. */
|
|
192
247
|
function check(self, what, get) {
|
|
193
248
|
const present = () => {
|
|
@@ -442,8 +497,8 @@ export class Session {
|
|
|
442
497
|
return {
|
|
443
498
|
content: message.content,
|
|
444
499
|
embeds: toCamelCase(message.embeds),
|
|
445
|
-
buttons: buttons.map(buttonHandle),
|
|
446
|
-
menus: menus.map(menuHandle),
|
|
500
|
+
buttons: buttons.map(button => buttonHandle(button, raw.id)),
|
|
501
|
+
menus: menus.map(menu => menuHandle(menu, raw.id)),
|
|
447
502
|
ephemeral: ((message.flags ?? 0) & MessageFlags.Ephemeral) !== 0,
|
|
448
503
|
present: true,
|
|
449
504
|
};
|
|
@@ -563,9 +618,14 @@ export class Session {
|
|
|
563
618
|
opensForm(form) {
|
|
564
619
|
expectThat(record.modal !== null, `The bot should have opened a form, it ${record.original ? `answered ${shown(record.original.content)}` : 'opened none'}.`);
|
|
565
620
|
if (form) {
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
621
|
+
// Only the name is compared: the form itself names it, with no
|
|
622
|
+
// need for the data it was opened with.
|
|
623
|
+
const state = componentStateOf(form);
|
|
624
|
+
const expected = state?.kind === 'modal' && state.bound
|
|
625
|
+
? state.bound.name
|
|
626
|
+
: typeof form === 'object' && 'customId' in form
|
|
627
|
+
? form.customId
|
|
628
|
+
: self.#customIdOf(form, 'modal');
|
|
569
629
|
const opened = record.modal.custom_id.split(':')[0];
|
|
570
630
|
expectThat(opened === expected.split(':')[0], `The bot should have opened the form ${expected.split(':')[0]}, it opened ${opened}.`);
|
|
571
631
|
}
|
|
@@ -740,8 +800,11 @@ export class Session {
|
|
|
740
800
|
return entry;
|
|
741
801
|
}
|
|
742
802
|
async use(command, ...args) {
|
|
743
|
-
const [given = {}, where] = args;
|
|
744
803
|
const entry = this.#commandEntry(command);
|
|
804
|
+
if (entry.command.type !== 'slash') {
|
|
805
|
+
return this.#useMenu(entry, args[0], args[1]);
|
|
806
|
+
}
|
|
807
|
+
const [given = {}, where] = args;
|
|
745
808
|
const actor = this.#actor(where);
|
|
746
809
|
const channel = this.#place(where, actor, entry.command.where === 'dm');
|
|
747
810
|
const { options, resolved } = this.#commandOptions(entry, given, channel);
|
|
@@ -773,6 +836,54 @@ export class Session {
|
|
|
773
836
|
value: choice.value,
|
|
774
837
|
}));
|
|
775
838
|
}
|
|
839
|
+
/**
|
|
840
|
+
* Uses a menu command on a person or a message, as Discord's client
|
|
841
|
+
* does: on a message, in the channel of that message; on a person, where
|
|
842
|
+
* `where` says (`#general`, or the private conversation of a
|
|
843
|
+
* `where: 'dm'` command, by default).
|
|
844
|
+
*/
|
|
845
|
+
async #useMenu(entry, target, where) {
|
|
846
|
+
const { type, path, where: place } = entry.command;
|
|
847
|
+
const kind = MENU_COMMAND_TYPES[type];
|
|
848
|
+
const name = describeCommand(entry.command);
|
|
849
|
+
const on = target?.on;
|
|
850
|
+
const actor = this.#actor(where);
|
|
851
|
+
const resolved = {};
|
|
852
|
+
let channel;
|
|
853
|
+
let targetId;
|
|
854
|
+
if (type === 'message') {
|
|
855
|
+
const message = on && !this.#isMember(on) ? this.world.findMessage(on.id) : undefined;
|
|
856
|
+
if (!message) {
|
|
857
|
+
throw new TestFailure(on && !this.#isMember(on)
|
|
858
|
+
? `The message ${on.id} does not exist any more: ${name} can not be used on it.`
|
|
859
|
+
: `${capitalize(name)} is used on a message: bot.use(command, { on: message }), with a message of the test (await bot.say('...'), or one the bot sent).`);
|
|
860
|
+
}
|
|
861
|
+
const found = this.world.channels.get(message.channel_id);
|
|
862
|
+
if (!found) {
|
|
863
|
+
throw new TestFailure(`The channel of the message ${message.id} does not exist any more.`);
|
|
864
|
+
}
|
|
865
|
+
channel = found;
|
|
866
|
+
targetId = message.id;
|
|
867
|
+
(resolved.messages ??= {})[message.id] = message;
|
|
868
|
+
}
|
|
869
|
+
else {
|
|
870
|
+
if (!this.#isMember(on)) {
|
|
871
|
+
throw new TestFailure(`${capitalize(name)} is used on a person: bot.use(command, { on: bot.member('alice') }).`);
|
|
872
|
+
}
|
|
873
|
+
channel = this.#place(where, actor, place === 'dm');
|
|
874
|
+
targetId = this.#resolveMember(`${capitalize(name)}`, on, resolved, channel);
|
|
875
|
+
}
|
|
876
|
+
const registered = this.world.guildCommands.find(command => command.name === path[0] && command.type === kind);
|
|
877
|
+
const raw = this.#rawInteraction(InteractionType.ApplicationCommand, actor, channel, {
|
|
878
|
+
id: registered?.id ?? this.world.snowflake(),
|
|
879
|
+
name: path[0],
|
|
880
|
+
type: kind,
|
|
881
|
+
target_id: targetId,
|
|
882
|
+
resolved,
|
|
883
|
+
...(channel.guild_id ? { guild_id: channel.guild_id } : {}),
|
|
884
|
+
});
|
|
885
|
+
return this.#reply(await this.#interact(raw));
|
|
886
|
+
}
|
|
776
887
|
/** The `data` of a command interaction, subcommands nested as Discord does. */
|
|
777
888
|
#commandData(entry, options, resolved) {
|
|
778
889
|
const [name, ...rest] = entry.command.path;
|
|
@@ -788,7 +899,9 @@ export class Session {
|
|
|
788
899
|
},
|
|
789
900
|
];
|
|
790
901
|
}
|
|
791
|
-
const registered = this.world.guildCommands.find(command => command.name === name
|
|
902
|
+
const registered = this.world.guildCommands.find(command => command.name === name &&
|
|
903
|
+
(command.type ?? ApplicationCommandType.ChatInput) ===
|
|
904
|
+
ApplicationCommandType.ChatInput);
|
|
792
905
|
return {
|
|
793
906
|
id: registered?.id ?? this.world.snowflake(),
|
|
794
907
|
name: name,
|
|
@@ -991,8 +1104,13 @@ export class Session {
|
|
|
991
1104
|
return [...this.project.components.values()].find(entry => entry.component.kind === kind && entry.component.name === name);
|
|
992
1105
|
}
|
|
993
1106
|
/** The message a component is on: the one given, or one of the bot. */
|
|
994
|
-
#messageFor(where, actor, channel, component) {
|
|
1107
|
+
#messageFor(where, actor, channel, component, target) {
|
|
995
1108
|
const given = where?.on;
|
|
1109
|
+
// Found on a message: that message, as long as it is there.
|
|
1110
|
+
const found = given ? undefined : foundOn(target);
|
|
1111
|
+
const sent = found ? this.world.findMessage(found) : undefined;
|
|
1112
|
+
if (sent)
|
|
1113
|
+
return sent;
|
|
996
1114
|
if (given) {
|
|
997
1115
|
const id = 'message' in given ? given.message?.id : given.id;
|
|
998
1116
|
const found = id ? this.world.findMessage(id) : undefined;
|
|
@@ -1036,11 +1154,7 @@ export class Session {
|
|
|
1036
1154
|
const entry = this.#componentEntry('button', customId);
|
|
1037
1155
|
const actor = this.#actor(where);
|
|
1038
1156
|
const channel = this.#place(where, actor, entry?.component.kind === 'button' && entry.component.where === 'dm');
|
|
1039
|
-
const message = this.#messageFor(where, actor, channel, {
|
|
1040
|
-
type: ComponentType.Button,
|
|
1041
|
-
style: 2,
|
|
1042
|
-
custom_id: customId,
|
|
1043
|
-
});
|
|
1157
|
+
const message = this.#messageFor(where, actor, channel, { type: ComponentType.Button, style: 2, custom_id: customId }, button);
|
|
1044
1158
|
const raw = this.#rawInteraction(InteractionType.MessageComponent, actor, channel, { custom_id: customId, component_type: ComponentType.Button }, message);
|
|
1045
1159
|
return this.#reply(await this.#interact(raw));
|
|
1046
1160
|
}
|
|
@@ -1091,8 +1205,10 @@ export class Session {
|
|
|
1091
1205
|
const message = this.#messageFor(where, actor, channel, {
|
|
1092
1206
|
type: componentType,
|
|
1093
1207
|
custom_id: customId,
|
|
1094
|
-
...(componentType === ComponentType.StringSelect
|
|
1095
|
-
|
|
1208
|
+
...(componentType === ComponentType.StringSelect
|
|
1209
|
+
? { options: [] }
|
|
1210
|
+
: {}),
|
|
1211
|
+
}, menu);
|
|
1096
1212
|
const raw = this.#rawInteraction(InteractionType.MessageComponent, actor, channel, {
|
|
1097
1213
|
custom_id: customId,
|
|
1098
1214
|
component_type: componentType,
|
|
@@ -1377,6 +1493,10 @@ export class Session {
|
|
|
1377
1493
|
settle() {
|
|
1378
1494
|
return this.#observer.settle();
|
|
1379
1495
|
}
|
|
1496
|
+
async endWaits() {
|
|
1497
|
+
this.bot.ctx.waiters?.close();
|
|
1498
|
+
await this.#observer.settle();
|
|
1499
|
+
}
|
|
1380
1500
|
/**
|
|
1381
1501
|
* Sends a gateway event to the bot and waits for the files to be done
|
|
1382
1502
|
* with it. For the checks of `chapterjs test`, not for scenarios.
|
package/dist/testing/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export { scenario } from './scenario.js';
|
|
2
2
|
export type { ScenarioDeclaration } from './scenario.js';
|
|
3
3
|
export { TestFailure } from './failure.js';
|
|
4
|
-
export type { ButtonTarget, FieldInputsOf, FormTarget, OptionInputsOf, SelectInput, SelectTarget, TestAssertions, TestBot, TestButton, TestChannel, TestChannelOptions, TestChannelType, TestFile, TestForm, TestMember, TestMemberOptions, TestMenu, TestMessage, TestReply, TestRole, TestRoleOptions, TestSuggestion, TestWhere, } from './harness.js';
|
|
4
|
+
export type { ButtonTarget, FieldInputsOf, FormTarget, OptionInputsOf, SelectInput, SelectTarget, TestAssertions, TestBot, TestButton, TestChannel, TestChannelOptions, TestChannelType, TestFile, TestForm, TestMember, TestMemberOptions, TestMenu, TestMenuTarget, TestMessage, TestReply, TestRole, TestRoleOptions, TestSuggestion, TestWhere, } from './harness.js';
|