Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

push-workflow-sync.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.

push-workflow-sync.test.tsBlame203 lines · 1 contributor
61897c4ccanty labs1/**
2 * Tests for src/lib/push-workflow-sync.ts — the fix for the missing
3 * "push actually triggers CI" wiring (2026-07-15). Before this, nothing
4 * synced `.gluecron/workflows/*.yml` into the `workflows` table on push,
5 * so `on: push` triggers never fired automatically — only manual dispatch,
6 * PR slash commands, and scheduled (cron) runs worked.
7 *
8 * git tree/blob reads and enqueueRun are passed in via the injectable
9 * `deps` param rather than mock.module()'d — those two modules
10 * (../git/repository, ./workflow-runner) are imported by dozens of
11 * unrelated test files, and a top-level mock.module() on either leaks
12 * globally across the whole `bun test` run and breaks them (verified:
13 * an earlier version of this file did exactly that and broke 15 tests
14 * in git.test.ts/api-v2/web-routes). Only ../db is mocked, matching the
15 * established, already-safe pattern in push.test.ts / notify-activity-feed.test.ts.
16 */
17
18import { afterAll, beforeEach, describe, expect, it, mock } from "bun:test";
19
20const _realDb = await import("../db");
21
22let _insertedRows: Array<{ id: string; disabled: boolean }> = [];
23let _upsertCalls: any[] = [];
24
25mock.module("../db", () => ({
26 db: {
27 insert: () => ({
28 values: (vals: any) => ({
29 onConflictDoUpdate: (args: any) => {
30 _upsertCalls.push({ vals, args });
31 const row = _insertedRows.shift();
32 return {
33 returning: async () => (row ? [row] : []),
34 };
35 },
36 }),
37 }),
38 },
39}));
40
41const { syncAndEnqueuePushWorkflows } = await import("../lib/push-workflow-sync");
42
43afterAll(() => {
44 mock.module("../db", () => _realDb);
45});
46
47let _treeEntries: Array<{ mode: string; type: string; sha: string; size?: number; name: string }> = [];
48let _blobs: Record<string, { content: string; size: number; isBinary: boolean }> = {};
49let _enqueueCalls: any[] = [];
50let _enqueueShouldThrow = false;
51
52const fakeDeps = {
53 getTree: async () => _treeEntries,
54 getBlob: async (_owner: string, _repo: string, _ref: string, path: string) =>
55 _blobs[path] ?? null,
56 enqueueRun: async (opts: any) => {
57 if (_enqueueShouldThrow) throw new Error("enqueue boom");
58 _enqueueCalls.push(opts);
59 return "run-id-1";
60 },
61} as any;
62
63beforeEach(() => {
64 _treeEntries = [];
65 _blobs = {};
66 _insertedRows = [];
67 _upsertCalls = [];
68 _enqueueCalls = [];
69 _enqueueShouldThrow = false;
70});
71
72const CI_YML = `
73name: CI
74on: push
75jobs:
76 build:
77 steps:
78 - run: echo hi
79`;
80
81const DISPATCH_ONLY_YML = `
82name: Manual only
83on: workflow_dispatch
84jobs:
85 build:
86 steps:
87 - run: echo manual
88`;
89
90const BROKEN_YML = `
91jobs:
92 - this is not valid: [
93`;
94
95function baseOpts(overrides: Partial<Parameters<typeof syncAndEnqueuePushWorkflows>[0]> = {}) {
96 return {
97 owner: "acme",
98 repo: "widgets",
99 repositoryId: "repo-1",
100 branch: "main",
101 commitSha: "a".repeat(40),
102 triggeredBy: "user-1",
103 ...overrides,
104 };
105}
106
107describe("syncAndEnqueuePushWorkflows", () => {
108 it("returns zeros when .gluecron/workflows is empty/missing", async () => {
109 _treeEntries = [];
110 const result = await syncAndEnqueuePushWorkflows(baseOpts(), fakeDeps);
111 expect(result).toEqual({ synced: 0, enqueued: 0, errors: [] });
112 expect(_upsertCalls).toHaveLength(0);
113 expect(_enqueueCalls).toHaveLength(0);
114 });
115
116 it("syncs and enqueues a workflow with an on:push trigger", async () => {
117 _treeEntries = [{ mode: "100644", type: "blob", sha: "x", name: "ci.yml" }];
118 _blobs[".gluecron/workflows/ci.yml"] = { content: CI_YML, size: CI_YML.length, isBinary: false };
119 _insertedRows = [{ id: "wf-1", disabled: false }];
120
121 const result = await syncAndEnqueuePushWorkflows(baseOpts(), fakeDeps);
122
123 expect(result.synced).toBe(1);
124 expect(result.enqueued).toBe(1);
125 expect(result.errors).toEqual([]);
126 expect(_enqueueCalls[0]).toMatchObject({
127 workflowId: "wf-1",
128 repositoryId: "repo-1",
129 event: "push",
130 ref: "main",
131 commitSha: "a".repeat(40),
132 triggeredBy: "user-1",
133 });
134 // Upsert wrote the parsed shape + onEvents.
135 expect(_upsertCalls[0].vals.path).toBe(".gluecron/workflows/ci.yml");
136 expect(JSON.parse(_upsertCalls[0].vals.onEvents)).toEqual(["push"]);
137 });
138
139 it("syncs but does NOT enqueue a workflow with no push trigger", async () => {
140 _treeEntries = [{ mode: "100644", type: "blob", sha: "x", name: "manual.yml" }];
141 _blobs[".gluecron/workflows/manual.yml"] = {
142 content: DISPATCH_ONLY_YML,
143 size: DISPATCH_ONLY_YML.length,
144 isBinary: false,
145 };
146 _insertedRows = [{ id: "wf-2", disabled: false }];
147
148 const result = await syncAndEnqueuePushWorkflows(baseOpts(), fakeDeps);
149
150 expect(result.synced).toBe(1);
151 expect(result.enqueued).toBe(0);
152 expect(_enqueueCalls).toHaveLength(0);
153 });
154
155 it("syncs but does NOT enqueue a disabled workflow", async () => {
156 _treeEntries = [{ mode: "100644", type: "blob", sha: "x", name: "ci.yml" }];
157 _blobs[".gluecron/workflows/ci.yml"] = { content: CI_YML, size: CI_YML.length, isBinary: false };
158 _insertedRows = [{ id: "wf-3", disabled: true }];
159
160 const result = await syncAndEnqueuePushWorkflows(baseOpts(), fakeDeps);
161
162 expect(result.synced).toBe(1);
163 expect(result.enqueued).toBe(0);
164 expect(_enqueueCalls).toHaveLength(0);
165 });
166
167 it("records a parse error per-file without crashing or blocking other files", async () => {
168 _treeEntries = [
169 { mode: "100644", type: "blob", sha: "x", name: "broken.yml" },
170 { mode: "100644", type: "blob", sha: "y", name: "ci.yml" },
171 ];
172 _blobs[".gluecron/workflows/broken.yml"] = { content: BROKEN_YML, size: 1, isBinary: false };
173 _blobs[".gluecron/workflows/ci.yml"] = { content: CI_YML, size: CI_YML.length, isBinary: false };
174 _insertedRows = [{ id: "wf-4", disabled: false }];
175
176 const result = await syncAndEnqueuePushWorkflows(baseOpts(), fakeDeps);
177
178 // The good file still gets synced + enqueued despite the broken one.
179 expect(result.synced).toBe(1);
180 expect(result.enqueued).toBe(1);
181 });
182
183 it("ignores non-yaml entries in the workflows directory", async () => {
184 _treeEntries = [{ mode: "100644", type: "blob", sha: "x", name: "README.md" }];
185 const result = await syncAndEnqueuePushWorkflows(baseOpts(), fakeDeps);
186 expect(result.synced).toBe(0);
187 expect(_upsertCalls).toHaveLength(0);
188 });
189
190 it("records an error but keeps synced count when enqueueRun throws", async () => {
191 _treeEntries = [{ mode: "100644", type: "blob", sha: "x", name: "ci.yml" }];
192 _blobs[".gluecron/workflows/ci.yml"] = { content: CI_YML, size: CI_YML.length, isBinary: false };
193 _insertedRows = [{ id: "wf-5", disabled: false }];
194 _enqueueShouldThrow = true;
195
196 const result = await syncAndEnqueuePushWorkflows(baseOpts(), fakeDeps);
197
198 expect(result.synced).toBe(1);
199 expect(result.enqueued).toBe(0);
200 expect(result.errors.length).toBe(1);
201 expect(result.errors[0]).toContain("enqueue");
202 });
203});