@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,103 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createConnectionCheck,
|
|
3
|
+
type ConnectionCheckOptions,
|
|
4
|
+
type PingableSocket,
|
|
5
|
+
} from './connection-check.js';
|
|
6
|
+
import { AppBridgeDisconnectedError } from './errors.js';
|
|
7
|
+
import {
|
|
8
|
+
createJsBlockBudgetPolicy,
|
|
9
|
+
getIdleBudget,
|
|
10
|
+
type JsBlockBudgetPolicyOptions,
|
|
11
|
+
} from './js-block-budget-policy.js';
|
|
12
|
+
import { createJsWatchdog } from './js-watchdog.js';
|
|
13
|
+
import type { BridgeEvents } from './shared.js';
|
|
14
|
+
|
|
15
|
+
export type AppLivenessOptions = {
|
|
16
|
+
connectionCheck: ConnectionCheckOptions;
|
|
17
|
+
jsBlockBudget: JsBlockBudgetPolicyOptions;
|
|
18
|
+
jsProbeIntervalMs?: number;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type AppLivenessOverrides = {
|
|
22
|
+
connectionCheck?: Partial<ConnectionCheckOptions>;
|
|
23
|
+
jsBlockBudget?: Partial<JsBlockBudgetPolicyOptions>;
|
|
24
|
+
jsProbeIntervalMs?: number;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const DEFAULT_APP_LIVENESS_OPTIONS: AppLivenessOptions = {
|
|
28
|
+
connectionCheck: { intervalMs: 5_000, timeoutMs: 20_000 },
|
|
29
|
+
jsBlockBudget: { idleMs: 20_000, fileLoadMs: 60_000, graceMs: 5_000 },
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// An override left `undefined` keeps the default instead of replacing it,
|
|
33
|
+
// which would turn a budget into `undefined` and disable it.
|
|
34
|
+
const definedOnly = <T extends object>(values: Partial<T> = {}): Partial<T> =>
|
|
35
|
+
Object.fromEntries(
|
|
36
|
+
Object.entries(values).filter(([, value]) => value !== undefined),
|
|
37
|
+
) as Partial<T>;
|
|
38
|
+
|
|
39
|
+
export const resolveAppLivenessOptions = (
|
|
40
|
+
overrides: AppLivenessOverrides = {},
|
|
41
|
+
): AppLivenessOptions => ({
|
|
42
|
+
connectionCheck: {
|
|
43
|
+
...DEFAULT_APP_LIVENESS_OPTIONS.connectionCheck,
|
|
44
|
+
...definedOnly(overrides.connectionCheck),
|
|
45
|
+
},
|
|
46
|
+
jsBlockBudget: {
|
|
47
|
+
...DEFAULT_APP_LIVENESS_OPTIONS.jsBlockBudget,
|
|
48
|
+
...definedOnly(overrides.jsBlockBudget),
|
|
49
|
+
},
|
|
50
|
+
jsProbeIntervalMs: overrides.jsProbeIntervalMs,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
export type AppLiveness = {
|
|
54
|
+
handleEvent: (event: BridgeEvents) => void;
|
|
55
|
+
handleProbeAck: (id: number) => void;
|
|
56
|
+
dispose: () => void;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Watches one app connection with two independent checks: the connection
|
|
61
|
+
* check (is the process reachable?) and the JS watchdog (is the JS thread
|
|
62
|
+
* getting a turn?). See docs/adr/0001-app-liveness-detection.md.
|
|
63
|
+
*/
|
|
64
|
+
export const createAppLiveness = (options: {
|
|
65
|
+
socket: PingableSocket;
|
|
66
|
+
sendProbe: (id: number) => void;
|
|
67
|
+
onLost: (error: AppBridgeDisconnectedError) => void;
|
|
68
|
+
config: AppLivenessOptions;
|
|
69
|
+
}): AppLiveness => {
|
|
70
|
+
const connectionCheck = createConnectionCheck(options.socket, {
|
|
71
|
+
...options.config.connectionCheck,
|
|
72
|
+
onLost: () => {
|
|
73
|
+
options.onLost(new AppBridgeDisconnectedError('connection-lost'));
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const policy = createJsBlockBudgetPolicy(options.config.jsBlockBudget);
|
|
78
|
+
const watchdog = createJsWatchdog({
|
|
79
|
+
sendProbe: options.sendProbe,
|
|
80
|
+
initialBudget: getIdleBudget(options.config.jsBlockBudget),
|
|
81
|
+
probeIntervalMs: options.config.jsProbeIntervalMs,
|
|
82
|
+
onUnresponsive: (budget) => {
|
|
83
|
+
options.onLost(
|
|
84
|
+
new AppBridgeDisconnectedError('js-thread-unresponsive', budget.label),
|
|
85
|
+
);
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
handleEvent: (event) => {
|
|
91
|
+
const budget = policy.getBudgetForEvent(event);
|
|
92
|
+
|
|
93
|
+
if (budget) {
|
|
94
|
+
watchdog.setBlockBudget(budget);
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
handleProbeAck: watchdog.notifyProbeAck,
|
|
98
|
+
dispose: () => {
|
|
99
|
+
connectionCheck.dispose();
|
|
100
|
+
watchdog.dispose();
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
};
|
package/src/binary.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { LinkChannel, LinkConnection } from './link/index.js';
|
|
2
|
+
|
|
3
|
+
export type OutgoingBinary = {
|
|
4
|
+
channelId: string;
|
|
5
|
+
/** Settles once every byte is handed to the link, or rejects if the channel fails. */
|
|
6
|
+
written: Promise<void>;
|
|
7
|
+
abort: (reason?: string) => void;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Opens a link channel and starts writing `data` into it. The channel id travels in an
|
|
12
|
+
* RPC message; the peer reads the bytes with `receiveBinary`. The link sends the channel's
|
|
13
|
+
* OPEN before any later message, so the peer always knows the id by the time it asks.
|
|
14
|
+
*/
|
|
15
|
+
export const sendBinary = async (
|
|
16
|
+
link: LinkConnection,
|
|
17
|
+
data: Uint8Array,
|
|
18
|
+
): Promise<OutgoingBinary> => {
|
|
19
|
+
const channel = await link.openChannel({
|
|
20
|
+
signal: new AbortController().signal,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const written = (async () => {
|
|
24
|
+
const writer = channel.writable.getWriter();
|
|
25
|
+
await writer.write(data);
|
|
26
|
+
await writer.close();
|
|
27
|
+
})();
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
channelId: channel.id,
|
|
31
|
+
written,
|
|
32
|
+
abort: (reason) => channel.abort(reason),
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const readAll = async (channel: LinkChannel): Promise<Uint8Array> => {
|
|
37
|
+
const reader = channel.readable.getReader();
|
|
38
|
+
const chunks: Uint8Array[] = [];
|
|
39
|
+
let size = 0;
|
|
40
|
+
|
|
41
|
+
for (;;) {
|
|
42
|
+
const { done, value } = await reader.read();
|
|
43
|
+
|
|
44
|
+
if (done) {
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
chunks.push(value);
|
|
49
|
+
size += value.length;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const bytes = new Uint8Array(size);
|
|
53
|
+
let offset = 0;
|
|
54
|
+
|
|
55
|
+
for (const chunk of chunks) {
|
|
56
|
+
bytes.set(chunk, offset);
|
|
57
|
+
offset += chunk.length;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return bytes;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** Accepts the channel the peer opened with `sendBinary` and reads it to the end. */
|
|
64
|
+
export const receiveBinary = async (
|
|
65
|
+
link: LinkConnection,
|
|
66
|
+
channelId: string,
|
|
67
|
+
): Promise<Uint8Array> => {
|
|
68
|
+
const channel = link.acceptChannel(channelId);
|
|
69
|
+
|
|
70
|
+
// Nothing flows back to the sender; ending this direction lets the channel free its slot.
|
|
71
|
+
void channel.writable.close().catch(() => undefined);
|
|
72
|
+
|
|
73
|
+
return readAll(channel);
|
|
74
|
+
};
|
package/src/client.ts
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { sendBinary } from './binary.js';
|
|
2
|
+
import {
|
|
3
|
+
connectStandardWebSocket,
|
|
4
|
+
createLink,
|
|
5
|
+
globalClock,
|
|
6
|
+
type LinkConnection,
|
|
7
|
+
type Logger,
|
|
8
|
+
type StandardWebSocket,
|
|
9
|
+
type UplinkTransportConnection,
|
|
10
|
+
} from './link/index.js';
|
|
2
11
|
import { serializeBridgeMessage } from './protocol.js';
|
|
3
12
|
import { createRpcPeer } from './rpc-peer.js';
|
|
4
|
-
import {
|
|
5
|
-
createRpcTransport,
|
|
6
|
-
type BridgeTransport,
|
|
7
|
-
} from './transport.js';
|
|
8
|
-
import { createWebSocketClientTransport } from './websocket-client-transport.js';
|
|
9
13
|
import type {
|
|
10
14
|
BridgeClientFunctions,
|
|
11
15
|
BridgeServerFunctions,
|
|
@@ -39,184 +43,182 @@ export type HarnessHandle = {
|
|
|
39
43
|
};
|
|
40
44
|
|
|
41
45
|
export type ConnectToHarnessOptions = {
|
|
42
|
-
transport
|
|
46
|
+
/** An already open link transport, used instead of dialing `url`. */
|
|
47
|
+
transport?: UplinkTransportConnection;
|
|
48
|
+
/** Creates the WebSocket to dial `url` with. Defaults to the global `WebSocket`. */
|
|
49
|
+
createSocket?: (url: string) => StandardWebSocket;
|
|
43
50
|
};
|
|
44
51
|
|
|
45
|
-
export {
|
|
52
|
+
export type { UplinkTransportConnection };
|
|
46
53
|
|
|
47
54
|
const noop = (): void => undefined;
|
|
48
55
|
|
|
49
|
-
|
|
56
|
+
const createConsoleLogger = (): Logger => {
|
|
57
|
+
const logger: Logger = {
|
|
58
|
+
debug: noop,
|
|
59
|
+
info: noop,
|
|
60
|
+
warn: (message) => console.warn(`[harness] ${message}`),
|
|
61
|
+
error: (message) => console.error(`[harness] ${message}`),
|
|
62
|
+
child: () => logger,
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
return logger;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// React Native has no `crypto.randomUUID`, so channel ids come from a counter. Ids only
|
|
69
|
+
// need to be unique among this connection's channels.
|
|
70
|
+
const createChannelIdGenerator = (): (() => string) => {
|
|
71
|
+
let nextId = 1;
|
|
72
|
+
return () => `app-${nextId++}`;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const dial = async (
|
|
76
|
+
url: string,
|
|
77
|
+
options: ConnectToHarnessOptions,
|
|
78
|
+
): Promise<UplinkTransportConnection> => {
|
|
79
|
+
if (options.transport) {
|
|
80
|
+
return options.transport;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
return await connectStandardWebSocket({
|
|
85
|
+
url,
|
|
86
|
+
createSocket: options.createSocket,
|
|
87
|
+
});
|
|
88
|
+
} catch (error) {
|
|
89
|
+
const detail =
|
|
90
|
+
error instanceof Error && error.message ? `: ${error.message}` : '';
|
|
91
|
+
throw new Error(`Failed to connect to Harness at ${url}${detail}`);
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
export const connectToHarness = async (
|
|
50
96
|
url: string,
|
|
51
97
|
callbacks: HarnessCallbacks,
|
|
52
98
|
options: ConnectToHarnessOptions = {},
|
|
53
|
-
): Promise<HarnessHandle> =>
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
}
|
|
99
|
+
): Promise<HarnessHandle> => {
|
|
100
|
+
const transport = await dial(url, options);
|
|
101
|
+
const link: LinkConnection = createLink(transport, {
|
|
102
|
+
end: 'worker',
|
|
103
|
+
clock: globalClock,
|
|
104
|
+
logger: createConsoleLogger(),
|
|
105
|
+
generateId: createChannelIdGenerator(),
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const rpc = createRpcPeer<
|
|
109
|
+
BridgeClientFunctions,
|
|
110
|
+
BridgeServerFunctions,
|
|
111
|
+
BridgeEvents
|
|
112
|
+
>({
|
|
113
|
+
localMethods: callbacks,
|
|
114
|
+
transport: {
|
|
115
|
+
send: (message) => {
|
|
116
|
+
void link.write(message);
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
let peerClosed = false;
|
|
122
|
+
let offMessage: () => void = noop;
|
|
123
|
+
let offClose: () => void = noop;
|
|
124
|
+
let offError: () => void = noop;
|
|
72
125
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
};
|
|
126
|
+
const getLinkClosedError = () => {
|
|
127
|
+
return new Error('Harness bridge link is closed');
|
|
128
|
+
};
|
|
77
129
|
|
|
78
|
-
|
|
79
|
-
|
|
130
|
+
const closePeer = (reason: Error) => {
|
|
131
|
+
if (peerClosed) {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
peerClosed = true;
|
|
136
|
+
offMessage();
|
|
137
|
+
offClose();
|
|
138
|
+
offError();
|
|
139
|
+
rpc.close(reason);
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const handleMessage = async (data: string) => {
|
|
143
|
+
const controlMessage = await rpc.handleMessage(data);
|
|
144
|
+
|
|
145
|
+
if (!controlMessage) {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (controlMessage.type === 'js-probe') {
|
|
150
|
+
void link.write(
|
|
151
|
+
serializeBridgeMessage({ type: 'js-probe-ack', id: controlMessage.id }),
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
offMessage = link.onData((message) => {
|
|
157
|
+
void handleMessage(message).catch((error) => {
|
|
158
|
+
closePeer(
|
|
159
|
+
error instanceof Error
|
|
160
|
+
? error
|
|
161
|
+
: new Error('Received invalid Harness bridge message'),
|
|
162
|
+
);
|
|
163
|
+
void link.close();
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
offError = link.onError((error) => {
|
|
168
|
+
closePeer(error);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
offClose = link.onClose(() => {
|
|
172
|
+
closePeer(new Error('Harness connection closed'));
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
reportReady: (device) => {
|
|
177
|
+
if (link.closed) {
|
|
178
|
+
closePeer(getLinkClosedError());
|
|
80
179
|
return;
|
|
81
180
|
}
|
|
82
181
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
>({
|
|
95
|
-
localMethods: callbacks,
|
|
96
|
-
transport: createRpcTransport(transport),
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
let offMessage: () => void = noop;
|
|
100
|
-
let offRuntimeClose: () => void = noop;
|
|
101
|
-
let offRuntimeError: () => void = noop;
|
|
102
|
-
|
|
103
|
-
const closePeer = (reason: Error) => {
|
|
104
|
-
if (peerClosed) {
|
|
105
|
-
return;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
peerClosed = true;
|
|
109
|
-
offMessage();
|
|
110
|
-
offRuntimeClose();
|
|
111
|
-
offRuntimeError();
|
|
112
|
-
rpc.close(reason);
|
|
113
|
-
};
|
|
114
|
-
|
|
115
|
-
const handleMessage = async (data: string) => {
|
|
116
|
-
const controlMessage = await rpc.handleMessage(data);
|
|
117
|
-
|
|
118
|
-
if (!controlMessage) {
|
|
119
|
-
return;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
if (controlMessage.type === 'ping') {
|
|
123
|
-
transport.send(
|
|
124
|
-
serializeBridgeMessage({ type: 'pong', id: controlMessage.id }),
|
|
125
|
-
);
|
|
126
|
-
}
|
|
127
|
-
};
|
|
128
|
-
|
|
129
|
-
offMessage = transport.onMessage((message) => {
|
|
130
|
-
if (typeof message !== 'string') {
|
|
131
|
-
return;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
void handleMessage(message).catch((error) => {
|
|
135
|
-
closePeer(
|
|
136
|
-
error instanceof Error
|
|
137
|
-
? error
|
|
138
|
-
: new Error('Received invalid Harness bridge message'),
|
|
139
|
-
);
|
|
140
|
-
|
|
141
|
-
if (transport.state === 'open') {
|
|
142
|
-
transport.close(1002, 'Invalid message');
|
|
143
|
-
}
|
|
144
|
-
});
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
offRuntimeClose = transport.onClose((event) => {
|
|
148
|
-
closePeer(
|
|
149
|
-
new Error(
|
|
150
|
-
`Harness connection closed (code ${event.code}${
|
|
151
|
-
event.reason ? `, reason: ${event.reason}` : ''
|
|
152
|
-
})`,
|
|
153
|
-
),
|
|
154
|
-
);
|
|
155
|
-
});
|
|
156
|
-
|
|
157
|
-
offRuntimeError = transport.onError((error) => {
|
|
158
|
-
closePeer(error);
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
resolve({
|
|
162
|
-
reportReady: (device) => {
|
|
163
|
-
try {
|
|
164
|
-
if (transport.state !== 'open') {
|
|
165
|
-
throw getTransportNotOpenError();
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
transport.send(serializeBridgeMessage({ type: 'ready', device }));
|
|
169
|
-
} catch (error) {
|
|
170
|
-
closePeer(
|
|
171
|
-
error instanceof Error ? error : getTransportNotOpenError(),
|
|
172
|
-
);
|
|
173
|
-
}
|
|
174
|
-
},
|
|
175
|
-
emitEvent: (event) => {
|
|
176
|
-
rpc.sendEvent(event);
|
|
177
|
-
},
|
|
178
|
-
transferScreenshot: async (data, metadata) => {
|
|
179
|
-
const transferId = generateTransferId();
|
|
180
|
-
transport.send(createBinaryFrame(transferId, data));
|
|
181
|
-
return rpc.invoke(
|
|
182
|
+
void link.write(serializeBridgeMessage({ type: 'ready', device }));
|
|
183
|
+
},
|
|
184
|
+
emitEvent: (event) => {
|
|
185
|
+
rpc.sendEvent(event);
|
|
186
|
+
},
|
|
187
|
+
transferScreenshot: async (data, metadata) => {
|
|
188
|
+
const binary = await sendBinary(link, data);
|
|
189
|
+
|
|
190
|
+
try {
|
|
191
|
+
const [file] = await Promise.all([
|
|
192
|
+
rpc.invoke(
|
|
182
193
|
'device.screenshot.receive',
|
|
183
|
-
{
|
|
194
|
+
{
|
|
195
|
+
type: 'binary',
|
|
196
|
+
channelId: binary.channelId,
|
|
197
|
+
size: data.length,
|
|
198
|
+
mimeType: 'image/png',
|
|
199
|
+
},
|
|
184
200
|
metadata,
|
|
185
|
-
);
|
|
186
|
-
},
|
|
187
|
-
matchImageSnapshot: (screenshot, testPath, options, runner) =>
|
|
188
|
-
rpc.invoke(
|
|
189
|
-
'test.matchImageSnapshot',
|
|
190
|
-
screenshot,
|
|
191
|
-
testPath,
|
|
192
|
-
options,
|
|
193
|
-
runner,
|
|
194
201
|
),
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
transport.close();
|
|
198
|
-
},
|
|
199
|
-
});
|
|
200
|
-
};
|
|
201
|
-
|
|
202
|
-
const handleError = (error: Error) => {
|
|
203
|
-
const detail = error.message ? `: ${error.message}` : '';
|
|
204
|
-
fail(`Failed to connect to Harness at ${url}${detail}`);
|
|
205
|
-
};
|
|
206
|
-
|
|
207
|
-
const handleClose = (event: { code: number; reason: string }) => {
|
|
208
|
-
fail(
|
|
209
|
-
`Harness connection at ${url} closed before becoming ready (code ${event.code}${
|
|
210
|
-
event.reason ? `, reason: ${event.reason}` : ''
|
|
211
|
-
})`,
|
|
212
|
-
);
|
|
213
|
-
};
|
|
202
|
+
binary.written,
|
|
203
|
+
]);
|
|
214
204
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
205
|
+
return file;
|
|
206
|
+
} catch (error) {
|
|
207
|
+
binary.abort();
|
|
208
|
+
throw error;
|
|
209
|
+
}
|
|
210
|
+
},
|
|
211
|
+
matchImageSnapshot: (screenshot, testPath, options, runner) =>
|
|
212
|
+
rpc.invoke(
|
|
213
|
+
'test.matchImageSnapshot',
|
|
214
|
+
screenshot,
|
|
215
|
+
testPath,
|
|
216
|
+
options,
|
|
217
|
+
runner,
|
|
218
|
+
),
|
|
219
|
+
disconnect: () => {
|
|
220
|
+
closePeer(new Error('Harness connection closed by client'));
|
|
221
|
+
void link.close();
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Checks that the app process is still reachable, using WebSocket protocol
|
|
3
|
+
* ping frames (RFC 6455). The app's native WebSocket answers them without
|
|
4
|
+
* involving JS (OkHttp on Android, SocketRocket on iOS, the network stack in
|
|
5
|
+
* browsers), so a blocked JS thread does not fail this check.
|
|
6
|
+
*/
|
|
7
|
+
export type PingableSocket = {
|
|
8
|
+
ping: () => void;
|
|
9
|
+
on: (event: 'pong', listener: () => void) => unknown;
|
|
10
|
+
off: (event: 'pong', listener: () => void) => unknown;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type ConnectionCheckOptions = {
|
|
14
|
+
intervalMs: number;
|
|
15
|
+
timeoutMs: number;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type ConnectionCheck = {
|
|
19
|
+
dispose: () => void;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const createConnectionCheck = (
|
|
23
|
+
socket: PingableSocket,
|
|
24
|
+
options: ConnectionCheckOptions & { onLost: () => void },
|
|
25
|
+
): ConnectionCheck => {
|
|
26
|
+
let lastPongAt = Date.now();
|
|
27
|
+
let disposed = false;
|
|
28
|
+
|
|
29
|
+
const handlePong = () => {
|
|
30
|
+
lastPongAt = Date.now();
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
socket.on('pong', handlePong);
|
|
34
|
+
|
|
35
|
+
const dispose = () => {
|
|
36
|
+
if (disposed) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
disposed = true;
|
|
41
|
+
clearInterval(interval);
|
|
42
|
+
socket.off('pong', handlePong);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const interval = setInterval(() => {
|
|
46
|
+
if (Date.now() - lastPongAt > options.timeoutMs) {
|
|
47
|
+
dispose();
|
|
48
|
+
options.onLost();
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
socket.ping();
|
|
54
|
+
} catch {
|
|
55
|
+
// The socket is closing; its close event ends the session.
|
|
56
|
+
}
|
|
57
|
+
}, options.intervalMs);
|
|
58
|
+
|
|
59
|
+
return { dispose };
|
|
60
|
+
};
|
package/src/errors.ts
CHANGED
|
@@ -13,7 +13,8 @@ export class DeviceNotRespondingError extends HarnessError {
|
|
|
13
13
|
export type AppBridgeDisconnectedReason =
|
|
14
14
|
| 'app-disconnected'
|
|
15
15
|
| 'app-replaced'
|
|
16
|
-
| '
|
|
16
|
+
| 'connection-lost'
|
|
17
|
+
| 'js-thread-unresponsive'
|
|
17
18
|
| 'socket-error'
|
|
18
19
|
| 'bridge-disposed';
|
|
19
20
|
|
|
@@ -23,8 +24,10 @@ const appBridgeDisconnectedMessage = (
|
|
|
23
24
|
switch (reason) {
|
|
24
25
|
case 'app-replaced':
|
|
25
26
|
return 'The app bridge was replaced by a newer app connection. This can happen when the app reloads, restarts, or reconnects while a test file is still running.';
|
|
26
|
-
case '
|
|
27
|
-
return 'The app
|
|
27
|
+
case 'connection-lost':
|
|
28
|
+
return 'The app stopped answering WebSocket pings during test execution. The app process was likely killed, frozen, or lost its network connection. Check .harness/crash-reports for a crash report.';
|
|
29
|
+
case 'js-thread-unresponsive':
|
|
30
|
+
return "The app's JS thread was blocked for longer than allowed during test execution.";
|
|
28
31
|
case 'socket-error':
|
|
29
32
|
return 'The app bridge connection failed during test execution. This can happen if the app was killed, crashed, or the underlying WebSocket connection closed unexpectedly.';
|
|
30
33
|
case 'bridge-disposed':
|
|
@@ -35,8 +38,16 @@ const appBridgeDisconnectedMessage = (
|
|
|
35
38
|
};
|
|
36
39
|
|
|
37
40
|
export class AppBridgeDisconnectedError extends HarnessError {
|
|
38
|
-
constructor(
|
|
39
|
-
|
|
41
|
+
constructor(
|
|
42
|
+
public readonly reason: AppBridgeDisconnectedReason,
|
|
43
|
+
/** What the app was busy with, when known. Appended to the message. */
|
|
44
|
+
public readonly detail?: string
|
|
45
|
+
) {
|
|
46
|
+
super(
|
|
47
|
+
detail
|
|
48
|
+
? `${appBridgeDisconnectedMessage(reason)} It was busy with: ${detail}.`
|
|
49
|
+
: appBridgeDisconnectedMessage(reason)
|
|
50
|
+
);
|
|
40
51
|
this.name = 'AppBridgeDisconnectedError';
|
|
41
52
|
this.stack = `${this.name}: ${this.message}`;
|
|
42
53
|
}
|