From 26da32017249e3e85e9ecb2cdf747477b9f6c0db Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Sun, 27 Sep 2026 06:26:14 -0400 Subject: [PATCH 1/2] ci(mobile): roll back or republish OTA updates from the release workflow, and gate publishes on a boot test Fixes #1657 --- .github/workflows/mobile-release.yml | 63 +++++++- RELEASE.md | 3 + apps/mobile/README.md | 88 ++++++++++ apps/mobile/package.json | 5 + apps/mobile/src/app-boot.test.tsx | 229 +++++++++++++++++++++++++++ pnpm-lock.yaml | 151 +++++++++++++++++- 6 files changed, 532 insertions(+), 7 deletions(-) create mode 100644 apps/mobile/src/app-boot.test.tsx diff --git a/.github/workflows/mobile-release.yml b/.github/workflows/mobile-release.yml index 2ae0ed413..b977d2973 100644 --- a/.github/workflows/mobile-release.yml +++ b/.github/workflows/mobile-release.yml @@ -6,10 +6,18 @@ on: workflow_dispatch: inputs: mode: - description: auto publishes an update when the latest production build has the same runtime, and builds and submits otherwise. + description: auto publishes an update when the latest production build has the same runtime, and builds and submits otherwise. rollback and republish change what channel production serves. type: choice - options: [auto, update, build] + options: [auto, update, build, rollback, republish] default: auto + runtime: + description: rollback only. The runtime to roll back to its embedded update; empty takes the latest finished production build's. + type: string + default: '' + group: + description: republish only. The update group to publish again on channel production. + type: string + default: '' permissions: contents: read @@ -54,6 +62,10 @@ jobs: - name: Install before any secret is loaded run: pnpm install --frozen-lockfile --filter stim-mobile + - name: The app boots in the tests before anything is published + if: inputs.mode != 'rollback' && inputs.mode != 'republish' + run: pnpm test --ci + - name: The tag matches the app version if: github.event_name == 'push' run: | @@ -97,6 +109,53 @@ jobs: message="$(git log -1 --format=%s)" eas update --channel production --environment production --platform ios --message "$message" --non-interactive + - name: Roll back to the embedded update + if: steps.mode.outputs.mode == 'rollback' + env: + INPUT_RUNTIME: ${{ inputs.runtime }} + run: | + runtime="$INPUT_RUNTIME" + if [ -z "$runtime" ]; then + build="$(eas build:list --platform ios --build-profile production --status finished --limit 1 --json --non-interactive | jq -c '.[0] // {}')" + runtime="$(jq -r '.runtime.version // empty' <<<"$build")" + echo "Latest finished production build: $(jq -r '"\(.id // "none") build \(.appBuildVersion // "?") from \(.gitCommitHash // "?")"' <<<"$build")" + fi + if [ -z "$runtime" ]; then + echo "::error::No runtime given and no finished production build has one. Pass -f runtime=." + exit 1 + fi + eas update:roll-back-to-embedded --channel production --runtime-version "$runtime" --platform ios \ + --message "Roll back to embedded (run $GITHUB_RUN_ID)" --non-interactive --json > rollback.json + group="$(jq -r '.[0].group' rollback.json)" + echo "::notice::Channel production on runtime $runtime now serves the embedded update (group $group)." + + - name: Republish an update group + if: steps.mode.outputs.mode == 'republish' + env: + INPUT_GROUP: ${{ inputs.group }} + run: | + if ! [[ "$INPUT_GROUP" =~ ^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$ ]]; then + echo "::error::group must be an update group id, not '$INPUT_GROUP'. List them with eas update:list --branch production." + exit 1 + fi + update="$(eas update:view "$INPUT_GROUP" --json | jq -c '[.[] | select(.platform == "ios")][0] // empty')" + if [ -z "$update" ]; then + echo "::error::Update group $INPUT_GROUP has no iOS update." + exit 1 + fi + branch="$(jq -r .branch <<<"$update")" + runtime="$(jq -r .runtimeVersion <<<"$update")" + echo "Group $INPUT_GROUP: branch $branch, runtime $runtime, rollback to embedded $(jq -r .isRollBackToEmbedded <<<"$update"), message: $(jq -r .message <<<"$update")" + latest="$(eas build:list --platform ios --build-profile production --status finished --limit 1 --json --non-interactive | jq -r '.[0].runtime.version // empty')" + if [ "$runtime" != "$latest" ]; then + echo "::warning::Runtime $runtime is not the latest production build's runtime (${latest:-none}); only builds on $runtime get this update." + fi + destination=() + if [ "$branch" != production ]; then destination=(--destination-channel production); fi + eas update:republish --group "$INPUT_GROUP" "${destination[@]}" --platform ios \ + --message "Republish $INPUT_GROUP (run $GITHUB_RUN_ID)" --non-interactive --json > republish.json + echo "::notice::Channel production on runtime $runtime now serves group $INPUT_GROUP, republished as $(jq -r '.[0].group' republish.json)." + - name: Build and submit to TestFlight if: steps.mode.outputs.mode == 'build' env: diff --git a/RELEASE.md b/RELEASE.md index b7f488e07..cec98fdbe 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -6,6 +6,9 @@ real workflow at the same time. Stim Desktop has its own tags and workflow; see [apps/desktop/RELEASING.md](./apps/desktop/RELEASING.md). +The phone app ships with its own workflow, which also rolls back a bad update; +see [Each release](./apps/mobile/README.md#each-release) and +[Roll back a bad update](./apps/mobile/README.md#roll-back-a-bad-update). ## 0. The six packages diff --git a/apps/mobile/README.md b/apps/mobile/README.md index b4eb1e3f8..c76990768 100644 --- a/apps/mobile/README.md +++ b/apps/mobile/README.md @@ -638,6 +638,20 @@ pnpm test `.github/workflows/mobile.yml` runs them for changes under `apps/mobile` and to the root `package.json`, `pnpm-lock.yaml` and `pnpm-workspace.yaml`. +`src/app-boot.test.tsx` is the boot test. It renders the whole app, from +`src/app/_layout.tsx` down to home, in Jest. The phone starts from what the +previous release stored: a paired machine, that machine's cached status from an +older `stim`, and notification preferences and state in their old shapes. The +machine then connects to a fake `stim-server` that serves +`mock-server/fixtures/status.json`, and the app turns `active` the way iOS does +after launch. The test fails when React reports an error or anything throws, +so it catches JS that throws while launching over an earlier release's state, +on the screens it renders. Reanimated, the drawer and Lottie are stubbed, so +the test does not cover animations or native code. + +When a change stores something new on the phone, or reads a stored value in a +new shape, add the previous shape to `seedPreviousInstall` in that test. + ## Ship to TestFlight The app ships to TestFlight with EAS, under the App&Flow Expo account @@ -732,9 +746,21 @@ or prefix each command with `npx`). JS-only; an update for a runtime no build has reaches no one. - `build`: builds with the `production` profile and submits to TestFlight (`eas build --auto-submit`). + - `rollback`: points channel `production` back at the JS embedded in the + build (`eas update:roll-back-to-embedded`). By default it uses the runtime + of the latest finished production build; the `runtime` input names + another one. See [Roll back a bad update](#roll-back-a-bad-update). + - `republish`: publishes the update group named by the `group` input again + on channel `production` (`eas update:republish`), so installed builds go + back to that update. - A `mobile-v` tag on `main` always builds and submits. The version must equal `version` in `app.config.ts`, so raise it first. +Every mode except `rollback` and `republish` runs the unit tests, the boot test +included, before the step that loads `EXPO_TOKEN`. A failing test stops the run +before anything is published or built. `rollback` and `republish` skip them +because they must still work when `main` is broken. + `eas build` records as the build's runtime the fingerprint computed on the machine that starts it, so a build from this workflow and the fingerprint `auto` compares are both computed on the Linux runner. A build started by hand @@ -767,3 +793,65 @@ The build appears in TestFlight after Apple finishes processing it, usually within 30 minutes. Add testers under the app's **TestFlight** tab. Raise `version` in `app.config.ts` for a new marketing version; build numbers need no change. + +### Roll back a bad update + +An update reaches every installed build on its runtime the next time the app +launches, and it runs on the launch after that. A bad update shows up as: + +- the app closing at launch, or showing an error screen, on phones that were + fine before; +- crashes in `eas update:view --insights` for the newest group. + +Find the newest groups and the one that was good before the bad one: + +```bash +cd apps/mobile +eas update:list --branch production --limit 10 +eas update:view --insights +``` + +Roll back to the JS embedded in the build. This works whatever is on the +channel, and needs no group id: + +```bash +gh workflow run mobile-release.yml --ref main -f mode=rollback +gh workflow run mobile-release.yml --ref main -f mode=rollback -f runtime= +``` + +Or go back to a specific update that worked: + +```bash +gh workflow run mobile-release.yml --ref main -f mode=republish -f group= +``` + +Both runs wait for approval in the `release` environment. Approve them as in +step 7 of [RELEASE.md](../../RELEASE.md#4-cut-the-release), with the run id +from `gh run list --workflow mobile-release.yml --limit 1`. The run log's +notice names the runtime and group it acted on. `republish` warns when the +group's runtime is not the latest production build's, because only builds on +that runtime get it. When the group is on a branch other than `production`, +`republish` publishes it to channel `production` with `--destination-channel`. + +Phones pick up the rollback when a launch checks for updates, and run it on the +launch after that. A phone that crashes at launch may need a few launches. + +After a rollback, fix the bug on `main`. The next `auto` run publishes the fix +as a new update, which replaces the rollback. Build for TestFlight (`mode=build`) +instead when the fix changes native code or dependencies. Do the same when the +embedded JS itself is broken, because then a rollback cannot help: `auto` builds +only when the runtime changes, so pick `build` explicitly. + +The workflow has no staging channel. Publishing to a staging branch first and +promoting it with `republish` would need a build that reads that channel, +installed on a phone someone checks before promoting. Without that build, +staging adds a step and verifies nothing. A simulator run of each update on a +macOS runner, using a production-runtime simulator build per runtime, would +catch native and render crashes. It would also cost a macOS runner and an EAS +simulator build for every runtime. The boot test catches JS errors on launch, +which covers this class of crash, at no extra cost. To check an update by hand +before publishing it, run a Release build of the same commit on a simulator +with `stim ios --configuration Release`. Stim puts the current JS into the +cached build, so this works without Metro. Then read `stim logs --errors`. A +Release build ignores `.env.local`, so it starts with no paired machine unless +you pair it. diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 93312784e..2c1783243 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -49,6 +49,7 @@ }, "devDependencies": { "@react-native/metro-config": "0.88.0-rc.1", + "@testing-library/react-native": "14.0.1", "@types/jest": "^29.5.14", "@types/react": "~19.3.0", "@types/ws": "^8.18.1", @@ -60,10 +61,14 @@ "oxfmt": "^0.65.0", "sf-symbols-typescript": "2.2.0", "sharp": "^0.35.4", + "test-renderer": "^1.3.0", "typescript": "~6.0.3", "ws": "^8.21.3" }, "jest": { + "moduleNameMapper": { + "^@/assets/(.*)$": "/assets/$1" + }, "preset": "jest-expo", "testPathIgnorePatterns": [ "/node_modules/", diff --git a/apps/mobile/src/app-boot.test.tsx b/apps/mobile/src/app-boot.test.tsx new file mode 100644 index 000000000..31412824a --- /dev/null +++ b/apps/mobile/src/app-boot.test.tsx @@ -0,0 +1,229 @@ +import 'react-native-unistyles/mocks'; +import 'react-native-gesture-handler/jestSetup'; + +import { act, renderRouter, screen } from 'expo-router/testing-library'; +import { AppState } from 'react-native'; + +import '@/design/unistyles'; + +import statusFixture from '../mock-server/fixtures/status.json'; + +// expo-router/testing-library mocks Reanimated with `react-native-reanimated/mock`, which throws while loading under +// Reanimated 4.7 and leaves an empty module, so the mock it loads is replaced. +jest.mock('react-native-reanimated/mock', () => { + const inert: () => unknown = () => + new Proxy(() => {}, { get: (_, key) => (key === 'value' ? 0 : inert()), apply: () => inert() }); + const STYLE = {}; + const sharedValue = (value: unknown) => ({ + value, + get: () => value, + set: () => {}, + modify: () => {}, + addListener: () => {}, + removeListener: () => {}, + }); + const named: Record = { + __esModule: true, + interpolate: () => 0, + isSharedValue: () => false, + useAnimatedStyle: () => STYLE, + useReducedMotion: () => true, + useSharedValue: (value: unknown) => jest.requireActual('react').useState(() => sharedValue(value))[0], + withTiming: (value: unknown) => value, + withSpring: (value: unknown) => value, + }; + const animated = new Proxy( + { createAnimatedComponent: (component: unknown) => component, call: () => {} }, + { + get: (target, key) => + key in target + ? target[key as keyof typeof target] + : (jest.requireActual('react-native') as Record)[key as string], + }, + ); + return new Proxy(named, { + get: (target, key) => (key === 'default' ? animated : key in target ? target[key as string] : inert()), + }); +}); + +jest.mock('react-native-drawer-layout', () => ({ + Drawer: ({ children }: { children: React.ReactNode }) => { + const { GestureHandlerRootView } = jest.requireActual('react-native-gesture-handler'); + return {children}; + }, + useDrawerProgress: () => ({ value: 0 }), +})); + +jest.mock('lottie-react-native', () => () => null); + +const mockStores = new Map>(); +const mockStore = (id: string) => { + let store = mockStores.get(id); + if (!store) mockStores.set(id, (store = new Map())); + return store; +}; + +jest.mock('react-native-mmkv', () => ({ + createMMKV: ({ id }: { id: string }) => { + const store = mockStore(id); + return { + getString: (key: string) => store.get(key), + set: (key: string, value: unknown) => void store.set(key, String(value)), + remove: (key: string) => store.delete(key), + getAllKeys: () => [...store.keys()], + contains: (key: string) => store.has(key), + }; + }, +})); + +jest.mock('expo-secure-store', () => { + const store = mockStore('secure-store'); + return { + getItemAsync: async (key: string) => store.get(key) ?? null, + setItemAsync: async (key: string, value: string) => void store.set(key, value), + deleteItemAsync: async (key: string) => void store.delete(key), + }; +}); + +jest.mock('expo-notifications', () => ({ + AndroidImportance: { HIGH: 4, LOW: 2 }, + DEFAULT_ACTION_IDENTIFIER: 'expo.modules.notifications.actions.DEFAULT', + setNotificationHandler: () => {}, + setNotificationChannelAsync: async () => null, + getPermissionsAsync: async () => ({ granted: true, canAskAgain: true }), + requestPermissionsAsync: async () => ({ granted: true, canAskAgain: true }), + scheduleNotificationAsync: async () => 'id', + getExpoPushTokenAsync: async () => ({ type: 'expo', data: 'ExponentPushToken[boot-test]' }), + addPushTokenListener: () => ({ remove: () => {} }), + useLastNotificationResponse: () => null, +})); + +const MAC_ID = 'mac1'; +const ENDPOINT = 'ws://mock-mac:7787'; + +/** Answers `hello` and `status.subscribe` like stim-server, with the mock server's captured status. */ +class FakeSocket { + static sent: string[] = []; + onopen: (() => void) | null = null; + onmessage: ((event: { data: string }) => void) | null = null; + onclose: ((event: { code: number; reason: string }) => void) | null = null; + onerror: (() => void) | null = null; + readyState = 0; + + constructor() { + setTimeout(() => { + this.readyState = 1; + this.onopen?.(); + }, 0); + } + + send(raw: string) { + const { id, method } = JSON.parse(raw) as { id: number; method: string }; + FakeSocket.sent.push(method); + const reply = (message: object) => setTimeout(() => this.onmessage?.({ data: JSON.stringify(message) }), 0); + if (method === 'hello') { + reply({ + id, + result: { + protocol: 1, + server: { + name: 'Mock Mac', + version: '0.0.0-mock', + stim: statusFixture.stimVersion, + home: statusFixture.home, + }, + capabilities: ['read', 'control'], + actions: ['reload', 'stop'], + device: { id: 'phone', name: 'Phone' }, + }, + }); + } else if (method === 'status.subscribe') { + reply({ id, result: { subscription: 's1' } }); + reply({ event: 'status', subscription: 's1', payload: statusFixture.payload }); + } else { + reply({ id, error: { code: 'not-implemented', message: `The boot test does not serve ${method}.` } }); + } + } + + close() { + this.readyState = 3; + } +} + +/** The status an older `stim` reported, before lifecycle phases, the owned Chrome and measured memory. */ +function olderStatus() { + return { + ...statusFixture.payload, + environments: statusFixture.payload.environments.map((env) => { + const older: Record = { ...env }; + delete older.phase; + delete older.phaseSince; + delete older.warmStep; + delete older.web; + delete older.memorySource; + return older; + }), + }; +} + +/** What the previous release left on a phone with a paired machine and notifications on. */ +function seedPreviousInstall() { + const secure = mockStore('secure-store'); + secure.set( + 'stim.macs', + JSON.stringify([{ id: MAC_ID, name: 'Mock Mac', endpoint: ENDPOINT, pairedAt: '2026-09-01T00:00:00.000Z' }]), + ); + secure.set(`stim.mac.${MAC_ID}.token`, 'device-token'); + mockStore('stim.status').set( + `status:${MAC_ID}`, + JSON.stringify({ v: 1, name: 'Mock Mac', seenAt: Date.now() - 60_000, status: olderStatus() }), + ); + const path = statusFixture.payload.environments[0]!.path; + const entry = { occurrence: 'a', since: 0, heldSince: 0, seenAt: Date.now() - 60_000, notifiedAt: null, done: false }; + const notifications = mockStore('stim.notifications'); + notifications.set( + 'prefs', + JSON.stringify({ enabled: true, events: ['build-failed', 'log-errors', 'offline'], agentOnly: false }), + ); + notifications.set( + 'state', + JSON.stringify({ [`link:${MAC_ID}`]: {}, [`status:${MAC_ID}`]: { [`build-failed:${path}`]: entry } }), + ); +} + +/** + * iOS starts the JS while the app is still `inactive` and reports `active` after the pairings load and the machine + * connects, so the notifier first runs on the stored state with the machine's live status. + */ +function launchInactive() { + const listeners: ((state: string) => void)[] = []; + Object.assign(AppState, { currentState: 'inactive' }); + jest.spyOn(AppState, 'addEventListener').mockImplementation((type, listener) => { + if (type === 'change') listeners.push(listener as (state: string) => void); + return { remove: () => {} } as ReturnType; + }); + return () => { + Object.assign(AppState, { currentState: 'active' }); + for (const listener of listeners) listener('active'); + }; +} + +it('boots on the state the previous release left and renders the live machine', async () => { + (globalThis as { WebSocket?: unknown }).WebSocket = FakeSocket; + seedPreviousInstall(); + const activate = launchInactive(); + const errors: unknown[][] = []; + const consoleError = jest.spyOn(console, 'error').mockImplementation((...args) => void errors.push(args)); + + await renderRouter('./src/app', { initialUrl: '/' }); + await act(() => jest.advanceTimersByTimeAsync(1000)); + expect(FakeSocket.sent).toContain('status.subscribe'); + await act(async () => { + activate(); + await jest.advanceTimersByTimeAsync(1000); + }); + consoleError.mockRestore(); + + expect(errors).toEqual([]); + expect(screen.getAllByText('Mock Mac').length).toBeGreaterThan(0); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b2efd9aa5..3a55265b7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -171,7 +171,7 @@ importers: version: 58.0.7(expo@58.0.0-preview.7)(react-native@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.0)(supports-color@8.1.1))(react@19.3.0)(supports-color@8.1.1)(typescript@6.0.3) expo-router: specifier: ~58.0.8 - version: 58.0.8(729283f6b97d3a4167f8d9d876f4826b) + version: 58.0.8(71370cef79192531445932e2a9c1508c) expo-screen-orientation: specifier: ~58.0.0 version: 58.0.0(expo@58.0.0-preview.7)(react-native@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.0)(supports-color@8.1.1)) @@ -236,6 +236,9 @@ importers: '@react-native/metro-config': specifier: 0.88.0-rc.1 version: 0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + '@testing-library/react-native': + specifier: 14.0.1 + version: 14.0.1(jest@29.7.0(@types/node@26.3.0)(supports-color@8.1.1))(react-native@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.0)(supports-color@8.1.1))(react@19.3.0)(test-renderer@1.3.0(@types/react@19.3.0)(react@19.3.0)) '@types/jest': specifier: ^29.5.14 version: 29.5.14 @@ -269,6 +272,9 @@ importers: sharp: specifier: ^0.35.4 version: 0.35.4(@types/node@26.3.0) + test-renderer: + specifier: ^1.3.0 + version: 1.3.0(@types/react@19.3.0)(react@19.3.0) typescript: specifier: ~6.0.3 version: 6.0.3 @@ -2447,6 +2453,10 @@ packages: resolution: {integrity: sha512-4QqS3LY5PBmTRHj9sAg1HLoPzqAI0uOX6wI/TRqHIcOxlFidy6YEmCQJk6FSZjNLGCeubDMfmkWL+qaLKhSGQA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + '@jest/diff-sequences@30.5.0': + resolution: {integrity: sha512-OsqBjHXCn8cadasoAZBP6nWYvMsRhpMzGXTpxJ5aO04NlbdhIz+FVe3q49l0AwVhsz/cEmIpBes6gAFl1/dWQg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/environment@29.7.0': resolution: {integrity: sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -2463,6 +2473,10 @@ packages: resolution: {integrity: sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + '@jest/get-type@30.5.0': + resolution: {integrity: sha512-9/2VUPitAjmBzbvDvqrxmvB7BzWsBW0WmkkojX1ODuxX1NLGxx9gfaZpHB0z8DtJ9uhGNmZG/VXBhf8uO0OV8Q==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/globals@29.7.0': resolution: {integrity: sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -2480,6 +2494,10 @@ packages: resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + '@jest/schemas@30.5.0': + resolution: {integrity: sha512-/hunigyNpc4RCjC0VaW3f5RCUZVM2+WQ65qP7z083Gmvac7or2LI50XVNOtE4YPgBpV0yxYiAgorAPGniCoJmg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/source-map@29.6.3': resolution: {integrity: sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -3791,6 +3809,9 @@ packages: '@sinclair/typebox@0.27.12': resolution: {integrity: sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==} + '@sinclair/typebox@0.34.52': + resolution: {integrity: sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==} + '@sindresorhus/is@4.6.0': resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} engines: {node: '>=10'} @@ -4071,6 +4092,18 @@ packages: resolution: {integrity: sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw==} engines: {node: '>=14.16'} + '@testing-library/react-native@14.0.1': + resolution: {integrity: sha512-2r2e2y8SkUNRWKRXlUGqDYunB+AkbdXUPn49Bs5rpv9oxRb0K3USBVugbPbAKJjfj4w5Ba91NJ8LcQCZyopUZA==} + engines: {node: ^22.13.0 || >=24} + peerDependencies: + jest: '>=29.0.0' + react: '>=19.0.0' + react-native: '>=0.78' + test-renderer: ^1.0.0 + peerDependenciesMeta: + jest: + optional: true + '@tootallnate/once@2.0.1': resolution: {integrity: sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ==} engines: {node: '>= 10'} @@ -4203,6 +4236,11 @@ packages: peerDependencies: '@types/react': ^19.2.0 + '@types/react-reconciler@0.33.0': + resolution: {integrity: sha512-HZOXsKT0tGI9LlUw2LuedXsVeB88wFa536vVL0M6vE8zN63nI+sSr1ByxmPToP5K5bukaVscyeCJcF9guVNJ1g==} + peerDependencies: + '@types/react': '*' + '@types/react-router-config@5.0.11': resolution: {integrity: sha512-WmSAg7WgqW7m4x8Mt4N6ZyKz0BubSj/2tVUMsAHp+Yd2AMwcSbeFq9WympT19p5heCFmF97R9eD5uUR/t4HEqw==} @@ -7397,6 +7435,10 @@ packages: resolution: {integrity: sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + jest-diff@30.5.2: + resolution: {integrity: sha512-rBtHnI6BWTiWj3lM7fOLVfChx7R9B0u9VV3PRHawKs79x6ZjW1QoSrKenaigNGWlvhtoi2BqvDbABDie+Os3xQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-docblock@29.7.0: resolution: {integrity: sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -7448,6 +7490,10 @@ packages: resolution: {integrity: sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + jest-matcher-utils@30.5.2: + resolution: {integrity: sha512-lFkB365PTIHOhPzwrb9T6gvlDnPpOZ7/c3ewOKKB7bzztqjVrtlyRL2MkyfJXpyY2u2SKw001JKrTx14fBUFcQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-message-util@29.7.0: resolution: {integrity: sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -8158,6 +8204,10 @@ packages: resolution: {integrity: sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + mini-css-extract-plugin@2.10.2: resolution: {integrity: sha512-AOSS0IdEB95ayVkxn5oGzNQwqAi2J0Jb/kKm43t7H73s8+f5873g0yuj0PNvK4dO75mu5DHg4nlgp4k6Kga8eg==} engines: {node: '>= 12.13.0'} @@ -9029,6 +9079,10 @@ packages: resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + pretty-format@30.5.1: + resolution: {integrity: sha512-byhRAPguVKMQIj4kjJwJ5lAskVhfuiSdiYl/aLTWpgkGEmic2jYhJh1yE9ih8Ox44Xg9ccCT11/S6QrzSJuNrg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + pretty-time@1.1.0: resolution: {integrity: sha512-28iF6xPQrP8Oa6uxE6a1biz+lWeTOAPKggvjB8HAs6nVMKZwf5bG++632Dx614hIWgUPkgivRfG+a8uAXGTIbA==} engines: {node: '>=4'} @@ -9266,6 +9320,12 @@ packages: '@types/react': optional: true + react-reconciler@0.34.0: + resolution: {integrity: sha512-eVibvB1fc9j5Qf1KRkiM3GzRPBdjg2P6G6QV2f3P6Eb3OGMxRBhdg2rrUwjbV5JrQaJj8nOa8uajmmJA5tJWjQ==} + engines: {node: '>=0.10.0'} + peerDependencies: + react: ^19.3.0 + react-refresh@0.14.2: resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==} engines: {node: '>=0.10.0'} @@ -9324,6 +9384,10 @@ packages: recma-stringify@1.0.0: resolution: {integrity: sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g==} + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + reflect-metadata@0.2.2: resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} @@ -9909,6 +9973,10 @@ packages: resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} engines: {node: '>=6'} + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + strip-json-comments@2.0.1: resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} engines: {node: '>=0.10.0'} @@ -10038,6 +10106,11 @@ packages: resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} engines: {node: '>=8'} + test-renderer@1.3.0: + resolution: {integrity: sha512-DXyHv7JrVmDc5dKpfVHFhJ0Evo/EFY3TqlNt8CR65ua3SCsI09K/mQDJmYUOnZz36GANYqjp0ApeRER/mOxktQ==} + peerDependencies: + react: ^19.0.0 + thingies@2.6.1: resolution: {integrity: sha512-cV/CMGTK3M4MlnJ/0At6ismOw/A0EEniDNScajjz/Br3c1sqE72YD01rGpPTKwd27wAxI5Pr+6+0w8yofzFRYw==} engines: {node: '>=10.18'} @@ -13635,7 +13708,7 @@ snapshots: ws: 8.21.3 zod: 3.25.76 optionalDependencies: - expo-router: 58.0.8(729283f6b97d3a4167f8d9d876f4826b) + expo-router: 58.0.8(71370cef79192531445932e2a9c1508c) react-native: 0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.0)(supports-color@8.1.1) transitivePeerDependencies: - '@expo/dom-webview' @@ -13947,7 +14020,7 @@ snapshots: react: 19.3.0 optionalDependencies: '@expo/metro-runtime': 58.0.7(@expo/log-box@58.0.5)(expo@58.0.0-preview.7)(react-dom@19.3.0(react@19.3.0))(react-native@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.0)(supports-color@8.1.1))(react@19.3.0) - expo-router: 58.0.8(729283f6b97d3a4167f8d9d876f4826b) + expo-router: 58.0.8(71370cef79192531445932e2a9c1508c) react-dom: 19.3.0(react@19.3.0) transitivePeerDependencies: - supports-color @@ -14171,6 +14244,8 @@ snapshots: dependencies: '@jest/types': 29.6.3 + '@jest/diff-sequences@30.5.0': {} + '@jest/environment@29.7.0': dependencies: '@jest/fake-timers': 29.7.0 @@ -14198,6 +14273,8 @@ snapshots: jest-mock: 29.7.0 jest-util: 29.7.0 + '@jest/get-type@30.5.0': {} + '@jest/globals@29.7.0(supports-color@8.1.1)': dependencies: '@jest/environment': 29.7.0 @@ -14240,6 +14317,10 @@ snapshots: dependencies: '@sinclair/typebox': 0.27.12 + '@jest/schemas@30.5.0': + dependencies: + '@sinclair/typebox': 0.34.52 + '@jest/source-map@29.6.3': dependencies: '@jridgewell/trace-mapping': 0.3.31 @@ -15364,6 +15445,8 @@ snapshots: '@sinclair/typebox@0.27.12': {} + '@sinclair/typebox@0.34.52': {} + '@sindresorhus/is@4.6.0': {} '@sindresorhus/is@5.6.0': {} @@ -15602,6 +15685,18 @@ snapshots: dependencies: defer-to-connect: 2.0.1 + '@testing-library/react-native@14.0.1(jest@29.7.0(@types/node@26.3.0)(supports-color@8.1.1))(react-native@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.0)(supports-color@8.1.1))(react@19.3.0)(test-renderer@1.3.0(@types/react@19.3.0)(react@19.3.0))': + dependencies: + jest-matcher-utils: 30.5.2 + picocolors: 1.1.1 + pretty-format: 30.5.1 + react: 19.3.0 + react-native: 0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.0)(supports-color@8.1.1) + redent: 3.0.0 + test-renderer: 1.3.0(@types/react@19.3.0)(react@19.3.0) + optionalDependencies: + jest: 29.7.0(@types/node@26.3.0)(supports-color@8.1.1) + '@tootallnate/once@2.0.1': {} '@tybys/wasm-util@0.10.3': @@ -15756,7 +15851,7 @@ snapshots: dependencies: '@types/react': 19.2.18 - '@types/react-dom@19.2.7(@types/react@19.3.0)': + '@types/react-reconciler@0.33.0(@types/react@19.3.0)': dependencies: '@types/react': 19.3.0 @@ -18120,7 +18215,7 @@ snapshots: - supports-color - typescript - expo-router@58.0.8(729283f6b97d3a4167f8d9d876f4826b): + expo-router@58.0.8(71370cef79192531445932e2a9c1508c): dependencies: '@expo/log-box': 58.0.5(@expo/dom-webview@58.0.1)(expo@58.0.0-preview.7)(react-native@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.0)(supports-color@8.1.1))(react@19.3.0) '@expo/metro-runtime': 58.0.7(@expo/log-box@58.0.5)(expo@58.0.0-preview.7)(react-dom@19.3.0(react@19.3.0))(react-native@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.0)(supports-color@8.1.1))(react@19.3.0) @@ -18149,6 +18244,7 @@ snapshots: standard-navigation: 0.0.5 optionalDependencies: '@expo/ui': 58.0.7(@babel/core@7.29.7(supports-color@8.1.1))(expo@58.0.0-preview.7)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(react-native@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.0)(supports-color@8.1.1))(react@19.3.0)(supports-color@8.1.1))(react-native@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.0)(supports-color@8.1.1))(react@19.3.0) + '@testing-library/react-native': 14.0.1(jest@29.7.0(@types/node@26.3.0)(supports-color@8.1.1))(react-native@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.0)(supports-color@8.1.1))(react@19.3.0)(test-renderer@1.3.0(@types/react@19.3.0)(react@19.3.0)) react-dom: 19.3.0(react@19.3.0) react-native-gesture-handler: 3.3.0(react-native@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.0)(supports-color@8.1.1))(react@19.3.0) react-native-reanimated: 4.7.0(react-native-worklets@0.13.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(react-native@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.0)(supports-color@8.1.1))(react@19.3.0)(supports-color@8.1.1))(react-native@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.0)(supports-color@8.1.1))(react@19.3.0) @@ -19340,6 +19436,13 @@ snapshots: jest-get-type: 29.6.3 pretty-format: 29.7.0 + jest-diff@30.5.2: + dependencies: + '@jest/diff-sequences': 30.5.0 + '@jest/get-type': 30.5.0 + chalk: 4.1.2 + pretty-format: 30.5.1 + jest-docblock@29.7.0: dependencies: detect-newline: 3.1.0 @@ -19433,6 +19536,13 @@ snapshots: jest-get-type: 29.6.3 pretty-format: 29.7.0 + jest-matcher-utils@30.5.2: + dependencies: + '@jest/get-type': 30.5.0 + chalk: 4.1.2 + jest-diff: 30.5.2 + pretty-format: 30.5.1 + jest-message-util@29.7.0: dependencies: '@babel/code-frame': 7.29.7 @@ -20658,6 +20768,8 @@ snapshots: mimic-response@4.0.0: {} + min-indent@1.0.1: {} + mini-css-extract-plugin@2.10.2(webpack@5.109.2(@swc/core@1.16.1)(@swc/html@1.16.1)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)): dependencies: schema-utils: 4.3.3 @@ -21641,6 +21753,13 @@ snapshots: ansi-styles: 5.2.0 react-is: 18.3.1 + pretty-format@30.5.1: + dependencies: + '@jest/react-is-18': react-is@18.3.1 + '@jest/react-is-19': react-is@19.3.0 + '@jest/schemas': 30.5.0 + ansi-styles: 5.2.0 + pretty-time@1.1.0: {} prism-react-renderer@2.4.1(react@19.2.8): @@ -21908,6 +22027,11 @@ snapshots: - supports-color - utf-8-validate + react-reconciler@0.34.0(react@19.3.0): + dependencies: + react: 19.3.0 + scheduler: 0.28.0 + react-refresh@0.14.2: {} react-router-config@5.1.1(react-router@5.3.4(react@19.2.8))(react@19.2.8): @@ -21999,6 +22123,11 @@ snapshots: unified: 11.0.5 vfile: 6.0.3 + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + reflect-metadata@0.2.2: {} reflect.getprototypeof@1.0.10: @@ -22735,6 +22864,10 @@ snapshots: strip-final-newline@2.0.0: {} + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + strip-json-comments@2.0.1: {} strip-json-comments@3.1.1: {} @@ -22836,6 +22969,14 @@ snapshots: glob: 7.2.3 minimatch: 3.1.5 + test-renderer@1.3.0(@types/react@19.3.0)(react@19.3.0): + dependencies: + '@types/react-reconciler': 0.33.0(@types/react@19.3.0) + react: 19.3.0 + react-reconciler: 0.34.0(react@19.3.0) + transitivePeerDependencies: + - '@types/react' + thingies@2.6.1(tslib@2.8.1): dependencies: tslib: 2.8.1 From 60dfd932e05fc90ac53c0064cd1574ed4d6499f1 Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Sun, 27 Sep 2026 06:38:54 -0400 Subject: [PATCH 2/2] test(mobile): cover the drawer menu and the live status in the boot test; queue rollbacks apart --- .github/workflows/mobile-release.yml | 2 +- apps/mobile/README.md | 6 +++++- apps/mobile/src/app-boot.test.tsx | 19 ++++++++++++++++--- 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/.github/workflows/mobile-release.yml b/.github/workflows/mobile-release.yml index b977d2973..31d4a7adb 100644 --- a/.github/workflows/mobile-release.yml +++ b/.github/workflows/mobile-release.yml @@ -23,7 +23,7 @@ permissions: contents: read concurrency: - group: mobile-release + group: ${{ (inputs.mode == 'rollback' || inputs.mode == 'republish') && 'mobile-rollback' || 'mobile-release' }} cancel-in-progress: false jobs: diff --git a/apps/mobile/README.md b/apps/mobile/README.md index c76990768..8316e76a0 100644 --- a/apps/mobile/README.md +++ b/apps/mobile/README.md @@ -825,7 +825,11 @@ Or go back to a specific update that worked: gh workflow run mobile-release.yml --ref main -f mode=republish -f group= ``` -Both runs wait for approval in the `release` environment. Approve them as in +Both runs wait for approval in the `release` environment. They queue apart +from the publishing modes, so a build in progress does not hold them up. A +publishing run that is still in progress or waiting for approval can publish +after the rollback and undo it, so cancel it first +(`gh run cancel `). Approve them as in step 7 of [RELEASE.md](../../RELEASE.md#4-cut-the-release), with the run id from `gh run list --workflow mobile-release.yml --limit 1`. The run log's notice names the runtime and group it acted on. `republish` warns when the diff --git a/apps/mobile/src/app-boot.test.tsx b/apps/mobile/src/app-boot.test.tsx index 31412824a..22d3b844a 100644 --- a/apps/mobile/src/app-boot.test.tsx +++ b/apps/mobile/src/app-boot.test.tsx @@ -47,9 +47,20 @@ jest.mock('react-native-reanimated/mock', () => { }); jest.mock('react-native-drawer-layout', () => ({ - Drawer: ({ children }: { children: React.ReactNode }) => { + Drawer: ({ + children, + renderDrawerContent, + }: { + children: React.ReactNode; + renderDrawerContent: () => React.ReactNode; + }) => { const { GestureHandlerRootView } = jest.requireActual('react-native-gesture-handler'); - return {children}; + return ( + + {renderDrawerContent()} + {children} + + ); }, useDrawerProgress: () => ({ value: 0 }), })); @@ -226,4 +237,6 @@ it('boots on the state the previous release left and renders the live machine', expect(errors).toEqual([]); expect(screen.getAllByText('Mock Mac').length).toBeGreaterThan(0); -}); + const state = JSON.parse(mockStore('stim.notifications').get('state')!) as Record; + expect(state[`status:${MAC_ID}`]?.workspaces).toBeDefined(); +}, 60_000);