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
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { type IncomingHttpHeaders } from 'node:http';
|
|
2
|
+
/** A request the code under test sent to the fake Discord. */
|
|
3
|
+
export interface FakeDiscordRequest {
|
|
4
|
+
method: string;
|
|
5
|
+
/** The path without the API version: `/guilds/1/members`. */
|
|
6
|
+
path: string;
|
|
7
|
+
/** The query string parameters; repeated ones keep every value. */
|
|
8
|
+
query: Record<string, string[]>;
|
|
9
|
+
headers: IncomingHttpHeaders;
|
|
10
|
+
/**
|
|
11
|
+
* The JSON body. For a multipart request: the content of its
|
|
12
|
+
* `payload_json` part.
|
|
13
|
+
*/
|
|
14
|
+
body: unknown;
|
|
15
|
+
/** The files of a multipart request, by form field name. */
|
|
16
|
+
files: Record<string, {
|
|
17
|
+
name: string;
|
|
18
|
+
type: string;
|
|
19
|
+
text: string;
|
|
20
|
+
}>;
|
|
21
|
+
}
|
|
22
|
+
/** What the fake Discord answers. */
|
|
23
|
+
export interface FakeDiscordResponse {
|
|
24
|
+
/** Default: 200, or 204 when there is no body. */
|
|
25
|
+
status?: number;
|
|
26
|
+
body?: unknown;
|
|
27
|
+
headers?: Record<string, string>;
|
|
28
|
+
/** Milliseconds to wait before answering. */
|
|
29
|
+
delay?: number;
|
|
30
|
+
}
|
|
31
|
+
export type FakeDiscordResponder = FakeDiscordResponse | ((request: FakeDiscordRequest) => FakeDiscordResponse | Promise<FakeDiscordResponse>);
|
|
32
|
+
export interface FakeDiscord {
|
|
33
|
+
/** The base URL to give to the code under test, without the version. */
|
|
34
|
+
url: string;
|
|
35
|
+
/** Every request received, in order. */
|
|
36
|
+
requests: FakeDiscordRequest[];
|
|
37
|
+
/**
|
|
38
|
+
* Sets the answer to a route. A list of answers is consumed one request
|
|
39
|
+
* at a time; the last one stays. The path has no version and no query.
|
|
40
|
+
*/
|
|
41
|
+
on(method: string, path: string, ...responses: FakeDiscordResponder[]): void;
|
|
42
|
+
/** The requests received for one route. */
|
|
43
|
+
requestsTo(method: string, path: string): FakeDiscordRequest[];
|
|
44
|
+
/** Stops the server. */
|
|
45
|
+
close(): Promise<void>;
|
|
46
|
+
}
|
|
47
|
+
export interface FakeDiscordOptions {
|
|
48
|
+
/**
|
|
49
|
+
* Answers the routes that were not set with `on()`. Without it, they
|
|
50
|
+
* answer 404 with Discord's "Unknown" error shape.
|
|
51
|
+
*/
|
|
52
|
+
fallback?: FakeDiscordResponder;
|
|
53
|
+
}
|
|
54
|
+
/** What Discord answers for a route that does not exist. */
|
|
55
|
+
export declare const NOT_FOUND: FakeDiscordResponse;
|
|
56
|
+
/**
|
|
57
|
+
* Starts the fake Discord. The caller closes it.
|
|
58
|
+
*/
|
|
59
|
+
export declare function fakeDiscord(options?: FakeDiscordOptions): Promise<FakeDiscord>;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// A local HTTP server that plays the REST API of Discord: the real client
|
|
2
|
+
// of the framework runs against it. Answers are programmed per route, or
|
|
3
|
+
// given by a fallback for everything else; every request is recorded.
|
|
4
|
+
import { createServer } from 'node:http';
|
|
5
|
+
/** What Discord answers for a route that does not exist. */
|
|
6
|
+
export const NOT_FOUND = {
|
|
7
|
+
status: 404,
|
|
8
|
+
body: { message: '404: Not Found', code: 0 },
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Starts the fake Discord. The caller closes it.
|
|
12
|
+
*/
|
|
13
|
+
export async function fakeDiscord(options = {}) {
|
|
14
|
+
const routes = new Map();
|
|
15
|
+
const requests = [];
|
|
16
|
+
const server = createServer(async (req, res) => {
|
|
17
|
+
const chunks = [];
|
|
18
|
+
for await (const chunk of req)
|
|
19
|
+
chunks.push(chunk);
|
|
20
|
+
const raw = Buffer.concat(chunks);
|
|
21
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
22
|
+
const path = url.pathname.replace(/^\/v\d+/, '');
|
|
23
|
+
const query = {};
|
|
24
|
+
for (const [key, value] of url.searchParams)
|
|
25
|
+
(query[key] ??= []).push(value);
|
|
26
|
+
let body;
|
|
27
|
+
const files = {};
|
|
28
|
+
const type = req.headers['content-type'] ?? '';
|
|
29
|
+
if (type.startsWith('multipart/form-data')) {
|
|
30
|
+
const form = await new Request('http://localhost', {
|
|
31
|
+
method: 'POST',
|
|
32
|
+
headers: { 'content-type': type },
|
|
33
|
+
body: raw,
|
|
34
|
+
}).formData();
|
|
35
|
+
for (const [field, value] of form) {
|
|
36
|
+
if (typeof value === 'string') {
|
|
37
|
+
if (field === 'payload_json')
|
|
38
|
+
body = JSON.parse(value);
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
files[field] = {
|
|
42
|
+
name: value.name,
|
|
43
|
+
type: value.type,
|
|
44
|
+
text: await value.text(),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
else if (raw.length > 0) {
|
|
50
|
+
const text = raw.toString('utf8');
|
|
51
|
+
try {
|
|
52
|
+
body = JSON.parse(text);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
body = text;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const request = {
|
|
59
|
+
method: req.method ?? 'GET',
|
|
60
|
+
path,
|
|
61
|
+
query,
|
|
62
|
+
headers: req.headers,
|
|
63
|
+
body,
|
|
64
|
+
files,
|
|
65
|
+
};
|
|
66
|
+
requests.push(request);
|
|
67
|
+
const queue = routes.get(`${request.method} ${path}`);
|
|
68
|
+
let responder = options.fallback ?? NOT_FOUND;
|
|
69
|
+
if (queue && queue.length > 0) {
|
|
70
|
+
responder = queue.length > 1 ? queue.shift() : queue[0];
|
|
71
|
+
}
|
|
72
|
+
let response;
|
|
73
|
+
try {
|
|
74
|
+
response =
|
|
75
|
+
typeof responder === 'function' ? await responder(request) : responder;
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
// A fallback that throws is a bug of the fake, not of the bot: say so.
|
|
79
|
+
response = {
|
|
80
|
+
status: 500,
|
|
81
|
+
body: {
|
|
82
|
+
message: `The fake Discord failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
83
|
+
code: 0,
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
if (response.delay) {
|
|
88
|
+
await new Promise(resolve => setTimeout(resolve, response.delay));
|
|
89
|
+
}
|
|
90
|
+
const hasBody = response.body !== undefined;
|
|
91
|
+
const payload = !hasBody
|
|
92
|
+
? ''
|
|
93
|
+
: typeof response.body === 'string'
|
|
94
|
+
? response.body
|
|
95
|
+
: JSON.stringify(response.body);
|
|
96
|
+
res.writeHead(response.status ?? (hasBody ? 200 : 204), {
|
|
97
|
+
...(hasBody && typeof response.body !== 'string'
|
|
98
|
+
? { 'content-type': 'application/json' }
|
|
99
|
+
: {}),
|
|
100
|
+
...response.headers,
|
|
101
|
+
});
|
|
102
|
+
res.end(payload);
|
|
103
|
+
});
|
|
104
|
+
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
|
105
|
+
const { port } = server.address();
|
|
106
|
+
return {
|
|
107
|
+
url: `http://127.0.0.1:${port}`,
|
|
108
|
+
requests,
|
|
109
|
+
on(method, path, ...responses) {
|
|
110
|
+
routes.set(`${method} ${path}`, responses);
|
|
111
|
+
},
|
|
112
|
+
requestsTo(method, path) {
|
|
113
|
+
return requests.filter(request => request.method === method && request.path === path);
|
|
114
|
+
},
|
|
115
|
+
close: () => new Promise(resolve => {
|
|
116
|
+
server.closeAllConnections();
|
|
117
|
+
server.close(() => resolve());
|
|
118
|
+
}),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/** A payload exchanged with the gateway: `{ op, d, s, t }`. */
|
|
2
|
+
export interface FakeGatewayPayload {
|
|
3
|
+
op: number;
|
|
4
|
+
d?: unknown;
|
|
5
|
+
s?: number | null;
|
|
6
|
+
t?: string | null;
|
|
7
|
+
}
|
|
8
|
+
/** One WebSocket connection the code under test opened. */
|
|
9
|
+
export interface FakeGatewayConnection {
|
|
10
|
+
/** The query string of the URL it connected to (`v`, `encoding`...). */
|
|
11
|
+
query: Record<string, string>;
|
|
12
|
+
/** Every payload received from the code under test, in order. */
|
|
13
|
+
received: FakeGatewayPayload[];
|
|
14
|
+
/** Set once the connection is closed, with the code the client gave. */
|
|
15
|
+
closed: {
|
|
16
|
+
code: number | null;
|
|
17
|
+
} | null;
|
|
18
|
+
/** Sends any payload. */
|
|
19
|
+
send(payload: FakeGatewayPayload): void;
|
|
20
|
+
/** Sends a Dispatch (opcode 0) with the next sequence number. */
|
|
21
|
+
dispatch(type: string, data: unknown): void;
|
|
22
|
+
/** Closes the connection with a close code, as Discord does. */
|
|
23
|
+
close(code: number): void;
|
|
24
|
+
/** Drops the connection without a close frame (a network failure). */
|
|
25
|
+
drop(): void;
|
|
26
|
+
/** Resolves with the next (or an already received) payload that matches. */
|
|
27
|
+
waitFor(match: number | ((payload: FakeGatewayPayload) => boolean), timeout?: number): Promise<FakeGatewayPayload>;
|
|
28
|
+
/** Resolves when the connection is closed, with the client's close code. */
|
|
29
|
+
waitForClose(timeout?: number): Promise<number | null>;
|
|
30
|
+
}
|
|
31
|
+
/** How the fake gateway reacts; change it at any time. */
|
|
32
|
+
export interface FakeGatewayBehavior {
|
|
33
|
+
/** The `heartbeat_interval` of Hello, in ms; `null` sends no Hello. */
|
|
34
|
+
heartbeatInterval: number | null;
|
|
35
|
+
/** Whether heartbeats are acknowledged. */
|
|
36
|
+
ackHeartbeats: boolean;
|
|
37
|
+
/**
|
|
38
|
+
* Called for each Identify (opcode 2). By default answers with a Ready
|
|
39
|
+
* built from `ready`.
|
|
40
|
+
*/
|
|
41
|
+
onIdentify: (connection: FakeGatewayConnection, data: unknown) => void;
|
|
42
|
+
/** Called for each Resume (opcode 6). By default answers Resumed. */
|
|
43
|
+
onResume: (connection: FakeGatewayConnection, data: unknown) => void;
|
|
44
|
+
/** Fields of the Ready event sent by the default `onIdentify`. */
|
|
45
|
+
ready: Record<string, unknown>;
|
|
46
|
+
}
|
|
47
|
+
export interface FakeGateway {
|
|
48
|
+
/** The URL to connect to: `ws://127.0.0.1:port`. */
|
|
49
|
+
url: string;
|
|
50
|
+
behavior: FakeGatewayBehavior;
|
|
51
|
+
/** Every connection received, in order. */
|
|
52
|
+
connections: FakeGatewayConnection[];
|
|
53
|
+
/** Resolves with the connection number `index` (0 is the first one). */
|
|
54
|
+
connection(index: number, timeout?: number): Promise<FakeGatewayConnection>;
|
|
55
|
+
/** Drops every connection and stops the server. */
|
|
56
|
+
close(): Promise<void>;
|
|
57
|
+
}
|
|
58
|
+
/** Starts the fake gateway. The caller closes it. */
|
|
59
|
+
export declare function fakeGateway(options?: Partial<FakeGatewayBehavior>): Promise<FakeGateway>;
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// A local WebSocket server that plays the gateway of Discord: the real
|
|
2
|
+
// shard code of the framework connects to it. By default it says Hello,
|
|
3
|
+
// acknowledges heartbeats, answers Identify with Ready and Resume with
|
|
4
|
+
// Resumed; every part of that can be changed, and each connection driven
|
|
5
|
+
// by hand (dispatch an event, close with a code, drop the socket).
|
|
6
|
+
// https://docs.discord.com/developers/events/gateway
|
|
7
|
+
import { webSocketServer } from './websocket.js';
|
|
8
|
+
/** Starts the fake gateway. The caller closes it. */
|
|
9
|
+
export async function fakeGateway(options = {}) {
|
|
10
|
+
const connections = [];
|
|
11
|
+
const connectionWaiters = [];
|
|
12
|
+
let sessions = 0;
|
|
13
|
+
const behavior = {
|
|
14
|
+
heartbeatInterval: 45_000,
|
|
15
|
+
ackHeartbeats: true,
|
|
16
|
+
ready: {},
|
|
17
|
+
onIdentify(connection) {
|
|
18
|
+
connection.dispatch('READY', {
|
|
19
|
+
v: 10,
|
|
20
|
+
user: { id: '100000000000000002', username: 'bot', discriminator: '0' },
|
|
21
|
+
guilds: [],
|
|
22
|
+
session_id: `session-${++sessions}`,
|
|
23
|
+
resume_gateway_url: server.url,
|
|
24
|
+
application: { id: '100000000000000002', flags: 0 },
|
|
25
|
+
...behavior.ready,
|
|
26
|
+
});
|
|
27
|
+
},
|
|
28
|
+
onResume(connection) {
|
|
29
|
+
connection.dispatch('RESUMED', {});
|
|
30
|
+
},
|
|
31
|
+
...options,
|
|
32
|
+
};
|
|
33
|
+
const server = await webSocketServer((peer, _request, on) => {
|
|
34
|
+
let sequence = 0;
|
|
35
|
+
const waiters = [];
|
|
36
|
+
const connection = {
|
|
37
|
+
query: peer.query,
|
|
38
|
+
received: [],
|
|
39
|
+
get closed() {
|
|
40
|
+
return peer.closed;
|
|
41
|
+
},
|
|
42
|
+
send(payload) {
|
|
43
|
+
peer.sendText(JSON.stringify(payload));
|
|
44
|
+
},
|
|
45
|
+
dispatch(type, data) {
|
|
46
|
+
connection.send({ op: 0, t: type, s: ++sequence, d: data });
|
|
47
|
+
},
|
|
48
|
+
close: code => peer.close(code),
|
|
49
|
+
drop: () => peer.drop(),
|
|
50
|
+
waitFor(match, timeout = 2000) {
|
|
51
|
+
const test = typeof match === 'number'
|
|
52
|
+
? (payload) => payload.op === match
|
|
53
|
+
: match;
|
|
54
|
+
const found = connection.received.find(test);
|
|
55
|
+
if (found)
|
|
56
|
+
return Promise.resolve(found);
|
|
57
|
+
return new Promise((resolve, reject) => {
|
|
58
|
+
const timer = setTimeout(() => reject(new Error(`The fake gateway waited ${timeout}ms for a payload that never came. Received: ${JSON.stringify(connection.received)}`)), timeout);
|
|
59
|
+
waiters.push({
|
|
60
|
+
match: test,
|
|
61
|
+
resolve: payload => {
|
|
62
|
+
clearTimeout(timer);
|
|
63
|
+
resolve(payload);
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
},
|
|
68
|
+
waitForClose: timeout => peer.waitForClose(timeout),
|
|
69
|
+
};
|
|
70
|
+
on.message(data => {
|
|
71
|
+
if (typeof data !== 'string')
|
|
72
|
+
return;
|
|
73
|
+
const payload = JSON.parse(data);
|
|
74
|
+
connection.received.push(payload);
|
|
75
|
+
for (let index = waiters.length - 1; index >= 0; index--) {
|
|
76
|
+
if (waiters[index].match(payload)) {
|
|
77
|
+
waiters.splice(index, 1)[0].resolve(payload);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (payload.op === 1 && behavior.ackHeartbeats) {
|
|
81
|
+
connection.send({ op: 11 });
|
|
82
|
+
}
|
|
83
|
+
else if (payload.op === 2) {
|
|
84
|
+
behavior.onIdentify(connection, payload.d);
|
|
85
|
+
}
|
|
86
|
+
else if (payload.op === 6) {
|
|
87
|
+
behavior.onResume(connection, payload.d);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
connections.push(connection);
|
|
91
|
+
for (const resolve of connectionWaiters.splice(0))
|
|
92
|
+
resolve();
|
|
93
|
+
if (behavior.heartbeatInterval !== null) {
|
|
94
|
+
connection.send({
|
|
95
|
+
op: 10,
|
|
96
|
+
d: { heartbeat_interval: behavior.heartbeatInterval },
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
return {
|
|
101
|
+
url: server.url,
|
|
102
|
+
behavior,
|
|
103
|
+
connections,
|
|
104
|
+
async connection(index, timeout = 2000) {
|
|
105
|
+
const deadline = Date.now() + timeout;
|
|
106
|
+
while (connections.length <= index) {
|
|
107
|
+
const left = deadline - Date.now();
|
|
108
|
+
if (left <= 0) {
|
|
109
|
+
throw new Error(`The fake gateway waited ${timeout}ms for connection ${index}: only ${connections.length} were opened.`);
|
|
110
|
+
}
|
|
111
|
+
await new Promise(resolve => {
|
|
112
|
+
const timer = setTimeout(resolve, left);
|
|
113
|
+
connectionWaiters.push(() => {
|
|
114
|
+
clearTimeout(timer);
|
|
115
|
+
resolve();
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
return connections[index];
|
|
120
|
+
},
|
|
121
|
+
close: () => server.close(),
|
|
122
|
+
};
|
|
123
|
+
}
|