|
| 1 | +import { by, device, element, waitFor } from 'detox'; |
| 2 | +import { |
| 3 | + getLocalUpdateEndpoint, |
| 4 | + LOCAL_UPDATE_HASHES, |
| 5 | + LOCAL_UPDATE_LABELS, |
| 6 | + LOCAL_UPDATE_PORT, |
| 7 | +} from '../localUpdateConfig.ts'; |
| 8 | + |
| 9 | +// The native cold-start check (NATIVE_CHECKUPDATE_DESIGN §10) exists for one |
| 10 | +// scenario: the running update is broken badly enough that JS never starts, so |
| 11 | +// nothing in JS can fetch the fix. Staging a genuinely bricked bundle is not |
| 12 | +// testable through Detox — it cannot attach to an app whose JS never boots — so |
| 13 | +// this suite covers the same capability minus that one step: the app never |
| 14 | +// performs a JS check (checkStrategy: null, and the check button is never |
| 15 | +// tapped), yet the device still ends up on a new version. Only the native |
| 16 | +// orchestrator can produce that outcome. |
| 17 | +// |
| 18 | +// The activation itself is driven by the server's per-version forceBoot |
| 19 | +// directive, which is exactly how a real rescue is triggered from the console. |
| 20 | + |
| 21 | +const NATIVE_CHECK_SETTLE_MS = 25000; |
| 22 | +const READY_TIMEOUT = 30000; |
| 23 | +const LABEL_TIMEOUT = 30000; |
| 24 | + |
| 25 | +function getDetoxLaunchArgs() { |
| 26 | + if (device.getPlatform() !== 'android') { |
| 27 | + return {}; |
| 28 | + } |
| 29 | + return { launchArgs: { detoxEnableSynchronization: '0' } }; |
| 30 | +} |
| 31 | + |
| 32 | +async function relaunchAppPreservingData() { |
| 33 | + await device.launchApp({ newInstance: true, ...getDetoxLaunchArgs() }); |
| 34 | + // The native check talks to the same local server; keeping those requests out |
| 35 | + // of Detox's idle synchronization is what the other suites do too. |
| 36 | + await device.setURLBlacklist([`.*:${LOCAL_UPDATE_PORT}.*`]); |
| 37 | +} |
| 38 | + |
| 39 | +async function setForceBoot(enabled: boolean) { |
| 40 | + const endpoint = getLocalUpdateEndpoint(device.getPlatform()); |
| 41 | + // Reached from the test runner (the host), not from the device, so localhost |
| 42 | + // is correct even when the app talks to 10.0.2.2. |
| 43 | + const hostEndpoint = endpoint.replace('10.0.2.2', '127.0.0.1'); |
| 44 | + const response = await fetch(`${hostEndpoint}/control/force-boot`, { |
| 45 | + method: 'POST', |
| 46 | + headers: { 'Content-Type': 'application/json' }, |
| 47 | + body: JSON.stringify({ enabled }), |
| 48 | + }); |
| 49 | + if (!response.ok) { |
| 50 | + throw new Error(`failed to set forceBoot=${enabled}: ${response.status}`); |
| 51 | + } |
| 52 | +} |
| 53 | + |
| 54 | +async function waitForReady() { |
| 55 | + await waitFor(element(by.id('bundle-label'))) |
| 56 | + .toBeVisible() |
| 57 | + .withTimeout(READY_TIMEOUT); |
| 58 | +} |
| 59 | + |
| 60 | +async function waitForBundleLabel(label: string) { |
| 61 | + await waitFor(element(by.id('bundle-label'))) |
| 62 | + .toHaveText(`bundleLabel: ${label}`) |
| 63 | + .withTimeout(LABEL_TIMEOUT); |
| 64 | +} |
| 65 | + |
| 66 | +async function waitForHash(hash: string) { |
| 67 | + await waitFor(element(by.id('current-hash'))) |
| 68 | + .toHaveText(`currentHash: ${hash || '(empty)'}`) |
| 69 | + .withTimeout(LABEL_TIMEOUT); |
| 70 | +} |
| 71 | + |
| 72 | +describe('Native cold-start check', () => { |
| 73 | + beforeAll(async () => { |
| 74 | + await setForceBoot(false); |
| 75 | + // A fresh install already sits on the packaged bundle, which is the state |
| 76 | + // both halves below start from — no reset round-trip needed. |
| 77 | + await device.launchApp({ delete: true, ...getDetoxLaunchArgs() }); |
| 78 | + await device.setURLBlacklist([`.*:${LOCAL_UPDATE_PORT}.*`]); |
| 79 | + await waitForReady(); |
| 80 | + await waitForBundleLabel(LOCAL_UPDATE_LABELS.base); |
| 81 | + }); |
| 82 | + |
| 83 | + afterAll(async () => { |
| 84 | + // Never leave the directive on: the other suites drive activation |
| 85 | + // themselves and would race a forced one. |
| 86 | + await setForceBoot(false); |
| 87 | + }); |
| 88 | + |
| 89 | + // Both directions live in one test on purpose: every extra app launch costs |
| 90 | + // real wall clock on the iOS simulator, and this suite shares a 40-minute |
| 91 | + // job budget with the rest of the e2e. |
| 92 | + it('activates only what the server forces, with no JS check involved', async () => { |
| 93 | + // Without the directive the round may download, but with automatic checks |
| 94 | + // off (checkStrategy: null) it must never activate on its own. |
| 95 | + await new Promise((resolve) => setTimeout(resolve, NATIVE_CHECK_SETTLE_MS)); |
| 96 | + await relaunchAppPreservingData(); |
| 97 | + await waitForReady(); |
| 98 | + await waitForBundleLabel(LOCAL_UPDATE_LABELS.base); |
| 99 | + await waitForHash(''); |
| 100 | + |
| 101 | + // Flip the directive before the launch whose round should honor it. |
| 102 | + await setForceBoot(true); |
| 103 | + await relaunchAppPreservingData(); |
| 104 | + await waitForReady(); |
| 105 | + await waitForBundleLabel(LOCAL_UPDATE_LABELS.base); |
| 106 | + await new Promise((resolve) => setTimeout(resolve, NATIVE_CHECK_SETTLE_MS)); |
| 107 | + |
| 108 | + // Turn it off before observing, so the next launch cannot walk further |
| 109 | + // along the update chain while the assertions run. |
| 110 | + await setForceBoot(false); |
| 111 | + await relaunchAppPreservingData(); |
| 112 | + await waitForReady(); |
| 113 | + await waitForBundleLabel(LOCAL_UPDATE_LABELS.full); |
| 114 | + await waitForHash(LOCAL_UPDATE_HASHES.full); |
| 115 | + }); |
| 116 | +}); |
0 commit comments