@react-native-harness/bridge 1.6.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.
- package/dist/app-liveness.d.ts +33 -0
- package/dist/app-liveness.d.ts.map +1 -0
- package/dist/app-liveness.js +57 -0
- package/dist/client.d.ts +6 -4
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +112 -105
- package/dist/connection-check.d.ts +22 -0
- package/dist/connection-check.d.ts.map +1 -0
- package/dist/connection-check.js +30 -0
- package/dist/errors.d.ts +6 -2
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +12 -4
- package/dist/image-snapshot.d.ts +2 -2
- package/dist/image-snapshot.d.ts.map +1 -1
- package/dist/image-snapshot.js +21 -8
- package/dist/index.d.ts +0 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +0 -2
- package/dist/js-block-budget-policy.d.ts +31 -0
- package/dist/js-block-budget-policy.d.ts.map +1 -0
- package/dist/js-block-budget-policy.js +60 -0
- package/dist/js-watchdog.d.ts +31 -0
- package/dist/js-watchdog.d.ts.map +1 -0
- package/dist/js-watchdog.js +46 -0
- package/dist/protocol.d.ts +10 -5
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +2 -2
- package/dist/rpc-peer.d.ts +3 -1
- package/dist/rpc-peer.d.ts.map +1 -1
- package/dist/rpc-peer.js +2 -2
- package/dist/server.d.ts +4 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +71 -56
- package/dist/shared/test-runner.d.ts +11 -0
- package/dist/shared/test-runner.d.ts.map +1 -1
- package/dist/shared.d.ts +5 -1
- package/dist/shared.d.ts.map +1 -1
- package/package.json +4 -3
- package/src/app-liveness.ts +103 -0
- package/src/binary.ts +74 -0
- package/src/client.ts +172 -170
- package/src/connection-check.ts +60 -0
- package/src/errors.ts +16 -5
- package/src/image-snapshot.ts +31 -9
- package/src/index.ts +0 -2
- package/src/js-block-budget-policy.ts +90 -0
- package/src/js-watchdog.ts +78 -0
- package/src/link/README.md +43 -0
- package/src/link/channel.ts +473 -0
- package/src/link/envelope.ts +110 -0
- package/src/link/index.ts +321 -0
- package/src/link/ports.ts +42 -0
- package/src/link/scheduler.ts +132 -0
- package/src/link/standard-websocket.ts +251 -0
- package/src/link/streams.ts +34 -0
- package/src/protocol.ts +14 -9
- package/src/rpc-peer.ts +6 -3
- package/src/server.ts +99 -67
- package/src/shared/test-runner.ts +12 -0
- package/src/shared.ts +6 -1
- package/dist/binary-transfer.d.ts +0 -17
- package/dist/binary-transfer.d.ts.map +0 -1
- package/dist/binary-transfer.js +0 -60
- package/dist/events.d.ts +0 -45
- package/dist/events.d.ts.map +0 -1
- package/dist/events.js +0 -1
- package/dist/heartbeat.d.ts +0 -13
- package/dist/heartbeat.d.ts.map +0 -1
- package/dist/heartbeat.js +0 -49
- package/dist/platform-bridge.d.ts +0 -4
- package/dist/platform-bridge.d.ts.map +0 -1
- package/dist/platform-bridge.js +0 -25
- package/dist/transport.d.ts +0 -22
- package/dist/transport.d.ts.map +0 -1
- package/dist/transport.js +0 -22
- package/dist/websocket-client-transport.d.ts +0 -3
- package/dist/websocket-client-transport.d.ts.map +0 -1
- package/dist/websocket-client-transport.js +0 -45
- package/dist/websocket-server-transport.d.ts +0 -4
- package/dist/websocket-server-transport.d.ts.map +0 -1
- package/dist/websocket-server-transport.js +0 -43
- package/src/binary-transfer.ts +0 -79
- package/src/heartbeat.ts +0 -67
- package/src/transport.ts +0 -47
- package/src/websocket-client-transport.ts +0 -85
- 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
|
-
|
|
42
|
-
|
|
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
|
|
47
|
-
type: '
|
|
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
|
-
|
|
|
54
|
-
|
|
|
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 '
|
|
175
|
-
case '
|
|
179
|
+
case 'js-probe':
|
|
180
|
+
case 'js-probe-ack': {
|
|
176
181
|
readNumber(parsed.id, 'id');
|
|
177
|
-
return parsed as
|
|
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
|
-
|
|
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 '
|
|
221
|
-
case '
|
|
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 {
|
|
10
|
+
import { receiveBinary } from './binary.js';
|
|
11
11
|
import {
|
|
12
12
|
AppBridgeDisconnectedError,
|
|
13
13
|
DeviceNotRespondingError,
|
|
14
14
|
} from './errors.js';
|
|
15
|
-
import {
|
|
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
|
-
|
|
107
|
+
link: LinkConnection,
|
|
96
108
|
reference: BinaryDataReference,
|
|
97
109
|
): Promise<FileReference> => {
|
|
98
|
-
const data =
|
|
99
|
-
|
|
110
|
+
const data = await receiveBinary(link, reference.channelId);
|
|
111
|
+
|
|
112
|
+
if (data.length !== reference.size) {
|
|
100
113
|
throw new Error(
|
|
101
|
-
`
|
|
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;
|
|
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
|
|
135
|
-
|
|
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) =>
|
|
157
|
-
|
|
158
|
-
|
|
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
|
|
171
|
-
|
|
172
|
-
|
|
208
|
+
const appLiveness = createAppLiveness({
|
|
209
|
+
socket: ws,
|
|
210
|
+
sendProbe: (id) => {
|
|
211
|
+
void link.write(serializeBridgeMessage({ type: 'js-probe', id }));
|
|
173
212
|
},
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
191
|
-
binaryStore.dispose();
|
|
238
|
+
appLiveness.dispose();
|
|
192
239
|
|
|
193
|
-
if (activeSession?.
|
|
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
|
-
|
|
249
|
+
void link.close();
|
|
203
250
|
|
|
204
251
|
if (readyConnection) {
|
|
205
252
|
emitter.emit('disconnected');
|
|
206
253
|
}
|
|
207
254
|
};
|
|
208
255
|
|
|
209
|
-
activeSession = { disconnect,
|
|
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 '
|
|
245
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 = {
|