From a3eeb1065b5f6814c26673c16601d0bdfc2a6b9f Mon Sep 17 00:00:00 2001 From: Kristen Hewell Garrett Date: Wed, 20 May 2026 17:04:16 -0400 Subject: [PATCH] fix(cli): default device picker to existing simulator/AVD The interactive picker now pre-selects the most recently booted simulator (iOS) or first available AVD (Android) instead of "Create new". This matches Expo CLI's heuristic and prevents vitest-mobile from hijacking the default simulator that other tools rely on. Also remove the need to include cleanup() in the test file, it's automatically included now. --- .changeset/default-to-existing-device.md | 19 ++++++ .../vitest-mobile/src/cli/device-picker.ts | 68 +++++++++---------- packages/vitest-mobile/src/node/device/ios.ts | 2 +- test-packages/counter/tests/counter.test.tsx | 8 +-- .../greeting/tests/greeting.test.tsx | 8 +-- .../todo-list/tests/todo-list.test.tsx | 8 +-- test-packages/toggle/tests/toggle.test.tsx | 8 +-- 7 files changed, 62 insertions(+), 59 deletions(-) create mode 100644 .changeset/default-to-existing-device.md diff --git a/.changeset/default-to-existing-device.md b/.changeset/default-to-existing-device.md new file mode 100644 index 0000000..eda6ec6 --- /dev/null +++ b/.changeset/default-to-existing-device.md @@ -0,0 +1,19 @@ +--- +"vitest-mobile": patch +--- + +Default the device picker to an existing simulator/AVD instead of "Create new" + +The interactive device picker (used by `bootstrap` and `boot-device`) now +defaults to an existing device rather than prompting to create a dedicated +one. On iOS the pre-selected device is the most recently booted simulator, +matching Expo CLI's heuristic; on Android it's the first available AVD. + +"Create new dedicated simulator/AVD" is still available at the bottom of +the list for users who want isolation. The non-interactive (CI) fallback +is unchanged — it still auto-creates a project-scoped device. + +This prevents vitest-mobile from stealing Expo CLI's default simulator: +previously, creating and booting a `VitestMobile-*` sim made it macOS +Simulator.app's "most recently used" device, so Expo would target it +on the next `expo start` → `i` press. diff --git a/packages/vitest-mobile/src/cli/device-picker.ts b/packages/vitest-mobile/src/cli/device-picker.ts index 5608ecc..a2798f5 100644 --- a/packages/vitest-mobile/src/cli/device-picker.ts +++ b/packages/vitest-mobile/src/cli/device-picker.ts @@ -5,6 +5,11 @@ * the device deterministically from the project path (matches the previous * `VitestMobile-` / `vitest-mobile-` pattern). * + * Defaults to an existing device — on iOS the most recently booted + * simulator (matching Expo CLI's heuristic), on Android the first + * available AVD. "Create new" appears at the end of the list for users + * who want a dedicated device. + * * The result feeds into the device-mapping store; subsequent test runs * read that mapping and don't re-prompt. * @@ -15,7 +20,7 @@ */ import { isCancel, select } from '@clack/prompts'; -import { listAllIOSSimulators, primarySimulatorName } from '../node/device/ios'; +import { getBootedSimulators, listAllIOSSimulators, primarySimulatorName } from '../node/device/ios'; import { listAllAvds, avdNameForProject, hasAvdProvisioningTools } from '../node/device/android'; import { getDeviceMapping, setDeviceMapping, type DeviceMapping } from '../node/device/mapping'; import type { Platform } from '../node/types'; @@ -55,31 +60,32 @@ async function pickIOS(appDir: string, currentChoice?: string): Promise !s.name.startsWith('VitestMobile-') || s.name === projectSim) + .sort((a, b) => a.name.localeCompare(b.name)) + .map(s => ({ value: s.name, label: s.name, hint: s.runtime })); + const options = [ + ...existingSims, { value: CREATE_NEW, label: `Create new dedicated simulator (${projectSim})`, - hint: 'recommended', }, - ...sims - // Hide auto-created simulators for *other* projects — they'd be - // confusing to pick. - .filter(s => !s.name.startsWith('VitestMobile-') || s.name === projectSim) - .sort((a, b) => a.name.localeCompare(b.name)) - .map(s => ({ value: s.name, label: s.name, hint: s.runtime })), ]; - // Preference for the pre-selected option: - // 1. The currently-mapped device (user's "keep the same" default). - // 2. The existing project simulator (for pre-mapping migrations). - // 3. Otherwise prompt to create a new one. - const projectExists = sims.some(s => s.name === projectSim); + // Default to the most recently booted simulator (matches Expo's heuristic), + // falling back to the first available simulator, then "Create new" only if + // the host has no simulators at all. + const booted = getBootedSimulators(); + const firstBooted = booted.find(b => existingSims.some(s => s.value === b.name)); const initialValue = currentChoice && options.some(o => o.value === currentChoice) ? currentChoice - : projectExists - ? projectSim - : CREATE_NEW; + : firstBooted + ? firstBooted.name + : existingSims.length > 0 + ? existingSims[0]!.value + : CREATE_NEW; const choice = await select({ message: 'Which simulator should vitest-mobile use for this project?', @@ -89,8 +95,6 @@ async function pickIOS(appDir: string, currentChoice?: string): Promise (!a.startsWith('vitest-mobile-') && a !== 'vitest-mobile') || a === projectAvd) + .sort() + .map(a => ({ value: a, label: a })); + + const createHint = canCreate ? undefined : 'requires Android cmdline-tools (sdkmanager + avdmanager)'; const options = [ + ...existingAvds, { value: CREATE_NEW, label: `Create new dedicated AVD (${projectAvd})`, hint: createHint, }, - ...avds - .filter(a => (!a.startsWith('vitest-mobile-') && a !== 'vitest-mobile') || a === projectAvd) - .sort() - .map(a => ({ value: a, label: a })), ]; - // Preference for the pre-selected option: - // 1. Currently-mapped device (user's "keep the same" default). - // 2. Existing project AVD. - // 3. "Create new" when we can actually create one. - // 4. First existing AVD otherwise. + // Default to the first existing AVD (matches the "use what you already have" + // convention), falling back to "Create new" only if no AVDs exist. const initialValue = currentChoice && options.some(o => o.value === currentChoice) ? currentChoice - : existing - ? projectAvd - : canCreate - ? CREATE_NEW - : (avds[0] ?? CREATE_NEW); + : existingAvds.length > 0 + ? existingAvds[0]!.value + : CREATE_NEW; const choice = await select({ message: 'Which AVD should vitest-mobile use for this project?', diff --git a/packages/vitest-mobile/src/node/device/ios.ts b/packages/vitest-mobile/src/node/device/ios.ts index 523c77e..09bd2f7 100644 --- a/packages/vitest-mobile/src/node/device/ios.ts +++ b/packages/vitest-mobile/src/node/device/ios.ts @@ -142,7 +142,7 @@ export function getBootedSimulator(): string | null { return info?.udid ?? null; } -function getBootedSimulators(excludeIds: string[] = []): SimulatorInfo[] { +export function getBootedSimulators(excludeIds: string[] = []): SimulatorInfo[] { const json = run('xcrun simctl list devices booted -j'); if (!json) return []; const devices = parseSimctlDevicesJson(json); diff --git a/test-packages/counter/tests/counter.test.tsx b/test-packages/counter/tests/counter.test.tsx index 826d6ea..5991be7 100644 --- a/test-packages/counter/tests/counter.test.tsx +++ b/test-packages/counter/tests/counter.test.tsx @@ -1,12 +1,8 @@ import React from 'react'; -import { describe, it, expect, afterEach } from 'vitest'; -import { render, cleanup, waitFor } from 'vitest-mobile/runtime'; +import { describe, it, expect } from 'vitest'; +import { render, waitFor } from 'vitest-mobile/runtime'; import { CounterModule } from '../CounterModule'; -afterEach(async () => { - await cleanup(); -}); - describe('CounterModule', () => { it('renders initial count of zero', async () => { const screen = await render(); diff --git a/test-packages/greeting/tests/greeting.test.tsx b/test-packages/greeting/tests/greeting.test.tsx index 1fe6ccd..5af5dd9 100644 --- a/test-packages/greeting/tests/greeting.test.tsx +++ b/test-packages/greeting/tests/greeting.test.tsx @@ -1,12 +1,8 @@ import React from 'react'; -import { describe, it, expect, afterEach } from 'vitest'; -import { render, cleanup } from 'vitest-mobile/runtime'; +import { describe, it, expect } from 'vitest'; +import { render } from 'vitest-mobile/runtime'; import { GreetingModule } from '../GreetingModule'; -afterEach(async () => { - await cleanup(); -}); - describe('GreetingModule', () => { it('shows placeholder when no name is entered', async () => { const screen = await render(); diff --git a/test-packages/todo-list/tests/todo-list.test.tsx b/test-packages/todo-list/tests/todo-list.test.tsx index 93c93a9..d848367 100644 --- a/test-packages/todo-list/tests/todo-list.test.tsx +++ b/test-packages/todo-list/tests/todo-list.test.tsx @@ -1,12 +1,8 @@ import React from 'react'; -import { describe, it, expect, afterEach } from 'vitest'; -import { render, cleanup } from 'vitest-mobile/runtime'; +import { describe, it, expect } from 'vitest'; +import { render } from 'vitest-mobile/runtime'; import { TodoListModule } from '../TodoListModule'; -afterEach(async () => { - await cleanup(); -}); - describe('TodoListModule', () => { it('shows empty message when no items', async () => { const screen = await render(); diff --git a/test-packages/toggle/tests/toggle.test.tsx b/test-packages/toggle/tests/toggle.test.tsx index f5afa40..d3816d0 100644 --- a/test-packages/toggle/tests/toggle.test.tsx +++ b/test-packages/toggle/tests/toggle.test.tsx @@ -1,12 +1,8 @@ import React from 'react'; -import { describe, it, expect, afterEach } from 'vitest'; -import { render, cleanup, waitFor } from 'vitest-mobile/runtime'; +import { describe, it, expect } from 'vitest'; +import { render, waitFor } from 'vitest-mobile/runtime'; import { ToggleModule } from '../ToggleModule'; -afterEach(async () => { - await cleanup(); -}); - describe('ToggleModule', () => { it('renders in off state by default', async () => { const screen = await render();