@react-native-harness/bridge 1.2.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 +133 -67
- package/dist/shared/test-collector.d.ts +7 -3
- package/dist/shared/test-collector.d.ts.map +1 -1
- package/dist/shared/test-context.d.ts +21 -0
- package/dist/shared/test-context.d.ts.map +1 -0
- package/dist/shared/test-context.js +1 -0
- package/dist/shared/test-runner.d.ts +13 -0
- package/dist/shared/test-runner.d.ts.map +1 -1
- package/dist/shared.d.ts +2 -3
- 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 +191 -109
- package/src/shared/test-collector.ts +10 -3
- package/src/shared/test-context.ts +21 -0
- package/src/shared/test-runner.ts +14 -0
- package/src/shared.ts +6 -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 @@
|
|
|
1
|
+
{"version":3,"file":"client.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/client.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { connectToHarness } from '../client.js';
|
|
3
|
+
const createMockTransport = (initialState = 'connecting') => {
|
|
4
|
+
let state = initialState;
|
|
5
|
+
const openListeners = new Set();
|
|
6
|
+
const messageListeners = new Set();
|
|
7
|
+
const closeListeners = new Set();
|
|
8
|
+
const errorListeners = new Set();
|
|
9
|
+
const sent = [];
|
|
10
|
+
const transport = {
|
|
11
|
+
get state() {
|
|
12
|
+
return state;
|
|
13
|
+
},
|
|
14
|
+
send: (message) => {
|
|
15
|
+
sent.push(message);
|
|
16
|
+
},
|
|
17
|
+
close: (code = 1000, reason = '') => {
|
|
18
|
+
state = 'closed';
|
|
19
|
+
for (const listener of closeListeners) {
|
|
20
|
+
listener({ code, reason });
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
onOpen: (listener) => {
|
|
24
|
+
openListeners.add(listener);
|
|
25
|
+
return () => openListeners.delete(listener);
|
|
26
|
+
},
|
|
27
|
+
onMessage: (listener) => {
|
|
28
|
+
messageListeners.add(listener);
|
|
29
|
+
return () => messageListeners.delete(listener);
|
|
30
|
+
},
|
|
31
|
+
onClose: (listener) => {
|
|
32
|
+
closeListeners.add(listener);
|
|
33
|
+
return () => closeListeners.delete(listener);
|
|
34
|
+
},
|
|
35
|
+
onError: (listener) => {
|
|
36
|
+
errorListeners.add(listener);
|
|
37
|
+
return () => errorListeners.delete(listener);
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
return {
|
|
41
|
+
transport,
|
|
42
|
+
sent,
|
|
43
|
+
open: () => {
|
|
44
|
+
state = 'open';
|
|
45
|
+
for (const listener of openListeners) {
|
|
46
|
+
listener();
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
receive: (message) => {
|
|
50
|
+
for (const listener of messageListeners) {
|
|
51
|
+
listener(message);
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
fail: (error) => {
|
|
55
|
+
for (const listener of errorListeners) {
|
|
56
|
+
listener(error);
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
};
|
|
61
|
+
describe('connectToHarness transport injection', () => {
|
|
62
|
+
it('uses the injected transport instead of constructing a WebSocket', async () => {
|
|
63
|
+
const mockTransport = createMockTransport();
|
|
64
|
+
const handlePromise = connectToHarness('ws://unused', { runTests: vi.fn() }, { transport: mockTransport.transport });
|
|
65
|
+
mockTransport.open();
|
|
66
|
+
const handle = await handlePromise;
|
|
67
|
+
handle.reportReady({
|
|
68
|
+
platform: 'ios',
|
|
69
|
+
manufacturer: 'Apple',
|
|
70
|
+
model: 'Injected',
|
|
71
|
+
osVersion: '18.0',
|
|
72
|
+
});
|
|
73
|
+
expect(mockTransport.sent).toHaveLength(1);
|
|
74
|
+
expect(mockTransport.sent[0]).toBeTypeOf('string');
|
|
75
|
+
expect(JSON.parse(mockTransport.sent[0])).toMatchObject({
|
|
76
|
+
type: 'ready',
|
|
77
|
+
device: { model: 'Injected' },
|
|
78
|
+
});
|
|
79
|
+
handle.disconnect();
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"heartbeat.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/heartbeat.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { createHeartbeat } from '../heartbeat.js';
|
|
3
|
+
describe('bridge heartbeat', () => {
|
|
4
|
+
it('sends pings and times out stale sessions', () => {
|
|
5
|
+
vi.useFakeTimers();
|
|
6
|
+
const sendPing = vi.fn();
|
|
7
|
+
const onTimeout = vi.fn();
|
|
8
|
+
createHeartbeat({
|
|
9
|
+
sendPing,
|
|
10
|
+
onTimeout,
|
|
11
|
+
intervalMs: 5,
|
|
12
|
+
timeoutMs: 10,
|
|
13
|
+
});
|
|
14
|
+
vi.advanceTimersByTime(5);
|
|
15
|
+
expect(sendPing).toHaveBeenCalledWith(1);
|
|
16
|
+
vi.advanceTimersByTime(10);
|
|
17
|
+
expect(onTimeout).toHaveBeenCalledTimes(1);
|
|
18
|
+
vi.useRealTimers();
|
|
19
|
+
});
|
|
20
|
+
it('clears the timeout when the matching pong arrives', () => {
|
|
21
|
+
vi.useFakeTimers();
|
|
22
|
+
const sendPing = vi.fn();
|
|
23
|
+
const onTimeout = vi.fn();
|
|
24
|
+
const heartbeat = createHeartbeat({
|
|
25
|
+
sendPing,
|
|
26
|
+
onTimeout,
|
|
27
|
+
intervalMs: 5,
|
|
28
|
+
timeoutMs: 10,
|
|
29
|
+
});
|
|
30
|
+
vi.advanceTimersByTime(5);
|
|
31
|
+
heartbeat.notifyPong(1);
|
|
32
|
+
vi.advanceTimersByTime(10);
|
|
33
|
+
expect(onTimeout).not.toHaveBeenCalled();
|
|
34
|
+
heartbeat.dispose();
|
|
35
|
+
vi.useRealTimers();
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"protocol.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/protocol.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { deserializeBridgeError, parseBridgeMessage, serializeBridgeError, serializeBridgeMessage, } from '../protocol.js';
|
|
3
|
+
describe('bridge protocol', () => {
|
|
4
|
+
it('round-trips invoke messages', () => {
|
|
5
|
+
const raw = serializeBridgeMessage({
|
|
6
|
+
type: 'invoke',
|
|
7
|
+
id: 1,
|
|
8
|
+
method: 'runTests',
|
|
9
|
+
args: ['example.ts', { runner: '/runner.js' }],
|
|
10
|
+
});
|
|
11
|
+
expect(parseBridgeMessage(raw)).toEqual({
|
|
12
|
+
type: 'invoke',
|
|
13
|
+
id: 1,
|
|
14
|
+
method: 'runTests',
|
|
15
|
+
args: ['example.ts', { runner: '/runner.js' }],
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
it('serializes and restores errors with metadata', () => {
|
|
19
|
+
const cause = new Error('inner');
|
|
20
|
+
const error = new TypeError('boom', { cause });
|
|
21
|
+
error.stack = 'stack trace';
|
|
22
|
+
const restored = deserializeBridgeError(serializeBridgeError(error));
|
|
23
|
+
expect(restored.name).toBe('TypeError');
|
|
24
|
+
expect(restored.message).toBe('boom');
|
|
25
|
+
expect(restored.stack).toBe('stack trace');
|
|
26
|
+
expect(restored.cause).toBe(cause);
|
|
27
|
+
});
|
|
28
|
+
it('serializes non-Error thrown values explicitly', () => {
|
|
29
|
+
expect(serializeBridgeError('boom')).toEqual({
|
|
30
|
+
name: 'NonErrorThrown',
|
|
31
|
+
message: 'boom',
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
it('rejects malformed messages', () => {
|
|
35
|
+
expect(() => parseBridgeMessage('{"type":"invoke","id":"1"}')).toThrow('Invalid bridge message: id must be a number');
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"rpc-peer.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/rpc-peer.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { createRpcPeer } from '../rpc-peer.js';
|
|
3
|
+
const createMockTransport = () => {
|
|
4
|
+
const messages = [];
|
|
5
|
+
return {
|
|
6
|
+
state: 'open',
|
|
7
|
+
messages,
|
|
8
|
+
send: (message) => {
|
|
9
|
+
messages.push(message);
|
|
10
|
+
},
|
|
11
|
+
};
|
|
12
|
+
};
|
|
13
|
+
describe('rpc-peer', () => {
|
|
14
|
+
it('resolves successful RPC calls', async () => {
|
|
15
|
+
let serverHandleMessage = null;
|
|
16
|
+
const client = createRpcPeer({
|
|
17
|
+
localMethods: {},
|
|
18
|
+
transport: {
|
|
19
|
+
state: 'open',
|
|
20
|
+
send: (message) => {
|
|
21
|
+
if (!serverHandleMessage) {
|
|
22
|
+
throw new Error('Server peer not initialized');
|
|
23
|
+
}
|
|
24
|
+
void serverHandleMessage(message);
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
const server = createRpcPeer({
|
|
29
|
+
localMethods: {
|
|
30
|
+
add: async (left, right) => left + right,
|
|
31
|
+
fail: async () => {
|
|
32
|
+
throw new Error('unused');
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
transport: {
|
|
36
|
+
state: 'open',
|
|
37
|
+
send: (message) => {
|
|
38
|
+
void client.handleMessage(message);
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
serverHandleMessage = server.handleMessage;
|
|
43
|
+
const result = await client.invoke('add', 2, 3);
|
|
44
|
+
expect(result).toBe(5);
|
|
45
|
+
});
|
|
46
|
+
it('rejects failed RPC calls with restored errors', async () => {
|
|
47
|
+
const client = createRpcPeer({
|
|
48
|
+
localMethods: {},
|
|
49
|
+
transport: createMockTransport(),
|
|
50
|
+
});
|
|
51
|
+
const server = createRpcPeer({
|
|
52
|
+
localMethods: {
|
|
53
|
+
fail: async () => {
|
|
54
|
+
throw new TypeError('boom');
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
transport: {
|
|
58
|
+
state: 'open',
|
|
59
|
+
send: (message) => {
|
|
60
|
+
void client.handleMessage(message);
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
await server.handleMessage(JSON.stringify({ type: 'invoke', id: 1, method: 'fail', args: [] }));
|
|
65
|
+
await expect(client.handleMessage('{"type":"return","id":1,"ok":false,"error":{"name":"TypeError","message":"boom"}}')).resolves.toBeNull();
|
|
66
|
+
});
|
|
67
|
+
it('rejects unknown methods', async () => {
|
|
68
|
+
let response = '';
|
|
69
|
+
const peer = createRpcPeer({
|
|
70
|
+
localMethods: {},
|
|
71
|
+
transport: {
|
|
72
|
+
state: 'open',
|
|
73
|
+
send: (message) => {
|
|
74
|
+
response = message;
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
await peer.handleMessage(JSON.stringify({ type: 'invoke', id: 1, method: 'missing', args: [] }));
|
|
79
|
+
expect(JSON.parse(response)).toMatchObject({
|
|
80
|
+
type: 'return',
|
|
81
|
+
id: 1,
|
|
82
|
+
ok: false,
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
it('dispatches incoming events', async () => {
|
|
86
|
+
const onEvent = vi.fn();
|
|
87
|
+
const peer = createRpcPeer({
|
|
88
|
+
localMethods: {},
|
|
89
|
+
transport: createMockTransport(),
|
|
90
|
+
onEvent,
|
|
91
|
+
});
|
|
92
|
+
await peer.handleMessage(JSON.stringify({
|
|
93
|
+
type: 'event',
|
|
94
|
+
event: { type: 'collection-started', file: 'example.ts' },
|
|
95
|
+
}));
|
|
96
|
+
expect(onEvent).toHaveBeenCalledWith({
|
|
97
|
+
type: 'collection-started',
|
|
98
|
+
file: 'example.ts',
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
it('rejects pending calls when closed', async () => {
|
|
102
|
+
const peer = createRpcPeer({
|
|
103
|
+
localMethods: {},
|
|
104
|
+
transport: createMockTransport(),
|
|
105
|
+
});
|
|
106
|
+
const pending = peer.invoke('runTests', 'example.ts', { runner: '/runner.js' });
|
|
107
|
+
peer.close(new Error('closed'));
|
|
108
|
+
await expect(pending).rejects.toThrow('closed');
|
|
109
|
+
});
|
|
110
|
+
it('rejects pending calls on timeout', async () => {
|
|
111
|
+
const peer = createRpcPeer({
|
|
112
|
+
localMethods: {},
|
|
113
|
+
transport: createMockTransport(),
|
|
114
|
+
callTimeoutMs: 10,
|
|
115
|
+
createTimeoutError: () => new Error('timed out'),
|
|
116
|
+
});
|
|
117
|
+
const pending = peer.invoke('runTests', 'example.ts', { runner: '/runner.js' });
|
|
118
|
+
await expect(pending).rejects.toThrow('timed out');
|
|
119
|
+
});
|
|
120
|
+
it('throws on malformed messages', async () => {
|
|
121
|
+
const peer = createRpcPeer({
|
|
122
|
+
localMethods: {},
|
|
123
|
+
transport: createMockTransport(),
|
|
124
|
+
});
|
|
125
|
+
await expect(peer.handleMessage('{"type":"invoke"}')).rejects.toThrow('Invalid bridge message: id must be a number');
|
|
126
|
+
});
|
|
127
|
+
});
|
package/dist/client.d.ts
CHANGED
|
@@ -1,32 +1,25 @@
|
|
|
1
|
+
import { type BridgeTransport } from './transport.js';
|
|
2
|
+
import { createWebSocketClientTransport } from './websocket-client-transport.js';
|
|
1
3
|
import type { DeviceDescriptor, BridgeEvents, FileReference, ImageSnapshotOptions, TestExecutionOptions, TestSuiteResult } from './shared.js';
|
|
2
|
-
/** Handlers the app must implement for the CLI to call into. */
|
|
3
4
|
export type HarnessCallbacks = {
|
|
4
5
|
runTests: (path: string, options: TestExecutionOptions) => Promise<TestSuiteResult>;
|
|
5
6
|
};
|
|
6
|
-
/** The app-side handle returned by connectToHarness. */
|
|
7
7
|
export type HarnessHandle = {
|
|
8
|
-
/** Call once when the app is initialised and ready to run tests. */
|
|
9
8
|
reportReady: (device: DeviceDescriptor) => void;
|
|
10
|
-
/** Forward a test or bundler event to the CLI. */
|
|
11
9
|
emitEvent: (event: BridgeEvents) => void;
|
|
12
|
-
/** Send a screenshot to the CLI and receive a file reference for snapshot comparison. */
|
|
13
10
|
transferScreenshot: (data: Uint8Array, metadata: {
|
|
14
11
|
width: number;
|
|
15
12
|
height: number;
|
|
16
13
|
}) => Promise<FileReference>;
|
|
17
|
-
/** Request an image snapshot comparison on the CLI. */
|
|
18
14
|
matchImageSnapshot: (screenshot: FileReference, testPath: string, options: ImageSnapshotOptions, runner: string) => Promise<{
|
|
19
15
|
pass: boolean;
|
|
20
16
|
message: string;
|
|
21
17
|
}>;
|
|
22
18
|
disconnect: () => void;
|
|
23
19
|
};
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
* transfer protocol and RPC wiring are fully encapsulated.
|
|
30
|
-
*/
|
|
31
|
-
export declare const connectToHarness: (url: string, callbacks: HarnessCallbacks) => Promise<HarnessHandle>;
|
|
20
|
+
export type ConnectToHarnessOptions = {
|
|
21
|
+
transport?: BridgeTransport;
|
|
22
|
+
};
|
|
23
|
+
export { createWebSocketClientTransport };
|
|
24
|
+
export declare const connectToHarness: (url: string, callbacks: HarnessCallbacks, options?: ConnectToHarnessOptions) => Promise<HarnessHandle>;
|
|
32
25
|
//# sourceMappingURL=client.d.ts.map
|
package/dist/client.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAGV,gBAAgB,EAChB,YAAY,EACZ,aAAa,EACb,oBAAoB,EACpB,oBAAoB,EACpB,eAAe,EAChB,MAAM,aAAa,CAAC;
|
|
1
|
+
{"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAGA,OAAO,EAEL,KAAK,eAAe,EACrB,MAAM,gBAAgB,CAAC;AACxB,OAAO,EAAE,8BAA8B,EAAE,MAAM,iCAAiC,CAAC;AACjF,OAAO,KAAK,EAGV,gBAAgB,EAChB,YAAY,EACZ,aAAa,EACb,oBAAoB,EACpB,oBAAoB,EACpB,eAAe,EAChB,MAAM,aAAa,CAAC;AAErB,MAAM,MAAM,gBAAgB,GAAG;IAC7B,QAAQ,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,oBAAoB,KAAK,OAAO,CAAC,eAAe,CAAC,CAAC;CACrF,CAAC;AAEF,MAAM,MAAM,aAAa,GAAG;IAC1B,WAAW,EAAE,CAAC,MAAM,EAAE,gBAAgB,KAAK,IAAI,CAAC;IAChD,SAAS,EAAE,CAAC,KAAK,EAAE,YAAY,KAAK,IAAI,CAAC;IACzC,kBAAkB,EAAE,CAClB,IAAI,EAAE,UAAU,EAChB,QAAQ,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,KACxC,OAAO,CAAC,aAAa,CAAC,CAAC;IAC5B,kBAAkB,EAAE,CAClB,UAAU,EAAE,aAAa,EACzB,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,oBAAoB,EAC7B,MAAM,EAAE,MAAM,KACX,OAAO,CAAC;QAAE,IAAI,EAAE,OAAO,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACjD,UAAU,EAAE,MAAM,IAAI,CAAC;CACxB,CAAC;AAEF,MAAM,MAAM,uBAAuB,GAAG;IACpC,SAAS,CAAC,EAAE,eAAe,CAAC;CAC7B,CAAC;AAEF,OAAO,EAAE,8BAA8B,EAAE,CAAC;AAI1C,eAAO,MAAM,gBAAgB,GAC3B,KAAK,MAAM,EACX,WAAW,gBAAgB,EAC3B,UAAS,uBAA4B,KACpC,OAAO,CAAC,aAAa,CAyKpB,CAAC"}
|
package/dist/client.js
CHANGED
|
@@ -1,68 +1,122 @@
|
|
|
1
|
-
import { createBirpc } from 'birpc';
|
|
2
|
-
import { deserialize, serialize } from './serializer.js';
|
|
3
1
|
import { createBinaryFrame, generateTransferId } from './binary-transfer.js';
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
* transfer protocol and RPC wiring are fully encapsulated.
|
|
13
|
-
*/
|
|
14
|
-
export const connectToHarness = (url, callbacks) => new Promise((resolve, reject) => {
|
|
15
|
-
const ws = new WebSocket(url);
|
|
16
|
-
ws.binaryType = 'arraybuffer';
|
|
2
|
+
import { serializeBridgeMessage } from './protocol.js';
|
|
3
|
+
import { createRpcPeer } from './rpc-peer.js';
|
|
4
|
+
import { createRpcTransport, } from './transport.js';
|
|
5
|
+
import { createWebSocketClientTransport } from './websocket-client-transport.js';
|
|
6
|
+
export { createWebSocketClientTransport };
|
|
7
|
+
const noop = () => undefined;
|
|
8
|
+
export const connectToHarness = (url, callbacks, options = {}) => new Promise((resolve, reject) => {
|
|
9
|
+
const transport = options.transport ?? createWebSocketClientTransport(url);
|
|
17
10
|
let settled = false;
|
|
11
|
+
let peerClosed = false;
|
|
12
|
+
let offOpen = noop;
|
|
13
|
+
let offError = noop;
|
|
14
|
+
let offClose = noop;
|
|
18
15
|
const cleanup = () => {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
16
|
+
offOpen();
|
|
17
|
+
offError();
|
|
18
|
+
offClose();
|
|
22
19
|
};
|
|
23
20
|
const fail = (message) => {
|
|
24
|
-
if (settled)
|
|
21
|
+
if (settled) {
|
|
25
22
|
return;
|
|
23
|
+
}
|
|
26
24
|
settled = true;
|
|
27
25
|
cleanup();
|
|
28
26
|
reject(new Error(message));
|
|
29
27
|
};
|
|
30
28
|
const handleOpen = () => {
|
|
29
|
+
if (settled) {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
31
32
|
settled = true;
|
|
32
33
|
cleanup();
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
34
|
+
const getTransportNotOpenError = () => {
|
|
35
|
+
return new Error('Harness bridge transport is not open');
|
|
36
|
+
};
|
|
37
|
+
const rpc = createRpcPeer({
|
|
38
|
+
localMethods: callbacks,
|
|
39
|
+
transport: createRpcTransport(transport),
|
|
40
|
+
});
|
|
41
|
+
let offMessage = noop;
|
|
42
|
+
let offRuntimeClose = noop;
|
|
43
|
+
let offRuntimeError = noop;
|
|
44
|
+
const closePeer = (reason) => {
|
|
45
|
+
if (peerClosed) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
peerClosed = true;
|
|
49
|
+
offMessage();
|
|
50
|
+
offRuntimeClose();
|
|
51
|
+
offRuntimeError();
|
|
52
|
+
rpc.close(reason);
|
|
53
|
+
};
|
|
54
|
+
const handleMessage = async (data) => {
|
|
55
|
+
const controlMessage = await rpc.handleMessage(data);
|
|
56
|
+
if (!controlMessage) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (controlMessage.type === 'ping') {
|
|
60
|
+
transport.send(serializeBridgeMessage({ type: 'pong', id: controlMessage.id }));
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
offMessage = transport.onMessage((message) => {
|
|
64
|
+
if (typeof message !== 'string') {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
void handleMessage(message).catch((error) => {
|
|
68
|
+
closePeer(error instanceof Error
|
|
69
|
+
? error
|
|
70
|
+
: new Error('Received invalid Harness bridge message'));
|
|
71
|
+
if (transport.state === 'open') {
|
|
72
|
+
transport.close(1002, 'Invalid message');
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
offRuntimeClose = transport.onClose((event) => {
|
|
77
|
+
closePeer(new Error(`Harness connection closed (code ${event.code}${event.reason ? `, reason: ${event.reason}` : ''})`));
|
|
78
|
+
});
|
|
79
|
+
offRuntimeError = transport.onError((error) => {
|
|
80
|
+
closePeer(error);
|
|
43
81
|
});
|
|
44
82
|
resolve({
|
|
45
|
-
reportReady: (device) =>
|
|
46
|
-
|
|
83
|
+
reportReady: (device) => {
|
|
84
|
+
try {
|
|
85
|
+
if (transport.state !== 'open') {
|
|
86
|
+
throw getTransportNotOpenError();
|
|
87
|
+
}
|
|
88
|
+
transport.send(serializeBridgeMessage({ type: 'ready', device }));
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
closePeer(error instanceof Error ? error : getTransportNotOpenError());
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
emitEvent: (event) => {
|
|
95
|
+
rpc.sendEvent(event);
|
|
96
|
+
},
|
|
47
97
|
transferScreenshot: async (data, metadata) => {
|
|
48
98
|
const transferId = generateTransferId();
|
|
49
|
-
|
|
50
|
-
return rpc
|
|
99
|
+
transport.send(createBinaryFrame(transferId, data));
|
|
100
|
+
return rpc.invoke('device.screenshot.receive', { type: 'binary', transferId, size: data.length, mimeType: 'image/png' }, metadata);
|
|
101
|
+
},
|
|
102
|
+
matchImageSnapshot: (screenshot, testPath, options, runner) => rpc.invoke('test.matchImageSnapshot', screenshot, testPath, options, runner),
|
|
103
|
+
disconnect: () => {
|
|
104
|
+
closePeer(new Error('Harness connection closed by client'));
|
|
105
|
+
transport.close();
|
|
51
106
|
},
|
|
52
|
-
matchImageSnapshot: (screenshot, testPath, options, runner) => rpc['test.matchImageSnapshot'](screenshot, testPath, options, runner),
|
|
53
|
-
disconnect: () => ws.close(),
|
|
54
107
|
});
|
|
55
108
|
};
|
|
56
|
-
const handleError = (
|
|
57
|
-
const detail =
|
|
58
|
-
? `: ${event.message}`
|
|
59
|
-
: '';
|
|
109
|
+
const handleError = (error) => {
|
|
110
|
+
const detail = error.message ? `: ${error.message}` : '';
|
|
60
111
|
fail(`Failed to connect to Harness at ${url}${detail}`);
|
|
61
112
|
};
|
|
62
113
|
const handleClose = (event) => {
|
|
63
114
|
fail(`Harness connection at ${url} closed before becoming ready (code ${event.code}${event.reason ? `, reason: ${event.reason}` : ''})`);
|
|
64
115
|
};
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
116
|
+
offOpen = transport.onOpen(handleOpen);
|
|
117
|
+
offError = transport.onError(handleError);
|
|
118
|
+
offClose = transport.onClose(handleClose);
|
|
119
|
+
if (transport.state === 'open') {
|
|
120
|
+
handleOpen();
|
|
121
|
+
}
|
|
68
122
|
});
|
package/dist/errors.d.ts
CHANGED
|
@@ -4,4 +4,9 @@ export declare class DeviceNotRespondingError extends HarnessError {
|
|
|
4
4
|
readonly args: unknown[];
|
|
5
5
|
constructor(functionName: string, args: unknown[]);
|
|
6
6
|
}
|
|
7
|
+
export type AppBridgeDisconnectedReason = 'app-disconnected' | 'app-replaced' | 'heartbeat-timeout' | 'socket-error' | 'bridge-disposed';
|
|
8
|
+
export declare class AppBridgeDisconnectedError extends HarnessError {
|
|
9
|
+
readonly reason: AppBridgeDisconnectedReason;
|
|
10
|
+
constructor(reason: AppBridgeDisconnectedReason);
|
|
11
|
+
}
|
|
7
12
|
//# sourceMappingURL=errors.d.ts.map
|
package/dist/errors.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,6BAA6B,CAAC;AAE3D,qBAAa,wBAAyB,SAAQ,YAAY;aAEtC,YAAY,EAAE,MAAM;aACpB,IAAI,EAAE,OAAO,EAAE;gBADf,YAAY,EAAE,MAAM,EACpB,IAAI,EAAE,OAAO,EAAE;CAKlC"}
|
|
1
|
+
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,6BAA6B,CAAC;AAE3D,qBAAa,wBAAyB,SAAQ,YAAY;aAEtC,YAAY,EAAE,MAAM;aACpB,IAAI,EAAE,OAAO,EAAE;gBADf,YAAY,EAAE,MAAM,EACpB,IAAI,EAAE,OAAO,EAAE;CAKlC;AAED,MAAM,MAAM,2BAA2B,GACnC,kBAAkB,GAClB,cAAc,GACd,mBAAmB,GACnB,cAAc,GACd,iBAAiB,CAAC;AAmBtB,qBAAa,0BAA2B,SAAQ,YAAY;aAC9B,MAAM,EAAE,2BAA2B;gBAAnC,MAAM,EAAE,2BAA2B;CAKhE"}
|
package/dist/errors.js
CHANGED
|
@@ -9,3 +9,26 @@ export class DeviceNotRespondingError extends HarnessError {
|
|
|
9
9
|
this.name = 'DeviceNotRespondingError';
|
|
10
10
|
}
|
|
11
11
|
}
|
|
12
|
+
const appBridgeDisconnectedMessage = (reason) => {
|
|
13
|
+
switch (reason) {
|
|
14
|
+
case 'app-replaced':
|
|
15
|
+
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.';
|
|
16
|
+
case 'heartbeat-timeout':
|
|
17
|
+
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.';
|
|
18
|
+
case 'socket-error':
|
|
19
|
+
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.';
|
|
20
|
+
case 'bridge-disposed':
|
|
21
|
+
return 'The app bridge was disposed before the test file finished running.';
|
|
22
|
+
case 'app-disconnected':
|
|
23
|
+
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.';
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
export class AppBridgeDisconnectedError extends HarnessError {
|
|
27
|
+
reason;
|
|
28
|
+
constructor(reason) {
|
|
29
|
+
super(appBridgeDisconnectedMessage(reason));
|
|
30
|
+
this.reason = reason;
|
|
31
|
+
this.name = 'AppBridgeDisconnectedError';
|
|
32
|
+
this.stack = `${this.name}: ${this.message}`;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export declare const DEFAULT_HEARTBEAT_INTERVAL_MS = 5000;
|
|
2
|
+
export declare const DEFAULT_HEARTBEAT_TIMEOUT_MS = 20000;
|
|
3
|
+
export type BridgeHeartbeat = {
|
|
4
|
+
notifyPong: (id: number) => void;
|
|
5
|
+
dispose: () => void;
|
|
6
|
+
};
|
|
7
|
+
export declare const createHeartbeat: (options: {
|
|
8
|
+
sendPing: (id: number) => void;
|
|
9
|
+
onTimeout: () => void;
|
|
10
|
+
intervalMs?: number;
|
|
11
|
+
timeoutMs?: number;
|
|
12
|
+
}) => BridgeHeartbeat;
|
|
13
|
+
//# sourceMappingURL=heartbeat.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"heartbeat.d.ts","sourceRoot":"","sources":["../src/heartbeat.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,6BAA6B,OAAQ,CAAC;AACnD,eAAO,MAAM,4BAA4B,QAAS,CAAC;AAEnD,MAAM,MAAM,eAAe,GAAG;IAC5B,UAAU,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,CAAC;IACjC,OAAO,EAAE,MAAM,IAAI,CAAC;CACrB,CAAC;AAEF,eAAO,MAAM,eAAe,GAAI,SAAS;IACvC,QAAQ,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,CAAC;IAC/B,SAAS,EAAE,MAAM,IAAI,CAAC;IACtB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB,KAAG,eAqDH,CAAC"}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export const DEFAULT_HEARTBEAT_INTERVAL_MS = 5_000;
|
|
2
|
+
export const DEFAULT_HEARTBEAT_TIMEOUT_MS = 20_000;
|
|
3
|
+
export const createHeartbeat = (options) => {
|
|
4
|
+
const intervalMs = options.intervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;
|
|
5
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_HEARTBEAT_TIMEOUT_MS;
|
|
6
|
+
let nextPingId = 1;
|
|
7
|
+
let pendingPingId = null;
|
|
8
|
+
let disposed = false;
|
|
9
|
+
let timeoutHandle = null;
|
|
10
|
+
const clearPendingTimeout = () => {
|
|
11
|
+
if (timeoutHandle) {
|
|
12
|
+
clearTimeout(timeoutHandle);
|
|
13
|
+
timeoutHandle = null;
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
const intervalHandle = setInterval(() => {
|
|
17
|
+
if (disposed || pendingPingId !== null) {
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
const pingId = nextPingId++;
|
|
21
|
+
pendingPingId = pingId;
|
|
22
|
+
options.sendPing(pingId);
|
|
23
|
+
timeoutHandle = setTimeout(() => {
|
|
24
|
+
if (disposed || pendingPingId !== pingId) {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
pendingPingId = null;
|
|
28
|
+
timeoutHandle = null;
|
|
29
|
+
options.onTimeout();
|
|
30
|
+
}, timeoutMs);
|
|
31
|
+
}, intervalMs);
|
|
32
|
+
return {
|
|
33
|
+
notifyPong: (id) => {
|
|
34
|
+
if (id !== pendingPingId) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
pendingPingId = null;
|
|
38
|
+
clearPendingTimeout();
|
|
39
|
+
},
|
|
40
|
+
dispose: () => {
|
|
41
|
+
if (disposed) {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
disposed = true;
|
|
45
|
+
clearInterval(intervalHandle);
|
|
46
|
+
clearPendingTimeout();
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
};
|
package/dist/index.d.ts
CHANGED
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,aAAa,CAAC;AAC5B,cAAc,sBAAsB,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,aAAa,CAAC;AAC5B,cAAc,sBAAsB,CAAC;AACrC,cAAc,gBAAgB,CAAC"}
|
package/dist/index.js
CHANGED