1import { describe, expect, it, vi } from "vite-plus/test";
2
3import {
4 createAppUpdateDeferral,
5 createAppUpdateLaunchCheck,
6 FOREGROUND_APP_UPDATE_RECHECK_AFTER_MS,
7 registerHiddenUpdateTap,
8 runAppUpdateCheck,
9 shouldRecheckAppUpdateOnForeground,
10 type AppUpdateCheckState,
11 type AppUpdateClient,
12 type AppUpdateEnvironment,
13} from "./app-updates";
14
15vi.mock("expo-updates", () => ({
16 isEnabled: true,
17 checkForUpdateAsync: vi.fn(),
18 fetchUpdateAsync: vi.fn(),
19 reloadAsync: vi.fn(),
20}));
21
22function makeUpdateClient(overrides: Partial<AppUpdateClient> = {}): AppUpdateClient {
23 return {
24 isEnabled: true,
25 checkForUpdateAsync: vi.fn(async () => ({
26 isAvailable: false,
27 isRollBackToEmbedded: false,
28 })),
29 fetchUpdateAsync: vi.fn(async () => ({
30 isNew: true,
31 isRollBackToEmbedded: false,
32 })),
33 reloadAsync: vi.fn(async () => {}),
34 ...overrides,
35 };
36}
37
38function makeUpdateEnvironment(overrides: Partial<AppUpdateEnvironment> = {}): {
39 readonly backgroundCallbacks: Array<() => void>;
40 readonly environment: AppUpdateEnvironment;
41 readonly foregroundStayCallbacks: Array<() => void>;
42} {
43 const backgroundCallbacks: Array<() => void> = [];
44 const foregroundStayCallbacks: Array<() => void> = [];
45 return {
46 backgroundCallbacks,
47 foregroundStayCallbacks,
48 environment: {
49 confirmInstallNow: vi.fn(async () => true),
50 flushPendingWrites: vi.fn(async () => {}),
51 isSafeToRestartInBackground: vi.fn(async () => true),
52 onNextBackground: vi.fn((apply: () => void, _includeCurrent: boolean) => {
53 backgroundCallbacks.push(apply);
54 }),
55 onForegroundStay: vi.fn((apply: () => void) => {
56 foregroundStayCallbacks.push(apply);
57 }),
58 ...overrides,
59 },
60 };
61}
62
63function makeAvailableUpdateClient(overrides: Partial<AppUpdateClient> = {}): AppUpdateClient {
64 return makeUpdateClient({
65 checkForUpdateAsync: vi.fn(async () => ({
66 isAvailable: true,
67 isRollBackToEmbedded: false,
68 })),
69 ...overrides,
70 });
71}
72
73describe("runAppUpdateCheck", () => {
74 it("does nothing while running from the Metro development server", async () => {
75 vi.stubGlobal("__DEV__", true);
76 const client = makeUpdateClient();
77
78 try {
79 await runAppUpdateCheck({ client });
80 } finally {
81 vi.unstubAllGlobals();
82 }
83
84 expect(client.checkForUpdateAsync).not.toHaveBeenCalled();
85 });
86
87 it("downloads silently and installs at the next backgrounding", async () => {
88 const client = makeAvailableUpdateClient();
89 const { backgroundCallbacks, environment } = makeUpdateEnvironment();
90 const deferral = createAppUpdateDeferral();
91 const states: AppUpdateCheckState[] = [];
92
93 await runAppUpdateCheck({
94 client,
95 deferral,
96 environment,
97 onStateChange: (state) => states.push(state),
98 });
99
100 expect(client.checkForUpdateAsync).toHaveBeenCalledOnce();
101 expect(client.fetchUpdateAsync).toHaveBeenCalledOnce();
102 expect(environment.confirmInstallNow).not.toHaveBeenCalled();
103 expect(client.reloadAsync).not.toHaveBeenCalled();
104 expect(states).toEqual(["checking", "downloading", "ready"]);
105 expect(deferral.pendingInstall).toBe(true);
106 expect(backgroundCallbacks).toHaveLength(1);
107
108 backgroundCallbacks[0]!();
109 await vi.waitFor(() => expect(client.reloadAsync).toHaveBeenCalledOnce());
110 expect(environment.flushPendingWrites).toHaveBeenCalled();
111 });
112
113 it("flushes pending writes before restarting", async () => {
114 const client = makeAvailableUpdateClient();
115 const { environment } = makeUpdateEnvironment();
116
117 await runAppUpdateCheck({
118 applyMode: "immediate",
119 client,
120 deferral: createAppUpdateDeferral(),
121 environment,
122 });
123
124 const flushOrder = vi.mocked(environment.flushPendingWrites).mock.invocationCallOrder[0]!;
125 const reloadOrder = vi.mocked(client.reloadAsync).mock.invocationCallOrder[0]!;
126 expect(flushOrder).toBeLessThan(reloadOrder);
127 });
128
129 it("prompts once the app has stayed foregrounded with the download waiting", async () => {
130 const client = makeAvailableUpdateClient();
131 const { environment, foregroundStayCallbacks } = makeUpdateEnvironment();
132 const deferral = createAppUpdateDeferral();
133
134 await runAppUpdateCheck({ client, deferral, environment });
135 expect(environment.confirmInstallNow).not.toHaveBeenCalled();
136 expect(foregroundStayCallbacks).toHaveLength(1);
137
138 foregroundStayCallbacks[0]!();
139 await vi.waitFor(() => expect(client.reloadAsync).toHaveBeenCalledOnce());
140 expect(environment.confirmInstallNow).toHaveBeenCalledOnce();
141 expect(environment.flushPendingWrites).toHaveBeenCalled();
142 });
143
144 it("keeps the background install armed when the foreground prompt is declined", async () => {
145 const client = makeAvailableUpdateClient();
146 const { backgroundCallbacks, environment, foregroundStayCallbacks } = makeUpdateEnvironment({
147 confirmInstallNow: vi.fn(async () => false),
148 });
149 const deferral = createAppUpdateDeferral();
150
151 await runAppUpdateCheck({ client, deferral, environment });
152
153 foregroundStayCallbacks[0]!();
154 await vi.waitFor(() => expect(environment.confirmInstallNow).toHaveBeenCalledOnce());
155 expect(client.reloadAsync).not.toHaveBeenCalled();
156 expect(deferral.pendingInstall).toBe(true);
157
158 backgroundCallbacks[0]!();
159 await vi.waitFor(() => expect(client.reloadAsync).toHaveBeenCalledOnce());
160 });
161
162 it("skips the foreground prompt once the install is no longer pending", async () => {
163 const client = makeAvailableUpdateClient();
164 const { environment, foregroundStayCallbacks } = makeUpdateEnvironment();
165 const deferral = createAppUpdateDeferral();
166
167 await runAppUpdateCheck({ client, deferral, environment });
168
169 // A failed deferred reload resets the deferral before the stay fires.
170 deferral.pendingInstall = false;
171 foregroundStayCallbacks[0]!();
172
173 expect(environment.confirmInstallNow).not.toHaveBeenCalled();
174 expect(client.reloadAsync).not.toHaveBeenCalled();
175 });
176
177 it("re-arms instead of restarting when the app is no longer safely backgrounded", async () => {
178 const client = makeAvailableUpdateClient();
179 const safe = vi.fn(async () => false);
180 const { backgroundCallbacks, environment } = makeUpdateEnvironment({
181 isSafeToRestartInBackground: safe,
182 });
183 const deferral = createAppUpdateDeferral();
184
185 await runAppUpdateCheck({ client, deferral, environment });
186 expect(backgroundCallbacks).toHaveLength(1);
187 // Arming may fire for an already-backgrounded app…
188 expect(vi.mocked(environment.onNextBackground).mock.calls[0]![1]).toBe(true);
189
190 backgroundCallbacks[0]!();
191 await vi.waitFor(() => expect(backgroundCallbacks).toHaveLength(2));
192 expect(client.reloadAsync).not.toHaveBeenCalled();
193 expect(deferral.pendingInstall).toBe(true);
194 // …but a re-arm must wait for a fresh transition, or an unsafe attempt
195 // would retry in a tight loop within the same background session.
196 expect(vi.mocked(environment.onNextBackground).mock.calls[1]![1]).toBe(false);
197
198 safe.mockResolvedValue(true);
199 backgroundCallbacks[1]!();
200 await vi.waitFor(() => expect(client.reloadAsync).toHaveBeenCalledOnce());
201 });
202
203 it("resets the deferral when the deferred restart fails", async () => {
204 const reportError = vi.spyOn(console, "error").mockImplementation(() => {});
205 const client = makeAvailableUpdateClient({
206 reloadAsync: vi.fn(async () => {
207 throw new Error("reload rejected");
208 }),
209 });
210 const { backgroundCallbacks, environment } = makeUpdateEnvironment();
211 const deferral = createAppUpdateDeferral();
212
213 await runAppUpdateCheck({ client, deferral, environment });
214 backgroundCallbacks[0]!();
215
216 await vi.waitFor(() => expect(deferral.pendingInstall).toBe(false));
217 reportError.mockRestore();
218 });
219
220 it("arms the deferred install once across repeated checks", async () => {
221 const client = makeAvailableUpdateClient();
222 const { environment } = makeUpdateEnvironment();
223 const deferral = createAppUpdateDeferral();
224
225 await runAppUpdateCheck({ client, deferral, environment });
226 await runAppUpdateCheck({ client, deferral, environment });
227
228 expect(environment.onNextBackground).toHaveBeenCalledOnce();
229 expect(environment.onForegroundStay).toHaveBeenCalledOnce();
230 });
231
232 it("restarts into an already-downloaded update when the user asks to install", async () => {
233 const client = makeUpdateClient();
234 const { environment } = makeUpdateEnvironment();
235 const deferral = createAppUpdateDeferral();
236 deferral.pendingInstall = true;
237
238 await runAppUpdateCheck({ applyMode: "immediate", client, deferral, environment });
239
240 expect(client.checkForUpdateAsync).not.toHaveBeenCalled();
241 expect(client.reloadAsync).toHaveBeenCalledOnce();
242 });
243
244 it("honors an immediate request that joined an in-flight background check", async () => {
245 let resolveCheck!: (result: {
246 readonly isAvailable: boolean;
247 readonly isRollBackToEmbedded: boolean;
248 }) => void;
249 const checkResult = new Promise<{
250 readonly isAvailable: boolean;
251 readonly isRollBackToEmbedded: boolean;
252 }>((resolve) => {
253 resolveCheck = resolve;
254 });
255 const client = makeUpdateClient({
256 checkForUpdateAsync: vi.fn(() => checkResult),
257 });
258 const { environment } = makeUpdateEnvironment();
259 const deferral = createAppUpdateDeferral();
260
261 const backgroundCheck = runAppUpdateCheck({ client, deferral, environment });
262 const manualCheck = runAppUpdateCheck({
263 applyMode: "immediate",
264 client,
265 deferral,
266 environment,
267 });
268
269 resolveCheck({ isAvailable: true, isRollBackToEmbedded: false });
270 await Promise.all([backgroundCheck, manualCheck]);
271
272 // The coalesced background check deferred the download, but the manual
273 // caller explicitly asked to install, so the restart happens anyway.
274 expect(client.checkForUpdateAsync).toHaveBeenCalledOnce();
275 expect(client.reloadAsync).toHaveBeenCalledOnce();
276 });
277
278 it("runs a single restart when the deferred install races the foreground prompt", async () => {
279 const client = makeAvailableUpdateClient();
280 let releaseFlush!: () => void;
281 const blockedFlush = new Promise<void>((resolve) => {
282 releaseFlush = resolve;
283 });
284 const flushPendingWrites = vi.fn(async (): Promise<void> => {});
285 const { backgroundCallbacks, environment, foregroundStayCallbacks } = makeUpdateEnvironment({
286 flushPendingWrites,
287 });
288 const deferral = createAppUpdateDeferral();
289
290 await runAppUpdateCheck({ client, deferral, environment });
291 flushPendingWrites.mockReturnValue(blockedFlush);
292
293 // The deferred install starts and blocks on its flush; the foreground
294 // prompt firing in that window must not begin a second restart.
295 backgroundCallbacks[0]!();
296 await vi.waitFor(() => expect(flushPendingWrites).toHaveBeenCalledOnce());
297 foregroundStayCallbacks[0]!();
298 await new Promise((resolve) => setTimeout(resolve, 0));
299 expect(environment.confirmInstallNow).not.toHaveBeenCalled();
300
301 releaseFlush();
302 await vi.waitFor(() => expect(client.reloadAsync).toHaveBeenCalledOnce());
303 });
304
305 it("holds the deferred restart and re-arms when the pre-restart flush fails", async () => {
306 const reportError = vi.spyOn(console, "error").mockImplementation(() => {});
307 const client = makeAvailableUpdateClient();
308 const { backgroundCallbacks, environment } = makeUpdateEnvironment({
309 flushPendingWrites: vi.fn(async () => {
310 throw new Error("disk full");
311 }),
312 });
313 const deferral = createAppUpdateDeferral();
314
315 await runAppUpdateCheck({ client, deferral, environment });
316 backgroundCallbacks[0]!();
317
318 await vi.waitFor(() => expect(backgroundCallbacks).toHaveLength(2));
319 expect(client.reloadAsync).not.toHaveBeenCalled();
320 expect(deferral.pendingInstall).toBe(true);
321 reportError.mockRestore();
322 });
323
324 it("restarts without prompting when the caller asked for an immediate install", async () => {
325 const client = makeAvailableUpdateClient();
326 const { environment } = makeUpdateEnvironment();
327 const states: AppUpdateCheckState[] = [];
328
329 await runAppUpdateCheck({
330 applyMode: "immediate",
331 client,
332 deferral: createAppUpdateDeferral(),
333 environment,
334 onStateChange: (state) => states.push(state),
335 });
336
337 expect(environment.confirmInstallNow).not.toHaveBeenCalled();
338 expect(client.reloadAsync).toHaveBeenCalledOnce();
339 expect(states).toEqual(["checking", "downloading", "restarting"]);
340 });
341
342 it("holds an automatic rollback restart when the flush fails and re-arms it", async () => {
343 const reportError = vi.spyOn(console, "error").mockImplementation(() => {});
344 const client = makeUpdateClient({
345 checkForUpdateAsync: vi.fn(async () => ({
346 isAvailable: false,
347 isRollBackToEmbedded: true,
348 })),
349 fetchUpdateAsync: vi.fn(async () => ({
350 isNew: false,
351 isRollBackToEmbedded: true,
352 })),
353 });
354 const flushPendingWrites = vi.fn(async (): Promise<void> => {
355 throw new Error("storage unavailable");
356 });
357 const { backgroundCallbacks, environment } = makeUpdateEnvironment({ flushPendingWrites });
358 const deferral = createAppUpdateDeferral();
359
360 await runAppUpdateCheck({ client, deferral, environment });
361
362 // Nobody asked for this restart, so it must not discard the state it
363 // failed to land; the rollback waits armed for the next backgrounding.
364 expect(client.reloadAsync).not.toHaveBeenCalled();
365 expect(deferral.pendingInstall).toBe(true);
366 expect(backgroundCallbacks).toHaveLength(1);
367
368 flushPendingWrites.mockResolvedValue(undefined);
369 backgroundCallbacks[0]!();
370 await vi.waitFor(() => expect(client.reloadAsync).toHaveBeenCalledOnce());
371 reportError.mockRestore();
372 });
373
374 it("still restarts a user-requested install when the flush fails", async () => {
375 const reportError = vi.spyOn(console, "error").mockImplementation(() => {});
376 const client = makeAvailableUpdateClient();
377 const { environment } = makeUpdateEnvironment({
378 flushPendingWrites: vi.fn(async () => {
379 throw new Error("storage unavailable");
380 }),
381 });
382
383 await runAppUpdateCheck({
384 applyMode: "immediate",
385 client,
386 deferral: createAppUpdateDeferral(),
387 environment,
388 });
389
390 expect(client.reloadAsync).toHaveBeenCalledOnce();
391 reportError.mockRestore();
392 });
393
394 it("restarts into the embedded bundle for a rollback directive", async () => {
395 const client = makeUpdateClient({
396 checkForUpdateAsync: vi.fn(async () => ({
397 isAvailable: false,
398 isRollBackToEmbedded: true,
399 })),
400 fetchUpdateAsync: vi.fn(async () => ({
401 isNew: false,
402 isRollBackToEmbedded: true,
403 })),
404 });
405 const { environment } = makeUpdateEnvironment();
406
407 await runAppUpdateCheck({ client, deferral: createAppUpdateDeferral(), environment });
408
409 expect(client.fetchUpdateAsync).toHaveBeenCalledOnce();
410 // A rollback pulls a broken bundle, so it never waits on the prompt.
411 expect(environment.confirmInstallNow).not.toHaveBeenCalled();
412 expect(client.reloadAsync).toHaveBeenCalledOnce();
413 });
414
415 it("stops quietly when the running bundle is current", async () => {
416 const client = makeUpdateClient();
417 const states: AppUpdateCheckState[] = [];
418
419 await runAppUpdateCheck({ client, onStateChange: (state) => states.push(state) });
420
421 expect(client.fetchUpdateAsync).not.toHaveBeenCalled();
422 expect(client.reloadAsync).not.toHaveBeenCalled();
423 expect(states).toEqual(["checking", "current"]);
424 });
425
426 it("reports manual failures without continuing the update", async () => {
427 const reportError = vi.spyOn(console, "error").mockImplementation(() => {});
428 const client = makeUpdateClient({
429 checkForUpdateAsync: vi.fn(async () => {
430 throw new Error("offline");
431 }),
432 });
433 const failures: string[] = [];
434 const states: AppUpdateCheckState[] = [];
435
436 await runAppUpdateCheck({
437 client,
438 onFailure: (message) => failures.push(message),
439 onStateChange: (state) => states.push(state),
440 });
441
442 expect(client.fetchUpdateAsync).not.toHaveBeenCalled();
443 expect(failures).toEqual(["offline"]);
444 expect(states).toEqual(["checking", "idle"]);
445 reportError.mockRestore();
446 });
447
448 it.each(["ERR_NOT_AVAILABLE_IN_DEV_CLIENT", "ERR_UPDATES_DISABLED"])(
449 "treats Expo's %s failure as an unavailable update check",
450 async (code) => {
451 const reportError = vi.spyOn(console, "error").mockImplementation(() => {});
452 const error = Object.assign(new Error("Updates are unavailable"), { code });
453 const client = makeUpdateClient({
454 checkForUpdateAsync: vi.fn(async () => {
455 throw error;
456 }),
457 });
458 const failures: string[] = [];
459 const states: AppUpdateCheckState[] = [];
460
461 await runAppUpdateCheck({
462 client,
463 onFailure: (message) => failures.push(message),
464 onStateChange: (state) => states.push(state),
465 });
466
467 expect(reportError).not.toHaveBeenCalled();
468 expect(failures).toEqual([]);
469 expect(states).toEqual(["checking", "idle"]);
470 reportError.mockRestore();
471 },
472 );
473
474 it("coalesces overlapping launch and manual checks", async () => {
475 let resolveCheck!: (result: {
476 readonly isAvailable: boolean;
477 readonly isRollBackToEmbedded: boolean;
478 }) => void;
479 const checkResult = new Promise<{
480 readonly isAvailable: boolean;
481 readonly isRollBackToEmbedded: boolean;
482 }>((resolve) => {
483 resolveCheck = resolve;
484 });
485 const client = makeUpdateClient({
486 checkForUpdateAsync: vi.fn(() => checkResult),
487 });
488 const checkOnLaunch = createAppUpdateLaunchCheck(client);
489 const manualStates: AppUpdateCheckState[] = [];
490
491 const launchCheck = checkOnLaunch();
492 const manualCheck = runAppUpdateCheck({
493 client,
494 onStateChange: (state) => manualStates.push(state),
495 });
496
497 expect(client.checkForUpdateAsync).toHaveBeenCalledOnce();
498 expect(manualStates).toEqual(["checking"]);
499
500 resolveCheck({
501 isAvailable: false,
502 isRollBackToEmbedded: false,
503 });
504 await Promise.all([launchCheck, manualCheck]);
505
506 expect(manualStates).toEqual(["checking", "current"]);
507
508 await runAppUpdateCheck({ client });
509 expect(client.checkForUpdateAsync).toHaveBeenCalledTimes(2);
510 });
511
512 it("forwards failures to a manual check coalesced with the launch check", async () => {
513 const reportError = vi.spyOn(console, "error").mockImplementation(() => {});
514 let rejectCheck!: (error: Error) => void;
515 const checkResult = new Promise<{
516 readonly isAvailable: boolean;
517 readonly isRollBackToEmbedded: boolean;
518 }>((_resolve, reject) => {
519 rejectCheck = reject;
520 });
521 const client = makeUpdateClient({
522 checkForUpdateAsync: vi.fn(() => checkResult),
523 });
524 const checkOnLaunch = createAppUpdateLaunchCheck(client);
525 const failures: string[] = [];
526 const manualStates: AppUpdateCheckState[] = [];
527
528 const launchCheck = checkOnLaunch();
529 const manualCheck = runAppUpdateCheck({
530 client,
531 onFailure: (message) => failures.push(message),
532 onStateChange: (state) => manualStates.push(state),
533 });
534
535 rejectCheck(new Error("offline"));
536 await Promise.all([launchCheck, manualCheck]);
537
538 expect(client.checkForUpdateAsync).toHaveBeenCalledOnce();
539 expect(failures).toEqual(["offline"]);
540 expect(manualStates).toEqual(["checking", "idle"]);
541 reportError.mockRestore();
542 });
543
544 it("publishes the in-flight check before a state callback can re-enter", async () => {
545 let resolveCheck!: (result: {
546 readonly isAvailable: boolean;
547 readonly isRollBackToEmbedded: boolean;
548 }) => void;
549 const checkResult = new Promise<{
550 readonly isAvailable: boolean;
551 readonly isRollBackToEmbedded: boolean;
552 }>((resolve) => {
553 resolveCheck = resolve;
554 });
555 const client = makeUpdateClient({
556 checkForUpdateAsync: vi.fn(() => checkResult),
557 });
558 const reentrantStates: AppUpdateCheckState[] = [];
559 let reentrantCheck: Promise<void> | undefined;
560 let didReenter = false;
561
562 const initialCheck = runAppUpdateCheck({
563 client,
564 onStateChange: (state) => {
565 if (state !== "checking" || didReenter) return;
566 didReenter = true;
567 reentrantCheck = runAppUpdateCheck({
568 client,
569 onStateChange: (reentrantState) => reentrantStates.push(reentrantState),
570 });
571 },
572 });
573
574 expect(client.checkForUpdateAsync).toHaveBeenCalledOnce();
575 expect(reentrantCheck).toBeDefined();
576 expect(reentrantStates).toEqual(["checking"]);
577
578 resolveCheck({
579 isAvailable: false,
580 isRollBackToEmbedded: false,
581 });
582 await Promise.all([initialCheck, reentrantCheck]);
583
584 expect(client.checkForUpdateAsync).toHaveBeenCalledOnce();
585 expect(reentrantStates).toEqual(["checking", "current"]);
586 });
587});
588
589describe("createAppUpdateLaunchCheck", () => {
590 it("checks at most once for each JavaScript launch", async () => {
591 const client = makeUpdateClient();
592 const checkOnLaunch = createAppUpdateLaunchCheck(client);
593
594 const first = checkOnLaunch();
595 const second = checkOnLaunch();
596 await first;
597
598 expect(second).toBeUndefined();
599 expect(client.checkForUpdateAsync).toHaveBeenCalledOnce();
600 });
601
602 it("does nothing when Expo updates are disabled", () => {
603 const client = makeUpdateClient({ isEnabled: false });
604 const checkOnLaunch = createAppUpdateLaunchCheck(client);
605
606 expect(checkOnLaunch()).toBeUndefined();
607 expect(client.checkForUpdateAsync).not.toHaveBeenCalled();
608 });
609});
610
611describe("shouldRecheckAppUpdateOnForeground", () => {
612 it("requires a meaningful background gap", () => {
613 expect(shouldRecheckAppUpdateOnForeground(null, 100_000, false)).toBe(false);
614 expect(
615 shouldRecheckAppUpdateOnForeground(
616 100_000,
617 100_000 + FOREGROUND_APP_UPDATE_RECHECK_AFTER_MS - 1,
618 false,
619 ),
620 ).toBe(false);
621 expect(
622 shouldRecheckAppUpdateOnForeground(
623 100_000,
624 100_000 + FOREGROUND_APP_UPDATE_RECHECK_AFTER_MS,
625 false,
626 ),
627 ).toBe(true);
628 });
629
630 it("stays quiet while a downloaded update waits for its install", () => {
631 expect(
632 shouldRecheckAppUpdateOnForeground(
633 100_000,
634 100_000 + FOREGROUND_APP_UPDATE_RECHECK_AFTER_MS,
635 true,
636 ),
637 ).toBe(false);
638 });
639});
640
641describe("registerHiddenUpdateTap", () => {
642 it("unlocks the manual check on the fifth tap", () => {
643 let count = 0;
644
645 for (let tap = 1; tap <= 5; tap += 1) {
646 const result = registerHiddenUpdateTap(count);
647 expect(result.shouldCheck).toBe(tap === 5);
648 count = result.nextCount;
649 }
650
651 expect(count).toBe(0);
652 });
653});