// rnx detox — run existing detox suites against rnx // // use the customer's jest config unchanged. the rnx preset maps the detox // driver and owns browser cleanup; jest owns setup, selection and deadlines. import { spawn } from 'child_process' import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync, unlinkSync, } from 'fs' import { createRequire } from 'module' import { fileURLToPath } from 'node:url' import { tmpdir } from 'os' import { dirname, resolve, join, relative } from 'path' import { rnxPublicBrand } from '../../src/public-brand' import { resolveCliAuth } from '../auth' import { registerRun, shouldRegisterRun } from '../run-registry' import { rnxExit } from '../run-rnx' import { buildShellUrl } from './control' import { discoverSootsimUrl } from './flow' import { resolveDefaultUploadOrigin } from './upload' interface RunDetoxOpts { port?: number verbose?: boolean } const HELP = ` rnx detox — run detox-style tests against an rnx shell usage: rnx detox [testFiles...] run all tests under e2e/, test/e2e/, or detox/ rnx detox --config use a specific jest config rnx detox init scaffold rnx-detox.config.cjs + sample test rnx detox --watch jest watch mode rnx detox --headed keep the rnx shell window visible rnx detox -t pass a jest --testNamePattern rnx detox --no-launch skip auto-launching an rnx shell rnx detox auto-launches an rnx shell if none is running on the expected port. disable with --no-launch if you're managing the shell yourself. ` export async function runDetox(args: string[], opts: RunDetoxOpts = {}): Promise { if (args.includes('--help') || args.includes('-h')) { console.log(HELP) rnxExit(0) } if (args[0] === 'init') { return initDetoxProject() } const getFlag = (name: string) => { const inline = args.find((arg) => arg.startsWith(`${name}=`)) if (inline) return inline.slice(name.length + 1) const i = args.indexOf(name) return i >= 0 ? args[i + 1] : undefined } const hasFlag = (name: string) => args.includes(name) const watch = hasFlag('--watch') || hasFlag('--watchAll') const headed = hasFlag('--headed') const noLaunch = hasFlag('--no-launch') const configFlag = getFlag('--config') const port = Number(getFlag('--port')) || opts.port || Number(process.env.SOOTSIM_PORT) || 5173 if (getFlag('--sim')) { throw new Error( 'Detox owns its browser context; pin its shell with RNX_URL and --device instead of --sim', ) } // consume only rnx options. jest must receive its complete argv, including // selection, timeouts, reporters, coverage, and options added by future jest versions. const jestArgs: string[] = ['jest'] for (let index = 0; index < args.length; index++) { const arg = args[index] if (arg === '--headed' || arg === '--no-launch') continue const name = arg.split('=', 1)[0] if (['--config', '--app', '--device', '--port'].includes(name)) { if (!arg.includes('=')) { if (!args[index + 1] || args[index + 1].startsWith('-')) { throw new Error(`${name} requires a value`) } index++ } continue } jestArgs.push(arg === '--grep' ? '--testNamePattern' : arg) } let localConfig = configFlag || firstExisting([ 'rnx-detox.config.cjs', 'jest.config.ts', 'jest.config.mts', 'jest.config.mjs', 'jest.config.cjs', 'jest.config.js', 'jest.config.cts', 'jest.config.json', ]) if (!localConfig && existsSync('package.json')) { const pkg = JSON.parse(readFileSync('package.json', 'utf8')) if (pkg.jest) localConfig = 'package.json' } // test dir discovery. external test suites set RNX_TEST_DIR; otherwise // we auto-detect the conventional locations. const detoxDirs = ['e2e', 'test/e2e', 'detox'] let testDir: string | null = process.env.RNX_TEST_DIR ? resolve(process.cwd(), process.env.RNX_TEST_DIR) : null if (!testDir && !localConfig && args[0] && !args[0].startsWith('-')) { const target = resolve(args[0]) if (existsSync(target)) testDir = statSync(target).isDirectory() ? target : dirname(target) } if (!testDir && !localConfig) { for (const dir of detoxDirs) { const full = resolve(process.cwd(), dir) if (existsSync(full)) { testDir = full break } } } if (!testDir && !localConfig) { console.error( ` error: no detox tests found. expected one of: ${detoxDirs.join(', ')}\n` + ` run 'rnx detox init' to scaffold a starter suite.`, ) rnxExit(1) } testDir ??= process.cwd() // auto-launch: make sure an rnx shell is reachable before running jest. // Reuse the Maestro discovery path: scan local dev servers. // and `rnx open` the first match. if (!noLaunch) { await ensureShellRunning(port) } // build the jest command. prefer the user's config if they passed --config // or if a rnx-detox.config.cjs exists in cwd. otherwise write a temporary // config that loads our preset. let tempConfig: string | null = null if (localConfig) { jestArgs.push( '--config', localConfig.startsWith('{') ? localConfig : resolve(process.cwd(), localConfig), ) } else { // no local config — write a temp config that pulls in our preset. // jest's --preset flag has quirky module resolution that breaks with // subpath exports, so we generate a real config file instead. // scope roots + testMatch to the discovered test dir so jest doesn't // scan the entire repo (which causes haste collisions in monorepos). const presetPath = fileURLToPath( import.meta.resolve(`${rnxPublicBrand.packageName}/detox/jest-preset`), ) // an external suite may optionally declare its own ignore patterns via e2e/jest-ignore.cjs const suiteIgnoreFile = testDir ? join(testDir, 'e2e', 'jest-ignore.cjs') : null let suiteIgnorePatterns: string[] = [] if (suiteIgnoreFile && existsSync(suiteIgnoreFile)) { const req = createRequire(import.meta.url) const exported = req(suiteIgnoreFile) const patterns = Array.isArray(exported) ? exported : exported.testPathIgnorePatterns if ( !Array.isArray(patterns) || !patterns.every((pattern) => typeof pattern === 'string') ) { throw new Error(`${suiteIgnoreFile} must export string ignore patterns`) } suiteIgnorePatterns = patterns } tempConfig = join(tmpdir(), `rnx-detox-${process.pid}.config.cjs`) const cfg = { rootDir: testDir, roots: [''], testMatch: ['/**/*.test.ts', '/**/*.test.js'], ...(suiteIgnorePatterns.length > 0 ? { testPathIgnorePatterns: suiteIgnorePatterns } : {}), } writeFileSync( tempConfig, `module.exports = { ...require(${JSON.stringify(presetPath)}), ...${JSON.stringify(cfg)} }\n`, ) jestArgs.push('--config', tempConfig) } // hosted run registration (org dashboard pass/fail row). when it applies, // capture jest's structured results via --json --outputFile so the row can // carry test counts + the first failure; console output stays untouched. const auth = resolveCliAuth() const wantsRegistration = !watch && (process.env.RNX_REQUIRE_REGISTRATION === 'true' || shouldRegisterRun({ uploadedShare: false, auth })) let resultsFile: string | null = getFlag('--outputFile') || process.env.SOOTSIM_JEST_OUTPUT_FILE || null if ((process.env.SOOTSIM_JEST_JSON === '1' || wantsRegistration) && !hasFlag('--json')) jestArgs.push('--json') if (!getFlag('--outputFile') && process.env.SOOTSIM_JEST_OUTPUT_FILE) { jestArgs.push('--outputFile', process.env.SOOTSIM_JEST_OUTPUT_FILE) } else if (!resultsFile && wantsRegistration) { resultsFile = join(tmpdir(), `rnx-detox-results-${process.pid}.json`) jestArgs.push('--outputFile', resultsFile) } console.log(` rnx detox`) console.log(` test dir: ${testDir}`) console.log(` port: ${port}${headed ? ' (headed)' : ''}`) if (localConfig) console.log(` config: ${localConfig}`) const shellBaseUrl = process.env.RNX_URL || `http://localhost:${port}` const app = getFlag('--app') const shellUrl = new URL(app ? await buildShellUrl(app, shellBaseUrl) : shellBaseUrl) const device = getFlag('--device') if (device) shellUrl.searchParams.set('device', device) // proof capture runs under the repo's pinned node runtime. its v8 collector // crashes in ClearStaleLeftTrimmedPointerVisitor when Sparkplug baseline // compilation (Builtins_BaselineOutOfLinePrologue) pushes untagged register // values onto the stack across CEntry, which MarkCompact root iteration // then visits as tagged heap pointers. disable both Maglev and Sparkplug so // execution stays on the stable Ignition + TurboFan pipeline under Node 24. // normal user detox runs retain their node defaults and resolve jest through npx. const proofCapture = process.env.CONFORMANCE_PROOF === '1' || !!process.env.CONFORMANCE_PROOF_DIR || process.env.CONFORMANCE_TARGET === 'sootsim' const command = proofCapture ? 'node' : 'npx' const commandArgs = proofCapture ? [ '--no-maglev', '--no-sparkplug', createRequire(join(process.cwd(), 'package.json')).resolve('jest/bin/jest'), ...jestArgs.slice(1), ] : jestArgs const child = spawn(command, commandArgs, { cwd: process.cwd(), stdio: 'inherit', env: { ...process.env, RNX_TEST_DIR: testDir, SOOTSIM_PORT: String(port), SOOTSIM_HEADED: headed ? '1' : '', RNX_URL: shellUrl.toString(), }, }) // jest owns the browsers, and it only reaps them from its own signal and exit // handlers. this process is the one a caller has a pid for, so a SIGTERM aimed // at `rnx detox` lands here and jest never hears about it: it is a // grandchild (this -> npx -> jest), so it keeps running with no parent and // every chrome it launched is orphaned for good. forward the signal instead of // exiting out from under it, and let its exit code end this process. const forward = (signal: NodeJS.Signals) => () => { child.kill(signal) } const onTerm = forward('SIGTERM') const onInt = forward('SIGINT') process.on('SIGTERM', onTerm) process.on('SIGINT', onInt) const startedAt = Date.now() let code: number try { code = await new Promise((resolveCode, reject) => { child.once('error', reject) child.once('exit', (exitCode, signal) => { if (signal) { console.error(` error: jest terminated by ${signal}`) resolveCode(1) return } resolveCode(exitCode ?? 1) }) }) } finally { process.off('SIGTERM', onTerm) process.off('SIGINT', onInt) if (tempConfig) unlinkSync(tempConfig) } if (wantsRegistration) { const results = resultsFile && existsSync(resultsFile) && statSync(resultsFile).mtimeMs >= startedAt ? readJestResults(resultsFile) : null // a selection that skipped every test is not a passing suite. if (code === 0 && results && results.numPassedTests === 0) { console.error(' error: no tests were selected or executed') code = 1 } const origin = await resolveDefaultUploadOrigin() const run = await registerRun({ origin, kind: 'detox', name: relative(process.cwd(), testDir) || 'e2e', status: code === 0 ? 'passed' : 'failed', summary: results ? `${results.numPassedTests}/${results.numTotalTests} tests passed` : null, failureMessage: code === 0 ? null : (results?.firstFailure ?? null), // tests map onto the row's step summary: total count + first failing // index, same shape flow lanes fill from their step trace. stepCount: results?.numTotalTests ?? null, failedStepIndex: results?.firstFailedIndex ?? null, durationMs: Date.now() - startedAt, auth, }) if (!run) rnxExit(code || 1) console.log(` run: ${run.id}`) writeFileSync( join(process.cwd(), 'test-result.json'), JSON.stringify({ runId: run.id, previewUrl: run.previewUrl, traceUrl: run.traceUrl, }), ) } if ( resultsFile && existsSync(resultsFile) && !getFlag('--outputFile') && !process.env.SOOTSIM_JEST_OUTPUT_FILE ) { unlinkSync(resultsFile) } rnxExit(code) } export type JestRunSummary = { numTotalTests: number numPassedTests: number firstFailure: string | null firstFailedIndex: number | null } // parse jest's --json output file down to the counts + first failure the // hosted run row carries. tolerant: any parse problem returns null and the // run registers with exit-code status only. export function readJestResults(file: string): JestRunSummary | null { try { const parsed: unknown = JSON.parse(readFileSync(file, 'utf8')) if ( !parsed || typeof parsed !== 'object' || !('numTotalTests' in parsed) || typeof parsed.numTotalTests !== 'number' || !('numPassedTests' in parsed) || typeof parsed.numPassedTests !== 'number' || !('testResults' in parsed) || !Array.isArray(parsed.testResults) ) return null let firstFailure: string | null = null let firstFailedIndex: number | null = null let index = 0 for (const suite of parsed.testResults) { if (!suite || typeof suite !== 'object' || !Array.isArray(suite.assertionResults)) return null for (const test of suite.assertionResults) { if (!test || typeof test !== 'object') return null if (firstFailedIndex == null && test.status === 'failed') { firstFailedIndex = index const messages: unknown[] = Array.isArray(test.failureMessages) ? test.failureMessages : [] const message = messages .filter((value): value is string => typeof value === 'string') .join('\n') firstFailure = [typeof test.fullName === 'string' ? test.fullName : '', message] .filter(Boolean) .join(': ') || null } index += 1 } } return { numTotalTests: parsed.numTotalTests, numPassedTests: parsed.numPassedTests, firstFailure, firstFailedIndex, } } catch { return null } } function firstExisting(names: string[]): string | null { for (const n of names) { if (existsSync(resolve(process.cwd(), n))) return n } return null } async function ensureShellRunning(port: number): Promise { const shellUrl = await discoverSootsimUrl() if (shellUrl) return console.warn( ` warn: no rnx shell reachable on :${port}. start your app, then run \`rnx open \`\n` + ` or pass --no-launch if you're managing the shell yourself.`, ) } function initDetoxProject(): void { const cwd = process.cwd() const configPath = resolve(cwd, 'rnx-detox.config.cjs') if (existsSync(configPath)) { console.log(` skip: ${configPath} already exists`) } else { writeFileSync( configPath, `// rnx detox jest config. // extends the rnx preset, which rewrites \`import ... from 'detox'\` // to the rnx driver and keeps your jest transforms intact. /** @type {import('@jest/types').Config.InitialOptions} */ module.exports = { preset: 'rnxsim/detox', rootDir: __dirname, testMatch: ['/e2e/**/*.test.ts', '/e2e/**/*.test.js'], } `, ) console.log(` created ${configPath}`) } const e2eDir = resolve(cwd, 'e2e') const samplePath = resolve(e2eDir, 'example.test.ts') if (existsSync(samplePath)) { console.log(` skip: ${samplePath} already exists`) } else { mkdirSync(e2eDir, { recursive: true }) writeFileSync( samplePath, `// sample rnx detox test. resolve \`detox\` via the jest preset in // rnx-detox.config.cjs — no extra imports required. import { by, device, element, expect, waitFor } from 'detox' describe('example', () => { beforeAll(async () => { await device.launchApp() }) it('shows the welcome text', async () => { await waitFor(element(by.text('Welcome'))).toBeVisible().withTimeout(5000) }) it('taps a button by id', async () => { await element(by.id('start-button')).tap() await expect(element(by.id('home-screen'))).toBeVisible() }) }) `, ) console.log(` created ${samplePath}`) } console.log(`\n next:`) console.log(` rnx detox # run the sample`) console.log(` rnx detox --watch`) rnxExit(0) }