// rnx test — choose Maestro or Detox, support local bundle loopback and standalone cloud execution. import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs' import { createServer, type Server } from 'node:http' import { basename, dirname, extname, join, relative, resolve, sep } from 'node:path' import { RNX_CLOUD_TEST_FILES_MAX_BYTES, RNX_CLOUD_TEST_FILES_MAX_COUNT, } from '../../src/cloud-contract' import { authHeaderValue, resolveCliAuth } from '../auth' import { parseCloudTestSidecarPorts, parseLiveDevStackApp, publishLiveDevStack, } from '../cloud-test-tunnel' import { parseFlowFile } from '../flow-file' import { buildShellUrl, resolveShellBaseUrlForBridgePort } from './control' import { FLOW_ARG_VALUE_FLAGS, hoistLeadingSimFlag } from './flow' import { resolveDefaultUploadOrigin } from './upload' export interface RunTestOptions { port?: number verbose?: boolean pollIntervalMs?: number timeoutMs?: number } interface TestAppTarget { target: string close: () => Promise } // every flag that swallows the token after it, so the positional scan finds // the flow/suite target no matter where the flags sit (`rnx test // --include-tags smoke .maestro/` must not take `smoke` as the target). // maestro compat flags live here alongside the flow-runner vocabulary; // without them a flag-first maestro command line mis-resolves its target. const VALUE_FLAGS = new Set([ '--app', '--bundle', '--bundle-url', '--config', '--sim', '--session', '--tab', '--port', '--device', '-d', '--env', '-e', '--driver', '--base-url', '--url', '--include-tags', '--exclude-tags', '--format', '--output', '--test-output-dir', '--debug-output', '--platform', '-t', '--testNamePattern', '--testTimeout', '--grep', '--maxWorkers', '--shard', '--outputFile', '--origin', '--owner', '--repo', '--poll-interval', '--timeout', '--sidecar-ports', '--cancel', ...FLOW_ARG_VALUE_FLAGS, ]) function valueAfter(args: string[], name: string): string | null { const inline = args.find((arg) => arg.startsWith(`${name}=`)) if (inline) return inline.slice(name.length + 1) || null const index = args.indexOf(name) if (index < 0) return null const value = args[index + 1] return value && !value.startsWith('-') ? value : null } function targetIndexFromArgs(args: string[]): number { for (let index = 0; index < args.length; index++) { const arg = args[index] if (VALUE_FLAGS.has(arg)) { index += 1 continue } if (arg.startsWith('--') && arg.includes('=')) { continue } if (!arg.startsWith('-')) return index } return -1 } function targetFromArgs(args: string[]): string | null { const index = targetIndexFromArgs(args) return index >= 0 ? args[index] : null } const APP_FLAGS = ['--app', '--bundle', '--bundle-url'] function isAppFlag(arg: string): boolean { return APP_FLAGS.some((flag) => arg === flag || arg.startsWith(`${flag}=`)) } function withoutApp(args: string[]): string[] { const result: string[] = [] for (let i = 0; i < args.length; i++) { const arg = args[i] if (APP_FLAGS.includes(arg)) { i += 1 continue } if (isAppFlag(arg)) { continue } result.push(arg) } return result } // the maestro runner reads the app target as --url; rnx test spells it // --app/--bundle/--bundle-url. map each spelling explicitly: a prefix // replace would turn --bundle-url into --url-url. function mapAppFlagToUrl(arg: string): string { if (APP_FLAGS.includes(arg)) return '--url' for (const flag of APP_FLAGS) { if (arg.startsWith(`${flag}=`)) return `--url=${arg.slice(flag.length + 1)}` } return arg } function appFromArgs(args: string[]): string | null { return ( valueAfter(args, '--app') ?? valueAfter(args, '--bundle') ?? valueAfter(args, '--bundle-url') ) } function closeServer(server: Server): Promise { return new Promise((resolveClose, rejectClose) => { server.close((error) => { if (error) rejectClose(error) else resolveClose() }) }) } export async function servePrebuiltBundle(path: string): Promise { const file = resolve(process.cwd(), path) const stat = statSync(file) if (!stat.isFile() || extname(file) !== '.js') { throw new Error('--app must name a regular Metro .js bundle or a packager target') } const bundle = readFileSync(file) if (bundle.length === 0) throw new Error('--app bundle is empty') const server = createServer((request, response) => { if (new URL(request.url || '/', 'http://127.0.0.1').pathname !== '/bundle.js') { response.writeHead(404).end() return } response.writeHead(200, { 'access-control-allow-origin': '*', 'cache-control': 'no-store', 'content-type': 'application/javascript; charset=utf-8', 'content-length': String(bundle.length), }) response.end(bundle) }) await new Promise((resolveListen, rejectListen) => { server.once('error', rejectListen) server.listen(0, '127.0.0.1', () => { server.off('error', rejectListen) resolveListen() }) }) const address = server.address() if (!address || typeof address === 'string') { await closeServer(server) throw new Error('could not start the local prebuilt-bundle server') } return { target: `http://127.0.0.1:${address.port}/bundle.js`, close: () => closeServer(server), } } export async function resolveTestAppTarget(app: string): Promise { const localPath = resolve(process.cwd(), app) if (existsSync(localPath)) return servePrebuiltBundle(localPath) return { target: app, close: async () => {} } } function restoreRnxUrl(previous: string | undefined): void { if (previous === undefined) { delete process.env.RNX_URL } else { process.env.RNX_URL = previous } } // `--config` is claimed by both interpreters: a YAML value is a maestro // workspace config, anything else is a detox jest config. function isMaestroConfigValue(value: string | null): boolean { return value !== null && /\.ya?ml$/i.test(value) } // dispatch only: each interpreter retains its own selection and execution rules. export function resolveTestCommand(args: string[], cwd = process.cwd()) { const forwarded = hoistLeadingSimFlag(args) const hasConfig = forwarded.some( (arg) => arg === '--config' || arg.startsWith('--config='), ) const maestroConfig = hasConfig && isMaestroConfigValue(valueAfter(forwarded, '--config')) if (hasConfig && !maestroConfig) return { command: 'detox', args: forwarded } // the target is the first positional, wherever it sits: maestro users put // flags before the path (`maestro test --include-tags smoke .maestro/`), // so reading only argv[0] breaks the exact pasted command line. let target = targetFromArgs(forwarded) ?? undefined if (!target && maestroConfig) { // `rnx test --config e2e/config.yaml`: the config describes a workspace, // so run the directory it lives in. target = dirname(resolve(cwd, valueAfter(forwarded, '--config') as string)) } if (!target) { throw new Error( 'pass a Maestro flow/directory, a Jest suite, or --config ', ) } const absolute = resolve(cwd, target) if (!existsSync(absolute)) throw new Error(`test target not found: ${target}`) const directory = statSync(absolute).isDirectory() if ( !directory && /^(?:jest|rnx-detox)\.config\.[cm]?[jt]s$|^jest\.config\.json$/.test( basename(absolute), ) ) { return { command: 'detox', args: ['--config', target, ...forwarded.slice(1)] } } const maestro = directory ? readdirSync(absolute).some((name) => /\.ya?ml$/.test(name)) || ['.maestro', 'maestro'].includes(basename(absolute)) : /\.ya?ml$/.test(extname(absolute)) if (maestro) { // when the target names a workspace config file, run its directory. // the replacement lands on the target positional by index: with // flag-first argv the target is not argv[0], and a path can equal a // flag value elsewhere in the line. const targetIndex = targetIndexFromArgs(forwarded) const runConfigDir = !directory && /^config\.ya?ml$/.test(basename(absolute)) const mapped = forwarded.map((arg, index) => { if (index === targetIndex && runConfigDir) return dirname(absolute) return mapAppFlagToUrl(arg) }) if (targetIndex < 0) { // synthesized target (`--config e2e/config.yaml` with no positional): // name the workspace dir so maestro does not auto-discover cwd. mapped.push(target) } return { command: 'maestro', args: ['test', ...mapped] } } if (!directory && !/\.[cm]?[jt]sx?$/.test(extname(absolute))) { throw new Error(`expected Maestro YAML or a Detox/Jest suite: ${target}`) } return { command: 'detox', args: forwarded } } // helper files a single flow pulls in through runFlow / runScript file // refs, resolved relative to each referencing file exactly like the runner, // transitively with a cycle guard. a ref that does not exist is the runner's // loud failure, not the submitter's: warn and let the hosted run report it // at its step, like a local run does. function collectFlowHelperPaths(flowAbsPath: string): string[] { if (!flowAbsPath.endsWith('.yml') && !flowAbsPath.endsWith('.yaml')) return [] const helpers: string[] = [] const visited = new Set([resolve(flowAbsPath)]) const queue = [resolve(flowAbsPath)] while (queue.length > 0) { const current = queue.pop() as string if (!current.endsWith('.yml') && !current.endsWith('.yaml')) continue let steps: unknown[] try { steps = parseFlowFile(readFileSync(current, 'utf8')).steps } catch (err) { console.warn( ` warn: could not parse flow refs in ${current}: ${err instanceof Error ? err.message : String(err)}`, ) continue } for (const step of steps) { if (typeof step !== 'object' || step === null) continue for (const key of ['runFlow', 'runScript'] as const) { const spec = (step as Record)[key] if (spec === undefined || spec === null) continue const file = typeof spec === 'string' ? spec : typeof (spec as Record).file === 'string' ? ((spec as Record).file as string) : null if (!file) continue const abs = resolve(dirname(current), file) if (visited.has(abs)) continue visited.add(abs) if (existsSync(abs) && statSync(abs).isFile()) { helpers.push(abs) queue.push(abs) } else { console.warn(` warn: flow ref not found: ${file} (from ${current})`) } } } } return helpers } function collectTestFiles( targetPath: string, ignoredPath: string | null, ): Record { const files: Record = {} let sourceBytes = 0 const add = (path: string, relativePath: string) => { if (Object.keys(files).length >= RNX_CLOUD_TEST_FILES_MAX_COUNT) { throw new Error( `cloud test suites may contain at most ${RNX_CLOUD_TEST_FILES_MAX_COUNT} files`, ) } sourceBytes += statSync(path).size if (sourceBytes > RNX_CLOUD_TEST_FILES_MAX_BYTES) { throw new Error( `cloud test files may contain at most ${RNX_CLOUD_TEST_FILES_MAX_BYTES} bytes`, ) } files[relativePath] = readFileSync(path, 'utf8') } const stat = statSync(targetPath) if (stat.isDirectory()) { function walk(dir: string, base: string) { for (const entry of readdirSync(dir, { withFileTypes: true })) { if (entry.name.startsWith('.') && entry.name !== '.maestro') continue if (['node_modules', 'dist', 'build', '.git'].includes(entry.name)) continue const full = join(dir, entry.name) if (ignoredPath && resolve(full) === ignoredPath) continue const rel = base ? `${base}/${entry.name}` : entry.name if (entry.isDirectory()) { walk(full, rel) } else if (entry.isFile()) { add(full, rel) } } } walk(targetPath, '') } else if (stat.isFile()) { const parent = dirname(targetPath) const helperAbsPaths = collectFlowHelperPaths(targetPath) if (helperAbsPaths.length === 0) { add(targetPath, basename(targetPath)) } else { // the flow references helpers by path relative to itself (runFlow / // runScript file). key every file by its path under their common root // so the recorder's unpack preserves those relative refs. const all = [resolve(targetPath), ...helperAbsPaths] const split = all.map((abs) => abs.split(sep)) const commonParts = split[0]?.slice(0) ?? [] while ( commonParts.length > 0 && !split.every( (parts) => parts.length >= commonParts.length && commonParts.every((part, i) => parts[i] === part), ) ) { commonParts.pop() } const common = commonParts.join(sep) || sep const keys = all.map((abs) => relative(common, abs).split(sep).join('/')) for (const [index, abs] of all.entries()) { add(abs, keys[index] as string) } // the recorder runs an unpacked multi-file upload as a workspace, // whose default glob only sees top-level flows: a nested entry would // lose to a top-level helper. pin a config that selects the entry. if (!keys.some((key) => /^config\.ya?ml$/.test(basename(key)))) { if (Object.keys(files).length >= RNX_CLOUD_TEST_FILES_MAX_COUNT) { throw new Error( `cloud test suites may contain at most ${RNX_CLOUD_TEST_FILES_MAX_COUNT} files`, ) } const entryKey = keys[0] as string const glob = entryKey.replace(/[\\*?[\]{}()!+@]/g, '\\$&') files['config.yaml'] = '# synthesized by rnx test --cloud: run exactly the submitted entry flow.\n' + `flows:\n - ${JSON.stringify(glob)}\n` } } for (const configName of [ 'rnx-detox.config.cjs', 'jest.config.cjs', 'jest.config.js', ]) { const configPath = join(parent, configName) if (existsSync(configPath) && !files[configName]) { add(configPath, configName) } } } const serializedBytes = Buffer.byteLength(JSON.stringify(files)) if (serializedBytes > RNX_CLOUD_TEST_FILES_MAX_BYTES) { throw new Error( `cloud test files serialize to ${serializedBytes} bytes; the limit is ${RNX_CLOUD_TEST_FILES_MAX_BYTES}`, ) } return files } async function readJobStatus( endpoint: string, authorization: string, ): Promise { try { const res = await fetch(endpoint, { headers: { authorization } }) if (!res.ok) return null const json = (await res.json()) as { status?: unknown } | null return json && typeof json.status === 'string' ? json.status : null } catch { return null } } export async function cancelCloudTest(jobId: string, origin: string): Promise { const auth = resolveCliAuth() if (!auth) { console.error(' error: cancelling a cloud test requires `rnx login` or RNX_API_KEY') return 1 } const endpoint = `${origin.replace(/\/$/, '')}/api/sootsim/runner/jobs?id=${encodeURIComponent(jobId)}` let response: Response try { response = await fetch(endpoint, { method: 'DELETE', headers: { authorization: authHeaderValue(auth) }, }) } catch (err) { console.error( ` error: could not connect to ${endpoint}: ${err instanceof Error ? err.message : String(err)}`, ) return 1 } if (response.status === 404) { console.error(` error: cloud test job not found: ${jobId}`) return 1 } if (!response.ok) { const body = await response.text() let message = body try { const json = JSON.parse(body) if (json.message) message = json.message } catch {} console.error(` error: cancel failed (${response.status}): ${message}`) return 1 } let status: string | null = null try { const json = (await response.json()) as { status?: unknown } | null if (json && typeof json.status === 'string') status = json.status } catch { // an older server answers 2xx with no cancel body; fall through to the // read-back below rather than claim anything. } if (status === null) { status = await readJobStatus(endpoint, authHeaderValue(auth)) if (status === 'queued' || status === 'running') { console.error( ` error: the server left ${jobId} ${status}; cancel needs a newer job api`, ) return 1 } if (status === null) { console.error(` error: cancel sent but the state of ${jobId} is unknown`) return 1 } } if (status === 'cancelled') { console.log(` cloud test job cancelled: ${jobId}`) } else { console.log(` cloud test job already ${status}: ${jobId}`) } return 0 } export async function runCloudTest( args: string[], opts: RunTestOptions = {}, ): Promise { const forwarded = args.filter((a) => a !== '--cloud' && a !== '--hosted') const originFlag = valueAfter(forwarded, '--origin') const origin = await resolveDefaultUploadOrigin(originFlag ?? undefined) const cancelId = valueAfter(forwarded, '--cancel') if (forwarded.includes('--cancel') || cancelId !== null) { if (!cancelId) { console.error(' usage: rnx test --cancel ') return 1 } return cancelCloudTest(cancelId, origin) } const app = valueAfter(forwarded, '--app') || valueAfter(forwarded, '--bundle') || valueAfter(forwarded, '--bundle-url') || valueAfter(forwarded, '--url') || forwarded.find((a) => a.startsWith('--app='))?.split('=')[1] || forwarded.find((a) => a.startsWith('--bundle='))?.split('=')[1] || forwarded.find((a) => a.startsWith('--url='))?.split('=')[1] const owner = valueAfter(forwarded, '--owner') const repo = valueAfter(forwarded, '--repo') let target = targetFromArgs(forwarded) const config = valueAfter(forwarded, '--config') if (!target && config) { // a maestro workspace config uploads its directory; a jest config is // collected from its own dir by the path logic below. target = isMaestroConfigValue(config) ? dirname(resolve(process.cwd(), config)) : config } if (!target) { for (const cand of ['.maestro', 'maestro', 'flows', 'e2e']) { if (existsSync(resolve(process.cwd(), cand))) { target = cand break } } } if (!target) { console.error(' usage: rnx test --app --cloud') return 1 } const targetPath = resolve(process.cwd(), target) if (!existsSync(targetPath)) { console.error(` error: test target not found: ${target}`) return 1 } if (!app) { console.error( ' error: rnx test --cloud requires --app ', ) return 1 } const localOnly = [ '--include-tags', '--exclude-tags', '--format', '--output', '--test-output-dir', '--debug-output', ].filter((flag) => forwarded.some((arg) => arg === flag || arg.startsWith(`${flag}=`))) if (localOnly.length > 0) { console.warn( ` warn: ${localOnly.join(', ')} apply to local runs only and are ignored by --cloud`, ) } const auth = resolveCliAuth() if (!auth) { console.error(' error: rnx test --cloud requires `rnx login` or RNX_API_KEY') return 1 } const pollIntervalFlag = valueAfter(forwarded, '--poll-interval') const timeoutFlag = valueAfter(forwarded, '--timeout') const pollIntervalMs = pollIntervalFlag === null ? (opts.pollIntervalMs ?? 1000) : Number(pollIntervalFlag) const timeoutMs = timeoutFlag === null ? (opts.timeoutMs ?? 20 * 60 * 1000) : Number(timeoutFlag) if (!Number.isFinite(pollIntervalMs) || pollIntervalMs < 1) { console.error(' error: --poll-interval must be a positive number of milliseconds') return 1 } if (!Number.isFinite(timeoutMs) || timeoutMs < 1) { console.error(' error: --timeout must be a positive number of milliseconds') return 1 } let bundleUrl: string | undefined let bundle: string | undefined let bundleShareId: string | undefined let tunnelTargetUrl: string | undefined let tunnelHostHeader: string | undefined let tunnelJobToken: string | undefined let tunnelSidecarPorts: string | undefined let closeTunnel: (() => void) | null = null const liveDevStack = parseLiveDevStackApp(app) const sidecarFlag = valueAfter(forwarded, '--sidecar-ports') let liveSidecarPorts: number[] | null = null if (liveDevStack) { const parsedSidecars = parseCloudTestSidecarPorts(sidecarFlag, liveDevStack.port) if (!parsedSidecars.ok) { console.error(` error: ${parsedSidecars.message}`) return 1 } liveSidecarPorts = parsedSidecars.ports } else { if (sidecarFlag !== null) { console.error( ' error: --sidecar-ports requires a live dev stack --app (http://localhost:)', ) return 1 } if (app.startsWith('http://') || app.startsWith('https://')) { bundleUrl = app } else if (app.startsWith('srb_') || app.includes('/api/preview/share/bundle')) { bundleShareId = app.replace(/^.*id=/, '') } else { const localBundle = resolve(process.cwd(), app) if (!existsSync(localBundle) || !statSync(localBundle).isFile()) { console.error(` error: --app bundle file not found: ${app}`) return 1 } bundle = readFileSync(localBundle, 'utf8') } } const targetName = basename(targetPath) const collectionPath = statSync(targetPath).isFile() && (/^(?:jest|rnx-detox)\.config\.[cm]?[jt]s$|^jest\.config\.json$/.test(targetName) || /\.[cm]?[jt]sx?$/.test(extname(targetPath))) ? dirname(targetPath) : targetPath const localBundlePath = app.startsWith('http://') || app.startsWith('https://') || app.startsWith('srb_') || app.includes('/api/preview/share/bundle') ? null : resolve(process.cwd(), app) const testFiles = collectTestFiles(collectionPath, localBundlePath) if (Object.keys(testFiles).length === 0) { console.error(` error: no test files found at ${target}`) return 1 } // the tunnel stays open from submit until the job reaches a terminal // state; tearing it down early strands the recorder mid-run. if (liveDevStack && liveSidecarPorts) { try { const published = await publishLiveDevStack({ devPort: liveDevStack.port, sidecarPorts: liveSidecarPorts, }) tunnelTargetUrl = published.targetUrl tunnelHostHeader = published.hostHeader tunnelJobToken = published.jobToken tunnelSidecarPorts = published.sidecarPorts.join(',') || undefined closeTunnel = published.close console.log(` live dev stack published: ${published.targetUrl}`) } catch (err) { console.error(` error: ${err instanceof Error ? err.message : String(err)}`) return 1 } } const submitPayload: Record = { kind: 'test', testFiles, } if (bundleUrl) submitPayload.bundleUrl = bundleUrl if (bundle) submitPayload.bundle = bundle if (bundleShareId) submitPayload.bundleShareId = bundleShareId if (tunnelTargetUrl) submitPayload.targetUrl = tunnelTargetUrl if (tunnelHostHeader) submitPayload.hostHeader = tunnelHostHeader if (tunnelJobToken) submitPayload.jobToken = tunnelJobToken if (tunnelSidecarPorts) submitPayload.sidecarPorts = tunnelSidecarPorts if (owner) submitPayload.owner = owner if (repo) submitPayload.repo = repo try { const endpoint = `${origin.replace(/\/$/, '')}/api/sootsim/runner/jobs` let response: Response try { response = await fetch(endpoint, { method: 'POST', headers: { 'content-type': 'application/json', authorization: authHeaderValue(auth), }, body: JSON.stringify(submitPayload), }) } catch (err) { console.error( ` error: could not connect to ${endpoint}: ${err instanceof Error ? err.message : String(err)}`, ) return 1 } if (!response.ok) { const body = await response.text() let message = body try { const json = JSON.parse(body) if (json.message) message = json.message } catch {} console.error( ` error: cloud test submission failed (${response.status}): ${message}`, ) return 1 } const { id } = (await response.json()) as { id: string } console.log(` cloud test job submitted: ${id}`) const pollUrl = `${origin.replace(/\/$/, '')}/api/sootsim/runner/jobs?id=${encodeURIComponent(id)}` const startedAt = Date.now() while (Date.now() - startedAt < timeoutMs) { await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)) let pollRes: Response try { pollRes = await fetch(pollUrl, { headers: { authorization: authHeaderValue(auth) }, }) } catch (err) { console.warn( ` warn: error polling job: ${err instanceof Error ? err.message : String(err)}`, ) continue } if (!pollRes.ok) { console.warn(` warn: polling error: HTTP ${pollRes.status}`) continue } const job = (await pollRes.json()) as { id: string status: string result?: { previewId?: string | null previewUrl?: string | null runId?: string | null exitCode?: number | null failureReason?: string | null } | null } if ( job.status === 'done' || job.status === 'failed' || job.status === 'expired' || job.status === 'cancelled' ) { const result = job.result // a job the recorder failed after a passing suite (missing recording or // registration) is still a failed run. const exitCode = job.status === 'done' ? (result?.exitCode ?? 0) : result?.exitCode || 1 console.log(` status: ${job.status}`) console.log(` exit code: ${exitCode}`) if (result?.previewUrl) { console.log(` preview: ${result.previewUrl}`) } else if (result?.previewId) { console.log( ` preview: ${origin.replace(/\/$/, '')}/preview/${result.previewId}`, ) } if (result?.runId) { console.log(` run: ${result.runId}`) } if (result?.failureReason) { console.error(` failure: ${result.failureReason}`) } return exitCode } } console.error(` error: cloud test timed out after ${Math.round(timeoutMs / 1000)}s`) return 1 } finally { closeTunnel?.() } } export async function runTest( args: string[], opts: RunTestOptions = {}, ): Promise { if ( args.includes('--cloud') || args.includes('--hosted') || args.some((arg) => arg === '--cancel' || arg.startsWith('--cancel=')) ) { return runCloudTest(args, opts) } const selected = resolveTestCommand(args) const app = appFromArgs(args) const appTarget = app ? await resolveTestAppTarget(app) : { target: '', close: async () => {} } try { if (selected.command === 'maestro') { const { flowTimeoutScale } = await import('../../src/flow-timeout-scale') if (flowTimeoutScale() !== 1) { throw new Error( 'rnx test requires original flow timeouts; unset SOOTSIM_FLOW_TIMEOUT_SCALE', ) } const { runMaestro } = await import('./maestro') const maestroArgs = appTarget.target ? selected.args.map((arg, idx) => { const prev = selected.args[idx - 1] if (prev === '--url') return appTarget.target if (arg.startsWith('--url=')) return `--url=${appTarget.target}` return arg }) : selected.args const code = await runMaestro(maestroArgs, opts) return typeof code === 'number' ? code : 0 } const { runDetox } = await import('./detox') const detoxArgs = withoutApp(selected.args) const previousRnxUrl = process.env.RNX_URL if (appTarget.target) { process.env.RNX_URL = await buildShellUrl( appTarget.target, resolveShellBaseUrlForBridgePort( opts.port ?? (Number(process.env.SOOTSIM_PORT) || 5173), ), ) } try { await runDetox(detoxArgs, opts) return 0 } finally { if (appTarget.target) { restoreRnxUrl(previousRnxUrl) } } } finally { await appTarget.close() } }