Skip to content
Merged
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
10 changes: 10 additions & 0 deletions apps/playground/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
module.exports = {
projects: [
{
runner: '@react-native-harness/jest',
testMatch: [
'<rootDir>/src/__tests__/**/*.(test|spec|harness).(js|jsx|ts|tsx)',
],
},
],
};
4 changes: 4 additions & 0 deletions apps/playground/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,9 @@
"dependencies": {
"react-native": "*",
"react-native-harness": "workspace:*"
},
"devDependencies": {
"@react-native-harness/jest": "workspace:*",
"jest": "^30.2.0"
}
}
3 changes: 3 additions & 0 deletions apps/playground/tsconfig.app.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@
"react-native-harness.d.ts"
],
"references": [
{
"path": "../../packages/jest/tsconfig.lib.json"
},
{
"path": "../../packages/react-native-harness/tsconfig.lib.json"
}
Expand Down
3 changes: 3 additions & 0 deletions apps/playground/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
"files": [],
"include": [],
"references": [
{
"path": "../../packages/jest"
},
{
"path": "../../packages/react-native-harness"
},
Expand Down
15 changes: 15 additions & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,21 @@
"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"
},
"./external": {
"development": "./src/external.ts",
"types": "./dist/external.d.ts",
"import": "./dist/external.js",
"default": "./dist/external.js"
}
},
"dependencies": {
"@react-native-harness/bridge": "workspace:*",
"@react-native-harness/config": "workspace:*",
Expand Down
44 changes: 44 additions & 0 deletions packages/cli/src/external.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { TestRunnerConfig } from '@react-native-harness/config';
import { Environment } from './platforms/platform-adapter.js';
import {
BridgeServer,
getBridgeServer,
} from '@react-native-harness/bridge/server';
import { BridgeTimeoutError } from './errors/errors.js';
import { getPlatformAdapter } from './platforms/platform-registry.js';

export type Harness = {
environment: Environment;
bridge: BridgeServer;
};

export const getHarness = async (
runner: TestRunnerConfig
): Promise<Harness> => {
const bridgeTimeout = 60000;
const platformAdapter = await getPlatformAdapter(runner.platform);
const serverBridge = await getBridgeServer({
port: 3001,
});

const readyPromise = new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
reject(
new BridgeTimeoutError(bridgeTimeout, runner.name, runner.platform)
);
}, bridgeTimeout);

serverBridge.once('ready', () => {
clearTimeout(timeout);
resolve();
});
});

const environment = await platformAdapter.getEnvironment(runner);
await readyPromise;

return {
environment,
bridge: serverBridge,
};
};
40 changes: 40 additions & 0 deletions packages/jest/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
![harness-banner](https://react-native-harness.dev/harness-banner.jpg)

### Experimental Jest Runner for React Native Harness

[![mit licence][license-badge]][license]
[![npm downloads][npm-downloads-badge]][npm-downloads]
[![Chat][chat-badge]][chat]
[![PRs Welcome][prs-welcome-badge]][prs-welcome]

⚠️ **EXPERIMENTAL** ⚠️

An experimental Jest runner that integrates React Native Harness with Jest's infrastructure. This package allows you to leverage existing Jest features like watchers, coverage reporting, and the familiar developer experience while running React Native Harness tests.

## Features

- **Jest Integration**: Re-uses Jest's existing infrastructure for a familiar developer experience
- **Watch Mode**: Automatic test re-running when files change
- **Coverage Reports**: Built-in code coverage support through Jest
- **Serial Execution**: Tests run sequentially to ensure proper React Native environment management
- **Harness Configuration**: Supports all React Native Harness runners and configurations

## Usage

This runner is designed to work with Jest's test runner system. Configure it in your Jest configuration to run React Native Harness tests through Jest's interface.

## Made with ❤️ at Callstack

`@react-native-harness/jest` 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!

Like the project? ⚛️ [Join the team](https://callstack.com/careers/?utm_campaign=Senior_RN&utm_source=github&utm_medium=readme) who does amazing stuff for clients and drives React Native Open Source! 🔥

[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/jest?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/jest?style=for-the-badge
[npm-downloads]: https://www.npmjs.com/package/@react-native-harness/jest
[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
19 changes: 19 additions & 0 deletions packages/jest/eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import baseConfig from '../../eslint.config.mjs';

export default [
...baseConfig,
{
files: ['**/*.json'],
rules: {
'@nx/dependency-checks': [
'error',
{
ignoredFiles: ['{projectRoot}/eslint.config.{js,cjs,mjs,ts,cts,mts}'],
},
],
},
languageOptions: {
parser: await import('jsonc-eslint-parser'),
},
},
];
30 changes: 30 additions & 0 deletions packages/jest/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{
"name": "@react-native-harness/jest",
"version": "1.0.0-alpha.15",
"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": {
"@jest/test-result": "^30.2.0",
"jest-runner": "^30.2.0",
"p-limit": "^7.1.1",
"tslib": "^2.3.0",
"yargs": "^17.7.2",
"@react-native-harness/cli": "workspace:*",
"@react-native-harness/bridge": "workspace:*",
"@react-native-harness/config": "workspace:*"
},
"devDependencies": {
"@types/yargs": "^17.0.32"
}
}
29 changes: 29 additions & 0 deletions packages/jest/src/cli-args.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';

export type HarnessCliArgs = {
harnessRunner?: string;
};

export const getAdditionalCliArgs = (): HarnessCliArgs => {
const argv = yargs(hideBin(process.argv))
.option('harnessRunner', {
type: 'string',
description: 'Specify which Harness runner to use',
coerce: (value: string) => {
if (!value || value.trim().length === 0) {
throw new Error('harnessRunner must be a non-empty string');
}
return value.trim();
},
})
.strict(false)
.help(false)
.version(false)
.exitProcess(false)
.parseSync();

return {
harnessRunner: argv.harnessRunner,
};
};
129 changes: 129 additions & 0 deletions packages/jest/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import type {
CallbackTestRunnerInterface,
Config,
OnTestFailure,
OnTestStart,
OnTestSuccess,
Test,
TestRunnerOptions,
TestWatcher,
} from 'jest-runner';
import pLimit from 'p-limit';
import { runHarnessTestFile } from './run.js';
import { getHarness } from '@react-native-harness/cli/external';
import {
getConfig,
Config as HarnessConfig,
TestRunnerConfig as HarnessTestRunnerConfig,
} from '@react-native-harness/config';
import { getAdditionalCliArgs, HarnessCliArgs } from './cli-args.js';
import type { Harness } from '@react-native-harness/cli/external';

class CancelRun extends Error {
constructor(message?: string) {
super(message);
this.name = 'CancelRun';
}
}

const getHarnessRunner = (
config: HarnessConfig,
cliArgs: HarnessCliArgs
): HarnessTestRunnerConfig => {
const selectedRunnerName = cliArgs.harnessRunner ?? config.defaultRunner;
const runner = config.runners.find(
(runner) => runner.name === selectedRunnerName
);

if (!runner) {
throw new Error(`Runner "${selectedRunnerName}" not found`);
}

return runner;
};
export default class JestHarness implements CallbackTestRunnerInterface {
readonly isSerial = true;

#globalConfig: Config.GlobalConfig;

constructor(globalConfig: Config.GlobalConfig) {
this.#globalConfig = globalConfig;
}

async runTests(
tests: Array<Test>,
watcher: TestWatcher,
onStart: OnTestStart,
onResult: OnTestSuccess,
onFailure: OnTestFailure,
options: TestRunnerOptions
): Promise<void> {
if (!options.serial) {
throw new Error('Parallel test running is not supported');
}

const projectRoot = this.#globalConfig.rootDir;
const { config: harnessConfig } = await getConfig(projectRoot);
const cliArgs = getAdditionalCliArgs();
const selectedRunner = getHarnessRunner(harnessConfig, cliArgs);
const harness = await getHarness(selectedRunner);

try {
return await this._createInBandTestRun(
tests,
watcher,
harness,
onStart,
onResult,
onFailure
);
} finally {
harness.bridge.dispose();
await harness.environment.dispose();
}
}

async _createInBandTestRun(
tests: Array<Test>,
watcher: TestWatcher,
harness: Harness,
onStart: OnTestStart,
onResult: OnTestSuccess,
onFailure: OnTestFailure
): Promise<void> {
const mutex = pLimit(1);
let isFirstTest = true;

return tests.reduce(
(promise, test) =>
mutex(() =>
promise
.then(async () => {
if (watcher.isInterrupted()) {
throw new CancelRun();
}

if (!isFirstTest) {
await new Promise((resolve) => {
harness.bridge.once('ready', resolve);
harness.environment.restart();
});
}
isFirstTest = false;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Event Listener Timing Issue

A race condition exists where the 'ready' event listener is attached after harness.environment.restart() is called. If the environment becomes ready quickly, the event can be missed, causing the test runner to hang indefinitely. This wait also lacks timeout or error handling.

Fix in Cursor Fix in Web


return onStart(test).then(() =>
runHarnessTestFile({
testPath: test.path,
harness,
globalConfig: this.#globalConfig,
projectConfig: test.context.config,
})
);
})
.then((result) => onResult(test, result))
.catch((err) => onFailure(test, err))
),
Promise.resolve()
);
}
}
Loading
Loading