@noego/wood 0.6.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (247) hide show
  1. package/README.md +7 -0
  2. package/dist/browser-test-setup.cjs +29 -0
  3. package/dist/browser-test-setup.cjs.map +1 -0
  4. package/dist/browser-test-setup.d.cts +8 -0
  5. package/dist/browser-test-setup.d.ts +8 -0
  6. package/dist/browser-test-setup.js +9 -0
  7. package/dist/browser-test-setup.js.map +1 -0
  8. package/dist/client/global_polyfill.d.cts +1 -0
  9. package/dist/client/global_polyfill.d.ts +1 -0
  10. package/dist/client/index.cjs +7 -0
  11. package/dist/client/index.cjs.map +1 -1
  12. package/dist/client/index.d.cts +8 -4
  13. package/dist/client/index.d.ts +8 -4
  14. package/dist/client/index.js +6 -1
  15. package/dist/client/index.js.map +1 -1
  16. package/dist/codegen/renderer_app_generator.cjs +97 -24
  17. package/dist/codegen/renderer_app_generator.cjs.map +1 -1
  18. package/dist/codegen/renderer_app_generator.js +97 -24
  19. package/dist/codegen/renderer_app_generator.js.map +1 -1
  20. package/dist/codegen/type_generator.cjs +9 -0
  21. package/dist/codegen/type_generator.cjs.map +1 -1
  22. package/dist/codegen/type_generator.js +9 -0
  23. package/dist/codegen/type_generator.js.map +1 -1
  24. package/dist/config/config_parser.cjs +10 -0
  25. package/dist/config/config_parser.cjs.map +1 -1
  26. package/dist/config/config_parser.js +10 -0
  27. package/dist/config/config_parser.js.map +1 -1
  28. package/dist/fetch.cjs +38 -0
  29. package/dist/fetch.cjs.map +1 -0
  30. package/dist/fetch.d.cts +7 -0
  31. package/dist/fetch.d.ts +7 -0
  32. package/dist/fetch.js +12 -0
  33. package/dist/fetch.js.map +1 -0
  34. package/dist/frontend/frontend_page.cjs +3 -3
  35. package/dist/frontend/frontend_page.cjs.map +1 -1
  36. package/dist/frontend/frontend_page.d.cts +1 -1
  37. package/dist/frontend/frontend_page.d.ts +1 -1
  38. package/dist/frontend/frontend_page.js +3 -3
  39. package/dist/frontend/frontend_page.js.map +1 -1
  40. package/dist/frontend/frontend_surface.cjs +31 -17
  41. package/dist/frontend/frontend_surface.cjs.map +1 -1
  42. package/dist/frontend/frontend_surface.d.cts +13 -2
  43. package/dist/frontend/frontend_surface.d.ts +13 -2
  44. package/dist/frontend/frontend_surface.js +37 -17
  45. package/dist/frontend/frontend_surface.js.map +1 -1
  46. package/dist/frontend/index.d.cts +3 -0
  47. package/dist/frontend/index.d.ts +3 -0
  48. package/dist/index.cjs +5 -0
  49. package/dist/index.cjs.map +1 -1
  50. package/dist/index.d.cts +27 -1
  51. package/dist/index.d.ts +27 -1
  52. package/dist/index.js +3 -0
  53. package/dist/index.js.map +1 -1
  54. package/dist/loader/index.cjs +19 -2
  55. package/dist/loader/index.cjs.map +1 -1
  56. package/dist/loader/index.d.cts +1 -0
  57. package/dist/loader/index.d.ts +1 -0
  58. package/dist/loader/index.js +19 -1
  59. package/dist/loader/index.js.map +1 -1
  60. package/dist/loader/loader_orchestration.cjs +82 -0
  61. package/dist/loader/loader_orchestration.cjs.map +1 -0
  62. package/dist/loader/loader_orchestration.d.cts +66 -0
  63. package/dist/loader/loader_orchestration.d.ts +66 -0
  64. package/dist/loader/loader_orchestration.js +51 -0
  65. package/dist/loader/loader_orchestration.js.map +1 -0
  66. package/dist/loader/loader_runner.cjs +46 -41
  67. package/dist/loader/loader_runner.cjs.map +1 -1
  68. package/dist/loader/loader_runner.d.cts +12 -6
  69. package/dist/loader/loader_runner.d.ts +12 -6
  70. package/dist/loader/loader_runner.js +50 -41
  71. package/dist/loader/loader_runner.js.map +1 -1
  72. package/dist/native_browser.types-BY2daUOS.d.ts +189 -0
  73. package/dist/native_browser.types-vWXtE4rM.d.cts +189 -0
  74. package/dist/navigation/index.cjs +15 -2
  75. package/dist/navigation/index.cjs.map +1 -1
  76. package/dist/navigation/index.d.cts +6 -28
  77. package/dist/navigation/index.d.ts +6 -28
  78. package/dist/navigation/index.js +8 -1
  79. package/dist/navigation/index.js.map +1 -1
  80. package/dist/navigation/layout_preservation.cjs.map +1 -1
  81. package/dist/navigation/layout_preservation.d.cts +27 -2
  82. package/dist/navigation/layout_preservation.d.ts +27 -2
  83. package/dist/navigation/layout_preservation.js.map +1 -1
  84. package/dist/runtime/get_container.cjs +2 -0
  85. package/dist/runtime/get_container.cjs.map +1 -1
  86. package/dist/runtime/get_container.js +2 -0
  87. package/dist/runtime/get_container.js.map +1 -1
  88. package/dist/runtime/index.cjs +5 -0
  89. package/dist/runtime/index.cjs.map +1 -1
  90. package/dist/runtime/index.d.cts +2 -0
  91. package/dist/runtime/index.d.ts +2 -0
  92. package/dist/runtime/index.js +3 -0
  93. package/dist/runtime/index.js.map +1 -1
  94. package/dist/runtime/runtime_capabilities.cjs +35 -0
  95. package/dist/runtime/runtime_capabilities.cjs.map +1 -0
  96. package/dist/runtime/runtime_capabilities.d.cts +6 -0
  97. package/dist/runtime/runtime_capabilities.d.ts +6 -0
  98. package/dist/runtime/runtime_capabilities.js +11 -0
  99. package/dist/runtime/runtime_capabilities.js.map +1 -0
  100. package/dist/runtime/runtime_module.cjs +2 -0
  101. package/dist/runtime/runtime_module.cjs.map +1 -1
  102. package/dist/runtime/runtime_module.js +2 -0
  103. package/dist/runtime/runtime_module.js.map +1 -1
  104. package/dist/test-wood.cjs +150 -0
  105. package/dist/test-wood.cjs.map +1 -0
  106. package/dist/test-wood.d.cts +62 -0
  107. package/dist/test-wood.d.ts +62 -0
  108. package/dist/test-wood.js +115 -0
  109. package/dist/test-wood.js.map +1 -0
  110. package/dist/testing/browser.cjs +92 -163
  111. package/dist/testing/browser.cjs.map +1 -1
  112. package/dist/testing/browser.d.cts +14 -13
  113. package/dist/testing/browser.d.ts +14 -13
  114. package/dist/testing/browser.js +88 -162
  115. package/dist/testing/browser.js.map +1 -1
  116. package/dist/testing/create_controller_harness.d.cts +3 -1
  117. package/dist/testing/create_controller_harness.d.ts +3 -1
  118. package/dist/testing/create_router_bridge.cjs +19 -15
  119. package/dist/testing/create_router_bridge.cjs.map +1 -1
  120. package/dist/testing/create_router_bridge.d.cts +8 -1
  121. package/dist/testing/create_router_bridge.d.ts +8 -1
  122. package/dist/testing/create_router_bridge.js +18 -15
  123. package/dist/testing/create_router_bridge.js.map +1 -1
  124. package/dist/testing/index.cjs +12 -4
  125. package/dist/testing/index.cjs.map +1 -1
  126. package/dist/testing/index.d.cts +12 -5
  127. package/dist/testing/index.d.ts +12 -5
  128. package/dist/testing/index.js +9 -3
  129. package/dist/testing/index.js.map +1 -1
  130. package/dist/testing/native_browser/browser_pool.cjs +31 -0
  131. package/dist/testing/native_browser/browser_pool.cjs.map +1 -0
  132. package/dist/testing/native_browser/browser_pool.d.cts +1 -0
  133. package/dist/testing/native_browser/browser_pool.d.ts +1 -0
  134. package/dist/testing/native_browser/browser_pool.js +6 -0
  135. package/dist/testing/native_browser/browser_pool.js.map +1 -0
  136. package/dist/testing/native_browser/happy_dom_tab.cjs +107 -0
  137. package/dist/testing/native_browser/happy_dom_tab.cjs.map +1 -0
  138. package/dist/testing/native_browser/happy_dom_tab.d.cts +35 -0
  139. package/dist/testing/native_browser/happy_dom_tab.d.ts +35 -0
  140. package/dist/testing/native_browser/happy_dom_tab.js +73 -0
  141. package/dist/testing/native_browser/happy_dom_tab.js.map +1 -0
  142. package/dist/testing/native_browser/happy_dom_transfer.cjs +29 -0
  143. package/dist/testing/native_browser/happy_dom_transfer.cjs.map +1 -0
  144. package/dist/testing/native_browser/happy_dom_transfer.d.cts +1 -0
  145. package/dist/testing/native_browser/happy_dom_transfer.d.ts +1 -0
  146. package/dist/testing/native_browser/happy_dom_transfer.js +5 -0
  147. package/dist/testing/native_browser/happy_dom_transfer.js.map +1 -0
  148. package/dist/testing/native_browser/host_transport.cjs +89 -0
  149. package/dist/testing/native_browser/host_transport.cjs.map +1 -0
  150. package/dist/testing/native_browser/host_transport.d.cts +44 -0
  151. package/dist/testing/native_browser/host_transport.d.ts +44 -0
  152. package/dist/testing/native_browser/host_transport.js +65 -0
  153. package/dist/testing/native_browser/host_transport.js.map +1 -0
  154. package/dist/testing/native_browser/native_browser.types.cjs +17 -0
  155. package/dist/testing/native_browser/native_browser.types.cjs.map +1 -0
  156. package/dist/testing/native_browser/native_browser.types.d.cts +31 -0
  157. package/dist/testing/native_browser/native_browser.types.d.ts +31 -0
  158. package/dist/testing/native_browser/native_browser.types.js +1 -0
  159. package/dist/testing/native_browser/native_browser.types.js.map +1 -0
  160. package/dist/testing/native_browser/native_browser_environment.cjs +143 -0
  161. package/dist/testing/native_browser/native_browser_environment.cjs.map +1 -0
  162. package/dist/testing/native_browser/native_browser_environment.d.cts +36 -0
  163. package/dist/testing/native_browser/native_browser_environment.d.ts +36 -0
  164. package/dist/testing/native_browser/native_browser_environment.js +109 -0
  165. package/dist/testing/native_browser/native_browser_environment.js.map +1 -0
  166. package/dist/testing/native_browser/native_browser_lifecycle.cjs +31 -0
  167. package/dist/testing/native_browser/native_browser_lifecycle.cjs.map +1 -0
  168. package/dist/testing/native_browser/native_browser_lifecycle.d.cts +1 -0
  169. package/dist/testing/native_browser/native_browser_lifecycle.d.ts +1 -0
  170. package/dist/testing/native_browser/native_browser_lifecycle.js +6 -0
  171. package/dist/testing/native_browser/native_browser_lifecycle.js.map +1 -0
  172. package/dist/testing/native_browser/playwright_tab.cjs +124 -0
  173. package/dist/testing/native_browser/playwright_tab.cjs.map +1 -0
  174. package/dist/testing/native_browser/playwright_tab.d.cts +35 -0
  175. package/dist/testing/native_browser/playwright_tab.d.ts +35 -0
  176. package/dist/testing/native_browser/playwright_tab.js +100 -0
  177. package/dist/testing/native_browser/playwright_tab.js.map +1 -0
  178. package/dist/testing/native_browser/renderer_compilation.cjs +149 -0
  179. package/dist/testing/native_browser/renderer_compilation.cjs.map +1 -0
  180. package/dist/testing/native_browser/renderer_compilation.d.cts +35 -0
  181. package/dist/testing/native_browser/renderer_compilation.d.ts +35 -0
  182. package/dist/testing/native_browser/renderer_compilation.js +115 -0
  183. package/dist/testing/native_browser/renderer_compilation.js.map +1 -0
  184. package/dist/testing/native_browser/renderer_entry.cjs +148 -0
  185. package/dist/testing/native_browser/renderer_entry.cjs.map +1 -0
  186. package/dist/testing/native_browser/renderer_entry.d.cts +13 -0
  187. package/dist/testing/native_browser/renderer_entry.d.ts +13 -0
  188. package/dist/testing/native_browser/renderer_entry.js +114 -0
  189. package/dist/testing/native_browser/renderer_entry.js.map +1 -0
  190. package/dist/testing/registration_only_root.cjs +61 -5
  191. package/dist/testing/registration_only_root.cjs.map +1 -1
  192. package/dist/testing/registration_only_root.d.cts +25 -4
  193. package/dist/testing/registration_only_root.d.ts +25 -4
  194. package/dist/testing/registration_only_root.js +60 -5
  195. package/dist/testing/registration_only_root.js.map +1 -1
  196. package/dist/testing/runner_importer.cjs +75 -0
  197. package/dist/testing/runner_importer.cjs.map +1 -0
  198. package/dist/testing/runner_importer.d.cts +8 -0
  199. package/dist/testing/runner_importer.d.ts +8 -0
  200. package/dist/testing/runner_importer.js +41 -0
  201. package/dist/testing/runner_importer.js.map +1 -0
  202. package/dist/testing/test_wood_config.cjs +387 -272
  203. package/dist/testing/test_wood_config.cjs.map +1 -1
  204. package/dist/testing/test_wood_config.d.cts +19 -123
  205. package/dist/testing/test_wood_config.d.ts +19 -123
  206. package/dist/testing/test_wood_config.js +396 -270
  207. package/dist/testing/test_wood_config.js.map +1 -1
  208. package/dist/testing/wood_config_source.cjs +12 -0
  209. package/dist/testing/wood_config_source.cjs.map +1 -1
  210. package/dist/testing/wood_config_source.d.cts +10 -0
  211. package/dist/testing/wood_config_source.d.ts +10 -0
  212. package/dist/testing/wood_config_source.js +12 -0
  213. package/dist/testing/wood_config_source.js.map +1 -1
  214. package/dist/testing/wood_env_renderer.cjs +28 -3
  215. package/dist/testing/wood_env_renderer.cjs.map +1 -1
  216. package/dist/testing/wood_env_renderer.d.cts +13 -1
  217. package/dist/testing/wood_env_renderer.d.ts +13 -1
  218. package/dist/testing/wood_env_renderer.js +28 -3
  219. package/dist/testing/wood_env_renderer.js.map +1 -1
  220. package/dist/testing/wood_lazy_runtime.cjs +109 -0
  221. package/dist/testing/wood_lazy_runtime.cjs.map +1 -0
  222. package/dist/testing/wood_lazy_runtime.d.cts +36 -0
  223. package/dist/testing/wood_lazy_runtime.d.ts +36 -0
  224. package/dist/testing/wood_lazy_runtime.js +84 -0
  225. package/dist/testing/wood_lazy_runtime.js.map +1 -0
  226. package/dist/testing/wood_test_selection.cjs +34 -55
  227. package/dist/testing/wood_test_selection.cjs.map +1 -1
  228. package/dist/testing/wood_test_selection.d.cts +18 -24
  229. package/dist/testing/wood_test_selection.d.ts +18 -24
  230. package/dist/testing/wood_test_selection.js +30 -54
  231. package/dist/testing/wood_test_selection.js.map +1 -1
  232. package/dist/testing-api.cjs +17 -0
  233. package/dist/testing-api.cjs.map +1 -0
  234. package/dist/testing-api.d.cts +18 -0
  235. package/dist/testing-api.d.ts +18 -0
  236. package/dist/testing-api.js +1 -0
  237. package/dist/testing-api.js.map +1 -0
  238. package/dist/types/config.cjs.map +1 -1
  239. package/dist/types/config.d.cts +4 -0
  240. package/dist/types/config.d.ts +4 -0
  241. package/docs/native-browser-testing.md +164 -0
  242. package/package.json +31 -5
  243. package/src/components/NavigationShell.browser.test.ts +10 -10
  244. package/src/components/NavigationShell.cleanup.browser.test.ts +57 -0
  245. package/src/components/NavigationShell.scopes.browser.test.ts +5 -5
  246. package/src/components/NavigationShell.svelte +50 -24
  247. package/src/navigation/layout_preservation.ts +3 -1
@@ -0,0 +1,164 @@
1
+ # Native renderer test hosts
2
+
3
+ `testWood(config)` now accepts an explicit renderer host. This is application
4
+ composition and lifecycle support, not a new browser-testing language.
5
+
6
+ ```ts
7
+ import { testWood } from '@noego/wood';
8
+ import { resourceCase } from '@noego/testing';
9
+ import { it, expect } from 'vitest';
10
+
11
+ it('renders a conversation', resourceCase(async () => {
12
+ const env = await testWood('./wood.config.yml')
13
+ .select({main: {}, renderer: {path: ['main.chat']}})
14
+ .browser()
15
+ .use(preparedDatabase.stub)
16
+ .build();
17
+
18
+ const page = await env.open('main.chat', {
19
+ params: {id: '42'},
20
+ query: {filter: 'active'},
21
+ });
22
+ // The return value IS playwright.Page, not a proxy or Wood surface.
23
+ await page.getByRole('button', {name: 'Send'}).click();
24
+ await expect.poll(() => page.locator('.message').count()).toBe(1);
25
+ }));
26
+ ```
27
+
28
+ The test still declares any necessary schema/JSON data and application seams.
29
+ There is no automatic migration execution or copied application database.
30
+
31
+ ## Host and types
32
+
33
+ `.browser()` and `.browser('playwright')` select Playwright/Chromium. `open()`
34
+ returns `Promise<Page>` from Playwright. Use the ordinary locator, evaluate,
35
+ screenshot, context and page APIs. The test runner's assertions remain the test
36
+ runner's; Wood does not wrap them.
37
+
38
+ `.browser('happy_dom')` selects a real `Window` from happy-dom:
39
+
40
+ ```ts
41
+ const env = await testWood('./wood.config.yml')
42
+ .select({main: {}, renderer: {path: ['main.chat']}})
43
+ .browser('happy_dom')
44
+ .build();
45
+ const window = await env.open('main.chat');
46
+ window.document.querySelector('button')?.click();
47
+ await env.settle();
48
+ expect(window.document.querySelector('.message')?.textContent).toBe('Sent');
49
+ await env.dispose();
50
+ ```
51
+
52
+ The chosen type survives `.select()`, `.use()`, and replacement methods in
53
+ any order. These native environments intentionally do not expose Wood's old
54
+ `click`, `fill`, `exists`, `frontend`, or `rendererRoot` surfaces. Main `api`,
55
+ `rawApi`, `get`, `controller`, and the main root remain available. There is no
56
+ phantom second renderer container in the Node test process.
57
+
58
+ No `.browser()` call preserves the pre-existing testWood behavior, including
59
+ runner-provided DOM hosting, during migration. This additive release does not
60
+ change every old `open()` call's return type.
61
+
62
+ ## One browser for the run, many isolated tabs
63
+
64
+ Use the framework's Vitest global setup for one Chromium BrowserServer across
65
+ all fork workers:
66
+
67
+ ```ts
68
+ export default defineConfig({
69
+ test: {
70
+ environment: 'node',
71
+ globalSetup: ['@noego/wood/browser-test-setup'],
72
+ },
73
+ });
74
+ ```
75
+
76
+ An existing custom global setup can instead call
77
+ `startSharedWoodBrowser(rootDir)` from `@noego/wood/testing` and return its
78
+ `dispose()` as part of runner teardown. An existing `WOOD_BROWSER_WS_ENDPOINT`
79
+ is borrowed, not replaced or closed by a second setup owner.
80
+
81
+ Every built environment lazily owns its own BrowserContext. Each `open()`
82
+ creates a Page in that context; tabs belonging to that environment share its
83
+ browser storage and main backend, while different environments do not share
84
+ cookies/storage or backend roots. `env.dispose()` closes only its pages and
85
+ context. The runner closes the shared Chromium server. Ordinary cases should close their
86
+ environment or Page, not call `close()` on the borrowed shared Browser. With no global setup,
87
+ Wood uses a worker-local browser pool; that is reuse per worker, not a promise
88
+ of one browser across processes. An explicit shared endpoint that fails to
89
+ connect produces an error rather than silently launching another Chromium.
90
+
91
+ Happy-dom needs no Chromium. Each open owns a separate native Window/VM realm,
92
+ DOM, timers and storage. Windows from one environment share the main backend.
93
+ No Node global `window`/`document` swapping is performed. This is test-state
94
+ isolation, **not a security sandbox**: evaluate only trusted application/test
95
+ code. Happy-dom's JavaScript-evaluation safety warning is not suppressed.
96
+
97
+ ## What the framework assembles
98
+
99
+ The native host consumes the original Wood config, the selected views/layouts,
100
+ production controllers and companion data loaders. It uses the production
101
+ renderer generator and Vite's Svelte pipeline to compile the selected renderer.
102
+ Only compiled code is cached; dependency changes invalidate it. Each native
103
+ realm creates its own real renderer container and navigation state.
104
+
105
+ Optional global CSS belongs in application configuration, not a test harness:
106
+
107
+ ```yaml
108
+ renderer:
109
+ componentDir: ui
110
+ openapi: ui/stitch.yaml
111
+ controllers: ui/controllers
112
+ css: [ui/styles.css]
113
+ ```
114
+
115
+ The bridge is built from the production operation catalogue. Renderer calls
116
+ enter the same selected main router, validation, middleware, IoC scope and
117
+ controller as `env.api`. Out-of-aperture main calls and renderer `open()` calls
118
+ fail; importing a complete typed API does not widen selection.
119
+
120
+ A compose hook may publish backend events through its standard `context.events`
121
+ source, or return its existing `events` source. The native host subscribes to
122
+ that source automatically. Initial subscription registration completes before
123
+ `open()` resolves; RPC waits for admitted subscriptions too. The framework does
124
+ not fabricate replayed events to hide late-subscription bugs.
125
+
126
+ Renderer trace deliveries use Wood's trace ingress. `env.settle()` drains host
127
+ bridge deliveries; a configured application trace persistence writer still owns
128
+ its durable commit barrier. There is no pretend durable batch acknowledgement.
129
+
130
+ ## Lifecycle and scope of this first version
131
+
132
+ Building records application composition without opening a tab or Window.
133
+ First `open()` compiles the selected renderer and acquires the host. Native
134
+ Playwright reload remounts the renderer without duplicating subscriptions.
135
+ Closing an environment fences new opens, joins admitted opens, attempts all
136
+ cleanup steps, and reports cleanup failures without abandoning other resources.
137
+ Page mount errors include the route and browser diagnostics.
138
+
139
+ Main-process `class/function/method/value/middleware` seams retain the existing
140
+ NoEgo contract. **Renderer-side Node replacement objects/closures and `.load()`
141
+ overrides are not transferred into native realms in this first version.** Those
142
+ requests fail rather than silently applying to an unused Node renderer. Keep
143
+ such tests on the existing in-process renderer lane until a separately designed
144
+ renderer-replacement transport exists. Production renderer controllers and
145
+ loaders themselves do run in both new hosts.
146
+
147
+ Native browser hosts do not claim real Electron IPC, window controls, GPU or
148
+ OS integration. They are not an emulator for Electron-only methods.
149
+
150
+ Existing `browser.createHarness()`, application adapters, and old tests remain
151
+ available until an explicit later migration. Do not mass-convert the suite as
152
+ part of adopting this foundation.
153
+
154
+ ## Validation
155
+
156
+ - `npm run typecheck`
157
+ - `npm run build`
158
+ - `npx tsc -p qualification/native_browser/tsconfig.json`
159
+ - `npx vitest run src/testing/native_browser/native_browser.test.ts`
160
+ - `npm test` (the Wood runner now owns its shared browser globally)
161
+
162
+ The native contract tests use real Chromium and real happy-dom, not mocked
163
+ browser/page implementations. The declaration fixture checks native return
164
+ types, chain preservation and rejection of the wrong host's APIs.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noego/wood",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -47,6 +47,12 @@
47
47
  "import": "./dist/testing/index.js",
48
48
  "default": "./dist/testing/index.js"
49
49
  },
50
+ "./testing-api": {
51
+ "types": "./dist/testing-api.d.ts",
52
+ "require": "./dist/testing-api.cjs",
53
+ "import": "./dist/testing-api.js",
54
+ "default": "./dist/testing-api.js"
55
+ },
50
56
  "./trace-testing": {
51
57
  "types": "./dist/trace-testing/index.d.ts",
52
58
  "require": "./dist/trace-testing/index.cjs",
@@ -117,7 +123,17 @@
117
123
  "./navigation-shell": "./src/components/NavigationShell.svelte",
118
124
  "./components": "./src/components/index.js",
119
125
  "./loader": "./loaders/loader.mjs",
120
- "./package.json": "./package.json"
126
+ "./package.json": "./package.json",
127
+ "./browser-test-setup": {
128
+ "types": "./dist/browser-test-setup.d.ts",
129
+ "import": "./dist/browser-test-setup.js",
130
+ "require": "./dist/browser-test-setup.cjs"
131
+ },
132
+ "./fetch": {
133
+ "types": "./dist/fetch.d.ts",
134
+ "import": "./dist/fetch.js",
135
+ "require": "./dist/fetch.cjs"
136
+ }
121
137
  },
122
138
  "scripts": {
123
139
  "build": "tsup",
@@ -129,6 +145,7 @@
129
145
  "dependencies": {
130
146
  "@noego/ioc": "^0.8.0",
131
147
  "@noego/logger": "^0.2.0",
148
+ "@noego/runtime": "^0.2.0",
132
149
  "@noego/stitch": "^1.1.0",
133
150
  "@noego/telemetry": "^0.2.0",
134
151
  "@noego/trace": "^0.2.0",
@@ -142,11 +159,13 @@
142
159
  "rxjs": "^7.x"
143
160
  },
144
161
  "peerDependencies": {
145
- "@noego/testing": ">=0.6.0",
162
+ "@noego/testing": ">=0.7.0",
146
163
  "@sveltejs/vite-plugin-svelte": "^4.0.0",
147
164
  "electron": ">=28.0.0",
165
+ "happy-dom": "^20.14.0",
148
166
  "playwright": "^1.40.0",
149
- "svelte": "^5.x"
167
+ "svelte": "^5.x",
168
+ "vite": "^5.0.0"
150
169
  },
151
170
  "peerDependenciesMeta": {
152
171
  "@sveltejs/vite-plugin-svelte": {
@@ -157,10 +176,16 @@
157
176
  },
158
177
  "@noego/testing": {
159
178
  "optional": true
179
+ },
180
+ "happy-dom": {
181
+ "optional": true
182
+ },
183
+ "vite": {
184
+ "optional": true
160
185
  }
161
186
  },
162
187
  "devDependencies": {
163
- "@noego/testing": "^0.6.0",
188
+ "@noego/testing": "^0.7.0",
164
189
  "@sveltejs/vite-plugin-svelte": "^4.0.4",
165
190
  "@types/better-sqlite3": "^9.6.0",
166
191
  "@types/js-yaml": "^4.x",
@@ -169,6 +194,7 @@
169
194
  "electron-builder": "^25.x",
170
195
  "electron-vite": "^2.x",
171
196
  "esbuild": "^0.25.x",
197
+ "happy-dom": "^20.14.0",
172
198
  "playwright": "^1.40.0",
173
199
  "tsup": "^8.5.0",
174
200
  "typescript": "^5.8.3",
@@ -2,7 +2,7 @@ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import { afterEach, describe, expect, test } from 'vitest';
5
- import { browser } from '../testing/browser';
5
+ import { createBrowserFixture } from '../testing/browser';
6
6
 
7
7
  type ReproSnapshot = {
8
8
  pageCreateCount: number;
@@ -142,7 +142,7 @@ describe('NavigationShell failed transition recovery', () => {
142
142
 
143
143
  const pageErrors: string[] = [];
144
144
  const consoleErrors: string[] = [];
145
- const harness = await browser.createHarness({
145
+ const harness = await createBrowserFixture({
146
146
  rootDir: woodRoot,
147
147
  browser: { headless: true },
148
148
  page: {
@@ -158,7 +158,7 @@ describe('NavigationShell failed transition recovery', () => {
158
158
  const mounted = await harness.mountComponent(
159
159
  path.join(rootDir, 'ui', 'NavigationShellRepro.svelte'),
160
160
  );
161
- await mounted.locator('[data-repro-view]').waitFor({ state: 'visible', timeout: 5_000 }).catch(
161
+ await mounted.page.locator('[data-repro-view]').waitFor({ state: 'visible', timeout: 5_000 }).catch(
162
162
  async (error: unknown) => {
163
163
  throw new Error(
164
164
  `${error instanceof Error ? error.message : String(error)}\n` +
@@ -170,16 +170,16 @@ describe('NavigationShell failed transition recovery', () => {
170
170
  );
171
171
 
172
172
  await expect.poll(async () => {
173
- return (await mounted.locator('[data-repro-view]').textContent())?.replace(/\s+/g, ' ').trim();
173
+ return (await mounted.page.locator('[data-repro-view]').textContent())?.replace(/\s+/g, ' ').trim();
174
174
  }).toBe('thread=1;controller=initial-page');
175
175
 
176
- await mounted.evaluate(() => {
176
+ await mounted.page.evaluate(() => {
177
177
  (window as unknown as {
178
178
  __WOOD_NAVIGATION_REPRO_GO__: (threadId: string) => void;
179
179
  }).__WOOD_NAVIGATION_REPRO_GO__('2');
180
180
  });
181
181
  await expect.poll(async () => {
182
- return await mounted.evaluate(() => {
182
+ return await mounted.page.evaluate(() => {
183
183
  return (window as unknown as {
184
184
  __WOOD_NAVIGATION_REPRO__: { layoutCreateCount: number };
185
185
  }).__WOOD_NAVIGATION_REPRO__.layoutCreateCount;
@@ -187,23 +187,23 @@ describe('NavigationShell failed transition recovery', () => {
187
187
  }).toBe(1);
188
188
  await expect.poll(() => pageErrors.length + consoleErrors.length).toBeGreaterThan(0);
189
189
 
190
- await mounted.evaluate(() => {
190
+ await mounted.page.evaluate(() => {
191
191
  (window as unknown as {
192
192
  __WOOD_NAVIGATION_REPRO_GO__: (threadId: string) => void;
193
193
  }).__WOOD_NAVIGATION_REPRO_GO__('2');
194
194
  });
195
195
  await expect.poll(async () => {
196
- return await mounted.evaluate(() => {
196
+ return await mounted.page.evaluate(() => {
197
197
  return (window as unknown as {
198
198
  __WOOD_NAVIGATION_REPRO__: { pageCreateCount: number };
199
199
  }).__WOOD_NAVIGATION_REPRO__.pageCreateCount;
200
200
  });
201
201
  }).toBe(2);
202
202
  await expect.poll(async () => {
203
- return (await mounted.locator('[data-repro-view]').textContent())?.replace(/\s+/g, ' ').trim();
203
+ return (await mounted.page.locator('[data-repro-view]').textContent())?.replace(/\s+/g, ' ').trim();
204
204
  }).toBe('thread=2;controller=page-2');
205
205
 
206
- const snapshot = await mounted.evaluate<ReproSnapshot>(() => {
206
+ const snapshot = await mounted.page.evaluate<ReproSnapshot>(() => {
207
207
  const state = (window as unknown as {
208
208
  __WOOD_NAVIGATION_REPRO__: Omit<ReproSnapshot, 'rendered' | 'pageErrors'>;
209
209
  }).__WOOD_NAVIGATION_REPRO__;
@@ -0,0 +1,57 @@
1
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { expect, test } from 'vitest';
5
+ import { resourceCase } from '@noego/testing';
6
+ import { createBrowserFixture } from '../testing/browser';
7
+
8
+ test('retains every renderer cleanup failure and disposes remaining owners once', resourceCase(async (resources) => {
9
+ const root = mkdtempSync(path.join(os.tmpdir(), 'wood-shell-cleanup-'));
10
+ resources.own({ dispose: () => rmSync(root, { recursive: true, force: true }) });
11
+ const woodRoot = path.resolve(__dirname, '../..');
12
+ writeFileSync(path.join(root, 'View.svelte'), '<main>cleanup probe</main>');
13
+ writeFileSync(path.join(root, 'Probe.svelte'), `<script lang="ts">
14
+ import '/@fs/${woodRoot}/src/client/global_polyfill.ts';
15
+ import NavigationShell from '/@fs/${woodRoot}/src/components/NavigationShell.svelte';
16
+ import View from './View.svelte';
17
+ const ledger: string[] = [];
18
+ const controllerError = new Error('controller failed');
19
+ const layoutError = new Error('layout scope failed');
20
+ const pageError = new Error('page scope failed');
21
+ const observerError = new Error('observer failed');
22
+ const entry = { key: 'main.probe', windowName: 'main', pageName: 'probe',
23
+ layoutPaths: [], layoutControllers: [], view: View, layouts: [] };
24
+ const initialControllerSet = {
25
+ controller: { dispose() { ledger.push('controller'); throw controllerError; } },
26
+ layoutControllers: [],
27
+ layoutScopes: [{ async dispose() { ledger.push('layout'); throw layoutError; } }],
28
+ pageScope: { async dispose() { ledger.push('page'); throw pageError; } },
29
+ };
30
+ let shell: any;
31
+ (window as any).__CHECK_CLEANUP__ = async () => {
32
+ const first = shell.disposeNavigation();
33
+ const same = first === shell.disposeNavigation();
34
+ try { await first; throw new Error('missing failure'); }
35
+ catch (error: any) {
36
+ const flatten = (value: any): any[] => value?.errors ? value.errors.flatMap(flatten) : [value];
37
+ return { same, ledger, errors: flatten(error).map((value: any) => value.message),
38
+ original: flatten(error).includes(controllerError) && flatten(error).includes(layoutError)
39
+ && flatten(error).includes(pageError) && flatten(error).includes(observerError) };
40
+ }
41
+ };
42
+ </script>
43
+ <NavigationShell bind:this={shell} manifest={{ [entry.key]: entry }} initialEntry={entry}
44
+ {initialControllerSet} getViewManifestEntryFn={() => entry}
45
+ onLifecycleError={() => { throw observerError; }} />`);
46
+ const harness = await createBrowserFixture({ rootDir: woodRoot, browser: { headless: true } });
47
+ resources.own({ dispose: () => harness.destroy() });
48
+ const mounted = await harness.mountComponent(path.join(root, 'Probe.svelte'));
49
+ await mounted.page.locator('main').waitFor({ state: 'visible' });
50
+ const result = await mounted.page.evaluate(() => (window as any).__CHECK_CLEANUP__());
51
+ expect(result).toEqual({
52
+ same: true,
53
+ ledger: ['controller', 'layout', 'page'],
54
+ errors: ['controller failed', 'observer failed', 'layout scope failed', 'observer failed', 'page scope failed', 'observer failed'],
55
+ original: true,
56
+ });
57
+ }), 60_000);
@@ -2,7 +2,7 @@ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import { afterEach, describe, expect, test } from 'vitest';
5
- import { browser } from '../testing/browser';
5
+ import { createBrowserFixture } from '../testing/browser';
6
6
 
7
7
  type ScopeReproSnapshot = {
8
8
  scopeDisposeCounts: Record<string, number>;
@@ -184,7 +184,7 @@ describe('NavigationShell renderer scope ownership', () => {
184
184
 
185
185
  const pageErrors: string[] = [];
186
186
  const consoleErrors: string[] = [];
187
- const harness = await browser.createHarness({
187
+ const harness = await createBrowserFixture({
188
188
  rootDir: woodRoot,
189
189
  browser: { headless: true },
190
190
  page: {
@@ -200,7 +200,7 @@ describe('NavigationShell renderer scope ownership', () => {
200
200
  const mounted = await harness.mountComponent(
201
201
  path.join(rootDir, 'ui', 'NavigationShellScopesRepro.svelte'),
202
202
  );
203
- await mounted.locator('[data-repro-view]').waitFor({ state: 'visible', timeout: 5_000 }).catch(
203
+ await mounted.page.locator('[data-repro-view]').waitFor({ state: 'visible', timeout: 5_000 }).catch(
204
204
  async (error: unknown) => {
205
205
  throw new Error(
206
206
  `${error instanceof Error ? error.message : String(error)}\n` +
@@ -223,10 +223,10 @@ describe('NavigationShell renderer scope ownership', () => {
223
223
  };
224
224
 
225
225
  const renderedController = async () =>
226
- (await mounted.locator('[data-repro-view]').textContent())?.replace(/\s+/g, ' ').trim();
226
+ (await mounted.page.locator('[data-repro-view]').textContent())?.replace(/\s+/g, ' ').trim();
227
227
 
228
228
  const snapshot = async (): Promise<ScopeReproSnapshot> => {
229
- const result = await mounted.evaluate<Omit<ScopeReproSnapshot, 'rendered' | 'pageErrors'> & { rendered: string }>(() => {
229
+ const result = await mounted.page.evaluate<Omit<ScopeReproSnapshot, 'rendered' | 'pageErrors'> & { rendered: string }>(() => {
230
230
  const state = (window as unknown as {
231
231
  __WOOD_SCOPE_REPRO__: Omit<ScopeReproSnapshot, 'rendered' | 'pageErrors'>;
232
232
  }).__WOOD_SCOPE_REPRO__;
@@ -36,7 +36,8 @@
36
36
  pageScope?: ScopeLike | null;
37
37
  layoutScopes?: Array<ScopeLike | null>;
38
38
  };
39
- type CreateControllerSetFn = (entry: ViewManifestEntry, preservedDepth?: number) => Promise<ControllerSet>;
39
+ type RouteInput = { params: Record<string, string>; query: Record<string, string> };
40
+ type CreateControllerSetFn = (entry: ViewManifestEntry, preservedDepth?: number, route?: RouteInput) => Promise<ControllerSet>;
40
41
  type GetViewManifestEntryFn = (viewKey: string) => ViewManifestEntry | null;
41
42
  type NavigationOptions = {
42
43
  preserveLayouts?: boolean;
@@ -54,6 +55,7 @@
54
55
  let {
55
56
  manifest,
56
57
  initialEntry,
58
+ initialRoute = { params: {}, query: {} },
57
59
  initialController,
58
60
  initialLayoutControllers,
59
61
  createViewControllerFn,
@@ -65,6 +67,7 @@
65
67
  }: {
66
68
  manifest: Record<string, ViewManifestEntry>;
67
69
  initialEntry: ViewManifestEntry;
70
+ initialRoute?: RouteInput;
68
71
  initialController?: unknown;
69
72
  initialLayoutControllers?: Array<unknown>;
70
73
  createViewControllerFn?: CreateViewControllerFn;
@@ -110,9 +113,9 @@
110
113
  let activeEntry = $state(getInitialEntry());
111
114
  let activeController = $state(getInitialController());
112
115
  let activeLayoutControllers: Array<unknown> = $state(getInitialLayoutControllers());
113
- let activeParams: Record<string, unknown> = $state({});
114
- let activeQuery: Record<string, unknown> = $state({});
115
- let activePageKey = $state(createNavigationMountKey(getInitialEntry().key, {}));
116
+ let activeParams: Record<string, unknown> = $state({ ...initialRoute.params });
117
+ let activeQuery: Record<string, unknown> = $state({ ...initialRoute.query });
118
+ let activePageKey = $state(createNavigationMountKey(getInitialEntry().key, initialRoute.params));
116
119
  let activeLayoutKeys: string[] = $state(
117
120
  getInitialEntry().layoutPaths.map((_, index) =>
118
121
  createLayoutMountKey(getInitialEntry(), index, activePageKey),
@@ -242,10 +245,17 @@
242
245
  await cleanupAll(layoutControllers.map(controller => () => disposeControllerLifecycle(controller)), 'Wood layout cleanup failed');
243
246
  }
244
247
 
248
+ // Navigation may recover for the user, but the owning host must still observe
249
+ // every failure when it settles and disposes this renderer.
250
+ const lifecycleErrors: unknown[] = [];
245
251
  function reportNavigationError(context: string, error: unknown): void {
252
+ lifecycleErrors.push(error);
246
253
  const message = error instanceof Error ? error.message : String(error);
247
254
  console.error(`[wood] ${context}: ${message}`);
248
- try { onLifecycleError?.(error); } catch (observerError) { console.error('[wood] lifecycle error observer failed', observerError); }
255
+ try { onLifecycleError?.(error); } catch (observerError) {
256
+ lifecycleErrors.push(observerError);
257
+ console.error('[wood] lifecycle error observer failed', observerError);
258
+ }
249
259
  }
250
260
 
251
261
  async function disposeStagedControllers(
@@ -266,11 +276,13 @@
266
276
  async function createTransitionControllers(
267
277
  entry: ViewManifestEntry,
268
278
  preservedDepth: number,
279
+ route: RouteInput,
269
280
  ): Promise<ControllerSet> {
270
281
  if (createControllerSetFn) {
271
282
  // The scope-aware factory owns its own failure cleanup (it disposes
272
- // the scopes it created before rethrowing).
273
- return createControllerSetFn(entry, preservedDepth);
283
+ // the scopes it created before rethrowing). The route feeds the
284
+ // page/layout data loaders and controller initialize().
285
+ return createControllerSetFn(entry, preservedDepth, route);
274
286
  }
275
287
 
276
288
  const [controllerResult, layoutControllersResult] = await Promise.allSettled([
@@ -337,7 +349,10 @@
337
349
  const previousLayoutControllers = [...activeLayoutControllers];
338
350
  const previousPageScope = activePageScope;
339
351
  const previousLayoutScopes = [...activeLayoutScopes];
340
- const nextSet = await createTransitionControllers(entry, preservedDepth);
352
+ const nextSet = await createTransitionControllers(entry, preservedDepth, {
353
+ params: { ...navEntry.params },
354
+ query: { ...navEntry.query },
355
+ });
341
356
  const { controller, layoutControllers } = nextSet;
342
357
 
343
358
  if (destroyed) {
@@ -477,23 +492,34 @@
477
492
  navigation.replace(route, {}, {});
478
493
  });
479
494
 
480
- onDestroy(() => {
495
+ let navigationDisposal: Promise<void> | undefined;
496
+ /** Awaitable lifecycle hook for the framework host, including staged navigation. */
497
+ export function disposeNavigation(): Promise<void> {
498
+ if (navigationDisposal) return navigationDisposal;
481
499
  destroyed = true;
482
- unsubscribe();
483
- unsubscribeWindowNavigate?.();
484
- void navigationTransitionQueue
485
- .then(async () => {
486
- await disposeActiveControllers();
487
- for (const scope of activeLayoutScopes) await disposeScope(scope, 'teardown layout scope disposal failed');
488
- await disposeScope(activePageScope, 'teardown page scope disposal failed');
489
- for (const retained of retainedPageScopes) {
490
- await disposeScope(retained.scope, 'teardown retained scope disposal failed');
491
- }
492
- retainedPageScopes = [];
493
- })
494
- .catch((error: unknown) => {
495
- reportNavigationError('navigation teardown failed', error);
496
- });
500
+ navigationDisposal = Promise.resolve().then(async () => {
501
+ try { unsubscribe(); } catch (error) { reportNavigationError('navigation unsubscribe failed', error); }
502
+ try { unsubscribeWindowNavigate?.(); } catch (error) { reportNavigationError('window navigation unsubscribe failed', error); }
503
+ await navigationTransitionQueue;
504
+ await disposeActiveControllers();
505
+ for (const scope of activeLayoutScopes) await disposeScope(scope, 'teardown layout scope disposal failed');
506
+ await disposeScope(activePageScope, 'teardown page scope disposal failed');
507
+ for (const retained of retainedPageScopes) {
508
+ await disposeScope(retained.scope, 'teardown retained scope disposal failed');
509
+ }
510
+ retainedPageScopes = [];
511
+ if (lifecycleErrors.length) {
512
+ throw Object.assign(new Error('Wood navigation lifecycle failed'), {
513
+ errors: [...lifecycleErrors], cause: lifecycleErrors[0],
514
+ });
515
+ }
516
+ });
517
+ return navigationDisposal;
518
+ }
519
+
520
+ onDestroy(() => {
521
+ // Svelte does not await onDestroy; the host awaits the same memoized promise.
522
+ void disposeNavigation().catch((error: unknown) => console.error('[wood] navigation teardown failed', error));
497
523
  });
498
524
  </script>
499
525
 
@@ -13,7 +13,9 @@
13
13
  * both mark it `preserve: true`. `NavigationOptions.preserveLayouts`
14
14
  * overrides the declaration in both directions.
15
15
  */
16
- import type { NavigationOptions } from './index.js';
16
+ export interface NavigationOptions {
17
+ preserveLayouts?: boolean;
18
+ }
17
19
 
18
20
  /** The layout stack of one view, in the shape both callers can supply. */
19
21
  export interface LayoutStack {