Skip to content

Commit 2f43c46

Browse files
committed
merge origin/main
2 parents d71730f + bf624b5 commit 2f43c46

24 files changed

Lines changed: 1640 additions & 189 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
__default__: patch
3+
---
4+
5+
Harness now handles app bridge reloads, reconnects, disconnects, and dropped sockets more reliably during test runs. For Harness users, this means fewer stuck runs waiting on a dead RPC channel and clearer failures when the app reloads, crashes, or loses its bridge connection mid-test.

packages/bridge/eslint.config.mjs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@ export default [
88
'@nx/dependency-checks': [
99
'error',
1010
{
11-
ignoredFiles: ['{projectRoot}/eslint.config.{js,cjs,mjs,ts,cts,mts}'],
11+
ignoredDependencies: ['vite', 'vitest'],
12+
ignoredFiles: [
13+
'{projectRoot}/eslint.config.{js,cjs,mjs,ts,cts,mts}',
14+
'{projectRoot}/src/**/__tests__/**',
15+
],
1216
},
1317
],
1418
},

packages/bridge/package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@
2929
"dependencies": {
3030
"@react-native-harness/platforms": "workspace:*",
3131
"@react-native-harness/tools": "workspace:*",
32-
"birpc": "^2.4.0",
3332
"pixelmatch": "^7.1.0",
3433
"pngjs": "^7.0.0",
3534
"ssim.js": "^3.5.0",
@@ -39,7 +38,9 @@
3938
"devDependencies": {
4039
"@types/pixelmatch": "^5.2.6",
4140
"@types/pngjs": "^6.0.5",
42-
"@types/ws": "^8.18.1"
41+
"@types/ws": "^8.18.1",
42+
"vite": "^7.2.2",
43+
"vitest": "^3.2.4"
4344
},
4445
"license": "MIT"
4546
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { describe, expect, it, vi } from 'vitest';
2+
import { connectToHarness } from '../client.js';
3+
import type {
4+
BridgeTransport,
5+
BridgeTransportCloseEvent,
6+
BridgeTransportMessage,
7+
BridgeTransportState,
8+
} from '../transport.js';
9+
10+
const createMockTransport = (initialState: BridgeTransportState = 'connecting') => {
11+
let state = initialState;
12+
const openListeners = new Set<() => void>();
13+
const messageListeners = new Set<(message: BridgeTransportMessage) => void>();
14+
const closeListeners = new Set<(event: BridgeTransportCloseEvent) => void>();
15+
const errorListeners = new Set<(error: Error) => void>();
16+
const sent: BridgeTransportMessage[] = [];
17+
18+
const transport: BridgeTransport = {
19+
get state() {
20+
return state;
21+
},
22+
send: (message) => {
23+
sent.push(message);
24+
},
25+
close: (code = 1000, reason = '') => {
26+
state = 'closed';
27+
for (const listener of closeListeners) {
28+
listener({ code, reason });
29+
}
30+
},
31+
onOpen: (listener) => {
32+
openListeners.add(listener);
33+
return () => openListeners.delete(listener);
34+
},
35+
onMessage: (listener) => {
36+
messageListeners.add(listener);
37+
return () => messageListeners.delete(listener);
38+
},
39+
onClose: (listener) => {
40+
closeListeners.add(listener);
41+
return () => closeListeners.delete(listener);
42+
},
43+
onError: (listener) => {
44+
errorListeners.add(listener);
45+
return () => errorListeners.delete(listener);
46+
},
47+
};
48+
49+
return {
50+
transport,
51+
sent,
52+
open: () => {
53+
state = 'open';
54+
for (const listener of openListeners) {
55+
listener();
56+
}
57+
},
58+
receive: (message: BridgeTransportMessage) => {
59+
for (const listener of messageListeners) {
60+
listener(message);
61+
}
62+
},
63+
fail: (error: Error) => {
64+
for (const listener of errorListeners) {
65+
listener(error);
66+
}
67+
},
68+
};
69+
};
70+
71+
describe('connectToHarness transport injection', () => {
72+
it('uses the injected transport instead of constructing a WebSocket', async () => {
73+
const mockTransport = createMockTransport();
74+
const handlePromise = connectToHarness(
75+
'ws://unused',
76+
{ runTests: vi.fn() },
77+
{ transport: mockTransport.transport },
78+
);
79+
80+
mockTransport.open();
81+
82+
const handle = await handlePromise;
83+
handle.reportReady({
84+
platform: 'ios',
85+
manufacturer: 'Apple',
86+
model: 'Injected',
87+
osVersion: '18.0',
88+
});
89+
90+
expect(mockTransport.sent).toHaveLength(1);
91+
expect(mockTransport.sent[0]).toBeTypeOf('string');
92+
expect(JSON.parse(mockTransport.sent[0] as string)).toMatchObject({
93+
type: 'ready',
94+
device: { model: 'Injected' },
95+
});
96+
97+
handle.disconnect();
98+
});
99+
});
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { describe, expect, it, vi } from 'vitest';
2+
import { createHeartbeat } from '../heartbeat.js';
3+
4+
describe('bridge heartbeat', () => {
5+
it('sends pings and times out stale sessions', () => {
6+
vi.useFakeTimers();
7+
8+
const sendPing = vi.fn();
9+
const onTimeout = vi.fn();
10+
createHeartbeat({
11+
sendPing,
12+
onTimeout,
13+
intervalMs: 5,
14+
timeoutMs: 10,
15+
});
16+
17+
vi.advanceTimersByTime(5);
18+
expect(sendPing).toHaveBeenCalledWith(1);
19+
20+
vi.advanceTimersByTime(10);
21+
expect(onTimeout).toHaveBeenCalledTimes(1);
22+
23+
vi.useRealTimers();
24+
});
25+
26+
it('clears the timeout when the matching pong arrives', () => {
27+
vi.useFakeTimers();
28+
29+
const sendPing = vi.fn();
30+
const onTimeout = vi.fn();
31+
const heartbeat = createHeartbeat({
32+
sendPing,
33+
onTimeout,
34+
intervalMs: 5,
35+
timeoutMs: 10,
36+
});
37+
38+
vi.advanceTimersByTime(5);
39+
heartbeat.notifyPong(1);
40+
vi.advanceTimersByTime(10);
41+
42+
expect(onTimeout).not.toHaveBeenCalled();
43+
44+
heartbeat.dispose();
45+
vi.useRealTimers();
46+
});
47+
});
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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 commit comments

Comments
 (0)