@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
@@ -77,7 +77,9 @@ function mergeBridge(base, override) {
77
77
  ...base?.events ?? {},
78
78
  ...override?.events ?? {}
79
79
  },
80
- record: override?.record ?? base?.record
80
+ record: override?.record ?? base?.record,
81
+ ...override?.transport ?? base?.transport ? { transport: override?.transport ?? base?.transport } : {},
82
+ ...override?.rpcManifest ?? base?.rpcManifest ? { rpcManifest: override?.rpcManifest ?? base?.rpcManifest } : {}
81
83
  };
82
84
  }
83
85
  async function waitForBoundedClose(promise, timeoutMs = DEFAULT_RESOURCE_CLOSE_TIMEOUT_MS) {
@@ -118,7 +120,9 @@ function createBridgeState(input) {
118
120
  initialEvents,
119
121
  calls: [],
120
122
  emittedEvents: [],
121
- record: input?.record ?? true
123
+ record: input?.record ?? true,
124
+ ...input?.transport ? { transport: input.transport } : {},
125
+ ...input?.rpcManifest ? { rpcManifest: input.rpcManifest } : {}
122
126
  };
123
127
  }
124
128
  function createBrowserBridgeFixture(input) {
@@ -126,20 +130,29 @@ function createBrowserBridgeFixture(input) {
126
130
  }
127
131
  function createBrowserBridgeFixtureWithTrace(input, traceConfig) {
128
132
  const state = createBridgeState(input);
129
- const manifest = Array.from(state.operations.entries()).map(([channel, handler]) => channelToManifestEntry(channel, handler));
133
+ const manifest = state.rpcManifest ?? Array.from(state.operations.entries()).map(([channel, handler]) => channelToManifestEntry(channel, handler));
130
134
  return {
131
135
  state,
132
136
  manifest,
133
- bridgeInit: buildBridgeInitScript(manifest, traceConfig)
137
+ bridgeInit: buildBridgeInitScript(manifest, traceConfig, Boolean(state.transport))
134
138
  };
135
139
  }
136
- function buildBridgeInitScript(manifest, traceConfig) {
140
+ function buildBridgeInitScript(manifest, traceConfig, transportMode) {
137
141
  const traceEnabled = traceConfig?.enabled === true;
138
142
  const traceMinLevel = traceConfig?.minLevel ?? "debug";
139
143
  return `
140
144
  (() => {
141
145
  const manifest = ${JSON.stringify(manifest)};
146
+ const transportMode = ${JSON.stringify(transportMode)};
142
147
  const listeners = new Map();
148
+ window.__woodBrowserTracePending = new Set();
149
+ window.__woodBrowserTraceFlush = async () => {
150
+ // Drain in-flight __trace exposeBinding round-trips so the test-process
151
+ // renderer-trace ack loop can barrier on a definite quiescent point.
152
+ while (window.__woodBrowserTracePending.size > 0) {
153
+ await Promise.allSettled(Array.from(window.__woodBrowserTracePending));
154
+ }
155
+ };
143
156
  const bridge = {
144
157
  __rpcManifest: manifest,
145
158
  __woodConfig: async () => ({
@@ -147,7 +160,14 @@ function buildBridgeInitScript(manifest, traceConfig) {
147
160
  rendererTraceMinLevel: ${JSON.stringify(traceMinLevel)}
148
161
  }),
149
162
  __log: () => {},
150
- __trace: (payload) => window.__woodBrowserTrace?.(payload),
163
+ __trace: (payload) => {
164
+ const result = window.__woodBrowserTrace?.(payload);
165
+ if (result && typeof result.then === 'function') {
166
+ window.__woodBrowserTracePending.add(result);
167
+ result.finally(() => window.__woodBrowserTracePending.delete(result));
168
+ }
169
+ return result;
170
+ },
151
171
  __window: {
152
172
  current: async () => ({ ok: true, windowId: 'wood-browser-harness', defaultRoute: window.__WOOD_BROWSER_DEFAULT_ROUTE__ }),
153
173
  open: async () => ({}),
@@ -157,8 +177,13 @@ function buildBridgeInitScript(manifest, traceConfig) {
157
177
  maximize: async () => ({}),
158
178
  },
159
179
  on(channel, callback) {
160
- if (!listeners.has(channel)) listeners.set(channel, new Set());
180
+ const first = !listeners.has(channel);
181
+ if (first) listeners.set(channel, new Set());
161
182
  listeners.get(channel).add(callback);
183
+ // In real-backend mode, relay the first subscription per channel to the
184
+ // test process so it can subscribe on the WoodRpcTransport and forward
185
+ // main-process push events back into __woodBrowserEmit.
186
+ if (transportMode && first) window.__woodBrowserSubscribe?.(channel);
162
187
  return () => listeners.get(channel)?.delete(callback);
163
188
  },
164
189
  };
@@ -209,6 +234,11 @@ function resolvePackageModule(rootDir, source) {
209
234
  }
210
235
  }
211
236
  }
237
+ function resolveEsmModule(rootDir, source) {
238
+ const resolved = resolvePackageModule(rootDir, source);
239
+ const esm = resolved?.endsWith(".cjs") ? resolved.replace(/\.cjs$/, ".js") : resolved;
240
+ return esm && import_node_fs.default.existsSync(esm) ? viteImport(esm) : source;
241
+ }
212
242
  function resolvePackageDir(rootDir, packageName) {
213
243
  const packageJson = resolvePackageModule(rootDir, `${packageName}/package.json`);
214
244
  return packageJson ? import_node_path.default.dirname(packageJson) : null;
@@ -519,9 +549,22 @@ window.__WOOD_BROWSER_APP__ = mount(NavigationShell, {
519
549
  `;
520
550
  }
521
551
  function buildGeneratedAppEntrySource(input) {
552
+ const hasRouteContext = Object.keys(input.route.params).length > 0 || Object.keys(input.route.query).length > 0;
553
+ const rendererTraceEnable = input.enableRendererTrace ? `try {
554
+ const traceConfiguration = await getContainer().instance(TraceProviderConfiguration);
555
+ traceConfiguration.configureRuntime('renderer');
556
+ traceConfiguration.configureMinLevel(${JSON.stringify(input.traceMinLevel)});
557
+ traceConfiguration.configureEnabled(true);
558
+ } catch (error) { /* best-effort renderer-trace enable */ }` : "";
559
+ const navigationApply = hasRouteContext ? `getNavigation().replace(${JSON.stringify(input.routeKey)}, ${JSON.stringify(input.route.params)}, ${JSON.stringify(input.route.query)});` : "";
522
560
  return `import { app } from '${viteImport(input.generatedApp)}';
561
+ import { getNavigation } from '${input.navigationModule}';
562
+ import { getContainer, TraceProviderConfiguration } from '${input.clientModule}';
523
563
  window.__WOOD_BROWSER_DEFAULT_ROUTE__ = ${JSON.stringify(input.routeKey)};
524
564
  window.__WOOD_BROWSER_APP__ = await app(document.getElementById('app'));
565
+ window.__WOOD_BROWSER_NAV__ = getNavigation();
566
+ ${rendererTraceEnable}
567
+ ${navigationApply}
525
568
  `;
526
569
  }
527
570
  function buildComponentEntrySource(input) {
@@ -562,7 +605,7 @@ window.__WOOD_BROWSER_APP__ = mount(WoodRecursiveRender, {
562
605
  });
563
606
  `;
564
607
  }
565
- async function installBridge(page, bridgeState, manifest, bridgeInit) {
608
+ async function installBridge(page, bridgeState, manifest, bridgeInit, transportCleanups) {
566
609
  await page.exposeBinding("__woodBrowserInvoke", async (_source, channel, input) => {
567
610
  const call = {
568
611
  channel,
@@ -572,17 +615,39 @@ async function installBridge(page, bridgeState, manifest, bridgeInit) {
572
615
  if (bridgeState.record) {
573
616
  bridgeState.calls.push(call);
574
617
  }
618
+ if (bridgeState.transport && !bridgeState.operations.has(channel)) {
619
+ return await bridgeState.transport.invoke(channel, input);
620
+ }
575
621
  const handler = bridgeState.operations.get(channel);
576
622
  if (typeof handler === "function") {
577
623
  return await handler(input, { channel, calls: bridgeState.calls });
578
624
  }
579
625
  return handler;
580
626
  });
627
+ await installTransportEventForwarding(page, bridgeState, transportCleanups);
581
628
  await page.addInitScript(bridgeInit);
582
629
  await page.addInitScript((entries) => {
583
630
  window.__WOOD_BROWSER_BRIDGE_MANIFEST__ = entries;
584
631
  }, manifest);
585
632
  }
633
+ async function installTransportEventForwarding(page, bridgeState, transportCleanups) {
634
+ const transport = bridgeState.transport;
635
+ if (!transport) return;
636
+ const subscribed = /* @__PURE__ */ new Set();
637
+ await page.exposeBinding("__woodBrowserSubscribe", (_source, channel) => {
638
+ if (subscribed.has(channel)) return;
639
+ subscribed.add(channel);
640
+ const unsubscribe = transport.subscribe(channel, (payload) => {
641
+ void page.evaluate(
642
+ ({ eventChannel, eventPayload }) => {
643
+ window.__woodBrowserEmit?.(eventChannel, eventPayload);
644
+ },
645
+ { eventChannel: channel, eventPayload: payload }
646
+ ).catch(() => void 0);
647
+ });
648
+ transportCleanups.push(unsubscribe);
649
+ });
650
+ }
586
651
  async function installTraceBinding(page, traceRecorder, context) {
587
652
  if (!traceRecorder) {
588
653
  return;
@@ -666,6 +731,259 @@ async function elementRect(page, selector) {
666
731
  };
667
732
  }).catch(() => null);
668
733
  }
734
+ async function firstRect(page, selector) {
735
+ return await page.locator(selector).first().evaluate((element) => {
736
+ const rect = element.getBoundingClientRect();
737
+ return {
738
+ top: rect.top,
739
+ left: rect.left,
740
+ right: rect.right,
741
+ bottom: rect.bottom,
742
+ width: rect.width,
743
+ height: rect.height
744
+ };
745
+ }).catch(() => null);
746
+ }
747
+ function describeHitElement(hit) {
748
+ if (!hit.tag) return "<no element at point>";
749
+ const id = hit.id ? `#${hit.id}` : "";
750
+ const classes = hit.classes.length > 0 ? `.${hit.classes.join(".")}` : "";
751
+ return `<${hit.tag.toLowerCase()}${id}${classes}>`;
752
+ }
753
+ async function capturePaintOrders(page, selectors) {
754
+ const context = page.context?.();
755
+ if (!context || typeof context.newCDPSession !== "function") {
756
+ throw new Error(
757
+ "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."
758
+ );
759
+ }
760
+ const cdp = await context.newCDPSession(page);
761
+ try {
762
+ await cdp.send("DOM.enable");
763
+ const { root } = await cdp.send("DOM.getDocument", { depth: -1 });
764
+ const backendIds = /* @__PURE__ */ new Map();
765
+ for (const selector of selectors) {
766
+ let backendNodeId = null;
767
+ try {
768
+ const { nodeId } = await cdp.send("DOM.querySelector", { nodeId: root.nodeId, selector });
769
+ if (nodeId) {
770
+ const described = await cdp.send("DOM.describeNode", { nodeId });
771
+ backendNodeId = described.node?.backendNodeId ?? null;
772
+ }
773
+ } catch {
774
+ backendNodeId = null;
775
+ }
776
+ backendIds.set(selector, backendNodeId);
777
+ }
778
+ const snapshot = await cdp.send("DOMSnapshot.captureSnapshot", {
779
+ computedStyles: [],
780
+ includePaintOrder: true,
781
+ includeDOMRects: false
782
+ });
783
+ const paintByBackend = collectMaxPaintOrders(snapshot);
784
+ const result = /* @__PURE__ */ new Map();
785
+ for (const selector of selectors) {
786
+ const backendId = backendIds.get(selector) ?? null;
787
+ const paint = backendId == null ? void 0 : paintByBackend.get(backendId);
788
+ result.set(selector, { paintOrder: paint ?? null, found: paint !== void 0 });
789
+ }
790
+ return result;
791
+ } finally {
792
+ await cdp.detach().catch(() => void 0);
793
+ }
794
+ }
795
+ function collectMaxPaintOrders(snapshot) {
796
+ const paintByBackend = /* @__PURE__ */ new Map();
797
+ for (const doc of snapshot.documents ?? []) {
798
+ const backendOfNode = doc.nodes?.backendNodeId ?? [];
799
+ const nodeIndex = doc.layout?.nodeIndex ?? [];
800
+ const paintOrders = doc.layout?.paintOrders ?? [];
801
+ for (let i = 0; i < nodeIndex.length; i += 1) {
802
+ const paint = paintOrders[i];
803
+ const backendId = backendOfNode[nodeIndex[i]];
804
+ if (paint === void 0 || backendId === void 0) continue;
805
+ const prev = paintByBackend.get(backendId);
806
+ if (prev === void 0 || paint > prev) paintByBackend.set(backendId, paint);
807
+ }
808
+ }
809
+ return paintByBackend;
810
+ }
811
+ function axisEdges(rect, axis) {
812
+ return axis === "horizontal" ? { leading: rect.left, trailing: rect.right } : { leading: rect.top, trailing: rect.bottom };
813
+ }
814
+ function axisGap(first, second, axis) {
815
+ const a = axisEdges(first, axis);
816
+ const b = axisEdges(second, axis);
817
+ const [earlier, later] = a.leading <= b.leading ? [a, b] : [b, a];
818
+ return later.leading - earlier.trailing;
819
+ }
820
+ function describeGapConstraint(constraint) {
821
+ const parts = [];
822
+ if (constraint.equals !== void 0) parts.push(`equals ${constraint.equals}`);
823
+ if (constraint.min !== void 0) parts.push(`min ${constraint.min}`);
824
+ if (constraint.max !== void 0) parts.push(`max ${constraint.max}`);
825
+ return parts.join(", ");
826
+ }
827
+ function gapConstraintViolation(gap, constraint, tolerance) {
828
+ if (constraint.equals !== void 0 && Math.abs(gap - constraint.equals) > tolerance) {
829
+ return `expected gap to equal ${constraint.equals} (\xB1${tolerance})`;
830
+ }
831
+ if (constraint.min !== void 0 && gap < constraint.min - tolerance) {
832
+ return `expected gap >= ${constraint.min}`;
833
+ }
834
+ if (constraint.max !== void 0 && gap > constraint.max + tolerance) {
835
+ return `expected gap <= ${constraint.max}`;
836
+ }
837
+ return null;
838
+ }
839
+ function rectsOverlap(a, b) {
840
+ return Boolean(
841
+ a && b && a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top
842
+ );
843
+ }
844
+ const COVERAGE_STYLE_KEYS = [
845
+ "opacity",
846
+ "visibility",
847
+ "background-color",
848
+ "background-image",
849
+ "border-top-width",
850
+ "border-right-width",
851
+ "border-bottom-width",
852
+ "border-left-width"
853
+ ];
854
+ function colorAlphaIsZero(color) {
855
+ if (color === "transparent") return true;
856
+ const match = color.match(/^rgba?\(([^)]*)\)/);
857
+ if (!match) return false;
858
+ const parts = match[1].split(",").map((s) => s.trim());
859
+ return parts.length >= 4 && parseFloat(parts[3]) === 0;
860
+ }
861
+ function snapshotRectsOverlap(a, b) {
862
+ 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;
863
+ }
864
+ function indexLayoutNodes(doc, strings) {
865
+ const byNode = /* @__PURE__ */ new Map();
866
+ const nodeIndex = doc.layout?.nodeIndex ?? [];
867
+ const bounds = doc.layout?.bounds ?? [];
868
+ const paintOrders = doc.layout?.paintOrders ?? [];
869
+ const styleRows = doc.layout?.styles ?? [];
870
+ for (let k = 0; k < nodeIndex.length; k += 1) {
871
+ const ni = nodeIndex[k];
872
+ const paint = paintOrders[k] ?? 0;
873
+ const existing = byNode.get(ni);
874
+ if (existing) {
875
+ if (paint > existing.paint) existing.paint = paint;
876
+ continue;
877
+ }
878
+ const b = bounds[k] ?? [0, 0, 0, 0];
879
+ const styles = {};
880
+ const row = styleRows[k] ?? [];
881
+ COVERAGE_STYLE_KEYS.forEach((key, j) => {
882
+ styles[key] = row[j] === void 0 ? "" : strings[row[j]];
883
+ });
884
+ byNode.set(ni, { rect: { x: b[0], y: b[1], w: b[2], h: b[3] }, paint, styles });
885
+ }
886
+ return byNode;
887
+ }
888
+ function nodeIndexForBackendId(nodes, backendId) {
889
+ const backendIds = nodes.backendNodeId ?? [];
890
+ for (let i = 0; i < backendIds.length; i += 1) if (backendIds[i] === backendId) return i;
891
+ return -1;
892
+ }
893
+ function isSnapshotAncestor(parentIndex, a, b) {
894
+ let cursor = parentIndex[b];
895
+ while (cursor >= 0) {
896
+ if (cursor === a) return true;
897
+ cursor = parentIndex[cursor];
898
+ }
899
+ return false;
900
+ }
901
+ function snapshotNodeHasOwnContent(nodes, strings, i) {
902
+ const tag = (strings[nodes.nodeName[i]] ?? "").toUpperCase();
903
+ if (["IMG", "SVG", "CANVAS", "VIDEO", "INPUT", "TEXTAREA", "SELECT", "PICTURE"].includes(tag)) return true;
904
+ const parentIndex = nodes.parentIndex ?? [];
905
+ const nodeType = nodes.nodeType ?? [];
906
+ const nodeValue = nodes.nodeValue ?? [];
907
+ for (let c = 0; c < parentIndex.length; c += 1) {
908
+ if (parentIndex[c] === i && nodeType[c] === 3) {
909
+ const v = nodeValue[c];
910
+ if (v >= 0 && (strings[v] ?? "").trim().length > 0) return true;
911
+ }
912
+ }
913
+ return false;
914
+ }
915
+ function snapshotNodePaintsNothing(styles, hasOwnContent) {
916
+ if (styles.opacity === "0") return true;
917
+ if (styles.visibility === "hidden") return true;
918
+ const noBackground = colorAlphaIsZero(styles["background-color"] ?? "") && (styles["background-image"] === "none" || !styles["background-image"]);
919
+ const noBorder = ["border-top-width", "border-right-width", "border-bottom-width", "border-left-width"].every((key) => (styles[key] || "0px") === "0px");
920
+ return noBackground && noBorder && !hasOwnContent;
921
+ }
922
+ function describeSnapshotNode(nodes, strings, i) {
923
+ const tag = (strings[nodes.nodeName[i]] ?? "").toLowerCase();
924
+ const attrs = nodes.attributes?.[i] ?? [];
925
+ let id = "";
926
+ let classes = "";
927
+ for (let a = 0; a + 1 < attrs.length; a += 2) {
928
+ const name = strings[attrs[a]];
929
+ const value = strings[attrs[a + 1]] ?? "";
930
+ if (name === "id") id = value ? `#${value}` : "";
931
+ else if (name === "class") classes = value ? `.${value.trim().split(/\s+/).join(".")}` : "";
932
+ }
933
+ const pseudo = nodes.pseudoType?.[i] !== void 0 && nodes.pseudoType[i] !== null ? `::${strings[nodes.pseudoType[i]] ?? "pseudo"}` : "";
934
+ return `<${tag}${id}${classes}${pseudo}>`;
935
+ }
936
+ function collectCoverageCandidates(doc, strings, targetNodeIndex) {
937
+ const nodes = doc.nodes;
938
+ const parentIndex = nodes.parentIndex ?? [];
939
+ const nodeType = nodes.nodeType ?? [];
940
+ const layout = indexLayoutNodes(doc, strings);
941
+ const target = layout.get(targetNodeIndex);
942
+ if (!target) return { targetPaint: 0, targetRect: { x: 0, y: 0, w: 0, h: 0 }, covers: [] };
943
+ const covers = [];
944
+ for (const [ni, rec] of layout) {
945
+ if (nodeType[ni] !== 1) continue;
946
+ if (ni === targetNodeIndex) continue;
947
+ if (isSnapshotAncestor(parentIndex, ni, targetNodeIndex) || isSnapshotAncestor(parentIndex, targetNodeIndex, ni)) continue;
948
+ if (!snapshotRectsOverlap(rec.rect, target.rect)) continue;
949
+ const hasContent = snapshotNodeHasOwnContent(nodes, strings, ni);
950
+ if (snapshotNodePaintsNothing(rec.styles, hasContent)) continue;
951
+ covers.push({ description: describeSnapshotNode(nodes, strings, ni), paint: rec.paint, rect: rec.rect });
952
+ }
953
+ return { targetPaint: target.paint, targetRect: target.rect, covers };
954
+ }
955
+ async function captureCoverage(page, selector) {
956
+ const context = page.context?.();
957
+ if (!context || typeof context.newCDPSession !== "function") {
958
+ throw new Error(
959
+ "notCovered requires a Chromium CDP session (page.context().newCDPSession); it is Chromium-only."
960
+ );
961
+ }
962
+ const cdp = await context.newCDPSession(page);
963
+ try {
964
+ await cdp.send("DOM.enable");
965
+ const { root } = await cdp.send("DOM.getDocument", { depth: -1 });
966
+ const { nodeId } = await cdp.send("DOM.querySelector", { nodeId: root.nodeId, selector });
967
+ if (!nodeId) throw new Error(`Expected "${selector}" to exist for notCovered.`);
968
+ const described = await cdp.send("DOM.describeNode", { nodeId });
969
+ const backendId = described.node?.backendNodeId;
970
+ if (backendId == null) throw new Error(`Could not resolve backendNodeId for "${selector}".`);
971
+ const snapshot = await cdp.send("DOMSnapshot.captureSnapshot", {
972
+ computedStyles: [...COVERAGE_STYLE_KEYS],
973
+ includePaintOrder: true,
974
+ includeDOMRects: true
975
+ });
976
+ const doc = snapshot.documents?.[0];
977
+ if (!doc) throw new Error("DOMSnapshot returned no documents.");
978
+ const targetNodeIndex = nodeIndexForBackendId(doc.nodes, backendId);
979
+ if (targetNodeIndex < 0) {
980
+ throw new Error(`Expected "${selector}" to be laid out and painted for notCovered, but it was not in the paint tree.`);
981
+ }
982
+ return collectCoverageCandidates(doc, snapshot.strings ?? [], targetNodeIndex);
983
+ } finally {
984
+ await cdp.detach().catch(() => void 0);
985
+ }
986
+ }
669
987
  function createExpect(page, outputDir) {
670
988
  async function waitForVisible(selector) {
671
989
  const locator = page.locator(selector).first();
@@ -680,6 +998,64 @@ function createExpect(page, outputDir) {
680
998
  throw new Error(`Expected "${selector}" to be visible.`);
681
999
  }
682
1000
  }
1001
+ async function hitTestAtPoint(selector, x, y) {
1002
+ const locator = page.locator(selector).first();
1003
+ const raw = await locator.evaluate((target, coords) => {
1004
+ const hit = document.elementFromPoint(coords.x, coords.y);
1005
+ if (!hit) {
1006
+ return { found: false, tag: null, id: null, classes: [], isTarget: false, isDescendantOfTarget: false, isAncestorOfTarget: false };
1007
+ }
1008
+ return {
1009
+ found: true,
1010
+ tag: hit.tagName,
1011
+ id: hit.id || null,
1012
+ classes: Array.from(hit.classList),
1013
+ isTarget: hit === target,
1014
+ isDescendantOfTarget: hit !== target && target.contains(hit),
1015
+ isAncestorOfTarget: hit !== target && hit.contains(target)
1016
+ };
1017
+ }, { x, y });
1018
+ return {
1019
+ ...raw,
1020
+ point: { x, y },
1021
+ description: describeHitElement(raw)
1022
+ };
1023
+ }
1024
+ async function resolvePoint(selector, point) {
1025
+ if (point && point !== "center") return point;
1026
+ const rect = await firstRect(page, selector);
1027
+ if (!rect) throw new Error(`Expected "${selector}" to exist.`);
1028
+ return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
1029
+ }
1030
+ async function inFrontOfByPaintOrder(topSelector, lowerSelector) {
1031
+ const orders = await capturePaintOrders(page, [topSelector, lowerSelector]);
1032
+ const top = orders.get(topSelector);
1033
+ const lower = orders.get(lowerSelector);
1034
+ if (!top.found || top.paintOrder === null) {
1035
+ 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).`);
1036
+ }
1037
+ if (!lower.found || lower.paintOrder === null) {
1038
+ 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).`);
1039
+ }
1040
+ if (top.paintOrder > lower.paintOrder) return;
1041
+ const topRect = await firstRect(page, topSelector);
1042
+ const lowerRect = await firstRect(page, lowerSelector);
1043
+ 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.)`;
1044
+ throw new Error(
1045
+ `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}`
1046
+ );
1047
+ }
1048
+ async function inFrontOfAtPoint(topSelector, lowerSelector, at) {
1049
+ const topHit = await hitTestAtPoint(topSelector, at.x, at.y);
1050
+ const lowerHit = await hitTestAtPoint(lowerSelector, at.x, at.y);
1051
+ const hitsTop = topHit.isTarget || topHit.isDescendantOfTarget;
1052
+ const hitsLower = lowerHit.isTarget || lowerHit.isDescendantOfTarget;
1053
+ if (!hitsTop || hitsLower) {
1054
+ throw new Error(
1055
+ `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}).`
1056
+ );
1057
+ }
1058
+ }
683
1059
  return {
684
1060
  async visible(selector) {
685
1061
  await waitForVisible(selector);
@@ -756,6 +1132,135 @@ function createExpect(page, outputDir) {
756
1132
  throw new Error(`Expected "${topSelector}" z-index (${values.top}) to be above "${lowerSelector}" (${values.lower}).`);
757
1133
  }
758
1134
  },
1135
+ async rects(selectors) {
1136
+ const snapshots = [];
1137
+ for (const selector of selectors) {
1138
+ const rect = await firstRect(page, selector);
1139
+ if (!rect) throw new Error(`Expected "${selector}" to exist.`);
1140
+ snapshots.push({ selector, ...rect });
1141
+ }
1142
+ return snapshots;
1143
+ },
1144
+ async hitTest(selector, point = "center") {
1145
+ await waitForVisible(selector);
1146
+ const resolved = await resolvePoint(selector, point);
1147
+ return hitTestAtPoint(selector, resolved.x, resolved.y);
1148
+ },
1149
+ async sharedEdge(a, b, edge, options = {}) {
1150
+ await waitForVisible(a);
1151
+ await waitForVisible(b);
1152
+ const tolerance = options.tolerance ?? 1;
1153
+ const first = await firstRect(page, a);
1154
+ const second = await firstRect(page, b);
1155
+ if (!first || !second) throw new Error(`Expected both "${a}" and "${b}" to exist.`);
1156
+ const delta = Math.abs(first[edge] - second[edge]);
1157
+ if (delta > tolerance) {
1158
+ throw new Error(
1159
+ `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)}`
1160
+ );
1161
+ }
1162
+ },
1163
+ async sharedAxis(a, b, axis, options = {}) {
1164
+ await waitForVisible(a);
1165
+ await waitForVisible(b);
1166
+ const tolerance = options.tolerance ?? 1;
1167
+ const first = await firstRect(page, a);
1168
+ const second = await firstRect(page, b);
1169
+ if (!first || !second) throw new Error(`Expected both "${a}" and "${b}" to exist.`);
1170
+ const center = (rect) => axis === "centerX" ? rect.left + rect.width / 2 : rect.top + rect.height / 2;
1171
+ const firstCenter = center(first);
1172
+ const secondCenter = center(second);
1173
+ const delta = Math.abs(firstCenter - secondCenter);
1174
+ if (delta > tolerance) {
1175
+ throw new Error(
1176
+ `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)}`
1177
+ );
1178
+ }
1179
+ },
1180
+ async unoccluded(selector, options = {}) {
1181
+ await waitForVisible(selector);
1182
+ const resolved = await resolvePoint(selector, options.point ?? "center");
1183
+ const hit = await hitTestAtPoint(selector, resolved.x, resolved.y);
1184
+ if (!hit.found) {
1185
+ throw new Error(`Expected "${selector}" to receive pixels at (${resolved.x.toFixed(1)}, ${resolved.y.toFixed(1)}), but no element was painted there.`);
1186
+ }
1187
+ if (hit.isTarget || hit.isDescendantOfTarget || hit.isAncestorOfTarget) return;
1188
+ throw new Error(
1189
+ `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.`
1190
+ );
1191
+ },
1192
+ async inFrontOf(topSelector, lowerSelector, options = {}) {
1193
+ await waitForVisible(topSelector);
1194
+ await waitForVisible(lowerSelector);
1195
+ if (options.at) {
1196
+ await inFrontOfAtPoint(topSelector, lowerSelector, options.at);
1197
+ return;
1198
+ }
1199
+ await inFrontOfByPaintOrder(topSelector, lowerSelector);
1200
+ },
1201
+ async notCovered(selector) {
1202
+ await waitForVisible(selector);
1203
+ const coverage = await captureCoverage(page, selector);
1204
+ const covering = coverage.covers.filter((c) => c.paint > coverage.targetPaint);
1205
+ if (covering.length === 0) return;
1206
+ const worst = covering.reduce((a, b) => b.paint > a.paint ? b : a);
1207
+ throw new Error(
1208
+ `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.`
1209
+ );
1210
+ },
1211
+ async gapBetween(a, b, axis, options) {
1212
+ if (options.equals === void 0 && options.min === void 0 && options.max === void 0) {
1213
+ throw new Error("gapBetween requires at least one of { equals, min, max }.");
1214
+ }
1215
+ await waitForVisible(a);
1216
+ await waitForVisible(b);
1217
+ const tolerance = options.tolerance ?? 1;
1218
+ const first = await firstRect(page, a);
1219
+ const second = await firstRect(page, b);
1220
+ if (!first || !second) throw new Error(`Expected both "${a}" and "${b}" to exist.`);
1221
+ const gap = axisGap(first, second, axis);
1222
+ const allowsNegative = options.equals !== void 0 && options.equals < 0 || options.min !== void 0 && options.min < 0;
1223
+ const rectsNote = `Rects: ${a}=${JSON.stringify(first)}, ${b}=${JSON.stringify(second)}`;
1224
+ if (gap < -tolerance && !allowsNegative) {
1225
+ throw new Error(
1226
+ `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}`
1227
+ );
1228
+ }
1229
+ const violation = gapConstraintViolation(gap, options, tolerance);
1230
+ if (violation) {
1231
+ throw new Error(
1232
+ `Expected "${a}" and "${b}" ${axis} gap to satisfy ${describeGapConstraint(options)} within ${tolerance}px, but ${violation} (actual gap ${gap.toFixed(2)}px). ${rectsNote}`
1233
+ );
1234
+ }
1235
+ },
1236
+ async evenlySpaced(selectors, axis, options = {}) {
1237
+ if (selectors.length < 3) {
1238
+ throw new Error(`evenlySpaced requires at least 3 elements, got ${selectors.length}.`);
1239
+ }
1240
+ const tolerance = options.tolerance ?? 1;
1241
+ const boxes = [];
1242
+ for (const selector of selectors) {
1243
+ await waitForVisible(selector);
1244
+ const rect = await firstRect(page, selector);
1245
+ if (!rect) throw new Error(`Expected "${selector}" to exist.`);
1246
+ boxes.push({ selector, rect });
1247
+ }
1248
+ const sorted = [...boxes].sort((x, y) => axisEdges(x.rect, axis).leading - axisEdges(y.rect, axis).leading);
1249
+ const gaps = sorted.slice(1).map((box, i) => ({
1250
+ from: sorted[i].selector,
1251
+ to: box.selector,
1252
+ gap: axisGap(sorted[i].rect, box.rect, axis)
1253
+ }));
1254
+ const widest = gaps.reduce((acc, g) => g.gap > acc.gap ? g : acc);
1255
+ const narrowest = gaps.reduce((acc, g) => g.gap < acc.gap ? g : acc);
1256
+ const spread = widest.gap - narrowest.gap;
1257
+ if (spread > tolerance) {
1258
+ const list = gaps.map((g) => `${g.from}\u2192${g.to}=${g.gap.toFixed(2)}px`).join(", ");
1259
+ throw new Error(
1260
+ `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}`
1261
+ );
1262
+ }
1263
+ },
759
1264
  async stableAfterAnimation(selector = "body", options = {}) {
760
1265
  const frameCount = options.frameCount ?? 3;
761
1266
  const timeoutMs = options.timeoutMs ?? 2e3;
@@ -815,7 +1320,13 @@ async function openMountPage(input) {
815
1320
  const entryPath = writeEntry(input.runtime.tempDir, input.name, input.entrySource);
816
1321
  const htmlPath = writeHtml(input.runtime.tempDir, input.name, entryPath, input.css);
817
1322
  await installTraceBinding(input.runtime.page, input.traceRecorder, input.traceContext ?? {});
818
- await installBridge(page, input.bridge.state, input.bridge.manifest, input.bridge.bridgeInit);
1323
+ await installBridge(
1324
+ page,
1325
+ input.bridge.state,
1326
+ input.bridge.manifest,
1327
+ input.bridge.bridgeInit,
1328
+ input.runtime.transportCleanups
1329
+ );
819
1330
  const mountedBridge = createMountedBridge(page, input.bridge.state);
820
1331
  const context = {
821
1332
  page,
@@ -890,7 +1401,8 @@ async function createHarness(config = {}) {
890
1401
  server: workerRuntime.server,
891
1402
  page,
892
1403
  tempDir,
893
- rootDir
1404
+ rootDir,
1405
+ transportCleanups: []
894
1406
  };
895
1407
  const css = (config.css ?? []).map((cssPath) => {
896
1408
  const resolved = resolveFromRoot(rootDir, cssPath);
@@ -921,7 +1433,15 @@ async function createHarness(config = {}) {
921
1433
  } else if (config.generatedApp) {
922
1434
  const generatedApp = resolveFromRoot(rootDir, config.generatedApp);
923
1435
  assertFileExists(generatedApp, "Generated app");
924
- entrySource = buildGeneratedAppEntrySource({ generatedApp, routeKey });
1436
+ entrySource = buildGeneratedAppEntrySource({
1437
+ generatedApp,
1438
+ routeKey,
1439
+ route,
1440
+ navigationModule: resolveEsmModule(rootDir, "@noego/wood/navigation"),
1441
+ clientModule: resolveEsmModule(rootDir, "@noego/wood/client"),
1442
+ enableRendererTrace: config.trace?.enabled === true,
1443
+ traceMinLevel: config.trace?.minLevel ?? "debug"
1444
+ });
925
1445
  } else {
926
1446
  throw new Error("mountRoute requires either viewsConfig or generatedApp in browser.createHarness().");
927
1447
  }
@@ -1018,11 +1538,20 @@ async function createHarness(config = {}) {
1018
1538
  hooks: config.page
1019
1539
  });
1020
1540
  },
1541
+ async flushRendererTraces() {
1542
+ await page.evaluate(() => window.__woodBrowserTraceFlush?.()).catch(() => void 0);
1543
+ },
1021
1544
  async destroy() {
1022
1545
  if (destroyed) {
1023
1546
  return;
1024
1547
  }
1025
1548
  destroyed = true;
1549
+ for (const cleanup of runtime.transportCleanups.splice(0)) {
1550
+ try {
1551
+ cleanup();
1552
+ } catch {
1553
+ }
1554
+ }
1026
1555
  await Promise.all([
1027
1556
  waitForBoundedClose(page.close()),
1028
1557
  waitForBoundedClose(context.close())