Skip to content

Commit 159484a

Browse files
authored
feat(cli): add config wizard (#60)
Adds an interactive init wizard that guides users through setting up React Native Harness in their project.
1 parent 8418a37 commit 159484a

File tree

33 files changed

+1183
-306
lines changed

33 files changed

+1183
-306
lines changed
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@react-native-harness/cli": prerelease
3+
---
4+
5+
Add interactive Harness init wizard to guide users through setup and config.
6+

actions/shared/index.cjs

Lines changed: 106 additions & 101 deletions
Large diffs are not rendered by default.

apps/playground/jest.config.js

Lines changed: 0 additions & 17 deletions
This file was deleted.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
export default {
2+
preset: 'react-native-harness',
3+
testMatch: ['<rootDir>/**/__tests__/**/*.harness.[jt]s?(x)'],
4+
setupFiles: ['./src/setupFile.ts'],
5+
setupFilesAfterEnv: ['./src/setupFileAfterEnv.ts'],
6+
// This is necessary to prevent Jest from transforming the workspace packages.
7+
// Not needed in users projects, as they will have the packages installed in their node_modules.
8+
transformIgnorePatterns: ['/packages/', '/node_modules/'],
9+
collectCoverageFrom: ['./src/**/*.(ts|tsx)'],
10+
};

apps/playground/rn-harness.config.mjs

Lines changed: 8 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -18,40 +18,20 @@ import {
1818
chrome,
1919
} from '@react-native-harness/platform-web';
2020

21-
const config = {
21+
export default {
2222
entryPoint: './index.js',
2323
appRegistryComponentName: 'HarnessPlayground',
2424

2525
runners: [
2626
androidPlatform({
27-
name: 'android',
28-
device: androidEmulator('Pixel_8_API_35', {
29-
apiLevel: 35,
30-
profile: 'pixel_6',
31-
diskSize: '1G',
32-
heapSize: '1G',
33-
}),
34-
bundleId: 'com.harnessplayground',
35-
}),
36-
androidPlatform({
37-
name: 'moto-g72',
38-
device: physicalAndroidDevice('Motorola', 'Moto G72'),
39-
bundleId: 'com.harnessplayground',
40-
}),
41-
applePlatform({
42-
name: 'iphone-16-pro',
43-
device: applePhysicalDevice('iPhone (Szymon) (2)'),
44-
bundleId: 'react-native-harness',
27+
name: 'pixel_8_api_33',
28+
device: androidEmulator('Pixel_8_API_33'),
29+
bundleId: 'com.example',
4530
}),
4631
applePlatform({
47-
name: 'ios',
48-
device: appleSimulator('iPhone 16 Pro', '18.6'),
49-
bundleId: 'com.harnessplayground',
50-
}),
51-
vegaPlatform({
52-
name: 'vega',
53-
device: vegaEmulator('VegaTV_1'),
54-
bundleId: 'com.playground',
32+
name: 'iphone-16-pro-max',
33+
device: appleSimulator('iPhone 16 Pro Max', '26.0'),
34+
bundleId: 'com.example',
5535
}),
5636
webPlatform({
5737
name: 'web',
@@ -62,12 +42,5 @@ const config = {
6242
browser: chromium('http://localhost:8081/index.html', { headless: true }),
6343
}),
6444
],
65-
defaultRunner: 'android',
66-
bridgeTimeout: 120000,
67-
webSocketPort: 3002,
68-
69-
resetEnvironmentBetweenTestFiles: true,
70-
unstable__skipAlreadyIncludedModules: false,
45+
defaultRunner: 'pixel_8_api_33',
7146
};
72-
73-
export default config;

packages/cli/eslint.config.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ export default [
99
'error',
1010
{
1111
ignoredFiles: ['{projectRoot}/eslint.config.{js,cjs,mjs,ts,cts,mts}'],
12-
ignoredDependencies: ['@react-native-harness/bridge'],
12+
ignoredDependencies: ['@react-native-harness/bridge', '@react-native-harness/platform-android', '@react-native-harness/platform-apple', '@react-native-harness/platform-web'],
1313
},
1414
],
1515
},

packages/cli/package.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,15 @@
2323
"dependencies": {
2424
"@react-native-harness/bridge": "workspace:*",
2525
"@react-native-harness/config": "workspace:*",
26+
"@react-native-harness/platforms": "workspace:*",
27+
"@react-native-harness/tools": "workspace:*",
2628
"tslib": "^2.3.0"
2729
},
2830
"devDependencies": {
29-
"jest-cli": "^30.2.0"
31+
"jest-cli": "^30.2.0",
32+
"@react-native-harness/platform-android": "workspace:*",
33+
"@react-native-harness/platform-apple": "workspace:*",
34+
"@react-native-harness/platform-web": "workspace:*"
3035
},
3136
"peerDependencies": {
3237
"jest-cli": "*"

packages/cli/src/index.ts

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,18 @@
11
import { run, yargsOptions } from 'jest-cli';
22
import { getConfig } from '@react-native-harness/config';
3+
import { runInitWizard } from './wizard/index.js';
4+
import fs from 'node:fs';
5+
import path from 'node:path';
6+
7+
const JEST_CONFIG_EXTENSIONS = ['.mjs', '.js', '.cjs'];
8+
const JEST_HARNESS_CONFIG_BASE = 'jest.harness.config';
39

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

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

@@ -67,5 +73,28 @@ const patchYargsOptions = () => {
6773
delete yargsOptions.logHeapUsage;
6874
};
6975

70-
patchYargsOptions();
71-
checkForOldConfig().then(() => run());
76+
if (process.argv.includes('init')) {
77+
runInitWizard();
78+
} else {
79+
patchYargsOptions();
80+
81+
const hasConfigArg =
82+
process.argv.includes('--config') || process.argv.includes('-c');
83+
84+
if (!hasConfigArg) {
85+
const existingConfigExt = JEST_CONFIG_EXTENSIONS.find((ext) =>
86+
fs.existsSync(
87+
path.join(process.cwd(), `${JEST_HARNESS_CONFIG_BASE}${ext}`)
88+
)
89+
);
90+
91+
if (existingConfigExt) {
92+
process.argv.push(
93+
'--config',
94+
`${JEST_HARNESS_CONFIG_BASE}${existingConfigExt}`
95+
);
96+
}
97+
}
98+
99+
checkForOldConfig().then(() => run());
100+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { promptText } from '@react-native-harness/tools';
2+
3+
export const getBundleIds = async (
4+
selectedPlatforms: string[]
5+
): Promise<Record<string, string>> => {
6+
const bundleIds: Record<string, string> = {};
7+
8+
if (selectedPlatforms.includes('android')) {
9+
bundleIds.android = await promptText({
10+
message: 'Enter Android package name',
11+
placeholder: 'com.example.app',
12+
validate: (value: string | undefined) => {
13+
if (!value) return 'Package name is required';
14+
const parts = value.split('.');
15+
if (parts.length < 2) {
16+
return 'Package name must have at least two segments (e.g., com.example)';
17+
}
18+
for (const segment of parts) {
19+
if (!segment) return 'Segments cannot be empty';
20+
if (!/^[a-zA-Z]/.test(segment)) {
21+
return `Segment "${segment}" must start with a letter`;
22+
}
23+
if (!/^[a-zA-Z0-9_]+$/.test(segment)) {
24+
return `Segment "${segment}" can only contain alphanumeric characters or underscores`;
25+
}
26+
}
27+
return;
28+
},
29+
});
30+
}
31+
32+
if (selectedPlatforms.includes('ios')) {
33+
bundleIds.ios = await promptText({
34+
message: 'Enter iOS bundle identifier',
35+
placeholder: 'com.example.app',
36+
validate: (value: string | undefined) => {
37+
if (!value) return 'Bundle identifier is required';
38+
if (!/^[a-zA-Z0-9.-]+$/.test(value)) {
39+
return 'Bundle identifier can only contain alphanumeric characters, hyphens, and periods';
40+
}
41+
if (value.startsWith('.') || value.endsWith('.')) {
42+
return 'Bundle identifier cannot start or end with a period';
43+
}
44+
if (value.includes('..')) {
45+
return 'Bundle identifier cannot contain consecutive periods';
46+
}
47+
return;
48+
},
49+
});
50+
}
51+
52+
if (selectedPlatforms.includes('web')) {
53+
bundleIds.web = await promptText({
54+
message: 'Enter application URL',
55+
initialValue: 'http://localhost:8081/index.html',
56+
placeholder: 'http://localhost:8081/index.html',
57+
validate: (value: string | undefined) => {
58+
if (!value) return 'URL is required';
59+
try {
60+
new URL(value);
61+
return;
62+
} catch (e) {
63+
return 'Please enter a valid URL';
64+
}
65+
},
66+
});
67+
}
68+
69+
return bundleIds;
70+
};
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import fs from 'node:fs';
2+
import path from 'node:path';
3+
import type { RunTarget } from '@react-native-harness/platforms';
4+
import type { ProjectConfig } from './projectType.js';
5+
6+
const q = (s: string) => `'${s.replace(/'/g, "\\'")}'`;
7+
8+
const getDeviceCall = (target: RunTarget): string => {
9+
const { device, type, platform } = target;
10+
if (platform === 'android') {
11+
if (type === 'emulator') {
12+
return `androidEmulator(${q(device.name)})`;
13+
}
14+
return `physicalAndroidDevice(${q(device.manufacturer)}, ${q(
15+
device.model
16+
)})`;
17+
}
18+
if (platform === 'ios') {
19+
if (type === 'emulator') {
20+
return `appleSimulator(${q(device.name)}, ${q(device.systemVersion)})`;
21+
}
22+
return `applePhysicalDevice(${q(device.name)})`;
23+
}
24+
return JSON.stringify(device);
25+
};
26+
27+
const getPlatformFn = (platform: string): string => {
28+
if (platform === 'android') return 'androidPlatform';
29+
if (platform === 'ios') return 'applePlatform';
30+
return `${platform}Platform`;
31+
};
32+
33+
export const generateConfig = (
34+
projectConfig: ProjectConfig,
35+
selectedPlatforms: string[],
36+
selectedTargets: RunTarget[],
37+
bundleIds: Record<string, string>
38+
) => {
39+
const imports: string[] = [];
40+
if (selectedPlatforms.includes('android')) {
41+
const androidFactories = ['androidPlatform'];
42+
if (
43+
selectedTargets.some(
44+
(t) => t.platform === 'android' && t.type === 'emulator'
45+
)
46+
)
47+
androidFactories.push('androidEmulator');
48+
if (
49+
selectedTargets.some(
50+
(t) => t.platform === 'android' && t.type === 'physical'
51+
)
52+
)
53+
androidFactories.push('physicalAndroidDevice');
54+
55+
imports.push(
56+
`import { ${androidFactories.join(
57+
', '
58+
)} } from "@react-native-harness/platform-android";`
59+
);
60+
}
61+
if (selectedPlatforms.includes('ios')) {
62+
const iosFactories = ['applePlatform'];
63+
if (
64+
selectedTargets.some((t) => t.platform === 'ios' && t.type === 'emulator')
65+
)
66+
iosFactories.push('appleSimulator');
67+
if (
68+
selectedTargets.some((t) => t.platform === 'ios' && t.type === 'physical')
69+
)
70+
iosFactories.push('applePhysicalDevice');
71+
72+
imports.push(
73+
`import { ${iosFactories.join(
74+
', '
75+
)} } from "@react-native-harness/platform-apple";`
76+
);
77+
}
78+
if (selectedPlatforms.includes('web')) {
79+
const webFactories = ['webPlatform'];
80+
const browsers = new Set(
81+
selectedTargets
82+
.filter((t) => t.platform === 'web')
83+
.map((t) => t.device.browserType)
84+
);
85+
for (const browser of browsers) {
86+
if (browser) webFactories.push(browser);
87+
}
88+
89+
imports.push(
90+
`import { ${webFactories.join(
91+
', '
92+
)} } from "@react-native-harness/platform-web";`
93+
);
94+
}
95+
96+
const runnerConfigs = selectedTargets.map((target) => {
97+
const platformFn = getPlatformFn(target.platform);
98+
const name = target.name.toLowerCase().replace(/\s+/g, '-');
99+
100+
if (target.platform === 'web') {
101+
const url = bundleIds[target.platform];
102+
const browserCall = `${target.device.browserType}(${q(url)})`;
103+
return ` ${platformFn}({
104+
name: ${q(name)},
105+
browser: ${browserCall},
106+
}),`;
107+
}
108+
109+
const bundleId = bundleIds[target.platform];
110+
const deviceCall = getDeviceCall(target);
111+
112+
return ` ${platformFn}({
113+
name: ${q(name)},
114+
device: ${deviceCall},
115+
bundleId: ${q(bundleId)},
116+
}),`;
117+
});
118+
119+
const configContent = `
120+
${imports.join('\n')}
121+
122+
export default {
123+
entryPoint: ${q(projectConfig.entryPoint)},
124+
appRegistryComponentName: ${q(projectConfig.appRegistryComponentName)},
125+
126+
runners: [
127+
${runnerConfigs.join('\n')}
128+
],
129+
defaultRunner: ${q(
130+
selectedTargets[0].name.toLowerCase().replace(/\s+/g, '-')
131+
)},
132+
};
133+
`;
134+
135+
fs.writeFileSync(
136+
path.join(process.cwd(), 'rn-harness.config.mjs'),
137+
configContent.trim() + '\n'
138+
);
139+
};

0 commit comments

Comments
 (0)