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
---

The `@react-native-harness/platform-windows` package now supplies its own Metro wiring through the `metroConfigEnhancer` hook: the `react-native` -> `react-native-windows` resolver redirect, the `windows` and `native` `resolver.platforms` entries, and React Native Windows' `InitializeCore`. A `windowsPlatform()` runner no longer needs any of this hand-added to `metro.config.js`, and `@react-native-harness/bundler-metro` no longer reads `@react-native-community/cli-config` to detect out-of-tree platforms. 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
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
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
2 changes: 2 additions & 0 deletions packages/bundler-metro/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
export { getMetroInstance } from './factory.js';
export type {
MetroConfigEnhancer,
MetroConfigEnhancerContext,
MetroInstance,
MetroFactory,
MetroOptions,
Expand Down
5 changes: 4 additions & 1 deletion packages/cache/src/__tests__/boundary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
7 changes: 6 additions & 1 deletion packages/cli/src/ci/workspace-root.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('/');
82 changes: 82 additions & 0 deletions packages/config/src/__tests__/reader.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
41 changes: 41 additions & 0 deletions packages/config/src/__tests__/runner-schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,47 @@ const runner = {
};

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();
});

it('preserves a platform-provided metroConfigEnhancer path', () => {
const parsed = ConfigSchema.parse({
...baseConfig,
Expand Down
7 changes: 6 additions & 1 deletion packages/config/src/reader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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 {
Expand Down
9 changes: 9 additions & 0 deletions packages/config/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,15 @@ const RunnerSchema = z.object({
// imported and run by the bundler while it composes the config.
metroConfigEnhancer: 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<object, unknown>;
Expand Down
7 changes: 6 additions & 1 deletion packages/jest/src/__tests__/execute-run.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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) }));
});
Expand Down
17 changes: 15 additions & 2 deletions packages/jest/src/resource-lock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
4 changes: 4 additions & 0 deletions packages/platform-windows/.npmignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
**/__tests__/
**/*.test.*
**/*.tsbuildinfo
dist/*.tsbuildinfo
Loading
Loading