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
package/dist/cli/test.js
ADDED
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
// `chapterjs test`: runs the bot of the current folder against a Discord
|
|
2
|
+
// in memory and checks it, without a token and without touching Discord.
|
|
3
|
+
// First what needs nothing written: every command, button, menu, form,
|
|
4
|
+
// event and task is used once with ordinary values and with hard ones,
|
|
5
|
+
// and must answer without throwing. Then the scenarios of the project
|
|
6
|
+
// (`src/**/*.test.ts`, written with `scenario()` from 'chapterjs/test'),
|
|
7
|
+
// each on a fresh server.
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { enableProjectLoader } from '../loader/hot.js';
|
|
10
|
+
import { listTests, loadFiles, readDeclarations, siteName, } from '../loader/loader.js';
|
|
11
|
+
import { locate, messageOf } from '../loader/locate.js';
|
|
12
|
+
import { MemoryStoreBackend } from '../store/memory-backend.js';
|
|
13
|
+
import { TestFailure } from '../testing/failure.js';
|
|
14
|
+
import { captureLog, Observer, Session } from '../testing/harness.js';
|
|
15
|
+
import { scenarioDeclaration, } from '../testing/scenario.js';
|
|
16
|
+
import { smokeChecks } from '../testing/smoke.js';
|
|
17
|
+
import { World } from '../testing/world.js';
|
|
18
|
+
import { createProject, hasSources, writeTypes, } from './project.js';
|
|
19
|
+
/**
|
|
20
|
+
* What a terminal shows while a file is tested: `↻ Testing src/close.ts`,
|
|
21
|
+
* then one line per thing and case as it is tried. Once the file is done,
|
|
22
|
+
* those lines are erased and its result takes their place, so a report
|
|
23
|
+
* that passed reads as if nothing had moved. When nobody is watching (the
|
|
24
|
+
* output is not a terminal), nothing is shown meanwhile.
|
|
25
|
+
*/
|
|
26
|
+
export function checkProgress(enabled, log, write) {
|
|
27
|
+
let lines = 0;
|
|
28
|
+
return {
|
|
29
|
+
start(title) {
|
|
30
|
+
if (!enabled)
|
|
31
|
+
return;
|
|
32
|
+
log.reload(title);
|
|
33
|
+
lines = 1;
|
|
34
|
+
},
|
|
35
|
+
step(label) {
|
|
36
|
+
if (!enabled)
|
|
37
|
+
return;
|
|
38
|
+
write(` → ${label}\n`);
|
|
39
|
+
lines++;
|
|
40
|
+
},
|
|
41
|
+
clear() {
|
|
42
|
+
if (!enabled || lines === 0)
|
|
43
|
+
return;
|
|
44
|
+
// Up to the first line of the check, then everything below it goes.
|
|
45
|
+
write(`\x1b[${lines}A\x1b[0J`);
|
|
46
|
+
lines = 0;
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/** What the files said to the developer during a check, as failures. */
|
|
51
|
+
function problems(captured) {
|
|
52
|
+
const failures = [...captured.errors];
|
|
53
|
+
const warnings = [];
|
|
54
|
+
for (const warning of captured.warnings) {
|
|
55
|
+
// The person sees "The application did not respond": a failure.
|
|
56
|
+
if (warning.includes('finished without answering'))
|
|
57
|
+
failures.push(warning);
|
|
58
|
+
else
|
|
59
|
+
warnings.push(warning);
|
|
60
|
+
}
|
|
61
|
+
return { failures, warnings };
|
|
62
|
+
}
|
|
63
|
+
/** Where a failure of a scenario comes from, when it is in the project. */
|
|
64
|
+
function where(error, cwd) {
|
|
65
|
+
const found = locate(error, cwd);
|
|
66
|
+
return found ? `${found.file}:${found.line} ` : '';
|
|
67
|
+
}
|
|
68
|
+
/** Runs the whole project in a fresh world; `close` ends it. */
|
|
69
|
+
async function startSession(project, observer, stores) {
|
|
70
|
+
stores.clear();
|
|
71
|
+
observer.reset();
|
|
72
|
+
const world = new World();
|
|
73
|
+
await world.start();
|
|
74
|
+
let bot;
|
|
75
|
+
try {
|
|
76
|
+
bot = await project.connect({
|
|
77
|
+
token: 'test-token',
|
|
78
|
+
apiUrl: world.discord.url,
|
|
79
|
+
privateEvents: true,
|
|
80
|
+
onFatal: () => { },
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
await world.close();
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
const session = new Session(world, project, bot, observer);
|
|
88
|
+
return {
|
|
89
|
+
session,
|
|
90
|
+
close: async () => {
|
|
91
|
+
await bot.close();
|
|
92
|
+
await world.close();
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* What the files print while they are tested, kept out of the report: a
|
|
98
|
+
* `console.log` of a `ready` event saying "online" would read as the bot
|
|
99
|
+
* being online. The runner writes through `report()`; what the files wrote
|
|
100
|
+
* is given back by `take()`, to show under a failure.
|
|
101
|
+
*/
|
|
102
|
+
function quiet() {
|
|
103
|
+
const original = {
|
|
104
|
+
out: process.stdout.write.bind(process.stdout),
|
|
105
|
+
err: process.stderr.write.bind(process.stderr),
|
|
106
|
+
};
|
|
107
|
+
let printed = '';
|
|
108
|
+
const swallow = (chunk) => {
|
|
109
|
+
printed +=
|
|
110
|
+
typeof chunk === 'string'
|
|
111
|
+
? chunk
|
|
112
|
+
: Buffer.from(chunk).toString();
|
|
113
|
+
return true;
|
|
114
|
+
};
|
|
115
|
+
const silence = () => {
|
|
116
|
+
process.stdout.write = swallow;
|
|
117
|
+
process.stderr.write = swallow;
|
|
118
|
+
};
|
|
119
|
+
const restore = () => {
|
|
120
|
+
process.stdout.write = original.out;
|
|
121
|
+
process.stderr.write = original.err;
|
|
122
|
+
};
|
|
123
|
+
silence();
|
|
124
|
+
return {
|
|
125
|
+
report(write) {
|
|
126
|
+
restore();
|
|
127
|
+
try {
|
|
128
|
+
write();
|
|
129
|
+
}
|
|
130
|
+
finally {
|
|
131
|
+
silence();
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
take() {
|
|
135
|
+
const taken = printed;
|
|
136
|
+
printed = '';
|
|
137
|
+
return taken;
|
|
138
|
+
},
|
|
139
|
+
restore,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
/** What the files printed, as the lines under a failure. */
|
|
143
|
+
function printed(output) {
|
|
144
|
+
const text = output.trim();
|
|
145
|
+
if (text === '')
|
|
146
|
+
return '';
|
|
147
|
+
return `\nWhat the files printed meanwhile:\n${text}`;
|
|
148
|
+
}
|
|
149
|
+
/** Resolves with the exit code of the process. */
|
|
150
|
+
export async function test(options) {
|
|
151
|
+
const { cwd } = options;
|
|
152
|
+
if (!(await hasSources(cwd, options.log)))
|
|
153
|
+
return 1;
|
|
154
|
+
const src = join(cwd, 'src');
|
|
155
|
+
const output = quiet();
|
|
156
|
+
try {
|
|
157
|
+
return await run(options, src, output);
|
|
158
|
+
}
|
|
159
|
+
finally {
|
|
160
|
+
output.restore();
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
async function run(options, src, output) {
|
|
164
|
+
const { cwd } = options;
|
|
165
|
+
// The report, written while what the files print is kept out of it.
|
|
166
|
+
const log = {
|
|
167
|
+
success: message => output.report(() => options.log.success(message)),
|
|
168
|
+
info: message => output.report(() => options.log.info(message)),
|
|
169
|
+
reload: message => output.report(() => options.log.reload(message)),
|
|
170
|
+
warn: message => output.report(() => options.log.warn(message)),
|
|
171
|
+
error: message => output.report(() => options.log.error(message)),
|
|
172
|
+
};
|
|
173
|
+
// 1. The files of the project, every one of them running.
|
|
174
|
+
enableProjectLoader(src, { reload: false });
|
|
175
|
+
const capture = captureLog();
|
|
176
|
+
const observer = new Observer();
|
|
177
|
+
const stores = new MemoryStoreBackend();
|
|
178
|
+
const project = createProject({
|
|
179
|
+
cwd,
|
|
180
|
+
version: options.version,
|
|
181
|
+
log: capture.log,
|
|
182
|
+
stores,
|
|
183
|
+
observe: observer.observe,
|
|
184
|
+
});
|
|
185
|
+
await writeTypes(cwd);
|
|
186
|
+
const failures = await project.load();
|
|
187
|
+
await writeTypes(cwd, project.typesInfo());
|
|
188
|
+
if (failures.length > 0) {
|
|
189
|
+
for (const failure of failures)
|
|
190
|
+
report(failure, cwd, log);
|
|
191
|
+
log.error(`${failures.length === 1 ? 'This file' : `These ${failures.length} files`} can't run, so your bot was not tested. Fix ${failures.length === 1 ? 'it' : 'them'} and test again: chapterjs dev shows the same errors while you write.${printed(output.take())}`);
|
|
192
|
+
await project.closeStores();
|
|
193
|
+
return 1;
|
|
194
|
+
}
|
|
195
|
+
if (project.isEmpty()) {
|
|
196
|
+
log.info(project.summary());
|
|
197
|
+
await project.closeStores();
|
|
198
|
+
return 0;
|
|
199
|
+
}
|
|
200
|
+
log.success(project.summary());
|
|
201
|
+
output.take();
|
|
202
|
+
let checksPassed = 0;
|
|
203
|
+
let checksFailed = 0;
|
|
204
|
+
let scenariosPassed = 0;
|
|
205
|
+
let scenariosFailed = 0;
|
|
206
|
+
try {
|
|
207
|
+
// 2. What needs nothing written: every file used once. The report
|
|
208
|
+
// has one line per file: what was checked in it only shows when
|
|
209
|
+
// something failed.
|
|
210
|
+
const { checks, notSimulated } = smokeChecks(project);
|
|
211
|
+
const smoke = await startSession(project, observer, stores);
|
|
212
|
+
const progress = checkProgress(options.progress === true, log, text => output.report(() => process.stdout.write(text)));
|
|
213
|
+
try {
|
|
214
|
+
// The ready event runs first, as it does online; its result is
|
|
215
|
+
// reported with the file it comes from.
|
|
216
|
+
project.ready(smoke.session.bot);
|
|
217
|
+
await smoke.session.settle();
|
|
218
|
+
const atStart = problems(capture.take());
|
|
219
|
+
const readyPrinted = output.take();
|
|
220
|
+
const readyFiles = [
|
|
221
|
+
...new Set([...project.events.values()]
|
|
222
|
+
.filter(({ event }) => event.name === 'ready')
|
|
223
|
+
.map(({ file }) => file)),
|
|
224
|
+
];
|
|
225
|
+
for (const warning of atStart.warnings)
|
|
226
|
+
log.warn(warning);
|
|
227
|
+
/** What was found in each file, in the order of the files. */
|
|
228
|
+
const byFile = new Map();
|
|
229
|
+
for (const file of readyFiles)
|
|
230
|
+
byFile.set(file, []);
|
|
231
|
+
for (const check of checks) {
|
|
232
|
+
byFile.set(check.file, [...(byFile.get(check.file) ?? []), check]);
|
|
233
|
+
}
|
|
234
|
+
// The files that passed are one line at the end: these checks are
|
|
235
|
+
// the framework's, what the developer reads is the scenarios.
|
|
236
|
+
const quietFiles = [];
|
|
237
|
+
let quietChecks = 0;
|
|
238
|
+
for (const [file, own] of [...byFile].sort(([a], [b]) => a < b ? -1 : 1)) {
|
|
239
|
+
progress.start(`Checking ${file}`);
|
|
240
|
+
const failures = [];
|
|
241
|
+
const warnings = [];
|
|
242
|
+
let passed = 0;
|
|
243
|
+
if (readyFiles.includes(file)) {
|
|
244
|
+
if (atStart.failures.length > 0) {
|
|
245
|
+
failures.push(`event ready failed: ${atStart.failures.map(failure => failure.split('\n').join(' ')).join('; ')}${printed(readyPrinted)}`);
|
|
246
|
+
}
|
|
247
|
+
else
|
|
248
|
+
passed++;
|
|
249
|
+
}
|
|
250
|
+
for (const check of own) {
|
|
251
|
+
progress.step(check.subject);
|
|
252
|
+
const result = await runCheck(check, smoke.session, capture, cwd, label => progress.step(`${check.subject}, ${label}`));
|
|
253
|
+
const wrote = output.take();
|
|
254
|
+
if (result.failed.length === 0)
|
|
255
|
+
passed++;
|
|
256
|
+
else {
|
|
257
|
+
// One case: its label says nothing more than the subject.
|
|
258
|
+
failures.push(check.cases.length === 1
|
|
259
|
+
? `${check.subject} failed\n${result.failed[0].message}${printed(wrote)}`
|
|
260
|
+
: `${check.subject} failed in ${result.failed.length === check.cases.length ? 'every case' : `${result.failed.length} of ${check.cases.length} cases`}\n${result.failed.map(one => `${one.label}: ${one.message}`).join('\n')}${printed(wrote)}`);
|
|
261
|
+
}
|
|
262
|
+
for (const warning of result.warnings)
|
|
263
|
+
warnings.push(`${check.subject}: ${warning}`);
|
|
264
|
+
}
|
|
265
|
+
progress.clear();
|
|
266
|
+
checksPassed += passed;
|
|
267
|
+
checksFailed += failures.length;
|
|
268
|
+
if (failures.length === 0) {
|
|
269
|
+
quietFiles.push(file);
|
|
270
|
+
quietChecks += passed;
|
|
271
|
+
}
|
|
272
|
+
for (const failure of failures)
|
|
273
|
+
log.error(`Checked ${file}: ${failure}`);
|
|
274
|
+
for (const warning of warnings)
|
|
275
|
+
log.warn(`Checked ${file}: ${warning}`);
|
|
276
|
+
}
|
|
277
|
+
if (quietFiles.length > 0) {
|
|
278
|
+
const files = `${quietFiles.length} ${quietFiles.length === 1 ? 'file' : 'files'}`;
|
|
279
|
+
const count = `${quietChecks} ${quietChecks === 1 ? 'check' : 'checks'}`;
|
|
280
|
+
log.success(quietFiles.length === byFile.size
|
|
281
|
+
? `Checked every file once: ${count} in ${files}`
|
|
282
|
+
: `Checked the other ${files} once: ${count}`);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
finally {
|
|
286
|
+
await smoke.close();
|
|
287
|
+
}
|
|
288
|
+
if (notSimulated.length > 0) {
|
|
289
|
+
log.info(`${notSimulated.length === 1 ? 'An event is' : 'Some events are'} not simulated yet, so ${notSimulated.length === 1 ? 'its file was' : 'their files were'} not run: ${notSimulated.join(', ')}.`);
|
|
290
|
+
}
|
|
291
|
+
// 3. The scenarios of the project, each on a fresh server: one line
|
|
292
|
+
// per test file, the scenarios of it only shown when one failed.
|
|
293
|
+
const files = await listTests(cwd);
|
|
294
|
+
const { loaded, broken } = await loadScenarios(files, cwd, log);
|
|
295
|
+
scenariosFailed += broken;
|
|
296
|
+
const scenariosByFile = new Map();
|
|
297
|
+
for (const { scenario, file } of loaded) {
|
|
298
|
+
scenariosByFile.set(file, [
|
|
299
|
+
...(scenariosByFile.get(file) ?? []),
|
|
300
|
+
scenario,
|
|
301
|
+
]);
|
|
302
|
+
}
|
|
303
|
+
for (const [file, own] of scenariosByFile) {
|
|
304
|
+
progress.start(`Testing ${file}`);
|
|
305
|
+
const failures = [];
|
|
306
|
+
const warnings = [];
|
|
307
|
+
let passed = 0;
|
|
308
|
+
for (const scenario of own) {
|
|
309
|
+
progress.step(`"${scenario.label}"`);
|
|
310
|
+
const run = await startSession(project, observer, stores);
|
|
311
|
+
let failure = null;
|
|
312
|
+
try {
|
|
313
|
+
project.ready(run.session.bot);
|
|
314
|
+
await run.session.settle();
|
|
315
|
+
capture.take();
|
|
316
|
+
output.take();
|
|
317
|
+
await withTimeout(Promise.resolve(scenario.run(run.session)), options.scenarioTimeout ?? 10_000, `took more than ${(options.scenarioTimeout ?? 10_000) / 1000} s: is something waiting for an answer that never comes?`);
|
|
318
|
+
await run.session.settle();
|
|
319
|
+
}
|
|
320
|
+
catch (error) {
|
|
321
|
+
failure =
|
|
322
|
+
error instanceof TestFailure
|
|
323
|
+
? `${where(error, cwd)}${error.message}`
|
|
324
|
+
: `${where(error, cwd)}${messageOf(error)}`;
|
|
325
|
+
}
|
|
326
|
+
finally {
|
|
327
|
+
await run.close();
|
|
328
|
+
}
|
|
329
|
+
const captured = problems(capture.take());
|
|
330
|
+
const wrote = output.take();
|
|
331
|
+
if (failure === null && captured.failures.length === 0)
|
|
332
|
+
passed++;
|
|
333
|
+
else {
|
|
334
|
+
failures.push(`"${scenario.label}" failed\n${[...(failure ? [failure] : []), ...captured.failures].join('\n')}${printed(wrote)}`);
|
|
335
|
+
}
|
|
336
|
+
for (const warning of captured.warnings)
|
|
337
|
+
warnings.push(`"${scenario.label}": ${warning}`);
|
|
338
|
+
}
|
|
339
|
+
progress.clear();
|
|
340
|
+
scenariosPassed += passed;
|
|
341
|
+
scenariosFailed += failures.length;
|
|
342
|
+
if (failures.length === 0)
|
|
343
|
+
log.success(`Tested ${file}`);
|
|
344
|
+
for (const failure of failures)
|
|
345
|
+
log.error(`Tested ${file}: ${failure}`);
|
|
346
|
+
for (const warning of warnings)
|
|
347
|
+
log.warn(`Tested ${file}: ${warning}`);
|
|
348
|
+
}
|
|
349
|
+
if (files.length === 0) {
|
|
350
|
+
log.info(`No scenario yet. Write one in a *.test.ts file of src/ with scenario() from 'chapterjs/test' to check what your bot answers.`);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
finally {
|
|
354
|
+
await project.closeStores();
|
|
355
|
+
}
|
|
356
|
+
// 4. The bottom line.
|
|
357
|
+
const checksTotal = checksPassed + checksFailed;
|
|
358
|
+
const scenariosTotal = scenariosPassed + scenariosFailed;
|
|
359
|
+
const parts = [
|
|
360
|
+
`${checksTotal} ${checksTotal === 1 ? 'check' : 'checks'}`,
|
|
361
|
+
...(scenariosTotal > 0
|
|
362
|
+
? [`${scenariosTotal} ${scenariosTotal === 1 ? 'scenario' : 'scenarios'}`]
|
|
363
|
+
: []),
|
|
364
|
+
];
|
|
365
|
+
if (checksFailed === 0 && scenariosFailed === 0) {
|
|
366
|
+
log.success(`${parts.join(' and ')} passed`);
|
|
367
|
+
return 0;
|
|
368
|
+
}
|
|
369
|
+
log.error(`${[
|
|
370
|
+
...(checksFailed > 0
|
|
371
|
+
? [
|
|
372
|
+
`${checksFailed} of ${checksTotal} ${checksTotal === 1 ? 'check' : 'checks'}`,
|
|
373
|
+
]
|
|
374
|
+
: []),
|
|
375
|
+
...(scenariosFailed > 0
|
|
376
|
+
? [
|
|
377
|
+
`${scenariosFailed} of ${scenariosTotal} ${scenariosTotal === 1 ? 'scenario' : 'scenarios'}`,
|
|
378
|
+
]
|
|
379
|
+
: []),
|
|
380
|
+
].join(' and ')} failed`);
|
|
381
|
+
return 1;
|
|
382
|
+
}
|
|
383
|
+
/** Shows a file (or an export) that could not run. */
|
|
384
|
+
function report(failure, cwd, log) {
|
|
385
|
+
const found = locate(failure.error, cwd);
|
|
386
|
+
log.error(`${found ? `${found.file}:${found.line}` : siteName(failure)} ${messageOf(failure.error)}`);
|
|
387
|
+
}
|
|
388
|
+
/** Runs the cases of a check, one after the other, in the same world. */
|
|
389
|
+
async function runCheck(check, session, capture, cwd, onCase = () => { }) {
|
|
390
|
+
const failed = [];
|
|
391
|
+
const warnings = [];
|
|
392
|
+
for (const one of check.cases) {
|
|
393
|
+
onCase(one.label);
|
|
394
|
+
capture.take();
|
|
395
|
+
let thrown = null;
|
|
396
|
+
try {
|
|
397
|
+
await withTimeout(one.run(session), 5000, 'the bot did not answer within 5 s');
|
|
398
|
+
}
|
|
399
|
+
catch (error) {
|
|
400
|
+
thrown =
|
|
401
|
+
error instanceof TestFailure
|
|
402
|
+
? error.message
|
|
403
|
+
: `${where(error, cwd)}${messageOf(error)}`;
|
|
404
|
+
}
|
|
405
|
+
const captured = problems(capture.take());
|
|
406
|
+
const messages = [...(thrown ? [thrown] : []), ...captured.failures];
|
|
407
|
+
if (messages.length > 0) {
|
|
408
|
+
failed.push({ label: one.label, message: messages.join('; ') });
|
|
409
|
+
}
|
|
410
|
+
for (const warning of captured.warnings) {
|
|
411
|
+
if (!warnings.includes(warning))
|
|
412
|
+
warnings.push(warning);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
return { failed, warnings };
|
|
416
|
+
}
|
|
417
|
+
/** Loads the test files and reads their scenarios; what fails is reported. */
|
|
418
|
+
async function loadScenarios(files, cwd, log) {
|
|
419
|
+
const sources = await loadFiles(files);
|
|
420
|
+
const read = readDeclarations(sources.modules, scenarioDeclaration);
|
|
421
|
+
const failures = [...sources.failed, ...read.failed];
|
|
422
|
+
for (const failure of failures)
|
|
423
|
+
report(failure, cwd, log);
|
|
424
|
+
const loaded = read.loaded.map(item => ({
|
|
425
|
+
scenario: item.value,
|
|
426
|
+
file: item.file,
|
|
427
|
+
}));
|
|
428
|
+
for (const { file, exports } of sources.modules) {
|
|
429
|
+
if (!read.loaded.some(item => item.file === file) &&
|
|
430
|
+
!read.failed.some(item => item.file === file)) {
|
|
431
|
+
log.warn(`${file} declares no scenario: export one made with scenario() from 'chapterjs/test'${Object.keys(exports).length === 0 ? '' : ` (its exports are: ${Object.keys(exports).join(', ')})`}.`);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
return { loaded, broken: failures.length };
|
|
435
|
+
}
|
|
436
|
+
function withTimeout(promise, ms, message) {
|
|
437
|
+
let timer;
|
|
438
|
+
return Promise.race([
|
|
439
|
+
promise.finally(() => clearTimeout(timer)),
|
|
440
|
+
new Promise((_, reject) => {
|
|
441
|
+
timer = setTimeout(() => reject(new TestFailure(message)), ms);
|
|
442
|
+
}),
|
|
443
|
+
]);
|
|
444
|
+
}
|
|
@@ -10,4 +10,4 @@ export declare const MAX_SUGGESTIONS = 25;
|
|
|
10
10
|
* is empty, so every path ends with an answer, even when the function
|
|
11
11
|
* fails: the person is never left with a spinner.
|
|
12
12
|
*/
|
|
13
|
-
export declare function suggest(ctx: Context, raw: RawInteraction, entry: CommandEntry | undefined, given: readonly RawApplicationCommandInteractionDataOption[], reporter: Reporter, warnedOnce: Set<string>): void
|
|
13
|
+
export declare function suggest(ctx: Context, raw: RawInteraction, entry: CommandEntry | undefined, given: readonly RawApplicationCommandInteractionDataOption[], reporter: Reporter, warnedOnce: Set<string>): Promise<void>;
|
|
@@ -27,39 +27,37 @@ const UNKNOWN_INTERACTION = 10062;
|
|
|
27
27
|
export function suggest(ctx, raw, entry, given, reporter, warnedOnce) {
|
|
28
28
|
const focused = given.find(option => option.focused);
|
|
29
29
|
const started = Date.now();
|
|
30
|
-
const answer = (choices) =>
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
})
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
if (Date.now() - started < DEADLINE)
|
|
47
|
-
return;
|
|
48
|
-
reporter.onWarning(entry.file, `The suggestions for ${name} took ${((Date.now() - started) / 1000).toFixed(1)} s: Discord waits 3 seconds, then shows none. Make the autocomplete function faster.`);
|
|
30
|
+
const answer = (choices) => ctx.rest
|
|
31
|
+
.request(CreateInteractionResponse, [raw.id, raw.token], {
|
|
32
|
+
body: {
|
|
33
|
+
type: InteractionCallbackType.ApplicationCommandAutocompleteResult,
|
|
34
|
+
data: { choices },
|
|
35
|
+
},
|
|
36
|
+
auth: false,
|
|
37
|
+
})
|
|
38
|
+
.then(() => { }, (error) => {
|
|
39
|
+
if (!entry || !focused)
|
|
40
|
+
return;
|
|
41
|
+
const name = `${commandName(entry.command.path)} ${focused.name}`;
|
|
42
|
+
if (error instanceof DiscordApiError &&
|
|
43
|
+
error.code === UNKNOWN_INTERACTION) {
|
|
44
|
+
// Too late, or the person typed on and Discord dropped this one.
|
|
45
|
+
if (Date.now() - started < DEADLINE)
|
|
49
46
|
return;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
}
|
|
53
|
-
|
|
47
|
+
reporter.onWarning(entry.file, `The suggestions for ${name} took ${((Date.now() - started) / 1000).toFixed(1)} s: Discord waits 3 seconds, then shows none. Make the autocomplete function faster.`);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
reporter.onError(entry.file, error);
|
|
51
|
+
});
|
|
54
52
|
// The command, the option or its function is gone: nothing to suggest.
|
|
55
53
|
const fn = focused && entry?.command.autocomplete[focused.name];
|
|
56
54
|
if (!entry || !focused || !fn) {
|
|
57
|
-
answer([]);
|
|
55
|
+
const sent = answer([]);
|
|
58
56
|
if (entry && focused) {
|
|
59
57
|
// Still registered with suggestions, but the file has no function.
|
|
60
58
|
reporter.onWarning(entry.file, `Discord asked suggestions for ${commandName(entry.command.path)} ${focused.name}, which has none in "autocomplete": the command was registered with an older version of this file.`);
|
|
61
59
|
}
|
|
62
|
-
return;
|
|
60
|
+
return sent;
|
|
63
61
|
}
|
|
64
62
|
const { command, file } = entry;
|
|
65
63
|
const name = `${commandName(command.path)} ${focused.name}`;
|
|
@@ -97,24 +95,22 @@ export function suggest(ctx, raw, entry, given, reporter, warnedOnce) {
|
|
|
97
95
|
// Suggestions are shown to the person alone: their language.
|
|
98
96
|
...translation(ctx, raw.locale ?? null),
|
|
99
97
|
});
|
|
100
|
-
new Promise(resolve => resolve(fn(context)))
|
|
101
|
-
.then(suggestions => {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
}));
|
|
114
|
-
})
|
|
98
|
+
return new Promise(resolve => resolve(fn(context)))
|
|
99
|
+
.then(suggestions => answer(toChoices(suggestions, {
|
|
100
|
+
name,
|
|
101
|
+
isText,
|
|
102
|
+
integer: option.type === 'integer',
|
|
103
|
+
warn: message => {
|
|
104
|
+
// Said once per option: it happens on every keystroke.
|
|
105
|
+
if (warnedOnce.has(`${file} ${name} ${message}`))
|
|
106
|
+
return;
|
|
107
|
+
warnedOnce.add(`${file} ${name} ${message}`);
|
|
108
|
+
reporter.onWarning(file, `${name}: ${message}`);
|
|
109
|
+
},
|
|
110
|
+
})))
|
|
115
111
|
.catch((error) => {
|
|
116
112
|
reporter.onError(file, error);
|
|
117
|
-
answer([]);
|
|
113
|
+
return answer([]);
|
|
118
114
|
});
|
|
119
115
|
}
|
|
120
116
|
/**
|
|
@@ -15,6 +15,9 @@ export declare class CommandRouter {
|
|
|
15
15
|
/**
|
|
16
16
|
* Handles an Interaction Create event, if it is a slash command being
|
|
17
17
|
* used, or someone typing in one of its options with suggestions.
|
|
18
|
+
* Returns a promise of everything being done (the function run, the
|
|
19
|
+
* person answered), or `null` when the interaction is not for this
|
|
20
|
+
* router. Nothing of the bot waits for it: `chapterjs test` does.
|
|
18
21
|
*/
|
|
19
|
-
dispatch(ctx: Context, raw: RawInteraction): void;
|
|
22
|
+
dispatch(ctx: Context, raw: RawInteraction): Promise<void> | null;
|
|
20
23
|
}
|
package/dist/commands/router.js
CHANGED
|
@@ -42,15 +42,18 @@ export class CommandRouter {
|
|
|
42
42
|
/**
|
|
43
43
|
* Handles an Interaction Create event, if it is a slash command being
|
|
44
44
|
* used, or someone typing in one of its options with suggestions.
|
|
45
|
+
* Returns a promise of everything being done (the function run, the
|
|
46
|
+
* person answered), or `null` when the interaction is not for this
|
|
47
|
+
* router. Nothing of the bot waits for it: `chapterjs test` does.
|
|
45
48
|
*/
|
|
46
49
|
dispatch(ctx, raw) {
|
|
47
50
|
const autocomplete = raw.type === InteractionType.ApplicationCommandAutocomplete;
|
|
48
51
|
if (raw.type !== InteractionType.ApplicationCommand && !autocomplete) {
|
|
49
|
-
return;
|
|
52
|
+
return null;
|
|
50
53
|
}
|
|
51
54
|
const data = raw.data;
|
|
52
55
|
if (!data || data.type !== ApplicationCommandType.ChatInput)
|
|
53
|
-
return;
|
|
56
|
+
return null;
|
|
54
57
|
// `/mod roles add` comes as a command with nested options.
|
|
55
58
|
const path = [data.name];
|
|
56
59
|
let given = data.options ?? [];
|
|
@@ -60,13 +63,12 @@ export class CommandRouter {
|
|
|
60
63
|
}
|
|
61
64
|
const entry = this.#commands.get(path.join(' '));
|
|
62
65
|
if (autocomplete) {
|
|
63
|
-
suggest(ctx, raw, entry, given, this.#options, this.#warnedOnce);
|
|
64
|
-
return;
|
|
66
|
+
return suggest(ctx, raw, entry, given, this.#options, this.#warnedOnce);
|
|
65
67
|
}
|
|
66
68
|
const name = commandName(path);
|
|
67
69
|
const author = authorOf(raw);
|
|
68
70
|
if (!author)
|
|
69
|
-
return;
|
|
71
|
+
return null;
|
|
70
72
|
const { user: _user, member: _member, message: _message, ...rest } = raw;
|
|
71
73
|
const user = ctx.entities.user(author.user);
|
|
72
74
|
const interaction = new CommandInteraction(ctx, rest, user, {
|
|
@@ -75,14 +77,12 @@ export class CommandRouter {
|
|
|
75
77
|
});
|
|
76
78
|
if (!entry) {
|
|
77
79
|
// Still registered on Discord, but its file is gone or broken.
|
|
78
|
-
refuse(interaction, 'unavailable');
|
|
79
|
-
return;
|
|
80
|
+
return refuse(interaction, 'unavailable');
|
|
80
81
|
}
|
|
81
82
|
const { command, file } = entry;
|
|
82
83
|
const placed = resolvePlace(ctx, raw, user, author.member, command.where, message => this.#options.onWarning(file, `${name} ${message}`));
|
|
83
84
|
if (!placed.place) {
|
|
84
|
-
refuse(interaction, placed.refusal, { what: 'command' });
|
|
85
|
-
return;
|
|
85
|
+
return refuse(interaction, placed.refusal, { what: 'command' });
|
|
86
86
|
}
|
|
87
87
|
const { place } = placed;
|
|
88
88
|
// What the member can do where the command was used, overwrites
|
|
@@ -90,11 +90,10 @@ export class CommandRouter {
|
|
|
90
90
|
if (command.permissions !== 0n && author.member) {
|
|
91
91
|
const missing = new Permissions(author.member.permissions ?? '0').missing(command.permissions);
|
|
92
92
|
if (missing.length > 0) {
|
|
93
|
-
refuse(interaction, 'needsPermissions', {
|
|
93
|
+
return refuse(interaction, 'needsPermissions', {
|
|
94
94
|
permissions: missing.join(', '),
|
|
95
95
|
count: missing.length,
|
|
96
96
|
});
|
|
97
|
-
return;
|
|
98
97
|
}
|
|
99
98
|
}
|
|
100
99
|
// Used too soon: the person is told when, and nothing runs.
|
|
@@ -104,8 +103,7 @@ export class CommandRouter {
|
|
|
104
103
|
guild: place.guild?.id,
|
|
105
104
|
});
|
|
106
105
|
if (again > 0) {
|
|
107
|
-
this.#refuseUntil(interaction, again);
|
|
108
|
-
return;
|
|
106
|
+
return this.#refuseUntil(interaction, again);
|
|
109
107
|
}
|
|
110
108
|
let options;
|
|
111
109
|
try {
|
|
@@ -113,8 +111,7 @@ export class CommandRouter {
|
|
|
113
111
|
}
|
|
114
112
|
catch (error) {
|
|
115
113
|
this.#options.onError(file, error);
|
|
116
|
-
refuse(interaction, 'failed', { what: 'command' });
|
|
117
|
-
return;
|
|
114
|
+
return refuse(interaction, 'failed', { what: 'command' });
|
|
118
115
|
}
|
|
119
116
|
remember(interaction, {
|
|
120
117
|
guild: place.guild ?? undefined,
|
|
@@ -133,7 +130,7 @@ export class CommandRouter {
|
|
|
133
130
|
ephemeral: command.ephemeral,
|
|
134
131
|
})),
|
|
135
132
|
});
|
|
136
|
-
runInteraction({
|
|
133
|
+
return runInteraction({
|
|
137
134
|
interaction,
|
|
138
135
|
file,
|
|
139
136
|
name,
|
|
@@ -159,12 +156,13 @@ export class CommandRouter {
|
|
|
159
156
|
const when = timestamp(at, deletable ? TimestampStyle.Relative : TimestampStyle.LongDateShortTime);
|
|
160
157
|
const refused = refuse(interaction, 'cooldown', { what: 'command', when });
|
|
161
158
|
if (!deletable)
|
|
162
|
-
return;
|
|
159
|
+
return refused;
|
|
163
160
|
const timer = setTimeout(() => {
|
|
164
161
|
refused.then(() => interaction.delete()).catch(() => { });
|
|
165
162
|
}, wait);
|
|
166
163
|
// Nothing of the bot waits for it: the process may stop meanwhile.
|
|
167
164
|
timer.unref();
|
|
165
|
+
return refused;
|
|
168
166
|
}
|
|
169
167
|
/** The value of each declared option, as structures where it applies. */
|
|
170
168
|
#readOptions(ctx, raw, data, given, { command }) {
|