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.
Files changed (84) hide show
  1. package/README.md +3 -1
  2. package/dist/assets/public.js +4 -4
  3. package/dist/cli/build.d.ts +6 -2
  4. package/dist/cli/build.js +3 -3
  5. package/dist/cli/dev.js +8 -5
  6. package/dist/cli/project.d.ts +31 -2
  7. package/dist/cli/project.js +166 -34
  8. package/dist/cli/redis.js +14 -2
  9. package/dist/cli/start.js +11 -1
  10. package/dist/cli/test.js +108 -16
  11. package/dist/commands/command.d.ts +140 -5
  12. package/dist/commands/command.js +17 -2
  13. package/dist/commands/declaration.d.ts +17 -4
  14. package/dist/commands/declaration.js +128 -2
  15. package/dist/commands/router.js +78 -18
  16. package/dist/commands/tree.d.ts +25 -4
  17. package/dist/commands/tree.js +95 -26
  18. package/dist/commands/twins.d.ts +8 -0
  19. package/dist/commands/twins.js +24 -6
  20. package/dist/components/button.d.ts +8 -3
  21. package/dist/components/declaration.d.ts +2 -2
  22. package/dist/components/declaration.js +16 -12
  23. package/dist/components/render.js +5 -0
  24. package/dist/components/router.js +41 -0
  25. package/dist/components/select.d.ts +12 -5
  26. package/dist/core/bot.d.ts +7 -0
  27. package/dist/core/bot.js +44 -0
  28. package/dist/discord/api.d.ts +2 -0
  29. package/dist/discord/api.js +2 -0
  30. package/dist/discord/permissions.d.ts +4 -4
  31. package/dist/discord/permissions.js +13 -0
  32. package/dist/events/registry.d.ts +19 -10
  33. package/dist/events/registry.js +10 -4
  34. package/dist/index.d.ts +3 -2
  35. package/dist/index.js +1 -1
  36. package/dist/interactions/dispatch.js +4 -2
  37. package/dist/loader/loader.d.ts +16 -0
  38. package/dist/loader/loader.js +52 -7
  39. package/dist/loader/usage.d.ts +15 -0
  40. package/dist/loader/usage.js +40 -0
  41. package/dist/loader/watch.d.ts +5 -3
  42. package/dist/loader/watch.js +88 -17
  43. package/dist/messages/commands.js +3 -0
  44. package/dist/messages/messages.d.ts +1 -0
  45. package/dist/messages/translate.js +5 -1
  46. package/dist/presence/declaration.d.ts +49 -2
  47. package/dist/presence/declaration.js +122 -39
  48. package/dist/presence/presence.d.ts +65 -6
  49. package/dist/presence/presence.js +11 -0
  50. package/dist/presence/rotation.d.ts +32 -0
  51. package/dist/presence/rotation.js +167 -0
  52. package/dist/rest/errors.d.ts +0 -1
  53. package/dist/rest/errors.js +15 -1
  54. package/dist/rest/rest.js +10 -1
  55. package/dist/store/file-backend.d.ts +0 -1
  56. package/dist/store/file-backend.js +18 -1
  57. package/dist/store/scope.d.ts +6 -1
  58. package/dist/store/scope.js +9 -3
  59. package/dist/structures/channel.d.ts +13 -0
  60. package/dist/structures/channel.js +24 -0
  61. package/dist/structures/context.d.ts +3 -0
  62. package/dist/structures/entities.js +1 -0
  63. package/dist/structures/interaction.d.ts +6 -0
  64. package/dist/structures/interaction.js +20 -0
  65. package/dist/structures/message.d.ts +17 -0
  66. package/dist/structures/message.js +33 -0
  67. package/dist/structures/wait.d.ts +105 -0
  68. package/dist/structures/wait.js +85 -0
  69. package/dist/testing/harness.d.ts +48 -8
  70. package/dist/testing/harness.js +156 -36
  71. package/dist/testing/index.d.ts +1 -1
  72. package/dist/testing/smoke.js +112 -1
  73. package/dist/testing/world.js +51 -2
  74. package/dist/util/call-site.d.ts +4 -0
  75. package/dist/util/call-site.js +22 -0
  76. package/dist/voice/connection.d.ts +2 -0
  77. package/dist/voice/connection.js +15 -1
  78. package/dist/voice/usage.d.ts +3 -2
  79. package/dist/voice/usage.js +7 -19
  80. package/dist/waiting/usage.d.ts +5 -0
  81. package/dist/waiting/usage.js +19 -0
  82. package/dist/waiting/waiters.d.ts +48 -0
  83. package/dist/waiting/waiters.js +107 -0
  84. 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
- /** Checks that the bot opened a form, this one when given. */
194
- opensForm(form?: ModalDeclaration<ModalFields, never> | TestForm): TestReply;
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
- /** Uses a slash command, as a person would. Resolves once the bot answered. */
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
- /** Forgets the interactions seen so far: a new world starts. */
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
- /** Resolves once the files are done with the interaction `id`. */
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
- /** Resolves once the files are done with the next event that matches. */
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
- /** Resolves once nothing the files started is still running. */
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.
@@ -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
- /** Forgets the interactions seen so far: a new world starts. */
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
- /** Resolves once the files are done with the interaction `id`. */
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
- const known = this.#interactions.get(id);
77
- if (known)
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, done => {
116
+ this.#interactionWaiters.set(id, () => {
85
117
  clearTimeout(timer);
86
- resolve(done);
118
+ resolve();
87
119
  });
88
120
  });
89
121
  }
90
- /** Resolves once the files are done with the next event that matches. */
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: (done) => {
130
+ resolve: () => {
96
131
  clearTimeout(timer);
97
- resolve(done);
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
- /** Resolves once nothing the files started is still running. */
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
- while (this.#pending.size > 0) {
112
- await Promise.all([...this.#pending]);
113
- }
114
- // Answers sent at the very end have reached the world by now.
115
- await new Promise(resolve => setImmediate(resolve));
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
- const buttonHandle = (raw) => Object.freeze({
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) => Object.freeze({
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
- const expected = 'customId' in form
567
- ? form.customId
568
- : self.#customIdOf(form, 'modal');
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 ? { options: [] } : {}),
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.
@@ -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';