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()
+ })
+})