Skip to content

Commit ab5a226

Browse files
authored
feat: add Harness plugin hooks (#85)
# What changed Harness now has a first-class plugin system. Users can register Harness plugins in rn-harness.config.*, and those plugins can tap into key lifecycle and runtime events during a test run. # Why this was added Plugins give us a stable extension point for features that should live outside core Harness. Instead of adding plugin-specific config fields or platform APIs directly to Harness, we can build those features as plugins. # What Harness plugins are Harness plugins are user-configured extensions loaded from the Harness config file. A plugin can observe or react to events such as Harness startup, Metro activity, app lifecycle signals, collection, and test execution, and run custom Node.js logic around them. # What this enables This is the foundation for custom integrations such as native coverage collection, reporting, or other workflow-specific extensions, while keeping the core Harness API smaller and cleaner.
1 parent d5f1086 commit ab5a226

32 files changed

+2120
-147
lines changed

actions/shared/index.cjs

Lines changed: 77 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -4208,46 +4208,42 @@ var coerce = {
42084208
};
42094209
var NEVER = INVALID;
42104210

4211-
// ../config/dist/types.js
4212-
var DEFAULT_METRO_PORT = 8081;
4213-
var RunnerSchema = external_exports.object({
4214-
name: external_exports.string().min(1, "Runner name is required").regex(/^[a-zA-Z0-9._-]+$/, "Runner name can only contain alphanumeric characters, dots, underscores, and hyphens"),
4215-
config: external_exports.record(external_exports.any()),
4216-
runner: external_exports.string(),
4217-
platformId: external_exports.string()
4218-
});
4219-
var ConfigSchema = external_exports.object({
4220-
entryPoint: external_exports.string().min(1, "Entry point is required"),
4221-
appRegistryComponentName: external_exports.string().min(1, "App registry component name is required"),
4222-
runners: external_exports.array(RunnerSchema).min(1, "At least one runner is required"),
4223-
defaultRunner: external_exports.string().optional(),
4224-
host: external_exports.string().min(1, "Host is required").optional(),
4225-
metroPort: external_exports.number().int("Metro port must be an integer").min(1, "Metro port must be at least 1").max(65535, "Metro port must be at most 65535").optional().default(DEFAULT_METRO_PORT),
4226-
webSocketPort: external_exports.number().optional().default(3001),
4227-
bridgeTimeout: external_exports.number().min(1e3, "Bridge timeout must be at least 1 second").default(6e4),
4228-
bundleStartTimeout: external_exports.number().min(1e3, "Bundle start timeout must be at least 1 second").default(15e3),
4229-
maxAppRestarts: external_exports.number().min(0, "Max app restarts must be at least 0").default(2),
4230-
resetEnvironmentBetweenTestFiles: external_exports.boolean().optional().default(true),
4231-
unstable__skipAlreadyIncludedModules: external_exports.boolean().optional().default(false),
4232-
unstable__enableMetroCache: external_exports.boolean().optional().default(false),
4233-
detectNativeCrashes: external_exports.boolean().optional().default(true),
4234-
crashDetectionInterval: external_exports.number().min(100, "Crash detection interval must be at least 100ms").default(500),
4235-
disableViewFlattening: external_exports.boolean().optional().default(false).describe("Disable view flattening in React Native. This will set collapsable={true} for all View components to ensure they are not flattened by the native layout engine."),
4236-
coverage: external_exports.object({
4237-
root: external_exports.string().optional().describe(`Root directory for coverage instrumentation in monorepo setups. Specifies the directory from which coverage data should be collected. Use ".." for create-react-native-library projects where tests run from example/ but source files are in parent directory. Passed to babel-plugin-istanbul's cwd option.`)
4238-
}).optional(),
4239-
forwardClientLogs: external_exports.boolean().optional().default(false).describe("Enable forwarding of console.log, console.warn, console.error, and other console method calls from the React Native app to the terminal. When enabled, all console output from your app will be displayed in the test runner terminal with styled level indicators (log, warn, error)."),
4240-
// Deprecated property - used for migration detection
4241-
include: external_exports.array(external_exports.string()).optional()
4242-
}).refine((config) => {
4243-
if (config.defaultRunner) {
4244-
return config.runners.some((runner) => runner.name === config.defaultRunner);
4211+
// ../plugins/dist/utils.js
4212+
var isHookTree = (value) => {
4213+
if (value == null || typeof value !== "object" || Array.isArray(value)) {
4214+
return false;
4215+
}
4216+
for (const child of Object.values(value)) {
4217+
if (child === void 0) {
4218+
continue;
4219+
}
4220+
if (typeof child === "function") {
4221+
continue;
4222+
}
4223+
if (child == null || typeof child !== "object" || Array.isArray(child) || !isHookTree(child)) {
4224+
return false;
4225+
}
42454226
}
42464227
return true;
4247-
}, {
4248-
message: "Default runner must match one of the configured runner names",
4249-
path: ["defaultRunner"]
4250-
});
4228+
};
4229+
4230+
// ../plugins/dist/plugin.js
4231+
var isHarnessPlugin = (value) => {
4232+
if (value == null || typeof value !== "object" || Array.isArray(value)) {
4233+
return false;
4234+
}
4235+
const candidate = value;
4236+
if (typeof candidate.name !== "string" || candidate.name.length === 0) {
4237+
return false;
4238+
}
4239+
if (candidate.createState != null && typeof candidate.createState !== "function") {
4240+
return false;
4241+
}
4242+
if (candidate.hooks != null && !isHookTree(candidate.hooks)) {
4243+
return false;
4244+
}
4245+
return true;
4246+
};
42514247

42524248
// ../tools/dist/logger.js
42534249
var import_node_util2 = __toESM(require("util"), 1);
@@ -4342,6 +4338,49 @@ var import_node_fs4 = __toESM(require("fs"), 1);
43424338
var import_node_path4 = __toESM(require("path"), 1);
43434339
var DEFAULT_ARTIFACT_ROOT = import_node_path4.default.join(process.cwd(), ".harness", "crash-reports");
43444340

4341+
// ../config/dist/types.js
4342+
var DEFAULT_METRO_PORT = 8081;
4343+
var RunnerSchema = external_exports.object({
4344+
name: external_exports.string().min(1, "Runner name is required").regex(/^[a-zA-Z0-9._-]+$/, "Runner name can only contain alphanumeric characters, dots, underscores, and hyphens"),
4345+
config: external_exports.record(external_exports.any()),
4346+
runner: external_exports.string(),
4347+
platformId: external_exports.string()
4348+
});
4349+
var PluginSchema = external_exports.custom((value) => isHarnessPlugin(value), "Invalid Harness plugin");
4350+
var ConfigSchema = external_exports.object({
4351+
entryPoint: external_exports.string().min(1, "Entry point is required"),
4352+
appRegistryComponentName: external_exports.string().min(1, "App registry component name is required"),
4353+
runners: external_exports.array(RunnerSchema).min(1, "At least one runner is required"),
4354+
plugins: external_exports.array(PluginSchema).optional().default([]),
4355+
defaultRunner: external_exports.string().optional(),
4356+
host: external_exports.string().min(1, "Host is required").optional(),
4357+
metroPort: external_exports.number().int("Metro port must be an integer").min(1, "Metro port must be at least 1").max(65535, "Metro port must be at most 65535").optional().default(DEFAULT_METRO_PORT),
4358+
webSocketPort: external_exports.number().optional().default(3001),
4359+
bridgeTimeout: external_exports.number().min(1e3, "Bridge timeout must be at least 1 second").default(6e4),
4360+
bundleStartTimeout: external_exports.number().min(1e3, "Bundle start timeout must be at least 1 second").default(15e3),
4361+
maxAppRestarts: external_exports.number().min(0, "Max app restarts must be at least 0").default(2),
4362+
resetEnvironmentBetweenTestFiles: external_exports.boolean().optional().default(true),
4363+
unstable__skipAlreadyIncludedModules: external_exports.boolean().optional().default(false),
4364+
unstable__enableMetroCache: external_exports.boolean().optional().default(false),
4365+
detectNativeCrashes: external_exports.boolean().optional().default(true),
4366+
crashDetectionInterval: external_exports.number().min(100, "Crash detection interval must be at least 100ms").default(500),
4367+
disableViewFlattening: external_exports.boolean().optional().default(false).describe("Disable view flattening in React Native. This will set collapsable={true} for all View components to ensure they are not flattened by the native layout engine."),
4368+
coverage: external_exports.object({
4369+
root: external_exports.string().optional().describe(`Root directory for coverage instrumentation in monorepo setups. Specifies the directory from which coverage data should be collected. Use ".." for create-react-native-library projects where tests run from example/ but source files are in parent directory. Passed to babel-plugin-istanbul's cwd option.`)
4370+
}).optional(),
4371+
forwardClientLogs: external_exports.boolean().optional().default(false).describe("Enable forwarding of console.log, console.warn, console.error, and other console method calls from the React Native app to the terminal. When enabled, all console output from your app will be displayed in the test runner terminal with styled level indicators (log, warn, error)."),
4372+
// Deprecated property - used for migration detection
4373+
include: external_exports.array(external_exports.string()).optional()
4374+
}).refine((config) => {
4375+
if (config.defaultRunner) {
4376+
return config.runners.some((runner) => runner.name === config.defaultRunner);
4377+
}
4378+
return true;
4379+
}, {
4380+
message: "Default runner must match one of the configured runner names",
4381+
path: ["defaultRunner"]
4382+
});
4383+
43454384
// ../config/dist/errors.js
43464385
var ConfigValidationError = class extends HarnessError {
43474386
filePath;
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
const logWithDetails = (hookName, getDetails) => {
2+
return (ctx) => {
3+
const details = getDetails?.(ctx);
4+
const suffix = details ? ` ${details}` : '';
5+
ctx.logger.info(`${hookName}${suffix}`);
6+
};
7+
};
8+
9+
export const harnessLoggingPlugin = () => ({
10+
name: 'playground-logging',
11+
hooks: {
12+
harness: {
13+
beforeCreation: logWithDetails(
14+
'harness.beforeCreation',
15+
(ctx) => `runner=${ctx.runner.name}`
16+
),
17+
beforeDispose: logWithDetails(
18+
'harness.beforeDispose',
19+
(ctx) => `reason=${ctx.reason ?? 'normal'}`
20+
),
21+
},
22+
run: {
23+
started: logWithDetails(
24+
'run.started',
25+
(ctx) => `runId=${ctx.runId} files=${ctx.testFiles.length}`
26+
),
27+
finished: logWithDetails(
28+
'run.finished',
29+
(ctx) =>
30+
`runId=${ctx.runId} status=${ctx.status} duration=${ctx.duration}ms`
31+
),
32+
},
33+
runtime: {
34+
ready: logWithDetails(
35+
'runtime.ready',
36+
(ctx) => `runId=${ctx.runId} device=${ctx.device.platform}`
37+
),
38+
disconnected: logWithDetails(
39+
'runtime.disconnected',
40+
(ctx) => `runId=${ctx.runId} reason=${ctx.reason ?? 'unknown'}`
41+
),
42+
},
43+
metro: {
44+
initialized: logWithDetails(
45+
'metro.initialized',
46+
(ctx) => `runId=${ctx.runId} port=${ctx.port}`
47+
),
48+
bundleStarted: logWithDetails(
49+
'metro.bundleStarted',
50+
(ctx) => `runId=${ctx.runId} target=${ctx.target} file=${ctx.file}`
51+
),
52+
bundleFinished: logWithDetails(
53+
'metro.bundleFinished',
54+
(ctx) =>
55+
`runId=${ctx.runId} target=${ctx.target} file=${ctx.file} duration=${ctx.duration}ms`
56+
),
57+
bundleFailed: logWithDetails(
58+
'metro.bundleFailed',
59+
(ctx) =>
60+
`runId=${ctx.runId} target=${ctx.target} file=${ctx.file} error=${ctx.error}`
61+
),
62+
clientLog: logWithDetails(
63+
'metro.clientLog',
64+
(ctx) => `runId=${ctx.runId} level=${ctx.level}`
65+
),
66+
},
67+
app: {
68+
started: logWithDetails(
69+
'app.started',
70+
(ctx) => `runId=${ctx.runId} testFile=${ctx.testFile ?? 'n/a'}`
71+
),
72+
exited: logWithDetails(
73+
'app.exited',
74+
(ctx) => `runId=${ctx.runId} testFile=${ctx.testFile ?? 'n/a'}`
75+
),
76+
possibleCrash: logWithDetails(
77+
'app.possibleCrash',
78+
(ctx) => `runId=${ctx.runId} testFile=${ctx.testFile ?? 'n/a'}`
79+
),
80+
},
81+
collection: {
82+
started: logWithDetails(
83+
'collection.started',
84+
(ctx) => `runId=${ctx.runId} file=${ctx.file}`
85+
),
86+
finished: logWithDetails(
87+
'collection.finished',
88+
(ctx) =>
89+
`runId=${ctx.runId} file=${ctx.file} totalTests=${ctx.totalTests}`
90+
),
91+
},
92+
testFile: {
93+
started: logWithDetails(
94+
'testFile.started',
95+
(ctx) => `runId=${ctx.runId} file=${ctx.file}`
96+
),
97+
finished: logWithDetails(
98+
'testFile.finished',
99+
(ctx) =>
100+
`runId=${ctx.runId} file=${ctx.file} status=${ctx.status} duration=${ctx.duration}ms`
101+
),
102+
},
103+
suite: {
104+
started: logWithDetails(
105+
'suite.started',
106+
(ctx) => `runId=${ctx.runId} suite=${ctx.name}`
107+
),
108+
finished: logWithDetails(
109+
'suite.finished',
110+
(ctx) =>
111+
`runId=${ctx.runId} suite=${ctx.name} status=${ctx.status} duration=${ctx.duration}ms`
112+
),
113+
},
114+
test: {
115+
started: logWithDetails(
116+
'test.started',
117+
(ctx) => `runId=${ctx.runId} suite=${ctx.suite} test=${ctx.name}`
118+
),
119+
finished: logWithDetails(
120+
'test.finished',
121+
(ctx) =>
122+
`runId=${ctx.runId} suite=${ctx.suite} test=${ctx.name} status=${ctx.status} duration=${ctx.duration}ms`
123+
),
124+
},
125+
},
126+
});

apps/playground/rn-harness.config.mjs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,12 @@ import {
1717
chromium,
1818
chrome,
1919
} from '@react-native-harness/platform-web';
20+
import { harnessLoggingPlugin } from './harness-logging-plugin.mjs';
2021

2122
export default {
2223
entryPoint: './index.js',
2324
appRegistryComponentName: 'HarnessPlayground',
25+
plugins: [harnessLoggingPlugin()],
2426

2527
runners: [
2628
androidPlatform({
@@ -75,7 +77,7 @@ export default {
7577
}),
7678
applePlatform({
7779
name: 'ios',
78-
device: appleSimulator('iPhone 16 Pro', '18.6'),
80+
device: appleSimulator('iPhone 17 Pro', '26.2'),
7981
bundleId: 'com.harnessplayground',
8082
}),
8183
applePlatform({

packages/bridge/src/shared/test-collector.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export type TestCollectionFinishedEvent = {
3636
type: 'collection-finished';
3737
file: string;
3838
duration: number;
39+
totalTests: number;
3940
};
4041

4142
export type TestCollectorEvents =

packages/config/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
}
1616
},
1717
"dependencies": {
18+
"@react-native-harness/plugins": "workspace:*",
1819
"@react-native-harness/tools": "workspace:*",
1920
"tslib": "^2.3.0",
2021
"zod": "^3.25.67"

packages/config/src/types.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import { z } from 'zod';
2+
import type { HarnessPlugin } from '@react-native-harness/plugins';
3+
import { isHarnessPlugin } from '@react-native-harness/plugins';
24

35
export const DEFAULT_METRO_PORT = 8081;
46

@@ -15,13 +17,21 @@ const RunnerSchema = z.object({
1517
platformId: z.string(),
1618
});
1719

20+
type AnyHarnessPlugin = HarnessPlugin<any, any, any>;
21+
22+
const PluginSchema = z.custom<AnyHarnessPlugin>(
23+
(value) => isHarnessPlugin(value),
24+
'Invalid Harness plugin'
25+
);
26+
1827
export const ConfigSchema = z
1928
.object({
2029
entryPoint: z.string().min(1, 'Entry point is required'),
2130
appRegistryComponentName: z
2231
.string()
2332
.min(1, 'App registry component name is required'),
2433
runners: z.array(RunnerSchema).min(1, 'At least one runner is required'),
34+
plugins: z.array(PluginSchema).optional().default([]),
2535
defaultRunner: z.string().optional(),
2636
host: z.string().min(1, 'Host is required').optional(),
2737
metroPort: z

packages/config/tsconfig.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@
66
{
77
"path": "../tools"
88
},
9+
{
10+
"path": "../plugins"
11+
},
912
{
1013
"path": "./tsconfig.lib.json"
1114
}

packages/config/tsconfig.lib.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@
1313
"references": [
1414
{
1515
"path": "../tools/tsconfig.lib.json"
16+
},
17+
{
18+
"path": "../plugins/tsconfig.lib.json"
1619
}
1720
]
1821
}

packages/jest/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
"@react-native-harness/bridge": "workspace:*",
3838
"@react-native-harness/bundler-metro": "workspace:*",
3939
"@react-native-harness/config": "workspace:*",
40+
"@react-native-harness/plugins": "workspace:*",
4041
"@react-native-harness/platforms": "workspace:*",
4142
"@react-native-harness/tools": "workspace:*",
4243
"chalk": "^4.1.2",

0 commit comments

Comments
 (0)