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