From 9bab21ad51cad5558bbc3bc9e52d9486c935dbec Mon Sep 17 00:00:00 2001 From: Stanislav Doskalenko Date: Fri, 28 Aug 2026 16:16:15 -0700 Subject: [PATCH 1/8] feat: support running the harness on a Windows host (#1) Two host-side assumptions broke React Native Windows before the harness could do anything useful: - The config reader passed a bare absolute path to dynamic import() for `.mjs` configs. Node only accepts a file:// URL there; on Windows the drive letter is read as a URL scheme and rejected (ERR_UNSUPPORTED_ESM_URL_SCHEME). Normalize with pathToFileURL. - getDeviceDescriptor threw "Unsupported platform" for Platform.OS === 'windows', aborting the bridge handshake. Add a `windows` case and widen the DeviceDescriptor platform union (in both the runtime and bridge copies of the type). Co-authored-by: Claude Sonnet 5 --- .../version-plan-1787953392799.md | 5 + packages/bridge/src/shared.ts | 2 +- packages/config/src/__tests__/reader.test.ts | 82 ++++++++++++++++ packages/config/src/reader.ts | 7 +- .../src/client/getDeviceDescriptor.test.ts | 95 +++++++++++++++++++ .../runtime/src/client/getDeviceDescriptor.ts | 11 ++- 6 files changed, 199 insertions(+), 3 deletions(-) create mode 100644 .nx/version-plans/version-plan-1787953392799.md create mode 100644 packages/config/src/__tests__/reader.test.ts create mode 100644 packages/runtime/src/client/getDeviceDescriptor.test.ts diff --git a/.nx/version-plans/version-plan-1787953392799.md b/.nx/version-plans/version-plan-1787953392799.md new file mode 100644 index 00000000..099108e5 --- /dev/null +++ b/.nx/version-plans/version-plan-1787953392799.md @@ -0,0 +1,5 @@ +--- +__default__: patch +--- + +The harness now runs on a Windows host and recognizes React Native Windows as a device platform: ESM (`rn-harness.config.mjs`) configs load correctly when the harness process runs on Windows, and an app reporting `Platform.OS === 'windows'` completes the bridge handshake instead of failing with "Unsupported platform". diff --git a/packages/bridge/src/shared.ts b/packages/bridge/src/shared.ts index acbbae5c..64dc802e 100644 --- a/packages/bridge/src/shared.ts +++ b/packages/bridge/src/shared.ts @@ -113,7 +113,7 @@ export type { } from './shared/bundler.js'; export type DeviceDescriptor = { - platform: 'ios' | 'android' | 'vega' | 'web'; + platform: 'ios' | 'android' | 'vega' | 'web' | 'windows'; manufacturer: string; model: string; osVersion: string; diff --git a/packages/config/src/__tests__/reader.test.ts b/packages/config/src/__tests__/reader.test.ts new file mode 100644 index 00000000..1e2ca63e --- /dev/null +++ b/packages/config/src/__tests__/reader.test.ts @@ -0,0 +1,82 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { getConfig } from '../reader.js'; + +const CONFIG_BODY = { + entryPoint: './index.js', + appRegistryComponentName: 'App', + runners: [ + { + name: 'test-runner', + config: {}, + runner: 'test-runner', + platformId: 'test-platform', + }, + ], +}; + +let projectDir: string; + +beforeEach(() => { + projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rn-harness-reader-')); +}); + +afterEach(() => { + fs.rmSync(projectDir, { recursive: true, force: true }); +}); + +describe('getConfig', () => { + it('loads an ESM (.mjs) config via a file:// URL', async () => { + // A bare absolute path passed to dynamic import() is rejected on Windows + // (ERR_UNSUPPORTED_ESM_URL_SCHEME because `C:` reads as a URL scheme); the + // reader must convert it with pathToFileURL first. This exercises that path + // on every OS and regression-guards it on Windows. + fs.writeFileSync( + path.join(projectDir, 'rn-harness.config.mjs'), + `export default ${JSON.stringify(CONFIG_BODY)};\n` + ); + + const { config, projectRoot } = await getConfig(projectDir); + + expect(config.entryPoint).toBe('./index.js'); + expect(config.runners).toHaveLength(1); + expect(projectRoot).toBe(projectDir); + }); + + it('loads a CommonJS (.js) config', async () => { + fs.writeFileSync( + path.join(projectDir, 'rn-harness.config.js'), + `module.exports = ${JSON.stringify(CONFIG_BODY)};\n` + ); + + const { config } = await getConfig(projectDir); + + expect(config.appRegistryComponentName).toBe('App'); + }); + + it('loads a JSON config', async () => { + fs.writeFileSync( + path.join(projectDir, 'rn-harness.config.json'), + JSON.stringify(CONFIG_BODY) + ); + + const { config } = await getConfig(projectDir); + + expect(config.entryPoint).toBe('./index.js'); + }); + + it('walks up to a parent directory to find the config', async () => { + fs.writeFileSync( + path.join(projectDir, 'rn-harness.config.mjs'), + `export default ${JSON.stringify(CONFIG_BODY)};\n` + ); + const nested = path.join(projectDir, 'a', 'b'); + fs.mkdirSync(nested, { recursive: true }); + + const { projectRoot } = await getConfig(nested); + + expect(projectRoot).toBe(projectDir); + }); +}); diff --git a/packages/config/src/reader.ts b/packages/config/src/reader.ts index 83183787..1a3c5220 100644 --- a/packages/config/src/reader.ts +++ b/packages/config/src/reader.ts @@ -6,6 +6,7 @@ import { } from './errors.js'; import path from 'node:path'; import fs from 'node:fs'; +import { pathToFileURL } from 'node:url'; import { createRequire } from 'node:module'; import { ZodError } from 'zod'; @@ -28,7 +29,11 @@ const importUp = async ( try { if (ext === '.mjs') { - rawConfig = await import(filePathWithExt).then( + // A dynamic import() of an absolute path only accepts a file:// URL. + // On POSIX the bare path happens to work; on Windows it is read as a + // URL and `C:` is rejected as an unknown scheme + // (ERR_UNSUPPORTED_ESM_URL_SCHEME). pathToFileURL normalizes both. + rawConfig = await import(pathToFileURL(filePathWithExt).href).then( (module) => module.default ); } else { diff --git a/packages/runtime/src/client/getDeviceDescriptor.test.ts b/packages/runtime/src/client/getDeviceDescriptor.test.ts new file mode 100644 index 00000000..48bb796a --- /dev/null +++ b/packages/runtime/src/client/getDeviceDescriptor.test.ts @@ -0,0 +1,95 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { getDeviceDescriptor } from './getDeviceDescriptor.js'; + +const mocks = vi.hoisted(() => ({ + Platform: { + OS: 'ios' as string, + constants: {} as Record, + }, +})); + +vi.mock('react-native', () => ({ + Platform: mocks.Platform, +})); + +beforeEach(() => { + mocks.Platform.OS = 'ios'; + mocks.Platform.constants = {}; +}); + +describe('getDeviceDescriptor', () => { + it('describes an iOS device', () => { + mocks.Platform.OS = 'ios'; + mocks.Platform.constants = { osVersion: '17.4' }; + + expect(getDeviceDescriptor()).toEqual({ + platform: 'ios', + manufacturer: 'Apple', + model: 'Unknown', + osVersion: '17.4', + }); + }); + + it('describes an Android device', () => { + mocks.Platform.OS = 'android'; + mocks.Platform.constants = { + Manufacturer: 'Google', + Model: 'Pixel 8', + Release: '14', + }; + + expect(getDeviceDescriptor()).toEqual({ + platform: 'android', + manufacturer: 'Google', + model: 'Pixel 8', + osVersion: '14', + }); + }); + + it('describes web', () => { + mocks.Platform.OS = 'web'; + + expect(getDeviceDescriptor()).toEqual({ + platform: 'web', + manufacturer: '', + model: '', + osVersion: '', + }); + }); + + it('maps the kepler OS to the vega platform', () => { + mocks.Platform.OS = 'kepler'; + + expect(getDeviceDescriptor()).toEqual({ + platform: 'vega', + manufacturer: '', + model: '', + osVersion: '', + }); + }); + + it('describes a Windows device', () => { + mocks.Platform.OS = 'windows'; + mocks.Platform.constants = { osVersion: 10 }; + + expect(getDeviceDescriptor()).toEqual({ + platform: 'windows', + manufacturer: '', + model: '', + osVersion: '10', + }); + }); + + it('tolerates a Windows device without an osVersion constant', () => { + mocks.Platform.OS = 'windows'; + mocks.Platform.constants = {}; + + expect(getDeviceDescriptor().osVersion).toBe(''); + }); + + it('throws for an unknown platform', () => { + mocks.Platform.OS = 'tizen'; + + expect(() => getDeviceDescriptor()).toThrow('Unsupported platform'); + }); +}); diff --git a/packages/runtime/src/client/getDeviceDescriptor.ts b/packages/runtime/src/client/getDeviceDescriptor.ts index 2819727b..c159e4a4 100644 --- a/packages/runtime/src/client/getDeviceDescriptor.ts +++ b/packages/runtime/src/client/getDeviceDescriptor.ts @@ -11,7 +11,7 @@ const getPlatform = (): Platform | PlatformKeplerStatic => { }; export type DeviceDescriptor = { - platform: 'ios' | 'android' | 'vega' | 'web'; + platform: 'ios' | 'android' | 'vega' | 'web' | 'windows'; manufacturer: string; model: string; osVersion: string; @@ -56,5 +56,14 @@ export const getDeviceDescriptor = (): DeviceDescriptor => { }; } + if (platform.OS === 'windows') { + return { + platform: 'windows', + manufacturer: '', + model: '', + osVersion: String(platform.constants?.osVersion ?? ''), + }; + } + throw new Error('Unsupported platform'); }; From e91e8206f34a8985cdbc795464087f22b6708f23 Mon Sep 17 00:00:00 2001 From: Stanislav Doskalenko Date: Fri, 28 Aug 2026 17:57:39 -0700 Subject: [PATCH 2/8] feat(bundler-metro): wire out-of-tree platforms into Metro (#2) * feat(bundler-metro): wire out-of-tree platforms into Metro The harness loads Metro's config with a bare `Metro.loadConfig`, bypassing `@react-native/community-cli-plugin`. That plugin is what teaches Metro about out-of-tree platforms (react-native-windows, react-native-macos): the `react-native` -> platform-package resolver redirect, the platform's `Libraries/Core/InitializeCore`, and the extra `resolver.platforms` entries. Without it a `--platform windows` bundle can't resolve `react-native/...` and the instance redboxes before HMRClient is registered, so every RNW + harness project has had to reproduce this in its own `metro.config.js`. Read the React Native CLI config and, when an out-of-tree platform is registered there, apply the same wiring `loadMetroConfig` does. Gated on that detection, so iOS/Android runs produce a byte-identical Metro config. `@react-native-community/cli-config` is resolved from the project (optional peer dep); its absence just means no out-of-tree platforms. Co-Authored-By: Claude Sonnet 5 * refactor(bundler-metro): rename out-of-tree-platforms to metro-platforms Matches the package's metro-* naming (metro-block-list, metro-cache, metro-workers). No behavior change. Co-Authored-By: Claude Sonnet 5 --------- Co-authored-by: Claude Sonnet 5 --- .../version-plan-1787960210915.md | 5 + packages/bundler-metro/package.json | 6 + .../src/__tests__/metro-platforms.test.ts | 96 +++++++++++ .../src/__tests__/withRnHarness.test.ts | 129 +++++++++++++- packages/bundler-metro/src/metro-platforms.ts | 160 ++++++++++++++++++ packages/bundler-metro/src/withRnHarness.ts | 53 +++++- 6 files changed, 446 insertions(+), 3 deletions(-) create mode 100644 .nx/version-plans/version-plan-1787960210915.md create mode 100644 packages/bundler-metro/src/__tests__/metro-platforms.test.ts create mode 100644 packages/bundler-metro/src/metro-platforms.ts diff --git a/.nx/version-plans/version-plan-1787960210915.md b/.nx/version-plans/version-plan-1787960210915.md new file mode 100644 index 00000000..a203ba62 --- /dev/null +++ b/.nx/version-plans/version-plan-1787960210915.md @@ -0,0 +1,5 @@ +--- +__default__: patch +--- + +Out-of-tree React Native platforms (react-native-windows, react-native-macos, …) now work without hand-editing `metro.config.js`. When such a platform is registered in the React Native CLI config, the harness applies the same Metro wiring `react-native start` does — the `react-native` → platform-package resolver redirect, the platform's `InitializeCore`, and the extra `resolver.platforms` entries — so its bundles resolve and initialize correctly. iOS and Android runs are unaffected. diff --git a/packages/bundler-metro/package.json b/packages/bundler-metro/package.json index 18d10bcf..daff9d83 100644 --- a/packages/bundler-metro/package.json +++ b/packages/bundler-metro/package.json @@ -27,11 +27,17 @@ "tslib": "^2.3.0" }, "peerDependencies": { + "@react-native-community/cli-config": "*", "metro": "*", "metro-cache": "*", "metro-config": "*", "metro-resolver": "*" }, + "peerDependenciesMeta": { + "@react-native-community/cli-config": { + "optional": true + } + }, "devDependencies": { "@types/connect": "^3.4.38", "metro": "*", diff --git a/packages/bundler-metro/src/__tests__/metro-platforms.test.ts b/packages/bundler-metro/src/__tests__/metro-platforms.test.ts new file mode 100644 index 00000000..9616a831 --- /dev/null +++ b/packages/bundler-metro/src/__tests__/metro-platforms.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { CustomResolutionContext } from 'metro-resolver'; +import { + parseOutOfTreePlatforms, + createPlatformPackageResolver, +} from '../metro-platforms.js'; + +describe('parseOutOfTreePlatforms', () => { + it('returns platforms that declare an npmPackageName', () => { + expect( + parseOutOfTreePlatforms({ + platforms: { + ios: {}, + android: {}, + windows: { npmPackageName: 'react-native-windows' }, + macos: { npmPackageName: 'react-native-macos' }, + }, + }) + ).toEqual([ + { name: 'windows', npmPackageName: 'react-native-windows' }, + { name: 'macos', npmPackageName: 'react-native-macos' }, + ]); + }); + + it('returns nothing when only in-tree platforms are registered', () => { + expect( + parseOutOfTreePlatforms({ platforms: { ios: {}, android: {} } }) + ).toEqual([]); + }); + + it('tolerates a missing or malformed platforms map', () => { + expect(parseOutOfTreePlatforms(undefined)).toEqual([]); + expect(parseOutOfTreePlatforms({})).toEqual([]); + expect(parseOutOfTreePlatforms({ platforms: null })).toEqual([]); + }); +}); + +describe('createPlatformPackageResolver', () => { + const context = { + resolveRequest: vi.fn(), + } as unknown as CustomResolutionContext; + + it('redirects react-native to the platform package when bundling for that platform', () => { + const next = vi.fn(); + const resolver = createPlatformPackageResolver( + { windows: 'react-native-windows' }, + next + ); + + resolver(context, 'react-native', 'windows'); + expect(next).toHaveBeenCalledWith(context, 'react-native-windows', 'windows'); + + resolver(context, 'react-native/Libraries/Core/InitializeCore', 'windows'); + expect(next).toHaveBeenLastCalledWith( + context, + 'react-native-windows/Libraries/Core/InitializeCore', + 'windows' + ); + }); + + it('leaves imports untouched for in-tree platforms and non-react-native modules', () => { + const next = vi.fn(); + const resolver = createPlatformPackageResolver( + { windows: 'react-native-windows' }, + next + ); + + resolver(context, 'react-native', 'ios'); + expect(next).toHaveBeenLastCalledWith(context, 'react-native', 'ios'); + + resolver(context, 'react-native-reanimated', 'windows'); + expect(next).toHaveBeenLastCalledWith( + context, + 'react-native-reanimated', + 'windows' + ); + + resolver(context, 'react-native', null); + expect(next).toHaveBeenLastCalledWith(context, 'react-native', null); + }); + + it('does not rewrite a module that merely starts with the string react-native', () => { + const next = vi.fn(); + const resolver = createPlatformPackageResolver( + { windows: 'react-native-windows' }, + next + ); + + resolver(context, 'react-native-svg', 'windows'); + expect(next).toHaveBeenLastCalledWith( + context, + 'react-native-svg', + 'windows' + ); + }); +}); diff --git a/packages/bundler-metro/src/__tests__/withRnHarness.test.ts b/packages/bundler-metro/src/__tests__/withRnHarness.test.ts index 4f64046f..f584ee16 100644 --- a/packages/bundler-metro/src/__tests__/withRnHarness.test.ts +++ b/packages/bundler-metro/src/__tests__/withRnHarness.test.ts @@ -1,5 +1,5 @@ import os from 'node:os'; -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { getConfig } from '@react-native-harness/config'; type MinimalMetroConfig = { @@ -11,6 +11,7 @@ type MinimalMetroConfig = { hasteMapCacheDirectory?: string; serializer?: { isThirdPartyModule?: (module: { path: string }) => boolean; + getModulesRunBeforeMainModule?: (entryPoint: string) => string[]; }; symbolicator?: { customizeFrame?: (frame: { file?: string | null }) => Promise<{ @@ -19,6 +20,12 @@ type MinimalMetroConfig = { }; resolver?: { blockList?: RegExp; + platforms?: string[]; + resolveRequest?: ( + context: unknown, + moduleName: string, + platform: string | null, + ) => unknown; }; server?: { useGlobalHotkey?: boolean; @@ -66,10 +73,35 @@ vi.mock('../metro-cache.js', () => ({ getHarnessCacheStores: vi.fn(() => []), })); +const { harnessResolver } = vi.hoisted(() => ({ + harnessResolver: vi.fn(), +})); + vi.mock('../resolvers/resolver.js', () => ({ - getHarnessResolver: vi.fn(() => vi.fn()), + getHarnessResolver: vi.fn(() => harnessResolver), +})); + +const { resolveOutOfTreePlatforms, resolveOutOfTreeInitializeCore } = vi.hoisted( + () => ({ + resolveOutOfTreePlatforms: vi.fn< + () => { name: string; npmPackageName: string }[] + >(() => []), + resolveOutOfTreeInitializeCore: vi.fn<() => string | null>(() => null), + }), +); + +vi.mock('../metro-platforms.js', async (importOriginal) => ({ + ...(await importOriginal()), + resolveOutOfTreePlatforms, + resolveOutOfTreeInitializeCore, })); +beforeEach(() => { + resolveOutOfTreePlatforms.mockReturnValue([]); + resolveOutOfTreeInitializeCore.mockReturnValue(null); + harnessResolver.mockReset(); +}); + describe('withRnHarness', () => { it('treats installed Harness packages as internal callsites', async () => { const { withRnHarness } = await import('../withRnHarness.js'); @@ -310,4 +342,97 @@ describe('withRnHarness', () => { /^react-native-harness:\d+\.\d+\.\d+.*:my-salt$/, ); }); + + it('leaves resolver.platforms and getModulesRunBeforeMainModule alone without an out-of-tree platform', async () => { + const { withRnHarness } = await import('../withRnHarness.js'); + + const runBeforeMain = vi.fn(() => ['/repo/rn/InitializeCore.js']); + + const config = (await withRnHarness( + { + projectRoot: '/tmp/app', + resolver: { platforms: ['ios', 'android'] }, + serializer: { getModulesRunBeforeMainModule: runBeforeMain }, + }, + true, + )()) as unknown as MinimalMetroConfig; + + expect(config.resolver?.platforms).toEqual(['ios', 'android']); + expect(config.resolver?.resolveRequest).toBe(harnessResolver); + // Untouched: still the project's own function, not a harness wrapper. + expect(config.serializer?.getModulesRunBeforeMainModule).toBe(runBeforeMain); + }); + + it('wires an out-of-tree platform into the resolver and serializer', async () => { + resolveOutOfTreePlatforms.mockReturnValue([ + { name: 'windows', npmPackageName: 'react-native-windows' }, + ]); + resolveOutOfTreeInitializeCore.mockReturnValue( + '/repo/node_modules/react-native-windows/Libraries/Core/InitializeCore.js', + ); + + const { withRnHarness } = await import('../withRnHarness.js'); + + const config = (await withRnHarness( + { + projectRoot: '/tmp/app', + resolver: { platforms: ['ios', 'android'] }, + serializer: { + getModulesRunBeforeMainModule: () => ['/repo/rn/InitializeCore.js'], + }, + }, + true, + )()) as unknown as MinimalMetroConfig; + + // The out-of-tree platform and `native` are added, existing entries kept. + expect(config.resolver?.platforms).toEqual([ + 'ios', + 'android', + 'windows', + 'native', + ]); + + // `react-native` imports for that platform now resolve against its package. + config.resolver?.resolveRequest?.( + { some: 'context' }, + 'react-native', + 'windows', + ); + expect(harnessResolver).toHaveBeenCalledWith( + { some: 'context' }, + 'react-native-windows', + 'windows', + ); + + // The platform's InitializeCore is appended after the project's own. + expect( + config.serializer?.getModulesRunBeforeMainModule?.('index.js'), + ).toEqual([ + '/repo/rn/InitializeCore.js', + '/repo/node_modules/react-native-windows/Libraries/Core/InitializeCore.js', + ]); + }); + + it('does not add getModulesRunBeforeMainModule when the platform InitializeCore cannot be resolved', async () => { + resolveOutOfTreePlatforms.mockReturnValue([ + { name: 'windows', npmPackageName: 'react-native-windows' }, + ]); + resolveOutOfTreeInitializeCore.mockReturnValue(null); + + const { withRnHarness } = await import('../withRnHarness.js'); + + const runBeforeMain = vi.fn(() => ['/repo/rn/InitializeCore.js']); + + const config = (await withRnHarness( + { + projectRoot: '/tmp/app', + serializer: { getModulesRunBeforeMainModule: runBeforeMain }, + }, + true, + )()) as unknown as MinimalMetroConfig; + + expect(config.serializer?.getModulesRunBeforeMainModule).toBe(runBeforeMain); + // The resolver wiring still happens. + expect(config.resolver?.platforms).toContain('windows'); + }); }); diff --git a/packages/bundler-metro/src/metro-platforms.ts b/packages/bundler-metro/src/metro-platforms.ts new file mode 100644 index 00000000..3c5e2452 --- /dev/null +++ b/packages/bundler-metro/src/metro-platforms.ts @@ -0,0 +1,160 @@ +import { createRequire } from 'node:module'; +import { logger } from '@react-native-harness/tools'; +import type { CustomResolutionContext, Resolution } from 'metro-resolver'; +import type { MetroResolver } from './resolvers/types.js'; + +const require = createRequire(import.meta.url); +const log = logger.child('metro-platforms'); + +export type OutOfTreePlatform = { + /** The platform name, e.g. `windows` — the value of Metro's `platform` param. */ + name: string; + /** The package `react-native` imports are redirected to, e.g. `react-native-windows`. */ + npmPackageName: string; +}; + +type CliConfigModule = { + loadConfig?: (options: unknown) => unknown; + default?: (options: unknown) => unknown; +}; + +/** + * Reads the React Native CLI config (`react-native.config.js` plus every + * dependency's `react-native.config.js`) and returns the out-of-tree platforms + * registered there — the ones with an `npmPackageName`, such as + * `react-native-windows` or `react-native-macos`. + * + * The Harness loads Metro's config with a bare `Metro.loadConfig`, bypassing + * `@react-native/community-cli-plugin`, so none of the out-of-tree wiring that + * `react-native start` installs is applied. This is the first half of putting + * it back; see `applyOutOfTreePlatformConfig`. + * + * Returns `[]` (and the caller leaves Metro's config untouched) when the CLI + * config cannot be read — e.g. a project without `@react-native-community/cli`. + */ +export const resolveOutOfTreePlatforms = ( + projectRoot: string +): OutOfTreePlatform[] => { + let cliConfigModule: CliConfigModule; + try { + const cliConfigPath = require.resolve('@react-native-community/cli-config', { + paths: [projectRoot], + }); + cliConfigModule = require(cliConfigPath) as CliConfigModule; + } catch { + log.debug( + '@react-native-community/cli-config is not resolvable from %s; assuming no out-of-tree platforms', + projectRoot + ); + return []; + } + + const loadConfig = cliConfigModule.loadConfig ?? cliConfigModule.default; + if (typeof loadConfig !== 'function') { + return []; + } + + let cliConfig: unknown; + try { + // CLI >= 14 takes an options object; older releases took a positional + // projectRoot string. + cliConfig = loadConfig({ projectRoot }); + } catch { + try { + cliConfig = (loadConfig as (root: string) => unknown)(projectRoot); + } catch (error) { + log.debug( + 'could not load the React Native CLI config: %s', + error instanceof Error ? error.message : String(error) + ); + return []; + } + } + + const outOfTree = parseOutOfTreePlatforms(cliConfig); + + if (outOfTree.length > 0) { + log.debug( + 'detected out-of-tree platform(s): %s', + outOfTree.map((p) => `${p.name} -> ${p.npmPackageName}`).join(', ') + ); + } + + return outOfTree; +}; + +/** + * Extracts the out-of-tree platforms from a loaded React Native CLI config — + * the entries of `config.platforms` that carry an `npmPackageName`. + */ +export const parseOutOfTreePlatforms = ( + cliConfig: unknown +): OutOfTreePlatform[] => { + const platforms = + (cliConfig as { platforms?: Record }) + ?.platforms ?? {}; + + return Object.entries(platforms) + .filter(([, config]) => Boolean(config?.npmPackageName)) + .map(([name, config]) => ({ + name, + npmPackageName: config.npmPackageName as string, + })); +}; + +/** + * Wraps a Metro `resolveRequest` so that, when bundling for an out-of-tree + * platform, `react-native` / `react-native/*` imports resolve against that + * platform's package instead. + * + * Mirrors `reactNativePlatformResolver` from + * `@react-native/community-cli-plugin` (`utils/metroPlatformResolver`), which + * the package does not export. Kept in sync deliberately. + */ +export const createPlatformPackageResolver = ( + platformImplementations: Record, + next: MetroResolver +): MetroResolver => { + return ( + context: CustomResolutionContext, + moduleName: string, + platform: string | null + ): Resolution => { + let redirected = moduleName; + const implementation = + platform != null ? platformImplementations[platform] : undefined; + + if (implementation != null) { + if (moduleName === 'react-native') { + redirected = implementation; + } else if (moduleName.startsWith('react-native/')) { + redirected = `${implementation}/${moduleName.slice('react-native/'.length)}`; + } + } + + return next(context, redirected, platform); + }; +}; + +/** + * Resolves an out-of-tree platform's `Libraries/Core/InitializeCore` entry, so + * it can be added to `getModulesRunBeforeMainModule` the way the CLI plugin + * does. Metro only emits a `require()` for run-before modules that are actually + * in a bundle's graph, so this is a no-op for in-tree (iOS/Android) bundles. + */ +export const resolveOutOfTreeInitializeCore = ( + npmPackageName: string, + projectRoot: string +): string | null => { + const specifier = `${npmPackageName}/Libraries/Core/InitializeCore`; + try { + return require.resolve(specifier, { paths: [projectRoot] }); + } catch { + log.warn( + 'could not resolve %s; %s bundles may fail to register HMRClient and other core modules', + specifier, + npmPackageName + ); + return null; + } +}; diff --git a/packages/bundler-metro/src/withRnHarness.ts b/packages/bundler-metro/src/withRnHarness.ts index 71e4dab0..44ab5cd6 100644 --- a/packages/bundler-metro/src/withRnHarness.ts +++ b/packages/bundler-metro/src/withRnHarness.ts @@ -16,6 +16,11 @@ import { getHarnessBlockList } from './metro-block-list.js'; import { getHarnessCacheStores } from './metro-cache.js'; import { getCappedMaxWorkers } from './metro-workers.js'; import { getHarnessResolver } from './resolvers/resolver.js'; +import { + resolveOutOfTreePlatforms, + createPlatformPackageResolver, + resolveOutOfTreeInitializeCore, +} from './metro-platforms.js'; import type { NotReadOnly } from './utils.js'; const require = createRequire(import.meta.url); @@ -50,6 +55,31 @@ export const withRnHarness = ( const harnessCache = createHarnessCache({ projectRoot }); const harnessResolver = getHarnessResolver(metroConfig, harnessConfig); + + // `react-native start` runs Metro through + // `@react-native/community-cli-plugin`, which teaches Metro about + // out-of-tree platforms (react-native-windows, react-native-macos, …): + // the `react-native` -> platform-package redirect, the platform's + // `InitializeCore`, and the extra `resolver.platforms` entries. The + // harness loads Metro's config directly and misses all of it. Put it + // back, but only when such a platform is actually registered so + // iOS/Android runs are byte-for-byte unchanged. + const outOfTreePlatforms = resolveOutOfTreePlatforms(projectRoot); + const hasOutOfTreePlatforms = outOfTreePlatforms.length > 0; + + const resolveRequest = hasOutOfTreePlatforms + ? createPlatformPackageResolver( + Object.fromEntries( + outOfTreePlatforms.map((p) => [p.name, p.npmPackageName]) + ), + harnessResolver + ) + : harnessResolver; + + const outOfTreeInitializeCore = outOfTreePlatforms + .map((p) => resolveOutOfTreeInitializeCore(p.npmPackageName, projectRoot)) + .filter((entry): entry is string => entry != null); + const harnessManifest = getHarnessManifest(harnessConfig); const harnessBabelTransformerPath = getHarnessBabelTransformerPath(metroConfig); @@ -93,6 +123,16 @@ export const withRnHarness = ( }, serializer: { ...metroConfig.serializer, + ...(outOfTreeInitializeCore.length > 0 + ? { + getModulesRunBeforeMainModule: (entryPoint: string) => [ + ...(metroConfig.serializer?.getModulesRunBeforeMainModule?.( + entryPoint + ) ?? []), + ...outOfTreeInitializeCore, + ], + } + : {}), getPolyfills: (...args) => [ ...(metroConfig.serializer?.getPolyfills?.(...args) ?? []), harnessManifest, @@ -114,8 +154,19 @@ export const withRnHarness = ( resolver: { ...metroConfig.resolver, blockList: harnessBlockList, - resolveRequest: harnessResolver, + resolveRequest, useWatchman: process.env.RN_HARNESS_DEBUG_USE_WATCHMAN !== '0', + ...(hasOutOfTreePlatforms + ? { + platforms: [ + ...new Set([ + ...(metroConfig.resolver?.platforms ?? ['ios', 'android']), + ...outOfTreePlatforms.map((p) => p.name), + 'native', + ]), + ], + } + : {}), }, transformer: { ...metroConfig.transformer, From 226cfeae21fad84bee0a62ccbb04291b8603f3c0 Mon Sep 17 00:00:00 2001 From: Stanislav Doskalenko Date: Fri, 28 Aug 2026 18:32:36 -0700 Subject: [PATCH 3/8] feat(platform-windows): add the React Native Windows platform (#3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New `@react-native-harness/platform-windows` package, mirroring `platform-vega`: `windowsPlatform({ name, packageName })` in `rn-harness.config.mjs` runs the harness against an already-deployed RNW app. The runner resolves the package family name from the manifest identity name via `Get-AppxPackage`, shell-activates the app by its AUMID (`!`, `appId` defaulting to the template's `App`), confirms the process came up, then polls it and emits `app_exited` when it goes away. `init.signal` cancels the readiness wait but never disposes — the harness owns that. Also adds `WindowsAppLaunchOptions` to `@react-native-harness/platforms`. Co-authored-by: Claude Sonnet 5 --- .../version-plan-1787965767199.md | 5 + packages/platform-windows/.npmignore | 4 + packages/platform-windows/README.md | 76 ++++++++ packages/platform-windows/eslint.config.mjs | 23 +++ packages/platform-windows/package.json | 26 +++ .../src/__tests__/runner.test.ts | 137 ++++++++++++++ packages/platform-windows/src/config.ts | 36 ++++ packages/platform-windows/src/factory.ts | 12 ++ packages/platform-windows/src/index.ts | 6 + packages/platform-windows/src/pwsh.ts | 73 ++++++++ packages/platform-windows/src/runner.ts | 173 ++++++++++++++++++ packages/platform-windows/tsconfig.json | 19 ++ packages/platform-windows/tsconfig.lib.json | 24 +++ packages/platform-windows/vite.config.ts | 18 ++ packages/platforms/src/index.ts | 1 + packages/platforms/src/types.ts | 5 +- pnpm-lock.yaml | 20 ++ tsconfig.json | 3 + 18 files changed, 660 insertions(+), 1 deletion(-) create mode 100644 .nx/version-plans/version-plan-1787965767199.md create mode 100644 packages/platform-windows/.npmignore create mode 100644 packages/platform-windows/README.md create mode 100644 packages/platform-windows/eslint.config.mjs create mode 100644 packages/platform-windows/package.json create mode 100644 packages/platform-windows/src/__tests__/runner.test.ts create mode 100644 packages/platform-windows/src/config.ts create mode 100644 packages/platform-windows/src/factory.ts create mode 100644 packages/platform-windows/src/index.ts create mode 100644 packages/platform-windows/src/pwsh.ts create mode 100644 packages/platform-windows/src/runner.ts create mode 100644 packages/platform-windows/tsconfig.json create mode 100644 packages/platform-windows/tsconfig.lib.json create mode 100644 packages/platform-windows/vite.config.ts diff --git a/.nx/version-plans/version-plan-1787965767199.md b/.nx/version-plans/version-plan-1787965767199.md new file mode 100644 index 00000000..c47a11f2 --- /dev/null +++ b/.nx/version-plans/version-plan-1787965767199.md @@ -0,0 +1,5 @@ +--- +__default__: patch +--- + +New `@react-native-harness/platform-windows` package: run harness tests against a deployed React Native Windows app. Add `windowsPlatform({ name, packageName })` to `rn-harness.config.mjs` — the runner resolves the package family name via `Get-AppxPackage`, shell-activates the app by its AUMID, and tracks it by process name. Requires the app to be deployed first (`react-native run-windows`). diff --git a/packages/platform-windows/.npmignore b/packages/platform-windows/.npmignore new file mode 100644 index 00000000..2ab54727 --- /dev/null +++ b/packages/platform-windows/.npmignore @@ -0,0 +1,4 @@ +**/__tests__/ +**/*.test.* +**/*.tsbuildinfo +dist/*.tsbuildinfo diff --git a/packages/platform-windows/README.md b/packages/platform-windows/README.md new file mode 100644 index 00000000..623771a0 --- /dev/null +++ b/packages/platform-windows/README.md @@ -0,0 +1,76 @@ +![harness-banner](https://react-native-harness.dev/harness-banner.jpg) + +[![mit licence][license-badge]][license] +[![npm downloads][npm-downloads-badge]][npm-downloads] +[![Chat][chat-badge]][chat] +[![PRs Welcome][prs-welcome-badge]][prs-welcome] + +React Native Windows platform for React Native Harness — runs your harness tests against a deployed React Native Windows app. + +## Installation + +```bash +npm install --save-dev @react-native-harness/platform-windows +# or +pnpm add -D @react-native-harness/platform-windows +# or +yarn add -D @react-native-harness/platform-windows +``` + +## Usage + +Add the Windows platform to your `rn-harness.config.mjs`: + +```javascript +import { windowsPlatform } from '@react-native-harness/platform-windows'; + +export default { + entryPoint: './index.js', + appRegistryComponentName: 'MyApp', + runners: [ + windowsPlatform({ + name: 'windows', + // Package.appxmanifest Identity/@Name + packageName: 'MyApp', + }), + ], +}; +``` + +Deploy the app before running the harness — the runner launches an already +installed package, it does not build: + +```bash +npx react-native run-windows --arch x64 --no-launch --no-packager +npx react-native-harness --harnessRunner windows +``` + +## API + +### `windowsPlatform(config)` + +**Parameters:** + +- `config.name` — unique name for the runner. +- `config.packageName` — the app's `Identity/@Name` from `Package.appxmanifest`. Used to look the deployed package up with `Get-AppxPackage`. +- `config.appId` — the app's `Application/@Id` from `Package.appxmanifest`. Combined with the package family name into the AUMID used to launch the app. Defaults to `App` (the React Native Windows template value). +- `config.processName` — the app's process name (without `.exe`), for tracking whether it is still running. Defaults to `packageName`. + +## Requirements + +- Windows 10/11 with the app already deployed (`react-native run-windows`). +- The harness Metro server reachable at the app's configured bundle URL (`http://localhost:8081` by default). + +## Made with ❤️ at Callstack + +`react-native-harness` is an open source project and will always remain free to use. If you think it's cool, please star it 🌟. [Callstack][callstack-readme-with-love] is a group of React and React Native geeks, contact us at [hello@callstack.com](mailto:hello@callstack.com) if you need any help with these or just want to say hi! + +[callstack-readme-with-love]: https://callstack.com/?utm_source=github.com&utm_medium=referral&utm_campaign=react-native-harness&utm_term=readme-with-love +[license-badge]: https://img.shields.io/npm/l/react-native-harness?style=for-the-badge +[license]: https://github.com/callstackincubator/react-native-harness/blob/main/LICENSE +[npm-downloads-badge]: https://img.shields.io/npm/dm/react-native-harness?style=for-the-badge +[npm-downloads]: https://www.npmjs.com/package/react-native-harness +[prs-welcome-badge]: https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=for-the-badge +[prs-welcome]: ./CONTRIBUTING.md +[chat-badge]: https://img.shields.io/discord/426714625279524876.svg?style=for-the-badge +[chat]: https://discord.gg/xgGt7KAjxv diff --git a/packages/platform-windows/eslint.config.mjs b/packages/platform-windows/eslint.config.mjs new file mode 100644 index 00000000..8c8d168e --- /dev/null +++ b/packages/platform-windows/eslint.config.mjs @@ -0,0 +1,23 @@ +import baseConfig from '../../eslint.config.mjs'; + +export default [ + ...baseConfig, + { + files: ['**/*.json'], + rules: { + '@nx/dependency-checks': [ + 'error', + { + ignoredDependencies: ['vite', 'vitest'], + ignoredFiles: [ + '{projectRoot}/eslint.config.{js,cjs,mjs,ts,cts,mts}', + '{projectRoot}/src/**/__tests__/**', + ], + }, + ], + }, + languageOptions: { + parser: await import('jsonc-eslint-parser'), + }, + }, +]; diff --git a/packages/platform-windows/package.json b/packages/platform-windows/package.json new file mode 100644 index 00000000..eb1aa6d5 --- /dev/null +++ b/packages/platform-windows/package.json @@ -0,0 +1,26 @@ +{ + "name": "@react-native-harness/platform-windows", + "description": "React Native Windows platform for React Native Harness", + "version": "1.4.1", + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + "./package.json": "./package.json", + ".": { + "development": "./src/index.ts", + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + } + }, + "dependencies": { + "@react-native-harness/config": "workspace:*", + "@react-native-harness/platforms": "workspace:*", + "@react-native-harness/tools": "workspace:*", + "zod": "^3.25.67", + "tslib": "^2.3.0" + }, + "license": "MIT" +} diff --git a/packages/platform-windows/src/__tests__/runner.test.ts b/packages/platform-windows/src/__tests__/runner.test.ts new file mode 100644 index 00000000..884a0a47 --- /dev/null +++ b/packages/platform-windows/src/__tests__/runner.test.ts @@ -0,0 +1,137 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + DEFAULT_METRO_PORT, + type Config as HarnessConfig, +} from '@react-native-harness/config'; +import { AppNotInstalledError } from '@react-native-harness/platforms'; +import type { WindowsPlatformConfigInput } from '../config.js'; + +const pwsh = vi.hoisted(() => ({ + getPackageFamilyName: vi.fn<() => Promise>(), + isProcessRunning: vi.fn<() => Promise>(), + launchAppByAumid: vi.fn<() => Promise>(), + stopProcess: vi.fn<() => Promise>(), +})); + +vi.mock('../pwsh.js', () => pwsh); + +const harnessConfig = { metroPort: DEFAULT_METRO_PORT } as HarnessConfig; + +const config: WindowsPlatformConfigInput = { + name: 'windows', + packageName: 'ReactNativeNitroExample', +}; + +const init = () => ({ signal: new AbortController().signal }); + +afterEach(() => { + vi.clearAllMocks(); +}); + +const loadRunner = async () => (await import('../runner.js')).default; + +describe('getWindowsRunner', () => { + it('is invoked with the (config, harnessConfig, init) shape the harness session uses', async () => { + // Same regression guard as the other platform runners: the session calls + // module.default(config, runtimeConfig, init); an optional `init` param + // would drop out of Function.length and silently break `init.signal`. + const getWindowsRunner = await loadRunner(); + expect(getWindowsRunner.length).toBe(3); + }); + + it('throws AppNotInstalledError when the package is not deployed', async () => { + pwsh.getPackageFamilyName.mockResolvedValue(null); + const getWindowsRunner = await loadRunner(); + + await expect( + getWindowsRunner(config, harnessConfig, init()) + ).rejects.toBeInstanceOf(AppNotInstalledError); + }); + + it('launches the resolved AUMID and tracks the process', async () => { + pwsh.getPackageFamilyName.mockResolvedValue('Contoso.Example_1a2b3c'); + pwsh.isProcessRunning.mockResolvedValue(true); + const getWindowsRunner = await loadRunner(); + + const runner = await getWindowsRunner(config, harnessConfig, init()); + const session = await runner.createAppSession(); + + // A stale instance is cleared before launch, then the app is shell-activated. + expect(pwsh.stopProcess).toHaveBeenCalledWith('ReactNativeNitroExample'); + expect(pwsh.launchAppByAumid).toHaveBeenCalledWith( + 'Contoso.Example_1a2b3c!App' + ); + expect((await session.getState()).status).toBe('running'); + + await session.dispose(); + expect((await session.getState()).status).toBe('disposed'); + }); + + it('honours a custom appId and processName', async () => { + pwsh.getPackageFamilyName.mockResolvedValue('Contoso.Example_1a2b3c'); + pwsh.isProcessRunning.mockResolvedValue(true); + const getWindowsRunner = await loadRunner(); + + const runner = await getWindowsRunner( + { ...config, appId: 'MyApp', processName: 'Example' }, + harnessConfig, + init() + ); + await runner.createAppSession(); + + expect(pwsh.launchAppByAumid).toHaveBeenCalledWith( + 'Contoso.Example_1a2b3c!MyApp' + ); + expect(pwsh.stopProcess).toHaveBeenCalledWith('Example'); + }); + + it('throws if the process never starts after launch', async () => { + vi.useFakeTimers(); + try { + pwsh.getPackageFamilyName.mockResolvedValue('Contoso.Example_1a2b3c'); + pwsh.isProcessRunning.mockResolvedValue(false); + const getWindowsRunner = await loadRunner(); + + const runner = await getWindowsRunner(config, harnessConfig, init()); + const pending = runner.createAppSession(); + // Flush the fixed number of start-poll delays without real waiting. + await vi.advanceTimersByTimeAsync(15 * 400); + + await expect(pending).rejects.toThrow(/did not start/); + } finally { + vi.useRealTimers(); + } + }); + + it('emits app_exited and reports the exited state when the process disappears', async () => { + pwsh.getPackageFamilyName.mockResolvedValue('Contoso.Example_1a2b3c'); + // Up for the startup check, gone by the first poll iteration (which runs + // before any delay), so this settles without waiting on the poll timer. + pwsh.isProcessRunning.mockResolvedValueOnce(true).mockResolvedValue(false); + const getWindowsRunner = await loadRunner(); + + const runner = await getWindowsRunner(config, harnessConfig, init()); + const session = await runner.createAppSession(); + + await vi.waitFor(async () => + expect((await session.getState()).status).toBe('exited') + ); + }); + + it('does not stop the app when the init signal aborts after session creation', async () => { + pwsh.getPackageFamilyName.mockResolvedValue('Contoso.Example_1a2b3c'); + pwsh.isProcessRunning.mockResolvedValue(true); + const getWindowsRunner = await loadRunner(); + + const controller = new AbortController(); + const runner = await getWindowsRunner(config, harnessConfig, { + signal: controller.signal, + }); + await runner.createAppSession(); + pwsh.stopProcess.mockClear(); + + controller.abort(); + + expect(pwsh.stopProcess).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/platform-windows/src/config.ts b/packages/platform-windows/src/config.ts new file mode 100644 index 00000000..4133f612 --- /dev/null +++ b/packages/platform-windows/src/config.ts @@ -0,0 +1,36 @@ +import { z } from 'zod'; + +export const WindowsAppLaunchOptionsSchema = z.object({}); + +export const WindowsPlatformConfigSchema = z.object({ + name: z.string().min(1, 'Name is required'), + /** + * The app's `Identity/@Name` from `Package.appxmanifest` — e.g. + * `ReactNativeNitroExample`. Used to look the deployed package up with + * `Get-AppxPackage`. + */ + packageName: z.string().min(1, 'packageName is required'), + /** + * The app's `Application/@Id` from `Package.appxmanifest`. Combined with the + * package family name into the AUMID used to launch the app. Defaults to + * `App`, which is what the React Native Windows template generates. + */ + appId: z.string().min(1).optional().default('App'), + /** + * The name of the app's process (without `.exe`), for tracking whether it is + * still running. Defaults to `packageName`, which is correct for the RNW + * template. + */ + processName: z.string().min(1).optional(), + appLaunchOptions: WindowsAppLaunchOptionsSchema.optional(), +}); + +export type WindowsAppLaunchOptions = z.infer< + typeof WindowsAppLaunchOptionsSchema +>; +export type WindowsPlatformConfig = z.infer; + +/** The `WindowsPlatformConfig` before Zod applies defaults (e.g. `appId`). */ +export type WindowsPlatformConfigInput = z.input< + typeof WindowsPlatformConfigSchema +>; diff --git a/packages/platform-windows/src/factory.ts b/packages/platform-windows/src/factory.ts new file mode 100644 index 00000000..fdb453a8 --- /dev/null +++ b/packages/platform-windows/src/factory.ts @@ -0,0 +1,12 @@ +import { HarnessPlatform } from '@react-native-harness/platforms'; +import type { WindowsPlatformConfigInput } from './config.js'; + +export const windowsPlatform = ( + config: WindowsPlatformConfigInput +): HarnessPlatform => ({ + name: config.name, + config, + runner: import.meta.resolve('./runner.js'), + platformId: 'windows', + getResourceLockKey: () => `windows:${config.packageName}`, +}); diff --git a/packages/platform-windows/src/index.ts b/packages/platform-windows/src/index.ts new file mode 100644 index 00000000..42460fd7 --- /dev/null +++ b/packages/platform-windows/src/index.ts @@ -0,0 +1,6 @@ +export { windowsPlatform } from './factory.js'; +export type { + WindowsPlatformConfig, + WindowsPlatformConfigInput, + WindowsAppLaunchOptions, +} from './config.js'; diff --git a/packages/platform-windows/src/pwsh.ts b/packages/platform-windows/src/pwsh.ts new file mode 100644 index 00000000..ff0e0d3b --- /dev/null +++ b/packages/platform-windows/src/pwsh.ts @@ -0,0 +1,73 @@ +import { spawn } from '@react-native-harness/tools'; + +/** + * Runs a PowerShell snippet non-interactively and returns its trimmed stdout. + * `-NoProfile` keeps it fast and hermetic; `-NonInteractive` makes sure it + * never blocks on a prompt. + */ +export const runPowerShell = async (script: string): Promise => { + const { stdout } = await spawn( + 'powershell', + ['-NoProfile', '-NonInteractive', '-Command', script], + { windowsHide: true } + ); + return stdout.trim(); +}; + +/** + * Resolves the PackageFamilyName of a deployed MSIX package from its identity + * name (`Package.appxmanifest` `Identity/@Name`). Returns `null` when the app + * is not deployed. + */ +export const getPackageFamilyName = async ( + identityName: string +): Promise => { + const pfn = await runPowerShell( + `(Get-AppxPackage -Name '${identityName}' | Select-Object -First 1).PackageFamilyName` + ); + return pfn === '' ? null : pfn; +}; + +/** Whether at least one process with the given name (no `.exe`) is running. */ +export const isProcessRunning = async ( + processName: string +): Promise => { + try { + const count = await runPowerShell( + `(Get-Process -Name '${processName}' -ErrorAction SilentlyContinue | Measure-Object).Count` + ); + return Number(count) > 0; + } catch { + return false; + } +}; + +/** + * Launches a deployed MSIX app by its AUMID + * (`!`). + * + * `explorer.exe shell:AppsFolder\` is the reliable activation path, but + * `explorer.exe` almost always exits non-zero even on success, so its failure + * is swallowed — the caller confirms the app came up by polling for its + * process. + */ +export const launchAppByAumid = async (aumid: string): Promise => { + try { + await spawn('explorer.exe', [`shell:AppsFolder\\${aumid}`], { + windowsHide: true, + }); + } catch { + // Expected: explorer.exe reports a non-zero exit even on success. + } +}; + +/** Force-terminates every process with the given name. Safe to call when none exist. */ +export const stopProcess = async (processName: string): Promise => { + try { + await runPowerShell( + `Get-Process -Name '${processName}' -ErrorAction SilentlyContinue | Stop-Process -Force` + ); + } catch { + // Nothing to stop, or it exited between the query and the kill. + } +}; diff --git a/packages/platform-windows/src/runner.ts b/packages/platform-windows/src/runner.ts new file mode 100644 index 00000000..fac31ec4 --- /dev/null +++ b/packages/platform-windows/src/runner.ts @@ -0,0 +1,173 @@ +import { + createAppSessionEmitter, + type AppSession, + type AppSessionState, + AppNotInstalledError, + type HarnessPlatformRunnerFactory, +} from '@react-native-harness/platforms'; +import type { Config as HarnessConfig } from '@react-native-harness/config'; +import { logger } from '@react-native-harness/tools'; +import { + WindowsPlatformConfigSchema, + type WindowsPlatformConfigInput, +} from './config.js'; +import { + getPackageFamilyName, + isProcessRunning, + launchAppByAumid, + stopProcess, +} from './pwsh.js'; + +const log = logger.child('platform-windows'); + +const APP_EXIT_POLL_INTERVAL_MS = 1000; +const APP_START_POLL_INTERVAL_MS = 400; +const APP_START_POLL_ATTEMPTS = 15; + +const delay = (ms: number, signal: AbortSignal) => + new Promise((resolve, reject) => { + if (signal.aborted) { + reject(signal.reason); + return; + } + const timer = setTimeout(() => { + signal.removeEventListener('abort', onAbort); + resolve(); + }, ms); + const onAbort = () => { + clearTimeout(timer); + reject(signal.reason); + }; + signal.addEventListener('abort', onAbort, { once: true }); + }); + +const getWindowsRunner: HarnessPlatformRunnerFactory< + WindowsPlatformConfigInput, + HarnessConfig +> = async (config, _harnessConfig, init) => { + void _harnessConfig; + + const parsedConfig = WindowsPlatformConfigSchema.parse(config); + const { packageName, appId } = parsedConfig; + const processName = parsedConfig.processName ?? packageName; + + const packageFamilyName = await getPackageFamilyName(packageName); + + if (packageFamilyName == null) { + throw new AppNotInstalledError(packageName, 'this machine'); + } + + const aumid = `${packageFamilyName}!${appId}`; + log.debug('resolved AUMID %s for package %s', aumid, packageName); + + return { + createAppSession: async (): Promise => { + // Clean slate: never attach to an instance left over from a previous run. + await stopProcess(processName); + await launchAppByAumid(aumid); + + // `explorer.exe` returns before the app is up (and lies about its exit + // code), so confirm the process actually started. `init.signal` cancels + // this finite readiness wait; it is not a disposal signal. + let started = false; + for (let attempt = 0; attempt < APP_START_POLL_ATTEMPTS; attempt += 1) { + if (await isProcessRunning(processName)) { + started = true; + break; + } + await delay(APP_START_POLL_INTERVAL_MS, init.signal); + } + + if (!started) { + await stopProcess(processName); + throw new Error( + `The Windows app '${processName}' did not start after launching ${aumid}. ` + + `Deploy it first, e.g. \`npx react-native run-windows --arch x64 --no-launch\`.` + ); + } + + const emitter = createAppSessionEmitter(); + let state: AppSessionState = { status: 'running' }; + let disposed = false; + let stopPolling = false; + let pollDelayTimeout: ReturnType | null = null; + let resolvePollDelay: (() => void) | null = null; + + // Unlike a raced delay() loser (which is fine to just discard), this + // wait is directly `await`ed by pollTask with nothing else racing it, + // so cancelling it must also resolve the promise immediately — + // otherwise dispose() blocks on `await pollTask` for up to + // APP_EXIT_POLL_INTERVAL_MS instead of returning right away. + const waitForNextPoll = () => + new Promise((resolve) => { + resolvePollDelay = () => { + resolvePollDelay = null; + pollDelayTimeout = null; + resolve(); + }; + + pollDelayTimeout = setTimeout(() => { + resolvePollDelay?.(); + }, APP_EXIT_POLL_INTERVAL_MS); + }); + + const cancelPendingPollDelay = () => { + if (pollDelayTimeout) { + clearTimeout(pollDelayTimeout); + pollDelayTimeout = null; + } + + resolvePollDelay?.(); + }; + + const pollTask = (async () => { + while (!stopPolling) { + if (!(await isProcessRunning(processName))) { + if (!disposed && state.status === 'running') { + state = { + status: 'exited', + occurredAt: Date.now(), + reason: 'process-gone', + }; + emitter.emit({ type: 'app_exited' }); + } + return; + } + + if (stopPolling) { + return; + } + + await waitForNextPoll(); + } + })(); + + const session: AppSession = { + dispose: async () => { + if (disposed) { + return; + } + + disposed = true; + stopPolling = true; + cancelPendingPollDelay(); + state = { status: 'disposed', occurredAt: Date.now() }; + emitter.clear(); + await stopProcess(processName); + await pollTask; + }, + getState: async () => state, + getLogs: () => [], + addListener: emitter.addListener, + removeListener: emitter.removeListener, + }; + + return session; + }, + dispose: async () => { + await stopProcess(processName); + }, + }; +}; + +export default getWindowsRunner; diff --git a/packages/platform-windows/tsconfig.json b/packages/platform-windows/tsconfig.json new file mode 100644 index 00000000..0faf2791 --- /dev/null +++ b/packages/platform-windows/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": [], + "references": [ + { + "path": "../tools" + }, + { + "path": "../platforms" + }, + { + "path": "../config" + }, + { + "path": "./tsconfig.lib.json" + } + ] +} diff --git a/packages/platform-windows/tsconfig.lib.json b/packages/platform-windows/tsconfig.lib.json new file mode 100644 index 00000000..1267bfb9 --- /dev/null +++ b/packages/platform-windows/tsconfig.lib.json @@ -0,0 +1,24 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "baseUrl": ".", + "rootDir": "src", + "outDir": "dist", + "tsBuildInfoFile": "dist/tsconfig.lib.tsbuildinfo", + "emitDeclarationOnly": false, + "forceConsistentCasingInFileNames": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "references": [ + { + "path": "../tools/tsconfig.lib.json" + }, + { + "path": "../platforms/tsconfig.lib.json" + }, + { + "path": "../config/tsconfig.lib.json" + } + ] +} diff --git a/packages/platform-windows/vite.config.ts b/packages/platform-windows/vite.config.ts new file mode 100644 index 00000000..ba257310 --- /dev/null +++ b/packages/platform-windows/vite.config.ts @@ -0,0 +1,18 @@ +/// +import { defineConfig } from 'vite'; + +export default defineConfig(() => ({ + root: __dirname, + cacheDir: '../../node_modules/.vite/packages/platform-windows', + test: { + watch: false, + globals: true, + environment: 'node', + include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], + reporters: ['default'], + coverage: { + reportsDirectory: './test-output/vitest/coverage', + provider: 'v8' as const, + }, + }, +})); diff --git a/packages/platforms/src/index.ts b/packages/platforms/src/index.ts index c5561b7e..f15048bc 100644 --- a/packages/platforms/src/index.ts +++ b/packages/platforms/src/index.ts @@ -24,6 +24,7 @@ export type { RunTarget, VegaAppLaunchOptions, WebAppLaunchOptions, + WindowsAppLaunchOptions, } from './types.js'; export { createAppSessionEmitter, diff --git a/packages/platforms/src/types.ts b/packages/platforms/src/types.ts index 85006436..48f4760c 100644 --- a/packages/platforms/src/types.ts +++ b/packages/platforms/src/types.ts @@ -105,11 +105,14 @@ export type WebAppLaunchOptions = Record; export type VegaAppLaunchOptions = Record; +export type WindowsAppLaunchOptions = Record; + export type AppLaunchOptions = | AndroidAppLaunchOptions | AppleAppLaunchOptions | WebAppLaunchOptions - | VegaAppLaunchOptions; + | VegaAppLaunchOptions + | WindowsAppLaunchOptions; export type CollectNativeCoverageOptions = { pods: string[]; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5ae9b49d..2e7f6fac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -526,6 +526,24 @@ importers: specifier: ^3.25.67 version: 3.25.67 + packages/platform-windows: + dependencies: + '@react-native-harness/config': + specifier: workspace:* + version: link:../config + '@react-native-harness/platforms': + specifier: workspace:* + version: link:../platforms + '@react-native-harness/tools': + specifier: workspace:* + version: link:../tools + tslib: + specifier: ^2.3.0 + version: 2.8.1 + zod: + specifier: ^3.25.67 + version: 3.25.67 + packages/platforms: dependencies: '@react-native-harness/tools': @@ -4184,6 +4202,7 @@ packages: cron-parser@4.9.0: resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==} engines: {node: '>=12.0.0'} + deprecated: v4 is no longer maintained, upgrade to v5 cross-fetch@3.2.0: resolution: {integrity: sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==} @@ -4729,6 +4748,7 @@ packages: eslint@9.29.0: resolution: {integrity: sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true peerDependencies: jiti: '*' diff --git a/tsconfig.json b/tsconfig.json index acde8899..754fa33c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -66,6 +66,9 @@ { "path": "./packages/coverage-ios" }, + { + "path": "./packages/platform-windows" + }, { "path": "./website" } From 8cf9e09c8516299ed637f44333b62972f03568ca Mon Sep 17 00:00:00 2001 From: Stanislav Doskalenko Date: Fri, 28 Aug 2026 19:18:19 -0700 Subject: [PATCH 4/8] fix(github-action): make the composite action work on Windows runners (#4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `${{ github.action_path }}` is a native path, so on a Windows runner it is `D:\a\_actions\...`. Passed unquoted to `node` inside a `shell: bash` step, bash eats the backslashes (`D:\a\_actions` -> `D:a_actions`) and the helper scripts fail to load — the action is unusable on Windows. Quote every `${{ github.action_path }}` interpolation. Also: - exempt the `windows` platform from the "app input required" check, like web — the harness launches an already-deployed package by identity; - derive `HARNESS_PROJECT_ROOT` with `pwd -W` so hook subprocesses get a native `D:/...` path rather than an unusable `/d/...` msys path; - regenerate the bundled `actions/shared/*.cjs`, which picks up the earlier `pathToFileURL` config-reader fix (#1) that the Windows load-config step needs. The deprecated per-platform sub-actions get the same quoting fix. Co-authored-by: Claude Sonnet 5 --- action.yml | 28 +++++++++++++------ actions/android/action.yml | 2 +- actions/ios/action.yml | 2 +- actions/shared/index.cjs | 3 +- actions/shared/plan-restore.cjs | 3 +- actions/shared/plan-save.cjs | 3 +- actions/shared/snapshot-metro.cjs | 3 +- actions/web/action.yml | 2 +- packages/github-action/src/action.yml | 28 +++++++++++++------ packages/github-action/src/android/action.yml | 2 +- packages/github-action/src/ios/action.yml | 2 +- packages/github-action/src/web/action.yml | 2 +- 12 files changed, 54 insertions(+), 26 deletions(-) diff --git a/action.yml b/action.yml index 0a6fe34b..e9c1672f 100644 --- a/action.yml +++ b/action.yml @@ -1,12 +1,15 @@ name: React Native Harness -description: Run React Native Harness tests on iOS, Android or Web +description: Run React Native Harness tests on iOS, Android, Web or Windows inputs: runner: description: The runner to use (must match a runner name defined in your harness config) required: true type: string app: - description: The path to the app (.app for iOS, .apk for Android). Not required for web. + description: >- + The path to the built app (.app for iOS, .apk for Android). Not required + for web, or for Windows (deploy the app with `react-native run-windows` + before this action runs). required: false type: string projectRoot: @@ -59,9 +62,11 @@ runs: INPUT_PROJECTROOT: ${{ inputs.projectRoot }} HARNESS_AVD_CACHING: ${{ inputs.cacheAvd }} run: | - node ${{ github.action_path }}/actions/shared/index.cjs + node "${{ github.action_path }}/actions/shared/index.cjs" - name: Verify native app input - if: fromJson(steps.load-config.outputs.config).platformId != 'web' + # Windows, like web, takes no `app` path: the harness launches an + # already-deployed MSIX package by its identity. + if: ${{ fromJson(steps.load-config.outputs.config).platformId != 'web' && fromJson(steps.load-config.outputs.config).platformId != 'windows' }} shell: bash run: | if [ -z "${{ inputs.app }}" ]; then @@ -75,7 +80,7 @@ runs: env: INPUT_PROJECTROOT: ${{ steps.load-config.outputs.projectRoot }} run: | - node ${{ github.action_path }}/actions/shared/plan-restore.cjs + node "${{ github.action_path }}/actions/shared/plan-restore.cjs" - name: Restore Metro cache (.harness/cache/metro) id: restore-metro uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 @@ -101,7 +106,7 @@ runs: # the Metro cache was actually restored and from which key. METRO_RESTORED_KEY: ${{ steps.restore-metro.outputs.cache-matched-key }} run: | - node ${{ github.action_path }}/actions/shared/snapshot-metro.cjs + node "${{ github.action_path }}/actions/shared/snapshot-metro.cjs" - name: Restore Harness cache id: cache-harness-restore if: fromJson(steps.load-config.outputs.config).platformId == 'ios' @@ -176,6 +181,10 @@ runs: if: fromJson(steps.load-config.outputs.config).platformId == 'web' shell: bash run: npx playwright install --with-deps chromium + # ── Windows ────────────────────────────────────────────────────────────── + # Nothing to set up here: run the workflow on a `windows-*` runner and + # deploy the app with `react-native run-windows --no-launch` in an earlier + # step. The harness launches the deployed package and tracks its process. # ── Shared ─────────────────────────────────────────────────────────────── - name: Detect Package Manager @@ -238,7 +247,10 @@ runs: HARNESS_APP_PATH: ${{ inputs.app }} HARNESS_AVD_CACHING: ${{ inputs.cacheAvd }} run: | - export HARNESS_PROJECT_ROOT="$PWD" + # `pwd -W` prints the native Windows path under Git Bash, so child + # processes get `D:/...` rather than an unusable `/d/...` msys path; + # it fails on Linux/macOS, where plain `pwd` is already correct. + export HARNESS_PROJECT_ROOT="$(pwd -W 2>/dev/null || pwd)" set +e ${{ steps.detect-pm.outputs.runner }}react-native-harness --harnessRunner ${{ inputs.runner }} ${{ inputs.harnessArgs }} @@ -283,7 +295,7 @@ runs: INPUT_CACHESAVEPOLICY: ${{ inputs.cacheSavePolicy }} IS_DEFAULT_BRANCH: ${{ github.ref_name == github.event.repository.default_branch }} run: | - node ${{ github.action_path }}/actions/shared/plan-save.cjs + node "${{ github.action_path }}/actions/shared/plan-save.cjs" - name: Save Metro cache if: always() && steps.plan-metro-save.outputs.metroShouldSave == 'true' uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 diff --git a/actions/android/action.yml b/actions/android/action.yml index b650943f..88d34313 100644 --- a/actions/android/action.yml +++ b/actions/android/action.yml @@ -48,7 +48,7 @@ runs: INPUT_PROJECTROOT: ${{ inputs.projectRoot }} HARNESS_AVD_CACHING: ${{ inputs.cacheAvd }} run: | - node ${{ github.action_path }}/../shared/index.cjs + node "${{ github.action_path }}/../shared/index.cjs" - name: Verify Android config if: ${{ fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' }} shell: bash diff --git a/actions/ios/action.yml b/actions/ios/action.yml index 79772e43..681e045b 100644 --- a/actions/ios/action.yml +++ b/actions/ios/action.yml @@ -42,7 +42,7 @@ runs: INPUT_RUNNER: ${{ inputs.runner }} INPUT_PROJECTROOT: ${{ inputs.projectRoot }} run: | - node ${{ github.action_path }}/../shared/index.cjs + node "${{ github.action_path }}/../shared/index.cjs" - name: Detect Package Manager id: detect-pm shell: bash diff --git a/actions/shared/index.cjs b/actions/shared/index.cjs index cee93346..2674195c 100644 --- a/actions/shared/index.cjs +++ b/actions/shared/index.cjs @@ -4565,6 +4565,7 @@ var ConfigLoadError = class extends HarnessError { // ../config/dist/reader.js var import_node_path7 = __toESM(require("path"), 1); var import_node_fs6 = __toESM(require("fs"), 1); +var import_node_url = require("url"); var import_node_module2 = require("module"); var import_meta = {}; var extensions = [".js", ".mjs", ".cjs", ".json"]; @@ -4576,7 +4577,7 @@ var importUp = async (dir, name) => { let rawConfig; try { if (ext === ".mjs") { - rawConfig = await import(filePathWithExt).then((module2) => module2.default); + rawConfig = await import((0, import_node_url.pathToFileURL)(filePathWithExt).href).then((module2) => module2.default); } else { const require2 = (0, import_node_module2.createRequire)(import_meta.url); rawConfig = require2(filePathWithExt); diff --git a/actions/shared/plan-restore.cjs b/actions/shared/plan-restore.cjs index 3525d74c..aa8af210 100644 --- a/actions/shared/plan-restore.cjs +++ b/actions/shared/plan-restore.cjs @@ -4899,6 +4899,7 @@ var ConfigLoadError = class extends HarnessError { // ../config/dist/reader.js var import_node_path10 = __toESM(require("path"), 1); var import_node_fs10 = __toESM(require("fs"), 1); +var import_node_url = require("url"); var import_node_module2 = require("module"); var import_meta = {}; var extensions = [".js", ".mjs", ".cjs", ".json"]; @@ -4910,7 +4911,7 @@ var importUp = async (dir, name) => { let rawConfig; try { if (ext === ".mjs") { - rawConfig = await import(filePathWithExt).then((module2) => module2.default); + rawConfig = await import((0, import_node_url.pathToFileURL)(filePathWithExt).href).then((module2) => module2.default); } else { const require2 = (0, import_node_module2.createRequire)(import_meta.url); rawConfig = require2(filePathWithExt); diff --git a/actions/shared/plan-save.cjs b/actions/shared/plan-save.cjs index 5e69267e..7b376604 100644 --- a/actions/shared/plan-save.cjs +++ b/actions/shared/plan-save.cjs @@ -4899,6 +4899,7 @@ var ConfigLoadError = class extends HarnessError { // ../config/dist/reader.js var import_node_path10 = __toESM(require("path"), 1); var import_node_fs10 = __toESM(require("fs"), 1); +var import_node_url = require("url"); var import_node_module2 = require("module"); var import_meta = {}; var extensions = [".js", ".mjs", ".cjs", ".json"]; @@ -4910,7 +4911,7 @@ var importUp = async (dir, name) => { let rawConfig; try { if (ext === ".mjs") { - rawConfig = await import(filePathWithExt).then((module2) => module2.default); + rawConfig = await import((0, import_node_url.pathToFileURL)(filePathWithExt).href).then((module2) => module2.default); } else { const require2 = (0, import_node_module2.createRequire)(import_meta.url); rawConfig = require2(filePathWithExt); diff --git a/actions/shared/snapshot-metro.cjs b/actions/shared/snapshot-metro.cjs index c39358e8..8ea2c080 100644 --- a/actions/shared/snapshot-metro.cjs +++ b/actions/shared/snapshot-metro.cjs @@ -4822,6 +4822,7 @@ var ConfigLoadError = class extends HarnessError { // ../config/dist/reader.js var import_node_path10 = __toESM(require("path"), 1); var import_node_fs10 = __toESM(require("fs"), 1); +var import_node_url = require("url"); var import_node_module2 = require("module"); var import_meta = {}; var extensions = [".js", ".mjs", ".cjs", ".json"]; @@ -4833,7 +4834,7 @@ var importUp = async (dir, name) => { let rawConfig; try { if (ext === ".mjs") { - rawConfig = await import(filePathWithExt).then((module2) => module2.default); + rawConfig = await import((0, import_node_url.pathToFileURL)(filePathWithExt).href).then((module2) => module2.default); } else { const require2 = (0, import_node_module2.createRequire)(import_meta.url); rawConfig = require2(filePathWithExt); diff --git a/actions/web/action.yml b/actions/web/action.yml index ff593a4f..bb20d7fd 100644 --- a/actions/web/action.yml +++ b/actions/web/action.yml @@ -39,7 +39,7 @@ runs: INPUT_RUNNER: ${{ inputs.runner }} INPUT_PROJECTROOT: ${{ inputs.projectRoot }} run: | - node ${{ github.action_path }}/../shared/index.cjs + node "${{ github.action_path }}/../shared/index.cjs" - name: Install Playwright Browsers shell: bash run: npx playwright install --with-deps chromium diff --git a/packages/github-action/src/action.yml b/packages/github-action/src/action.yml index 0a6fe34b..e9c1672f 100644 --- a/packages/github-action/src/action.yml +++ b/packages/github-action/src/action.yml @@ -1,12 +1,15 @@ name: React Native Harness -description: Run React Native Harness tests on iOS, Android or Web +description: Run React Native Harness tests on iOS, Android, Web or Windows inputs: runner: description: The runner to use (must match a runner name defined in your harness config) required: true type: string app: - description: The path to the app (.app for iOS, .apk for Android). Not required for web. + description: >- + The path to the built app (.app for iOS, .apk for Android). Not required + for web, or for Windows (deploy the app with `react-native run-windows` + before this action runs). required: false type: string projectRoot: @@ -59,9 +62,11 @@ runs: INPUT_PROJECTROOT: ${{ inputs.projectRoot }} HARNESS_AVD_CACHING: ${{ inputs.cacheAvd }} run: | - node ${{ github.action_path }}/actions/shared/index.cjs + node "${{ github.action_path }}/actions/shared/index.cjs" - name: Verify native app input - if: fromJson(steps.load-config.outputs.config).platformId != 'web' + # Windows, like web, takes no `app` path: the harness launches an + # already-deployed MSIX package by its identity. + if: ${{ fromJson(steps.load-config.outputs.config).platformId != 'web' && fromJson(steps.load-config.outputs.config).platformId != 'windows' }} shell: bash run: | if [ -z "${{ inputs.app }}" ]; then @@ -75,7 +80,7 @@ runs: env: INPUT_PROJECTROOT: ${{ steps.load-config.outputs.projectRoot }} run: | - node ${{ github.action_path }}/actions/shared/plan-restore.cjs + node "${{ github.action_path }}/actions/shared/plan-restore.cjs" - name: Restore Metro cache (.harness/cache/metro) id: restore-metro uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 @@ -101,7 +106,7 @@ runs: # the Metro cache was actually restored and from which key. METRO_RESTORED_KEY: ${{ steps.restore-metro.outputs.cache-matched-key }} run: | - node ${{ github.action_path }}/actions/shared/snapshot-metro.cjs + node "${{ github.action_path }}/actions/shared/snapshot-metro.cjs" - name: Restore Harness cache id: cache-harness-restore if: fromJson(steps.load-config.outputs.config).platformId == 'ios' @@ -176,6 +181,10 @@ runs: if: fromJson(steps.load-config.outputs.config).platformId == 'web' shell: bash run: npx playwright install --with-deps chromium + # ── Windows ────────────────────────────────────────────────────────────── + # Nothing to set up here: run the workflow on a `windows-*` runner and + # deploy the app with `react-native run-windows --no-launch` in an earlier + # step. The harness launches the deployed package and tracks its process. # ── Shared ─────────────────────────────────────────────────────────────── - name: Detect Package Manager @@ -238,7 +247,10 @@ runs: HARNESS_APP_PATH: ${{ inputs.app }} HARNESS_AVD_CACHING: ${{ inputs.cacheAvd }} run: | - export HARNESS_PROJECT_ROOT="$PWD" + # `pwd -W` prints the native Windows path under Git Bash, so child + # processes get `D:/...` rather than an unusable `/d/...` msys path; + # it fails on Linux/macOS, where plain `pwd` is already correct. + export HARNESS_PROJECT_ROOT="$(pwd -W 2>/dev/null || pwd)" set +e ${{ steps.detect-pm.outputs.runner }}react-native-harness --harnessRunner ${{ inputs.runner }} ${{ inputs.harnessArgs }} @@ -283,7 +295,7 @@ runs: INPUT_CACHESAVEPOLICY: ${{ inputs.cacheSavePolicy }} IS_DEFAULT_BRANCH: ${{ github.ref_name == github.event.repository.default_branch }} run: | - node ${{ github.action_path }}/actions/shared/plan-save.cjs + node "${{ github.action_path }}/actions/shared/plan-save.cjs" - name: Save Metro cache if: always() && steps.plan-metro-save.outputs.metroShouldSave == 'true' uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 diff --git a/packages/github-action/src/android/action.yml b/packages/github-action/src/android/action.yml index b650943f..88d34313 100644 --- a/packages/github-action/src/android/action.yml +++ b/packages/github-action/src/android/action.yml @@ -48,7 +48,7 @@ runs: INPUT_PROJECTROOT: ${{ inputs.projectRoot }} HARNESS_AVD_CACHING: ${{ inputs.cacheAvd }} run: | - node ${{ github.action_path }}/../shared/index.cjs + node "${{ github.action_path }}/../shared/index.cjs" - name: Verify Android config if: ${{ fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' }} shell: bash diff --git a/packages/github-action/src/ios/action.yml b/packages/github-action/src/ios/action.yml index 79772e43..681e045b 100644 --- a/packages/github-action/src/ios/action.yml +++ b/packages/github-action/src/ios/action.yml @@ -42,7 +42,7 @@ runs: INPUT_RUNNER: ${{ inputs.runner }} INPUT_PROJECTROOT: ${{ inputs.projectRoot }} run: | - node ${{ github.action_path }}/../shared/index.cjs + node "${{ github.action_path }}/../shared/index.cjs" - name: Detect Package Manager id: detect-pm shell: bash diff --git a/packages/github-action/src/web/action.yml b/packages/github-action/src/web/action.yml index ff593a4f..bb20d7fd 100644 --- a/packages/github-action/src/web/action.yml +++ b/packages/github-action/src/web/action.yml @@ -39,7 +39,7 @@ runs: INPUT_RUNNER: ${{ inputs.runner }} INPUT_PROJECTROOT: ${{ inputs.projectRoot }} run: | - node ${{ github.action_path }}/../shared/index.cjs + node "${{ github.action_path }}/../shared/index.cjs" - name: Install Playwright Browsers shell: bash run: npx playwright install --with-deps chromium From 11cf1ac229c048c95966644b684480534c85d57a Mon Sep 17 00:00:00 2001 From: Stanislav Doskalenko Date: Fri, 28 Aug 2026 19:58:05 -0700 Subject: [PATCH 5/8] docs: document the Windows platform (#5) Add a Windows platform guide covering `windowsPlatform()` config, the `react-native run-windows --no-launch` deploy step the runner expects, and where to find the package identity name. Add a "Windows in CI" section to the CI/CD guide with a `windows-latest` workflow example, and list Windows alongside the other platforms in the configuration guide. Co-authored-by: Claude Sonnet 5 --- .../docs/getting-started/configuration.mdx | 1 + website/src/docs/guides/ci-cd.md | 46 +++++++++- website/src/docs/platforms/_meta.json | 5 ++ website/src/docs/platforms/windows.mdx | 87 +++++++++++++++++++ 4 files changed, 138 insertions(+), 1 deletion(-) create mode 100644 website/src/docs/platforms/windows.mdx diff --git a/website/src/docs/getting-started/configuration.mdx b/website/src/docs/getting-started/configuration.mdx index 72449980..2a0d6f16 100644 --- a/website/src/docs/getting-started/configuration.mdx +++ b/website/src/docs/getting-started/configuration.mdx @@ -140,6 +140,7 @@ For detailed installation and configuration instructions, please refer to the pl - [**Android**](/docs/platforms/android) - [**iOS**](/docs/platforms/ios) - [**Web**](/docs/platforms/web) +- [**Windows**](/docs/platforms/windows) ## Default Runner diff --git a/website/src/docs/guides/ci-cd.md b/website/src/docs/guides/ci-cd.md index 982637e4..2e4014a0 100644 --- a/website/src/docs/guides/ci-cd.md +++ b/website/src/docs/guides/ci-cd.md @@ -36,7 +36,7 @@ The action reads your `rn-harness.config.mjs` file to determine the selected run The action accepts the following inputs: -- `app` (optional): Path to your built app (`.apk` for Android, `.app` for iOS). Not needed for web runners +- `app` (optional): Path to your built app (`.apk` for Android, `.app` for iOS). Not needed for web or Windows runners - `runner` (required): The runner name from your Harness config (for example `"android"`, `"ios"`, or `"chromium"`) - `projectRoot` (optional): The project root directory (defaults to the repository root) - `uploadVisualTestArtifacts` (optional): Whether to upload visual test diff and actual images as artifacts @@ -268,6 +268,50 @@ The official action supports web runners as well. At the moment, the action inst If your workflow depends on a different browser setup, make that expectation explicit in your CI configuration. +## Windows in CI + +The official action supports the `windows` runner. Run the job on a `windows-*` runner, deploy the app with `react-native run-windows` in an earlier step, then call the action with no `app` input — the Windows runner launches an already-deployed package by its identity (see the [Windows platform guide](/docs/platforms/windows)). + +```yaml +jobs: + test-windows: + name: Test Windows + runs-on: windows-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install pnpm + uses: pnpm/action-setup@v6 + with: + version: latest + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '24' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install + + - name: Add MSBuild to PATH + uses: microsoft/setup-msbuild@v2 + + - name: Build and deploy the Windows app + run: npx react-native run-windows --arch x64 --no-launch --no-packager --logging + + # Keep @v… in sync with the react-native-harness version in package.json + - name: Run React Native Harness + uses: callstackincubator/react-native-harness@v1.0.0 + with: + runner: windows + packageManager: pnpm +``` + +The Windows toolchain (Visual Studio with the C++ workload, the Windows SDK, NuGet) is preinstalled on GitHub's `windows-*` images. As with Android and iOS, you can cache the build output between runs so unchanged native modules don't trigger a rebuild. + ## Build Artifact Caching The workflow includes build artifact caching to significantly reduce CI execution times. When native modules haven't changed, you can reuse the same debug builds instead of rebuilding from scratch. diff --git a/website/src/docs/platforms/_meta.json b/website/src/docs/platforms/_meta.json index facb818e..ead4d63d 100644 --- a/website/src/docs/platforms/_meta.json +++ b/website/src/docs/platforms/_meta.json @@ -13,5 +13,10 @@ "type": "file", "name": "web", "label": "Web" + }, + { + "type": "file", + "name": "windows", + "label": "Windows" } ] diff --git a/website/src/docs/platforms/windows.mdx b/website/src/docs/platforms/windows.mdx new file mode 100644 index 00000000..4b694afd --- /dev/null +++ b/website/src/docs/platforms/windows.mdx @@ -0,0 +1,87 @@ +import { PackageManagerTabs } from '@theme'; + +# Windows + +React Native Harness runs tests against a deployed [React Native Windows](https://microsoft.github.io/react-native-windows/) app. + +## Overview + +Unlike Android and iOS, where Harness boots an emulator or simulator, the Windows runner works with an app you have **already deployed** to the machine. Harness resolves the app by its package identity, launches it, and tracks its process for the duration of the run. Build and deploy the app with `react-native run-windows` before invoking Harness. + +## Installation + + + +## Configuration + +Import the Windows platform helper in your `rn-harness.config.mjs`: + +```javascript +import { windowsPlatform } from '@react-native-harness/platform-windows'; + +export default { + entryPoint: './index.js', + appRegistryComponentName: 'MyApp', + runners: [ + windowsPlatform({ + name: 'windows', + // The Identity/@Name from windows//Package.appxmanifest + packageName: 'MyApp', + }), + ], +}; +``` + +### Options + +| Option | Type | Default | Description | +| :------------ | :------- | :------------ | :------------------------------------------------------------------------------------------------------------------------------------------ | +| `name` | `string` | – | Unique name for the runner. | +| `packageName` | `string` | – | The `Identity/@Name` from `Package.appxmanifest`. Harness looks the deployed package up with `Get-AppxPackage -Name `. | +| `appId` | `string` | `'App'` | The `Application/@Id` from `Package.appxmanifest`. Combined with the package family name into the AUMID used to activate the app. | +| `processName` | `string` | `packageName` | The app's process name (without `.exe`), used to detect whether it is still running. | + +### Finding the package identity + +Open `windows//Package.appxmanifest` and look at the `Identity` and `Application` elements: + +```xml + +... + + + +``` + +Here `packageName` is `MyApp` and `appId` is `App` (the default). + +## Deploying the app + +The Windows runner never builds — deploy the app first: + +```bash +npx react-native run-windows --arch x64 --no-launch --no-packager --logging +``` + +`--no-launch` registers the MSIX package without starting it (Harness starts it), and `--no-packager` keeps `run-windows` from starting its own Metro server on the port Harness wants. + +Then run the tests: + +```bash +npx react-native-harness --harnessRunner windows +``` + +## Metro configuration + +Harness applies the same out-of-tree platform wiring that `react-native start` does (the `react-native` → `react-native-windows` resolver redirect and the Windows `InitializeCore`), so a plain `metro.config.js` works — you do **not** need to add a `windows` case to `resolver.resolveRequest` or `serializer.getModulesRunBeforeMainModule` yourself. + +## Requirements + +- Windows 10 or 11. +- The React Native Windows toolchain (Visual Studio with the C++ workload, the Windows SDK) to build the app. +- The app deployed via `react-native run-windows` before the run. +- The harness Metro server reachable at the app's bundle URL (`http://localhost:8081` by default). An RNW **Debug** build points there out of the box. + +## CI + +The official GitHub Action supports the `windows` runner. Run the job on a `windows-*` runner, deploy the app in an earlier step, then invoke the action without an `app` input — see [Running in CI/CD](/docs/guides/ci-cd#windows-in-ci). From e47fc9acef8cd808c7250cbb2f8a6ada98930fc1 Mon Sep 17 00:00:00 2001 From: Stanislav Doskalenko Date: Fri, 28 Aug 2026 22:45:09 -0700 Subject: [PATCH 6/8] fix: make the test suite pass on a Windows host (#6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The suite assumed POSIX path separators in several places, so it failed when run from Windows (CI is Linux, so this never showed up there): - bundler-metro `paths.test.ts`: passed `/tmp/...`, which is not absolute on Windows, so `path.resolve` prepended the cwd drive; - bundler-metro `metro-block-list.test.ts`: asserted against forward-slash paths, but Metro's `exclusionList` rewrites its patterns to `path.sep`, so an inherited blockList only matches the host separator; - jest `execute-run.test.ts`: expected `../a.ts` for a span attribute that is `path.relative`-derived (`..\a.ts` on Windows); - cache `boundary.test.ts`: matched a `path.relative` result against a forward-slash allowlist entry. Also fixes two real issues surfaced along the way: - `resource-lock.ts`: a heartbeat refresh whose write throws (owner file racing a release, or a transient FS error such as EPERM on Windows when a directory is torn down) escaped the `setInterval` callback as an unhandled rejection. Swallow it — a missed refresh just lets the lock go stale and be reclaimed, which is the designed behavior. - `platform-windows` `runner.test.ts`: attach the rejection handler before advancing fake timers so the promise is never momentarily unhandled. Co-authored-by: Claude Sonnet 5 --- .../version-plan-1787973049799.md | 5 +++++ .../src/__tests__/metro-block-list.test.ts | 19 ++++++++++++++----- .../bundler-metro/src/__tests__/paths.test.ts | 5 ++++- packages/cache/src/__tests__/boundary.test.ts | 5 ++++- .../jest/src/__tests__/execute-run.test.ts | 7 ++++++- packages/jest/src/resource-lock.ts | 17 +++++++++++++++-- .../src/__tests__/runner.test.ts | 12 ++++++++---- 7 files changed, 56 insertions(+), 14 deletions(-) create mode 100644 .nx/version-plans/version-plan-1787973049799.md diff --git a/.nx/version-plans/version-plan-1787973049799.md b/.nx/version-plans/version-plan-1787973049799.md new file mode 100644 index 00000000..13d58fd0 --- /dev/null +++ b/.nx/version-plans/version-plan-1787973049799.md @@ -0,0 +1,5 @@ +--- +__default__: patch +--- + +A resource-lock heartbeat refresh that fails to write (for example the owner file racing a concurrent release, or a transient filesystem error) is now swallowed instead of surfacing as an unhandled rejection — the lock simply goes stale and is reclaimed, as it already would if the refresh were missed. diff --git a/packages/bundler-metro/src/__tests__/metro-block-list.test.ts b/packages/bundler-metro/src/__tests__/metro-block-list.test.ts index fe64a861..6c98f1cd 100644 --- a/packages/bundler-metro/src/__tests__/metro-block-list.test.ts +++ b/packages/bundler-metro/src/__tests__/metro-block-list.test.ts @@ -22,6 +22,11 @@ const withBlockList = ( const HARNESS_CACHE_ROOT = '/p/.harness/cache'; +// Metro's `exclusionList` rewrites `/` in its patterns to `path.sep`, so a +// blockList inherited from it only matches paths in the host OS's separator. +// The harness's own patterns match either separator; these need the switch. +const sys = (posixPath: string) => posixPath.split('/').join(path.sep); + const getBlockList = ( blockList: NonNullable['blockList'] ) => getHarnessBlockList(withBlockList(blockList), HARNESS_CACHE_ROOT); @@ -148,7 +153,9 @@ describe('getHarnessBlockList', () => { const { blockList, dropped } = getBlockList(exclusionList()); expect(dropped).toEqual([]); - expect(blockList.test('/p/src/__tests__/smoke.harness.ts')).toBe(false); + expect(blockList.test(sys('/p/src/__tests__/smoke.harness.ts'))).toBe( + false + ); }); it("keeps a project's own exclusions while still crawling tests", () => { @@ -159,9 +166,11 @@ describe('getHarnessBlockList', () => { ); expect(dropped).toEqual([]); - expect(blockList.test('/p/ios/build/Release/x.json')).toBe(true); - expect(blockList.test('/p/src/__tests__/smoke.harness.ts')).toBe(false); - expect(blockList.test(getHarnessManifestPath('/p'))).toBe(false); + expect(blockList.test(sys('/p/ios/build/Release/x.json'))).toBe(true); + expect(blockList.test(sys('/p/src/__tests__/smoke.harness.ts'))).toBe( + false + ); + expect(blockList.test(getHarnessManifestPath(sys('/p')))).toBe(false); }); it('keeps tests crawlable even inside an otherwise excluded directory', () => { @@ -187,7 +196,7 @@ describe('getHarnessBlockList', () => { '/p/vendor/lib.js', '/p/src/app.tsx', '/p/ios/build/__tests__/nested.harness.ts', - ]; + ].map(sys); for (const pattern of patterns) { const { blockList } = getBlockList(pattern); diff --git a/packages/bundler-metro/src/__tests__/paths.test.ts b/packages/bundler-metro/src/__tests__/paths.test.ts index 3e7539b4..69e8e456 100644 --- a/packages/bundler-metro/src/__tests__/paths.test.ts +++ b/packages/bundler-metro/src/__tests__/paths.test.ts @@ -4,7 +4,10 @@ import { getHarnessManifestPath, getHarnessRootPath } from '../paths.js'; describe('bundler metro paths', () => { it('resolves the harness root under the project root', () => { - const projectRoot = '/tmp/some-project'; + // An absolute path on the host OS -- `/tmp/...` is not absolute on + // Windows, so `path.resolve` would prepend the cwd drive and the + // assertions below would never match. + const projectRoot = path.resolve('some-project'); expect(getHarnessRootPath(projectRoot)).toBe( path.join(projectRoot, '.harness') diff --git a/packages/cache/src/__tests__/boundary.test.ts b/packages/cache/src/__tests__/boundary.test.ts index e8779b54..5398c8a6 100644 --- a/packages/cache/src/__tests__/boundary.test.ts +++ b/packages/cache/src/__tests__/boundary.test.ts @@ -79,7 +79,10 @@ describe('cache path boundary', () => { } for (const file of collectSourceFiles(srcDir)) { - const relativePath = path.relative(packagesRoot, file); + const relativePath = path + .relative(packagesRoot, file) + .split(path.sep) + .join('/'); if (ALLOWLIST.has(relativePath)) { continue; } diff --git a/packages/jest/src/__tests__/execute-run.test.ts b/packages/jest/src/__tests__/execute-run.test.ts index 12966f90..3c234f1d 100644 --- a/packages/jest/src/__tests__/execute-run.test.ts +++ b/packages/jest/src/__tests__/execute-run.test.ts @@ -1,3 +1,4 @@ +import path from 'node:path'; import { describe, expect, it, vi, beforeEach } from 'vitest'; import type { Config, Test, TestWatcher } from 'jest-runner'; import type { TestResult as JestTestResult } from '@jest/test-result'; @@ -221,7 +222,11 @@ describe('executeRun', () => { expect(runEntry).toMatchObject({ status: 'ok', attrs: { status: 'passed' } }); expect(runEntry?.attrs?.runId).toBeTypeOf('string'); - expect(fileEntry).toMatchObject({ status: 'ok', attrs: { file: '../a.ts', status: 'passed' } }); + expect(fileEntry).toMatchObject({ + status: 'ok', + // path.relative('/project', '/a.ts') -- OS-separated, so `..\a.ts` on Windows. + attrs: { file: path.join('..', 'a.ts'), status: 'passed' }, + }); expect(fileEntry?.attrs?.runId).toBeTypeOf('string'); expect(mockWriteTraceFile).toHaveBeenCalledWith(entries, expect.objectContaining({ runId: expect.any(String) })); }); diff --git a/packages/jest/src/resource-lock.ts b/packages/jest/src/resource-lock.ts index e94703ed..5798aee0 100644 --- a/packages/jest/src/resource-lock.ts +++ b/packages/jest/src/resource-lock.ts @@ -392,8 +392,21 @@ export const createResourceLockManager = ( return; } - await writeJsonFileAtomic(paths.ownerFilePath, nextMetadata); - scopedLogger.debug('refreshed heartbeat for ticket %s', ticketId); + try { + await writeJsonFileAtomic(paths.ownerFilePath, nextMetadata); + scopedLogger.debug('refreshed heartbeat for ticket %s', ticketId); + } catch (error) { + // A failed refresh is not fatal by design: the lock goes stale + // and another holder reclaims it. Swallow it so a transient + // write error (e.g. the owner file racing a concurrent release, + // or an EPERM on Windows when the directory is being torn down) + // never surfaces as an unhandled rejection from this interval. + scopedLogger.debug( + 'heartbeat refresh for ticket %s failed: %s', + ticketId, + error instanceof Error ? error.message : String(error), + ); + } } finally { heartbeatInFlight = false; } diff --git a/packages/platform-windows/src/__tests__/runner.test.ts b/packages/platform-windows/src/__tests__/runner.test.ts index 884a0a47..7ad6f998 100644 --- a/packages/platform-windows/src/__tests__/runner.test.ts +++ b/packages/platform-windows/src/__tests__/runner.test.ts @@ -93,11 +93,15 @@ describe('getWindowsRunner', () => { const getWindowsRunner = await loadRunner(); const runner = await getWindowsRunner(config, harnessConfig, init()); - const pending = runner.createAppSession(); - // Flush the fixed number of start-poll delays without real waiting. - await vi.advanceTimersByTimeAsync(15 * 400); - await expect(pending).rejects.toThrow(/did not start/); + // Attach the rejection handler before advancing timers so the promise is + // never momentarily unhandled, then flush the fixed number of start-poll + // delays without waiting in real time. + const assertion = expect(runner.createAppSession()).rejects.toThrow( + /did not start/ + ); + await vi.advanceTimersByTimeAsync(15 * 400); + await assertion; } finally { vi.useRealTimers(); } From c7ba139b286cab413dbac32abc97123f0c17bb56 Mon Sep 17 00:00:00 2001 From: Stanislav Doskalenko Date: Fri, 28 Aug 2026 22:45:36 -0700 Subject: [PATCH 7/8] fix(config): preserve a runner's getResourceLockKey (#7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `RunnerSchema` is a bare `z.object()`, which strips unknown keys, so the `getResourceLockKey` every platform factory sets never survived `ConfigSchema.parse`. The session's `platform.getResourceLockKey?.()` was therefore always undefined and every run fell back to `:` — serializing all runs of a platform even when they target different devices. Add the field to the schema (typed as `() => string | Promise`) so the platform-provided key is honored. Co-authored-by: Claude Sonnet 5 --- .../version-plan-1787973282091.md | 5 ++ .../src/__tests__/runner-schema.test.ts | 57 +++++++++++++++++++ packages/config/src/types.ts | 9 +++ 3 files changed, 71 insertions(+) create mode 100644 .nx/version-plans/version-plan-1787973282091.md create mode 100644 packages/config/src/__tests__/runner-schema.test.ts diff --git a/.nx/version-plans/version-plan-1787973282091.md b/.nx/version-plans/version-plan-1787973282091.md new file mode 100644 index 00000000..f024c3b8 --- /dev/null +++ b/.nx/version-plans/version-plan-1787973282091.md @@ -0,0 +1,5 @@ +--- +__default__: patch +--- + +The resource lock a platform runner defines via `getResourceLockKey` is now honored. Concurrent Harness runs that target the same platform but different devices — two iOS simulators, or an emulator and a physical device — no longer queue behind each other; only runs that share a device wait. Previously the key was silently dropped by config validation and every run of a platform serialized on `:`. diff --git a/packages/config/src/__tests__/runner-schema.test.ts b/packages/config/src/__tests__/runner-schema.test.ts new file mode 100644 index 00000000..98f0684d --- /dev/null +++ b/packages/config/src/__tests__/runner-schema.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest'; +import { ConfigSchema } from '../types.js'; + +const baseConfig = { + entryPoint: './index.js', + appRegistryComponentName: 'App', +}; + +const runner = { + name: 'ios', + config: {}, + runner: 'file:///runner.js', + platformId: 'ios', +}; + +describe('ConfigSchema runner', () => { + it('preserves a platform-provided getResourceLockKey', () => { + const getResourceLockKey = () => 'ios:iPhone 16 Pro:18.0'; + + const parsed = ConfigSchema.parse({ + ...baseConfig, + runners: [{ ...runner, getResourceLockKey }], + }); + + expect(parsed.runners[0]?.getResourceLockKey?.()).toBe( + 'ios:iPhone 16 Pro:18.0' + ); + }); + + it('accepts an async getResourceLockKey', async () => { + const parsed = ConfigSchema.parse({ + ...baseConfig, + runners: [ + { ...runner, getResourceLockKey: async () => 'android:Pixel_8' }, + ], + }); + + await expect(parsed.runners[0]?.getResourceLockKey?.()).resolves.toBe( + 'android:Pixel_8' + ); + }); + + it('is optional', () => { + const parsed = ConfigSchema.parse({ ...baseConfig, runners: [runner] }); + + expect(parsed.runners[0]?.getResourceLockKey).toBeUndefined(); + }); + + it('rejects a non-function getResourceLockKey', () => { + expect(() => + ConfigSchema.parse({ + ...baseConfig, + runners: [{ ...runner, getResourceLockKey: 'ios:lock' }], + }) + ).toThrow(); + }); +}); diff --git a/packages/config/src/types.ts b/packages/config/src/types.ts index 3a4eb1d0..980da6a9 100644 --- a/packages/config/src/types.ts +++ b/packages/config/src/types.ts @@ -19,6 +19,15 @@ const RunnerSchema = z.object({ runner: z.string(), cli: z.string().optional(), platformId: z.string(), + // Set by the platform factories (`HarnessPlatform.getResourceLockKey`) to + // scope the run's resource lock — e.g. per emulator/simulator/device rather + // than per platform. A bare `z.object()` strips unknown keys, so without + // this the harness always fell back to `${platformId}:${name}`. + getResourceLockKey: z + .function() + .args() + .returns(z.union([z.string(), z.promise(z.string())])) + .optional(), }); type AnyHarnessPlugin = HarnessPlugin; From 8e8163b3337f6a1966620205e60eb7af52a28552 Mon Sep 17 00:00:00 2001 From: Stanislav Doskalenko Date: Mon, 31 Aug 2026 10:58:12 -0700 Subject: [PATCH 8/8] fix(cli): emit the ci projectRoot output with forward slashes `harness ci load-config` writes `projectRoot=` to GITHUB_OUTPUT from a raw `path.relative`, which is `apps\foo` on a Windows runner. The action feeds that into `actions/cache` globs, `hashFiles()`, and a bash `working-directory`, all of which want `/`. Normalize the separator. Co-Authored-By: Claude Sonnet 5 --- packages/cli/src/ci/workspace-root.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/ci/workspace-root.ts b/packages/cli/src/ci/workspace-root.ts index 4e199c76..4c4ef94d 100644 --- a/packages/cli/src/ci/workspace-root.ts +++ b/packages/cli/src/ci/workspace-root.ts @@ -34,6 +34,11 @@ export const resolveProjectRoot = ( * GITHUB_OUTPUT is relative to the workspace root, matching what * downstream non-bash steps (actions/cache, actions/upload-artifact, * hashFiles(...)) resolve paths against. + * + * Emitted with forward slashes so the value is stable across runner OSes: + * `actions/cache` globs, `hashFiles()`, and a bash `working-directory` all + * accept `/` on Windows, whereas a raw `path.relative` result would be + * `apps\foo` there. */ export const relativeToWorkspaceRoot = (target: string): string => - path.relative(getWorkspaceRoot(), target) || '.'; + (path.relative(getWorkspaceRoot(), target) || '.').split(path.sep).join('/');