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
|
@@ -27,6 +27,17 @@ export class Invite extends Structure {
|
|
|
27
27
|
get channelId() {
|
|
28
28
|
return dataOf(this).channel?.id ?? null;
|
|
29
29
|
}
|
|
30
|
+
/**
|
|
31
|
+
* The channel the invite leads to, when the bot knows it. It never sends
|
|
32
|
+
* a request.
|
|
33
|
+
*/
|
|
34
|
+
get channel() {
|
|
35
|
+
const id = this.channelId;
|
|
36
|
+
const found = id ? ctxOf(this).cache.channels.get(id) : undefined;
|
|
37
|
+
return found && found.guildId !== null && !found.isThread()
|
|
38
|
+
? found
|
|
39
|
+
: null;
|
|
40
|
+
}
|
|
30
41
|
/** When the invite stops working; `null` when it never does. */
|
|
31
42
|
get expiresAt() {
|
|
32
43
|
return toDate(dataOf(this).expires_at);
|
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|