Improve mobile emulator pairing startup (#6527)
* Implement automated git preparation workflow for mobile PR creation Introduce a structured hosted review intent preparation workflow to handle staging, AI commit message generation, committing, and pushing changes automatically before displaying the pull request composer on mobile. - Map creation block reasons to descriptive user-facing validation errors (e.g., dirty working tree, default branch, detached head) to match desktop. - Decouple hosted-review business logic into a dedicated service helper. - Update source control runner hooks to handle the new preparation flow. * Refactor mobile PR creation to run intent and open URL directly Remove MobilePrComposeSheet and the local compose form, moving instead to a direct PR creation workflow that matches the desktop experience. - Add runMobileHostedReviewCreateIntent to handle the full prepare, push, and create sequence. - Replace useMobileOpenPrSheetRunner with useMobileCreatePrRunner to trigger the creation workflow and directly open the created PR URL. - Simplify state management by removing showPrSheet, prPrefill, and associated local compose sheets. * Propagate git status and commit state on PR creation failure Update `MobileHostedReviewCreateIntentOutcome` and the local change commit helper to include optional `committed` and `status` fields in their failure results. This ensures that if PR preparation fails, callers still receive the current repository status and know if their local changes have already been committed. * Add tests for mobile hosted review creation flow Introduce unit tests for runMobileHostedReviewCreateIntent to verify different scenarios of creating a hosted review on mobile, including: - Successful flow including staging, committing, pushing, and creating - Eligibility block handling (e.g., authentication requirements) - Error reporting when creation fails after an automatic commit * Block mobile PR creation on unresolved conflicts and refresh status Prevent creating a hosted review on mobile when there are unresolved merge conflicts. Also, return the latest git status on failures and reload it in the UI to keep the source control screen in sync. * Prefer fetched PR head SHA over cached status SHA for PR checks On mobile, a create command can commit before opening the review, meaning the fetched PR's head SHA is fresher than the route's cached status SHA. Prioritizing the fetched PR head SHA ensures we fetch checks for the most up-to-date commit. * Fix mobile PR creation errors and validate branch presence - Reject branch matches when the status branch is null or missing to prevent PR creation when the branch is lost. - Display actual PR creation errors in the sidebar instead of silently ignoring them on failure. - Trim leading and trailing whitespace from the base branch reference before persisting the worktree link. * Improve mobile emulator pairing startup
This commit is contained in:
parent
76331ff597
commit
c16aa89ea7
|
|
@ -0,0 +1,154 @@
|
|||
import { spawn } from 'node:child_process'
|
||||
import { mkdtempSync } from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
import readline from 'node:readline'
|
||||
|
||||
function primaryLanIp(lanIpCandidates) {
|
||||
return lanIpCandidates()[0] || '127.0.0.1'
|
||||
}
|
||||
|
||||
export async function startHeadlessPairingRuntime({
|
||||
enabled,
|
||||
orcaCli,
|
||||
cwd,
|
||||
lanIpCandidates,
|
||||
logStep,
|
||||
logSuccess
|
||||
}) {
|
||||
if (!enabled) {
|
||||
return null
|
||||
}
|
||||
|
||||
logStep('0', 'Starting temporary desktop runtime for mobile pairing...')
|
||||
const runDir = mkdtempSync(path.join(os.tmpdir(), 'orca-mobile-run.'))
|
||||
const userData = path.join(runDir, 'userData')
|
||||
const pairingAddress = primaryLanIp(lanIpCandidates)
|
||||
const child = spawn(
|
||||
orcaCli,
|
||||
['serve', '--mobile-pairing', '--pairing-address', pairingAddress, '--json'],
|
||||
{
|
||||
cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
ORCA_E2E_USER_DATA_DIR: userData
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
}
|
||||
)
|
||||
|
||||
return await waitForPairingRuntime({ child, userData, pairingAddress, logSuccess })
|
||||
}
|
||||
|
||||
export async function registerWorktreeForPairingRuntime(runtime, worktree, tools) {
|
||||
if (!runtime) {
|
||||
return
|
||||
}
|
||||
tools.logStep('0.1', 'Registering current worktree in temporary runtime...')
|
||||
await tools.orca(['repo', 'add', '--path', worktree, '--json'], {
|
||||
cwd: worktree,
|
||||
env: runtime.env,
|
||||
timeout: 60000
|
||||
})
|
||||
tools.logSuccess('Registered worktree for mobile runtime')
|
||||
}
|
||||
|
||||
async function waitForPairingRuntime({ child, userData, pairingAddress, logSuccess }) {
|
||||
let output = ''
|
||||
let stderr = ''
|
||||
let resolved = false
|
||||
let exited = false
|
||||
let rl = null
|
||||
let rlErr = null
|
||||
|
||||
const stop = () => {
|
||||
if (!exited) {
|
||||
child.kill('SIGTERM')
|
||||
}
|
||||
rl?.close()
|
||||
rlErr?.close()
|
||||
child.stdout?.destroy()
|
||||
child.stderr?.destroy()
|
||||
}
|
||||
|
||||
const runtimeResult = (pairingUrl) => ({
|
||||
pairingUrl,
|
||||
userData,
|
||||
process: child,
|
||||
env: {
|
||||
...process.env,
|
||||
ORCA_USER_DATA_PATH: userData
|
||||
},
|
||||
stop
|
||||
})
|
||||
|
||||
return await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
if (!resolved) {
|
||||
resolved = true
|
||||
stop()
|
||||
reject(new Error('Timeout waiting for temporary desktop runtime pairing URL'))
|
||||
}
|
||||
}, 120000)
|
||||
|
||||
const finishResolve = (pairingUrl) => {
|
||||
if (resolved) {
|
||||
return
|
||||
}
|
||||
resolved = true
|
||||
clearTimeout(timeout)
|
||||
logSuccess(`Temporary desktop runtime ready (${pairingAddress})`)
|
||||
resolve(runtimeResult(pairingUrl))
|
||||
}
|
||||
|
||||
const finishReject = (error) => {
|
||||
if (resolved) {
|
||||
return
|
||||
}
|
||||
resolved = true
|
||||
clearTimeout(timeout)
|
||||
stop()
|
||||
reject(error)
|
||||
}
|
||||
|
||||
rl = readline.createInterface({ input: child.stdout })
|
||||
rl.on('line', (line) => {
|
||||
output += line + '\n'
|
||||
handleRuntimeLine(line, finishResolve)
|
||||
})
|
||||
|
||||
rlErr = readline.createInterface({ input: child.stderr })
|
||||
rlErr.on('line', (line) => {
|
||||
stderr += line + '\n'
|
||||
})
|
||||
|
||||
child.on('error', (error) => {
|
||||
finishReject(new Error(`Failed to start temporary desktop runtime: ${error.message}`))
|
||||
})
|
||||
|
||||
child.on('exit', (code) => {
|
||||
exited = true
|
||||
if (!resolved) {
|
||||
const detail = stderr.trim() || output.trim() || `exit code ${code}`
|
||||
finishReject(new Error(`Temporary desktop runtime exited before pairing: ${detail}`))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function handleRuntimeLine(line, finishResolve) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed.startsWith('{')) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const result = JSON.parse(trimmed)
|
||||
const pairingUrl = result?.pairing?.url
|
||||
if (typeof pairingUrl === 'string' && pairingUrl.length > 0) {
|
||||
finishResolve(pairingUrl)
|
||||
}
|
||||
} catch {
|
||||
// Ignore non-JSON log lines from Electron startup.
|
||||
}
|
||||
}
|
||||
|
|
@ -11,16 +11,22 @@
|
|||
* --device <name> Device name (default: 'iPhone 17 Pro')
|
||||
* --port <port> Metro port (default: Expo default)
|
||||
* --no-open Don't open the app URL automatically
|
||||
* --no-pair Don't create a temporary paired desktop runtime
|
||||
* --wait-for-ready Wait for Metro to be ready before opening URL
|
||||
* --screenshot Take a screenshot after opening
|
||||
*/
|
||||
|
||||
import { spawn, execFile } from 'node:child_process'
|
||||
import { existsSync } from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import { promisify } from 'node:util'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
import readline from 'node:readline'
|
||||
import {
|
||||
registerWorktreeForPairingRuntime,
|
||||
startHeadlessPairingRuntime
|
||||
} from './start-emulator-pairing-runtime.mjs'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
|
|
@ -31,6 +37,7 @@ const options = {
|
|||
device: 'iPhone 17 Pro',
|
||||
port: null,
|
||||
open: true,
|
||||
pair: true,
|
||||
waitForReady: false,
|
||||
screenshot: false
|
||||
}
|
||||
|
|
@ -45,6 +52,8 @@ for (let i = 0; i < args.length; i++) {
|
|||
options.port = args[++i]
|
||||
} else if (arg === '--no-open') {
|
||||
options.open = false
|
||||
} else if (arg === '--no-pair') {
|
||||
options.pair = false
|
||||
} else if (arg === '--wait-for-ready') {
|
||||
options.waitForReady = true
|
||||
} else if (arg === '--screenshot') {
|
||||
|
|
@ -57,6 +66,7 @@ Options:
|
|||
--device <name> Device name (default: 'iPhone 17 Pro')
|
||||
--port <port> Metro port (default: Expo default)
|
||||
--no-open Don't open the app URL automatically
|
||||
--no-pair Don't create a temporary paired desktop runtime
|
||||
--wait-for-ready Wait for Metro to be ready before opening URL
|
||||
--screenshot Take a screenshot after opening
|
||||
--help, -h Show this help message
|
||||
|
|
@ -110,6 +120,7 @@ function assertIosSimulatorPlatform() {
|
|||
async function orca(args, options = {}) {
|
||||
const { stdout, stderr } = await execFileAsync(ORCA_CLI, args, {
|
||||
cwd: options.cwd || process.cwd(),
|
||||
env: options.env || process.env,
|
||||
encoding: 'utf8',
|
||||
timeout: options.timeout || 30000
|
||||
})
|
||||
|
|
@ -146,13 +157,41 @@ function getMobileDir(worktree) {
|
|||
return path.join(worktree, 'mobile')
|
||||
}
|
||||
|
||||
async function ensureMobileDependencies(worktree) {
|
||||
const mobileDir = getMobileDir(worktree)
|
||||
const expoPath = path.join(mobileDir, 'node_modules', '.bin', 'expo')
|
||||
if (existsSync(expoPath)) {
|
||||
return
|
||||
}
|
||||
|
||||
logStep('deps', 'Installing mobile dependencies...')
|
||||
await new Promise((resolve, reject) => {
|
||||
const install = spawn('pnpm', ['install'], {
|
||||
cwd: mobileDir,
|
||||
env: process.env,
|
||||
stdio: 'inherit'
|
||||
})
|
||||
install.on('error', reject)
|
||||
install.on('exit', (code) => {
|
||||
if (code === 0) {
|
||||
resolve()
|
||||
} else {
|
||||
reject(new Error(`pnpm install exited with code ${code}`))
|
||||
}
|
||||
})
|
||||
})
|
||||
logSuccess('Mobile dependencies installed')
|
||||
}
|
||||
|
||||
// Attach to emulator
|
||||
async function attachEmulator(worktree, device) {
|
||||
async function attachEmulator(worktree, device, runtime) {
|
||||
logStep('1', `Attaching to emulator: ${device.name}`)
|
||||
|
||||
try {
|
||||
await orca(['emulator', 'attach', device.udid, '--worktree', worktree, '--focus', '--json'], {
|
||||
cwd: worktree
|
||||
cwd: worktree,
|
||||
env: runtime?.env || process.env,
|
||||
timeout: 60000
|
||||
})
|
||||
logSuccess(`Attached to ${device.name}`)
|
||||
} catch (error) {
|
||||
|
|
@ -431,6 +470,25 @@ async function openInSimulator(url, deviceUdid) {
|
|||
}
|
||||
}
|
||||
|
||||
async function openPairingUrlInSimulator(pairingUrl, deviceUdid, runtime, worktree) {
|
||||
if (!pairingUrl || !options.open) {
|
||||
return
|
||||
}
|
||||
|
||||
logStep('4', 'Pairing mobile app to temporary desktop runtime...')
|
||||
await execFileAsync('xcrun', ['simctl', 'openurl', deviceUdid, pairingUrl])
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000))
|
||||
|
||||
// Why: the mobile app intentionally asks for a trust confirmation before
|
||||
// saving a host. This lands on the Pair button on current iPhone simulators.
|
||||
await orca(['emulator', 'tap', '0.5', '0.56', '--worktree', worktree, '--json'], {
|
||||
cwd: worktree,
|
||||
env: runtime?.env || process.env,
|
||||
timeout: 30000
|
||||
})
|
||||
logSuccess('Opened pairing link and confirmed Pair')
|
||||
}
|
||||
|
||||
// Take a screenshot
|
||||
async function takeScreenshot(
|
||||
deviceUdid,
|
||||
|
|
@ -477,6 +535,7 @@ async function findReachableMetroUrl(initialUrl) {
|
|||
// Main function
|
||||
async function main() {
|
||||
log(colors.bright + 'Starting Orca Mobile in Emulator\n' + colors.reset)
|
||||
let pairingRuntime = null
|
||||
|
||||
try {
|
||||
assertIosSimulatorPlatform()
|
||||
|
|
@ -484,6 +543,21 @@ async function main() {
|
|||
// Get worktree
|
||||
const worktree = await getWorktree()
|
||||
logInfo(`Using worktree: ${worktree}`)
|
||||
await ensureMobileDependencies(worktree)
|
||||
|
||||
pairingRuntime = await startHeadlessPairingRuntime({
|
||||
enabled: options.pair,
|
||||
orcaCli: ORCA_CLI,
|
||||
cwd: process.cwd(),
|
||||
lanIpCandidates,
|
||||
logStep,
|
||||
logSuccess
|
||||
})
|
||||
await registerWorktreeForPairingRuntime(pairingRuntime, worktree, {
|
||||
orca,
|
||||
logStep,
|
||||
logSuccess
|
||||
})
|
||||
|
||||
// Find best device
|
||||
const device = await findBestDevice(options.device)
|
||||
|
|
@ -491,7 +565,7 @@ async function main() {
|
|||
|
||||
// Why: emulator helpers are worktree-scoped in Orca; attach is idempotent
|
||||
// for the active worktree, while a global helper list cannot prove that.
|
||||
await attachEmulator(worktree, device)
|
||||
await attachEmulator(worktree, device, pairingRuntime)
|
||||
|
||||
// Start Metro
|
||||
const metro = await startMetro(worktree)
|
||||
|
|
@ -514,6 +588,12 @@ async function main() {
|
|||
// Open in simulator
|
||||
if (options.open) {
|
||||
await openInSimulator(metro.url, device.udid)
|
||||
await openPairingUrlInSimulator(
|
||||
pairingRuntime?.pairingUrl,
|
||||
device.udid,
|
||||
pairingRuntime,
|
||||
worktree
|
||||
)
|
||||
|
||||
// Take screenshot if requested
|
||||
if (options.screenshot) {
|
||||
|
|
@ -528,7 +608,7 @@ async function main() {
|
|||
}
|
||||
|
||||
log(colors.bright + '\nSetup complete!' + colors.reset)
|
||||
logInfo('Press Ctrl+C to stop Metro')
|
||||
logInfo('Press Ctrl+C to stop Metro and the temporary desktop runtime')
|
||||
|
||||
// Keep running until Metro exits
|
||||
await new Promise((resolve) => {
|
||||
|
|
@ -542,6 +622,7 @@ async function main() {
|
|||
process.off('SIGINT', stopMetro)
|
||||
process.off('SIGTERM', stopMetro)
|
||||
metro.closeOutput?.()
|
||||
pairingRuntime?.stop()
|
||||
resolve()
|
||||
}
|
||||
const stopMetro = () => {
|
||||
|
|
@ -564,6 +645,7 @@ async function main() {
|
|||
})
|
||||
process.exit(0)
|
||||
} catch (error) {
|
||||
pairingRuntime?.stop()
|
||||
logError(error.message)
|
||||
process.exit(1)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue