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/.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/.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/.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/.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/action.yml b/action.yml index 1f12e196..30c75df6 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: @@ -147,7 +150,9 @@ runs: run: | ${{ steps.detect-pm.outputs.runner }}react-native-harness ci load-config - 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 @@ -264,6 +269,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: Run E2E tests @@ -277,7 +286,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 }} 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/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-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__/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__/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/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, 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/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('/'); 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/__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/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/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; 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/.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..7ad6f998 --- /dev/null +++ b/packages/platform-windows/src/__tests__/runner.test.ts @@ -0,0 +1,141 @@ +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()); + + // 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(); + } + }); + + 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/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'); }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9912625e..ff01ffe2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -513,6 +513,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': @@ -4114,6 +4132,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==} @@ -4654,6 +4673,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 02cdd5eb..0d520766 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -63,6 +63,9 @@ { "path": "./packages/coverage-ios" }, + { + "path": "./packages/platform-windows" + }, { "path": "./website" } 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 f634f4ff..d89c6895 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).