Skip to content

Add a ProcessManager to HarnessContext to make subprocess (adb/xcrun/…) access testable #166

Description

@V3RON

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 in tools is removed in favour of getProcessManager().spawn; the current wrapper behavior (default options, logging) moves onto the real process-manager implementation.
  • The raw node:child_process call at adb.ts:79 is 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, replacing vi.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's Subprocess shape — a thenable that also supports streaming / async iteration and carries a handle to the child — so streaming callers such as streamLogs and 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

  • HarnessContext carries a ProcessManager, exposed via getProcessManager(), with a real nano-spawn-backed default.
  • Subprocess call sites use getProcessManager().spawn(...); the standalone spawn() export is removed and the adb.ts:79 node:child_process escape 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 off vi.spyOn(tools, 'spawn') to the fake.

High-level implementation plan

  1. (Depends on the FileSystem issue (Introduce a FileSystem port injected via HarnessContext (AsyncLocalStorage) for hermetic tests #165) HarnessContext + AsyncLocalStorage plumbing.) Define the ProcessManager type, add getProcessManager() with a real nano-spawn-backed default, and move the current spawn() wrapper behavior onto the real manager.
  2. Migrate subprocess call sites from spawn(...) to getProcessManager().spawn(...), remove the standalone spawn() export, and route the raw node:child_process call at adb.ts:79 through the manager.
  3. Build the argv-programmable fake with Subprocess-shaped return values in the testing-only entry.
  4. Migrate existing spawn-spy tests to the fake.
  5. Document how to build a stateful process-runner fake for suites that need device-state fidelity.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions