diff --git a/packages/cap-vite/README.md b/packages/cap-vite/README.md index 3c6ea01ba..dd46a5d2d 100644 --- a/packages/cap-vite/README.md +++ b/packages/cap-vite/README.md @@ -20,6 +20,7 @@ pnpm -F @proj-airi/stage-pocket run dev:ios -- --target --list --json`. - `cap-vite` always launches the Vite dev server. Do not pass `vite dev` or `vite serve` as extra args. - After the dev server starts, press `R` in the terminal to re-run `cap run` without restarting Vite. diff --git a/packages/cap-vite/src/native.test.ts b/packages/cap-vite/src/native.test.ts index 934ff0211..f25a8d520 100644 --- a/packages/cap-vite/src/native.test.ts +++ b/packages/cap-vite/src/native.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { hasCapacitorTargetArg, parseCapacitorPlatform, pickServerUrl, resolveCapRunArgs, shouldRestartForNativeChange } from './native' @@ -43,40 +43,82 @@ describe('pickServerUrl', () => { }) describe('resolveCapRunArgs', () => { - it('keeps an explicit --target argument untouched', () => { - expect(resolveCapRunArgs( + it('keeps an explicit --target argument untouched', async () => { + await expect(resolveCapRunArgs( ['ios', '--target', 'iPhone 16 Pro', '--scheme', 'AIRI'], { CAPACITOR_DEVICE_ID_IOS: 'ignored-device' }, - )).toEqual(['ios', '--target', 'iPhone 16 Pro', '--scheme', 'AIRI']) + )).resolves.toEqual(['ios', '--target', 'iPhone 16 Pro', '--scheme', 'AIRI']) }) - it('injects --target from CAPACITOR_DEVICE_ID_ANDROID when it is missing', () => { - expect(resolveCapRunArgs( + it('injects --target from CAPACITOR_DEVICE_ID_ANDROID when it is missing', async () => { + await expect(resolveCapRunArgs( ['android', '--flavor', 'release'], { CAPACITOR_DEVICE_ID_ANDROID: 'emulator-5554' }, - )).toEqual(['android', '--target', 'emulator-5554', '--flavor', 'release']) + )).resolves.toEqual(['android', '--target', 'emulator-5554', '--flavor', 'release']) }) - it('injects --target from CAPACITOR_DEVICE_ID_IOS when it is missing', () => { - expect(resolveCapRunArgs( + it('injects --target from CAPACITOR_DEVICE_ID_IOS when it is missing', async () => { + await expect(resolveCapRunArgs( ['ios', '--scheme', 'AIRI'], { CAPACITOR_DEVICE_ID_IOS: 'iPhone 16 Pro' }, - )).toEqual(['ios', '--target', 'iPhone 16 Pro', '--scheme', 'AIRI']) + )).resolves.toEqual(['ios', '--target', 'iPhone 16 Pro', '--scheme', 'AIRI']) }) - it('does not use the other platform device id', () => { - expect(resolveCapRunArgs( + it('does not use the other platform device id', async () => { + const listTargets = vi.fn(async () => [ + { id: 'ios-device' }, + ]) + + await expect(resolveCapRunArgs( ['ios'], { CAPACITOR_DEVICE_ID_ANDROID: 'emulator-5554' }, - )).toEqual(['ios']) + listTargets, + )).resolves.toEqual(['ios', '--target', 'ios-device']) }) - it('supports the --target=value form when checking existing args', () => { + it('supports the --target=value form when checking existing args', async () => { expect(hasCapacitorTargetArg(['android', '--target=emulator-5554'])).toBe(true) - expect(resolveCapRunArgs( + await expect(resolveCapRunArgs( ['android', '--target=emulator-5554', '--flavor', 'release'], { CAPACITOR_DEVICE_ID_ANDROID: 'ignored-device' }, - )).toEqual(['android', '--target=emulator-5554', '--flavor', 'release']) + )).resolves.toEqual(['android', '--target=emulator-5554', '--flavor', 'release']) + }) + + it('injects the first listed device when --target and platform device env are missing', async () => { + const listTargets = vi.fn(async () => [ + { id: 'first-device' }, + { id: 'second-device' }, + ]) + + await expect(resolveCapRunArgs( + ['android', '--flavor', 'release'], + {}, + listTargets, + )).resolves.toEqual(['android', '--target', 'first-device', '--flavor', 'release']) + expect(listTargets).toHaveBeenCalledWith('android') + }) + + it('prefers platform device env over the first listed device', async () => { + const listTargets = vi.fn(async () => [ + { id: 'first-device' }, + ]) + + await expect(resolveCapRunArgs( + ['ios'], + { CAPACITOR_DEVICE_ID_IOS: 'configured-device' }, + listTargets, + )).resolves.toEqual(['ios', '--target', 'configured-device']) + expect(listTargets).not.toHaveBeenCalled() + }) + + it('throws when no default device target is available', async () => { + const listTargets = vi.fn(async () => []) + + await expect(resolveCapRunArgs( + ['ios'], + {}, + listTargets, + )).rejects.toThrow('No ios devices or simulators found.') }) }) diff --git a/packages/cap-vite/src/native.ts b/packages/cap-vite/src/native.ts index 559ffe2ee..851dbc497 100644 --- a/packages/cap-vite/src/native.ts +++ b/packages/cap-vite/src/native.ts @@ -4,8 +4,16 @@ import process from 'node:process' import { basename, extname, relative, resolve, sep } from 'node:path' +import { x } from 'tinyexec' + export type CapacitorPlatform = 'android' | 'ios' +interface CapacitorTarget { + id?: string +} + +type ListCapacitorTargets = (platform: CapacitorPlatform) => Promise + const nativeExtensionsByPlatform: Record> = { ios: new Set([ '.entitlements', @@ -84,7 +92,42 @@ export function hasCapacitorTargetArg(capArgs: string[]): boolean { return capArgs.some((arg, index) => arg === '--target' || (index > 0 && arg.startsWith('--target='))) } -export function resolveCapRunArgs(capArgs: string[], env: NodeJS.ProcessEnv = process.env): string[] { +function parseCapacitorTargetList(value: string): CapacitorTarget[] { + const parsed = JSON.parse(value) + if (!Array.isArray(parsed)) { + throw new TypeError('Expected `cap run --list --json` to return a JSON array.') + } + + return parsed + .filter((target): target is CapacitorTarget => typeof target === 'object' && target !== null && typeof (target as CapacitorTarget).id === 'string') +} + +async function listCapacitorTargets(platform: CapacitorPlatform): Promise { + const output = await x('cap', ['run', platform, '--list', '--json']) + + return parseCapacitorTargetList(output.stdout) +} + +/** + * Resolves Capacitor run arguments by applying target defaults. + * + * Use when: + * - `cap-vite` is about to run `cap run` + * - Callers want env-based device IDs before falling back to the first available target + * + * Expects: + * - `capArgs[0]` is already validated as `ios` or `android` by the CLI boundary + * - Explicit `--target` arguments must stay untouched so Capacitor can validate them + * + * Returns: + * - The original args when a target is explicit + * - Args with `--target` injected from env or the first listed Capacitor target + */ +export async function resolveCapRunArgs( + capArgs: string[], + env: NodeJS.ProcessEnv = process.env, + listTargets: ListCapacitorTargets = listCapacitorTargets, +): Promise { if (capArgs.length === 0 || hasCapacitorTargetArg(capArgs)) { return capArgs } @@ -100,7 +143,16 @@ export function resolveCapRunArgs(capArgs: string[], env: NodeJS.ProcessEnv = pr } if (!target) { - return capArgs + if (!platform) { + return capArgs + } + + const targets = await listTargets(platform) + target = targets.find(device => device.id)?.id + } + + if (!target) { + throw new Error(`No ${platform} devices or simulators found. Connect a device, start a simulator or emulator, or pass --target explicitly.`) } return [platformArg, '--target', target, ...rest] diff --git a/packages/cap-vite/src/vite-plugin.test.ts b/packages/cap-vite/src/vite-plugin.test.ts index ac63a876e..6f3d470d6 100644 --- a/packages/cap-vite/src/vite-plugin.test.ts +++ b/packages/cap-vite/src/vite-plugin.test.ts @@ -82,7 +82,7 @@ function createMockStdin() { return stdin } -function configurePluginServer(plugin: Plugin, server: ReturnType) { +async function configurePluginServer(plugin: Plugin, server: ReturnType) { const configureServer = plugin.configureServer if (!configureServer) { throw new Error('cap-vite plugin is missing configureServer().') @@ -92,7 +92,7 @@ function configurePluginServer(plugin: Plugin, server: ReturnType { const { capVitePlugin } = await import('./vite-plugin') const server = createMockServer() - configurePluginServer(capVitePlugin({ - capArgs: ['ios', '--scheme', 'AIRI'], + await configurePluginServer(capVitePlugin({ + capArgs: ['ios', '--target', 'iPhone 16 Pro', '--scheme', 'AIRI'], }), server) server.httpServer.emit('listening') expect(emitKeypressEvents).toHaveBeenCalledWith(stdin) expect(stdin.setRawMode).toHaveBeenCalledWith(true) - expect(x).toHaveBeenNthCalledWith(1, 'cap', ['run', 'ios', '--scheme', 'AIRI'], { + expect(x).toHaveBeenNthCalledWith(1, 'cap', ['run', 'ios', '--target', 'iPhone 16 Pro', '--scheme', 'AIRI'], { nodeOptions: { cwd: '/repo/app', env: { @@ -146,8 +146,8 @@ describe('capVitePlugin', () => { await vi.waitFor(() => { expect(firstRun.kill).toHaveBeenCalledWith('SIGINT') - expect(server.config.logger.info).toHaveBeenCalledWith('[cap-vite] manual restart requested. Re-running "cap run ios --scheme AIRI".') - expect(x).toHaveBeenNthCalledWith(2, 'cap', ['run', 'ios', '--scheme', 'AIRI'], { + expect(server.config.logger.info).toHaveBeenCalledWith('[cap-vite] manual restart requested. Re-running "cap run ios --target iPhone 16 Pro --scheme AIRI".') + expect(x).toHaveBeenNthCalledWith(2, 'cap', ['run', 'ios', '--target', 'iPhone 16 Pro', '--scheme', 'AIRI'], { nodeOptions: { cwd: '/repo/app', env: { @@ -180,8 +180,8 @@ describe('capVitePlugin', () => { const { capVitePlugin } = await import('./vite-plugin') const server = createMockServer() - configurePluginServer(capVitePlugin({ - capArgs: ['android'], + await configurePluginServer(capVitePlugin({ + capArgs: ['android', '--target', 'emulator-5554'], }), server) server.httpServer.emit('listening') diff --git a/packages/cap-vite/src/vite-plugin.ts b/packages/cap-vite/src/vite-plugin.ts index 53329afeb..5549d2678 100644 --- a/packages/cap-vite/src/vite-plugin.ts +++ b/packages/cap-vite/src/vite-plugin.ts @@ -102,8 +102,7 @@ function bindCapViteShortcuts( } export function capVitePlugin(options: CapVitePluginOptions): Plugin { - const resolvedCapArgs = resolveCapRunArgs(options.capArgs) - const platform = parseCapacitorPlatform(resolvedCapArgs[0]) + const platform = parseCapacitorPlatform(options.capArgs[0]) if (!platform) { throw new Error('The first `cap run` argument must be `ios` or `android`.') } @@ -112,7 +111,8 @@ export function capVitePlugin(options: CapVitePluginOptions): Plugin { return { apply: 'serve', name: 'cap-vite:run-capacitor', - configureServer(server) { + async configureServer(server) { + const resolvedCapArgs = await resolveCapRunArgs(options.capArgs) const cwd = resolve(server.config.root) const platformRoot = resolve(cwd, resolvedPlatform) const debounceMs = 300