Problem Statement
Filesystem access is wired directly through import fs from 'node:fs' in roughly 30 files across ~12 packages (tools, cache, config, cli, bundler-metro, platform-ios, platform-android, github-action, jest, bridge), overwhelmingly using the synchronous API (writeFileSync, mkdirSync, existsSync, readFileSync, rmSync, mkdtempSync, readdirSync, …), plus a smaller node:fs/promises surface.
Because there is no seam, filesystem-dependent logic is hard to test:
- Tests must either mock
node:fs (brittle, hoisting-sensitive, module-global) or hit the real filesystem.
- Several
github-action tests already create real temp directories via fs.mkdtempSync(os.tmpdir()). That is slow, risks polluting the local system, leaves state behind, and is prone to flakiness from real IO.
- As a result, filesystem behavior is either tested with high friction or not covered at all.
Why this is worth doing
- An in-memory filesystem in tests is fast, hermetic, parallel-safe, needs no cleanup, and cannot pollute the developer's machine or CI runner.
- A single, stable, POSIX-like contract documents exactly what Harness needs from a filesystem and decouples business logic from Node's
fs module.
- It establishes the shared runtime-context plumbing that the subprocess/process-manager work reuses (see the companion issue).
Solution
Introduce a HarnessContext propagated via a single AsyncLocalStorage instance living in @react-native-harness/tools. This issue delivers the plumbing and the first member of the context: a FileSystem port.
AsyncLocalStorage is chosen deliberately over threading a dependency object through every signature: the filesystem footprint is broad, mechanical, and sync-heavy, so explicit threading would be high churn for little architectural insight, and much of the footprint lives in packages that never receive the existing platform init context. This is host-side orchestration (CLI, emulator control, Jest driving), not a hot path, so the propagation overhead is negligible against subprocess and disk latency.
Design:
- Context type.
HarnessContext = { fs: FileSystem } (extended with a process manager in the companion issue).
FileSystem port. An interface derived from the methods actually used today — sync: writeFileSync, mkdirSync, existsSync, readFileSync, rmSync, mkdtempSync, readdirSync, statSync, copyFileSync, appendFileSync, unlinkSync, renameSync, realpathSync, utimesSync, createWriteStream; async: readFile, writeFile, mkdir, rm, access, readdir, mkdtemp, stat, rename, cp. Scope to observed usage; grow deliberately.
- Explicit accessors, no ambient wrappers. The context is always read through named
get* accessors so the injected origin is visible at every call site — getHarnessContext() is the primitive, with getFs() (and, in the companion issue, getProcessManager()) as thin conveniences over it. We deliberately do not provide plain-looking wrappers that silently read the context; getFs().writeFileSync(...) makes the dependency-injection origin obvious, a bare writeFile(...) would hide it.
- Real default. When no store is active, the accessors fall back to a real
node:fs-backed implementation. This default-provider guarantee means production paths and external consumers never break if code runs outside a wrapped context — tests opt into the fake, everything else falls through to the real thing.
- Composition roots (CLI command handlers, Jest session setup, github-action entry points) wrap their work in
runWithHarnessContext(ctx, fn).
- Real adapter = a thin wrapper over
node:fs.
- Fake = a
memfs Volume, which already implements the full node:fs API, so the fake is near-zero-maintenance. Exposed from a testing-only entry / dev dependency so memfs stays out of production bundles.
Migration is incremental, package by package. The one discipline: never capture fs at module-load time — always resolve via getFs() at the use site so the ambient swap applies.
Expected outcome
HarnessContext + AsyncLocalStorage plumbing, runWithHarnessContext, and getFs() (with a real default) live in @react-native-harness/tools.
- A
FileSystem port defined from observed usage, with a real node:fs adapter and a memfs-backed fake.
github-action tests (and other filesystem-dependent tests) run against the in-memory filesystem instead of real temp directories.
- Migrated modules contain no direct
import fs from 'node:fs' in business logic; a documented pattern (and lint guard) prevents regressions.
High-level implementation plan
- Add
AsyncLocalStorage<HarnessContext>, runWithHarnessContext, getHarnessContext(), and the getFs() convenience (real node:fs default) in tools.
- Define the
FileSystem port from the observed call sites.
- Implement the real adapter (
node:fs) and the memfs fake, exposed from a testing-only entry.
- Wrap the composition roots (CLI, Jest session, github-action) in
runWithHarnessContext.
- Migrate call sites package by package — start with
github-action to remove the real-temp-dir tests — converting the corresponding tests to the fake.
- Add a lint guard against new direct
node:fs imports in migrated packages.
Dependency note: the companion process-manager issue extends this same HarnessContext and reuses this plumbing, so this issue should land first.
Problem Statement
Filesystem access is wired directly through
import fs from 'node:fs'in roughly 30 files across ~12 packages (tools,cache,config,cli,bundler-metro,platform-ios,platform-android,github-action,jest,bridge), overwhelmingly using the synchronous API (writeFileSync,mkdirSync,existsSync,readFileSync,rmSync,mkdtempSync,readdirSync, …), plus a smallernode:fs/promisessurface.Because there is no seam, filesystem-dependent logic is hard to test:
node:fs(brittle, hoisting-sensitive, module-global) or hit the real filesystem.github-actiontests already create real temp directories viafs.mkdtempSync(os.tmpdir()). That is slow, risks polluting the local system, leaves state behind, and is prone to flakiness from real IO.Why this is worth doing
fsmodule.Solution
Introduce a
HarnessContextpropagated via a singleAsyncLocalStorageinstance living in@react-native-harness/tools. This issue delivers the plumbing and the first member of the context: aFileSystemport.AsyncLocalStorageis chosen deliberately over threading a dependency object through every signature: the filesystem footprint is broad, mechanical, and sync-heavy, so explicit threading would be high churn for little architectural insight, and much of the footprint lives in packages that never receive the existing platform init context. This is host-side orchestration (CLI, emulator control, Jest driving), not a hot path, so the propagation overhead is negligible against subprocess and disk latency.Design:
HarnessContext = { fs: FileSystem }(extended with a process manager in the companion issue).FileSystemport. An interface derived from the methods actually used today — sync:writeFileSync,mkdirSync,existsSync,readFileSync,rmSync,mkdtempSync,readdirSync,statSync,copyFileSync,appendFileSync,unlinkSync,renameSync,realpathSync,utimesSync,createWriteStream; async:readFile,writeFile,mkdir,rm,access,readdir,mkdtemp,stat,rename,cp. Scope to observed usage; grow deliberately.get*accessors so the injected origin is visible at every call site —getHarnessContext()is the primitive, withgetFs()(and, in the companion issue,getProcessManager()) as thin conveniences over it. We deliberately do not provide plain-looking wrappers that silently read the context;getFs().writeFileSync(...)makes the dependency-injection origin obvious, a barewriteFile(...)would hide it.node:fs-backed implementation. This default-provider guarantee means production paths and external consumers never break if code runs outside a wrapped context — tests opt into the fake, everything else falls through to the real thing.runWithHarnessContext(ctx, fn).node:fs.memfsVolume, which already implements the fullnode:fsAPI, so the fake is near-zero-maintenance. Exposed from a testing-only entry / dev dependency somemfsstays out of production bundles.Migration is incremental, package by package. The one discipline: never capture
fsat module-load time — always resolve viagetFs()at the use site so the ambient swap applies.Expected outcome
HarnessContext+AsyncLocalStorageplumbing,runWithHarnessContext, andgetFs()(with a real default) live in@react-native-harness/tools.FileSystemport defined from observed usage, with a realnode:fsadapter and amemfs-backed fake.github-actiontests (and other filesystem-dependent tests) run against the in-memory filesystem instead of real temp directories.import fs from 'node:fs'in business logic; a documented pattern (and lint guard) prevents regressions.High-level implementation plan
AsyncLocalStorage<HarnessContext>,runWithHarnessContext,getHarnessContext(), and thegetFs()convenience (realnode:fsdefault) intools.FileSystemport from the observed call sites.node:fs) and thememfsfake, exposed from a testing-only entry.runWithHarnessContext.github-actionto remove the real-temp-dir tests — converting the corresponding tests to the fake.node:fsimports in migrated packages.Dependency note: the companion process-manager issue extends this same
HarnessContextand reuses this plumbing, so this issue should land first.