@react-native-harness/bridge 1.6.0 → 1.7.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.
Files changed (86) hide show
  1. package/dist/app-liveness.d.ts +33 -0
  2. package/dist/app-liveness.d.ts.map +1 -0
  3. package/dist/app-liveness.js +57 -0
  4. package/dist/client.d.ts +6 -4
  5. package/dist/client.d.ts.map +1 -1
  6. package/dist/client.js +112 -105
  7. package/dist/connection-check.d.ts +22 -0
  8. package/dist/connection-check.d.ts.map +1 -0
  9. package/dist/connection-check.js +30 -0
  10. package/dist/errors.d.ts +6 -2
  11. package/dist/errors.d.ts.map +1 -1
  12. package/dist/errors.js +12 -4
  13. package/dist/image-snapshot.d.ts +2 -2
  14. package/dist/image-snapshot.d.ts.map +1 -1
  15. package/dist/image-snapshot.js +21 -8
  16. package/dist/index.d.ts +0 -2
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +0 -2
  19. package/dist/js-block-budget-policy.d.ts +31 -0
  20. package/dist/js-block-budget-policy.d.ts.map +1 -0
  21. package/dist/js-block-budget-policy.js +60 -0
  22. package/dist/js-watchdog.d.ts +31 -0
  23. package/dist/js-watchdog.d.ts.map +1 -0
  24. package/dist/js-watchdog.js +46 -0
  25. package/dist/protocol.d.ts +10 -5
  26. package/dist/protocol.d.ts.map +1 -1
  27. package/dist/protocol.js +2 -2
  28. package/dist/rpc-peer.d.ts +3 -1
  29. package/dist/rpc-peer.d.ts.map +1 -1
  30. package/dist/rpc-peer.js +2 -2
  31. package/dist/server.d.ts +4 -0
  32. package/dist/server.d.ts.map +1 -1
  33. package/dist/server.js +71 -56
  34. package/dist/shared/test-runner.d.ts +11 -0
  35. package/dist/shared/test-runner.d.ts.map +1 -1
  36. package/dist/shared.d.ts +5 -1
  37. package/dist/shared.d.ts.map +1 -1
  38. package/package.json +4 -3
  39. package/src/app-liveness.ts +103 -0
  40. package/src/binary.ts +74 -0
  41. package/src/client.ts +172 -170
  42. package/src/connection-check.ts +60 -0
  43. package/src/errors.ts +16 -5
  44. package/src/image-snapshot.ts +31 -9
  45. package/src/index.ts +0 -2
  46. package/src/js-block-budget-policy.ts +90 -0
  47. package/src/js-watchdog.ts +78 -0
  48. package/src/link/README.md +43 -0
  49. package/src/link/channel.ts +473 -0
  50. package/src/link/envelope.ts +110 -0
  51. package/src/link/index.ts +321 -0
  52. package/src/link/ports.ts +42 -0
  53. package/src/link/scheduler.ts +132 -0
  54. package/src/link/standard-websocket.ts +251 -0
  55. package/src/link/streams.ts +34 -0
  56. package/src/protocol.ts +14 -9
  57. package/src/rpc-peer.ts +6 -3
  58. package/src/server.ts +99 -67
  59. package/src/shared/test-runner.ts +12 -0
  60. package/src/shared.ts +6 -1
  61. package/dist/binary-transfer.d.ts +0 -17
  62. package/dist/binary-transfer.d.ts.map +0 -1
  63. package/dist/binary-transfer.js +0 -60
  64. package/dist/events.d.ts +0 -45
  65. package/dist/events.d.ts.map +0 -1
  66. package/dist/events.js +0 -1
  67. package/dist/heartbeat.d.ts +0 -13
  68. package/dist/heartbeat.d.ts.map +0 -1
  69. package/dist/heartbeat.js +0 -49
  70. package/dist/platform-bridge.d.ts +0 -4
  71. package/dist/platform-bridge.d.ts.map +0 -1
  72. package/dist/platform-bridge.js +0 -25
  73. package/dist/transport.d.ts +0 -22
  74. package/dist/transport.d.ts.map +0 -1
  75. package/dist/transport.js +0 -22
  76. package/dist/websocket-client-transport.d.ts +0 -3
  77. package/dist/websocket-client-transport.d.ts.map +0 -1
  78. package/dist/websocket-client-transport.js +0 -45
  79. package/dist/websocket-server-transport.d.ts +0 -4
  80. package/dist/websocket-server-transport.d.ts.map +0 -1
  81. package/dist/websocket-server-transport.js +0 -43
  82. package/src/binary-transfer.ts +0 -79
  83. package/src/heartbeat.ts +0 -67
  84. package/src/transport.ts +0 -47
  85. package/src/websocket-client-transport.ts +0 -85
  86. package/src/websocket-server-transport.ts +0 -54
@@ -0,0 +1,33 @@
1
+ import { type ConnectionCheckOptions, type PingableSocket } from './connection-check.js';
2
+ import { AppBridgeDisconnectedError } from './errors.js';
3
+ import { type JsBlockBudgetPolicyOptions } from './js-block-budget-policy.js';
4
+ import type { BridgeEvents } from './shared.js';
5
+ export type AppLivenessOptions = {
6
+ connectionCheck: ConnectionCheckOptions;
7
+ jsBlockBudget: JsBlockBudgetPolicyOptions;
8
+ jsProbeIntervalMs?: number;
9
+ };
10
+ export type AppLivenessOverrides = {
11
+ connectionCheck?: Partial<ConnectionCheckOptions>;
12
+ jsBlockBudget?: Partial<JsBlockBudgetPolicyOptions>;
13
+ jsProbeIntervalMs?: number;
14
+ };
15
+ export declare const DEFAULT_APP_LIVENESS_OPTIONS: AppLivenessOptions;
16
+ export declare const resolveAppLivenessOptions: (overrides?: AppLivenessOverrides) => AppLivenessOptions;
17
+ export type AppLiveness = {
18
+ handleEvent: (event: BridgeEvents) => void;
19
+ handleProbeAck: (id: number) => void;
20
+ dispose: () => void;
21
+ };
22
+ /**
23
+ * Watches one app connection with two independent checks: the connection
24
+ * check (is the process reachable?) and the JS watchdog (is the JS thread
25
+ * getting a turn?). See docs/adr/0001-app-liveness-detection.md.
26
+ */
27
+ export declare const createAppLiveness: (options: {
28
+ socket: PingableSocket;
29
+ sendProbe: (id: number) => void;
30
+ onLost: (error: AppBridgeDisconnectedError) => void;
31
+ config: AppLivenessOptions;
32
+ }) => AppLiveness;
33
+ //# sourceMappingURL=app-liveness.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"app-liveness.d.ts","sourceRoot":"","sources":["../src/app-liveness.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,sBAAsB,EAC3B,KAAK,cAAc,EACpB,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EAAE,0BAA0B,EAAE,MAAM,aAAa,CAAC;AACzD,OAAO,EAGL,KAAK,0BAA0B,EAChC,MAAM,6BAA6B,CAAC;AAErC,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAEhD,MAAM,MAAM,kBAAkB,GAAG;IAC/B,eAAe,EAAE,sBAAsB,CAAC;IACxC,aAAa,EAAE,0BAA0B,CAAC;IAC1C,iBAAiB,CAAC,EAAE,MAAM,CAAC;CAC5B,CAAC;AAEF,MAAM,MAAM,oBAAoB,GAAG;IACjC,eAAe,CAAC,EAAE,OAAO,CAAC,sBAAsB,CAAC,CAAC;IAClD,aAAa,CAAC,EAAE,OAAO,CAAC,0BAA0B,CAAC,CAAC;IACpD,iBAAiB,CAAC,EAAE,MAAM,CAAC;CAC5B,CAAC;AAEF,eAAO,MAAM,4BAA4B,EAAE,kBAG1C,CAAC;AASF,eAAO,MAAM,yBAAyB,GACpC,YAAW,oBAAyB,KACnC,kBAUD,CAAC;AAEH,MAAM,MAAM,WAAW,GAAG;IACxB,WAAW,EAAE,CAAC,KAAK,EAAE,YAAY,KAAK,IAAI,CAAC;IAC3C,cAAc,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,CAAC;IACrC,OAAO,EAAE,MAAM,IAAI,CAAC;CACrB,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,iBAAiB,GAAI,SAAS;IACzC,MAAM,EAAE,cAAc,CAAC;IACvB,SAAS,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,CAAC;IAChC,MAAM,EAAE,CAAC,KAAK,EAAE,0BAA0B,KAAK,IAAI,CAAC;IACpD,MAAM,EAAE,kBAAkB,CAAC;CAC5B,KAAG,WAkCH,CAAC"}
@@ -0,0 +1,57 @@
1
+ import { createConnectionCheck, } from './connection-check.js';
2
+ import { AppBridgeDisconnectedError } from './errors.js';
3
+ import { createJsBlockBudgetPolicy, getIdleBudget, } from './js-block-budget-policy.js';
4
+ import { createJsWatchdog } from './js-watchdog.js';
5
+ export const DEFAULT_APP_LIVENESS_OPTIONS = {
6
+ connectionCheck: { intervalMs: 5_000, timeoutMs: 20_000 },
7
+ jsBlockBudget: { idleMs: 20_000, fileLoadMs: 60_000, graceMs: 5_000 },
8
+ };
9
+ // An override left `undefined` keeps the default instead of replacing it,
10
+ // which would turn a budget into `undefined` and disable it.
11
+ const definedOnly = (values = {}) => Object.fromEntries(Object.entries(values).filter(([, value]) => value !== undefined));
12
+ export const resolveAppLivenessOptions = (overrides = {}) => ({
13
+ connectionCheck: {
14
+ ...DEFAULT_APP_LIVENESS_OPTIONS.connectionCheck,
15
+ ...definedOnly(overrides.connectionCheck),
16
+ },
17
+ jsBlockBudget: {
18
+ ...DEFAULT_APP_LIVENESS_OPTIONS.jsBlockBudget,
19
+ ...definedOnly(overrides.jsBlockBudget),
20
+ },
21
+ jsProbeIntervalMs: overrides.jsProbeIntervalMs,
22
+ });
23
+ /**
24
+ * Watches one app connection with two independent checks: the connection
25
+ * check (is the process reachable?) and the JS watchdog (is the JS thread
26
+ * getting a turn?). See docs/adr/0001-app-liveness-detection.md.
27
+ */
28
+ export const createAppLiveness = (options) => {
29
+ const connectionCheck = createConnectionCheck(options.socket, {
30
+ ...options.config.connectionCheck,
31
+ onLost: () => {
32
+ options.onLost(new AppBridgeDisconnectedError('connection-lost'));
33
+ },
34
+ });
35
+ const policy = createJsBlockBudgetPolicy(options.config.jsBlockBudget);
36
+ const watchdog = createJsWatchdog({
37
+ sendProbe: options.sendProbe,
38
+ initialBudget: getIdleBudget(options.config.jsBlockBudget),
39
+ probeIntervalMs: options.config.jsProbeIntervalMs,
40
+ onUnresponsive: (budget) => {
41
+ options.onLost(new AppBridgeDisconnectedError('js-thread-unresponsive', budget.label));
42
+ },
43
+ });
44
+ return {
45
+ handleEvent: (event) => {
46
+ const budget = policy.getBudgetForEvent(event);
47
+ if (budget) {
48
+ watchdog.setBlockBudget(budget);
49
+ }
50
+ },
51
+ handleProbeAck: watchdog.notifyProbeAck,
52
+ dispose: () => {
53
+ connectionCheck.dispose();
54
+ watchdog.dispose();
55
+ },
56
+ };
57
+ };
package/dist/client.d.ts CHANGED
@@ -1,5 +1,4 @@
1
- import { type BridgeTransport } from './transport.js';
2
- import { createWebSocketClientTransport } from './websocket-client-transport.js';
1
+ import { type StandardWebSocket, type UplinkTransportConnection } from './link/index.js';
3
2
  import type { DeviceDescriptor, BridgeEvents, FileReference, ImageSnapshotOptions, TestExecutionOptions, TestSuiteResult } from './shared.js';
4
3
  export type HarnessCallbacks = {
5
4
  runTests: (path: string, options: TestExecutionOptions) => Promise<TestSuiteResult>;
@@ -19,8 +18,11 @@ export type HarnessHandle = {
19
18
  disconnect: () => void;
20
19
  };
21
20
  export type ConnectToHarnessOptions = {
22
- transport?: BridgeTransport;
21
+ /** An already open link transport, used instead of dialing `url`. */
22
+ transport?: UplinkTransportConnection;
23
+ /** Creates the WebSocket to dial `url` with. Defaults to the global `WebSocket`. */
24
+ createSocket?: (url: string) => StandardWebSocket;
23
25
  };
24
- export { createWebSocketClientTransport };
26
+ export type { UplinkTransportConnection };
25
27
  export declare const connectToHarness: (url: string, callbacks: HarnessCallbacks, options?: ConnectToHarnessOptions) => Promise<HarnessHandle>;
26
28
  //# sourceMappingURL=client.d.ts.map
@@ -1 +1 @@
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;IACpF,gBAAgB,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CACvC,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"}
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AACA,OAAO,EAML,KAAK,iBAAiB,EACtB,KAAK,yBAAyB,EAC/B,MAAM,iBAAiB,CAAC;AAGzB,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;IACpF,gBAAgB,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CACvC,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,qEAAqE;IACrE,SAAS,CAAC,EAAE,yBAAyB,CAAC;IACtC,oFAAoF;IACpF,YAAY,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,iBAAiB,CAAC;CACnD,CAAC;AAEF,YAAY,EAAE,yBAAyB,EAAE,CAAC;AA2C1C,eAAO,MAAM,gBAAgB,GAC3B,KAAK,MAAM,EACX,WAAW,gBAAgB,EAC3B,UAAS,uBAA4B,KACpC,OAAO,CAAC,aAAa,CA6HvB,CAAC"}
package/dist/client.js CHANGED
@@ -1,122 +1,129 @@
1
- import { createBinaryFrame, generateTransferId } from './binary-transfer.js';
1
+ import { sendBinary } from './binary.js';
2
+ import { connectStandardWebSocket, createLink, globalClock, } from './link/index.js';
2
3
  import { serializeBridgeMessage } from './protocol.js';
3
4
  import { createRpcPeer } from './rpc-peer.js';
4
- import { createRpcTransport, } from './transport.js';
5
- import { createWebSocketClientTransport } from './websocket-client-transport.js';
6
- export { createWebSocketClientTransport };
7
5
  const noop = () => undefined;
8
- export const connectToHarness = (url, callbacks, options = {}) => new Promise((resolve, reject) => {
9
- const transport = options.transport ?? createWebSocketClientTransport(url);
10
- let settled = false;
6
+ const createConsoleLogger = () => {
7
+ const logger = {
8
+ debug: noop,
9
+ info: noop,
10
+ warn: (message) => console.warn(`[harness] ${message}`),
11
+ error: (message) => console.error(`[harness] ${message}`),
12
+ child: () => logger,
13
+ };
14
+ return logger;
15
+ };
16
+ // React Native has no `crypto.randomUUID`, so channel ids come from a counter. Ids only
17
+ // need to be unique among this connection's channels.
18
+ const createChannelIdGenerator = () => {
19
+ let nextId = 1;
20
+ return () => `app-${nextId++}`;
21
+ };
22
+ const dial = async (url, options) => {
23
+ if (options.transport) {
24
+ return options.transport;
25
+ }
26
+ try {
27
+ return await connectStandardWebSocket({
28
+ url,
29
+ createSocket: options.createSocket,
30
+ });
31
+ }
32
+ catch (error) {
33
+ const detail = error instanceof Error && error.message ? `: ${error.message}` : '';
34
+ throw new Error(`Failed to connect to Harness at ${url}${detail}`);
35
+ }
36
+ };
37
+ export const connectToHarness = async (url, callbacks, options = {}) => {
38
+ const transport = await dial(url, options);
39
+ const link = createLink(transport, {
40
+ end: 'worker',
41
+ clock: globalClock,
42
+ logger: createConsoleLogger(),
43
+ generateId: createChannelIdGenerator(),
44
+ });
45
+ const rpc = createRpcPeer({
46
+ localMethods: callbacks,
47
+ transport: {
48
+ send: (message) => {
49
+ void link.write(message);
50
+ },
51
+ },
52
+ });
11
53
  let peerClosed = false;
12
- let offOpen = noop;
13
- let offError = noop;
54
+ let offMessage = noop;
14
55
  let offClose = noop;
15
- const cleanup = () => {
16
- offOpen();
17
- offError();
18
- offClose();
56
+ let offError = noop;
57
+ const getLinkClosedError = () => {
58
+ return new Error('Harness bridge link is closed');
19
59
  };
20
- const fail = (message) => {
21
- if (settled) {
60
+ const closePeer = (reason) => {
61
+ if (peerClosed) {
22
62
  return;
23
63
  }
24
- settled = true;
25
- cleanup();
26
- reject(new Error(message));
64
+ peerClosed = true;
65
+ offMessage();
66
+ offClose();
67
+ offError();
68
+ rpc.close(reason);
27
69
  };
28
- const handleOpen = () => {
29
- if (settled) {
70
+ const handleMessage = async (data) => {
71
+ const controlMessage = await rpc.handleMessage(data);
72
+ if (!controlMessage) {
30
73
  return;
31
74
  }
32
- settled = true;
33
- cleanup();
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),
75
+ if (controlMessage.type === 'js-probe') {
76
+ void link.write(serializeBridgeMessage({ type: 'js-probe-ack', id: controlMessage.id }));
77
+ }
78
+ };
79
+ offMessage = link.onData((message) => {
80
+ void handleMessage(message).catch((error) => {
81
+ closePeer(error instanceof Error
82
+ ? error
83
+ : new Error('Received invalid Harness bridge message'));
84
+ void link.close();
40
85
  });
41
- let offMessage = noop;
42
- let offRuntimeClose = noop;
43
- let offRuntimeError = noop;
44
- const closePeer = (reason) => {
45
- if (peerClosed) {
86
+ });
87
+ offError = link.onError((error) => {
88
+ closePeer(error);
89
+ });
90
+ offClose = link.onClose(() => {
91
+ closePeer(new Error('Harness connection closed'));
92
+ });
93
+ return {
94
+ reportReady: (device) => {
95
+ if (link.closed) {
96
+ closePeer(getLinkClosedError());
46
97
  return;
47
98
  }
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 }));
99
+ void link.write(serializeBridgeMessage({ type: 'ready', device }));
100
+ },
101
+ emitEvent: (event) => {
102
+ rpc.sendEvent(event);
103
+ },
104
+ transferScreenshot: async (data, metadata) => {
105
+ const binary = await sendBinary(link, data);
106
+ try {
107
+ const [file] = await Promise.all([
108
+ rpc.invoke('device.screenshot.receive', {
109
+ type: 'binary',
110
+ channelId: binary.channelId,
111
+ size: data.length,
112
+ mimeType: 'image/png',
113
+ }, metadata),
114
+ binary.written,
115
+ ]);
116
+ return file;
61
117
  }
62
- };
63
- offMessage = transport.onMessage((message) => {
64
- if (typeof message !== 'string') {
65
- return;
118
+ catch (error) {
119
+ binary.abort();
120
+ throw error;
66
121
  }
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);
81
- });
82
- resolve({
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
- },
97
- transferScreenshot: async (data, metadata) => {
98
- const transferId = generateTransferId();
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();
106
- },
107
- });
122
+ },
123
+ matchImageSnapshot: (screenshot, testPath, options, runner) => rpc.invoke('test.matchImageSnapshot', screenshot, testPath, options, runner),
124
+ disconnect: () => {
125
+ closePeer(new Error('Harness connection closed by client'));
126
+ void link.close();
127
+ },
108
128
  };
109
- const handleError = (error) => {
110
- const detail = error.message ? `: ${error.message}` : '';
111
- fail(`Failed to connect to Harness at ${url}${detail}`);
112
- };
113
- const handleClose = (event) => {
114
- fail(`Harness connection at ${url} closed before becoming ready (code ${event.code}${event.reason ? `, reason: ${event.reason}` : ''})`);
115
- };
116
- offOpen = transport.onOpen(handleOpen);
117
- offError = transport.onError(handleError);
118
- offClose = transport.onClose(handleClose);
119
- if (transport.state === 'open') {
120
- handleOpen();
121
- }
122
- });
129
+ };
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Checks that the app process is still reachable, using WebSocket protocol
3
+ * ping frames (RFC 6455). The app's native WebSocket answers them without
4
+ * involving JS (OkHttp on Android, SocketRocket on iOS, the network stack in
5
+ * browsers), so a blocked JS thread does not fail this check.
6
+ */
7
+ export type PingableSocket = {
8
+ ping: () => void;
9
+ on: (event: 'pong', listener: () => void) => unknown;
10
+ off: (event: 'pong', listener: () => void) => unknown;
11
+ };
12
+ export type ConnectionCheckOptions = {
13
+ intervalMs: number;
14
+ timeoutMs: number;
15
+ };
16
+ export type ConnectionCheck = {
17
+ dispose: () => void;
18
+ };
19
+ export declare const createConnectionCheck: (socket: PingableSocket, options: ConnectionCheckOptions & {
20
+ onLost: () => void;
21
+ }) => ConnectionCheck;
22
+ //# sourceMappingURL=connection-check.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"connection-check.d.ts","sourceRoot":"","sources":["../src/connection-check.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,MAAM,MAAM,cAAc,GAAG;IAC3B,IAAI,EAAE,MAAM,IAAI,CAAC;IACjB,EAAE,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,IAAI,KAAK,OAAO,CAAC;IACrD,GAAG,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,IAAI,KAAK,OAAO,CAAC;CACvD,CAAC;AAEF,MAAM,MAAM,sBAAsB,GAAG;IACnC,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;CACnB,CAAC;AAEF,MAAM,MAAM,eAAe,GAAG;IAC5B,OAAO,EAAE,MAAM,IAAI,CAAC;CACrB,CAAC;AAEF,eAAO,MAAM,qBAAqB,GAChC,QAAQ,cAAc,EACtB,SAAS,sBAAsB,GAAG;IAAE,MAAM,EAAE,MAAM,IAAI,CAAA;CAAE,KACvD,eAmCF,CAAC"}
@@ -0,0 +1,30 @@
1
+ export const createConnectionCheck = (socket, options) => {
2
+ let lastPongAt = Date.now();
3
+ let disposed = false;
4
+ const handlePong = () => {
5
+ lastPongAt = Date.now();
6
+ };
7
+ socket.on('pong', handlePong);
8
+ const dispose = () => {
9
+ if (disposed) {
10
+ return;
11
+ }
12
+ disposed = true;
13
+ clearInterval(interval);
14
+ socket.off('pong', handlePong);
15
+ };
16
+ const interval = setInterval(() => {
17
+ if (Date.now() - lastPongAt > options.timeoutMs) {
18
+ dispose();
19
+ options.onLost();
20
+ return;
21
+ }
22
+ try {
23
+ socket.ping();
24
+ }
25
+ catch {
26
+ // The socket is closing; its close event ends the session.
27
+ }
28
+ }, options.intervalMs);
29
+ return { dispose };
30
+ };
package/dist/errors.d.ts CHANGED
@@ -4,9 +4,13 @@ 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';
7
+ export type AppBridgeDisconnectedReason = 'app-disconnected' | 'app-replaced' | 'connection-lost' | 'js-thread-unresponsive' | 'socket-error' | 'bridge-disposed';
8
8
  export declare class AppBridgeDisconnectedError extends HarnessError {
9
9
  readonly reason: AppBridgeDisconnectedReason;
10
- constructor(reason: AppBridgeDisconnectedReason);
10
+ /** What the app was busy with, when known. Appended to the message. */
11
+ readonly detail?: string | undefined;
12
+ constructor(reason: AppBridgeDisconnectedReason,
13
+ /** What the app was busy with, when known. Appended to the message. */
14
+ detail?: string | undefined);
11
15
  }
12
16
  //# sourceMappingURL=errors.d.ts.map
@@ -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;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"}
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,iBAAiB,GACjB,wBAAwB,GACxB,cAAc,GACd,iBAAiB,CAAC;AAqBtB,qBAAa,0BAA2B,SAAQ,YAAY;aAExC,MAAM,EAAE,2BAA2B;IACnD,uEAAuE;aACvD,MAAM,CAAC,EAAE,MAAM;gBAFf,MAAM,EAAE,2BAA2B;IACnD,uEAAuE;IACvD,MAAM,CAAC,EAAE,MAAM,YAAA;CAUlC"}
package/dist/errors.js CHANGED
@@ -13,8 +13,10 @@ const appBridgeDisconnectedMessage = (reason) => {
13
13
  switch (reason) {
14
14
  case 'app-replaced':
15
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.';
16
+ case 'connection-lost':
17
+ return 'The app stopped answering WebSocket pings during test execution. The app process was likely killed, frozen, or lost its network connection. Check .harness/crash-reports for a crash report.';
18
+ case 'js-thread-unresponsive':
19
+ return "The app's JS thread was blocked for longer than allowed during test execution.";
18
20
  case 'socket-error':
19
21
  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
22
  case 'bridge-disposed':
@@ -25,9 +27,15 @@ const appBridgeDisconnectedMessage = (reason) => {
25
27
  };
26
28
  export class AppBridgeDisconnectedError extends HarnessError {
27
29
  reason;
28
- constructor(reason) {
29
- super(appBridgeDisconnectedMessage(reason));
30
+ detail;
31
+ constructor(reason,
32
+ /** What the app was busy with, when known. Appended to the message. */
33
+ detail) {
34
+ super(detail
35
+ ? `${appBridgeDisconnectedMessage(reason)} It was busy with: ${detail}.`
36
+ : appBridgeDisconnectedMessage(reason));
30
37
  this.reason = reason;
38
+ this.detail = detail;
31
39
  this.name = 'AppBridgeDisconnectedError';
32
40
  this.stack = `${this.name}: ${this.message}`;
33
41
  }
@@ -1,5 +1,5 @@
1
- import type { FileReference, ImageSnapshotOptions } from './shared.js';
2
- export declare const matchImageSnapshot: (screenshot: FileReference, testFilePath: string, options: ImageSnapshotOptions, platformName: string) => Promise<{
1
+ import type { FileReference, ImageSnapshotOptions, SnapshotUpdateState } from './shared.js';
2
+ export declare const matchImageSnapshot: (screenshot: FileReference, testFilePath: string, options: ImageSnapshotOptions, platformName: string, updateSnapshot: SnapshotUpdateState) => Promise<{
3
3
  pass: boolean;
4
4
  message: string;
5
5
  }>;
@@ -1 +1 @@
1
- {"version":3,"file":"image-snapshot.d.ts","sourceRoot":"","sources":["../src/image-snapshot.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,aAAa,EAAE,oBAAoB,EAAE,MAAM,aAAa,CAAC;AA0CvE,eAAO,MAAM,kBAAkB,GAC7B,YAAY,aAAa,EACzB,cAAc,MAAM,EACpB,SAAS,oBAAoB,EAC7B,cAAc,MAAM;;;EAsHrB,CAAC"}
1
+ {"version":3,"file":"image-snapshot.d.ts","sourceRoot":"","sources":["../src/image-snapshot.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EACV,aAAa,EACb,oBAAoB,EACpB,mBAAmB,EACpB,MAAM,aAAa,CAAC;AA2CrB,eAAO,MAAM,kBAAkB,GAC7B,YAAY,aAAa,EACzB,cAAc,MAAM,EACpB,SAAS,oBAAoB,EAC7B,cAAc,MAAM,EACpB,gBAAgB,mBAAmB;;;EAsIpC,CAAC"}
@@ -4,6 +4,7 @@ import fs from 'node:fs/promises';
4
4
  import path from 'node:path';
5
5
  import { PNG } from 'pngjs';
6
6
  const SNAPSHOT_DIR_NAME = '__image_snapshots__';
7
+ const DIFF_OUTPUT_DIR_NAME = '__diff_output__';
7
8
  const DEFAULT_OPTIONS_FOR_PIXELMATCH = {
8
9
  threshold: 0.1,
9
10
  includeAA: false,
@@ -31,7 +32,7 @@ function maskRegions(data, imageWidth, regions) {
31
32
  }
32
33
  }
33
34
  }
34
- export const matchImageSnapshot = async (screenshot, testFilePath, options, platformName) => {
35
+ export const matchImageSnapshot = async (screenshot, testFilePath, options, platformName, updateSnapshot) => {
35
36
  const pixelmatchOptions = {
36
37
  ...DEFAULT_OPTIONS_FOR_PIXELMATCH,
37
38
  ...options,
@@ -47,13 +48,25 @@ export const matchImageSnapshot = async (screenshot, testFilePath, options, plat
47
48
  // Create __image_snapshots__ directory in same directory as test file
48
49
  const testDir = path.dirname(testFilePath);
49
50
  const snapshotsDir = path.join(testDir, SNAPSHOT_DIR_NAME, platformName);
50
- const snapshotName = `${options.name}.png`;
51
- const snapshotPath = path.join(snapshotsDir, snapshotName);
51
+ const snapshotPath = path.join(snapshotsDir, `${options.name}.png`);
52
+ // Failure artifacts live in their own folder so they can never overwrite
53
+ // a baseline of another snapshot (e.g. one named `<name>-diff`).
54
+ const diffOutputDir = path.join(snapshotsDir, DIFF_OUTPUT_DIR_NAME);
55
+ const failureImagePath = (image) => path.join(diffOutputDir, `${options.name}-${image}.png`);
56
+ const actualPath = failureImagePath('actual');
52
57
  await fs.mkdir(snapshotsDir, { recursive: true });
53
58
  try {
54
59
  await fs.access(snapshotPath);
55
60
  }
56
61
  catch {
62
+ if (updateSnapshot === 'none') {
63
+ await fs.mkdir(diffOutputDir, { recursive: true });
64
+ await fs.writeFile(actualPath, receivedBuffer);
65
+ return {
66
+ pass: false,
67
+ message: `New snapshot was not written: ${snapshotPath} does not exist, and missing snapshots are not written in CI mode. Received image saved at ${actualPath}; commit it as ${snapshotPath}, or run without CI mode (--ci=false) on a machine with this device and commit the snapshot it writes.`,
68
+ };
69
+ }
57
70
  // First time - create snapshot
58
71
  await fs.writeFile(snapshotPath, receivedBuffer);
59
72
  return {
@@ -70,9 +83,11 @@ export const matchImageSnapshot = async (screenshot, testFilePath, options, plat
70
83
  const { width, height } = img1;
71
84
  const diff = new PNG({ width, height });
72
85
  if (img1.width !== img2.width || img1.height !== img2.height) {
86
+ await fs.mkdir(diffOutputDir, { recursive: true });
87
+ await fs.writeFile(actualPath, receivedBuffer);
73
88
  return {
74
89
  pass: false,
75
- message: `Images have different dimensions. Received image width: ${img1.width}, height: ${img1.height}. Snapshot image width: ${img2.width}, height: ${img2.height}.`,
90
+ message: `Images have different dimensions. Received image width: ${img1.width}, height: ${img1.height}. Snapshot image width: ${img2.width}, height: ${img2.height}. Actual image saved at ${actualPath}.`,
76
91
  };
77
92
  }
78
93
  if (options.ignoreRegions) {
@@ -116,11 +131,9 @@ export const matchImageSnapshot = async (screenshot, testFilePath, options, plat
116
131
  }
117
132
  // Save diff and actual images when test fails
118
133
  if (!pass) {
119
- const diffFileName = `${snapshotName.replace('.png', '')}-diff.png`;
120
- const diffPath = path.join(snapshotsDir, diffFileName);
134
+ await fs.mkdir(diffOutputDir, { recursive: true });
135
+ const diffPath = failureImagePath('diff');
121
136
  await fs.writeFile(diffPath, PNG.sync.write(diff));
122
- const actualFileName = `${snapshotName.replace('.png', '')}-actual.png`;
123
- const actualPath = path.join(snapshotsDir, actualFileName);
124
137
  await fs.writeFile(actualPath, receivedBuffer);
125
138
  message += ` Diff saved at ${diffPath}. Actual image saved at ${actualPath}.`;
126
139
  }
package/dist/index.d.ts CHANGED
@@ -1,4 +1,2 @@
1
1
  export * from './shared.js';
2
- export * from './binary-transfer.js';
3
- export * from './transport.js';
4
2
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
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"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,aAAa,CAAC"}
package/dist/index.js CHANGED
@@ -1,3 +1 @@
1
1
  export * from './shared.js';
2
- export * from './binary-transfer.js';
3
- export * from './transport.js';
@@ -0,0 +1,31 @@
1
+ import type { JsBlockBudget } from './js-watchdog.js';
2
+ import type { BridgeEvents } from './shared.js';
3
+ export type JsBlockBudgetPolicyOptions = {
4
+ /** Budget outside any test, hook or file load. */
5
+ idleMs: number;
6
+ /** Budget while a setup file or test file loads (module code and `describe` bodies). */
7
+ fileLoadMs: number;
8
+ /** Added to a test's or hook's own timeout. */
9
+ graceMs: number;
10
+ };
11
+ export declare const IDLE_BUDGET_LABEL = "between test phases";
12
+ export declare const getIdleBudget: (options: JsBlockBudgetPolicyOptions) => JsBlockBudget;
13
+ export type JsBlockBudgetPolicy = {
14
+ /**
15
+ * Returns the block budget that applies from this event on, or `null` for
16
+ * events that do not change it.
17
+ */
18
+ getBudgetForEvent: (event: BridgeEvents) => JsBlockBudget | null;
19
+ };
20
+ /**
21
+ * Maps runner events to block budgets. Budgets come from timeouts the user
22
+ * already configures, so long work within them never fails a run:
23
+ *
24
+ * - loading a setup file or test file: `fileLoadMs`
25
+ * - a test, including its `beforeEach`/`afterEach` hooks: its timeout + grace
26
+ * - a suite's `beforeAll`/`afterAll` hooks: the hook timeout + grace, from
27
+ * `suite-started` until `suite-finished`, outside the suite's tests
28
+ * - anything else: `idleMs`
29
+ */
30
+ export declare const createJsBlockBudgetPolicy: (options: JsBlockBudgetPolicyOptions) => JsBlockBudgetPolicy;
31
+ //# sourceMappingURL=js-block-budget-policy.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"js-block-budget-policy.d.ts","sourceRoot":"","sources":["../src/js-block-budget-policy.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAEhD,MAAM,MAAM,0BAA0B,GAAG;IACvC,kDAAkD;IAClD,MAAM,EAAE,MAAM,CAAC;IACf,wFAAwF;IACxF,UAAU,EAAE,MAAM,CAAC;IACnB,+CAA+C;IAC/C,OAAO,EAAE,MAAM,CAAC;CACjB,CAAC;AAEF,eAAO,MAAM,iBAAiB,wBAAwB,CAAC;AAEvD,eAAO,MAAM,aAAa,GACxB,SAAS,0BAA0B,KAClC,aAAmE,CAAC;AAEvE,MAAM,MAAM,mBAAmB,GAAG;IAChC;;;OAGG;IACH,iBAAiB,EAAE,CAAC,KAAK,EAAE,YAAY,KAAK,aAAa,GAAG,IAAI,CAAC;CAClE,CAAC;AAEF;;;;;;;;;GASG;AACH,eAAO,MAAM,yBAAyB,GACpC,SAAS,0BAA0B,KAClC,mBAmDF,CAAC"}