rnxsim 0.1.408 → 0.1.410

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.md +11 -10
  2. package/cli/bin.ts +16 -643
  3. package/cli/commands/assert.ts +6 -6
  4. package/cli/commands/box/rnx-command.ts +3 -11
  5. package/cli/commands/control.ts +3 -9
  6. package/cli/commands/daemon-mac-app.ts +5 -11
  7. package/cli/commands/daemon.ts +63 -113
  8. package/cli/drivers/playwright-provisioning.ts +33 -29
  9. package/cli/drivers/playwright-sim-host.ts +501 -0
  10. package/cli/drivers/playwright.ts +35 -522
  11. package/cli/hints.ts +2 -4
  12. package/cli/internal-child.ts +176 -0
  13. package/cli/maestro-js.ts +28 -39
  14. package/cli/main.ts +645 -0
  15. package/cli/outbound-endpoints.ts +8 -0
  16. package/cli/self-invocation.ts +34 -0
  17. package/dist-lib/agent-daemon-client.cjs +35 -25
  18. package/dist-lib/agent-events.cjs +1 -1
  19. package/dist-lib/agent-identity.cjs +1 -1
  20. package/dist-lib/agent-sessions.cjs +35 -25
  21. package/dist-lib/attached-projects.cjs +1 -1
  22. package/dist-lib/auth/shared-session.cjs +1 -1
  23. package/dist-lib/backend-origin.cjs +1 -1
  24. package/dist-lib/beta.cjs +1 -1
  25. package/dist-lib/beta.mjs +1 -1
  26. package/dist-lib/bridge-constants.cjs +1 -1
  27. package/dist-lib/bridge-contract-input.cjs +1 -1
  28. package/dist-lib/bridge-contract-input.mjs +1 -1
  29. package/dist-lib/bridge-contract.cjs +1 -1
  30. package/dist-lib/bridge-contract.mjs +1 -1
  31. package/dist-lib/capture-contract.cjs +1 -1
  32. package/dist-lib/capture-contract.mjs +1 -1
  33. package/dist-lib/cli-constants.cjs +1 -1
  34. package/dist-lib/cloud-contract.cjs +1 -4
  35. package/dist-lib/cloud-contract.mjs +1 -3
  36. package/dist-lib/config.cjs +1 -1
  37. package/dist-lib/detox/index.cjs +1 -1
  38. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  39. package/dist-lib/home-paths.cjs +1 -1
  40. package/dist-lib/host/bridge-host.cjs +25 -15
  41. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  42. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  43. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  44. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  45. package/dist-lib/host/websocket-proxy.cjs +1 -1
  46. package/dist-lib/index.cjs +1 -1
  47. package/dist-lib/jump-to-source-babel.cjs +1 -1
  48. package/dist-lib/menu.cjs +1 -1
  49. package/dist-lib/menu.mjs +1 -1
  50. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  51. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  52. package/dist-lib/metro-production-bundle.cjs +1 -1
  53. package/dist-lib/metro-production-bundle.mjs +1 -1
  54. package/dist-lib/metro.cjs +1 -1
  55. package/dist-lib/profiles.cjs +1 -1
  56. package/dist-lib/public-brand.cjs +1 -1
  57. package/dist-lib/react-native-host-modules.cjs +1 -1
  58. package/dist-lib/react-native-host-modules.mjs +1 -1
  59. package/dist-lib/render-mode.cjs +1 -1
  60. package/dist-lib/scripts/dev-server-scanner.cjs +1 -1
  61. package/dist-lib/sdk.cjs +1 -1
  62. package/dist-lib/sdk.mjs +1 -1
  63. package/dist-lib/skills.cjs +134 -608
  64. package/dist-lib/vite.cjs +1 -1
  65. package/package.json +1 -1
  66. package/src/agent-daemon-client.ts +2 -2
  67. package/src/agent-sessions.ts +29 -24
  68. package/src/cloud-contract.ts +0 -1
  69. package/src/vite-plugin.ts +62 -0
@@ -15,6 +15,7 @@
15
15
 
16
16
  import { spawn } from 'child_process'
17
17
  import { rnxExit } from '../run-rnx'
18
+ import { rnxSelfInvocation } from '../self-invocation'
18
19
 
19
20
  // READ_VERBS lists every command whose stdout we know is valid JSON under
20
21
  // --json. keeping this explicit means `rnx assert tap 100 200` fails
@@ -161,14 +162,13 @@ interface VerbResult {
161
162
  }
162
163
 
163
164
  async function runVerbOnce(verbArgs: string[]): Promise<VerbResult> {
164
- // re-invoke self using whichever entrypoint is actually running (bin.ts
165
- // in dev, dist-cli/bin.js when installed). running the real bin keeps us
166
- // honest every code path the user's verb would take, assert takes too.
167
- const binPath = process.argv[1]
168
- const args = [binPath, ...verbArgs, '--json']
165
+ // re-invoke self through the real CLI entry, so every code path the user's
166
+ // verb would take, assert takes too.
167
+ const { executable, prefixArgs } = rnxSelfInvocation()
168
+ const args = [...prefixArgs, ...verbArgs, '--json']
169
169
 
170
170
  return new Promise<VerbResult>((resolve) => {
171
- const proc = spawn(process.execPath, args, {
171
+ const proc = spawn(executable, args, {
172
172
  stdio: ['ignore', 'pipe', 'pipe'],
173
173
  env: process.env,
174
174
  })
@@ -11,17 +11,9 @@ import path from 'node:path'
11
11
  import { defineCommand, type CustomCommand } from '@rnx/box/shell'
12
12
  import { PROJECT_MOUNT } from '@rnx/box/shell'
13
13
  import { rnxPublicBrand } from '../../../src/public-brand'
14
+ import { rnxSelfInvocation } from '../../self-invocation'
14
15
  import { RNX_PLACEMENT_ENV } from '../../ws-bridge'
15
16
 
16
- // the standalone binary is its own entry; the npm package runs through node
17
- // with the CLI as argv[1]. this is the same shape bin.ts uses to respawn
18
- // itself for automatic cleanup.
19
- function selfInvocation(): { command: string; prefix: string[] } {
20
- const entry = process.argv[1]
21
- if (!entry) return { command: process.execPath, prefix: [] }
22
- return { command: process.execPath, prefix: [entry] }
23
- }
24
-
25
17
  // map a shell path under /project back onto the real checkout, so a child
26
18
  // process started from `cd src && rnx describe` sees the directory the user
27
19
  // is actually standing in.
@@ -36,8 +28,8 @@ export function createRnxCommand(options: {
36
28
  shellCwd: () => string
37
29
  }): CustomCommand {
38
30
  return defineCommand(rnxPublicBrand.commandName, async (args) => {
39
- const { command, prefix } = selfInvocation()
40
- const child = spawn(command, [...prefix, ...args], {
31
+ const { executable, prefixArgs } = rnxSelfInvocation()
32
+ const child = spawn(executable, [...prefixArgs, ...args], {
41
33
  cwd: hostCwd(options.root, options.shellCwd()),
42
34
  // mark the child as running inside a local box. this is the only thing
43
35
  // that sets a placement — nothing infers one from cwd — and it is what
@@ -57,6 +57,7 @@ import {
57
57
  import { getDriver } from '../drivers/registry'
58
58
  import { openUrl as openUrlInBrowser } from '../open-url'
59
59
  import { rethrowIfExit, rnxExit } from '../run-rnx'
60
+ import { rnxSelfInvocation } from '../self-invocation'
60
61
  import {
61
62
  BridgeSimLockedError,
62
63
  bridgePortForShellBaseUrl,
@@ -250,15 +251,8 @@ async function ensureRuntimeForOpen() {
250
251
  }
251
252
 
252
253
  function spawnSootsim(args: string[]) {
253
- const entry = process.argv[1]
254
- if (entry && fs.existsSync(entry)) {
255
- return spawn(process.execPath, [entry, ...args], {
256
- detached: true,
257
- stdio: 'ignore',
258
- env: process.env,
259
- })
260
- }
261
- return spawn(rnxPublicBrand.commandName, args, {
254
+ const { executable, prefixArgs } = rnxSelfInvocation()
255
+ return spawn(executable, [...prefixArgs, ...args], {
262
256
  detached: true,
263
257
  stdio: 'ignore',
264
258
  env: process.env,
@@ -11,8 +11,8 @@
11
11
  //
12
12
  // the bundle is regenerated every time `daemonInstall` runs — it's tiny
13
13
  // (a plist + a shell launcher) and the launcher embeds a snapshot of the
14
- // invocation, so an `npm update -g sootsim` followed by `rnx daemon
15
- // install --force` is the documented upgrade path. there's no notarization
14
+ // invocation, so `rnx upgrade` followed by `rnx daemon install --force` is
15
+ // the documented upgrade path. there's no notarization
16
16
  // or Developer ID signing here yet; ad-hoc + hardened runtime is enough to
17
17
  // fix the attribution, and a real signed bundle (shipped via the runtime
18
18
  // CDN) is a follow-up.
@@ -25,6 +25,7 @@ import {
25
25
  daemonAppDir,
26
26
  daemonAppLauncherPath,
27
27
  } from '../../src/home-paths'
28
+ import type { RnxSelfInvocation } from '../self-invocation'
28
29
 
29
30
  const BUNDLE_IDENTIFIER = 'dev.sootsim.daemon'
30
31
  const BUNDLE_DISPLAY_NAME = 'RNX Daemon'
@@ -35,13 +36,6 @@ const BUNDLE_DISPLAY_NAME = 'RNX Daemon'
35
36
  // checkouts.
36
37
  const BUNDLE_VERSION = '0.1.36'
37
38
 
38
- export interface DaemonInvocation {
39
- /** absolute path to the executable launchd should spawn. */
40
- executable: string
41
- /** arguments passed to the executable (before server/--port flags). */
42
- prefixArgs: string[]
43
- }
44
-
45
39
  export interface DaemonAppPaths {
46
40
  /** the rebuilt .app bundle. */
47
41
  bundlePath: string
@@ -53,7 +47,7 @@ export interface DaemonAppPaths {
53
47
  * and re-sign it ad-hoc. safe to call repeatedly; the directory is wiped
54
48
  * before write so stale state from earlier installs can't leak through. */
55
49
  export function ensureDaemonApp(
56
- invocation: DaemonInvocation,
50
+ invocation: RnxSelfInvocation,
57
51
  port: number,
58
52
  logDir: { stdout: string; stderr: string },
59
53
  ): DaemonAppPaths {
@@ -121,7 +115,7 @@ function renderInfoPlist(): string {
121
115
  }
122
116
 
123
117
  function renderLauncherScript(
124
- invocation: DaemonInvocation,
118
+ invocation: RnxSelfInvocation,
125
119
  port: number,
126
120
  logDir: { stdout: string; stderr: string },
127
121
  ): string {
@@ -1,9 +1,9 @@
1
1
  // rnx daemon — manage the persistent rnx bridge daemon
2
2
  //
3
3
  // wraps `rnx serve` so it starts automatically on login and restarts if
4
- // it crashes. the agent is tied to whichever `rnxsim` binary resolves at
5
- // registration time, so `npm update -g rnxsim` + `rnx daemon restart`
6
- // picks up the upgrade without touching the plist/unit file.
4
+ // it crashes. the agent points at the executable that ran `daemon install`,
5
+ // so `rnx upgrade` (which replaces that file in place) is picked up by
6
+ // `rnx daemon restart` without touching the plist/unit file.
7
7
  //
8
8
  // `daemonInstall` is shared by `rnx setup` and the explicit
9
9
  // `rnx daemon install` machine-bootstrap path.
@@ -15,7 +15,7 @@
15
15
  // the macOS launchd Label is `dev.sootsim.daemon` (matching the .app
16
16
  // CFBundleIdentifier).
17
17
 
18
- import { execFileSync, spawnSync } from 'child_process'
18
+ import { spawnSync } from 'child_process'
19
19
  import {
20
20
  existsSync,
21
21
  mkdirSync,
@@ -25,11 +25,10 @@ import {
25
25
  writeFileSync,
26
26
  } from 'fs'
27
27
  import { homedir } from 'os'
28
- import { basename, dirname, resolve, sep } from 'path'
28
+ import { dirname, resolve } from 'path'
29
29
  import { DEFAULT_SOOTSIM_BRIDGE_PORT } from '../../src/bridge-constants'
30
- import { getCliVersion } from '../../src/cli-version'
31
30
  import {
32
- daemonAppBundlePath,
31
+ daemonAppDir,
33
32
  isDaemonLockfileFresh,
34
33
  readDaemonLockfile,
35
34
  shouldSkipPersistentDaemon,
@@ -38,6 +37,7 @@ import {
38
37
  import { rnxPublicBrand } from '../../src/public-brand'
39
38
  import { printCommandHelp } from '../help'
40
39
  import { rnxExit } from '../run-rnx'
40
+ import { rnxSelfInvocation, type RnxSelfInvocation } from '../self-invocation'
41
41
  import { ensureDaemonApp } from './daemon-mac-app'
42
42
 
43
43
  // minimum seconds between launchd / systemd respawns. without this, a daemon
@@ -69,7 +69,7 @@ export async function runDaemon(args: string[], opts: DaemonOptions = {}) {
69
69
  case 'install':
70
70
  return daemonInstall({ port, force: rest.includes('--force') })
71
71
  case 'uninstall':
72
- return daemonUninstall()
72
+ return daemonUninstall(rest)
73
73
  case 'status':
74
74
  return daemonStatus()
75
75
  case 'restart':
@@ -93,94 +93,17 @@ function printHelp() {
93
93
 
94
94
  // --- resolve sootsim binary ----------------------------------------------
95
95
 
96
- interface SootsimInvocation {
97
- /** absolute path to the executable launchd / systemd should spawn directly. */
98
- executable: string
99
- /** args to pass before `serve --quiet --port <port>`. */
100
- prefixArgs: string[]
101
- }
102
-
103
- function resolveSootsimInvocation(): SootsimInvocation {
104
- // launchd / systemd run agents with a stripped PATH (basically just
105
- // /usr/bin:/bin:/usr/sbin:/sbin), so a `#!/usr/bin/env node` shebang
106
- // resolves nothing and the daemon never starts. spawn the absolute node
107
- // binary directly with the rnx entry script as an argument.
108
- //
109
- // process.execPath is whichever node ran the install command, which is
110
- // also the node the user expects rnx to run on.
111
- const npxInvocation = resolveNpxDaemonInvocation()
112
- if (npxInvocation) return npxInvocation
113
- const entry = resolveSootsimEntryScript()
114
- return { executable: process.execPath, prefixArgs: [entry] }
115
- }
116
-
117
- function resolveNpxDaemonInvocation(): SootsimInvocation | null {
118
- if (!isNpxSootsimInvocation()) return null
119
-
120
- const npmCli = resolveNpmCliPath()
121
- if (!npmCli) return null
122
-
123
- const version = getCliVersion()
124
- const packageSpec =
125
- version === '0.0.0'
126
- ? rnxPublicBrand.packageName
127
- : `${rnxPublicBrand.packageName}@${version}`
96
+ // launchd and systemd run agents with a stripped PATH (basically just
97
+ // /usr/bin:/bin:/usr/sbin:/sbin), so anything that depends on PATH resolution
98
+ // never starts. rnxSelfInvocation() answers with absolute paths for both CLI
99
+ // shapes; resolve symlinks on the entry script so the service spawns the real
100
+ // file rather than a bin shim that would itself need PATH.
101
+ function resolveDaemonInvocation(): RnxSelfInvocation {
102
+ const invocation = rnxSelfInvocation()
128
103
  return {
129
- executable: process.execPath,
130
- prefixArgs: [
131
- npmCli,
132
- 'exec',
133
- '--yes',
134
- '--package',
135
- packageSpec,
136
- '--',
137
- rnxPublicBrand.commandName,
138
- ],
139
- }
140
- }
141
-
142
- function isNpxSootsimInvocation(): boolean {
143
- const argv1 = process.argv[1]
144
- if (process.env.npm_command === 'exec') return true
145
- if (!argv1) return false
146
- const real = realPathOrSelf(argv1)
147
- return real.includes(`${sep}_npx${sep}`)
148
- }
149
-
150
- function resolveNpmCliPath(): string | null {
151
- try {
152
- const out = execFileSync('which', ['npm'], { encoding: 'utf8' }).trim()
153
- if (out && existsSync(out)) return realNpmCliPath(out)
154
- } catch {}
155
-
156
- const envPath = process.env.npm_execpath
157
- if (envPath && existsSync(envPath)) return realNpmCliPath(envPath)
158
- return null
159
- }
160
-
161
- function realNpmCliPath(path: string): string {
162
- const real = realPathOrSelf(path)
163
- if (basename(real) === 'npx-cli.js') {
164
- const npmCli = resolve(dirname(real), 'npm-cli.js')
165
- if (existsSync(npmCli)) return npmCli
104
+ executable: invocation.executable,
105
+ prefixArgs: invocation.prefixArgs.map(realPathOrSelf),
166
106
  }
167
- return real
168
- }
169
-
170
- function resolveSootsimEntryScript(): string {
171
- // prefer which(1) → handles the user's $PATH (npm/bun/pnpm global bin dirs).
172
- // fall back to process.argv[1] for dev checkouts. resolve any symlink so
173
- // launchd / systemd spawn the real script, not a /node_modules/.bin shim
174
- // that may itself depend on PATH.
175
- try {
176
- const out = execFileSync('which', [rnxPublicBrand.commandName], {
177
- encoding: 'utf8',
178
- }).trim()
179
- if (out && existsSync(out)) return realPathOrSelf(out)
180
- } catch {}
181
- const argv1 = process.argv[1]
182
- if (argv1 && existsSync(argv1)) return realPathOrSelf(argv1)
183
- throw new Error(`could not locate the ${rnxPublicBrand.commandName} binary`)
184
107
  }
185
108
 
186
109
  function realPathOrSelf(p: string): string {
@@ -193,7 +116,7 @@ function realPathOrSelf(p: string): string {
193
116
 
194
117
  // --- plist / unit generation ---------------------------------------------
195
118
 
196
- function renderPlist(invocation: SootsimInvocation, port: number): string {
119
+ function renderPlist(invocation: RnxSelfInvocation, port: number): string {
197
120
  mkdirSync(LOG_DIR, { recursive: true })
198
121
  const stdout = resolve(LOG_DIR, 'bridge.out.log')
199
122
  const stderr = resolve(LOG_DIR, 'bridge.err.log')
@@ -222,7 +145,7 @@ ${programArgs}
222
145
  `
223
146
  }
224
147
 
225
- function renderSystemdUnit(invocation: SootsimInvocation, port: number): string {
148
+ function renderSystemdUnit(invocation: RnxSelfInvocation, port: number): string {
226
149
  mkdirSync(LOG_DIR_LINUX, { recursive: true })
227
150
  const exec = [invocation.executable, ...invocation.prefixArgs].map(shellQuote).join(' ')
228
151
  return `[Unit]
@@ -293,7 +216,7 @@ export async function daemonInstall({ port, force }: { port: number; force: bool
293
216
  throw new Error('background daemon setup is unavailable in this environment')
294
217
  }
295
218
 
296
- const invocation = resolveSootsimInvocation()
219
+ const invocation = resolveDaemonInvocation()
297
220
  console.log(` binary: ${[invocation.executable, ...invocation.prefixArgs].join(' ')}`)
298
221
  console.log(` port: ${port}`)
299
222
 
@@ -511,10 +434,7 @@ export function teardownLegacyMacDaemonArtifacts(
511
434
 
512
435
  /** stop + disable + remove the persistent launchd / systemd registration
513
436
  * and its support files (mac .app bundle, log dir). does NOT touch user
514
- * data under ~/.rnx. idempotent: each entry is checked before remove.
515
- * exported for reuse by the npm preuninstall hook (scripts/preuninstall.cjs)
516
- * via the bundled CLI so a `npm rm -g rnxsim` tears down the daemon before
517
- * the bin disappears. */
437
+ * data under ~/.rnx. idempotent: each entry is checked before remove. */
518
438
  export function teardownDaemonService(): TeardownReport {
519
439
  const removed: string[] = []
520
440
  if (process.platform === 'darwin') {
@@ -530,10 +450,12 @@ export function teardownDaemonService(): TeardownReport {
530
450
  rmSync(plist, { force: true })
531
451
  removed.push(plist)
532
452
  }
533
- const bundle = daemonAppBundlePath()
534
- if (existsSync(bundle)) {
535
- rmSync(bundle, { recursive: true, force: true })
536
- removed.push(bundle)
453
+ // daemon-app holds only the generated .app launcher, so the whole
454
+ // directory goes with the service.
455
+ const appDir = daemonAppDir()
456
+ if (existsSync(appDir)) {
457
+ rmSync(appDir, { recursive: true, force: true })
458
+ removed.push(appDir)
537
459
  }
538
460
  if (existsSync(LOG_DIR)) {
539
461
  rmSync(LOG_DIR, { recursive: true, force: true })
@@ -545,8 +467,8 @@ export function teardownDaemonService(): TeardownReport {
545
467
  const unit = linuxUnitPath()
546
468
  if (existsSync(unit)) {
547
469
  // `disable --now` stops + disables in one call. ignore exit status: a
548
- // unit pointing at a now-stale ExecStart (the npm preuninstall path)
549
- // can return non-zero while still successfully disabling the unit.
470
+ // unit whose ExecStart no longer exists can return non-zero while
471
+ // still successfully disabling the unit.
550
472
  spawnSync('systemctl', ['--user', 'disable', '--now', SERVICE_NAME_LINUX], {
551
473
  stdio: 'ignore',
552
474
  })
@@ -571,10 +493,12 @@ export function teardownDaemonService(): TeardownReport {
571
493
  return { removed, missing: true }
572
494
  }
573
495
 
574
- /** remove the user-data cache under ~/.rnx (runtimes, electron support,
575
- * profiles, lockfile, config). idempotent. honors $RNX_HOME. exported
576
- * for reuse by the npm preuninstall hook. */
577
- export function teardownSootsimHome(): TeardownReport {
496
+ /** remove EVERYTHING under ~/.rnx: installed runtimes, electron support,
497
+ * device profiles and their app storage, live dev-bridge state, lockfile,
498
+ * and config. only `rnx daemon uninstall --purge` calls this, because a
499
+ * user who asked to remove a background service did not ask to lose the
500
+ * simulators they have been working in. idempotent. honors $RNX_HOME. */
501
+ export function teardownRnxHome(): TeardownReport {
578
502
  const removed: string[] = []
579
503
  const home = rnxHomeDir()
580
504
  if (existsSync(home)) {
@@ -584,10 +508,36 @@ export function teardownSootsimHome(): TeardownReport {
584
508
  return { removed, missing: removed.length === 0 }
585
509
  }
586
510
 
587
- async function daemonUninstall() {
511
+ // `uninstall` removes the background service and nothing else. device
512
+ // profiles, their app storage, and live dev-bridge state belong to simulators
513
+ // the user may still be working in, so deleting them is something you ask for
514
+ // by name with `--purge`.
515
+ async function daemonUninstall(rest: string[]) {
516
+ const unknown = rest.filter((arg) => arg !== '--purge')
517
+ if (unknown.length > 0) {
518
+ console.error(` unknown daemon uninstall option: ${unknown[0]}`)
519
+ printHelp()
520
+ rnxExit(1)
521
+ return
522
+ }
523
+ const purge = rest.includes('--purge')
524
+
588
525
  const service = teardownDaemonService()
589
526
  for (const path of service.removed) console.log(` removed ${path}`)
590
- const home = teardownSootsimHome()
527
+
528
+ if (!purge) {
529
+ if (service.missing) console.log(' not installed')
530
+ const home = rnxHomeDir()
531
+ if (existsSync(home)) {
532
+ console.log(` kept ${home} (runtimes, device profiles, open simulators)`)
533
+ console.log(
534
+ ` remove that too with \`${rnxPublicBrand.commandName} daemon uninstall --purge\``,
535
+ )
536
+ }
537
+ return
538
+ }
539
+
540
+ const home = teardownRnxHome()
591
541
  for (const path of home.removed) console.log(` removed ${path}`)
592
542
  if (service.missing && home.missing) console.log(' not installed')
593
543
  }
@@ -2,6 +2,9 @@ import { spawnSync } from 'node:child_process'
2
2
  import { existsSync, readFileSync } from 'node:fs'
3
3
  import { createRequire } from 'node:module'
4
4
  import { dirname, join } from 'node:path'
5
+ import { RNX_INTERNAL_CHILDREN, RNX_INTERNAL_COMMAND } from '../internal-child'
6
+ import { rnxSelfInvocation } from '../self-invocation'
7
+
5
8
  export interface ResolvedPlaywright {
6
9
  // package name that resolved (for human-facing messaging)
7
10
  spec: string
@@ -24,23 +27,6 @@ export type PlaywrightBrowserProvisioningResult =
24
27
  message: string
25
28
  }
26
29
 
27
- const PLAYWRIGHT_BROWSER_PROBE = `
28
- const { existsSync } = require('node:fs');
29
- const modulePath = process.env.SOOTSIM_PW_MODULE;
30
- try {
31
- const playwright = require(modulePath);
32
- const browser = playwright && playwright.chromium;
33
- if (!browser || typeof browser.executablePath !== 'function') {
34
- throw new Error('resolved package does not expose browser "chromium"');
35
- }
36
- const executablePath = browser.executablePath();
37
- process.stdout.write(JSON.stringify({ executablePath, exists: existsSync(executablePath) }));
38
- } catch (error) {
39
- process.stderr.write(String((error && error.stack) || error));
40
- process.exit(1);
41
- }
42
- `
43
-
44
30
  function isRecord(value: unknown): value is Record<string, unknown> {
45
31
  return typeof value === 'object' && value !== null && !Array.isArray(value)
46
32
  }
@@ -93,14 +79,19 @@ function probeBrowser(
93
79
  resolved: ResolvedPlaywright,
94
80
  env: NodeJS.ProcessEnv,
95
81
  ): BrowserProbeResult {
96
- const probe = spawnSync(process.execPath, ['-e', PLAYWRIGHT_BROWSER_PROBE], {
97
- env: {
98
- ...env,
99
- SOOTSIM_PW_MODULE: resolved.modulePath,
82
+ const self = rnxSelfInvocation()
83
+ const probe = spawnSync(
84
+ self.executable,
85
+ [...self.prefixArgs, RNX_INTERNAL_COMMAND, RNX_INTERNAL_CHILDREN.playwrightProbe],
86
+ {
87
+ env: {
88
+ ...env,
89
+ SOOTSIM_PW_MODULE: resolved.modulePath,
90
+ },
91
+ encoding: 'utf8',
92
+ timeout: 30_000,
100
93
  },
101
- encoding: 'utf8',
102
- timeout: 30_000,
103
- })
94
+ )
104
95
  if (probe.status !== 0) {
105
96
  const detail = (probe.stderr || probe.error?.message || 'browser probe failed').trim()
106
97
  return {
@@ -145,11 +136,24 @@ export function ensurePlaywrightBrowser(
145
136
  // the resolved package is the authority for both the required executable
146
137
  // path and the installer. never scan a shared cache or select a neighboring
147
138
  // revision: Playwright's own CLI installs the exact revision its API named.
148
- const install = spawnSync(process.execPath, [resolved.cliPath, 'install', 'chromium'], {
149
- env,
150
- encoding: 'utf8',
151
- timeout: 10 * 60_000,
152
- })
139
+ // that CLI is a javascript file, so this CLI runs it as itself.
140
+ const installer = rnxSelfInvocation()
141
+ const install = spawnSync(
142
+ installer.executable,
143
+ [
144
+ ...installer.prefixArgs,
145
+ RNX_INTERNAL_COMMAND,
146
+ RNX_INTERNAL_CHILDREN.playwrightInstall,
147
+ resolved.cliPath,
148
+ 'install',
149
+ 'chromium',
150
+ ],
151
+ {
152
+ env,
153
+ encoding: 'utf8',
154
+ timeout: 10 * 60_000,
155
+ },
156
+ )
153
157
  if (install.status !== 0) {
154
158
  const rawDetail = (
155
159
  install.stderr ||