feat(cap-vite): auto select the first device when no target specified

This commit is contained in:
LemonNeko
2026-06-29 14:51:43 +08:00
parent de9a2bac7f
commit f8d3e3db2c
5 changed files with 125 additions and 30 deletions
+1
View File
@@ -20,6 +20,7 @@ pnpm -F @proj-airi/stage-pocket run dev:ios -- --target <DEVICE_ID_OR_SIMULATOR_
- Arguments before `--` are forwarded to `vite`.
- Arguments after `--` are forwarded to `cap run`.
- If the platform-specific env is set (`CAPACITOR_DEVICE_ID_IOS` or `CAPACITOR_DEVICE_ID_ANDROID`) and `cap run` args do not contain `--target`, `cap-vite` injects `--target` with that value automatically.
- If no `--target` argument or platform-specific env is set, `cap-vite` uses the first target from `cap run <platform> --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.
+58 -16
View File
@@ -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.')
})
})
+54 -2
View File
@@ -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<readonly CapacitorTarget[]>
const nativeExtensionsByPlatform: Record<CapacitorPlatform, Set<string>> = {
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<CapacitorTarget[]> {
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<string[]> {
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]
+9 -9
View File
@@ -82,7 +82,7 @@ function createMockStdin() {
return stdin
}
function configurePluginServer(plugin: Plugin, server: ReturnType<typeof createMockServer>) {
async function configurePluginServer(plugin: Plugin, server: ReturnType<typeof createMockServer>) {
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<typeof createM
? configureServer
: configureServer.handler
handler.call({} as any, server as any)
await handler.call({} as any, server as any)
}
const originalStdin = Object.getOwnPropertyDescriptor(process, 'stdin')
@@ -123,15 +123,15 @@ describe('capVitePlugin', () => {
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')
+3 -3
View File
@@ -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