From df17db0403f5a9be9888ebe34f60c7c07e497a12 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Fri, 24 Jul 2026 08:04:54 +0700 Subject: [PATCH 1/6] feat: add extension update state utilities --- src/utils/extensionUpdate.ts | 215 +++++++++++++++++++++++++++++++++++ 1 file changed, 215 insertions(+) create mode 100644 src/utils/extensionUpdate.ts diff --git a/src/utils/extensionUpdate.ts b/src/utils/extensionUpdate.ts new file mode 100644 index 00000000..8e481f60 --- /dev/null +++ b/src/utils/extensionUpdate.ts @@ -0,0 +1,215 @@ +export const EXTENSION_UPDATE_STORAGE_KEY = "extension_update_prompt"; +export const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; +export const UPDATE_SNOOZE_MS = 7 * 24 * 60 * 60 * 1000; + +export type ExtensionUpdateStatus = "idle" | "available" | "ready"; +export type RuntimeUpdateCheckStatus = + | "throttled" + | "no_update" + | "update_available"; + +export interface RuntimeUpdateCheckResult { + status: RuntimeUpdateCheckStatus; + version?: string; +} + +export interface ExtensionUpdateState { + status: ExtensionUpdateStatus; + lastCheckedAt?: number; + availableVersion?: string; + dismissedVersion?: string; + dismissedUntil?: number; +} + +interface ParsedVersion { + core: number[]; + prerelease: string[] | null; + normalized: string; +} + +/** Parses browser-store versions without depending on a runtime semver package. */ +function parseVersion(value: unknown): ParsedVersion | null { + if (typeof value !== "string") return null; + + const normalized = value.trim().replace(/^v/i, ""); + const match = normalized.match( + /^(\d+(?:\.\d+){0,3})(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/, + ); + if (!match) return null; + + const core = match[1].split(".").map(Number); + while (core.length < 4) core.push(0); + + return { + core, + prerelease: match[2]?.split(".") ?? null, + normalized, + }; +} + +/** Compares two extension versions. Positive means left is newer. */ +export function compareExtensionVersions(left: string, right: string): number { + const parsedLeft = parseVersion(left); + const parsedRight = parseVersion(right); + if (!parsedLeft || !parsedRight) return 0; + + for (let index = 0; index < parsedLeft.core.length; index += 1) { + const difference = parsedLeft.core[index] - parsedRight.core[index]; + if (difference !== 0) return difference; + } + + if (!parsedLeft.prerelease && !parsedRight.prerelease) return 0; + if (!parsedLeft.prerelease) return 1; + if (!parsedRight.prerelease) return -1; + + const length = Math.max( + parsedLeft.prerelease.length, + parsedRight.prerelease.length, + ); + for (let index = 0; index < length; index += 1) { + const leftPart = parsedLeft.prerelease[index]; + const rightPart = parsedRight.prerelease[index]; + if (leftPart === undefined) return -1; + if (rightPart === undefined) return 1; + if (leftPart === rightPart) continue; + + const leftNumber = /^\d+$/.test(leftPart) ? Number(leftPart) : null; + const rightNumber = /^\d+$/.test(rightPart) ? Number(rightPart) : null; + if (leftNumber !== null && rightNumber !== null) { + return leftNumber - rightNumber; + } + if (leftNumber !== null) return -1; + if (rightNumber !== null) return 1; + return leftPart.localeCompare(rightPart); + } + + return 0; +} + +/** Returns true only when the store version is newer than the installed version. */ +export function isExtensionVersionNewer( + storeVersion: string, + installedVersion: string, +): boolean { + return compareExtensionVersions(storeVersion, installedVersion) > 0; +} + +/** Validates persisted update state before it is consumed by the popup. */ +export function normalizeExtensionUpdateState( + value: unknown, +): ExtensionUpdateState { + if (!value || typeof value !== "object") return { status: "idle" }; + + const candidate = value as Partial; + const status: ExtensionUpdateStatus = ["idle", "available", "ready"].includes( + candidate.status ?? "", + ) + ? (candidate.status as ExtensionUpdateStatus) + : "idle"; + + return { + status, + lastCheckedAt: + typeof candidate.lastCheckedAt === "number" + ? candidate.lastCheckedAt + : undefined, + availableVersion: + parseVersion(candidate.availableVersion)?.normalized ?? undefined, + dismissedVersion: + parseVersion(candidate.dismissedVersion)?.normalized ?? undefined, + dismissedUntil: + typeof candidate.dismissedUntil === "number" + ? candidate.dismissedUntil + : undefined, + }; +} + +/** Limits automatic Store checks while still allowing an explicit user retry. */ +export function shouldCheckForExtensionUpdate( + state: ExtensionUpdateState, + now: number, + force = false, +): boolean { + if (force) return true; + return ( + state.lastCheckedAt === undefined || + now - state.lastCheckedAt >= UPDATE_CHECK_INTERVAL_MS + ); +} + +/** Reduces a browser runtime update-check result into persisted popup state. */ +export function applyRuntimeUpdateCheckResult( + state: ExtensionUpdateState, + result: RuntimeUpdateCheckResult, + checkedAt: number, +): ExtensionUpdateState { + if (result.status === "no_update") { + return { status: "idle", lastCheckedAt: checkedAt }; + } + + if (result.status === "throttled") { + return { ...state, lastCheckedAt: checkedAt }; + } + + const availableVersion = + parseVersion(result.version)?.normalized ?? state.availableVersion; + return { + ...state, + status: availableVersion ? "available" : state.status, + availableVersion, + lastCheckedAt: checkedAt, + }; +} + +/** Marks an update downloaded by the browser and ready to apply. */ +export function markExtensionUpdateReady( + state: ExtensionUpdateState, + version: string, + checkedAt: number, +): ExtensionUpdateState { + const availableVersion = parseVersion(version)?.normalized; + if (!availableVersion) return state; + + return { + ...state, + status: "ready", + availableVersion, + lastCheckedAt: checkedAt, + }; +} + +/** Hides stale or snoozed prompts but lets a newer release bypass an old snooze. */ +export function shouldShowExtensionUpdatePrompt( + state: ExtensionUpdateState, + installedVersion: string, + now: number, +): boolean { + const availableVersion = state.availableVersion; + if ( + state.status === "idle" || + !availableVersion || + !isExtensionVersionNewer(availableVersion, installedVersion) + ) { + return false; + } + + return !( + state.dismissedVersion === availableVersion && + typeof state.dismissedUntil === "number" && + state.dismissedUntil > now + ); +} + +/** Creates a seven-day snooze tied to one specific available version. */ +export function snoozeExtensionUpdate( + state: ExtensionUpdateState, + now: number, +): ExtensionUpdateState { + if (!state.availableVersion) return state; + + return { + ...state, + dismissedVersion: state.availableVersion, + dismissedUntil: now + UPDATE_SNOOZE_MS, + }; +} From 20e4d5f8c3c589f631ac42ee3a176798697d6332 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Fri, 24 Jul 2026 08:05:36 +0700 Subject: [PATCH 2/6] feat: localize extension update prompt --- src/i18n/updatePrompt.ts | 177 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 src/i18n/updatePrompt.ts diff --git a/src/i18n/updatePrompt.ts b/src/i18n/updatePrompt.ts new file mode 100644 index 00000000..2b29e428 --- /dev/null +++ b/src/i18n/updatePrompt.ts @@ -0,0 +1,177 @@ +export interface UpdatePromptCopy { + title: string; + available: string; + ready: string; + checkNow: string; + applyNow: string; + later: string; + checking: string; +} + +const UPDATE_PROMPT_COPY: Record = { + en: { + title: "Update available", + available: + "You are using v{current}. Version v{latest} is available from your browser store.", + ready: "Version v{latest} is downloaded and ready to apply.", + checkNow: "Check & update", + applyNow: "Apply update", + later: "Later", + checking: "Checking...", + }, + vi: { + title: "Có bản cập nhật", + available: + "Bạn đang dùng v{current}. Phiên bản v{latest} đã có trên cửa hàng tiện ích.", + ready: "Phiên bản v{latest} đã tải xong và sẵn sàng áp dụng.", + checkNow: "Kiểm tra và cập nhật", + applyNow: "Áp dụng cập nhật", + later: "Để sau", + checking: "Đang kiểm tra...", + }, + de: { + title: "Update verfügbar", + available: + "Du verwendest v{current}. Version v{latest} ist im Browser-Store verfügbar.", + ready: "Version v{latest} wurde heruntergeladen und kann angewendet werden.", + checkNow: "Prüfen und aktualisieren", + applyNow: "Update anwenden", + later: "Später", + checking: "Wird geprüft...", + }, + es: { + title: "Actualización disponible", + available: + "Estás usando v{current}. La versión v{latest} está disponible en la tienda del navegador.", + ready: "La versión v{latest} se descargó y está lista para aplicarse.", + checkNow: "Comprobar y actualizar", + applyNow: "Aplicar actualización", + later: "Más tarde", + checking: "Comprobando...", + }, + fr: { + title: "Mise à jour disponible", + available: + "Vous utilisez la v{current}. La version v{latest} est disponible dans la boutique du navigateur.", + ready: "La version v{latest} est téléchargée et prête à être appliquée.", + checkNow: "Vérifier et mettre à jour", + applyNow: "Appliquer la mise à jour", + later: "Plus tard", + checking: "Vérification...", + }, + hi: { + title: "अपडेट उपलब्ध है", + available: + "आप v{current} का उपयोग कर रहे हैं। v{latest} ब्राउज़र स्टोर पर उपलब्ध है।", + ready: "v{latest} डाउनलोड हो गया है और लागू करने के लिए तैयार है।", + checkNow: "जाँचें और अपडेट करें", + applyNow: "अपडेट लागू करें", + later: "बाद में", + checking: "जाँच हो रही है...", + }, + it: { + title: "Aggiornamento disponibile", + available: + "Stai usando la v{current}. La versione v{latest} è disponibile nello store del browser.", + ready: "La versione v{latest} è stata scaricata ed è pronta da applicare.", + checkNow: "Controlla e aggiorna", + applyNow: "Applica aggiornamento", + later: "Più tardi", + checking: "Controllo...", + }, + ja: { + title: "アップデートがあります", + available: + "現在 v{current} を使用しています。ブラウザストアで v{latest} を利用できます。", + ready: "v{latest} のダウンロードが完了し、適用できます。", + checkNow: "確認して更新", + applyNow: "更新を適用", + later: "後で", + checking: "確認中...", + }, + ko: { + title: "업데이트 사용 가능", + available: + "현재 v{current}을 사용 중입니다. 브라우저 스토어에서 v{latest}을 사용할 수 있습니다.", + ready: "v{latest} 다운로드가 완료되어 적용할 수 있습니다.", + checkNow: "확인 및 업데이트", + applyNow: "업데이트 적용", + later: "나중에", + checking: "확인 중...", + }, + pl: { + title: "Dostępna aktualizacja", + available: + "Używasz wersji v{current}. W sklepie przeglądarki jest dostępna wersja v{latest}.", + ready: "Wersja v{latest} została pobrana i jest gotowa do zastosowania.", + checkNow: "Sprawdź i zaktualizuj", + applyNow: "Zastosuj aktualizację", + later: "Później", + checking: "Sprawdzanie...", + }, + pt_br: { + title: "Atualização disponível", + available: + "Você está usando a v{current}. A versão v{latest} está disponível na loja do navegador.", + ready: "A versão v{latest} foi baixada e está pronta para ser aplicada.", + checkNow: "Verificar e atualizar", + applyNow: "Aplicar atualização", + later: "Mais tarde", + checking: "Verificando...", + }, + ru: { + title: "Доступно обновление", + available: + "У вас установлена v{current}. Версия v{latest} доступна в магазине браузера.", + ready: "Версия v{latest} загружена и готова к применению.", + checkNow: "Проверить и обновить", + applyNow: "Применить обновление", + later: "Позже", + checking: "Проверка...", + }, + tr: { + title: "Güncelleme mevcut", + available: + "v{current} kullanıyorsunuz. v{latest} tarayıcı mağazasında mevcut.", + ready: "v{latest} indirildi ve uygulanmaya hazır.", + checkNow: "Kontrol et ve güncelle", + applyNow: "Güncellemeyi uygula", + later: "Daha sonra", + checking: "Kontrol ediliyor...", + }, + zh_cn: { + title: "有可用更新", + available: + "你正在使用 v{current}。浏览器扩展商店已提供 v{latest}。", + ready: "v{latest} 已下载完成,可以立即应用。", + checkNow: "检查并更新", + applyNow: "应用更新", + later: "稍后", + checking: "正在检查...", + }, +}; + +/** Resolves update copy for the active browser UI locale. */ +export function getUpdatePromptCopy(locale?: string): UpdatePromptCopy { + const resolvedLocale = ( + locale ?? browser.i18n.getUILanguage?.() ?? "en" + ) + .toLowerCase() + .replace("-", "_"); + const baseLocale = resolvedLocale.split("_")[0]; + return ( + UPDATE_PROMPT_COPY[resolvedLocale] ?? + UPDATE_PROMPT_COPY[baseLocale] ?? + UPDATE_PROMPT_COPY.en + ); +} + +/** Replaces the small set of named placeholders used by update messages. */ +export function formatUpdatePromptCopy( + template: string, + values: Record<"current" | "latest", string>, +): string { + return template + .replaceAll("{current}", values.current) + .replaceAll("{latest}", values.latest); +} From d4a5d123b02040f235ef757eec25fd7f87efa911 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Fri, 24 Jul 2026 08:06:43 +0700 Subject: [PATCH 3/6] feat: add non-blocking extension update banner --- .../alias/ExtensionUpdateBanner.tsx | 229 ++++++++++++++++++ 1 file changed, 229 insertions(+) create mode 100644 src/components/alias/ExtensionUpdateBanner.tsx diff --git a/src/components/alias/ExtensionUpdateBanner.tsx b/src/components/alias/ExtensionUpdateBanner.tsx new file mode 100644 index 00000000..4df5a3b2 --- /dev/null +++ b/src/components/alias/ExtensionUpdateBanner.tsx @@ -0,0 +1,229 @@ +import { Download, RefreshCw } from "lucide-react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { + formatUpdatePromptCopy, + getUpdatePromptCopy, +} from "src/i18n/updatePrompt"; +import { + applyRuntimeUpdateCheckResult, + EXTENSION_UPDATE_STORAGE_KEY, + type ExtensionUpdateState, + isExtensionVersionNewer, + markExtensionUpdateReady, + normalizeExtensionUpdateState, + type RuntimeUpdateCheckResult, + shouldCheckForExtensionUpdate, + shouldShowExtensionUpdatePrompt, + snoozeExtensionUpdate, +} from "src/utils/extensionUpdate"; + +interface RuntimeUpdateApi { + getManifest(): { version: string }; + reload(): void; + requestUpdateCheck?: () => Promise; + onUpdateAvailable?: { + addListener(listener: (details: { version: string }) => void): void; + removeListener(listener: (details: { version: string }) => void): void; + }; +} + +const updateRuntime = browser.runtime as unknown as RuntimeUpdateApi; +let activeUpdateCheck: Promise | null = null; + +/** Reads and validates the locally cached update prompt state. */ +async function readUpdateState(): Promise { + const result = await browser.storage.local.get(EXTENSION_UPDATE_STORAGE_KEY); + return normalizeExtensionUpdateState(result[EXTENSION_UPDATE_STORAGE_KEY]); +} + +/** Persists one update prompt state for all extension surfaces. */ +async function writeUpdateState(state: ExtensionUpdateState): Promise { + await browser.storage.local.set({ [EXTENSION_UPDATE_STORAGE_KEY]: state }); +} + +/** Removes a cached prompt after the installed extension has caught up. */ +function clearInstalledUpdate( + state: ExtensionUpdateState, + installedVersion: string, +): ExtensionUpdateState { + if ( + state.availableVersion && + !isExtensionVersionNewer(state.availableVersion, installedVersion) + ) { + return { status: "idle", lastCheckedAt: state.lastCheckedAt }; + } + return state; +} + +/** Requests a Store-backed browser update check with a shared in-flight guard. */ +async function checkBrowserStoreForUpdate( + installedVersion: string, + force = false, +): Promise { + const existing = clearInstalledUpdate(await readUpdateState(), installedVersion); + const now = Date.now(); + + if (!shouldCheckForExtensionUpdate(existing, now, force)) return existing; + if (!updateRuntime.requestUpdateCheck) return existing; + if (activeUpdateCheck) return activeUpdateCheck; + + activeUpdateCheck = (async () => { + try { + const result = await updateRuntime.requestUpdateCheck?.(); + if (!result) return existing; + + const nextState = clearInstalledUpdate( + applyRuntimeUpdateCheckResult(existing, result, now), + installedVersion, + ); + await writeUpdateState(nextState); + return nextState; + } catch { + return existing; + } finally { + activeUpdateCheck = null; + } + })(); + + return activeUpdateCheck; +} + +/** Compact Store update prompt shown below the popup header. */ +export default function ExtensionUpdateBanner() { + const installedVersion = updateRuntime.getManifest().version; + const copy = useMemo(() => getUpdatePromptCopy(), []); + const [updateState, setUpdateState] = useState({ + status: "idle", + }); + const [isChecking, setIsChecking] = useState(false); + + const syncState = useCallback( + async (nextState: ExtensionUpdateState) => { + const usableState = clearInstalledUpdate(nextState, installedVersion); + setUpdateState(usableState); + if (usableState !== nextState) await writeUpdateState(usableState); + }, + [installedVersion], + ); + + useEffect(() => { + let active = true; + + const initialize = async () => { + const storedState = await readUpdateState(); + if (active) await syncState(storedState); + + const checkedState = await checkBrowserStoreForUpdate(installedVersion); + if (active) await syncState(checkedState); + }; + + const handleUpdateAvailable = async (details: { version: string }) => { + const currentState = await readUpdateState(); + const nextState = markExtensionUpdateReady( + currentState, + details.version, + Date.now(), + ); + await writeUpdateState(nextState); + if (active) setUpdateState(nextState); + }; + + const handleStorageChange = ( + changes: Record, + areaName: string, + ) => { + if (areaName !== "local" || !changes[EXTENSION_UPDATE_STORAGE_KEY]) { + return; + } + const nextState = normalizeExtensionUpdateState( + changes[EXTENSION_UPDATE_STORAGE_KEY].newValue, + ); + if (active) setUpdateState(clearInstalledUpdate(nextState, installedVersion)); + }; + + updateRuntime.onUpdateAvailable?.addListener(handleUpdateAvailable); + browser.storage.onChanged.addListener(handleStorageChange); + void initialize(); + + return () => { + active = false; + updateRuntime.onUpdateAvailable?.removeListener(handleUpdateAvailable); + browser.storage.onChanged.removeListener(handleStorageChange); + }; + }, [installedVersion, syncState]); + + const availableVersion = updateState.availableVersion; + const shouldShow = shouldShowExtensionUpdatePrompt( + updateState, + installedVersion, + Date.now(), + ); + if (!shouldShow || !availableVersion) return null; + + const message = formatUpdatePromptCopy( + updateState.status === "ready" ? copy.ready : copy.available, + { current: installedVersion, latest: availableVersion }, + ); + + const handleUpdate = async () => { + if (updateState.status === "ready") { + updateRuntime.reload(); + return; + } + + setIsChecking(true); + const nextState = await checkBrowserStoreForUpdate(installedVersion, true); + setUpdateState(nextState); + setIsChecking(false); + + if (nextState.status === "ready") updateRuntime.reload(); + }; + + const handleLater = async () => { + const nextState = snoozeExtensionUpdate(updateState, Date.now()); + setUpdateState(nextState); + await writeUpdateState(nextState); + }; + + return ( +
+
+ + +
+

{copy.title}

+

{message}

+
+ + +
+
+
+
+ ); +} From 11c4c4d502a985d4432a1e822e0a122e08a09a8c Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Fri, 24 Jul 2026 08:07:08 +0700 Subject: [PATCH 4/6] feat: show extension update prompt below popup header --- src/components/alias/PopupHeader.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/alias/PopupHeader.tsx b/src/components/alias/PopupHeader.tsx index 1a7ab46d..5bbee2c6 100644 --- a/src/components/alias/PopupHeader.tsx +++ b/src/components/alias/PopupHeader.tsx @@ -3,6 +3,7 @@ import { Settings, Sparkles } from "lucide-react"; import { Button } from "src/components/motion/button/base"; import { Tooltip } from "src/components/motion/tooltip"; import { ThemeToggle } from "src/components/motion/theme-toggle"; +import ExtensionUpdateBanner from "./ExtensionUpdateBanner"; import { t } from "../../../lib/i18n"; export interface PopupHeaderProps { @@ -99,6 +100,7 @@ export default function PopupHeader({ /> + ); } From dc1567b26faf27344b810c1dc099db653308a569 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Fri, 24 Jul 2026 08:07:39 +0700 Subject: [PATCH 5/6] test: cover extension update prompt behavior --- tests/utils/extensionUpdate.test.ts | 163 ++++++++++++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 tests/utils/extensionUpdate.test.ts diff --git a/tests/utils/extensionUpdate.test.ts b/tests/utils/extensionUpdate.test.ts new file mode 100644 index 00000000..07d3572a --- /dev/null +++ b/tests/utils/extensionUpdate.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it } from "vitest"; +import { + applyRuntimeUpdateCheckResult, + compareExtensionVersions, + isExtensionVersionNewer, + markExtensionUpdateReady, + normalizeExtensionUpdateState, + shouldCheckForExtensionUpdate, + shouldShowExtensionUpdatePrompt, + snoozeExtensionUpdate, + UPDATE_CHECK_INTERVAL_MS, + UPDATE_SNOOZE_MS, + type ExtensionUpdateState, +} from "../../src/utils/extensionUpdate"; + +describe("extension update versions", () => { + it("compares browser extension versions", () => { + expect(compareExtensionVersions("1.3.2", "1.3.1")).toBeGreaterThan(0); + expect(compareExtensionVersions("v1.3.2", "1.3.2")).toBe(0); + expect(compareExtensionVersions("1.3.2", "1.3.2-beta.1")).toBeGreaterThan( + 0, + ); + expect(compareExtensionVersions("1.3.2-beta.2", "1.3.2-beta.10")).toBeLessThan( + 0, + ); + expect(isExtensionVersionNewer("1.4.0", "1.3.2")).toBe(true); + expect(isExtensionVersionNewer("1.3.2", "1.3.2")).toBe(false); + }); + + it("normalizes untrusted persisted state", () => { + expect( + normalizeExtensionUpdateState({ + status: "ready", + availableVersion: "v1.4.0", + lastCheckedAt: 123, + dismissedUntil: 456, + }), + ).toEqual({ + status: "ready", + availableVersion: "1.4.0", + lastCheckedAt: 123, + dismissedVersion: undefined, + dismissedUntil: 456, + }); + expect(normalizeExtensionUpdateState({ status: "unexpected" })).toEqual({ + status: "idle", + lastCheckedAt: undefined, + availableVersion: undefined, + dismissedVersion: undefined, + dismissedUntil: undefined, + }); + }); +}); + +describe("extension update checks", () => { + const now = 1_000_000; + + it("limits automatic checks to once per day but permits a user retry", () => { + expect(shouldCheckForExtensionUpdate({ status: "idle" }, now)).toBe(true); + expect( + shouldCheckForExtensionUpdate( + { status: "idle", lastCheckedAt: now - UPDATE_CHECK_INTERVAL_MS + 1 }, + now, + ), + ).toBe(false); + expect( + shouldCheckForExtensionUpdate( + { status: "idle", lastCheckedAt: now - UPDATE_CHECK_INTERVAL_MS }, + now, + ), + ).toBe(true); + expect( + shouldCheckForExtensionUpdate( + { status: "idle", lastCheckedAt: now }, + now, + true, + ), + ).toBe(true); + }); + + it("stores available, ready, throttled, and no-update results", () => { + const initial: ExtensionUpdateState = { status: "idle" }; + const available = applyRuntimeUpdateCheckResult( + initial, + { status: "update_available", version: "1.4.0" }, + now, + ); + expect(available).toMatchObject({ + status: "available", + availableVersion: "1.4.0", + lastCheckedAt: now, + }); + + expect(markExtensionUpdateReady(available, "1.4.0", now + 10)).toMatchObject({ + status: "ready", + availableVersion: "1.4.0", + lastCheckedAt: now + 10, + }); + + expect( + applyRuntimeUpdateCheckResult( + available, + { status: "throttled" }, + now + 20, + ), + ).toMatchObject({ + status: "available", + availableVersion: "1.4.0", + lastCheckedAt: now + 20, + }); + + expect( + applyRuntimeUpdateCheckResult( + available, + { status: "no_update" }, + now + 30, + ), + ).toEqual({ status: "idle", lastCheckedAt: now + 30 }); + }); +}); + +describe("extension update prompt visibility", () => { + const now = 5_000_000; + const available: ExtensionUpdateState = { + status: "available", + availableVersion: "1.4.0", + lastCheckedAt: now, + }; + + it("shows only for a newer Store version", () => { + expect(shouldShowExtensionUpdatePrompt(available, "1.3.2", now)).toBe(true); + expect(shouldShowExtensionUpdatePrompt(available, "1.4.0", now)).toBe(false); + expect( + shouldShowExtensionUpdatePrompt( + { status: "idle", availableVersion: "1.4.0" }, + "1.3.2", + now, + ), + ).toBe(false); + }); + + it("snoozes one version for seven days and shows a newer version immediately", () => { + const snoozed = snoozeExtensionUpdate(available, now); + expect(snoozed.dismissedUntil).toBe(now + UPDATE_SNOOZE_MS); + expect(shouldShowExtensionUpdatePrompt(snoozed, "1.3.2", now + 1)).toBe( + false, + ); + expect( + shouldShowExtensionUpdatePrompt( + { ...snoozed, availableVersion: "1.5.0" }, + "1.3.2", + now + 1, + ), + ).toBe(true); + expect( + shouldShowExtensionUpdatePrompt( + snoozed, + "1.3.2", + now + UPDATE_SNOOZE_MS, + ), + ).toBe(true); + }); +}); From 8c8694cb8e3bebebe349fbe820f0e9d75dfe7249 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Fri, 24 Jul 2026 08:07:53 +0700 Subject: [PATCH 6/6] test: cover update prompt localization --- tests/utils/updatePromptI18n.test.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 tests/utils/updatePromptI18n.test.ts diff --git a/tests/utils/updatePromptI18n.test.ts b/tests/utils/updatePromptI18n.test.ts new file mode 100644 index 00000000..b2332e0a --- /dev/null +++ b/tests/utils/updatePromptI18n.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { + formatUpdatePromptCopy, + getUpdatePromptCopy, +} from "../../src/i18n/updatePrompt"; + +describe("update prompt localization", () => { + it("resolves supported regional locales and falls back to English", () => { + expect(getUpdatePromptCopy("vi-VN").title).toBe("Có bản cập nhật"); + expect(getUpdatePromptCopy("pt-BR").title).toBe("Atualização disponível"); + expect(getUpdatePromptCopy("zh-CN").title).toBe("有可用更新"); + expect(getUpdatePromptCopy("unknown").title).toBe("Update available"); + }); + + it("formats installed and Store versions", () => { + expect( + formatUpdatePromptCopy("v{current} -> v{latest}", { + current: "1.3.1", + latest: "1.3.2", + }), + ).toBe("v1.3.1 -> v1.3.2"); + }); +});