Skip to content

Commit 04e5937

Browse files
committed
feat: move Harness skills into the CLI
1 parent e307aad commit 04e5937

6 files changed

Lines changed: 381 additions & 169 deletions

File tree

packages/cli/skills/core.md

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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.

packages/cli/skills/mocking.md

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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.

packages/cli/skills/ui.md

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
```

packages/cli/src/index.ts

Lines changed: 118 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,124 @@ import { getConfig } from '@react-native-harness/config';
33
import { runInitWizard } from './wizard/index.js';
44
import fs from 'node:fs';
55
import path from 'node:path';
6+
import { fileURLToPath } from 'node:url';
67

78
const JEST_CONFIG_EXTENSIONS = ['.mjs', '.js', '.cjs'];
89
const JEST_HARNESS_CONFIG_BASE = 'jest.harness.config';
10+
const SKILLS_DIRECTORY = path.resolve(
11+
path.dirname(fileURLToPath(import.meta.url)),
12+
'../skills'
13+
);
14+
15+
type SkillMetadata = {
16+
fileName: string;
17+
name: string;
18+
description: string;
19+
};
20+
21+
const readSkillMetadata = (fileName: string): SkillMetadata => {
22+
const filePath = path.join(SKILLS_DIRECTORY, fileName);
23+
const content = fs.readFileSync(filePath, 'utf8');
24+
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
25+
26+
const metadata = {
27+
name: fileName.replace(/\.md$/, ''),
28+
description: '',
29+
};
30+
31+
if (frontmatterMatch) {
32+
for (const line of frontmatterMatch[1].split('\n')) {
33+
const separatorIndex = line.indexOf(':');
34+
35+
if (separatorIndex === -1) {
36+
continue;
37+
}
38+
39+
const key = line.slice(0, separatorIndex).trim();
40+
const value = line.slice(separatorIndex + 1).trim().replace(/^['"]|['"]$/g, '');
41+
42+
if (key === 'name') {
43+
metadata.name = value;
44+
}
45+
46+
if (key === 'description') {
47+
metadata.description = value;
48+
}
49+
}
50+
}
51+
52+
return {
53+
fileName,
54+
name: metadata.name,
55+
description: metadata.description,
56+
};
57+
};
58+
59+
const listSkills = () =>
60+
fs
61+
.readdirSync(SKILLS_DIRECTORY)
62+
.filter((file) => file.endsWith('.md'))
63+
.map(readSkillMetadata)
64+
.sort((left, right) => left.name.localeCompare(right.name));
65+
66+
const printSkillList = () => {
67+
for (const skill of listSkills()) {
68+
console.log(`${skill.name}: ${skill.description}`);
69+
}
70+
};
71+
72+
const printSkillUsage = () => {
73+
console.log(`Usage: harness skill <command>
74+
75+
Commands:
76+
list List bundled skills
77+
get <name> Print a bundled skill file
78+
79+
Examples:
80+
harness skill list
81+
harness skill get core`);
82+
};
83+
84+
const runSkillCommand = () => {
85+
const [, , commandName, subcommand, skillName] = process.argv;
86+
87+
if (subcommand === undefined || subcommand === 'list') {
88+
printSkillList();
89+
return;
90+
}
91+
92+
if (subcommand === '--help' || subcommand === '-h') {
93+
printSkillUsage();
94+
return;
95+
}
96+
97+
if (subcommand === 'get') {
98+
if (!skillName) {
99+
console.error('Missing skill name.');
100+
printSkillUsage();
101+
process.exit(1);
102+
}
103+
104+
const skillPath = path.join(SKILLS_DIRECTORY, `${skillName}.md`);
105+
106+
if (!fs.existsSync(skillPath)) {
107+
console.error(`Unknown skill '${skillName}'.`);
108+
console.error(
109+
`Available skills: ${listSkills()
110+
.map((skill) => skill.name)
111+
.join(', ')}`
112+
);
113+
process.exit(1);
114+
}
115+
116+
console.log(fs.readFileSync(skillPath, 'utf8'));
117+
return;
118+
}
119+
120+
console.error(`Unknown ${commandName} subcommand '${subcommand}'.`);
121+
printSkillUsage();
122+
process.exit(1);
123+
};
9124

10125
const checkForOldConfig = async () => {
11126
try {
@@ -73,7 +188,9 @@ const patchYargsOptions = () => {
73188
delete yargsOptions.logHeapUsage;
74189
};
75190

76-
if (process.argv.includes('init')) {
191+
if (process.argv[2] === 'skill' || process.argv[2] === 'skills') {
192+
runSkillCommand();
193+
} else if (process.argv.includes('init')) {
77194
runInitWizard();
78195
} else {
79196
patchYargsOptions();

0 commit comments

Comments
 (0)