Skip to content

Commit 32fce2f

Browse files
anandgupta42claude
andcommitted
test: add e2e tests for upgrade indicator lifecycle
Add 21 end-to-end tests covering the full upgrade indicator flow: - Full lifecycle: fresh install → UpdateAvailable → indicator shown → Updated → indicator hidden - F1 regression: stale indicator after autoupgrade is cleared - F2 regression: downgrade arrow prevention with `semver.gt()` - F3 regression: empty/corrupted/non-string KV values rejected - Semver integration: prerelease, build metadata, v-prefix handling - Edge cases: rapid events, Updated without UpdateAvailable, same version available as current Uses mock KV store pattern consistent with codebase test conventions (algorithm extraction, no Solid.js context required). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 52eaacb commit 32fce2f

1 file changed

Lines changed: 338 additions & 0 deletions

File tree

Lines changed: 338 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,338 @@
1+
import { describe, expect, test } from "bun:test"
2+
import semver from "semver"
3+
import { UPGRADE_KV_KEY, getAvailableVersion } from "../../../src/cli/cmd/tui/component/upgrade-indicator-utils"
4+
import { Installation } from "../../../src/installation"
5+
6+
/**
7+
* End-to-end tests for the upgrade indicator feature.
8+
*
9+
* These simulate the full lifecycle:
10+
* UpdateAvailable event → KV store → getAvailableVersion → indicator visibility
11+
* Updated event → KV reset → indicator hidden
12+
*
13+
* Regression tests for the three original bot findings:
14+
* F1: Stale indicator after autoupgrade (KV not cleared on Updated event)
15+
* F2: Downgrade arrow (KV has older version than current)
16+
* F3: Empty string leaks as valid version
17+
*
18+
* Also covers the semver library integration (replacing custom isNewer).
19+
*/
20+
21+
// ─── KV Store Simulation ──────────────────────────────────────────────────────
22+
// Simulates the KV store behavior from context/kv.tsx without Solid.js context.
23+
// The real KV store uses createStore + Filesystem.writeJson; we simulate the
24+
// get/set interface with a plain object.
25+
26+
function createMockKV() {
27+
const store: Record<string, any> = {}
28+
return {
29+
get(key: string, defaultValue?: any) {
30+
return store[key] ?? defaultValue
31+
},
32+
set(key: string, value: any) {
33+
store[key] = value
34+
},
35+
raw: store,
36+
}
37+
}
38+
39+
// ─── Event Handler Simulation ─────────────────────────────────────────────────
40+
// Mirrors the event handlers in app.tsx:843-857
41+
42+
function simulateUpdateAvailableEvent(kv: ReturnType<typeof createMockKV>, version: string) {
43+
kv.set(UPGRADE_KV_KEY, version)
44+
}
45+
46+
function simulateUpdatedEvent(kv: ReturnType<typeof createMockKV>) {
47+
if (kv.get(UPGRADE_KV_KEY) !== Installation.VERSION) {
48+
kv.set(UPGRADE_KV_KEY, Installation.VERSION)
49+
}
50+
}
51+
52+
// ─── Full Lifecycle E2E Tests ─────────────────────────────────────────────────
53+
54+
describe("upgrade indicator e2e: full lifecycle", () => {
55+
test("fresh install: no indicator shown", () => {
56+
const kv = createMockKV()
57+
// No events fired yet — KV has no update_available_version key
58+
const version = getAvailableVersion(kv.get(UPGRADE_KV_KEY))
59+
expect(version).toBeUndefined()
60+
})
61+
62+
test("UpdateAvailable → indicator shown → user sees upgrade prompt", () => {
63+
const kv = createMockKV()
64+
65+
// Step 1: Server publishes UpdateAvailable with newer version
66+
simulateUpdateAvailableEvent(kv, "999.0.0")
67+
68+
// Step 2: Indicator should show the new version
69+
const version = getAvailableVersion(kv.get(UPGRADE_KV_KEY))
70+
expect(version).toBe("999.0.0")
71+
})
72+
73+
test("UpdateAvailable → user upgrades → Updated event → indicator hidden", () => {
74+
const kv = createMockKV()
75+
76+
// Step 1: Update available
77+
simulateUpdateAvailableEvent(kv, "999.0.0")
78+
expect(getAvailableVersion(kv.get(UPGRADE_KV_KEY))).toBe("999.0.0")
79+
80+
// Step 2: User runs `altimate upgrade`, Updated event fires
81+
simulateUpdatedEvent(kv)
82+
83+
// Step 3: Indicator should be hidden (KV now matches VERSION)
84+
expect(getAvailableVersion(kv.get(UPGRADE_KV_KEY))).toBeUndefined()
85+
})
86+
87+
test("multiple UpdateAvailable events: latest version wins", () => {
88+
const kv = createMockKV()
89+
90+
simulateUpdateAvailableEvent(kv, "998.0.0")
91+
expect(getAvailableVersion(kv.get(UPGRADE_KV_KEY))).toBe("998.0.0")
92+
93+
simulateUpdateAvailableEvent(kv, "999.0.0")
94+
expect(getAvailableVersion(kv.get(UPGRADE_KV_KEY))).toBe("999.0.0")
95+
})
96+
97+
test("KV persists across route changes (simulated)", () => {
98+
const kv = createMockKV()
99+
100+
// UpdateAvailable fires on home page
101+
simulateUpdateAvailableEvent(kv, "999.0.0")
102+
103+
// User navigates to session — same KV, indicator still shows
104+
const versionOnSession = getAvailableVersion(kv.get(UPGRADE_KV_KEY))
105+
expect(versionOnSession).toBe("999.0.0")
106+
107+
// User navigates back to home — still there
108+
const versionOnHome = getAvailableVersion(kv.get(UPGRADE_KV_KEY))
109+
expect(versionOnHome).toBe("999.0.0")
110+
})
111+
})
112+
113+
// ─── Regression: F1 — Stale indicator after autoupgrade ───────────────────────
114+
115+
describe("upgrade indicator e2e: F1 regression — stale after autoupgrade", () => {
116+
test("autoupgrade completes → Updated event clears indicator", () => {
117+
const kv = createMockKV()
118+
119+
// UpdateAvailable fires
120+
simulateUpdateAvailableEvent(kv, "999.0.0")
121+
expect(getAvailableVersion(kv.get(UPGRADE_KV_KEY))).toBe("999.0.0")
122+
123+
// Autoupgrade succeeds, Updated event fires
124+
simulateUpdatedEvent(kv)
125+
126+
// Indicator must be hidden — the bug was that Updated wasn't handled
127+
expect(getAvailableVersion(kv.get(UPGRADE_KV_KEY))).toBeUndefined()
128+
})
129+
130+
test("Updated event is idempotent (no unnecessary KV writes)", () => {
131+
const kv = createMockKV()
132+
133+
// Already at current version — Updated should not write
134+
kv.set(UPGRADE_KV_KEY, Installation.VERSION)
135+
const before = kv.get(UPGRADE_KV_KEY)
136+
137+
simulateUpdatedEvent(kv)
138+
139+
// Value unchanged — conditional check prevented redundant write
140+
expect(kv.get(UPGRADE_KV_KEY)).toBe(before)
141+
})
142+
})
143+
144+
// ─── Regression: F2 — Downgrade arrow ─────────────────────────────────────────
145+
146+
describe("upgrade indicator e2e: F2 regression — downgrade arrow prevention", () => {
147+
test("stale KV with older version does not show downgrade indicator", () => {
148+
const kv = createMockKV()
149+
150+
// Scenario: user on 0.5.3, KV has stale "0.5.0" from before external upgrade
151+
kv.set(UPGRADE_KV_KEY, "0.5.0")
152+
153+
const version = getAvailableVersion(kv.get(UPGRADE_KV_KEY))
154+
155+
if (Installation.VERSION === "local") {
156+
// Dev mode: semver.valid("0.5.0") is valid, so indicator shows
157+
expect(version).toBe("0.5.0")
158+
} else {
159+
// Production: 0.5.0 is NOT newer than current VERSION → hidden
160+
expect(version).toBeUndefined()
161+
}
162+
})
163+
164+
test("user upgrades externally past stored version", () => {
165+
const kv = createMockKV()
166+
167+
// UpdateAvailable stored "1.0.0", user upgrades to "2.0.0" externally
168+
// On restart, VERSION is "2.0.0" but KV still has "1.0.0"
169+
kv.set(UPGRADE_KV_KEY, "1.0.0")
170+
171+
const version = getAvailableVersion(kv.get(UPGRADE_KV_KEY))
172+
173+
if (Installation.VERSION === "local") {
174+
expect(version).toBe("1.0.0")
175+
} else {
176+
// 1.0.0 is NOT newer than current → should NOT show
177+
const current = semver.valid(Installation.VERSION)
178+
if (current && semver.gt("1.0.0", current)) {
179+
expect(version).toBe("1.0.0")
180+
} else {
181+
expect(version).toBeUndefined()
182+
}
183+
}
184+
})
185+
186+
test("only truly newer versions show the indicator", () => {
187+
// This test only makes sense in production (VERSION is semver)
188+
if (Installation.VERSION === "local") return
189+
190+
const current = semver.valid(Installation.VERSION)
191+
if (!current) return
192+
193+
// Older version — should NOT show
194+
const older = semver.valid("0.0.1")!
195+
expect(getAvailableVersion(older)).toBeUndefined()
196+
197+
// Same version — should NOT show
198+
expect(getAvailableVersion(current)).toBeUndefined()
199+
200+
// Newer version — SHOULD show
201+
const newer = semver.inc(current, "patch")!
202+
expect(getAvailableVersion(newer)).toBe(newer)
203+
})
204+
})
205+
206+
// ─── Regression: F3 — Empty string leak ───────────────────────────────────────
207+
208+
describe("upgrade indicator e2e: F3 regression — empty/invalid value handling", () => {
209+
test("empty string in KV does not show indicator", () => {
210+
const kv = createMockKV()
211+
kv.set(UPGRADE_KV_KEY, "")
212+
213+
expect(getAvailableVersion(kv.get(UPGRADE_KV_KEY))).toBeUndefined()
214+
})
215+
216+
test("corrupted KV value does not show indicator", () => {
217+
const kv = createMockKV()
218+
219+
const corrupted = ["error", "null", "undefined", "not-a-version", "{}", "[]", "v", ".."]
220+
for (const value of corrupted) {
221+
kv.set(UPGRADE_KV_KEY, value)
222+
const result = getAvailableVersion(kv.get(UPGRADE_KV_KEY))
223+
expect(result).toBeUndefined()
224+
}
225+
})
226+
227+
test("non-string KV values do not show indicator", () => {
228+
const kv = createMockKV()
229+
230+
const invalid = [null, undefined, 123, true, false, {}, [], NaN]
231+
for (const value of invalid) {
232+
kv.raw[UPGRADE_KV_KEY] = value
233+
expect(getAvailableVersion(kv.get(UPGRADE_KV_KEY))).toBeUndefined()
234+
}
235+
})
236+
})
237+
238+
// ─── Semver Integration Tests ─────────────────────────────────────────────────
239+
240+
describe("upgrade indicator e2e: semver integration", () => {
241+
test("prerelease versions are handled correctly", () => {
242+
// Prerelease of a very high version
243+
const result = getAvailableVersion("99.0.0-beta.1")
244+
if (Installation.VERSION === "local") {
245+
// Dev mode: semver.valid("99.0.0-beta.1") is valid
246+
expect(result).toBe("99.0.0-beta.1")
247+
} else {
248+
// Production: prerelease is lower than release
249+
// "99.0.0-beta.1" < "99.0.0" but still > most current versions
250+
const current = semver.valid(Installation.VERSION)
251+
if (current && semver.gt("99.0.0-beta.1", current)) {
252+
expect(result).toBe("99.0.0-beta.1")
253+
} else {
254+
expect(result).toBeUndefined()
255+
}
256+
}
257+
})
258+
259+
test("build metadata versions are handled", () => {
260+
// semver ignores build metadata in comparisons
261+
const result = getAvailableVersion("999.0.0+build.123")
262+
// semver.valid("999.0.0+build.123") returns "999.0.0+build.123"
263+
if (semver.valid("999.0.0+build.123")) {
264+
expect(result).toBe("999.0.0+build.123")
265+
} else {
266+
expect(result).toBeUndefined()
267+
}
268+
})
269+
270+
test("v-prefixed versions are accepted (semver strips the prefix)", () => {
271+
// semver.valid("v99.0.0") returns "99.0.0" — it normalizes the v prefix
272+
const result = getAvailableVersion("v99.0.0")
273+
expect(result).toBe("v99.0.0")
274+
})
275+
276+
test("dev mode shows indicator for any valid semver", () => {
277+
if (Installation.VERSION !== "local") return
278+
279+
// In dev mode, any valid semver candidate should show
280+
const validVersions = ["0.0.1", "1.0.0", "99.99.99", "1.0.0-alpha.1"]
281+
for (const v of validVersions) {
282+
expect(getAvailableVersion(v)).toBe(v)
283+
}
284+
285+
// Invalid semver should NOT show even in dev mode
286+
const invalidVersions = ["not-semver", "abc", "1.2", ""]
287+
for (const v of invalidVersions) {
288+
expect(getAvailableVersion(v)).toBeUndefined()
289+
}
290+
})
291+
292+
test("dev mode rejects invalid semver (no false positives from corrupted KV)", () => {
293+
if (Installation.VERSION !== "local") return
294+
295+
// These are the values that the old custom isNewer would have shown
296+
// because NaN fallback returned true — semver.valid rejects them
297+
expect(getAvailableVersion("error")).toBeUndefined()
298+
expect(getAvailableVersion("corrupted-data")).toBeUndefined()
299+
expect(getAvailableVersion("local")).toBeUndefined() // matches VERSION anyway
300+
})
301+
})
302+
303+
// ─── Race Condition / Edge Case Tests ─────────────────────────────────────────
304+
305+
describe("upgrade indicator e2e: edge cases", () => {
306+
test("rapid UpdateAvailable then Updated — indicator should be hidden", () => {
307+
const kv = createMockKV()
308+
309+
// Rapid succession: update available then immediately upgraded
310+
simulateUpdateAvailableEvent(kv, "999.0.0")
311+
simulateUpdatedEvent(kv)
312+
313+
expect(getAvailableVersion(kv.get(UPGRADE_KV_KEY))).toBeUndefined()
314+
})
315+
316+
test("Updated without prior UpdateAvailable — no-op", () => {
317+
const kv = createMockKV()
318+
319+
// Updated fires but no UpdateAvailable was received
320+
// KV key doesn't exist, so conditional check prevents write
321+
simulateUpdatedEvent(kv)
322+
323+
// KV should still not have the key (undefined !== Installation.VERSION)
324+
// Actually: undefined !== VERSION is true, so it WILL write
325+
// This is fine — setting to VERSION means getAvailableVersion returns undefined
326+
expect(getAvailableVersion(kv.get(UPGRADE_KV_KEY))).toBeUndefined()
327+
})
328+
329+
test("same version available as current — indicator hidden", () => {
330+
const kv = createMockKV()
331+
332+
// Server sends UpdateAvailable with current version (edge case)
333+
simulateUpdateAvailableEvent(kv, Installation.VERSION)
334+
335+
// Should not show — kvValue === Installation.VERSION check catches this
336+
expect(getAvailableVersion(kv.get(UPGRADE_KV_KEY))).toBeUndefined()
337+
})
338+
})

0 commit comments

Comments
 (0)