Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/slim-expo-e2e-simulators.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
1 change: 1 addition & 0 deletions .claude/skills/verify-clerk-expo/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ Set up each machine once.
2. Create the iOS template simulator, which the CLI clones to make each simulator it drives, with `xcrun simctl create "Clerk Verify Template iOS" "iPhone Air"`. That makes a new simulator that holds only the system's apps. Do not clone a simulator you already use: a clone holds every app of the original, and those apps show on the home screen at the start of a video. Boot the template once and shut it down: a clone of a template that never booted takes about twenty seconds longer to boot. `doctor` says when a system HTTPS proxy needs its CA trusted there. Android needs no template: the first `up --platform android` writes the `Clerk_Verify_Pixel` AVD.
3. Give the machine the team's Clerk Platform API key. Set `CLERK_PLATFORM_API_KEY` to the key. Or set `CLERK_PLATFORM_API_KEY_FILE` to a file that holds it and that only you can read (mode 0600). Or set `VERIFY_PLATFORM_KEY_REFERENCE` to the key's 1Password secret reference, which the 1Password CLI reads once a person approves the request in the 1Password app, within 60 seconds. Never put the key or the reference in a file inside a repository.
4. Install ffmpeg (`brew install ffmpeg`), which `attach --video` uses to cut the video to the part that `host.evidence` marks. Without it, `attach` posts the whole video and says that ffmpeg is not installed.
5. Install simslim (`brew install mobai-app/tap/simslim`), so that each local iOS simulator boots without its background services and uses about a quarter of the memory. `doctor` says whether it is installed. Set `VERIFY_SIMSLIM=0` to boot stock simulators.

Then, in each worktree:

Expand Down
15 changes: 15 additions & 0 deletions .github/workflows/verify-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -123,10 +123,25 @@ jobs:
- name: Wait for the device image
wait: device-image

- name: Install simslim
if: matrix.platform == 'ios'
run: |
set -euo pipefail
archive="$RUNNER_TEMP/simslim.tar.gz"
curl --fail --silent --show-error --location --retry 3 --output "$archive" \
https://github.com/MobAI-App/simslim/releases/download/v0.12.2/simslim-v0.12.2-macos-arm64.tar.gz
echo "b47302da6fb83b8b4c21f4fdd61507469770c07b0ff07fd1f48207bda4270887 $archive" | shasum -a 256 -c -
mkdir -p "$RUNNER_TEMP/bin"
tar -xzf "$archive" -C "$RUNNER_TEMP/bin" simslim
echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH"

- name: Build the fixture and boot a device
timeout-minutes: 30
env:
CLERK_PLATFORM_API_KEY: ${{ secrets.MOBILE_VERIFICATION_PLATFORM_API_KEY }}
VERIFY_SIMSLIM: ${{ matrix.platform == 'ios' && '1' || '' }}
SIMSLIM_BOOT_TIMEOUT: 15m
SIMSLIM_SPAWN_TIMEOUT: 5m
run: |
"$CLI" up --platform "$PLATFORM" --backend local

Expand Down
46 changes: 44 additions & 2 deletions integration/expo-native/src/platform/ios/local.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
type AcquireRequest,
type DeviceBackend,
type DoctorCheck,
type DoctorCheckId,
type EvidencePath,
type LocalLease,
type Recording,
Expand Down Expand Up @@ -41,6 +42,47 @@ async function simctl(args: readonly string[], what: string): Promise<string> {
return result.stdout;
}

// VERIFY_SIMSLIM=1 requires simslim and VERIFY_SIMSLIM=0 turns it off; otherwise a lane is slimmed
// whenever simslim is installed.
export function laneBoot(flag: string | undefined, simslimInstalled: boolean): 'slim' | 'stock' {
if (flag === '1') return 'slim';
if (flag === '0') return 'stock';
return simslimInstalled ? 'slim' : 'stock';
}

async function simslimVersion(): Promise<string | null> {
const probe = await run('simslim', ['--version']);
return probe.code === 0 ? probe.stdout.trim().replace(/^simslim\s+/, '') : null;
}

// A clone does not inherit simslim's overrides, so each lane is slimmed before its first boot, when
// the background services it turns off would otherwise keep the host's cores busy for minutes.
async function boot(udid: string, name: string): Promise<void> {
if (laneBoot(process.env.VERIFY_SIMSLIM, (await simslimVersion()) !== null) === 'stock') {
await simctl(['boot', udid], `simctl boot ${name}`);
await simctl(['bootstatus', udid, '-b'], `simctl bootstatus ${name}`);
return;
}
const slim = await run('simslim', ['on', udid]);
if (slim.code !== 0) {
throw new VerifyFailure('NOT_READY', `simslim on ${name} failed: ${slim.stderr.trim() || slim.stdout.trim()}`, 'install simslim, or set VERIFY_SIMSLIM=0');
}
}

async function simslimCheck(): Promise<DoctorCheck> {
// The shared DoctorCheckId union has no simslim entry yet.
const id = 'simslim' as DoctorCheckId;
const version = await simslimVersion();
if (version === null) {
return process.env.VERIFY_SIMSLIM === '1'
? { id, ok: false, detail: 'VERIFY_SIMSLIM=1 needs simslim, and it is not installed', fix: 'brew install mobai-app/tap/simslim, or unset VERIFY_SIMSLIM' }
: { id, ok: true, state: 'warning', detail: 'not installed; lanes boot stock (brew install mobai-app/tap/simslim)' };
}
return laneBoot(process.env.VERIFY_SIMSLIM, true) === 'slim'
? { id, ok: true, detail: `slim on (simslim ${version})` }
: { id, ok: true, state: 'warning', detail: `simslim ${version} is installed; VERIFY_SIMSLIM=0 keeps lanes stock` };
}

function simulatorDataDir(udid: string): string {
return join(homedir(), 'Library', 'Developer', 'CoreSimulator', 'Devices', udid, 'data');
}
Expand Down Expand Up @@ -116,8 +158,7 @@ export function localIosBackend(options: { readonly os?: NodeJS.Platform } = {})
await deleteSimulators((d) => d.name === claim.deviceName);
request.progress(`device ${claim.deviceName} cloning ${TEMPLATE_NAME}`);
udid = (await simctl(['clone', template.udid, claim.deviceName], `simctl clone ${claim.deviceName}`)).trim();
await simctl(['boot', udid], `simctl boot ${claim.deviceName}`);
await simctl(['bootstatus', udid, '-b'], `simctl bootstatus ${claim.deviceName}`);
await boot(udid, claim.deviceName);
} catch (error) {
await clearSlot(claim, request.worktree).catch(() => undefined);
throw error;
Expand Down Expand Up @@ -228,6 +269,7 @@ export function localIosBackend(options: { readonly os?: NodeJS.Platform } = {})
xcode.code === 0
? { id: 'xcode', ok: true, detail: xcode.stdout.split('\n')[0]?.replace('Xcode ', '') ?? '' }
: { id: 'xcode', ok: false, detail: 'xcodebuild is not available', fix: 'install Xcode and run `sudo xcode-select -s /Applications/Xcode.app`' },
await simslimCheck(),
];
const template = xcode.code === 0 ? (await listSimulators()).find((d) => d.name === TEMPLATE_NAME) : undefined;
const device: DoctorCheck[] = [
Expand Down
12 changes: 10 additions & 2 deletions integration/expo-native/test/ios-doctor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { chmodSync, mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { describe, it } from 'node:test';
import { localIosBackend } from '../src/platform/ios/local.ts';
import { laneBoot, localIosBackend } from '../src/platform/ios/local.ts';

describe('iOS doctor', () => {
it('reports the missing Xcode rather than failing when xcrun simctl cannot run', async () => {
Expand All @@ -17,10 +17,18 @@ describe('iOS doctor', () => {
process.env.PATH = bin;
try {
const { toolchain, device } = await localIosBackend({ os: 'darwin' }).doctorChecks({ live: false, worktree: '/nowhere', progress: () => undefined });
assert.deepEqual(toolchain.map(({ id, ok }) => ({ id, ok })), [{ id: 'xcode', ok: false }]);
assert.deepEqual(toolchain.map(({ id, ok }) => ({ id, ok })), [{ id: 'xcode', ok: false }, { id: 'simslim', ok: true }]);
assert.deepEqual(toolchain[1], { id: 'simslim', ok: true, state: 'warning', detail: 'not installed; lanes boot stock (brew install mobai-app/tap/simslim)' });
assert.deepEqual(device.find((check) => check.id === 'lane-ports'), { id: 'lane-ports', ok: true, state: 'not-run', detail: 'not run: needs xcode' });
} finally {
process.env.PATH = path;
}
});

it('slims a lane whenever simslim is installed, unless VERIFY_SIMSLIM says otherwise', () => {
assert.equal(laneBoot(undefined, true), 'slim');
assert.equal(laneBoot(undefined, false), 'stock');
assert.equal(laneBoot('0', true), 'stock');
assert.equal(laneBoot('1', false), 'slim');
});
});
Loading