Skip to content

Commit 1291e57

Browse files
committed
Add a test suite and run it in CI
80 tests across 8 files, using `bun test`. This is the step 6 item I kept flagging: everything verified during the refactor was throwaway scripts, so none of it protected the next change. tests/ids.test.ts codec round-trips (separators, percent signs, non-ascii, empty values), the 100-character cap, snowflake and oneOf validation, and the two decode failures that mean "stale id" tests/dispatch.test.ts command, autocomplete and component routing; guildOnly and ownerOnly gating; duplicate registration; a throwing handler being reported tests/session.test.ts the session state machine and the ownership guard tests/paginator.test.ts navigation, clamping at both ends, empty lists, and the disable-on-end pass tests/messages.test.ts notice flags and structure, modlog entries in both avatar shapes, tag container and modal rendering tests/loader.test.ts the real command and event directories load; a file may export several listeners; a malformed module throws naming the file tests/commands.test.ts a snapshot of every deployed command payload tests/regressions.test.ts one test per bug fixed during the refactor The payload snapshot is the one I most wanted. Each refactor step was checked by dumping every command's deployed JSON before and after and diffing it by hand; tests/fixtures/command-payloads.json makes CI do that. Confirmed it works by changing one character of a command description and watching it fail. When a change is intended, `bun run tests/fixtures/regenerate-payloads.ts` rewrites the fixture and the diff becomes the review. The regression file names the failure each test guards: - the /g regex that made /cleanname server skip members, asserted stable across repeated calls - the config ids, asserted well-formed and distinct - notices being plain objects that need no cast, and interpolation happening in the template rather than in markup, which is what produced "$updated" Two helpers keep the tests free of gateway or network setup: tests/helpers/interactions.ts stubs the type guards and reply methods the dispatcher actually calls, and tests/helpers/session.ts stands in for the message collector. Both confine their casts to one file. The session harness waits for runSession to attach its collector before emitting, since a press issued immediately would otherwise land before the listener exists — that cost three failing tests before I spotted it. silenceConsole() marks the tests that deliberately provoke a log so a real failure still stands out in the output. CI now runs test, typecheck and lint. Verified all three from an empty node_modules on the pinned Bun. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015hDX4g1EjTN7424A5wnptE
1 parent 23c9852 commit 1291e57

19 files changed

Lines changed: 1565 additions & 2 deletions

.github/workflows/ci.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ on:
77

88
jobs:
99
check:
10-
name: Typecheck and lint
10+
name: Test, typecheck and lint
1111
runs-on: ubuntu-latest
1212

1313
steps:
@@ -23,6 +23,9 @@ jobs:
2323
- name: Install dependencies
2424
run: bun install --frozen-lockfile
2525

26+
- name: Test
27+
run: bun test
28+
2629
- name: Typecheck
2730
run: bun run typecheck
2831

bun.lock

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
"main": "src/index.ts",
66
"type": "module",
77
"scripts": {
8-
"test": "echo \"No test suite yet\" && exit 0",
8+
"test": "bun test",
99
"start": "bun run --bun src/index.ts",
1010
"deploy": "bun run --bun scripts/deploy-commands.ts",
1111
"clear": "bun run --bun scripts/deploy-commands.ts --clear",
@@ -16,6 +16,7 @@
1616
"author": "Zerebos",
1717
"license": "MIT",
1818
"devDependencies": {
19+
"@types/bun": "^1.4.0",
1920
"@types/string-similarity": "^4.0.2",
2021
"@zerebos/eslint-config": "^1.0.3",
2122
"@zerebos/eslint-config-typescript": "^1.1.1",

tests/commands.test.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import path from "node:path";
2+
import {describe, expect, test} from "bun:test";
3+
import {loadCommands} from "../src/framework";
4+
import expected from "./fixtures/command-payloads.json";
5+
6+
7+
/**
8+
* A snapshot of exactly what gets deployed to Discord.
9+
*
10+
* Refactors are supposed to leave this untouched; when one legitimately
11+
* changes a command, the diff here is the review. Regenerate with:
12+
*
13+
* bun run tests/fixtures/regenerate-payloads.ts
14+
*/
15+
16+
const sortKeys = (value: unknown): unknown => {
17+
if (Array.isArray(value)) return value.map(sortKeys);
18+
if (value && typeof value === "object") {
19+
const record = value as Record<string, unknown>;
20+
return Object.fromEntries(Object.keys(record).sort().map(key => [key, sortKeys(record[key])]));
21+
}
22+
return value;
23+
};
24+
25+
async function currentPayloads(): Promise<Record<string, unknown>> {
26+
const payloads: Record<string, unknown> = {};
27+
for (const command of await loadCommands(path.join(import.meta.dir, "..", "src", "commands"))) {
28+
const data: unknown = JSON.parse(JSON.stringify(command.data));
29+
payloads[command.name] = sortKeys({data, ownerOnly: command.ownerOnly});
30+
}
31+
return payloads;
32+
}
33+
34+
35+
describe("deployed command payloads", () => {
36+
test("match the committed snapshot", async () => {
37+
expect(await currentPayloads()).toEqual(expected as Record<string, unknown>);
38+
});
39+
40+
test("the snapshot covers every command that loads", async () => {
41+
expect(Object.keys(await currentPayloads()).sort()).toEqual(Object.keys(expected).sort());
42+
});
43+
});
44+
45+
46+
describe("payload invariants", () => {
47+
test("owner-only commands are deployed to the guild, not globally", async () => {
48+
const commands = await loadCommands(path.join(import.meta.dir, "..", "src", "commands"));
49+
expect(commands.filter(command => command.ownerOnly).map(command => command.name)).toEqual(["botadmin"]);
50+
});
51+
52+
test("no command still uses the deprecated dm_permission field", async () => {
53+
for (const command of await loadCommands(path.join(import.meta.dir, "..", "src", "commands"))) {
54+
expect(command.data).not.toHaveProperty("dm_permission");
55+
}
56+
});
57+
58+
test("subcommand options come last, as the API requires", async () => {
59+
for (const command of await loadCommands(path.join(import.meta.dir, "..", "src", "commands"))) {
60+
for (const option of command.data.options ?? []) {
61+
const nested = (option as {options?: Array<{required?: boolean}>}).options ?? [];
62+
const firstOptional = nested.findIndex(child => child.required !== true);
63+
if (firstOptional === -1) continue;
64+
expect(nested.slice(firstOptional).every(child => child.required !== true)).toBe(true);
65+
}
66+
}
67+
});
68+
});

tests/dispatch.test.ts

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
import {beforeEach, describe, expect, test} from "bun:test";
2+
import {Dispatcher} from "../src/framework/dispatch";
3+
import {defineCommand, defineComponent} from "../src/framework/registry";
4+
import {Num, oneOf} from "../src/framework/ids";
5+
import {sessionId} from "../src/framework/session";
6+
import {lastReply, silenceConsole, stubInteraction} from "./helpers/interactions";
7+
8+
9+
const calls: string[] = [];
10+
11+
const picker = defineComponent({
12+
id: "demo.pick",
13+
kind: "button",
14+
guildOnly: true,
15+
params: {mode: oneOf("user", "admin"), page: Num},
16+
run: (_interaction, {mode, page}) => {calls.push(`pick:${mode}:${page}`); return Promise.resolve();}
17+
});
18+
19+
const anywhere = defineComponent({
20+
id: "demo.any",
21+
kind: "button",
22+
params: {},
23+
run: () => {calls.push("any"); return Promise.resolve();}
24+
});
25+
26+
const guildCommand = defineCommand({
27+
guildOnly: true,
28+
data: {name: "demo", description: "d"},
29+
execute: () => {calls.push("demo"); return Promise.resolve();},
30+
autocomplete: () => {calls.push("demo:auto"); return Promise.resolve();}
31+
});
32+
33+
const ownerCommand = defineCommand({
34+
ownerOnly: true,
35+
data: {name: "secret", description: "d"},
36+
execute: () => {calls.push("secret"); return Promise.resolve();}
37+
});
38+
39+
40+
function build() {
41+
const dispatcher = new Dispatcher({ownerId: "owner-1"});
42+
dispatcher.addCommand(guildCommand);
43+
dispatcher.addCommand(ownerCommand);
44+
dispatcher.addComponent(picker);
45+
dispatcher.addComponent(anywhere);
46+
return dispatcher;
47+
}
48+
49+
beforeEach(() => {calls.length = 0;});
50+
51+
52+
describe("command routing", () => {
53+
test("runs a registered command", async () => {
54+
await build().dispatch(stubInteraction({kind: "chat", commandName: "demo"}).interaction);
55+
expect(calls).toEqual(["demo"]);
56+
});
57+
58+
test("guildOnly is enforced before the handler runs", async () => {
59+
const stub = stubInteraction({kind: "chat", commandName: "demo", cached: false});
60+
await build().dispatch(stub.interaction);
61+
expect(calls).toEqual([]);
62+
expect(lastReply(stub)).toContain("can't use that command here");
63+
});
64+
65+
test("ownerOnly is enforced before the handler runs", async () => {
66+
const stub = stubInteraction({kind: "chat", commandName: "secret", userId: "someone-else"});
67+
await build().dispatch(stub.interaction);
68+
expect(calls).toEqual([]);
69+
70+
const owner = stubInteraction({kind: "chat", commandName: "secret", userId: "owner-1"});
71+
await build().dispatch(owner.interaction);
72+
expect(calls).toEqual(["secret"]);
73+
});
74+
75+
test("an unregistered command says so rather than failing silently", async () => {
76+
const restore = silenceConsole();
77+
const stub = stubInteraction({kind: "chat", commandName: "ghost"});
78+
await build().dispatch(stub.interaction);
79+
restore();
80+
expect(lastReply(stub)).toContain("isn't registered");
81+
});
82+
83+
test("onCommandRun fires for stats, once per command", async () => {
84+
const seen: string[] = [];
85+
const dispatcher = new Dispatcher({ownerId: "owner-1", onCommandRun: i => {seen.push(i.commandName); return Promise.resolve();}});
86+
dispatcher.addCommand(guildCommand);
87+
await dispatcher.dispatch(stubInteraction({kind: "chat", commandName: "demo"}).interaction);
88+
expect(seen).toEqual(["demo"]);
89+
});
90+
});
91+
92+
93+
describe("autocomplete routing", () => {
94+
test("reaches the command's autocomplete handler", async () => {
95+
await build().dispatch(stubInteraction({kind: "autocomplete", commandName: "demo"}).interaction);
96+
expect(calls).toEqual(["demo:auto"]);
97+
});
98+
99+
test("responds empty rather than throwing when there is no handler", async () => {
100+
const stub = stubInteraction({kind: "autocomplete", commandName: "secret"});
101+
await build().dispatch(stub.interaction);
102+
expect(stub.autocompleteResponses).toEqual([[]]);
103+
});
104+
});
105+
106+
107+
describe("component routing", () => {
108+
test("decodes params and passes them typed", async () => {
109+
await build().dispatch(stubInteraction({kind: "button", customId: picker.customId({mode: "admin", page: 7})}).interaction);
110+
expect(calls).toEqual(["pick:admin:7"]);
111+
});
112+
113+
test("guildOnly components are gated too", async () => {
114+
const stub = stubInteraction({kind: "button", customId: picker.customId({mode: "user", page: 1}), cached: false});
115+
await build().dispatch(stub.interaction);
116+
expect(calls).toEqual([]);
117+
expect(lastReply(stub)).toContain("can't use that");
118+
});
119+
120+
test("a component registered for one kind refuses another", async () => {
121+
const restore = silenceConsole();
122+
await build().dispatch(stubInteraction({kind: "stringSelect", customId: anywhere.customId({})}).interaction);
123+
restore();
124+
expect(calls).toEqual([]);
125+
});
126+
127+
test("a stale id from before a deploy explains itself", async () => {
128+
const restore = silenceConsole();
129+
const stub = stubInteraction({kind: "button", customId: "demo.pick:admin"});
130+
await build().dispatch(stub.interaction);
131+
restore();
132+
expect(calls).toEqual([]);
133+
expect(lastReply(stub)).toContain("out of date");
134+
});
135+
136+
test("a malformed param value explains itself the same way", async () => {
137+
const restore = silenceConsole();
138+
const stub = stubInteraction({kind: "button", customId: "demo.pick:sudo:1"});
139+
await build().dispatch(stub.interaction);
140+
restore();
141+
expect(lastReply(stub)).toContain("out of date");
142+
});
143+
144+
// This silence is the contract that lets sessions and registered
145+
// components share one custom-id space.
146+
test("session-owned ids are left to their own collector", async () => {
147+
const stub = stubInteraction({kind: "button", customId: sessionId("next")});
148+
await build().dispatch(stub.interaction);
149+
expect(calls).toEqual([]);
150+
expect(stub.replies).toEqual([]);
151+
});
152+
153+
test("an unknown namespace is ignored, not treated as an error", async () => {
154+
const stub = stubInteraction({kind: "button", customId: "nobody.knows:1"});
155+
await build().dispatch(stub.interaction);
156+
expect(stub.replies).toEqual([]);
157+
});
158+
159+
// The pre-framework router matched on customId.split("-")[0].
160+
test("hyphenated ids no longer route anywhere", async () => {
161+
const stub = stubInteraction({kind: "roleSelect", customId: "cleanname-whatever"});
162+
await build().dispatch(stub.interaction);
163+
expect(calls).toEqual([]);
164+
expect(stub.replies).toEqual([]);
165+
});
166+
});
167+
168+
169+
describe("registration and failure", () => {
170+
test("duplicate command names throw at startup", () => {
171+
const dispatcher = build();
172+
expect(() => dispatcher.addCommand(guildCommand)).toThrow(/duplicate command/);
173+
});
174+
175+
test("duplicate component namespaces throw at startup", () => {
176+
const dispatcher = build();
177+
expect(() => dispatcher.addComponent(picker)).toThrow(/duplicate component/);
178+
});
179+
180+
test("a throwing handler is reported to the user, not swallowed", async () => {
181+
const restore = silenceConsole();
182+
const dispatcher = new Dispatcher({ownerId: "owner-1"});
183+
dispatcher.addCommand(defineCommand({
184+
data: {name: "boom", description: "d"},
185+
execute: () => {throw new Error("kaboom");}
186+
}));
187+
const stub = stubInteraction({kind: "chat", commandName: "boom"});
188+
await dispatcher.dispatch(stub.interaction);
189+
restore();
190+
expect(lastReply(stub)).toContain("Something went wrong");
191+
});
192+
193+
test("counts report what is registered", () => {
194+
expect(build().counts).toEqual({commands: 2, components: 2});
195+
});
196+
});

tests/fixtures/broken/nocommand.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
/** A module that exports nothing the loader can use. */
2+
export const somethingElse = 42;

0 commit comments

Comments
 (0)