@react-native-harness/cli 1.2.0-rc.1 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=platform-commands.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"platform-commands.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/platform-commands.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,207 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { discoverPlatformCommands, runPlatformCommand, } from '../platform-commands.js';
3
+ const createCommandModuleUrl = (body) => `data:text/javascript,${encodeURIComponent(body)}`;
4
+ const globalState = globalThis;
5
+ describe('platform CLI command discovery', () => {
6
+ afterEach(() => {
7
+ delete globalState.__platformCommandCall;
8
+ });
9
+ it('runs a discovered platform command', async () => {
10
+ const moduleUrl = createCommandModuleUrl(`
11
+ export const commands = [{
12
+ name: 'xctest',
13
+ async run(args, context) {
14
+ globalThis.__platformCommandCall = { args, context };
15
+ }
16
+ }];
17
+ `);
18
+ const loadConfig = vi.fn(async () => ({
19
+ projectRoot: '/tmp/project',
20
+ config: {
21
+ entryPoint: 'index.js',
22
+ appRegistryComponentName: 'App',
23
+ runners: [
24
+ {
25
+ name: 'ios',
26
+ config: {},
27
+ runner: '/virtual/runner.js',
28
+ cli: moduleUrl,
29
+ platformId: 'ios',
30
+ },
31
+ ],
32
+ plugins: [],
33
+ metroPort: 8081,
34
+ webSocketPort: undefined,
35
+ bridgeTimeout: 60000,
36
+ platformReadyTimeout: 300000,
37
+ bundleStartTimeout: 60000,
38
+ maxAppRestarts: 2,
39
+ resetEnvironmentBetweenTestFiles: true,
40
+ unstable__skipAlreadyIncludedModules: false,
41
+ unstable__enableMetroCache: false,
42
+ permissions: false,
43
+ detectNativeCrashes: true,
44
+ crashDetectionInterval: 500,
45
+ disableViewFlattening: false,
46
+ forwardClientLogs: false,
47
+ },
48
+ }));
49
+ expect(await runPlatformCommand({
50
+ argv: ['xctest', 'build', '--destination', 'simulator'],
51
+ cwd: '/tmp/project',
52
+ loadConfig,
53
+ })).toBe(true);
54
+ expect(globalState.__platformCommandCall).toEqual({
55
+ args: ['build', '--destination', 'simulator'],
56
+ context: {
57
+ cwd: '/tmp/project',
58
+ projectRoot: '/tmp/project',
59
+ },
60
+ });
61
+ });
62
+ it('deduplicates platform CLI modules across runners', async () => {
63
+ const moduleUrl = createCommandModuleUrl(`
64
+ export const commands = [{
65
+ name: 'xctest',
66
+ async run() {}
67
+ }];
68
+ `);
69
+ const loadConfig = vi.fn(async () => ({
70
+ projectRoot: '/tmp/project',
71
+ config: {
72
+ entryPoint: 'index.js',
73
+ appRegistryComponentName: 'App',
74
+ runners: [
75
+ {
76
+ name: 'ios-sim',
77
+ config: {},
78
+ runner: '/virtual/ios-sim-runner.js',
79
+ cli: moduleUrl,
80
+ platformId: 'ios',
81
+ },
82
+ {
83
+ name: 'ios-device',
84
+ config: {},
85
+ runner: '/virtual/ios-device-runner.js',
86
+ cli: moduleUrl,
87
+ platformId: 'ios',
88
+ },
89
+ ],
90
+ plugins: [],
91
+ metroPort: 8081,
92
+ webSocketPort: undefined,
93
+ bridgeTimeout: 60000,
94
+ platformReadyTimeout: 300000,
95
+ bundleStartTimeout: 60000,
96
+ maxAppRestarts: 2,
97
+ resetEnvironmentBetweenTestFiles: true,
98
+ unstable__skipAlreadyIncludedModules: false,
99
+ unstable__enableMetroCache: false,
100
+ permissions: false,
101
+ detectNativeCrashes: true,
102
+ crashDetectionInterval: 500,
103
+ disableViewFlattening: false,
104
+ forwardClientLogs: false,
105
+ },
106
+ }));
107
+ const discoveredCommands = await discoverPlatformCommands({
108
+ cwd: '/tmp/project',
109
+ loadConfig,
110
+ });
111
+ expect(discoveredCommands?.commands).toHaveLength(1);
112
+ });
113
+ it('returns false when no platform command matches', async () => {
114
+ const loadConfig = vi.fn(async () => ({
115
+ projectRoot: '/tmp/project',
116
+ config: {
117
+ entryPoint: 'index.js',
118
+ appRegistryComponentName: 'App',
119
+ runners: [
120
+ {
121
+ name: 'android',
122
+ config: {},
123
+ runner: '/virtual/android-runner.js',
124
+ platformId: 'android',
125
+ },
126
+ ],
127
+ plugins: [],
128
+ metroPort: 8081,
129
+ webSocketPort: undefined,
130
+ bridgeTimeout: 60000,
131
+ platformReadyTimeout: 300000,
132
+ bundleStartTimeout: 60000,
133
+ maxAppRestarts: 2,
134
+ resetEnvironmentBetweenTestFiles: true,
135
+ unstable__skipAlreadyIncludedModules: false,
136
+ unstable__enableMetroCache: false,
137
+ permissions: false,
138
+ detectNativeCrashes: true,
139
+ crashDetectionInterval: 500,
140
+ disableViewFlattening: false,
141
+ forwardClientLogs: false,
142
+ },
143
+ }));
144
+ await expect(runPlatformCommand({
145
+ argv: ['xctest', 'build'],
146
+ cwd: '/tmp/project',
147
+ loadConfig,
148
+ })).resolves.toBe(false);
149
+ });
150
+ it('throws when two platform modules define the same command', async () => {
151
+ const firstModuleUrl = createCommandModuleUrl(`
152
+ export const commands = [{
153
+ name: 'xctest',
154
+ async run() {}
155
+ }];
156
+ `);
157
+ const secondModuleUrl = createCommandModuleUrl(`
158
+ // second module
159
+ export const commands = [{
160
+ name: 'xctest',
161
+ async run() {}
162
+ }];
163
+ `);
164
+ const loadConfig = vi.fn(async () => ({
165
+ projectRoot: '/tmp/project',
166
+ config: {
167
+ entryPoint: 'index.js',
168
+ appRegistryComponentName: 'App',
169
+ runners: [
170
+ {
171
+ name: 'ios',
172
+ config: {},
173
+ runner: '/virtual/ios-runner.js',
174
+ cli: firstModuleUrl,
175
+ platformId: 'ios',
176
+ },
177
+ {
178
+ name: 'android',
179
+ config: {},
180
+ runner: '/virtual/android-runner.js',
181
+ cli: secondModuleUrl,
182
+ platformId: 'android',
183
+ },
184
+ ],
185
+ plugins: [],
186
+ metroPort: 8081,
187
+ webSocketPort: undefined,
188
+ bridgeTimeout: 60000,
189
+ platformReadyTimeout: 300000,
190
+ bundleStartTimeout: 60000,
191
+ maxAppRestarts: 2,
192
+ resetEnvironmentBetweenTestFiles: true,
193
+ unstable__skipAlreadyIncludedModules: false,
194
+ unstable__enableMetroCache: false,
195
+ permissions: false,
196
+ detectNativeCrashes: true,
197
+ crashDetectionInterval: 500,
198
+ disableViewFlattening: false,
199
+ forwardClientLogs: false,
200
+ },
201
+ }));
202
+ await expect(discoverPlatformCommands({
203
+ cwd: '/tmp/project',
204
+ loadConfig,
205
+ })).rejects.toThrow("Duplicate platform CLI command 'xctest'");
206
+ });
207
+ });
package/dist/index.js CHANGED
@@ -1,10 +1,101 @@
1
1
  import { run, yargsOptions } from 'jest-cli';
2
2
  import { getConfig } from '@react-native-harness/config';
3
3
  import { runInitWizard } from './wizard/index.js';
4
+ import { runPlatformCommand } from './platform-commands.js';
4
5
  import fs from 'node:fs';
5
6
  import path from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
6
8
  const JEST_CONFIG_EXTENSIONS = ['.mjs', '.js', '.cjs'];
7
9
  const JEST_HARNESS_CONFIG_BASE = 'jest.harness.config';
10
+ const SKILLS_DIRECTORY = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../skills');
11
+ const readSkillMetadata = (fileName) => {
12
+ const filePath = path.join(SKILLS_DIRECTORY, fileName);
13
+ const content = fs.readFileSync(filePath, 'utf8');
14
+ const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
15
+ const metadata = {
16
+ name: fileName.replace(/\.md$/, ''),
17
+ description: '',
18
+ };
19
+ if (frontmatterMatch) {
20
+ for (const line of frontmatterMatch[1].split('\n')) {
21
+ const separatorIndex = line.indexOf(':');
22
+ if (separatorIndex === -1) {
23
+ continue;
24
+ }
25
+ const key = line.slice(0, separatorIndex).trim();
26
+ const value = line.slice(separatorIndex + 1).trim().replace(/^['"]|['"]$/g, '');
27
+ if (key === 'name') {
28
+ metadata.name = value;
29
+ }
30
+ if (key === 'description') {
31
+ metadata.description = value;
32
+ }
33
+ }
34
+ }
35
+ return {
36
+ fileName,
37
+ name: metadata.name,
38
+ description: metadata.description,
39
+ };
40
+ };
41
+ const listSkills = () => fs
42
+ .readdirSync(SKILLS_DIRECTORY)
43
+ .filter((file) => file.endsWith('.md'))
44
+ .map(readSkillMetadata)
45
+ .sort((left, right) => left.name.localeCompare(right.name));
46
+ const printSkillList = () => {
47
+ for (const skill of listSkills()) {
48
+ console.log(`${skill.name}: ${skill.description}`);
49
+ }
50
+ };
51
+ const printSkillUsage = () => {
52
+ console.log(`Usage: harness skill <command>
53
+
54
+ Commands:
55
+ list List bundled skills
56
+ get <name> Print a bundled skill file
57
+
58
+ Examples:
59
+ harness skill list
60
+ harness skill get core`);
61
+ };
62
+ const getErrorMessage = (error) => {
63
+ if (error instanceof Error) {
64
+ return error.message;
65
+ }
66
+ return String(error);
67
+ };
68
+ const runSkillCommand = () => {
69
+ const [, , commandName, subcommand, skillName] = process.argv;
70
+ if (subcommand === undefined || subcommand === 'list') {
71
+ printSkillList();
72
+ return;
73
+ }
74
+ if (subcommand === '--help' || subcommand === '-h') {
75
+ printSkillUsage();
76
+ return;
77
+ }
78
+ if (subcommand === 'get') {
79
+ if (!skillName) {
80
+ console.error('Missing skill name.');
81
+ printSkillUsage();
82
+ process.exit(1);
83
+ }
84
+ const skillPath = path.join(SKILLS_DIRECTORY, `${skillName}.md`);
85
+ if (!fs.existsSync(skillPath)) {
86
+ console.error(`Unknown skill '${skillName}'.`);
87
+ console.error(`Available skills: ${listSkills()
88
+ .map((skill) => skill.name)
89
+ .join(', ')}`);
90
+ process.exit(1);
91
+ }
92
+ console.log(fs.readFileSync(skillPath, 'utf8'));
93
+ return;
94
+ }
95
+ console.error(`Unknown ${commandName} subcommand '${subcommand}'.`);
96
+ printSkillUsage();
97
+ process.exit(1);
98
+ };
8
99
  const checkForOldConfig = async () => {
9
100
  try {
10
101
  const { config } = await getConfig(process.cwd());
@@ -62,10 +153,21 @@ const patchYargsOptions = () => {
62
153
  delete yargsOptions.coverageProvider;
63
154
  delete yargsOptions.logHeapUsage;
64
155
  };
65
- if (process.argv.includes('init')) {
66
- runInitWizard();
67
- }
68
- else {
156
+ const main = async () => {
157
+ if (process.argv[2] === 'skill' || process.argv[2] === 'skills') {
158
+ runSkillCommand();
159
+ return;
160
+ }
161
+ if (process.argv.includes('init')) {
162
+ runInitWizard();
163
+ return;
164
+ }
165
+ if (await runPlatformCommand({
166
+ argv: process.argv.slice(2),
167
+ cwd: process.cwd(),
168
+ })) {
169
+ return;
170
+ }
69
171
  patchYargsOptions();
70
172
  const hasConfigArg = process.argv.includes('--config') || process.argv.includes('-c');
71
173
  if (!hasConfigArg) {
@@ -74,5 +176,10 @@ else {
74
176
  process.argv.push('--config', `${JEST_HARNESS_CONFIG_BASE}${existingConfigExt}`);
75
177
  }
76
178
  }
77
- checkForOldConfig().then(() => run());
78
- }
179
+ await checkForOldConfig();
180
+ run();
181
+ };
182
+ main().catch((error) => {
183
+ console.error(getErrorMessage(error));
184
+ process.exit(1);
185
+ });
@@ -0,0 +1,18 @@
1
+ import { getConfig } from '@react-native-harness/config';
2
+ import type { HarnessCliCommand } from '@react-native-harness/platforms';
3
+ type ConfigLoader = typeof getConfig;
4
+ type DiscoveredPlatformCommands = {
5
+ commands: HarnessCliCommand[];
6
+ projectRoot: string;
7
+ };
8
+ export declare const discoverPlatformCommands: (options: {
9
+ cwd: string;
10
+ loadConfig?: ConfigLoader;
11
+ }) => Promise<DiscoveredPlatformCommands | null>;
12
+ export declare const runPlatformCommand: (options: {
13
+ argv: string[];
14
+ cwd: string;
15
+ loadConfig?: ConfigLoader;
16
+ }) => Promise<boolean>;
17
+ export {};
18
+ //# sourceMappingURL=platform-commands.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"platform-commands.d.ts","sourceRoot":"","sources":["../src/platform-commands.ts"],"names":[],"mappings":"AAAA,OAAO,EAAuB,SAAS,EAAE,MAAM,8BAA8B,CAAC;AAC9E,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,iCAAiC,CAAC;AAEzE,KAAK,YAAY,GAAG,OAAO,SAAS,CAAC;AAErC,KAAK,0BAA0B,GAAG;IAChC,QAAQ,EAAE,iBAAiB,EAAE,CAAC;IAC9B,WAAW,EAAE,MAAM,CAAC;CACrB,CAAC;AAsEF,eAAO,MAAM,wBAAwB,GAAU,SAAS;IACtD,GAAG,EAAE,MAAM,CAAC;IACZ,UAAU,CAAC,EAAE,YAAY,CAAC;CAC3B,KAAG,OAAO,CAAC,0BAA0B,GAAG,IAAI,CAgC5C,CAAC;AAEF,eAAO,MAAM,kBAAkB,GAAU,SAAS;IAChD,IAAI,EAAE,MAAM,EAAE,CAAC;IACf,GAAG,EAAE,MAAM,CAAC;IACZ,UAAU,CAAC,EAAE,YAAY,CAAC;CAC3B,KAAG,OAAO,CAAC,OAAO,CA4BlB,CAAC"}
@@ -0,0 +1,84 @@
1
+ import { ConfigNotFoundError, getConfig } from '@react-native-harness/config';
2
+ const isRecord = (value) => typeof value === 'object' && value !== null;
3
+ const getModuleCommands = (importedModule, modulePath) => {
4
+ const moduleValue = isRecord(importedModule)
5
+ ? importedModule
6
+ : {};
7
+ const defaultExport = isRecord(moduleValue.default)
8
+ ? moduleValue.default
9
+ : undefined;
10
+ const commandsValue = moduleValue.commands ?? defaultExport?.commands;
11
+ if (!Array.isArray(commandsValue)) {
12
+ throw new Error(`Invalid platform CLI module '${modulePath}': expected a commands array.`);
13
+ }
14
+ return commandsValue.map((command, index) => {
15
+ if (!isRecord(command) || typeof command.name !== 'string') {
16
+ throw new Error(`Invalid platform CLI module '${modulePath}': command #${index + 1} is missing a valid name.`);
17
+ }
18
+ if (typeof command.run !== 'function') {
19
+ throw new Error(`Invalid platform CLI module '${modulePath}': command '${command.name}' is missing a run handler.`);
20
+ }
21
+ if (command.aliases !== undefined &&
22
+ (!Array.isArray(command.aliases) ||
23
+ command.aliases.some((alias) => typeof alias !== 'string'))) {
24
+ throw new Error(`Invalid platform CLI module '${modulePath}': command '${command.name}' has invalid aliases.`);
25
+ }
26
+ return command;
27
+ });
28
+ };
29
+ const registerCommandNames = (seenNames, modulePath, command) => {
30
+ const names = [command.name, ...(command.aliases ?? [])];
31
+ for (const name of names) {
32
+ const existingSource = seenNames.get(name);
33
+ if (existingSource !== undefined) {
34
+ throw new Error(`Duplicate platform CLI command '${name}' in '${modulePath}' and '${existingSource}'.`);
35
+ }
36
+ seenNames.set(name, modulePath);
37
+ }
38
+ };
39
+ export const discoverPlatformCommands = async (options) => {
40
+ const loadConfig = options.loadConfig ?? getConfig;
41
+ try {
42
+ const { config, projectRoot } = await loadConfig(options.cwd);
43
+ const modulePaths = [...new Set(config.runners.map((runner) => runner.cli))].filter((modulePath) => typeof modulePath === 'string');
44
+ const commands = [];
45
+ const seenNames = new Map();
46
+ for (const modulePath of modulePaths) {
47
+ const importedModule = await import(modulePath);
48
+ const moduleCommands = getModuleCommands(importedModule, modulePath);
49
+ for (const command of moduleCommands) {
50
+ registerCommandNames(seenNames, modulePath, command);
51
+ commands.push(command);
52
+ }
53
+ }
54
+ return {
55
+ commands,
56
+ projectRoot,
57
+ };
58
+ }
59
+ catch (error) {
60
+ if (error instanceof ConfigNotFoundError) {
61
+ return null;
62
+ }
63
+ throw error;
64
+ }
65
+ };
66
+ export const runPlatformCommand = async (options) => {
67
+ const commandName = options.argv[0];
68
+ if (typeof commandName !== 'string' || commandName.length === 0) {
69
+ return false;
70
+ }
71
+ const discoveredCommands = await discoverPlatformCommands(options);
72
+ if (discoveredCommands === null) {
73
+ return false;
74
+ }
75
+ const command = discoveredCommands.commands.find((entry) => entry.name === commandName || entry.aliases?.includes(commandName) === true);
76
+ if (command === undefined) {
77
+ return false;
78
+ }
79
+ await command.run(options.argv.slice(1), {
80
+ cwd: options.cwd,
81
+ projectRoot: discoveredCommands.projectRoot,
82
+ });
83
+ return true;
84
+ };