@noego/wood 0.5.1 → 0.6.1

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 (151) hide show
  1. package/README.md +31 -0
  2. package/dist/client/index.cjs +30 -11
  3. package/dist/client/index.cjs.map +1 -1
  4. package/dist/client/index.d.cts +33 -1
  5. package/dist/client/index.d.ts +33 -1
  6. package/dist/client/index.js +28 -11
  7. package/dist/client/index.js.map +1 -1
  8. package/dist/codegen/preload_generator.cjs +44 -5
  9. package/dist/codegen/preload_generator.cjs.map +1 -1
  10. package/dist/codegen/preload_generator.js +44 -5
  11. package/dist/codegen/preload_generator.js.map +1 -1
  12. package/dist/codegen/renderer_app_generator.cjs +2 -9
  13. package/dist/codegen/renderer_app_generator.cjs.map +1 -1
  14. package/dist/codegen/renderer_app_generator.js +2 -9
  15. package/dist/codegen/renderer_app_generator.js.map +1 -1
  16. package/dist/controller/cleanup.cjs +39 -0
  17. package/dist/controller/cleanup.cjs.map +1 -0
  18. package/dist/controller/cleanup.d.cts +4 -0
  19. package/dist/controller/cleanup.d.ts +4 -0
  20. package/dist/controller/cleanup.js +15 -0
  21. package/dist/controller/cleanup.js.map +1 -0
  22. package/dist/controller/controller_lifecycle.cjs +26 -2
  23. package/dist/controller/controller_lifecycle.cjs.map +1 -1
  24. package/dist/controller/controller_lifecycle.d.cts +3 -1
  25. package/dist/controller/controller_lifecycle.d.ts +3 -1
  26. package/dist/controller/controller_lifecycle.js +24 -1
  27. package/dist/controller/controller_lifecycle.js.map +1 -1
  28. package/dist/frontend/frontend_lifecycle.cjs +5 -7
  29. package/dist/frontend/frontend_lifecycle.cjs.map +1 -1
  30. package/dist/frontend/frontend_lifecycle.d.cts +1 -14
  31. package/dist/frontend/frontend_lifecycle.d.ts +1 -14
  32. package/dist/frontend/frontend_lifecycle.js +5 -7
  33. package/dist/frontend/frontend_lifecycle.js.map +1 -1
  34. package/dist/frontend/frontend_surface.cjs +130 -104
  35. package/dist/frontend/frontend_surface.cjs.map +1 -1
  36. package/dist/frontend/frontend_surface.js +131 -105
  37. package/dist/frontend/frontend_surface.js.map +1 -1
  38. package/dist/frontend/work_registry.cjs +29 -4
  39. package/dist/frontend/work_registry.cjs.map +1 -1
  40. package/dist/frontend/work_registry.d.cts +2 -0
  41. package/dist/frontend/work_registry.d.ts +2 -0
  42. package/dist/frontend/work_registry.js +29 -4
  43. package/dist/frontend/work_registry.js.map +1 -1
  44. package/dist/index.d.cts +2 -0
  45. package/dist/index.d.ts +2 -0
  46. package/dist/ipc/index.cjs +6 -2
  47. package/dist/ipc/index.cjs.map +1 -1
  48. package/dist/ipc/index.d.cts +9 -0
  49. package/dist/ipc/index.d.ts +9 -0
  50. package/dist/ipc/index.js +6 -2
  51. package/dist/ipc/index.js.map +1 -1
  52. package/dist/main/index.cjs +81 -82
  53. package/dist/main/index.cjs.map +1 -1
  54. package/dist/main/index.d.cts +13 -4
  55. package/dist/main/index.d.ts +13 -4
  56. package/dist/main/index.js +89 -87
  57. package/dist/main/index.js.map +1 -1
  58. package/dist/main/shutdown_coordinator.cjs +153 -0
  59. package/dist/main/shutdown_coordinator.cjs.map +1 -0
  60. package/dist/main/shutdown_coordinator.d.cts +81 -0
  61. package/dist/main/shutdown_coordinator.d.ts +81 -0
  62. package/dist/main/shutdown_coordinator.js +127 -0
  63. package/dist/main/shutdown_coordinator.js.map +1 -0
  64. package/dist/main/startup_failure_handler.cjs +67 -0
  65. package/dist/main/startup_failure_handler.cjs.map +1 -0
  66. package/dist/main/startup_failure_handler.d.cts +16 -0
  67. package/dist/main/startup_failure_handler.d.ts +16 -0
  68. package/dist/main/startup_failure_handler.js +43 -0
  69. package/dist/main/startup_failure_handler.js.map +1 -0
  70. package/dist/router/index.d.cts +3 -0
  71. package/dist/router/index.d.ts +3 -0
  72. package/dist/router/wood_router.cjs +204 -123
  73. package/dist/router/wood_router.cjs.map +1 -1
  74. package/dist/router/wood_router.d.cts +20 -0
  75. package/dist/router/wood_router.d.ts +20 -0
  76. package/dist/router/wood_router.js +206 -125
  77. package/dist/router/wood_router.js.map +1 -1
  78. package/dist/runtime/index.cjs +13 -0
  79. package/dist/runtime/index.cjs.map +1 -1
  80. package/dist/runtime/index.d.cts +8 -4
  81. package/dist/runtime/index.d.ts +8 -4
  82. package/dist/runtime/index.js +8 -0
  83. package/dist/runtime/index.js.map +1 -1
  84. package/dist/runtime/renderer_config_extension.cjs +1 -2
  85. package/dist/runtime/renderer_config_extension.cjs.map +1 -1
  86. package/dist/runtime/renderer_config_extension.d.cts +1 -1
  87. package/dist/runtime/renderer_config_extension.d.ts +1 -1
  88. package/dist/runtime/renderer_config_extension.js +1 -2
  89. package/dist/runtime/renderer_config_extension.js.map +1 -1
  90. package/dist/runtime/renderer_log_dispatcher.cjs +2 -0
  91. package/dist/runtime/renderer_log_dispatcher.cjs.map +1 -1
  92. package/dist/runtime/renderer_log_dispatcher.d.cts +1 -1
  93. package/dist/runtime/renderer_log_dispatcher.d.ts +1 -1
  94. package/dist/runtime/renderer_log_dispatcher.js +1 -0
  95. package/dist/runtime/renderer_log_dispatcher.js.map +1 -1
  96. package/dist/runtime/runtime.cjs +107 -57
  97. package/dist/runtime/runtime.cjs.map +1 -1
  98. package/dist/runtime/runtime.d.cts +12 -0
  99. package/dist/runtime/runtime.d.ts +12 -0
  100. package/dist/runtime/runtime.js +110 -59
  101. package/dist/runtime/runtime.js.map +1 -1
  102. package/dist/runtime/runtime_module.cjs +58 -0
  103. package/dist/runtime/runtime_module.cjs.map +1 -0
  104. package/dist/runtime/runtime_module.d.cts +22 -0
  105. package/dist/runtime/runtime_module.d.ts +22 -0
  106. package/dist/runtime/runtime_module.js +30 -0
  107. package/dist/runtime/runtime_module.js.map +1 -0
  108. package/dist/testing/create_controller_harness.d.cts +3 -0
  109. package/dist/testing/create_controller_harness.d.ts +3 -0
  110. package/dist/testing/create_router_bridge.d.cts +3 -0
  111. package/dist/testing/create_router_bridge.d.ts +3 -0
  112. package/dist/testing/create_test_router.d.cts +3 -0
  113. package/dist/testing/create_test_router.d.ts +3 -0
  114. package/dist/testing/index.cjs +3 -0
  115. package/dist/testing/index.cjs.map +1 -1
  116. package/dist/testing/index.d.cts +6 -1
  117. package/dist/testing/index.d.ts +6 -1
  118. package/dist/testing/index.js +2 -0
  119. package/dist/testing/index.js.map +1 -1
  120. package/dist/testing/registration_only_root.cjs +168 -0
  121. package/dist/testing/registration_only_root.cjs.map +1 -0
  122. package/dist/testing/registration_only_root.d.cts +46 -0
  123. package/dist/testing/registration_only_root.d.ts +46 -0
  124. package/dist/testing/registration_only_root.js +141 -0
  125. package/dist/testing/registration_only_root.js.map +1 -0
  126. package/dist/testing/test_wood_config.cjs +382 -70
  127. package/dist/testing/test_wood_config.cjs.map +1 -1
  128. package/dist/testing/test_wood_config.d.cts +25 -1
  129. package/dist/testing/test_wood_config.d.ts +25 -1
  130. package/dist/testing/test_wood_config.js +388 -71
  131. package/dist/testing/test_wood_config.js.map +1 -1
  132. package/dist/testing/wood_dom_animation.cjs +213 -0
  133. package/dist/testing/wood_dom_animation.cjs.map +1 -0
  134. package/dist/testing/wood_dom_animation.d.cts +89 -0
  135. package/dist/testing/wood_dom_animation.d.ts +89 -0
  136. package/dist/testing/wood_dom_animation.js +185 -0
  137. package/dist/testing/wood_dom_animation.js.map +1 -0
  138. package/dist/testing/wood_env_renderer.cjs +81 -32
  139. package/dist/testing/wood_env_renderer.cjs.map +1 -1
  140. package/dist/testing/wood_env_renderer.d.cts +32 -1
  141. package/dist/testing/wood_env_renderer.d.ts +32 -1
  142. package/dist/testing/wood_env_renderer.js +84 -32
  143. package/dist/testing/wood_env_renderer.js.map +1 -1
  144. package/dist/transport/in_process_router_transport.d.cts +3 -0
  145. package/dist/transport/in_process_router_transport.d.ts +3 -0
  146. package/dist/transport/index.d.cts +3 -0
  147. package/dist/transport/index.d.ts +3 -0
  148. package/package.json +7 -6
  149. package/src/components/NavigationShell.svelte +12 -7
  150. package/src/controller/cleanup.ts +8 -0
  151. package/src/controller/controller_lifecycle.ts +24 -0
@@ -1,8 +1,10 @@
1
+ import { disposeControllerLifecycle, guardControllerLifecycleOnce } from "../controller/controller_lifecycle.js";
1
2
  import path from "node:path";
2
- import { ExecutionContext } from "@noego/ioc";
3
+ import { ExecutionContext, flattenModule } from "@noego/ioc";
3
4
  import { testIoc, ENV_REGISTRY } from "@noego/testing";
4
- import { WoodRouter } from "../router/wood_router.js";
5
- import { TestWoodError } from "./test_wood.js";
5
+ import { installWoodRuntime, WoodRouter } from "../runtime/runtime_module.js";
6
+ import { Navigation } from "../navigation/index.js";
7
+ import { TestWoodError, WOOD_FRONTEND_BRIDGE } from "./test_wood.js";
6
8
  import {
7
9
  defaultImporter,
8
10
  importDefaultExport,
@@ -16,6 +18,12 @@ import {
16
18
  import {
17
19
  buildRendererTier
18
20
  } from "./wood_env_renderer.js";
21
+ import {
22
+ buildRecordedEnvironment,
23
+ cleanupAll,
24
+ RegistrationOnlyRoot,
25
+ TestWoodCleanupError
26
+ } from "./registration_only_root.js";
19
27
  const CONTAINER_REGISTRY_SYMBOL = /* @__PURE__ */ Symbol.for("@noego/wood.container.registry");
20
28
  function globalRegistrySize() {
21
29
  const registry = globalThis[CONTAINER_REGISTRY_SYMBOL];
@@ -26,23 +34,158 @@ function tierMissing(tier, member) {
26
34
  `the ${member} is not available: this environment was built without { ${tier} } \u2014 add it to select()`
27
35
  );
28
36
  }
29
- function buildRouter(root, source, operations, importer) {
30
- const router = new WoodRouter({
31
- container: root,
32
- controllersDir: source.mainControllersDir,
33
- middlewareDir: source.mainMiddlewareDir,
34
- mode: "dev"
35
- });
36
- const bound = /* @__PURE__ */ new Set();
37
+ function recordComposer(compose) {
38
+ if (typeof compose !== "function") throw new TestWoodError("owner composition expects a callback");
39
+ const calls = [];
40
+ const root = {
41
+ use: (preset) => {
42
+ calls.push({ method: "use", args: [preset], flat: false });
43
+ return root;
44
+ },
45
+ classes: (config) => {
46
+ calls.push({ method: "classes", args: [config], flat: false });
47
+ return root;
48
+ },
49
+ functions: (config) => {
50
+ calls.push({ method: "functions", args: [config], flat: false });
51
+ return root;
52
+ },
53
+ values: (config) => {
54
+ calls.push({ method: "values", args: [config], flat: false });
55
+ return root;
56
+ },
57
+ methods: (config) => {
58
+ calls.push({ method: "methods", args: [config], flat: false });
59
+ return root;
60
+ }
61
+ };
62
+ compose(root);
63
+ return calls;
64
+ }
65
+ function applyCalls(builder, calls) {
66
+ let next = builder;
67
+ for (const call of [...calls].sort((a, b) => a.order - b.order)) {
68
+ next = next[call.method](...call.args);
69
+ }
70
+ return next;
71
+ }
72
+ function ownerTokens(builder) {
73
+ const tokens = /* @__PURE__ */ new Set();
74
+ const visit = (token) => {
75
+ if (tokens.has(token)) return;
76
+ tokens.add(token);
77
+ if (typeof token !== "function") return;
78
+ const injected = Reflect.getMetadata?.(/* @__PURE__ */ Symbol.for("ioc:inject:tokens"), token);
79
+ const design = Reflect.getMetadata?.("design:paramtypes", token) ?? [];
80
+ const indices = /* @__PURE__ */ new Set([...design.keys(), ...injected?.keys() ?? []]);
81
+ for (const index of indices) visit(injected?.has(index) ? injected.get(index) : design[index]);
82
+ };
83
+ for (const write of builder.writes) {
84
+ if (write.op === "use") {
85
+ for (const registration of flattenModule(write.module)) {
86
+ visit(registration.token);
87
+ if (registration.kind === "class") visit(registration.implementation);
88
+ for (const dependency of registration.dependencies) visit(dependency);
89
+ }
90
+ } else if (write.op !== "methods") {
91
+ visit(write.key);
92
+ if (write.op === "classes") visit(write.implementation);
93
+ }
94
+ }
95
+ return tokens;
96
+ }
97
+ function tokenLabel(token) {
98
+ if (typeof token === "function" && token.name) return token.name;
99
+ return String(token);
100
+ }
101
+ function callEntries(call) {
102
+ applyCalls(testIoc(), [{ ...call, order: 0 }]);
103
+ const config = call.args[0];
104
+ if (Array.isArray(config) || config instanceof Map) return [...config];
105
+ return Reflect.ownKeys(config).map((key) => [key, config[key]]);
106
+ }
107
+ function resolveOwnerToken(key, exact, main, renderer, method) {
108
+ let token = key;
109
+ if (!exact && typeof key === "string") {
110
+ const matches = [.../* @__PURE__ */ new Set([...main, ...renderer])].filter(
111
+ (candidate) => candidate === key || typeof candidate === "function" && candidate.name === key
112
+ );
113
+ if (matches.length > 1) {
114
+ throw new TestWoodError(`Ambiguous ${method} replacement name "${key}"; use an exact token`);
115
+ }
116
+ token = matches[0] ?? key;
117
+ }
118
+ const ownsMain = main.has(token);
119
+ const ownsRenderer = renderer.has(token);
120
+ if (!ownsMain && !ownsRenderer) {
121
+ throw new TestWoodError(
122
+ `Unknown ${method} replacement ${tokenLabel(token)} in the selected roots; register it with .main() or .renderer()`
123
+ );
124
+ }
125
+ if (ownsMain && ownsRenderer) {
126
+ throw new TestWoodError(
127
+ `Ambiguous ${method} replacement ${tokenLabel(token)} belongs to main and renderer; target .main() or .renderer() explicitly`
128
+ );
129
+ }
130
+ return { token, owner: ownsMain ? "main" : "renderer" };
131
+ }
132
+ function routeCalls(calls, main, renderer, target) {
133
+ for (const call of calls) {
134
+ if (!call.flat || call.method !== "classes") continue;
135
+ const exact = Array.isArray(call.args[0]) || call.args[0] instanceof Map;
136
+ for (const [key, implementation] of callEntries(call)) {
137
+ const routed = resolveOwnerToken(key, exact || typeof key !== "string", main, renderer, call.method);
138
+ (routed.owner === "main" ? main : renderer).add(implementation);
139
+ }
140
+ }
141
+ const result = [];
142
+ for (const call of calls) {
143
+ if (!call.flat) {
144
+ if (target === "main") result.push(call);
145
+ continue;
146
+ }
147
+ const exact = Array.isArray(call.args[0]) || call.args[0] instanceof Map;
148
+ for (const [key, value] of callEntries(call)) {
149
+ const routed = resolveOwnerToken(key, exact || typeof key !== "string", main, renderer, call.method);
150
+ if (routed.owner === target) {
151
+ result.push({ ...call, flat: false, args: [[[routed.token, value]]] });
152
+ }
153
+ }
154
+ }
155
+ return result;
156
+ }
157
+ async function loadController(source, importer, reference, owner) {
158
+ const file = owner === "main" ? source.mainControllerFile(reference) : source.rendererControllerFile(reference);
159
+ const controller = await importDefaultExport(
160
+ importer,
161
+ file,
162
+ `${owner} controller "${reference}"`
163
+ );
164
+ if (typeof controller !== "function") {
165
+ throw new TestWoodError(`${owner} controller "${reference}" must default-export a class (${file})`);
166
+ }
167
+ return controller;
168
+ }
169
+ async function loadMainControllers(source, operations, importer) {
170
+ const controllers = /* @__PURE__ */ new Map();
37
171
  for (const operation of operations.values()) {
38
- if (bound.has(operation.controller)) continue;
39
- bound.add(operation.controller);
40
- const file = source.mainControllerFile(operation.controller);
41
- router.registerControllerLoader(operation.controller, () => importer(file));
172
+ if (!controllers.has(operation.controller)) {
173
+ controllers.set(operation.controller, await loadController(source, importer, operation.controller, "main"));
174
+ }
175
+ }
176
+ return controllers;
177
+ }
178
+ async function loadRendererControllers(source, views, importer) {
179
+ const controllers = /* @__PURE__ */ new Map();
180
+ const references = /* @__PURE__ */ new Set();
181
+ for (const view of views) {
182
+ if (view.controller) references.add(view.controller);
183
+ for (const controller of view.layoutControllers ?? []) if (controller) references.add(controller);
184
+ }
185
+ for (const reference of references) {
186
+ controllers.set(reference, await loadController(source, importer, reference, "renderer"));
42
187
  }
43
- router.loadOperations(operations);
44
- router.validateControllers();
45
- return router;
188
+ return controllers;
46
189
  }
47
190
  async function runCompose(root, source, context) {
48
191
  const composePath = source.hookModule(source.config.main.testing?.compose);
@@ -73,16 +216,8 @@ function assertNoGlobalContainerLeak(before, source) {
73
216
  );
74
217
  }
75
218
  }
76
- async function assembleEnvironment(state, selection) {
77
- const source = loadWoodConfigSource(state.configPath);
78
- const operations = selectOperations(source.operations, selection.main);
79
- const catalog = selection.pages ? selectCatalog(source.views(), selection.pages) : null;
80
- const registryBefore = globalRegistrySize();
81
- let composed = null;
82
- let renderer = null;
83
- const controllerScopes = [];
84
- let router = null;
85
- const context = {
219
+ function createComposeContext(source, state, selection, operations, live) {
220
+ return {
86
221
  configPath: source.configPath,
87
222
  configDir: source.configDir,
88
223
  config: source.config,
@@ -90,47 +225,189 @@ async function assembleEnvironment(state, selection) {
90
225
  operations,
91
226
  importer: state.importer,
92
227
  call: async (operation, input) => {
93
- if (!router) throw new TestWoodError("compose context call() is available from start() onward, not during compose");
94
- const live = router;
95
- return await ExecutionContext.run(env.root, () => live.dispatch(operation, input));
228
+ if (!live.router || !live.env) {
229
+ throw new TestWoodError("compose context call() is available from start() onward, not during compose");
230
+ }
231
+ const router = live.router;
232
+ return await ExecutionContext.run(
233
+ live.env.root,
234
+ () => router.dispatch(operation, input)
235
+ );
96
236
  }
97
237
  };
98
- const env = await state.ioc.build({
99
- prepare: async (root2) => {
100
- composed = await runCompose(root2, source, context);
238
+ }
239
+ function createAssemblyDisposer(live, mainRegistrations, rendererRegistrations, controllerScopes) {
240
+ let closing = null;
241
+ return () => closing ?? (closing = cleanupAll("testWood cleanup failed", [
242
+ ["renderer", async () => {
243
+ if (live.renderer) await live.renderer.dispose();
244
+ }],
245
+ ...[...controllerScopes].reverse().map(
246
+ (scope, index) => [`controller scope ${controllerScopes.length - index}`, () => scope.dispose()]
247
+ ),
248
+ ["compose drain", async () => {
249
+ await live.composed?.drain?.();
250
+ }],
251
+ ["main root", async () => {
252
+ if (live.env) await disposeRoot(live.env);
253
+ else await mainRegistrations.actualRoot.dispose();
254
+ }],
255
+ ["unbuilt renderer root", async () => {
256
+ if (!live.renderer && rendererRegistrations) await rendererRegistrations.actualRoot.dispose();
257
+ }],
258
+ ["compose external dispose", async () => {
259
+ await live.composed?.dispose?.();
260
+ }]
261
+ ]));
262
+ }
263
+ async function loadAssemblyControllers(inputs) {
264
+ const main = await loadMainControllers(inputs.source, inputs.operations, inputs.state.importer);
265
+ const renderer = inputs.catalog ? await loadRendererControllers(inputs.source, inputs.catalog.all(), inputs.state.importer) : /* @__PURE__ */ new Map();
266
+ return { main, renderer };
267
+ }
268
+ function installAssemblyRuntime(inputs, mainControllers) {
269
+ installWoodRuntime(inputs.mainRegistrations.root, {
270
+ controllersDir: inputs.source.mainControllersDir,
271
+ middlewareDir: inputs.source.mainMiddlewareDir,
272
+ mode: "dev",
273
+ registerRuntime: (configuredRouter) => {
274
+ for (const [identity, controller] of mainControllers) {
275
+ configuredRouter.registerControllerLoader(identity, () => Promise.resolve({ default: controller }));
276
+ }
277
+ configuredRouter.loadOperations(inputs.operations);
101
278
  }
102
279
  });
103
- const root = env.root;
104
- const dispose = async () => {
105
- if (renderer) await renderer.dispose();
106
- for (const scope of controllerScopes.reverse()) await scope.dispose();
107
- await composed?.drain?.();
108
- try {
109
- await disposeRoot(env);
110
- } finally {
111
- await composed?.dispose?.();
280
+ }
281
+ async function configureRendererRegistrations(inputs) {
282
+ const registrations = inputs.rendererRegistrations;
283
+ if (!registrations) return;
284
+ registrations.declare(Navigation);
285
+ registrations.declare(WOOD_FRONTEND_BRIDGE);
286
+ await ExecutionContext.run(
287
+ registrations.root,
288
+ () => inputs.live.composed?.configureRendererRoot?.(registrations.root)
289
+ );
290
+ }
291
+ function registerControllersBeforeOverlays(inputs, mainControllers, rendererControllers) {
292
+ for (const controller of mainControllers.values()) {
293
+ inputs.mainRegistrations.declare(controller);
294
+ if (!inputs.mainRegistrations.actualRoot.isRegistered(controller)) {
295
+ inputs.mainRegistrations.root.registerClass(controller);
112
296
  }
297
+ }
298
+ for (const controller of rendererControllers.values()) {
299
+ inputs.rendererRegistrations?.declare(controller);
300
+ }
301
+ }
302
+ function buildAssemblyIoc(inputs) {
303
+ const explicitMain = inputs.state.mainCalls.filter((call) => !call.flat);
304
+ const mainOwners = /* @__PURE__ */ new Set([
305
+ ...inputs.mainRegistrations.owners,
306
+ ...ownerTokens(applyCalls(testIoc(), explicitMain))
307
+ ]);
308
+ const rendererOwners = /* @__PURE__ */ new Set([
309
+ ...inputs.rendererRegistrations?.owners ?? [],
310
+ ...ownerTokens(applyCalls(testIoc(), inputs.state.rendererCalls))
311
+ ]);
312
+ const routedMain = routeCalls(inputs.state.mainCalls, mainOwners, rendererOwners, "main");
313
+ const routedRenderer = routeCalls(inputs.state.mainCalls, mainOwners, rendererOwners, "renderer");
314
+ return {
315
+ main: applyCalls(testIoc(), routedMain),
316
+ renderer: applyCalls(testIoc(), [...inputs.state.rendererCalls, ...routedRenderer])
113
317
  };
318
+ }
319
+ async function materializeEnvironment(inputs) {
320
+ const controllers = await loadAssemblyControllers(inputs);
321
+ inputs.live.composed = await runCompose(inputs.mainRegistrations.root, inputs.source, inputs.context);
322
+ installAssemblyRuntime(inputs, controllers.main);
323
+ await configureRendererRegistrations(inputs);
324
+ registerControllersBeforeOverlays(inputs, controllers.main, controllers.renderer);
325
+ const ioc = buildAssemblyIoc(inputs);
326
+ inputs.live.env = await buildRecordedEnvironment(ioc.main, inputs.mainRegistrations, "main");
327
+ const root = inputs.live.env.root;
328
+ inputs.live.router = await Promise.resolve(
329
+ ExecutionContext.run(root, () => root.get(WoodRouter))
330
+ );
331
+ await ExecutionContext.run(root, () => inputs.live.composed?.start?.());
332
+ assertNoGlobalContainerLeak(inputs.registryBefore, inputs.source);
333
+ if (inputs.catalog && inputs.rendererRegistrations) {
334
+ inputs.live.renderer = await buildRendererTier({
335
+ source: inputs.source,
336
+ router: inputs.live.router,
337
+ catalog: inputs.catalog,
338
+ operations: inputs.operations,
339
+ importer: inputs.state.importer,
340
+ hooks: inputs.live.composed,
341
+ dom: inputs.selection.dom,
342
+ ioc: ioc.renderer,
343
+ registrations: inputs.rendererRegistrations,
344
+ controllerClasses: controllers.renderer
345
+ });
346
+ }
347
+ return environmentSurface({
348
+ root,
349
+ env: inputs.live.env,
350
+ router: inputs.live.router,
351
+ operations: inputs.operations,
352
+ renderer: inputs.live.renderer,
353
+ controllerScopes: inputs.controllerScopes,
354
+ dispose: inputs.dispose
355
+ });
356
+ }
357
+ async function rollbackEnvironment(error, dispose) {
114
358
  try {
115
- router = buildRouter(root, source, operations, state.importer);
116
- await ExecutionContext.run(root, () => composed?.start?.());
117
- assertNoGlobalContainerLeak(registryBefore, source);
118
- if (catalog) {
119
- renderer = await buildRendererTier({
120
- source,
121
- router,
122
- catalog,
123
- operations,
124
- importer: state.importer,
125
- hooks: composed,
126
- dom: selection.dom
127
- });
128
- }
129
- return environmentSurface({ root, env, router, operations, renderer, controllerScopes, dispose });
359
+ await dispose();
360
+ } catch (cleanupError) {
361
+ const failures = [
362
+ ...error instanceof TestWoodCleanupError ? error.failures : [],
363
+ ...cleanupError instanceof TestWoodCleanupError ? cleanupError.failures : [{ owner: "build rollback", error: cleanupError }]
364
+ ];
365
+ throw new TestWoodCleanupError("testWood build failed and rollback failed", failures, error);
366
+ }
367
+ throw error;
368
+ }
369
+ async function assembleEnvironment(state, selection) {
370
+ const source = loadWoodConfigSource(state.configPath);
371
+ const operations = selectOperations(source.operations, selection.main);
372
+ const catalog = selection.pages ? selectCatalog(source.views(), selection.pages) : null;
373
+ if (!catalog && state.rendererCalls.length) {
374
+ throw new TestWoodError(".renderer() composition requires a selected pages tier");
375
+ }
376
+ const registryBefore = globalRegistrySize();
377
+ const mainRegistrations = new RegistrationOnlyRoot("main.testing.compose");
378
+ const rendererRegistrations = catalog ? new RegistrationOnlyRoot("configureRendererRoot") : null;
379
+ const controllerScopes = [];
380
+ const live = {
381
+ composed: null,
382
+ env: null,
383
+ renderer: null,
384
+ router: null
385
+ };
386
+ const context = createComposeContext(source, state, selection, operations, live);
387
+ const dispose = createAssemblyDisposer(
388
+ live,
389
+ mainRegistrations,
390
+ rendererRegistrations,
391
+ controllerScopes
392
+ );
393
+ const inputs = {
394
+ state,
395
+ selection,
396
+ source,
397
+ operations,
398
+ catalog,
399
+ registryBefore,
400
+ mainRegistrations,
401
+ rendererRegistrations,
402
+ controllerScopes,
403
+ live,
404
+ context,
405
+ dispose
406
+ };
407
+ try {
408
+ return await materializeEnvironment(inputs);
130
409
  } catch (error) {
131
- await dispose().catch(() => {
132
- });
133
- throw error;
410
+ return rollbackEnvironment(error, dispose);
134
411
  }
135
412
  }
136
413
  function environmentSurface(parts) {
@@ -152,9 +429,18 @@ function environmentSurface(parts) {
152
429
  },
153
430
  controller: async (controller) => {
154
431
  const scope = root.extend();
155
- controllerScopes.push(scope);
432
+ const ownsController = !root.isRegistered(controller);
433
+ let instance;
434
+ controllerScopes.push({ dispose: () => ExecutionContext.run(scope, () => cleanupAll("controller cleanup failed", [
435
+ ["controller", async () => {
436
+ if (ownsController) await disposeControllerLifecycle(instance);
437
+ }],
438
+ ["scope", () => scope.dispose()]
439
+ ])) });
156
440
  if (!scope.isRegistered(controller)) scope.registerClass(controller);
157
- return await scope.get(controller);
441
+ instance = await ExecutionContext.run(scope, () => scope.get(controller));
442
+ guardControllerLifecycleOnce(instance);
443
+ return instance;
158
444
  },
159
445
  get: async (token) => await root.get(token),
160
446
  get frontend() {
@@ -181,7 +467,19 @@ function environmentSurface(parts) {
181
467
  // Method expectations only: `settle()` is the completion contract for
182
468
  // owned renderer work, and a test may legitimately verify while a
183
469
  // deliberately deferred stream is still in flight.
184
- verify: () => env.verify(),
470
+ verify: async () => {
471
+ const failures = [];
472
+ await env.verify().catch((error) => failures.push({ owner: "main", error }));
473
+ if (renderer) {
474
+ await renderer.verify().catch((error) => failures.push({ owner: "renderer", error }));
475
+ }
476
+ if (failures.length === 1) throw failures[0].error;
477
+ if (failures.length) {
478
+ throw new TestWoodError(
479
+ "testWood verification failed in both roots:\n " + failures.map(({ owner, error }) => `${owner}: ${error instanceof Error ? error.message : String(error)}`).join("\n ")
480
+ );
481
+ }
482
+ },
185
483
  dispose,
186
484
  [ENV_REGISTRY]: env[ENV_REGISTRY]
187
485
  };
@@ -197,7 +495,8 @@ class TestWoodBuilder {
197
495
  return new TestWoodBuilder({
198
496
  configPath: path.resolve(configPath),
199
497
  selection: null,
200
- ioc: testIoc(),
498
+ mainCalls: [],
499
+ rendererCalls: [],
201
500
  importer: defaultImporter
202
501
  });
203
502
  }
@@ -211,21 +510,33 @@ class TestWoodBuilder {
211
510
  if (typeof importer !== "function") throw new TestWoodError("importer() expects a function");
212
511
  return this.derive({ importer });
213
512
  }
214
- /* ---- Composition: exact @noego/testing semantics on the main root ---- */
513
+ /** Explicitly compose or replace dependencies on the main-process root. */
514
+ main(compose) {
515
+ return this.append("main", recordComposer(compose));
516
+ }
517
+ /** Explicitly compose or replace dependencies on the renderer root. */
518
+ renderer(compose) {
519
+ return this.append("renderer", recordComposer(compose));
520
+ }
521
+ /** Reusable presets retain the historical main-root default. */
215
522
  use(preset) {
216
- return this.derive({ ioc: this.state.ioc.use(preset) });
523
+ return this.append("main", [{ method: "use", args: [preset], flat: false }]);
217
524
  }
525
+ /** Exact-token replacement routed to the sole declared selected owner. */
218
526
  classes(config) {
219
- return this.derive({ ioc: this.state.ioc.classes(config) });
527
+ return this.append("main", [{ method: "classes", args: [config], flat: true }]);
220
528
  }
529
+ /** Exact-token replacement routed to the sole declared selected owner. */
221
530
  functions(config) {
222
- return this.derive({ ioc: this.state.ioc.functions(config) });
531
+ return this.append("main", [{ method: "functions", args: [config], flat: true }]);
223
532
  }
533
+ /** Exact-token replacement routed to the sole declared selected owner. */
224
534
  values(config) {
225
- return this.derive({ ioc: this.state.ioc.values(config) });
535
+ return this.append("main", [{ method: "values", args: [config], flat: true }]);
226
536
  }
537
+ /** Exact-token method seams routed to the sole declared selected owner. */
227
538
  methods(config) {
228
- return this.derive({ ioc: this.state.ioc.methods(config) });
539
+ return this.append("main", [{ method: "methods", args: [config], flat: true }]);
229
540
  }
230
541
  async build() {
231
542
  if (!this.state.selection) {
@@ -233,6 +544,12 @@ class TestWoodBuilder {
233
544
  }
234
545
  return assembleEnvironment(this.state, resolveSelection(this.state.selection));
235
546
  }
547
+ append(owner, calls) {
548
+ const start = this.state.mainCalls.length + this.state.rendererCalls.length;
549
+ const ordered = calls.map((call, index) => ({ ...call, order: start + index }));
550
+ applyCalls(testIoc(), ordered);
551
+ return owner === "main" ? this.derive({ mainCalls: [...this.state.mainCalls, ...ordered] }) : this.derive({ rendererCalls: [...this.state.rendererCalls, ...ordered] });
552
+ }
236
553
  derive(patch) {
237
554
  return new TestWoodBuilder({ ...this.state, ...patch });
238
555
  }