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