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