diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 93f029636..b7b75a075 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -6,6 +6,7 @@ import { RouteProvider, useRoute } from "@tui/context/route" import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show, on } from "solid-js" import { win32DisableProcessedInput, win32FlushInputBuffer, win32InstallCtrlCGuard } from "./win32" import { Installation } from "@/installation" +import { UPGRADE_KV_KEY } from "./component/upgrade-indicator-utils" import { Flag } from "@/flag/flag" import { Log } from "@/util/log" import { DialogProvider, useDialog } from "@tui/ui/dialog" @@ -842,6 +843,7 @@ function App() { // altimate_change start — branding: altimate upgrade sdk.event.on(Installation.Event.UpdateAvailable.type, (evt) => { + kv.set(UPGRADE_KV_KEY, evt.properties.version) toast.show({ variant: "info", title: "Update Available", @@ -849,6 +851,12 @@ function App() { duration: 10000, }) }) + + sdk.event.on(Installation.Event.Updated.type, () => { + if (kv.get(UPGRADE_KV_KEY) !== Installation.VERSION) { + kv.set(UPGRADE_KV_KEY, Installation.VERSION) + } + }) // altimate_change end return ( diff --git a/packages/opencode/src/cli/cmd/tui/component/upgrade-indicator-utils.ts b/packages/opencode/src/cli/cmd/tui/component/upgrade-indicator-utils.ts new file mode 100644 index 000000000..32e86df13 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/upgrade-indicator-utils.ts @@ -0,0 +1,22 @@ +import semver from "semver" +import { Installation } from "@/installation" + +export const UPGRADE_KV_KEY = "update_available_version" + +function isNewer(candidate: string, current: string): boolean { + // Dev mode: show indicator for any valid semver candidate + if (current === "local") { + return semver.valid(candidate) !== null + } + if (!semver.valid(candidate) || !semver.valid(current)) { + return false + } + return semver.gt(candidate, current) +} + +export function getAvailableVersion(kvValue: unknown): string | undefined { + if (typeof kvValue !== "string" || !kvValue) return undefined + if (kvValue === Installation.VERSION) return undefined + if (!isNewer(kvValue, Installation.VERSION)) return undefined + return kvValue +} diff --git a/packages/opencode/src/cli/cmd/tui/component/upgrade-indicator.tsx b/packages/opencode/src/cli/cmd/tui/component/upgrade-indicator.tsx new file mode 100644 index 000000000..5bcc86ee0 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/upgrade-indicator.tsx @@ -0,0 +1,29 @@ +import { createMemo, Show, type JSX } from "solid-js" +import { useTerminalDimensions } from "@opentui/solid" +import { useTheme } from "@tui/context/theme" +import { useKV } from "../context/kv" +import { UPGRADE_KV_KEY, getAvailableVersion } from "./upgrade-indicator-utils" + +export function UpgradeIndicator(props: { fallback?: JSX.Element }) { + const { theme } = useTheme() + const kv = useKV() + const dimensions = useTerminalDimensions() + + const latestVersion = createMemo(() => getAvailableVersion(kv.get(UPGRADE_KV_KEY))) + const isCompact = createMemo(() => dimensions().width < 100) + + return ( + + {(version) => ( + + + {version()} + + update available · + + altimate upgrade + + )} + + ) +} diff --git a/packages/opencode/src/cli/cmd/tui/routes/home.tsx b/packages/opencode/src/cli/cmd/tui/routes/home.tsx index e76e165b2..54a0e47aa 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/home.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/home.tsx @@ -15,6 +15,7 @@ import { Installation } from "@/installation" import { useKV } from "../context/kv" import { useCommandDialog } from "../component/dialog-command" import { useLocal } from "../context/local" +import { UpgradeIndicator } from "../component/upgrade-indicator" // TODO: what is the best way to do this? let once = false @@ -152,7 +153,7 @@ export function Home() { - {Installation.VERSION} + {Installation.VERSION}} /> diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx index 39d3e5f5d..98cb87ebb 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx @@ -8,6 +8,7 @@ import { useRoute } from "../../context/route" // altimate_change start - yolo mode visual indicator import { Flag } from "@/flag/flag" // altimate_change end +import { UpgradeIndicator } from "../../component/upgrade-indicator" export function Footer() { const { theme } = useTheme() @@ -95,6 +96,7 @@ export function Footer() { /status + ) diff --git a/packages/opencode/test/cli/tui/upgrade-indicator-e2e.test.ts b/packages/opencode/test/cli/tui/upgrade-indicator-e2e.test.ts new file mode 100644 index 000000000..deb8ba5e6 --- /dev/null +++ b/packages/opencode/test/cli/tui/upgrade-indicator-e2e.test.ts @@ -0,0 +1,338 @@ +import { describe, expect, test } from "bun:test" +import semver from "semver" +import { UPGRADE_KV_KEY, getAvailableVersion } from "../../../src/cli/cmd/tui/component/upgrade-indicator-utils" +import { Installation } from "../../../src/installation" + +/** + * End-to-end tests for the upgrade indicator feature. + * + * These simulate the full lifecycle: + * UpdateAvailable event → KV store → getAvailableVersion → indicator visibility + * Updated event → KV reset → indicator hidden + * + * Regression tests for the three original bot findings: + * F1: Stale indicator after autoupgrade (KV not cleared on Updated event) + * F2: Downgrade arrow (KV has older version than current) + * F3: Empty string leaks as valid version + * + * Also covers the semver library integration (replacing custom isNewer). + */ + +// ─── KV Store Simulation ────────────────────────────────────────────────────── +// Simulates the KV store behavior from context/kv.tsx without Solid.js context. +// The real KV store uses createStore + Filesystem.writeJson; we simulate the +// get/set interface with a plain object. + +function createMockKV() { + const store: Record = {} + return { + get(key: string, defaultValue?: any) { + return store[key] ?? defaultValue + }, + set(key: string, value: any) { + store[key] = value + }, + raw: store, + } +} + +// ─── Event Handler Simulation ───────────────────────────────────────────────── +// Mirrors the event handlers in app.tsx:843-857 + +function simulateUpdateAvailableEvent(kv: ReturnType, version: string) { + kv.set(UPGRADE_KV_KEY, version) +} + +function simulateUpdatedEvent(kv: ReturnType) { + if (kv.get(UPGRADE_KV_KEY) !== Installation.VERSION) { + kv.set(UPGRADE_KV_KEY, Installation.VERSION) + } +} + +// ─── Full Lifecycle E2E Tests ───────────────────────────────────────────────── + +describe("upgrade indicator e2e: full lifecycle", () => { + test("fresh install: no indicator shown", () => { + const kv = createMockKV() + // No events fired yet — KV has no update_available_version key + const version = getAvailableVersion(kv.get(UPGRADE_KV_KEY)) + expect(version).toBeUndefined() + }) + + test("UpdateAvailable → indicator shown → user sees upgrade prompt", () => { + const kv = createMockKV() + + // Step 1: Server publishes UpdateAvailable with newer version + simulateUpdateAvailableEvent(kv, "999.0.0") + + // Step 2: Indicator should show the new version + const version = getAvailableVersion(kv.get(UPGRADE_KV_KEY)) + expect(version).toBe("999.0.0") + }) + + test("UpdateAvailable → user upgrades → Updated event → indicator hidden", () => { + const kv = createMockKV() + + // Step 1: Update available + simulateUpdateAvailableEvent(kv, "999.0.0") + expect(getAvailableVersion(kv.get(UPGRADE_KV_KEY))).toBe("999.0.0") + + // Step 2: User runs `altimate upgrade`, Updated event fires + simulateUpdatedEvent(kv) + + // Step 3: Indicator should be hidden (KV now matches VERSION) + expect(getAvailableVersion(kv.get(UPGRADE_KV_KEY))).toBeUndefined() + }) + + test("multiple UpdateAvailable events: latest version wins", () => { + const kv = createMockKV() + + simulateUpdateAvailableEvent(kv, "998.0.0") + expect(getAvailableVersion(kv.get(UPGRADE_KV_KEY))).toBe("998.0.0") + + simulateUpdateAvailableEvent(kv, "999.0.0") + expect(getAvailableVersion(kv.get(UPGRADE_KV_KEY))).toBe("999.0.0") + }) + + test("KV persists across route changes (simulated)", () => { + const kv = createMockKV() + + // UpdateAvailable fires on home page + simulateUpdateAvailableEvent(kv, "999.0.0") + + // User navigates to session — same KV, indicator still shows + const versionOnSession = getAvailableVersion(kv.get(UPGRADE_KV_KEY)) + expect(versionOnSession).toBe("999.0.0") + + // User navigates back to home — still there + const versionOnHome = getAvailableVersion(kv.get(UPGRADE_KV_KEY)) + expect(versionOnHome).toBe("999.0.0") + }) +}) + +// ─── Regression: F1 — Stale indicator after autoupgrade ─────────────────────── + +describe("upgrade indicator e2e: F1 regression — stale after autoupgrade", () => { + test("autoupgrade completes → Updated event clears indicator", () => { + const kv = createMockKV() + + // UpdateAvailable fires + simulateUpdateAvailableEvent(kv, "999.0.0") + expect(getAvailableVersion(kv.get(UPGRADE_KV_KEY))).toBe("999.0.0") + + // Autoupgrade succeeds, Updated event fires + simulateUpdatedEvent(kv) + + // Indicator must be hidden — the bug was that Updated wasn't handled + expect(getAvailableVersion(kv.get(UPGRADE_KV_KEY))).toBeUndefined() + }) + + test("Updated event is idempotent (no unnecessary KV writes)", () => { + const kv = createMockKV() + + // Already at current version — Updated should not write + kv.set(UPGRADE_KV_KEY, Installation.VERSION) + const before = kv.get(UPGRADE_KV_KEY) + + simulateUpdatedEvent(kv) + + // Value unchanged — conditional check prevented redundant write + expect(kv.get(UPGRADE_KV_KEY)).toBe(before) + }) +}) + +// ─── Regression: F2 — Downgrade arrow ───────────────────────────────────────── + +describe("upgrade indicator e2e: F2 regression — downgrade arrow prevention", () => { + test("stale KV with older version does not show downgrade indicator", () => { + const kv = createMockKV() + + // Scenario: user on 0.5.3, KV has stale "0.5.0" from before external upgrade + kv.set(UPGRADE_KV_KEY, "0.5.0") + + const version = getAvailableVersion(kv.get(UPGRADE_KV_KEY)) + + if (Installation.VERSION === "local") { + // Dev mode: semver.valid("0.5.0") is valid, so indicator shows + expect(version).toBe("0.5.0") + } else { + // Production: 0.5.0 is NOT newer than current VERSION → hidden + expect(version).toBeUndefined() + } + }) + + test("user upgrades externally past stored version", () => { + const kv = createMockKV() + + // UpdateAvailable stored "1.0.0", user upgrades to "2.0.0" externally + // On restart, VERSION is "2.0.0" but KV still has "1.0.0" + kv.set(UPGRADE_KV_KEY, "1.0.0") + + const version = getAvailableVersion(kv.get(UPGRADE_KV_KEY)) + + if (Installation.VERSION === "local") { + expect(version).toBe("1.0.0") + } else { + // 1.0.0 is NOT newer than current → should NOT show + const current = semver.valid(Installation.VERSION) + if (current && semver.gt("1.0.0", current)) { + expect(version).toBe("1.0.0") + } else { + expect(version).toBeUndefined() + } + } + }) + + test("only truly newer versions show the indicator", () => { + // This test only makes sense in production (VERSION is semver) + if (Installation.VERSION === "local") return + + const current = semver.valid(Installation.VERSION) + if (!current) return + + // Older version — should NOT show + const older = semver.valid("0.0.1")! + expect(getAvailableVersion(older)).toBeUndefined() + + // Same version — should NOT show + expect(getAvailableVersion(current)).toBeUndefined() + + // Newer version — SHOULD show + const newer = semver.inc(current, "patch")! + expect(getAvailableVersion(newer)).toBe(newer) + }) +}) + +// ─── Regression: F3 — Empty string leak ─────────────────────────────────────── + +describe("upgrade indicator e2e: F3 regression — empty/invalid value handling", () => { + test("empty string in KV does not show indicator", () => { + const kv = createMockKV() + kv.set(UPGRADE_KV_KEY, "") + + expect(getAvailableVersion(kv.get(UPGRADE_KV_KEY))).toBeUndefined() + }) + + test("corrupted KV value does not show indicator", () => { + const kv = createMockKV() + + const corrupted = ["error", "null", "undefined", "not-a-version", "{}", "[]", "v", ".."] + for (const value of corrupted) { + kv.set(UPGRADE_KV_KEY, value) + const result = getAvailableVersion(kv.get(UPGRADE_KV_KEY)) + expect(result).toBeUndefined() + } + }) + + test("non-string KV values do not show indicator", () => { + const kv = createMockKV() + + const invalid = [null, undefined, 123, true, false, {}, [], NaN] + for (const value of invalid) { + kv.raw[UPGRADE_KV_KEY] = value + expect(getAvailableVersion(kv.get(UPGRADE_KV_KEY))).toBeUndefined() + } + }) +}) + +// ─── Semver Integration Tests ───────────────────────────────────────────────── + +describe("upgrade indicator e2e: semver integration", () => { + test("prerelease versions are handled correctly", () => { + // Prerelease of a very high version + const result = getAvailableVersion("99.0.0-beta.1") + if (Installation.VERSION === "local") { + // Dev mode: semver.valid("99.0.0-beta.1") is valid + expect(result).toBe("99.0.0-beta.1") + } else { + // Production: prerelease is lower than release + // "99.0.0-beta.1" < "99.0.0" but still > most current versions + const current = semver.valid(Installation.VERSION) + if (current && semver.gt("99.0.0-beta.1", current)) { + expect(result).toBe("99.0.0-beta.1") + } else { + expect(result).toBeUndefined() + } + } + }) + + test("build metadata versions are handled", () => { + // semver ignores build metadata in comparisons + const result = getAvailableVersion("999.0.0+build.123") + // semver.valid("999.0.0+build.123") returns "999.0.0+build.123" + if (semver.valid("999.0.0+build.123")) { + expect(result).toBe("999.0.0+build.123") + } else { + expect(result).toBeUndefined() + } + }) + + test("v-prefixed versions are accepted (semver strips the prefix)", () => { + // semver.valid("v99.0.0") returns "99.0.0" — it normalizes the v prefix + const result = getAvailableVersion("v99.0.0") + expect(result).toBe("v99.0.0") + }) + + test("dev mode shows indicator for any valid semver", () => { + if (Installation.VERSION !== "local") return + + // In dev mode, any valid semver candidate should show + const validVersions = ["0.0.1", "1.0.0", "99.99.99", "1.0.0-alpha.1"] + for (const v of validVersions) { + expect(getAvailableVersion(v)).toBe(v) + } + + // Invalid semver should NOT show even in dev mode + const invalidVersions = ["not-semver", "abc", "1.2", ""] + for (const v of invalidVersions) { + expect(getAvailableVersion(v)).toBeUndefined() + } + }) + + test("dev mode rejects invalid semver (no false positives from corrupted KV)", () => { + if (Installation.VERSION !== "local") return + + // These are the values that the old custom isNewer would have shown + // because NaN fallback returned true — semver.valid rejects them + expect(getAvailableVersion("error")).toBeUndefined() + expect(getAvailableVersion("corrupted-data")).toBeUndefined() + expect(getAvailableVersion("local")).toBeUndefined() // matches VERSION anyway + }) +}) + +// ─── Race Condition / Edge Case Tests ───────────────────────────────────────── + +describe("upgrade indicator e2e: edge cases", () => { + test("rapid UpdateAvailable then Updated — indicator should be hidden", () => { + const kv = createMockKV() + + // Rapid succession: update available then immediately upgraded + simulateUpdateAvailableEvent(kv, "999.0.0") + simulateUpdatedEvent(kv) + + expect(getAvailableVersion(kv.get(UPGRADE_KV_KEY))).toBeUndefined() + }) + + test("Updated without prior UpdateAvailable — no-op", () => { + const kv = createMockKV() + + // Updated fires but no UpdateAvailable was received + // KV key doesn't exist, so conditional check prevents write + simulateUpdatedEvent(kv) + + // KV should still not have the key (undefined !== Installation.VERSION) + // Actually: undefined !== VERSION is true, so it WILL write + // This is fine — setting to VERSION means getAvailableVersion returns undefined + expect(getAvailableVersion(kv.get(UPGRADE_KV_KEY))).toBeUndefined() + }) + + test("same version available as current — indicator hidden", () => { + const kv = createMockKV() + + // Server sends UpdateAvailable with current version (edge case) + simulateUpdateAvailableEvent(kv, Installation.VERSION) + + // Should not show — kvValue === Installation.VERSION check catches this + expect(getAvailableVersion(kv.get(UPGRADE_KV_KEY))).toBeUndefined() + }) +}) diff --git a/packages/opencode/test/cli/tui/upgrade-indicator.test.ts b/packages/opencode/test/cli/tui/upgrade-indicator.test.ts new file mode 100644 index 000000000..cca2b2ee8 --- /dev/null +++ b/packages/opencode/test/cli/tui/upgrade-indicator.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, test } from "bun:test" +import { UPGRADE_KV_KEY, getAvailableVersion } from "../../../src/cli/cmd/tui/component/upgrade-indicator-utils" +import { Installation } from "../../../src/installation" + +describe("upgrade-indicator-utils", () => { + describe("UPGRADE_KV_KEY", () => { + test("exports a consistent KV key", () => { + expect(UPGRADE_KV_KEY).toBe("update_available_version") + }) + }) + + describe("getAvailableVersion", () => { + test("returns undefined when KV value is undefined", () => { + expect(getAvailableVersion(undefined)).toBeUndefined() + }) + + test("returns undefined when KV value is null", () => { + expect(getAvailableVersion(null)).toBeUndefined() + }) + + test("returns undefined when KV value is not a string", () => { + expect(getAvailableVersion(123)).toBeUndefined() + expect(getAvailableVersion(true)).toBeUndefined() + expect(getAvailableVersion({})).toBeUndefined() + expect(getAvailableVersion([])).toBeUndefined() + }) + + test("returns undefined when KV value matches current version", () => { + expect(getAvailableVersion(Installation.VERSION)).toBeUndefined() + }) + + test("returns version string when it is newer than current version", () => { + const result = getAvailableVersion("99.99.99") + expect(result).toBe("99.99.99") + }) + + test("returns undefined for empty string", () => { + expect(getAvailableVersion("")).toBeUndefined() + }) + + test("returns undefined for invalid/corrupted version strings", () => { + // Invalid versions should not show the indicator (semver rejects them) + expect(getAvailableVersion("not-a-version")).toBeUndefined() + expect(getAvailableVersion("error")).toBeUndefined() + }) + + test("handles prerelease versions correctly", () => { + // Prerelease of a very high version should still show + const result = getAvailableVersion("99.0.0-beta.1") + if (Installation.VERSION === "local") { + expect(result).toBe("99.0.0-beta.1") + } else { + // semver.gt handles prerelease correctly + expect(typeof result === "string" || result === undefined).toBe(true) + } + }) + + test("returns version for valid semver in dev mode", () => { + // When VERSION="local" (dev), any valid semver candidate shows + const result = getAvailableVersion("0.0.1") + if (Installation.VERSION === "local") { + expect(result).toBe("0.0.1") + } else { + expect(result).toBeUndefined() + } + }) + }) +}) diff --git a/packages/opencode/test/cli/upgrade-notify.test.ts b/packages/opencode/test/cli/upgrade-notify.test.ts new file mode 100644 index 000000000..58b19e174 --- /dev/null +++ b/packages/opencode/test/cli/upgrade-notify.test.ts @@ -0,0 +1,145 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { Installation } from "../../src/installation" +import { UPGRADE_KV_KEY, getAvailableVersion } from "../../src/cli/cmd/tui/component/upgrade-indicator-utils" + +const fetch0 = globalThis.fetch + +afterEach(() => { + globalThis.fetch = fetch0 +}) + +describe("upgrade notification flow", () => { + describe("event definitions", () => { + test("UpdateAvailable has correct event type", () => { + expect(Installation.Event.UpdateAvailable.type).toBe("installation.update-available") + }) + + test("Updated has correct event type", () => { + expect(Installation.Event.Updated.type).toBe("installation.updated") + }) + + test("UpdateAvailable schema validates version string", () => { + const result = Installation.Event.UpdateAvailable.properties.safeParse({ version: "1.2.3" }) + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.version).toBe("1.2.3") + } + }) + + test("UpdateAvailable schema rejects missing version", () => { + const result = Installation.Event.UpdateAvailable.properties.safeParse({}) + expect(result.success).toBe(false) + }) + + test("UpdateAvailable schema rejects non-string version", () => { + const result = Installation.Event.UpdateAvailable.properties.safeParse({ version: 123 }) + expect(result.success).toBe(false) + }) + }) + + describe("Installation.VERSION", () => { + test("is a non-empty string", () => { + expect(typeof Installation.VERSION).toBe("string") + expect(Installation.VERSION.length).toBeGreaterThan(0) + }) + }) + + describe("latest version fetch", () => { + test("returns version from GitHub releases for unknown method", async () => { + globalThis.fetch = (async () => + new Response(JSON.stringify({ tag_name: "v5.0.0" }), { + status: 200, + headers: { "content-type": "application/json" }, + })) as unknown as typeof fetch + + const latest = await Installation.latest("unknown") + expect(latest).toBe("5.0.0") + }) + + test("strips v prefix from GitHub tag", async () => { + globalThis.fetch = (async () => + new Response(JSON.stringify({ tag_name: "v10.20.30" }), { + status: 200, + headers: { "content-type": "application/json" }, + })) as unknown as typeof fetch + + const latest = await Installation.latest("unknown") + expect(latest).toBe("10.20.30") + }) + + test("returns npm version for npm method", async () => { + globalThis.fetch = (async () => + new Response(JSON.stringify({ version: "4.0.0" }), { + status: 200, + headers: { "content-type": "application/json" }, + })) as unknown as typeof fetch + + const latest = await Installation.latest("npm") + expect(latest).toBe("4.0.0") + }) + }) +}) + +describe("KV-based upgrade indicator integration", () => { + test("UPGRADE_KV_KEY is consistent", () => { + expect(UPGRADE_KV_KEY).toBe("update_available_version") + }) + + test("simulated KV store correctly tracks update version", () => { + const store: Record = {} + store[UPGRADE_KV_KEY] = "999.0.0" + expect(store[UPGRADE_KV_KEY]).toBe("999.0.0") + }) + + test("indicator hidden when stored version is older (prevents downgrade arrow)", () => { + // F2 fix: user on 0.5.3, KV has stale "0.5.0" — should NOT show downgrade + // In dev mode (VERSION="local"), valid semver candidates still show + const result = getAvailableVersion("0.5.0") + if (Installation.VERSION === "local") { + expect(result).toBe("0.5.0") + } else { + expect(result).toBeUndefined() + } + }) + + test("indicator hidden for invalid/corrupted KV values", () => { + expect(getAvailableVersion("corrupted")).toBeUndefined() + expect(getAvailableVersion("not-semver")).toBeUndefined() + }) + + test("indicator shown when stored version is newer than current", () => { + const store: Record = {} + store[UPGRADE_KV_KEY] = "999.0.0" + + const result = getAvailableVersion(store[UPGRADE_KV_KEY]) + expect(result).toBe("999.0.0") + }) + + test("indicator hidden when key is absent", () => { + const store: Record = {} + const result = getAvailableVersion(store[UPGRADE_KV_KEY]) + expect(result).toBeUndefined() + }) + + test("KV value can be overwritten with newer version", () => { + const store: Record = {} + store[UPGRADE_KV_KEY] = "998.0.0" + store[UPGRADE_KV_KEY] = "999.0.0" + expect(store[UPGRADE_KV_KEY]).toBe("999.0.0") + + const result = getAvailableVersion(store[UPGRADE_KV_KEY]) + expect(result).toBe("999.0.0") + }) + + test("end-to-end: event → KV → indicator → reset on Updated", () => { + const store: Record = {} + + // Step 1: UpdateAvailable event stores version + store[UPGRADE_KV_KEY] = "999.0.0" + expect(getAvailableVersion(store[UPGRADE_KV_KEY])).toBe("999.0.0") + + // Step 2: Updated event sets KV to current version (F1 fix) + store[UPGRADE_KV_KEY] = Installation.VERSION + expect(getAvailableVersion(store[UPGRADE_KV_KEY])).toBeUndefined() + }) +})