refactor(cap-vite): use plugin instead of createServer
This commit is contained in:
@@ -16,7 +16,7 @@
|
||||
"preview": "vite preview",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
"dev:web": "vite",
|
||||
"dev:ios": "cap-vite ios"
|
||||
"dev:ios": "cap-vite -- ios"
|
||||
},
|
||||
"dependencies": {
|
||||
"@capacitor/core": "catalog:",
|
||||
|
||||
+28
-14
@@ -5,25 +5,32 @@ CLI for [Capacitor](https://capacitorjs.com/) live-reload development using Vite
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
pnpm cap-vite ios --target <DEVICE_ID_OR_SIMULATOR_NAME>
|
||||
pnpm cap-vite android --target <DEVICE_ID_OR_SIMULATOR_NAME>
|
||||
pnpm cap-vite ios --target <DEVICE_ID_OR_SIMULATOR_NAME> -- --scheme AIRI
|
||||
pnpm cap-vite android --target <DEVICE_ID_OR_SIMULATOR_NAME> -- --flavor release
|
||||
# Or
|
||||
CAPACITOR_DEVICE_ID=<DEVICE_ID_OR_SIMULATOR_NAME> pnpm cap-vite ios
|
||||
CAPACITOR_DEVICE_ID=<DEVICE_ID_OR_SIMULATOR_NAME> pnpm cap-vite android
|
||||
cap-vite [vite args...] -- <ios|android> [cap run args...]
|
||||
```
|
||||
|
||||
- Arguments after `--` are forwarded to `cap run`, example: `pnpm cap-vite ios --target <DEVICE_ID_OR_SIMULATOR_NAME> -- --scheme AIRI` will run `cap run ios --target <DEVICE_ID_OR_SIMULATOR_NAME> --scheme AIRI`.
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
pnpm exec cap-vite -- ios --target <DEVICE_ID_OR_SIMULATOR_NAME>
|
||||
pnpm exec cap-vite -- --host 0.0.0.0 --port 5173 -- android --target <DEVICE_ID_OR_SIMULATOR_NAME> --flavor release
|
||||
CAPACITOR_DEVICE_ID=<DEVICE_ID_OR_SIMULATOR_NAME> pnpm exec cap-vite -- ios
|
||||
pnpm -F @proj-airi/stage-pocket run dev:ios -- --target <DEVICE_ID_OR_SIMULATOR_NAME>
|
||||
```
|
||||
|
||||
- Arguments before `--` are forwarded to `vite`.
|
||||
- Arguments after `--` are forwarded to `cap run`.
|
||||
- If `CAPACITOR_DEVICE_ID` is set and `cap run` args do not contain `--target`, `cap-vite` injects `--target <CAPACITOR_DEVICE_ID>` automatically.
|
||||
- `cap-vite` always launches the Vite dev server. Do not pass `vite dev` or `vite serve` as extra args.
|
||||
|
||||
You can see the list of available devices and simulators by running `pnpm exec cap run ios --list` or `pnpm exec cap run android --list`.
|
||||
|
||||
## Capacitor Configuration
|
||||
|
||||
You need to set `server.url` in `capacitor.config.ts` to the env variable `CAPACITOR_DEV_SERVER_URL`.
|
||||
You need to set `server.url` in `capacitor.config.ts` to the env variable `CAPACITOR_DEV_SERVER_URL`, then the cli will handle rest for you.
|
||||
|
||||
```ts
|
||||
const serverURL = env.CAPACITOR_DEV_SERVER_URL
|
||||
const isCleartext = serverURL?.startsWith('http://') ?? false
|
||||
|
||||
const config: CapacitorConfig = {
|
||||
appId: 'com.example.app',
|
||||
@@ -32,7 +39,7 @@ const config: CapacitorConfig = {
|
||||
server: serverURL
|
||||
? {
|
||||
url: serverURL,
|
||||
cleartext: false,
|
||||
cleartext: isCleartext,
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
@@ -40,8 +47,15 @@ const config: CapacitorConfig = {
|
||||
export default config
|
||||
```
|
||||
|
||||
## What It Does
|
||||
## Why we need this?
|
||||
|
||||
- Starts the project's own Vite config through the Vite API.
|
||||
- Executes the local `cap` binary via `tinyexec`.
|
||||
- Watches native files under `ios/` or `android/` and re-runs `cap run` after a small debounce.
|
||||
- No need to care what `server.url` should be, it will be automatically set to the correct value.
|
||||
- Rerun native app when native code changes, you won't forget to start it.
|
||||
- No need to open two terminals to run the project, you can run it with one command.
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
- Vite arguments are left to the real Vite CLI instead of being reimplemented inside `cap-vite`.
|
||||
- `cap-vite` injects a wrapper config so it can append its own Vite plugin without editing the user's existing `vite.config.*`.
|
||||
- The injected plugin reads `server.resolvedUrls`, starts `cap run`, and restarts it when files under the native platform directory change.
|
||||
- `cap-vite` only splits the two argument groups and passes the `cap run` arguments into the injected plugin through environment variables.
|
||||
|
||||
@@ -3,15 +3,19 @@
|
||||
import process from 'node:process'
|
||||
|
||||
import { runCapVite } from '..'
|
||||
import { parseCapViteCliArgs } from '../cli'
|
||||
import { getCapViteCliHelpText, parseCapViteCliArgs } from '../cli'
|
||||
|
||||
async function main() {
|
||||
const parsed = parseCapViteCliArgs(process.argv.slice(2))
|
||||
if (!parsed) {
|
||||
process.stdout.write(`${getCapViteCliHelpText()}\n`)
|
||||
return
|
||||
}
|
||||
|
||||
await runCapVite(parsed.platform, parsed.target, { capArgs: parsed.capArgs })
|
||||
const result = await runCapVite(parsed.viteArgs, parsed.capArgs)
|
||||
if (typeof result.exitCode === 'number') {
|
||||
process.exitCode = result.exitCode
|
||||
}
|
||||
}
|
||||
|
||||
void main().catch((error) => {
|
||||
|
||||
@@ -3,39 +3,39 @@ import { describe, expect, it } from 'vitest'
|
||||
import { parseCapViteCliArgs } from './cli'
|
||||
|
||||
describe('parseCapViteCliArgs', () => {
|
||||
it('parses target and cap args after double dashes', () => {
|
||||
expect(parseCapViteCliArgs(['ios', '--target', 'iPhone 16 Pro', '--', '--flavor', 'release'])).toEqual({
|
||||
capArgs: ['--flavor', 'release'],
|
||||
platform: 'ios',
|
||||
target: 'iPhone 16 Pro',
|
||||
it('splits vite args from cap run args', () => {
|
||||
expect(parseCapViteCliArgs(['--host', '0.0.0.0', '--port', '5173', '--', 'ios', '--target', 'iPhone 16 Pro'])).toEqual({
|
||||
capArgs: ['ios', '--target', 'iPhone 16 Pro'],
|
||||
viteArgs: ['--host', '0.0.0.0', '--port', '5173'],
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to CAPACITOR_DEVICE_ID when --target is omitted', () => {
|
||||
expect(parseCapViteCliArgs(['android'], { CAPACITOR_DEVICE_ID: 'emulator-5554' })).toEqual({
|
||||
capArgs: [],
|
||||
platform: 'android',
|
||||
target: 'emulator-5554',
|
||||
it('returns null for help output', () => {
|
||||
expect(parseCapViteCliArgs(['--help'])).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps cap run args untouched after double dashes', () => {
|
||||
expect(parseCapViteCliArgs(['--mode', 'release', '--', 'android', '--target', 'emulator-5554', '--flavor', 'release'])).toEqual({
|
||||
capArgs: ['android', '--target', 'emulator-5554', '--flavor', 'release'],
|
||||
viteArgs: ['--mode', 'release'],
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps cap args untouched even when they look like wrapper flags', () => {
|
||||
expect(parseCapViteCliArgs(['android', '--target', 'emulator-5554', '--', '--mode', 'release'])).toEqual({
|
||||
capArgs: ['--mode', 'release'],
|
||||
platform: 'android',
|
||||
target: 'emulator-5554',
|
||||
})
|
||||
it('rejects invocations without the cap run separator', () => {
|
||||
expect(() => parseCapViteCliArgs(['ios', '--target', 'iPhone 16 Pro'])).toThrow(
|
||||
'cap-vite [vite args...] -- <ios|android> [cap run args...]',
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects unknown wrapper options before double dashes', () => {
|
||||
expect(() => parseCapViteCliArgs(['ios', '--target', 'iPhone 16 Pro', '--mode', 'mobile'])).toThrow(/Unknown option `--mode`/)
|
||||
})
|
||||
|
||||
it('allows only one positional argument', () => {
|
||||
expect(() => parseCapViteCliArgs(['ios', 'iPhone 16 Pro'])).toThrow('cap-vite <ios|android> [--target <DEVICE_ID_OR_SIMULATOR_NAME>] [-- <cap args...>]')
|
||||
it('rejects invocations without a platform after the separator', () => {
|
||||
expect(() => parseCapViteCliArgs(['--host', '0.0.0.0', '--'])).toThrow(
|
||||
'cap-vite [vite args...] -- <ios|android> [cap run args...]',
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects unsupported platforms', () => {
|
||||
expect(() => parseCapViteCliArgs(['web', '--target', 'chrome'])).toThrow('cap-vite <ios|android> [--target <DEVICE_ID_OR_SIMULATOR_NAME>] [-- <cap args...>]')
|
||||
expect(() => parseCapViteCliArgs(['--host', '0.0.0.0', '--', 'web', '--target', 'chrome'])).toThrow(
|
||||
'cap-vite [vite args...] -- <ios|android> [cap run args...]',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,63 +1,55 @@
|
||||
import type { CapacitorPlatform } from '.'
|
||||
const usage = 'cap-vite [vite args...] -- <ios|android> [cap run args...]'
|
||||
|
||||
import process from 'node:process'
|
||||
|
||||
import { cac } from 'cac'
|
||||
const helpText = [
|
||||
'Run a Vite dev server and forward a second argument group to `cap run`.',
|
||||
'',
|
||||
'Usage:',
|
||||
` ${usage}`,
|
||||
'',
|
||||
'Examples:',
|
||||
' cap-vite -- ios --target "iPhone 16 Pro"',
|
||||
' cap-vite --host 0.0.0.0 --port 5173 -- android --target emulator-5554 --flavor release',
|
||||
'',
|
||||
'Notes:',
|
||||
' Arguments before `--` are forwarded to Vite.',
|
||||
' Arguments after `--` are forwarded to `cap run`.',
|
||||
].join('\n')
|
||||
|
||||
export interface ParsedCapViteCliArgs {
|
||||
capArgs: string[]
|
||||
platform: CapacitorPlatform
|
||||
target: string
|
||||
viteArgs: string[]
|
||||
}
|
||||
|
||||
const usage = 'cap-vite <ios|android> [--target <DEVICE_ID_OR_SIMULATOR_NAME>] [-- <cap args...>]'
|
||||
|
||||
function createCapViteCli() {
|
||||
const cli = cac('cap-vite')
|
||||
|
||||
cli.help()
|
||||
cli
|
||||
.command('<platform>', 'Run Capacitor with a Vite dev server')
|
||||
.usage(usage)
|
||||
.option('--target <target>', 'Set the Capacitor device target')
|
||||
.example('cap-vite ios --target "iPhone 16 Pro" -- --scheme AIRI')
|
||||
.example('CAPACITOR_DEVICE_ID=emulator-5554 cap-vite android -- --flavor release')
|
||||
|
||||
return cli
|
||||
export function getCapViteCliHelpText(): string {
|
||||
return helpText
|
||||
}
|
||||
|
||||
export function parseCapViteCliArgs(
|
||||
argv: string[],
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): ParsedCapViteCliArgs | null {
|
||||
const cli = createCapViteCli()
|
||||
const parsed = cli.parse(['node', 'cap-vite', ...argv], { run: false })
|
||||
export function getCapViteCliUsage(): string {
|
||||
return usage
|
||||
}
|
||||
|
||||
if (cli.options.help) {
|
||||
export function parseCapViteCliArgs(argv: string[]): ParsedCapViteCliArgs | null {
|
||||
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
|
||||
return null
|
||||
}
|
||||
|
||||
cli.matchedCommand?.checkUnknownOptions()
|
||||
cli.matchedCommand?.checkOptionValue()
|
||||
cli.matchedCommand?.checkRequiredArgs()
|
||||
|
||||
if (parsed.args.length > 1) {
|
||||
const separatorIndex = argv.indexOf('--')
|
||||
if (separatorIndex === -1) {
|
||||
throw new Error(usage)
|
||||
}
|
||||
|
||||
const platform = parsed.args[0]
|
||||
const capArgs = argv.slice(separatorIndex + 1)
|
||||
if (capArgs.length === 0) {
|
||||
throw new Error(usage)
|
||||
}
|
||||
|
||||
const platform = capArgs[0]
|
||||
if (platform !== 'android' && platform !== 'ios') {
|
||||
throw new Error(usage)
|
||||
}
|
||||
|
||||
const target = typeof parsed.options.target === 'string' ? parsed.options.target : env.CAPACITOR_DEVICE_ID
|
||||
if (!target) {
|
||||
throw new Error(usage)
|
||||
}
|
||||
|
||||
return {
|
||||
capArgs: Array.isArray(parsed.options['--']) ? parsed.options['--'] : [],
|
||||
platform,
|
||||
target,
|
||||
capArgs,
|
||||
viteArgs: argv.slice(0, separatorIndex),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
import { runCapVite } from './index'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { createServer, x } = vi.hoisted(() => ({
|
||||
createServer: vi.fn(),
|
||||
const { x } = vi.hoisted(() => ({
|
||||
x: vi.fn(),
|
||||
}))
|
||||
|
||||
@@ -11,68 +10,70 @@ vi.mock('tinyexec', () => ({
|
||||
x,
|
||||
}))
|
||||
|
||||
vi.mock('vite', () => ({
|
||||
createServer,
|
||||
}))
|
||||
describe('prepareCapViteLaunch', () => {
|
||||
it('captures the user config while forwarding the remaining vite args', async () => {
|
||||
const { prepareCapViteLaunch } = await import('./index')
|
||||
|
||||
expect(prepareCapViteLaunch(['--host', '0.0.0.0', '--config', 'vite.mobile.ts', '--configLoader', 'runner'])).toEqual({
|
||||
baseConfigFile: resolve(process.cwd(), 'vite.mobile.ts'),
|
||||
configLoader: 'runner',
|
||||
projectRoot: process.cwd(),
|
||||
viteArgs: ['--host', '0.0.0.0', '--configLoader', 'runner'],
|
||||
wrapperConfigFile: expect.stringMatching(/packages\/cap-vite\/(src|dist)\/vite-wrapper-config\.(ts|mjs)$/),
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the leading positional vite root as the wrapper root', async () => {
|
||||
const { prepareCapViteLaunch } = await import('./index')
|
||||
|
||||
expect(prepareCapViteLaunch(['apps/stage-pocket', '--host', '0.0.0.0'])).toEqual({
|
||||
baseConfigFile: undefined,
|
||||
configLoader: undefined,
|
||||
projectRoot: resolve(process.cwd(), 'apps/stage-pocket'),
|
||||
viteArgs: ['apps/stage-pocket', '--host', '0.0.0.0'],
|
||||
wrapperConfigFile: expect.stringMatching(/packages\/cap-vite\/(src|dist)\/vite-wrapper-config\.(ts|mjs)$/),
|
||||
})
|
||||
})
|
||||
|
||||
it('throws when --config is missing its value', async () => {
|
||||
const { prepareCapViteLaunch } = await import('./index')
|
||||
|
||||
expect(() => prepareCapViteLaunch(['--config'])).toThrow('Missing value for `--config`.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('runCapVite', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('forwards cap args into cap run', async () => {
|
||||
const processOnce = vi.spyOn(process, 'once').mockImplementation(() => process)
|
||||
|
||||
createServer.mockResolvedValue({
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
config: {
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
},
|
||||
},
|
||||
listen: vi.fn().mockResolvedValue(undefined),
|
||||
printUrls: vi.fn(),
|
||||
resolvedUrls: {
|
||||
local: ['http://127.0.0.1:5173/'],
|
||||
},
|
||||
watcher: {
|
||||
add: vi.fn(),
|
||||
off: vi.fn(),
|
||||
on: vi.fn(),
|
||||
unwatch: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
it('launches vite with the wrapper config and cap-vite env vars', async () => {
|
||||
const { runCapVite } = await import('./index')
|
||||
x.mockResolvedValue({
|
||||
exitCode: 0,
|
||||
stderr: '',
|
||||
stdout: '',
|
||||
})
|
||||
|
||||
x.mockReturnValue({
|
||||
kill: vi.fn(),
|
||||
})
|
||||
await runCapVite(
|
||||
['--host', '0.0.0.0', '--config', 'vite.mobile.ts', '--configLoader=runner'],
|
||||
['ios', '--target', 'iPhone 16 Pro', '--scheme', 'AIRI'],
|
||||
)
|
||||
|
||||
await runCapVite('android', 'emulator-5554', {
|
||||
capArgs: ['--flavor', 'release'],
|
||||
})
|
||||
|
||||
expect(createServer).toHaveBeenCalledWith({
|
||||
clearScreen: false,
|
||||
root: process.cwd(),
|
||||
})
|
||||
|
||||
expect(x).toHaveBeenCalledWith('cap', ['run', 'android', '--target', 'emulator-5554', '--flavor', 'release'], {
|
||||
expect(x).toHaveBeenCalledWith('vite', [
|
||||
'--config',
|
||||
expect.stringMatching(/packages\/cap-vite\/(src|dist)\/vite-wrapper-config\.(ts|mjs)$/),
|
||||
'--host',
|
||||
'0.0.0.0',
|
||||
'--configLoader=runner',
|
||||
], {
|
||||
nodeOptions: {
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
CAPACITOR_DEV_SERVER_URL: 'http://127.0.0.1:5173/',
|
||||
CAP_VITE_BASE_CONFIG: resolve(process.cwd(), 'vite.mobile.ts'),
|
||||
CAP_VITE_CAP_ARGS_JSON: JSON.stringify(['ios', '--target', 'iPhone 16 Pro', '--scheme', 'AIRI']),
|
||||
CAP_VITE_CONFIG_LOADER: 'runner',
|
||||
CAP_VITE_ROOT: process.cwd(),
|
||||
},
|
||||
stdio: 'inherit',
|
||||
},
|
||||
persist: true,
|
||||
throwOnError: false,
|
||||
})
|
||||
|
||||
expect(processOnce).toHaveBeenCalledWith('SIGINT', expect.any(Function))
|
||||
expect(processOnce).toHaveBeenCalledWith('SIGTERM', expect.any(Function))
|
||||
})
|
||||
})
|
||||
|
||||
+141
-186
@@ -1,221 +1,176 @@
|
||||
import type { Result } from 'tinyexec'
|
||||
import type { ViteDevServer } from 'vite'
|
||||
import type { Output } from 'tinyexec'
|
||||
|
||||
import process from 'node:process'
|
||||
|
||||
import { basename, extname, relative, resolve, sep } from 'node:path'
|
||||
import { extname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { x } from 'tinyexec'
|
||||
import { createServer } from 'vite'
|
||||
|
||||
export type CapacitorPlatform = 'android' | 'ios'
|
||||
import { parseCapacitorPlatform } from './native'
|
||||
|
||||
export type { CapacitorPlatform } from './native'
|
||||
|
||||
export interface RunCapViteOptions {
|
||||
capArgs?: string[]
|
||||
cwd?: string
|
||||
debounceMs?: number
|
||||
}
|
||||
|
||||
const nativeExtensionsByPlatform: Record<CapacitorPlatform, Set<string>> = {
|
||||
ios: new Set([
|
||||
'.entitlements',
|
||||
'.h',
|
||||
'.hpp',
|
||||
'.m',
|
||||
'.mm',
|
||||
'.pbxproj',
|
||||
'.plist',
|
||||
'.storyboard',
|
||||
'.strings',
|
||||
'.swift',
|
||||
'.xcodeproj',
|
||||
'.xcconfig',
|
||||
'.xcscheme',
|
||||
'.xib',
|
||||
]),
|
||||
android: new Set([
|
||||
'.gradle',
|
||||
'.java',
|
||||
'.json',
|
||||
'.kts',
|
||||
'.kt',
|
||||
'.properties',
|
||||
'.xml',
|
||||
]),
|
||||
interface PreparedViteLaunch {
|
||||
baseConfigFile?: string
|
||||
configLoader?: 'bundle' | 'native' | 'runner'
|
||||
projectRoot: string
|
||||
viteArgs: string[]
|
||||
wrapperConfigFile: string
|
||||
}
|
||||
|
||||
const nativeNamesByPlatform: Record<CapacitorPlatform, Set<string>> = {
|
||||
ios: new Set([
|
||||
'Podfile',
|
||||
'Podfile.lock',
|
||||
'project.pbxproj',
|
||||
]),
|
||||
android: new Set([
|
||||
'AndroidManifest.xml',
|
||||
'build.gradle',
|
||||
'build.gradle.kts',
|
||||
'gradle.properties',
|
||||
'settings.gradle',
|
||||
'settings.gradle.kts',
|
||||
]),
|
||||
interface ParsedViteArg {
|
||||
baseConfigFile?: string
|
||||
configLoader?: 'bundle' | 'native' | 'runner'
|
||||
consumedArgs: number
|
||||
forwardedArgs: string[]
|
||||
}
|
||||
|
||||
const ignoredNames = new Set([
|
||||
'capacitor.config.json',
|
||||
])
|
||||
|
||||
const ignoredPathSegments = new Set([
|
||||
'.gradle',
|
||||
'DerivedData',
|
||||
'Pods',
|
||||
'build',
|
||||
'xcuserdata',
|
||||
])
|
||||
|
||||
const ignoredPathPrefixesByPlatform: Record<CapacitorPlatform, string[][]> = {
|
||||
ios: [
|
||||
['App', 'CapApp-SPM'],
|
||||
],
|
||||
android: [],
|
||||
function resolveWrapperConfigFile(): string {
|
||||
const currentModulePath = fileURLToPath(import.meta.url)
|
||||
const wrapperExtension = extname(currentModulePath) === '.ts' ? '.ts' : '.mjs'
|
||||
return fileURLToPath(new URL(`./vite-wrapper-config${wrapperExtension}`, import.meta.url))
|
||||
}
|
||||
|
||||
function pickServerUrl(server: ViteDevServer): URL {
|
||||
const url = server.resolvedUrls?.network?.[0] ?? server.resolvedUrls?.local?.[0]
|
||||
|
||||
if (!url) {
|
||||
throw new Error('Vite did not expose a reachable dev server URL.')
|
||||
function parseViteConfigLoader(value: string | undefined): 'bundle' | 'native' | 'runner' | undefined {
|
||||
if (value === 'bundle' || value === 'native' || value === 'runner') {
|
||||
return value
|
||||
}
|
||||
|
||||
const resolved = new URL(url)
|
||||
|
||||
return resolved
|
||||
return undefined
|
||||
}
|
||||
|
||||
function shouldRestartForNativeChange(file: string, platform: CapacitorPlatform, cwd: string): boolean {
|
||||
const absoluteFile = resolve(cwd, file)
|
||||
const platformRoot = resolve(cwd, platform)
|
||||
|
||||
if (!absoluteFile.startsWith(`${platformRoot}${sep}`) && absoluteFile !== platformRoot) {
|
||||
return false
|
||||
}
|
||||
|
||||
const fileName = basename(absoluteFile)
|
||||
|
||||
if (ignoredNames.has(fileName)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const segments = absoluteFile.split(sep)
|
||||
if (segments.some(segment => ignoredPathSegments.has(segment))) {
|
||||
return false
|
||||
}
|
||||
|
||||
const relativeFile = relative(platformRoot, absoluteFile)
|
||||
const relativeSegments = relativeFile.split(sep).filter(Boolean)
|
||||
|
||||
if (ignoredPathPrefixesByPlatform[platform].some(prefix =>
|
||||
prefix.every((segment, index) => relativeSegments[index] === segment),
|
||||
)) {
|
||||
// NOTICE: Capacitor regenerates ios/App/CapApp-SPM/Package.swift during `cap run`.
|
||||
// Treating that generated tree as a native source change causes an infinite restart loop.
|
||||
return false
|
||||
}
|
||||
|
||||
if (nativeNamesByPlatform[platform].has(fileName)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return nativeExtensionsByPlatform[platform].has(extname(fileName).toLowerCase())
|
||||
function resolveConfigPath(cwd: string, value: string): string {
|
||||
return resolve(cwd, value)
|
||||
}
|
||||
|
||||
async function stopCapProcess(current: Result | undefined) {
|
||||
if (!current) {
|
||||
return
|
||||
function readRequiredOptionValue(viteArgs: string[], index: number, optionName: string): string {
|
||||
const value = viteArgs[index + 1]
|
||||
if (!value) {
|
||||
throw new Error(`Missing value for \`${optionName}\`.`)
|
||||
}
|
||||
|
||||
current.kill('SIGINT')
|
||||
|
||||
try {
|
||||
await current
|
||||
}
|
||||
catch {
|
||||
// tinyexec rejects on interrupted exits when the child was stopped for a restart.
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function startCapProcess(cwd: string, platform: CapacitorPlatform, target: string, url: URL, capArgs: string[]) {
|
||||
console.info('\n----------------------\n')
|
||||
console.info('Running cap run', platform, '--target', target, ...capArgs)
|
||||
return x('cap', ['run', platform, '--target', target, ...capArgs], { persist: true, throwOnError: false, nodeOptions: { cwd, stdio: 'inherit', env: { CAPACITOR_DEV_SERVER_URL: url.toString() } } })
|
||||
function parseConfigArg(viteArgs: string[], index: number, cwd: string): ParsedViteArg | null {
|
||||
const arg = viteArgs[index]
|
||||
|
||||
// NOTICE: Vite only accepts one `--config` entrypoint. cap-vite consumes that slot
|
||||
// for its wrapper config, then loads the user config from inside the wrapper.
|
||||
if (arg === '--config' || arg === '-c') {
|
||||
return {
|
||||
baseConfigFile: resolveConfigPath(cwd, readRequiredOptionValue(viteArgs, index, '--config')),
|
||||
consumedArgs: 2,
|
||||
forwardedArgs: [],
|
||||
}
|
||||
}
|
||||
|
||||
if (arg.startsWith('--config=')) {
|
||||
return {
|
||||
baseConfigFile: resolveConfigPath(cwd, arg.slice('--config='.length)),
|
||||
consumedArgs: 1,
|
||||
forwardedArgs: [],
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function parseConfigLoaderArg(viteArgs: string[], index: number): ParsedViteArg | null {
|
||||
const arg = viteArgs[index]
|
||||
|
||||
if (arg === '--configLoader') {
|
||||
const value = readRequiredOptionValue(viteArgs, index, '--configLoader')
|
||||
|
||||
return {
|
||||
configLoader: parseViteConfigLoader(value),
|
||||
consumedArgs: 2,
|
||||
forwardedArgs: [arg, value],
|
||||
}
|
||||
}
|
||||
|
||||
if (arg.startsWith('--configLoader=')) {
|
||||
return {
|
||||
configLoader: parseViteConfigLoader(arg.slice('--configLoader='.length)),
|
||||
consumedArgs: 1,
|
||||
forwardedArgs: [arg],
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function parseViteArg(viteArgs: string[], index: number, cwd: string): ParsedViteArg {
|
||||
return parseConfigArg(viteArgs, index, cwd)
|
||||
?? parseConfigLoaderArg(viteArgs, index)
|
||||
?? {
|
||||
consumedArgs: 1,
|
||||
forwardedArgs: [viteArgs[index]],
|
||||
}
|
||||
}
|
||||
|
||||
function resolveProjectRoot(viteArgs: string[], cwd: string): string {
|
||||
const firstArg = viteArgs[0]
|
||||
|
||||
return firstArg && !firstArg.startsWith('-')
|
||||
? resolve(cwd, firstArg)
|
||||
: cwd
|
||||
}
|
||||
|
||||
export function prepareCapViteLaunch(viteArgs: string[], cwd: string = process.cwd()): PreparedViteLaunch {
|
||||
const resolvedCwd = resolve(cwd)
|
||||
const projectRoot = resolveProjectRoot(viteArgs, resolvedCwd)
|
||||
|
||||
let baseConfigFile: string | undefined
|
||||
let configLoader: 'bundle' | 'native' | 'runner' | undefined
|
||||
const forwardedViteArgs: string[] = []
|
||||
|
||||
for (let index = 0; index < viteArgs.length;) {
|
||||
const parsedArg = parseViteArg(viteArgs, index, resolvedCwd)
|
||||
|
||||
baseConfigFile = parsedArg.baseConfigFile ?? baseConfigFile
|
||||
configLoader = parsedArg.configLoader ?? configLoader
|
||||
forwardedViteArgs.push(...parsedArg.forwardedArgs)
|
||||
index += parsedArg.consumedArgs
|
||||
}
|
||||
|
||||
return {
|
||||
baseConfigFile,
|
||||
configLoader,
|
||||
projectRoot,
|
||||
viteArgs: forwardedViteArgs,
|
||||
wrapperConfigFile: resolveWrapperConfigFile(),
|
||||
}
|
||||
}
|
||||
|
||||
export async function runCapVite(
|
||||
platform: CapacitorPlatform,
|
||||
target: string,
|
||||
viteArgs: string[],
|
||||
capArgs: string[],
|
||||
options: RunCapViteOptions = {},
|
||||
): Promise<void> {
|
||||
const capArgs = options.capArgs ?? []
|
||||
): Promise<Output> {
|
||||
if (!parseCapacitorPlatform(capArgs[0])) {
|
||||
throw new Error('The first `cap run` argument must be `ios` or `android`.')
|
||||
}
|
||||
|
||||
const cwd = resolve(options.cwd ?? process.cwd())
|
||||
const debounceMs = options.debounceMs ?? 300
|
||||
const server = await createServer({
|
||||
clearScreen: false,
|
||||
root: cwd,
|
||||
})
|
||||
const prepared = prepareCapViteLaunch(viteArgs, cwd)
|
||||
|
||||
await server.listen()
|
||||
server.printUrls()
|
||||
|
||||
const url = pickServerUrl(server)
|
||||
const logger = server.config.logger
|
||||
|
||||
let currentCapProcess: Result | undefined = startCapProcess(cwd, platform, target, url, capArgs)
|
||||
let restartTimer: NodeJS.Timeout | undefined
|
||||
let shuttingDown = false
|
||||
|
||||
async function restartCapProcess(reason: string) {
|
||||
if (shuttingDown) {
|
||||
return
|
||||
}
|
||||
|
||||
logger.info(`[cap-vite] ${reason}. Re-running cap run ${platform}.`)
|
||||
const previous = currentCapProcess
|
||||
currentCapProcess = undefined
|
||||
await stopCapProcess(previous)
|
||||
currentCapProcess = startCapProcess(cwd, platform, target, url, capArgs)
|
||||
}
|
||||
|
||||
const onWatcherEvent = (_event: string, file: string) => {
|
||||
if (!shouldRestartForNativeChange(file, platform, cwd)) {
|
||||
return
|
||||
}
|
||||
|
||||
clearTimeout(restartTimer)
|
||||
restartTimer = setTimeout(() => {
|
||||
void restartCapProcess(`native file changed: ${resolve(cwd, file)}`)
|
||||
}, debounceMs)
|
||||
}
|
||||
|
||||
const shutdown = async (exitCode: number) => {
|
||||
if (shuttingDown) {
|
||||
return
|
||||
}
|
||||
|
||||
shuttingDown = true
|
||||
clearTimeout(restartTimer)
|
||||
server.watcher.off('all', onWatcherEvent)
|
||||
await server.watcher.unwatch(platform)
|
||||
await server.close()
|
||||
await stopCapProcess(currentCapProcess)
|
||||
process.exit(exitCode)
|
||||
}
|
||||
|
||||
server.watcher.add(platform)
|
||||
server.watcher.on('all', onWatcherEvent)
|
||||
|
||||
process.once('SIGINT', () => {
|
||||
void shutdown(0)
|
||||
})
|
||||
process.once('SIGTERM', () => {
|
||||
void shutdown(0)
|
||||
return await x('vite', ['--config', prepared.wrapperConfigFile, ...prepared.viteArgs], {
|
||||
throwOnError: false,
|
||||
nodeOptions: {
|
||||
cwd,
|
||||
env: {
|
||||
CAP_VITE_BASE_CONFIG: prepared.baseConfigFile ?? '',
|
||||
CAP_VITE_CAP_ARGS_JSON: JSON.stringify(capArgs),
|
||||
CAP_VITE_CONFIG_LOADER: prepared.configLoader ?? '',
|
||||
CAP_VITE_ROOT: prepared.projectRoot,
|
||||
},
|
||||
stdio: 'inherit',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { hasCapacitorTargetArg, parseCapacitorPlatform, pickServerUrl, resolveCapRunArgs, shouldRestartForNativeChange } from './native'
|
||||
|
||||
describe('parseCapacitorPlatform', () => {
|
||||
it('accepts supported platforms', () => {
|
||||
expect(parseCapacitorPlatform('ios')).toBe('ios')
|
||||
expect(parseCapacitorPlatform('android')).toBe('android')
|
||||
})
|
||||
|
||||
it('rejects unsupported platforms', () => {
|
||||
expect(parseCapacitorPlatform('web')).toBeNull()
|
||||
expect(parseCapacitorPlatform(undefined)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('pickServerUrl', () => {
|
||||
it('prefers network urls over local urls', () => {
|
||||
expect(pickServerUrl({
|
||||
resolvedUrls: {
|
||||
local: ['http://127.0.0.1:5173/'],
|
||||
network: ['http://192.168.1.10:5173/'],
|
||||
},
|
||||
} as any).toString()).toBe('http://192.168.1.10:5173/')
|
||||
})
|
||||
|
||||
it('falls back to local urls when no network url exists', () => {
|
||||
expect(pickServerUrl({
|
||||
resolvedUrls: {
|
||||
local: ['http://127.0.0.1:5173/'],
|
||||
},
|
||||
} as any).toString()).toBe('http://127.0.0.1:5173/')
|
||||
})
|
||||
|
||||
it('throws when vite did not expose any reachable url', () => {
|
||||
expect(() => pickServerUrl({
|
||||
resolvedUrls: {
|
||||
local: [],
|
||||
network: [],
|
||||
},
|
||||
} as any)).toThrow('Vite did not expose a reachable dev server URL.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveCapRunArgs', () => {
|
||||
it('keeps an explicit --target argument untouched', () => {
|
||||
expect(resolveCapRunArgs(
|
||||
['ios', '--target', 'iPhone 16 Pro', '--scheme', 'AIRI'],
|
||||
{ CAPACITOR_DEVICE_ID: 'ignored-device' },
|
||||
)).toEqual(['ios', '--target', 'iPhone 16 Pro', '--scheme', 'AIRI'])
|
||||
})
|
||||
|
||||
it('injects --target from CAPACITOR_DEVICE_ID when it is missing', () => {
|
||||
expect(resolveCapRunArgs(
|
||||
['android', '--flavor', 'release'],
|
||||
{ CAPACITOR_DEVICE_ID: 'emulator-5554' },
|
||||
)).toEqual(['android', '--target', 'emulator-5554', '--flavor', 'release'])
|
||||
})
|
||||
|
||||
it('supports the --target=value form when checking existing args', () => {
|
||||
expect(hasCapacitorTargetArg(['android', '--target=emulator-5554'])).toBe(true)
|
||||
expect(resolveCapRunArgs(
|
||||
['android', '--target=emulator-5554', '--flavor', 'release'],
|
||||
{ CAPACITOR_DEVICE_ID: 'ignored-device' },
|
||||
)).toEqual(['android', '--target=emulator-5554', '--flavor', 'release'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldRestartForNativeChange', () => {
|
||||
it('restarts for native source files inside the selected platform directory', () => {
|
||||
expect(shouldRestartForNativeChange('/repo/app/ios/App/AppDelegate.swift', 'ios', '/repo/app')).toBe(true)
|
||||
expect(shouldRestartForNativeChange('/repo/app/android/app/src/main/AndroidManifest.xml', 'android', '/repo/app')).toBe(true)
|
||||
})
|
||||
|
||||
it('ignores web-side files and generated native output', () => {
|
||||
expect(shouldRestartForNativeChange('/repo/app/src/main.ts', 'ios', '/repo/app')).toBe(false)
|
||||
expect(shouldRestartForNativeChange('/repo/app/ios/App/CapApp-SPM/Package.swift', 'ios', '/repo/app')).toBe(false)
|
||||
expect(shouldRestartForNativeChange('/repo/app/android/build/generated/file.kt', 'android', '/repo/app')).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores capacitor config json updates', () => {
|
||||
expect(shouldRestartForNativeChange('/repo/app/android/capacitor.config.json', 'android', '/repo/app')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,140 @@
|
||||
import type { ViteDevServer } from 'vite'
|
||||
|
||||
import process from 'node:process'
|
||||
|
||||
import { basename, extname, relative, resolve, sep } from 'node:path'
|
||||
|
||||
export type CapacitorPlatform = 'android' | 'ios'
|
||||
|
||||
const nativeExtensionsByPlatform: Record<CapacitorPlatform, Set<string>> = {
|
||||
ios: new Set([
|
||||
'.entitlements',
|
||||
'.h',
|
||||
'.hpp',
|
||||
'.m',
|
||||
'.mm',
|
||||
'.pbxproj',
|
||||
'.plist',
|
||||
'.storyboard',
|
||||
'.strings',
|
||||
'.swift',
|
||||
'.xcodeproj',
|
||||
'.xcconfig',
|
||||
'.xcscheme',
|
||||
'.xib',
|
||||
]),
|
||||
android: new Set([
|
||||
'.gradle',
|
||||
'.java',
|
||||
'.json',
|
||||
'.kts',
|
||||
'.kt',
|
||||
'.properties',
|
||||
'.xml',
|
||||
]),
|
||||
}
|
||||
|
||||
const nativeNamesByPlatform: Record<CapacitorPlatform, Set<string>> = {
|
||||
ios: new Set([
|
||||
'Podfile',
|
||||
'Podfile.lock',
|
||||
'project.pbxproj',
|
||||
]),
|
||||
android: new Set([
|
||||
'AndroidManifest.xml',
|
||||
'build.gradle',
|
||||
'build.gradle.kts',
|
||||
'gradle.properties',
|
||||
'settings.gradle',
|
||||
'settings.gradle.kts',
|
||||
]),
|
||||
}
|
||||
|
||||
const ignoredNames = new Set([
|
||||
'capacitor.config.json',
|
||||
])
|
||||
|
||||
const ignoredPathSegments = new Set([
|
||||
'.gradle',
|
||||
'DerivedData',
|
||||
'Pods',
|
||||
'build',
|
||||
'xcuserdata',
|
||||
])
|
||||
|
||||
const ignoredPathPrefixesByPlatform: Record<CapacitorPlatform, string[][]> = {
|
||||
ios: [
|
||||
['App', 'CapApp-SPM'],
|
||||
],
|
||||
android: [],
|
||||
}
|
||||
|
||||
export function parseCapacitorPlatform(value: string | undefined): CapacitorPlatform | null {
|
||||
return value === 'android' || value === 'ios' ? value : null
|
||||
}
|
||||
|
||||
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[] {
|
||||
if (capArgs.length === 0 || hasCapacitorTargetArg(capArgs)) {
|
||||
return capArgs
|
||||
}
|
||||
|
||||
const target = env.CAPACITOR_DEVICE_ID
|
||||
if (!target) {
|
||||
return capArgs
|
||||
}
|
||||
|
||||
const [platform, ...rest] = capArgs
|
||||
|
||||
return [platform, '--target', target, ...rest]
|
||||
}
|
||||
|
||||
export function pickServerUrl(server: Pick<ViteDevServer, 'resolvedUrls'>): URL {
|
||||
const url = server.resolvedUrls?.network?.[0] ?? server.resolvedUrls?.local?.[0]
|
||||
|
||||
if (!url) {
|
||||
throw new Error('Vite did not expose a reachable dev server URL.')
|
||||
}
|
||||
|
||||
return new URL(url)
|
||||
}
|
||||
|
||||
export function shouldRestartForNativeChange(file: string, platform: CapacitorPlatform, cwd: string): boolean {
|
||||
const absoluteFile = resolve(cwd, file)
|
||||
const platformRoot = resolve(cwd, platform)
|
||||
|
||||
if (!absoluteFile.startsWith(`${platformRoot}${sep}`) && absoluteFile !== platformRoot) {
|
||||
return false
|
||||
}
|
||||
|
||||
const fileName = basename(absoluteFile)
|
||||
|
||||
if (ignoredNames.has(fileName)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const segments = absoluteFile.split(sep)
|
||||
if (segments.some(segment => ignoredPathSegments.has(segment))) {
|
||||
return false
|
||||
}
|
||||
|
||||
const relativeFile = relative(platformRoot, absoluteFile)
|
||||
const relativeSegments = relativeFile.split(sep).filter(Boolean)
|
||||
|
||||
if (ignoredPathPrefixesByPlatform[platform].some(prefix =>
|
||||
prefix.every((segment, index) => relativeSegments[index] === segment),
|
||||
)) {
|
||||
// NOTICE: Capacitor regenerates ios/App/CapApp-SPM/Package.swift during `cap run`.
|
||||
// Treating that generated tree as a native source change causes an infinite restart loop.
|
||||
return false
|
||||
}
|
||||
|
||||
if (nativeNamesByPlatform[platform].has(fileName)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return nativeExtensionsByPlatform[platform].has(extname(fileName).toLowerCase())
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import type { Result } from 'tinyexec'
|
||||
import type { Plugin } from 'vite'
|
||||
|
||||
import process from 'node:process'
|
||||
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
import { x } from 'tinyexec'
|
||||
|
||||
import { parseCapacitorPlatform, pickServerUrl, resolveCapRunArgs, shouldRestartForNativeChange } from './native'
|
||||
|
||||
export interface CapVitePluginOptions {
|
||||
capArgs: string[]
|
||||
}
|
||||
|
||||
async function stopCapProcess(current: Result | undefined) {
|
||||
if (!current) {
|
||||
return
|
||||
}
|
||||
|
||||
current.kill('SIGINT')
|
||||
|
||||
try {
|
||||
await current
|
||||
}
|
||||
catch {
|
||||
// tinyexec rejects when a process is stopped during a restart.
|
||||
}
|
||||
}
|
||||
|
||||
function startCapProcess(cwd: string, capArgs: string[], url: URL) {
|
||||
console.info('\n----------------------\n')
|
||||
console.info('Running cap run', ...capArgs)
|
||||
|
||||
return x('cap', ['run', ...capArgs], {
|
||||
throwOnError: false,
|
||||
nodeOptions: {
|
||||
cwd,
|
||||
env: {
|
||||
CAPACITOR_DEV_SERVER_URL: url.toString(),
|
||||
},
|
||||
stdio: 'inherit',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function capVitePlugin(options: CapVitePluginOptions): Plugin {
|
||||
const resolvedCapArgs = resolveCapRunArgs(options.capArgs)
|
||||
const platform = parseCapacitorPlatform(resolvedCapArgs[0])
|
||||
if (!platform) {
|
||||
throw new Error('The first `cap run` argument must be `ios` or `android`.')
|
||||
}
|
||||
|
||||
return {
|
||||
apply: 'serve',
|
||||
name: 'cap-vite:run-capacitor',
|
||||
configureServer(server) {
|
||||
const cwd = resolve(server.config.root)
|
||||
const platformRoot = resolve(cwd, platform)
|
||||
const debounceMs = 300
|
||||
const logger = server.config.logger
|
||||
|
||||
let currentCapProcess: Result | undefined
|
||||
let shuttingDown = false
|
||||
let restartTimer: NodeJS.Timeout | undefined
|
||||
|
||||
const start = () => {
|
||||
const url = pickServerUrl(server)
|
||||
currentCapProcess = startCapProcess(cwd, resolvedCapArgs, url)
|
||||
}
|
||||
|
||||
const restartCapProcess = async (reason: string) => {
|
||||
if (shuttingDown) {
|
||||
return
|
||||
}
|
||||
|
||||
logger.info(`[cap-vite] ${reason}. Re-running cap run ${platform}.`)
|
||||
const previous = currentCapProcess
|
||||
currentCapProcess = undefined
|
||||
await stopCapProcess(previous)
|
||||
start()
|
||||
}
|
||||
|
||||
const onWatcherEvent = (_event, file) => {
|
||||
if (!shouldRestartForNativeChange(file, platform, cwd)) {
|
||||
return
|
||||
}
|
||||
|
||||
clearTimeout(restartTimer)
|
||||
restartTimer = setTimeout(() => {
|
||||
restartCapProcess(`native file changed: ${resolve(cwd, file)}`)
|
||||
}, debounceMs)
|
||||
}
|
||||
|
||||
const shutdown = async () => {
|
||||
if (shuttingDown) {
|
||||
return
|
||||
}
|
||||
|
||||
shuttingDown = true
|
||||
clearTimeout(restartTimer)
|
||||
server.watcher.off('all', onWatcherEvent)
|
||||
await server.watcher.unwatch(platformRoot)
|
||||
await stopCapProcess(currentCapProcess)
|
||||
}
|
||||
|
||||
server.watcher.add(platformRoot)
|
||||
server.watcher.on('all', onWatcherEvent)
|
||||
|
||||
server.httpServer?.once('listening', () => {
|
||||
try {
|
||||
start()
|
||||
}
|
||||
catch (error) {
|
||||
logger.error(`[cap-vite] ${error instanceof Error ? error.message : String(error)}`)
|
||||
shutdown()
|
||||
}
|
||||
})
|
||||
server.httpServer?.once('close', shutdown)
|
||||
process.once('SIGINT', shutdown)
|
||||
process.once('SIGTERM', shutdown)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { capVitePlugin, defineConfig, loadConfigFromFile, mergeConfig } = vi.hoisted(() => ({
|
||||
capVitePlugin: vi.fn(),
|
||||
defineConfig: vi.fn((config: unknown) => config),
|
||||
loadConfigFromFile: vi.fn(),
|
||||
mergeConfig: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('vite', () => ({
|
||||
defineConfig,
|
||||
loadConfigFromFile,
|
||||
mergeConfig,
|
||||
}))
|
||||
|
||||
vi.mock('./vite-plugin', () => ({
|
||||
capVitePlugin,
|
||||
}))
|
||||
|
||||
describe('vite-wrapper-config', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.resetModules()
|
||||
process.env.CAP_VITE_BASE_CONFIG = '/repo/app/vite.config.ts'
|
||||
process.env.CAP_VITE_CAP_ARGS_JSON = JSON.stringify(['ios', '--target', 'iPhone 16 Pro'])
|
||||
process.env.CAP_VITE_CONFIG_LOADER = 'runner'
|
||||
process.env.CAP_VITE_ROOT = '/repo/app'
|
||||
})
|
||||
|
||||
it('loads the user config and merges the injected plugin', async () => {
|
||||
loadConfigFromFile.mockResolvedValue({
|
||||
config: {
|
||||
server: {
|
||||
port: 5173,
|
||||
},
|
||||
},
|
||||
dependencies: [],
|
||||
path: '/repo/app/vite.config.ts',
|
||||
})
|
||||
mergeConfig.mockImplementation((defaults: Record<string, unknown>, overrides: Record<string, unknown>) => ({
|
||||
...defaults,
|
||||
...overrides,
|
||||
}))
|
||||
capVitePlugin.mockReturnValue({ name: 'cap-vite:run-capacitor' })
|
||||
|
||||
const module = await import('./vite-wrapper-config')
|
||||
const config = await module.default({
|
||||
command: 'serve',
|
||||
mode: 'development',
|
||||
isPreview: false,
|
||||
})
|
||||
|
||||
expect(defineConfig).toHaveBeenCalledTimes(1)
|
||||
expect(loadConfigFromFile).toHaveBeenCalledWith(
|
||||
{
|
||||
command: 'serve',
|
||||
isPreview: false,
|
||||
mode: 'development',
|
||||
},
|
||||
'/repo/app/vite.config.ts',
|
||||
'/repo/app',
|
||||
undefined,
|
||||
undefined,
|
||||
'runner',
|
||||
)
|
||||
expect(capVitePlugin).toHaveBeenCalledWith({
|
||||
capArgs: ['ios', '--target', 'iPhone 16 Pro'],
|
||||
})
|
||||
expect(config).toEqual({
|
||||
plugins: [{ name: 'cap-vite:run-capacitor' }],
|
||||
server: {
|
||||
port: 5173,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
import process from 'node:process'
|
||||
|
||||
import { defineConfig, loadConfigFromFile, mergeConfig } from 'vite'
|
||||
|
||||
import { capVitePlugin } from './vite-plugin'
|
||||
|
||||
function parseCapArgs(): string[] {
|
||||
const value = process.env.CAP_VITE_CAP_ARGS_JSON
|
||||
if (!value) {
|
||||
return []
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(value)
|
||||
if (!Array.isArray(parsed) || parsed.some(arg => typeof arg !== 'string')) {
|
||||
throw new Error('CAP_VITE_CAP_ARGS_JSON must be a JSON string array.')
|
||||
}
|
||||
|
||||
return parsed
|
||||
}
|
||||
|
||||
function parseConfigLoader(): 'bundle' | 'native' | 'runner' | undefined {
|
||||
const value = process.env.CAP_VITE_CONFIG_LOADER
|
||||
if (value === 'bundle' || value === 'native' || value === 'runner') {
|
||||
return value
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
export default defineConfig(async (env) => {
|
||||
const root = process.env.CAP_VITE_ROOT ?? process.cwd()
|
||||
const baseConfigFile = process.env.CAP_VITE_BASE_CONFIG || undefined
|
||||
const configLoader = parseConfigLoader()
|
||||
|
||||
const loaded = await loadConfigFromFile(
|
||||
env,
|
||||
baseConfigFile,
|
||||
root,
|
||||
undefined,
|
||||
undefined,
|
||||
configLoader,
|
||||
)
|
||||
|
||||
return mergeConfig(loaded?.config ?? {}, {
|
||||
plugins: [
|
||||
capVitePlugin({
|
||||
capArgs: parseCapArgs(),
|
||||
}),
|
||||
],
|
||||
})
|
||||
})
|
||||
@@ -4,6 +4,8 @@ export default defineConfig({
|
||||
entry: {
|
||||
'index': 'src/index.ts',
|
||||
'bin/run': 'src/bin/run.ts',
|
||||
'vite-plugin': 'src/vite-plugin.ts',
|
||||
'vite-wrapper-config': 'src/vite-wrapper-config.ts',
|
||||
},
|
||||
target: 'node18',
|
||||
outDir: 'dist',
|
||||
|
||||
Reference in New Issue
Block a user