@react-native-harness/bridge 1.5.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 (104) 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/binary.d.ts +16 -0
  5. package/dist/binary.d.ts.map +1 -0
  6. package/dist/binary.js +47 -0
  7. package/dist/client.d.ts +6 -4
  8. package/dist/client.d.ts.map +1 -1
  9. package/dist/client.js +112 -105
  10. package/dist/connection-check.d.ts +22 -0
  11. package/dist/connection-check.d.ts.map +1 -0
  12. package/dist/connection-check.js +30 -0
  13. package/dist/errors.d.ts +6 -2
  14. package/dist/errors.d.ts.map +1 -1
  15. package/dist/errors.js +12 -4
  16. package/dist/image-snapshot.d.ts +2 -2
  17. package/dist/image-snapshot.d.ts.map +1 -1
  18. package/dist/image-snapshot.js +21 -8
  19. package/dist/index.d.ts +0 -2
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +0 -2
  22. package/dist/js-block-budget-policy.d.ts +31 -0
  23. package/dist/js-block-budget-policy.d.ts.map +1 -0
  24. package/dist/js-block-budget-policy.js +60 -0
  25. package/dist/js-watchdog.d.ts +31 -0
  26. package/dist/js-watchdog.d.ts.map +1 -0
  27. package/dist/js-watchdog.js +46 -0
  28. package/dist/link/channel.d.ts +45 -0
  29. package/dist/link/channel.d.ts.map +1 -0
  30. package/dist/link/channel.js +414 -0
  31. package/dist/link/envelope.d.ts +38 -0
  32. package/dist/link/envelope.d.ts.map +1 -0
  33. package/dist/link/envelope.js +90 -0
  34. package/dist/link/index.d.ts +22 -0
  35. package/dist/link/index.d.ts.map +1 -0
  36. package/dist/link/index.js +271 -0
  37. package/dist/link/ports.d.ts +31 -0
  38. package/dist/link/ports.d.ts.map +1 -0
  39. package/dist/link/ports.js +7 -0
  40. package/dist/link/scheduler.d.ts +21 -0
  41. package/dist/link/scheduler.d.ts.map +1 -0
  42. package/dist/link/scheduler.js +116 -0
  43. package/dist/link/standard-websocket.d.ts +36 -0
  44. package/dist/link/standard-websocket.d.ts.map +1 -0
  45. package/dist/link/standard-websocket.js +214 -0
  46. package/dist/link/streams.d.ts +4 -0
  47. package/dist/link/streams.d.ts.map +1 -0
  48. package/dist/link/streams.js +15 -0
  49. package/dist/protocol.d.ts +10 -5
  50. package/dist/protocol.d.ts.map +1 -1
  51. package/dist/protocol.js +2 -2
  52. package/dist/rpc-peer.d.ts +3 -1
  53. package/dist/rpc-peer.d.ts.map +1 -1
  54. package/dist/rpc-peer.js +2 -2
  55. package/dist/server.d.ts +4 -0
  56. package/dist/server.d.ts.map +1 -1
  57. package/dist/server.js +71 -56
  58. package/dist/shared/test-runner.d.ts +11 -0
  59. package/dist/shared/test-runner.d.ts.map +1 -1
  60. package/dist/shared.d.ts +5 -1
  61. package/dist/shared.d.ts.map +1 -1
  62. package/package.json +4 -3
  63. package/src/app-liveness.ts +103 -0
  64. package/src/binary.ts +74 -0
  65. package/src/client.ts +172 -170
  66. package/src/connection-check.ts +60 -0
  67. package/src/errors.ts +16 -5
  68. package/src/image-snapshot.ts +31 -9
  69. package/src/index.ts +0 -2
  70. package/src/js-block-budget-policy.ts +90 -0
  71. package/src/js-watchdog.ts +78 -0
  72. package/src/link/README.md +43 -0
  73. package/src/link/channel.ts +473 -0
  74. package/src/link/envelope.ts +110 -0
  75. package/src/link/index.ts +321 -0
  76. package/src/link/ports.ts +42 -0
  77. package/src/link/scheduler.ts +132 -0
  78. package/src/link/standard-websocket.ts +251 -0
  79. package/src/link/streams.ts +34 -0
  80. package/src/protocol.ts +14 -9
  81. package/src/rpc-peer.ts +6 -3
  82. package/src/server.ts +99 -67
  83. package/src/shared/test-runner.ts +12 -0
  84. package/src/shared.ts +6 -1
  85. package/dist/binary-transfer.d.ts +0 -17
  86. package/dist/binary-transfer.d.ts.map +0 -1
  87. package/dist/binary-transfer.js +0 -60
  88. package/dist/heartbeat.d.ts +0 -13
  89. package/dist/heartbeat.d.ts.map +0 -1
  90. package/dist/heartbeat.js +0 -49
  91. package/dist/transport.d.ts +0 -22
  92. package/dist/transport.d.ts.map +0 -1
  93. package/dist/transport.js +0 -22
  94. package/dist/websocket-client-transport.d.ts +0 -3
  95. package/dist/websocket-client-transport.d.ts.map +0 -1
  96. package/dist/websocket-client-transport.js +0 -45
  97. package/dist/websocket-server-transport.d.ts +0 -4
  98. package/dist/websocket-server-transport.d.ts.map +0 -1
  99. package/dist/websocket-server-transport.js +0 -43
  100. package/src/binary-transfer.ts +0 -79
  101. package/src/heartbeat.ts +0 -67
  102. package/src/transport.ts +0 -47
  103. package/src/websocket-client-transport.ts +0 -85
  104. package/src/websocket-server-transport.ts +0 -54
@@ -3,11 +3,16 @@ import { ssim } from 'ssim.js';
3
3
  import fs from 'node:fs/promises';
4
4
  import path from 'node:path';
5
5
  import { PNG } from 'pngjs';
6
- import type { FileReference, ImageSnapshotOptions } from './shared.js';
6
+ import type {
7
+ FileReference,
8
+ ImageSnapshotOptions,
9
+ SnapshotUpdateState,
10
+ } from './shared.js';
7
11
 
8
12
  type PixelmatchOptions = Parameters<typeof pixelmatch>[5];
9
13
 
10
14
  const SNAPSHOT_DIR_NAME = '__image_snapshots__';
15
+ const DIFF_OUTPUT_DIR_NAME = '__diff_output__';
11
16
  const DEFAULT_OPTIONS_FOR_PIXELMATCH: PixelmatchOptions = {
12
17
  threshold: 0.1,
13
18
  includeAA: false,
@@ -49,7 +54,8 @@ export const matchImageSnapshot = async (
49
54
  screenshot: FileReference,
50
55
  testFilePath: string,
51
56
  options: ImageSnapshotOptions,
52
- platformName: string
57
+ platformName: string,
58
+ updateSnapshot: SnapshotUpdateState
53
59
  ) => {
54
60
  const pixelmatchOptions = {
55
61
  ...DEFAULT_OPTIONS_FOR_PIXELMATCH,
@@ -69,14 +75,28 @@ export const matchImageSnapshot = async (
69
75
  const testDir = path.dirname(testFilePath);
70
76
  const snapshotsDir = path.join(testDir, SNAPSHOT_DIR_NAME, platformName);
71
77
 
72
- const snapshotName = `${options.name}.png`;
73
- const snapshotPath = path.join(snapshotsDir, snapshotName);
78
+ const snapshotPath = path.join(snapshotsDir, `${options.name}.png`);
79
+ // Failure artifacts live in their own folder so they can never overwrite
80
+ // a baseline of another snapshot (e.g. one named `<name>-diff`).
81
+ const diffOutputDir = path.join(snapshotsDir, DIFF_OUTPUT_DIR_NAME);
82
+ const failureImagePath = (image: 'actual' | 'diff'): string =>
83
+ path.join(diffOutputDir, `${options.name}-${image}.png`);
84
+ const actualPath = failureImagePath('actual');
74
85
 
75
86
  await fs.mkdir(snapshotsDir, { recursive: true });
76
87
 
77
88
  try {
78
89
  await fs.access(snapshotPath);
79
90
  } catch {
91
+ if (updateSnapshot === 'none') {
92
+ await fs.mkdir(diffOutputDir, { recursive: true });
93
+ await fs.writeFile(actualPath, receivedBuffer);
94
+ return {
95
+ pass: false,
96
+ 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.`,
97
+ };
98
+ }
99
+
80
100
  // First time - create snapshot
81
101
  await fs.writeFile(snapshotPath, receivedBuffer);
82
102
  return {
@@ -95,9 +115,12 @@ export const matchImageSnapshot = async (
95
115
  const diff = new PNG({ width, height });
96
116
 
97
117
  if (img1.width !== img2.width || img1.height !== img2.height) {
118
+ await fs.mkdir(diffOutputDir, { recursive: true });
119
+ await fs.writeFile(actualPath, receivedBuffer);
120
+
98
121
  return {
99
122
  pass: false,
100
- message: `Images have different dimensions. Received image width: ${img1.width}, height: ${img1.height}. Snapshot image width: ${img2.width}, height: ${img2.height}.`,
123
+ 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}.`,
101
124
  };
102
125
  }
103
126
 
@@ -152,12 +175,11 @@ export const matchImageSnapshot = async (
152
175
 
153
176
  // Save diff and actual images when test fails
154
177
  if (!pass) {
155
- const diffFileName = `${snapshotName.replace('.png', '')}-diff.png`;
156
- const diffPath = path.join(snapshotsDir, diffFileName);
178
+ await fs.mkdir(diffOutputDir, { recursive: true });
179
+
180
+ const diffPath = failureImagePath('diff');
157
181
  await fs.writeFile(diffPath, PNG.sync.write(diff));
158
182
 
159
- const actualFileName = `${snapshotName.replace('.png', '')}-actual.png`;
160
- const actualPath = path.join(snapshotsDir, actualFileName);
161
183
  await fs.writeFile(actualPath, receivedBuffer);
162
184
 
163
185
  message += ` Diff saved at ${diffPath}. Actual image saved at ${actualPath}.`;
package/src/index.ts CHANGED
@@ -1,3 +1 @@
1
1
  export * from './shared.js';
2
- export * from './binary-transfer.js';
3
- export * from './transport.js';
@@ -0,0 +1,90 @@
1
+ import type { JsBlockBudget } from './js-watchdog.js';
2
+ import type { BridgeEvents } from './shared.js';
3
+
4
+ export type JsBlockBudgetPolicyOptions = {
5
+ /** Budget outside any test, hook or file load. */
6
+ idleMs: number;
7
+ /** Budget while a setup file or test file loads (module code and `describe` bodies). */
8
+ fileLoadMs: number;
9
+ /** Added to a test's or hook's own timeout. */
10
+ graceMs: number;
11
+ };
12
+
13
+ export const IDLE_BUDGET_LABEL = 'between test phases';
14
+
15
+ export const getIdleBudget = (
16
+ options: JsBlockBudgetPolicyOptions,
17
+ ): JsBlockBudget => ({ ms: options.idleMs, label: IDLE_BUDGET_LABEL });
18
+
19
+ export type JsBlockBudgetPolicy = {
20
+ /**
21
+ * Returns the block budget that applies from this event on, or `null` for
22
+ * events that do not change it.
23
+ */
24
+ getBudgetForEvent: (event: BridgeEvents) => JsBlockBudget | null;
25
+ };
26
+
27
+ /**
28
+ * Maps runner events to block budgets. Budgets come from timeouts the user
29
+ * already configures, so long work within them never fails a run:
30
+ *
31
+ * - loading a setup file or test file: `fileLoadMs`
32
+ * - a test, including its `beforeEach`/`afterEach` hooks: its timeout + grace
33
+ * - a suite's `beforeAll`/`afterAll` hooks: the hook timeout + grace, from
34
+ * `suite-started` until `suite-finished`, outside the suite's tests
35
+ * - anything else: `idleMs`
36
+ */
37
+ export const createJsBlockBudgetPolicy = (
38
+ options: JsBlockBudgetPolicyOptions,
39
+ ): JsBlockBudgetPolicy => {
40
+ const idle = getIdleBudget(options);
41
+ // Hook budgets of the suites currently running, innermost last.
42
+ const suites: JsBlockBudget[] = [];
43
+ const outsideTests = () => suites[suites.length - 1] ?? idle;
44
+
45
+ return {
46
+ getBudgetForEvent: (event) => {
47
+ switch (event.type) {
48
+ case 'setup-file-bundling-finished':
49
+ return {
50
+ ms: options.fileLoadMs,
51
+ label: `loading setup file ${event.file}`,
52
+ };
53
+ case 'collection-started':
54
+ return { ms: options.fileLoadMs, label: `loading ${event.file}` };
55
+ case 'setup-file-bundling-failed':
56
+ case 'collection-finished':
57
+ case 'file-started':
58
+ case 'file-finished':
59
+ suites.length = 0;
60
+ return idle;
61
+ case 'suite-started':
62
+ suites.push(
63
+ event.timeout === undefined
64
+ ? outsideTests()
65
+ : {
66
+ ms: event.timeout + options.graceMs,
67
+ label: `beforeAll/afterAll hooks of suite "${event.name}" (${event.timeout}ms timeout + ${options.graceMs}ms grace)`,
68
+ },
69
+ );
70
+ return outsideTests();
71
+ case 'suite-finished':
72
+ suites.pop();
73
+ return outsideTests();
74
+ case 'test-started':
75
+ if (event.timeout === undefined) {
76
+ return null;
77
+ }
78
+
79
+ return {
80
+ ms: event.timeout + options.graceMs,
81
+ label: `test "${event.fullName}" (${event.timeout}ms timeout + ${options.graceMs}ms grace)`,
82
+ };
83
+ case 'test-finished':
84
+ return outsideTests();
85
+ default:
86
+ return null;
87
+ }
88
+ },
89
+ };
90
+ };
@@ -0,0 +1,78 @@
1
+ /**
2
+ * The longest the app's JS thread may go without answering a `js-probe`, and
3
+ * a label naming what it is busy with, used in the failure message.
4
+ */
5
+ export type JsBlockBudget = {
6
+ ms: number;
7
+ label: string;
8
+ };
9
+
10
+ export type JsWatchdog = {
11
+ notifyProbeAck: (id: number) => void;
12
+ /**
13
+ * Replaces the current budget. The new budget covers silence from the
14
+ * later of the last acknowledged probe and the change itself: a block that
15
+ * fitted the previous budget is never judged against a smaller one.
16
+ */
17
+ setBlockBudget: (budget: JsBlockBudget) => void;
18
+ dispose: () => void;
19
+ };
20
+
21
+ export const DEFAULT_JS_PROBE_INTERVAL_MS = 1_000;
22
+
23
+ /**
24
+ * Checks that the app's JS thread keeps getting a turn to run. The server
25
+ * sends `js-probe` messages, which only JS can answer, and fails once the
26
+ * thread has been silent for longer than the current block budget.
27
+ */
28
+ export const createJsWatchdog = (options: {
29
+ sendProbe: (id: number) => void;
30
+ onUnresponsive: (budget: JsBlockBudget) => void;
31
+ initialBudget: JsBlockBudget;
32
+ probeIntervalMs?: number;
33
+ }): JsWatchdog => {
34
+ let budget = options.initialBudget;
35
+ let lastAckAt = Date.now();
36
+ let budgetSetAt = lastAckAt;
37
+ let nextProbeId = 1;
38
+ let pendingProbeId: number | null = null;
39
+ let disposed = false;
40
+
41
+ const dispose = () => {
42
+ if (disposed) {
43
+ return;
44
+ }
45
+
46
+ disposed = true;
47
+ clearInterval(interval);
48
+ };
49
+
50
+ const interval = setInterval(() => {
51
+ if (Date.now() - Math.max(lastAckAt, budgetSetAt) > budget.ms) {
52
+ dispose();
53
+ options.onUnresponsive(budget);
54
+ return;
55
+ }
56
+
57
+ if (pendingProbeId === null) {
58
+ pendingProbeId = nextProbeId++;
59
+ options.sendProbe(pendingProbeId);
60
+ }
61
+ }, options.probeIntervalMs ?? DEFAULT_JS_PROBE_INTERVAL_MS);
62
+
63
+ return {
64
+ notifyProbeAck: (id) => {
65
+ if (id !== pendingProbeId) {
66
+ return;
67
+ }
68
+
69
+ pendingProbeId = null;
70
+ lastAckAt = Date.now();
71
+ },
72
+ setBlockBudget: (next) => {
73
+ budget = next;
74
+ budgetSetAt = Date.now();
75
+ },
76
+ dispose,
77
+ };
78
+ };
@@ -0,0 +1,43 @@
1
+ # Link
2
+
3
+ A copy of the link from [simlock](https://github.com/callstackincubator/simlock)
4
+ (`src/link/`, commit `b730ef37536b51ebdb37d076e7592954c32d0904`), plus the
5
+ standard `WebSocket` transport from simlock's browser link check.
6
+
7
+ The link wraps one WebSocket and carries two kinds of traffic:
8
+
9
+ - **Text messages** (`write` / `onData`): the bridge's JSON RPC, events, and
10
+ heartbeat ping/pong. They go in the priority lane, so they are never stuck
11
+ behind a large transfer.
12
+ - **Byte channels** (`openChannel` / `acceptChannel`): raw bytes as
13
+ `ReadableStream` / `WritableStream`, in 64 KiB frames with per-channel credit.
14
+ The bridge uses them for screenshots (`../binary.ts`).
15
+
16
+ See simlock's `src/link/AGENTS.md` for the wire format, limits and rules.
17
+
18
+ ## Harness changes
19
+
20
+ Keep the files close to upstream so they are easy to sync. Changes are marked
21
+ with `harness:` comments:
22
+
23
+ - `ports.ts` holds the port types the link imports from simlock's `src/ports`,
24
+ plus `globalClock`.
25
+ - `streams.ts` uses the global WHATWG streams when present and the
26
+ `web-streams-polyfill` ponyfill otherwise (React Native). It never installs
27
+ globals.
28
+ - `envelope.ts` encodes channel ids as ASCII instead of using
29
+ `TextEncoder`/`TextDecoder`.
30
+ - `standard-websocket.ts` treats a missing `bufferedAmount` as `0`, since React
31
+ Native's `WebSocket` never sets it.
32
+ - `__tests__/` holds upstream's link tests. The in-memory pair and `FakeClock`
33
+ come from simlock's ports, and the WebSocket pair uses `ws` with
34
+ `StandardWebSocketConnection` on both ends.
35
+
36
+ ## React Native notes
37
+
38
+ - React Native's `WebSocket` base64-encodes binary frames on the way to the
39
+ native module. The protocol is binary, but stock React Native still pays for
40
+ that encoding inside the socket.
41
+ - With no `bufferedAmount`, the link cannot see the socket's send buffer, so
42
+ only per-channel credit (4 MiB) limits how much is in flight.
43
+ - There is no `crypto.randomUUID`, so the client passes `generateId`.