CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
scheduled-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.
| 665c8bf | 1 | /** |
| 2 | * Tests for src/lib/scheduled-workflows.ts (pure helpers + the | |
| 3 | * parser-side schedule extractor in workflow-parser.ts). | |
| 4 | * | |
| 5 | * The DB-touching pipeline (runScheduledWorkflowsTick) is exercised | |
| 6 | * indirectly by the autopilot smoke test — here we focus on the | |
| 7 | * pure decisions that drive it. | |
| 8 | */ | |
| 9 | ||
| 10 | import { describe, it, expect } from "bun:test"; | |
| 11 | import { | |
| 12 | schedulesFromParsedJson, | |
| 13 | firstCronToFire, | |
| 14 | runScheduledWorkflowsTick, | |
| 15 | MAX_RUNS_PER_TICK, | |
| 16 | } from "../lib/scheduled-workflows"; | |
| 17 | import { __test as parserTest } from "../lib/workflow-parser"; | |
| 18 | ||
| 19 | describe("schedulesFromParsedJson", () => { | |
| 20 | it("returns [] for empty / malformed input", () => { | |
| 21 | expect(schedulesFromParsedJson("")).toEqual([]); | |
| 22 | expect(schedulesFromParsedJson("{}")).toEqual([]); | |
| 23 | expect(schedulesFromParsedJson("not json")).toEqual([]); | |
| 24 | }); | |
| 25 | ||
| 26 | it("returns [] when schedules is missing or wrong type", () => { | |
| 27 | expect(schedulesFromParsedJson('{"on":["push"]}')).toEqual([]); | |
| 28 | expect(schedulesFromParsedJson('{"schedules":"0 * * * *"}')).toEqual([]); | |
| 29 | expect(schedulesFromParsedJson('{"schedules":42}')).toEqual([]); | |
| 30 | }); | |
| 31 | ||
| 32 | it("extracts a non-empty schedules array", () => { | |
| 33 | const json = '{"schedules":["0 * * * *","30 9 * * 1"]}'; | |
| 34 | expect(schedulesFromParsedJson(json)).toEqual(["0 * * * *", "30 9 * * 1"]); | |
| 35 | }); | |
| 36 | ||
| 37 | it("filters out non-string and empty entries", () => { | |
| 38 | const json = '{"schedules":["0 * * * *","",42,null,"15 14 * * *"]}'; | |
| 39 | expect(schedulesFromParsedJson(json)).toEqual(["0 * * * *", "15 14 * * *"]); | |
| 40 | }); | |
| 41 | }); | |
| 42 | ||
| 43 | describe("workflow-parser extractSchedules", () => { | |
| 44 | const ex = parserTest.extractSchedules; | |
| 45 | ||
| 46 | it("returns [] for non-mapping triggers", () => { | |
| 47 | expect(ex("push")).toEqual([]); | |
| 48 | expect(ex(["push"])).toEqual([]); | |
| 49 | expect(ex(null)).toEqual([]); | |
| 50 | expect(ex(undefined)).toEqual([]); | |
| 51 | }); | |
| 52 | ||
| 53 | it("returns [] when the mapping has no schedule key", () => { | |
| 54 | expect(ex({ push: { branches: ["main"] } })).toEqual([]); | |
| 55 | }); | |
| 56 | ||
| 57 | it("extracts a single schedule entry (object form)", () => { | |
| 58 | expect(ex({ schedule: { cron: "0 * * * *" } })).toEqual(["0 * * * *"]); | |
| 59 | }); | |
| 60 | ||
| 61 | it("extracts an array of schedule entries", () => { | |
| 62 | expect( | |
| 63 | ex({ schedule: [{ cron: "0 * * * *" }, { cron: "30 12 * * 5" }] }) | |
| 64 | ).toEqual(["0 * * * *", "30 12 * * 5"]); | |
| 65 | }); | |
| 66 | ||
| 67 | it("tolerates a bare string in schedule", () => { | |
| 68 | expect(ex({ schedule: "0 * * * *" })).toEqual(["0 * * * *"]); | |
| 69 | expect(ex({ schedule: ["0 * * * *", "30 9 * * 1"] })).toEqual([ | |
| 70 | "0 * * * *", | |
| 71 | "30 9 * * 1", | |
| 72 | ]); | |
| 73 | }); | |
| 74 | ||
| 75 | it("ignores entries that are missing or non-string cron", () => { | |
| 76 | expect(ex({ schedule: [{ cron: "" }, { cron: 42 }, {}] })).toEqual([]); | |
| 77 | }); | |
| 78 | }); | |
| 79 | ||
| 80 | describe("firstCronToFire", () => { | |
| 81 | const since = new Date("2026-04-30T12:00:00Z"); | |
| 82 | const until = new Date("2026-04-30T13:05:00Z"); | |
| 83 | ||
| 84 | it("returns null when schedules is empty", () => { | |
| 85 | expect(firstCronToFire([], since, until)).toBeNull(); | |
| 86 | }); | |
| 87 | ||
| 88 | it("returns the first cron that fires in the window", () => { | |
| 89 | // 0 13 * * * fires at 13:00 — in (12:00, 13:05] | |
| 90 | expect(firstCronToFire(["0 13 * * *"], since, until)).toBe("0 13 * * *"); | |
| 91 | }); | |
| 92 | ||
| 93 | it("returns null when no cron in the list fires", () => { | |
| 94 | // 0 23 fires at 23:00; not in our 12:00–13:05 window | |
| 95 | expect(firstCronToFire(["0 23 * * *"], since, until)).toBeNull(); | |
| 96 | }); | |
| 97 | ||
| 98 | it("skips invalid cron strings without crashing", () => { | |
| 99 | expect( | |
| 100 | firstCronToFire(["@hourly", "0 13 * * *"], since, until) | |
| 101 | ).toBe("0 13 * * *"); | |
| 102 | }); | |
| 103 | ||
| 104 | it("returns the first matching cron, not all of them", () => { | |
| 105 | expect( | |
| 106 | firstCronToFire(["0 13 * * *", "30 13 * * *"], since, until) | |
| 107 | ).toBe("0 13 * * *"); | |
| 108 | }); | |
| 109 | }); | |
| 110 | ||
| 111 | describe("runScheduledWorkflowsTick — fail-open", () => { | |
| 112 | it("returns a result object with the expected keys, never throws", async () => { | |
| 113 | const r = await runScheduledWorkflowsTick(new Date("2026-04-30T13:00:00Z")); | |
| 114 | expect(typeof r.considered).toBe("number"); | |
| 115 | expect(typeof r.fired).toBe("number"); | |
| 116 | expect(typeof r.errors).toBe("number"); | |
| 117 | expect(r.considered).toBeGreaterThanOrEqual(0); | |
| 118 | expect(r.fired).toBeGreaterThanOrEqual(0); | |
| 119 | expect(r.errors).toBeGreaterThanOrEqual(0); | |
| 120 | }); | |
| 121 | ||
| 122 | it("MAX_RUNS_PER_TICK is a safe positive integer", () => { | |
| 123 | expect(MAX_RUNS_PER_TICK).toBeGreaterThan(0); | |
| 124 | expect(MAX_RUNS_PER_TICK).toBeLessThanOrEqual(1000); | |
| 125 | }); | |
| 126 | }); |