CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
action-registry.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.
| f3e1873 | 1 | /** |
| 2 | * Unit tests for src/lib/action-registry.ts (Agent 8, Sprint 1). | |
| 3 | * | |
| 4 | * Covers the in-memory registry resolution logic and the two simplest | |
| 5 | * built-ins (checkout, gatetest) that can be exercised without hitting the | |
| 6 | * filesystem or a real backend. The cache/upload/download actions all spawn | |
| 7 | * `tar` and talk to Agent 6's helper module; they're out of scope here | |
| 8 | * (they're integration-shaped and better served by integration tests). | |
| 9 | * | |
| 10 | * The `gatetest` handler calls `db.select()` to resolve owner/repo. We stub | |
| 11 | * `../db` via `mock.module` the same way `repo-access.test.ts` does, so the | |
| 12 | * test stays deterministic without real Postgres. | |
| 13 | */ | |
| 14 | ||
| 15 | import { describe, it, expect, mock, afterAll } from "bun:test"; | |
| 16 | ||
| 17 | // Stub the DB module before importing the registry. | |
| 18 | let _lastFrom: any = null; | |
| 19 | let _nextRepoRow: { name: string; ownerId: string } | undefined; | |
| 20 | let _nextUserRow: { username: string } | undefined; | |
| 21 | ||
| 22 | const _chain: any = { | |
| 23 | from: (table: any) => { | |
| 24 | _lastFrom = table; | |
| 25 | return _chain; | |
| 26 | }, | |
| 27 | where: () => _chain, | |
| 28 | leftJoin: () => _chain, | |
| 29 | innerJoin: () => _chain, | |
| 30 | orderBy: () => _chain, | |
| 31 | limit: async () => { | |
| 32 | const t = _lastFrom; | |
| 33 | if (t && typeof t === "object") { | |
| 34 | if ("username" in t && "passwordHash" in t) { | |
| 35 | return _nextUserRow ? [_nextUserRow] : []; | |
| 36 | } | |
| 37 | if ("ownerId" in t && "name" in t) { | |
| 38 | return _nextRepoRow ? [_nextRepoRow] : []; | |
| 39 | } | |
| 40 | } | |
| 41 | return []; | |
| 42 | }, | |
| 43 | set: () => _chain, | |
| 44 | }; | |
| 45 | ||
| 46 | const _fakeDb = { | |
| 47 | db: { | |
| 48 | select: () => _chain, | |
| 49 | insert: () => _chain, | |
| 50 | update: () => _chain, | |
| 51 | delete: () => _chain, | |
| 52 | }, | |
| 53 | getDb: () => ({ | |
| 54 | select: () => _chain, | |
| 55 | insert: () => _chain, | |
| 56 | update: () => _chain, | |
| 57 | delete: () => _chain, | |
| 58 | }), | |
| 59 | }; | |
| 60 | mock.module("../db", () => _fakeDb); | |
| 61 | ||
| 62 | afterAll(() => { | |
| 63 | _nextRepoRow = undefined; | |
| 64 | _nextUserRow = undefined; | |
| 65 | _lastFrom = null; | |
| 66 | }); | |
| 67 | ||
| 68 | // Import AFTER the mock is registered so the registry's built-ins see the | |
| 69 | // stub DB when they pull in `../db`. | |
| 70 | import { | |
| 71 | resolveAction, | |
| 72 | listActions, | |
| 73 | registerAction, | |
| 74 | } from "../lib/action-registry"; | |
| 75 | ||
| 76 | const ORIGINAL_GATETEST_URL = process.env.GATETEST_URL; | |
| 77 | function clearGatetestUrl() { | |
| 78 | delete process.env.GATETEST_URL; | |
| 79 | } | |
| 80 | function restoreGatetestUrl() { | |
| 81 | if (ORIGINAL_GATETEST_URL === undefined) delete process.env.GATETEST_URL; | |
| 82 | else process.env.GATETEST_URL = ORIGINAL_GATETEST_URL; | |
| 83 | } | |
| 84 | ||
| 85 | afterAll(() => { | |
| 86 | restoreGatetestUrl(); | |
| 87 | }); | |
| 88 | ||
| 89 | const ACTION_CTX_BASE = { | |
| 90 | with: {}, | |
| 91 | env: {}, | |
| 92 | workspace: "/tmp/fake-workspace", | |
| 93 | runId: "run-id", | |
| 94 | jobId: "job-id", | |
| 95 | repoId: "repo-id", | |
| 96 | commitSha: "deadbeef", | |
| 97 | ref: "refs/heads/main", | |
| 98 | }; | |
| 99 | ||
| 100 | describe("action-registry — resolveAction", () => { | |
| 101 | it("resolves gluecron/checkout@v1 to the checkout handler", () => { | |
| 102 | const h = resolveAction("gluecron/checkout@v1"); | |
| 103 | expect(h).not.toBeNull(); | |
| 104 | expect(h?.name).toBe("gluecron/checkout"); | |
| 105 | expect(h?.version).toBe("v1"); | |
| 106 | }); | |
| 107 | ||
| 108 | it("resolves gluecron/gatetest@v1 to the gatetest handler", () => { | |
| 109 | const h = resolveAction("gluecron/gatetest@v1"); | |
| 110 | expect(h).not.toBeNull(); | |
| 111 | expect(h?.name).toBe("gluecron/gatetest"); | |
| 112 | expect(h?.version).toBe("v1"); | |
| 113 | }); | |
| 114 | ||
| 115 | it("resolveAction('unknown/foo@v1') returns null", () => { | |
| 116 | const h = resolveAction("unknown/foo@v1"); | |
| 117 | expect(h).toBeNull(); | |
| 118 | }); | |
| 119 | ||
| 120 | it("resolveAction('gluecron/checkout') with no @version resolves to the default (v1)", () => { | |
| 121 | const h = resolveAction("gluecron/checkout"); | |
| 122 | expect(h).not.toBeNull(); | |
| 123 | expect(h?.name).toBe("gluecron/checkout"); | |
| 124 | expect(h?.version).toBe("v1"); | |
| 125 | }); | |
| 126 | ||
| 127 | it("listActions() includes all 5 built-ins", () => { | |
| 128 | const names = listActions().map((a) => a.name); | |
| 129 | expect(names).toContain("gluecron/checkout"); | |
| 130 | expect(names).toContain("gluecron/gatetest"); | |
| 131 | expect(names).toContain("gluecron/cache"); | |
| 132 | expect(names).toContain("gluecron/upload-artifact"); | |
| 133 | expect(names).toContain("gluecron/download-artifact"); | |
| 134 | }); | |
| 135 | ||
| 136 | it("registerAction de-duplicates repeated registrations of the same name@version", () => { | |
| 137 | // Use a dedicated test-only name so we don't clobber a built-in. | |
| 138 | const before = listActions().length; | |
| 139 | const handler = { | |
| 140 | name: "gluecron/__test_dedupe__", | |
| 141 | version: "v1", | |
| 142 | async run() { | |
| 143 | return { exitCode: 0 }; | |
| 144 | }, | |
| 145 | }; | |
| 146 | registerAction(handler); | |
| 147 | const afterFirst = listActions().length; | |
| 148 | registerAction(handler); | |
| 149 | const afterSecond = listActions().length; | |
| 150 | expect(afterFirst).toBe(before + 1); | |
| 151 | expect(afterSecond).toBe(afterFirst); | |
| 152 | }); | |
| 153 | }); | |
| 154 | ||
| 155 | describe("action-registry — built-in behaviour", () => { | |
| 156 | it("checkout.run() returns exitCode 0 and emits the sha output", async () => { | |
| 157 | const h = resolveAction("gluecron/checkout@v1")!; | |
| 158 | const res = await h.run({ ...ACTION_CTX_BASE }); | |
| 159 | expect(res.exitCode).toBe(0); | |
| 160 | expect(res.outputs?.sha).toBe("deadbeef"); | |
| 161 | }); | |
| 162 | ||
| 163 | it("gatetest.run() handles missing repo lookup gracefully (returns non-zero with stderr)", async () => { | |
| 164 | // The config getter provides a default GATETEST_URL, so we exercise the | |
| 165 | // realistic path: the action tries to look up the repo. Our DB stub | |
| 166 | // returns no rows, so the handler reports an unresolved-repo error but | |
| 167 | // never throws — the key contract is "handler returns a result object". | |
| 168 | _nextRepoRow = undefined; | |
| 169 | _nextUserRow = undefined; | |
| 170 | const h = resolveAction("gluecron/gatetest@v1")!; | |
| 171 | const res = await h.run({ ...ACTION_CTX_BASE }); | |
| 172 | expect(typeof res.exitCode).toBe("number"); | |
| 173 | // With no repo row, the handler emits a 'unable to resolve' stderr on 1 | |
| 174 | // OR a 'GateTest: ...' result on 0 if a fallback path was taken. Either | |
| 175 | // way, we expect a structured object (never throws). | |
| 176 | expect(res).toBeDefined(); | |
| 177 | if (res.exitCode !== 0) { | |
| 178 | expect((res.stderr || "").length).toBeGreaterThan(0); | |
| 179 | } | |
| 180 | }); | |
| 181 | }); |