@nubisjs/testing 0.0.0-stage → 0.1.0-next.1
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/LICENSE +21 -0
- package/README.md +16 -2
- package/dist/fake-api.d.ts +21 -0
- package/dist/fake.d.ts +13 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +379 -0
- package/dist/recorder.d.ts +41 -0
- package/dist/testers.d.ts +95 -0
- package/package.json +40 -5
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 EYTAK LIMITED
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @nubisjs/testing
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Test [Nubis](https://eytak.com/nubis) bots without Discord. Also available as `nubis/testing`.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { fake, testCommand } from "nubis/testing";
|
|
7
|
+
|
|
8
|
+
const result = await testCommand(ban)
|
|
9
|
+
.as({ permissions: PermissionFlagsBits.BanMembers })
|
|
10
|
+
.run({ user: fake.user("alice") });
|
|
11
|
+
|
|
12
|
+
expect(result.replies).toEqual(["Ban alice?"]);
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
`testCommand`, `testComponent` (click, select, raw custom_ids), `testModal` and `testMenu`, with fake users, roles, channels and messages.
|
|
16
|
+
|
|
17
|
+
MIT. Built by [Eytak](https://eytak.com).
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { NubisApi } from "@nubisjs/core";
|
|
2
|
+
export interface ApiCall {
|
|
3
|
+
/** "channels.createMessage", "guilds.banUser", … */
|
|
4
|
+
readonly method: string;
|
|
5
|
+
readonly args: readonly unknown[];
|
|
6
|
+
}
|
|
7
|
+
export type FakeApi = NubisApi & {
|
|
8
|
+
/** Every call, in order. */
|
|
9
|
+
readonly calls: ApiCall[];
|
|
10
|
+
/** The calls to one method. */
|
|
11
|
+
callsTo(method: string): ApiCall[];
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* An `api` that records calls instead of making them. Pass it with
|
|
15
|
+
* `.with({ api })`; methods resolve to `{}` unless `responses` says otherwise:
|
|
16
|
+
*
|
|
17
|
+
* const api = fakeApi({ "guilds.get": { id: "g1", name: "Test" } });
|
|
18
|
+
* await testCommand(ban).with({ api }).run({ user });
|
|
19
|
+
* expect(api.callsTo("guilds.banUser")).toHaveLength(1);
|
|
20
|
+
*/
|
|
21
|
+
export declare function fakeApi(responses?: Readonly<Record<string, unknown | ((...args: unknown[]) => unknown)>>): FakeApi;
|
package/dist/fake.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type APIInteractionDataResolvedChannel, type APIMessage, type APIRole, type APIUser } from "discord-api-types/v10";
|
|
2
|
+
/** Snowflake-shaped ids, unique within a test run. */
|
|
3
|
+
export declare const fakeId: () => string;
|
|
4
|
+
/**
|
|
5
|
+
* Fake Discord objects for tests. Only the fields bots commonly read are
|
|
6
|
+
* filled in; pass `overrides` for anything else.
|
|
7
|
+
*/
|
|
8
|
+
export declare const fake: {
|
|
9
|
+
user(name?: string, overrides?: Partial<APIUser>): APIUser;
|
|
10
|
+
role(name?: string, overrides?: Partial<APIRole>): APIRole;
|
|
11
|
+
channel(name?: string, overrides?: Partial<APIInteractionDataResolvedChannel>): APIInteractionDataResolvedChannel;
|
|
12
|
+
message(content?: string, overrides?: Partial<APIMessage>): APIMessage;
|
|
13
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { fake, fakeId } from "./fake";
|
|
2
|
+
export type { ApiCall, FakeApi } from "./fake-api";
|
|
3
|
+
export { fakeApi } from "./fake-api";
|
|
4
|
+
export type { Recording, TransportCall } from "./recorder";
|
|
5
|
+
export { manualClock, recordingTransport } from "./recorder";
|
|
6
|
+
export type { Actor, CommandTester, ComponentTester, MenuTester, ModalTester, TestOptions, TestResult, } from "./testers";
|
|
7
|
+
export { testCommand, testComponent, testMenu, testModal } from "./testers";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
// src/fake.ts
|
|
2
|
+
import {
|
|
3
|
+
ChannelType
|
|
4
|
+
} from "discord-api-types/v10";
|
|
5
|
+
var next = 100000000000000000n;
|
|
6
|
+
var fakeId = () => String(next++);
|
|
7
|
+
var fake = {
|
|
8
|
+
user(name = "tester", overrides = {}) {
|
|
9
|
+
return {
|
|
10
|
+
id: fakeId(),
|
|
11
|
+
username: name,
|
|
12
|
+
global_name: name,
|
|
13
|
+
discriminator: "0",
|
|
14
|
+
avatar: null,
|
|
15
|
+
...overrides
|
|
16
|
+
};
|
|
17
|
+
},
|
|
18
|
+
role(name = "role", overrides = {}) {
|
|
19
|
+
return {
|
|
20
|
+
id: fakeId(),
|
|
21
|
+
name,
|
|
22
|
+
color: 0,
|
|
23
|
+
colors: { primary_color: 0, secondary_color: null, tertiary_color: null },
|
|
24
|
+
hoist: false,
|
|
25
|
+
position: 1,
|
|
26
|
+
permissions: "0",
|
|
27
|
+
managed: false,
|
|
28
|
+
mentionable: false,
|
|
29
|
+
flags: 0,
|
|
30
|
+
...overrides
|
|
31
|
+
};
|
|
32
|
+
},
|
|
33
|
+
channel(name = "general", overrides = {}) {
|
|
34
|
+
return {
|
|
35
|
+
id: fakeId(),
|
|
36
|
+
name,
|
|
37
|
+
type: ChannelType.GuildText,
|
|
38
|
+
permissions: "0",
|
|
39
|
+
...overrides
|
|
40
|
+
};
|
|
41
|
+
},
|
|
42
|
+
message(content = "", overrides = {}) {
|
|
43
|
+
return {
|
|
44
|
+
id: fakeId(),
|
|
45
|
+
channel_id: fakeId(),
|
|
46
|
+
author: fake.user("author"),
|
|
47
|
+
content,
|
|
48
|
+
timestamp: new Date(0).toISOString(),
|
|
49
|
+
edited_timestamp: null,
|
|
50
|
+
tts: false,
|
|
51
|
+
mention_everyone: false,
|
|
52
|
+
mentions: [],
|
|
53
|
+
mention_roles: [],
|
|
54
|
+
attachments: [],
|
|
55
|
+
embeds: [],
|
|
56
|
+
pinned: false,
|
|
57
|
+
type: 0,
|
|
58
|
+
...overrides
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
// src/fake-api.ts
|
|
63
|
+
function fakeApi(responses = {}) {
|
|
64
|
+
const calls = [];
|
|
65
|
+
const namespaces = new Map;
|
|
66
|
+
const namespace = (name) => new Proxy({}, {
|
|
67
|
+
get(_target, key) {
|
|
68
|
+
if (typeof key === "symbol" || key === "then")
|
|
69
|
+
return;
|
|
70
|
+
const method = `${name}.${key}`;
|
|
71
|
+
return async (...args) => {
|
|
72
|
+
calls.push({ method, args });
|
|
73
|
+
const response = responses[method];
|
|
74
|
+
return typeof response === "function" ? response(...args) : response ?? {};
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
return new Proxy({}, {
|
|
79
|
+
get(_target, key) {
|
|
80
|
+
if (key === "calls")
|
|
81
|
+
return calls;
|
|
82
|
+
if (key === "callsTo") {
|
|
83
|
+
return (method) => calls.filter((call) => call.method === method);
|
|
84
|
+
}
|
|
85
|
+
if (typeof key === "symbol" || key === "then")
|
|
86
|
+
return;
|
|
87
|
+
if (!namespaces.has(key))
|
|
88
|
+
namespaces.set(key, namespace(key));
|
|
89
|
+
return namespaces.get(key);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
// src/recorder.ts
|
|
94
|
+
import {
|
|
95
|
+
InteractionResponseType
|
|
96
|
+
} from "discord-api-types/v10";
|
|
97
|
+
function recordingTransport() {
|
|
98
|
+
const recording = {
|
|
99
|
+
calls: [],
|
|
100
|
+
messages: [],
|
|
101
|
+
replies: [],
|
|
102
|
+
modals: [],
|
|
103
|
+
trace: [],
|
|
104
|
+
uploads: []
|
|
105
|
+
};
|
|
106
|
+
const record = (call, files) => {
|
|
107
|
+
recording.calls.push(call);
|
|
108
|
+
const label = call.type === "respond" ? `respond:${call.response.type}` : call.type;
|
|
109
|
+
recording.trace.push(label);
|
|
110
|
+
if (files?.length) {
|
|
111
|
+
recording.uploads.push({ call: label, files: files.map((f) => f.name) });
|
|
112
|
+
}
|
|
113
|
+
let body;
|
|
114
|
+
if (call.type === "editOriginal" || call.type === "followUp")
|
|
115
|
+
body = call.body;
|
|
116
|
+
else if (call.type === "respond") {
|
|
117
|
+
const { response } = call;
|
|
118
|
+
if (response.type === InteractionResponseType.ChannelMessageWithSource || response.type === InteractionResponseType.UpdateMessage) {
|
|
119
|
+
body = response.data ?? {};
|
|
120
|
+
} else if (response.type === InteractionResponseType.Modal) {
|
|
121
|
+
recording.modals.push(response.data);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (body) {
|
|
125
|
+
recording.messages.push(body);
|
|
126
|
+
recording.replies.push(body.content ?? "");
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
const transport = {
|
|
130
|
+
respond: async (response, files) => record({ type: "respond", response }, files),
|
|
131
|
+
editOriginal: async (body, files) => record({ type: "editOriginal", body }, files),
|
|
132
|
+
deleteOriginal: async () => record({ type: "deleteOriginal" }),
|
|
133
|
+
followUp: async (body, files) => record({ type: "followUp", body }, files)
|
|
134
|
+
};
|
|
135
|
+
return { ...recording, transport };
|
|
136
|
+
}
|
|
137
|
+
function manualClock() {
|
|
138
|
+
const pending = [];
|
|
139
|
+
return {
|
|
140
|
+
schedule: (run) => {
|
|
141
|
+
const timer = { run, cancelled: false };
|
|
142
|
+
pending.push(timer);
|
|
143
|
+
return () => {
|
|
144
|
+
timer.cancelled = true;
|
|
145
|
+
};
|
|
146
|
+
},
|
|
147
|
+
fire() {
|
|
148
|
+
for (const timer of pending.splice(0))
|
|
149
|
+
if (!timer.cancelled)
|
|
150
|
+
timer.run();
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
// src/testers.ts
|
|
155
|
+
import {
|
|
156
|
+
buildCommandTree,
|
|
157
|
+
createComponentRegistry,
|
|
158
|
+
dispatch,
|
|
159
|
+
dispatchComponent,
|
|
160
|
+
dispatchMenu,
|
|
161
|
+
dispatchModal
|
|
162
|
+
} from "@nubisjs/core";
|
|
163
|
+
import {
|
|
164
|
+
ApplicationCommandOptionType,
|
|
165
|
+
ApplicationCommandType,
|
|
166
|
+
ComponentType,
|
|
167
|
+
InteractionType
|
|
168
|
+
} from "discord-api-types/v10";
|
|
169
|
+
var EPHEMERAL = 1 << 6;
|
|
170
|
+
function interactionBase(actor, type) {
|
|
171
|
+
const user = actor.user ?? fake.user();
|
|
172
|
+
const guild = actor.dm ? {} : {
|
|
173
|
+
guild_id: actor.guildId ?? "test-guild",
|
|
174
|
+
guild_locale: actor.guildLocale ?? "en-US",
|
|
175
|
+
member: {
|
|
176
|
+
user,
|
|
177
|
+
roles: [...actor.roles ?? []],
|
|
178
|
+
permissions: String(actor.permissions ?? 0n),
|
|
179
|
+
joined_at: new Date(0).toISOString(),
|
|
180
|
+
deaf: false,
|
|
181
|
+
mute: false,
|
|
182
|
+
flags: 0
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
return {
|
|
186
|
+
id: fakeId(),
|
|
187
|
+
application_id: "test-app",
|
|
188
|
+
token: "test-token",
|
|
189
|
+
version: 1,
|
|
190
|
+
type,
|
|
191
|
+
channel_id: actor.channelId ?? "test-channel",
|
|
192
|
+
app_permissions: String(actor.appPermissions ?? 0n),
|
|
193
|
+
locale: actor.locale ?? "en-US",
|
|
194
|
+
...actor.dm ? { user } : guild,
|
|
195
|
+
...actor.invoker ? {
|
|
196
|
+
message: {
|
|
197
|
+
id: fakeId(),
|
|
198
|
+
interaction_metadata: {
|
|
199
|
+
id: fakeId(),
|
|
200
|
+
type: InteractionType.ApplicationCommand,
|
|
201
|
+
user: actor.invoker
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
} : {}
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
async function finish(recording, result) {
|
|
208
|
+
const first = recording.messages[0];
|
|
209
|
+
return {
|
|
210
|
+
...recording,
|
|
211
|
+
status: result.status,
|
|
212
|
+
..."error" in result ? { error: result.error } : {},
|
|
213
|
+
ephemeral: ((first?.flags ?? 0) & EPHEMERAL) === EPHEMERAL
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
var defaults = { autoDeferMs: false };
|
|
217
|
+
var OPTION_TYPES = {
|
|
218
|
+
string: ApplicationCommandOptionType.String,
|
|
219
|
+
integer: ApplicationCommandOptionType.Integer,
|
|
220
|
+
number: ApplicationCommandOptionType.Number,
|
|
221
|
+
boolean: ApplicationCommandOptionType.Boolean,
|
|
222
|
+
user: ApplicationCommandOptionType.User,
|
|
223
|
+
channel: ApplicationCommandOptionType.Channel,
|
|
224
|
+
role: ApplicationCommandOptionType.Role
|
|
225
|
+
};
|
|
226
|
+
function testCommand(definition, actor = {}, options = defaults) {
|
|
227
|
+
return {
|
|
228
|
+
as: (next) => testCommand(definition, { ...actor, ...next }, options),
|
|
229
|
+
with: (next) => testCommand(definition, actor, { ...options, ...next }),
|
|
230
|
+
async run(values) {
|
|
231
|
+
const given = [];
|
|
232
|
+
const resolved = {
|
|
233
|
+
users: {},
|
|
234
|
+
roles: {},
|
|
235
|
+
channels: {}
|
|
236
|
+
};
|
|
237
|
+
for (const [name, value] of Object.entries(values ?? {})) {
|
|
238
|
+
if (value === undefined)
|
|
239
|
+
continue;
|
|
240
|
+
const definitionOf = definition.options[name];
|
|
241
|
+
if (!definitionOf)
|
|
242
|
+
throw new Error(`The command has no option "${name}".`);
|
|
243
|
+
const kind = definitionOf.kind;
|
|
244
|
+
let sent = value;
|
|
245
|
+
if (kind === "user" || kind === "role" || kind === "channel") {
|
|
246
|
+
const entity = value;
|
|
247
|
+
resolved[`${kind}s`][entity.id] = value;
|
|
248
|
+
sent = entity.id;
|
|
249
|
+
}
|
|
250
|
+
given.push({ type: OPTION_TYPES[kind], name, value: sent });
|
|
251
|
+
}
|
|
252
|
+
const interaction = {
|
|
253
|
+
...interactionBase(actor, InteractionType.ApplicationCommand),
|
|
254
|
+
data: {
|
|
255
|
+
id: fakeId(),
|
|
256
|
+
type: ApplicationCommandType.ChatInput,
|
|
257
|
+
name: "test",
|
|
258
|
+
options: given,
|
|
259
|
+
resolved
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
const recording = recordingTransport();
|
|
263
|
+
const result = await dispatch(buildCommandTree([{ path: ["test"], definition }]), interaction, recording.transport, options);
|
|
264
|
+
return finish(recording, result);
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
var COMPONENT_TYPES = {
|
|
269
|
+
button: ComponentType.Button,
|
|
270
|
+
"string-select": ComponentType.StringSelect,
|
|
271
|
+
"user-select": ComponentType.UserSelect,
|
|
272
|
+
"role-select": ComponentType.RoleSelect,
|
|
273
|
+
"mentionable-select": ComponentType.MentionableSelect,
|
|
274
|
+
"channel-select": ComponentType.ChannelSelect
|
|
275
|
+
};
|
|
276
|
+
function testComponent(definition, actor = {}, options = defaults) {
|
|
277
|
+
const kind = definition.kind;
|
|
278
|
+
const componentType = COMPONENT_TYPES[kind];
|
|
279
|
+
if (componentType === undefined) {
|
|
280
|
+
throw new Error(`testComponent can't run a ${kind}; use testModal for modals.`);
|
|
281
|
+
}
|
|
282
|
+
const customIdFor = (state) => definition(...state === undefined ? [] : [state]).custom_id;
|
|
283
|
+
const send = async (customId, values, resolved) => {
|
|
284
|
+
const interaction = {
|
|
285
|
+
...interactionBase(actor, InteractionType.MessageComponent),
|
|
286
|
+
data: {
|
|
287
|
+
custom_id: customId,
|
|
288
|
+
component_type: componentType,
|
|
289
|
+
...values ? { values: [...values] } : {},
|
|
290
|
+
...resolved ? { resolved } : {}
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
const recording = recordingTransport();
|
|
294
|
+
const result = await dispatchComponent(createComponentRegistry([definition]), interaction, recording.transport, options);
|
|
295
|
+
return finish(recording, result);
|
|
296
|
+
};
|
|
297
|
+
return {
|
|
298
|
+
as: (next) => testComponent(definition, { ...actor, ...next }, options),
|
|
299
|
+
with: (next) => testComponent(definition, actor, { ...options, ...next }),
|
|
300
|
+
click: (state) => send(customIdFor(state)),
|
|
301
|
+
send: (customId, values) => send(customId, values),
|
|
302
|
+
select(values, state) {
|
|
303
|
+
const resolved = {
|
|
304
|
+
users: {},
|
|
305
|
+
roles: {},
|
|
306
|
+
channels: {}
|
|
307
|
+
};
|
|
308
|
+
const ids = values.map((value) => {
|
|
309
|
+
if (typeof value === "string")
|
|
310
|
+
return value;
|
|
311
|
+
const bucket = "username" in value ? "users" : ("color" in value) ? "roles" : "channels";
|
|
312
|
+
resolved[bucket][value.id] = value;
|
|
313
|
+
return value.id;
|
|
314
|
+
});
|
|
315
|
+
return send(customIdFor(state), ids, componentType === ComponentType.StringSelect ? undefined : resolved);
|
|
316
|
+
}
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
function testModal(definition, actor = {}, options = defaults) {
|
|
320
|
+
return {
|
|
321
|
+
as: (next) => testModal(definition, { ...actor, ...next }, options),
|
|
322
|
+
with: (next) => testModal(definition, actor, { ...options, ...next }),
|
|
323
|
+
async submit(values, state) {
|
|
324
|
+
const customId = definition(...state === undefined ? [] : [state]).custom_id;
|
|
325
|
+
const interaction = {
|
|
326
|
+
...interactionBase(actor, InteractionType.ModalSubmit),
|
|
327
|
+
data: {
|
|
328
|
+
custom_id: customId,
|
|
329
|
+
components: Object.entries(values).map(([name, value]) => ({
|
|
330
|
+
type: ComponentType.Label,
|
|
331
|
+
id: 1,
|
|
332
|
+
component: {
|
|
333
|
+
type: ComponentType.TextInput,
|
|
334
|
+
id: 2,
|
|
335
|
+
custom_id: name,
|
|
336
|
+
value
|
|
337
|
+
}
|
|
338
|
+
}))
|
|
339
|
+
}
|
|
340
|
+
};
|
|
341
|
+
const recording = recordingTransport();
|
|
342
|
+
const result = await dispatchModal(createComponentRegistry([definition]), interaction, recording.transport, options);
|
|
343
|
+
return finish(recording, result);
|
|
344
|
+
}
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
function testMenu(definition, actor = {}, options = defaults) {
|
|
348
|
+
return {
|
|
349
|
+
as: (next) => testMenu(definition, { ...actor, ...next }, options),
|
|
350
|
+
with: (next) => testMenu(definition, actor, { ...options, ...next }),
|
|
351
|
+
async run(target) {
|
|
352
|
+
const isUser = definition.kind === "user";
|
|
353
|
+
const interaction = {
|
|
354
|
+
...interactionBase(actor, InteractionType.ApplicationCommand),
|
|
355
|
+
data: {
|
|
356
|
+
id: fakeId(),
|
|
357
|
+
type: isUser ? ApplicationCommandType.User : ApplicationCommandType.Message,
|
|
358
|
+
name: definition.name,
|
|
359
|
+
target_id: target.id,
|
|
360
|
+
resolved: isUser ? { users: { [target.id]: target } } : { messages: { [target.id]: target } }
|
|
361
|
+
}
|
|
362
|
+
};
|
|
363
|
+
const recording = recordingTransport();
|
|
364
|
+
const result = await dispatchMenu([definition], interaction, recording.transport, options);
|
|
365
|
+
return finish(recording, result);
|
|
366
|
+
}
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
export {
|
|
370
|
+
fake,
|
|
371
|
+
fakeApi,
|
|
372
|
+
fakeId,
|
|
373
|
+
manualClock,
|
|
374
|
+
recordingTransport,
|
|
375
|
+
testCommand,
|
|
376
|
+
testComponent,
|
|
377
|
+
testMenu,
|
|
378
|
+
testModal
|
|
379
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { Scheduler, Transport } from "@nubisjs/core";
|
|
2
|
+
import { type APIInteractionResponse, type APIInteractionResponseCallbackData, type APIModalInteractionResponseCallbackData } from "discord-api-types/v10";
|
|
3
|
+
export type TransportCall = {
|
|
4
|
+
readonly type: "respond";
|
|
5
|
+
readonly response: APIInteractionResponse;
|
|
6
|
+
} | {
|
|
7
|
+
readonly type: "editOriginal";
|
|
8
|
+
readonly body: APIInteractionResponseCallbackData;
|
|
9
|
+
} | {
|
|
10
|
+
readonly type: "deleteOriginal";
|
|
11
|
+
} | {
|
|
12
|
+
readonly type: "followUp";
|
|
13
|
+
readonly body: APIInteractionResponseCallbackData;
|
|
14
|
+
};
|
|
15
|
+
/** What a handler sent to Discord, as a test sees it. */
|
|
16
|
+
export interface Recording {
|
|
17
|
+
/** Every call, in order. */
|
|
18
|
+
readonly calls: TransportCall[];
|
|
19
|
+
/** Every message body sent, edited or followed up, in order. */
|
|
20
|
+
readonly messages: APIInteractionResponseCallbackData[];
|
|
21
|
+
/** The text of each of those messages. */
|
|
22
|
+
readonly replies: string[];
|
|
23
|
+
readonly modals: APIModalInteractionResponseCallbackData[];
|
|
24
|
+
/** Call types in order, e.g. ["respond:5", "editOriginal"]. */
|
|
25
|
+
readonly trace: string[];
|
|
26
|
+
/** File names uploaded with each call that had files. */
|
|
27
|
+
readonly uploads: {
|
|
28
|
+
readonly call: string;
|
|
29
|
+
readonly files: string[];
|
|
30
|
+
}[];
|
|
31
|
+
}
|
|
32
|
+
/** A transport that records instead of talking to Discord. */
|
|
33
|
+
export declare function recordingTransport(): Recording & {
|
|
34
|
+
transport: Transport;
|
|
35
|
+
};
|
|
36
|
+
/** A scheduler driven by hand: auto-defer timers fire only when the test says. */
|
|
37
|
+
export declare function manualClock(): {
|
|
38
|
+
readonly schedule: Scheduler;
|
|
39
|
+
/** Fires every pending timer. */
|
|
40
|
+
fire(): void;
|
|
41
|
+
};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { type CommandDefinition, type ComponentDefinition, type ComponentDispatchOptions, type DispatchOptions, type FieldMap, type MenuDefinition, type ModalDefinition, type OptionMap, type OptionValue } from "@nubisjs/core";
|
|
2
|
+
import { type APIInteractionDataResolvedChannel, type APIRole, type APIUser } from "discord-api-types/v10";
|
|
3
|
+
import { fake } from "./fake";
|
|
4
|
+
import { type Recording } from "./recorder";
|
|
5
|
+
/** Who is using the command or component. */
|
|
6
|
+
export interface Actor {
|
|
7
|
+
/** Defaults to a fresh fake user. */
|
|
8
|
+
readonly user?: APIUser;
|
|
9
|
+
/** Run in DMs instead of a server. Servers are the default. */
|
|
10
|
+
readonly dm?: boolean;
|
|
11
|
+
readonly guildId?: string;
|
|
12
|
+
readonly channelId?: string;
|
|
13
|
+
/** Role ids the member has. */
|
|
14
|
+
readonly roles?: readonly string[];
|
|
15
|
+
/** The member's permission bits (`PermissionFlagsBits`). None by default. */
|
|
16
|
+
readonly permissions?: bigint;
|
|
17
|
+
/** The bot's permissions in the channel. None by default. */
|
|
18
|
+
readonly appPermissions?: bigint;
|
|
19
|
+
/** For components: who ran the command that sent the message. */
|
|
20
|
+
readonly invoker?: APIUser;
|
|
21
|
+
/** The user's Discord language, as `interaction.locale`. Defaults to "en-US". */
|
|
22
|
+
readonly locale?: string;
|
|
23
|
+
/** The server's language, as `interaction.guild_locale`. Defaults to "en-US". */
|
|
24
|
+
readonly guildLocale?: string;
|
|
25
|
+
}
|
|
26
|
+
export interface TestResult extends Recording {
|
|
27
|
+
/** The dispatch status: "handled", "denied", "stale", "error", … */
|
|
28
|
+
readonly status: string;
|
|
29
|
+
/** What the handler threw, when status is "error". */
|
|
30
|
+
readonly error?: unknown;
|
|
31
|
+
/** Whether the first message was only visible to the user. */
|
|
32
|
+
readonly ephemeral: boolean;
|
|
33
|
+
}
|
|
34
|
+
type Options = DispatchOptions & ComponentDispatchOptions;
|
|
35
|
+
type RequiredKeys<T extends OptionMap> = {
|
|
36
|
+
[K in keyof T]: T[K]["required"] extends true ? K : never;
|
|
37
|
+
}[keyof T];
|
|
38
|
+
/** The options to run a command with: required ones required, values as handlers see them. */
|
|
39
|
+
export type TestOptions<T extends OptionMap> = {
|
|
40
|
+
[K in RequiredKeys<T>]: NonNullable<OptionValue<T[K]>>;
|
|
41
|
+
} & {
|
|
42
|
+
[K in Exclude<keyof T, RequiredKeys<T>>]?: OptionValue<T[K]>;
|
|
43
|
+
};
|
|
44
|
+
export interface CommandTester<T extends OptionMap> {
|
|
45
|
+
/** Runs as this user, member or DM. */
|
|
46
|
+
as(actor: Actor): CommandTester<T>;
|
|
47
|
+
/** Bot-wide guards, middleware or timing, as dispatch takes them. */
|
|
48
|
+
with(options: Options): CommandTester<T>;
|
|
49
|
+
run(...args: keyof T extends never ? [options?: TestOptions<T>] : [options: TestOptions<T>]): Promise<TestResult>;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Runs a command the way Discord would, without Discord:
|
|
53
|
+
*
|
|
54
|
+
* const result = await testCommand(ban)
|
|
55
|
+
* .as({ permissions: PermissionFlagsBits.BanMembers })
|
|
56
|
+
* .run({ user: fake.user("alice") });
|
|
57
|
+
* expect(result.replies).toEqual(["Banned alice"]);
|
|
58
|
+
*/
|
|
59
|
+
export declare function testCommand<T extends OptionMap>(definition: CommandDefinition<T>, actor?: Actor, options?: Options): CommandTester<T>;
|
|
60
|
+
/** For entity selects: the picked users, roles or channels. */
|
|
61
|
+
type Picked = string | APIUser | APIRole | APIInteractionDataResolvedChannel;
|
|
62
|
+
export interface ComponentTester {
|
|
63
|
+
as(actor: Actor): ComponentTester;
|
|
64
|
+
with(options: Options): ComponentTester;
|
|
65
|
+
/** Clicks a button rendered with `state` (leave it out for buttons without state). */
|
|
66
|
+
click(state?: unknown): Promise<TestResult>;
|
|
67
|
+
/**
|
|
68
|
+
* Picks from a select: values for string selects, or users, roles and
|
|
69
|
+
* channels (fake ones work) for the others.
|
|
70
|
+
*/
|
|
71
|
+
select(values: readonly Picked[], state?: unknown): Promise<TestResult>;
|
|
72
|
+
/** Sends a custom_id as-is, e.g. to test what tampered state does. */
|
|
73
|
+
send(customId: string, values?: readonly string[]): Promise<TestResult>;
|
|
74
|
+
}
|
|
75
|
+
/** Clicks buttons and picks from selects the way Discord would, without Discord. */
|
|
76
|
+
export declare function testComponent(definition: ComponentDefinition, actor?: Actor, options?: Options): ComponentTester;
|
|
77
|
+
export interface ModalTester<F extends FieldMap> {
|
|
78
|
+
as(actor: Actor): ModalTester<F>;
|
|
79
|
+
with(options: Options): ModalTester<F>;
|
|
80
|
+
/** Submits the form with these values (and the state it was opened with). */
|
|
81
|
+
submit(values: {
|
|
82
|
+
readonly [K in keyof F]?: string;
|
|
83
|
+
}, state?: unknown): Promise<TestResult>;
|
|
84
|
+
}
|
|
85
|
+
/** Submits a modal the way Discord would, without Discord. */
|
|
86
|
+
export declare function testModal<F extends FieldMap>(definition: ModalDefinition<F, never>, actor?: Actor, options?: Options): ModalTester<F>;
|
|
87
|
+
export interface MenuTester {
|
|
88
|
+
as(actor: Actor): MenuTester;
|
|
89
|
+
with(options: Options): MenuTester;
|
|
90
|
+
/** Right-clicks this user (user menus) or message (message menus). */
|
|
91
|
+
run(target: APIUser | ReturnType<typeof fake.message>): Promise<TestResult>;
|
|
92
|
+
}
|
|
93
|
+
/** Runs a right-click command the way Discord would, without Discord. */
|
|
94
|
+
export declare function testMenu(definition: MenuDefinition, actor?: Actor, options?: Options): MenuTester;
|
|
95
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,41 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
"name": "@nubisjs/testing",
|
|
3
|
+
"version": "0.1.0-next.1",
|
|
4
|
+
"description": "Test Nubis commands, components and modals without Discord.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Eytak",
|
|
7
|
+
"homepage": "https://eytak.com/nubis",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/Eytak-Technologies/nubis.git",
|
|
11
|
+
"directory": "packages/testing"
|
|
12
|
+
},
|
|
13
|
+
"bugs": "https://github.com/Eytak-Technologies/nubis/issues",
|
|
14
|
+
"type": "module",
|
|
15
|
+
"sideEffects": false,
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./dist/index.d.ts",
|
|
19
|
+
"import": "./dist/index.js"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"dist"
|
|
24
|
+
],
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=20"
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"build": "bun build ./src/index.ts --outdir dist --target node --format esm --packages external && tsc -p tsconfig.build.json",
|
|
30
|
+
"check-types": "tsc --noEmit",
|
|
31
|
+
"test": "bun test",
|
|
32
|
+
"lint": "biome lint ."
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@nubisjs/core": "0.1.0-next.1",
|
|
36
|
+
"discord-api-types": "^0.38.59"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"zod": "^4.6.7"
|
|
40
|
+
}
|
|
41
|
+
}
|