Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .nx/version-plans/version-plan-1787953392799.md
Original file line number Diff line number Diff line change
@@ -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".
5 changes: 5 additions & 0 deletions .nx/version-plans/version-plan-1787960210915.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .nx/version-plans/version-plan-1787965767199.md
Original file line number Diff line number Diff line change
@@ -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`).
5 changes: 5 additions & 0 deletions .nx/version-plans/version-plan-1787973049799.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .nx/version-plans/version-plan-1787973282091.md
Original file line number Diff line number Diff line change
@@ -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 `<platformId>:<runnerName>`.
20 changes: 16 additions & 4 deletions action.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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 }}
Expand Down
2 changes: 1 addition & 1 deletion packages/bridge/src/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
6 changes: 6 additions & 0 deletions packages/bundler-metro/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "*",
Expand Down
19 changes: 14 additions & 5 deletions packages/bundler-metro/src/__tests__/metro-block-list.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<MetroConfig['resolver']>['blockList']
) => getHarnessBlockList(withBlockList(blockList), HARNESS_CACHE_ROOT);
Expand Down Expand Up @@ -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", () => {
Expand All @@ -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', () => {
Expand All @@ -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);
Expand Down
96 changes: 96 additions & 0 deletions packages/bundler-metro/src/__tests__/metro-platforms.test.ts
Original file line number Diff line number Diff line change
@@ -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'
);
});
});
5 changes: 4 additions & 1 deletion packages/bundler-metro/src/__tests__/paths.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
Loading