diff --git a/.github/workflows/ci_framework-majors.yml b/.github/workflows/ci_framework-majors.yml new file mode 100644 index 0000000..9f18c77 --- /dev/null +++ b/.github/workflows/ci_framework-majors.yml @@ -0,0 +1,63 @@ +name: '🔬 CI — Framework Majors' + +on: + push: + branches: + - main + paths: + - '.github/workflows/ci_framework-majors.yml' + - 'src/**' + - 'tests/**' + - 'run.test.ts' + - 'package-lock.json' + - 'package.json' + pull_request: + paths: + - '.github/workflows/ci_framework-majors.yml' + - 'src/**' + - 'tests/**' + - 'run.test.ts' + - 'package-lock.json' + - 'package.json' + workflow_dispatch: + +jobs: + framework-majors: + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + angular-major: ['18', '19', '20', '21'] + name: Angular ${{ matrix.angular-major }} + steps: + - name: ➕ Actions - Checkout + uses: actions/checkout@v4 + + - name: ➕ Actions - Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: ➕ Cache dependencies + uses: actions/cache@v4 + with: + path: ~/.npm + key: npm-linux-${{ hashFiles('package-lock.json') }} + restore-keys: npm-linux- + + - name: 📦 Installing Dependencies + run: npm ci + + - name: 🔁 Pin Angular ${{ matrix.angular-major }} + run: | + npm install --no-save \ + @angular/common@${{ matrix.angular-major }} \ + @angular/compiler@${{ matrix.angular-major }} \ + @angular/core@${{ matrix.angular-major }} \ + @angular/forms@${{ matrix.angular-major }} \ + @angular/platform-browser@${{ matrix.angular-major }} \ + @angular/platform-browser-dynamic@${{ matrix.angular-major }} + + - name: 🔬 Angular ${{ matrix.angular-major }} + run: npm test \ No newline at end of file diff --git a/README.md b/README.md index f83a998..ef2968d 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Enjoying **Poku**? [Give him a star to show your support](https://github.com/wel > [!TIP] > -> Render standalone Angular components in isolated test files — automatic TypeScript loader injection, DOM environment setup, Angular TestBed configuration, and optional render metrics included. +> Render standalone Angular components in isolated test files — automatic TypeScript loader injection, DOM environment setup, isolated Angular runtime applications, and optional render metrics included. --- @@ -26,9 +26,21 @@ Enjoying **Poku**? [Give him a star to show your support](https://github.com/wel ### Install ```bash -npm i -D @pokujs/angular +npm i -D poku @pokujs/angular ``` +This package is intended for Angular workspaces that already depend on Angular. +Supported Angular majors are `18.x`, `19.x`, `20.x`, and `21.x`. + +Required Angular peers: + +- `@angular/common` +- `@angular/compiler` +- `@angular/core` +- `@angular/platform-browser` +- `@angular/platform-browser-dynamic` +- `@angular/forms` when testing forms APIs + Install a DOM adapter (at least one is required): @@ -96,14 +108,49 @@ await test('increments the counter', async () => { }); ``` -> [!IMPORTANT] -> -> Because Angular's `TestBed` module state is global, use `await test(...)` (not bare `test(...)`) within each test file to ensure tests execute **sequentially**. Concurrent test execution will cause `configureTestingModule()` to reset a sibling test's fixture mid-run. - --- ## Compatibility +### Angular Support + +| Angular | Status | +| ------- | :----: | +| `18.x` | ✅ | +| `19.x` | ✅ | +| `20.x` | ✅ | +| `21.x` | ✅ | + +Signal-input rerender support relies on Angular's current JIT signal internals, +and this package verifies current support through Angular 21. + +### Poku And DOM Support + +| Package | Supported range | +| ------- | :-------------: | +| `poku` | `>=4.1.0` | +| `happy-dom` | `>=20` | +| `jsdom` | `>=22` | + +### Isolation Support + +| Isolation mode | Node validation | +| -------------- | :-------------: | +| `none` | ✅ | +| `process` | ✅ | + +Angular cleanup is scope-aware in shared-process runs, so one concurrent test's teardown does not reset sibling fixtures. + +### Multi-Major Suite + +Use this suite to verify Angular major compatibility locally: + +```bash +npm run test:multi-major +``` + +It executes the full adapter tests four times, pinning Angular 18, 19, 20, and 21 package sets in sequence. + ### Runtime × DOM Adapter | | Node.js ≥ 20 | Bun ≥ 1 | @@ -117,7 +164,11 @@ await test('increments the counter', async () => { ### `render(component, options?)` -Mounts a standalone Angular component into a fresh `TestBed` module and returns [Testing Library](https://testing-library.com/docs/dom-testing-library/api-queries) queries together with Angular-specific helpers. +Mounts a standalone Angular component into an isolated Angular runtime application and returns [Testing Library](https://testing-library.com/docs/dom-testing-library/api-queries) queries together with Angular-specific helpers. + +`render()` is designed for standalone components. If your test target still +depends on NgModule-declared components, import the relevant module graph via +`options.imports`. ```ts const view = await render(MyComponent, { @@ -138,15 +189,19 @@ view.getByRole('heading'); await view.detectChanges(); // run a CD cycle await view.rerender({ title: 'Hi' }); // update inputs view.unmount(); // destroy fixture -view.fixture; // raw ComponentFixture +view.fixture; // runtime fixture wrapper with componentRef/CD helpers ``` ### `renderHook(fn, options?)` -Runs a factory function inside Angular's injection context so it can call `inject()` and use signals. +Runs a factory function inside an isolated Angular application injector so it can call `inject()` and use signals. + +Each `rerender()` call tears down the previous injector-scoped execution before +promoting the new one, so `DestroyRef` cleanups and injector-bound effects do +not accumulate across renders. ```ts -const { result, rerender, unmount } = renderHook( +const { result, rerender, unmount } = await renderHook( () => inject(CounterService) ); @@ -156,7 +211,10 @@ assert.strictEqual(result.current.count(), 1); ### `cleanup()` -Destroys all mounted fixtures and resets `TestBed`. Call in `afterEach`. +Destroys all mounted Angular runtime handles for the current test scope. Call in `afterEach`. + +If Angular teardown code throws, `cleanup()` rejects with that failure instead +of hiding it, while still attempting to clean the remaining mounted fixtures. ```ts afterEach(cleanup); @@ -170,6 +228,9 @@ Lazy proxy over `@testing-library/dom`'s `screen` — safe across test isolation Async wrapper around `@testing-library/dom`'s `fireEvent` — automatically triggers Angular change detection after every event. +If the event causes Angular change detection to throw, the returned promise +rejects with the application error. + ```ts await fireEvent.click(button); await fireEvent.input(input, { target: { value: 'hello' } }); diff --git a/package-lock.json b/package-lock.json index d64f0bf..82c7be5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,18 +13,19 @@ }, "devDependencies": { "@angular/common": "^19.0.0", + "@angular/compiler": "^19.0.0", "@angular/core": "^19.0.0", "@angular/forms": "^19.0.0", "@angular/platform-browser": "^19.0.0", "@angular/platform-browser-dynamic": "^19.0.0", "@happy-dom/global-registrator": "^20.8.9", - "@pokujs/dom": "^1.2.0", + "@pokujs/dom": "^1.3.0", "@types/jsdom": "^28.0.1", "@types/node": "^25.5.0", "cross-env": "^10.1.0", "happy-dom": "^20.8.9", "jsdom": "^26.1.0", - "poku": "4.2.0", + "poku": "^4.3.0", "rimraf": "^6.0.1", "tsup": "^8.5.0", "tsx": "^4.21.0", @@ -36,11 +37,12 @@ "typescript": ">=6.x.x" }, "peerDependencies": { - "@angular/common": ">=18", - "@angular/core": ">=18", - "@angular/forms": ">=18", - "@angular/platform-browser": ">=18", - "@angular/platform-browser-dynamic": ">=18", + "@angular/common": ">=18 <22", + "@angular/compiler": ">=18 <22", + "@angular/core": ">=18 <22", + "@angular/forms": ">=18 <22", + "@angular/platform-browser": ">=18 <22", + "@angular/platform-browser-dynamic": ">=18 <22", "happy-dom": ">=20", "jsdom": ">=22", "poku": ">=4.1.0" @@ -58,9 +60,9 @@ } }, "node_modules/@angular/common": { - "version": "19.2.20", - "resolved": "https://registry.npmjs.org/@angular/common/-/common-19.2.20.tgz", - "integrity": "sha512-1M3W3FjUUbVKXDMs+yQpBhnkD/pCe0Jn79rPE5W+EGWWxFoLSyGX+fhnRO5m4c9k66p3nvYrikWQ0ZzMv3M5tw==", + "version": "19.2.21", + "resolved": "https://registry.npmjs.org/@angular/common/-/common-19.2.21.tgz", + "integrity": "sha512-L+X0AOc+8SN+1ys1/nzOlkdwB7FUz6ts3MKdWW6wIPSIB6LcDth+QcJzx+XbwZ+zfPFEQkKsRWjo/eF82JePcg==", "dev": true, "license": "MIT", "dependencies": { @@ -70,17 +72,16 @@ "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/core": "19.2.20", + "@angular/core": "19.2.21", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/compiler": { - "version": "19.2.20", - "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-19.2.20.tgz", - "integrity": "sha512-LvjE8W58EACgTFaAoqmNe7FRsbvoQ0GvCB/rmm6AEMWx/0W/JBvWkQTrOQlwpoeYOHcMZRGdmPcZoUDwU3JySQ==", + "version": "19.2.21", + "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-19.2.21.tgz", + "integrity": "sha512-lLWXzeLPk+4kkXKpy/h0OAie3V2YImpDrzluufJ0xR8OlCffJNpanfBjm7R4tOZB7i0ONIxhsD67Z0oRhEECCQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -89,9 +90,9 @@ } }, "node_modules/@angular/core": { - "version": "19.2.20", - "resolved": "https://registry.npmjs.org/@angular/core/-/core-19.2.20.tgz", - "integrity": "sha512-pxzQh8ouqfE57lJlXjIzXFuRETwkfMVwS+NFCfv2yh01Qtx+vymO8ZClcJMgLPfBYinhBYX+hrRYVSa1nzlkRQ==", + "version": "19.2.21", + "resolved": "https://registry.npmjs.org/@angular/core/-/core-19.2.21.tgz", + "integrity": "sha512-jdOEwkvUyF7VdJMURXkSB1yQy595wt1lSfSgUux9tZftA7UwamCsMdidUsKqRbdaI9M3r+RdIM7qrDAWwqzz+A==", "dev": true, "license": "MIT", "dependencies": { @@ -106,9 +107,9 @@ } }, "node_modules/@angular/forms": { - "version": "19.2.20", - "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-19.2.20.tgz", - "integrity": "sha512-agi7InbMzop1jrud6L7SlNwnZk3iNolORcFIwBQMvKxLkcJ+ttbSYuM0KAw56IundWHf4dL9GP4cSygm4kUeFA==", + "version": "19.2.21", + "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-19.2.21.tgz", + "integrity": "sha512-2r5V8JyInrmpLhBDKCmF2ELWUXxEVxyIm5wDqD7RxWnuGtEr08QlJmiuyqM58xWFmcaSx7s2c62OdCqAAUSQGg==", "dev": true, "license": "MIT", "dependencies": { @@ -118,16 +119,16 @@ "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/common": "19.2.20", - "@angular/core": "19.2.20", - "@angular/platform-browser": "19.2.20", + "@angular/common": "19.2.21", + "@angular/core": "19.2.21", + "@angular/platform-browser": "19.2.21", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/platform-browser": { - "version": "19.2.20", - "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-19.2.20.tgz", - "integrity": "sha512-O9ZoQKILPC1T2c64OASS75XlOLBxY81m5AAgsBKhwiFWq+V28RsO0cnwpi1YSh/z4ryH8Fe7IUFz8jGrsJi3hQ==", + "version": "19.2.21", + "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-19.2.21.tgz", + "integrity": "sha512-v5KhTTK9FWaYmo6auXftQx1ll+9kbDhD44v68CFaIgDQG82ZihWatcdeKmPrA6dmoRXzyyNWIVSk1cNRCPKZRg==", "dev": true, "license": "MIT", "dependencies": { @@ -137,9 +138,9 @@ "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/animations": "19.2.20", - "@angular/common": "19.2.20", - "@angular/core": "19.2.20" + "@angular/animations": "19.2.21", + "@angular/common": "19.2.21", + "@angular/core": "19.2.21" }, "peerDependenciesMeta": { "@angular/animations": { @@ -148,9 +149,9 @@ } }, "node_modules/@angular/platform-browser-dynamic": { - "version": "19.2.20", - "resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-19.2.20.tgz", - "integrity": "sha512-bNuykQy/MrDeARqvDPf6bqx+m1Qqanep7FhDy9QYWxfqz7D++/phqT9l8Ubj88juFCSDfR5ktUWdpnDz3/BCfA==", + "version": "19.2.21", + "resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-19.2.21.tgz", + "integrity": "sha512-+jOSCdZ3sPiPS28Cu/82aYmJFo3HfEIPyRCEwcStzKiASzbqNsdQzn3lIoZCKsD6qdP4uPU7m/KelJNOKDzfDQ==", "dev": true, "license": "MIT", "dependencies": { @@ -160,10 +161,10 @@ "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/common": "19.2.20", - "@angular/compiler": "19.2.20", - "@angular/core": "19.2.20", - "@angular/platform-browser": "19.2.20" + "@angular/common": "19.2.21", + "@angular/compiler": "19.2.21", + "@angular/core": "19.2.21", + "@angular/platform-browser": "19.2.21" } }, "node_modules/@asamuzakjp/css-color": { @@ -777,14 +778,14 @@ } }, "node_modules/@happy-dom/global-registrator": { - "version": "20.8.9", - "resolved": "https://registry.npmjs.org/@happy-dom/global-registrator/-/global-registrator-20.8.9.tgz", - "integrity": "sha512-DtZeRRHY9A/bisTJziUBBPrdnPui7+R185G/hzi6/Boymhqh7/wi53AY+IvQHS1+7OPaqfO/1XNpngNwthLz+A==", + "version": "20.9.0", + "resolved": "https://registry.npmjs.org/@happy-dom/global-registrator/-/global-registrator-20.9.0.tgz", + "integrity": "sha512-lBW6/m5BIFl3pMuWPNN0lIOYw9LMCmPfix53ExS3FBi4E+NELEljQ3xH6aAV9IYiQRfn9YIIgzzMrD0vIcD7tw==", "dev": true, "license": "MIT", "dependencies": { "@types/node": ">=20.0.0", - "happy-dom": "^20.8.9" + "happy-dom": "^20.9.0" }, "engines": { "node": ">=20.0.0" @@ -830,12 +831,13 @@ } }, "node_modules/@pokujs/dom": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@pokujs/dom/-/dom-1.2.0.tgz", - "integrity": "sha512-RafJKjW+7skIPF6dl2GLV7nMnvPJzIkWd6nNehJOq3m82OahEDWWBfeWvX/PumZj9liq11/JCMsifUvywfJhAQ==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@pokujs/dom/-/dom-1.3.0.tgz", + "integrity": "sha512-O7aZdRoeq0FrU5uSIVSA10/pHtVpedmkkVggi3qooGCU8+5SGzAFKYRzlwCwAr3gQjdaU34JqVflNgPmz61JGw==", "dev": true, "license": "MIT", "dependencies": { + "@pokujs/scope-hooks": "^1.1.0", "@testing-library/dom": "^10.4.1" }, "engines": { @@ -858,10 +860,26 @@ } } }, + "node_modules/@pokujs/scope-hooks": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@pokujs/scope-hooks/-/scope-hooks-1.1.0.tgz", + "integrity": "sha512-EhUy0aP4k+mMoHxd6eK+Cjuo5YFcpSKfySgT/1+6u7D3UD37JKAbb8cAvnyzhLi+Vfb4y9CQgcyiHksGYtGMZw==", + "dev": true, + "license": "MIT", + "engines": { + "bun": ">=1.x.x", + "deno": ">=2.x.x", + "node": ">=20.x.x", + "typescript": ">=6.x.x" + }, + "peerDependencies": { + "poku": ">=4.3.0" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", - "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz", + "integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==", "cpu": [ "arm" ], @@ -873,9 +891,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", - "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz", + "integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==", "cpu": [ "arm64" ], @@ -887,9 +905,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", - "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz", + "integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==", "cpu": [ "arm64" ], @@ -901,9 +919,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", - "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz", + "integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==", "cpu": [ "x64" ], @@ -915,9 +933,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", - "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz", + "integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==", "cpu": [ "arm64" ], @@ -929,9 +947,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", - "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz", + "integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==", "cpu": [ "x64" ], @@ -943,9 +961,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", - "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz", + "integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==", "cpu": [ "arm" ], @@ -957,9 +975,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", - "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz", + "integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==", "cpu": [ "arm" ], @@ -971,9 +989,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", - "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz", + "integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==", "cpu": [ "arm64" ], @@ -985,9 +1003,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", - "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz", + "integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==", "cpu": [ "arm64" ], @@ -999,9 +1017,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", - "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz", + "integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==", "cpu": [ "loong64" ], @@ -1013,9 +1031,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", - "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz", + "integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==", "cpu": [ "loong64" ], @@ -1027,9 +1045,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", - "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz", + "integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==", "cpu": [ "ppc64" ], @@ -1041,9 +1059,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", - "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz", + "integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==", "cpu": [ "ppc64" ], @@ -1055,9 +1073,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", - "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz", + "integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==", "cpu": [ "riscv64" ], @@ -1069,9 +1087,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", - "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz", + "integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==", "cpu": [ "riscv64" ], @@ -1083,9 +1101,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", - "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz", + "integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==", "cpu": [ "s390x" ], @@ -1097,9 +1115,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", - "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz", + "integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==", "cpu": [ "x64" ], @@ -1111,9 +1129,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", - "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz", + "integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==", "cpu": [ "x64" ], @@ -1125,9 +1143,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", - "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz", + "integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==", "cpu": [ "x64" ], @@ -1139,9 +1157,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", - "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz", + "integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==", "cpu": [ "arm64" ], @@ -1153,9 +1171,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", - "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz", + "integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==", "cpu": [ "arm64" ], @@ -1167,9 +1185,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", - "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz", + "integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==", "cpu": [ "ia32" ], @@ -1181,9 +1199,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", - "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz", + "integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==", "cpu": [ "x64" ], @@ -1195,9 +1213,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", - "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz", + "integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==", "cpu": [ "x64" ], @@ -1254,19 +1272,19 @@ } }, "node_modules/@types/node": { - "version": "25.5.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.2.tgz", - "integrity": "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==", + "version": "25.6.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", + "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.18.0" + "undici-types": "~7.19.0" } }, "node_modules/@types/node/node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", "dev": true, "license": "MIT" }, @@ -1658,9 +1676,9 @@ } }, "node_modules/get-tsconfig": { - "version": "4.13.7", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", - "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==", + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", "dev": true, "license": "MIT", "dependencies": { @@ -1689,9 +1707,9 @@ } }, "node_modules/happy-dom": { - "version": "20.8.9", - "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.8.9.tgz", - "integrity": "sha512-Tz23LR9T9jOGVZm2x1EPdXqwA37G/owYMxRwU0E4miurAtFsPMQ1d2Jc2okUaSjZqAFz2oEn3FLXC5a0a+siyA==", + "version": "20.9.0", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.9.0.tgz", + "integrity": "sha512-GZZ9mKe8r646NUAf/zemnGbjYh4Bt8/MqASJY+pSm5ZDtc3YQox+4gsLI7yi1hba6o+eCsGxpHn5+iEVn31/FQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2032,9 +2050,9 @@ } }, "node_modules/path-scurry/node_modules/lru-cache": { - "version": "11.2.7", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", - "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", + "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -2090,9 +2108,9 @@ } }, "node_modules/poku": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/poku/-/poku-4.2.0.tgz", - "integrity": "sha512-GygMGFGgEJ9kfs6Z+QPg/ODs9OF3oGHN8+hYIxtBox3pwYISO+Vu660vH1e+YzjpGoaoy2o5y6YwE1tX5yZx3Q==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/poku/-/poku-4.3.0.tgz", + "integrity": "sha512-s6xHA93lzirvScBuW5UxUAbx4Cw6C/5MEMTe/27jTtLkDmIsWNpUH2CiMbSOKMxLGj7C3JoM2zfacu3kCrlk3Q==", "dev": true, "license": "MIT", "bin": { @@ -2237,9 +2255,9 @@ } }, "node_modules/rollup": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", - "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz", + "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2253,31 +2271,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.60.1", - "@rollup/rollup-android-arm64": "4.60.1", - "@rollup/rollup-darwin-arm64": "4.60.1", - "@rollup/rollup-darwin-x64": "4.60.1", - "@rollup/rollup-freebsd-arm64": "4.60.1", - "@rollup/rollup-freebsd-x64": "4.60.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", - "@rollup/rollup-linux-arm-musleabihf": "4.60.1", - "@rollup/rollup-linux-arm64-gnu": "4.60.1", - "@rollup/rollup-linux-arm64-musl": "4.60.1", - "@rollup/rollup-linux-loong64-gnu": "4.60.1", - "@rollup/rollup-linux-loong64-musl": "4.60.1", - "@rollup/rollup-linux-ppc64-gnu": "4.60.1", - "@rollup/rollup-linux-ppc64-musl": "4.60.1", - "@rollup/rollup-linux-riscv64-gnu": "4.60.1", - "@rollup/rollup-linux-riscv64-musl": "4.60.1", - "@rollup/rollup-linux-s390x-gnu": "4.60.1", - "@rollup/rollup-linux-x64-gnu": "4.60.1", - "@rollup/rollup-linux-x64-musl": "4.60.1", - "@rollup/rollup-openbsd-x64": "4.60.1", - "@rollup/rollup-openharmony-arm64": "4.60.1", - "@rollup/rollup-win32-arm64-msvc": "4.60.1", - "@rollup/rollup-win32-ia32-msvc": "4.60.1", - "@rollup/rollup-win32-x64-gnu": "4.60.1", - "@rollup/rollup-win32-x64-msvc": "4.60.1", + "@rollup/rollup-android-arm-eabi": "4.60.2", + "@rollup/rollup-android-arm64": "4.60.2", + "@rollup/rollup-darwin-arm64": "4.60.2", + "@rollup/rollup-darwin-x64": "4.60.2", + "@rollup/rollup-freebsd-arm64": "4.60.2", + "@rollup/rollup-freebsd-x64": "4.60.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", + "@rollup/rollup-linux-arm-musleabihf": "4.60.2", + "@rollup/rollup-linux-arm64-gnu": "4.60.2", + "@rollup/rollup-linux-arm64-musl": "4.60.2", + "@rollup/rollup-linux-loong64-gnu": "4.60.2", + "@rollup/rollup-linux-loong64-musl": "4.60.2", + "@rollup/rollup-linux-ppc64-gnu": "4.60.2", + "@rollup/rollup-linux-ppc64-musl": "4.60.2", + "@rollup/rollup-linux-riscv64-gnu": "4.60.2", + "@rollup/rollup-linux-riscv64-musl": "4.60.2", + "@rollup/rollup-linux-s390x-gnu": "4.60.2", + "@rollup/rollup-linux-x64-gnu": "4.60.2", + "@rollup/rollup-linux-x64-musl": "4.60.2", + "@rollup/rollup-openbsd-x64": "4.60.2", + "@rollup/rollup-openharmony-arm64": "4.60.2", + "@rollup/rollup-win32-arm64-msvc": "4.60.2", + "@rollup/rollup-win32-ia32-msvc": "4.60.2", + "@rollup/rollup-win32-x64-gnu": "4.60.2", + "@rollup/rollup-win32-x64-msvc": "4.60.2", "fsevents": "~2.3.2" } }, @@ -2413,14 +2431,14 @@ "license": "MIT" }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -2573,9 +2591,9 @@ } }, "node_modules/typescript": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz", - "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -2594,9 +2612,9 @@ "license": "MIT" }, "node_modules/undici-types": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.7.tgz", - "integrity": "sha512-XA+gOBkzYD3C74sZowtCLTpgtaCdqZhqCvR6y9LXvrKTt/IVU6bz49T4D+BPi475scshCCkb0IklJRw6T1ZlgQ==", + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.25.0.tgz", + "integrity": "sha512-AXNgS1Byr27fTI+2bsPEkV9CxkT8H6xNyRI68b3TatlZo3RkzlqQBLL+w7SmGPVpokjHbcuNVQUWE7FRTg+LRA==", "dev": true, "license": "MIT" }, diff --git a/package.json b/package.json index 72cbdb3..332696f 100644 --- a/package.json +++ b/package.json @@ -31,12 +31,18 @@ }, "scripts": { "test": "node --import=tsx run.test.ts", + "test:pack": "node ./tools/publish-smoke.mjs", "test:happy": "cross-env POKU_ANGULAR_TEST_DOM=happy-dom node --import=tsx ./node_modules/poku/lib/bin/index.js tests --showLogs", "test:happy:none": "cross-env POKU_ANGULAR_TEST_DOM=happy-dom node --import=tsx ./node_modules/poku/lib/bin/index.js tests --showLogs --isolation=none", "test:happy:process": "cross-env POKU_ANGULAR_TEST_DOM=happy-dom node --import=tsx ./node_modules/poku/lib/bin/index.js tests --showLogs --isolation=process", "test:jsdom": "cross-env POKU_ANGULAR_TEST_DOM=jsdom node --import=tsx ./node_modules/poku/lib/bin/index.js tests --showLogs", "test:jsdom:none": "cross-env POKU_ANGULAR_TEST_DOM=jsdom node --import=tsx ./node_modules/poku/lib/bin/index.js tests --showLogs --isolation=none", "test:jsdom:process": "cross-env POKU_ANGULAR_TEST_DOM=jsdom node --import=tsx ./node_modules/poku/lib/bin/index.js tests --showLogs --isolation=process", + "test:major:18": "npm install --no-save @angular/common@18 @angular/compiler@18 @angular/core@18 @angular/forms@18 @angular/platform-browser@18 @angular/platform-browser-dynamic@18 && npm test", + "test:major:19": "npm install --no-save @angular/common@19 @angular/compiler@19 @angular/core@19 @angular/forms@19 @angular/platform-browser@19 @angular/platform-browser-dynamic@19 && npm test", + "test:major:20": "npm install --no-save @angular/common@20 @angular/compiler@20 @angular/core@20 @angular/forms@20 @angular/platform-browser@20 @angular/platform-browser-dynamic@20 && npm test", + "test:major:21": "npm install --no-save @angular/common@21 @angular/compiler@21 @angular/core@21 @angular/forms@21 @angular/platform-browser@21 @angular/platform-browser-dynamic@21 && npm test", + "test:multi-major": "npm run test:major:18 && npm run test:major:19 && npm run test:major:20 && npm run test:major:21", "clean": "rimraf dist", "build": "tsup src/index.ts src/plugin.ts src/angular-testing.ts src/dom-setup-happy.ts src/dom-setup-jsdom.ts --format esm --dts --target node20 --sourcemap --clean --tsconfig tsconfig.tsup.json", "typecheck": "tsc -p tsconfig.build.json --noEmit", @@ -44,6 +50,7 @@ "format:check": "prettier --check .", "lint": "npm run typecheck", "check": "npm run typecheck && npm test", + "check:publish": "npm run test:pack", "prepack": "npm run build" }, "keywords": [ @@ -74,11 +81,12 @@ "@testing-library/dom": "^10.4.1" }, "peerDependencies": { - "@angular/common": ">=18", - "@angular/core": ">=18", - "@angular/forms": ">=18", - "@angular/platform-browser": ">=18", - "@angular/platform-browser-dynamic": ">=18", + "@angular/common": ">=18 <22", + "@angular/compiler": ">=18 <22", + "@angular/core": ">=18 <22", + "@angular/forms": ">=18 <22", + "@angular/platform-browser": ">=18 <22", + "@angular/platform-browser-dynamic": ">=18 <22", "happy-dom": ">=20", "jsdom": ">=22", "poku": ">=4.1.0" @@ -96,18 +104,19 @@ }, "devDependencies": { "@angular/common": "^19.0.0", + "@angular/compiler": "^19.0.0", "@angular/core": "^19.0.0", "@angular/forms": "^19.0.0", "@angular/platform-browser": "^19.0.0", "@angular/platform-browser-dynamic": "^19.0.0", "@happy-dom/global-registrator": "^20.8.9", - "@pokujs/dom": "^1.2.0", + "@pokujs/dom": "^1.3.0", "@types/jsdom": "^28.0.1", "@types/node": "^25.5.0", "cross-env": "^10.1.0", "happy-dom": "^20.8.9", "jsdom": "^26.1.0", - "poku": "4.2.0", + "poku": "^4.3.0", "rimraf": "^6.0.1", "tsup": "^8.5.0", "tsx": "^4.21.0", diff --git a/src/angular-testing.ts b/src/angular-testing.ts index 6684d99..3334633 100644 --- a/src/angular-testing.ts +++ b/src/angular-testing.ts @@ -1,138 +1,412 @@ -import type { BoundFunctions, Screen } from '@testing-library/dom'; -import type { ComponentFixture } from '@angular/core/testing'; -import type { EnvironmentProviders, Provider, Type } from '@angular/core'; -import { getQueriesForElement, queries } from '@testing-library/dom'; -import * as domTestingLibrary from '@testing-library/dom'; -import { TestBed } from '@angular/core/testing'; -import { provideExperimentalZonelessChangeDetection } from '@angular/core'; +import type { BoundFunctions, Screen } from "@testing-library/dom"; +import type { + ApplicationRef, + ChangeDetectorRef, + ComponentRef, + DebugElement, + ElementRef, + EnvironmentInjector, + Provider, + Type, +} from "@angular/core"; +import type { EnvironmentProviders } from "@angular/core"; +import { getQueriesForElement, queries } from "@testing-library/dom"; +import * as domTestingLibrary from "@testing-library/dom"; +import * as pokuDom from "@pokujs/dom"; +import { + VERSION, + createComponent, + createEnvironmentInjector, + getDebugNode, + importProvidersFrom, + runInInjectionContext, +} from "@angular/core"; import { createRenderMetricsEmitter, createScreen, getNow, wrapFireEventMethods, -} from '@pokujs/dom'; -import { parseRuntimeOptions } from './runtime-options.ts'; +} from "@pokujs/dom"; +import { createApplication } from "@angular/platform-browser"; +import { parseRuntimeOptions } from "./runtime-options.ts"; +import { provideCompatibleZonelessChangeDetection } from "./zoneless-change-detection.ts"; const runtimeOptions = parseRuntimeOptions(); const metrics = createRenderMetricsEmitter({ runtimeOptions, - metricsStateKey: Symbol.for('@pokujs/angular.metrics-runtime-state'), - metricsBatchMessageType: 'POKU_ANGULAR_RENDER_METRIC_BATCH', + metricsStateKey: Symbol.for("@pokujs/angular.metrics-runtime-state"), + metricsBatchMessageType: "POKU_ANGULAR_RENDER_METRIC_BATCH", }); -// Track all live fixtures so cleanup() and fireEvent can operate on them. -const mountedFixtures = new Set>(); - -// --------------------------------------------------------------------------- -// Signal-input detection + application -// -// Angular's JIT compiler does not register signal inputs (input() / input.required()) -// in ɵcmp.inputs because it lacks the source-level analysis that Angular AOT performs. -// ComponentRef.setInput() therefore silently fails (NG0303) in this environment. -// -// We work around this by directly applying values to the underlying ReactiveNode -// via the same `applyValueToInputSignal` function that Angular's own change detection -// uses internally. The detection key is `applyValueToInputSignal` existing on the -// node's prototype, which is unique to InputSignalNode (not present on WritableSignal). -// --------------------------------------------------------------------------- - -const SIGNAL_SYMBOL_STR = 'Symbol(SIGNAL)'; - -const getSignalNode = (value: unknown): Record | null => { - if (typeof value !== 'function') return null; - const sym = Object.getOwnPropertySymbols(value).find( - (s) => s.toString() === SIGNAL_SYMBOL_STR +type ScopeSlot = { + readonly value: T; +}; + +type ScopeLike = { + getOrCreateSlot(key: symbol, init: () => T): ScopeSlot; + getSlot?(key: symbol): ScopeSlot | undefined; + addCleanup?(fn: () => void | Promise): void; +}; + +type DomScopeApi = { + defineSlotKey?: (name: string) => symbol; + getOrCreateScope?: () => ScopeLike | undefined; + getCurrentScope?: () => ScopeLike | undefined; +}; + +type MountedHandle = { + destroy(): void; + detectChanges?(): Promise; +}; + +type RenderMountedHandle = MountedHandle & { + detectChanges(): Promise; +}; + +type RenderHookExecution = { + injector: EnvironmentInjector; + result: Result; + destroyed: boolean; +}; + +type ScopedRuntimeState = { + mountedHandles: Set; + cleanupRegistered: boolean; +}; + +export type AngularFixture = { + componentRef: ComponentRef; + componentInstance: T; + nativeElement: HTMLElement; + elementRef: ElementRef; + changeDetectorRef: ChangeDetectorRef; + debugElement: DebugElement | null; + detectChanges(checkNoChanges?: boolean): void; + checkNoChanges(): void; + isStable(): boolean; + whenStable(): Promise; + whenRenderingDone(): Promise; + destroy(): void; +}; + +type RenderFixture = AngularFixture; + +const domScopeApi = pokuDom as unknown as DomScopeApi; + +const RUNTIME_STATE_SLOT_KEY = + typeof domScopeApi.defineSlotKey === "function" + ? domScopeApi.defineSlotKey( + "@pokujs/angular.runtime-state", + ) + : undefined; + +const fallbackMountedHandles = new Set(); + +const supportedAngularMajorRange = { + min: 18, + max: 21, +} as const; + +const currentAngularMajor = Number.parseInt(VERSION.major, 10); + +const canUseSignalInputFallback = + Number.isFinite(currentAngularMajor) && + currentAngularMajor >= supportedAngularMajorRange.min && + currentAngularMajor <= supportedAngularMajorRange.max; + +const throwCollectedErrors = (errors: unknown[], message: string) => { + if (errors.length === 0) return; + if (errors.length === 1) { + throw errors[0]; + } + + throw new AggregateError(errors, message); +}; + +const runCleanupSteps = (steps: Array<() => void>, errorMessage: string) => { + const errors: unknown[] = []; + + for (const step of steps) { + try { + step(); + } catch (error) { + errors.push(error); + } + } + + throwCollectedErrors(errors, errorMessage); +}; + +const destroyMountedHandles = (mountedHandles: Set) => { + const errors: unknown[] = []; + + for (const handle of [...mountedHandles]) { + try { + handle.destroy(); + } catch (error) { + errors.push(error); + } + } + + throwCollectedErrors( + errors, + "@pokujs/angular: cleanup failed while destroying mounted handles.", ); - return sym ? (value as Record)[sym] as Record : null; }; -const applyInputValue = ( +const getScopedRuntimeState = (): ScopedRuntimeState | undefined => { + if (!RUNTIME_STATE_SLOT_KEY) return undefined; + if (typeof domScopeApi.getOrCreateScope !== "function") return undefined; + + const scope = domScopeApi.getOrCreateScope(); + if (!scope) return undefined; + + const state = scope.getOrCreateSlot(RUNTIME_STATE_SLOT_KEY, () => ({ + mountedHandles: new Set(), + cleanupRegistered: false, + })).value; + + if (!state.cleanupRegistered && typeof scope.addCleanup === "function") { + state.cleanupRegistered = true; + scope.addCleanup(() => { + try { + destroyMountedHandles(state.mountedHandles); + } finally { + metrics.flushMetricBuffer(); + } + }); + } + + return state; +}; + +const getCurrentScopedRuntimeState = (): ScopedRuntimeState | undefined => { + if (!RUNTIME_STATE_SLOT_KEY) return undefined; + if (typeof domScopeApi.getCurrentScope !== "function") return undefined; + + const scope = domScopeApi.getCurrentScope(); + const slot = scope?.getSlot?.(RUNTIME_STATE_SLOT_KEY); + return slot?.value; +}; + +const getMountedHandles = (): Set => + getScopedRuntimeState()?.mountedHandles ?? fallbackMountedHandles; + +const getCurrentMountedHandles = (): Set => + getCurrentScopedRuntimeState()?.mountedHandles ?? fallbackMountedHandles; + +/** + * Attempt to set an Angular signal input directly via the signal node's own + * internal setter. This is required in JIT-compiled test environments where + * Angular's JIT compiler does not register signal inputs (`input()`) in the + * component def's `inputs` map, causing `ComponentRef.setInput()` to log + * NG0303 and return without updating the signal. + * + * Detection: locate the canonical `'SIGNAL'` symbol on the instance property + * via `Symbol.prototype.description` (ES2019+) and verify the node exposes + * `applyValueToInputSignal`. The `'SIGNAL'` description is Angular's + * foundational reactive primitive — the same one that drives every template + * binding — so it is maximally stable. + * + * Returns `true` if the value was applied, `false` if the property is not a + * recognised signal input (caller should fall back to `ComponentRef.setInput`). + */ +const trySetSignalInput = ( instance: Record, key: string, - value: unknown -): void => { - const node = getSignalNode(instance[key]); - if (!node) return; + value: unknown, +): boolean => { + if (!canUseSignalInputFallback) return false; - // InputSignalNode has `applyValueToInputSignal`; WritableSignalNode does not. - const applyFn = node['applyValueToInputSignal'] as - | ((n: Record, v: unknown) => void) - | undefined; + const prop = instance[key]; + if (typeof prop !== "function") return false; - if (typeof applyFn === 'function') { - applyFn(node, value); - } + const signalSym = Object.getOwnPropertySymbols(prop).find( + (s) => s.description === "SIGNAL", + ); + if (!signalSym) return false; + + const node = (prop as unknown as Record)[ + signalSym + ] as Record; + if (node === null || typeof node !== "object") return false; + + const applyFn = node["applyValueToInputSignal"]; + if (typeof applyFn !== "function") return false; + + (applyFn as (n: unknown, v: unknown) => void)(node, value); + return true; }; const applyInputs = ( - fixture: ComponentFixture, - inputs: Record + componentRef: ComponentRef, + inputs: Record, ): void => { - const instance = fixture.componentInstance as unknown as Record; + const instance = componentRef.instance as Record; for (const [key, value] of Object.entries(inputs)) { - applyInputValue(instance, key, value); + // Probe for a signal input first. In JIT mode, `ComponentRef.setInput()` + // silently no-ops for `input()` signal properties (logging NG0303 to the + // console) because the JIT compiler does not add them to the component + // def's inputs map. The signal-node path bypasses that limitation while + // the public API handles all traditional `@Input()` decorators. + if (!trySetSignalInput(instance, key, value)) { + componentRef.setInput(key, value); + } + } +}; + +const buildEnvironmentProviders = ( + optionsProviders: Array | undefined, + imports: Array> | undefined, +): Array => { + const providers: Array = [ + provideCompatibleZonelessChangeDetection(), + ]; + + if (imports && imports.length > 0) { + providers.push(importProvidersFrom(...imports)); } + + if (optionsProviders && optionsProviders.length > 0) { + providers.push(...optionsProviders); + } + + return providers; +}; + +const createIsolatedApplication = async ( + optionsProviders?: Array, + imports?: Array>, +) => + await createApplication({ + providers: buildEnvironmentProviders(optionsProviders, imports), + }); + +const waitForFixtureStability = async (fixture: RenderFixture) => { + await fixture.whenStable(); +}; + +const createFixture = ( + componentRef: ComponentRef, + applicationRef: ApplicationRef, + onDestroy: () => void, +): RenderFixture => { + let destroyed = false; + let stable = false; + + const stabilitySubscription = applicationRef.isStable.subscribe((isStable) => { + stable = isStable; + }); + + const waitForStability = async () => { + await applicationRef.whenStable(); + await Promise.resolve(); + }; + + return { + componentRef, + componentInstance: componentRef.instance, + nativeElement: componentRef.location.nativeElement as HTMLElement, + elementRef: componentRef.location, + changeDetectorRef: componentRef.changeDetectorRef, + get debugElement() { + return ( + (getDebugNode( + componentRef.location.nativeElement, + ) as DebugElement | null) ?? null + ); + }, + detectChanges(checkNoChanges = false) { + componentRef.changeDetectorRef.detectChanges(); + if (checkNoChanges) { + componentRef.changeDetectorRef.checkNoChanges(); + } + }, + checkNoChanges() { + componentRef.changeDetectorRef.checkNoChanges(); + }, + isStable() { + return !destroyed && stable; + }, + whenStable() { + return waitForStability(); + }, + whenRenderingDone() { + return waitForStability(); + }, + destroy() { + if (destroyed) return; + destroyed = true; + stabilitySubscription.unsubscribe(); + onDestroy(); + }, + }; }; -// --------------------------------------------------------------------------- -// Flush Angular change detection across all live fixtures. -// Called automatically after every fireEvent so signal-driven template -// updates reach the DOM without requiring explicit detectChanges() calls. -// --------------------------------------------------------------------------- const flushAllFixtures = async () => { await Promise.resolve(); - for (const fixture of mountedFixtures) { + const mountedHandles = [...getCurrentMountedHandles()]; + + for (const handle of mountedHandles) { try { - fixture.detectChanges(); - } catch { - // Fixture may have been destroyed between the event and the flush. + await handle.detectChanges?.(); + } catch (error) { + if (!getCurrentMountedHandles().has(handle)) { + continue; + } + + throw error; } } await Promise.resolve(); }; -// --------------------------------------------------------------------------- -// Public API types -// --------------------------------------------------------------------------- +const createRenderHookExecution = ( + runtimeApplication: ApplicationRef, + hookFn: (props: Props) => Result, + props: Props, +): RenderHookExecution => { + const injector = createEnvironmentInjector( + [], + runtimeApplication.injector, + "@pokujs/angular.renderHook", + ); + + try { + return { + injector, + result: runInInjectionContext(injector, () => hookFn(props)), + destroyed: false, + }; + } catch (error) { + injector.destroy(); + throw error; + } +}; + +const destroyRenderHookExecution = (execution: RenderHookExecution) => { + if (execution.destroyed) return; -export type RenderOptions = { - /** Additional Angular providers for the test module. */ + try { + execution.injector.destroy(); + } finally { + execution.destroyed = true; + } +}; + +export type RenderOptions = { providers?: Array; - /** - * Additional standalone components, directives, pipes, or NgModules to - * import into the test module (e.g. shared modules). - */ imports?: Array>; - /** - * Initial values for the component's signal inputs (`input()` / `input.required()`). - * Applied before the first change-detection cycle so required inputs are satisfied. - */ inputs?: Record; - /** - * Set to `false` to skip the automatic `detectChanges()` + `whenStable()` - * call after mounting — useful when you need manual control before first render. - */ detectChanges?: boolean; }; export type RenderResult = BoundFunctions & { - /** The component's host element, appended to `document.body`. */ container: HTMLElement; - /** Always `document.body`; Testing Library queries are scoped here. */ baseElement: HTMLElement; - /** The underlying Angular `ComponentFixture` for framework-level assertions. */ - fixture: ComponentFixture; - /** - * Trigger a synchronous change-detection cycle and wait for any pending - * async work (e.g. resolved promises inside `ngOnInit`). - */ + fixture: AngularFixture; detectChanges: () => Promise; - /** Destroy the component and remove it from the DOM. */ unmount: () => void; - /** - * Apply new signal-input values, trigger change detection, and wait for - * stability — equivalent to a parent updating bound `@Input()` values. - */ rerender: (inputs?: Record) => Promise; }; @@ -149,82 +423,98 @@ export type RenderHookResult = { unmount: () => void; }; -// --------------------------------------------------------------------------- -// render() -// --------------------------------------------------------------------------- - -/** - * Mount a standalone Angular component into a fresh TestBed module and return - * a Testing Library query surface together with Angular-specific helpers. - * - * Call `afterEach(cleanup)` to reset the TestBed between tests. Only one - * component should be actively managed per test. - * - * @example - * ```typescript - * import { afterEach, assert, test } from 'poku'; - * import { cleanup, fireEvent, render, screen } from '@pokujs/angular'; - * import { CounterButton } from './CounterButton.ts'; - * - * afterEach(cleanup); - * - * test('increments the counter', async () => { - * await render(CounterButton, { inputs: { initialCount: 1 } }); - * await fireEvent.click(screen.getByRole('button', { name: 'Increment' })); - * assert.strictEqual(screen.getByRole('heading').textContent, 'Count: 2'); - * }); - * ``` - */ export const render = async ( component: Type, - options: RenderOptions = {} + options: RenderOptions = {}, ): Promise> => { - await TestBed.configureTestingModule({ - imports: [component, ...(options.imports ?? [])], - providers: [ - provideExperimentalZonelessChangeDetection(), - ...(options.providers ?? []), - ], - }).compileComponents(); - - const fixture = TestBed.createComponent(component); - mountedFixtures.add(fixture as ComponentFixture); - - // Apply signal inputs BEFORE the first change-detection cycle so that - // required inputs (input.required()) are satisfied when renderning begins. + const startedAt = getNow(); + const runtimeApplication = await createIsolatedApplication( + options.providers, + options.imports, + ); + const mountedHandles = getMountedHandles(); + + const baseElement = document.body; + const container = document.createElement("div"); + baseElement.appendChild(container); + + const componentRef = createComponent(component, { + environmentInjector: runtimeApplication.injector, + hostElement: container, + }); + runtimeApplication.attachView(componentRef.hostView); + + const teardown = () => { + runCleanupSteps( + [ + () => { + if (!runtimeApplication.destroyed && !componentRef.hostView.destroyed) { + runtimeApplication.detachView(componentRef.hostView); + } + }, + () => { + if (!componentRef.hostView.destroyed) { + componentRef.destroy(); + } + }, + () => { + if (!runtimeApplication.destroyed) { + runtimeApplication.destroy(); + } + }, + () => { + if (container.parentNode) { + container.parentNode.removeChild(container); + } + }, + ], + "@pokujs/angular: cleanup failed while destroying a rendered component.", + ); + }; + + const fixture = createFixture(componentRef, runtimeApplication, teardown); + + const handle: RenderMountedHandle = { + destroy() { + if (!mountedHandles.has(handle)) return; + try { + fixture.destroy(); + } finally { + mountedHandles.delete(handle); + } + }, + async detectChanges() { + fixture.detectChanges(); + await waitForFixtureStability(fixture as RenderFixture); + }, + }; + + mountedHandles.add(handle); + if (options.inputs) { - applyInputs(fixture as ComponentFixture, options.inputs); + applyInputs(componentRef as ComponentRef, options.inputs); } if (options.detectChanges !== false) { - fixture.detectChanges(); - await fixture.whenStable(); + await handle.detectChanges(); } - const componentName = component.name ?? 'AnonymousComponent'; - const startedAt = getNow(); + const componentName = component.name ?? "AnonymousComponent"; metrics.emitRenderMetric(componentName, getNow() - startedAt); - const baseElement = document.body; - const container = fixture.nativeElement as HTMLElement; - const detectChanges = async () => { - fixture.detectChanges(); - await fixture.whenStable(); + await handle.detectChanges(); }; const unmount = () => { - if (!mountedFixtures.has(fixture as ComponentFixture)) return; - fixture.destroy(); - mountedFixtures.delete(fixture as ComponentFixture); + handle.destroy(); }; const rerender = async (inputs?: Record) => { if (inputs) { - applyInputs(fixture as ComponentFixture, inputs); + applyInputs(componentRef as ComponentRef, inputs); } - fixture.detectChanges(); - await fixture.whenStable(); + await handle.detectChanges(); }; return { @@ -238,92 +528,115 @@ export const render = async ( }; }; -// --------------------------------------------------------------------------- -// renderHook() -// --------------------------------------------------------------------------- - -/** - * Run a factory function inside Angular's injection context so it can call - * `inject()` and use signals. Mirrors the React/Vue `renderHook` API. - * - * @example - * ```typescript - * import { inject } from '@angular/core'; - * import { renderHook, cleanup } from '@pokujs/angular'; - * import { CounterService } from './CounterService.ts'; - * - * afterEach(cleanup); - * - * test('service increments its signal counter', () => { - * const { result } = renderHook(() => inject(CounterService)); - * assert.strictEqual(result.current.count(), 0); - * result.current.increment(); - * assert.strictEqual(result.current.count(), 1); - * }); - * ``` - */ -export const renderHook = >( +export const renderHook = async >( hookFn: (props: Props) => Result, - options: RenderHookOptions = {} -): RenderHookResult => { - TestBed.configureTestingModule({ - providers: [ - provideExperimentalZonelessChangeDetection(), - ...(options.providers ?? []), - ], - }); + options: RenderHookOptions = {}, +): Promise> => { + const runtimeApplication = await createIsolatedApplication(options.providers); + const mountedHandles = getMountedHandles(); const initialProps = (options.initialProps ?? {}) as Props; let currentProps = initialProps; - let currentResult: Result = TestBed.runInInjectionContext(() => - hookFn(currentProps) + let currentExecution = createRenderHookExecution( + runtimeApplication, + hookFn, + currentProps, ); + let currentResult = currentExecution.result; + let unmounted = false; + + // A stable container whose `current` property always reflects the latest + // result. Destructuring `const { result } = await renderHook(...)` must + // still see updated values after `rerender()` — using a live getter here + // ensures that `result.current` is not a stale snapshot. + const resultRef: { current: Result } = { + get current() { + return currentResult; + }, + } as { current: Result }; + + const handle: MountedHandle = { + destroy() { + if (!mountedHandles.has(handle)) return; + unmounted = true; + + try { + runCleanupSteps( + [ + () => { + destroyRenderHookExecution( + currentExecution as RenderHookExecution, + ); + }, + () => { + runtimeApplication.destroy(); + }, + ], + "@pokujs/angular: cleanup failed while destroying a rendered hook.", + ); + } finally { + mountedHandles.delete(handle); + } + }, + }; + + mountedHandles.add(handle); return { get result() { - return { current: currentResult }; + return resultRef; }, rerender(nextProps = currentProps) { + if (unmounted) { + throw new Error( + "@pokujs/angular: cannot call rerender() after the hook has been unmounted.", + ); + } + const nextExecution = createRenderHookExecution( + runtimeApplication, + hookFn, + nextProps, + ); + + try { + destroyRenderHookExecution( + currentExecution as RenderHookExecution, + ); + } catch (destroyError) { + try { + destroyRenderHookExecution( + nextExecution as RenderHookExecution, + ); + } catch (rollbackError) { + throwCollectedErrors( + [destroyError, rollbackError], + "@pokujs/angular: renderHook rerender failed while rolling back the previous hook execution.", + ); + } + + throw destroyError; + } + currentProps = nextProps; - currentResult = TestBed.runInInjectionContext(() => hookFn(currentProps)); + currentExecution = nextExecution; + currentResult = nextExecution.result; }, unmount() { - TestBed.resetTestingModule(); + handle.destroy(); }, }; }; -// --------------------------------------------------------------------------- -// cleanup() -// --------------------------------------------------------------------------- - -/** - * Destroy all mounted fixtures, reset the TestBed module, and flush any - * buffered render metrics. Call this in `afterEach` to keep tests isolated. - */ export const cleanup = async () => { - for (const fixture of [...mountedFixtures]) { - try { - fixture.destroy(); - } catch { - // Ignore errors from already-destroyed fixtures. - } + try { + destroyMountedHandles(getCurrentMountedHandles()); + } finally { + metrics.flushMetricBuffer(); } - mountedFixtures.clear(); - TestBed.resetTestingModule(); - metrics.flushMetricBuffer(); }; -// --------------------------------------------------------------------------- -// screen (lazy proxy — safe across test isolation boundaries) -// --------------------------------------------------------------------------- - export const screen = createScreen() as Screen; -// --------------------------------------------------------------------------- -// fireEvent (async wrapper — triggers Angular CD after each event) -// --------------------------------------------------------------------------- - const baseFireEventInstance = domTestingLibrary.fireEvent; type AsyncifyFunction = T extends (...args: infer Args) => infer Result @@ -351,8 +664,7 @@ wrapFireEventMethods( const result = invoke(); await flushAllFixtures(); return result; - } + }, ); export const fireEvent = wrappedFireEvent; - diff --git a/src/dom-setup-happy.ts b/src/dom-setup-happy.ts index e758efe..66c4f17 100644 --- a/src/dom-setup-happy.ts +++ b/src/dom-setup-happy.ts @@ -1,24 +1,8 @@ +import '@angular/compiler'; import { setupHappyDomEnvironment } from '@pokujs/dom'; import { parseRuntimeOptions } from './runtime-options.ts'; -import { getTestBed } from '@angular/core/testing'; -import { BrowserDynamicTestingModule, platformBrowserDynamicTesting } from '@angular/platform-browser-dynamic/testing'; await setupHappyDomEnvironment({ runtimeOptions: parseRuntimeOptions(), packageTag: '@pokujs/angular', }); - -// Initialize Angular's JIT testing environment once per process. -// Guard against calling initTestEnvironment twice when isolation is 'none'. -const INIT_KEY = Symbol.for('@pokujs/angular.testbed-initialized'); -type GlobalWithInitFlag = typeof globalThis & { [INIT_KEY]?: boolean }; -const g = globalThis as GlobalWithInitFlag; - -if (!g[INIT_KEY]) { - g[INIT_KEY] = true; - getTestBed().initTestEnvironment( - BrowserDynamicTestingModule, - platformBrowserDynamicTesting(), - { teardown: { destroyAfterEach: false } } - ); -} diff --git a/src/dom-setup-jsdom.ts b/src/dom-setup-jsdom.ts index f585e48..e70ddcd 100644 --- a/src/dom-setup-jsdom.ts +++ b/src/dom-setup-jsdom.ts @@ -1,22 +1,8 @@ +import '@angular/compiler'; import { setupJsdomEnvironment } from '@pokujs/dom'; import { parseRuntimeOptions } from './runtime-options.ts'; -import { getTestBed } from '@angular/core/testing'; -import { BrowserDynamicTestingModule, platformBrowserDynamicTesting } from '@angular/platform-browser-dynamic/testing'; await setupJsdomEnvironment({ runtimeOptions: parseRuntimeOptions(), packageTag: '@pokujs/angular', }); - -const INIT_KEY = Symbol.for('@pokujs/angular.testbed-initialized'); -type GlobalWithInitFlag = typeof globalThis & { [INIT_KEY]?: boolean }; -const g = globalThis as GlobalWithInitFlag; - -if (!g[INIT_KEY]) { - g[INIT_KEY] = true; - getTestBed().initTestEnvironment( - BrowserDynamicTestingModule, - platformBrowserDynamicTesting(), - { teardown: { destroyAfterEach: false } } - ); -} diff --git a/src/zoneless-change-detection.ts b/src/zoneless-change-detection.ts new file mode 100644 index 0000000..353631d --- /dev/null +++ b/src/zoneless-change-detection.ts @@ -0,0 +1,31 @@ +import type { EnvironmentProviders } from '@angular/core'; +import * as angularCore from '@angular/core'; + +type AngularCoreZonelessApi = { + provideZonelessChangeDetection?: () => EnvironmentProviders; + provideExperimentalZonelessChangeDetection?: () => EnvironmentProviders; +}; + +const angularCoreZonelessApi = angularCore as AngularCoreZonelessApi; + +/** + * Resolve Angular's zoneless change-detection provider across versions. + * Prefers the stable `provideZonelessChangeDetection()` API when present and + * falls back to `provideExperimentalZonelessChangeDetection()` for older releases. + */ +export const provideCompatibleZonelessChangeDetection = () => { + if (typeof angularCoreZonelessApi.provideZonelessChangeDetection === 'function') { + return angularCoreZonelessApi.provideZonelessChangeDetection(); + } + + if ( + typeof angularCoreZonelessApi.provideExperimentalZonelessChangeDetection === + 'function' + ) { + return angularCoreZonelessApi.provideExperimentalZonelessChangeDetection(); + } + + throw new Error( + 'Angular does not expose provideZonelessChangeDetection or provideExperimentalZonelessChangeDetection.' + ); +}; \ No newline at end of file diff --git a/tests/angular-cleanup.test.ts b/tests/angular-cleanup.test.ts new file mode 100644 index 0000000..d9c458d --- /dev/null +++ b/tests/angular-cleanup.test.ts @@ -0,0 +1,99 @@ +import { Component, OnDestroy, computed, signal } from '@angular/core'; +import { afterEach, assert, test } from 'poku'; +import { CounterButton } from './__fixtures__/CounterButton.ts'; +import { cleanup, fireEvent, render, screen } from '../src/index.ts'; + +@Component({ + standalone: true, + selector: 'app-throwing-destroy', + host: { 'data-fixture': 'throwing-destroy' }, + template: `Throwing destroy`, +}) +class ThrowingDestroyComponent implements OnDestroy { + ngOnDestroy() { + throw new Error('destroy failure'); + } +} + +@Component({ + standalone: true, + selector: 'app-throwing-click', + host: { 'data-fixture': 'throwing-click' }, + template: ` + + {{ label() }} + `, +}) +class ThrowingClickComponent { + private readonly shouldThrow = signal(false); + readonly label = computed(() => { + if (this.shouldThrow()) { + throw new Error('click failure'); + } + + return 'safe'; + }); + + explode() { + this.shouldThrow.set(true); + } +} + +afterEach(cleanup); + +test('cleanup with no mounted components does not throw', async () => { + // calling cleanup on an empty slate must be a no-op + await cleanup(); + assert.ok(true); +}); + +test('double cleanup does not throw and leaves the DOM clean', async () => { + await render(CounterButton, { inputs: { initialCount: 3 } }); + + await cleanup(); + // second cleanup — all handles have already been removed from the set + await cleanup(); + + assert.throws(() => screen.getByRole('heading', { name: 'Count: 3' })); +}); + +test('cleanup removes the mounted component from the DOM', async () => { + await render(CounterButton, { inputs: { initialCount: 5 } }); + + assert.strictEqual( + screen.getByRole('heading', { name: 'Count: 5' }).textContent, + 'Count: 5' + ); + + await cleanup(); + + assert.throws(() => screen.getByRole('heading', { name: 'Count: 5' })); +}); + +test('cleanup only removes components from the current scope, not others', async () => { + // Render two components in the same synchronous scope. + await render(CounterButton, { inputs: { initialCount: 1 } }); + await render(CounterButton, { inputs: { initialCount: 2 } }); + + // Implicit cleanup via afterEach should remove both without throwing. +}); + +test('cleanup surfaces ngOnDestroy failures instead of swallowing them', async () => { + await render(ThrowingDestroyComponent); + await render(CounterButton, { inputs: { initialCount: 8 } }); + + await assert.rejects(cleanup(), /destroy failure/); + await cleanup(); + + assert.throws(() => screen.getByText('Throwing destroy')); + assert.throws(() => screen.getByRole('heading', { name: 'Count: 8' })); +}); + +test('fireEvent surfaces post-event change-detection failures', async () => { + await render(ThrowingClickComponent); + + await assert.rejects( + fireEvent.click(screen.getByRole('button', { name: 'Explode' })), + /click failure/ + ); +}); diff --git a/tests/angular-hooks.test.ts b/tests/angular-hooks.test.ts index e5983c2..7479673 100644 --- a/tests/angular-hooks.test.ts +++ b/tests/angular-hooks.test.ts @@ -1,3 +1,4 @@ +import { DestroyRef, inject } from '@angular/core'; import { afterEach, assert, test } from 'poku'; import { ToggleHarness } from './__fixtures__/ToggleHarness.ts'; import { cleanup, fireEvent, render, renderHook, screen } from '../src/index.ts'; @@ -5,7 +6,7 @@ import { useToggle } from './helpers/use-toggle.ts'; afterEach(cleanup); -await test('tests signal composables through a component harness and renderHook', async () => { +test('tests signal composables through a component harness and renderHook', async () => { await render(ToggleHarness); assert.strictEqual( @@ -21,8 +22,8 @@ await test('tests signal composables through a component harness and renderHook' ); }); -await test('tests signal hook logic directly with renderHook', () => { - const { result } = renderHook( +test('tests signal hook logic directly with renderHook', async () => { + const { result } = await renderHook( ({ initial }: { initial: boolean }) => useToggle(initial), { initialProps: { initial: true } } ); @@ -34,3 +35,85 @@ await test('tests signal hook logic directly with renderHook', () => { // Signal values update immediately when mutated through the returned ref. assert.strictEqual(result.current.enabled(), false); }); + +test('renderHook.rerender re-evaluates the hook with updated props', async () => { + const { result, rerender } = await renderHook( + ({ initial }: { initial: boolean }) => useToggle(initial), + { initialProps: { initial: false } } + ); + + assert.strictEqual(result.current.enabled(), false); + + // rerender creates a fresh hook invocation with the new props. + rerender({ initial: true }); + + assert.strictEqual(result.current.enabled(), true); +}); + +test('renderHook.rerender after unmount throws a descriptive error', async () => { + const { rerender, unmount } = await renderHook( + ({ initial }: { initial: boolean }) => useToggle(initial), + { initialProps: { initial: false } } + ); + + unmount(); + + assert.throws( + () => rerender({ initial: true }), + /cannot call rerender\(\) after the hook has been unmounted/ + ); +}); + +test('renderHook.rerender destroys the previous injection context', async () => { + const destroyed: string[] = []; + + const { rerender, unmount } = await renderHook( + ({ label }: { label: string }) => { + inject(DestroyRef).onDestroy(() => { + destroyed.push(label); + }); + + return label; + }, + { initialProps: { label: 'first' } } + ); + + assert.deepStrictEqual(destroyed, []); + + rerender({ label: 'second' }); + assert.deepStrictEqual(destroyed, ['first']); + + unmount(); + assert.deepStrictEqual(destroyed, ['first', 'second']); +}); + +test('renderHook.rerender rolls back the next execution if previous cleanup fails', async () => { + const destroyed: string[] = []; + let shouldThrowOnFirstDestroy = true; + + const { result, rerender } = await renderHook( + ({ label }: { label: string }) => { + inject(DestroyRef).onDestroy(() => { + destroyed.push(label); + + if (label === 'first' && shouldThrowOnFirstDestroy) { + shouldThrowOnFirstDestroy = false; + throw new Error('first destroy failure'); + } + }); + + return label; + }, + { initialProps: { label: 'first' } } + ); + + assert.strictEqual(result.current, 'first'); + + assert.throws( + () => rerender({ label: 'second' }), + /first destroy failure/ + ); + + assert.deepStrictEqual(destroyed, ['first', 'second']); + assert.strictEqual(result.current, 'first'); +}); diff --git a/tests/angular-lifecycle.test.ts b/tests/angular-lifecycle.test.ts index dbf8647..6a04136 100644 --- a/tests/angular-lifecycle.test.ts +++ b/tests/angular-lifecycle.test.ts @@ -1,6 +1,8 @@ import { afterEach, assert, test } from 'poku'; +import { AsyncPipeline } from './__fixtures__/AsyncPipeline.ts'; import { GreetingCard } from './__fixtures__/GreetingCard.ts'; import { UnmountWatcher } from './__fixtures__/UnmountWatcher.ts'; +import { CounterButton } from './__fixtures__/CounterButton.ts'; import { cleanup, render, screen } from '../src/index.ts'; afterEach(cleanup); @@ -38,3 +40,46 @@ await test('unmount fires ngOnDestroy on the component instance', async () => { view.unmount(); assert.strictEqual(cleaned, true); }); + +await test('fixture.debugElement is non-null after render', async () => { + const { fixture } = await render(CounterButton); + + // getDebugNode is backed by Angular's live debug map, which is populated + // after the first change-detection pass triggered by render(). + assert.ok( + fixture.debugElement !== null, + 'debugElement should be populated after render' + ); +}); + +await test('render with detectChanges:false defers DOM population until explicit detectChanges', async () => { + const view = await render(CounterButton, { + inputs: { initialCount: 7 }, + detectChanges: false, + }); + + // No change detection has run yet — the interpolated text is not in the DOM. + assert.throws(() => + screen.getByRole('heading', { name: 'Count: 7' }) + ); + + // Trigger change detection manually. + await view.detectChanges(); + + assert.strictEqual( + screen.getByRole('heading', { name: 'Count: 7' }).textContent, + 'Count: 7' + ); +}); + +await test('fixture.isStable reflects Angular application stability', async () => { + const view = await render(AsyncPipeline, { + detectChanges: false, + }); + + view.fixture.detectChanges(); + assert.strictEqual(view.fixture.isStable(), false); + + await view.fixture.whenStable(); + assert.strictEqual(view.fixture.isStable(), true); +}); diff --git a/tests/angular-scope-isolation.test.ts b/tests/angular-scope-isolation.test.ts new file mode 100644 index 0000000..98a0b6f --- /dev/null +++ b/tests/angular-scope-isolation.test.ts @@ -0,0 +1,137 @@ +import * as pokuDom from '@pokujs/dom'; +import { Component, input } from '@angular/core'; +import { assert, describe, it } from 'poku'; +import { cleanup, render, screen } from '../src/index.ts'; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +const SCOPE_HOOKS_KEY = Symbol.for('@pokujs/poku.test-scope-hooks'); + +type ScopeHooks = { + createHolder: () => { scope: unknown }; + runScoped: ( + holder: { scope: unknown }, + fn: () => Promise | unknown + ) => Promise; +}; + +const hasDomScopeApi = + typeof (pokuDom as Record).defineSlotKey === 'function' && + typeof (pokuDom as Record).getOrCreateScope === 'function'; + +@Component({ + standalone: true, + selector: 'poku-scope-probe', + template: `
{{ label() }}
`, +}) +class ScopeProbeComponent { + readonly label = input.required(); +} + +const testHooksDisabled = async () => { + let resolveARendered!: () => void; + let resolveBRendered!: () => void; + let resolveACleaned!: () => void; + + const aRendered = new Promise((resolve) => { + resolveARendered = resolve; + }); + const bRendered = new Promise((resolve) => { + resolveBRendered = resolve; + }); + const aCleaned = new Promise((resolve) => { + resolveACleaned = resolve; + }); + + await Promise.all([ + it('suite A cleanup removes suite B fixture when isolation is unavailable', async () => { + await render(ScopeProbeComponent, { inputs: { label: 'suite-a' } }); + assert.strictEqual(screen.getByTestId('suite-a').textContent, 'suite-a'); + + resolveARendered(); + await bRendered; + + await cleanup(); + resolveACleaned(); + + assert.throws(() => screen.getByTestId('suite-a')); + }), + + it('suite B is contaminated by suite A cleanup when isolation is unavailable', async () => { + await aRendered; + await render(ScopeProbeComponent, { inputs: { label: 'suite-b' } }); + assert.strictEqual(screen.getByTestId('suite-b').textContent, 'suite-b'); + + resolveBRendered(); + await aCleaned; + await sleep(0); + + assert.throws(() => screen.getByTestId('suite-b')); + }), + ]); +}; + +const testHooksEnabled = async () => { + let resolveARendered!: () => void; + let resolveBRendered!: () => void; + let resolveACleaned!: () => void; + + const aRendered = new Promise((resolve) => { + resolveARendered = resolve; + }); + const bRendered = new Promise((resolve) => { + resolveBRendered = resolve; + }); + const aCleaned = new Promise((resolve) => { + resolveACleaned = resolve; + }); + + await Promise.all([ + it('suite A cleanup does not remove suite B fixture', async () => { + await render(ScopeProbeComponent, { inputs: { label: 'suite-a' } }); + assert.strictEqual(screen.getByTestId('suite-a').textContent, 'suite-a'); + + resolveARendered(); + await bRendered; + + await cleanup(); + resolveACleaned(); + + assert.throws(() => screen.getByTestId('suite-a')); + }), + + it('suite B remains mounted while suite A cleans up', async () => { + await aRendered; + await render(ScopeProbeComponent, { inputs: { label: 'suite-b' } }); + assert.strictEqual(screen.getByTestId('suite-b').textContent, 'suite-b'); + + resolveBRendered(); + await aCleaned; + await sleep(0); + + assert.strictEqual(screen.getByTestId('suite-b').textContent, 'suite-b'); + + await cleanup(); + assert.throws(() => screen.getByTestId('suite-b')); + }), + ]); +}; + +describe('angular scope isolation', () => { + let hasRegisteredHooks = false; + + it('scope-hook contract probe', () => { + const g = globalThis as Record; + hasRegisteredHooks = typeof g[SCOPE_HOOKS_KEY] === 'object'; + assert.ok(true, 'runtime probe'); + }); + + if (!hasRegisteredHooks || !hasDomScopeApi) { + return it( + 'test hooks are disabled when scope hooks are unavailable', + testHooksDisabled + ); + } + + it('test hooks are enabled when scope hooks are available', testHooksEnabled); +}); \ No newline at end of file diff --git a/tests/angular-service.test.ts b/tests/angular-service.test.ts index 3eefe52..74c3dec 100644 --- a/tests/angular-service.test.ts +++ b/tests/angular-service.test.ts @@ -49,8 +49,8 @@ await test('mocks injectable service via useValue provider override', async () = assert.strictEqual(incrementCalled, true); }); -await test('injects service directly via renderHook and verifies signal state', () => { - const { result } = renderHook(() => inject(CounterService)); +await test('injects service directly via renderHook and verifies signal state', async () => { + const { result } = await renderHook(() => inject(CounterService)); assert.strictEqual(result.current.count(), 0); @@ -60,3 +60,22 @@ await test('injects service directly via renderHook and verifies signal state', result.current.reset(); assert.strictEqual(result.current.count(), 0); }); + +await test('concurrent renderHook calls receive independent providedIn:root service instances', async () => { + // Each renderHook call creates its own isolated application, so + // providedIn: 'root' services are not shared between them. + const [first, second] = await Promise.all([ + renderHook(() => inject(CounterService)), + renderHook(() => inject(CounterService)), + ]); + + first.result.current.increment(); + first.result.current.increment(); + + // Mutation in `first` must not leak into `second`. + assert.strictEqual(first.result.current.count(), 2); + assert.strictEqual(second.result.current.count(), 0); + + first.unmount(); + second.unmount(); +}); diff --git a/tools/publish-smoke.mjs b/tools/publish-smoke.mjs new file mode 100644 index 0000000..762b5bf --- /dev/null +++ b/tools/publish-smoke.mjs @@ -0,0 +1,15 @@ +import { spawnSync } from 'node:child_process'; + +const result = spawnSync('npm', ['pack', '--dry-run'], { + cwd: new URL('..', import.meta.url), + stdio: 'inherit', + shell: process.platform === 'win32', +}); + +if (result.error) { + throw result.error; +} + +if (result.status !== 0) { + process.exit(result.status ?? 1); +} \ No newline at end of file