@react-native-harness/bridge 1.1.0 → 1.2.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,259 @@ 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();
165
143
 
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;
144
+ const serverFunctions: BridgeServerFunctions = {
145
+ reportReady: (device) => {
146
+ const conn: AppConnection = {
147
+ device,
148
+ runTests: (testPath, opts) => rpc.runTests(testPath, opts),
149
+ };
150
+ currentConnection = conn;
151
+ bridgeLogger.debug(
152
+ 'app ready: platform=%s model=%s',
153
+ device.platform,
154
+ device.model,
155
+ );
156
+ emitter.emit('connected', conn);
157
+ for (const { resolve } of connectionWaiters.splice(0)) resolve(conn);
175
158
  },
176
- onTimeoutError(functionName, args) {
177
- throw new DeviceNotRespondingError(functionName, args);
159
+ emitEvent: (_, data) => {
160
+ emitter.emit('event', data);
178
161
  },
179
- }
180
- );
162
+ 'device.screenshot.receive': (ref) => receiveScreenshot(binaryStore, ref),
163
+ 'test.matchImageSnapshot': (screenshot, testPath, opts) =>
164
+ matchImageSnapshot(screenshot, testPath, opts, context.platform.name),
165
+ };
181
166
 
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({
167
+ const rpc: BirpcReturn<BridgeClientFunctions, BridgeServerFunctions> = createBirpc<BridgeClientFunctions, BridgeServerFunctions>(
168
+ serverFunctions,
169
+ {
194
170
  post: (data) => ws.send(data),
195
171
  on: (handler) => {
196
172
  ws.on(
197
173
  'message',
198
- (event: Buffer | ArrayBuffer | Buffer[], isBinary: boolean) => {
174
+ (msg: Buffer | ArrayBuffer | Buffer[], isBinary: boolean) => {
199
175
  if (isBinary) {
200
- const uint8Array = new Uint8Array(event as any);
201
176
  try {
202
- const { transferId, data } = parseBinaryFrame(uint8Array);
177
+ const messageBuffer = Array.isArray(msg)
178
+ ? Buffer.concat(msg)
179
+ : Buffer.isBuffer(msg)
180
+ ? msg
181
+ : Buffer.from(msg);
182
+ const { transferId, data } = parseBinaryFrame(
183
+ new Uint8Array(messageBuffer),
184
+ );
203
185
  binaryStore.add(transferId, data);
204
- return;
205
- } catch (error) {
206
- bridgeLogger.warn('failed to parse binary frame', error);
186
+ } catch (err) {
187
+ bridgeLogger.warn('failed to parse binary frame: %s', err);
207
188
  }
189
+ } else {
190
+ handler(msg.toString());
208
191
  }
209
- const message = event.toString();
210
- handler(message);
211
- }
192
+ },
212
193
  );
213
194
  },
214
195
  serialize,
215
196
  deserialize,
216
- });
197
+ timeout,
198
+ onFunctionError: (error, functionName, args) => {
199
+ bridgeLogger.error(
200
+ 'rpc function failed: %s args=%o',
201
+ functionName,
202
+ args,
203
+ );
204
+ throw error;
205
+ },
206
+ onTimeoutError: (fn, args) => {
207
+ throw new DeviceNotRespondingError(fn, args);
208
+ },
209
+ },
210
+ );
211
+
212
+ ws.on('close', () => {
213
+ bridgeLogger.debug('app disconnected');
214
+ binaryStore.dispose();
215
+ currentConnection = null;
216
+ emitter.emit('disconnected');
217
217
  });
218
218
  });
219
219
 
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
- };
229
-
230
220
  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,
221
+ get ws() {
222
+ return wss;
223
+ },
224
+ get connection() {
225
+ return currentConnection;
226
+ },
227
+ nextConnection: (signal) => {
228
+ if (signal?.aborted) {
229
+ return Promise.reject(
230
+ signal.reason ?? new DOMException('Aborted', 'AbortError'),
231
+ );
232
+ }
233
+ // If the app already connected before this call (e.g. fast simulator
234
+ // startup between startAttempt and waitForReady), return it immediately
235
+ // rather than waiting for a second reportReady that will never come.
236
+ if (currentConnection) {
237
+ return Promise.resolve(currentConnection);
238
+ }
239
+ return new Promise((resolve, reject) => {
240
+ const entry = { resolve, reject };
241
+ connectionWaiters.push(entry);
242
+ signal?.addEventListener(
243
+ 'abort',
244
+ () => {
245
+ const idx = connectionWaiters.indexOf(entry);
246
+ if (idx !== -1) connectionWaiters.splice(idx, 1);
247
+ reject(signal.reason ?? new DOMException('Aborted', 'AbortError'));
248
+ },
249
+ { once: true },
250
+ );
251
+ });
252
+ },
253
+ on: (event, listener) => emitter.on(event, listener),
254
+ off: (event, listener) => emitter.off(event, listener),
255
+ dispose: () => {
256
+ bridgeLogger.debug('disposing bridge');
257
+ for (const { reject } of connectionWaiters.splice(0)) {
258
+ reject(new Error('Bridge disposed'));
259
+ }
260
+ for (const client of wss.clients) client.terminate();
261
+ wss.close();
262
+ emitter.removeAllListeners();
263
+ },
237
264
  };
238
265
  };
package/src/shared.ts CHANGED
@@ -149,10 +149,7 @@ export type ScreenshotData = BinaryDataReference;
149
149
 
150
150
  export type BridgeServerFunctions = {
151
151
  reportReady: (device: DeviceDescriptor) => void;
152
- emitEvent: <TEvent extends BridgeEvents>(
153
- event: TEvent['type'],
154
- data: TEvent
155
- ) => void;
152
+ emitEvent: (event: BridgeEvents['type'], data: BridgeEvents) => void;
156
153
  'device.screenshot.receive': (
157
154
  reference: BinaryDataReference,
158
155
  metadata: { width: number; height: number }