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
6 changes: 6 additions & 0 deletions .nx/version-plans/version-plan-1769012201799.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@react-native-harness/cli": prerelease
---

Add interactive Harness init wizard to guide users through setup and config.

207 changes: 106 additions & 101 deletions actions/shared/index.cjs

Large diffs are not rendered by default.

17 changes: 0 additions & 17 deletions apps/playground/jest.config.js

This file was deleted.

10 changes: 10 additions & 0 deletions apps/playground/jest.harness.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export default {
preset: 'react-native-harness',
testMatch: ['<rootDir>/**/__tests__/**/*.harness.[jt]s?(x)'],
setupFiles: ['./src/setupFile.ts'],
setupFilesAfterEnv: ['./src/setupFileAfterEnv.ts'],
// This is necessary to prevent Jest from transforming the workspace packages.
// Not needed in users projects, as they will have the packages installed in their node_modules.
transformIgnorePatterns: ['/packages/', '/node_modules/'],
collectCoverageFrom: ['./src/**/*.(ts|tsx)'],
};
43 changes: 8 additions & 35 deletions apps/playground/rn-harness.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,40 +18,20 @@ import {
chrome,
} from '@react-native-harness/platform-web';

const config = {
export default {
entryPoint: './index.js',
appRegistryComponentName: 'HarnessPlayground',

runners: [
androidPlatform({
name: 'android',
device: androidEmulator('Pixel_8_API_35', {
apiLevel: 35,
profile: 'pixel_6',
diskSize: '1G',
heapSize: '1G',
}),
bundleId: 'com.harnessplayground',
}),
androidPlatform({
name: 'moto-g72',
device: physicalAndroidDevice('Motorola', 'Moto G72'),
bundleId: 'com.harnessplayground',
}),
applePlatform({
name: 'iphone-16-pro',
device: applePhysicalDevice('iPhone (Szymon) (2)'),
bundleId: 'react-native-harness',
name: 'pixel_8_api_33',
device: androidEmulator('Pixel_8_API_33'),
bundleId: 'com.example',
}),
applePlatform({
name: 'ios',
device: appleSimulator('iPhone 16 Pro', '18.6'),
bundleId: 'com.harnessplayground',
}),
vegaPlatform({
name: 'vega',
device: vegaEmulator('VegaTV_1'),
bundleId: 'com.playground',
name: 'iphone-16-pro-max',
device: appleSimulator('iPhone 16 Pro Max', '26.0'),
bundleId: 'com.example',
}),
webPlatform({
name: 'web',
Expand All @@ -62,12 +42,5 @@ const config = {
browser: chromium('http://localhost:8081/index.html', { headless: true }),
}),
],
defaultRunner: 'android',
bridgeTimeout: 120000,
webSocketPort: 3002,

resetEnvironmentBetweenTestFiles: true,
unstable__skipAlreadyIncludedModules: false,
defaultRunner: 'pixel_8_api_33',
};

export default config;
2 changes: 1 addition & 1 deletion packages/cli/eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export default [
'error',
{
ignoredFiles: ['{projectRoot}/eslint.config.{js,cjs,mjs,ts,cts,mts}'],
ignoredDependencies: ['@react-native-harness/bridge'],
ignoredDependencies: ['@react-native-harness/bridge', '@react-native-harness/platform-android', '@react-native-harness/platform-apple', '@react-native-harness/platform-web'],
},
],
},
Expand Down
7 changes: 6 additions & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,15 @@
"dependencies": {
"@react-native-harness/bridge": "workspace:*",
"@react-native-harness/config": "workspace:*",
"@react-native-harness/platforms": "workspace:*",
"@react-native-harness/tools": "workspace:*",
"tslib": "^2.3.0"
},
"devDependencies": {
"jest-cli": "^30.2.0"
"jest-cli": "^30.2.0",
"@react-native-harness/platform-android": "workspace:*",
"@react-native-harness/platform-apple": "workspace:*",
"@react-native-harness/platform-web": "workspace:*"
},
"peerDependencies": {
"jest-cli": "*"
Expand Down
37 changes: 33 additions & 4 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
import { run, yargsOptions } from 'jest-cli';
import { getConfig } from '@react-native-harness/config';
import { runInitWizard } from './wizard/index.js';
import fs from 'node:fs';
import path from 'node:path';

const JEST_CONFIG_EXTENSIONS = ['.mjs', '.js', '.cjs'];
const JEST_HARNESS_CONFIG_BASE = 'jest.harness.config';

const checkForOldConfig = async () => {
try {
const { config } = await getConfig(process.cwd());

if (config.include) {
console.error('\n❌ Migration Required\n');
console.error('\n❌ Migration required\n');
console.error('React Native Harness has migrated to the Jest CLI.');
console.error(
'The "include" property in your rn-harness.config file is no longer supported.\n'
Expand All @@ -27,7 +33,7 @@ const checkForOldConfig = async () => {
const patchYargsOptions = () => {
yargsOptions.harnessRunner = {
type: 'string',
description: 'Specify which Harness runner to use',
description: 'Specify which harness runner to use',
requiresArg: true,
};

Expand Down Expand Up @@ -67,5 +73,28 @@ const patchYargsOptions = () => {
delete yargsOptions.logHeapUsage;
};

patchYargsOptions();
checkForOldConfig().then(() => run());
if (process.argv.includes('init')) {
runInitWizard();
} else {
patchYargsOptions();

const hasConfigArg =
process.argv.includes('--config') || process.argv.includes('-c');

if (!hasConfigArg) {
const existingConfigExt = JEST_CONFIG_EXTENSIONS.find((ext) =>
fs.existsSync(
path.join(process.cwd(), `${JEST_HARNESS_CONFIG_BASE}${ext}`)
)
);

if (existingConfigExt) {
process.argv.push(
'--config',
`${JEST_HARNESS_CONFIG_BASE}${existingConfigExt}`
);
}
}

checkForOldConfig().then(() => run());
}
70 changes: 70 additions & 0 deletions packages/cli/src/wizard/bundleId.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { promptText } from '@react-native-harness/tools';

export const getBundleIds = async (
selectedPlatforms: string[]
): Promise<Record<string, string>> => {
const bundleIds: Record<string, string> = {};

if (selectedPlatforms.includes('android')) {
bundleIds.android = await promptText({
message: 'Enter Android package name',
placeholder: 'com.example.app',
validate: (value: string | undefined) => {
if (!value) return 'Package name is required';
const parts = value.split('.');
if (parts.length < 2) {
return 'Package name must have at least two segments (e.g., com.example)';
}
for (const segment of parts) {
if (!segment) return 'Segments cannot be empty';
if (!/^[a-zA-Z]/.test(segment)) {
return `Segment "${segment}" must start with a letter`;
}
if (!/^[a-zA-Z0-9_]+$/.test(segment)) {
return `Segment "${segment}" can only contain alphanumeric characters or underscores`;
}
}
return;
},
});
}

if (selectedPlatforms.includes('ios')) {
bundleIds.ios = await promptText({
message: 'Enter iOS bundle identifier',
placeholder: 'com.example.app',
validate: (value: string | undefined) => {
if (!value) return 'Bundle identifier is required';
if (!/^[a-zA-Z0-9.-]+$/.test(value)) {
return 'Bundle identifier can only contain alphanumeric characters, hyphens, and periods';
}
if (value.startsWith('.') || value.endsWith('.')) {
return 'Bundle identifier cannot start or end with a period';
}
if (value.includes('..')) {
return 'Bundle identifier cannot contain consecutive periods';
}
return;
},
});
}

if (selectedPlatforms.includes('web')) {
bundleIds.web = await promptText({
message: 'Enter application URL',
initialValue: 'http://localhost:8081/index.html',
placeholder: 'http://localhost:8081/index.html',
validate: (value: string | undefined) => {
if (!value) return 'URL is required';
try {
new URL(value);
return;
} catch (e) {
return 'Please enter a valid URL';
}
},
});
}

return bundleIds;
};
139 changes: 139 additions & 0 deletions packages/cli/src/wizard/configGenerator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import fs from 'node:fs';
import path from 'node:path';
import type { RunTarget } from '@react-native-harness/platforms';
import type { ProjectConfig } from './projectType.js';

const q = (s: string) => `'${s.replace(/'/g, "\\'")}'`;

const getDeviceCall = (target: RunTarget): string => {
const { device, type, platform } = target;
if (platform === 'android') {
if (type === 'emulator') {
return `androidEmulator(${q(device.name)})`;
}
return `physicalAndroidDevice(${q(device.manufacturer)}, ${q(
device.model
)})`;
}
if (platform === 'ios') {
if (type === 'emulator') {
return `appleSimulator(${q(device.name)}, ${q(device.systemVersion)})`;
}
return `applePhysicalDevice(${q(device.name)})`;
}
return JSON.stringify(device);
};

const getPlatformFn = (platform: string): string => {
if (platform === 'android') return 'androidPlatform';
if (platform === 'ios') return 'applePlatform';
return `${platform}Platform`;
};

export const generateConfig = (
projectConfig: ProjectConfig,
selectedPlatforms: string[],
selectedTargets: RunTarget[],
bundleIds: Record<string, string>
) => {
const imports: string[] = [];
if (selectedPlatforms.includes('android')) {
const androidFactories = ['androidPlatform'];
if (
selectedTargets.some(
(t) => t.platform === 'android' && t.type === 'emulator'
)
)
androidFactories.push('androidEmulator');
if (
selectedTargets.some(
(t) => t.platform === 'android' && t.type === 'physical'
)
)
androidFactories.push('physicalAndroidDevice');

imports.push(
`import { ${androidFactories.join(
', '
)} } from "@react-native-harness/platform-android";`
);
}
if (selectedPlatforms.includes('ios')) {
const iosFactories = ['applePlatform'];
if (
selectedTargets.some((t) => t.platform === 'ios' && t.type === 'emulator')
)
iosFactories.push('appleSimulator');
if (
selectedTargets.some((t) => t.platform === 'ios' && t.type === 'physical')
)
iosFactories.push('applePhysicalDevice');

imports.push(
`import { ${iosFactories.join(
', '
)} } from "@react-native-harness/platform-apple";`
);
}
if (selectedPlatforms.includes('web')) {
const webFactories = ['webPlatform'];
const browsers = new Set(
selectedTargets
.filter((t) => t.platform === 'web')
.map((t) => t.device.browserType)
);
for (const browser of browsers) {
if (browser) webFactories.push(browser);
}

imports.push(
`import { ${webFactories.join(
', '
)} } from "@react-native-harness/platform-web";`
);
}

const runnerConfigs = selectedTargets.map((target) => {
const platformFn = getPlatformFn(target.platform);
const name = target.name.toLowerCase().replace(/\s+/g, '-');

if (target.platform === 'web') {
const url = bundleIds[target.platform];
const browserCall = `${target.device.browserType}(${q(url)})`;
return ` ${platformFn}({
name: ${q(name)},
browser: ${browserCall},
}),`;
}

const bundleId = bundleIds[target.platform];
const deviceCall = getDeviceCall(target);

return ` ${platformFn}({
name: ${q(name)},
device: ${deviceCall},
bundleId: ${q(bundleId)},
}),`;
});

const configContent = `
${imports.join('\n')}

export default {
entryPoint: ${q(projectConfig.entryPoint)},
appRegistryComponentName: ${q(projectConfig.appRegistryComponentName)},

runners: [
${runnerConfigs.join('\n')}
],
defaultRunner: ${q(
selectedTargets[0].name.toLowerCase().replace(/\s+/g, '-')
)},
};
`;

fs.writeFileSync(
path.join(process.cwd(), 'rn-harness.config.mjs'),
configContent.trim() + '\n'
);
};
Loading