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> = {}): 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<void> | 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); });});
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> = {}): 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<void> | 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);
});
});