Skip to content

Commit f2b7b9f

Browse files
authored
fix(testing): wire appended libraries before the module-under-test (#88) (#330)
Two coherent changes on this branch, one commit each. ## 1. `fix(testing)` — appendLibrary ordering (closes #88) `TestRunner.appendLibrary` and the module-extension `appendLibrary` disagreed on *when* an appended library constructs relative to the module-under-test: | append site | before | after | |---|---|---| | `.extend().appendLibrary(x).toTest()` | `x` → module | `x` → module | | `.extend().toTest().appendLibrary(x)` | **module → `x`** (bug) | `x` → module | **Root cause:** `TestRunner.buildApp` demotes the module-under-test to a library and keeps only the test service in the synthetic application, so the appended library and the module were unordered siblings — declaration order made the module construct first. The module-extension path keeps the module as the *application* (its services wire last), so appended libraries naturally wire first. **Fix:** declare appended libraries as `optionalDepends` of the wrapped target, so `buildSortOrder` wires them before the module — matching the extension path, so doubles/spies can be installed before the module's construction logic runs. Appended libraries stay on the same `bootstrap({ appendLibrary })` path (replace-by-name + warning untouched); no change to the shared `bootstrap()` engine. **Also:** guards a NPE in `module.appendLibrary`/`replaceLibrary` when the base library declares no `depends` (it dereferenced `workingModule.depends` without the optional chaining `toApplication`/`toLibrary` already use). Regression tests added in `testing/testing.spec.mts` cover both append sites, cross-site agreement, and the no-`depends` case. ## 2. `chore` — toolchain / deps - yarn `4.14.1` → `4.16.0` (`yarn set version stable`) - 48h supply-chain age gate: `.yarnrc.yml` `npmMinimalAgeGate: 2880` (yarn measures this in minutes; its default is `1440` = 24h) - `upgrade` script gains `ncu --cooldown 48h` so dependency *selection* respects the same 48h window that `yarn install` enforces via the gate - ran `yarn upgrade`: 16 deps bumped — the cooldown visibly held `eslint-plugin-unicorn` at `65.0.1` instead of the <48h-old `66.0.0` - package version `26.6.14` → `26.6.15` ## Verification - `yarn test run` → **392 passed / 10 skipped / 0 failed** - `yarn lint` → clean - `tsc -p tsconfig.lib.json --noEmit` → exit 0 ## Follow-up Filed #329 (feat: opt-in pre-construction setup hook for `TestRunner`) — the ergonomic, additive companion to the appendLibrary path. It intentionally does **not** change `.setup()`'s post-wire semantics ("full graph available, seed early state before the test runs").
1 parent 63eaa9a commit f2b7b9f

6 files changed

Lines changed: 690 additions & 589 deletions

File tree

.yarnrc.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,9 @@ compressionLevel: mixed
22

33
nodeLinker: node-modules
44

5+
# 48h supply-chain age gate: refuse to resolve npm versions published less than
6+
# 2880 minutes ago (yarn measures npmMinimalAgeGate in minutes; default is 1440).
7+
# `yarn upgrade` (ncu --cooldown 48h) applies the same 48h window at selection time.
8+
npmMinimalAgeGate: 2880
9+
510
npmRegistryServer: "https://registry.npmjs.org/"

package.json

Lines changed: 20 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
"repository": {
66
"url": "git+https://github.com/Digital-Alchemy-TS/core"
77
},
8-
"version": "26.6.14",
8+
"version": "26.6.15",
99
"author": {
1010
"url": "https://github.com/zoe-codez",
1111
"name": "Zoe Codez"
@@ -16,7 +16,7 @@
1616
"build": "rm -rf dist/; tsc -p tsconfig.lib.json",
1717
"lint": "eslint src",
1818
"test": "vitest",
19-
"upgrade": "ncu --upgrade && truncate -s 0 yarn.lock && yarn install"
19+
"upgrade": "ncu --upgrade --cooldown 48h && truncate -s 0 yarn.lock && yarn install"
2020
},
2121
"bugs": {
2222
"email": "bugs@digital-alchemy.app",
@@ -48,55 +48,55 @@
4848
"node": ">=20"
4949
},
5050
"dependencies": {
51-
"@dotenvx/dotenvx": "^1.69.1",
51+
"@dotenvx/dotenvx": "^1.71.3",
5252
"chalk": "^5.6.2",
5353
"cron": "^4.4.0",
5454
"dayjs": "^1.11.21",
5555
"ini": "^7.0.0",
56-
"js-yaml": "^4.1.1",
56+
"js-yaml": "^4.2.0",
5757
"minimist": "^1.2.8",
5858
"uuid": "^14.0.0"
5959
},
6060
"devDependencies": {
61-
"@cspell/eslint-plugin": "^10.0.0",
61+
"@cspell/eslint-plugin": "^10.0.1",
6262
"@eslint/compat": "^2.1.0",
6363
"@eslint/eslintrc": "^3.3.5",
6464
"@eslint/js": "^10.0.1",
6565
"@faker-js/faker": "^10.4.0",
6666
"@types/ini": "^4.1.1",
6767
"@types/js-yaml": "^4.0.9",
6868
"@types/minimist": "^1.2.5",
69-
"@types/node": "^25.9.1",
69+
"@types/node": "^25.9.3",
7070
"@types/sinonjs__fake-timers": "^15.0.1",
71-
"@typescript-eslint/eslint-plugin": "8.60.1",
72-
"@typescript-eslint/parser": "8.60.1",
73-
"@vitest/coverage-v8": "^4.1.7",
71+
"@typescript-eslint/eslint-plugin": "8.61.0",
72+
"@typescript-eslint/parser": "8.61.0",
73+
"@vitest/coverage-v8": "^4.1.8",
7474
"chalk": "^5.6.2",
7575
"dayjs": "^1.11.21",
76-
"eslint": "10.4.1",
76+
"eslint": "10.5.0",
7777
"eslint-config-prettier": "10.1.8",
7878
"eslint-plugin-import": "^2.32.0",
79-
"eslint-plugin-jsonc": "^3.1.2",
79+
"eslint-plugin-jsonc": "^3.2.0",
8080
"eslint-plugin-no-unsanitized": "^4.1.5",
8181
"eslint-plugin-prettier": "^5.5.6",
82-
"eslint-plugin-security": "^4.0.0",
82+
"eslint-plugin-security": "^4.0.1",
8383
"eslint-plugin-simple-import-sort": "^13.0.0",
8484
"eslint-plugin-sonarjs": "^4.0.3",
8585
"eslint-plugin-sort-keys-fix": "^1.1.2",
86-
"eslint-plugin-unicorn": "^64.0.0",
86+
"eslint-plugin-unicorn": "^65.0.1",
8787
"ini": "^7.0.0",
88-
"js-yaml": "^4.1.1",
88+
"js-yaml": "^4.2.0",
8989
"minimist": "^1.2.8",
90-
"npm-check-updates": "^22.2.1",
90+
"npm-check-updates": "^22.2.3",
9191
"pino": "^10.3.1",
9292
"pino-pretty": "^13.1.3",
93-
"prettier": "^3.8.3",
93+
"prettier": "^3.8.4",
9494
"tslib": "^2.8.1",
95-
"tsx": "^4.22.3",
96-
"type-fest": "^5.6.0",
95+
"tsx": "^4.22.4",
96+
"type-fest": "^5.7.0",
9797
"typescript": "^6.0.3",
9898
"uuid": "^14.0.0",
99-
"vitest": "^4.1.7"
99+
"vitest": "^4.1.8"
100100
},
101-
"packageManager": "yarn@4.14.1"
101+
"packageManager": "yarn@4.16.0"
102102
}

src/helpers/module.mts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -233,7 +233,9 @@ export function createModule<S extends ServiceMap, C extends OptionalModuleConfi
233233
`${name} already is appended`,
234234
);
235235
}
236-
const exists = workingModule.depends.some(i => i.name === name);
236+
// depends may be undefined when seeded from a library/application with no
237+
// declared dependencies (createModule.fromLibrary of a no-depends library)
238+
const exists = workingModule.depends?.some(i => i.name === name) ?? false;
237239
if (exists) {
238240
// base depends list owns this name; callers must use replaceLibrary to swap it
239241
throw new BootstrapException(
@@ -280,7 +282,8 @@ export function createModule<S extends ServiceMap, C extends OptionalModuleConfi
280282
// already in the appended set — replace in-place
281283
appendLibrary.set(name, library);
282284
} else {
283-
const exists = workingModule.depends.some(i => i.name === name);
285+
// depends may be undefined (see appendLibrary above)
286+
const exists = workingModule.depends?.some(i => i.name === name) ?? false;
284287
if (!exists) {
285288
// neither appended nor in base depends; require explicit append first
286289
throw new BootstrapException(

src/testing/test-module.mts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -275,12 +275,22 @@ export function TestRunner<S extends ServiceMap, C extends OptionalModuleConfigu
275275

276276
// wrap the target's services and configuration into a library so it can be
277277
// mixed with appended libraries and the run_first setup library
278+
//
279+
// issue #88: the module-extension path (createModule.extend().appendLibrary())
280+
// keeps the module-under-test as the *application*, so appended libraries —
281+
// being plain libraries — wire before it. Here the target is demoted to a
282+
// library, so without an explicit constraint it and the appended libraries
283+
// are unordered siblings and declaration order lets the target win. Declaring
284+
// the appended libraries as optional dependencies of the wrapped target makes
285+
// buildSortOrder wire them first, matching the extension path so doubles/spies
286+
// are installed before the module's construction logic runs.
287+
const optionalDepends = [...(optional ?? []), ...appendLibraries.values()];
278288
const testLibrary = target
279289
? CreateLibrary({
280290
configuration: target.configuration,
281291
depends,
282292
name: target.name,
283-
optionalDepends: optional,
293+
optionalDepends,
284294
priorityInit: target.priorityInit,
285295
services: target.services,
286296
})

testing/testing.spec.mts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,95 @@ describe("Testing", () => {
166166
});
167167
});
168168

169+
// #MARK: appendLibrary ordering (issue #88)
170+
describe("appendLibrary ordering (issue #88)", () => {
171+
// https://github.com/Digital-Alchemy-TS/core/issues/88
172+
// An appended library must construct BEFORE the module-under-test regardless
173+
// of whether appendLibrary is called on the module extension (Version 1) or
174+
// the test runner (Version 2), so doubles/spies can be installed against its
175+
// dependencies before the module's own construction logic runs.
176+
let order: string[];
177+
178+
// no `depends` on purpose: also guards the regressed NPE in
179+
// module.appendLibrary (workingModule.depends.some on undefined)
180+
const orderedExample = CreateLibrary({
181+
// @ts-expect-error testing
182+
name: "example",
183+
services: {
184+
main() {
185+
order.push("example");
186+
},
187+
},
188+
});
189+
190+
const orderedSpy = CreateLibrary({
191+
// @ts-expect-error testing
192+
name: "spy",
193+
services: {
194+
spy() {
195+
order.push("spy");
196+
},
197+
},
198+
});
199+
200+
beforeEach(() => {
201+
order = [];
202+
});
203+
204+
it("constructs the appended library first (append on module extension)", async () => {
205+
expect.assertions(1);
206+
const test = createModule
207+
.fromLibrary(orderedExample)
208+
.extend()
209+
.appendLibrary(orderedSpy)
210+
.toTest();
211+
await test.run(() => {});
212+
await test.teardown();
213+
expect(order).toEqual(["spy", "example"]);
214+
});
215+
216+
it("constructs the appended library first (append on test runner)", async () => {
217+
expect.assertions(1);
218+
const test = createModule
219+
.fromLibrary(orderedExample)
220+
.extend()
221+
.toTest()
222+
.appendLibrary(orderedSpy);
223+
await test.run(() => {});
224+
await test.teardown();
225+
expect(order).toEqual(["spy", "example"]);
226+
});
227+
228+
it("produces the same order from both append sites", async () => {
229+
expect.assertions(1);
230+
const v1 = createModule
231+
.fromLibrary(orderedExample)
232+
.extend()
233+
.appendLibrary(orderedSpy)
234+
.toTest();
235+
await v1.run(() => {});
236+
await v1.teardown();
237+
const v1Order = [...order];
238+
239+
order = [];
240+
const v2 = createModule
241+
.fromLibrary(orderedExample)
242+
.extend()
243+
.toTest()
244+
.appendLibrary(orderedSpy);
245+
await v2.run(() => {});
246+
await v2.teardown();
247+
expect([...order]).toEqual(v1Order);
248+
});
249+
250+
it("does not throw when appending to a base library with no depends", () => {
251+
expect.assertions(1);
252+
expect(() =>
253+
createModule.fromLibrary(orderedExample).extend().appendLibrary(orderedSpy),
254+
).not.toThrow();
255+
});
256+
});
257+
169258
// #MARK: appendService
170259
describe("appendService", () => {
171260
it("cannot append an existing service", () => {

0 commit comments

Comments
 (0)