@react-native-harness/bridge 1.3.0 → 1.4.0-rc.1

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.
Files changed (69) hide show
  1. package/dist/__tests__/client.test.d.ts +2 -0
  2. package/dist/__tests__/client.test.d.ts.map +1 -0
  3. package/dist/__tests__/client.test.js +81 -0
  4. package/dist/__tests__/heartbeat.test.d.ts +2 -0
  5. package/dist/__tests__/heartbeat.test.d.ts.map +1 -0
  6. package/dist/__tests__/heartbeat.test.js +37 -0
  7. package/dist/__tests__/protocol.test.d.ts +2 -0
  8. package/dist/__tests__/protocol.test.d.ts.map +1 -0
  9. package/dist/__tests__/protocol.test.js +37 -0
  10. package/dist/__tests__/rpc-peer.test.d.ts +2 -0
  11. package/dist/__tests__/rpc-peer.test.d.ts.map +1 -0
  12. package/dist/__tests__/rpc-peer.test.js +127 -0
  13. package/dist/client.d.ts +7 -14
  14. package/dist/client.d.ts.map +1 -1
  15. package/dist/client.js +96 -42
  16. package/dist/errors.d.ts +5 -0
  17. package/dist/errors.d.ts.map +1 -1
  18. package/dist/errors.js +23 -0
  19. package/dist/heartbeat.d.ts +13 -0
  20. package/dist/heartbeat.d.ts.map +1 -0
  21. package/dist/heartbeat.js +49 -0
  22. package/dist/index.d.ts +1 -0
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +1 -0
  25. package/dist/protocol.d.ts +51 -0
  26. package/dist/protocol.d.ts.map +1 -0
  27. package/dist/protocol.js +132 -0
  28. package/dist/rpc-peer.d.ts +28 -0
  29. package/dist/rpc-peer.d.ts.map +1 -0
  30. package/dist/rpc-peer.js +146 -0
  31. package/dist/server.d.ts +1 -20
  32. package/dist/server.d.ts.map +1 -1
  33. package/dist/server.js +122 -71
  34. package/dist/shared/test-collector.d.ts +2 -0
  35. package/dist/shared/test-collector.d.ts.map +1 -1
  36. package/dist/shared/test-runner.d.ts +13 -0
  37. package/dist/shared/test-runner.d.ts.map +1 -1
  38. package/dist/shared.d.ts +0 -2
  39. package/dist/shared.d.ts.map +1 -1
  40. package/dist/transport.d.ts +22 -0
  41. package/dist/transport.d.ts.map +1 -0
  42. package/dist/transport.js +22 -0
  43. package/dist/tsconfig.lib.tsbuildinfo +1 -1
  44. package/dist/websocket-client-transport.d.ts +3 -0
  45. package/dist/websocket-client-transport.d.ts.map +1 -0
  46. package/dist/websocket-client-transport.js +45 -0
  47. package/dist/websocket-server-transport.d.ts +4 -0
  48. package/dist/websocket-server-transport.d.ts.map +1 -0
  49. package/dist/websocket-server-transport.js +43 -0
  50. package/eslint.config.mjs +5 -1
  51. package/package.json +6 -5
  52. package/src/__tests__/client.test.ts +99 -0
  53. package/src/__tests__/heartbeat.test.ts +47 -0
  54. package/src/__tests__/protocol.test.ts +51 -0
  55. package/src/__tests__/rpc-peer.test.ts +181 -0
  56. package/src/client.ts +146 -57
  57. package/src/errors.ts +32 -0
  58. package/src/heartbeat.ts +67 -0
  59. package/src/index.ts +1 -0
  60. package/src/protocol.ts +233 -0
  61. package/src/rpc-peer.ts +222 -0
  62. package/src/server.ts +179 -114
  63. package/src/shared/test-collector.ts +3 -0
  64. package/src/shared/test-runner.ts +14 -0
  65. package/src/shared.ts +0 -2
  66. package/src/transport.ts +47 -0
  67. package/src/websocket-client-transport.ts +85 -0
  68. package/src/websocket-server-transport.ts +54 -0
  69. package/vite.config.ts +18 -0
package/src/server.ts CHANGED
@@ -1,5 +1,4 @@
1
1
  import { WebSocketServer, type WebSocket } from 'ws';
2
- import { createBirpc, type BirpcReturn } from 'birpc';
3
2
  import { EventEmitter } from 'node:events';
4
3
  import type { Server as HttpServer } from 'node:http';
5
4
  import type { Server as HttpsServer } from 'node:https';
@@ -9,9 +8,19 @@ import path from 'node:path';
9
8
  import { randomUUID } from 'node:crypto';
10
9
  import { logger } from '@react-native-harness/tools';
11
10
  import { BinaryStore, parseBinaryFrame } from './binary-transfer.js';
12
- import { deserialize, serialize } from './serializer.js';
13
- import { DeviceNotRespondingError } from './errors.js';
11
+ import {
12
+ AppBridgeDisconnectedError,
13
+ DeviceNotRespondingError,
14
+ } from './errors.js';
15
+ import { createHeartbeat } from './heartbeat.js';
14
16
  import { matchImageSnapshot } from './image-snapshot.js';
17
+ import { serializeBridgeMessage } from './protocol.js';
18
+ import { createRpcPeer } from './rpc-peer.js';
19
+ import {
20
+ createRpcTransport,
21
+ type BridgeTransport,
22
+ } from './transport.js';
23
+ import { createNodeWebSocketTransport } from './websocket-server-transport.js';
15
24
  import type {
16
25
  BridgeServerFunctions,
17
26
  BridgeClientFunctions,
@@ -24,29 +33,22 @@ import type {
24
33
  TestSuiteResult,
25
34
  } from './shared.js';
26
35
 
27
- export { DeviceNotRespondingError } from './errors.js';
36
+ export {
37
+ AppBridgeDisconnectedError,
38
+ DeviceNotRespondingError,
39
+ } from './errors.js';
28
40
 
29
41
  const bridgeLogger = logger.child('bridge');
42
+ const noop = (): void => undefined;
30
43
 
31
- // ---------------------------------------------------------------------------
32
- // Public types
33
- // ---------------------------------------------------------------------------
34
-
35
- /**
36
- * Represents a single app session — one app launch to the next restart.
37
- * Obtained via HarnessBridge.nextConnection().
38
- */
39
44
  export type AppConnection = {
40
45
  readonly device: DeviceDescriptor;
41
46
  runTests: (path: string, options: TestExecutionOptions) => Promise<TestSuiteResult>;
42
47
  };
43
48
 
44
49
  export type HarnessBridgeEvents = {
45
- /** Fired when the app connects and calls reportReady. */
46
50
  connected: (connection: AppConnection) => void;
47
- /** Fired when the app's WebSocket closes. */
48
51
  disconnected: () => void;
49
- /** Fired for every test/bundler event the app emits. */
50
52
  event: (event: BridgeEvents) => void;
51
53
  };
52
54
 
@@ -60,31 +62,15 @@ export type HarnessBridgeOptions = TransportOptions & {
60
62
  context: HarnessContext;
61
63
  };
62
64
 
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
65
  export type HarnessBridge = {
69
- /** The underlying WebSocket server, used to attach to Metro's HTTP server. */
70
66
  readonly ws: WebSocketServer;
71
- /** The currently active app connection, null if the app is not connected. */
72
67
  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
68
  nextConnection: (signal?: AbortSignal) => Promise<AppConnection>;
79
69
  on: <T extends keyof HarnessBridgeEvents>(event: T, listener: HarnessBridgeEvents[T]) => void;
80
70
  off: <T extends keyof HarnessBridgeEvents>(event: T, listener: HarnessBridgeEvents[T]) => void;
81
71
  dispose: () => void;
82
72
  };
83
73
 
84
- // ---------------------------------------------------------------------------
85
- // Helpers
86
- // ---------------------------------------------------------------------------
87
-
88
74
  const createWss = (transport: TransportOptions): Promise<WebSocketServer> => {
89
75
  if ('port' in transport) {
90
76
  return new Promise<WebSocketServer>((resolve) => {
@@ -94,6 +80,7 @@ const createWss = (transport: TransportOptions): Promise<WebSocketServer> => {
94
80
  );
95
81
  });
96
82
  }
83
+
97
84
  return Promise.resolve<WebSocketServer>(
98
85
  new WebSocketServer(
99
86
  'server' in transport
@@ -113,124 +100,193 @@ const receiveScreenshot = async (
113
100
  `Binary data for transfer ${reference.transferId} not found or expired`,
114
101
  );
115
102
  }
103
+
116
104
  binaryStore.delete(reference.transferId);
117
105
  const file = path.join(os.tmpdir(), `harness-screenshot-${randomUUID()}.png`);
118
106
  await fs.writeFile(file, data);
119
107
  return { path: file };
120
108
  };
121
109
 
122
- // ---------------------------------------------------------------------------
123
- // Factory
124
- // ---------------------------------------------------------------------------
125
-
126
110
  export const createHarnessBridge = async (
127
111
  options: HarnessBridgeOptions,
128
112
  ): Promise<HarnessBridge> => {
129
- const { timeout, context, ...transport } = options;
130
- const wss = await createWss(transport);
113
+ const { timeout, context, ...transportOptions } = options;
114
+ const wss = await createWss(transportOptions);
131
115
  bridgeLogger.debug('bridge server ready');
132
116
 
133
117
  const emitter = new EventEmitter();
134
118
  let currentConnection: AppConnection | null = null;
119
+ let activeSession: { disconnect: (reason?: Error) => void; transport: BridgeTransport } | null =
120
+ null;
135
121
  const connectionWaiters: Array<{
136
122
  resolve: (c: AppConnection) => void;
137
123
  reject: (e: unknown) => void;
138
124
  }> = [];
139
125
 
140
126
  wss.on('connection', (ws: WebSocket) => {
127
+ if (activeSession) {
128
+ bridgeLogger.info('replacing existing app connection with a newer client');
129
+ activeSession.disconnect(new AppBridgeDisconnectedError('app-replaced'));
130
+ }
131
+
141
132
  bridgeLogger.debug('app connected');
133
+ const transport = createNodeWebSocketTransport(ws);
142
134
  const binaryStore = new BinaryStore();
143
135
  let readyConnection: AppConnection | null = null;
144
136
  let disconnected = false;
137
+ let offMessage: () => void = noop;
138
+ let offClose: () => void = noop;
139
+ let offError: () => void = noop;
145
140
 
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);
141
+ const closeTransport = () => {
142
+ if (transport.state === 'closing' || transport.state === 'closed') {
143
+ return;
144
+ }
145
+
146
+ transport.close(1012);
147
+ };
148
+
149
+ const rpc = createRpcPeer<
150
+ BridgeServerFunctions,
151
+ BridgeClientFunctions,
152
+ BridgeEvents
153
+ >({
154
+ localMethods: {
155
+ 'device.screenshot.receive': (ref) => receiveScreenshot(binaryStore, ref),
156
+ 'test.matchImageSnapshot': (screenshot, testPath, opts) =>
157
+ matchImageSnapshot(screenshot, testPath, opts, context.platform.name),
161
158
  },
162
- emitEvent: (_, data) => {
163
- emitter.emit('event', data);
159
+ transport: createRpcTransport(transport),
160
+ onEvent: (event) => {
161
+ emitter.emit('event', event);
164
162
  },
165
- 'device.screenshot.receive': (ref) => receiveScreenshot(binaryStore, ref),
166
- 'test.matchImageSnapshot': (screenshot, testPath, opts) =>
167
- matchImageSnapshot(screenshot, testPath, opts, context.platform.name),
168
- };
163
+ callTimeoutMs: timeout,
164
+ createTimeoutError: (functionName, args) => {
165
+ return new DeviceNotRespondingError(functionName, args) as unknown as Error;
166
+ },
167
+ });
169
168
 
170
- const rpc: BirpcReturn<BridgeClientFunctions, BridgeServerFunctions> = createBirpc<BridgeClientFunctions, BridgeServerFunctions>(
171
- serverFunctions,
172
- {
173
- post: (data) => ws.send(data),
174
- on: (handler) => {
175
- ws.on(
176
- 'message',
177
- (msg: Buffer | ArrayBuffer | Buffer[], isBinary: boolean) => {
178
- if (isBinary) {
179
- try {
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
- );
188
- binaryStore.add(transferId, data);
189
- } catch (err) {
190
- bridgeLogger.warn('failed to parse binary frame: %s', err);
191
- }
192
- } else {
193
- handler(msg.toString());
194
- }
195
- },
196
- );
197
- },
198
- serialize,
199
- deserialize,
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
- },
169
+ const heartbeat = createHeartbeat({
170
+ sendPing: (id) => {
171
+ transport.send(serializeBridgeMessage({ type: 'ping', id }));
212
172
  },
213
- );
173
+ onTimeout: () => {
174
+ bridgeLogger.warn('app heartbeat timed out');
175
+ disconnect(new AppBridgeDisconnectedError('heartbeat-timeout'));
176
+ },
177
+ });
214
178
 
215
179
  const disconnect = (reason?: Error) => {
216
- if (disconnected) return;
217
- disconnected = true;
180
+ if (disconnected) {
181
+ return;
182
+ }
218
183
 
184
+ disconnected = true;
185
+ offMessage();
186
+ offClose();
187
+ offError();
219
188
  bridgeLogger.debug('app disconnected');
189
+ heartbeat.dispose();
220
190
  binaryStore.dispose();
191
+
192
+ if (activeSession?.transport === transport) {
193
+ activeSession = null;
194
+ }
195
+
221
196
  if (currentConnection === readyConnection) {
222
197
  currentConnection = null;
223
198
  }
224
- rpc.$close(reason ?? new Error('App bridge disconnected'));
225
- emitter.emit('disconnected');
199
+
200
+ rpc.close(reason ?? new AppBridgeDisconnectedError('app-disconnected'));
201
+ closeTransport();
202
+
203
+ if (readyConnection) {
204
+ emitter.emit('disconnected');
205
+ }
206
+ };
207
+
208
+ activeSession = { disconnect, transport };
209
+
210
+ const handleControlMessage = async (message: string) => {
211
+ const controlMessage = await rpc.handleMessage(message);
212
+
213
+ if (!controlMessage) {
214
+ return;
215
+ }
216
+
217
+ switch (controlMessage.type) {
218
+ case 'ready': {
219
+ if (readyConnection) {
220
+ return;
221
+ }
222
+
223
+ const conn: AppConnection = {
224
+ device: controlMessage.device,
225
+ runTests: (testPath, opts) => rpc.invoke('runTests', testPath, opts),
226
+ };
227
+
228
+ readyConnection = conn;
229
+ currentConnection = conn;
230
+ bridgeLogger.debug(
231
+ 'app ready: platform=%s model=%s',
232
+ controlMessage.device.platform,
233
+ controlMessage.device.model,
234
+ );
235
+ emitter.emit('connected', conn);
236
+
237
+ for (const { resolve } of connectionWaiters.splice(0)) {
238
+ resolve(conn);
239
+ }
240
+ return;
241
+ }
242
+ case 'ping': {
243
+ transport.send(
244
+ serializeBridgeMessage({ type: 'pong', id: controlMessage.id }),
245
+ );
246
+ return;
247
+ }
248
+ case 'pong': {
249
+ heartbeat.notifyPong(controlMessage.id);
250
+ return;
251
+ }
252
+ }
226
253
  };
227
254
 
228
- ws.on('close', () => {
255
+ const handleBinaryMessage = (message: Uint8Array) => {
256
+ try {
257
+ const { transferId, data } = parseBinaryFrame(message);
258
+ binaryStore.add(transferId, data);
259
+ } catch (error) {
260
+ bridgeLogger.warn('failed to parse binary frame: %s', error);
261
+ }
262
+ };
263
+
264
+ offMessage = transport.onMessage((message) => {
265
+ if (typeof message !== 'string') {
266
+ handleBinaryMessage(message);
267
+ return;
268
+ }
269
+
270
+ void handleControlMessage(message).catch((error) => {
271
+ bridgeLogger.warn('failed to handle bridge message: %s', error);
272
+ disconnect(
273
+ error instanceof Error
274
+ ? error
275
+ : new Error('Received invalid app bridge message'),
276
+ );
277
+ });
278
+ });
279
+
280
+ offClose = transport.onClose(() => {
229
281
  disconnect();
230
282
  });
231
283
 
232
- ws.on('error', (error) => {
233
- disconnect(error instanceof Error ? error : new Error('App bridge socket error'));
284
+ offError = transport.onError((error) => {
285
+ disconnect(
286
+ error instanceof Error
287
+ ? error
288
+ : new AppBridgeDisconnectedError('socket-error'),
289
+ );
234
290
  });
235
291
  });
236
292
 
@@ -247,12 +303,11 @@ export const createHarnessBridge = async (
247
303
  signal.reason ?? new DOMException('Aborted', 'AbortError'),
248
304
  );
249
305
  }
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.
306
+
253
307
  if (currentConnection) {
254
308
  return Promise.resolve(currentConnection);
255
309
  }
310
+
256
311
  return new Promise((resolve, reject) => {
257
312
  const entry = { resolve, reject };
258
313
  connectionWaiters.push(entry);
@@ -260,7 +315,10 @@ export const createHarnessBridge = async (
260
315
  'abort',
261
316
  () => {
262
317
  const idx = connectionWaiters.indexOf(entry);
263
- if (idx !== -1) connectionWaiters.splice(idx, 1);
318
+ if (idx !== -1) {
319
+ connectionWaiters.splice(idx, 1);
320
+ }
321
+
264
322
  reject(signal.reason ?? new DOMException('Aborted', 'AbortError'));
265
323
  },
266
324
  { once: true },
@@ -271,10 +329,17 @@ export const createHarnessBridge = async (
271
329
  off: (event, listener) => emitter.off(event, listener),
272
330
  dispose: () => {
273
331
  bridgeLogger.debug('disposing bridge');
332
+
274
333
  for (const { reject } of connectionWaiters.splice(0)) {
275
- reject(new Error('Bridge disposed'));
334
+ reject(new AppBridgeDisconnectedError('bridge-disposed'));
276
335
  }
277
- for (const client of wss.clients) client.terminate();
336
+
337
+ activeSession?.disconnect(new AppBridgeDisconnectedError('bridge-disposed'));
338
+
339
+ for (const client of wss.clients) {
340
+ client.terminate();
341
+ }
342
+
278
343
  wss.close();
279
344
  emitter.removeAllListeners();
280
345
  },
@@ -2,6 +2,8 @@ import type { HarnessTestContext } from './test-context.js';
2
2
 
3
3
  export type TestStatus = 'active' | 'skipped' | 'todo';
4
4
 
5
+ export type TestDeclarationMode = 'only' | 'skip' | 'todo';
6
+
5
7
  export type TestFn = (context: HarnessTestContext) => void | Promise<void>;
6
8
 
7
9
  export type SuiteHookFn = () => void | Promise<void>;
@@ -10,6 +12,7 @@ export type TestCase = {
10
12
  name: string;
11
13
  fn: TestFn;
12
14
  status: TestStatus;
15
+ declarationMode?: TestDeclarationMode;
13
16
  };
14
17
 
15
18
  export type TestSuite = {
@@ -1,3 +1,5 @@
1
+ import type { TestDeclarationMode } from './test-collector.js';
2
+
1
3
  export type CodeFrame = {
2
4
  content: string;
3
5
  location?: {
@@ -37,6 +39,10 @@ export type TestRunnerTestStartedEvent = {
37
39
  name: string;
38
40
  suite: string;
39
41
  file: string;
42
+ ancestorTitles: string[];
43
+ fullName: string;
44
+ startedAt: number;
45
+ declarationMode?: TestDeclarationMode;
40
46
  };
41
47
 
42
48
  export type TestRunnerTestFinishedEvent = {
@@ -44,6 +50,10 @@ export type TestRunnerTestFinishedEvent = {
44
50
  name: string;
45
51
  suite: string;
46
52
  file: string;
53
+ ancestorTitles: string[];
54
+ fullName: string;
55
+ startedAt: number;
56
+ declarationMode?: TestDeclarationMode;
47
57
  duration: number;
48
58
  error?: SerializedError;
49
59
  status: TestResultStatus;
@@ -71,6 +81,10 @@ export type TestResult = {
71
81
  status: TestResultStatus;
72
82
  error?: SerializedError;
73
83
  duration: number;
84
+ ancestorTitles?: string[];
85
+ fullName?: string;
86
+ startedAt?: number;
87
+ declarationMode?: TestDeclarationMode;
74
88
  };
75
89
 
76
90
  export type TestSuiteResult = {
package/src/shared.ts CHANGED
@@ -154,8 +154,6 @@ export type BinaryDataReference = {
154
154
  export type ScreenshotData = BinaryDataReference;
155
155
 
156
156
  export type BridgeServerFunctions = {
157
- reportReady: (device: DeviceDescriptor) => void;
158
- emitEvent: (event: BridgeEvents['type'], data: BridgeEvents) => void;
159
157
  'device.screenshot.receive': (
160
158
  reference: BinaryDataReference,
161
159
  metadata: { width: number; height: number }
@@ -0,0 +1,47 @@
1
+ export type BridgeTransportState = 'connecting' | 'open' | 'closing' | 'closed';
2
+
3
+ export type BridgeTransportMessage = string | Uint8Array;
4
+
5
+ export type BridgeTransportCloseEvent = {
6
+ code: number;
7
+ reason: string;
8
+ };
9
+
10
+ export type BridgeTransport = {
11
+ readonly state: BridgeTransportState;
12
+ send: (message: BridgeTransportMessage) => void;
13
+ close: (code?: number, reason?: string) => void;
14
+ onOpen: (listener: () => void) => () => void;
15
+ onMessage: (listener: (message: BridgeTransportMessage) => void) => () => void;
16
+ onClose: (listener: (event: BridgeTransportCloseEvent) => void) => () => void;
17
+ onError: (listener: (error: Error) => void) => () => void;
18
+ };
19
+
20
+ export type RpcTransport = {
21
+ readonly state: BridgeTransportState;
22
+ send: (message: string) => void;
23
+ };
24
+
25
+ export const toTransportState = (readyState: number): BridgeTransportState => {
26
+ switch (readyState) {
27
+ case 0:
28
+ return 'connecting';
29
+ case 1:
30
+ return 'open';
31
+ case 2:
32
+ return 'closing';
33
+ default:
34
+ return 'closed';
35
+ }
36
+ };
37
+
38
+ export const createRpcTransport = (transport: BridgeTransport): RpcTransport => {
39
+ return {
40
+ get state() {
41
+ return transport.state;
42
+ },
43
+ send: (message) => {
44
+ transport.send(message);
45
+ },
46
+ };
47
+ };
@@ -0,0 +1,85 @@
1
+ import {
2
+ toTransportState,
3
+ type BridgeTransport,
4
+ } from './transport.js';
5
+
6
+ type BrowserWebSocketLike = {
7
+ readonly readyState: number;
8
+ binaryType: BinaryType;
9
+ send: (data: string | ArrayBufferLike | Blob | ArrayBufferView) => void;
10
+ close: (code?: number, reason?: string) => void;
11
+ addEventListener: {
12
+ (type: 'open', listener: () => void): void;
13
+ (
14
+ type: 'message',
15
+ listener: (event: MessageEvent<string | ArrayBuffer>) => void,
16
+ ): void;
17
+ (type: 'close', listener: (event: CloseEvent) => void): void;
18
+ (
19
+ type: 'error',
20
+ listener: (event: Event & { message?: string }) => void,
21
+ ): void;
22
+ };
23
+ removeEventListener: {
24
+ (type: 'open', listener: () => void): void;
25
+ (
26
+ type: 'message',
27
+ listener: (event: MessageEvent<string | ArrayBuffer>) => void,
28
+ ): void;
29
+ (type: 'close', listener: (event: CloseEvent) => void): void;
30
+ (
31
+ type: 'error',
32
+ listener: (event: Event & { message?: string }) => void,
33
+ ): void;
34
+ };
35
+ };
36
+
37
+ export const createWebSocketClientTransport = (url: string): BridgeTransport => {
38
+ const socket = new WebSocket(url) as BrowserWebSocketLike;
39
+ socket.binaryType = 'arraybuffer';
40
+
41
+ return {
42
+ get state() {
43
+ return toTransportState(socket.readyState);
44
+ },
45
+ send: (message) => {
46
+ socket.send(message);
47
+ },
48
+ close: (code, reason) => {
49
+ socket.close(code, reason);
50
+ },
51
+ onOpen: (listener) => {
52
+ socket.addEventListener('open', listener);
53
+ return () => socket.removeEventListener('open', listener);
54
+ },
55
+ onMessage: (listener) => {
56
+ const handleMessage = (event: MessageEvent<string | ArrayBuffer>) => {
57
+ if (typeof event.data === 'string') {
58
+ listener(event.data);
59
+ return;
60
+ }
61
+
62
+ listener(new Uint8Array(event.data));
63
+ };
64
+
65
+ socket.addEventListener('message', handleMessage);
66
+ return () => socket.removeEventListener('message', handleMessage);
67
+ },
68
+ onClose: (listener) => {
69
+ const handleClose = (event: CloseEvent) => {
70
+ listener({ code: event.code, reason: event.reason });
71
+ };
72
+
73
+ socket.addEventListener('close', handleClose);
74
+ return () => socket.removeEventListener('close', handleClose);
75
+ },
76
+ onError: (listener) => {
77
+ const handleError = (event: Event & { message?: string }) => {
78
+ listener(new Error(event.message || 'Harness connection error'));
79
+ };
80
+
81
+ socket.addEventListener('error', handleError);
82
+ return () => socket.removeEventListener('error', handleError);
83
+ },
84
+ };
85
+ };
@@ -0,0 +1,54 @@
1
+ import type { RawData, WebSocket as NodeWebSocket } from 'ws';
2
+ import {
3
+ toTransportState,
4
+ type BridgeTransport,
5
+ } from './transport.js';
6
+
7
+ const toUint8Array = (message: RawData): Uint8Array => {
8
+ const buffer = Array.isArray(message)
9
+ ? Buffer.concat(message)
10
+ : Buffer.isBuffer(message)
11
+ ? message
12
+ : Buffer.from(message);
13
+
14
+ return new Uint8Array(buffer);
15
+ };
16
+
17
+ export const createNodeWebSocketTransport = (
18
+ socket: NodeWebSocket,
19
+ ): BridgeTransport => {
20
+ return {
21
+ get state() {
22
+ return toTransportState(socket.readyState);
23
+ },
24
+ send: (message) => {
25
+ socket.send(message);
26
+ },
27
+ close: (code, reason) => {
28
+ socket.close(code, reason);
29
+ },
30
+ onOpen: () => {
31
+ return () => undefined;
32
+ },
33
+ onMessage: (listener) => {
34
+ const handleMessage = (message: RawData, isBinary: boolean) => {
35
+ listener(isBinary ? toUint8Array(message) : message.toString());
36
+ };
37
+
38
+ socket.on('message', handleMessage);
39
+ return () => socket.off('message', handleMessage);
40
+ },
41
+ onClose: (listener) => {
42
+ const handleClose = (code: number, reason: Buffer) => {
43
+ listener({ code, reason: reason.toString() });
44
+ };
45
+
46
+ socket.on('close', handleClose);
47
+ return () => socket.off('close', handleClose);
48
+ },
49
+ onError: (listener) => {
50
+ socket.on('error', listener);
51
+ return () => socket.off('error', listener);
52
+ },
53
+ };
54
+ };
package/vite.config.ts ADDED
@@ -0,0 +1,18 @@
1
+ /// <reference types='vitest' />
2
+ import { defineConfig } from 'vite';
3
+
4
+ export default defineConfig(() => ({
5
+ root: __dirname,
6
+ cacheDir: '../../node_modules/.vite/packages/bridge',
7
+ test: {
8
+ watch: false,
9
+ globals: true,
10
+ environment: 'node',
11
+ include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
12
+ reporters: ['default'],
13
+ coverage: {
14
+ reportsDirectory: './test-output/vitest/coverage',
15
+ provider: 'v8' as const,
16
+ },
17
+ },
18
+ }));