@react-native-harness/bridge 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/src/server.ts CHANGED
@@ -1,6 +1,5 @@
1
1
  import { WebSocketServer, type WebSocket } from 'ws';
2
- import { type BirpcGroup, createBirpcGroup } from 'birpc';
3
- import { logger } from '@react-native-harness/tools';
2
+ import { createBirpc, type BirpcReturn } from 'birpc';
4
3
  import { EventEmitter } from 'node:events';
5
4
  import type { Server as HttpServer } from 'node:http';
6
5
  import type { Server as HttpsServer } from 'node:https';
@@ -8,231 +7,276 @@ import fs from 'node:fs/promises';
8
7
  import os from 'node:os';
9
8
  import path from 'node:path';
10
9
  import { randomUUID } from 'node:crypto';
10
+ import { logger } from '@react-native-harness/tools';
11
11
  import { BinaryStore, parseBinaryFrame } from './binary-transfer.js';
12
+ import { deserialize, serialize } from './serializer.js';
13
+ import { DeviceNotRespondingError } from './errors.js';
14
+ import { matchImageSnapshot } from './image-snapshot.js';
12
15
  import type {
13
16
  BridgeServerFunctions,
14
17
  BridgeClientFunctions,
15
18
  DeviceDescriptor,
16
19
  BridgeEvents,
17
- ImageSnapshotOptions,
18
- HarnessContext,
19
20
  BinaryDataReference,
20
21
  FileReference,
22
+ HarnessContext,
23
+ TestExecutionOptions,
24
+ TestSuiteResult,
21
25
  } from './shared.js';
22
- import { deserialize, serialize } from './serializer.js';
23
- import { DeviceNotRespondingError } from './errors.js';
24
- import { matchImageSnapshot } from './image-snapshot.js';
25
26
 
26
27
  export { DeviceNotRespondingError } from './errors.js';
28
+
27
29
  const bridgeLogger = logger.child('bridge');
28
30
 
29
- type BridgeServerStandaloneOptions = {
30
- port: number;
31
- host?: string;
32
- };
31
+ // ---------------------------------------------------------------------------
32
+ // Public types
33
+ // ---------------------------------------------------------------------------
33
34
 
34
- type BridgeServerAttachedOptions = {
35
- server: HttpServer | HttpsServer;
36
- path?: string;
35
+ /**
36
+ * Represents a single app session — one app launch to the next restart.
37
+ * Obtained via HarnessBridge.nextConnection().
38
+ */
39
+ export type AppConnection = {
40
+ readonly device: DeviceDescriptor;
41
+ runTests: (path: string, options: TestExecutionOptions) => Promise<TestSuiteResult>;
37
42
  };
38
43
 
39
- type BridgeServerNoServerOptions = {
40
- noServer: true;
44
+ export type HarnessBridgeEvents = {
45
+ /** Fired when the app connects and calls reportReady. */
46
+ connected: (connection: AppConnection) => void;
47
+ /** Fired when the app's WebSocket closes. */
48
+ disconnected: () => void;
49
+ /** Fired for every test/bundler event the app emits. */
50
+ event: (event: BridgeEvents) => void;
41
51
  };
42
52
 
43
- export type BridgeServerOptions = (
44
- | BridgeServerStandaloneOptions
45
- | BridgeServerAttachedOptions
46
- | BridgeServerNoServerOptions
47
- ) & {
53
+ type TransportOptions =
54
+ | { noServer: true }
55
+ | { port: number; host?: string }
56
+ | { server: HttpServer | HttpsServer; path?: string };
57
+
58
+ export type HarnessBridgeOptions = TransportOptions & {
48
59
  timeout?: number;
49
60
  context: HarnessContext;
50
61
  };
51
62
 
52
- export type BridgeServerEvents = {
53
- ready: (device: DeviceDescriptor) => void;
54
- event: (event: BridgeEvents) => void;
55
- disconnect: () => void;
56
- };
57
-
58
- export type BridgeServer = {
59
- ws: WebSocketServer;
60
- rpc: BirpcGroup<BridgeClientFunctions, BridgeServerFunctions>;
61
- on: <T extends keyof BridgeServerEvents>(
62
- event: T,
63
- listener: BridgeServerEvents[T]
64
- ) => void;
65
- once: <T extends keyof BridgeServerEvents>(
66
- event: T,
67
- listener: BridgeServerEvents[T]
68
- ) => void;
69
- off: <T extends keyof BridgeServerEvents>(
70
- event: T,
71
- listener: BridgeServerEvents[T]
72
- ) => void;
63
+ /**
64
+ * The persistent CLI-side bridge. Spans the full test run regardless of how
65
+ * many times the app is restarted. Each restart produces a new AppConnection
66
+ * via nextConnection().
67
+ */
68
+ export type HarnessBridge = {
69
+ /** The underlying WebSocket server, used to attach to Metro's HTTP server. */
70
+ readonly ws: WebSocketServer;
71
+ /** The currently active app connection, null if the app is not connected. */
72
+ readonly connection: AppConnection | null;
73
+ /**
74
+ * Resolves with the next AppConnection once the app connects and reports
75
+ * ready. Register this waiter before restarting the app so no ready signal
76
+ * is missed. Rejects if the supplied signal is aborted.
77
+ */
78
+ nextConnection: (signal?: AbortSignal) => Promise<AppConnection>;
79
+ on: <T extends keyof HarnessBridgeEvents>(event: T, listener: HarnessBridgeEvents[T]) => void;
80
+ off: <T extends keyof HarnessBridgeEvents>(event: T, listener: HarnessBridgeEvents[T]) => void;
73
81
  dispose: () => void;
74
82
  };
75
83
 
76
- export const getBridgeServer = async ({
77
- timeout,
78
- context,
79
- ...transport
80
- }: BridgeServerOptions): Promise<BridgeServer> => {
81
- const wss =
82
- 'port' in transport
83
- ? await new Promise<WebSocketServer>((resolve) => {
84
- const server = new WebSocketServer(
85
- {
86
- port: transport.port,
87
- host: transport.host ?? '0.0.0.0',
88
- },
89
- () => {
90
- resolve(server);
91
- }
92
- );
93
- })
94
- : new WebSocketServer(
95
- 'server' in transport
96
- ? {
97
- server: transport.server,
98
- path: transport.path,
99
- }
100
- : {
101
- noServer: true,
102
- }
103
- );
84
+ // ---------------------------------------------------------------------------
85
+ // Helpers
86
+ // ---------------------------------------------------------------------------
87
+
88
+ const createWss = (transport: TransportOptions): Promise<WebSocketServer> => {
104
89
  if ('port' in transport) {
105
- bridgeLogger.debug('bridge server listening on port %d', transport.port);
106
- } else if ('server' in transport) {
107
- bridgeLogger.debug(
108
- 'bridge server attached to existing HTTP server at path %s',
109
- transport.path ?? '/'
90
+ return new Promise<WebSocketServer>((resolve) => {
91
+ const wss: WebSocketServer = new WebSocketServer(
92
+ { port: transport.port, host: transport.host ?? '0.0.0.0' },
93
+ () => resolve(wss),
94
+ );
95
+ });
96
+ }
97
+ return Promise.resolve<WebSocketServer>(
98
+ new WebSocketServer(
99
+ 'server' in transport
100
+ ? { server: transport.server, path: transport.path }
101
+ : { noServer: true },
102
+ ),
103
+ );
104
+ };
105
+
106
+ const receiveScreenshot = async (
107
+ binaryStore: BinaryStore,
108
+ reference: BinaryDataReference,
109
+ ): Promise<FileReference> => {
110
+ const data = binaryStore.get(reference.transferId);
111
+ if (!data) {
112
+ throw new Error(
113
+ `Binary data for transfer ${reference.transferId} not found or expired`,
110
114
  );
111
- } else {
112
- bridgeLogger.debug('bridge server created in noServer mode');
113
115
  }
114
- const emitter = new EventEmitter();
115
- const clients = new Set<WebSocket>();
116
- const binaryStore = new BinaryStore();
116
+ binaryStore.delete(reference.transferId);
117
+ const file = path.join(os.tmpdir(), `harness-screenshot-${randomUUID()}.png`);
118
+ await fs.writeFile(file, data);
119
+ return { path: file };
120
+ };
117
121
 
118
- const baseFunctions: BridgeServerFunctions = {
119
- reportReady: (device) => {
120
- emitter.emit('ready', device);
121
- },
122
- emitEvent: (_, data) => {
123
- emitter.emit('event', data);
124
- },
125
- 'device.screenshot.receive': async (
126
- reference: BinaryDataReference,
127
- metadata: { width: number; height: number }
128
- ) => {
129
- const data = binaryStore.get(reference.transferId);
130
- if (!data) {
131
- throw new Error(
132
- `Binary data for transfer ${reference.transferId} not found or expired`
133
- );
134
- }
122
+ // ---------------------------------------------------------------------------
123
+ // Factory
124
+ // ---------------------------------------------------------------------------
135
125
 
136
- // Clean up from store
137
- binaryStore.delete(reference.transferId);
126
+ export const createHarnessBridge = async (
127
+ options: HarnessBridgeOptions,
128
+ ): Promise<HarnessBridge> => {
129
+ const { timeout, context, ...transport } = options;
130
+ const wss = await createWss(transport);
131
+ bridgeLogger.debug('bridge server ready');
138
132
 
139
- // Write to temp file
140
- const tempFile = path.join(
141
- os.tmpdir(),
142
- `harness-screenshot-${randomUUID()}.png`
143
- );
144
- await fs.writeFile(tempFile, data);
133
+ const emitter = new EventEmitter();
134
+ let currentConnection: AppConnection | null = null;
135
+ const connectionWaiters: Array<{
136
+ resolve: (c: AppConnection) => void;
137
+ reject: (e: unknown) => void;
138
+ }> = [];
145
139
 
146
- return {
147
- path: tempFile,
148
- width: metadata.width,
149
- height: metadata.height,
150
- };
151
- },
152
- 'test.matchImageSnapshot': async (
153
- screenshot: FileReference,
154
- testPath: string,
155
- options: ImageSnapshotOptions
156
- ) => {
157
- return await matchImageSnapshot(
158
- screenshot,
159
- testPath,
160
- options,
161
- context.platform.name
162
- );
163
- },
164
- };
140
+ wss.on('connection', (ws: WebSocket) => {
141
+ bridgeLogger.debug('app connected');
142
+ const binaryStore = new BinaryStore();
143
+ let readyConnection: AppConnection | null = null;
144
+ let disconnected = false;
165
145
 
166
- const group = createBirpcGroup<BridgeClientFunctions, BridgeServerFunctions>(
167
- baseFunctions,
168
- [],
169
- {
170
- timeout,
171
- onFunctionError: (error, functionName, args) => {
172
- bridgeLogger.error('rpc function failed: %s args=%o', functionName, args);
173
- bridgeLogger.error(error);
174
- throw error;
146
+ const serverFunctions: BridgeServerFunctions = {
147
+ reportReady: (device) => {
148
+ const conn: AppConnection = {
149
+ device,
150
+ runTests: (testPath, opts) => rpc.runTests(testPath, opts),
151
+ };
152
+ readyConnection = conn;
153
+ currentConnection = conn;
154
+ bridgeLogger.debug(
155
+ 'app ready: platform=%s model=%s',
156
+ device.platform,
157
+ device.model,
158
+ );
159
+ emitter.emit('connected', conn);
160
+ for (const { resolve } of connectionWaiters.splice(0)) resolve(conn);
175
161
  },
176
- onTimeoutError(functionName, args) {
177
- throw new DeviceNotRespondingError(functionName, args);
162
+ emitEvent: (_, data) => {
163
+ emitter.emit('event', data);
178
164
  },
179
- }
180
- );
165
+ 'device.screenshot.receive': (ref) => receiveScreenshot(binaryStore, ref),
166
+ 'test.matchImageSnapshot': (screenshot, testPath, opts) =>
167
+ matchImageSnapshot(screenshot, testPath, opts, context.platform.name),
168
+ };
181
169
 
182
- wss.on('connection', (ws: WebSocket) => {
183
- bridgeLogger.debug('client connected');
184
- ws.on('close', () => {
185
- bridgeLogger.debug('client disconnected');
186
-
187
- // TODO: Remove channel when connection is closed.
188
- clients.delete(ws);
189
- emitter.emit('disconnect');
190
- });
191
-
192
- group.updateChannels((channels) => {
193
- channels.push({
170
+ const rpc: BirpcReturn<BridgeClientFunctions, BridgeServerFunctions> = createBirpc<BridgeClientFunctions, BridgeServerFunctions>(
171
+ serverFunctions,
172
+ {
194
173
  post: (data) => ws.send(data),
195
174
  on: (handler) => {
196
175
  ws.on(
197
176
  'message',
198
- (event: Buffer | ArrayBuffer | Buffer[], isBinary: boolean) => {
177
+ (msg: Buffer | ArrayBuffer | Buffer[], isBinary: boolean) => {
199
178
  if (isBinary) {
200
- const uint8Array = new Uint8Array(event as any);
201
179
  try {
202
- const { transferId, data } = parseBinaryFrame(uint8Array);
180
+ const messageBuffer = Array.isArray(msg)
181
+ ? Buffer.concat(msg)
182
+ : Buffer.isBuffer(msg)
183
+ ? msg
184
+ : Buffer.from(msg);
185
+ const { transferId, data } = parseBinaryFrame(
186
+ new Uint8Array(messageBuffer),
187
+ );
203
188
  binaryStore.add(transferId, data);
204
- return;
205
- } catch (error) {
206
- bridgeLogger.warn('failed to parse binary frame', error);
189
+ } catch (err) {
190
+ bridgeLogger.warn('failed to parse binary frame: %s', err);
207
191
  }
192
+ } else {
193
+ handler(msg.toString());
208
194
  }
209
- const message = event.toString();
210
- handler(message);
211
- }
195
+ },
212
196
  );
213
197
  },
214
198
  serialize,
215
199
  deserialize,
216
- });
200
+ timeout,
201
+ onFunctionError: (error, functionName, args) => {
202
+ bridgeLogger.error(
203
+ 'rpc function failed: %s args=%o',
204
+ functionName,
205
+ args,
206
+ );
207
+ throw error;
208
+ },
209
+ onTimeoutError: (fn, args) => {
210
+ throw new DeviceNotRespondingError(fn, args);
211
+ },
212
+ },
213
+ );
214
+
215
+ const disconnect = (reason?: Error) => {
216
+ if (disconnected) return;
217
+ disconnected = true;
218
+
219
+ bridgeLogger.debug('app disconnected');
220
+ binaryStore.dispose();
221
+ if (currentConnection === readyConnection) {
222
+ currentConnection = null;
223
+ }
224
+ rpc.$close(reason ?? new Error('App bridge disconnected'));
225
+ emitter.emit('disconnected');
226
+ };
227
+
228
+ ws.on('close', () => {
229
+ disconnect();
217
230
  });
218
- });
219
231
 
220
- const dispose = () => {
221
- bridgeLogger.debug('disposing bridge server');
222
- for (const client of wss.clients) {
223
- client.terminate();
224
- }
225
- wss.close();
226
- emitter.removeAllListeners();
227
- binaryStore.dispose();
228
- };
232
+ ws.on('error', (error) => {
233
+ disconnect(error instanceof Error ? error : new Error('App bridge socket error'));
234
+ });
235
+ });
229
236
 
230
237
  return {
231
- ws: wss,
232
- rpc: group,
233
- on: emitter.on.bind(emitter),
234
- once: emitter.once.bind(emitter),
235
- off: emitter.off.bind(emitter),
236
- dispose,
238
+ get ws() {
239
+ return wss;
240
+ },
241
+ get connection() {
242
+ return currentConnection;
243
+ },
244
+ nextConnection: (signal) => {
245
+ if (signal?.aborted) {
246
+ return Promise.reject(
247
+ signal.reason ?? new DOMException('Aborted', 'AbortError'),
248
+ );
249
+ }
250
+ // If the app already connected before this call (e.g. fast simulator
251
+ // startup between startAttempt and waitForReady), return it immediately
252
+ // rather than waiting for a second reportReady that will never come.
253
+ if (currentConnection) {
254
+ return Promise.resolve(currentConnection);
255
+ }
256
+ return new Promise((resolve, reject) => {
257
+ const entry = { resolve, reject };
258
+ connectionWaiters.push(entry);
259
+ signal?.addEventListener(
260
+ 'abort',
261
+ () => {
262
+ const idx = connectionWaiters.indexOf(entry);
263
+ if (idx !== -1) connectionWaiters.splice(idx, 1);
264
+ reject(signal.reason ?? new DOMException('Aborted', 'AbortError'));
265
+ },
266
+ { once: true },
267
+ );
268
+ });
269
+ },
270
+ on: (event, listener) => emitter.on(event, listener),
271
+ off: (event, listener) => emitter.off(event, listener),
272
+ dispose: () => {
273
+ bridgeLogger.debug('disposing bridge');
274
+ for (const { reject } of connectionWaiters.splice(0)) {
275
+ reject(new Error('Bridge disposed'));
276
+ }
277
+ for (const client of wss.clients) client.terminate();
278
+ wss.close();
279
+ emitter.removeAllListeners();
280
+ },
237
281
  };
238
282
  };
@@ -1,6 +1,10 @@
1
+ import type { HarnessTestContext } from './test-context.js';
2
+
1
3
  export type TestStatus = 'active' | 'skipped' | 'todo';
2
4
 
3
- export type TestFn = () => void | Promise<void>;
5
+ export type TestFn = (context: HarnessTestContext) => void | Promise<void>;
6
+
7
+ export type SuiteHookFn = () => void | Promise<void>;
4
8
 
5
9
  export type TestCase = {
6
10
  name: string;
@@ -13,8 +17,8 @@ export type TestSuite = {
13
17
  tests: TestCase[];
14
18
  suites: TestSuite[];
15
19
  parent?: TestSuite;
16
- beforeAll: TestFn[];
17
- afterAll: TestFn[];
20
+ beforeAll: SuiteHookFn[];
21
+ afterAll: SuiteHookFn[];
18
22
  beforeEach: TestFn[];
19
23
  afterEach: TestFn[];
20
24
  status?: TestStatus;
@@ -0,0 +1,21 @@
1
+ export type HarnessTaskContext = {
2
+ name: string;
3
+ type: 'test';
4
+ mode: 'run' | 'skip' | 'todo';
5
+ file: {
6
+ name: string;
7
+ };
8
+ suite: {
9
+ name: string;
10
+ };
11
+ };
12
+
13
+ export type HarnessTestContext = {
14
+ task: HarnessTaskContext;
15
+ onTestFailed: (fn: () => void | Promise<void>) => void;
16
+ onTestFinished: (fn: () => void | Promise<void>) => void;
17
+ skip: {
18
+ (note?: string): never;
19
+ (condition: boolean, note?: string): void;
20
+ };
21
+ };
package/src/shared.ts CHANGED
@@ -81,7 +81,13 @@ export type {
81
81
  TestSuite,
82
82
  TestCase,
83
83
  CollectionResult,
84
+ TestFn,
85
+ SuiteHookFn,
84
86
  } from './shared/test-collector.js';
87
+ export type {
88
+ HarnessTaskContext,
89
+ HarnessTestContext,
90
+ } from './shared/test-context.js';
85
91
  export type {
86
92
  TestRunnerEvents,
87
93
  TestRunnerFileStartedEvent,
@@ -149,10 +155,7 @@ export type ScreenshotData = BinaryDataReference;
149
155
 
150
156
  export type BridgeServerFunctions = {
151
157
  reportReady: (device: DeviceDescriptor) => void;
152
- emitEvent: <TEvent extends BridgeEvents>(
153
- event: TEvent['type'],
154
- data: TEvent
155
- ) => void;
158
+ emitEvent: (event: BridgeEvents['type'], data: BridgeEvents) => void;
156
159
  'device.screenshot.receive': (
157
160
  reference: BinaryDataReference,
158
161
  metadata: { width: number; height: number }