chapterjs 0.2.3 → 0.4.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/build.js +14 -1
- package/dist/cli/cli.js +41 -6
- package/dist/cli/dev.js +29 -1
- package/dist/cli/env.d.ts +5 -0
- package/dist/cli/env.js +39 -3
- package/dist/cli/project.d.ts +49 -7
- package/dist/cli/project.js +103 -19
- package/dist/cli/redis.d.ts +23 -0
- package/dist/cli/redis.js +57 -0
- package/dist/cli/start.js +43 -17
- package/dist/cli/test.d.ts +29 -0
- package/dist/cli/test.js +446 -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/registry.d.ts +252 -239
- package/dist/events/registry.js +125 -100
- package/dist/events/router.d.ts +9 -4
- package/dist/events/router.js +24 -11
- package/dist/index.d.ts +4 -2
- package/dist/index.js +1 -0
- 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/ranking/declaration.d.ts +12 -0
- package/dist/ranking/declaration.js +48 -0
- package/dist/ranking/ranking.d.ts +102 -0
- package/dist/ranking/ranking.js +129 -0
- package/dist/redis/client.d.ts +36 -0
- package/dist/redis/client.js +252 -0
- package/dist/redis/protocol.d.ts +22 -0
- package/dist/redis/protocol.js +99 -0
- package/dist/redis/url.d.ts +24 -0
- package/dist/redis/url.js +50 -0
- package/dist/store/backend.d.ts +49 -4
- package/dist/store/backend.js +66 -11
- package/dist/store/declaration.d.ts +3 -0
- package/dist/store/declaration.js +8 -3
- package/dist/store/file-backend.d.ts +3 -1
- package/dist/store/file-backend.js +29 -0
- package/dist/store/ipc-backend.d.ts +24 -1
- package/dist/store/ipc-backend.js +49 -0
- package/dist/store/memory-backend.d.ts +17 -0
- package/dist/store/memory-backend.js +82 -0
- package/dist/store/ranking-backend.d.ts +45 -0
- package/dist/store/ranking-backend.js +82 -0
- package/dist/store/redis-backend.d.ts +38 -0
- package/dist/store/redis-backend.js +245 -0
- package/dist/store/redis-ranking.d.ts +15 -0
- package/dist/store/redis-ranking.js +69 -0
- package/dist/store/scope.d.ts +11 -0
- package/dist/store/scope.js +23 -0
- package/dist/store/store.d.ts +39 -5
- package/dist/store/store.js +83 -33
- package/dist/structures/invite.d.ts +6 -0
- package/dist/structures/invite.js +11 -0
- 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
|
@@ -0,0 +1,659 @@
|
|
|
1
|
+
// The checks `chapterjs test` runs without anything written: every command,
|
|
2
|
+
// button, menu, form, event and task of the project, once with ordinary
|
|
3
|
+
// values and once with the hard ones (empty, at the limit, unicode, each
|
|
4
|
+
// choice, someone who left). A check passes when the bot answered and no
|
|
5
|
+
// function threw: the content is the scenarios' business.
|
|
6
|
+
import { Permissions } from '../discord/permissions.js';
|
|
7
|
+
import { ChannelType } from '../discord/types/channel.js';
|
|
8
|
+
import { commandName } from '../commands/tree.js';
|
|
9
|
+
import { EVENTS } from '../events/registry.js';
|
|
10
|
+
const UNICODE = '🙂 héllo wörld — ça va ?';
|
|
11
|
+
const FILE = {
|
|
12
|
+
name: 'photo.png',
|
|
13
|
+
content: 'not really a picture',
|
|
14
|
+
type: 'image/png',
|
|
15
|
+
};
|
|
16
|
+
/** A text of the length an option allows, at most `max`. */
|
|
17
|
+
const textOf = (length) => 'a'.repeat(Math.max(0, Math.floor(length)));
|
|
18
|
+
/** Who uses a command: someone with the permissions it asks for. */
|
|
19
|
+
function userFor(session, command) {
|
|
20
|
+
if (command.permissions === 0n)
|
|
21
|
+
return session.someone;
|
|
22
|
+
const names = new Permissions(command.permissions).toArray();
|
|
23
|
+
return session.member('mod', { permissions: names });
|
|
24
|
+
}
|
|
25
|
+
/** The ordinary value of an option. */
|
|
26
|
+
function typical(session, option) {
|
|
27
|
+
const choices = 'choices' in option && option.choices
|
|
28
|
+
? Array.isArray(option.choices)
|
|
29
|
+
? [...option.choices]
|
|
30
|
+
: Object.values(option.choices)
|
|
31
|
+
: null;
|
|
32
|
+
if (choices && choices.length > 0)
|
|
33
|
+
return choices[0];
|
|
34
|
+
switch (option.type) {
|
|
35
|
+
case 'string': {
|
|
36
|
+
const min = option.minLength ?? 0;
|
|
37
|
+
const max = option.maxLength ?? 6000;
|
|
38
|
+
const text = 'hello';
|
|
39
|
+
if (text.length < min)
|
|
40
|
+
return textOf(min);
|
|
41
|
+
if (text.length > max)
|
|
42
|
+
return textOf(max);
|
|
43
|
+
return text;
|
|
44
|
+
}
|
|
45
|
+
case 'integer': {
|
|
46
|
+
const value = Math.max(option.min ?? 1, Math.min(option.max ?? 1, 1));
|
|
47
|
+
return Math.round(value);
|
|
48
|
+
}
|
|
49
|
+
case 'number':
|
|
50
|
+
return Math.max(option.min ?? 1.5, Math.min(option.max ?? 1.5, 1.5));
|
|
51
|
+
case 'boolean':
|
|
52
|
+
return true;
|
|
53
|
+
case 'user':
|
|
54
|
+
return session.member('bob');
|
|
55
|
+
case 'role':
|
|
56
|
+
return session.role('Members');
|
|
57
|
+
case 'channel':
|
|
58
|
+
return option.channelTypes &&
|
|
59
|
+
!option.channelTypes.includes(ChannelType.GuildText)
|
|
60
|
+
? session.channel('smoke-voice', { type: 'voice' })
|
|
61
|
+
: session.channel('general');
|
|
62
|
+
case 'mentionable':
|
|
63
|
+
return session.member('bob');
|
|
64
|
+
case 'attachment':
|
|
65
|
+
return FILE;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/** The hard values of an option. */
|
|
69
|
+
function hard(option) {
|
|
70
|
+
const choices = 'choices' in option && option.choices
|
|
71
|
+
? Array.isArray(option.choices)
|
|
72
|
+
? [...option.choices]
|
|
73
|
+
: Object.values(option.choices)
|
|
74
|
+
: null;
|
|
75
|
+
if (choices && choices.length > 0) {
|
|
76
|
+
return choices.slice(1).map(value => ({
|
|
77
|
+
label: `with ${option.name} = ${JSON.stringify(value)}`,
|
|
78
|
+
value: () => value,
|
|
79
|
+
}));
|
|
80
|
+
}
|
|
81
|
+
const fixed = (label, value) => ({
|
|
82
|
+
label,
|
|
83
|
+
value: () => value,
|
|
84
|
+
});
|
|
85
|
+
switch (option.type) {
|
|
86
|
+
case 'string': {
|
|
87
|
+
const min = option.minLength ?? 0;
|
|
88
|
+
const max = option.maxLength ?? 6000;
|
|
89
|
+
const cases = [
|
|
90
|
+
fixed(`with ${option.name} at its longest (${max} characters)`, textOf(max)),
|
|
91
|
+
];
|
|
92
|
+
if (UNICODE.length >= min && UNICODE.length <= max) {
|
|
93
|
+
cases.push(fixed(`with ${option.name} in unicode`, UNICODE));
|
|
94
|
+
}
|
|
95
|
+
if (min > 0)
|
|
96
|
+
cases.push(fixed(`with ${option.name} at its shortest (${min} characters)`, textOf(min)));
|
|
97
|
+
return cases;
|
|
98
|
+
}
|
|
99
|
+
case 'integer':
|
|
100
|
+
case 'number': {
|
|
101
|
+
const cases = [];
|
|
102
|
+
if (option.min !== undefined)
|
|
103
|
+
cases.push(fixed(`with ${option.name} at its lowest (${option.min})`, option.min));
|
|
104
|
+
else
|
|
105
|
+
cases.push(fixed(`with ${option.name} negative`, -1));
|
|
106
|
+
if (option.max !== undefined)
|
|
107
|
+
cases.push(fixed(`with ${option.name} at its highest (${option.max})`, option.max));
|
|
108
|
+
else
|
|
109
|
+
cases.push(fixed(`with ${option.name} huge`, option.type === 'integer' ? 1_000_000_000 : 1e9 + 0.5));
|
|
110
|
+
if ((option.min ?? -Infinity) <= 0 && (option.max ?? Infinity) >= 0) {
|
|
111
|
+
cases.push(fixed(`with ${option.name} = 0`, 0));
|
|
112
|
+
}
|
|
113
|
+
return cases;
|
|
114
|
+
}
|
|
115
|
+
case 'boolean':
|
|
116
|
+
return [fixed(`with ${option.name} = false`, false)];
|
|
117
|
+
case 'user':
|
|
118
|
+
case 'mentionable':
|
|
119
|
+
return [
|
|
120
|
+
{
|
|
121
|
+
label: `with ${option.name} being who uses it`,
|
|
122
|
+
value: (_, by) => by,
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
label: `with ${option.name} being the bot`,
|
|
126
|
+
value: session => session.me,
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
label: `with ${option.name} being the owner`,
|
|
130
|
+
value: session => session.owner,
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
label: `with ${option.name} being someone who left`,
|
|
134
|
+
value: session => session.member('gone'),
|
|
135
|
+
},
|
|
136
|
+
];
|
|
137
|
+
case 'role':
|
|
138
|
+
return [
|
|
139
|
+
{
|
|
140
|
+
label: `with ${option.name} = @everyone`,
|
|
141
|
+
value: session => session.role('@everyone'),
|
|
142
|
+
},
|
|
143
|
+
];
|
|
144
|
+
case 'channel':
|
|
145
|
+
return [];
|
|
146
|
+
case 'attachment':
|
|
147
|
+
return [
|
|
148
|
+
fixed(`with ${option.name} being an empty file`, {
|
|
149
|
+
name: 'empty.txt',
|
|
150
|
+
content: '',
|
|
151
|
+
}),
|
|
152
|
+
];
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/** The checks of the commands. */
|
|
156
|
+
function commandChecks(project) {
|
|
157
|
+
return [...project.commands.values()].map((entry) => {
|
|
158
|
+
const { command, file } = entry;
|
|
159
|
+
const declaration = { config: { run: command.run } };
|
|
160
|
+
const subject = `command ${commandName(command.path)}`;
|
|
161
|
+
const use = (session, values, where = {}) => {
|
|
162
|
+
const by = userFor(session, command);
|
|
163
|
+
return session
|
|
164
|
+
.use(declaration, values(session, by), {
|
|
165
|
+
by,
|
|
166
|
+
...where,
|
|
167
|
+
})
|
|
168
|
+
.then(() => { });
|
|
169
|
+
};
|
|
170
|
+
const all = (session) => Object.fromEntries(command.options.map(option => [option.name, typical(session, option)]));
|
|
171
|
+
const required = (session) => Object.fromEntries(command.options
|
|
172
|
+
.filter(option => option.required)
|
|
173
|
+
.map(option => [option.name, typical(session, option)]));
|
|
174
|
+
const cases = [
|
|
175
|
+
{
|
|
176
|
+
label: command.options.length > 0 ? 'with every option' : 'as is',
|
|
177
|
+
run: session => use(session, all),
|
|
178
|
+
},
|
|
179
|
+
];
|
|
180
|
+
if (command.options.some(option => !option.required)) {
|
|
181
|
+
cases.push({
|
|
182
|
+
label: 'with the required options only',
|
|
183
|
+
run: session => use(session, required),
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
for (const option of command.options) {
|
|
187
|
+
for (const one of hard(option)) {
|
|
188
|
+
cases.push({
|
|
189
|
+
label: one.label,
|
|
190
|
+
run: session => use(session, (current, by) => {
|
|
191
|
+
const value = one.value(current, by);
|
|
192
|
+
// Someone who left: in the world, but not in the server.
|
|
193
|
+
if (one.label.endsWith('who left')) {
|
|
194
|
+
current.world.members.delete(value.id);
|
|
195
|
+
}
|
|
196
|
+
return { ...all(current), [option.name]: value };
|
|
197
|
+
}),
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (command.where === 'both') {
|
|
202
|
+
cases.push({
|
|
203
|
+
label: 'in a private message',
|
|
204
|
+
run: session => use(session, all, { in: 'dm' }),
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
return { subject, file, cases };
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
/** The ordinary data of a component. */
|
|
211
|
+
const typicalData = (shape) => Object.fromEntries(Object.entries(shape).map(([name, field]) => {
|
|
212
|
+
const kind = typeof field === 'string' ? field : field.type;
|
|
213
|
+
return [name, kind === 'string' ? 'value' : kind === 'number' ? 1 : true];
|
|
214
|
+
}));
|
|
215
|
+
/** The hard data of a component, one field at a time. */
|
|
216
|
+
function hardData(shape) {
|
|
217
|
+
const base = typicalData(shape);
|
|
218
|
+
const cases = [];
|
|
219
|
+
for (const [name, field] of Object.entries(shape)) {
|
|
220
|
+
const kind = typeof field === 'string' ? field : field.type;
|
|
221
|
+
const values = kind === 'string'
|
|
222
|
+
? ['', 'a:b\\c', UNICODE]
|
|
223
|
+
: kind === 'number'
|
|
224
|
+
? [0, -1, 1e15]
|
|
225
|
+
: [false];
|
|
226
|
+
for (const value of values) {
|
|
227
|
+
cases.push({
|
|
228
|
+
label: `with ${name} = ${JSON.stringify(value)}`,
|
|
229
|
+
data: { ...base, [name]: value },
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return cases;
|
|
234
|
+
}
|
|
235
|
+
/** What a test fills in a field of a form, ordinarily. */
|
|
236
|
+
function typicalField(session, loaded) {
|
|
237
|
+
const { field, min, max } = loaded;
|
|
238
|
+
switch (field.type) {
|
|
239
|
+
case 'text': {
|
|
240
|
+
const text = 'hello';
|
|
241
|
+
return text.length < min
|
|
242
|
+
? textOf(min)
|
|
243
|
+
: text.length > max
|
|
244
|
+
? textOf(max)
|
|
245
|
+
: text;
|
|
246
|
+
}
|
|
247
|
+
case 'select':
|
|
248
|
+
case 'checkboxes':
|
|
249
|
+
return loaded.options
|
|
250
|
+
.slice(0, Math.max(1, min))
|
|
251
|
+
.map(option => option.value);
|
|
252
|
+
case 'radio':
|
|
253
|
+
return loaded.options[0]?.value;
|
|
254
|
+
case 'checkbox':
|
|
255
|
+
return true;
|
|
256
|
+
case 'user':
|
|
257
|
+
return [session.member('bob')];
|
|
258
|
+
case 'role':
|
|
259
|
+
return [session.role('Members')];
|
|
260
|
+
case 'channel':
|
|
261
|
+
return [session.channel('general')];
|
|
262
|
+
case 'mentionable':
|
|
263
|
+
return [session.member('bob')];
|
|
264
|
+
case 'files':
|
|
265
|
+
return [FILE];
|
|
266
|
+
case 'note':
|
|
267
|
+
return undefined;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
/** The checks of the buttons, menus and forms. */
|
|
271
|
+
function componentChecks(project) {
|
|
272
|
+
const checks = [];
|
|
273
|
+
for (const entry of project.components.values()) {
|
|
274
|
+
const { component, file } = entry;
|
|
275
|
+
if (component.kind === 'embed')
|
|
276
|
+
continue;
|
|
277
|
+
const { name, data } = component;
|
|
278
|
+
const target = (values) => ({
|
|
279
|
+
customId: [
|
|
280
|
+
name,
|
|
281
|
+
...Object.keys(data).map(key => String(values[key]).replace(/[\\:]/g, c => `\\${c}`)),
|
|
282
|
+
].join(':'),
|
|
283
|
+
...(component.kind === 'select'
|
|
284
|
+
? {
|
|
285
|
+
type: component.type,
|
|
286
|
+
placeholder: null,
|
|
287
|
+
disabled: false,
|
|
288
|
+
options: [],
|
|
289
|
+
}
|
|
290
|
+
: {}),
|
|
291
|
+
...(component.kind === 'modal' ? { title: '', fields: [] } : {}),
|
|
292
|
+
});
|
|
293
|
+
const where = (session) => component.where === 'dm'
|
|
294
|
+
? { in: 'dm', by: session.someone }
|
|
295
|
+
: { by: session.someone };
|
|
296
|
+
const act = (session, values) => {
|
|
297
|
+
if (component.kind === 'button') {
|
|
298
|
+
return session.click(target(values), where(session));
|
|
299
|
+
}
|
|
300
|
+
if (component.kind === 'select') {
|
|
301
|
+
const picked = component.type === 'string'
|
|
302
|
+
? component.options
|
|
303
|
+
.slice(0, Math.max(1, component.min))
|
|
304
|
+
.map(option => option.value)
|
|
305
|
+
: component.type === 'user' || component.type === 'mentionable'
|
|
306
|
+
? [session.member('bob')]
|
|
307
|
+
: component.type === 'role'
|
|
308
|
+
? [session.role('Members')]
|
|
309
|
+
: [session.channel('general')];
|
|
310
|
+
return session.select(target(values), picked.length > 0 ? picked : ['value'], where(session));
|
|
311
|
+
}
|
|
312
|
+
const fields = Object.fromEntries(component.fields
|
|
313
|
+
.filter(loaded => loaded.field.type !== 'note')
|
|
314
|
+
.map(loaded => [loaded.name, typicalField(session, loaded)]));
|
|
315
|
+
return session.submit(target(values), fields, where(session));
|
|
316
|
+
};
|
|
317
|
+
// The function the file called (`modal()`), and its export, a function too.
|
|
318
|
+
const what = component.kind;
|
|
319
|
+
const cases = [
|
|
320
|
+
{
|
|
321
|
+
label: Object.keys(data).length > 0 ? 'with its data' : 'as is',
|
|
322
|
+
run: session => act(session, typicalData(data)).then(() => { }),
|
|
323
|
+
},
|
|
324
|
+
];
|
|
325
|
+
for (const one of hardData(data)) {
|
|
326
|
+
cases.push({
|
|
327
|
+
label: one.label,
|
|
328
|
+
run: session => act(session, one.data).then(() => { }),
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
if (component.kind === 'modal' &&
|
|
332
|
+
component.fields.some(loaded => !loaded.required && loaded.field.type !== 'note')) {
|
|
333
|
+
cases.push({
|
|
334
|
+
label: 'with the required fields only',
|
|
335
|
+
run: session => session
|
|
336
|
+
.submit(target(typicalData(data)), Object.fromEntries(component.fields
|
|
337
|
+
.filter(loaded => loaded.required && loaded.field.type !== 'note')
|
|
338
|
+
.map(loaded => [loaded.name, typicalField(session, loaded)])), where(session))
|
|
339
|
+
.then(() => { }),
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
checks.push({ subject: `${what} ${name}()`, file, cases });
|
|
343
|
+
}
|
|
344
|
+
return checks;
|
|
345
|
+
}
|
|
346
|
+
/** How the world makes each event happen. `null`: not simulated yet. */
|
|
347
|
+
const TRIGGERS = {
|
|
348
|
+
messageCreate: async (session) => {
|
|
349
|
+
await session.say('hello from a test', { by: session.member('bob') });
|
|
350
|
+
},
|
|
351
|
+
messageUpdate: async (session) => {
|
|
352
|
+
const message = await session.say('before', { by: session.member('bob') });
|
|
353
|
+
const raw = session.world.findMessage(message.id);
|
|
354
|
+
await session.expectEvent('MESSAGE_UPDATE', () => session.world.editMessage(raw, { content: 'after' }));
|
|
355
|
+
},
|
|
356
|
+
messageDelete: async (session) => {
|
|
357
|
+
const message = await session.say('to delete', {
|
|
358
|
+
by: session.member('bob'),
|
|
359
|
+
});
|
|
360
|
+
const raw = session.world.findMessage(message.id);
|
|
361
|
+
await session.expectEvent('MESSAGE_DELETE', () => session.world.deleteMessage(raw));
|
|
362
|
+
},
|
|
363
|
+
memberJoin: async (session) => {
|
|
364
|
+
await session.join('newcomer');
|
|
365
|
+
},
|
|
366
|
+
memberLeave: async (session) => {
|
|
367
|
+
await session.leave(session.member('leaver'));
|
|
368
|
+
},
|
|
369
|
+
memberUpdate: async (session) => {
|
|
370
|
+
const member = session.member('renamed');
|
|
371
|
+
const raw = session.world.members.get(member.id);
|
|
372
|
+
raw.nick = 'New nick';
|
|
373
|
+
await session.expectEvent('GUILD_MEMBER_UPDATE', () => session.world.memberUpdated(raw));
|
|
374
|
+
},
|
|
375
|
+
guildUpdate: async (session) => {
|
|
376
|
+
session.world.guildName = 'Renamed Server';
|
|
377
|
+
await session.emit('GUILD_UPDATE', session.world.guildFields());
|
|
378
|
+
},
|
|
379
|
+
channelCreate: async (session) => {
|
|
380
|
+
session.channel('created-by-the-test');
|
|
381
|
+
await session.settle();
|
|
382
|
+
},
|
|
383
|
+
channelUpdate: async (session) => {
|
|
384
|
+
const channel = session.world.findChannel('general');
|
|
385
|
+
channel.topic = 'A new topic';
|
|
386
|
+
await session.emit('CHANNEL_UPDATE', channel);
|
|
387
|
+
},
|
|
388
|
+
channelDelete: async (session) => {
|
|
389
|
+
const channel = session.world.addChannel('to-delete');
|
|
390
|
+
await session.emit('CHANNEL_CREATE', channel);
|
|
391
|
+
session.world.channels.delete(channel.id);
|
|
392
|
+
await session.emit('CHANNEL_DELETE', channel);
|
|
393
|
+
},
|
|
394
|
+
roleCreate: async (session) => {
|
|
395
|
+
session.role('Created by the test');
|
|
396
|
+
await session.settle();
|
|
397
|
+
},
|
|
398
|
+
roleUpdate: async (session) => {
|
|
399
|
+
const role = session.world.roles.get(session.role('Members').id);
|
|
400
|
+
role.name = 'Members (renamed)';
|
|
401
|
+
await session.emit('GUILD_ROLE_UPDATE', {
|
|
402
|
+
guild_id: session.world.guildId,
|
|
403
|
+
role,
|
|
404
|
+
});
|
|
405
|
+
},
|
|
406
|
+
roleDelete: async (session) => {
|
|
407
|
+
const role = session.world.roles.get(session.role('To delete').id);
|
|
408
|
+
session.world.roles.delete(role.id);
|
|
409
|
+
await session.emit('GUILD_ROLE_DELETE', {
|
|
410
|
+
guild_id: session.world.guildId,
|
|
411
|
+
role_id: role.id,
|
|
412
|
+
});
|
|
413
|
+
},
|
|
414
|
+
banAdd: async (session) => {
|
|
415
|
+
const banned = session.member('banned');
|
|
416
|
+
const user = session.world.users.get(banned.id);
|
|
417
|
+
session.world.bans.set(user.id, { user, reason: 'a test' });
|
|
418
|
+
await session.expectEvent('GUILD_MEMBER_REMOVE', () => session.world.removeMember(user.id));
|
|
419
|
+
await session.emit('GUILD_BAN_ADD', {
|
|
420
|
+
guild_id: session.world.guildId,
|
|
421
|
+
user,
|
|
422
|
+
});
|
|
423
|
+
},
|
|
424
|
+
banRemove: async (session) => {
|
|
425
|
+
const user = session.world.addUser('unbanned');
|
|
426
|
+
await session.emit('GUILD_BAN_REMOVE', {
|
|
427
|
+
guild_id: session.world.guildId,
|
|
428
|
+
user,
|
|
429
|
+
});
|
|
430
|
+
},
|
|
431
|
+
reactionAdd: async (session) => {
|
|
432
|
+
const message = await session.say('react to me', {
|
|
433
|
+
by: session.member('bob'),
|
|
434
|
+
});
|
|
435
|
+
await session.react(message, '👍', { by: session.someone });
|
|
436
|
+
},
|
|
437
|
+
reactionRemove: async (session) => {
|
|
438
|
+
const message = await session.say('reacted', { by: session.member('bob') });
|
|
439
|
+
await session.emit('MESSAGE_REACTION_REMOVE', {
|
|
440
|
+
user_id: session.someone.id,
|
|
441
|
+
channel_id: session.world.findChannel('general').id,
|
|
442
|
+
message_id: message.id,
|
|
443
|
+
guild_id: session.world.guildId,
|
|
444
|
+
emoji: { id: null, name: '👍' },
|
|
445
|
+
burst: false,
|
|
446
|
+
type: 0,
|
|
447
|
+
});
|
|
448
|
+
},
|
|
449
|
+
reactionClear: async (session) => {
|
|
450
|
+
const message = await session.say('cleared', { by: session.member('bob') });
|
|
451
|
+
await session.emit('MESSAGE_REACTION_REMOVE_ALL', {
|
|
452
|
+
channel_id: session.world.findChannel('general').id,
|
|
453
|
+
message_id: message.id,
|
|
454
|
+
guild_id: session.world.guildId,
|
|
455
|
+
});
|
|
456
|
+
},
|
|
457
|
+
typingStart: async (session) => {
|
|
458
|
+
await session.emit('TYPING_START', {
|
|
459
|
+
channel_id: session.world.findChannel('general').id,
|
|
460
|
+
guild_id: session.world.guildId,
|
|
461
|
+
user_id: session.someone.id,
|
|
462
|
+
timestamp: Math.floor(Date.now() / 1000),
|
|
463
|
+
member: session.world.members.get(session.someone.id),
|
|
464
|
+
});
|
|
465
|
+
},
|
|
466
|
+
threadMemberJoin: async (session) => {
|
|
467
|
+
const thread = session.world.addChannel('a thread', {
|
|
468
|
+
type: ChannelType.PublicThread,
|
|
469
|
+
parentId: session.world.findChannel('general').id,
|
|
470
|
+
});
|
|
471
|
+
thread.thread_metadata = {
|
|
472
|
+
archived: false,
|
|
473
|
+
auto_archive_duration: 1440,
|
|
474
|
+
archive_timestamp: new Date().toISOString(),
|
|
475
|
+
locked: false,
|
|
476
|
+
};
|
|
477
|
+
await session.emit('THREAD_CREATE', { ...thread, newly_created: true });
|
|
478
|
+
await session.emit('THREAD_MEMBERS_UPDATE', {
|
|
479
|
+
id: thread.id,
|
|
480
|
+
guild_id: session.world.guildId,
|
|
481
|
+
member_count: 2,
|
|
482
|
+
added_members: [
|
|
483
|
+
{
|
|
484
|
+
id: thread.id,
|
|
485
|
+
user_id: session.someone.id,
|
|
486
|
+
join_timestamp: new Date().toISOString(),
|
|
487
|
+
flags: 0,
|
|
488
|
+
member: session.world.members.get(session.someone.id),
|
|
489
|
+
},
|
|
490
|
+
],
|
|
491
|
+
});
|
|
492
|
+
},
|
|
493
|
+
threadMemberLeave: async (session) => {
|
|
494
|
+
const thread = session.world.addChannel('another thread', {
|
|
495
|
+
type: ChannelType.PublicThread,
|
|
496
|
+
parentId: session.world.findChannel('general').id,
|
|
497
|
+
});
|
|
498
|
+
thread.thread_metadata = {
|
|
499
|
+
archived: false,
|
|
500
|
+
auto_archive_duration: 1440,
|
|
501
|
+
archive_timestamp: new Date().toISOString(),
|
|
502
|
+
locked: false,
|
|
503
|
+
};
|
|
504
|
+
await session.emit('THREAD_CREATE', { ...thread, newly_created: true });
|
|
505
|
+
await session.emit('THREAD_MEMBERS_UPDATE', {
|
|
506
|
+
id: thread.id,
|
|
507
|
+
guild_id: session.world.guildId,
|
|
508
|
+
member_count: 1,
|
|
509
|
+
removed_member_ids: [session.someone.id],
|
|
510
|
+
});
|
|
511
|
+
},
|
|
512
|
+
inviteCreate: async (session) => {
|
|
513
|
+
await session.emit('INVITE_CREATE', {
|
|
514
|
+
channel_id: session.world.findChannel('general').id,
|
|
515
|
+
code: 'smoketest',
|
|
516
|
+
created_at: new Date().toISOString(),
|
|
517
|
+
guild_id: session.world.guildId,
|
|
518
|
+
inviter: session.world.users.get(session.someone.id),
|
|
519
|
+
max_age: 86400,
|
|
520
|
+
max_uses: 0,
|
|
521
|
+
temporary: false,
|
|
522
|
+
uses: 0,
|
|
523
|
+
});
|
|
524
|
+
},
|
|
525
|
+
inviteDelete: async (session) => {
|
|
526
|
+
await session.emit('INVITE_DELETE', {
|
|
527
|
+
channel_id: session.world.findChannel('general').id,
|
|
528
|
+
guild_id: session.world.guildId,
|
|
529
|
+
code: 'smoketest',
|
|
530
|
+
});
|
|
531
|
+
},
|
|
532
|
+
presenceUpdate: async (session) => {
|
|
533
|
+
await session.emit('PRESENCE_UPDATE', {
|
|
534
|
+
user: { id: session.someone.id },
|
|
535
|
+
guild_id: session.world.guildId,
|
|
536
|
+
status: 'idle',
|
|
537
|
+
activities: [],
|
|
538
|
+
client_status: { desktop: 'idle' },
|
|
539
|
+
});
|
|
540
|
+
},
|
|
541
|
+
voiceJoin: async (session) => {
|
|
542
|
+
const voice = session.channel('Voice', { type: 'voice' });
|
|
543
|
+
await session.settle();
|
|
544
|
+
await session.emit('VOICE_STATE_UPDATE', voiceState(session, voice.id));
|
|
545
|
+
},
|
|
546
|
+
voiceLeave: async (session) => {
|
|
547
|
+
const voice = session.channel('Voice', { type: 'voice' });
|
|
548
|
+
await session.settle();
|
|
549
|
+
await session.emit('VOICE_STATE_UPDATE', voiceState(session, voice.id));
|
|
550
|
+
await session.emit('VOICE_STATE_UPDATE', voiceState(session, null));
|
|
551
|
+
},
|
|
552
|
+
voiceMove: async (session) => {
|
|
553
|
+
const first = session.channel('Voice', { type: 'voice' });
|
|
554
|
+
const second = session.channel('Voice 2', { type: 'voice' });
|
|
555
|
+
await session.settle();
|
|
556
|
+
await session.emit('VOICE_STATE_UPDATE', voiceState(session, first.id));
|
|
557
|
+
await session.emit('VOICE_STATE_UPDATE', voiceState(session, second.id));
|
|
558
|
+
},
|
|
559
|
+
voiceUpdate: async (session) => {
|
|
560
|
+
const voice = session.channel('Voice', { type: 'voice' });
|
|
561
|
+
await session.settle();
|
|
562
|
+
await session.emit('VOICE_STATE_UPDATE', voiceState(session, voice.id));
|
|
563
|
+
await session.emit('VOICE_STATE_UPDATE', {
|
|
564
|
+
...voiceState(session, voice.id),
|
|
565
|
+
self_mute: true,
|
|
566
|
+
});
|
|
567
|
+
},
|
|
568
|
+
emojiCreate: async (session) => {
|
|
569
|
+
await session.emit('GUILD_EMOJIS_UPDATE', {
|
|
570
|
+
guild_id: session.world.guildId,
|
|
571
|
+
emojis: [
|
|
572
|
+
{
|
|
573
|
+
id: session.world.snowflake(),
|
|
574
|
+
name: 'smoke',
|
|
575
|
+
roles: [],
|
|
576
|
+
require_colons: true,
|
|
577
|
+
managed: false,
|
|
578
|
+
animated: false,
|
|
579
|
+
available: true,
|
|
580
|
+
},
|
|
581
|
+
],
|
|
582
|
+
});
|
|
583
|
+
},
|
|
584
|
+
auditLogEntryCreate: async (session) => {
|
|
585
|
+
await session.emit('GUILD_AUDIT_LOG_ENTRY_CREATE', {
|
|
586
|
+
id: session.world.snowflake(),
|
|
587
|
+
guild_id: session.world.guildId,
|
|
588
|
+
user_id: session.owner.id,
|
|
589
|
+
target_id: session.someone.id,
|
|
590
|
+
action_type: 20,
|
|
591
|
+
changes: [],
|
|
592
|
+
});
|
|
593
|
+
},
|
|
594
|
+
};
|
|
595
|
+
const voiceState = (session, channelId) => ({
|
|
596
|
+
guild_id: session.world.guildId,
|
|
597
|
+
channel_id: channelId,
|
|
598
|
+
user_id: session.someone.id,
|
|
599
|
+
member: session.world.members.get(session.someone.id),
|
|
600
|
+
session_id: 'voice-session',
|
|
601
|
+
deaf: false,
|
|
602
|
+
mute: false,
|
|
603
|
+
self_deaf: false,
|
|
604
|
+
self_mute: false,
|
|
605
|
+
self_video: false,
|
|
606
|
+
suppress: false,
|
|
607
|
+
request_to_speak_timestamp: null,
|
|
608
|
+
});
|
|
609
|
+
/** The checks of the events: one occurrence each, when the world can make it. */
|
|
610
|
+
function eventChecks(project) {
|
|
611
|
+
const checks = [];
|
|
612
|
+
const notSimulated = [];
|
|
613
|
+
const seen = new Set();
|
|
614
|
+
for (const { event, file } of project.events.values()) {
|
|
615
|
+
if (event.name === 'ready' || seen.has(event.name))
|
|
616
|
+
continue;
|
|
617
|
+
seen.add(event.name);
|
|
618
|
+
const trigger = TRIGGERS[event.name];
|
|
619
|
+
if (!trigger || !(event.name in EVENTS)) {
|
|
620
|
+
notSimulated.push(event.name);
|
|
621
|
+
continue;
|
|
622
|
+
}
|
|
623
|
+
checks.push({
|
|
624
|
+
subject: `event ${event.name}`,
|
|
625
|
+
file,
|
|
626
|
+
cases: [{ label: 'once', run: session => trigger(session) }],
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
return { checks, notSimulated };
|
|
630
|
+
}
|
|
631
|
+
/** The checks of the tasks: one run each. */
|
|
632
|
+
function taskChecks(project) {
|
|
633
|
+
return [...project.tasks.values()].map((entry) => ({
|
|
634
|
+
subject: `task ${entry.task.name}`,
|
|
635
|
+
file: entry.file,
|
|
636
|
+
cases: [
|
|
637
|
+
{
|
|
638
|
+
label: 'once',
|
|
639
|
+
run: async (session) => {
|
|
640
|
+
await session.project.runTask(entry, session.bot);
|
|
641
|
+
await session.settle();
|
|
642
|
+
},
|
|
643
|
+
},
|
|
644
|
+
],
|
|
645
|
+
}));
|
|
646
|
+
}
|
|
647
|
+
/** Every check of the project, in the order of the report. */
|
|
648
|
+
export function smokeChecks(project) {
|
|
649
|
+
const events = eventChecks(project);
|
|
650
|
+
return {
|
|
651
|
+
checks: [
|
|
652
|
+
...commandChecks(project),
|
|
653
|
+
...componentChecks(project),
|
|
654
|
+
...events.checks,
|
|
655
|
+
...taskChecks(project),
|
|
656
|
+
],
|
|
657
|
+
notSimulated: events.notSimulated,
|
|
658
|
+
};
|
|
659
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { type IncomingMessage } from 'node:http';
|
|
2
|
+
/** One WebSocket client of a `webSocketServer()`. */
|
|
3
|
+
export interface WebSocketPeer {
|
|
4
|
+
/** The query string of the URL it connected to. */
|
|
5
|
+
query: Record<string, string>;
|
|
6
|
+
/** Set once the connection is closed, with the code the client gave. */
|
|
7
|
+
closed: {
|
|
8
|
+
code: number | null;
|
|
9
|
+
} | null;
|
|
10
|
+
sendText(text: string): void;
|
|
11
|
+
sendBinary(data: Uint8Array): void;
|
|
12
|
+
/** Closes with a close code, as a server does. */
|
|
13
|
+
close(code: number): void;
|
|
14
|
+
/** Drops the connection without a close frame (a network failure). */
|
|
15
|
+
drop(): void;
|
|
16
|
+
/** Resolves when the connection is closed, with the client's close code. */
|
|
17
|
+
waitForClose(timeout?: number): Promise<number | null>;
|
|
18
|
+
}
|
|
19
|
+
/** A WebSocket server, listening. */
|
|
20
|
+
export interface WebSocketServer {
|
|
21
|
+
/** `ws://127.0.0.1:port`. */
|
|
22
|
+
url: string;
|
|
23
|
+
host: string;
|
|
24
|
+
port: number;
|
|
25
|
+
/** Drops every connection and stops listening. */
|
|
26
|
+
close(): Promise<void>;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* A local WebSocket server: it accepts every client, reads their (masked,
|
|
30
|
+
* possibly fragmented) messages and answers pings. The caller closes it.
|
|
31
|
+
*/
|
|
32
|
+
export declare function webSocketServer(onConnection: (peer: WebSocketPeer, request: IncomingMessage, on: {
|
|
33
|
+
message: (handler: (data: string | Buffer) => void) => void;
|
|
34
|
+
}) => void): Promise<WebSocketServer>;
|