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

dep-updater.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.

dep-updater.test.tsBlame286 lines · 1 contributor
3cbe3d6Claude1/**
2 * Block D2 — AI dependency updater tests.
3 *
4 * Pure-function helpers are covered exhaustively. Route-level tests use
5 * the standalone depUpdater router (the app-level mount is performed by
6 * the main thread in `src/app.tsx`). They therefore tolerate the whole
7 * class of graceful-degradation responses (302 / 404 / 503) that appear
8 * when auth redirects or the DB isn't reachable.
9 */
10
11import { describe, it, expect } from "bun:test";
12import {
13 parseManifest,
14 planUpdates,
15 applyBumps,
16} from "../lib/dep-updater";
17import depUpdater from "../routes/dep-updater";
18
19describe("parseManifest", () => {
20 it("parses a valid package.json", () => {
21 const json = JSON.stringify({
22 name: "demo",
23 dependencies: { hono: "^4.0.0" },
24 devDependencies: { typescript: "^5.0.0" },
25 });
26 const m = parseManifest(json);
27 expect(m.name).toBe("demo");
28 expect(m.dependencies.hono).toBe("^4.0.0");
29 expect(m.devDependencies.typescript).toBe("^5.0.0");
30 });
31
32 it("returns empty structures on invalid JSON", () => {
33 const m = parseManifest("{ not valid ]");
34 expect(m.dependencies).toEqual({});
35 expect(m.devDependencies).toEqual({});
36 });
37
38 it("returns empty structures for empty input", () => {
39 const m = parseManifest("");
40 expect(m.dependencies).toEqual({});
41 expect(m.devDependencies).toEqual({});
42 });
43
44 it("handles missing dependencies key gracefully", () => {
45 const m = parseManifest(JSON.stringify({ name: "x" }));
46 expect(m.dependencies).toEqual({});
47 expect(m.devDependencies).toEqual({});
48 expect(m.name).toBe("x");
49 });
50
51 it("ignores non-string dependency values", () => {
52 const m = parseManifest(
53 JSON.stringify({
54 dependencies: { a: "1.0.0", b: 42, c: null, d: { foo: 1 } } as any,
55 })
56 );
57 expect(m.dependencies.a).toBe("1.0.0");
58 expect(m.dependencies.b).toBeUndefined();
59 expect(m.dependencies.c).toBeUndefined();
60 expect(m.dependencies.d).toBeUndefined();
61 });
62});
63
64describe("planUpdates", () => {
65 it("produces bumps for outdated packages", async () => {
66 const fetchLatest = async (name: string) => {
67 const map: Record<string, string> = {
68 hono: "4.5.0",
69 typescript: "5.4.2",
70 };
71 return map[name] ?? null;
72 };
73 const bumps = await planUpdates(
74 {
75 dependencies: { hono: "^4.0.0" },
76 devDependencies: { typescript: "^5.0.0" },
77 },
78 { fetchLatest }
79 );
80 expect(bumps).toHaveLength(2);
81 const hono = bumps.find((b) => b.name === "hono")!;
82 expect(hono.from).toBe("^4.0.0");
83 expect(hono.to).toBe("4.5.0");
84 expect(hono.kind).toBe("dep");
85 expect(hono.major).toBe(false);
86 const ts = bumps.find((b) => b.name === "typescript")!;
87 expect(ts.kind).toBe("dev");
88 });
89
90 it("no-ops when current version matches latest", async () => {
91 const bumps = await planUpdates(
92 {
93 dependencies: { react: "^18.2.0" },
94 devDependencies: {},
95 },
96 { fetchLatest: async () => "18.2.0" }
97 );
98 expect(bumps).toHaveLength(0);
99 });
100
101 it("skips downgrades", async () => {
102 const bumps = await planUpdates(
103 {
104 dependencies: { foo: "^3.0.0" },
105 devDependencies: {},
106 },
107 { fetchLatest: async () => "2.9.0" }
108 );
109 expect(bumps).toHaveLength(0);
110 });
111
112 it("skips non-semver ranges", async () => {
113 const bumps = await planUpdates(
114 {
115 dependencies: {
116 a: "workspace:*",
117 b: "github:foo/bar",
118 c: "file:./local",
119 d: "*",
120 e: "latest",
121 f: "https://example.com/foo.tgz",
122 },
123 devDependencies: {},
124 },
125 { fetchLatest: async () => "9.9.9" }
126 );
127 expect(bumps).toHaveLength(0);
128 });
129
130 it("skips packages the registry doesn't return for", async () => {
131 const bumps = await planUpdates(
132 {
133 dependencies: { missing: "^1.0.0" },
134 devDependencies: {},
135 },
136 { fetchLatest: async () => null }
137 );
138 expect(bumps).toHaveLength(0);
139 });
140
141 it("flags major bumps", async () => {
142 const bumps = await planUpdates(
143 {
144 dependencies: { hono: "^3.9.0" },
145 devDependencies: {},
146 },
147 { fetchLatest: async () => "4.0.1" }
148 );
149 expect(bumps).toHaveLength(1);
150 expect(bumps[0].major).toBe(true);
151 });
152
153 it("does not flag minor bumps as major", async () => {
154 const bumps = await planUpdates(
155 {
156 dependencies: { hono: "^4.0.0" },
157 devDependencies: {},
158 },
159 { fetchLatest: async () => "4.5.0" }
160 );
161 expect(bumps[0].major).toBe(false);
162 });
163});
164
165describe("applyBumps", () => {
166 it("rewrites the version of a single dep", () => {
167 const input = `{
168 "name": "demo",
169 "dependencies": {
170 "hono": "^4.0.0",
171 "zod": "^3.22.0"
172 }
173}
174`;
175 const out = applyBumps(input, [
176 { name: "hono", to: "4.5.0", kind: "dep" },
177 ]);
178 expect(out).toContain(`"hono": "^4.5.0"`);
179 expect(out).toContain(`"zod": "^3.22.0"`);
180 expect(out.endsWith("\n")).toBe(true);
181 });
182
183 it("preserves trailing newline exactly", () => {
184 const input = `{"dependencies":{"a":"1.0.0"}}\n`;
185 const out = applyBumps(input, [
186 { name: "a", to: "1.0.1", kind: "dep" },
187 ]);
188 expect(out.endsWith("\n")).toBe(true);
189 expect(out).toContain(`"a":"1.0.1"`);
190 });
191
192 it("preserves absence of trailing newline", () => {
193 const input = `{"dependencies":{"a":"1.0.0"}}`;
194 const out = applyBumps(input, [
195 { name: "a", to: "1.0.1", kind: "dep" },
196 ]);
197 expect(out.endsWith("\n")).toBe(false);
198 });
199
200 it("does not touch devDependencies when bumping a dep", () => {
201 const input = `{
202 "dependencies": { "a": "^1.0.0" },
203 "devDependencies": { "a": "^9.0.0" }
204}
205`;
206 const out = applyBumps(input, [
207 { name: "a", to: "1.5.0", kind: "dep" },
208 ]);
209 expect(out).toContain(`"dependencies": { "a": "^1.5.0" }`);
210 expect(out).toContain(`"devDependencies": { "a": "^9.0.0" }`);
211 });
212
213 it("rewrites devDependencies when kind is dev", () => {
214 const input = `{
215 "dependencies": { "a": "^1.0.0" },
216 "devDependencies": { "a": "^9.0.0" }
217}
218`;
219 const out = applyBumps(input, [
220 { name: "a", to: "9.5.0", kind: "dev" },
221 ]);
222 expect(out).toContain(`"dependencies": { "a": "^1.0.0" }`);
223 expect(out).toContain(`"devDependencies": { "a": "^9.5.0" }`);
224 });
225
226 it("preserves the version prefix (^, ~, exact)", () => {
227 const input = `{"dependencies":{"caret":"^1.0.0","tilde":"~2.0.0","exact":"3.0.0"}}\n`;
228 const out = applyBumps(input, [
229 { name: "caret", to: "1.5.0", kind: "dep" },
230 { name: "tilde", to: "2.5.0", kind: "dep" },
231 { name: "exact", to: "3.5.0", kind: "dep" },
232 ]);
233 expect(out).toContain(`"caret":"^1.5.0"`);
234 expect(out).toContain(`"tilde":"~2.5.0"`);
235 expect(out).toContain(`"exact":"3.5.0"`);
236 expect(out).not.toContain(`"exact":"^3.5.0"`);
237 });
238
239 it("handles scoped package names", () => {
240 const input = `{"dependencies":{"@scope/pkg":"^1.0.0"}}\n`;
241 const out = applyBumps(input, [
242 { name: "@scope/pkg", to: "1.1.0", kind: "dep" },
243 ]);
244 expect(out).toContain(`"@scope/pkg":"^1.1.0"`);
245 });
246
247 it("is a no-op when the stanza is missing", () => {
248 const input = `{"name":"x"}\n`;
249 const out = applyBumps(input, [
250 { name: "nothing", to: "1.0.0", kind: "dep" },
251 ]);
252 expect(out).toBe(input);
253 });
254});
255
256describe("routes/dep-updater", () => {
257 it("GET without auth redirects to /login", async () => {
258 const res = await depUpdater.request(
259 "/alice/demo/settings/dep-updater",
260 { redirect: "manual" }
261 );
262 // No session cookie + no bearer -> requireAuth redirects.
263 expect([302, 303]).toContain(res.status);
264 const loc = res.headers.get("location") || "";
265 expect(loc).toContain("/login");
266 });
267
268 it("POST run without auth redirects to /login", async () => {
269 const res = await depUpdater.request(
270 "/alice/demo/settings/dep-updater/run",
271 { method: "POST", redirect: "manual" }
272 );
273 expect([302, 303]).toContain(res.status);
274 const loc = res.headers.get("location") || "";
275 expect(loc).toContain("/login");
276 });
277
278 it("GET for a non-existent repo returns redirect or 404", async () => {
279 const res = await depUpdater.request(
280 "/nobody/nothing/settings/dep-updater",
281 { redirect: "manual" }
282 );
283 // Unauthenticated -> redirect; authed-but-missing -> 404; DB down -> 503.
284 expect([302, 303, 404, 503]).toContain(res.status);
285 });
286});