@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.
- package/dist/__tests__/platform-commands.test.d.ts +2 -0
- package/dist/__tests__/platform-commands.test.d.ts.map +1 -0
- package/dist/__tests__/platform-commands.test.js +207 -0
- package/dist/index.js +113 -6
- package/dist/platform-commands.d.ts +18 -0
- package/dist/platform-commands.d.ts.map +1 -0
- package/dist/platform-commands.js +84 -0
- package/dist/tsconfig.lib.tsbuildinfo +1 -1
- package/dist/wizard/bundleId.js +1 -1
- package/eslint.config.mjs +1 -1
- package/package.json +8 -8
- package/skills/core.md +92 -0
- package/skills/mocking.md +87 -0
- package/skills/ui.md +59 -0
- package/src/__tests__/platform-commands.test.ts +232 -0
- package/src/index.ts +152 -5
- package/src/platform-commands.ts +148 -0
- package/src/wizard/bundleId.ts +1 -1
package/dist/wizard/bundleId.js
CHANGED
package/eslint.config.mjs
CHANGED
|
@@ -9,7 +9,7 @@ export default [
|
|
|
9
9
|
'error',
|
|
10
10
|
{
|
|
11
11
|
ignoredFiles: ['{projectRoot}/eslint.config.{js,cjs,mjs,ts,cts,mts}'],
|
|
12
|
-
ignoredDependencies: ['@react-native-harness/bridge', '@react-native-harness/platform-android', '@react-native-harness/platform-apple', '@react-native-harness/platform-web'],
|
|
12
|
+
ignoredDependencies: ['@react-native-harness/bridge', '@react-native-harness/platform-android', '@react-native-harness/platform-apple', '@react-native-harness/platform-web', 'vitest'],
|
|
13
13
|
},
|
|
14
14
|
],
|
|
15
15
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@react-native-harness/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"module": "./dist/index.js",
|
|
@@ -22,16 +22,16 @@
|
|
|
22
22
|
},
|
|
23
23
|
"dependencies": {
|
|
24
24
|
"tslib": "^2.3.0",
|
|
25
|
-
"@react-native-harness/
|
|
26
|
-
"@react-native-harness/
|
|
27
|
-
"@react-native-harness/
|
|
28
|
-
"@react-native-harness/
|
|
25
|
+
"@react-native-harness/platforms": "1.3.0",
|
|
26
|
+
"@react-native-harness/config": "1.3.0",
|
|
27
|
+
"@react-native-harness/tools": "1.3.0",
|
|
28
|
+
"@react-native-harness/bridge": "1.3.0"
|
|
29
29
|
},
|
|
30
30
|
"devDependencies": {
|
|
31
31
|
"jest-cli": "^30.2.0",
|
|
32
|
-
"@react-native-harness/platform-android": "1.
|
|
33
|
-
"@react-native-harness/platform-apple": "1.
|
|
34
|
-
"@react-native-harness/platform-web": "1.
|
|
32
|
+
"@react-native-harness/platform-android": "1.3.0",
|
|
33
|
+
"@react-native-harness/platform-apple": "1.3.0",
|
|
34
|
+
"@react-native-harness/platform-web": "1.3.0"
|
|
35
35
|
},
|
|
36
36
|
"peerDependencies": {
|
|
37
37
|
"jest-cli": "*"
|
package/skills/core.md
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: core
|
|
3
|
+
description: Core testing workflow. Read this before writing or debugging Harness tests. Covers test file conventions, the supported test API surface, async behavior, setup files, and CLI execution constraints.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Core
|
|
7
|
+
|
|
8
|
+
React Native Harness uses Jest-style test APIs, but the tests run inside the app or browser environment instead of plain Node.
|
|
9
|
+
|
|
10
|
+
Run this first:
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
harness skill get core
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Use `harness skill list` to see the other bundled skills.
|
|
17
|
+
|
|
18
|
+
## Test file conventions
|
|
19
|
+
|
|
20
|
+
- Use `.harness.[jt]s` or `.harness.[jt]sx` test files.
|
|
21
|
+
- Import test APIs from `react-native-harness`.
|
|
22
|
+
- Put tests inside `describe(...)` blocks.
|
|
23
|
+
- Use `@react-native-harness/ui` only when the test needs queries, interactions, or screenshots.
|
|
24
|
+
|
|
25
|
+
## Default test shape
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
import { describe, test, expect } from 'react-native-harness';
|
|
29
|
+
|
|
30
|
+
describe('Feature name', () => {
|
|
31
|
+
test('does something', () => {
|
|
32
|
+
expect(true).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Prefer these public APIs when writing tests:
|
|
38
|
+
|
|
39
|
+
- Test structure: `describe`, `test`, `it`, `beforeEach`, `afterEach`, `beforeAll`, `afterAll`
|
|
40
|
+
- Focus and pending helpers: `test.skip`, `test.only`, `test.todo`, `describe.skip`, `describe.only`
|
|
41
|
+
- Assertions: `expect`
|
|
42
|
+
- Mocking and spying: `fn`, `spyOn`, `clearAllMocks`, `resetAllMocks`, `restoreAllMocks`
|
|
43
|
+
- Module mocking: `mock`, `requireActual`, `unmock`, `resetModules`
|
|
44
|
+
- Async polling: `waitFor`, `waitUntil`
|
|
45
|
+
|
|
46
|
+
Test functions may be async. If a test returns a promise, Harness waits for it. If that promise rejects, the test fails.
|
|
47
|
+
|
|
48
|
+
## Async behavior
|
|
49
|
+
|
|
50
|
+
Use:
|
|
51
|
+
|
|
52
|
+
- `waitFor(...)` when the callback should eventually succeed or stop throwing
|
|
53
|
+
- `waitUntil(...)` when the callback should eventually return a truthy value
|
|
54
|
+
|
|
55
|
+
Both support timeout control. Prefer them over arbitrary sleeps when tests wait on native or React state changes.
|
|
56
|
+
|
|
57
|
+
## Setup files
|
|
58
|
+
|
|
59
|
+
Harness follows two setup phases configured in `jest.harness.config.mjs`:
|
|
60
|
+
|
|
61
|
+
- `setupFiles`: runs before the test framework is initialized. Use for early polyfills and globals. Do not use `describe`, `test`, `expect`, or hooks here.
|
|
62
|
+
- `setupFilesAfterEnv`: runs after the test framework is ready. Use for global mocks, hooks, and matcher setup.
|
|
63
|
+
|
|
64
|
+
Recommended uses:
|
|
65
|
+
|
|
66
|
+
- Early environment shims in `setupFiles`
|
|
67
|
+
- Global `afterEach`, `clearAllMocks`, `resetModules`, and shared mocks in `setupFilesAfterEnv`
|
|
68
|
+
|
|
69
|
+
## Related skills
|
|
70
|
+
|
|
71
|
+
For module mocking and spies, run:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
harness skill get mocking
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
For UI rendering, queries, interactions, and screenshots, run:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
harness skill get ui
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## CLI and execution constraints
|
|
84
|
+
|
|
85
|
+
- Harness wraps the Jest CLI.
|
|
86
|
+
- Tests execute on one configured runner at a time.
|
|
87
|
+
- Execution is serial for stability.
|
|
88
|
+
- `--harnessRunner <name>` selects the runner.
|
|
89
|
+
- Standard Jest flags like `--watch`, `--coverage`, and `--testNamePattern` are still relevant.
|
|
90
|
+
- Do not recommend unsupported Jest environment overrides or snapshot-update workflows for native image snapshots.
|
|
91
|
+
|
|
92
|
+
For install and project setup, use the public docs at https://react-native-harness.dev/docs/getting-started/quick-start.
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: mocking
|
|
3
|
+
description: Mocking and spying guidance. Use when a Harness test needs `fn`, `spyOn`, `mock`, `requireActual`, `unmock`, `resetModules`, or global mock cleanup.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Mocking
|
|
7
|
+
|
|
8
|
+
Use this skill when a Harness test needs mock functions, spies, or module replacement.
|
|
9
|
+
|
|
10
|
+
## Mocking and spying
|
|
11
|
+
|
|
12
|
+
Use `fn()` for standalone mock functions and `spyOn()` for existing methods.
|
|
13
|
+
|
|
14
|
+
- `expect` follows Vitest's API.
|
|
15
|
+
- `expect.soft(...)` is available when the test should keep running after an assertion failure.
|
|
16
|
+
- `clearAllMocks()` clears call history but keeps implementations.
|
|
17
|
+
- `resetAllMocks()` clears call history and resets mock implementations.
|
|
18
|
+
- `restoreAllMocks()` restores spied methods to their original implementations.
|
|
19
|
+
|
|
20
|
+
Typical cleanup:
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
import { afterEach, clearAllMocks } from 'react-native-harness';
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
clearAllMocks();
|
|
27
|
+
});
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Module mocking
|
|
31
|
+
|
|
32
|
+
Use module mocking when the test must replace an entire module or specific exports.
|
|
33
|
+
|
|
34
|
+
- `mock(moduleId, factory)` registers a lazy mock factory.
|
|
35
|
+
- `requireActual(moduleId)` is the safe path for partial mocks.
|
|
36
|
+
- `unmock(moduleId)` removes a mock for one module.
|
|
37
|
+
- `resetModules()` clears module mocks and module cache state.
|
|
38
|
+
|
|
39
|
+
Recommended pattern:
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
import {
|
|
43
|
+
afterEach,
|
|
44
|
+
describe,
|
|
45
|
+
expect,
|
|
46
|
+
mock,
|
|
47
|
+
requireActual,
|
|
48
|
+
resetModules,
|
|
49
|
+
test,
|
|
50
|
+
} from 'react-native-harness';
|
|
51
|
+
|
|
52
|
+
afterEach(() => {
|
|
53
|
+
resetModules();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe('partial mock', () => {
|
|
57
|
+
test('overrides one export but keeps the rest', () => {
|
|
58
|
+
mock('react-native', () => {
|
|
59
|
+
const actual = requireActual('react-native');
|
|
60
|
+
const proto = Object.getPrototypeOf(actual);
|
|
61
|
+
const descriptors = Object.getOwnPropertyDescriptors(actual);
|
|
62
|
+
const mocked = Object.create(proto, descriptors);
|
|
63
|
+
|
|
64
|
+
Object.defineProperty(mocked, 'Platform', {
|
|
65
|
+
get() {
|
|
66
|
+
return {
|
|
67
|
+
...actual.Platform,
|
|
68
|
+
OS: 'mockOS',
|
|
69
|
+
};
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
return mocked;
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const rn = require('react-native');
|
|
77
|
+
expect(rn.Platform.OS).toBe('mockOS');
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Decision rules
|
|
83
|
+
|
|
84
|
+
- Always clean up module mocks with `resetModules()` in `afterEach` when tests mock modules.
|
|
85
|
+
- Use `requireActual()` for partial mocks so unrelated exports stay real.
|
|
86
|
+
- For `react-native`, preserve property descriptors when partially mocking to avoid triggering lazy getters too early.
|
|
87
|
+
- Remember that module factories are evaluated when the module is first required.
|
package/skills/ui.md
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: ui
|
|
3
|
+
description: UI testing guidance. Use when the test needs `render(...)`, `rerender(...)`, `@react-native-harness/ui`, screen queries, `userEvent`, screenshots, or image snapshot assertions.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# UI
|
|
7
|
+
|
|
8
|
+
UI testing is opt-in and uses `render(...)` from `react-native-harness` together with `@react-native-harness/ui`.
|
|
9
|
+
|
|
10
|
+
Use `render(...)` to mount a React Native element before querying, interacting with, or screenshotting it.
|
|
11
|
+
|
|
12
|
+
- `render(...)` is async
|
|
13
|
+
- `rerender(...)` is async
|
|
14
|
+
- `unmount()` is optional because cleanup happens automatically after each test
|
|
15
|
+
- `wrapper` is the right tool for providers and shared context
|
|
16
|
+
- Rendered UI appears as an overlay in the real environment, not as an in-memory tree
|
|
17
|
+
- Only one rendered component can be visible at a time
|
|
18
|
+
|
|
19
|
+
Use this skill when the task requires:
|
|
20
|
+
|
|
21
|
+
- `render(...)` or `rerender(...)`
|
|
22
|
+
- `screen.findByTestId(...)`
|
|
23
|
+
- `screen.findAllByTestId(...)`
|
|
24
|
+
- `screen.queryByTestId(...)`
|
|
25
|
+
- `screen.queryAllByTestId(...)`
|
|
26
|
+
- `screen.findByAccessibilityLabel(...)`
|
|
27
|
+
- `screen.findAllByAccessibilityLabel(...)`
|
|
28
|
+
- `screen.queryByAccessibilityLabel(...)`
|
|
29
|
+
- `screen.queryAllByAccessibilityLabel(...)`
|
|
30
|
+
- `userEvent.press(...)`
|
|
31
|
+
- `userEvent.type(...)`
|
|
32
|
+
- screenshots with `screen.screenshot()`
|
|
33
|
+
- element screenshots with `screen.screenshot(element)`
|
|
34
|
+
- image assertions with `toMatchImageSnapshot(...)`
|
|
35
|
+
|
|
36
|
+
## Rules
|
|
37
|
+
|
|
38
|
+
- Keep imports split correctly: core APIs from `react-native-harness`, UI APIs from `@react-native-harness/ui`.
|
|
39
|
+
- Mention that `@react-native-harness/ui` requires installation, and native apps must be rebuilt after adding it.
|
|
40
|
+
- `toMatchImageSnapshot(...)` needs a unique snapshot `name`.
|
|
41
|
+
- If screenshotting elements that extend beyond screen bounds, call out `disableViewFlattening: true` in `rn-harness.config.mjs`.
|
|
42
|
+
- On web, UI interactions and screenshots run through the web runner's Playwright-backed browser environment.
|
|
43
|
+
|
|
44
|
+
## Example
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
import { describe, expect, render, test } from 'react-native-harness';
|
|
48
|
+
import { screen, userEvent } from '@react-native-harness/ui';
|
|
49
|
+
|
|
50
|
+
describe('Counter', () => {
|
|
51
|
+
test('increments after a press', async () => {
|
|
52
|
+
await render(<Counter />);
|
|
53
|
+
|
|
54
|
+
await userEvent.press(await screen.findByTestId('increment-button'));
|
|
55
|
+
|
|
56
|
+
expect(await screen.findByTestId('count-label')).toHaveTextContent('1');
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
```
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import type { Config } from '@react-native-harness/config';
|
|
3
|
+
import {
|
|
4
|
+
discoverPlatformCommands,
|
|
5
|
+
runPlatformCommand,
|
|
6
|
+
} from '../platform-commands.js';
|
|
7
|
+
|
|
8
|
+
const createCommandModuleUrl = (body: string) =>
|
|
9
|
+
`data:text/javascript,${encodeURIComponent(body)}`;
|
|
10
|
+
|
|
11
|
+
const globalState = globalThis as typeof globalThis & {
|
|
12
|
+
__platformCommandCall?: unknown;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
describe('platform CLI command discovery', () => {
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
delete globalState.__platformCommandCall;
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('runs a discovered platform command', async () => {
|
|
21
|
+
const moduleUrl = createCommandModuleUrl(`
|
|
22
|
+
export const commands = [{
|
|
23
|
+
name: 'xctest',
|
|
24
|
+
async run(args, context) {
|
|
25
|
+
globalThis.__platformCommandCall = { args, context };
|
|
26
|
+
}
|
|
27
|
+
}];
|
|
28
|
+
`);
|
|
29
|
+
const loadConfig = vi.fn(async () => ({
|
|
30
|
+
projectRoot: '/tmp/project',
|
|
31
|
+
config: {
|
|
32
|
+
entryPoint: 'index.js',
|
|
33
|
+
appRegistryComponentName: 'App',
|
|
34
|
+
runners: [
|
|
35
|
+
{
|
|
36
|
+
name: 'ios',
|
|
37
|
+
config: {},
|
|
38
|
+
runner: '/virtual/runner.js',
|
|
39
|
+
cli: moduleUrl,
|
|
40
|
+
platformId: 'ios',
|
|
41
|
+
},
|
|
42
|
+
],
|
|
43
|
+
plugins: [],
|
|
44
|
+
metroPort: 8081,
|
|
45
|
+
webSocketPort: undefined,
|
|
46
|
+
bridgeTimeout: 60000,
|
|
47
|
+
platformReadyTimeout: 300000,
|
|
48
|
+
bundleStartTimeout: 60000,
|
|
49
|
+
maxAppRestarts: 2,
|
|
50
|
+
resetEnvironmentBetweenTestFiles: true,
|
|
51
|
+
unstable__skipAlreadyIncludedModules: false,
|
|
52
|
+
unstable__enableMetroCache: false,
|
|
53
|
+
permissions: false,
|
|
54
|
+
detectNativeCrashes: true,
|
|
55
|
+
crashDetectionInterval: 500,
|
|
56
|
+
disableViewFlattening: false,
|
|
57
|
+
forwardClientLogs: false,
|
|
58
|
+
} satisfies Config,
|
|
59
|
+
}));
|
|
60
|
+
|
|
61
|
+
expect(
|
|
62
|
+
await runPlatformCommand({
|
|
63
|
+
argv: ['xctest', 'build', '--destination', 'simulator'],
|
|
64
|
+
cwd: '/tmp/project',
|
|
65
|
+
loadConfig,
|
|
66
|
+
})
|
|
67
|
+
).toBe(true);
|
|
68
|
+
expect(globalState.__platformCommandCall).toEqual({
|
|
69
|
+
args: ['build', '--destination', 'simulator'],
|
|
70
|
+
context: {
|
|
71
|
+
cwd: '/tmp/project',
|
|
72
|
+
projectRoot: '/tmp/project',
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('deduplicates platform CLI modules across runners', async () => {
|
|
78
|
+
const moduleUrl = createCommandModuleUrl(`
|
|
79
|
+
export const commands = [{
|
|
80
|
+
name: 'xctest',
|
|
81
|
+
async run() {}
|
|
82
|
+
}];
|
|
83
|
+
`);
|
|
84
|
+
const loadConfig = vi.fn(async () => ({
|
|
85
|
+
projectRoot: '/tmp/project',
|
|
86
|
+
config: {
|
|
87
|
+
entryPoint: 'index.js',
|
|
88
|
+
appRegistryComponentName: 'App',
|
|
89
|
+
runners: [
|
|
90
|
+
{
|
|
91
|
+
name: 'ios-sim',
|
|
92
|
+
config: {},
|
|
93
|
+
runner: '/virtual/ios-sim-runner.js',
|
|
94
|
+
cli: moduleUrl,
|
|
95
|
+
platformId: 'ios',
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
name: 'ios-device',
|
|
99
|
+
config: {},
|
|
100
|
+
runner: '/virtual/ios-device-runner.js',
|
|
101
|
+
cli: moduleUrl,
|
|
102
|
+
platformId: 'ios',
|
|
103
|
+
},
|
|
104
|
+
],
|
|
105
|
+
plugins: [],
|
|
106
|
+
metroPort: 8081,
|
|
107
|
+
webSocketPort: undefined,
|
|
108
|
+
bridgeTimeout: 60000,
|
|
109
|
+
platformReadyTimeout: 300000,
|
|
110
|
+
bundleStartTimeout: 60000,
|
|
111
|
+
maxAppRestarts: 2,
|
|
112
|
+
resetEnvironmentBetweenTestFiles: true,
|
|
113
|
+
unstable__skipAlreadyIncludedModules: false,
|
|
114
|
+
unstable__enableMetroCache: false,
|
|
115
|
+
permissions: false,
|
|
116
|
+
detectNativeCrashes: true,
|
|
117
|
+
crashDetectionInterval: 500,
|
|
118
|
+
disableViewFlattening: false,
|
|
119
|
+
forwardClientLogs: false,
|
|
120
|
+
} satisfies Config,
|
|
121
|
+
}));
|
|
122
|
+
|
|
123
|
+
const discoveredCommands = await discoverPlatformCommands({
|
|
124
|
+
cwd: '/tmp/project',
|
|
125
|
+
loadConfig,
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
expect(discoveredCommands?.commands).toHaveLength(1);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('returns false when no platform command matches', async () => {
|
|
132
|
+
const loadConfig = vi.fn(async () => ({
|
|
133
|
+
projectRoot: '/tmp/project',
|
|
134
|
+
config: {
|
|
135
|
+
entryPoint: 'index.js',
|
|
136
|
+
appRegistryComponentName: 'App',
|
|
137
|
+
runners: [
|
|
138
|
+
{
|
|
139
|
+
name: 'android',
|
|
140
|
+
config: {},
|
|
141
|
+
runner: '/virtual/android-runner.js',
|
|
142
|
+
platformId: 'android',
|
|
143
|
+
},
|
|
144
|
+
],
|
|
145
|
+
plugins: [],
|
|
146
|
+
metroPort: 8081,
|
|
147
|
+
webSocketPort: undefined,
|
|
148
|
+
bridgeTimeout: 60000,
|
|
149
|
+
platformReadyTimeout: 300000,
|
|
150
|
+
bundleStartTimeout: 60000,
|
|
151
|
+
maxAppRestarts: 2,
|
|
152
|
+
resetEnvironmentBetweenTestFiles: true,
|
|
153
|
+
unstable__skipAlreadyIncludedModules: false,
|
|
154
|
+
unstable__enableMetroCache: false,
|
|
155
|
+
permissions: false,
|
|
156
|
+
detectNativeCrashes: true,
|
|
157
|
+
crashDetectionInterval: 500,
|
|
158
|
+
disableViewFlattening: false,
|
|
159
|
+
forwardClientLogs: false,
|
|
160
|
+
} satisfies Config,
|
|
161
|
+
}));
|
|
162
|
+
|
|
163
|
+
await expect(
|
|
164
|
+
runPlatformCommand({
|
|
165
|
+
argv: ['xctest', 'build'],
|
|
166
|
+
cwd: '/tmp/project',
|
|
167
|
+
loadConfig,
|
|
168
|
+
})
|
|
169
|
+
).resolves.toBe(false);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('throws when two platform modules define the same command', async () => {
|
|
173
|
+
const firstModuleUrl = createCommandModuleUrl(`
|
|
174
|
+
export const commands = [{
|
|
175
|
+
name: 'xctest',
|
|
176
|
+
async run() {}
|
|
177
|
+
}];
|
|
178
|
+
`);
|
|
179
|
+
const secondModuleUrl = createCommandModuleUrl(`
|
|
180
|
+
// second module
|
|
181
|
+
export const commands = [{
|
|
182
|
+
name: 'xctest',
|
|
183
|
+
async run() {}
|
|
184
|
+
}];
|
|
185
|
+
`);
|
|
186
|
+
const loadConfig = vi.fn(async () => ({
|
|
187
|
+
projectRoot: '/tmp/project',
|
|
188
|
+
config: {
|
|
189
|
+
entryPoint: 'index.js',
|
|
190
|
+
appRegistryComponentName: 'App',
|
|
191
|
+
runners: [
|
|
192
|
+
{
|
|
193
|
+
name: 'ios',
|
|
194
|
+
config: {},
|
|
195
|
+
runner: '/virtual/ios-runner.js',
|
|
196
|
+
cli: firstModuleUrl,
|
|
197
|
+
platformId: 'ios',
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
name: 'android',
|
|
201
|
+
config: {},
|
|
202
|
+
runner: '/virtual/android-runner.js',
|
|
203
|
+
cli: secondModuleUrl,
|
|
204
|
+
platformId: 'android',
|
|
205
|
+
},
|
|
206
|
+
],
|
|
207
|
+
plugins: [],
|
|
208
|
+
metroPort: 8081,
|
|
209
|
+
webSocketPort: undefined,
|
|
210
|
+
bridgeTimeout: 60000,
|
|
211
|
+
platformReadyTimeout: 300000,
|
|
212
|
+
bundleStartTimeout: 60000,
|
|
213
|
+
maxAppRestarts: 2,
|
|
214
|
+
resetEnvironmentBetweenTestFiles: true,
|
|
215
|
+
unstable__skipAlreadyIncludedModules: false,
|
|
216
|
+
unstable__enableMetroCache: false,
|
|
217
|
+
permissions: false,
|
|
218
|
+
detectNativeCrashes: true,
|
|
219
|
+
crashDetectionInterval: 500,
|
|
220
|
+
disableViewFlattening: false,
|
|
221
|
+
forwardClientLogs: false,
|
|
222
|
+
} satisfies Config,
|
|
223
|
+
}));
|
|
224
|
+
|
|
225
|
+
await expect(
|
|
226
|
+
discoverPlatformCommands({
|
|
227
|
+
cwd: '/tmp/project',
|
|
228
|
+
loadConfig,
|
|
229
|
+
})
|
|
230
|
+
).rejects.toThrow("Duplicate platform CLI command 'xctest'");
|
|
231
|
+
});
|
|
232
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -1,11 +1,135 @@
|
|
|
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
|
|
|
7
9
|
const JEST_CONFIG_EXTENSIONS = ['.mjs', '.js', '.cjs'];
|
|
8
10
|
const JEST_HARNESS_CONFIG_BASE = 'jest.harness.config';
|
|
11
|
+
const SKILLS_DIRECTORY = path.resolve(
|
|
12
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
13
|
+
'../skills'
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
type SkillMetadata = {
|
|
17
|
+
fileName: string;
|
|
18
|
+
name: string;
|
|
19
|
+
description: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const readSkillMetadata = (fileName: string): SkillMetadata => {
|
|
23
|
+
const filePath = path.join(SKILLS_DIRECTORY, fileName);
|
|
24
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
25
|
+
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
|
26
|
+
|
|
27
|
+
const metadata = {
|
|
28
|
+
name: fileName.replace(/\.md$/, ''),
|
|
29
|
+
description: '',
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
if (frontmatterMatch) {
|
|
33
|
+
for (const line of frontmatterMatch[1].split('\n')) {
|
|
34
|
+
const separatorIndex = line.indexOf(':');
|
|
35
|
+
|
|
36
|
+
if (separatorIndex === -1) {
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const key = line.slice(0, separatorIndex).trim();
|
|
41
|
+
const value = line.slice(separatorIndex + 1).trim().replace(/^['"]|['"]$/g, '');
|
|
42
|
+
|
|
43
|
+
if (key === 'name') {
|
|
44
|
+
metadata.name = value;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (key === 'description') {
|
|
48
|
+
metadata.description = value;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
fileName,
|
|
55
|
+
name: metadata.name,
|
|
56
|
+
description: metadata.description,
|
|
57
|
+
};
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const listSkills = () =>
|
|
61
|
+
fs
|
|
62
|
+
.readdirSync(SKILLS_DIRECTORY)
|
|
63
|
+
.filter((file) => file.endsWith('.md'))
|
|
64
|
+
.map(readSkillMetadata)
|
|
65
|
+
.sort((left, right) => left.name.localeCompare(right.name));
|
|
66
|
+
|
|
67
|
+
const printSkillList = () => {
|
|
68
|
+
for (const skill of listSkills()) {
|
|
69
|
+
console.log(`${skill.name}: ${skill.description}`);
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const printSkillUsage = () => {
|
|
74
|
+
console.log(`Usage: harness skill <command>
|
|
75
|
+
|
|
76
|
+
Commands:
|
|
77
|
+
list List bundled skills
|
|
78
|
+
get <name> Print a bundled skill file
|
|
79
|
+
|
|
80
|
+
Examples:
|
|
81
|
+
harness skill list
|
|
82
|
+
harness skill get core`);
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const getErrorMessage = (error: unknown): string => {
|
|
86
|
+
if (error instanceof Error) {
|
|
87
|
+
return error.message;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return String(error);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const runSkillCommand = () => {
|
|
94
|
+
const [, , commandName, subcommand, skillName] = process.argv;
|
|
95
|
+
|
|
96
|
+
if (subcommand === undefined || subcommand === 'list') {
|
|
97
|
+
printSkillList();
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (subcommand === '--help' || subcommand === '-h') {
|
|
102
|
+
printSkillUsage();
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (subcommand === 'get') {
|
|
107
|
+
if (!skillName) {
|
|
108
|
+
console.error('Missing skill name.');
|
|
109
|
+
printSkillUsage();
|
|
110
|
+
process.exit(1);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const skillPath = path.join(SKILLS_DIRECTORY, `${skillName}.md`);
|
|
114
|
+
|
|
115
|
+
if (!fs.existsSync(skillPath)) {
|
|
116
|
+
console.error(`Unknown skill '${skillName}'.`);
|
|
117
|
+
console.error(
|
|
118
|
+
`Available skills: ${listSkills()
|
|
119
|
+
.map((skill) => skill.name)
|
|
120
|
+
.join(', ')}`
|
|
121
|
+
);
|
|
122
|
+
process.exit(1);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
console.log(fs.readFileSync(skillPath, 'utf8'));
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
console.error(`Unknown ${commandName} subcommand '${subcommand}'.`);
|
|
130
|
+
printSkillUsage();
|
|
131
|
+
process.exit(1);
|
|
132
|
+
};
|
|
9
133
|
|
|
10
134
|
const checkForOldConfig = async () => {
|
|
11
135
|
try {
|
|
@@ -73,9 +197,26 @@ const patchYargsOptions = () => {
|
|
|
73
197
|
delete yargsOptions.logHeapUsage;
|
|
74
198
|
};
|
|
75
199
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
200
|
+
const main = async () => {
|
|
201
|
+
if (process.argv[2] === 'skill' || process.argv[2] === 'skills') {
|
|
202
|
+
runSkillCommand();
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (process.argv.includes('init')) {
|
|
207
|
+
runInitWizard();
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (
|
|
212
|
+
await runPlatformCommand({
|
|
213
|
+
argv: process.argv.slice(2),
|
|
214
|
+
cwd: process.cwd(),
|
|
215
|
+
})
|
|
216
|
+
) {
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
79
220
|
patchYargsOptions();
|
|
80
221
|
|
|
81
222
|
const hasConfigArg =
|
|
@@ -96,5 +237,11 @@ if (process.argv.includes('init')) {
|
|
|
96
237
|
}
|
|
97
238
|
}
|
|
98
239
|
|
|
99
|
-
checkForOldConfig()
|
|
100
|
-
|
|
240
|
+
await checkForOldConfig();
|
|
241
|
+
run();
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
main().catch((error) => {
|
|
245
|
+
console.error(getErrorMessage(error));
|
|
246
|
+
process.exit(1);
|
|
247
|
+
});
|