From 19cabb8986e7f2821e50338cdbaa0df0d3e73884 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Tue, 21 Jul 2026 12:20:55 +0200 Subject: [PATCH 1/2] feat(tools): add harness filesystem context --- packages/tools/package.json | 7 + .../__tests__/harness-context-testing.test.ts | 18 + .../src/__tests__/harness-context.test.ts | 52 +++ packages/tools/src/harness-context.ts | 80 +++++ packages/tools/src/index.ts | 1 + packages/tools/src/testing.ts | 34 ++ pnpm-lock.yaml | 311 +++++++++++++++++- 7 files changed, 502 insertions(+), 1 deletion(-) create mode 100644 packages/tools/src/__tests__/harness-context-testing.test.ts create mode 100644 packages/tools/src/__tests__/harness-context.test.ts create mode 100644 packages/tools/src/harness-context.ts create mode 100644 packages/tools/src/testing.ts diff --git a/packages/tools/package.json b/packages/tools/package.json index f27fe83e..4613ce43 100644 --- a/packages/tools/package.json +++ b/packages/tools/package.json @@ -12,10 +12,17 @@ "types": "./dist/index.d.ts", "import": "./dist/index.js", "default": "./dist/index.js" + }, + "./testing": { + "development": "./src/testing.ts", + "types": "./dist/testing.d.ts", + "import": "./dist/testing.js", + "default": "./dist/testing.js" } }, "dependencies": { "@clack/prompts": "1.0.0-alpha.9", + "memfs": "^4.64.0", "nano-spawn": "^1.0.2", "picocolors": "^1.1.1", "tslib": "^2.3.0" diff --git a/packages/tools/src/__tests__/harness-context-testing.test.ts b/packages/tools/src/__tests__/harness-context-testing.test.ts new file mode 100644 index 00000000..fd414e3b --- /dev/null +++ b/packages/tools/src/__tests__/harness-context-testing.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; +import { getFs, runWithHarnessContext } from '../harness-context.js'; +import { createInMemoryFileSystem } from '../testing.js'; + +describe('createInMemoryFileSystem', () => { + it('keeps files isolated from the host filesystem', () => { + const fs = createInMemoryFileSystem(); + + runWithHarnessContext({ fs }, () => { + getFs().mkdirSync('/project'); + getFs().writeFileSync('/project/config.json', '{"enabled":true}'); + + expect(getFs().readFileSync('/project/config.json', 'utf8')).toBe( + '{"enabled":true}' + ); + }); + }); +}); diff --git a/packages/tools/src/__tests__/harness-context.test.ts b/packages/tools/src/__tests__/harness-context.test.ts new file mode 100644 index 00000000..e276e6ec --- /dev/null +++ b/packages/tools/src/__tests__/harness-context.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; +import { + getFs, + runWithHarnessContext, + type FileSystem, +} from '../harness-context.js'; + +const createFileSystem = (): FileSystem => + ({ + appendFileSync: () => undefined, + copyFileSync: () => undefined, + createWriteStream: () => undefined as never, + existsSync: () => false, + mkdirSync: () => undefined, + mkdtempSync: () => '/tmp/harness', + readFileSync: () => 'fake file', + readdirSync: () => [], + realpathSync: () => '/tmp/harness', + renameSync: () => undefined, + rmSync: () => undefined, + statSync: () => undefined as never, + unlinkSync: () => undefined, + utimesSync: () => undefined, + writeFileSync: () => undefined, + access: async () => undefined, + cp: async () => undefined, + mkdir: async () => undefined, + mkdtemp: async () => '/tmp/harness', + readFile: async () => Buffer.from('fake file'), + readdir: async () => [], + rename: async () => undefined, + rm: async () => undefined, + stat: async () => undefined as never, + writeFile: async () => undefined, + }) as unknown as FileSystem; + +describe('HarnessContext filesystem', () => { + it('uses the injected filesystem across async work', async () => { + const fs = createFileSystem(); + + await runWithHarnessContext({ fs }, async () => { + await Promise.resolve(); + + expect(getFs()).toBe(fs); + expect(getFs().readFileSync('/file', 'utf8')).toBe('fake file'); + }); + }); + + it('falls back to the real filesystem outside an injected context', () => { + expect(getFs().existsSync(__filename)).toBe(true); + }); +}); diff --git a/packages/tools/src/harness-context.ts b/packages/tools/src/harness-context.ts new file mode 100644 index 00000000..365126a4 --- /dev/null +++ b/packages/tools/src/harness-context.ts @@ -0,0 +1,80 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; +import fs from 'node:fs'; +import * as fsPromises from 'node:fs/promises'; + +export type FileSystem = Pick< + typeof fs, + | 'appendFileSync' + | 'copyFileSync' + | 'createWriteStream' + | 'existsSync' + | 'mkdirSync' + | 'mkdtempSync' + | 'readFileSync' + | 'readdirSync' + | 'realpathSync' + | 'renameSync' + | 'rmSync' + | 'statSync' + | 'unlinkSync' + | 'utimesSync' + | 'writeFileSync' +> & + Pick< + typeof fsPromises, + | 'access' + | 'cp' + | 'mkdir' + | 'mkdtemp' + | 'readFile' + | 'readdir' + | 'rename' + | 'rm' + | 'stat' + | 'writeFile' + >; + +export type HarnessContext = { + fs: FileSystem; +}; + +const realFileSystem: FileSystem = { + appendFileSync: fs.appendFileSync, + copyFileSync: fs.copyFileSync, + createWriteStream: fs.createWriteStream, + existsSync: fs.existsSync, + mkdirSync: fs.mkdirSync, + mkdtempSync: fs.mkdtempSync, + readFileSync: fs.readFileSync, + readdirSync: fs.readdirSync, + realpathSync: fs.realpathSync, + renameSync: fs.renameSync, + rmSync: fs.rmSync, + statSync: fs.statSync, + unlinkSync: fs.unlinkSync, + utimesSync: fs.utimesSync, + writeFileSync: fs.writeFileSync, + access: fsPromises.access, + cp: fsPromises.cp, + mkdir: fsPromises.mkdir, + mkdtemp: fsPromises.mkdtemp, + readFile: fsPromises.readFile, + readdir: fsPromises.readdir, + rename: fsPromises.rename, + rm: fsPromises.rm, + stat: fsPromises.stat, + writeFile: fsPromises.writeFile, +}; + +const defaultHarnessContext: HarnessContext = { fs: realFileSystem }; +const storage = new AsyncLocalStorage(); + +export const runWithHarnessContext = ( + context: HarnessContext, + fn: () => T, +): T => storage.run(context, fn); + +export const getHarnessContext = (): HarnessContext => + storage.getStore() ?? defaultHarnessContext; + +export const getFs = (): FileSystem => getHarnessContext().fs; diff --git a/packages/tools/src/index.ts b/packages/tools/src/index.ts index c73b7e56..1c175f61 100644 --- a/packages/tools/src/index.ts +++ b/packages/tools/src/index.ts @@ -16,3 +16,4 @@ export * from './regex.js'; export * from './isInteractive.js'; export * from './diagnostics.js'; export * from './device-core-budget.js'; +export * from './harness-context.js'; diff --git a/packages/tools/src/testing.ts b/packages/tools/src/testing.ts new file mode 100644 index 00000000..826bfbbe --- /dev/null +++ b/packages/tools/src/testing.ts @@ -0,0 +1,34 @@ +import { createFsFromVolume, Volume } from 'memfs'; +import type { FileSystem } from './harness-context.js'; + +export const createInMemoryFileSystem = (): FileSystem => { + const fs = createFsFromVolume(new Volume()); + + return { + appendFileSync: fs.appendFileSync.bind(fs), + copyFileSync: fs.copyFileSync.bind(fs), + createWriteStream: fs.createWriteStream.bind(fs), + existsSync: fs.existsSync.bind(fs), + mkdirSync: fs.mkdirSync.bind(fs), + mkdtempSync: fs.mkdtempSync.bind(fs), + readFileSync: fs.readFileSync.bind(fs), + readdirSync: fs.readdirSync.bind(fs), + realpathSync: fs.realpathSync.bind(fs), + renameSync: fs.renameSync.bind(fs), + rmSync: fs.rmSync.bind(fs), + statSync: fs.statSync.bind(fs), + unlinkSync: fs.unlinkSync.bind(fs), + utimesSync: fs.utimesSync.bind(fs), + writeFileSync: fs.writeFileSync.bind(fs), + access: fs.promises.access.bind(fs.promises), + cp: fs.promises.cp.bind(fs.promises), + mkdir: fs.promises.mkdir.bind(fs.promises), + mkdtemp: fs.promises.mkdtemp.bind(fs.promises), + readFile: fs.promises.readFile.bind(fs.promises), + readdir: fs.promises.readdir.bind(fs.promises), + rename: fs.promises.rename.bind(fs.promises), + rm: fs.promises.rm.bind(fs.promises), + stat: fs.promises.stat.bind(fs.promises), + writeFile: fs.promises.writeFile.bind(fs.promises), + } as unknown as FileSystem; +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f060f58e..39d7aced 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -614,6 +614,9 @@ importers: specifier: ^2.3.0 version: 2.8.1 devDependencies: + memfs: + specifier: ^4.64.0 + version: 4.64.0(tslib@2.8.1) react-native: specifier: '*' version: 0.82.1(@babel/core@7.27.4)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.82.1)(@types/react@19.1.13)(react@19.2.3) @@ -1894,6 +1897,126 @@ packages: '@jridgewell/trace-mapping@0.3.25': resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} + '@jsonjoy.com/base64@1.1.2': + resolution: {integrity: sha512-q6XAnWQDIMA3+FTiOYajoYqySkO+JSat0ytXGSuRdq9uXE7o92gzuQwQM14xaCRlBLGq3v5miDGC4vkVTn54xA==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/base64@17.67.0': + resolution: {integrity: sha512-5SEsJGsm15aP8TQGkDfJvz9axgPwAEm98S5DxOuYe8e1EbfajcDmgeXXzccEjh+mLnjqEKrkBdjHWS5vFNwDdw==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/buffers@1.2.1': + resolution: {integrity: sha512-12cdlDwX4RUM3QxmUbVJWqZ/mrK6dFQH4Zxq6+r1YXKXYBNgZXndx2qbCJwh3+WWkCSn67IjnlG3XYTvmvYtgA==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/buffers@17.67.0': + resolution: {integrity: sha512-tfExRpYxBvi32vPs9ZHaTjSP4fHAfzSmcahOfNxtvGHcyJel+aibkPlGeBB+7AoC6hL7lXIE++8okecBxx7lcw==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/codegen@1.0.0': + resolution: {integrity: sha512-E8Oy+08cmCf0EK/NMxpaJZmOxPqM+6iSe2S4nlSBrPZOORoDJILxtbSUEDKQyTamm/BVAhIGllOBNU79/dwf0g==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/codegen@17.67.0': + resolution: {integrity: sha512-idnkUplROpdBOV0HMcwhsCUS5TRUi9poagdGs70A6S4ux9+/aPuKbh8+UYRTLYQHtXvAdNfQWXDqZEx5k4Dj2Q==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/fs-core@4.64.0': + resolution: {integrity: sha512-zs2TAq7Six5jgMuoMNjpspAvOP3mhtgq/k1UyQodEzCtQi/N83y2/y+zcvnZSGp/Rxq96DBN+bValOBQAyn/ew==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/fs-fsa@4.64.0': + resolution: {integrity: sha512-nMWOVbkLFyEgmXZih3wyvxA9XpgyyqyfrINMHvEFqhi7uqfRl7c9ERJt6yX7vgMPrB9Uo+OJO+Spa0cFzPD01w==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/fs-node-builtins@4.64.0': + resolution: {integrity: sha512-/o7WRFhUWaM/fOrslwLZGnzn4RmRILykn+lAL+mNObqqRNw+CQSiij6hpCeZ+C7buhdoVo7go/OYqzaSUfDYmA==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/fs-node-to-fsa@4.64.0': + resolution: {integrity: sha512-WDD9WVs0hb7UAEKTgZW2f66WDrbj7gIIWwpP3spbLyXa0rghtUaFTB8L4gdR3ZCWwiKIsj38/CNijpVmpnuPUw==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/fs-node-utils@4.64.0': + resolution: {integrity: sha512-k5Indsx9hWW9xSF7Y6oSKKwtCUNhzZxadub3owhIlitc+iMRVlPPdX2duTKQWBL3qNWpXya8jykgaaWpheeS4w==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/fs-node@4.64.0': + resolution: {integrity: sha512-dO+NNkODbUli4uV42bcNrrLvq5rE7SNpdZ5TNd0dtbLsAaNK3MDiIC9lUi+brboGoIjW6vd2fB1qao60nrk5xA==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/fs-print@4.64.0': + resolution: {integrity: sha512-PHZFccchvkhWrwPWHjmVAhbC3vSHCtyZvlZfJJ3ho2bnzl450hXri6/8e6pbkWdH+SkmLXNml0sV8e5HDAfxKw==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/fs-snapshot@4.64.0': + resolution: {integrity: sha512-oM7UDeL83q6NBzzsfKAsYKXKVXlykKFqqOLh4xZZKAzzROTlInkPbc6LTDGThEOnPiFiUzA7tYziHG9xavd76Q==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/json-pack@1.21.0': + resolution: {integrity: sha512-+AKG+R2cfZMShzrF2uQw34v3zbeDYUqnQ+jg7ORic3BGtfw9p/+N6RJbq/kkV8JmYZaINknaEQ2m0/f693ZPpg==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/json-pack@17.67.0': + resolution: {integrity: sha512-t0ejURcGaZsn1ClbJ/3kFqSOjlryd92eQY465IYrezsXmPcfHPE/av4twRSxf6WE+TkZgLY+71vCZbiIiFKA/w==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/json-pointer@1.0.2': + resolution: {integrity: sha512-Fsn6wM2zlDzY1U+v4Nc8bo3bVqgfNTGcn6dMgs6FjrEnt4ZCe60o6ByKRjOGlI2gow0aE/Q41QOigdTqkyK5fg==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/json-pointer@17.67.0': + resolution: {integrity: sha512-+iqOFInH+QZGmSuaybBUNdh7yvNrXvqR+h3wjXm0N/3JK1EyyFAeGJvqnmQL61d1ARLlk/wJdFKSL+LHJ1eaUA==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/util@1.9.0': + resolution: {integrity: sha512-pLuQo+VPRnN8hfPqUTLTHk126wuYdXVxE6aDmjSeV4NCAgyxWbiOIeNJVtID3h1Vzpoi9m4jXezf73I6LgabgQ==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/util@17.67.0': + resolution: {integrity: sha512-6+8xBaz1rLSohlGh68D1pdw3AwDi9xydm8QNlAFkvnavCJYSze+pxoW2VKP8p308jtlMRLs5NTHfPlZLd4w7ew==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + '@mdx-js/mdx@3.1.1': resolution: {integrity: sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==} @@ -5087,6 +5210,12 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} + glob-to-regex.js@1.2.0: + resolution: {integrity: sha512-QMwlOQKU/IzqMUOAZWubUOT8Qft+Y0KQWnX9nK3ch0CJg0tTp4TvGZsTfudYKv2NzoQSyPcnA6TYeIQ3jGichQ==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + glob-to-regexp@0.4.1: resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} @@ -5303,6 +5432,10 @@ packages: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} engines: {node: '>=10.17.0'} + hyperdyperid@1.2.0: + resolution: {integrity: sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A==} + engines: {node: '>=10.18'} + hyphenate-style-name@1.1.0: resolution: {integrity: sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==} @@ -6195,6 +6328,11 @@ packages: medium-zoom@1.1.0: resolution: {integrity: sha512-ewyDsp7k4InCUp3jRmwHBRFGyjBimKps/AJLjRSox+2q/2H4p/PNpQf+pwONWlJiOudkBXtbdmVbFjqyybfTmQ==} + memfs@4.64.0: + resolution: {integrity: sha512-Kw72fgY7Wn+sD8KmtNWSafl1dz0UvAsE/PHs3YVfLiaZuA3HxNm9sRLqAu0ATiBGJvME1PxZXbBZPv5GycDeAw==} + peerDependencies: + tslib: '2' + memoize-one@5.2.1: resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==} @@ -7883,6 +8021,12 @@ packages: thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + thingies@2.6.0: + resolution: {integrity: sha512-rMHRjmlFLM1R96UYPvpmnc3LYtdFrT33JIB7L9hetGue1qAPfn1N2LJeEjxUSidu1Iku+haLZXDuEXUHNGO/lg==} + engines: {node: '>=10.18'} + peerDependencies: + tslib: ^2 + throat@5.0.0: resolution: {integrity: sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==} @@ -7945,6 +8089,12 @@ packages: resolution: {integrity: sha512-2lv/66T7e5yNyhAAC4NaKe5nVavzuGJQVVtRYLyQ2OI8tsJ61PMLlelehb0wi2Hx6+hT/OJUWZcw8MjlSRnxvw==} engines: {node: '>=14'} + tree-dump@1.1.0: + resolution: {integrity: sha512-rMuvhU4MCDbcbnleZTFezWsaZXRFemSqAM+7jPnzUl1fo9w3YEKOxAeui0fz3OI4EU4hf23iyA7uQRVko+UaBA==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -9918,6 +10068,134 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.0 + '@jsonjoy.com/base64@1.1.2(tslib@2.8.1)': + dependencies: + tslib: 2.8.1 + + '@jsonjoy.com/base64@17.67.0(tslib@2.8.1)': + dependencies: + tslib: 2.8.1 + + '@jsonjoy.com/buffers@1.2.1(tslib@2.8.1)': + dependencies: + tslib: 2.8.1 + + '@jsonjoy.com/buffers@17.67.0(tslib@2.8.1)': + dependencies: + tslib: 2.8.1 + + '@jsonjoy.com/codegen@1.0.0(tslib@2.8.1)': + dependencies: + tslib: 2.8.1 + + '@jsonjoy.com/codegen@17.67.0(tslib@2.8.1)': + dependencies: + tslib: 2.8.1 + + '@jsonjoy.com/fs-core@4.64.0(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/fs-node-builtins': 4.64.0(tslib@2.8.1) + '@jsonjoy.com/fs-node-utils': 4.64.0(tslib@2.8.1) + thingies: 2.6.0(tslib@2.8.1) + tslib: 2.8.1 + + '@jsonjoy.com/fs-fsa@4.64.0(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/fs-core': 4.64.0(tslib@2.8.1) + '@jsonjoy.com/fs-node-builtins': 4.64.0(tslib@2.8.1) + '@jsonjoy.com/fs-node-utils': 4.64.0(tslib@2.8.1) + thingies: 2.6.0(tslib@2.8.1) + tslib: 2.8.1 + + '@jsonjoy.com/fs-node-builtins@4.64.0(tslib@2.8.1)': + dependencies: + tslib: 2.8.1 + + '@jsonjoy.com/fs-node-to-fsa@4.64.0(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/fs-fsa': 4.64.0(tslib@2.8.1) + '@jsonjoy.com/fs-node-builtins': 4.64.0(tslib@2.8.1) + '@jsonjoy.com/fs-node-utils': 4.64.0(tslib@2.8.1) + tslib: 2.8.1 + + '@jsonjoy.com/fs-node-utils@4.64.0(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/fs-node-builtins': 4.64.0(tslib@2.8.1) + glob-to-regex.js: 1.2.0(tslib@2.8.1) + tslib: 2.8.1 + + '@jsonjoy.com/fs-node@4.64.0(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/fs-core': 4.64.0(tslib@2.8.1) + '@jsonjoy.com/fs-node-builtins': 4.64.0(tslib@2.8.1) + '@jsonjoy.com/fs-node-utils': 4.64.0(tslib@2.8.1) + '@jsonjoy.com/fs-print': 4.64.0(tslib@2.8.1) + '@jsonjoy.com/fs-snapshot': 4.64.0(tslib@2.8.1) + glob-to-regex.js: 1.2.0(tslib@2.8.1) + thingies: 2.6.0(tslib@2.8.1) + tslib: 2.8.1 + + '@jsonjoy.com/fs-print@4.64.0(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/fs-node-utils': 4.64.0(tslib@2.8.1) + tree-dump: 1.1.0(tslib@2.8.1) + tslib: 2.8.1 + + '@jsonjoy.com/fs-snapshot@4.64.0(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/buffers': 17.67.0(tslib@2.8.1) + '@jsonjoy.com/fs-node-utils': 4.64.0(tslib@2.8.1) + '@jsonjoy.com/json-pack': 17.67.0(tslib@2.8.1) + '@jsonjoy.com/util': 17.67.0(tslib@2.8.1) + tslib: 2.8.1 + + '@jsonjoy.com/json-pack@1.21.0(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/base64': 1.1.2(tslib@2.8.1) + '@jsonjoy.com/buffers': 1.2.1(tslib@2.8.1) + '@jsonjoy.com/codegen': 1.0.0(tslib@2.8.1) + '@jsonjoy.com/json-pointer': 1.0.2(tslib@2.8.1) + '@jsonjoy.com/util': 1.9.0(tslib@2.8.1) + hyperdyperid: 1.2.0 + thingies: 2.6.0(tslib@2.8.1) + tree-dump: 1.1.0(tslib@2.8.1) + tslib: 2.8.1 + + '@jsonjoy.com/json-pack@17.67.0(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/base64': 17.67.0(tslib@2.8.1) + '@jsonjoy.com/buffers': 17.67.0(tslib@2.8.1) + '@jsonjoy.com/codegen': 17.67.0(tslib@2.8.1) + '@jsonjoy.com/json-pointer': 17.67.0(tslib@2.8.1) + '@jsonjoy.com/util': 17.67.0(tslib@2.8.1) + hyperdyperid: 1.2.0 + thingies: 2.6.0(tslib@2.8.1) + tree-dump: 1.1.0(tslib@2.8.1) + tslib: 2.8.1 + + '@jsonjoy.com/json-pointer@1.0.2(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/codegen': 1.0.0(tslib@2.8.1) + '@jsonjoy.com/util': 1.9.0(tslib@2.8.1) + tslib: 2.8.1 + + '@jsonjoy.com/json-pointer@17.67.0(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/util': 17.67.0(tslib@2.8.1) + tslib: 2.8.1 + + '@jsonjoy.com/util@1.9.0(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/buffers': 1.2.1(tslib@2.8.1) + '@jsonjoy.com/codegen': 1.0.0(tslib@2.8.1) + tslib: 2.8.1 + + '@jsonjoy.com/util@17.67.0(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/buffers': 17.67.0(tslib@2.8.1) + '@jsonjoy.com/codegen': 17.67.0(tslib@2.8.1) + tslib: 2.8.1 + '@mdx-js/mdx@3.1.1': dependencies: '@types/estree': 1.0.8 @@ -12136,7 +12414,7 @@ snapshots: sirv: 3.0.1 tinyglobby: 0.2.15 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@20.19.25)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@22.1.0)(terser@5.42.0)(yaml@2.8.0) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@20.19.25)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@22.1.0)(terser@5.42.0)(yaml@2.8.0) '@vitest/utils@3.2.4': dependencies: @@ -14219,6 +14497,10 @@ snapshots: dependencies: is-glob: 4.0.3 + glob-to-regex.js@1.2.0(tslib@2.8.1): + dependencies: + tslib: 2.8.1 + glob-to-regexp@0.4.1: {} glob@10.4.5: @@ -14581,6 +14863,8 @@ snapshots: human-signals@2.1.0: {} + hyperdyperid@1.2.0: {} + hyphenate-style-name@1.1.0: {} iconv-lite@0.4.24: @@ -15824,6 +16108,23 @@ snapshots: medium-zoom@1.1.0: {} + memfs@4.64.0(tslib@2.8.1): + dependencies: + '@jsonjoy.com/fs-core': 4.64.0(tslib@2.8.1) + '@jsonjoy.com/fs-fsa': 4.64.0(tslib@2.8.1) + '@jsonjoy.com/fs-node': 4.64.0(tslib@2.8.1) + '@jsonjoy.com/fs-node-builtins': 4.64.0(tslib@2.8.1) + '@jsonjoy.com/fs-node-to-fsa': 4.64.0(tslib@2.8.1) + '@jsonjoy.com/fs-node-utils': 4.64.0(tslib@2.8.1) + '@jsonjoy.com/fs-print': 4.64.0(tslib@2.8.1) + '@jsonjoy.com/fs-snapshot': 4.64.0(tslib@2.8.1) + '@jsonjoy.com/json-pack': 1.21.0(tslib@2.8.1) + '@jsonjoy.com/util': 1.9.0(tslib@2.8.1) + glob-to-regex.js: 1.2.0(tslib@2.8.1) + thingies: 2.6.0(tslib@2.8.1) + tree-dump: 1.1.0(tslib@2.8.1) + tslib: 2.8.1 + memoize-one@5.2.1: {} memoize-one@6.0.0: {} @@ -17962,6 +18263,10 @@ snapshots: dependencies: any-promise: 1.3.0 + thingies@2.6.0(tslib@2.8.1): + dependencies: + tslib: 2.8.1 + throat@5.0.0: {} tinybench@2.9.0: {} @@ -18008,6 +18313,10 @@ snapshots: dependencies: punycode: 2.3.1 + tree-dump@1.1.0(tslib@2.8.1): + dependencies: + tslib: 2.8.1 + tree-kill@1.2.2: {} trim-lines@3.0.1: {} From aab8739f35492c19f17b4da309be2a861d248481 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Tue, 21 Jul 2026 12:32:25 +0200 Subject: [PATCH 2/2] feat(github-action): inject filesystem context --- .../version-plan-1784629893636.md | 5 + packages/github-action/eslint.config.mjs | 23 +++ packages/github-action/package.json | 3 +- .../src/shared/__tests__/plan-restore.test.ts | 25 ++-- .../src/shared/__tests__/plan-save.test.ts | 37 +++-- .../shared/__tests__/snapshot-metro.test.ts | 27 ++-- packages/github-action/src/shared/index.ts | 10 +- .../src/shared/metro-cache-inputs.ts | 8 +- .../src/shared/plan-restore-run.ts | 67 +++++++++ .../github-action/src/shared/plan-restore.ts | 74 +-------- .../github-action/src/shared/plan-save-run.ts | 135 +++++++++++++++++ .../github-action/src/shared/plan-save.ts | 140 +----------------- .../src/shared/snapshot-metro-run.ts | 67 +++++++++ .../src/shared/snapshot-metro.ts | 74 +-------- packages/github-action/tsconfig.json | 3 + packages/github-action/tsconfig.lib.json | 3 + packages/tools/package.json | 6 + pnpm-lock.yaml | 11 +- 18 files changed, 399 insertions(+), 319 deletions(-) create mode 100644 .nx/version-plans/version-plan-1784629893636.md create mode 100644 packages/github-action/src/shared/plan-restore-run.ts create mode 100644 packages/github-action/src/shared/plan-save-run.ts create mode 100644 packages/github-action/src/shared/snapshot-metro-run.ts diff --git a/.nx/version-plans/version-plan-1784629893636.md b/.nx/version-plans/version-plan-1784629893636.md new file mode 100644 index 00000000..2bc6b191 --- /dev/null +++ b/.nx/version-plans/version-plan-1784629893636.md @@ -0,0 +1,5 @@ +--- +__default__: minor +--- + +Add an injectable filesystem context and in-memory filesystem helper for hermetic Harness integrations and tests. diff --git a/packages/github-action/eslint.config.mjs b/packages/github-action/eslint.config.mjs index fabd4248..f613ca3a 100644 --- a/packages/github-action/eslint.config.mjs +++ b/packages/github-action/eslint.config.mjs @@ -2,6 +2,29 @@ import baseConfig from '../../eslint.config.mjs'; export default [ ...baseConfig, + { + files: ['src/shared/**/*.ts'], + ignores: ['src/shared/__tests__/**'], + rules: { + 'no-restricted-imports': [ + 'error', + { + paths: [ + { + name: 'node:fs', + message: + 'Use getFs() from @react-native-harness/tools/harness-context instead.', + }, + { + name: 'node:fs/promises', + message: + 'Use getFs() from @react-native-harness/tools/harness-context instead.', + }, + ], + }, + ], + }, + }, { files: ['**/*.json'], rules: { diff --git a/packages/github-action/package.json b/packages/github-action/package.json index 85b6bc0e..2434a728 100644 --- a/packages/github-action/package.json +++ b/packages/github-action/package.json @@ -13,7 +13,8 @@ "dependencies": { "@react-native-harness/cache": "workspace:*", "@react-native-harness/config": "workspace:*", - "@react-native-harness/platform-android": "workspace:*" + "@react-native-harness/platform-android": "workspace:*", + "@react-native-harness/tools": "workspace:*" }, "devDependencies": { "tsup": "^8.5.1" diff --git a/packages/github-action/src/shared/__tests__/plan-restore.test.ts b/packages/github-action/src/shared/__tests__/plan-restore.test.ts index e2c89d7f..36ba8107 100644 --- a/packages/github-action/src/shared/__tests__/plan-restore.test.ts +++ b/packages/github-action/src/shared/__tests__/plan-restore.test.ts @@ -1,7 +1,11 @@ -import fs from 'node:fs'; -import os from 'node:os'; import path from 'node:path'; +import { + runWithHarnessContext, + type FileSystem, +} from '@react-native-harness/tools/harness-context'; +import { createInMemoryFileSystem } from '@react-native-harness/tools/testing'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { run } from '../plan-restore-run.js'; const mocks = vi.hoisted(() => ({ getConfig: vi.fn(), @@ -23,16 +27,18 @@ vi.mock('@react-native-harness/cache', () => ({ describe('plan-restore', () => { let projectRoot: string; let githubOutputPath: string; + let fileSystem: FileSystem; let planRestoreMock: ReturnType; let writeKeysFileMock: ReturnType; beforeEach(() => { - vi.resetModules(); vi.clearAllMocks(); - projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gha-plan-restore-')); + fileSystem = createInMemoryFileSystem(); + projectRoot = '/project'; githubOutputPath = path.join(projectRoot, 'github-output.txt'); - fs.writeFileSync(githubOutputPath, ''); + fileSystem.mkdirSync(projectRoot, { recursive: true }); + fileSystem.writeFileSync(githubOutputPath, ''); process.env.INPUT_PROJECTROOT = projectRoot; process.env.GITHUB_OUTPUT = githubOutputPath; @@ -69,7 +75,6 @@ describe('plan-restore', () => { }); afterEach(() => { - fs.rmSync(projectRoot, { recursive: true, force: true }); delete process.env.INPUT_PROJECTROOT; delete process.env.GITHUB_OUTPUT; delete process.env.RUNNER_OS; @@ -77,13 +82,13 @@ describe('plan-restore', () => { }); it('writes metroRestoreKey and metroRestorePrefixes (as JSON) to GITHUB_OUTPUT', async () => { - await import('../plan-restore.js'); + await runWithHarnessContext({ fs: fileSystem }, run); await vi.waitFor(() => { expect(writeKeysFileMock).toHaveBeenCalled(); }); - const output = fs.readFileSync(githubOutputPath, 'utf8'); + const output = fileSystem.readFileSync(githubOutputPath, 'utf8'); expect(output).toContain('metroRestoreKey=harness-metro-linux-abc\n'); expect(output).toContain( 'metroRestorePrefixes=["harness-metro-linux-abc-"]\n' @@ -94,7 +99,7 @@ describe('plan-restore', () => { }); it('calls planRestore with the os and staticInputs assembled from the Metro key recipe', async () => { - await import('../plan-restore.js'); + await runWithHarnessContext({ fs: fileSystem }, run); await vi.waitFor(() => { expect(planRestoreMock).toHaveBeenCalled(); @@ -111,7 +116,7 @@ describe('plan-restore', () => { it('fails loudly (process.exit(1)) when GITHUB_OUTPUT is not set', async () => { delete process.env.GITHUB_OUTPUT; - await import('../plan-restore.js'); + await runWithHarnessContext({ fs: fileSystem }, run); await vi.waitFor(() => { expect(process.exit).toHaveBeenCalledWith(1); diff --git a/packages/github-action/src/shared/__tests__/plan-save.test.ts b/packages/github-action/src/shared/__tests__/plan-save.test.ts index 70dc4910..54fdff48 100644 --- a/packages/github-action/src/shared/__tests__/plan-save.test.ts +++ b/packages/github-action/src/shared/__tests__/plan-save.test.ts @@ -1,7 +1,11 @@ -import fs from 'node:fs'; -import os from 'node:os'; import path from 'node:path'; +import { + runWithHarnessContext, + type FileSystem, +} from '@react-native-harness/tools/harness-context'; +import { createInMemoryFileSystem } from '@react-native-harness/tools/testing'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { run } from '../plan-save-run.js'; const mocks = vi.hoisted(() => ({ getConfig: vi.fn(), @@ -21,16 +25,18 @@ vi.mock('@react-native-harness/cache', () => ({ describe('plan-save', () => { let projectRoot: string; let githubOutputPath: string; + let fileSystem: FileSystem; let planSaveMock: ReturnType; let writeKeysFileMock: ReturnType; beforeEach(() => { - vi.resetModules(); vi.clearAllMocks(); - projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gha-plan-save-')); + fileSystem = createInMemoryFileSystem(); + projectRoot = '/project'; githubOutputPath = path.join(projectRoot, 'github-output.txt'); - fs.writeFileSync(githubOutputPath, ''); + fileSystem.mkdirSync(projectRoot, { recursive: true }); + fileSystem.writeFileSync(githubOutputPath, ''); process.env.INPUT_PROJECTROOT = projectRoot; process.env.GITHUB_OUTPUT = githubOutputPath; @@ -69,7 +75,6 @@ describe('plan-save', () => { }); afterEach(() => { - fs.rmSync(projectRoot, { recursive: true, force: true }); delete process.env.INPUT_PROJECTROOT; delete process.env.GITHUB_OUTPUT; delete process.env.RUNNER_OS; @@ -80,13 +85,13 @@ describe('plan-save', () => { }); it('writes metroShouldSave and metroSaveKey to GITHUB_OUTPUT', async () => { - await import('../plan-save.js'); + await runWithHarnessContext({ fs: fileSystem }, run); await vi.waitFor(() => { expect(writeKeysFileMock).toHaveBeenCalled(); }); - const output = fs.readFileSync(githubOutputPath, 'utf8'); + const output = fileSystem.readFileSync(githubOutputPath, 'utf8'); expect(output).toContain('metroShouldSave=true\n'); expect(output).toContain( 'metroSaveKey=harness-metro-linux-abc-contenthash\n' @@ -94,7 +99,7 @@ describe('plan-save', () => { }); it('parses the metroSnapshot input and builds the SavePolicy from env/input', async () => { - await import('../plan-save.js'); + await runWithHarnessContext({ fs: fileSystem }, run); await vi.waitFor(() => { expect(planSaveMock).toHaveBeenCalled(); @@ -110,7 +115,7 @@ describe('plan-save', () => { it('treats an unparseable metroSnapshot input as an empty snapshot instead of throwing', async () => { process.env.INPUT_METRO_SNAPSHOT = 'not json'; - await import('../plan-save.js'); + await runWithHarnessContext({ fs: fileSystem }, run); await vi.waitFor(() => { expect(planSaveMock).toHaveBeenCalled(); @@ -127,7 +132,7 @@ describe('plan-save', () => { it('defaults an unrecognized cacheSavePolicy input to "default-branch"', async () => { process.env.INPUT_CACHESAVEPOLICY = 'bogus'; - await import('../plan-save.js'); + await runWithHarnessContext({ fs: fileSystem }, run); await vi.waitFor(() => { expect(planSaveMock).toHaveBeenCalled(); @@ -156,7 +161,7 @@ describe('plan-save', () => { }, }); - await import('../plan-save.js'); + await runWithHarnessContext({ fs: fileSystem }, run); await vi.waitFor(() => { expect(writeKeysFileMock).toHaveBeenCalled(); @@ -179,7 +184,7 @@ describe('plan-save', () => { }, }); - await import('../plan-save.js'); + await runWithHarnessContext({ fs: fileSystem }, run); await vi.waitFor(() => { expect(writeKeysFileMock).toHaveBeenCalled(); @@ -203,7 +208,7 @@ describe('plan-save', () => { }, }); - await import('../plan-save.js'); + await runWithHarnessContext({ fs: fileSystem }, run); await vi.waitFor(() => { expect(writeKeysFileMock).toHaveBeenCalled(); @@ -227,7 +232,7 @@ describe('plan-save', () => { }, }); - await import('../plan-save.js'); + await runWithHarnessContext({ fs: fileSystem }, run); await vi.waitFor(() => { expect(writeKeysFileMock).toHaveBeenCalled(); @@ -241,7 +246,7 @@ describe('plan-save', () => { it('fails loudly (process.exit(1)) when GITHUB_OUTPUT is not set', async () => { delete process.env.GITHUB_OUTPUT; - await import('../plan-save.js'); + await runWithHarnessContext({ fs: fileSystem }, run); await vi.waitFor(() => { expect(process.exit).toHaveBeenCalledWith(1); diff --git a/packages/github-action/src/shared/__tests__/snapshot-metro.test.ts b/packages/github-action/src/shared/__tests__/snapshot-metro.test.ts index 69b9b11f..ca1af3bf 100644 --- a/packages/github-action/src/shared/__tests__/snapshot-metro.test.ts +++ b/packages/github-action/src/shared/__tests__/snapshot-metro.test.ts @@ -1,7 +1,11 @@ -import fs from 'node:fs'; -import os from 'node:os'; import path from 'node:path'; +import { + runWithHarnessContext, + type FileSystem, +} from '@react-native-harness/tools/harness-context'; +import { createInMemoryFileSystem } from '@react-native-harness/tools/testing'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { run } from '../snapshot-metro-run.js'; const mocks = vi.hoisted(() => ({ getConfig: vi.fn(), @@ -19,15 +23,17 @@ vi.mock('@react-native-harness/cache', () => ({ describe('snapshot-metro', () => { let projectRoot: string; let githubOutputPath: string; + let fileSystem: FileSystem; let snapshotMock: ReturnType; beforeEach(() => { - vi.resetModules(); vi.clearAllMocks(); - projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gha-snapshot-metro-')); + fileSystem = createInMemoryFileSystem(); + projectRoot = '/project'; githubOutputPath = path.join(projectRoot, 'github-output.txt'); - fs.writeFileSync(githubOutputPath, ''); + fileSystem.mkdirSync(projectRoot, { recursive: true }); + fileSystem.writeFileSync(githubOutputPath, ''); process.env.INPUT_PROJECTROOT = projectRoot; process.env.GITHUB_OUTPUT = githubOutputPath; @@ -51,7 +57,6 @@ describe('snapshot-metro', () => { }); afterEach(() => { - fs.rmSync(projectRoot, { recursive: true, force: true }); delete process.env.INPUT_PROJECTROOT; delete process.env.GITHUB_OUTPUT; delete process.env.METRO_RESTORED_KEY; @@ -59,13 +64,13 @@ describe('snapshot-metro', () => { }); it('writes the current cache snapshot to GITHUB_OUTPUT as metroSnapshot', async () => { - await import('../snapshot-metro.js'); + await runWithHarnessContext({ fs: fileSystem }, run); await vi.waitFor(() => { expect(snapshotMock).toHaveBeenCalled(); }); - const output = fs.readFileSync(githubOutputPath, 'utf8'); + const output = fileSystem.readFileSync(githubOutputPath, 'utf8'); expect(output).toContain( 'metroSnapshot={"metro":[{"path":"metro/a","sizeBytes":3,"mtimeMs":1}]}\n' ); @@ -80,7 +85,7 @@ describe('snapshot-metro', () => { ], }); - await import('../snapshot-metro.js'); + await runWithHarnessContext({ fs: fileSystem }, run); await vi.waitFor(() => { expect(snapshotMock).toHaveBeenCalled(); @@ -94,7 +99,7 @@ describe('snapshot-metro', () => { it('reports a cold start when no cache entry matched', async () => { delete process.env.METRO_RESTORED_KEY; - await import('../snapshot-metro.js'); + await runWithHarnessContext({ fs: fileSystem }, run); await vi.waitFor(() => { expect(snapshotMock).toHaveBeenCalled(); @@ -108,7 +113,7 @@ describe('snapshot-metro', () => { it('fails loudly (process.exit(1)) when GITHUB_OUTPUT is not set', async () => { delete process.env.GITHUB_OUTPUT; - await import('../snapshot-metro.js'); + await runWithHarnessContext({ fs: fileSystem }, run); await vi.waitFor(() => { expect(process.exit).toHaveBeenCalledWith(1); diff --git a/packages/github-action/src/shared/index.ts b/packages/github-action/src/shared/index.ts index c1355e70..19554686 100644 --- a/packages/github-action/src/shared/index.ts +++ b/packages/github-action/src/shared/index.ts @@ -3,8 +3,12 @@ import { getEmulatorCpuCores, getHostAndroidSystemImageArch, } from '@react-native-harness/platform-android'; +import { + getFs, + getHarnessContext, + runWithHarnessContext, +} from '@react-native-harness/tools/harness-context'; import path from 'node:path'; -import fs from 'node:fs'; const resolveAvdCachingEnabled = ({ snapshotEnabled, @@ -122,7 +126,7 @@ const run = async (): Promise => { const output = `config=${JSON.stringify( resolvedRunner )}\nprojectRoot=${relativeProjectRoot}\n`; - fs.appendFileSync(githubOutput, output); + getFs().appendFileSync(githubOutput, output); } catch (error) { if (error instanceof Error) { console.error(error.message); @@ -134,4 +138,4 @@ const run = async (): Promise => { } }; -run(); +runWithHarnessContext(getHarnessContext(), run); diff --git a/packages/github-action/src/shared/metro-cache-inputs.ts b/packages/github-action/src/shared/metro-cache-inputs.ts index e03f04f4..e71a9c69 100644 --- a/packages/github-action/src/shared/metro-cache-inputs.ts +++ b/packages/github-action/src/shared/metro-cache-inputs.ts @@ -1,6 +1,6 @@ -import fs from 'node:fs'; import path from 'node:path'; import { computeMetroStaticInputs } from '@react-native-harness/cache'; +import { getFs } from '@react-native-harness/tools/harness-context'; const FALLBACK_BUNDLER_METRO_VERSION = 'unknown'; @@ -16,7 +16,7 @@ export const resolveRepoRoot = (projectRoot: string): string => { let dir = projectRoot; while (true) { - if (fs.existsSync(path.join(dir, '.git'))) { + if (getFs().existsSync(path.join(dir, '.git'))) { return dir; } @@ -65,7 +65,9 @@ const resolveBundlerMetroPackageJson = (projectRoot: string): string => { export const resolveBundlerMetroVersion = (projectRoot: string): string => { try { const packageJsonPath = resolveBundlerMetroPackageJson(projectRoot); - const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + const packageJson = JSON.parse( + getFs().readFileSync(packageJsonPath, 'utf8') + ); if (typeof packageJson.version !== 'string') { throw new Error('"version" field is missing or not a string'); diff --git a/packages/github-action/src/shared/plan-restore-run.ts b/packages/github-action/src/shared/plan-restore-run.ts new file mode 100644 index 00000000..8b30f05f --- /dev/null +++ b/packages/github-action/src/shared/plan-restore-run.ts @@ -0,0 +1,67 @@ +import path from 'node:path'; +import { createHarnessCache } from '@react-native-harness/cache'; +import { getConfig } from '@react-native-harness/config'; +import { getFs } from '@react-native-harness/tools/harness-context'; +import { resolveMetroStaticInputs } from './metro-cache-inputs.js'; + +export const run = async (): Promise => { + try { + const projectRootInput = process.env.INPUT_PROJECTROOT; + + const projectRoot = projectRootInput + ? path.resolve(projectRootInput) + : process.cwd(); + + console.info(`Planning Metro cache restore for: ${projectRoot}`); + + const { config, projectRoot: resolvedProjectRoot } = await getConfig( + projectRoot + ); + + const githubOutput = process.env.GITHUB_OUTPUT; + if (!githubOutput) { + throw new Error('GITHUB_OUTPUT environment variable is not set'); + } + + const staticInputs = resolveMetroStaticInputs({ + projectRoot: resolvedProjectRoot, + cacheVersionSalt: config.cache?.version, + }); + + const cache = createHarnessCache({ projectRoot: resolvedProjectRoot }); + const os = process.env.RUNNER_OS ?? process.platform; + + const restorePlan = cache.planRestore({ + metro: { os, staticInputs }, + }); + cache.writeKeysFile(restorePlan); + + const metroPlan = restorePlan.domains.metro; + + // Structured values are passed through GITHUB_OUTPUT as single-line JSON + // strings, matching the `config=${JSON.stringify(...)}` convention + // already used by shared/index.ts (parsed on the YAML side via + // `fromJson(...)`). + // + // The pre-run cache snapshot is deliberately NOT taken here: this step + // runs before the actual `actions/cache/restore` step, so the cache + // directories are still empty at this point. It's captured by a + // separate snapshot-metro step that runs after the restore action + // instead (see snapshot-metro.ts). + const output = + `metroRestoreKey=${metroPlan?.restoreKey ?? ''}\n` + + `metroRestorePrefixes=${JSON.stringify( + metroPlan?.restorePrefixes ?? [] + )}\n`; + + getFs().appendFileSync(githubOutput, output); + } catch (error) { + if (error instanceof Error) { + console.error(error.message); + } else { + console.error('Failed to plan Metro cache restore'); + } + + process.exit(1); + } +}; diff --git a/packages/github-action/src/shared/plan-restore.ts b/packages/github-action/src/shared/plan-restore.ts index 67691bef..76793408 100644 --- a/packages/github-action/src/shared/plan-restore.ts +++ b/packages/github-action/src/shared/plan-restore.ts @@ -1,69 +1,7 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import { createHarnessCache } from '@react-native-harness/cache'; -import { getConfig } from '@react-native-harness/config'; -import { resolveMetroStaticInputs } from './metro-cache-inputs.js'; +import { + getHarnessContext, + runWithHarnessContext, +} from '@react-native-harness/tools/harness-context'; +import { run } from './plan-restore-run.js'; -const run = async (): Promise => { - try { - const projectRootInput = process.env.INPUT_PROJECTROOT; - - const projectRoot = projectRootInput - ? path.resolve(projectRootInput) - : process.cwd(); - - console.info(`Planning Metro cache restore for: ${projectRoot}`); - - const { config, projectRoot: resolvedProjectRoot } = await getConfig( - projectRoot - ); - - const githubOutput = process.env.GITHUB_OUTPUT; - if (!githubOutput) { - throw new Error('GITHUB_OUTPUT environment variable is not set'); - } - - const staticInputs = resolveMetroStaticInputs({ - projectRoot: resolvedProjectRoot, - cacheVersionSalt: config.cache?.version, - }); - - const cache = createHarnessCache({ projectRoot: resolvedProjectRoot }); - const os = process.env.RUNNER_OS ?? process.platform; - - const restorePlan = cache.planRestore({ - metro: { os, staticInputs }, - }); - cache.writeKeysFile(restorePlan); - - const metroPlan = restorePlan.domains.metro; - - // Structured values are passed through GITHUB_OUTPUT as single-line JSON - // strings, matching the `config=${JSON.stringify(...)}` convention - // already used by shared/index.ts (parsed on the YAML side via - // `fromJson(...)`). - // - // The pre-run cache snapshot is deliberately NOT taken here: this step - // runs before the actual `actions/cache/restore` step, so the cache - // directories are still empty at this point. It's captured by a - // separate snapshot-metro step that runs after the restore action - // instead (see snapshot-metro.ts). - const output = - `metroRestoreKey=${metroPlan?.restoreKey ?? ''}\n` + - `metroRestorePrefixes=${JSON.stringify( - metroPlan?.restorePrefixes ?? [] - )}\n`; - - fs.appendFileSync(githubOutput, output); - } catch (error) { - if (error instanceof Error) { - console.error(error.message); - } else { - console.error('Failed to plan Metro cache restore'); - } - - process.exit(1); - } -}; - -run(); +runWithHarnessContext(getHarnessContext(), run); diff --git a/packages/github-action/src/shared/plan-save-run.ts b/packages/github-action/src/shared/plan-save-run.ts new file mode 100644 index 00000000..547c7e3a --- /dev/null +++ b/packages/github-action/src/shared/plan-save-run.ts @@ -0,0 +1,135 @@ +import path from 'node:path'; +import { + createHarnessCache, + type CacheSnapshot, + type DomainSavePlan, + type SavePolicy, +} from '@react-native-harness/cache'; +import { getConfig } from '@react-native-harness/config'; +import { getFs } from '@react-native-harness/tools/harness-context'; +import { formatMegabytes } from './format-bytes.js'; +import { resolveMetroStaticInputs } from './metro-cache-inputs.js'; + +const parseSnapshot = (raw: string | undefined): CacheSnapshot => { + if (!raw) { + return {}; + } + + try { + return JSON.parse(raw) as CacheSnapshot; + } catch (error) { + console.warn( + 'Failed to parse the metroSnapshot input. Treating the pre-run cache state as empty.', + error + ); + return {}; + } +}; + +const parseSavePolicyMode = ( + raw: string | undefined +): SavePolicy['mode'] => { + if (raw === 'always' || raw === 'never' || raw === 'default-branch') { + return raw; + } + + return 'default-branch'; +}; + +/** + * Single-line, grep-friendly job-log report of the save decision and its + * reason, so a skipped "Save Metro cache" step is never a mystery: it + * distinguishes "nothing changed" from "changed but blocked by policy". + */ +const logMetroSaveDecision = ( + plan: DomainSavePlan | undefined, + policy: SavePolicy +): void => { + if (!plan) { + return; + } + + const { delta, shouldSave, saveKey } = plan; + + if (delta.addedEntries + delta.removedEntries === 0) { + console.info('Metro cache: unchanged — skipping save.'); + return; + } + + const change = `+${delta.addedEntries} files / ${formatMegabytes(delta.addedBytes)} added, ${delta.removedEntries} removed`; + + if (shouldSave) { + console.info( + `Metro cache: changed (${change}) — saving new entry with key "${saveKey}".` + ); + return; + } + + const reason = + policy.mode === 'default-branch' + ? 'policy "default-branch" (current branch is not the default branch)' + : `policy "${policy.mode}"`; + console.info( + `Metro cache: changed (${change}) but save skipped by ${reason}.` + ); +}; + +export const run = async (): Promise => { + try { + const projectRootInput = process.env.INPUT_PROJECTROOT; + + const projectRoot = projectRootInput + ? path.resolve(projectRootInput) + : process.cwd(); + + console.info(`Planning Metro cache save for: ${projectRoot}`); + + const { config, projectRoot: resolvedProjectRoot } = await getConfig( + projectRoot + ); + + const githubOutput = process.env.GITHUB_OUTPUT; + if (!githubOutput) { + throw new Error('GITHUB_OUTPUT environment variable is not set'); + } + + // Recomputed rather than threaded through GITHUB_OUTPUT from + // plan-restore: computeMetroStaticInputs is deterministic given the same + // repoRoot/bundlerMetroVersion/salt, so this step can run independently. + const staticInputs = resolveMetroStaticInputs({ + projectRoot: resolvedProjectRoot, + cacheVersionSalt: config.cache?.version, + }); + + const before = parseSnapshot(process.env.INPUT_METRO_SNAPSHOT); + const policy: SavePolicy = { + mode: parseSavePolicyMode(process.env.INPUT_CACHESAVEPOLICY), + isDefaultBranch: process.env.IS_DEFAULT_BRANCH === 'true', + }; + + const cache = createHarnessCache({ projectRoot: resolvedProjectRoot }); + const os = process.env.RUNNER_OS ?? process.platform; + + const savePlan = cache.planSave(before, policy, { + metro: { os, staticInputs }, + }); + cache.writeKeysFile(savePlan); + + const metroPlan = savePlan.domains.metro; + logMetroSaveDecision(metroPlan, policy); + + const output = + `metroShouldSave=${metroPlan?.shouldSave ? 'true' : 'false'}\n` + + `metroSaveKey=${metroPlan?.saveKey ?? ''}\n`; + + getFs().appendFileSync(githubOutput, output); + } catch (error) { + if (error instanceof Error) { + console.error(error.message); + } else { + console.error('Failed to plan Metro cache save'); + } + + process.exit(1); + } +}; diff --git a/packages/github-action/src/shared/plan-save.ts b/packages/github-action/src/shared/plan-save.ts index 8c071987..9d179e00 100644 --- a/packages/github-action/src/shared/plan-save.ts +++ b/packages/github-action/src/shared/plan-save.ts @@ -1,137 +1,7 @@ -import fs from 'node:fs'; -import path from 'node:path'; import { - createHarnessCache, - type CacheSnapshot, - type DomainSavePlan, - type SavePolicy, -} from '@react-native-harness/cache'; -import { getConfig } from '@react-native-harness/config'; -import { formatMegabytes } from './format-bytes.js'; -import { resolveMetroStaticInputs } from './metro-cache-inputs.js'; + getHarnessContext, + runWithHarnessContext, +} from '@react-native-harness/tools/harness-context'; +import { run } from './plan-save-run.js'; -const parseSnapshot = (raw: string | undefined): CacheSnapshot => { - if (!raw) { - return {}; - } - - try { - return JSON.parse(raw) as CacheSnapshot; - } catch (error) { - console.warn( - 'Failed to parse the metroSnapshot input. Treating the pre-run cache state as empty.', - error - ); - return {}; - } -}; - -const parseSavePolicyMode = ( - raw: string | undefined -): SavePolicy['mode'] => { - if (raw === 'always' || raw === 'never' || raw === 'default-branch') { - return raw; - } - - return 'default-branch'; -}; - -/** - * Single-line, grep-friendly job-log report of the save decision and its - * reason, so a skipped "Save Metro cache" step is never a mystery: it - * distinguishes "nothing changed" from "changed but blocked by policy". - */ -const logMetroSaveDecision = ( - plan: DomainSavePlan | undefined, - policy: SavePolicy -): void => { - if (!plan) { - return; - } - - const { delta, shouldSave, saveKey } = plan; - - if (delta.addedEntries + delta.removedEntries === 0) { - console.info('Metro cache: unchanged — skipping save.'); - return; - } - - const change = `+${delta.addedEntries} files / ${formatMegabytes(delta.addedBytes)} added, ${delta.removedEntries} removed`; - - if (shouldSave) { - console.info( - `Metro cache: changed (${change}) — saving new entry with key "${saveKey}".` - ); - return; - } - - const reason = - policy.mode === 'default-branch' - ? 'policy "default-branch" (current branch is not the default branch)' - : `policy "${policy.mode}"`; - console.info( - `Metro cache: changed (${change}) but save skipped by ${reason}.` - ); -}; - -const run = async (): Promise => { - try { - const projectRootInput = process.env.INPUT_PROJECTROOT; - - const projectRoot = projectRootInput - ? path.resolve(projectRootInput) - : process.cwd(); - - console.info(`Planning Metro cache save for: ${projectRoot}`); - - const { config, projectRoot: resolvedProjectRoot } = await getConfig( - projectRoot - ); - - const githubOutput = process.env.GITHUB_OUTPUT; - if (!githubOutput) { - throw new Error('GITHUB_OUTPUT environment variable is not set'); - } - - // Recomputed rather than threaded through GITHUB_OUTPUT from - // plan-restore: computeMetroStaticInputs is deterministic given the same - // repoRoot/bundlerMetroVersion/salt, so this step can run independently. - const staticInputs = resolveMetroStaticInputs({ - projectRoot: resolvedProjectRoot, - cacheVersionSalt: config.cache?.version, - }); - - const before = parseSnapshot(process.env.INPUT_METRO_SNAPSHOT); - const policy: SavePolicy = { - mode: parseSavePolicyMode(process.env.INPUT_CACHESAVEPOLICY), - isDefaultBranch: process.env.IS_DEFAULT_BRANCH === 'true', - }; - - const cache = createHarnessCache({ projectRoot: resolvedProjectRoot }); - const os = process.env.RUNNER_OS ?? process.platform; - - const savePlan = cache.planSave(before, policy, { - metro: { os, staticInputs }, - }); - cache.writeKeysFile(savePlan); - - const metroPlan = savePlan.domains.metro; - logMetroSaveDecision(metroPlan, policy); - - const output = - `metroShouldSave=${metroPlan?.shouldSave ? 'true' : 'false'}\n` + - `metroSaveKey=${metroPlan?.saveKey ?? ''}\n`; - - fs.appendFileSync(githubOutput, output); - } catch (error) { - if (error instanceof Error) { - console.error(error.message); - } else { - console.error('Failed to plan Metro cache save'); - } - - process.exit(1); - } -}; - -run(); +runWithHarnessContext(getHarnessContext(), run); diff --git a/packages/github-action/src/shared/snapshot-metro-run.ts b/packages/github-action/src/shared/snapshot-metro-run.ts new file mode 100644 index 00000000..fa9ac124 --- /dev/null +++ b/packages/github-action/src/shared/snapshot-metro-run.ts @@ -0,0 +1,67 @@ +import path from 'node:path'; +import { createHarnessCache } from '@react-native-harness/cache'; +import { getConfig } from '@react-native-harness/config'; +import { getFs } from '@react-native-harness/tools/harness-context'; +import { formatMegabytes } from './format-bytes.js'; + +/** + * Must run AFTER the "Restore Metro cache" step and BEFORE the test run, so + * this snapshot reflects exactly what was restored from a prior save (empty + * on a cache miss) -- not the pre-restore, always-empty state of a fresh + * checkout. plan-save.ts diffs its own post-run snapshot against this one, so + * capturing it too early would make every run look like it added the whole + * restored cache, defeating the "only save when content actually changed" + * policy. + */ +export const run = async (): Promise => { + try { + const projectRootInput = process.env.INPUT_PROJECTROOT; + + const projectRoot = projectRootInput + ? path.resolve(projectRootInput) + : process.cwd(); + + console.info(`Snapshotting Metro cache for: ${projectRoot}`); + + const { projectRoot: resolvedProjectRoot } = await getConfig(projectRoot); + + const githubOutput = process.env.GITHUB_OUTPUT; + if (!githubOutput) { + throw new Error('GITHUB_OUTPUT environment variable is not set'); + } + + const cache = createHarnessCache({ projectRoot: resolvedProjectRoot }); + const snapshotAfterRestore = cache.snapshot(); + + // actions/cache/restore's cache-matched-key output, threaded in via the + // step env; empty on a cache miss. This makes the job log state plainly + // whether the Metro cache was actually restored. + const restoredKey = process.env.METRO_RESTORED_KEY; + if (restoredKey) { + const metroEntries = snapshotAfterRestore.metro ?? []; + const totalBytes = metroEntries.reduce( + (sum, entry) => sum + entry.sizeBytes, + 0 + ); + console.info( + `Metro cache: restored from key "${restoredKey}" (${metroEntries.length} files, ${formatMegabytes(totalBytes)}).` + ); + } else { + console.info( + 'Metro cache: no existing cache entry matched — starting cold.' + ); + } + + const output = `metroSnapshot=${JSON.stringify(snapshotAfterRestore)}\n`; + + getFs().appendFileSync(githubOutput, output); + } catch (error) { + if (error instanceof Error) { + console.error(error.message); + } else { + console.error('Failed to snapshot the Metro cache'); + } + + process.exit(1); + } +}; diff --git a/packages/github-action/src/shared/snapshot-metro.ts b/packages/github-action/src/shared/snapshot-metro.ts index 281ad55c..e43aa031 100644 --- a/packages/github-action/src/shared/snapshot-metro.ts +++ b/packages/github-action/src/shared/snapshot-metro.ts @@ -1,69 +1,7 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import { createHarnessCache } from '@react-native-harness/cache'; -import { getConfig } from '@react-native-harness/config'; -import { formatMegabytes } from './format-bytes.js'; +import { + getHarnessContext, + runWithHarnessContext, +} from '@react-native-harness/tools/harness-context'; +import { run } from './snapshot-metro-run.js'; -/** - * Must run AFTER the "Restore Metro cache" step and BEFORE the test run, so - * this snapshot reflects exactly what was restored from a prior save (empty - * on a cache miss) -- not the pre-restore, always-empty state of a fresh - * checkout. plan-save.ts diffs its own post-run snapshot against this one, so - * capturing it too early would make every run look like it added the whole - * restored cache, defeating the "only save when content actually changed" - * policy. - */ -const run = async (): Promise => { - try { - const projectRootInput = process.env.INPUT_PROJECTROOT; - - const projectRoot = projectRootInput - ? path.resolve(projectRootInput) - : process.cwd(); - - console.info(`Snapshotting Metro cache for: ${projectRoot}`); - - const { projectRoot: resolvedProjectRoot } = await getConfig(projectRoot); - - const githubOutput = process.env.GITHUB_OUTPUT; - if (!githubOutput) { - throw new Error('GITHUB_OUTPUT environment variable is not set'); - } - - const cache = createHarnessCache({ projectRoot: resolvedProjectRoot }); - const snapshotAfterRestore = cache.snapshot(); - - // actions/cache/restore's cache-matched-key output, threaded in via the - // step env; empty on a cache miss. This makes the job log state plainly - // whether the Metro cache was actually restored. - const restoredKey = process.env.METRO_RESTORED_KEY; - if (restoredKey) { - const metroEntries = snapshotAfterRestore.metro ?? []; - const totalBytes = metroEntries.reduce( - (sum, entry) => sum + entry.sizeBytes, - 0 - ); - console.info( - `Metro cache: restored from key "${restoredKey}" (${metroEntries.length} files, ${formatMegabytes(totalBytes)}).` - ); - } else { - console.info( - 'Metro cache: no existing cache entry matched — starting cold.' - ); - } - - const output = `metroSnapshot=${JSON.stringify(snapshotAfterRestore)}\n`; - - fs.appendFileSync(githubOutput, output); - } catch (error) { - if (error instanceof Error) { - console.error(error.message); - } else { - console.error('Failed to snapshot the Metro cache'); - } - - process.exit(1); - } -}; - -run(); +runWithHarnessContext(getHarnessContext(), run); diff --git a/packages/github-action/tsconfig.json b/packages/github-action/tsconfig.json index 0fe47aad..769faf8b 100644 --- a/packages/github-action/tsconfig.json +++ b/packages/github-action/tsconfig.json @@ -9,6 +9,9 @@ { "path": "../bundler-metro" }, + { + "path": "../tools" + }, { "path": "../platform-android" }, diff --git a/packages/github-action/tsconfig.lib.json b/packages/github-action/tsconfig.lib.json index c497d568..550f2492 100644 --- a/packages/github-action/tsconfig.lib.json +++ b/packages/github-action/tsconfig.lib.json @@ -17,6 +17,9 @@ { "path": "../bundler-metro/tsconfig.lib.json" }, + { + "path": "../tools/tsconfig.lib.json" + }, { "path": "../platform-android/tsconfig.lib.json" }, diff --git a/packages/tools/package.json b/packages/tools/package.json index 4613ce43..724cbfd5 100644 --- a/packages/tools/package.json +++ b/packages/tools/package.json @@ -13,6 +13,12 @@ "import": "./dist/index.js", "default": "./dist/index.js" }, + "./harness-context": { + "development": "./src/harness-context.ts", + "types": "./dist/harness-context.d.ts", + "import": "./dist/harness-context.js", + "default": "./dist/harness-context.js" + }, "./testing": { "development": "./src/testing.ts", "types": "./dist/testing.d.ts", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 39d7aced..69c2527f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -380,6 +380,9 @@ importers: '@react-native-harness/platform-android': specifier: workspace:* version: link:../platform-android + '@react-native-harness/tools': + specifier: workspace:* + version: link:../tools devDependencies: tsup: specifier: ^8.5.1 @@ -604,6 +607,9 @@ importers: '@clack/prompts': specifier: 1.0.0-alpha.9 version: 1.0.0-alpha.9 + memfs: + specifier: ^4.64.0 + version: 4.64.0(tslib@2.8.1) nano-spawn: specifier: ^1.0.2 version: 1.0.2 @@ -614,9 +620,6 @@ importers: specifier: ^2.3.0 version: 2.8.1 devDependencies: - memfs: - specifier: ^4.64.0 - version: 4.64.0(tslib@2.8.1) react-native: specifier: '*' version: 0.82.1(@babel/core@7.27.4)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.82.1)(@types/react@19.1.13)(react@19.2.3) @@ -12414,7 +12417,7 @@ snapshots: sirv: 3.0.1 tinyglobby: 0.2.15 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@20.19.25)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@22.1.0)(terser@5.42.0)(yaml@2.8.0) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@20.19.25)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@22.1.0)(terser@5.42.0)(yaml@2.8.0) '@vitest/utils@3.2.4': dependencies: