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.
| 3cbe3d6 | 1 | /** |
| 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 | ||
| 11 | import { describe, it, expect } from "bun:test"; | |
| 12 | import { | |
| 13 | parseManifest, | |
| 14 | planUpdates, | |
| 15 | applyBumps, | |
| 16 | } from "../lib/dep-updater"; | |
| 17 | import depUpdater from "../routes/dep-updater"; | |
| 18 | ||
| 19 | describe("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 | ||
| 64 | describe("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 | ||
| 165 | describe("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 | ||
| 256 | describe("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 | }); |