@react-native-harness/bridge 1.5.0 → 1.7.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.
Files changed (104) hide show
  1. package/dist/app-liveness.d.ts +33 -0
  2. package/dist/app-liveness.d.ts.map +1 -0
  3. package/dist/app-liveness.js +57 -0
  4. package/dist/binary.d.ts +16 -0
  5. package/dist/binary.d.ts.map +1 -0
  6. package/dist/binary.js +47 -0
  7. package/dist/client.d.ts +6 -4
  8. package/dist/client.d.ts.map +1 -1
  9. package/dist/client.js +112 -105
  10. package/dist/connection-check.d.ts +22 -0
  11. package/dist/connection-check.d.ts.map +1 -0
  12. package/dist/connection-check.js +30 -0
  13. package/dist/errors.d.ts +6 -2
  14. package/dist/errors.d.ts.map +1 -1
  15. package/dist/errors.js +12 -4
  16. package/dist/image-snapshot.d.ts +2 -2
  17. package/dist/image-snapshot.d.ts.map +1 -1
  18. package/dist/image-snapshot.js +21 -8
  19. package/dist/index.d.ts +0 -2
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +0 -2
  22. package/dist/js-block-budget-policy.d.ts +31 -0
  23. package/dist/js-block-budget-policy.d.ts.map +1 -0
  24. package/dist/js-block-budget-policy.js +60 -0
  25. package/dist/js-watchdog.d.ts +31 -0
  26. package/dist/js-watchdog.d.ts.map +1 -0
  27. package/dist/js-watchdog.js +46 -0
  28. package/dist/link/channel.d.ts +45 -0
  29. package/dist/link/channel.d.ts.map +1 -0
  30. package/dist/link/channel.js +414 -0
  31. package/dist/link/envelope.d.ts +38 -0
  32. package/dist/link/envelope.d.ts.map +1 -0
  33. package/dist/link/envelope.js +90 -0
  34. package/dist/link/index.d.ts +22 -0
  35. package/dist/link/index.d.ts.map +1 -0
  36. package/dist/link/index.js +271 -0
  37. package/dist/link/ports.d.ts +31 -0
  38. package/dist/link/ports.d.ts.map +1 -0
  39. package/dist/link/ports.js +7 -0
  40. package/dist/link/scheduler.d.ts +21 -0
  41. package/dist/link/scheduler.d.ts.map +1 -0
  42. package/dist/link/scheduler.js +116 -0
  43. package/dist/link/standard-websocket.d.ts +36 -0
  44. package/dist/link/standard-websocket.d.ts.map +1 -0
  45. package/dist/link/standard-websocket.js +214 -0
  46. package/dist/link/streams.d.ts +4 -0
  47. package/dist/link/streams.d.ts.map +1 -0
  48. package/dist/link/streams.js +15 -0
  49. package/dist/protocol.d.ts +10 -5
  50. package/dist/protocol.d.ts.map +1 -1
  51. package/dist/protocol.js +2 -2
  52. package/dist/rpc-peer.d.ts +3 -1
  53. package/dist/rpc-peer.d.ts.map +1 -1
  54. package/dist/rpc-peer.js +2 -2
  55. package/dist/server.d.ts +4 -0
  56. package/dist/server.d.ts.map +1 -1
  57. package/dist/server.js +71 -56
  58. package/dist/shared/test-runner.d.ts +11 -0
  59. package/dist/shared/test-runner.d.ts.map +1 -1
  60. package/dist/shared.d.ts +5 -1
  61. package/dist/shared.d.ts.map +1 -1
  62. package/package.json +4 -3
  63. package/src/app-liveness.ts +103 -0
  64. package/src/binary.ts +74 -0
  65. package/src/client.ts +172 -170
  66. package/src/connection-check.ts +60 -0
  67. package/src/errors.ts +16 -5
  68. package/src/image-snapshot.ts +31 -9
  69. package/src/index.ts +0 -2
  70. package/src/js-block-budget-policy.ts +90 -0
  71. package/src/js-watchdog.ts +78 -0
  72. package/src/link/README.md +43 -0
  73. package/src/link/channel.ts +473 -0
  74. package/src/link/envelope.ts +110 -0
  75. package/src/link/index.ts +321 -0
  76. package/src/link/ports.ts +42 -0
  77. package/src/link/scheduler.ts +132 -0
  78. package/src/link/standard-websocket.ts +251 -0
  79. package/src/link/streams.ts +34 -0
  80. package/src/protocol.ts +14 -9
  81. package/src/rpc-peer.ts +6 -3
  82. package/src/server.ts +99 -67
  83. package/src/shared/test-runner.ts +12 -0
  84. package/src/shared.ts +6 -1
  85. package/dist/binary-transfer.d.ts +0 -17
  86. package/dist/binary-transfer.d.ts.map +0 -1
  87. package/dist/binary-transfer.js +0 -60
  88. package/dist/heartbeat.d.ts +0 -13
  89. package/dist/heartbeat.d.ts.map +0 -1
  90. package/dist/heartbeat.js +0 -49
  91. package/dist/transport.d.ts +0 -22
  92. package/dist/transport.d.ts.map +0 -1
  93. package/dist/transport.js +0 -22
  94. package/dist/websocket-client-transport.d.ts +0 -3
  95. package/dist/websocket-client-transport.d.ts.map +0 -1
  96. package/dist/websocket-client-transport.js +0 -45
  97. package/dist/websocket-server-transport.d.ts +0 -4
  98. package/dist/websocket-server-transport.d.ts.map +0 -1
  99. package/dist/websocket-server-transport.js +0 -43
  100. package/src/binary-transfer.ts +0 -79
  101. package/src/heartbeat.ts +0 -67
  102. package/src/transport.ts +0 -47
  103. package/src/websocket-client-transport.ts +0 -85
  104. package/src/websocket-server-transport.ts +0 -54
@@ -0,0 +1,251 @@
1
+ import { globalClock, type Clock, type TimerHandle, type UplinkTransportConnection } from "./ports.js";
2
+
3
+ export { globalClock } from "./ports.js";
4
+
5
+ export interface StandardWebSocket {
6
+ binaryType: string;
7
+ // harness: React Native declares bufferedAmount but never sets it.
8
+ readonly bufferedAmount: number | undefined;
9
+ readonly readyState: number;
10
+ send(data: string | ArrayBufferView): void;
11
+ close(code?: number, reason?: string): void;
12
+ addEventListener(type: "open" | "close" | "error" | "message", listener: never): void;
13
+ removeEventListener(type: "open" | "close" | "error" | "message", listener: never): void;
14
+ }
15
+
16
+ const OPEN = 1;
17
+
18
+ export interface StandardWebSocketOptions {
19
+ readonly clock?: Clock;
20
+ readonly pollMs?: number;
21
+ }
22
+
23
+ const DEFAULT_POLL_MS = 4;
24
+
25
+ interface MessageLike {
26
+ readonly data: unknown;
27
+ }
28
+
29
+ interface Waiter {
30
+ readonly target: number;
31
+ readonly resolve: () => void;
32
+ }
33
+
34
+ export class StandardWebSocketConnection implements UplinkTransportConnection {
35
+ readonly #socket: StandardWebSocket;
36
+ readonly #clock: Clock;
37
+ readonly #pollMs: number;
38
+ readonly #dataListeners = new Set<(chunk: string) => void>();
39
+ readonly #binaryListeners = new Set<(bytes: Uint8Array) => void>();
40
+ readonly #closeListeners = new Set<() => void>();
41
+ readonly #errorListeners = new Set<(error: Error) => void>();
42
+ readonly #sentListeners = new Set<() => void>();
43
+ readonly #pending: string[] = [];
44
+ #waiters: Waiter[] = [];
45
+ #queued = 0;
46
+ #flushed = 0;
47
+ #checkScheduled = false;
48
+ #timer: TimerHandle | undefined;
49
+ #closed = false;
50
+
51
+ constructor(socket: StandardWebSocket, options: StandardWebSocketOptions = {}) {
52
+ this.#socket = socket;
53
+ this.#clock = options.clock ?? globalClock;
54
+ this.#pollMs = options.pollMs ?? DEFAULT_POLL_MS;
55
+ // why: the default, "blob", cannot be read without awaiting, which would reorder messages.
56
+ socket.binaryType = "arraybuffer";
57
+ const onMessage = (event: MessageLike) => {
58
+ this.#onMessage(event.data);
59
+ };
60
+ const onClose = () => this.#markClosed();
61
+ const onError = (event: { readonly error?: unknown }) => {
62
+ const error = event.error instanceof Error ? event.error : new Error("WebSocket error");
63
+ for (const listener of this.#errorListeners) listener(error);
64
+ this.#markClosed();
65
+ };
66
+ socket.addEventListener("message", onMessage as never);
67
+ socket.addEventListener("close", onClose as never);
68
+ socket.addEventListener("error", onError as never);
69
+ }
70
+
71
+ get closed(): boolean {
72
+ return this.#closed;
73
+ }
74
+
75
+ get #buffered(): number {
76
+ return this.#socket.bufferedAmount ?? 0;
77
+ }
78
+
79
+ get unsentBytes(): number {
80
+ return this.#buffered;
81
+ }
82
+
83
+ onData(listener: (chunk: string) => void): () => void {
84
+ this.#dataListeners.add(listener);
85
+ if (this.#pending.length > 0) {
86
+ for (const chunk of this.#pending.splice(0)) listener(chunk);
87
+ }
88
+ return () => {
89
+ this.#dataListeners.delete(listener);
90
+ };
91
+ }
92
+
93
+ onBinary(listener: (bytes: Uint8Array) => void): () => void {
94
+ this.#binaryListeners.add(listener);
95
+ return () => {
96
+ this.#binaryListeners.delete(listener);
97
+ };
98
+ }
99
+
100
+ onClose(listener: () => void): () => void {
101
+ if (this.#closed) {
102
+ listener();
103
+ return () => undefined;
104
+ }
105
+ this.#closeListeners.add(listener);
106
+ return () => {
107
+ this.#closeListeners.delete(listener);
108
+ };
109
+ }
110
+
111
+ onError(listener: (error: Error) => void): () => void {
112
+ this.#errorListeners.add(listener);
113
+ return () => {
114
+ this.#errorListeners.delete(listener);
115
+ };
116
+ }
117
+
118
+ onSent(listener: () => void): () => void {
119
+ this.#sentListeners.add(listener);
120
+ return () => {
121
+ this.#sentListeners.delete(listener);
122
+ };
123
+ }
124
+
125
+ write(contents: string): Promise<void> {
126
+ return this.#send(contents);
127
+ }
128
+
129
+ writeBinary(bytes: Uint8Array): Promise<void> {
130
+ return this.#send(bytes);
131
+ }
132
+
133
+ async close(): Promise<void> {
134
+ if (this.#closed) return;
135
+ this.#socket.close();
136
+ this.#markClosed();
137
+ }
138
+
139
+ #send(data: string | Uint8Array): Promise<void> {
140
+ if (this.#closed || this.#socket.readyState !== OPEN) return Promise.resolve();
141
+ // why: the standard send() has no completion callback, so what has left the buffer is worked out from bufferedAmount.
142
+ const before = this.#buffered;
143
+ this.#socket.send(data);
144
+ this.#queued += Math.max(0, this.#buffered - before);
145
+ return new Promise<void>((resolve) => {
146
+ this.#waiters.push({ resolve, target: this.#queued });
147
+ this.#scheduleCheck();
148
+ });
149
+ }
150
+
151
+ #onMessage(data: unknown): void {
152
+ if (typeof data === "string") {
153
+ if (this.#dataListeners.size === 0) {
154
+ this.#pending.push(data);
155
+ } else {
156
+ for (const listener of this.#dataListeners) listener(data);
157
+ }
158
+ } else if (data instanceof ArrayBuffer) {
159
+ const bytes = new Uint8Array(data);
160
+ for (const listener of this.#binaryListeners) listener(bytes);
161
+ } else if (ArrayBuffer.isView(data)) {
162
+ const bytes = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
163
+ for (const listener of this.#binaryListeners) listener(bytes);
164
+ }
165
+ // why: a peer's message, a credit most of all, says bytes are moving, and is a cheaper look than a timer.
166
+ if (this.#waiters.length > 0 || this.#flushed < this.#queued) this.#check();
167
+ }
168
+
169
+ #scheduleCheck(): void {
170
+ if (this.#checkScheduled) return;
171
+ // why: a write must not settle, or report sent, before the call that made it has returned.
172
+ this.#checkScheduled = true;
173
+ void Promise.resolve().then(() => {
174
+ this.#checkScheduled = false;
175
+ this.#check();
176
+ });
177
+ }
178
+
179
+ #check(): void {
180
+ if (this.#closed) return;
181
+ const buffered = this.#buffered;
182
+ const flushed = Math.max(this.#flushed, this.#queued - buffered);
183
+ const moved = flushed > this.#flushed;
184
+ this.#flushed = flushed;
185
+ if (this.#waiters.some((waiter) => waiter.target <= flushed)) {
186
+ const due = this.#waiters.filter((waiter) => waiter.target <= flushed);
187
+ this.#waiters = this.#waiters.filter((waiter) => waiter.target > flushed);
188
+ for (const waiter of due) waiter.resolve();
189
+ }
190
+ if (moved) for (const listener of this.#sentListeners) listener();
191
+ if (buffered === 0 && this.#timer !== undefined) {
192
+ this.#clock.cancel(this.#timer);
193
+ this.#timer = undefined;
194
+ }
195
+ // why: a background tab throttles timers to about one a second, so a large transfer there slows until the tab is shown again.
196
+ if (buffered > 0 && this.#timer === undefined) {
197
+ this.#timer = this.#clock.setTimer(this.#pollMs, () => {
198
+ this.#timer = undefined;
199
+ this.#check();
200
+ });
201
+ }
202
+ }
203
+
204
+ #markClosed(): void {
205
+ if (this.#closed) return;
206
+ this.#closed = true;
207
+ if (this.#timer !== undefined) {
208
+ this.#clock.cancel(this.#timer);
209
+ this.#timer = undefined;
210
+ }
211
+ for (const waiter of this.#waiters.splice(0)) waiter.resolve();
212
+ for (const listener of this.#closeListeners) listener();
213
+ }
214
+ }
215
+
216
+ export interface StandardWebSocketDialOptions extends StandardWebSocketOptions {
217
+ readonly url: string;
218
+ readonly protocols?: string | string[];
219
+ readonly createSocket?: (url: string, protocols?: string | string[]) => StandardWebSocket;
220
+ }
221
+
222
+ export async function connectStandardWebSocket(
223
+ options: StandardWebSocketDialOptions,
224
+ ): Promise<UplinkTransportConnection> {
225
+ const create =
226
+ options.createSocket ??
227
+ ((url: string, protocols?: string | string[]) =>
228
+ new WebSocket(url, protocols) as unknown as StandardWebSocket);
229
+ const socket = create(options.url, options.protocols);
230
+ const connection = new StandardWebSocketConnection(socket, options);
231
+ await new Promise<void>((resolve, reject) => {
232
+ if (socket.readyState === OPEN) return resolve();
233
+ const onOpen = () => {
234
+ cleanup();
235
+ resolve();
236
+ };
237
+ const onFail = () => {
238
+ cleanup();
239
+ reject(new Error(`could not open a WebSocket to ${options.url}`));
240
+ };
241
+ const cleanup = () => {
242
+ socket.removeEventListener("open", onOpen as never);
243
+ socket.removeEventListener("error", onFail as never);
244
+ socket.removeEventListener("close", onFail as never);
245
+ };
246
+ socket.addEventListener("open", onOpen as never);
247
+ socket.addEventListener("error", onFail as never);
248
+ socket.addEventListener("close", onFail as never);
249
+ });
250
+ return connection;
251
+ }
@@ -0,0 +1,34 @@
1
+ import {
2
+ ByteLengthQueuingStrategy as PonyfillByteLengthQueuingStrategy,
3
+ ReadableStream as PonyfillReadableStream,
4
+ WritableStream as PonyfillWritableStream,
5
+ } from "web-streams-polyfill";
6
+
7
+ // harness: React Native has no WHATWG streams. The ponyfill fills the gap without
8
+ // installing globals, so the app under test sees the runtime it would see in production.
9
+
10
+ interface StreamGlobals {
11
+ readonly ReadableStream?: typeof globalThis.ReadableStream;
12
+ readonly WritableStream?: typeof globalThis.WritableStream;
13
+ readonly ByteLengthQueuingStrategy?: typeof globalThis.ByteLengthQueuingStrategy;
14
+ }
15
+
16
+ const host = globalThis as StreamGlobals;
17
+
18
+ // why: one source for all three, so a native stream never meets a ponyfilled strategy.
19
+ const native =
20
+ typeof host.ReadableStream === "function" &&
21
+ typeof host.WritableStream === "function" &&
22
+ typeof host.ByteLengthQueuingStrategy === "function";
23
+
24
+ export const LinkReadableStream: typeof globalThis.ReadableStream = native
25
+ ? (host.ReadableStream as typeof globalThis.ReadableStream)
26
+ : (PonyfillReadableStream as unknown as typeof globalThis.ReadableStream);
27
+
28
+ export const LinkWritableStream: typeof globalThis.WritableStream = native
29
+ ? (host.WritableStream as typeof globalThis.WritableStream)
30
+ : (PonyfillWritableStream as unknown as typeof globalThis.WritableStream);
31
+
32
+ export const LinkByteLengthQueuingStrategy: typeof globalThis.ByteLengthQueuingStrategy = native
33
+ ? (host.ByteLengthQueuingStrategy as typeof globalThis.ByteLengthQueuingStrategy)
34
+ : (PonyfillByteLengthQueuingStrategy as unknown as typeof globalThis.ByteLengthQueuingStrategy);
package/src/protocol.ts CHANGED
@@ -38,20 +38,25 @@ export type BridgeReadyMessage = {
38
38
  device: DeviceDescriptor;
39
39
  };
40
40
 
41
- export type BridgePingMessage = {
42
- type: 'ping';
41
+ /**
42
+ * Sent by the server to check that the app's JS thread is getting a turn to
43
+ * run. Only JS answers it, unlike WebSocket protocol pings, which the native
44
+ * WebSocket answers.
45
+ */
46
+ export type BridgeJsProbeMessage = {
47
+ type: 'js-probe';
43
48
  id: number;
44
49
  };
45
50
 
46
- export type BridgePongMessage = {
47
- type: 'pong';
51
+ export type BridgeJsProbeAckMessage = {
52
+ type: 'js-probe-ack';
48
53
  id: number;
49
54
  };
50
55
 
51
56
  export type BridgeControlMessage =
52
57
  | BridgeReadyMessage
53
- | BridgePingMessage
54
- | BridgePongMessage;
58
+ | BridgeJsProbeMessage
59
+ | BridgeJsProbeAckMessage;
55
60
 
56
61
  export type BridgeMessage<Event extends { type: string } = BridgeEvents> =
57
62
  | BridgeInvokeMessage
@@ -171,10 +176,10 @@ export const parseBridgeMessage = (raw: string): BridgeMessage => {
171
176
  readDeviceDescriptor(parsed.device);
172
177
  return parsed as BridgeReadyMessage;
173
178
  }
174
- case 'ping':
175
- case 'pong': {
179
+ case 'js-probe':
180
+ case 'js-probe-ack': {
176
181
  readNumber(parsed.id, 'id');
177
- return parsed as BridgePingMessage | BridgePongMessage;
182
+ return parsed as BridgeJsProbeMessage | BridgeJsProbeAckMessage;
178
183
  }
179
184
  default:
180
185
  throw new Error(`Invalid bridge message: unknown type ${messageType}`);
package/src/rpc-peer.ts CHANGED
@@ -5,7 +5,10 @@ import {
5
5
  serializeBridgeMessage,
6
6
  type BridgeControlMessage,
7
7
  } from './protocol.js';
8
- import type { RpcTransport } from './transport.js';
8
+
9
+ export type RpcTransport = {
10
+ send: (message: string) => void;
11
+ };
9
12
 
10
13
  type RpcMethod = {
11
14
  bivarianceHack(...args: unknown[]): unknown;
@@ -217,8 +220,8 @@ export const createRpcPeer = <
217
220
  return null;
218
221
  }
219
222
  case 'ready':
220
- case 'ping':
221
- case 'pong':
223
+ case 'js-probe':
224
+ case 'js-probe-ack':
222
225
  return message;
223
226
  }
224
227
  },
package/src/server.ts CHANGED
@@ -7,20 +7,26 @@ import os from 'node:os';
7
7
  import path from 'node:path';
8
8
  import { randomUUID } from 'node:crypto';
9
9
  import { logger } from '@react-native-harness/tools';
10
- import { BinaryStore, parseBinaryFrame } from './binary-transfer.js';
10
+ import { receiveBinary } from './binary.js';
11
11
  import {
12
12
  AppBridgeDisconnectedError,
13
13
  DeviceNotRespondingError,
14
14
  } from './errors.js';
15
- import { createHeartbeat } from './heartbeat.js';
15
+ import {
16
+ createAppLiveness,
17
+ resolveAppLivenessOptions,
18
+ type AppLivenessOverrides,
19
+ } from './app-liveness.js';
16
20
  import { matchImageSnapshot } from './image-snapshot.js';
21
+ import {
22
+ createLink,
23
+ globalClock,
24
+ StandardWebSocketConnection,
25
+ type LinkConnection,
26
+ type StandardWebSocket,
27
+ } from './link/index.js';
17
28
  import { serializeBridgeMessage } from './protocol.js';
18
29
  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';
24
30
  import type {
25
31
  BridgeServerFunctions,
26
32
  BridgeClientFunctions,
@@ -37,6 +43,10 @@ export {
37
43
  AppBridgeDisconnectedError,
38
44
  DeviceNotRespondingError,
39
45
  } from './errors.js';
46
+ export {
47
+ DEFAULT_APP_LIVENESS_OPTIONS,
48
+ type AppLivenessOverrides,
49
+ } from './app-liveness.js';
40
50
 
41
51
  const bridgeLogger = logger.child('bridge');
42
52
  const noop = (): void => undefined;
@@ -61,6 +71,8 @@ type TransportOptions =
61
71
  export type HarnessBridgeOptions = TransportOptions & {
62
72
  timeout?: number;
63
73
  context: HarnessContext;
74
+ /** Overrides for the connection check and JS watchdog limits. */
75
+ liveness?: AppLivenessOverrides;
64
76
  };
65
77
 
66
78
  export type HarnessBridge = {
@@ -92,17 +104,17 @@ const createWss = (transport: TransportOptions): Promise<WebSocketServer> => {
92
104
  };
93
105
 
94
106
  const receiveScreenshot = async (
95
- binaryStore: BinaryStore,
107
+ link: LinkConnection,
96
108
  reference: BinaryDataReference,
97
109
  ): Promise<FileReference> => {
98
- const data = binaryStore.get(reference.transferId);
99
- if (!data) {
110
+ const data = await receiveBinary(link, reference.channelId);
111
+
112
+ if (data.length !== reference.size) {
100
113
  throw new Error(
101
- `Binary data for transfer ${reference.transferId} not found or expired`,
114
+ `Screenshot transfer ended after ${data.length} of ${reference.size} bytes`,
102
115
  );
103
116
  }
104
117
 
105
- binaryStore.delete(reference.transferId);
106
118
  const file = path.join(os.tmpdir(), `harness-screenshot-${randomUUID()}.png`);
107
119
  await fs.writeFile(file, data);
108
120
  return { path: file };
@@ -111,13 +123,18 @@ const receiveScreenshot = async (
111
123
  export const createHarnessBridge = async (
112
124
  options: HarnessBridgeOptions,
113
125
  ): Promise<HarnessBridge> => {
114
- const { timeout, context, ...transportOptions } = options;
126
+ const { timeout, context, liveness, ...transportOptions } = options;
127
+ const livenessOptions = resolveAppLivenessOptions(liveness);
115
128
  const wss = await createWss(transportOptions);
116
129
  bridgeLogger.debug('bridge server ready');
117
130
 
118
131
  const emitter = new EventEmitter();
132
+ // Screenshot files written by this bridge that have not been compared yet.
133
+ const receivedScreenshots = new Set<string>();
134
+ const removeScreenshot = (file: string) =>
135
+ fs.rm(file, { force: true }).catch(() => undefined);
119
136
  let currentConnection: AppConnection | null = null;
120
- let activeSession: { disconnect: (reason?: Error) => void; transport: BridgeTransport } | null =
137
+ let activeSession: { disconnect: (reason?: Error) => void; link: LinkConnection } | null =
121
138
  null;
122
139
  const connectionWaiters: Array<{
123
140
  resolve: (c: AppConnection) => void;
@@ -131,34 +148,55 @@ export const createHarnessBridge = async (
131
148
  }
132
149
 
133
150
  bridgeLogger.debug('app connected');
134
- const transport = createNodeWebSocketTransport(ws);
135
- const binaryStore = new BinaryStore();
151
+ const link = createLink(
152
+ new StandardWebSocketConnection(ws as unknown as StandardWebSocket),
153
+ {
154
+ end: 'gateway',
155
+ clock: globalClock,
156
+ logger: bridgeLogger.child('link'),
157
+ },
158
+ );
136
159
  let readyConnection: AppConnection | null = null;
137
160
  let disconnected = false;
138
161
  let offMessage: () => void = noop;
139
162
  let offClose: () => void = noop;
140
163
  let offError: () => void = noop;
141
164
 
142
- const closeTransport = () => {
143
- if (transport.state === 'closing' || transport.state === 'closed') {
144
- return;
145
- }
146
-
147
- transport.close(1012);
148
- };
149
-
150
165
  const rpc = createRpcPeer<
151
166
  BridgeServerFunctions,
152
167
  BridgeClientFunctions,
153
168
  BridgeEvents
154
169
  >({
155
170
  localMethods: {
156
- 'device.screenshot.receive': (ref) => receiveScreenshot(binaryStore, ref),
157
- 'test.matchImageSnapshot': (screenshot, testPath, opts) =>
158
- matchImageSnapshot(screenshot, testPath, opts, context.platform.name),
171
+ 'device.screenshot.receive': async (ref) => {
172
+ const file = await receiveScreenshot(link, ref);
173
+ receivedScreenshots.add(file.path);
174
+ return file;
175
+ },
176
+ 'test.matchImageSnapshot': async (screenshot, testPath, opts) => {
177
+ try {
178
+ return await matchImageSnapshot(
179
+ screenshot,
180
+ testPath,
181
+ opts,
182
+ context.platform.name,
183
+ context.updateSnapshot ?? 'new'
184
+ );
185
+ } finally {
186
+ // Only delete files this bridge created, never a client-supplied path.
187
+ if (receivedScreenshots.delete(screenshot.path)) {
188
+ await removeScreenshot(screenshot.path);
189
+ }
190
+ }
191
+ },
192
+ },
193
+ transport: {
194
+ send: (message) => {
195
+ void link.write(message);
196
+ },
159
197
  },
160
- transport: createRpcTransport(transport),
161
198
  onEvent: (event) => {
199
+ appLiveness.handleEvent(event);
162
200
  emitter.emit('event', event);
163
201
  },
164
202
  callTimeoutMs: (method) => method === 'runTests' ? undefined : timeout,
@@ -167,14 +205,24 @@ export const createHarnessBridge = async (
167
205
  },
168
206
  });
169
207
 
170
- const heartbeat = createHeartbeat({
171
- sendPing: (id) => {
172
- transport.send(serializeBridgeMessage({ type: 'ping', id }));
208
+ const appLiveness = createAppLiveness({
209
+ socket: ws,
210
+ sendProbe: (id) => {
211
+ void link.write(serializeBridgeMessage({ type: 'js-probe', id }));
173
212
  },
174
- onTimeout: () => {
175
- bridgeLogger.warn('app heartbeat timed out');
176
- disconnect(new AppBridgeDisconnectedError('heartbeat-timeout'));
213
+ onLost: (error) => {
214
+ if (error.reason === 'connection-lost') {
215
+ bridgeLogger.warn('app connection lost (no response to WebSocket ping)');
216
+ } else {
217
+ bridgeLogger.warn('app JS thread unresponsive: %s', error.detail);
218
+ }
219
+
220
+ disconnect(error);
221
+ // Neither a half-open socket nor a blocked JS thread completes a
222
+ // close handshake, so drop the socket instead of waiting for one.
223
+ ws.terminate();
177
224
  },
225
+ config: livenessOptions,
178
226
  });
179
227
 
180
228
  const disconnect = (reason?: Error) => {
@@ -187,10 +235,9 @@ export const createHarnessBridge = async (
187
235
  offClose();
188
236
  offError();
189
237
  bridgeLogger.debug('app disconnected');
190
- heartbeat.dispose();
191
- binaryStore.dispose();
238
+ appLiveness.dispose();
192
239
 
193
- if (activeSession?.transport === transport) {
240
+ if (activeSession?.link === link) {
194
241
  activeSession = null;
195
242
  }
196
243
 
@@ -199,14 +246,14 @@ export const createHarnessBridge = async (
199
246
  }
200
247
 
201
248
  rpc.close(reason ?? new AppBridgeDisconnectedError('app-disconnected'));
202
- closeTransport();
249
+ void link.close();
203
250
 
204
251
  if (readyConnection) {
205
252
  emitter.emit('disconnected');
206
253
  }
207
254
  };
208
255
 
209
- activeSession = { disconnect, transport };
256
+ activeSession = { disconnect, link };
210
257
 
211
258
  const handleControlMessage = async (message: string) => {
212
259
  const controlMessage = await rpc.handleMessage(message);
@@ -241,34 +288,14 @@ export const createHarnessBridge = async (
241
288
  }
242
289
  return;
243
290
  }
244
- case 'ping': {
245
- transport.send(
246
- serializeBridgeMessage({ type: 'pong', id: controlMessage.id }),
247
- );
248
- return;
249
- }
250
- case 'pong': {
251
- heartbeat.notifyPong(controlMessage.id);
291
+ case 'js-probe-ack': {
292
+ appLiveness.handleProbeAck(controlMessage.id);
252
293
  return;
253
294
  }
254
295
  }
255
296
  };
256
297
 
257
- const handleBinaryMessage = (message: Uint8Array) => {
258
- try {
259
- const { transferId, data } = parseBinaryFrame(message);
260
- binaryStore.add(transferId, data);
261
- } catch (error) {
262
- bridgeLogger.warn('failed to parse binary frame: %s', error);
263
- }
264
- };
265
-
266
- offMessage = transport.onMessage((message) => {
267
- if (typeof message !== 'string') {
268
- handleBinaryMessage(message);
269
- return;
270
- }
271
-
298
+ offMessage = link.onData((message) => {
272
299
  void handleControlMessage(message).catch((error) => {
273
300
  bridgeLogger.warn('failed to handle bridge message: %s', error);
274
301
  disconnect(
@@ -279,17 +306,17 @@ export const createHarnessBridge = async (
279
306
  });
280
307
  });
281
308
 
282
- offClose = transport.onClose(() => {
283
- disconnect();
284
- });
285
-
286
- offError = transport.onError((error) => {
309
+ offError = link.onError((error) => {
287
310
  disconnect(
288
311
  error instanceof Error
289
312
  ? error
290
313
  : new AppBridgeDisconnectedError('socket-error'),
291
314
  );
292
315
  });
316
+
317
+ offClose = link.onClose(() => {
318
+ disconnect();
319
+ });
293
320
  });
294
321
 
295
322
  return {
@@ -344,6 +371,11 @@ export const createHarnessBridge = async (
344
371
 
345
372
  wss.close();
346
373
  emitter.removeAllListeners();
374
+
375
+ for (const file of receivedScreenshots) {
376
+ void removeScreenshot(file);
377
+ }
378
+ receivedScreenshots.clear();
347
379
  },
348
380
  };
349
381
  };
@@ -14,6 +14,8 @@ export type TestResultStatus = 'passed' | 'failed' | 'skipped' | 'todo';
14
14
  export type SerializedError = {
15
15
  name: string;
16
16
  message: string;
17
+ // Expected/received diff of a failed assertion, formatted like Vitest's.
18
+ diff?: string;
17
19
  codeFrame?: CodeFrame;
18
20
  diagnostics?: {
19
21
  pendingPromises?: {
@@ -42,6 +44,11 @@ export type TestRunnerSuiteStartedEvent = {
42
44
  type: 'suite-started';
43
45
  name: string;
44
46
  file: string;
47
+ /**
48
+ * The timeout in ms of the suite's `beforeAll`/`afterAll` hooks. Absent
49
+ * for suites that are skipped or todo.
50
+ */
51
+ timeout?: number;
45
52
  };
46
53
 
47
54
  export type TestRunnerTestStartedEvent = {
@@ -53,6 +60,11 @@ export type TestRunnerTestStartedEvent = {
53
60
  fullName: string;
54
61
  startedAt: number;
55
62
  declarationMode?: TestDeclarationMode;
63
+ /**
64
+ * The test's timeout in ms, covering its `beforeEach`/`afterEach` hooks.
65
+ * Absent for tests that are skipped or todo.
66
+ */
67
+ timeout?: number;
56
68
  };
57
69
 
58
70
  export type TestRunnerTestFinishedEvent = {