@noego/wood 0.4.6 → 0.4.7

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 (241) hide show
  1. package/bin/wood.js +6 -1
  2. package/dist/client/app_env.cjs +39 -0
  3. package/dist/client/app_env.cjs.map +1 -0
  4. package/dist/client/app_env.d.cts +7 -0
  5. package/dist/client/app_env.d.ts +7 -0
  6. package/dist/client/app_env.js +14 -0
  7. package/dist/client/app_env.js.map +1 -0
  8. package/dist/client/index.cjs +33 -9
  9. package/dist/client/index.cjs.map +1 -1
  10. package/dist/client/index.d.cts +5 -1
  11. package/dist/client/index.d.ts +5 -1
  12. package/dist/client/index.js +32 -9
  13. package/dist/client/index.js.map +1 -1
  14. package/dist/codegen/channel_map_generator.cjs +102 -0
  15. package/dist/codegen/channel_map_generator.cjs.map +1 -0
  16. package/dist/codegen/channel_map_generator.d.cts +6 -0
  17. package/dist/codegen/channel_map_generator.d.ts +6 -0
  18. package/dist/codegen/channel_map_generator.js +78 -0
  19. package/dist/codegen/channel_map_generator.js.map +1 -0
  20. package/dist/codegen/controller_registration_generator.cjs +6 -1
  21. package/dist/codegen/controller_registration_generator.cjs.map +1 -1
  22. package/dist/codegen/controller_registration_generator.js +6 -1
  23. package/dist/codegen/controller_registration_generator.js.map +1 -1
  24. package/dist/codegen/generated_file_notice.cjs +40 -0
  25. package/dist/codegen/generated_file_notice.cjs.map +1 -0
  26. package/dist/codegen/generated_file_notice.d.cts +3 -0
  27. package/dist/codegen/generated_file_notice.d.ts +3 -0
  28. package/dist/codegen/generated_file_notice.js +16 -0
  29. package/dist/codegen/generated_file_notice.js.map +1 -0
  30. package/dist/codegen/index.cjs +3 -0
  31. package/dist/codegen/index.cjs.map +1 -1
  32. package/dist/codegen/index.d.cts +1 -0
  33. package/dist/codegen/index.d.ts +1 -0
  34. package/dist/codegen/index.js +2 -0
  35. package/dist/codegen/index.js.map +1 -1
  36. package/dist/codegen/preload_generator.cjs +97 -1
  37. package/dist/codegen/preload_generator.cjs.map +1 -1
  38. package/dist/codegen/preload_generator.js +97 -1
  39. package/dist/codegen/preload_generator.js.map +1 -1
  40. package/dist/codegen/renderer_app_generator.cjs +6 -1
  41. package/dist/codegen/renderer_app_generator.cjs.map +1 -1
  42. package/dist/codegen/renderer_app_generator.js +6 -1
  43. package/dist/codegen/renderer_app_generator.js.map +1 -1
  44. package/dist/codegen/renderer_controller_registration_generator.cjs +6 -1
  45. package/dist/codegen/renderer_controller_registration_generator.cjs.map +1 -1
  46. package/dist/codegen/renderer_controller_registration_generator.js +6 -1
  47. package/dist/codegen/renderer_controller_registration_generator.js.map +1 -1
  48. package/dist/codegen/renderer_view_manifest_generator.cjs +6 -1
  49. package/dist/codegen/renderer_view_manifest_generator.cjs.map +1 -1
  50. package/dist/codegen/renderer_view_manifest_generator.js +6 -1
  51. package/dist/codegen/renderer_view_manifest_generator.js.map +1 -1
  52. package/dist/codegen/runtime_generator.cjs +6 -1
  53. package/dist/codegen/runtime_generator.cjs.map +1 -1
  54. package/dist/codegen/runtime_generator.js +6 -1
  55. package/dist/codegen/runtime_generator.js.map +1 -1
  56. package/dist/codegen/test_mock_generator.cjs +5 -1
  57. package/dist/codegen/test_mock_generator.cjs.map +1 -1
  58. package/dist/codegen/test_mock_generator.js +5 -1
  59. package/dist/codegen/test_mock_generator.js.map +1 -1
  60. package/dist/codegen/type_generator.cjs +6 -1
  61. package/dist/codegen/type_generator.cjs.map +1 -1
  62. package/dist/codegen/type_generator.js +6 -1
  63. package/dist/codegen/type_generator.js.map +1 -1
  64. package/dist/index.cjs +19 -6
  65. package/dist/index.cjs.map +1 -1
  66. package/dist/index.d.cts +7 -1
  67. package/dist/index.d.ts +7 -1
  68. package/dist/index.js +13 -1
  69. package/dist/index.js.map +1 -1
  70. package/dist/main/index.cjs +133 -55
  71. package/dist/main/index.cjs.map +1 -1
  72. package/dist/main/index.d.cts +30 -2
  73. package/dist/main/index.d.ts +30 -2
  74. package/dist/main/index.js +134 -55
  75. package/dist/main/index.js.map +1 -1
  76. package/dist/main/renderer_trace_drain_coordinator.cjs +127 -0
  77. package/dist/main/renderer_trace_drain_coordinator.cjs.map +1 -0
  78. package/dist/main/renderer_trace_drain_coordinator.d.cts +46 -0
  79. package/dist/main/renderer_trace_drain_coordinator.d.ts +46 -0
  80. package/dist/main/renderer_trace_drain_coordinator.js +101 -0
  81. package/dist/main/renderer_trace_drain_coordinator.js.map +1 -0
  82. package/dist/router/wood_router.cjs +9 -1
  83. package/dist/router/wood_router.cjs.map +1 -1
  84. package/dist/router/wood_router.d.cts +1 -1
  85. package/dist/router/wood_router.d.ts +1 -1
  86. package/dist/router/wood_router.js +9 -1
  87. package/dist/router/wood_router.js.map +1 -1
  88. package/dist/runtime/app_env.cjs +52 -0
  89. package/dist/runtime/app_env.cjs.map +1 -0
  90. package/dist/runtime/app_env.d.cts +10 -0
  91. package/dist/runtime/app_env.d.ts +10 -0
  92. package/dist/runtime/app_env.js +18 -0
  93. package/dist/runtime/app_env.js.map +1 -0
  94. package/dist/runtime/index.cjs +9 -2
  95. package/dist/runtime/index.cjs.map +1 -1
  96. package/dist/runtime/index.d.cts +5 -1
  97. package/dist/runtime/index.d.ts +5 -1
  98. package/dist/runtime/index.js +9 -1
  99. package/dist/runtime/index.js.map +1 -1
  100. package/dist/runtime/renderer_log_dispatcher.cjs.map +1 -1
  101. package/dist/runtime/renderer_log_dispatcher.d.cts +1 -1
  102. package/dist/runtime/renderer_log_dispatcher.d.ts +1 -1
  103. package/dist/runtime/renderer_log_dispatcher.js.map +1 -1
  104. package/dist/runtime/renderer_trace_batch_receiver_registry.cjs +50 -0
  105. package/dist/runtime/renderer_trace_batch_receiver_registry.cjs.map +1 -0
  106. package/dist/runtime/renderer_trace_batch_receiver_registry.d.cts +10 -0
  107. package/dist/runtime/renderer_trace_batch_receiver_registry.d.ts +10 -0
  108. package/dist/runtime/renderer_trace_batch_receiver_registry.js +24 -0
  109. package/dist/runtime/renderer_trace_batch_receiver_registry.js.map +1 -0
  110. package/dist/runtime/runtime.cjs +75 -57
  111. package/dist/runtime/runtime.cjs.map +1 -1
  112. package/dist/runtime/runtime.d.cts +8 -1
  113. package/dist/runtime/runtime.d.ts +8 -1
  114. package/dist/runtime/runtime.js +80 -58
  115. package/dist/runtime/runtime.js.map +1 -1
  116. package/dist/testing/browser.cjs +540 -11
  117. package/dist/testing/browser.cjs.map +1 -1
  118. package/dist/testing/browser.d.cts +219 -3
  119. package/dist/testing/browser.d.ts +219 -3
  120. package/dist/testing/browser.js +540 -11
  121. package/dist/testing/browser.js.map +1 -1
  122. package/dist/testing/create_controller_harness.d.cts +1 -1
  123. package/dist/testing/create_controller_harness.d.ts +1 -1
  124. package/dist/testing/index.cjs +13 -0
  125. package/dist/testing/index.cjs.map +1 -1
  126. package/dist/testing/index.d.cts +7 -2
  127. package/dist/testing/index.d.ts +7 -2
  128. package/dist/testing/index.js +14 -0
  129. package/dist/testing/index.js.map +1 -1
  130. package/dist/trace-testing/index.d.cts +1 -1
  131. package/dist/trace-testing/index.d.ts +1 -1
  132. package/dist/trace-testing/trace_recorder.d.cts +1 -1
  133. package/dist/trace-testing/trace_recorder.d.ts +1 -1
  134. package/dist/trace-transport/index.cjs +32 -0
  135. package/dist/trace-transport/index.cjs.map +1 -0
  136. package/dist/trace-transport/index.d.cts +6 -0
  137. package/dist/trace-transport/index.d.ts +6 -0
  138. package/dist/trace-transport/index.js +7 -0
  139. package/dist/trace-transport/index.js.map +1 -0
  140. package/dist/trace_hub-Cob54qRx.d.cts +56 -0
  141. package/dist/trace_hub-Cob54qRx.d.ts +56 -0
  142. package/dist/tracing/index.cjs +14 -0
  143. package/dist/tracing/index.cjs.map +1 -1
  144. package/dist/tracing/index.d.cts +7 -1
  145. package/dist/tracing/index.d.ts +7 -1
  146. package/dist/tracing/index.js +12 -0
  147. package/dist/tracing/index.js.map +1 -1
  148. package/dist/tracing/renderer_trace_batch_host.cjs +80 -0
  149. package/dist/tracing/renderer_trace_batch_host.cjs.map +1 -0
  150. package/dist/tracing/renderer_trace_batch_host.d.cts +16 -0
  151. package/dist/tracing/renderer_trace_batch_host.d.ts +16 -0
  152. package/dist/tracing/renderer_trace_batch_host.js +56 -0
  153. package/dist/tracing/renderer_trace_batch_host.js.map +1 -0
  154. package/dist/tracing/renderer_trace_batch_transport.cjs +313 -0
  155. package/dist/tracing/renderer_trace_batch_transport.cjs.map +1 -0
  156. package/dist/tracing/renderer_trace_batch_transport.d.cts +53 -0
  157. package/dist/tracing/renderer_trace_batch_transport.d.ts +53 -0
  158. package/dist/tracing/renderer_trace_batch_transport.js +292 -0
  159. package/dist/tracing/renderer_trace_batch_transport.js.map +1 -0
  160. package/dist/tracing/trace_commit_writer.cjs +159 -0
  161. package/dist/tracing/trace_commit_writer.cjs.map +1 -0
  162. package/dist/tracing/trace_commit_writer.d.cts +97 -0
  163. package/dist/tracing/trace_commit_writer.d.ts +97 -0
  164. package/dist/tracing/trace_commit_writer.js +135 -0
  165. package/dist/tracing/trace_commit_writer.js.map +1 -0
  166. package/dist/tracing/trace_hub.cjs +58 -3
  167. package/dist/tracing/trace_hub.cjs.map +1 -1
  168. package/dist/tracing/trace_hub.d.cts +2 -46
  169. package/dist/tracing/trace_hub.d.ts +2 -46
  170. package/dist/tracing/trace_hub.js +56 -3
  171. package/dist/tracing/trace_hub.js.map +1 -1
  172. package/dist/tracing/trace_provider.cjs +42 -12
  173. package/dist/tracing/trace_provider.cjs.map +1 -1
  174. package/dist/tracing/trace_provider.d.cts +15 -1
  175. package/dist/tracing/trace_provider.d.ts +15 -1
  176. package/dist/tracing/trace_provider.js +43 -12
  177. package/dist/tracing/trace_provider.js.map +1 -1
  178. package/dist/tracing/types/wood_trace_batch.types.cjs +17 -0
  179. package/dist/tracing/types/wood_trace_batch.types.cjs.map +1 -0
  180. package/dist/tracing/types/wood_trace_batch.types.d.cts +124 -0
  181. package/dist/tracing/types/wood_trace_batch.types.d.ts +124 -0
  182. package/dist/tracing/types/wood_trace_batch.types.js +1 -0
  183. package/dist/tracing/types/wood_trace_batch.types.js.map +1 -0
  184. package/dist/tracing/types/wood_trace_sink.types.cjs +17 -0
  185. package/dist/tracing/types/wood_trace_sink.types.cjs.map +1 -0
  186. package/dist/tracing/types/wood_trace_sink.types.d.cts +2 -0
  187. package/dist/tracing/types/wood_trace_sink.types.d.ts +2 -0
  188. package/dist/tracing/types/wood_trace_sink.types.js +1 -0
  189. package/dist/tracing/types/wood_trace_sink.types.js.map +1 -0
  190. package/dist/tracing/wood_trace_batch_contract.cjs +229 -0
  191. package/dist/tracing/wood_trace_batch_contract.cjs.map +1 -0
  192. package/dist/tracing/wood_trace_batch_contract.d.cts +23 -0
  193. package/dist/tracing/wood_trace_batch_contract.d.ts +23 -0
  194. package/dist/tracing/wood_trace_batch_contract.js +205 -0
  195. package/dist/tracing/wood_trace_batch_contract.js.map +1 -0
  196. package/dist/transport/build_transport_bridge.cjs +62 -0
  197. package/dist/transport/build_transport_bridge.cjs.map +1 -0
  198. package/dist/transport/build_transport_bridge.d.cts +29 -0
  199. package/dist/transport/build_transport_bridge.d.ts +29 -0
  200. package/dist/transport/build_transport_bridge.js +38 -0
  201. package/dist/transport/build_transport_bridge.js.map +1 -0
  202. package/dist/transport/in_process_router_transport.cjs +41 -0
  203. package/dist/transport/in_process_router_transport.cjs.map +1 -0
  204. package/dist/transport/in_process_router_transport.d.cts +31 -0
  205. package/dist/transport/in_process_router_transport.d.ts +31 -0
  206. package/dist/transport/in_process_router_transport.js +17 -0
  207. package/dist/transport/in_process_router_transport.js.map +1 -0
  208. package/dist/transport/index.cjs +42 -0
  209. package/dist/transport/index.cjs.map +1 -0
  210. package/dist/transport/index.d.cts +8 -0
  211. package/dist/transport/index.d.ts +8 -0
  212. package/dist/transport/index.js +19 -0
  213. package/dist/transport/index.js.map +1 -0
  214. package/dist/transport/loopback_remote_transport.cjs +270 -0
  215. package/dist/transport/loopback_remote_transport.cjs.map +1 -0
  216. package/dist/transport/loopback_remote_transport.d.cts +49 -0
  217. package/dist/transport/loopback_remote_transport.d.ts +49 -0
  218. package/dist/transport/loopback_remote_transport.js +237 -0
  219. package/dist/transport/loopback_remote_transport.js.map +1 -0
  220. package/dist/transport/loopback_serialization.cjs +116 -0
  221. package/dist/transport/loopback_serialization.cjs.map +1 -0
  222. package/dist/transport/loopback_serialization.d.cts +28 -0
  223. package/dist/transport/loopback_serialization.d.ts +28 -0
  224. package/dist/transport/loopback_serialization.js +91 -0
  225. package/dist/transport/loopback_serialization.js.map +1 -0
  226. package/dist/transport/transport_contract_suite.cjs +110 -0
  227. package/dist/transport/transport_contract_suite.cjs.map +1 -0
  228. package/dist/transport/transport_contract_suite.d.cts +23 -0
  229. package/dist/transport/transport_contract_suite.d.ts +23 -0
  230. package/dist/transport/transport_contract_suite.js +85 -0
  231. package/dist/transport/transport_contract_suite.js.map +1 -0
  232. package/dist/transport/wood_rpc_transport.cjs +17 -0
  233. package/dist/transport/wood_rpc_transport.cjs.map +1 -0
  234. package/dist/transport/wood_rpc_transport.d.cts +44 -0
  235. package/dist/transport/wood_rpc_transport.d.ts +44 -0
  236. package/dist/transport/wood_rpc_transport.js +1 -0
  237. package/dist/transport/wood_rpc_transport.js.map +1 -0
  238. package/docs/browser-testing.md +139 -1
  239. package/package.json +22 -3
  240. package/src/components/NavigationShell.browser.test.ts +236 -0
  241. package/src/components/NavigationShell.svelte +162 -49
@@ -45,7 +45,9 @@ function mergeBridge(base, override) {
45
45
  ...base?.events ?? {},
46
46
  ...override?.events ?? {}
47
47
  },
48
- record: override?.record ?? base?.record
48
+ record: override?.record ?? base?.record,
49
+ ...override?.transport ?? base?.transport ? { transport: override?.transport ?? base?.transport } : {},
50
+ ...override?.rpcManifest ?? base?.rpcManifest ? { rpcManifest: override?.rpcManifest ?? base?.rpcManifest } : {}
49
51
  };
50
52
  }
51
53
  async function waitForBoundedClose(promise, timeoutMs = DEFAULT_RESOURCE_CLOSE_TIMEOUT_MS) {
@@ -86,7 +88,9 @@ function createBridgeState(input) {
86
88
  initialEvents,
87
89
  calls: [],
88
90
  emittedEvents: [],
89
- record: input?.record ?? true
91
+ record: input?.record ?? true,
92
+ ...input?.transport ? { transport: input.transport } : {},
93
+ ...input?.rpcManifest ? { rpcManifest: input.rpcManifest } : {}
90
94
  };
91
95
  }
92
96
  function createBrowserBridgeFixture(input) {
@@ -94,20 +98,29 @@ function createBrowserBridgeFixture(input) {
94
98
  }
95
99
  function createBrowserBridgeFixtureWithTrace(input, traceConfig) {
96
100
  const state = createBridgeState(input);
97
- const manifest = Array.from(state.operations.entries()).map(([channel, handler]) => channelToManifestEntry(channel, handler));
101
+ const manifest = state.rpcManifest ?? Array.from(state.operations.entries()).map(([channel, handler]) => channelToManifestEntry(channel, handler));
98
102
  return {
99
103
  state,
100
104
  manifest,
101
- bridgeInit: buildBridgeInitScript(manifest, traceConfig)
105
+ bridgeInit: buildBridgeInitScript(manifest, traceConfig, Boolean(state.transport))
102
106
  };
103
107
  }
104
- function buildBridgeInitScript(manifest, traceConfig) {
108
+ function buildBridgeInitScript(manifest, traceConfig, transportMode) {
105
109
  const traceEnabled = traceConfig?.enabled === true;
106
110
  const traceMinLevel = traceConfig?.minLevel ?? "debug";
107
111
  return `
108
112
  (() => {
109
113
  const manifest = ${JSON.stringify(manifest)};
114
+ const transportMode = ${JSON.stringify(transportMode)};
110
115
  const listeners = new Map();
116
+ window.__woodBrowserTracePending = new Set();
117
+ window.__woodBrowserTraceFlush = async () => {
118
+ // Drain in-flight __trace exposeBinding round-trips so the test-process
119
+ // renderer-trace ack loop can barrier on a definite quiescent point.
120
+ while (window.__woodBrowserTracePending.size > 0) {
121
+ await Promise.allSettled(Array.from(window.__woodBrowserTracePending));
122
+ }
123
+ };
111
124
  const bridge = {
112
125
  __rpcManifest: manifest,
113
126
  __woodConfig: async () => ({
@@ -115,7 +128,14 @@ function buildBridgeInitScript(manifest, traceConfig) {
115
128
  rendererTraceMinLevel: ${JSON.stringify(traceMinLevel)}
116
129
  }),
117
130
  __log: () => {},
118
- __trace: (payload) => window.__woodBrowserTrace?.(payload),
131
+ __trace: (payload) => {
132
+ const result = window.__woodBrowserTrace?.(payload);
133
+ if (result && typeof result.then === 'function') {
134
+ window.__woodBrowserTracePending.add(result);
135
+ result.finally(() => window.__woodBrowserTracePending.delete(result));
136
+ }
137
+ return result;
138
+ },
119
139
  __window: {
120
140
  current: async () => ({ ok: true, windowId: 'wood-browser-harness', defaultRoute: window.__WOOD_BROWSER_DEFAULT_ROUTE__ }),
121
141
  open: async () => ({}),
@@ -125,8 +145,13 @@ function buildBridgeInitScript(manifest, traceConfig) {
125
145
  maximize: async () => ({}),
126
146
  },
127
147
  on(channel, callback) {
128
- if (!listeners.has(channel)) listeners.set(channel, new Set());
148
+ const first = !listeners.has(channel);
149
+ if (first) listeners.set(channel, new Set());
129
150
  listeners.get(channel).add(callback);
151
+ // In real-backend mode, relay the first subscription per channel to the
152
+ // test process so it can subscribe on the WoodRpcTransport and forward
153
+ // main-process push events back into __woodBrowserEmit.
154
+ if (transportMode && first) window.__woodBrowserSubscribe?.(channel);
130
155
  return () => listeners.get(channel)?.delete(callback);
131
156
  },
132
157
  };
@@ -177,6 +202,11 @@ function resolvePackageModule(rootDir, source) {
177
202
  }
178
203
  }
179
204
  }
205
+ function resolveEsmModule(rootDir, source) {
206
+ const resolved = resolvePackageModule(rootDir, source);
207
+ const esm = resolved?.endsWith(".cjs") ? resolved.replace(/\.cjs$/, ".js") : resolved;
208
+ return esm && fs.existsSync(esm) ? viteImport(esm) : source;
209
+ }
180
210
  function resolvePackageDir(rootDir, packageName) {
181
211
  const packageJson = resolvePackageModule(rootDir, `${packageName}/package.json`);
182
212
  return packageJson ? path.dirname(packageJson) : null;
@@ -487,9 +517,22 @@ window.__WOOD_BROWSER_APP__ = mount(NavigationShell, {
487
517
  `;
488
518
  }
489
519
  function buildGeneratedAppEntrySource(input) {
520
+ const hasRouteContext = Object.keys(input.route.params).length > 0 || Object.keys(input.route.query).length > 0;
521
+ const rendererTraceEnable = input.enableRendererTrace ? `try {
522
+ const traceConfiguration = await getContainer().instance(TraceProviderConfiguration);
523
+ traceConfiguration.configureRuntime('renderer');
524
+ traceConfiguration.configureMinLevel(${JSON.stringify(input.traceMinLevel)});
525
+ traceConfiguration.configureEnabled(true);
526
+ } catch (error) { /* best-effort renderer-trace enable */ }` : "";
527
+ const navigationApply = hasRouteContext ? `getNavigation().replace(${JSON.stringify(input.routeKey)}, ${JSON.stringify(input.route.params)}, ${JSON.stringify(input.route.query)});` : "";
490
528
  return `import { app } from '${viteImport(input.generatedApp)}';
529
+ import { getNavigation } from '${input.navigationModule}';
530
+ import { getContainer, TraceProviderConfiguration } from '${input.clientModule}';
491
531
  window.__WOOD_BROWSER_DEFAULT_ROUTE__ = ${JSON.stringify(input.routeKey)};
492
532
  window.__WOOD_BROWSER_APP__ = await app(document.getElementById('app'));
533
+ window.__WOOD_BROWSER_NAV__ = getNavigation();
534
+ ${rendererTraceEnable}
535
+ ${navigationApply}
493
536
  `;
494
537
  }
495
538
  function buildComponentEntrySource(input) {
@@ -530,7 +573,7 @@ window.__WOOD_BROWSER_APP__ = mount(WoodRecursiveRender, {
530
573
  });
531
574
  `;
532
575
  }
533
- async function installBridge(page, bridgeState, manifest, bridgeInit) {
576
+ async function installBridge(page, bridgeState, manifest, bridgeInit, transportCleanups) {
534
577
  await page.exposeBinding("__woodBrowserInvoke", async (_source, channel, input) => {
535
578
  const call = {
536
579
  channel,
@@ -540,17 +583,39 @@ async function installBridge(page, bridgeState, manifest, bridgeInit) {
540
583
  if (bridgeState.record) {
541
584
  bridgeState.calls.push(call);
542
585
  }
586
+ if (bridgeState.transport && !bridgeState.operations.has(channel)) {
587
+ return await bridgeState.transport.invoke(channel, input);
588
+ }
543
589
  const handler = bridgeState.operations.get(channel);
544
590
  if (typeof handler === "function") {
545
591
  return await handler(input, { channel, calls: bridgeState.calls });
546
592
  }
547
593
  return handler;
548
594
  });
595
+ await installTransportEventForwarding(page, bridgeState, transportCleanups);
549
596
  await page.addInitScript(bridgeInit);
550
597
  await page.addInitScript((entries) => {
551
598
  window.__WOOD_BROWSER_BRIDGE_MANIFEST__ = entries;
552
599
  }, manifest);
553
600
  }
601
+ async function installTransportEventForwarding(page, bridgeState, transportCleanups) {
602
+ const transport = bridgeState.transport;
603
+ if (!transport) return;
604
+ const subscribed = /* @__PURE__ */ new Set();
605
+ await page.exposeBinding("__woodBrowserSubscribe", (_source, channel) => {
606
+ if (subscribed.has(channel)) return;
607
+ subscribed.add(channel);
608
+ const unsubscribe = transport.subscribe(channel, (payload) => {
609
+ void page.evaluate(
610
+ ({ eventChannel, eventPayload }) => {
611
+ window.__woodBrowserEmit?.(eventChannel, eventPayload);
612
+ },
613
+ { eventChannel: channel, eventPayload: payload }
614
+ ).catch(() => void 0);
615
+ });
616
+ transportCleanups.push(unsubscribe);
617
+ });
618
+ }
554
619
  async function installTraceBinding(page, traceRecorder, context) {
555
620
  if (!traceRecorder) {
556
621
  return;
@@ -634,6 +699,259 @@ async function elementRect(page, selector) {
634
699
  };
635
700
  }).catch(() => null);
636
701
  }
702
+ async function firstRect(page, selector) {
703
+ return await page.locator(selector).first().evaluate((element) => {
704
+ const rect = element.getBoundingClientRect();
705
+ return {
706
+ top: rect.top,
707
+ left: rect.left,
708
+ right: rect.right,
709
+ bottom: rect.bottom,
710
+ width: rect.width,
711
+ height: rect.height
712
+ };
713
+ }).catch(() => null);
714
+ }
715
+ function describeHitElement(hit) {
716
+ if (!hit.tag) return "<no element at point>";
717
+ const id = hit.id ? `#${hit.id}` : "";
718
+ const classes = hit.classes.length > 0 ? `.${hit.classes.join(".")}` : "";
719
+ return `<${hit.tag.toLowerCase()}${id}${classes}>`;
720
+ }
721
+ async function capturePaintOrders(page, selectors) {
722
+ const context = page.context?.();
723
+ if (!context || typeof context.newCDPSession !== "function") {
724
+ throw new Error(
725
+ "inFrontOf paint-order comparison requires a Chromium CDP session (page.context().newCDPSession). It is Chromium-only; pass { at } to use the point hit-test form on other browsers."
726
+ );
727
+ }
728
+ const cdp = await context.newCDPSession(page);
729
+ try {
730
+ await cdp.send("DOM.enable");
731
+ const { root } = await cdp.send("DOM.getDocument", { depth: -1 });
732
+ const backendIds = /* @__PURE__ */ new Map();
733
+ for (const selector of selectors) {
734
+ let backendNodeId = null;
735
+ try {
736
+ const { nodeId } = await cdp.send("DOM.querySelector", { nodeId: root.nodeId, selector });
737
+ if (nodeId) {
738
+ const described = await cdp.send("DOM.describeNode", { nodeId });
739
+ backendNodeId = described.node?.backendNodeId ?? null;
740
+ }
741
+ } catch {
742
+ backendNodeId = null;
743
+ }
744
+ backendIds.set(selector, backendNodeId);
745
+ }
746
+ const snapshot = await cdp.send("DOMSnapshot.captureSnapshot", {
747
+ computedStyles: [],
748
+ includePaintOrder: true,
749
+ includeDOMRects: false
750
+ });
751
+ const paintByBackend = collectMaxPaintOrders(snapshot);
752
+ const result = /* @__PURE__ */ new Map();
753
+ for (const selector of selectors) {
754
+ const backendId = backendIds.get(selector) ?? null;
755
+ const paint = backendId == null ? void 0 : paintByBackend.get(backendId);
756
+ result.set(selector, { paintOrder: paint ?? null, found: paint !== void 0 });
757
+ }
758
+ return result;
759
+ } finally {
760
+ await cdp.detach().catch(() => void 0);
761
+ }
762
+ }
763
+ function collectMaxPaintOrders(snapshot) {
764
+ const paintByBackend = /* @__PURE__ */ new Map();
765
+ for (const doc of snapshot.documents ?? []) {
766
+ const backendOfNode = doc.nodes?.backendNodeId ?? [];
767
+ const nodeIndex = doc.layout?.nodeIndex ?? [];
768
+ const paintOrders = doc.layout?.paintOrders ?? [];
769
+ for (let i = 0; i < nodeIndex.length; i += 1) {
770
+ const paint = paintOrders[i];
771
+ const backendId = backendOfNode[nodeIndex[i]];
772
+ if (paint === void 0 || backendId === void 0) continue;
773
+ const prev = paintByBackend.get(backendId);
774
+ if (prev === void 0 || paint > prev) paintByBackend.set(backendId, paint);
775
+ }
776
+ }
777
+ return paintByBackend;
778
+ }
779
+ function axisEdges(rect, axis) {
780
+ return axis === "horizontal" ? { leading: rect.left, trailing: rect.right } : { leading: rect.top, trailing: rect.bottom };
781
+ }
782
+ function axisGap(first, second, axis) {
783
+ const a = axisEdges(first, axis);
784
+ const b = axisEdges(second, axis);
785
+ const [earlier, later] = a.leading <= b.leading ? [a, b] : [b, a];
786
+ return later.leading - earlier.trailing;
787
+ }
788
+ function describeGapConstraint(constraint) {
789
+ const parts = [];
790
+ if (constraint.equals !== void 0) parts.push(`equals ${constraint.equals}`);
791
+ if (constraint.min !== void 0) parts.push(`min ${constraint.min}`);
792
+ if (constraint.max !== void 0) parts.push(`max ${constraint.max}`);
793
+ return parts.join(", ");
794
+ }
795
+ function gapConstraintViolation(gap, constraint, tolerance) {
796
+ if (constraint.equals !== void 0 && Math.abs(gap - constraint.equals) > tolerance) {
797
+ return `expected gap to equal ${constraint.equals} (\xB1${tolerance})`;
798
+ }
799
+ if (constraint.min !== void 0 && gap < constraint.min - tolerance) {
800
+ return `expected gap >= ${constraint.min}`;
801
+ }
802
+ if (constraint.max !== void 0 && gap > constraint.max + tolerance) {
803
+ return `expected gap <= ${constraint.max}`;
804
+ }
805
+ return null;
806
+ }
807
+ function rectsOverlap(a, b) {
808
+ return Boolean(
809
+ a && b && a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top
810
+ );
811
+ }
812
+ const COVERAGE_STYLE_KEYS = [
813
+ "opacity",
814
+ "visibility",
815
+ "background-color",
816
+ "background-image",
817
+ "border-top-width",
818
+ "border-right-width",
819
+ "border-bottom-width",
820
+ "border-left-width"
821
+ ];
822
+ function colorAlphaIsZero(color) {
823
+ if (color === "transparent") return true;
824
+ const match = color.match(/^rgba?\(([^)]*)\)/);
825
+ if (!match) return false;
826
+ const parts = match[1].split(",").map((s) => s.trim());
827
+ return parts.length >= 4 && parseFloat(parts[3]) === 0;
828
+ }
829
+ function snapshotRectsOverlap(a, b) {
830
+ return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y;
831
+ }
832
+ function indexLayoutNodes(doc, strings) {
833
+ const byNode = /* @__PURE__ */ new Map();
834
+ const nodeIndex = doc.layout?.nodeIndex ?? [];
835
+ const bounds = doc.layout?.bounds ?? [];
836
+ const paintOrders = doc.layout?.paintOrders ?? [];
837
+ const styleRows = doc.layout?.styles ?? [];
838
+ for (let k = 0; k < nodeIndex.length; k += 1) {
839
+ const ni = nodeIndex[k];
840
+ const paint = paintOrders[k] ?? 0;
841
+ const existing = byNode.get(ni);
842
+ if (existing) {
843
+ if (paint > existing.paint) existing.paint = paint;
844
+ continue;
845
+ }
846
+ const b = bounds[k] ?? [0, 0, 0, 0];
847
+ const styles = {};
848
+ const row = styleRows[k] ?? [];
849
+ COVERAGE_STYLE_KEYS.forEach((key, j) => {
850
+ styles[key] = row[j] === void 0 ? "" : strings[row[j]];
851
+ });
852
+ byNode.set(ni, { rect: { x: b[0], y: b[1], w: b[2], h: b[3] }, paint, styles });
853
+ }
854
+ return byNode;
855
+ }
856
+ function nodeIndexForBackendId(nodes, backendId) {
857
+ const backendIds = nodes.backendNodeId ?? [];
858
+ for (let i = 0; i < backendIds.length; i += 1) if (backendIds[i] === backendId) return i;
859
+ return -1;
860
+ }
861
+ function isSnapshotAncestor(parentIndex, a, b) {
862
+ let cursor = parentIndex[b];
863
+ while (cursor >= 0) {
864
+ if (cursor === a) return true;
865
+ cursor = parentIndex[cursor];
866
+ }
867
+ return false;
868
+ }
869
+ function snapshotNodeHasOwnContent(nodes, strings, i) {
870
+ const tag = (strings[nodes.nodeName[i]] ?? "").toUpperCase();
871
+ if (["IMG", "SVG", "CANVAS", "VIDEO", "INPUT", "TEXTAREA", "SELECT", "PICTURE"].includes(tag)) return true;
872
+ const parentIndex = nodes.parentIndex ?? [];
873
+ const nodeType = nodes.nodeType ?? [];
874
+ const nodeValue = nodes.nodeValue ?? [];
875
+ for (let c = 0; c < parentIndex.length; c += 1) {
876
+ if (parentIndex[c] === i && nodeType[c] === 3) {
877
+ const v = nodeValue[c];
878
+ if (v >= 0 && (strings[v] ?? "").trim().length > 0) return true;
879
+ }
880
+ }
881
+ return false;
882
+ }
883
+ function snapshotNodePaintsNothing(styles, hasOwnContent) {
884
+ if (styles.opacity === "0") return true;
885
+ if (styles.visibility === "hidden") return true;
886
+ const noBackground = colorAlphaIsZero(styles["background-color"] ?? "") && (styles["background-image"] === "none" || !styles["background-image"]);
887
+ const noBorder = ["border-top-width", "border-right-width", "border-bottom-width", "border-left-width"].every((key) => (styles[key] || "0px") === "0px");
888
+ return noBackground && noBorder && !hasOwnContent;
889
+ }
890
+ function describeSnapshotNode(nodes, strings, i) {
891
+ const tag = (strings[nodes.nodeName[i]] ?? "").toLowerCase();
892
+ const attrs = nodes.attributes?.[i] ?? [];
893
+ let id = "";
894
+ let classes = "";
895
+ for (let a = 0; a + 1 < attrs.length; a += 2) {
896
+ const name = strings[attrs[a]];
897
+ const value = strings[attrs[a + 1]] ?? "";
898
+ if (name === "id") id = value ? `#${value}` : "";
899
+ else if (name === "class") classes = value ? `.${value.trim().split(/\s+/).join(".")}` : "";
900
+ }
901
+ const pseudo = nodes.pseudoType?.[i] !== void 0 && nodes.pseudoType[i] !== null ? `::${strings[nodes.pseudoType[i]] ?? "pseudo"}` : "";
902
+ return `<${tag}${id}${classes}${pseudo}>`;
903
+ }
904
+ function collectCoverageCandidates(doc, strings, targetNodeIndex) {
905
+ const nodes = doc.nodes;
906
+ const parentIndex = nodes.parentIndex ?? [];
907
+ const nodeType = nodes.nodeType ?? [];
908
+ const layout = indexLayoutNodes(doc, strings);
909
+ const target = layout.get(targetNodeIndex);
910
+ if (!target) return { targetPaint: 0, targetRect: { x: 0, y: 0, w: 0, h: 0 }, covers: [] };
911
+ const covers = [];
912
+ for (const [ni, rec] of layout) {
913
+ if (nodeType[ni] !== 1) continue;
914
+ if (ni === targetNodeIndex) continue;
915
+ if (isSnapshotAncestor(parentIndex, ni, targetNodeIndex) || isSnapshotAncestor(parentIndex, targetNodeIndex, ni)) continue;
916
+ if (!snapshotRectsOverlap(rec.rect, target.rect)) continue;
917
+ const hasContent = snapshotNodeHasOwnContent(nodes, strings, ni);
918
+ if (snapshotNodePaintsNothing(rec.styles, hasContent)) continue;
919
+ covers.push({ description: describeSnapshotNode(nodes, strings, ni), paint: rec.paint, rect: rec.rect });
920
+ }
921
+ return { targetPaint: target.paint, targetRect: target.rect, covers };
922
+ }
923
+ async function captureCoverage(page, selector) {
924
+ const context = page.context?.();
925
+ if (!context || typeof context.newCDPSession !== "function") {
926
+ throw new Error(
927
+ "notCovered requires a Chromium CDP session (page.context().newCDPSession); it is Chromium-only."
928
+ );
929
+ }
930
+ const cdp = await context.newCDPSession(page);
931
+ try {
932
+ await cdp.send("DOM.enable");
933
+ const { root } = await cdp.send("DOM.getDocument", { depth: -1 });
934
+ const { nodeId } = await cdp.send("DOM.querySelector", { nodeId: root.nodeId, selector });
935
+ if (!nodeId) throw new Error(`Expected "${selector}" to exist for notCovered.`);
936
+ const described = await cdp.send("DOM.describeNode", { nodeId });
937
+ const backendId = described.node?.backendNodeId;
938
+ if (backendId == null) throw new Error(`Could not resolve backendNodeId for "${selector}".`);
939
+ const snapshot = await cdp.send("DOMSnapshot.captureSnapshot", {
940
+ computedStyles: [...COVERAGE_STYLE_KEYS],
941
+ includePaintOrder: true,
942
+ includeDOMRects: true
943
+ });
944
+ const doc = snapshot.documents?.[0];
945
+ if (!doc) throw new Error("DOMSnapshot returned no documents.");
946
+ const targetNodeIndex = nodeIndexForBackendId(doc.nodes, backendId);
947
+ if (targetNodeIndex < 0) {
948
+ throw new Error(`Expected "${selector}" to be laid out and painted for notCovered, but it was not in the paint tree.`);
949
+ }
950
+ return collectCoverageCandidates(doc, snapshot.strings ?? [], targetNodeIndex);
951
+ } finally {
952
+ await cdp.detach().catch(() => void 0);
953
+ }
954
+ }
637
955
  function createExpect(page, outputDir) {
638
956
  async function waitForVisible(selector) {
639
957
  const locator = page.locator(selector).first();
@@ -648,6 +966,64 @@ function createExpect(page, outputDir) {
648
966
  throw new Error(`Expected "${selector}" to be visible.`);
649
967
  }
650
968
  }
969
+ async function hitTestAtPoint(selector, x, y) {
970
+ const locator = page.locator(selector).first();
971
+ const raw = await locator.evaluate((target, coords) => {
972
+ const hit = document.elementFromPoint(coords.x, coords.y);
973
+ if (!hit) {
974
+ return { found: false, tag: null, id: null, classes: [], isTarget: false, isDescendantOfTarget: false, isAncestorOfTarget: false };
975
+ }
976
+ return {
977
+ found: true,
978
+ tag: hit.tagName,
979
+ id: hit.id || null,
980
+ classes: Array.from(hit.classList),
981
+ isTarget: hit === target,
982
+ isDescendantOfTarget: hit !== target && target.contains(hit),
983
+ isAncestorOfTarget: hit !== target && hit.contains(target)
984
+ };
985
+ }, { x, y });
986
+ return {
987
+ ...raw,
988
+ point: { x, y },
989
+ description: describeHitElement(raw)
990
+ };
991
+ }
992
+ async function resolvePoint(selector, point) {
993
+ if (point && point !== "center") return point;
994
+ const rect = await firstRect(page, selector);
995
+ if (!rect) throw new Error(`Expected "${selector}" to exist.`);
996
+ return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
997
+ }
998
+ async function inFrontOfByPaintOrder(topSelector, lowerSelector) {
999
+ const orders = await capturePaintOrders(page, [topSelector, lowerSelector]);
1000
+ const top = orders.get(topSelector);
1001
+ const lower = orders.get(lowerSelector);
1002
+ if (!top.found || top.paintOrder === null) {
1003
+ throw new Error(`Expected "${topSelector}" to have a paint-order entry, but it was not found in the paint tree (not laid out / not painted).`);
1004
+ }
1005
+ if (!lower.found || lower.paintOrder === null) {
1006
+ throw new Error(`Expected "${lowerSelector}" to have a paint-order entry, but it was not found in the paint tree (not laid out / not painted).`);
1007
+ }
1008
+ if (top.paintOrder > lower.paintOrder) return;
1009
+ const topRect = await firstRect(page, topSelector);
1010
+ const lowerRect = await firstRect(page, lowerSelector);
1011
+ const overlapNote = rectsOverlap(topRect, lowerRect) ? "" : ` (Note: the rects do not overlap, so there is no shared pixel where front/behind is visually observable \u2014 this is a global paint-order verdict.)`;
1012
+ throw new Error(
1013
+ `Expected "${topSelector}" (paint order ${top.paintOrder}) to paint in front of "${lowerSelector}" (paint order ${lower.paintOrder}), but it paints at or behind it (higher paint order == on top).${overlapNote}`
1014
+ );
1015
+ }
1016
+ async function inFrontOfAtPoint(topSelector, lowerSelector, at) {
1017
+ const topHit = await hitTestAtPoint(topSelector, at.x, at.y);
1018
+ const lowerHit = await hitTestAtPoint(lowerSelector, at.x, at.y);
1019
+ const hitsTop = topHit.isTarget || topHit.isDescendantOfTarget;
1020
+ const hitsLower = lowerHit.isTarget || lowerHit.isDescendantOfTarget;
1021
+ if (!hitsTop || hitsLower) {
1022
+ throw new Error(
1023
+ `Expected "${topSelector}" to be painted in front of "${lowerSelector}" at (${at.x.toFixed(1)}, ${at.y.toFixed(1)}), but the top-most painted element there was ${topHit.description} (is/descends-from "${topSelector}": ${hitsTop}; is/descends-from "${lowerSelector}": ${hitsLower}).`
1024
+ );
1025
+ }
1026
+ }
651
1027
  return {
652
1028
  async visible(selector) {
653
1029
  await waitForVisible(selector);
@@ -724,6 +1100,135 @@ function createExpect(page, outputDir) {
724
1100
  throw new Error(`Expected "${topSelector}" z-index (${values.top}) to be above "${lowerSelector}" (${values.lower}).`);
725
1101
  }
726
1102
  },
1103
+ async rects(selectors) {
1104
+ const snapshots = [];
1105
+ for (const selector of selectors) {
1106
+ const rect = await firstRect(page, selector);
1107
+ if (!rect) throw new Error(`Expected "${selector}" to exist.`);
1108
+ snapshots.push({ selector, ...rect });
1109
+ }
1110
+ return snapshots;
1111
+ },
1112
+ async hitTest(selector, point = "center") {
1113
+ await waitForVisible(selector);
1114
+ const resolved = await resolvePoint(selector, point);
1115
+ return hitTestAtPoint(selector, resolved.x, resolved.y);
1116
+ },
1117
+ async sharedEdge(a, b, edge, options = {}) {
1118
+ await waitForVisible(a);
1119
+ await waitForVisible(b);
1120
+ const tolerance = options.tolerance ?? 1;
1121
+ const first = await firstRect(page, a);
1122
+ const second = await firstRect(page, b);
1123
+ if (!first || !second) throw new Error(`Expected both "${a}" and "${b}" to exist.`);
1124
+ const delta = Math.abs(first[edge] - second[edge]);
1125
+ if (delta > tolerance) {
1126
+ throw new Error(
1127
+ `Expected "${a}" and "${b}" to share ${edge} edge within ${tolerance}px, but delta was ${delta.toFixed(2)}px ("${a}".${edge}=${first[edge].toFixed(2)}, "${b}".${edge}=${second[edge].toFixed(2)}). Rects: ${a}=${JSON.stringify(first)}, ${b}=${JSON.stringify(second)}`
1128
+ );
1129
+ }
1130
+ },
1131
+ async sharedAxis(a, b, axis, options = {}) {
1132
+ await waitForVisible(a);
1133
+ await waitForVisible(b);
1134
+ const tolerance = options.tolerance ?? 1;
1135
+ const first = await firstRect(page, a);
1136
+ const second = await firstRect(page, b);
1137
+ if (!first || !second) throw new Error(`Expected both "${a}" and "${b}" to exist.`);
1138
+ const center = (rect) => axis === "centerX" ? rect.left + rect.width / 2 : rect.top + rect.height / 2;
1139
+ const firstCenter = center(first);
1140
+ const secondCenter = center(second);
1141
+ const delta = Math.abs(firstCenter - secondCenter);
1142
+ if (delta > tolerance) {
1143
+ throw new Error(
1144
+ `Expected "${a}" and "${b}" to share ${axis} within ${tolerance}px, but delta was ${delta.toFixed(2)}px ("${a}".${axis}=${firstCenter.toFixed(2)}, "${b}".${axis}=${secondCenter.toFixed(2)}). Rects: ${a}=${JSON.stringify(first)}, ${b}=${JSON.stringify(second)}`
1145
+ );
1146
+ }
1147
+ },
1148
+ async unoccluded(selector, options = {}) {
1149
+ await waitForVisible(selector);
1150
+ const resolved = await resolvePoint(selector, options.point ?? "center");
1151
+ const hit = await hitTestAtPoint(selector, resolved.x, resolved.y);
1152
+ if (!hit.found) {
1153
+ throw new Error(`Expected "${selector}" to receive pixels at (${resolved.x.toFixed(1)}, ${resolved.y.toFixed(1)}), but no element was painted there.`);
1154
+ }
1155
+ if (hit.isTarget || hit.isDescendantOfTarget || hit.isAncestorOfTarget) return;
1156
+ throw new Error(
1157
+ `Expected "${selector}" to be unoccluded at (${resolved.x.toFixed(1)}, ${resolved.y.toFixed(1)}), but the painted element there was ${hit.description} \u2014 a foreign element occluding the target.`
1158
+ );
1159
+ },
1160
+ async inFrontOf(topSelector, lowerSelector, options = {}) {
1161
+ await waitForVisible(topSelector);
1162
+ await waitForVisible(lowerSelector);
1163
+ if (options.at) {
1164
+ await inFrontOfAtPoint(topSelector, lowerSelector, options.at);
1165
+ return;
1166
+ }
1167
+ await inFrontOfByPaintOrder(topSelector, lowerSelector);
1168
+ },
1169
+ async notCovered(selector) {
1170
+ await waitForVisible(selector);
1171
+ const coverage = await captureCoverage(page, selector);
1172
+ const covering = coverage.covers.filter((c) => c.paint > coverage.targetPaint);
1173
+ if (covering.length === 0) return;
1174
+ const worst = covering.reduce((a, b) => b.paint > a.paint ? b : a);
1175
+ throw new Error(
1176
+ `Expected "${selector}" (paint order ${coverage.targetPaint}, rect ${JSON.stringify(coverage.targetRect)}) not to be covered, but ${worst.description} paints on top (paint order ${worst.paint}, rect ${JSON.stringify(worst.rect)}). notCovered tracks PAINTED coverage from the real paint tree; a foreign element with a higher paint order that overlaps the target and paints visible pixels (background/border/text/image \u2014 the transparent-wrapper heuristic did not exempt it) fails here.`
1177
+ );
1178
+ },
1179
+ async gapBetween(a, b, axis, options) {
1180
+ if (options.equals === void 0 && options.min === void 0 && options.max === void 0) {
1181
+ throw new Error("gapBetween requires at least one of { equals, min, max }.");
1182
+ }
1183
+ await waitForVisible(a);
1184
+ await waitForVisible(b);
1185
+ const tolerance = options.tolerance ?? 1;
1186
+ const first = await firstRect(page, a);
1187
+ const second = await firstRect(page, b);
1188
+ if (!first || !second) throw new Error(`Expected both "${a}" and "${b}" to exist.`);
1189
+ const gap = axisGap(first, second, axis);
1190
+ const allowsNegative = options.equals !== void 0 && options.equals < 0 || options.min !== void 0 && options.min < 0;
1191
+ const rectsNote = `Rects: ${a}=${JSON.stringify(first)}, ${b}=${JSON.stringify(second)}`;
1192
+ if (gap < -tolerance && !allowsNegative) {
1193
+ throw new Error(
1194
+ `Expected "${a}" and "${b}" to have a non-negative ${axis} gap, but their border boxes overlap by ${(-gap).toFixed(2)}px (gap ${gap.toFixed(2)}px). Pass a negative min/equals to assert an overlap intentionally. ${rectsNote}`
1195
+ );
1196
+ }
1197
+ const violation = gapConstraintViolation(gap, options, tolerance);
1198
+ if (violation) {
1199
+ throw new Error(
1200
+ `Expected "${a}" and "${b}" ${axis} gap to satisfy ${describeGapConstraint(options)} within ${tolerance}px, but ${violation} (actual gap ${gap.toFixed(2)}px). ${rectsNote}`
1201
+ );
1202
+ }
1203
+ },
1204
+ async evenlySpaced(selectors, axis, options = {}) {
1205
+ if (selectors.length < 3) {
1206
+ throw new Error(`evenlySpaced requires at least 3 elements, got ${selectors.length}.`);
1207
+ }
1208
+ const tolerance = options.tolerance ?? 1;
1209
+ const boxes = [];
1210
+ for (const selector of selectors) {
1211
+ await waitForVisible(selector);
1212
+ const rect = await firstRect(page, selector);
1213
+ if (!rect) throw new Error(`Expected "${selector}" to exist.`);
1214
+ boxes.push({ selector, rect });
1215
+ }
1216
+ const sorted = [...boxes].sort((x, y) => axisEdges(x.rect, axis).leading - axisEdges(y.rect, axis).leading);
1217
+ const gaps = sorted.slice(1).map((box, i) => ({
1218
+ from: sorted[i].selector,
1219
+ to: box.selector,
1220
+ gap: axisGap(sorted[i].rect, box.rect, axis)
1221
+ }));
1222
+ const widest = gaps.reduce((acc, g) => g.gap > acc.gap ? g : acc);
1223
+ const narrowest = gaps.reduce((acc, g) => g.gap < acc.gap ? g : acc);
1224
+ const spread = widest.gap - narrowest.gap;
1225
+ if (spread > tolerance) {
1226
+ const list = gaps.map((g) => `${g.from}\u2192${g.to}=${g.gap.toFixed(2)}px`).join(", ");
1227
+ throw new Error(
1228
+ `Expected elements to be evenly spaced along ${axis} within ${tolerance}px, but gap spread was ${spread.toFixed(2)}px (widest ${widest.from}\u2192${widest.to}=${widest.gap.toFixed(2)}px, narrowest ${narrowest.from}\u2192${narrowest.to}=${narrowest.gap.toFixed(2)}px). Gaps: ${list}`
1229
+ );
1230
+ }
1231
+ },
727
1232
  async stableAfterAnimation(selector = "body", options = {}) {
728
1233
  const frameCount = options.frameCount ?? 3;
729
1234
  const timeoutMs = options.timeoutMs ?? 2e3;
@@ -783,7 +1288,13 @@ async function openMountPage(input) {
783
1288
  const entryPath = writeEntry(input.runtime.tempDir, input.name, input.entrySource);
784
1289
  const htmlPath = writeHtml(input.runtime.tempDir, input.name, entryPath, input.css);
785
1290
  await installTraceBinding(input.runtime.page, input.traceRecorder, input.traceContext ?? {});
786
- await installBridge(page, input.bridge.state, input.bridge.manifest, input.bridge.bridgeInit);
1291
+ await installBridge(
1292
+ page,
1293
+ input.bridge.state,
1294
+ input.bridge.manifest,
1295
+ input.bridge.bridgeInit,
1296
+ input.runtime.transportCleanups
1297
+ );
787
1298
  const mountedBridge = createMountedBridge(page, input.bridge.state);
788
1299
  const context = {
789
1300
  page,
@@ -858,7 +1369,8 @@ async function createHarness(config = {}) {
858
1369
  server: workerRuntime.server,
859
1370
  page,
860
1371
  tempDir,
861
- rootDir
1372
+ rootDir,
1373
+ transportCleanups: []
862
1374
  };
863
1375
  const css = (config.css ?? []).map((cssPath) => {
864
1376
  const resolved = resolveFromRoot(rootDir, cssPath);
@@ -889,7 +1401,15 @@ async function createHarness(config = {}) {
889
1401
  } else if (config.generatedApp) {
890
1402
  const generatedApp = resolveFromRoot(rootDir, config.generatedApp);
891
1403
  assertFileExists(generatedApp, "Generated app");
892
- entrySource = buildGeneratedAppEntrySource({ generatedApp, routeKey });
1404
+ entrySource = buildGeneratedAppEntrySource({
1405
+ generatedApp,
1406
+ routeKey,
1407
+ route,
1408
+ navigationModule: resolveEsmModule(rootDir, "@noego/wood/navigation"),
1409
+ clientModule: resolveEsmModule(rootDir, "@noego/wood/client"),
1410
+ enableRendererTrace: config.trace?.enabled === true,
1411
+ traceMinLevel: config.trace?.minLevel ?? "debug"
1412
+ });
893
1413
  } else {
894
1414
  throw new Error("mountRoute requires either viewsConfig or generatedApp in browser.createHarness().");
895
1415
  }
@@ -986,11 +1506,20 @@ async function createHarness(config = {}) {
986
1506
  hooks: config.page
987
1507
  });
988
1508
  },
1509
+ async flushRendererTraces() {
1510
+ await page.evaluate(() => window.__woodBrowserTraceFlush?.()).catch(() => void 0);
1511
+ },
989
1512
  async destroy() {
990
1513
  if (destroyed) {
991
1514
  return;
992
1515
  }
993
1516
  destroyed = true;
1517
+ for (const cleanup of runtime.transportCleanups.splice(0)) {
1518
+ try {
1519
+ cleanup();
1520
+ } catch {
1521
+ }
1522
+ }
994
1523
  await Promise.all([
995
1524
  waitForBoundedClose(page.close()),
996
1525
  waitForBoundedClose(context.close())