From 8565143f1960a30f42ce000d295781b241f5c868 Mon Sep 17 00:00:00 2001 From: Lojhan Date: Thu, 22 Oct 2026 13:07:53 +0000 Subject: [PATCH 1/4] fix: isolate Angular helpers and reset platform on DOM changes --- .github/workflows/ci_framework-majors.yml | 32 +- README.md | 62 ++-- package-lock.json | 127 ++++--- package.json | 25 +- poku.config.js | 10 +- run.test.ts | 4 +- src/angular-testing.ts | 403 +++++++++++++++------- src/dom-setup-happy.ts | 11 +- src/dom-setup-jsdom.ts | 11 +- src/index.ts | 20 +- src/platform-document.ts | 10 + src/plugin-command.ts | 24 +- src/plugin-metrics.ts | 17 +- src/plugin-types.ts | 9 +- src/plugin.ts | 25 +- src/runtime-options.ts | 7 +- src/zoneless-change-detection.ts | 14 +- tests/__fixtures__/AsyncPipeline.ts | 12 +- tests/__fixtures__/CounterButton.ts | 4 +- tests/__fixtures__/CounterService.ts | 4 +- tests/__fixtures__/GreetingCard.ts | 4 +- tests/__fixtures__/LoginForm.ts | 16 +- tests/__fixtures__/ServiceConsumer.ts | 6 +- tests/__fixtures__/ThemeConsumer.ts | 6 +- tests/__fixtures__/ThemeToken.ts | 6 +- tests/__fixtures__/ToggleHarness.ts | 8 +- tests/__fixtures__/UnmountWatcher.ts | 4 +- tests/angular-cleanup.test.ts | 50 +-- tests/angular-component.test.ts | 24 +- tests/angular-concurrency.test.ts | 30 +- tests/angular-context.test.ts | 22 +- tests/angular-forms.test.ts | 46 +-- tests/angular-hooks.test.ts | 71 ++-- tests/angular-lifecycle.test.ts | 118 +++++-- tests/angular-scope-isolation.test.ts | 68 ++-- tests/angular-service.test.ts | 40 ++- tests/helpers/use-toggle.ts | 2 +- tools/publish-smoke.mjs | 12 +- tsconfig.build.json | 7 +- 39 files changed, 821 insertions(+), 550 deletions(-) create mode 100644 src/platform-document.ts diff --git a/.github/workflows/ci_framework-majors.yml b/.github/workflows/ci_framework-majors.yml index 9f18c77..7c613d1 100644 --- a/.github/workflows/ci_framework-majors.yml +++ b/.github/workflows/ci_framework-majors.yml @@ -1,24 +1,24 @@ -name: '🔬 CI — Framework Majors' +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' + - ".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' + - ".github/workflows/ci_framework-majors.yml" + - "src/**" + - "tests/**" + - "run.test.ts" + - "package-lock.json" + - "package.json" workflow_dispatch: jobs: @@ -28,7 +28,7 @@ jobs: strategy: fail-fast: false matrix: - angular-major: ['18', '19', '20', '21'] + angular-major: ["18", "19", "20", "21"] name: Angular ${{ matrix.angular-major }} steps: - name: ➕ Actions - Checkout @@ -37,7 +37,7 @@ jobs: - name: ➕ Actions - Setup Node.js uses: actions/setup-node@v4 with: - node-version: '22' + node-version: "22" - name: ➕ Cache dependencies uses: actions/cache@v4 @@ -60,4 +60,4 @@ jobs: @angular/platform-browser-dynamic@${{ matrix.angular-major }} - name: 🔬 Angular ${{ matrix.angular-major }} - run: npm test \ No newline at end of file + run: npm test diff --git a/README.md b/README.md index ef2968d..9373e3a 100644 --- a/README.md +++ b/README.md @@ -69,13 +69,13 @@ npm i -D jsdom ```js // poku.config.js -import { defineConfig } from 'poku'; -import { angularTestingPlugin } from '@pokujs/angular/plugin'; +import { defineConfig } from "poku"; +import { angularTestingPlugin } from "@pokujs/angular/plugin"; export default defineConfig({ plugins: [ angularTestingPlugin({ - dom: 'happy-dom', + dom: "happy-dom", }), ], }); @@ -85,26 +85,20 @@ export default defineConfig({ ```ts // tests/counter.test.ts -import { afterEach, assert, test } from 'poku'; -import { cleanup, fireEvent, render, screen } from '@pokujs/angular'; -import { CounterButton } from './CounterButton.ts'; +import { afterEach, assert, test } from "poku"; +import { cleanup, fireEvent, render, screen } from "@pokujs/angular"; +import { CounterButton } from "./CounterButton.ts"; afterEach(cleanup); -await test('increments the counter', async () => { +await test("increments the counter", async () => { await render(CounterButton, { inputs: { initialCount: 1 } }); - assert.strictEqual( - screen.getByRole('heading').textContent, - 'Count: 1' - ); + assert.strictEqual(screen.getByRole("heading").textContent, "Count: 1"); - await fireEvent.click(screen.getByRole('button', { name: 'Increment' })); + await fireEvent.click(screen.getByRole("button", { name: "Increment" })); - assert.strictEqual( - screen.getByRole('heading').textContent, - 'Count: 2' - ); + assert.strictEqual(screen.getByRole("heading").textContent, "Count: 2"); }); ``` @@ -126,11 +120,11 @@ 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` | +| Package | Supported range | +| ----------- | :-------------: | +| `poku` | `>=4.1.0` | +| `happy-dom` | `>=20` | +| `jsdom` | `>=22` | ### Isolation Support @@ -173,9 +167,9 @@ depends on NgModule-declared components, import the relevant module graph via ```ts const view = await render(MyComponent, { /** Signal inputs (input() / input.required()) */ - inputs: { title: 'Hello' }, + inputs: { title: "Hello" }, /** Additional providers for the test module */ - providers: [{ provide: TOKEN, useValue: 'value' }], + providers: [{ provide: TOKEN, useValue: "value" }], /** Additional imports (shared modules, pipes, directives) */ imports: [SharedModule], /** Set false to skip the initial detectChanges() cycle */ @@ -183,13 +177,13 @@ const view = await render(MyComponent, { }); // Testing Library queries (scoped to document.body) -view.getByRole('heading'); +view.getByRole("heading"); // Angular-specific helpers -await view.detectChanges(); // run a CD cycle -await view.rerender({ title: 'Hi' }); // update inputs -view.unmount(); // destroy fixture -view.fixture; // runtime fixture wrapper with componentRef/CD helpers +await view.detectChanges(); // run a CD cycle +await view.rerender({ title: "Hi" }); // update inputs +view.unmount(); // destroy fixture +view.fixture; // runtime fixture wrapper with componentRef/CD helpers ``` ### `renderHook(fn, options?)` @@ -201,8 +195,8 @@ promoting the new one, so `DestroyRef` cleanups and injector-bound effects do not accumulate across renders. ```ts -const { result, rerender, unmount } = await renderHook( - () => inject(CounterService) +const { result, rerender, unmount } = await renderHook(() => + inject(CounterService), ); result.current.increment(); @@ -233,7 +227,7 @@ rejects with the application error. ```ts await fireEvent.click(button); -await fireEvent.input(input, { target: { value: 'hello' } }); +await fireEvent.input(input, { target: { value: "hello" } }); ``` --- @@ -248,10 +242,10 @@ angularTestingPlugin({ * - 'jsdom' — broader browser API coverage * - { setupModule } — path to a custom DOM setup module */ - dom: 'happy-dom', + dom: "happy-dom", /** Base URL assigned to the DOM environment. */ - domUrl: 'http://localhost:3000/', + domUrl: "http://localhost:3000/", /** * Render metrics. Disabled by default. @@ -273,3 +267,5 @@ angularTestingPlugin({ ## License [MIT](./LICENSE) + +Render results also expose `componentInstance`, `asFragment()`, and `debug()`. Import `waitFor` from `@pokujs/angular` for assertion-aware polling. diff --git a/package-lock.json b/package-lock.json index 82c7be5..ee8767a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,27 +9,28 @@ "version": "1.0.0", "license": "MIT", "dependencies": { + "@pokujs/dom": "^1.3.1", "@testing-library/dom": "^10.4.1" }, "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.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.3.0", + "prettier": "^3.8.1", "rimraf": "^6.0.1", "tsup": "^8.5.0", "tsx": "^4.21.0", - "typescript": "^6.0.2" + "typescript": "^6.0.2", + "@angular/compiler": "^19.0.0" }, "engines": { "bun": ">=1.x.x", @@ -171,7 +172,7 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@csstools/css-calc": "^2.1.3", @@ -217,7 +218,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -237,7 +238,7 @@ "version": "2.1.4", "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -261,7 +262,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -289,7 +290,7 @@ "version": "3.0.5", "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -312,7 +313,7 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -831,10 +832,9 @@ } }, "node_modules/@pokujs/dom": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@pokujs/dom/-/dom-1.3.0.tgz", - "integrity": "sha512-O7aZdRoeq0FrU5uSIVSA10/pHtVpedmkkVggi3qooGCU8+5SGzAFKYRzlwCwAr3gQjdaU34JqVflNgPmz61JGw==", - "dev": true, + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@pokujs/dom/-/dom-1.3.1.tgz", + "integrity": "sha512-ge4Lxx5tLUiiugftvD7Qk6HEPnoY/IRXJ5uOTFLxyEvog2q1wqCUQNLznIA3uvaEjoH+fPwOZgHX3nPqQkrzQg==", "license": "MIT", "dependencies": { "@pokujs/scope-hooks": "^1.1.0", @@ -860,11 +860,10 @@ } } }, - "node_modules/@pokujs/scope-hooks": { + "node_modules/@pokujs/dom/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", @@ -1275,7 +1274,7 @@ "version": "25.6.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~7.19.0" @@ -1285,7 +1284,7 @@ "version": "7.19.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@types/tough-cookie": { @@ -1299,14 +1298,14 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -1329,7 +1328,7 @@ "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">= 14" @@ -1501,7 +1500,7 @@ "version": "4.6.0", "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@asamuzakjp/css-color": "^3.2.0", @@ -1515,7 +1514,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "whatwg-mimetype": "^4.0.0", @@ -1529,7 +1528,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=18" @@ -1539,7 +1538,7 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -1557,7 +1556,7 @@ "version": "10.6.0", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/dequal": { @@ -1579,7 +1578,7 @@ "version": "7.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", - "dev": true, + "devOptional": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.12" @@ -1710,7 +1709,7 @@ "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, + "devOptional": true, "license": "MIT", "dependencies": { "@types/node": ">=20.0.0", @@ -1728,7 +1727,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "whatwg-encoding": "^3.1.1" @@ -1741,7 +1740,7 @@ "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "agent-base": "^7.1.0", @@ -1755,7 +1754,7 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "agent-base": "^7.1.2", @@ -1769,7 +1768,7 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -1782,7 +1781,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/isexe": { @@ -1812,7 +1811,7 @@ "version": "26.1.0", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "cssstyle": "^4.2.1", @@ -1852,7 +1851,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=18" @@ -1892,7 +1891,7 @@ "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/lz-string": { @@ -1957,7 +1956,7 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/mz": { @@ -1976,7 +1975,7 @@ "version": "2.2.23", "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/object-assign": { @@ -2000,7 +1999,7 @@ "version": "7.3.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "entities": "^6.0.0" @@ -2013,7 +2012,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", - "dev": true, + "devOptional": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.12" @@ -2170,6 +2169,22 @@ } } }, + "node_modules/prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/pretty-format": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", @@ -2188,7 +2203,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6" @@ -2303,7 +2318,7 @@ "version": "0.8.0", "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/rxjs": { @@ -2321,14 +2336,14 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/saxes": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "xmlchars": "^2.2.0" @@ -2397,7 +2412,7 @@ "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/thenify": { @@ -2451,7 +2466,7 @@ "version": "6.1.86", "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "tldts-core": "^6.1.86" @@ -2464,14 +2479,14 @@ "version": "6.1.86", "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/tough-cookie": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", - "dev": true, + "devOptional": true, "license": "BSD-3-Clause", "dependencies": { "tldts": "^6.1.32" @@ -2484,7 +2499,7 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "punycode": "^2.3.1" @@ -2622,7 +2637,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "xml-name-validator": "^5.0.0" @@ -2635,7 +2650,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", - "dev": true, + "devOptional": true, "license": "BSD-2-Clause", "engines": { "node": ">=12" @@ -2646,7 +2661,7 @@ "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "iconv-lite": "0.6.3" @@ -2659,7 +2674,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=12" @@ -2669,7 +2684,7 @@ "version": "14.2.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "tr46": "^5.1.0", @@ -2699,7 +2714,7 @@ "version": "8.20.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=10.0.0" @@ -2721,7 +2736,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=18" @@ -2731,7 +2746,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/zone.js": { diff --git a/package.json b/package.json index 332696f..32fe690 100644 --- a/package.json +++ b/package.json @@ -31,27 +31,27 @@ }, "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", + "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 --no-splitting --no-config --tsconfig tsconfig.tsup.json", "typecheck": "tsc -p tsconfig.build.json --noEmit", "format": "prettier --write .", "format:check": "prettier --check .", "lint": "npm run typecheck", "check": "npm run typecheck && npm test", - "check:publish": "npm run test:pack", - "prepack": "npm run build" + "prepack": "npm run build", + "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", + "test:pack": "node ./tools/publish-smoke.mjs", + "check:publish": "npm run test:pack" }, "keywords": [ "poku", @@ -78,6 +78,7 @@ "provenance": true }, "dependencies": { + "@pokujs/dom": "^1.3.1", "@testing-library/dom": "^10.4.1" }, "peerDependencies": { @@ -104,22 +105,22 @@ }, "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.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.3.0", + "prettier": "^3.8.1", "rimraf": "^6.0.1", "tsup": "^8.5.0", "tsx": "^4.21.0", - "typescript": "^6.0.2" + "typescript": "^6.0.2", + "@angular/compiler": "^19.0.0" } } diff --git a/poku.config.js b/poku.config.js index 1d166b7..fec0c26 100644 --- a/poku.config.js +++ b/poku.config.js @@ -1,12 +1,12 @@ -import { defineConfig } from 'poku'; -import { angularTestPlugin } from './src/plugin.ts'; +import { defineConfig } from "poku"; +import { angularTestingPlugin } from "./src/plugin.ts"; const dom = process.env.POKU_ANGULAR_TEST_DOM; if (!dom) { - throw new Error('POKU_ANGULAR_TEST_DOM environment variable is not set'); + throw new Error("POKU_ANGULAR_TEST_DOM environment variable is not set"); } export default defineConfig({ - plugins: [angularTestPlugin({ dom })], - isolation: 'none', + plugins: [angularTestingPlugin({ dom })], + isolation: "none", }); diff --git a/run.test.ts b/run.test.ts index 5c30102..71f0ead 100644 --- a/run.test.ts +++ b/run.test.ts @@ -4,11 +4,11 @@ import { angularTestingPlugin } from "./src/plugin.ts"; const doms = ["happy-dom", "jsdom"] as const; const isolations = ["none", "process"] as const; const matrix = doms.flatMap((dom) => - isolations.map((isolation) => ({ dom, isolation })) + isolations.map((isolation) => ({ dom, isolation })), ); for (const { dom, isolation } of matrix) { - const happyCode = await poku('tests', { + const happyCode = await poku("tests", { noExit: true, isolation, plugins: [angularTestingPlugin({ dom })], diff --git a/src/angular-testing.ts b/src/angular-testing.ts index 3334633..0352109 100644 --- a/src/angular-testing.ts +++ b/src/angular-testing.ts @@ -1,4 +1,8 @@ -import type { BoundFunctions, Screen } from "@testing-library/dom"; +import type { + BoundFunctions, + PrettyDOMOptions, + Screen, +} from "@testing-library/dom"; import type { ApplicationRef, ChangeDetectorRef, @@ -12,7 +16,6 @@ import type { 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, @@ -22,6 +25,10 @@ import { runInInjectionContext, } from "@angular/core"; import { + createAsFragment, + createDebug, + createScopedRegistry, + createWaitFor, createRenderMetricsEmitter, createScreen, getNow, @@ -31,29 +38,12 @@ 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, + runtimeOptions: () => parseRuntimeOptions(), metricsStateKey: Symbol.for("@pokujs/angular.metrics-runtime-state"), metricsBatchMessageType: "POKU_ANGULAR_RENDER_METRIC_BATCH", }); -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; @@ -69,11 +59,6 @@ type RenderHookExecution = { destroyed: boolean; }; -type ScopedRuntimeState = { - mountedHandles: Set; - cleanupRegistered: boolean; -}; - export type AngularFixture = { componentRef: ComponentRef; componentInstance: T; @@ -91,29 +76,6 @@ export type AngularFixture = { 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) { @@ -154,104 +116,263 @@ const destroyMountedHandles = (mountedHandles: Set) => { ); }; -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 registry = createScopedRegistry({ + name: "@pokujs/angular.mounted-handles", + onCleanup: (handles) => { + try { + destroyMountedHandles(handles); + } finally { + metrics.flushMetricBuffer(); + } + }, +}); - const state = scope.getOrCreateSlot(RUNTIME_STATE_SLOT_KEY, () => ({ - mountedHandles: new Set(), - cleanupRegistered: false, - })).value; +const getMountedHandles = (): Set => registry.getItems(); +const getCurrentMountedHandles = (): Set => + registry.getScopedItems() ?? registry.fallbackItems; + +const SIGNAL_SYMBOL_STR = "Symbol(SIGNAL)"; +const INPUT_FLAGS_SIGNAL_BASED = 1; +const ALIAS_OPTION_REGEX = /\balias\s*:\s*['"]([^'"]+)['"]/; +const SIGNAL_FACTORY_PREFIX_REGEX = + /(?:this\.)?([A-Za-z_$][\w$]*)\s*=\s*(?:[\w$]+\.)?(input|model|output|outputFromObservable)(?:\.required)?\s*(?:<[^>]*>)?\s*\(/g; + +type NgDirectiveDefLike = { + inputs: Record; + declaredInputs: Record; + outputs: Record; + dependencies?: (() => unknown[]) | unknown[] | null; + tView?: unknown; +}; - if (!state.cleanupRegistered && typeof scope.addCleanup === "function") { - state.cleanupRegistered = true; - scope.addCleanup(() => { - try { - destroyMountedHandles(state.mountedHandles); - } finally { - metrics.flushMetricBuffer(); +const extractBalancedArgs = ( + source: string, + openParenAfterIndex: number, +): string => { + let depth = 1; + let i = openParenAfterIndex; + while (i < source.length && depth > 0) { + const ch = source[i]; + if (ch === "'" || ch === '"' || ch === "`") { + const quote = ch; + i++; + while (i < source.length && source[i] !== quote) { + if (source[i] === "\\") i++; + i++; } - }); + } else if (ch === "(") { + depth++; + } else if (ch === ")") { + depth--; + if (depth === 0) break; + } + i++; } + return source.slice(openParenAfterIndex, i); +}; - return state; +const extractAliasOrName = ( + privateName: string, + argsSource: string, +): string => { + const aliasMatch = ALIAS_OPTION_REGEX.exec(argsSource); + return aliasMatch?.[1] || privateName; }; -const getCurrentScopedRuntimeState = (): ScopedRuntimeState | undefined => { - if (!RUNTIME_STATE_SLOT_KEY) return undefined; - if (typeof domScopeApi.getCurrentScope !== "function") return undefined; +const patchDefSignalMetadata = ( + target: Function, + def: NgDirectiveDefLike, +): void => { + let inputsMutated = false; + let outputsMutated = false; + + const ensureMutableInputs = () => { + if (!inputsMutated) { + def.inputs = { ...def.inputs }; + def.declaredInputs = { ...def.declaredInputs }; + inputsMutated = true; + } + }; + + const ensureMutableOutputs = () => { + if (!outputsMutated) { + def.outputs = { ...def.outputs }; + outputsMutated = true; + } + }; + + for ( + let ctor: unknown = target; + typeof ctor === "function" && ctor !== Function.prototype; + ctor = Object.getPrototypeOf(ctor) + ) { + const source = Function.prototype.toString.call(ctor); + + for (const match of source.matchAll(SIGNAL_FACTORY_PREFIX_REGEX)) { + const privateName = match[1]; + const kind = match[2]; + if (!privateName || !kind) continue; + + const argsStart = (match.index ?? 0) + match[0].length; + const argsSource = extractBalancedArgs(source, argsStart); + const publicName = extractAliasOrName(privateName, argsSource); + + if (kind === "input") { + if (!Object.prototype.hasOwnProperty.call(def.inputs, publicName)) { + ensureMutableInputs(); + def.inputs[publicName] = [ + privateName, + INPUT_FLAGS_SIGNAL_BASED, + null, + ]; + def.declaredInputs[publicName] = privateName; + } + } else if (kind === "model") { + if (!Object.prototype.hasOwnProperty.call(def.inputs, publicName)) { + ensureMutableInputs(); + def.inputs[publicName] = [ + privateName, + INPUT_FLAGS_SIGNAL_BASED, + null, + ]; + def.declaredInputs[publicName] = privateName; + } + const outputName = `${publicName}Change`; + if (!Object.prototype.hasOwnProperty.call(def.outputs, outputName)) { + ensureMutableOutputs(); + def.outputs[outputName] = privateName; + } + } else { + if (!Object.prototype.hasOwnProperty.call(def.outputs, publicName)) { + ensureMutableOutputs(); + def.outputs[publicName] = privateName; + } + } + } + } - const scope = domScopeApi.getCurrentScope(); - const slot = scope?.getSlot?.(RUNTIME_STATE_SLOT_KEY); - return slot?.value; + if ((inputsMutated || outputsMutated) && def.tView) { + def.tView = null; + } }; -const getMountedHandles = (): Set => - getScopedRuntimeState()?.mountedHandles ?? fallbackMountedHandles; +const registerJitSignalInputsAndOutputs = ( + root: unknown, + visited = new Set(), +): void => { + if (!root || visited.has(root)) return; + visited.add(root); -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, -): boolean => { - if (!canUseSignalInputFallback) return false; + if (Array.isArray(root)) { + for (const item of root) { + registerJitSignalInputsAndOutputs(item, visited); + } + return; + } - const prop = instance[key]; - if (typeof prop !== "function") return false; + if (typeof root !== "function") return; - const signalSym = Object.getOwnPropertySymbols(prop).find( - (s) => s.description === "SIGNAL", + const typed = root as unknown as { + ɵcmp?: NgDirectiveDefLike; + ɵdir?: NgDirectiveDefLike; + ɵmod?: { + imports?: unknown[] | (() => unknown[]); + declarations?: unknown[] | (() => unknown[]); + exports?: unknown[] | (() => unknown[]); + }; + }; + + const def = typed.ɵcmp ?? typed.ɵdir; + if (def) { + patchDefSignalMetadata(root, def); + const deps = + typeof def.dependencies === "function" + ? def.dependencies() + : def.dependencies; + if (deps) { + registerJitSignalInputsAndOutputs(deps, visited); + } + } + + if (typed.ɵmod) { + const mod = typed.ɵmod; + for (const group of [mod.imports, mod.declarations, mod.exports]) { + const resolved = typeof group === "function" ? group() : group; + if (resolved) { + registerJitSignalInputsAndOutputs(resolved, visited); + } + } + } +}; + +const getSignalNode = (value: unknown): Record | null => { + if (typeof value !== "function") return null; + const sym = Object.getOwnPropertySymbols(value).find( + (s) => s.toString() === SIGNAL_SYMBOL_STR, ); - if (!signalSym) return false; + return sym + ? ((value as unknown as Record)[sym] as Record< + string, + unknown + >) + : null; +}; - const node = (prop as unknown as Record)[ - signalSym - ] as Record; - if (node === null || typeof node !== "object") return false; +const applyInputValue = ( + componentRef: ComponentRef, + instance: Record, + key: string, + value: unknown, +): void => { + const cmpDef = ( + componentRef.componentType as unknown as { + ɵcmp?: NgDirectiveDefLike; + } + ).ɵcmp; + const privateKey = cmpDef?.inputs?.[key]?.[0] ?? key; + const currentProp = instance[privateKey]; + const node = getSignalNode(currentProp); + if (node) { + const transformFn = node["transformFn"] as + | ((v: unknown) => unknown) + | undefined; + const nextValue = + typeof transformFn === "function" ? transformFn(value) : value; + + // InputSignalNode has `applyValueToInputSignal`; WritableSignalNode does not. + const applyFn = node["applyValueToInputSignal"] as + | ((n: Record, v: unknown) => void) + | undefined; + + if (typeof applyFn === "function") { + applyFn(node, nextValue); + return; + } + } - const applyFn = node["applyValueToInputSignal"]; - if (typeof applyFn !== "function") return false; + if ( + typeof currentProp === "function" && + typeof (currentProp as unknown as { set?: (v: unknown) => void }).set === + "function" + ) { + (currentProp as unknown as { set: (v: unknown) => void }).set(value); + return; + } - (applyFn as (n: unknown, v: unknown) => void)(node, value); - return true; + try { + componentRef.setInput(key, value); + } catch { + instance[privateKey] = value; + } }; const applyInputs = ( componentRef: ComponentRef, inputs: Record, ): void => { - const instance = componentRef.instance as Record; + const instance = componentRef.instance as unknown as Record; for (const [key, value] of Object.entries(inputs)) { - // 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); - } + applyInputValue(componentRef, instance, key, value); } }; @@ -294,9 +415,11 @@ const createFixture = ( let destroyed = false; let stable = false; - const stabilitySubscription = applicationRef.isStable.subscribe((isStable) => { - stable = isStable; - }); + const stabilitySubscription = applicationRef.isStable.subscribe( + (isStable) => { + stable = isStable; + }, + ); const waitForStability = async () => { await applicationRef.whenStable(); @@ -384,7 +507,9 @@ const createRenderHookExecution = ( } }; -const destroyRenderHookExecution = (execution: RenderHookExecution) => { +const destroyRenderHookExecution = ( + execution: RenderHookExecution, +) => { if (execution.destroyed) return; try { @@ -405,6 +530,13 @@ export type RenderResult = BoundFunctions & { container: HTMLElement; baseElement: HTMLElement; fixture: AngularFixture; + componentInstance: T; + asFragment: () => DocumentFragment; + debug: ( + element?: Array | Element | HTMLDocument, + maxLength?: number, + options?: PrettyDOMOptions, + ) => void; detectChanges: () => Promise; unmount: () => void; rerender: (inputs?: Record) => Promise; @@ -438,6 +570,8 @@ export const render = async ( const container = document.createElement("div"); baseElement.appendChild(container); + registerJitSignalInputsAndOutputs([component, ...(options.imports ?? [])]); + const componentRef = createComponent(component, { environmentInjector: runtimeApplication.injector, hostElement: container, @@ -448,7 +582,10 @@ export const render = async ( runCleanupSteps( [ () => { - if (!runtimeApplication.destroyed && !componentRef.hostView.destroyed) { + if ( + !runtimeApplication.destroyed && + !componentRef.hostView.destroyed + ) { runtimeApplication.detachView(componentRef.hostView); } }, @@ -522,13 +659,16 @@ export const render = async ( container, baseElement, fixture, + componentInstance: componentRef.instance, + asFragment: createAsFragment(container), + debug: createDebug(baseElement), detectChanges, unmount, rerender, }; }; -export const renderHook = async >( +export const renderHook = async ( hookFn: (props: Props) => Result, options: RenderHookOptions = {}, ): Promise> => { @@ -553,7 +693,7 @@ export const renderHook = async >( get current() { return currentResult; }, - } as { current: Result }; + }; const handle: MountedHandle = { destroy() { @@ -630,6 +770,7 @@ export const renderHook = async >( export const cleanup = async () => { try { destroyMountedHandles(getCurrentMountedHandles()); + destroyMountedHandles(registry.fallbackItems); } finally { metrics.flushMetricBuffer(); } @@ -668,3 +809,7 @@ wrapFireEventMethods( ); export const fireEvent = wrappedFireEvent; + +export const waitFor = createWaitFor(domTestingLibrary.waitFor); + +export * from "@testing-library/dom"; diff --git a/src/dom-setup-happy.ts b/src/dom-setup-happy.ts index 66c4f17..d11c8de 100644 --- a/src/dom-setup-happy.ts +++ b/src/dom-setup-happy.ts @@ -1,8 +1,11 @@ -import '@angular/compiler'; -import { setupHappyDomEnvironment } from '@pokujs/dom'; -import { parseRuntimeOptions } from './runtime-options.ts'; +import "@angular/compiler"; +import { setupHappyDomEnvironment } from "@pokujs/dom"; +import { parseRuntimeOptions } from "./runtime-options.ts"; +import { resetPlatformDocument } from "./platform-document.ts"; await setupHappyDomEnvironment({ runtimeOptions: parseRuntimeOptions(), - packageTag: '@pokujs/angular', + packageTag: "@pokujs/angular", }); + +resetPlatformDocument(); diff --git a/src/dom-setup-jsdom.ts b/src/dom-setup-jsdom.ts index e70ddcd..2976f3b 100644 --- a/src/dom-setup-jsdom.ts +++ b/src/dom-setup-jsdom.ts @@ -1,8 +1,11 @@ -import '@angular/compiler'; -import { setupJsdomEnvironment } from '@pokujs/dom'; -import { parseRuntimeOptions } from './runtime-options.ts'; +import "@angular/compiler"; +import { setupJsdomEnvironment } from "@pokujs/dom"; +import { parseRuntimeOptions } from "./runtime-options.ts"; +import { resetPlatformDocument } from "./platform-document.ts"; await setupJsdomEnvironment({ runtimeOptions: parseRuntimeOptions(), - packageTag: '@pokujs/angular', + packageTag: "@pokujs/angular", }); + +resetPlatformDocument(); diff --git a/src/index.ts b/src/index.ts index c7273ab..251bb26 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,11 +1,15 @@ /** Plugin factory and alias for Poku integration. */ -export { createAngularTestingPlugin, angularTestingPlugin } from './plugin.ts'; +export { + createAngularTestingPlugin, + angularTestingPlugin, + angularTestPlugin, +} from "./plugin.ts"; export type { AngularDomAdapter, AngularMetricsOptions, AngularMetricsSummary, AngularTestingPluginOptions, -} from './plugin.ts'; +} from "./plugin.ts"; /** Angular testing helpers and DX exports. */ export { cleanup, @@ -13,5 +17,13 @@ export { render, renderHook, screen, -} from './angular-testing.ts'; -export * from './angular-testing.ts'; + waitFor, +} from "./angular-testing.ts"; +export type { + AngularFixture, + RenderHookOptions, + RenderHookResult, + RenderOptions, + RenderResult, +} from "./angular-testing.ts"; +export * from "@testing-library/dom"; diff --git a/src/platform-document.ts b/src/platform-document.ts new file mode 100644 index 0000000..bd657b7 --- /dev/null +++ b/src/platform-document.ts @@ -0,0 +1,10 @@ +import { destroyPlatform } from "@angular/core"; + +const documentKey = Symbol.for("@pokujs/angular.platform-document"); + +export const resetPlatformDocument = () => { + const state = globalThis as Record; + const previousDocument = state[documentKey]; + if (previousDocument && previousDocument !== document) destroyPlatform(); + state[documentKey] = document; +}; diff --git a/src/plugin-command.ts b/src/plugin-command.ts index 2d0ca05..ebff1ff 100644 --- a/src/plugin-command.ts +++ b/src/plugin-command.ts @@ -1,13 +1,13 @@ -import type { AngularDomAdapter } from './plugin-types.ts'; +import type { AngularDomAdapter } from "./plugin-types.ts"; import { buildRunnerCommand as buildCoreRunnerCommand, canHandleRuntime, createDomSetupPathResolver, type BuildRunnerCommandInput, -} from '@pokujs/dom'; -import { dirname, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { existsSync } from 'node:fs'; +} from "@pokujs/dom"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { existsSync } from "node:fs"; const currentDir = dirname(fileURLToPath(import.meta.url)); @@ -17,20 +17,20 @@ const resolveSetupModulePath = (baseName: string) => { return resolve(currentDir, `${baseName}.ts`); }; -const happyDomSetupPath = resolveSetupModulePath('dom-setup-happy'); -const jsdomSetupPath = resolveSetupModulePath('dom-setup-jsdom'); +const happyDomSetupPath = resolveSetupModulePath("dom-setup-happy"); +const jsdomSetupPath = resolveSetupModulePath("dom-setup-jsdom"); -// Angular components and tests exclusively use .ts files. -const angularExtensions = new Set(['.ts']); +// Angular components and tests use .ts files. Inline templates may use .html. +const angularExtensions = new Set([".ts", ".html"]); export const resolveDomSetupPath = createDomSetupPathResolver( - '@pokujs/angular', + "@pokujs/angular", happyDomSetupPath, - jsdomSetupPath + jsdomSetupPath, ); export const buildRunnerCommand = ( - input: Omit + input: Omit, ) => buildCoreRunnerCommand({ ...input, extensions: angularExtensions }); export { canHandleRuntime }; diff --git a/src/plugin-metrics.ts b/src/plugin-metrics.ts index 8553051..7700906 100644 --- a/src/plugin-metrics.ts +++ b/src/plugin-metrics.ts @@ -3,7 +3,7 @@ import type { AngularMetricsSummary, AngularTestingPluginOptions, RenderMetric, -} from './plugin-types.ts'; +} from "./plugin-types.ts"; import { buildRuntimeOptionArgs as buildCoreRuntimeOptionArgs, createMetricsSummary, @@ -13,11 +13,11 @@ import { normalizeMetricsOptions, printMetricsSummary as printCoreMetricsSummary, selectTopSlowestMetrics, -} from '@pokujs/dom'; -import { runtimeOptionArgPrefixes } from './runtime-options.ts'; +} from "@pokujs/dom"; +import { runtimeOptionArgPrefixes } from "./runtime-options.ts"; -const ANGULAR_RENDER_METRIC = 'POKU_ANGULAR_RENDER_METRIC'; -const ANGULAR_RENDER_METRIC_BATCH = 'POKU_ANGULAR_RENDER_METRIC_BATCH'; +const ANGULAR_RENDER_METRIC = "POKU_ANGULAR_RENDER_METRIC"; +const ANGULAR_RENDER_METRIC_BATCH = "POKU_ANGULAR_RENDER_METRIC_BATCH"; export const isRenderMetricMessage = (message: unknown) => isCoreRenderMetricMessage(message, ANGULAR_RENDER_METRIC); @@ -27,11 +27,12 @@ export const isRenderMetricBatchMessage = (message: unknown) => export const buildRuntimeOptionArgs = ( options: AngularTestingPluginOptions, - metricsOptions: NormalizedMetricsOptions -) => buildCoreRuntimeOptionArgs(options, metricsOptions, runtimeOptionArgPrefixes); + metricsOptions: NormalizedMetricsOptions, +) => + buildCoreRuntimeOptionArgs(options, metricsOptions, runtimeOptionArgPrefixes); export const printMetricsSummary = (summary: AngularMetricsSummary) => - printCoreMetricsSummary(summary, '@pokujs/angular'); + printCoreMetricsSummary(summary, "@pokujs/angular"); export { createMetricsSummary, diff --git a/src/plugin-types.ts b/src/plugin-types.ts index 6c9649f..2aba442 100644 --- a/src/plugin-types.ts +++ b/src/plugin-types.ts @@ -2,17 +2,12 @@ import type { DomAdapter, MetricsOptions, MetricsSummary, - NormalizedMetricsOptions, - RenderMetric, TestingPluginOptions, -} from '@pokujs/dom'; +} from "@pokujs/dom"; export type AngularDomAdapter = DomAdapter; export type AngularMetricsSummary = MetricsSummary; export type AngularMetricsOptions = MetricsOptions; export type AngularTestingPluginOptions = TestingPluginOptions; -export type { - NormalizedMetricsOptions, - RenderMetric, -}; +export type { NormalizedMetricsOptions, RenderMetric } from "@pokujs/dom"; diff --git a/src/plugin.ts b/src/plugin.ts index 9d61d22..fa9e648 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -3,16 +3,16 @@ import type { AngularMetricsOptions, AngularMetricsSummary, AngularTestingPluginOptions, -} from './plugin-types.ts'; +} from "./plugin-types.ts"; import { createFrameworkTestingPluginFactory, type FrameworkDescriptor, -} from '@pokujs/dom'; +} from "@pokujs/dom"; import { buildRunnerCommand, canHandleRuntime, resolveDomSetupPath, -} from './plugin-command.ts'; +} from "./plugin-command.ts"; import { buildRuntimeOptionArgs, createMetricsSummary, @@ -21,7 +21,7 @@ import { isRenderMetricMessage, normalizeMetricsOptions, selectTopSlowestMetrics, -} from './plugin-metrics.ts'; +} from "./plugin-metrics.ts"; export type { AngularDomAdapter, @@ -31,26 +31,27 @@ export type { }; const descriptor: FrameworkDescriptor = { - pluginName: 'angular-testing', - packageTag: '@pokujs/angular', - runtimeArgBase: 'poku-angular', - metricMessageType: 'POKU_ANGULAR_RENDER_METRIC', - metricBatchMessageType: 'POKU_ANGULAR_RENDER_METRIC_BATCH', + pluginName: "angular-testing", + packageTag: "@pokujs/angular", + runtimeArgBase: "poku-angular", + metricMessageType: "POKU_ANGULAR_RENDER_METRIC", + metricBatchMessageType: "POKU_ANGULAR_RENDER_METRIC_BATCH", // Angular test files — and the component fixtures they import — are all .ts - testFileExtensions: ['.ts'], + testFileExtensions: [".ts"], commandBuilder: (input) => buildRunnerCommand(input), }; const { createTestingPlugin } = createFrameworkTestingPluginFactory( descriptor, - import.meta.url + import.meta.url, ); export const createAngularTestingPlugin = ( - options: AngularTestingPluginOptions = {} + options: AngularTestingPluginOptions = {}, ) => createTestingPlugin(options); export const angularTestingPlugin = createAngularTestingPlugin; +export const angularTestPlugin = createAngularTestingPlugin; export const __internal = { buildRunnerCommand, diff --git a/src/runtime-options.ts b/src/runtime-options.ts index 46db773..e6d914e 100644 --- a/src/runtime-options.ts +++ b/src/runtime-options.ts @@ -1,11 +1,10 @@ import { createRuntimeOptionArgPrefixes, parseRuntimeOptions as parseCoreRuntimeOptions, -} from '@pokujs/dom'; +} from "@pokujs/dom"; -export const runtimeOptionArgPrefixes = createRuntimeOptionArgPrefixes( - 'poku-angular' -); +export const runtimeOptionArgPrefixes = + createRuntimeOptionArgPrefixes("poku-angular"); export const parseRuntimeOptions = (argv: string[] = process.argv) => parseCoreRuntimeOptions(runtimeOptionArgPrefixes, argv); diff --git a/src/zoneless-change-detection.ts b/src/zoneless-change-detection.ts index 353631d..6084d06 100644 --- a/src/zoneless-change-detection.ts +++ b/src/zoneless-change-detection.ts @@ -1,5 +1,5 @@ -import type { EnvironmentProviders } from '@angular/core'; -import * as angularCore from '@angular/core'; +import type { EnvironmentProviders } from "@angular/core"; +import * as angularCore from "@angular/core"; type AngularCoreZonelessApi = { provideZonelessChangeDetection?: () => EnvironmentProviders; @@ -14,18 +14,20 @@ const angularCoreZonelessApi = angularCore as AngularCoreZonelessApi; * falls back to `provideExperimentalZonelessChangeDetection()` for older releases. */ export const provideCompatibleZonelessChangeDetection = () => { - if (typeof angularCoreZonelessApi.provideZonelessChangeDetection === 'function') { + if ( + typeof angularCoreZonelessApi.provideZonelessChangeDetection === "function" + ) { return angularCoreZonelessApi.provideZonelessChangeDetection(); } if ( typeof angularCoreZonelessApi.provideExperimentalZonelessChangeDetection === - 'function' + "function" ) { return angularCoreZonelessApi.provideExperimentalZonelessChangeDetection(); } throw new Error( - 'Angular does not expose provideZonelessChangeDetection or provideExperimentalZonelessChangeDetection.' + "Angular does not expose provideZonelessChangeDetection or provideExperimentalZonelessChangeDetection.", ); -}; \ No newline at end of file +}; diff --git a/tests/__fixtures__/AsyncPipeline.ts b/tests/__fixtures__/AsyncPipeline.ts index dd9fd4a..eef34dd 100644 --- a/tests/__fixtures__/AsyncPipeline.ts +++ b/tests/__fixtures__/AsyncPipeline.ts @@ -1,4 +1,4 @@ -import { Component, OnInit, signal } from '@angular/core'; +import { Component, OnInit, signal } from "@angular/core"; /** * Component that loads asynchronously in `ngOnInit` and exposes a two-phase @@ -12,7 +12,7 @@ import { Component, OnInit, signal } from '@angular/core'; */ @Component({ standalone: true, - selector: 'app-async-pipeline', + selector: "app-async-pipeline", template: ` @if (loaded()) {

Loaded from async setup

@@ -24,8 +24,8 @@ import { Component, OnInit, signal } from '@angular/core'; }) export class AsyncPipeline implements OnInit { protected readonly loaded = signal(false); - protected readonly urgentState = signal('idle'); - protected readonly deferredState = signal('idle'); + protected readonly urgentState = signal("idle"); + protected readonly deferredState = signal("idle"); async ngOnInit() { // Simulate an async data-fetch / route resolver pattern. @@ -35,9 +35,9 @@ export class AsyncPipeline implements OnInit { async runPipeline() { // Urgent update — synchronous signal write. - this.urgentState.set('urgent-updated'); + this.urgentState.set("urgent-updated"); // Deferred update — queued as a microtask, just like Vue's queue. await Promise.resolve(); - this.deferredState.set('queued-updated'); + this.deferredState.set("queued-updated"); } } diff --git a/tests/__fixtures__/CounterButton.ts b/tests/__fixtures__/CounterButton.ts index 7952712..5237968 100644 --- a/tests/__fixtures__/CounterButton.ts +++ b/tests/__fixtures__/CounterButton.ts @@ -1,4 +1,4 @@ -import { Component, computed, input, signal } from '@angular/core'; +import { Component, computed, input, signal } from "@angular/core"; /** * Standalone counter component using Angular signal inputs and computed state. @@ -8,7 +8,7 @@ import { Component, computed, input, signal } from '@angular/core'; */ @Component({ standalone: true, - selector: 'app-counter-button', + selector: "app-counter-button", template: `

Count: {{ count() }}

diff --git a/tests/__fixtures__/CounterService.ts b/tests/__fixtures__/CounterService.ts index 2285147..e081950 100644 --- a/tests/__fixtures__/CounterService.ts +++ b/tests/__fixtures__/CounterService.ts @@ -1,11 +1,11 @@ -import { Injectable, signal } from '@angular/core'; +import { Injectable, signal } from "@angular/core"; /** * Simple counter service backed by Angular signals. * Registered with `providedIn: 'root'` so it is tree-shakable and injectable * without explicit module registration. */ -@Injectable({ providedIn: 'root' }) +@Injectable({ providedIn: "root" }) export class CounterService { readonly count = signal(0); diff --git a/tests/__fixtures__/GreetingCard.ts b/tests/__fixtures__/GreetingCard.ts index a41443d..c5abe1b 100644 --- a/tests/__fixtures__/GreetingCard.ts +++ b/tests/__fixtures__/GreetingCard.ts @@ -1,4 +1,4 @@ -import { Component, input } from '@angular/core'; +import { Component, input } from "@angular/core"; /** * Simple card component with a required signal input. @@ -6,7 +6,7 @@ import { Component, input } from '@angular/core'; */ @Component({ standalone: true, - selector: 'app-greeting-card', + selector: "app-greeting-card", template: `

Hello {{ name() }}

`, }) export class GreetingCard { diff --git a/tests/__fixtures__/LoginForm.ts b/tests/__fixtures__/LoginForm.ts index 2dc35da..4520dbf 100644 --- a/tests/__fixtures__/LoginForm.ts +++ b/tests/__fixtures__/LoginForm.ts @@ -1,5 +1,5 @@ -import { Component, inject, signal } from '@angular/core'; -import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms'; +import { Component, inject, signal } from "@angular/core"; +import { ReactiveFormsModule, FormBuilder, Validators } from "@angular/forms"; /** * Reactive login form built with Angular's `FormBuilder` and `inject()`. @@ -9,7 +9,7 @@ import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms'; @Component({ standalone: true, imports: [ReactiveFormsModule], - selector: 'app-login-form', + selector: "app-login-form", template: `
@@ -19,7 +19,7 @@ import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms'; formControlName="email" placeholder="you@example.com" /> - @if (form.controls['email'].invalid && form.controls['email'].touched) { + @if (form.controls["email"].invalid && form.controls["email"].touched) {

Valid email is required.

} @@ -31,7 +31,7 @@ import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms'; placeholder="••••••••" /> @if ( - form.controls['password'].invalid && form.controls['password'].touched + form.controls["password"].invalid && form.controls["password"].touched ) {

Password must be at least 8 characters.

} @@ -40,7 +40,7 @@ import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
@if (submitted()) { -

Welcome, {{ form.controls['email'].value }}!

+

Welcome, {{ form.controls["email"].value }}!

} `, }) @@ -49,8 +49,8 @@ export class LoginForm { protected readonly submitted = signal(false); protected readonly form = this.fb.group({ - email: ['', [Validators.required, Validators.email]], - password: ['', [Validators.required, Validators.minLength(8)]], + email: ["", [Validators.required, Validators.email]], + password: ["", [Validators.required, Validators.minLength(8)]], }); submit() { diff --git a/tests/__fixtures__/ServiceConsumer.ts b/tests/__fixtures__/ServiceConsumer.ts index 0d5540e..fbcfe43 100644 --- a/tests/__fixtures__/ServiceConsumer.ts +++ b/tests/__fixtures__/ServiceConsumer.ts @@ -1,5 +1,5 @@ -import { Component, inject } from '@angular/core'; -import { CounterService } from './CounterService.ts'; +import { Component, inject } from "@angular/core"; +import { CounterService } from "./CounterService.ts"; /** * Standalone component that consumes `CounterService` via `inject()`. @@ -7,7 +7,7 @@ import { CounterService } from './CounterService.ts'; */ @Component({ standalone: true, - selector: 'app-service-consumer', + selector: "app-service-consumer", template: `

Count: {{ service.count() }}

diff --git a/tests/__fixtures__/ThemeConsumer.ts b/tests/__fixtures__/ThemeConsumer.ts index 3634fbb..f9ae6ee 100644 --- a/tests/__fixtures__/ThemeConsumer.ts +++ b/tests/__fixtures__/ThemeConsumer.ts @@ -1,5 +1,5 @@ -import { Component, inject } from '@angular/core'; -import { THEME_TOKEN } from './ThemeToken.ts'; +import { Component, inject } from "@angular/core"; +import { THEME_TOKEN } from "./ThemeToken.ts"; /** * Reads the active theme from Angular's DI tree via `inject()`. @@ -7,7 +7,7 @@ import { THEME_TOKEN } from './ThemeToken.ts'; */ @Component({ standalone: true, - selector: 'app-theme-consumer', + selector: "app-theme-consumer", template: `

Theme: {{ theme }}

`, }) export class ThemeConsumer { diff --git a/tests/__fixtures__/ThemeToken.ts b/tests/__fixtures__/ThemeToken.ts index 6f27a08..0fb301d 100644 --- a/tests/__fixtures__/ThemeToken.ts +++ b/tests/__fixtures__/ThemeToken.ts @@ -1,4 +1,4 @@ -import { InjectionToken } from '@angular/core'; +import { InjectionToken } from "@angular/core"; /** * InjectionToken that carries the active theme name. @@ -11,7 +11,7 @@ import { InjectionToken } from '@angular/core'; * }); * ``` */ -export const THEME_TOKEN = new InjectionToken('poku.theme', { +export const THEME_TOKEN = new InjectionToken("poku.theme", { providedIn: null, - factory: () => 'light', + factory: () => "light", }); diff --git a/tests/__fixtures__/ToggleHarness.ts b/tests/__fixtures__/ToggleHarness.ts index 6e9ac17..3e87ad3 100644 --- a/tests/__fixtures__/ToggleHarness.ts +++ b/tests/__fixtures__/ToggleHarness.ts @@ -1,4 +1,4 @@ -import { Component, signal } from '@angular/core'; +import { Component, signal } from "@angular/core"; /** * Harness component that exposes a boolean toggle backed by a signal. @@ -6,10 +6,12 @@ import { Component, signal } from '@angular/core'; */ @Component({ standalone: true, - selector: 'app-toggle-harness', + selector: "app-toggle-harness", template: `
- {{ enabled() ? 'enabled' : 'disabled' }} + {{ + enabled() ? "enabled" : "disabled" + }}
`, diff --git a/tests/__fixtures__/UnmountWatcher.ts b/tests/__fixtures__/UnmountWatcher.ts index 105bc9f..824c4f8 100644 --- a/tests/__fixtures__/UnmountWatcher.ts +++ b/tests/__fixtures__/UnmountWatcher.ts @@ -1,4 +1,4 @@ -import { Component, input, OnDestroy } from '@angular/core'; +import { Component, input, OnDestroy } from "@angular/core"; /** * Component that fires an optional callback when destroyed. @@ -6,7 +6,7 @@ import { Component, input, OnDestroy } from '@angular/core'; */ @Component({ standalone: true, - selector: 'app-unmount-watcher', + selector: "app-unmount-watcher", template: `Mounted`, }) export class UnmountWatcher implements OnDestroy { diff --git a/tests/angular-cleanup.test.ts b/tests/angular-cleanup.test.ts index d9c458d..072edeb 100644 --- a/tests/angular-cleanup.test.ts +++ b/tests/angular-cleanup.test.ts @@ -1,24 +1,24 @@ -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'; +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' }, + selector: "app-throwing-destroy", + host: { "data-fixture": "throwing-destroy" }, template: `Throwing destroy`, }) class ThrowingDestroyComponent implements OnDestroy { ngOnDestroy() { - throw new Error('destroy failure'); + throw new Error("destroy failure"); } } @Component({ standalone: true, - selector: 'app-throwing-click', - host: { 'data-fixture': 'throwing-click' }, + selector: "app-throwing-click", + host: { "data-fixture": "throwing-click" }, template: ` {{ label() }} @@ -28,10 +28,10 @@ class ThrowingClickComponent { private readonly shouldThrow = signal(false); readonly label = computed(() => { if (this.shouldThrow()) { - throw new Error('click failure'); + throw new Error("click failure"); } - return 'safe'; + return "safe"; }); explode() { @@ -41,36 +41,36 @@ class ThrowingClickComponent { afterEach(cleanup); -test('cleanup with no mounted components does not throw', async () => { +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 () => { +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' })); + assert.throws(() => screen.getByRole("heading", { name: "Count: 3" })); }); -test('cleanup removes the mounted component from the DOM', async () => { +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' + screen.getByRole("heading", { name: "Count: 5" }).textContent, + "Count: 5", ); await cleanup(); - assert.throws(() => screen.getByRole('heading', { name: 'Count: 5' })); + assert.throws(() => screen.getByRole("heading", { name: "Count: 5" })); }); -test('cleanup only removes components from the current scope, not others', async () => { +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 } }); @@ -78,22 +78,22 @@ test('cleanup only removes components from the current scope, not others', async // Implicit cleanup via afterEach should remove both without throwing. }); -test('cleanup surfaces ngOnDestroy failures instead of swallowing them', async () => { +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' })); + assert.throws(() => screen.getByText("Throwing destroy")); + assert.throws(() => screen.getByRole("heading", { name: "Count: 8" })); }); -test('fireEvent surfaces post-event change-detection failures', async () => { +test("fireEvent surfaces post-event change-detection failures", async () => { await render(ThrowingClickComponent); await assert.rejects( - fireEvent.click(screen.getByRole('button', { name: 'Explode' })), - /click failure/ + fireEvent.click(screen.getByRole("button", { name: "Explode" })), + /click failure/, ); }); diff --git a/tests/angular-component.test.ts b/tests/angular-component.test.ts index a4e159c..f7f7ba6 100644 --- a/tests/angular-component.test.ts +++ b/tests/angular-component.test.ts @@ -1,32 +1,32 @@ -import { afterEach, assert, test } from 'poku'; -import { CounterButton } from './__fixtures__/CounterButton.ts'; -import { cleanup, fireEvent, render, screen } from '../src/index.ts'; +import { afterEach, assert, test } from "poku"; +import { CounterButton } from "./__fixtures__/CounterButton.ts"; +import { cleanup, fireEvent, render, screen } from "../src/index.ts"; afterEach(cleanup); -await test('renders and updates an Angular standalone component', async () => { +await test("renders and updates an Angular standalone component", async () => { await render(CounterButton, { inputs: { initialCount: 1 }, }); assert.strictEqual( - screen.getByRole('heading', { name: 'Count: 1' }).textContent, - 'Count: 1' + screen.getByRole("heading", { name: "Count: 1" }).textContent, + "Count: 1", ); - await fireEvent.click(screen.getByRole('button', { name: 'Increment' })); + await fireEvent.click(screen.getByRole("button", { name: "Increment" })); assert.strictEqual( - screen.getByRole('heading', { name: 'Count: 2' }).textContent, - 'Count: 2' + screen.getByRole("heading", { name: "Count: 2" }).textContent, + "Count: 2", ); }); -await test('renders component with default signal-input value', async () => { +await test("renders component with default signal-input value", async () => { await render(CounterButton); assert.strictEqual( - screen.getByRole('heading', { name: 'Count: 0' }).textContent, - 'Count: 0' + screen.getByRole("heading", { name: "Count: 0" }).textContent, + "Count: 0", ); }); diff --git a/tests/angular-concurrency.test.ts b/tests/angular-concurrency.test.ts index a85492f..9b2957f 100644 --- a/tests/angular-concurrency.test.ts +++ b/tests/angular-concurrency.test.ts @@ -1,10 +1,10 @@ -import { afterEach, assert, test } from 'poku'; -import { AsyncPipeline } from './__fixtures__/AsyncPipeline.ts'; -import { cleanup, fireEvent, render, screen } from '../src/index.ts'; +import { afterEach, assert, test } from "poku"; +import { AsyncPipeline } from "./__fixtures__/AsyncPipeline.ts"; +import { cleanup, fireEvent, render, screen } from "../src/index.ts"; afterEach(cleanup); -await test('handles async ngOnInit updates and queued signal transitions', async () => { +await test("handles async ngOnInit updates and queued signal transitions", async () => { const view = await render(AsyncPipeline); // The first detectChanges() in render() runs before the ngOnInit microtask @@ -12,32 +12,32 @@ await test('handles async ngOnInit updates and queued signal transitions', async await view.detectChanges(); assert.strictEqual( - screen.getByRole('heading', { + screen.getByRole("heading", { level: 2, - name: 'Loaded from async setup', + name: "Loaded from async setup", }).textContent, - 'Loaded from async setup' + "Loaded from async setup", ); - assert.strictEqual(screen.getByLabelText('urgent-state').textContent, 'idle'); + assert.strictEqual(screen.getByLabelText("urgent-state").textContent, "idle"); assert.strictEqual( - screen.getByLabelText('deferred-state').textContent, - 'idle' + screen.getByLabelText("deferred-state").textContent, + "idle", ); // Click triggers runPipeline(): urgentState is set synchronously, then // deferredState is set after a microtask. - await fireEvent.click(screen.getByRole('button', { name: 'Run pipeline' })); + await fireEvent.click(screen.getByRole("button", { name: "Run pipeline" })); // Flush the deferred microtask and re-render. await view.detectChanges(); assert.strictEqual( - screen.getByLabelText('urgent-state').textContent, - 'urgent-updated' + screen.getByLabelText("urgent-state").textContent, + "urgent-updated", ); assert.strictEqual( - screen.getByLabelText('deferred-state').textContent, - 'queued-updated' + screen.getByLabelText("deferred-state").textContent, + "queued-updated", ); }); diff --git a/tests/angular-context.test.ts b/tests/angular-context.test.ts index 4d4ffe5..a490310 100644 --- a/tests/angular-context.test.ts +++ b/tests/angular-context.test.ts @@ -1,26 +1,26 @@ -import { afterEach, assert, test } from 'poku'; -import { ThemeConsumer } from './__fixtures__/ThemeConsumer.ts'; -import { THEME_TOKEN } from './__fixtures__/ThemeToken.ts'; -import { cleanup, render, screen } from '../src/index.ts'; +import { afterEach, assert, test } from "poku"; +import { ThemeConsumer } from "./__fixtures__/ThemeConsumer.ts"; +import { THEME_TOKEN } from "./__fixtures__/ThemeToken.ts"; +import { cleanup, render, screen } from "../src/index.ts"; afterEach(cleanup); -await test('injects provided InjectionToken value via the providers array', async () => { +await test("injects provided InjectionToken value via the providers array", async () => { await render(ThemeConsumer, { - providers: [{ provide: THEME_TOKEN, useValue: 'dark' }], + providers: [{ provide: THEME_TOKEN, useValue: "dark" }], }); assert.strictEqual( - screen.getByText('Theme: dark').textContent, - 'Theme: dark' + screen.getByText("Theme: dark").textContent, + "Theme: dark", ); }); -await test('falls back to the token factory default when no provider is given', async () => { +await test("falls back to the token factory default when no provider is given", async () => { await render(ThemeConsumer); assert.strictEqual( - screen.getByText('Theme: light').textContent, - 'Theme: light' + screen.getByText("Theme: light").textContent, + "Theme: light", ); }); diff --git a/tests/angular-forms.test.ts b/tests/angular-forms.test.ts index a211950..fb4177d 100644 --- a/tests/angular-forms.test.ts +++ b/tests/angular-forms.test.ts @@ -1,57 +1,57 @@ -import { afterEach, assert, test } from 'poku'; -import { LoginForm } from './__fixtures__/LoginForm.ts'; -import { cleanup, fireEvent, render, screen } from '../src/index.ts'; +import { afterEach, assert, test } from "poku"; +import { LoginForm } from "./__fixtures__/LoginForm.ts"; +import { cleanup, fireEvent, render, screen } from "../src/index.ts"; afterEach(cleanup); -await test('submit button is disabled while the form is invalid', async () => { +await test("submit button is disabled while the form is invalid", async () => { await render(LoginForm); - const button = screen.getByRole('button', { name: 'Sign in' }); - assert.strictEqual(button.hasAttribute('disabled'), true); + const button = screen.getByRole("button", { name: "Sign in" }); + assert.strictEqual(button.hasAttribute("disabled"), true); }); -await test('shows email validation error after blurring an empty email field', async () => { +await test("shows email validation error after blurring an empty email field", async () => { await render(LoginForm); - const emailInput = screen.getByLabelText('Email'); + const emailInput = screen.getByLabelText("Email"); await fireEvent.focus(emailInput); await fireEvent.blur(emailInput); assert.strictEqual( - screen.getByRole('alert').textContent, - 'Valid email is required.' + screen.getByRole("alert").textContent, + "Valid email is required.", ); }); -await test('shows password validation error after a short password is entered', async () => { +await test("shows password validation error after a short password is entered", async () => { await render(LoginForm); - const passwordInput = screen.getByLabelText('Password'); + const passwordInput = screen.getByLabelText("Password"); await fireEvent.focus(passwordInput); - await fireEvent.input(passwordInput, { target: { value: 'short' } }); + await fireEvent.input(passwordInput, { target: { value: "short" } }); await fireEvent.blur(passwordInput); assert.strictEqual( - screen.getByRole('alert').textContent, - 'Password must be at least 8 characters.' + screen.getByRole("alert").textContent, + "Password must be at least 8 characters.", ); }); -await test('displays welcome message after valid credentials are submitted', async () => { +await test("displays welcome message after valid credentials are submitted", async () => { await render(LoginForm); - await fireEvent.input(screen.getByLabelText('Email'), { - target: { value: 'grace@example.com' }, + await fireEvent.input(screen.getByLabelText("Email"), { + target: { value: "grace@example.com" }, }); - await fireEvent.input(screen.getByLabelText('Password'), { - target: { value: 'securepassword' }, + await fireEvent.input(screen.getByLabelText("Password"), { + target: { value: "securepassword" }, }); - await fireEvent.click(screen.getByRole('button', { name: 'Sign in' })); + await fireEvent.click(screen.getByRole("button", { name: "Sign in" })); assert.strictEqual( - screen.getByRole('status').textContent, - 'Welcome, grace@example.com!' + screen.getByRole("status").textContent, + "Welcome, grace@example.com!", ); }); diff --git a/tests/angular-hooks.test.ts b/tests/angular-hooks.test.ts index 7479673..8d278d1 100644 --- a/tests/angular-hooks.test.ts +++ b/tests/angular-hooks.test.ts @@ -1,31 +1,37 @@ -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'; -import { useToggle } from './helpers/use-toggle.ts'; +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"; +import { useToggle } from "./helpers/use-toggle.ts"; afterEach(cleanup); -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( - screen.getByLabelText('toggle-state').textContent, - 'disabled' + screen.getByLabelText("toggle-state").textContent, + "disabled", ); - await fireEvent.click(screen.getByRole('button', { name: 'Toggle' })); + await fireEvent.click(screen.getByRole("button", { name: "Toggle" })); assert.strictEqual( - screen.getByLabelText('toggle-state').textContent, - 'enabled' + screen.getByLabelText("toggle-state").textContent, + "enabled", ); }); -test('tests signal hook logic directly with renderHook', async () => { +test("tests signal hook logic directly with renderHook", async () => { const { result } = await renderHook( ({ initial }: { initial: boolean }) => useToggle(initial), - { initialProps: { initial: true } } + { initialProps: { initial: true } }, ); assert.strictEqual(result.current.enabled(), true); @@ -36,10 +42,10 @@ test('tests signal hook logic directly with renderHook', async () => { assert.strictEqual(result.current.enabled(), false); }); -test('renderHook.rerender re-evaluates the hook with updated props', async () => { +test("renderHook.rerender re-evaluates the hook with updated props", async () => { const { result, rerender } = await renderHook( ({ initial }: { initial: boolean }) => useToggle(initial), - { initialProps: { initial: false } } + { initialProps: { initial: false } }, ); assert.strictEqual(result.current.enabled(), false); @@ -50,21 +56,21 @@ test('renderHook.rerender re-evaluates the hook with updated props', async () => assert.strictEqual(result.current.enabled(), true); }); -test('renderHook.rerender after unmount throws a descriptive error', async () => { +test("renderHook.rerender after unmount throws a descriptive error", async () => { const { rerender, unmount } = await renderHook( ({ initial }: { initial: boolean }) => useToggle(initial), - { initialProps: { initial: false } } + { initialProps: { initial: false } }, ); unmount(); assert.throws( () => rerender({ initial: true }), - /cannot call rerender\(\) after the hook has been unmounted/ + /cannot call rerender\(\) after the hook has been unmounted/, ); }); -test('renderHook.rerender destroys the previous injection context', async () => { +test("renderHook.rerender destroys the previous injection context", async () => { const destroyed: string[] = []; const { rerender, unmount } = await renderHook( @@ -75,19 +81,19 @@ test('renderHook.rerender destroys the previous injection context', async () => return label; }, - { initialProps: { label: 'first' } } + { initialProps: { label: "first" } }, ); assert.deepStrictEqual(destroyed, []); - rerender({ label: 'second' }); - assert.deepStrictEqual(destroyed, ['first']); + rerender({ label: "second" }); + assert.deepStrictEqual(destroyed, ["first"]); unmount(); - assert.deepStrictEqual(destroyed, ['first', 'second']); + assert.deepStrictEqual(destroyed, ["first", "second"]); }); -test('renderHook.rerender rolls back the next execution if previous cleanup fails', async () => { +test("renderHook.rerender rolls back the next execution if previous cleanup fails", async () => { const destroyed: string[] = []; let shouldThrowOnFirstDestroy = true; @@ -96,24 +102,21 @@ test('renderHook.rerender rolls back the next execution if previous cleanup fail inject(DestroyRef).onDestroy(() => { destroyed.push(label); - if (label === 'first' && shouldThrowOnFirstDestroy) { + if (label === "first" && shouldThrowOnFirstDestroy) { shouldThrowOnFirstDestroy = false; - throw new Error('first destroy failure'); + throw new Error("first destroy failure"); } }); return label; }, - { initialProps: { label: 'first' } } + { initialProps: { label: "first" } }, ); - assert.strictEqual(result.current, 'first'); + assert.strictEqual(result.current, "first"); - assert.throws( - () => rerender({ label: 'second' }), - /first destroy failure/ - ); + assert.throws(() => rerender({ label: "second" }), /first destroy failure/); - assert.deepStrictEqual(destroyed, ['first', 'second']); - assert.strictEqual(result.current, 'first'); + 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 6a04136..b16b664 100644 --- a/tests/angular-lifecycle.test.ts +++ b/tests/angular-lifecycle.test.ts @@ -1,31 +1,32 @@ -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'; +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 { Component, Input } from "@angular/core"; +import { cleanup, render, renderHook, screen, waitFor } from "../src/index.ts"; afterEach(cleanup); -await test('rerender propagates updated signal input to the DOM', async () => { +await test("rerender propagates updated signal input to the DOM", async () => { const view = await render(GreetingCard, { - inputs: { name: 'Ada' }, + inputs: { name: "Ada" }, }); assert.strictEqual( - screen.getByRole('heading', { level: 3 }).textContent, - 'Hello Ada' + screen.getByRole("heading", { level: 3 }).textContent, + "Hello Ada", ); - await view.rerender({ name: 'Grace' }); + await view.rerender({ name: "Grace" }); assert.strictEqual( - screen.getByRole('heading', { level: 3 }).textContent, - 'Hello Grace' + screen.getByRole("heading", { level: 3 }).textContent, + "Hello Grace", ); }); -await test('unmount fires ngOnDestroy on the component instance', async () => { +await test("unmount fires ngOnDestroy on the component instance", async () => { let cleaned = false; const view = await render(UnmountWatcher, { @@ -41,38 +42,36 @@ await test('unmount fires ngOnDestroy on the component instance', async () => { assert.strictEqual(cleaned, true); }); -await test('fixture.debugElement is non-null after render', async () => { +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' + "debugElement should be populated after render", ); }); -await test('render with detectChanges:false defers DOM population until explicit detectChanges', async () => { +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' }) - ); + 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' + screen.getByRole("heading", { name: "Count: 7" }).textContent, + "Count: 7", ); }); -await test('fixture.isStable reflects Angular application stability', async () => { +await test("fixture.isStable reflects Angular application stability", async () => { const view = await render(AsyncPipeline, { detectChanges: false, }); @@ -83,3 +82,76 @@ await test('fixture.isStable reflects Angular application stability', async () = await view.fixture.whenStable(); assert.strictEqual(view.fixture.isStable(), true); }); + +@Component({ + selector: "decorator-input-card", + standalone: true, + template: `

{{ label }}

`, +}) +class DecoratorInputCard { + @Input() label = "default"; +} + +await test("provides fragment snapshots after signal input rerenders", async () => { + const view = await render(GreetingCard, { + inputs: { name: "Ada" }, + }); + + assert.strictEqual( + screen.getByRole("heading", { level: 3 }).textContent, + "Hello Ada", + ); + + await view.rerender({ name: "Grace" }); + + assert.strictEqual( + screen.getByRole("heading", { level: 3 }).textContent, + "Hello Grace", + ); + assert.strictEqual( + view.asFragment().querySelector("h3")?.textContent, + "Hello Grace", + ); +}); + +await test("supports standard @Input() decorator properties, waitFor, and live renderHook.result", async () => { + const view = await render(DecoratorInputCard, { + inputs: { label: "First Label" }, + }); + + assert.strictEqual( + screen.getByTestId("decorator-label").textContent, + "First Label", + ); + + await view.rerender({ label: "Second Label" }); + await waitFor(() => { + assert.strictEqual( + screen.getByTestId("decorator-label").textContent, + "Second Label", + ); + }); + + const { result, rerender } = await renderHook((val: number) => val * 3, { + initialProps: 2, + }); + assert.strictEqual(result.current, 6); + rerender(4); + assert.strictEqual(result.current, 12); +}); + +await test("unmount fires ngOnDestroy on the component instance", async () => { + let cleaned = false; + + const view = await render(UnmountWatcher, { + inputs: { + onCleanup: () => { + cleaned = true; + }, + }, + }); + + assert.strictEqual(cleaned, false); + view.unmount(); + assert.strictEqual(cleaned, true); +}); diff --git a/tests/angular-scope-isolation.test.ts b/tests/angular-scope-isolation.test.ts index 98a0b6f..6eb77e6 100644 --- a/tests/angular-scope-isolation.test.ts +++ b/tests/angular-scope-isolation.test.ts @@ -1,27 +1,27 @@ -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'; +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'); +const SCOPE_HOOKS_KEY = Symbol.for("@pokujs/poku.test-scope-hooks"); type ScopeHooks = { createHolder: () => { scope: unknown }; runScoped: ( holder: { scope: unknown }, - fn: () => Promise | unknown + fn: () => Promise | unknown, ) => Promise; }; const hasDomScopeApi = - typeof (pokuDom as Record).defineSlotKey === 'function' && - typeof (pokuDom as Record).getOrCreateScope === 'function'; + typeof (pokuDom as Record).defineSlotKey === "function" && + typeof (pokuDom as Record).getOrCreateScope === "function"; @Component({ standalone: true, - selector: 'poku-scope-probe', + selector: "poku-scope-probe", template: `
{{ label() }}
`, }) class ScopeProbeComponent { @@ -44,9 +44,9 @@ const testHooksDisabled = async () => { }); 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'); + 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; @@ -54,19 +54,19 @@ const testHooksDisabled = async () => { await cleanup(); resolveACleaned(); - assert.throws(() => screen.getByTestId('suite-a')); + assert.throws(() => screen.getByTestId("suite-a")); }), - it('suite B is contaminated by suite A cleanup when isolation is unavailable', async () => { + 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'); + 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')); + assert.throws(() => screen.getByTestId("suite-b")); }), ]); }; @@ -87,9 +87,9 @@ const testHooksEnabled = async () => { }); 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'); + 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; @@ -97,41 +97,41 @@ const testHooksEnabled = async () => { await cleanup(); resolveACleaned(); - assert.throws(() => screen.getByTestId('suite-a')); + assert.throws(() => screen.getByTestId("suite-a")); }), - it('suite B remains mounted while suite A cleans up', async () => { + 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'); + 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'); + assert.strictEqual(screen.getByTestId("suite-b").textContent, "suite-b"); await cleanup(); - assert.throws(() => screen.getByTestId('suite-b')); + assert.throws(() => screen.getByTestId("suite-b")); }), ]); }; -describe('angular scope isolation', () => { +describe("angular scope isolation", () => { let hasRegisteredHooks = false; - it('scope-hook contract probe', () => { + it("scope-hook contract probe", () => { const g = globalThis as Record; - hasRegisteredHooks = typeof g[SCOPE_HOOKS_KEY] === 'object'; - assert.ok(true, 'runtime probe'); + 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 + "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 + it("test hooks are enabled when scope hooks are available", testHooksEnabled); +}); diff --git a/tests/angular-service.test.ts b/tests/angular-service.test.ts index 74c3dec..f82488d 100644 --- a/tests/angular-service.test.ts +++ b/tests/angular-service.test.ts @@ -1,28 +1,34 @@ -import { afterEach, assert, test } from 'poku'; -import { inject } from '@angular/core'; -import { ServiceConsumer } from './__fixtures__/ServiceConsumer.ts'; -import { CounterService } from './__fixtures__/CounterService.ts'; -import { cleanup, fireEvent, render, renderHook, screen } from '../src/index.ts'; +import { afterEach, assert, test } from "poku"; +import { inject } from "@angular/core"; +import { ServiceConsumer } from "./__fixtures__/ServiceConsumer.ts"; +import { CounterService } from "./__fixtures__/CounterService.ts"; +import { + cleanup, + fireEvent, + render, + renderHook, + screen, +} from "../src/index.ts"; afterEach(cleanup); -await test('component renders service state and interactions update the DOM', async () => { +await test("component renders service state and interactions update the DOM", async () => { await render(ServiceConsumer); assert.strictEqual( - screen.getByRole('heading', { name: 'Count: 0' }).textContent, - 'Count: 0' + screen.getByRole("heading", { name: "Count: 0" }).textContent, + "Count: 0", ); - await fireEvent.click(screen.getByRole('button', { name: 'Increment' })); + await fireEvent.click(screen.getByRole("button", { name: "Increment" })); assert.strictEqual( - screen.getByRole('heading', { name: 'Count: 1' }).textContent, - 'Count: 1' + screen.getByRole("heading", { name: "Count: 1" }).textContent, + "Count: 1", ); }); -await test('mocks injectable service via useValue provider override', async () => { +await test("mocks injectable service via useValue provider override", async () => { let incrementCalled = false; const mockService = { count: (() => { @@ -41,15 +47,15 @@ await test('mocks injectable service via useValue provider override', async () = }); assert.strictEqual( - screen.getByRole('heading', { name: 'Count: 42' }).textContent, - 'Count: 42' + screen.getByRole("heading", { name: "Count: 42" }).textContent, + "Count: 42", ); - await fireEvent.click(screen.getByRole('button', { name: 'Increment' })); + await fireEvent.click(screen.getByRole("button", { name: "Increment" })); assert.strictEqual(incrementCalled, true); }); -await test('injects service directly via renderHook and verifies signal state', async () => { +await test("injects service directly via renderHook and verifies signal state", async () => { const { result } = await renderHook(() => inject(CounterService)); assert.strictEqual(result.current.count(), 0); @@ -61,7 +67,7 @@ await test('injects service directly via renderHook and verifies signal state', assert.strictEqual(result.current.count(), 0); }); -await test('concurrent renderHook calls receive independent providedIn:root service instances', async () => { +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([ diff --git a/tests/helpers/use-toggle.ts b/tests/helpers/use-toggle.ts index 94c81c8..947ae3c 100644 --- a/tests/helpers/use-toggle.ts +++ b/tests/helpers/use-toggle.ts @@ -1,4 +1,4 @@ -import { signal } from '@angular/core'; +import { signal } from "@angular/core"; /** * Signal-based toggle composable — the Angular equivalent of Vue's diff --git a/tools/publish-smoke.mjs b/tools/publish-smoke.mjs index 762b5bf..546fd38 100644 --- a/tools/publish-smoke.mjs +++ b/tools/publish-smoke.mjs @@ -1,9 +1,9 @@ -import { spawnSync } from 'node:child_process'; +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', +const result = spawnSync("npm", ["pack", "--dry-run"], { + cwd: new URL("..", import.meta.url), + stdio: "inherit", + shell: process.platform === "win32", }); if (result.error) { @@ -12,4 +12,4 @@ if (result.error) { if (result.status !== 0) { process.exit(result.status ?? 1); -} \ No newline at end of file +} diff --git a/tsconfig.build.json b/tsconfig.build.json index 786524c..2575a8d 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -3,5 +3,10 @@ "compilerOptions": { "noEmit": true }, - "include": ["src/**/*.ts", "src/**/*.d.ts", "tests/**/*.ts", "tests/**/*.d.ts"] + "include": [ + "src/**/*.ts", + "src/**/*.d.ts", + "tests/**/*.ts", + "tests/**/*.d.ts" + ] } From 95ed5f1e79c408de700261c3b6a8129ea3c54070 Mon Sep 17 00:00:00 2001 From: Lojhan Date: Thu, 22 Oct 2026 17:20:21 +0000 Subject: [PATCH 2/4] fix: consume DOM release and align Angular matrix versions --- .github/workflows/ci_framework-majors.yml | 12 +------ package-lock.json | 13 ++++--- package.json | 10 +++--- tools/test-major.mjs | 43 +++++++++++++++++++++++ 4 files changed, 55 insertions(+), 23 deletions(-) create mode 100644 tools/test-major.mjs diff --git a/.github/workflows/ci_framework-majors.yml b/.github/workflows/ci_framework-majors.yml index 7c613d1..fb34fc4 100644 --- a/.github/workflows/ci_framework-majors.yml +++ b/.github/workflows/ci_framework-majors.yml @@ -49,15 +49,5 @@ jobs: - 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 + run: npm run test:major:${{ matrix.angular-major }} diff --git a/package-lock.json b/package-lock.json index ee8767a..6b19ccd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,11 +9,12 @@ "version": "1.0.0", "license": "MIT", "dependencies": { - "@pokujs/dom": "^1.3.1", + "@pokujs/dom": "^1.3.2", "@testing-library/dom": "^10.4.1" }, "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", @@ -29,8 +30,7 @@ "rimraf": "^6.0.1", "tsup": "^8.5.0", "tsx": "^4.21.0", - "typescript": "^6.0.2", - "@angular/compiler": "^19.0.0" + "typescript": "^6.0.2" }, "engines": { "bun": ">=1.x.x", @@ -832,9 +832,9 @@ } }, "node_modules/@pokujs/dom": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@pokujs/dom/-/dom-1.3.1.tgz", - "integrity": "sha512-ge4Lxx5tLUiiugftvD7Qk6HEPnoY/IRXJ5uOTFLxyEvog2q1wqCUQNLznIA3uvaEjoH+fPwOZgHX3nPqQkrzQg==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@pokujs/dom/-/dom-1.3.2.tgz", + "integrity": "sha512-YtIHIkAS58jMrmDeVx6s2FYVI3+u0S724Ym8Uemd5ISkHP9kW6sroBieOL4xKo3eG/xfl8EW6JUfb9PlFg7q3g==", "license": "MIT", "dependencies": { "@pokujs/scope-hooks": "^1.1.0", @@ -2110,7 +2110,6 @@ "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": { "poku": "lib/bin/index.js" diff --git a/package.json b/package.json index 32fe690..7138eb6 100644 --- a/package.json +++ b/package.json @@ -45,10 +45,10 @@ "lint": "npm run typecheck", "check": "npm run typecheck && npm test", "prepack": "npm run build", - "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:major:18": "node tools/test-major.mjs 18", + "test:major:19": "node tools/test-major.mjs 19", + "test:major:20": "node tools/test-major.mjs 20", + "test:major:21": "node tools/test-major.mjs 21", "test:multi-major": "npm run test:major:18 && npm run test:major:19 && npm run test:major:20 && npm run test:major:21", "test:pack": "node ./tools/publish-smoke.mjs", "check:publish": "npm run test:pack" @@ -78,7 +78,7 @@ "provenance": true }, "dependencies": { - "@pokujs/dom": "^1.3.1", + "@pokujs/dom": "^1.3.2", "@testing-library/dom": "^10.4.1" }, "peerDependencies": { diff --git a/tools/test-major.mjs b/tools/test-major.mjs new file mode 100644 index 0000000..2e0cc09 --- /dev/null +++ b/tools/test-major.mjs @@ -0,0 +1,43 @@ +import { execFileSync, spawnSync } from "node:child_process"; + +const major = process.argv[2]; + +if (!["18", "19", "20", "21"].includes(major)) { + throw new Error("Choose a supported Angular major: 18, 19, 20, or 21."); +} + +const npm = process.platform === "win32" ? "npm.cmd" : "npm"; +const versions = JSON.parse( + execFileSync(npm, ["view", `@angular/core@${major}`, "version", "--json"], { + encoding: "utf8", + }), +); +const version = Array.isArray(versions) ? versions.at(-1) : versions; + +if (typeof version !== "string" || !version.startsWith(`${major}.`)) { + throw new Error(`Could not resolve an Angular ${major} release.`); +} + +const packages = [ + "common", + "compiler", + "core", + "forms", + "platform-browser", + "platform-browser-dynamic", +].map((name) => `@angular/${name}@${version}`); + +for (const args of [ + ["install", "--no-save", "--package-lock=false", ...packages], + ["test"], +]) { + const result = spawnSync(npm, args, { stdio: "inherit" }); + + if (result.error) { + throw result.error; + } + + if (result.status !== 0) { + process.exit(result.status ?? 1); + } +} From dc2c882ea0b79917ff6444fde3ea98d8f948ec9b Mon Sep 17 00:00:00 2001 From: Lojhan Date: Thu, 22 Oct 2026 17:23:40 +0000 Subject: [PATCH 3/4] test: preserve locked dependencies in the Angular matrix --- tools/test-major.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/test-major.mjs b/tools/test-major.mjs index 2e0cc09..c85b901 100644 --- a/tools/test-major.mjs +++ b/tools/test-major.mjs @@ -28,7 +28,7 @@ const packages = [ ].map((name) => `@angular/${name}@${version}`); for (const args of [ - ["install", "--no-save", "--package-lock=false", ...packages], + ["install", "--no-save", ...packages], ["test"], ]) { const result = spawnSync(npm, args, { stdio: "inherit" }); From 3baaa440dc3e8eb3925b4b0e3984c6d165c17370 Mon Sep 17 00:00:00 2001 From: Lojhan Date: Thu, 22 Oct 2026 17:25:44 +0000 Subject: [PATCH 4/4] test: await asynchronous Angular test lifecycles --- tests/angular-cleanup.test.ts | 12 ++++++------ tests/angular-hooks.test.ts | 12 ++++++------ tests/angular-scope-isolation.test.ts | 9 ++++++--- 3 files changed, 18 insertions(+), 15 deletions(-) diff --git a/tests/angular-cleanup.test.ts b/tests/angular-cleanup.test.ts index 072edeb..9380883 100644 --- a/tests/angular-cleanup.test.ts +++ b/tests/angular-cleanup.test.ts @@ -41,13 +41,13 @@ class ThrowingClickComponent { afterEach(cleanup); -test("cleanup with no mounted components does not throw", async () => { +await 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 test("double cleanup does not throw and leaves the DOM clean", async () => { await render(CounterButton, { inputs: { initialCount: 3 } }); await cleanup(); @@ -57,7 +57,7 @@ test("double cleanup does not throw and leaves the DOM clean", async () => { assert.throws(() => screen.getByRole("heading", { name: "Count: 3" })); }); -test("cleanup removes the mounted component from the DOM", async () => { +await test("cleanup removes the mounted component from the DOM", async () => { await render(CounterButton, { inputs: { initialCount: 5 } }); assert.strictEqual( @@ -70,7 +70,7 @@ test("cleanup removes the mounted component from the DOM", async () => { assert.throws(() => screen.getByRole("heading", { name: "Count: 5" })); }); -test("cleanup only removes components from the current scope, not others", async () => { +await 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 } }); @@ -78,7 +78,7 @@ test("cleanup only removes components from the current scope, not others", async // Implicit cleanup via afterEach should remove both without throwing. }); -test("cleanup surfaces ngOnDestroy failures instead of swallowing them", async () => { +await test("cleanup surfaces ngOnDestroy failures instead of swallowing them", async () => { await render(ThrowingDestroyComponent); await render(CounterButton, { inputs: { initialCount: 8 } }); @@ -89,7 +89,7 @@ test("cleanup surfaces ngOnDestroy failures instead of swallowing them", async ( assert.throws(() => screen.getByRole("heading", { name: "Count: 8" })); }); -test("fireEvent surfaces post-event change-detection failures", async () => { +await test("fireEvent surfaces post-event change-detection failures", async () => { await render(ThrowingClickComponent); await assert.rejects( diff --git a/tests/angular-hooks.test.ts b/tests/angular-hooks.test.ts index 8d278d1..306fbbb 100644 --- a/tests/angular-hooks.test.ts +++ b/tests/angular-hooks.test.ts @@ -12,7 +12,7 @@ import { useToggle } from "./helpers/use-toggle.ts"; afterEach(cleanup); -test("tests signal composables through a component harness and renderHook", async () => { +await test("tests signal composables through a component harness and renderHook", async () => { await render(ToggleHarness); assert.strictEqual( @@ -28,7 +28,7 @@ test("tests signal composables through a component harness and renderHook", asyn ); }); -test("tests signal hook logic directly with renderHook", async () => { +await test("tests signal hook logic directly with renderHook", async () => { const { result } = await renderHook( ({ initial }: { initial: boolean }) => useToggle(initial), { initialProps: { initial: true } }, @@ -42,7 +42,7 @@ test("tests signal hook logic directly with renderHook", async () => { assert.strictEqual(result.current.enabled(), false); }); -test("renderHook.rerender re-evaluates the hook with updated props", async () => { +await test("renderHook.rerender re-evaluates the hook with updated props", async () => { const { result, rerender } = await renderHook( ({ initial }: { initial: boolean }) => useToggle(initial), { initialProps: { initial: false } }, @@ -56,7 +56,7 @@ test("renderHook.rerender re-evaluates the hook with updated props", async () => assert.strictEqual(result.current.enabled(), true); }); -test("renderHook.rerender after unmount throws a descriptive error", async () => { +await test("renderHook.rerender after unmount throws a descriptive error", async () => { const { rerender, unmount } = await renderHook( ({ initial }: { initial: boolean }) => useToggle(initial), { initialProps: { initial: false } }, @@ -70,7 +70,7 @@ test("renderHook.rerender after unmount throws a descriptive error", async () => ); }); -test("renderHook.rerender destroys the previous injection context", async () => { +await test("renderHook.rerender destroys the previous injection context", async () => { const destroyed: string[] = []; const { rerender, unmount } = await renderHook( @@ -93,7 +93,7 @@ test("renderHook.rerender destroys the previous injection context", async () => assert.deepStrictEqual(destroyed, ["first", "second"]); }); -test("renderHook.rerender rolls back the next execution if previous cleanup fails", async () => { +await test("renderHook.rerender rolls back the next execution if previous cleanup fails", async () => { const destroyed: string[] = []; let shouldThrowOnFirstDestroy = true; diff --git a/tests/angular-scope-isolation.test.ts b/tests/angular-scope-isolation.test.ts index 6eb77e6..3e825c8 100644 --- a/tests/angular-scope-isolation.test.ts +++ b/tests/angular-scope-isolation.test.ts @@ -117,10 +117,10 @@ const testHooksEnabled = async () => { ]); }; -describe("angular scope isolation", () => { +await describe("angular scope isolation", async () => { let hasRegisteredHooks = false; - it("scope-hook contract probe", () => { + await it("scope-hook contract probe", () => { const g = globalThis as Record; hasRegisteredHooks = typeof g[SCOPE_HOOKS_KEY] === "object"; assert.ok(true, "runtime probe"); @@ -133,5 +133,8 @@ describe("angular scope isolation", () => { ); } - it("test hooks are enabled when scope hooks are available", testHooksEnabled); + await it( + "test hooks are enabled when scope hooks are available", + testHooksEnabled, + ); });