CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
workflows.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.
| eafe8c6 | 1 | /** |
| 2 | * Tests for Block C1 — Actions-equivalent workflow runner. | |
| 3 | * | |
| 4 | * Covers the pure-function parser + route-level unauthed guards. The | |
| 5 | * shell-executor itself is exercised by higher-level integration tests | |
| 6 | * once a real test DB is wired — for now we only verify that the | |
| 7 | * exported surface exists and the route shell is correct. | |
| 8 | */ | |
| 9 | ||
| 10 | import { describe, it, expect } from "bun:test"; | |
| 11 | import app from "../app"; | |
| 12 | import { parseWorkflow } from "../lib/workflow-parser"; | |
| 13 | ||
| 14 | describe("workflow parser (C1)", () => { | |
| 15 | it("parses a minimal workflow", () => { | |
| 16 | const result = parseWorkflow(`name: CI | |
| 17 | on: [push] | |
| 18 | jobs: | |
| 19 | test: | |
| 20 | runs-on: default | |
| 21 | steps: | |
| 22 | - run: echo hello | |
| 23 | `); | |
| 24 | expect(result.ok).toBe(true); | |
| 25 | if (!result.ok) return; | |
| 26 | expect(result.workflow.name).toBe("CI"); | |
| 27 | expect(result.workflow.on).toContain("push"); | |
| 28 | expect(result.workflow.jobs).toHaveLength(1); | |
| 29 | expect(result.workflow.jobs[0].name).toBe("test"); | |
| 30 | expect(result.workflow.jobs[0].steps).toHaveLength(1); | |
| 31 | expect(result.workflow.jobs[0].steps[0].run).toBe("echo hello"); | |
| 32 | }); | |
| 33 | ||
| 34 | it("handles scalar 'on' trigger", () => { | |
| 35 | const result = parseWorkflow(`name: scalar | |
| 36 | on: push | |
| 37 | jobs: | |
| 38 | test: | |
| 39 | steps: | |
| 40 | - run: pwd | |
| 41 | `); | |
| 42 | expect(result.ok).toBe(true); | |
| 43 | if (!result.ok) return; | |
| 44 | expect(result.workflow.on).toEqual(["push"]); | |
| 45 | }); | |
| 46 | ||
| 47 | it("handles list 'on' triggers", () => { | |
| 48 | const result = parseWorkflow(`name: multi | |
| 49 | on: [push, pull_request] | |
| 50 | jobs: | |
| 51 | a: | |
| 52 | steps: | |
| 53 | - run: true | |
| 54 | `); | |
| 55 | expect(result.ok).toBe(true); | |
| 56 | if (!result.ok) return; | |
| 57 | expect(result.workflow.on).toContain("push"); | |
| 58 | expect(result.workflow.on).toContain("pull_request"); | |
| 59 | }); | |
| 60 | ||
| 61 | it("auto-names steps that only have a run field", () => { | |
| 62 | const result = parseWorkflow(`name: n | |
| 63 | on: [push] | |
| 64 | jobs: | |
| 65 | test: | |
| 66 | steps: | |
| 67 | - run: echo x | |
| 68 | `); | |
| 69 | expect(result.ok).toBe(true); | |
| 70 | if (!result.ok) return; | |
| 71 | expect(result.workflow.jobs[0].steps[0].name).toBeTruthy(); | |
| 72 | }); | |
| 73 | ||
| 74 | it("preserves explicit step names", () => { | |
| 75 | const result = parseWorkflow(`name: n | |
| 76 | on: [push] | |
| 77 | jobs: | |
| 78 | test: | |
| 79 | steps: | |
| 80 | - name: Install | |
| 81 | run: bun install | |
| 82 | - name: Test | |
| 83 | run: bun test | |
| 84 | `); | |
| 85 | expect(result.ok).toBe(true); | |
| 86 | if (!result.ok) return; | |
| 87 | const names = result.workflow.jobs[0].steps.map((s) => s.name); | |
| 88 | expect(names).toContain("Install"); | |
| 89 | expect(names).toContain("Test"); | |
| 90 | }); | |
| 91 | ||
| 92 | it("defaults runs-on to 'default' when omitted", () => { | |
| 93 | const result = parseWorkflow(`name: n | |
| 94 | on: [push] | |
| 95 | jobs: | |
| 96 | test: | |
| 97 | steps: | |
| 98 | - run: true | |
| 99 | `); | |
| 100 | expect(result.ok).toBe(true); | |
| 101 | if (!result.ok) return; | |
| 102 | expect(result.workflow.jobs[0].runsOn).toBe("default"); | |
| 103 | }); | |
| 104 | ||
| 105 | it("rejects workflows with no 'on' trigger", () => { | |
| 106 | const result = parseWorkflow(`name: bad | |
| 107 | jobs: | |
| 108 | test: | |
| 109 | steps: | |
| 110 | - run: true | |
| 111 | `); | |
| 112 | expect(result.ok).toBe(false); | |
| 113 | if (result.ok) return; | |
| 114 | expect(result.error.toLowerCase()).toContain("on"); | |
| 115 | }); | |
| 116 | ||
| 117 | it("rejects workflows with no jobs", () => { | |
| 118 | const result = parseWorkflow(`name: bad | |
| 119 | on: [push] | |
| 120 | `); | |
| 121 | expect(result.ok).toBe(false); | |
| 122 | }); | |
| 123 | ||
| 124 | it("rejects jobs with no steps", () => { | |
| 125 | const result = parseWorkflow(`name: bad | |
| 126 | on: [push] | |
| 127 | jobs: | |
| 128 | test: | |
| 129 | runs-on: default | |
| 130 | `); | |
| 131 | expect(result.ok).toBe(false); | |
| 132 | }); | |
| 133 | ||
| 134 | it("rejects steps without a 'run' command", () => { | |
| 135 | const result = parseWorkflow(`name: bad | |
| 136 | on: [push] | |
| 137 | jobs: | |
| 138 | test: | |
| 139 | steps: | |
| 140 | - name: no-op | |
| 141 | `); | |
| 142 | expect(result.ok).toBe(false); | |
| 143 | }); | |
| 144 | ||
| 145 | it("returns a default name when 'name' is missing", () => { | |
| 146 | const result = parseWorkflow(`on: [push] | |
| 147 | jobs: | |
| 148 | test: | |
| 149 | steps: | |
| 150 | - run: true | |
| 151 | `); | |
| 152 | expect(result.ok).toBe(true); | |
| 153 | if (!result.ok) return; | |
| 154 | expect(typeof result.workflow.name).toBe("string"); | |
| 155 | expect(result.workflow.name.length).toBeGreaterThan(0); | |
| 156 | }); | |
| 157 | ||
| 158 | it("never throws on malformed input", () => { | |
| 159 | const inputs = ["", " ", "not:\nyaml\n-\n:", "{]}", "jobs: oh no"]; | |
| 160 | for (const i of inputs) { | |
| 161 | expect(() => parseWorkflow(i)).not.toThrow(); | |
| 162 | } | |
| 163 | }); | |
| 164 | }); | |
| 165 | ||
| 166 | describe("workflow routes (C1) — unauthed behaviour", () => { | |
| 167 | it("POST /:owner/:repo/actions/:workflowId/run requires auth", async () => { | |
| 168 | const res = await app.request("/alice/project/actions/abc/run", { | |
| 169 | method: "POST", | |
| 170 | headers: { "content-type": "application/x-www-form-urlencoded" }, | |
| 171 | body: "", | |
| 172 | }); | |
| 173 | // Either a redirect to /login (repo exists, auth required), or 404 | |
| 174 | // (repo doesn't exist in DB-less tests), or 503 on DB failure. | |
| 175 | expect([301, 302, 303, 307, 404, 503]).toContain(res.status); | |
| 176 | }); | |
| 177 | ||
| 178 | it("POST /:owner/:repo/actions/runs/:id/cancel requires auth", async () => { | |
| 179 | const res = await app.request("/alice/project/actions/runs/xyz/cancel", { | |
| 180 | method: "POST", | |
| 181 | headers: { "content-type": "application/x-www-form-urlencoded" }, | |
| 182 | body: "", | |
| 183 | }); | |
| 184 | expect([301, 302, 303, 307, 404, 503]).toContain(res.status); | |
| 185 | }); | |
| 186 | }); |