@react-native-harness/bridge 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/binary-transfer.d.ts.map +1 -1
- package/dist/client.d.ts +29 -7
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +66 -61
- package/dist/events.d.ts +45 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +1 -0
- package/dist/platform-bridge.d.ts +4 -0
- package/dist/platform-bridge.d.ts.map +1 -0
- package/dist/platform-bridge.js +25 -0
- package/dist/server.d.ts +41 -21
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +128 -110
- package/dist/shared.d.ts +1 -1
- package/dist/shared.d.ts.map +1 -1
- package/dist/tsconfig.lib.tsbuildinfo +1 -1
- package/package.json +3 -3
- package/src/binary-transfer.ts +1 -1
- package/src/client.ts +80 -42
- package/src/server.ts +202 -175
- package/src/shared.ts +1 -4
package/src/server.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { WebSocketServer, type WebSocket } from 'ws';
|
|
2
|
-
import { type
|
|
3
|
-
import { logger } from '@react-native-harness/tools';
|
|
2
|
+
import { createBirpc, type BirpcReturn } from 'birpc';
|
|
4
3
|
import { EventEmitter } from 'node:events';
|
|
5
4
|
import type { Server as HttpServer } from 'node:http';
|
|
6
5
|
import type { Server as HttpsServer } from 'node:https';
|
|
@@ -8,231 +7,259 @@ import fs from 'node:fs/promises';
|
|
|
8
7
|
import os from 'node:os';
|
|
9
8
|
import path from 'node:path';
|
|
10
9
|
import { randomUUID } from 'node:crypto';
|
|
10
|
+
import { logger } from '@react-native-harness/tools';
|
|
11
11
|
import { BinaryStore, parseBinaryFrame } from './binary-transfer.js';
|
|
12
|
+
import { deserialize, serialize } from './serializer.js';
|
|
13
|
+
import { DeviceNotRespondingError } from './errors.js';
|
|
14
|
+
import { matchImageSnapshot } from './image-snapshot.js';
|
|
12
15
|
import type {
|
|
13
16
|
BridgeServerFunctions,
|
|
14
17
|
BridgeClientFunctions,
|
|
15
18
|
DeviceDescriptor,
|
|
16
19
|
BridgeEvents,
|
|
17
|
-
ImageSnapshotOptions,
|
|
18
|
-
HarnessContext,
|
|
19
20
|
BinaryDataReference,
|
|
20
21
|
FileReference,
|
|
22
|
+
HarnessContext,
|
|
23
|
+
TestExecutionOptions,
|
|
24
|
+
TestSuiteResult,
|
|
21
25
|
} from './shared.js';
|
|
22
|
-
import { deserialize, serialize } from './serializer.js';
|
|
23
|
-
import { DeviceNotRespondingError } from './errors.js';
|
|
24
|
-
import { matchImageSnapshot } from './image-snapshot.js';
|
|
25
26
|
|
|
26
27
|
export { DeviceNotRespondingError } from './errors.js';
|
|
28
|
+
|
|
27
29
|
const bridgeLogger = logger.child('bridge');
|
|
28
30
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
};
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Public types
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
33
34
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
/**
|
|
36
|
+
* Represents a single app session — one app launch to the next restart.
|
|
37
|
+
* Obtained via HarnessBridge.nextConnection().
|
|
38
|
+
*/
|
|
39
|
+
export type AppConnection = {
|
|
40
|
+
readonly device: DeviceDescriptor;
|
|
41
|
+
runTests: (path: string, options: TestExecutionOptions) => Promise<TestSuiteResult>;
|
|
37
42
|
};
|
|
38
43
|
|
|
39
|
-
type
|
|
40
|
-
|
|
44
|
+
export type HarnessBridgeEvents = {
|
|
45
|
+
/** Fired when the app connects and calls reportReady. */
|
|
46
|
+
connected: (connection: AppConnection) => void;
|
|
47
|
+
/** Fired when the app's WebSocket closes. */
|
|
48
|
+
disconnected: () => void;
|
|
49
|
+
/** Fired for every test/bundler event the app emits. */
|
|
50
|
+
event: (event: BridgeEvents) => void;
|
|
41
51
|
};
|
|
42
52
|
|
|
43
|
-
|
|
44
|
-
|
|
|
45
|
-
|
|
|
46
|
-
|
|
|
47
|
-
|
|
53
|
+
type TransportOptions =
|
|
54
|
+
| { noServer: true }
|
|
55
|
+
| { port: number; host?: string }
|
|
56
|
+
| { server: HttpServer | HttpsServer; path?: string };
|
|
57
|
+
|
|
58
|
+
export type HarnessBridgeOptions = TransportOptions & {
|
|
48
59
|
timeout?: number;
|
|
49
60
|
context: HarnessContext;
|
|
50
61
|
};
|
|
51
62
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
ws: WebSocketServer;
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
) => void;
|
|
69
|
-
off: <T extends keyof
|
|
70
|
-
event: T,
|
|
71
|
-
listener: BridgeServerEvents[T]
|
|
72
|
-
) => void;
|
|
63
|
+
/**
|
|
64
|
+
* The persistent CLI-side bridge. Spans the full test run regardless of how
|
|
65
|
+
* many times the app is restarted. Each restart produces a new AppConnection
|
|
66
|
+
* via nextConnection().
|
|
67
|
+
*/
|
|
68
|
+
export type HarnessBridge = {
|
|
69
|
+
/** The underlying WebSocket server, used to attach to Metro's HTTP server. */
|
|
70
|
+
readonly ws: WebSocketServer;
|
|
71
|
+
/** The currently active app connection, null if the app is not connected. */
|
|
72
|
+
readonly connection: AppConnection | null;
|
|
73
|
+
/**
|
|
74
|
+
* Resolves with the next AppConnection once the app connects and reports
|
|
75
|
+
* ready. Register this waiter before restarting the app so no ready signal
|
|
76
|
+
* is missed. Rejects if the supplied signal is aborted.
|
|
77
|
+
*/
|
|
78
|
+
nextConnection: (signal?: AbortSignal) => Promise<AppConnection>;
|
|
79
|
+
on: <T extends keyof HarnessBridgeEvents>(event: T, listener: HarnessBridgeEvents[T]) => void;
|
|
80
|
+
off: <T extends keyof HarnessBridgeEvents>(event: T, listener: HarnessBridgeEvents[T]) => void;
|
|
73
81
|
dispose: () => void;
|
|
74
82
|
};
|
|
75
83
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
const wss =
|
|
82
|
-
'port' in transport
|
|
83
|
-
? await new Promise<WebSocketServer>((resolve) => {
|
|
84
|
-
const server = new WebSocketServer(
|
|
85
|
-
{
|
|
86
|
-
port: transport.port,
|
|
87
|
-
host: transport.host ?? '0.0.0.0',
|
|
88
|
-
},
|
|
89
|
-
() => {
|
|
90
|
-
resolve(server);
|
|
91
|
-
}
|
|
92
|
-
);
|
|
93
|
-
})
|
|
94
|
-
: new WebSocketServer(
|
|
95
|
-
'server' in transport
|
|
96
|
-
? {
|
|
97
|
-
server: transport.server,
|
|
98
|
-
path: transport.path,
|
|
99
|
-
}
|
|
100
|
-
: {
|
|
101
|
-
noServer: true,
|
|
102
|
-
}
|
|
103
|
-
);
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// Helpers
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
const createWss = (transport: TransportOptions): Promise<WebSocketServer> => {
|
|
104
89
|
if ('port' in transport) {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
90
|
+
return new Promise<WebSocketServer>((resolve) => {
|
|
91
|
+
const wss: WebSocketServer = new WebSocketServer(
|
|
92
|
+
{ port: transport.port, host: transport.host ?? '0.0.0.0' },
|
|
93
|
+
() => resolve(wss),
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
return Promise.resolve<WebSocketServer>(
|
|
98
|
+
new WebSocketServer(
|
|
99
|
+
'server' in transport
|
|
100
|
+
? { server: transport.server, path: transport.path }
|
|
101
|
+
: { noServer: true },
|
|
102
|
+
),
|
|
103
|
+
);
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const receiveScreenshot = async (
|
|
107
|
+
binaryStore: BinaryStore,
|
|
108
|
+
reference: BinaryDataReference,
|
|
109
|
+
): Promise<FileReference> => {
|
|
110
|
+
const data = binaryStore.get(reference.transferId);
|
|
111
|
+
if (!data) {
|
|
112
|
+
throw new Error(
|
|
113
|
+
`Binary data for transfer ${reference.transferId} not found or expired`,
|
|
110
114
|
);
|
|
111
|
-
} else {
|
|
112
|
-
bridgeLogger.debug('bridge server created in noServer mode');
|
|
113
115
|
}
|
|
114
|
-
|
|
115
|
-
const
|
|
116
|
-
|
|
116
|
+
binaryStore.delete(reference.transferId);
|
|
117
|
+
const file = path.join(os.tmpdir(), `harness-screenshot-${randomUUID()}.png`);
|
|
118
|
+
await fs.writeFile(file, data);
|
|
119
|
+
return { path: file };
|
|
120
|
+
};
|
|
117
121
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
},
|
|
122
|
-
emitEvent: (_, data) => {
|
|
123
|
-
emitter.emit('event', data);
|
|
124
|
-
},
|
|
125
|
-
'device.screenshot.receive': async (
|
|
126
|
-
reference: BinaryDataReference,
|
|
127
|
-
metadata: { width: number; height: number }
|
|
128
|
-
) => {
|
|
129
|
-
const data = binaryStore.get(reference.transferId);
|
|
130
|
-
if (!data) {
|
|
131
|
-
throw new Error(
|
|
132
|
-
`Binary data for transfer ${reference.transferId} not found or expired`
|
|
133
|
-
);
|
|
134
|
-
}
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
// Factory
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
135
125
|
|
|
136
|
-
|
|
137
|
-
|
|
126
|
+
export const createHarnessBridge = async (
|
|
127
|
+
options: HarnessBridgeOptions,
|
|
128
|
+
): Promise<HarnessBridge> => {
|
|
129
|
+
const { timeout, context, ...transport } = options;
|
|
130
|
+
const wss = await createWss(transport);
|
|
131
|
+
bridgeLogger.debug('bridge server ready');
|
|
138
132
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
133
|
+
const emitter = new EventEmitter();
|
|
134
|
+
let currentConnection: AppConnection | null = null;
|
|
135
|
+
const connectionWaiters: Array<{
|
|
136
|
+
resolve: (c: AppConnection) => void;
|
|
137
|
+
reject: (e: unknown) => void;
|
|
138
|
+
}> = [];
|
|
145
139
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
height: metadata.height,
|
|
150
|
-
};
|
|
151
|
-
},
|
|
152
|
-
'test.matchImageSnapshot': async (
|
|
153
|
-
screenshot: FileReference,
|
|
154
|
-
testPath: string,
|
|
155
|
-
options: ImageSnapshotOptions
|
|
156
|
-
) => {
|
|
157
|
-
return await matchImageSnapshot(
|
|
158
|
-
screenshot,
|
|
159
|
-
testPath,
|
|
160
|
-
options,
|
|
161
|
-
context.platform.name
|
|
162
|
-
);
|
|
163
|
-
},
|
|
164
|
-
};
|
|
140
|
+
wss.on('connection', (ws: WebSocket) => {
|
|
141
|
+
bridgeLogger.debug('app connected');
|
|
142
|
+
const binaryStore = new BinaryStore();
|
|
165
143
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
bridgeLogger.
|
|
174
|
-
|
|
144
|
+
const serverFunctions: BridgeServerFunctions = {
|
|
145
|
+
reportReady: (device) => {
|
|
146
|
+
const conn: AppConnection = {
|
|
147
|
+
device,
|
|
148
|
+
runTests: (testPath, opts) => rpc.runTests(testPath, opts),
|
|
149
|
+
};
|
|
150
|
+
currentConnection = conn;
|
|
151
|
+
bridgeLogger.debug(
|
|
152
|
+
'app ready: platform=%s model=%s',
|
|
153
|
+
device.platform,
|
|
154
|
+
device.model,
|
|
155
|
+
);
|
|
156
|
+
emitter.emit('connected', conn);
|
|
157
|
+
for (const { resolve } of connectionWaiters.splice(0)) resolve(conn);
|
|
175
158
|
},
|
|
176
|
-
|
|
177
|
-
|
|
159
|
+
emitEvent: (_, data) => {
|
|
160
|
+
emitter.emit('event', data);
|
|
178
161
|
},
|
|
179
|
-
|
|
180
|
-
|
|
162
|
+
'device.screenshot.receive': (ref) => receiveScreenshot(binaryStore, ref),
|
|
163
|
+
'test.matchImageSnapshot': (screenshot, testPath, opts) =>
|
|
164
|
+
matchImageSnapshot(screenshot, testPath, opts, context.platform.name),
|
|
165
|
+
};
|
|
181
166
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
bridgeLogger.debug('client disconnected');
|
|
186
|
-
|
|
187
|
-
// TODO: Remove channel when connection is closed.
|
|
188
|
-
clients.delete(ws);
|
|
189
|
-
emitter.emit('disconnect');
|
|
190
|
-
});
|
|
191
|
-
|
|
192
|
-
group.updateChannels((channels) => {
|
|
193
|
-
channels.push({
|
|
167
|
+
const rpc: BirpcReturn<BridgeClientFunctions, BridgeServerFunctions> = createBirpc<BridgeClientFunctions, BridgeServerFunctions>(
|
|
168
|
+
serverFunctions,
|
|
169
|
+
{
|
|
194
170
|
post: (data) => ws.send(data),
|
|
195
171
|
on: (handler) => {
|
|
196
172
|
ws.on(
|
|
197
173
|
'message',
|
|
198
|
-
(
|
|
174
|
+
(msg: Buffer | ArrayBuffer | Buffer[], isBinary: boolean) => {
|
|
199
175
|
if (isBinary) {
|
|
200
|
-
const uint8Array = new Uint8Array(event as any);
|
|
201
176
|
try {
|
|
202
|
-
const
|
|
177
|
+
const messageBuffer = Array.isArray(msg)
|
|
178
|
+
? Buffer.concat(msg)
|
|
179
|
+
: Buffer.isBuffer(msg)
|
|
180
|
+
? msg
|
|
181
|
+
: Buffer.from(msg);
|
|
182
|
+
const { transferId, data } = parseBinaryFrame(
|
|
183
|
+
new Uint8Array(messageBuffer),
|
|
184
|
+
);
|
|
203
185
|
binaryStore.add(transferId, data);
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
bridgeLogger.warn('failed to parse binary frame', error);
|
|
186
|
+
} catch (err) {
|
|
187
|
+
bridgeLogger.warn('failed to parse binary frame: %s', err);
|
|
207
188
|
}
|
|
189
|
+
} else {
|
|
190
|
+
handler(msg.toString());
|
|
208
191
|
}
|
|
209
|
-
|
|
210
|
-
handler(message);
|
|
211
|
-
}
|
|
192
|
+
},
|
|
212
193
|
);
|
|
213
194
|
},
|
|
214
195
|
serialize,
|
|
215
196
|
deserialize,
|
|
216
|
-
|
|
197
|
+
timeout,
|
|
198
|
+
onFunctionError: (error, functionName, args) => {
|
|
199
|
+
bridgeLogger.error(
|
|
200
|
+
'rpc function failed: %s args=%o',
|
|
201
|
+
functionName,
|
|
202
|
+
args,
|
|
203
|
+
);
|
|
204
|
+
throw error;
|
|
205
|
+
},
|
|
206
|
+
onTimeoutError: (fn, args) => {
|
|
207
|
+
throw new DeviceNotRespondingError(fn, args);
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
ws.on('close', () => {
|
|
213
|
+
bridgeLogger.debug('app disconnected');
|
|
214
|
+
binaryStore.dispose();
|
|
215
|
+
currentConnection = null;
|
|
216
|
+
emitter.emit('disconnected');
|
|
217
217
|
});
|
|
218
218
|
});
|
|
219
219
|
|
|
220
|
-
const dispose = () => {
|
|
221
|
-
bridgeLogger.debug('disposing bridge server');
|
|
222
|
-
for (const client of wss.clients) {
|
|
223
|
-
client.terminate();
|
|
224
|
-
}
|
|
225
|
-
wss.close();
|
|
226
|
-
emitter.removeAllListeners();
|
|
227
|
-
binaryStore.dispose();
|
|
228
|
-
};
|
|
229
|
-
|
|
230
220
|
return {
|
|
231
|
-
ws
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
221
|
+
get ws() {
|
|
222
|
+
return wss;
|
|
223
|
+
},
|
|
224
|
+
get connection() {
|
|
225
|
+
return currentConnection;
|
|
226
|
+
},
|
|
227
|
+
nextConnection: (signal) => {
|
|
228
|
+
if (signal?.aborted) {
|
|
229
|
+
return Promise.reject(
|
|
230
|
+
signal.reason ?? new DOMException('Aborted', 'AbortError'),
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
// If the app already connected before this call (e.g. fast simulator
|
|
234
|
+
// startup between startAttempt and waitForReady), return it immediately
|
|
235
|
+
// rather than waiting for a second reportReady that will never come.
|
|
236
|
+
if (currentConnection) {
|
|
237
|
+
return Promise.resolve(currentConnection);
|
|
238
|
+
}
|
|
239
|
+
return new Promise((resolve, reject) => {
|
|
240
|
+
const entry = { resolve, reject };
|
|
241
|
+
connectionWaiters.push(entry);
|
|
242
|
+
signal?.addEventListener(
|
|
243
|
+
'abort',
|
|
244
|
+
() => {
|
|
245
|
+
const idx = connectionWaiters.indexOf(entry);
|
|
246
|
+
if (idx !== -1) connectionWaiters.splice(idx, 1);
|
|
247
|
+
reject(signal.reason ?? new DOMException('Aborted', 'AbortError'));
|
|
248
|
+
},
|
|
249
|
+
{ once: true },
|
|
250
|
+
);
|
|
251
|
+
});
|
|
252
|
+
},
|
|
253
|
+
on: (event, listener) => emitter.on(event, listener),
|
|
254
|
+
off: (event, listener) => emitter.off(event, listener),
|
|
255
|
+
dispose: () => {
|
|
256
|
+
bridgeLogger.debug('disposing bridge');
|
|
257
|
+
for (const { reject } of connectionWaiters.splice(0)) {
|
|
258
|
+
reject(new Error('Bridge disposed'));
|
|
259
|
+
}
|
|
260
|
+
for (const client of wss.clients) client.terminate();
|
|
261
|
+
wss.close();
|
|
262
|
+
emitter.removeAllListeners();
|
|
263
|
+
},
|
|
237
264
|
};
|
|
238
265
|
};
|
package/src/shared.ts
CHANGED
|
@@ -149,10 +149,7 @@ export type ScreenshotData = BinaryDataReference;
|
|
|
149
149
|
|
|
150
150
|
export type BridgeServerFunctions = {
|
|
151
151
|
reportReady: (device: DeviceDescriptor) => void;
|
|
152
|
-
emitEvent:
|
|
153
|
-
event: TEvent['type'],
|
|
154
|
-
data: TEvent
|
|
155
|
-
) => void;
|
|
152
|
+
emitEvent: (event: BridgeEvents['type'], data: BridgeEvents) => void;
|
|
156
153
|
'device.screenshot.receive': (
|
|
157
154
|
reference: BinaryDataReference,
|
|
158
155
|
metadata: { width: number; height: number }
|