Repository navigation
Add a ProcessManager to HarnessContext to make subprocess (adb/xcrun/…) access testable #166
Description
Problem Statement
All subprocess execution already funnels through a single spawn() in @react-native-harness/tools (a wrapper over nano-spawn) — with one exception: a raw node:child_process call at packages/platform-android/src/adb.ts:79.
The heaviest, most side-effecting host code shells out to external tools: adb (~21 call sites), xcrun simctl/devicectl (15+), xcodebuild, kepler, lipo/plutil/uname, and bash/unzip. Today it is tested with vi.spyOn(tools, 'spawn'), asserting on exact argv arrays and returning hand-cast fake result shapes. That approach:
- is module-global monkeypatching, so it is not parallel-safe;
- is brittle — it couples tests to exact CLI flag formatting;
- makes the gnarliest, most externally-dependent code the hardest to test.
Solution
Extend the HarnessContext introduced in the companion FileSystem issue (#165) with a process manager, injected via the same AsyncLocalStorage, and exposed through an explicit getProcessManager() accessor that mirrors getFs().
The process manager owns spawn as a method. Subprocess call sites move from the bare spawn(...) to getProcessManager().spawn(...):
// before
await spawn('xcrun', ['simctl', 'boot', udid]);
// after
await getProcessManager().spawn('xcrun', ['simctl', 'boot', udid]);This is a deliberate design choice over a "magic" spawn() that silently reads the ambient context. Consistency with getFs() matters: the dependency-injection origin should be visible at the call site. A plain-looking spawn(...) that secretly resolves an ambient runner hides where the implementation comes from; getProcessManager().spawn(...) makes it obvious. We accept a mechanical spawn(...) → getProcessManager().spawn(...) sweep across the subprocess-using modules as the price of that clarity. Crucially, this still avoids threading a dependency object through every function signature — the accessor reads from AsyncLocalStorage internally — so the core low-churn benefit of the context is preserved.
Consequences:
- The standalone
spawn()export intoolsis removed in favour ofgetProcessManager().spawn; the current wrapper behavior (default options, logging) moves onto the real process-manager implementation. - The raw
node:child_processcall atadb.ts:79is routed through the manager, closing the last escape hatch. - The default-provider fallback (a real
nano-spawn-backed manager when no context is active) keeps production and external consumers working outside a wrapped context. - Tests set a fake process manager via
runWithHarnessContext, replacingvi.spyOn(tools, 'spawn')with a parallel-safe, per-run mechanism.
Fake design (independent of the DI mechanism):
- The default fake is an argv-programmable process runner: match on command + args, return canned stdout/stderr/exit code. This alone is a large improvement over the status quo and covers the vast majority of cases.
- The fake must return a value matching
nano-spawn'sSubprocessshape — a thenable that also supports streaming / async iteration and carries a handle to the child — so streaming callers such asstreamLogsand device-log tails behave correctly. This fidelity cost exists under any DI mechanism. - If a specific suite later needs rich device-state simulation (installed apps, boot state, shell properties), a stateful process-runner fake that interprets commands into an in-memory model can be layered on the same seam. That is an optional escalation, not a blocker. Dedicated domain-level ports (
Adb,Simctl) remain a possible future refinement but are out of scope here.
Expected outcome
HarnessContextcarries aProcessManager, exposed viagetProcessManager(), with a realnano-spawn-backed default.- Subprocess call sites use
getProcessManager().spawn(...); the standalonespawn()export is removed and theadb.ts:79node:child_processescape hatch is closed. - An argv-programmable,
Subprocess-shaped fake is available from the testing-only entry. - Subprocess tests (e.g.
simctl,adb,shared-prefs) are migrated offvi.spyOn(tools, 'spawn')to the fake.
High-level implementation plan
- (Depends on the FileSystem issue (Introduce a FileSystem port injected via HarnessContext (AsyncLocalStorage) for hermetic tests #165)
HarnessContext+AsyncLocalStorageplumbing.) Define theProcessManagertype, addgetProcessManager()with a realnano-spawn-backed default, and move the currentspawn()wrapper behavior onto the real manager. - Migrate subprocess call sites from
spawn(...)togetProcessManager().spawn(...), remove the standalonespawn()export, and route the rawnode:child_processcall atadb.ts:79through the manager. - Build the argv-programmable fake with
Subprocess-shaped return values in the testing-only entry. - Migrate existing spawn-spy tests to the fake.
- Document how to build a stateful process-runner fake for suites that need device-state fidelity.
Activity
feat(tools): add ProcessManager to HarnessContext (#269)