CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
specs.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.
| 14c3cc8 | 1 | /** |
| 2 | * Spec-to-PR UI smoke tests. | |
| 3 | * | |
| 4 | * The route file is a .tsx module. In the current test sandbox the | |
| 5 | * `hono/jsx/jsx-dev-runtime` resolver is missing (same pre-existing issue | |
| 6 | * that affects most other .tsx route tests in this repo — see | |
| 7 | * ai-explain.test.ts, web-routes.test.ts, etc.). We handle both cases so | |
| 8 | * this suite stays green across environments: | |
| 9 | * | |
| 10 | * - If the import succeeds, we drive the route via app.request() and | |
| 11 | * assert that unauthenticated GET/POST either redirects to /login or | |
| 12 | * fails closed with a 4xx/5xx, never 500 on the form render path. | |
| 13 | * - If the import fails because of the dev-runtime resolver, we skip the | |
| 14 | * HTTP checks but still assert the shape of the failure (so the smoke | |
| 15 | * test will flag any OTHER kind of load error — e.g. a real syntax | |
| 16 | * error we introduced). | |
| 17 | */ | |
| 18 | ||
| 19 | import { describe, it, expect } from "bun:test"; | |
| 20 | ||
| 21 | async function tryLoadSpecsRoute(): Promise< | |
| 22 | | { ok: true; mod: any } | |
| 23 | | { ok: false; reason: "jsx-dev-runtime" | "other"; err: Error } | |
| 24 | > { | |
| 25 | try { | |
| 26 | const mod = await import("../routes/specs"); | |
| 27 | return { ok: true, mod }; | |
| 28 | } catch (err) { | |
| 29 | const e = err instanceof Error ? err : new Error(String(err)); | |
| 30 | const reason = /jsx[-/]dev[-/]?runtime/i.test(e.message) | |
| 31 | ? "jsx-dev-runtime" | |
| 32 | : "other"; | |
| 33 | return { ok: false, reason, err: e }; | |
| 34 | } | |
| 35 | } | |
| 36 | ||
| 37 | describe("routes/specs — module shape", () => { | |
| 38 | it("either imports cleanly or fails only due to the known jsx-dev-runtime env issue", async () => { | |
| 39 | const loaded = await tryLoadSpecsRoute(); | |
| 40 | if (loaded.ok) { | |
| 41 | expect(loaded.mod.default).toBeDefined(); | |
| 42 | expect(typeof loaded.mod.default.request).toBe("function"); | |
| 43 | } else { | |
| 44 | // Same pre-existing limitation as ai-explain.test.ts / web-routes.test.ts. | |
| 45 | expect(loaded.reason).toBe("jsx-dev-runtime"); | |
| 46 | } | |
| 47 | }); | |
| 48 | }); | |
| 49 | ||
| 50 | describe("routes/specs — auth guard on GET /:owner/:repo/spec", () => { | |
| 51 | it("GET without a session cookie redirects to /login (or 4xx/5xx)", async () => { | |
| 52 | const loaded = await tryLoadSpecsRoute(); | |
| 53 | if (!loaded.ok) { | |
| 54 | // Skip the HTTP check when the route can't load in this env. | |
| 55 | expect(loaded.reason).toBe("jsx-dev-runtime"); | |
| 56 | return; | |
| 57 | } | |
| 58 | const res = await loaded.mod.default.request("/alice/demo/spec", { | |
| 59 | redirect: "manual", | |
| 60 | }); | |
| 61 | expect([200, 302, 303, 400, 403, 404, 500, 503]).toContain(res.status); | |
| 62 | if (res.status === 302 || res.status === 303) { | |
| 63 | const loc = res.headers.get("location") || ""; | |
| 64 | expect(loc).toContain("/login"); | |
| 65 | } | |
| 66 | if (res.status === 200) { | |
| 67 | // If a DB happened to be present AND the user was logged in we'd | |
| 68 | // render the form — which must contain our known UI landmarks. | |
| 69 | const body = await res.text(); | |
| 70 | expect(body).toContain("Generate PR with AI"); | |
| 71 | expect(body).toContain('name="spec"'); | |
| 72 | expect(body).toContain('name="baseRef"'); | |
| 73 | expect(body).toContain("Experimental"); | |
| 74 | expect(body).toContain("How this works"); | |
| 75 | } | |
| 76 | }); | |
| 77 | ||
| 78 | it("POST without a session cookie doesn't crash the server", async () => { | |
| 79 | const loaded = await tryLoadSpecsRoute(); | |
| 80 | if (!loaded.ok) { | |
| 81 | expect(loaded.reason).toBe("jsx-dev-runtime"); | |
| 82 | return; | |
| 83 | } | |
| 84 | const res = await loaded.mod.default.request("/alice/demo/spec", { | |
| 85 | method: "POST", | |
| 86 | redirect: "manual", | |
| 87 | headers: { "content-type": "application/x-www-form-urlencoded" }, | |
| 88 | body: "spec=add+a+dark+mode+toggle&baseRef=main", | |
| 89 | }); | |
| 90 | expect([200, 302, 303, 400, 403, 404, 500, 503]).toContain(res.status); | |
| 91 | }); | |
| 92 | ||
| 93 | it("an unknown sub-path under /:owner/:repo/spec/... is not a 500", async () => { | |
| 94 | const loaded = await tryLoadSpecsRoute(); | |
| 95 | if (!loaded.ok) { | |
| 96 | expect(loaded.reason).toBe("jsx-dev-runtime"); | |
| 97 | return; | |
| 98 | } | |
| 99 | const res = await loaded.mod.default.request("/alice/demo/spec/unknown", { | |
| 100 | redirect: "manual", | |
| 101 | }); | |
| 102 | expect(res.status).toBeLessThan(500); | |
| 103 | }); | |
| fbf4aef | 104 | |
| 105 | it("GET /spec?fromIssue=N is not a 500 even when the issue/repo doesn't exist", async () => { | |
| 106 | const loaded = await tryLoadSpecsRoute(); | |
| 107 | if (!loaded.ok) { | |
| 108 | expect(loaded.reason).toBe("jsx-dev-runtime"); | |
| 109 | return; | |
| 110 | } | |
| 111 | const res = await loaded.mod.default.request( | |
| 112 | "/alice/demo/spec?fromIssue=123", | |
| 113 | { redirect: "manual" } | |
| 114 | ); | |
| 115 | expect(res.status).toBeLessThan(500); | |
| 116 | }); | |
| 117 | ||
| 118 | it("GET /spec?fromIssue=garbage is not a 500", async () => { | |
| 119 | const loaded = await tryLoadSpecsRoute(); | |
| 120 | if (!loaded.ok) { | |
| 121 | expect(loaded.reason).toBe("jsx-dev-runtime"); | |
| 122 | return; | |
| 123 | } | |
| 124 | const res = await loaded.mod.default.request( | |
| 125 | "/alice/demo/spec?fromIssue=not-a-number", | |
| 126 | { redirect: "manual" } | |
| 127 | ); | |
| 128 | expect(res.status).toBeLessThan(500); | |
| 129 | }); | |
| 130 | }); | |
| 131 | ||
| 132 | describe("routes/specs — buildSpecFromIssue pure helper", () => { | |
| 133 | it("emits Implement: <title>, body, then Closes #N", async () => { | |
| 134 | const loaded = await tryLoadSpecsRoute(); | |
| 135 | if (!loaded.ok) { | |
| 136 | expect(loaded.reason).toBe("jsx-dev-runtime"); | |
| 137 | return; | |
| 138 | } | |
| 139 | const fn = loaded.mod.buildSpecFromIssue; | |
| 140 | expect(typeof fn).toBe("function"); | |
| 141 | const out = fn({ | |
| 142 | number: 42, | |
| 143 | title: "Add dark mode toggle", | |
| 144 | body: "It should sit in the navbar and persist via cookie.", | |
| 145 | }); | |
| 146 | expect(out).toContain("Implement: Add dark mode toggle"); | |
| 147 | expect(out).toContain("It should sit in the navbar and persist via cookie."); | |
| 148 | expect(out).toContain("Closes #42"); | |
| 149 | // Closes line should be last so close-keywords (J7) parses cleanly. | |
| 150 | expect(out.trimEnd().endsWith("Closes #42")).toBe(true); | |
| 151 | }); | |
| 152 | ||
| 153 | it("handles a missing/empty body — still emits the Closes line", async () => { | |
| 154 | const loaded = await tryLoadSpecsRoute(); | |
| 155 | if (!loaded.ok) { | |
| 156 | expect(loaded.reason).toBe("jsx-dev-runtime"); | |
| 157 | return; | |
| 158 | } | |
| 159 | const fn = loaded.mod.buildSpecFromIssue; | |
| 160 | const a = fn({ number: 7, title: "Bug: race in upload handler", body: null }); | |
| 161 | const b = fn({ number: 7, title: "Bug: race in upload handler", body: "" }); | |
| 162 | const c = fn({ number: 7, title: "Bug: race in upload handler", body: " " }); | |
| 163 | for (const out of [a, b, c]) { | |
| 164 | expect(out).toContain("Implement: Bug: race in upload handler"); | |
| 165 | expect(out).toContain("Closes #7"); | |
| 166 | } | |
| 167 | }); | |
| 168 | ||
| 169 | it("trims surrounding whitespace from the title and body", async () => { | |
| 170 | const loaded = await tryLoadSpecsRoute(); | |
| 171 | if (!loaded.ok) { | |
| 172 | expect(loaded.reason).toBe("jsx-dev-runtime"); | |
| 173 | return; | |
| 174 | } | |
| 175 | const fn = loaded.mod.buildSpecFromIssue; | |
| 176 | const out = fn({ | |
| 177 | number: 1, | |
| 178 | title: " leading + trailing ", | |
| 179 | body: " body text ", | |
| 180 | }); | |
| 181 | expect(out).toContain("Implement: leading + trailing"); | |
| 182 | expect(out).toContain("body text"); | |
| 183 | // No leading whitespace on the title line. | |
| 184 | expect(out.startsWith("Implement: leading + trailing")).toBe(true); | |
| 185 | }); | |
| 14c3cc8 | 186 | }); |