@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
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
deserializeBridgeError,
|
|
4
|
+
parseBridgeMessage,
|
|
5
|
+
serializeBridgeError,
|
|
6
|
+
serializeBridgeMessage,
|
|
7
|
+
} from '../protocol.js';
|
|
8
|
+
|
|
9
|
+
describe('bridge protocol', () => {
|
|
10
|
+
it('round-trips invoke messages', () => {
|
|
11
|
+
const raw = serializeBridgeMessage({
|
|
12
|
+
type: 'invoke',
|
|
13
|
+
id: 1,
|
|
14
|
+
method: 'runTests',
|
|
15
|
+
args: ['example.ts', { runner: '/runner.js' }],
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
expect(parseBridgeMessage(raw)).toEqual({
|
|
19
|
+
type: 'invoke',
|
|
20
|
+
id: 1,
|
|
21
|
+
method: 'runTests',
|
|
22
|
+
args: ['example.ts', { runner: '/runner.js' }],
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('serializes and restores errors with metadata', () => {
|
|
27
|
+
const cause = new Error('inner');
|
|
28
|
+
const error = new TypeError('boom', { cause });
|
|
29
|
+
error.stack = 'stack trace';
|
|
30
|
+
|
|
31
|
+
const restored = deserializeBridgeError(serializeBridgeError(error));
|
|
32
|
+
|
|
33
|
+
expect(restored.name).toBe('TypeError');
|
|
34
|
+
expect(restored.message).toBe('boom');
|
|
35
|
+
expect(restored.stack).toBe('stack trace');
|
|
36
|
+
expect(restored.cause).toBe(cause);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('serializes non-Error thrown values explicitly', () => {
|
|
40
|
+
expect(serializeBridgeError('boom')).toEqual({
|
|
41
|
+
name: 'NonErrorThrown',
|
|
42
|
+
message: 'boom',
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('rejects malformed messages', () => {
|
|
47
|
+
expect(() => parseBridgeMessage('{"type":"invoke","id":"1"}')).toThrow(
|
|
48
|
+
'Invalid bridge message: id must be a number',
|
|
49
|
+
);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { createRpcPeer } from '../rpc-peer.js';
|
|
3
|
+
import type { BridgeEvents } from '../shared.js';
|
|
4
|
+
import type { RpcTransport } from '../transport.js';
|
|
5
|
+
|
|
6
|
+
type LocalMethods = {
|
|
7
|
+
add: (left: number, right: number) => Promise<number>;
|
|
8
|
+
fail: () => Promise<never>;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const createMockTransport = (): RpcTransport & { messages: string[] } => {
|
|
12
|
+
const messages: string[] = [];
|
|
13
|
+
|
|
14
|
+
return {
|
|
15
|
+
state: 'open',
|
|
16
|
+
messages,
|
|
17
|
+
send: (message) => {
|
|
18
|
+
messages.push(message);
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
describe('rpc-peer', () => {
|
|
24
|
+
it('resolves successful RPC calls', async () => {
|
|
25
|
+
let serverHandleMessage: ((message: string) => Promise<unknown>) | null = null;
|
|
26
|
+
|
|
27
|
+
const client = createRpcPeer<Record<string, never>, { add: LocalMethods['add'] }, BridgeEvents>({
|
|
28
|
+
localMethods: {},
|
|
29
|
+
transport: {
|
|
30
|
+
state: 'open',
|
|
31
|
+
send: (message) => {
|
|
32
|
+
if (!serverHandleMessage) {
|
|
33
|
+
throw new Error('Server peer not initialized');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
void serverHandleMessage(message);
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const server = createRpcPeer<LocalMethods, Record<string, never>, BridgeEvents>({
|
|
42
|
+
localMethods: {
|
|
43
|
+
add: async (left, right) => left + right,
|
|
44
|
+
fail: async () => {
|
|
45
|
+
throw new Error('unused');
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
transport: {
|
|
49
|
+
state: 'open',
|
|
50
|
+
send: (message) => {
|
|
51
|
+
void client.handleMessage(message);
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
serverHandleMessage = server.handleMessage;
|
|
57
|
+
|
|
58
|
+
const result = await client.invoke('add', 2, 3);
|
|
59
|
+
|
|
60
|
+
expect(result).toBe(5);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('rejects failed RPC calls with restored errors', async () => {
|
|
64
|
+
const client = createRpcPeer<Record<string, never>, { fail: () => Promise<void> }, BridgeEvents>({
|
|
65
|
+
localMethods: {},
|
|
66
|
+
transport: createMockTransport(),
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const server = createRpcPeer<
|
|
70
|
+
{ fail: () => Promise<void> },
|
|
71
|
+
Record<string, never>,
|
|
72
|
+
BridgeEvents
|
|
73
|
+
>({
|
|
74
|
+
localMethods: {
|
|
75
|
+
fail: async () => {
|
|
76
|
+
throw new TypeError('boom');
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
transport: {
|
|
80
|
+
state: 'open',
|
|
81
|
+
send: (message) => {
|
|
82
|
+
void client.handleMessage(message);
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
await server.handleMessage(
|
|
88
|
+
JSON.stringify({ type: 'invoke', id: 1, method: 'fail', args: [] }),
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
await expect(client.handleMessage('{"type":"return","id":1,"ok":false,"error":{"name":"TypeError","message":"boom"}}')).resolves.toBeNull();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('rejects unknown methods', async () => {
|
|
95
|
+
let response = '';
|
|
96
|
+
const peer = createRpcPeer<Record<string, never>, Record<string, never>, BridgeEvents>({
|
|
97
|
+
localMethods: {},
|
|
98
|
+
transport: {
|
|
99
|
+
state: 'open',
|
|
100
|
+
send: (message) => {
|
|
101
|
+
response = message;
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
await peer.handleMessage(
|
|
107
|
+
JSON.stringify({ type: 'invoke', id: 1, method: 'missing', args: [] }),
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
expect(JSON.parse(response)).toMatchObject({
|
|
111
|
+
type: 'return',
|
|
112
|
+
id: 1,
|
|
113
|
+
ok: false,
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('dispatches incoming events', async () => {
|
|
118
|
+
const onEvent = vi.fn();
|
|
119
|
+
const peer = createRpcPeer<Record<string, never>, Record<string, never>, BridgeEvents>({
|
|
120
|
+
localMethods: {},
|
|
121
|
+
transport: createMockTransport(),
|
|
122
|
+
onEvent,
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
await peer.handleMessage(
|
|
126
|
+
JSON.stringify({
|
|
127
|
+
type: 'event',
|
|
128
|
+
event: { type: 'collection-started', file: 'example.ts' },
|
|
129
|
+
}),
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
expect(onEvent).toHaveBeenCalledWith({
|
|
133
|
+
type: 'collection-started',
|
|
134
|
+
file: 'example.ts',
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('rejects pending calls when closed', async () => {
|
|
139
|
+
const peer = createRpcPeer<
|
|
140
|
+
Record<string, never>,
|
|
141
|
+
{ runTests: (path: string, options: { runner: string }) => Promise<void> },
|
|
142
|
+
BridgeEvents
|
|
143
|
+
>({
|
|
144
|
+
localMethods: {},
|
|
145
|
+
transport: createMockTransport(),
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const pending = peer.invoke('runTests', 'example.ts', { runner: '/runner.js' });
|
|
149
|
+
peer.close(new Error('closed'));
|
|
150
|
+
|
|
151
|
+
await expect(pending).rejects.toThrow('closed');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('rejects pending calls on timeout', async () => {
|
|
155
|
+
const peer = createRpcPeer<
|
|
156
|
+
Record<string, never>,
|
|
157
|
+
{ runTests: (path: string, options: { runner: string }) => Promise<void> },
|
|
158
|
+
BridgeEvents
|
|
159
|
+
>({
|
|
160
|
+
localMethods: {},
|
|
161
|
+
transport: createMockTransport(),
|
|
162
|
+
callTimeoutMs: 10,
|
|
163
|
+
createTimeoutError: () => new Error('timed out'),
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const pending = peer.invoke('runTests', 'example.ts', { runner: '/runner.js' });
|
|
167
|
+
|
|
168
|
+
await expect(pending).rejects.toThrow('timed out');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('throws on malformed messages', async () => {
|
|
172
|
+
const peer = createRpcPeer<Record<string, never>, Record<string, never>, BridgeEvents>({
|
|
173
|
+
localMethods: {},
|
|
174
|
+
transport: createMockTransport(),
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
await expect(peer.handleMessage('{"type":"invoke"}')).rejects.toThrow(
|
|
178
|
+
'Invalid bridge message: id must be a number',
|
|
179
|
+
);
|
|
180
|
+
});
|
|
181
|
+
});
|
package/src/client.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
|
-
import { createBirpc } from 'birpc';
|
|
2
|
-
import { deserialize, serialize } from './serializer.js';
|
|
3
1
|
import { createBinaryFrame, generateTransferId } from './binary-transfer.js';
|
|
2
|
+
import { serializeBridgeMessage } from './protocol.js';
|
|
3
|
+
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';
|
|
4
9
|
import type {
|
|
5
10
|
BridgeClientFunctions,
|
|
6
11
|
BridgeServerFunctions,
|
|
@@ -12,27 +17,17 @@ import type {
|
|
|
12
17
|
TestSuiteResult,
|
|
13
18
|
} from './shared.js';
|
|
14
19
|
|
|
15
|
-
// ---------------------------------------------------------------------------
|
|
16
|
-
// Public types
|
|
17
|
-
// ---------------------------------------------------------------------------
|
|
18
|
-
|
|
19
|
-
/** Handlers the app must implement for the CLI to call into. */
|
|
20
20
|
export type HarnessCallbacks = {
|
|
21
21
|
runTests: (path: string, options: TestExecutionOptions) => Promise<TestSuiteResult>;
|
|
22
22
|
};
|
|
23
23
|
|
|
24
|
-
/** The app-side handle returned by connectToHarness. */
|
|
25
24
|
export type HarnessHandle = {
|
|
26
|
-
/** Call once when the app is initialised and ready to run tests. */
|
|
27
25
|
reportReady: (device: DeviceDescriptor) => void;
|
|
28
|
-
/** Forward a test or bundler event to the CLI. */
|
|
29
26
|
emitEvent: (event: BridgeEvents) => void;
|
|
30
|
-
/** Send a screenshot to the CLI and receive a file reference for snapshot comparison. */
|
|
31
27
|
transferScreenshot: (
|
|
32
28
|
data: Uint8Array,
|
|
33
29
|
metadata: { width: number; height: number },
|
|
34
30
|
) => Promise<FileReference>;
|
|
35
|
-
/** Request an image snapshot comparison on the CLI. */
|
|
36
31
|
matchImageSnapshot: (
|
|
37
32
|
screenshot: FileReference,
|
|
38
33
|
testPath: string,
|
|
@@ -42,83 +37,173 @@ export type HarnessHandle = {
|
|
|
42
37
|
disconnect: () => void;
|
|
43
38
|
};
|
|
44
39
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
* exposing the operations the app needs to drive a test run. The binary
|
|
54
|
-
* transfer protocol and RPC wiring are fully encapsulated.
|
|
55
|
-
*/
|
|
40
|
+
export type ConnectToHarnessOptions = {
|
|
41
|
+
transport?: BridgeTransport;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export { createWebSocketClientTransport };
|
|
45
|
+
|
|
46
|
+
const noop = (): void => undefined;
|
|
47
|
+
|
|
56
48
|
export const connectToHarness = (
|
|
57
49
|
url: string,
|
|
58
50
|
callbacks: HarnessCallbacks,
|
|
51
|
+
options: ConnectToHarnessOptions = {},
|
|
59
52
|
): Promise<HarnessHandle> =>
|
|
60
53
|
new Promise((resolve, reject) => {
|
|
61
|
-
const
|
|
62
|
-
ws.binaryType = 'arraybuffer';
|
|
54
|
+
const transport = options.transport ?? createWebSocketClientTransport(url);
|
|
63
55
|
let settled = false;
|
|
56
|
+
let peerClosed = false;
|
|
57
|
+
let offOpen: () => void = noop;
|
|
58
|
+
let offError: () => void = noop;
|
|
59
|
+
let offClose: () => void = noop;
|
|
64
60
|
|
|
65
61
|
const cleanup = () => {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
62
|
+
offOpen();
|
|
63
|
+
offError();
|
|
64
|
+
offClose();
|
|
69
65
|
};
|
|
70
66
|
|
|
71
67
|
const fail = (message: string) => {
|
|
72
|
-
if (settled)
|
|
68
|
+
if (settled) {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
73
72
|
settled = true;
|
|
74
73
|
cleanup();
|
|
75
74
|
reject(new Error(message));
|
|
76
75
|
};
|
|
77
76
|
|
|
78
77
|
const handleOpen = () => {
|
|
78
|
+
if (settled) {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
79
82
|
settled = true;
|
|
80
83
|
cleanup();
|
|
81
84
|
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
85
|
+
const getTransportNotOpenError = () => {
|
|
86
|
+
return new Error('Harness bridge transport is not open');
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const rpc = createRpcPeer<
|
|
90
|
+
BridgeClientFunctions,
|
|
91
|
+
BridgeServerFunctions,
|
|
92
|
+
BridgeEvents
|
|
93
|
+
>({
|
|
94
|
+
localMethods: callbacks,
|
|
95
|
+
transport: createRpcTransport(transport),
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
let offMessage: () => void = noop;
|
|
99
|
+
let offRuntimeClose: () => void = noop;
|
|
100
|
+
let offRuntimeError: () => void = noop;
|
|
101
|
+
|
|
102
|
+
const closePeer = (reason: Error) => {
|
|
103
|
+
if (peerClosed) {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
peerClosed = true;
|
|
108
|
+
offMessage();
|
|
109
|
+
offRuntimeClose();
|
|
110
|
+
offRuntimeError();
|
|
111
|
+
rpc.close(reason);
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const handleMessage = async (data: string) => {
|
|
115
|
+
const controlMessage = await rpc.handleMessage(data);
|
|
116
|
+
|
|
117
|
+
if (!controlMessage) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (controlMessage.type === 'ping') {
|
|
122
|
+
transport.send(
|
|
123
|
+
serializeBridgeMessage({ type: 'pong', id: controlMessage.id }),
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
offMessage = transport.onMessage((message) => {
|
|
129
|
+
if (typeof message !== 'string') {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
void handleMessage(message).catch((error) => {
|
|
134
|
+
closePeer(
|
|
135
|
+
error instanceof Error
|
|
136
|
+
? error
|
|
137
|
+
: new Error('Received invalid Harness bridge message'),
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
if (transport.state === 'open') {
|
|
141
|
+
transport.close(1002, 'Invalid message');
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
offRuntimeClose = transport.onClose((event) => {
|
|
147
|
+
closePeer(
|
|
148
|
+
new Error(
|
|
149
|
+
`Harness connection closed (code ${event.code}${
|
|
150
|
+
event.reason ? `, reason: ${event.reason}` : ''
|
|
151
|
+
})`,
|
|
152
|
+
),
|
|
153
|
+
);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
offRuntimeError = transport.onError((error) => {
|
|
157
|
+
closePeer(error);
|
|
158
|
+
});
|
|
95
159
|
|
|
96
160
|
resolve({
|
|
97
|
-
reportReady: (device) =>
|
|
98
|
-
|
|
161
|
+
reportReady: (device) => {
|
|
162
|
+
try {
|
|
163
|
+
if (transport.state !== 'open') {
|
|
164
|
+
throw getTransportNotOpenError();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
transport.send(serializeBridgeMessage({ type: 'ready', device }));
|
|
168
|
+
} catch (error) {
|
|
169
|
+
closePeer(
|
|
170
|
+
error instanceof Error ? error : getTransportNotOpenError(),
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
},
|
|
174
|
+
emitEvent: (event) => {
|
|
175
|
+
rpc.sendEvent(event);
|
|
176
|
+
},
|
|
99
177
|
transferScreenshot: async (data, metadata) => {
|
|
100
178
|
const transferId = generateTransferId();
|
|
101
|
-
|
|
102
|
-
return rpc
|
|
179
|
+
transport.send(createBinaryFrame(transferId, data));
|
|
180
|
+
return rpc.invoke(
|
|
181
|
+
'device.screenshot.receive',
|
|
103
182
|
{ type: 'binary', transferId, size: data.length, mimeType: 'image/png' },
|
|
104
183
|
metadata,
|
|
105
184
|
);
|
|
106
185
|
},
|
|
107
186
|
matchImageSnapshot: (screenshot, testPath, options, runner) =>
|
|
108
|
-
rpc
|
|
109
|
-
|
|
187
|
+
rpc.invoke(
|
|
188
|
+
'test.matchImageSnapshot',
|
|
189
|
+
screenshot,
|
|
190
|
+
testPath,
|
|
191
|
+
options,
|
|
192
|
+
runner,
|
|
193
|
+
),
|
|
194
|
+
disconnect: () => {
|
|
195
|
+
closePeer(new Error('Harness connection closed by client'));
|
|
196
|
+
transport.close();
|
|
197
|
+
},
|
|
110
198
|
});
|
|
111
199
|
};
|
|
112
200
|
|
|
113
|
-
const handleError = (
|
|
114
|
-
const detail =
|
|
115
|
-
typeof event.message === 'string' && event.message
|
|
116
|
-
? `: ${event.message}`
|
|
117
|
-
: '';
|
|
201
|
+
const handleError = (error: Error) => {
|
|
202
|
+
const detail = error.message ? `: ${error.message}` : '';
|
|
118
203
|
fail(`Failed to connect to Harness at ${url}${detail}`);
|
|
119
204
|
};
|
|
120
205
|
|
|
121
|
-
const handleClose = (event:
|
|
206
|
+
const handleClose = (event: { code: number; reason: string }) => {
|
|
122
207
|
fail(
|
|
123
208
|
`Harness connection at ${url} closed before becoming ready (code ${event.code}${
|
|
124
209
|
event.reason ? `, reason: ${event.reason}` : ''
|
|
@@ -126,7 +211,11 @@ export const connectToHarness = (
|
|
|
126
211
|
);
|
|
127
212
|
};
|
|
128
213
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
214
|
+
offOpen = transport.onOpen(handleOpen);
|
|
215
|
+
offError = transport.onError(handleError);
|
|
216
|
+
offClose = transport.onClose(handleClose);
|
|
217
|
+
|
|
218
|
+
if (transport.state === 'open') {
|
|
219
|
+
handleOpen();
|
|
220
|
+
}
|
|
132
221
|
});
|
package/src/errors.ts
CHANGED
|
@@ -9,3 +9,35 @@ export class DeviceNotRespondingError extends HarnessError {
|
|
|
9
9
|
this.name = 'DeviceNotRespondingError';
|
|
10
10
|
}
|
|
11
11
|
}
|
|
12
|
+
|
|
13
|
+
export type AppBridgeDisconnectedReason =
|
|
14
|
+
| 'app-disconnected'
|
|
15
|
+
| 'app-replaced'
|
|
16
|
+
| 'heartbeat-timeout'
|
|
17
|
+
| 'socket-error'
|
|
18
|
+
| 'bridge-disposed';
|
|
19
|
+
|
|
20
|
+
const appBridgeDisconnectedMessage = (
|
|
21
|
+
reason: AppBridgeDisconnectedReason,
|
|
22
|
+
): string => {
|
|
23
|
+
switch (reason) {
|
|
24
|
+
case 'app-replaced':
|
|
25
|
+
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 'heartbeat-timeout':
|
|
27
|
+
return 'The app bridge stopped responding during test execution. This can happen if the app was killed, crashed, became unresponsive, or lost its WebSocket connection.';
|
|
28
|
+
case 'socket-error':
|
|
29
|
+
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
|
+
case 'bridge-disposed':
|
|
31
|
+
return 'The app bridge was disposed before the test file finished running.';
|
|
32
|
+
case 'app-disconnected':
|
|
33
|
+
return 'The app bridge disconnected during test execution. This can happen if the app was killed, crashed, reloaded, or restarted while the test file was running.';
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export class AppBridgeDisconnectedError extends HarnessError {
|
|
38
|
+
constructor(public readonly reason: AppBridgeDisconnectedReason) {
|
|
39
|
+
super(appBridgeDisconnectedMessage(reason));
|
|
40
|
+
this.name = 'AppBridgeDisconnectedError';
|
|
41
|
+
this.stack = `${this.name}: ${this.message}`;
|
|
42
|
+
}
|
|
43
|
+
}
|
package/src/heartbeat.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
export const DEFAULT_HEARTBEAT_INTERVAL_MS = 5_000;
|
|
2
|
+
export const DEFAULT_HEARTBEAT_TIMEOUT_MS = 20_000;
|
|
3
|
+
|
|
4
|
+
export type BridgeHeartbeat = {
|
|
5
|
+
notifyPong: (id: number) => void;
|
|
6
|
+
dispose: () => void;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export const createHeartbeat = (options: {
|
|
10
|
+
sendPing: (id: number) => void;
|
|
11
|
+
onTimeout: () => void;
|
|
12
|
+
intervalMs?: number;
|
|
13
|
+
timeoutMs?: number;
|
|
14
|
+
}): BridgeHeartbeat => {
|
|
15
|
+
const intervalMs = options.intervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;
|
|
16
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_HEARTBEAT_TIMEOUT_MS;
|
|
17
|
+
let nextPingId = 1;
|
|
18
|
+
let pendingPingId: number | null = null;
|
|
19
|
+
let disposed = false;
|
|
20
|
+
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
|
|
21
|
+
|
|
22
|
+
const clearPendingTimeout = () => {
|
|
23
|
+
if (timeoutHandle) {
|
|
24
|
+
clearTimeout(timeoutHandle);
|
|
25
|
+
timeoutHandle = null;
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const intervalHandle = setInterval(() => {
|
|
30
|
+
if (disposed || pendingPingId !== null) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const pingId = nextPingId++;
|
|
35
|
+
pendingPingId = pingId;
|
|
36
|
+
options.sendPing(pingId);
|
|
37
|
+
timeoutHandle = setTimeout(() => {
|
|
38
|
+
if (disposed || pendingPingId !== pingId) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
pendingPingId = null;
|
|
43
|
+
timeoutHandle = null;
|
|
44
|
+
options.onTimeout();
|
|
45
|
+
}, timeoutMs);
|
|
46
|
+
}, intervalMs);
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
notifyPong: (id) => {
|
|
50
|
+
if (id !== pendingPingId) {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
pendingPingId = null;
|
|
55
|
+
clearPendingTimeout();
|
|
56
|
+
},
|
|
57
|
+
dispose: () => {
|
|
58
|
+
if (disposed) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
disposed = true;
|
|
63
|
+
clearInterval(intervalHandle);
|
|
64
|
+
clearPendingTimeout();
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
};
|
package/src/index.ts
CHANGED