1import { AppState } from "react-native";
2import { describe, expect, it, vi } from "vite-plus/test";
3
4import {
5 createAppUpdateDeferral,
6 installAppUpdateChecks,
7 runAppUpdateCheck,
8 type AppUpdateCheckState,
9 type AppUpdateClient,
10 type AppUpdateEnvironment,
11} from "./app-updates";
12
13vi.mock("expo-updates", () => ({
14 isEnabled: true,
15 checkForUpdateAsync: vi.fn(),
16 fetchUpdateAsync: vi.fn(),
17 reloadAsync: vi.fn(),
18}));
19
20vi.mock("react-native", () => ({
21 AppState: {
22 currentState: "active",
23 addEventListener: vi.fn(() => ({ remove: vi.fn() })),
24 },
25}));
26
27function makeUpdateClient(overrides: Partial<AppUpdateClient> = {}): AppUpdateClient {
28 return {
29 isEnabled: true,
30 checkForUpdateAsync: vi.fn(async () => ({
31 isAvailable: false,
32 isRollBackToEmbedded: false,
33 })),
34 fetchUpdateAsync: vi.fn(async () => ({
35 isNew: true,
36 isRollBackToEmbedded: false,
37 })),
38 reloadAsync: vi.fn(async () => {}),
39 ...overrides,
40 };
41}
42
43function makeUpdateEnvironment(overrides: Partial<AppUpdateEnvironment> = {}): {
44 readonly backgroundCallbacks: Array<() => void>;
45 readonly environment: AppUpdateEnvironment;
46 readonly foregroundStayCallbacks: Array<() => void>;
47} {
48 const backgroundCallbacks: Array<() => void> = [];
49 const foregroundStayCallbacks: Array<() => void> = [];
50 return {
51 backgroundCallbacks,
52 foregroundStayCallbacks,
53 environment: {
54 confirmInstallNow: vi.fn(async () => true),
55 flushPendingWrites: vi.fn(async () => {}),
56 isSafeToRestartInBackground: vi.fn(async () => true),
57 onNextBackground: vi.fn((apply: () => void, _includeCurrent: boolean) => {
58 backgroundCallbacks.push(apply);
59 }),
60 onForegroundStay: vi.fn((apply: () => void) => {
61 foregroundStayCallbacks.push(apply);
62 }),
63 ...overrides,
64 },
65 };
66}
67
68function makeAvailableUpdateClient(overrides: Partial<AppUpdateClient> = {}): AppUpdateClient {
69 return makeUpdateClient({
70 checkForUpdateAsync: vi.fn(async () => ({
71 isAvailable: true,
72 isRollBackToEmbedded: false,
73 })),
74 ...overrides,
75 });
76}
77
78describe("runAppUpdateCheck", () => {
79 it("flushes pending writes before restarting", async () => {
80 const client = makeAvailableUpdateClient();
81 const { environment } = makeUpdateEnvironment();
82
83 await runAppUpdateCheck({
84 applyMode: "immediate",
85 client,
86 deferral: createAppUpdateDeferral(),
87 environment,
88 });
89
90 const flushOrder = vi.mocked(environment.flushPendingWrites).mock.invocationCallOrder[0]!;
91 const reloadOrder = vi.mocked(client.reloadAsync).mock.invocationCallOrder[0]!;
92 expect(flushOrder).toBeLessThan(reloadOrder);
93 });
94
95 it("restarts into an already-downloaded update when the user asks to install", async () => {
96 const client = makeUpdateClient();
97 const { environment } = makeUpdateEnvironment();
98 const deferral = createAppUpdateDeferral();
99 deferral.pendingInstall = true;
100
101 await runAppUpdateCheck({ applyMode: "immediate", client, deferral, environment });
102
103 expect(client.checkForUpdateAsync).not.toHaveBeenCalled();
104 expect(client.reloadAsync).toHaveBeenCalledOnce();
105 });
106
107 it("honors an immediate request that joined an in-flight background check", async () => {
108 let resolveCheck!: (result: {
109 readonly isAvailable: boolean;
110 readonly isRollBackToEmbedded: boolean;
111 }) => void;
112 const checkResult = new Promise<{
113 readonly isAvailable: boolean;
114 readonly isRollBackToEmbedded: boolean;
115 }>((resolve) => {
116 resolveCheck = resolve;
117 });
118 const client = makeUpdateClient({
119 checkForUpdateAsync: vi.fn(() => checkResult),
120 });
121 const { environment } = makeUpdateEnvironment();
122 const deferral = createAppUpdateDeferral();
123
124 const backgroundCheck = runAppUpdateCheck({ client, deferral, environment });
125 const manualCheck = runAppUpdateCheck({
126 applyMode: "immediate",
127 client,
128 deferral,
129 environment,
130 });
131
132 resolveCheck({ isAvailable: true, isRollBackToEmbedded: false });
133 await Promise.all([backgroundCheck, manualCheck]);
134
135 expect(client.checkForUpdateAsync).toHaveBeenCalledOnce();
136 expect(client.reloadAsync).toHaveBeenCalledOnce();
137 });
138
139 it("holds the deferred restart and re-arms when the pre-restart flush fails", async () => {
140 const reportError = vi.spyOn(console, "error").mockImplementation(() => {});
141 const client = makeAvailableUpdateClient();
142 const { backgroundCallbacks, environment } = makeUpdateEnvironment({
143 flushPendingWrites: vi.fn(async () => {
144 throw new Error("disk full");
145 }),
146 });
147 const deferral = createAppUpdateDeferral();
148
149 await runAppUpdateCheck({ client, deferral, environment });
150 backgroundCallbacks[0]!();
151
152 await vi.waitFor(() => expect(backgroundCallbacks).toHaveLength(2));
153 expect(client.reloadAsync).not.toHaveBeenCalled();
154 expect(deferral.pendingInstall).toBe(true);
155 reportError.mockRestore();
156 });
157
158 it("still restarts a user-requested install when the flush fails", async () => {
159 const reportError = vi.spyOn(console, "error").mockImplementation(() => {});
160 const client = makeAvailableUpdateClient();
161 const { environment } = makeUpdateEnvironment({
162 flushPendingWrites: vi.fn(async () => {
163 throw new Error("storage unavailable");
164 }),
165 });
166
167 await runAppUpdateCheck({
168 applyMode: "immediate",
169 client,
170 deferral: createAppUpdateDeferral(),
171 environment,
172 });
173
174 expect(client.reloadAsync).toHaveBeenCalledOnce();
175 reportError.mockRestore();
176 });
177
178 it("restarts into the embedded bundle for a rollback directive", async () => {
179 const client = makeUpdateClient({
180 checkForUpdateAsync: vi.fn(async () => ({
181 isAvailable: false,
182 isRollBackToEmbedded: true,
183 })),
184 fetchUpdateAsync: vi.fn(async () => ({
185 isNew: false,
186 isRollBackToEmbedded: true,
187 })),
188 });
189 const { environment } = makeUpdateEnvironment();
190
191 await runAppUpdateCheck({ client, deferral: createAppUpdateDeferral(), environment });
192
193 expect(client.fetchUpdateAsync).toHaveBeenCalledOnce();
194 expect(environment.confirmInstallNow).not.toHaveBeenCalled();
195 expect(client.reloadAsync).toHaveBeenCalledOnce();
196 });
197
198 it.each(["ERR_NOT_AVAILABLE_IN_DEV_CLIENT", "ERR_UPDATES_DISABLED"])(
199 "treats Expo's %s failure as an unavailable update check",
200 async (code) => {
201 const reportError = vi.spyOn(console, "error").mockImplementation(() => {});
202 const error = Object.assign(new Error("Updates are unavailable"), { code });
203 const client = makeUpdateClient({
204 checkForUpdateAsync: vi.fn(async () => {
205 throw error;
206 }),
207 });
208 const failures: string[] = [];
209 const states: AppUpdateCheckState[] = [];
210
211 await runAppUpdateCheck({
212 client,
213 onFailure: (message) => failures.push(message),
214 onStateChange: (state) => states.push(state),
215 });
216
217 expect(reportError).not.toHaveBeenCalled();
218 expect(failures).toEqual([]);
219 expect(states).toEqual(["checking", "idle"]);
220 reportError.mockRestore();
221 },
222 );
223
224 it("coalesces overlapping launch and manual checks", async () => {
225 let resolveCheck!: (result: {
226 readonly isAvailable: boolean;
227 readonly isRollBackToEmbedded: boolean;
228 }) => void;
229 const checkResult = new Promise<{
230 readonly isAvailable: boolean;
231 readonly isRollBackToEmbedded: boolean;
232 }>((resolve) => {
233 resolveCheck = resolve;
234 });
235 const client = makeUpdateClient({
236 checkForUpdateAsync: vi.fn(() => checkResult),
237 });
238 const manualStates: AppUpdateCheckState[] = [];
239
240 const launchCheck = runAppUpdateCheck({ client });
241 const manualCheck = runAppUpdateCheck({
242 client,
243 onStateChange: (state) => manualStates.push(state),
244 });
245
246 expect(client.checkForUpdateAsync).toHaveBeenCalledOnce();
247 expect(manualStates).toEqual(["checking"]);
248
249 resolveCheck({
250 isAvailable: false,
251 isRollBackToEmbedded: false,
252 });
253 await Promise.all([launchCheck, manualCheck]);
254
255 expect(manualStates).toEqual(["checking", "current"]);
256
257 await runAppUpdateCheck({ client });
258 expect(client.checkForUpdateAsync).toHaveBeenCalledTimes(2);
259 });
260
261 it("publishes the in-flight check before a state callback can re-enter", async () => {
262 let resolveCheck!: (result: {
263 readonly isAvailable: boolean;
264 readonly isRollBackToEmbedded: boolean;
265 }) => void;
266 const checkResult = new Promise<{
267 readonly isAvailable: boolean;
268 readonly isRollBackToEmbedded: boolean;
269 }>((resolve) => {
270 resolveCheck = resolve;
271 });
272 const client = makeUpdateClient({
273 checkForUpdateAsync: vi.fn(() => checkResult),
274 });
275 const reentrantStates: AppUpdateCheckState[] = [];
276 let reentrantCheck: Promise<void> | undefined;
277 let didReenter = false;
278
279 const initialCheck = runAppUpdateCheck({
280 client,
281 onStateChange: (state) => {
282 if (state !== "checking" || didReenter) return;
283 didReenter = true;
284 reentrantCheck = runAppUpdateCheck({
285 client,
286 onStateChange: (reentrantState) => reentrantStates.push(reentrantState),
287 });
288 },
289 });
290
291 expect(client.checkForUpdateAsync).toHaveBeenCalledOnce();
292 expect(reentrantCheck).toBeDefined();
293 expect(reentrantStates).toEqual(["checking"]);
294
295 resolveCheck({
296 isAvailable: false,
297 isRollBackToEmbedded: false,
298 });
299 await Promise.all([initialCheck, reentrantCheck]);
300
301 expect(client.checkForUpdateAsync).toHaveBeenCalledOnce();
302 expect(reentrantStates).toEqual(["checking", "current"]);
303 });
304});
305
306describe("installAppUpdateChecks", () => {
307 it("checks once at launch and stops listening for foregrounds when disposed", () => {
308 const remove = vi.fn();
309 vi.mocked(AppState.addEventListener).mockReturnValueOnce({ remove });
310 const client = makeUpdateClient();
311
312 const dispose = installAppUpdateChecks(client);
313 expect(client.checkForUpdateAsync).toHaveBeenCalledOnce();
314 dispose();
315
316 expect(remove).toHaveBeenCalledOnce();
317 });
318});