chapterjs 0.2.3 → 0.3.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 +65 -0
- package/dist/cli/cli.js +41 -6
- package/dist/cli/project.d.ts +19 -0
- package/dist/cli/project.js +18 -8
- package/dist/cli/test.d.ts +29 -0
- package/dist/cli/test.js +444 -0
- package/dist/commands/autocomplete.d.ts +1 -1
- package/dist/commands/autocomplete.js +37 -41
- package/dist/commands/router.d.ts +4 -1
- package/dist/commands/router.js +15 -17
- package/dist/components/router.d.ts +7 -2
- package/dist/components/router.js +16 -16
- package/dist/discord/types/channel.d.ts +9 -1
- package/dist/discord/types/guild.d.ts +5 -5
- package/dist/discord/types/permissions.d.ts +6 -0
- package/dist/discord/types/poll.d.ts +7 -1
- package/dist/events/router.d.ts +9 -4
- package/dist/events/router.js +24 -11
- package/dist/interactions/dispatch.d.ts +5 -1
- package/dist/interactions/dispatch.js +6 -2
- package/dist/loader/hot.js +22 -2
- package/dist/loader/loader.d.ts +7 -0
- package/dist/loader/loader.js +22 -12
- package/dist/store/memory-backend.d.ts +15 -0
- package/dist/store/memory-backend.js +56 -0
- package/dist/store/store.js +4 -1
- package/dist/tasks/scheduler.d.ts +6 -0
- package/dist/tasks/scheduler.js +9 -3
- package/dist/testing/failure.d.ts +9 -0
- package/dist/testing/failure.js +20 -0
- package/dist/testing/fake-discord.d.ts +59 -0
- package/dist/testing/fake-discord.js +120 -0
- package/dist/testing/fake-gateway.d.ts +59 -0
- package/dist/testing/fake-gateway.js +123 -0
- package/dist/testing/harness.d.ts +387 -0
- package/dist/testing/harness.js +1418 -0
- package/dist/testing/index.d.ts +4 -0
- package/dist/testing/index.js +5 -0
- package/dist/testing/scenario.d.ts +39 -0
- package/dist/testing/scenario.js +58 -0
- package/dist/testing/smoke.d.ts +20 -0
- package/dist/testing/smoke.js +659 -0
- package/dist/testing/websocket.d.ts +34 -0
- package/dist/testing/websocket.js +167 -0
- package/dist/testing/world.d.ts +145 -0
- package/dist/testing/world.js +1479 -0
- package/package.json +20 -3
|
@@ -14,6 +14,11 @@ export declare class ComponentRouter {
|
|
|
14
14
|
constructor(options: ComponentRouterOptions);
|
|
15
15
|
/** Replaces every component (after a load or a reload). */
|
|
16
16
|
set(entries: Iterable<ComponentEntry>): void;
|
|
17
|
-
/**
|
|
18
|
-
|
|
17
|
+
/**
|
|
18
|
+
* Handles an Interaction Create event, if it is a component or a form.
|
|
19
|
+
* Returns a promise of everything being done (the function run, the
|
|
20
|
+
* person answered), or `null` when the interaction is not for this
|
|
21
|
+
* router. Nothing of the bot waits for it: `chapterjs test` does.
|
|
22
|
+
*/
|
|
23
|
+
dispatch(ctx: Context, raw: RawInteraction): Promise<void> | null;
|
|
19
24
|
}
|
|
@@ -33,18 +33,23 @@ export class ComponentRouter {
|
|
|
33
33
|
}
|
|
34
34
|
this.#components = components;
|
|
35
35
|
}
|
|
36
|
-
/**
|
|
36
|
+
/**
|
|
37
|
+
* Handles an Interaction Create event, if it is a component or a form.
|
|
38
|
+
* Returns a promise of everything being done (the function run, the
|
|
39
|
+
* person answered), or `null` when the interaction is not for this
|
|
40
|
+
* router. Nothing of the bot waits for it: `chapterjs test` does.
|
|
41
|
+
*/
|
|
37
42
|
dispatch(ctx, raw) {
|
|
38
43
|
const isComponent = raw.type === InteractionType.MessageComponent;
|
|
39
44
|
const isModal = raw.type === InteractionType.ModalSubmit;
|
|
40
45
|
if (!isComponent && !isModal)
|
|
41
|
-
return;
|
|
46
|
+
return null;
|
|
42
47
|
const data = raw.data;
|
|
43
48
|
if (!data || typeof data.custom_id !== 'string')
|
|
44
|
-
return;
|
|
49
|
+
return null;
|
|
45
50
|
const author = authorOf(raw);
|
|
46
51
|
if (!author)
|
|
47
|
-
return;
|
|
52
|
+
return null;
|
|
48
53
|
const user = ctx.entities.user(author.user);
|
|
49
54
|
const { user: _user, member: _member, message: rawMessage, ...rest } = raw;
|
|
50
55
|
const message = rawMessage
|
|
@@ -79,7 +84,7 @@ export class ComponentRouter {
|
|
|
79
84
|
message: message,
|
|
80
85
|
});
|
|
81
86
|
if (isComponent && !message)
|
|
82
|
-
return;
|
|
87
|
+
return null;
|
|
83
88
|
// Embeds are never in the map: what is left has a `run`.
|
|
84
89
|
if (!entry ||
|
|
85
90
|
entry.component.kind === 'embed' ||
|
|
@@ -87,27 +92,23 @@ export class ComponentRouter {
|
|
|
87
92
|
SELECT_TYPES[entry.component.type] !==
|
|
88
93
|
data.component_type)) {
|
|
89
94
|
// Sent before the export was renamed, removed or changed of kind.
|
|
90
|
-
refuse(interaction, 'gone', { what });
|
|
91
|
-
return;
|
|
95
|
+
return refuse(interaction, 'gone', { what });
|
|
92
96
|
}
|
|
93
97
|
const { component, file } = entry;
|
|
94
98
|
const values = readData(component.data, parts);
|
|
95
99
|
if (!values) {
|
|
96
100
|
// The data of the component changed since the message was sent.
|
|
97
|
-
refuse(interaction, 'outdated', { what });
|
|
98
|
-
return;
|
|
101
|
+
return refuse(interaction, 'outdated', { what });
|
|
99
102
|
}
|
|
100
103
|
const placed = resolvePlace(ctx, raw, user, author.member, component.where, warning => this.#options.onWarning(file, `${name} ${warning}`));
|
|
101
104
|
if (!placed.place) {
|
|
102
|
-
refuse(interaction, placed.refusal, { what });
|
|
103
|
-
return;
|
|
105
|
+
return refuse(interaction, placed.refusal, { what });
|
|
104
106
|
}
|
|
105
107
|
const { place } = placed;
|
|
106
108
|
if (component.kind !== 'modal' &&
|
|
107
109
|
component.who === 'author' &&
|
|
108
110
|
rawMessage?.interaction_metadata?.user.id !== user.id) {
|
|
109
|
-
refuse(interaction, 'authorOnly', { what });
|
|
110
|
-
return;
|
|
111
|
+
return refuse(interaction, 'authorOnly', { what });
|
|
111
112
|
}
|
|
112
113
|
remember(interaction, {
|
|
113
114
|
guild: place.guild ?? undefined,
|
|
@@ -127,8 +128,7 @@ export class ComponentRouter {
|
|
|
127
128
|
}
|
|
128
129
|
catch (error) {
|
|
129
130
|
this.#options.onError(file, error);
|
|
130
|
-
refuse(interaction, 'failed', { what });
|
|
131
|
-
return;
|
|
131
|
+
return refuse(interaction, 'failed', { what });
|
|
132
132
|
}
|
|
133
133
|
const context = Object.freeze({
|
|
134
134
|
interaction,
|
|
@@ -144,7 +144,7 @@ export class ComponentRouter {
|
|
|
144
144
|
ephemeral: ephemeralOf(component),
|
|
145
145
|
})),
|
|
146
146
|
});
|
|
147
|
-
runInteraction({
|
|
147
|
+
return runInteraction({
|
|
148
148
|
interaction,
|
|
149
149
|
file,
|
|
150
150
|
name,
|
|
@@ -251,6 +251,14 @@ export interface RawForumTag {
|
|
|
251
251
|
/** the unicode character of the emoji * */
|
|
252
252
|
emoji_name: string | null;
|
|
253
253
|
}
|
|
254
|
+
/**
|
|
255
|
+
* A forum tag as a request gives it: a new tag has no `id` yet, an existing
|
|
256
|
+
* one keeps its own.
|
|
257
|
+
* @see https://docs.discord.com/developers/resources/channel#forum-tag-object
|
|
258
|
+
*/
|
|
259
|
+
export type RawForumTagInput = Omit<RawForumTag, 'id'> & {
|
|
260
|
+
id?: Snowflake;
|
|
261
|
+
};
|
|
254
262
|
/**
|
|
255
263
|
* JSON Params (Group DM)
|
|
256
264
|
* @see https://docs.discord.com/developers/resources/channel#modify-channel-json-params-group-dm
|
|
@@ -293,7 +301,7 @@ export interface ModifyGuildChannelJSONParams {
|
|
|
293
301
|
/** channel flags combined as a bitfield. */
|
|
294
302
|
flags?: number;
|
|
295
303
|
/** the set of tags that can be used in a `GUILD_FORUM` or a `GUILD_MEDIA` channel; limited to 20 */
|
|
296
|
-
available_tags?:
|
|
304
|
+
available_tags?: RawForumTagInput[];
|
|
297
305
|
/** the emoji to show in the add reaction button on a thread in a `GUILD_FORUM` or a `GUILD_MEDIA` channel */
|
|
298
306
|
default_reaction_emoji?: RawDefaultReaction | null;
|
|
299
307
|
/** the initial `rate_limit_per_user` to set on newly created threads in a channel. this field is copied to the thread at creation time and does not live update. */
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import type { RawApplication } from './application.js';
|
|
2
|
-
import type { ChannelType, ForumLayoutType, ForumSortOrderType, RawChannel, RawDefaultReaction,
|
|
2
|
+
import type { ChannelType, ForumLayoutType, ForumSortOrderType, RawChannel, RawDefaultReaction, RawForumTagInput, RawPermissionOverwrite, RawThreadMember, VideoQualityMode } from './channel.js';
|
|
3
3
|
import type { ISO8601Timestamp, ImageData, Locale, Snowflake } from './common.js';
|
|
4
4
|
import type { RawEmoji } from './emoji.js';
|
|
5
5
|
import type { OAuth2Scope } from './oauth2.js';
|
|
6
|
-
import type { RawRole,
|
|
6
|
+
import type { RawRole, RawRoleColorsInput } from './permissions.js';
|
|
7
7
|
import type { RawSticker } from './sticker.js';
|
|
8
8
|
import type { RawAvatarDecorationData, RawCollectibles, RawUser } from './user.js';
|
|
9
9
|
/**
|
|
@@ -605,7 +605,7 @@ export interface CreateGuildChannelJSONParams {
|
|
|
605
605
|
/** emoji to show in the add reaction button on a thread in a `GUILD_FORUM` or a `GUILD_MEDIA` channel */
|
|
606
606
|
default_reaction_emoji?: RawDefaultReaction | null;
|
|
607
607
|
/** set of tags that can be used in a `GUILD_FORUM` or a `GUILD_MEDIA` channel */
|
|
608
|
-
available_tags?:
|
|
608
|
+
available_tags?: RawForumTagInput[] | null;
|
|
609
609
|
/** the default sort order type used to order posts in `GUILD_FORUM` and `GUILD_MEDIA` channels */
|
|
610
610
|
default_sort_order?: ForumSortOrderType | null;
|
|
611
611
|
/** the default forum layout view used to display posts in `GUILD_FORUM` channels */
|
|
@@ -773,7 +773,7 @@ export interface CreateGuildRoleJSONParams {
|
|
|
773
773
|
/** **Deprecated** RGB color value */
|
|
774
774
|
color?: number;
|
|
775
775
|
/** the role's colors */
|
|
776
|
-
colors?:
|
|
776
|
+
colors?: RawRoleColorsInput;
|
|
777
777
|
/** whether the role should be displayed separately in the sidebar */
|
|
778
778
|
hoist?: boolean;
|
|
779
779
|
/** the role's icon image (if the guild has the `ROLE_ICONS` feature) */
|
|
@@ -805,7 +805,7 @@ export interface ModifyGuildRoleJSONParams {
|
|
|
805
805
|
/** **Deprecated** RGB color value */
|
|
806
806
|
color?: number;
|
|
807
807
|
/** the role's colors */
|
|
808
|
-
colors?:
|
|
808
|
+
colors?: RawRoleColorsInput;
|
|
809
809
|
/** whether the role should be displayed separately in the sidebar */
|
|
810
810
|
hoist?: boolean;
|
|
811
811
|
/** the role's icon image (if the guild has the `ROLE_ICONS` feature) */
|
|
@@ -172,6 +172,12 @@ export interface RawRoleColors {
|
|
|
172
172
|
/** the tertiary color for the role, this will turn the gradient into a holographic style */
|
|
173
173
|
tertiary_color: number | null;
|
|
174
174
|
}
|
|
175
|
+
/**
|
|
176
|
+
* The colors of a role as a request gives them: only the primary color is
|
|
177
|
+
* needed, the two others make a gradient when given.
|
|
178
|
+
* @see https://docs.discord.com/developers/topics/permissions#role-object-role-colors-object
|
|
179
|
+
*/
|
|
180
|
+
export type RawRoleColorsInput = Pick<RawRoleColors, 'primary_color'> & Partial<Omit<RawRoleColors, 'primary_color'>>;
|
|
175
181
|
/**
|
|
176
182
|
* Role Flags
|
|
177
183
|
* @see https://docs.discord.com/developers/topics/permissions#role-object-role-flags
|
|
@@ -27,7 +27,7 @@ export interface RawPollCreateRequest {
|
|
|
27
27
|
/** The question of the poll. Only `text` is supported. */
|
|
28
28
|
question: RawPollMedia;
|
|
29
29
|
/** Each of the answers available in the poll, up to 10 */
|
|
30
|
-
answers:
|
|
30
|
+
answers: RawPollAnswerInput[];
|
|
31
31
|
/** Number of hours the poll should be open for, up to 32 days. Defaults to 24 */
|
|
32
32
|
duration?: number;
|
|
33
33
|
/** Whether a user can select multiple answers. Defaults to false */
|
|
@@ -54,6 +54,12 @@ export interface RawPollMedia {
|
|
|
54
54
|
/** The emoji of the field */
|
|
55
55
|
emoji?: Partial<RawEmoji>;
|
|
56
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* A poll answer as a request gives it: `answer_id` is only sent as part of
|
|
59
|
+
* responses from Discord's API/Gateway.
|
|
60
|
+
* @see https://docs.discord.com/developers/resources/poll#poll-answer-object-poll-answer-object-structure
|
|
61
|
+
*/
|
|
62
|
+
export type RawPollAnswerInput = Omit<RawPollAnswer, 'answer_id'>;
|
|
57
63
|
/**
|
|
58
64
|
* Poll Answer Object Structure
|
|
59
65
|
* @see https://docs.discord.com/developers/resources/poll#poll-answer-object-poll-answer-object-structure
|
package/dist/events/router.d.ts
CHANGED
|
@@ -36,14 +36,19 @@ export declare class EventRouter {
|
|
|
36
36
|
* `dispatch` needs back; nothing is read for events nobody listens to.
|
|
37
37
|
*/
|
|
38
38
|
before<E extends GatewayDispatchEventName>(ctx: Context, event: E, data: GatewayDispatchEvents[E]): unknown[] | undefined;
|
|
39
|
-
/**
|
|
39
|
+
/**
|
|
40
|
+
* Runs the handlers of a gateway event, once the cache reflects it.
|
|
41
|
+
* Resolves once every handler is done (what was asked to Discord first
|
|
42
|
+
* included). Nothing of the bot waits for it: `chapterjs test` does.
|
|
43
|
+
*/
|
|
40
44
|
dispatch<E extends GatewayDispatchEventName>(ctx: Context, event: E, data: GatewayDispatchEvents[E], extra: {
|
|
41
45
|
joined: boolean;
|
|
42
46
|
before: unknown[] | undefined;
|
|
43
|
-
}): void
|
|
47
|
+
}): Promise<void>;
|
|
44
48
|
/**
|
|
45
49
|
* Runs the handlers of an event with what they receive, for the server
|
|
46
|
-
* it happened in (what a store per server reads).
|
|
50
|
+
* it happened in (what a store per server reads). Resolves once every
|
|
51
|
+
* handler is done.
|
|
47
52
|
*/
|
|
48
|
-
emit<Name extends EventName>(name: Name, context: EventContexts[Name], guildId?: string | null): void
|
|
53
|
+
emit<Name extends EventName>(name: Name, context: EventContexts[Name], guildId?: string | null): Promise<void>;
|
|
49
54
|
}
|
package/dist/events/router.js
CHANGED
|
@@ -79,11 +79,16 @@ export class EventRouter {
|
|
|
79
79
|
? source.before(ctx, data)
|
|
80
80
|
: undefined);
|
|
81
81
|
}
|
|
82
|
-
/**
|
|
82
|
+
/**
|
|
83
|
+
* Runs the handlers of a gateway event, once the cache reflects it.
|
|
84
|
+
* Resolves once every handler is done (what was asked to Discord first
|
|
85
|
+
* included). Nothing of the bot waits for it: `chapterjs test` does.
|
|
86
|
+
*/
|
|
83
87
|
dispatch(ctx, event, data, extra) {
|
|
84
88
|
const sources = BY_GATEWAY_EVENT.get(event);
|
|
85
89
|
if (!sources)
|
|
86
|
-
return;
|
|
90
|
+
return Promise.resolve();
|
|
91
|
+
const pending = [];
|
|
87
92
|
sources.forEach((source, index) => {
|
|
88
93
|
if (!this.#handlers.has(source.name))
|
|
89
94
|
return;
|
|
@@ -94,44 +99,52 @@ export class EventRouter {
|
|
|
94
99
|
prepared,
|
|
95
100
|
});
|
|
96
101
|
if (!built)
|
|
97
|
-
return;
|
|
102
|
+
return Promise.resolve();
|
|
98
103
|
// One gateway event can be several occurrences (messages deleted
|
|
99
104
|
// together, emojis changed at once): each is delivered.
|
|
100
105
|
const occurrences = Array.isArray(built)
|
|
101
106
|
? built
|
|
102
107
|
: [built];
|
|
103
108
|
const guildOf = EVENTS[source.name].guildOf;
|
|
109
|
+
const emitted = [];
|
|
104
110
|
for (const context of occurrences) {
|
|
105
111
|
// `t` speaks the language of the server the event happened in.
|
|
106
112
|
const guild = guildOf?.(context);
|
|
107
|
-
this.emit(source.name, {
|
|
113
|
+
emitted.push(this.emit(source.name, {
|
|
108
114
|
...context,
|
|
109
115
|
...translation(ctx, audienceLocale({ guild })),
|
|
110
|
-
}, guild?.id ?? null);
|
|
116
|
+
}, guild?.id ?? null));
|
|
111
117
|
}
|
|
118
|
+
return Promise.all(emitted).then(() => { });
|
|
112
119
|
};
|
|
113
120
|
const preparing = source.prepare?.(ctx, data);
|
|
114
|
-
if (!preparing)
|
|
115
|
-
|
|
116
|
-
|
|
121
|
+
if (!preparing) {
|
|
122
|
+
pending.push(deliver());
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
pending.push(preparing.then(deliver, (error) => this.#onSkipped(source.name, error)));
|
|
117
126
|
});
|
|
127
|
+
return Promise.all(pending).then(() => { });
|
|
118
128
|
}
|
|
119
129
|
/**
|
|
120
130
|
* Runs the handlers of an event with what they receive, for the server
|
|
121
|
-
* it happened in (what a store per server reads).
|
|
131
|
+
* it happened in (what a store per server reads). Resolves once every
|
|
132
|
+
* handler is done.
|
|
122
133
|
*/
|
|
123
134
|
emit(name, context, guildId = null) {
|
|
124
135
|
const handlers = this.#handlers.get(name);
|
|
125
136
|
if (!handlers)
|
|
126
|
-
return;
|
|
137
|
+
return Promise.resolve();
|
|
127
138
|
// Handlers can't change what the others receive.
|
|
128
139
|
const frozen = Object.freeze({ ...context });
|
|
129
140
|
const accepts = EVENTS[name].accepts;
|
|
141
|
+
const running = [];
|
|
130
142
|
for (const { file, event } of handlers) {
|
|
131
143
|
if (accepts && !accepts(frozen, event.options))
|
|
132
144
|
continue;
|
|
133
145
|
// One handler failing never stops the others, nor the bot.
|
|
134
|
-
new Promise(resolve => resolve(withGuild(guildId, () => event.handler(frozen)))).
|
|
146
|
+
running.push(new Promise(resolve => resolve(withGuild(guildId, () => event.handler(frozen)))).then(() => { }, (error) => this.#onError(file, error)));
|
|
135
147
|
}
|
|
148
|
+
return Promise.all(running).then(() => { });
|
|
136
149
|
}
|
|
137
150
|
}
|
|
@@ -89,5 +89,9 @@ export interface RunOptions {
|
|
|
89
89
|
* seconds: when `run` takes longer, the framework says the answer is
|
|
90
90
|
* coming. When `run` throws, the person gets a plain answer and the
|
|
91
91
|
* developer the error with its file and line.
|
|
92
|
+
*
|
|
93
|
+
* Resolves once everything is done (the function, and the answer sent for
|
|
94
|
+
* it when it failed): nothing of the bot waits for it, `chapterjs test`
|
|
95
|
+
* does.
|
|
92
96
|
*/
|
|
93
|
-
export declare function runInteraction(options: RunOptions): void
|
|
97
|
+
export declare function runInteraction(options: RunOptions): Promise<void>;
|
|
@@ -75,6 +75,10 @@ export function refuse(interaction, key, params) {
|
|
|
75
75
|
* seconds: when `run` takes longer, the framework says the answer is
|
|
76
76
|
* coming. When `run` throws, the person gets a plain answer and the
|
|
77
77
|
* developer the error with its file and line.
|
|
78
|
+
*
|
|
79
|
+
* Resolves once everything is done (the function, and the answer sent for
|
|
80
|
+
* it when it failed): nothing of the bot waits for it, `chapterjs test`
|
|
81
|
+
* does.
|
|
78
82
|
*/
|
|
79
83
|
export function runInteraction(options) {
|
|
80
84
|
const { interaction, file, name, reporter } = options;
|
|
@@ -82,7 +86,7 @@ export function runInteraction(options) {
|
|
|
82
86
|
sleep(reporter.deferAfter ?? 2000, undefined, { signal: stop.signal })
|
|
83
87
|
.then(() => options.defer())
|
|
84
88
|
.catch(() => { });
|
|
85
|
-
new Promise(resolve => resolve(withGuild(options.guildId, options.run)))
|
|
89
|
+
return new Promise(resolve => resolve(withGuild(options.guildId, options.run)))
|
|
86
90
|
.then(() => {
|
|
87
91
|
if (!interaction.answered) {
|
|
88
92
|
reporter.onWarning(file, `${name} ${options.unanswered}`);
|
|
@@ -105,7 +109,7 @@ export function runInteraction(options) {
|
|
|
105
109
|
: interaction.answered
|
|
106
110
|
? interaction.followUp({ content, ephemeral: true })
|
|
107
111
|
: interaction.reply({ content, ephemeral: true });
|
|
108
|
-
tell.
|
|
112
|
+
return tell.then(() => { }, () => { });
|
|
109
113
|
})
|
|
110
114
|
.finally(() => stop.abort());
|
|
111
115
|
}
|
package/dist/loader/hot.js
CHANGED
|
@@ -45,8 +45,15 @@ export function enableProjectLoader(dir, { reload }) {
|
|
|
45
45
|
result = nextResolve(specifier, context);
|
|
46
46
|
}
|
|
47
47
|
catch (error) {
|
|
48
|
-
|
|
48
|
+
// Not found, or a folder (Node refuses those as they are).
|
|
49
|
+
const code = error.code;
|
|
50
|
+
if (!fromProject ||
|
|
51
|
+
(code !== 'ERR_MODULE_NOT_FOUND' &&
|
|
52
|
+
code !== 'ERR_UNSUPPORTED_DIR_IMPORT')) {
|
|
49
53
|
throw error;
|
|
54
|
+
}
|
|
55
|
+
if (EXTENSION.test(specifier))
|
|
56
|
+
throw missing(specifier, false);
|
|
50
57
|
// No extension: look for the file people mean.
|
|
51
58
|
for (const candidate of CANDIDATES) {
|
|
52
59
|
try {
|
|
@@ -58,7 +65,7 @@ export function enableProjectLoader(dir, { reload }) {
|
|
|
58
65
|
}
|
|
59
66
|
}
|
|
60
67
|
if (!result)
|
|
61
|
-
throw
|
|
68
|
+
throw missing(specifier, true);
|
|
62
69
|
}
|
|
63
70
|
if (hot && root !== null && result.url.startsWith(root)) {
|
|
64
71
|
return {
|
|
@@ -70,6 +77,19 @@ export function enableProjectLoader(dir, { reload }) {
|
|
|
70
77
|
},
|
|
71
78
|
});
|
|
72
79
|
}
|
|
80
|
+
/**
|
|
81
|
+
* The error for an import of the project that names no file: said in the
|
|
82
|
+
* words of the import, not with the absolute paths Node gives.
|
|
83
|
+
*/
|
|
84
|
+
function missing(specifier, tried) {
|
|
85
|
+
const name = specifier.replace(/\/$/, '').split('/').pop() ?? specifier;
|
|
86
|
+
const looked = tried
|
|
87
|
+
? ` No ${name}.ts, ${name}.js or ${name}/index.ts is there.`
|
|
88
|
+
: '';
|
|
89
|
+
const error = new Error(`'${specifier}' does not exist.${looked} Check the path and the name of the file.`);
|
|
90
|
+
error.code = 'ERR_MODULE_NOT_FOUND';
|
|
91
|
+
return error;
|
|
92
|
+
}
|
|
73
93
|
/** Makes the next imports load the files again. */
|
|
74
94
|
export function nextGeneration() {
|
|
75
95
|
generation++;
|
package/dist/loader/loader.d.ts
CHANGED
|
@@ -59,6 +59,11 @@ export interface FoundFile {
|
|
|
59
59
|
}
|
|
60
60
|
/** Every source file of `src/`, in a stable order. */
|
|
61
61
|
export declare function listSources(projectDir: string): Promise<FoundFile[]>;
|
|
62
|
+
/**
|
|
63
|
+
* Every test file of `src/` (`*.test.ts`, `*.spec.ts`), in a stable order:
|
|
64
|
+
* what `chapterjs test` runs, and what the bot never loads.
|
|
65
|
+
*/
|
|
66
|
+
export declare function listTests(projectDir: string): Promise<FoundFile[]>;
|
|
62
67
|
/** A file of the project, run: what it exports. */
|
|
63
68
|
export interface LoadedModule {
|
|
64
69
|
/** The path from the project folder: `src/ping.ts`. */
|
|
@@ -72,6 +77,8 @@ export interface LoadedSources {
|
|
|
72
77
|
}
|
|
73
78
|
/** Runs every file of `src/`. A file that fails to run never throws. */
|
|
74
79
|
export declare function loadSources(projectDir: string): Promise<LoadedSources>;
|
|
80
|
+
/** Runs the files given. A file that fails to run never throws. */
|
|
81
|
+
export declare function loadFiles(files: readonly FoundFile[]): Promise<LoadedSources>;
|
|
75
82
|
/**
|
|
76
83
|
* Reads the declarations of one kind from what the files export. Every
|
|
77
84
|
* export is looked at; the ones the kind recognises are read, the others
|
package/dist/loader/loader.js
CHANGED
|
@@ -16,7 +16,7 @@ const DECLARATION = /\.d\.m?ts$/;
|
|
|
16
16
|
/** Tests next to the code: they are for a test runner, not for the bot. */
|
|
17
17
|
const TEST = /\.(?:test|spec)\.[^.]+$/;
|
|
18
18
|
/** The source files of a folder and its subfolders, in a stable order. */
|
|
19
|
-
async function scan(dir) {
|
|
19
|
+
async function scan(dir, tests = false) {
|
|
20
20
|
let entries;
|
|
21
21
|
try {
|
|
22
22
|
entries = await readdir(dir, { withFileTypes: true });
|
|
@@ -30,10 +30,10 @@ async function scan(dir) {
|
|
|
30
30
|
for (const entry of entries.sort((a, b) => (a.name < b.name ? -1 : 1))) {
|
|
31
31
|
const path = join(dir, entry.name);
|
|
32
32
|
if (entry.isDirectory())
|
|
33
|
-
files.push(...(await scan(path)));
|
|
33
|
+
files.push(...(await scan(path, tests)));
|
|
34
34
|
else if (SOURCE.test(entry.name) &&
|
|
35
35
|
!DECLARATION.test(entry.name) &&
|
|
36
|
-
|
|
36
|
+
TEST.test(entry.name) === tests) {
|
|
37
37
|
files.push(path);
|
|
38
38
|
}
|
|
39
39
|
}
|
|
@@ -41,19 +41,29 @@ async function scan(dir) {
|
|
|
41
41
|
}
|
|
42
42
|
/** Every source file of `src/`, in a stable order. */
|
|
43
43
|
export async function listSources(projectDir) {
|
|
44
|
-
|
|
45
|
-
return paths
|
|
46
|
-
.map(path => ({
|
|
47
|
-
file: relative(projectDir, path).split(sep).join('/'),
|
|
48
|
-
path,
|
|
49
|
-
}))
|
|
50
|
-
.sort((a, b) => (a.file < b.file ? -1 : 1));
|
|
44
|
+
return found(projectDir, await scan(join(projectDir, 'src')));
|
|
51
45
|
}
|
|
46
|
+
/**
|
|
47
|
+
* Every test file of `src/` (`*.test.ts`, `*.spec.ts`), in a stable order:
|
|
48
|
+
* what `chapterjs test` runs, and what the bot never loads.
|
|
49
|
+
*/
|
|
50
|
+
export async function listTests(projectDir) {
|
|
51
|
+
return found(projectDir, await scan(join(projectDir, 'src'), true));
|
|
52
|
+
}
|
|
53
|
+
const found = (projectDir, paths) => paths
|
|
54
|
+
.map(path => ({
|
|
55
|
+
file: relative(projectDir, path).split(sep).join('/'),
|
|
56
|
+
path,
|
|
57
|
+
}))
|
|
58
|
+
.sort((a, b) => (a.file < b.file ? -1 : 1));
|
|
52
59
|
/** Runs every file of `src/`. A file that fails to run never throws. */
|
|
53
60
|
export async function loadSources(projectDir) {
|
|
54
|
-
|
|
61
|
+
return loadFiles(await listSources(projectDir));
|
|
62
|
+
}
|
|
63
|
+
/** Runs the files given. A file that fails to run never throws. */
|
|
64
|
+
export async function loadFiles(files) {
|
|
55
65
|
const result = { modules: [], failed: [] };
|
|
56
|
-
await Promise.all(
|
|
66
|
+
await Promise.all(files.map(async ({ file, path }) => {
|
|
57
67
|
try {
|
|
58
68
|
result.modules.push({ file, exports: await importFile(path) });
|
|
59
69
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type StoreBackend, type StoreEntry, type StoreKeyParts, type StoreListedEntry } from './backend.js';
|
|
2
|
+
/** The data of every store, in memory, forgotten with the process. */
|
|
3
|
+
export declare class MemoryStoreBackend implements StoreBackend {
|
|
4
|
+
#private;
|
|
5
|
+
constructor(options?: {
|
|
6
|
+
now?: () => number;
|
|
7
|
+
});
|
|
8
|
+
get(store: string, key: StoreKeyParts): Promise<StoreEntry | undefined>;
|
|
9
|
+
set(store: string, key: StoreKeyParts, entry: StoreEntry): Promise<void>;
|
|
10
|
+
delete(store: string, key: StoreKeyParts): Promise<boolean>;
|
|
11
|
+
entries(store: string, prefix: StoreKeyParts): Promise<StoreListedEntry[]>;
|
|
12
|
+
/** Forgets everything: what a test does with its stores stays in it. */
|
|
13
|
+
clear(): void;
|
|
14
|
+
close(): Promise<void>;
|
|
15
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// A backend that keeps the data of the stores in memory and nowhere else:
|
|
2
|
+
// what `chapterjs test` gives a bot under test, so a test never touches
|
|
3
|
+
// the `data/` folder of the project and starts from nothing every time.
|
|
4
|
+
import { alive, decodeKey, encodeKey, startsWith, } from './backend.js';
|
|
5
|
+
/** The data of every store, in memory, forgotten with the process. */
|
|
6
|
+
export class MemoryStoreBackend {
|
|
7
|
+
#stores = new Map();
|
|
8
|
+
#now;
|
|
9
|
+
constructor(options = {}) {
|
|
10
|
+
this.#now = options.now ?? Date.now;
|
|
11
|
+
}
|
|
12
|
+
#store(name) {
|
|
13
|
+
let store = this.#stores.get(name);
|
|
14
|
+
if (!store) {
|
|
15
|
+
store = new Map();
|
|
16
|
+
this.#stores.set(name, store);
|
|
17
|
+
}
|
|
18
|
+
return store;
|
|
19
|
+
}
|
|
20
|
+
async get(store, key) {
|
|
21
|
+
const entry = this.#store(store).get(encodeKey(key));
|
|
22
|
+
if (!entry)
|
|
23
|
+
return undefined;
|
|
24
|
+
if (!alive(entry, this.#now())) {
|
|
25
|
+
this.#store(store).delete(encodeKey(key));
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
return entry;
|
|
29
|
+
}
|
|
30
|
+
async set(store, key, entry) {
|
|
31
|
+
this.#store(store).set(encodeKey(key), entry);
|
|
32
|
+
}
|
|
33
|
+
async delete(store, key) {
|
|
34
|
+
return this.#store(store).delete(encodeKey(key));
|
|
35
|
+
}
|
|
36
|
+
async entries(store, prefix) {
|
|
37
|
+
const now = this.#now();
|
|
38
|
+
const result = [];
|
|
39
|
+
for (const [encoded, entry] of this.#store(store)) {
|
|
40
|
+
const key = decodeKey(encoded);
|
|
41
|
+
if (!startsWith(key, prefix))
|
|
42
|
+
continue;
|
|
43
|
+
if (!alive(entry, now)) {
|
|
44
|
+
this.#store(store).delete(encoded);
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
result.push({ key, ...entry });
|
|
48
|
+
}
|
|
49
|
+
return result;
|
|
50
|
+
}
|
|
51
|
+
/** Forgets everything: what a test does with its stores stays in it. */
|
|
52
|
+
clear() {
|
|
53
|
+
this.#stores.clear();
|
|
54
|
+
}
|
|
55
|
+
async close() { }
|
|
56
|
+
}
|
package/dist/store/store.js
CHANGED
|
@@ -27,12 +27,15 @@ export function bindStore(declared, bound) {
|
|
|
27
27
|
const parts = (key) => {
|
|
28
28
|
const list = typeof key === 'string' ? [key] : [...key];
|
|
29
29
|
if (list.length === 0) {
|
|
30
|
-
throw new TypeError('A key of a store is a text, or a list of texts with at least one.');
|
|
30
|
+
throw new TypeError('A key of a store is a text that is not empty, or a list of such texts with at least one.');
|
|
31
31
|
}
|
|
32
32
|
for (const part of list) {
|
|
33
33
|
if (typeof part !== 'string') {
|
|
34
34
|
throw new TypeError(`A key of a store is made of texts, got ${JSON.stringify(part) ?? typeof part}. Write an id as a text: member.id, not a number.`);
|
|
35
35
|
}
|
|
36
|
+
if (part === '') {
|
|
37
|
+
throw new TypeError('A key of a store is a text that is not empty, or a list of such texts with at least one.');
|
|
38
|
+
}
|
|
36
39
|
}
|
|
37
40
|
return list;
|
|
38
41
|
};
|
|
@@ -25,6 +25,12 @@ export interface SchedulerOptions {
|
|
|
25
25
|
/** Something the developer should know, that is not an error of theirs. */
|
|
26
26
|
onWarning: (file: string, message: string) => void;
|
|
27
27
|
}
|
|
28
|
+
/**
|
|
29
|
+
* Runs a task once, as the scheduler does when its time comes: for no
|
|
30
|
+
* server (what a store per server reads), with the error of a `run` that
|
|
31
|
+
* throws given to `onError`. Resolves once the run is done.
|
|
32
|
+
*/
|
|
33
|
+
export declare function runTask(entry: TaskEntry, context: Omit<TaskContext, 'now'>, now: Date, onError: (file: string, error: unknown) => void): Promise<void>;
|
|
28
34
|
/** Tasks run by the timers of this process. */
|
|
29
35
|
export declare class TimerScheduler implements Scheduler {
|
|
30
36
|
#private;
|
package/dist/tasks/scheduler.js
CHANGED
|
@@ -5,6 +5,14 @@ import { withGuild } from '../store/scope.js';
|
|
|
5
5
|
import { describeSchedule, nextRun, scheduleKey } from './schedule.js';
|
|
6
6
|
/** The longest a timer of Node can wait. */
|
|
7
7
|
const MAX_DELAY = 2 ** 31 - 1;
|
|
8
|
+
/**
|
|
9
|
+
* Runs a task once, as the scheduler does when its time comes: for no
|
|
10
|
+
* server (what a store per server reads), with the error of a `run` that
|
|
11
|
+
* throws given to `onError`. Resolves once the run is done.
|
|
12
|
+
*/
|
|
13
|
+
export function runTask(entry, context, now, onError) {
|
|
14
|
+
return new Promise(resolve => resolve(withGuild(null, () => entry.task.run(Object.freeze({ ...context, now }))))).then(() => { }, (error) => onError(entry.file, error));
|
|
15
|
+
}
|
|
8
16
|
/** Tasks run by the timers of this process. */
|
|
9
17
|
export class TimerScheduler {
|
|
10
18
|
#options;
|
|
@@ -99,9 +107,7 @@ export class TimerScheduler {
|
|
|
99
107
|
return;
|
|
100
108
|
}
|
|
101
109
|
scheduled.running = true;
|
|
102
|
-
|
|
103
|
-
.catch((error) => this.#options.onError(file, error))
|
|
104
|
-
.finally(() => {
|
|
110
|
+
runTask(scheduled.entry, context, now, this.#options.onError).finally(() => {
|
|
105
111
|
scheduled.running = false;
|
|
106
112
|
});
|
|
107
113
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** An expectation of a test that the bot did not meet. */
|
|
2
|
+
export declare class TestFailure extends Error {
|
|
3
|
+
readonly name = "TestFailure";
|
|
4
|
+
constructor(message: string);
|
|
5
|
+
}
|
|
6
|
+
/** Throws a `TestFailure` unless `condition` holds. */
|
|
7
|
+
export declare function expectThat(condition: boolean, message: string): void;
|
|
8
|
+
/** A value as a failure shows it: quoted, cut when very long. */
|
|
9
|
+
export declare function shown(value: unknown): string;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// What a check of `chapterjs test` throws when the bot did not do what the
|
|
2
|
+
// test expected: a plain sentence, shown with the file and the line of the
|
|
3
|
+
// scenario that asked.
|
|
4
|
+
/** An expectation of a test that the bot did not meet. */
|
|
5
|
+
export class TestFailure extends Error {
|
|
6
|
+
name = 'TestFailure';
|
|
7
|
+
constructor(message) {
|
|
8
|
+
super(message);
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
/** Throws a `TestFailure` unless `condition` holds. */
|
|
12
|
+
export function expectThat(condition, message) {
|
|
13
|
+
if (!condition)
|
|
14
|
+
throw new TestFailure(message);
|
|
15
|
+
}
|
|
16
|
+
/** A value as a failure shows it: quoted, cut when very long. */
|
|
17
|
+
export function shown(value) {
|
|
18
|
+
const text = typeof value === 'string' ? JSON.stringify(value) : String(value);
|
|
19
|
+
return text.length > 200 ? `${text.slice(0, 197)}..."` : text;
|
|
20
|
+
}
|