import { describe, expect, it, vi } from "vite-plus/test"; import { createAppUpdateLaunchCheck, registerHiddenUpdateTap, runAppUpdateCheck, type AppUpdateCheckState, type AppUpdateClient, } from "./app-updates"; vi.mock("expo-updates", () => ({ isEnabled: true, checkForUpdateAsync: vi.fn(), fetchUpdateAsync: vi.fn(), reloadAsync: vi.fn(), })); function makeUpdateClient(overrides: Partial = {}): AppUpdateClient { return { isEnabled: true, checkForUpdateAsync: vi.fn(async () => ({ isAvailable: false, isRollBackToEmbedded: false, })), fetchUpdateAsync: vi.fn(async () => ({ isNew: true, isRollBackToEmbedded: false, })), reloadAsync: vi.fn(async () => {}), ...overrides, }; } describe("runAppUpdateCheck", () => { it("does nothing while running from the Metro development server", async () => { vi.stubGlobal("__DEV__", true); const client = makeUpdateClient(); try { await runAppUpdateCheck({ client }); } finally { vi.unstubAllGlobals(); } expect(client.checkForUpdateAsync).not.toHaveBeenCalled(); }); it("downloads and restarts when a new update is available", async () => { const client = makeUpdateClient({ checkForUpdateAsync: vi.fn(async () => ({ isAvailable: true, isRollBackToEmbedded: false, })), }); const states: AppUpdateCheckState[] = []; await runAppUpdateCheck({ client, onStateChange: (state) => states.push(state) }); expect(client.checkForUpdateAsync).toHaveBeenCalledOnce(); expect(client.fetchUpdateAsync).toHaveBeenCalledOnce(); expect(client.reloadAsync).toHaveBeenCalledOnce(); expect(states).toEqual(["checking", "downloading", "restarting"]); }); it("restarts into the embedded bundle for a rollback directive", async () => { const client = makeUpdateClient({ checkForUpdateAsync: vi.fn(async () => ({ isAvailable: false, isRollBackToEmbedded: true, })), fetchUpdateAsync: vi.fn(async () => ({ isNew: false, isRollBackToEmbedded: true, })), }); await runAppUpdateCheck({ client }); expect(client.fetchUpdateAsync).toHaveBeenCalledOnce(); expect(client.reloadAsync).toHaveBeenCalledOnce(); }); it("stops quietly when the running bundle is current", async () => { const client = makeUpdateClient(); const states: AppUpdateCheckState[] = []; await runAppUpdateCheck({ client, onStateChange: (state) => states.push(state) }); expect(client.fetchUpdateAsync).not.toHaveBeenCalled(); expect(client.reloadAsync).not.toHaveBeenCalled(); expect(states).toEqual(["checking", "current"]); }); it("reports manual failures without continuing the update", async () => { const reportError = vi.spyOn(console, "error").mockImplementation(() => {}); const client = makeUpdateClient({ checkForUpdateAsync: vi.fn(async () => { throw new Error("offline"); }), }); const failures: string[] = []; const states: AppUpdateCheckState[] = []; await runAppUpdateCheck({ client, onFailure: (message) => failures.push(message), onStateChange: (state) => states.push(state), }); expect(client.fetchUpdateAsync).not.toHaveBeenCalled(); expect(failures).toEqual(["offline"]); expect(states).toEqual(["checking", "idle"]); reportError.mockRestore(); }); it.each(["ERR_NOT_AVAILABLE_IN_DEV_CLIENT", "ERR_UPDATES_DISABLED"])( "treats Expo's %s failure as an unavailable update check", async (code) => { const reportError = vi.spyOn(console, "error").mockImplementation(() => {}); const error = Object.assign(new Error("Updates are unavailable"), { code }); const client = makeUpdateClient({ checkForUpdateAsync: vi.fn(async () => { throw error; }), }); const failures: string[] = []; const states: AppUpdateCheckState[] = []; await runAppUpdateCheck({ client, onFailure: (message) => failures.push(message), onStateChange: (state) => states.push(state), }); expect(reportError).not.toHaveBeenCalled(); expect(failures).toEqual([]); expect(states).toEqual(["checking", "idle"]); reportError.mockRestore(); }, ); it("coalesces overlapping launch and manual checks", async () => { let resolveCheck!: (result: { readonly isAvailable: boolean; readonly isRollBackToEmbedded: boolean; }) => void; const checkResult = new Promise<{ readonly isAvailable: boolean; readonly isRollBackToEmbedded: boolean; }>((resolve) => { resolveCheck = resolve; }); const client = makeUpdateClient({ checkForUpdateAsync: vi.fn(() => checkResult), }); const checkOnLaunch = createAppUpdateLaunchCheck(client); const manualStates: AppUpdateCheckState[] = []; const launchCheck = checkOnLaunch(); const manualCheck = runAppUpdateCheck({ client, onStateChange: (state) => manualStates.push(state), }); expect(client.checkForUpdateAsync).toHaveBeenCalledOnce(); expect(manualStates).toEqual(["checking"]); resolveCheck({ isAvailable: false, isRollBackToEmbedded: false, }); await Promise.all([launchCheck, manualCheck]); expect(manualStates).toEqual(["checking", "current"]); await runAppUpdateCheck({ client }); expect(client.checkForUpdateAsync).toHaveBeenCalledTimes(2); }); it("forwards failures to a manual check coalesced with the launch check", async () => { const reportError = vi.spyOn(console, "error").mockImplementation(() => {}); let rejectCheck!: (error: Error) => void; const checkResult = new Promise<{ readonly isAvailable: boolean; readonly isRollBackToEmbedded: boolean; }>((_resolve, reject) => { rejectCheck = reject; }); const client = makeUpdateClient({ checkForUpdateAsync: vi.fn(() => checkResult), }); const checkOnLaunch = createAppUpdateLaunchCheck(client); const failures: string[] = []; const manualStates: AppUpdateCheckState[] = []; const launchCheck = checkOnLaunch(); const manualCheck = runAppUpdateCheck({ client, onFailure: (message) => failures.push(message), onStateChange: (state) => manualStates.push(state), }); rejectCheck(new Error("offline")); await Promise.all([launchCheck, manualCheck]); expect(client.checkForUpdateAsync).toHaveBeenCalledOnce(); expect(failures).toEqual(["offline"]); expect(manualStates).toEqual(["checking", "idle"]); reportError.mockRestore(); }); it("publishes the in-flight check before a state callback can re-enter", async () => { let resolveCheck!: (result: { readonly isAvailable: boolean; readonly isRollBackToEmbedded: boolean; }) => void; const checkResult = new Promise<{ readonly isAvailable: boolean; readonly isRollBackToEmbedded: boolean; }>((resolve) => { resolveCheck = resolve; }); const client = makeUpdateClient({ checkForUpdateAsync: vi.fn(() => checkResult), }); const reentrantStates: AppUpdateCheckState[] = []; let reentrantCheck: Promise | undefined; let didReenter = false; const initialCheck = runAppUpdateCheck({ client, onStateChange: (state) => { if (state !== "checking" || didReenter) return; didReenter = true; reentrantCheck = runAppUpdateCheck({ client, onStateChange: (reentrantState) => reentrantStates.push(reentrantState), }); }, }); expect(client.checkForUpdateAsync).toHaveBeenCalledOnce(); expect(reentrantCheck).toBeDefined(); expect(reentrantStates).toEqual(["checking"]); resolveCheck({ isAvailable: false, isRollBackToEmbedded: false, }); await Promise.all([initialCheck, reentrantCheck]); expect(client.checkForUpdateAsync).toHaveBeenCalledOnce(); expect(reentrantStates).toEqual(["checking", "current"]); }); }); describe("createAppUpdateLaunchCheck", () => { it("checks at most once for each JavaScript launch", async () => { const client = makeUpdateClient(); const checkOnLaunch = createAppUpdateLaunchCheck(client); const first = checkOnLaunch(); const second = checkOnLaunch(); await first; expect(second).toBeUndefined(); expect(client.checkForUpdateAsync).toHaveBeenCalledOnce(); }); it("does nothing when Expo updates are disabled", () => { const client = makeUpdateClient({ isEnabled: false }); const checkOnLaunch = createAppUpdateLaunchCheck(client); expect(checkOnLaunch()).toBeUndefined(); expect(client.checkForUpdateAsync).not.toHaveBeenCalled(); }); }); describe("registerHiddenUpdateTap", () => { it("unlocks the manual check on the fifth tap", () => { let count = 0; for (let tap = 1; tap <= 5; tap += 1) { const result = registerHiddenUpdateTap(count); expect(result.shouldCheck).toBe(tap === 5); count = result.nextCount; } expect(count).toBe(0); }); });