Blame · Line-by-line history
pr-slash-commands.test.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| 15db0e0 | 1 | /** |
| 2 | * Tests for `src/lib/pr-slash-commands.ts`. | |
| 3 | * | |
| 4 | * Two layers (matching the project convention): | |
| 5 | * 1. Pure helpers — `parseSlashCommand`, marker detection, | |
| 6 | * `parseMergeStrategy`, `/help` rendering. Run unconditionally. | |
| 7 | * 2. End-to-end with a real DB row + bare repo. Gated on | |
| 8 | * `DATABASE_URL` via `HAS_DB` skipIf, matching ai-ci-healer.test.ts. | |
| 9 | * | |
| 10 | * All AI calls are stubbed via the `deps.anthropic` injection point. | |
| 11 | * The merge call is stubbed via `deps.merge` to avoid touching real | |
| 12 | * git refs (the executor's job is to format the result, not to re-test | |
| 13 | * pr-merge.ts). | |
| 14 | */ | |
| 15 | ||
| 16 | import { describe, it, expect, beforeAll, afterAll } from "bun:test"; | |
| 17 | import { join } from "path"; | |
| 18 | import { mkdir, rm } from "fs/promises"; | |
| 19 | import { eq } from "drizzle-orm"; | |
| 20 | ||
| 21 | import { | |
| 22 | parseSlashCommand, | |
| 23 | executeSlashCommand, | |
| 24 | detectSlashCmdComment, | |
| 25 | stripSlashCmdMarker, | |
| 26 | slashCmdMarker, | |
| 27 | SLASH_COMMANDS, | |
| 28 | __test, | |
| 29 | } from "../lib/pr-slash-commands"; | |
| 30 | import { db } from "../db"; | |
| 31 | import { | |
| 32 | pullRequests, | |
| 33 | prComments, | |
| 34 | repositories, | |
| 35 | users, | |
| 36 | workflows, | |
| 37 | } from "../db/schema"; | |
| 38 | import { initBareRepo, createOrUpdateFileOnBranch } from "../git/repository"; | |
| 39 | ||
| 40 | const HAS_DB = Boolean(process.env.DATABASE_URL); | |
| 41 | ||
| 42 | const TEST_REPOS = join( | |
| 43 | import.meta.dir, | |
| 44 | "../../.test-repos-pr-slash-" + Date.now() | |
| 45 | ); | |
| 46 | ||
| 47 | beforeAll(async () => { | |
| 48 | process.env.GIT_REPOS_PATH = TEST_REPOS; | |
| 49 | await rm(TEST_REPOS, { recursive: true, force: true }); | |
| 50 | await mkdir(TEST_REPOS, { recursive: true }); | |
| 51 | }); | |
| 52 | ||
| 53 | afterAll(async () => { | |
| 54 | await rm(TEST_REPOS, { recursive: true, force: true }); | |
| 55 | }); | |
| 56 | ||
| 57 | // --------------------------------------------------------------------------- | |
| 58 | // 1. Pure parser surface — runs without a DB | |
| 59 | // --------------------------------------------------------------------------- | |
| 60 | ||
| 61 | describe("parseSlashCommand", () => { | |
| 62 | it("returns null for empty input", () => { | |
| 63 | expect(parseSlashCommand("")).toBeNull(); | |
| 64 | expect(parseSlashCommand(" ")).toBeNull(); | |
| 65 | }); | |
| 66 | ||
| 67 | it("returns null for free-form text", () => { | |
| 68 | expect(parseSlashCommand("hey team, take a look")).toBeNull(); | |
| 69 | expect(parseSlashCommand("hey /merge")).toBeNull(); // must start at col 0 | |
| 70 | }); | |
| 71 | ||
| 72 | it("returns null for Unix-path-like comments that start with /", () => { | |
| 73 | expect(parseSlashCommand("/usr/local/bin/foo")).toBeNull(); | |
| 74 | expect(parseSlashCommand("/etc/passwd is interesting")).toBeNull(); | |
| 75 | }); | |
| 76 | ||
| 77 | it("recognises every command without args", () => { | |
| 78 | for (const cmd of SLASH_COMMANDS) { | |
| 79 | const parsed = parseSlashCommand(`/${cmd}`); | |
| 80 | expect(parsed).not.toBeNull(); | |
| 81 | expect(parsed!.command).toBe(cmd); | |
| 82 | expect(parsed!.args).toEqual([]); | |
| 83 | } | |
| 84 | }); | |
| 85 | ||
| 86 | it("parses /merge with a strategy arg", () => { | |
| 87 | expect(parseSlashCommand("/merge squash")).toEqual({ | |
| 88 | command: "merge", | |
| 89 | args: ["squash"], | |
| 90 | raw: "merge squash", | |
| 91 | }); | |
| 92 | expect(parseSlashCommand("/merge rebase")).toEqual({ | |
| 93 | command: "merge", | |
| 94 | args: ["rebase"], | |
| 95 | raw: "merge rebase", | |
| 96 | }); | |
| 97 | expect(parseSlashCommand("/merge merge")).toEqual({ | |
| 98 | command: "merge", | |
| 99 | args: ["merge"], | |
| 100 | raw: "merge merge", | |
| 101 | }); | |
| 102 | }); | |
| 103 | ||
| 104 | it("parses /cc with multiple @users", () => { | |
| 105 | const p = parseSlashCommand("/cc @alice @bob @carol"); | |
| 106 | expect(p).not.toBeNull(); | |
| 107 | expect(p!.command).toBe("cc"); | |
| 108 | expect(p!.args).toEqual(["@alice", "@bob", "@carol"]); | |
| 109 | }); | |
| 110 | ||
| 111 | it("only inspects the first non-blank line", () => { | |
| 112 | const p = parseSlashCommand("/needs-work\nplease tighten the loop body"); | |
| 113 | expect(p).not.toBeNull(); | |
| 114 | expect(p!.command).toBe("needs-work"); | |
| 115 | expect(p!.args).toEqual([]); | |
| 116 | }); | |
| 117 | ||
| 118 | it("normalises trailing punctuation", () => { | |
| 119 | expect(parseSlashCommand("/help.")!.command).toBe("help"); | |
| 120 | expect(parseSlashCommand("/HELP")!.command).toBe("help"); | |
| 121 | }); | |
| 122 | ||
| 123 | it("ignores a leading space after the slash", () => { | |
| 124 | expect(parseSlashCommand("/ merge")).toBeNull(); | |
| 125 | }); | |
| 126 | }); | |
| 127 | ||
| 128 | describe("detectSlashCmdComment / stripSlashCmdMarker", () => { | |
| 129 | it("detects each command marker", () => { | |
| 130 | for (const cmd of SLASH_COMMANDS) { | |
| 131 | const body = `${slashCmdMarker(cmd)}\n\nbody text`; | |
| 132 | expect(detectSlashCmdComment(body)).toBe(cmd); | |
| 133 | expect(stripSlashCmdMarker(body)).toBe("body text"); | |
| 134 | } | |
| 135 | }); | |
| 136 | ||
| 137 | it("returns null for normal comments", () => { | |
| 138 | expect(detectSlashCmdComment("just a plain comment")).toBeNull(); | |
| 139 | expect(detectSlashCmdComment("<!-- something else -->")).toBeNull(); | |
| 140 | }); | |
| 141 | }); | |
| 142 | ||
| 143 | describe("__test.parseMergeStrategy", () => { | |
| 144 | it("defaults to merge when the arg is missing or junk", () => { | |
| 145 | expect(__test.parseMergeStrategy(undefined)).toBe("merge"); | |
| 146 | expect(__test.parseMergeStrategy("")).toBe("merge"); | |
| 147 | expect(__test.parseMergeStrategy("rocket")).toBe("merge"); | |
| 148 | }); | |
| 149 | it("accepts the three documented strategies", () => { | |
| 150 | expect(__test.parseMergeStrategy("squash")).toBe("squash"); | |
| 151 | expect(__test.parseMergeStrategy("rebase")).toBe("rebase"); | |
| 152 | expect(__test.parseMergeStrategy("merge")).toBe("merge"); | |
| 153 | expect(__test.parseMergeStrategy("SQUASH")).toBe("squash"); | |
| 154 | }); | |
| 155 | }); | |
| 156 | ||
| 157 | // --------------------------------------------------------------------------- | |
| 158 | // 2. Executor — /help runs without any deps | |
| 159 | // --------------------------------------------------------------------------- | |
| 160 | ||
| 161 | describe("executeSlashCommand — /help", () => { | |
| 162 | it("renders the full command list with the marker", async () => { | |
| 163 | const result = await executeSlashCommand({ | |
| 164 | command: "help", | |
| 165 | args: [], | |
| 166 | prId: "00000000-0000-0000-0000-000000000000", | |
| 167 | userId: "00000000-0000-0000-0000-000000000000", | |
| 168 | repositoryId: "00000000-0000-0000-0000-000000000000", | |
| 169 | }); | |
| 170 | expect(result.ok).toBe(true); | |
| 171 | expect(result.marker).toBe(slashCmdMarker("help")); | |
| 172 | expect(result.body).toContain(slashCmdMarker("help")); | |
| 173 | // Every recognised command is documented in the help output. | |
| 174 | for (const cmd of SLASH_COMMANDS) { | |
| 175 | expect(result.body).toContain(`/${cmd}`); | |
| 176 | } | |
| 177 | }); | |
| 178 | }); | |
| 179 | ||
| 180 | describe("executeSlashCommand — unrecognised input never throws", () => { | |
| 181 | it("does not throw when no DB is available", async () => { | |
| 182 | // parseSlashCommand would normally reject this; we call execute | |
| 183 | // directly to make sure the route-side defence-in-depth is fine. | |
| 184 | const result = await executeSlashCommand({ | |
| 185 | // @ts-expect-error — exercising the default-case branch | |
| 186 | command: "does-not-exist", | |
| 187 | args: [], | |
| 188 | prId: "00000000-0000-0000-0000-000000000000", | |
| 189 | userId: "00000000-0000-0000-0000-000000000000", | |
| 190 | repositoryId: "00000000-0000-0000-0000-000000000000", | |
| 191 | }); | |
| 192 | expect(result.ok).toBe(false); | |
| 193 | expect(result.body).toContain("Unrecognised slash command"); | |
| 194 | }); | |
| 195 | }); | |
| 196 | ||
| 197 | // --------------------------------------------------------------------------- | |
| 198 | // 3. DB-backed flows — /explain (mock Claude), /merge (mock performMerge) | |
| 199 | // --------------------------------------------------------------------------- | |
| 200 | ||
| 201 | function fakeAnthropic(responseText: string) { | |
| 202 | return { | |
| 203 | messages: { | |
| 204 | create: async () => ({ | |
| 205 | id: "msg_test", | |
| 206 | type: "message" as const, | |
| 207 | role: "assistant" as const, | |
| 208 | model: "claude-sonnet-4-test", | |
| 209 | stop_reason: "end_turn" as const, | |
| 210 | stop_sequence: null, | |
| 211 | usage: { input_tokens: 0, output_tokens: 0 }, | |
| 212 | content: [{ type: "text" as const, text: responseText }], | |
| 213 | }), | |
| 214 | }, | |
| 215 | } as any; | |
| 216 | } | |
| 217 | ||
| 218 | interface SlashFixture { | |
| 219 | userId: string; | |
| 220 | repoId: string; | |
| 221 | prId: string; | |
| 222 | ownerUsername: string; | |
| 223 | repoName: string; | |
| 224 | } | |
| 225 | ||
| 226 | async function seedFixture(label: string): Promise<SlashFixture> { | |
| 227 | const username = `slash_${label}_${Date.now()}_${Math.random() | |
| 228 | .toString(36) | |
| 229 | .slice(2, 6)}`; | |
| 230 | const [u] = await db | |
| 231 | .insert(users) | |
| 232 | .values({ | |
| 233 | username, | |
| 234 | email: `${username}@example.com`, | |
| 235 | passwordHash: "x", | |
| 236 | }) | |
| 237 | .returning({ id: users.id }); | |
| 238 | ||
| 239 | const repoName = `subject_${label}_${Date.now()}`; | |
| 240 | const [r] = await db | |
| 241 | .insert(repositories) | |
| 242 | .values({ | |
| 243 | ownerId: u.id, | |
| 244 | name: repoName, | |
| 245 | diskPath: `/tmp/${username}/${repoName}`, | |
| 246 | defaultBranch: "main", | |
| 247 | }) | |
| 248 | .returning({ id: repositories.id }); | |
| 249 | ||
| 250 | await initBareRepo(username, repoName); | |
| 251 | // Seed a base commit on main + a head commit on feat-x so the diff | |
| 252 | // /explain consumes is non-empty. | |
| 253 | await createOrUpdateFileOnBranch({ | |
| 254 | owner: username, | |
| 255 | name: repoName, | |
| 256 | branch: "main", | |
| 257 | filePath: "src/index.ts", | |
| 258 | bytes: new TextEncoder().encode("export const v = 1;\n"), | |
| 259 | message: "base", | |
| 260 | authorName: "Seeder", | |
| 261 | authorEmail: "s@e.com", | |
| 262 | }); | |
| 263 | await createOrUpdateFileOnBranch({ | |
| 264 | owner: username, | |
| 265 | name: repoName, | |
| 266 | branch: "feat-x", | |
| 267 | filePath: "src/index.ts", | |
| 268 | bytes: new TextEncoder().encode("export const v = 2;\n"), | |
| 269 | message: "feat: bump v", | |
| 270 | authorName: "Seeder", | |
| 271 | authorEmail: "s@e.com", | |
| 272 | }); | |
| 273 | ||
| 274 | const [pr] = await db | |
| 275 | .insert(pullRequests) | |
| 276 | .values({ | |
| 277 | repositoryId: r.id, | |
| 278 | number: 1, | |
| 279 | title: "Bump v", | |
| 280 | body: "Bumps the version constant.", | |
| 281 | authorId: u.id, | |
| 282 | baseBranch: "main", | |
| 283 | headBranch: "feat-x", | |
| 284 | state: "open", | |
| 285 | }) | |
| 286 | .returning({ id: pullRequests.id }); | |
| 287 | ||
| 288 | return { | |
| 289 | userId: u.id, | |
| 290 | repoId: r.id, | |
| 291 | prId: pr.id, | |
| 292 | ownerUsername: username, | |
| 293 | repoName, | |
| 294 | }; | |
| 295 | } | |
| 296 | ||
| 297 | describe.skipIf(!HAS_DB)("executeSlashCommand — /explain (DB-backed)", () => { | |
| 298 | it("calls Claude and returns its explanation in the result body", async () => { | |
| 299 | const fx = await seedFixture("explain"); | |
| 300 | const result = await executeSlashCommand({ | |
| 301 | command: "explain", | |
| 302 | args: [], | |
| 303 | prId: fx.prId, | |
| 304 | userId: fx.userId, | |
| 305 | repositoryId: fx.repoId, | |
| 306 | deps: { anthropic: fakeAnthropic("This PR bumps `v` from 1 to 2.") }, | |
| 307 | }); | |
| 308 | expect(result.ok).toBe(true); | |
| 309 | expect(result.body).toContain(slashCmdMarker("explain")); | |
| 310 | expect(result.body).toContain("This PR bumps `v` from 1 to 2."); | |
| 311 | }, 15_000); | |
| 312 | }); | |
| 313 | ||
| 314 | describe.skipIf(!HAS_DB)("executeSlashCommand — /merge (DB-backed)", () => { | |
| 315 | it("forwards the requested strategy and reports the merge result", async () => { | |
| 316 | const fx = await seedFixture("merge"); | |
| 317 | ||
| 318 | let observedActor: string | null = null; | |
| 319 | const fakeMerge = async (mergeArgs: any) => { | |
| 320 | observedActor = mergeArgs.actorUserId; | |
| 321 | expect(mergeArgs.pr.id).toBe(fx.prId); | |
| 322 | expect(mergeArgs.pr.headBranch).toBe("feat-x"); | |
| 323 | expect(mergeArgs.pr.baseBranch).toBe("main"); | |
| 324 | return { | |
| 325 | ok: true as const, | |
| 326 | closedIssueNumbers: [42], | |
| 327 | resolvedFiles: [], | |
| 328 | }; | |
| 329 | }; | |
| 330 | ||
| 331 | const result = await executeSlashCommand({ | |
| 332 | command: "merge", | |
| 333 | args: ["squash"], | |
| 334 | prId: fx.prId, | |
| 335 | userId: fx.userId, | |
| 336 | repositoryId: fx.repoId, | |
| 337 | deps: { | |
| 338 | merge: fakeMerge as any, | |
| 339 | // Repo owner = userId, so resolveAccess would return "owner" | |
| 340 | // anyway; pin it for determinism + DB isolation. | |
| 341 | resolveAccess: async () => "owner", | |
| 342 | }, | |
| 343 | }); | |
| 344 | expect(result.ok).toBe(true); | |
| 345 | expect(observedActor).toBe(fx.userId); | |
| 346 | expect(result.body).toContain(slashCmdMarker("merge")); | |
| 347 | expect(result.body).toContain("Merged"); | |
| 348 | expect(result.body).toContain("/merge squash"); | |
| 349 | expect(result.body).toContain("#42"); | |
| 350 | }); | |
| 351 | ||
| 352 | it("refuses to merge when the actor lacks write access", async () => { | |
| 353 | const fx = await seedFixture("merge-noauth"); | |
| 354 | const result = await executeSlashCommand({ | |
| 355 | command: "merge", | |
| 356 | args: [], | |
| 357 | prId: fx.prId, | |
| 358 | userId: fx.userId, | |
| 359 | repositoryId: fx.repoId, | |
| 360 | deps: { | |
| 361 | merge: async () => { | |
| 362 | throw new Error("should not be called"); | |
| 363 | }, | |
| 364 | resolveAccess: async () => "read", | |
| 365 | }, | |
| 366 | }); | |
| 367 | expect(result.ok).toBe(false); | |
| 368 | expect(result.body).toContain("denied"); | |
| 369 | }); | |
| 370 | ||
| 371 | it("surfaces the underlying performMerge error verbatim", async () => { | |
| 372 | const fx = await seedFixture("merge-fail"); | |
| 373 | const result = await executeSlashCommand({ | |
| 374 | command: "merge", | |
| 375 | args: [], | |
| 376 | prId: fx.prId, | |
| 377 | userId: fx.userId, | |
| 378 | repositoryId: fx.repoId, | |
| 379 | deps: { | |
| 380 | merge: async () => ({ | |
| 381 | ok: false as const, | |
| 382 | error: "git update-ref failed: not-fast-forward", | |
| 383 | closedIssueNumbers: [], | |
| 384 | resolvedFiles: [], | |
| 385 | }), | |
| 386 | resolveAccess: async () => "owner", | |
| 387 | }, | |
| 388 | }); | |
| 389 | expect(result.ok).toBe(false); | |
| 390 | expect(result.body).toContain("not-fast-forward"); | |
| 391 | }); | |
| 392 | }); | |
| 393 | ||
| 394 | describe.skipIf(!HAS_DB)("executeSlashCommand — /lgtm + /needs-work (DB-backed)", () => { | |
| 395 | it("posts an approval-style body for /lgtm", async () => { | |
| 396 | const fx = await seedFixture("lgtm"); | |
| 397 | const result = await executeSlashCommand({ | |
| 398 | command: "lgtm", | |
| 399 | args: [], | |
| 400 | prId: fx.prId, | |
| 401 | userId: fx.userId, | |
| 402 | repositoryId: fx.repoId, | |
| 403 | }); | |
| 404 | expect(result.ok).toBe(true); | |
| 405 | expect(result.body.toLowerCase()).toContain("approved"); | |
| 406 | }); | |
| 407 | ||
| 408 | it("captures the reason given to /needs-work", async () => { | |
| 409 | const fx = await seedFixture("nw"); | |
| 410 | const result = await executeSlashCommand({ | |
| 411 | command: "needs-work", | |
| 412 | args: ["please", "tighten", "the", "loop"], | |
| 413 | prId: fx.prId, | |
| 414 | userId: fx.userId, | |
| 415 | repositoryId: fx.repoId, | |
| 416 | }); | |
| 417 | expect(result.ok).toBe(true); | |
| 418 | expect(result.body).toContain("please tighten the loop"); | |
| 419 | }); | |
| 420 | }); | |
| 421 | ||
| 422 | describe.skipIf(!HAS_DB)("executeSlashCommand — /cc (DB-backed)", () => { | |
| 423 | it("resolves known users and flags unknowns", async () => { | |
| 424 | const fx = await seedFixture("cc"); | |
| 425 | const knownName = `slash_cc_known_${Date.now()}`; | |
| 426 | await db | |
| 427 | .insert(users) | |
| 428 | .values({ | |
| 429 | username: knownName, | |
| 430 | email: `${knownName}@example.com`, | |
| 431 | passwordHash: "x", | |
| 432 | }); | |
| 433 | ||
| 434 | const result = await executeSlashCommand({ | |
| 435 | command: "cc", | |
| 436 | args: [`@${knownName}`, "@nobody-knows-this"], | |
| 437 | prId: fx.prId, | |
| 438 | userId: fx.userId, | |
| 439 | repositoryId: fx.repoId, | |
| 440 | }); | |
| 441 | expect(result.ok).toBe(true); | |
| 442 | expect(result.body).toContain(knownName); | |
| 443 | expect(result.body).toContain("Skipped"); | |
| 444 | }); | |
| 445 | ||
| 446 | it("rejects when no @users are supplied", async () => { | |
| 447 | const fx = await seedFixture("cc-empty"); | |
| 448 | const result = await executeSlashCommand({ | |
| 449 | command: "cc", | |
| 450 | args: [], | |
| 451 | prId: fx.prId, | |
| 452 | userId: fx.userId, | |
| 453 | repositoryId: fx.repoId, | |
| 454 | }); | |
| 455 | expect(result.ok).toBe(false); | |
| 456 | expect(result.body).toContain("requires one or more"); | |
| 457 | }); | |
| 458 | }); | |
| 459 | ||
| 460 | describe.skipIf(!HAS_DB)("executeSlashCommand — /test (DB-backed)", () => { | |
| 461 | it("enqueues a workflow_dispatch when a test.yml workflow exists", async () => { | |
| 462 | const fx = await seedFixture("test"); | |
| 463 | const [w] = await db | |
| 464 | .insert(workflows) | |
| 465 | .values({ | |
| 466 | repositoryId: fx.repoId, | |
| 467 | name: "Tests", | |
| 468 | path: ".gluecron/workflows/test.yml", | |
| 469 | yaml: "name: Tests\non: [push, workflow_dispatch]\njobs:\n t:\n steps:\n - run: bun test\n", | |
| 470 | parsed: JSON.stringify({ | |
| 471 | name: "Tests", | |
| 472 | on: ["push", "workflow_dispatch"], | |
| 473 | jobs: { t: { steps: [{ run: "bun test" }] } }, | |
| 474 | }), | |
| 475 | onEvents: JSON.stringify(["push", "workflow_dispatch"]), | |
| 476 | }) | |
| 477 | .returning({ id: workflows.id }); | |
| 478 | ||
| 479 | const result = await executeSlashCommand({ | |
| 480 | command: "test", | |
| 481 | args: [], | |
| 482 | prId: fx.prId, | |
| 483 | userId: fx.userId, | |
| 484 | repositoryId: fx.repoId, | |
| 485 | }); | |
| 486 | expect(result.ok).toBe(true); | |
| 487 | expect(result.body).toContain("dispatched"); | |
| 488 | ||
| 489 | // Sanity: the lookup helper actually finds the row we just inserted. | |
| 490 | const found = await __test.findTestWorkflow(fx.repoId); | |
| 491 | expect(found?.id).toBe(w.id); | |
| 492 | }, 15_000); | |
| 493 | ||
| 494 | it("falls back gracefully when no test workflow is configured", async () => { | |
| 495 | const fx = await seedFixture("test-missing"); | |
| 496 | const result = await executeSlashCommand({ | |
| 497 | command: "test", | |
| 498 | args: [], | |
| 499 | prId: fx.prId, | |
| 500 | userId: fx.userId, | |
| 501 | repositoryId: fx.repoId, | |
| 502 | }); | |
| 503 | expect(result.ok).toBe(false); | |
| 504 | expect(result.body).toContain("could not find a test workflow"); | |
| 505 | }); | |
| 506 | }); | |
| 507 | ||
| 508 | // --------------------------------------------------------------------------- | |
| 509 | // 4. Integration smoke — parse → execute round-trip preserves identity | |
| 510 | // --------------------------------------------------------------------------- | |
| 511 | ||
| 512 | describe("parse → execute round-trip (no DB needed)", () => { | |
| 513 | it("a slash that doesn't pass parseSlashCommand stays a normal comment", () => { | |
| 514 | // The route handler stores the comment verbatim and only THEN consults | |
| 515 | // parseSlashCommand. This test asserts the contract: | |
| 516 | // - "/usr/bin/x" returns null → route does NOT execute anything | |
| 517 | // - "/help" returns a parse → route executes | |
| 518 | expect(parseSlashCommand("/usr/bin/x")).toBeNull(); | |
| 519 | expect(parseSlashCommand("hello world")).toBeNull(); | |
| 520 | expect(parseSlashCommand("/help")).not.toBeNull(); | |
| 521 | }); | |
| 522 | }); |