653 lines · 22.5 KB · TypeScript
RawDownload
import { describe, expect, it, vi } from "vite-plus/test"; import {  createAppUpdateDeferral,  createAppUpdateLaunchCheck,  FOREGROUND_APP_UPDATE_RECHECK_AFTER_MS,  registerHiddenUpdateTap,  runAppUpdateCheck,  shouldRecheckAppUpdateOnForeground,  type AppUpdateCheckState,  type AppUpdateClient,  type AppUpdateEnvironment,} 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,  };} function makeUpdateEnvironment(overrides: Partial<AppUpdateEnvironment> = {}): {  readonly backgroundCallbacks: Array<() => void>;  readonly environment: AppUpdateEnvironment;  readonly foregroundStayCallbacks: Array<() => void>;} {  const backgroundCallbacks: Array<() => void> = [];  const foregroundStayCallbacks: Array<() => void> = [];  return {    backgroundCallbacks,    foregroundStayCallbacks,    environment: {      confirmInstallNow: vi.fn(async () => true),      flushPendingWrites: vi.fn(async () => {}),      isSafeToRestartInBackground: vi.fn(async () => true),      onNextBackground: vi.fn((apply: () => void, _includeCurrent: boolean) => {        backgroundCallbacks.push(apply);      }),      onForegroundStay: vi.fn((apply: () => void) => {        foregroundStayCallbacks.push(apply);      }),      ...overrides,    },  };} function makeAvailableUpdateClient(overrides: Partial<AppUpdateClient> = {}): AppUpdateClient {  return makeUpdateClient({    checkForUpdateAsync: vi.fn(async () => ({      isAvailable: true,      isRollBackToEmbedded: false,    })),    ...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 silently and installs at the next backgrounding", async () => {    const client = makeAvailableUpdateClient();    const { backgroundCallbacks, environment } = makeUpdateEnvironment();    const deferral = createAppUpdateDeferral();    const states: AppUpdateCheckState[] = [];     await runAppUpdateCheck({      client,      deferral,      environment,      onStateChange: (state) => states.push(state),    });     expect(client.checkForUpdateAsync).toHaveBeenCalledOnce();    expect(client.fetchUpdateAsync).toHaveBeenCalledOnce();    expect(environment.confirmInstallNow).not.toHaveBeenCalled();    expect(client.reloadAsync).not.toHaveBeenCalled();    expect(states).toEqual(["checking", "downloading", "ready"]);    expect(deferral.pendingInstall).toBe(true);    expect(backgroundCallbacks).toHaveLength(1);     backgroundCallbacks[0]!();    await vi.waitFor(() => expect(client.reloadAsync).toHaveBeenCalledOnce());    expect(environment.flushPendingWrites).toHaveBeenCalled();  });   it("flushes pending writes before restarting", async () => {    const client = makeAvailableUpdateClient();    const { environment } = makeUpdateEnvironment();     await runAppUpdateCheck({      applyMode: "immediate",      client,      deferral: createAppUpdateDeferral(),      environment,    });     const flushOrder = vi.mocked(environment.flushPendingWrites).mock.invocationCallOrder[0]!;    const reloadOrder = vi.mocked(client.reloadAsync).mock.invocationCallOrder[0]!;    expect(flushOrder).toBeLessThan(reloadOrder);  });   it("prompts once the app has stayed foregrounded with the download waiting", async () => {    const client = makeAvailableUpdateClient();    const { environment, foregroundStayCallbacks } = makeUpdateEnvironment();    const deferral = createAppUpdateDeferral();     await runAppUpdateCheck({ client, deferral, environment });    expect(environment.confirmInstallNow).not.toHaveBeenCalled();    expect(foregroundStayCallbacks).toHaveLength(1);     foregroundStayCallbacks[0]!();    await vi.waitFor(() => expect(client.reloadAsync).toHaveBeenCalledOnce());    expect(environment.confirmInstallNow).toHaveBeenCalledOnce();    expect(environment.flushPendingWrites).toHaveBeenCalled();  });   it("keeps the background install armed when the foreground prompt is declined", async () => {    const client = makeAvailableUpdateClient();    const { backgroundCallbacks, environment, foregroundStayCallbacks } = makeUpdateEnvironment({      confirmInstallNow: vi.fn(async () => false),    });    const deferral = createAppUpdateDeferral();     await runAppUpdateCheck({ client, deferral, environment });     foregroundStayCallbacks[0]!();    await vi.waitFor(() => expect(environment.confirmInstallNow).toHaveBeenCalledOnce());    expect(client.reloadAsync).not.toHaveBeenCalled();    expect(deferral.pendingInstall).toBe(true);     backgroundCallbacks[0]!();    await vi.waitFor(() => expect(client.reloadAsync).toHaveBeenCalledOnce());  });   it("skips the foreground prompt once the install is no longer pending", async () => {    const client = makeAvailableUpdateClient();    const { environment, foregroundStayCallbacks } = makeUpdateEnvironment();    const deferral = createAppUpdateDeferral();     await runAppUpdateCheck({ client, deferral, environment });     // A failed deferred reload resets the deferral before the stay fires.    deferral.pendingInstall = false;    foregroundStayCallbacks[0]!();     expect(environment.confirmInstallNow).not.toHaveBeenCalled();    expect(client.reloadAsync).not.toHaveBeenCalled();  });   it("re-arms instead of restarting when the app is no longer safely backgrounded", async () => {    const client = makeAvailableUpdateClient();    const safe = vi.fn(async () => false);    const { backgroundCallbacks, environment } = makeUpdateEnvironment({      isSafeToRestartInBackground: safe,    });    const deferral = createAppUpdateDeferral();     await runAppUpdateCheck({ client, deferral, environment });    expect(backgroundCallbacks).toHaveLength(1);    // Arming may fire for an already-backgrounded app…    expect(vi.mocked(environment.onNextBackground).mock.calls[0]![1]).toBe(true);     backgroundCallbacks[0]!();    await vi.waitFor(() => expect(backgroundCallbacks).toHaveLength(2));    expect(client.reloadAsync).not.toHaveBeenCalled();    expect(deferral.pendingInstall).toBe(true);    // …but a re-arm must wait for a fresh transition, or an unsafe attempt    // would retry in a tight loop within the same background session.    expect(vi.mocked(environment.onNextBackground).mock.calls[1]![1]).toBe(false);     safe.mockResolvedValue(true);    backgroundCallbacks[1]!();    await vi.waitFor(() => expect(client.reloadAsync).toHaveBeenCalledOnce());  });   it("resets the deferral when the deferred restart fails", async () => {    const reportError = vi.spyOn(console, "error").mockImplementation(() => {});    const client = makeAvailableUpdateClient({      reloadAsync: vi.fn(async () => {        throw new Error("reload rejected");      }),    });    const { backgroundCallbacks, environment } = makeUpdateEnvironment();    const deferral = createAppUpdateDeferral();     await runAppUpdateCheck({ client, deferral, environment });    backgroundCallbacks[0]!();     await vi.waitFor(() => expect(deferral.pendingInstall).toBe(false));    reportError.mockRestore();  });   it("arms the deferred install once across repeated checks", async () => {    const client = makeAvailableUpdateClient();    const { environment } = makeUpdateEnvironment();    const deferral = createAppUpdateDeferral();     await runAppUpdateCheck({ client, deferral, environment });    await runAppUpdateCheck({ client, deferral, environment });     expect(environment.onNextBackground).toHaveBeenCalledOnce();    expect(environment.onForegroundStay).toHaveBeenCalledOnce();  });   it("restarts into an already-downloaded update when the user asks to install", async () => {    const client = makeUpdateClient();    const { environment } = makeUpdateEnvironment();    const deferral = createAppUpdateDeferral();    deferral.pendingInstall = true;     await runAppUpdateCheck({ applyMode: "immediate", client, deferral, environment });     expect(client.checkForUpdateAsync).not.toHaveBeenCalled();    expect(client.reloadAsync).toHaveBeenCalledOnce();  });   it("honors an immediate request that joined an in-flight background check", 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 { environment } = makeUpdateEnvironment();    const deferral = createAppUpdateDeferral();     const backgroundCheck = runAppUpdateCheck({ client, deferral, environment });    const manualCheck = runAppUpdateCheck({      applyMode: "immediate",      client,      deferral,      environment,    });     resolveCheck({ isAvailable: true, isRollBackToEmbedded: false });    await Promise.all([backgroundCheck, manualCheck]);     // The coalesced background check deferred the download, but the manual    // caller explicitly asked to install, so the restart happens anyway.    expect(client.checkForUpdateAsync).toHaveBeenCalledOnce();    expect(client.reloadAsync).toHaveBeenCalledOnce();  });   it("runs a single restart when the deferred install races the foreground prompt", async () => {    const client = makeAvailableUpdateClient();    let releaseFlush!: () => void;    const blockedFlush = new Promise<void>((resolve) => {      releaseFlush = resolve;    });    const flushPendingWrites = vi.fn(async (): Promise<void> => {});    const { backgroundCallbacks, environment, foregroundStayCallbacks } = makeUpdateEnvironment({      flushPendingWrites,    });    const deferral = createAppUpdateDeferral();     await runAppUpdateCheck({ client, deferral, environment });    flushPendingWrites.mockReturnValue(blockedFlush);     // The deferred install starts and blocks on its flush; the foreground    // prompt firing in that window must not begin a second restart.    backgroundCallbacks[0]!();    await vi.waitFor(() => expect(flushPendingWrites).toHaveBeenCalledOnce());    foregroundStayCallbacks[0]!();    await new Promise((resolve) => setTimeout(resolve, 0));    expect(environment.confirmInstallNow).not.toHaveBeenCalled();     releaseFlush();    await vi.waitFor(() => expect(client.reloadAsync).toHaveBeenCalledOnce());  });   it("holds the deferred restart and re-arms when the pre-restart flush fails", async () => {    const reportError = vi.spyOn(console, "error").mockImplementation(() => {});    const client = makeAvailableUpdateClient();    const { backgroundCallbacks, environment } = makeUpdateEnvironment({      flushPendingWrites: vi.fn(async () => {        throw new Error("disk full");      }),    });    const deferral = createAppUpdateDeferral();     await runAppUpdateCheck({ client, deferral, environment });    backgroundCallbacks[0]!();     await vi.waitFor(() => expect(backgroundCallbacks).toHaveLength(2));    expect(client.reloadAsync).not.toHaveBeenCalled();    expect(deferral.pendingInstall).toBe(true);    reportError.mockRestore();  });   it("restarts without prompting when the caller asked for an immediate install", async () => {    const client = makeAvailableUpdateClient();    const { environment } = makeUpdateEnvironment();    const states: AppUpdateCheckState[] = [];     await runAppUpdateCheck({      applyMode: "immediate",      client,      deferral: createAppUpdateDeferral(),      environment,      onStateChange: (state) => states.push(state),    });     expect(environment.confirmInstallNow).not.toHaveBeenCalled();    expect(client.reloadAsync).toHaveBeenCalledOnce();    expect(states).toEqual(["checking", "downloading", "restarting"]);  });   it("holds an automatic rollback restart when the flush fails and re-arms it", async () => {    const reportError = vi.spyOn(console, "error").mockImplementation(() => {});    const client = makeUpdateClient({      checkForUpdateAsync: vi.fn(async () => ({        isAvailable: false,        isRollBackToEmbedded: true,      })),      fetchUpdateAsync: vi.fn(async () => ({        isNew: false,        isRollBackToEmbedded: true,      })),    });    const flushPendingWrites = vi.fn(async (): Promise<void> => {      throw new Error("storage unavailable");    });    const { backgroundCallbacks, environment } = makeUpdateEnvironment({ flushPendingWrites });    const deferral = createAppUpdateDeferral();     await runAppUpdateCheck({ client, deferral, environment });     // Nobody asked for this restart, so it must not discard the state it    // failed to land; the rollback waits armed for the next backgrounding.    expect(client.reloadAsync).not.toHaveBeenCalled();    expect(deferral.pendingInstall).toBe(true);    expect(backgroundCallbacks).toHaveLength(1);     flushPendingWrites.mockResolvedValue(undefined);    backgroundCallbacks[0]!();    await vi.waitFor(() => expect(client.reloadAsync).toHaveBeenCalledOnce());    reportError.mockRestore();  });   it("still restarts a user-requested install when the flush fails", async () => {    const reportError = vi.spyOn(console, "error").mockImplementation(() => {});    const client = makeAvailableUpdateClient();    const { environment } = makeUpdateEnvironment({      flushPendingWrites: vi.fn(async () => {        throw new Error("storage unavailable");      }),    });     await runAppUpdateCheck({      applyMode: "immediate",      client,      deferral: createAppUpdateDeferral(),      environment,    });     expect(client.reloadAsync).toHaveBeenCalledOnce();    reportError.mockRestore();  });   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,      })),    });    const { environment } = makeUpdateEnvironment();     await runAppUpdateCheck({ client, deferral: createAppUpdateDeferral(), environment });     expect(client.fetchUpdateAsync).toHaveBeenCalledOnce();    // A rollback pulls a broken bundle, so it never waits on the prompt.    expect(environment.confirmInstallNow).not.toHaveBeenCalled();    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({

Lines 1–500 of 653.