From 64dd13116c83e6e8fc79b8b0198ce63bd91f5637 Mon Sep 17 00:00:00 2001 From: Jeff Repanich Date: Fri, 11 Sep 2026 12:49:39 -0400 Subject: [PATCH 1/9] chore: refresh vite-plus and regenerate lockfiles --- .github/workflows/ci.yml | 4 +- .gitignore | 5 + AGENTS.md | 6 +- .../browser-console.setup.ts | 0 {tests/browser => benches}/browser-console.ts | 0 package-lock.json | 19 +- package.json | 3 +- playwright.config.ts | 45 ++ tests/browser/assertions.ts | 30 + .../browser/components/accordion/a11y.spec.ts | 12 + .../components/accordion/determinism.spec.ts | 13 + tests/browser/components/avatar/a11y.spec.ts | 12 + .../components/avatar/behavior.spec.ts | 18 + .../components/avatar/determinism.spec.ts | 10 + tests/browser/components/button/a11y.spec.ts | 72 ++ .../components/button/behavior.spec.ts | 191 ++++++ .../components/button/determinism.spec.ts | 52 ++ .../components/collapsible/a11y.spec.ts | 167 +++++ .../components/collapsible/behavior.spec.ts | 257 +++++++ .../collapsible/determinism.spec.ts | 162 +++++ tests/browser/components/dialog/a11y.spec.ts | 12 + .../components/dialog/determinism.spec.ts | 10 + .../browser/components/dropdown/a11y.spec.ts | 12 + .../components/dropdown/determinism.spec.ts | 13 + .../components/focus-scope/a11y.spec.ts | 12 + .../components/focus-scope/behavior.spec.ts | 34 + .../focus-scope/determinism.spec.ts | 13 + tests/browser/components/form/a11y.spec.ts | 12 + .../browser/components/form/behavior.spec.ts | 49 ++ .../components/form/determinism.spec.ts | 10 + .../components/hover-card/a11y.spec.ts | 12 + .../components/hover-card/determinism.spec.ts | 13 + tests/browser/components/label/a11y.spec.ts | 39 ++ .../browser/components/label/behavior.spec.ts | 27 + .../components/label/determinism.spec.ts | 22 + tests/browser/components/menubar/a11y.spec.ts | 14 + .../components/menubar/determinism.spec.ts | 10 + .../native-control/behavior.spec.ts | 40 ++ .../components/overlay-host/a11y.spec.ts | 12 + .../components/overlay-host/behavior.spec.ts | 33 + .../overlay-host/determinism.spec.ts | 13 + tests/browser/components/popover/a11y.spec.ts | 48 ++ .../components/popover/determinism.spec.ts | 10 + .../components/progress-circle/a11y.spec.ts | 12 + .../progress-circle/behavior.spec.ts | 20 + .../progress-circle/determinism.spec.ts | 13 + .../browser/components/progress/a11y.spec.ts | 12 + .../components/progress/behavior.spec.ts | 43 ++ .../components/progress/determinism.spec.ts | 13 + .../rtl-composites/behavior.spec.ts | 26 + tests/browser/components/table/a11y.spec.ts | 12 + .../browser/components/table/behavior.spec.ts | 56 ++ .../components/table/determinism.spec.ts | 10 + .../browser/components/textarea/a11y.spec.ts | 91 +++ .../components/textarea/behavior.spec.ts | 94 +++ .../components/textarea/determinism.spec.ts | 22 + tests/browser/components/toast/a11y.spec.ts | 12 + .../components/toast/determinism.spec.ts | 10 + .../components/toggle-group/a11y.spec.ts | 84 +++ .../toggle-group/determinism.spec.ts | 41 ++ tests/browser/components/toggle/a11y.spec.ts | 107 +++ .../components/toggle/behavior.spec.ts | 144 ++++ .../components/toggle/determinism.spec.ts | 41 ++ .../components/virtual-list/a11y.spec.ts | 12 + .../virtual-list/determinism.spec.ts | 13 + .../behavior.spec.ts | 43 ++ .../behavior.spec.ts | 64 ++ .../behavior.spec.ts | 71 ++ .../components/virtual-table/a11y.spec.ts | 12 + .../virtual-table/determinism.spec.ts | 13 + .../components/visually-hidden/a11y.spec.ts | 31 + .../visually-hidden/behavior.spec.ts | 22 + .../visually-hidden/determinism.spec.ts | 13 + tests/browser/fixtures.ts | 214 ++++++ tests/browser/harness.html | 17 + tests/browser/harness.ts | 110 +++ tests/browser/scenarios/_markup.tsx | 11 + tests/browser/scenarios/_mount.ts | 186 ++++++ .../scenarios/components/accordion/a11y.tsx | 22 + .../components/accordion/behavior.tsx | 379 +++++++++++ .../components/accordion/determinism.tsx | 27 + .../scenarios/components/avatar/a11y.tsx | 16 + .../scenarios/components/avatar/behavior.tsx | 25 + .../components/avatar/determinism.tsx | 19 + .../scenarios/components/button/a11y.tsx | 52 ++ .../scenarios/components/button/behavior.tsx | 216 ++++++ .../components/button/determinism.tsx | 78 +++ .../scenarios/components/collapsible/a11y.tsx | 218 ++++++ .../components/collapsible/behavior.tsx | 303 +++++++++ .../components/collapsible/determinism.tsx | 173 +++++ .../scenarios/components/dialog/a11y.tsx | 24 + .../components/dialog/determinism.tsx | 27 + .../scenarios/components/dropdown/a11y.tsx | 22 + .../components/dropdown/determinism.tsx | 25 + .../scenarios/components/focus-scope/a11y.tsx | 12 + .../components/focus-scope/behavior.tsx | 81 +++ .../components/focus-scope/determinism.tsx | 15 + .../scenarios/components/form/a11y.tsx | 13 + .../scenarios/components/form/behavior.tsx | 64 ++ .../scenarios/components/form/determinism.tsx | 14 + .../scenarios/components/hover-card/a11y.tsx | 18 + .../components/hover-card/determinism.tsx | 19 + .../scenarios/components/label/a11y.tsx | 33 + .../scenarios/components/label/behavior.tsx | 15 + .../components/label/determinism.tsx | 24 + .../scenarios/components/menubar/a11y.tsx | 37 ++ .../components/menubar/determinism.tsx | 28 + .../components/native-control/behavior.tsx | 95 +++ .../components/overlay-host/a11y.tsx | 19 + .../components/overlay-host/behavior.tsx | 83 +++ .../components/overlay-host/determinism.tsx | 22 + .../scenarios/components/popover/a11y.tsx | 71 ++ .../components/popover/determinism.tsx | 19 + .../components/progress-circle/a11y.tsx | 14 + .../components/progress-circle/behavior.tsx | 45 ++ .../progress-circle/determinism.tsx | 17 + .../scenarios/components/progress/a11y.tsx | 14 + .../components/progress/behavior.tsx | 70 ++ .../components/progress/determinism.tsx | 17 + .../components/rtl-composites/behavior.tsx | 72 ++ .../scenarios/components/table/a11y.tsx | 31 + .../scenarios/components/table/behavior.tsx | 75 +++ .../components/table/determinism.tsx | 32 + .../scenarios/components/textarea/a11y.tsx | 38 ++ .../components/textarea/behavior.tsx | 59 ++ .../components/textarea/determinism.tsx | 24 + .../scenarios/components/toast/a11y.tsx | 23 + .../components/toast/determinism.tsx | 22 + .../components/toggle-group/a11y.tsx | 71 ++ .../components/toggle-group/behavior.tsx | 507 ++++++++++++++ .../components/toggle-group/determinism.tsx | 91 +++ .../scenarios/components/toggle/a11y.tsx | 60 ++ .../scenarios/components/toggle/behavior.tsx | 117 ++++ .../components/toggle/determinism.tsx | 60 ++ .../components/virtual-list/a11y.tsx | 27 + .../components/virtual-list/determinism.tsx | 30 + .../behavior.tsx | 44 ++ .../behavior.tsx | 92 +++ .../behavior.tsx | 123 ++++ .../components/virtual-table/a11y.tsx | 42 ++ .../components/virtual-table/behavior.tsx | 628 ++++++++++++++++++ .../components/virtual-table/determinism.tsx | 45 ++ .../components/visually-hidden/a11y.tsx | 29 + .../components/visually-hidden/behavior.tsx | 15 + .../visually-hidden/determinism.tsx | 17 + .../dev_checks/test-suite-guidelines.test.ts | 30 +- vite.harness.config.ts | 55 ++ vitest.bench.tier4.config.ts | 35 +- vitest.config.ts | 9 +- 149 files changed, 8209 insertions(+), 22 deletions(-) rename {tests/browser => benches}/browser-console.setup.ts (100%) rename {tests/browser => benches}/browser-console.ts (100%) create mode 100644 playwright.config.ts create mode 100644 tests/browser/assertions.ts create mode 100644 tests/browser/components/accordion/a11y.spec.ts create mode 100644 tests/browser/components/accordion/determinism.spec.ts create mode 100644 tests/browser/components/avatar/a11y.spec.ts create mode 100644 tests/browser/components/avatar/behavior.spec.ts create mode 100644 tests/browser/components/avatar/determinism.spec.ts create mode 100644 tests/browser/components/button/a11y.spec.ts create mode 100644 tests/browser/components/button/behavior.spec.ts create mode 100644 tests/browser/components/button/determinism.spec.ts create mode 100644 tests/browser/components/collapsible/a11y.spec.ts create mode 100644 tests/browser/components/collapsible/behavior.spec.ts create mode 100644 tests/browser/components/collapsible/determinism.spec.ts create mode 100644 tests/browser/components/dialog/a11y.spec.ts create mode 100644 tests/browser/components/dialog/determinism.spec.ts create mode 100644 tests/browser/components/dropdown/a11y.spec.ts create mode 100644 tests/browser/components/dropdown/determinism.spec.ts create mode 100644 tests/browser/components/focus-scope/a11y.spec.ts create mode 100644 tests/browser/components/focus-scope/behavior.spec.ts create mode 100644 tests/browser/components/focus-scope/determinism.spec.ts create mode 100644 tests/browser/components/form/a11y.spec.ts create mode 100644 tests/browser/components/form/behavior.spec.ts create mode 100644 tests/browser/components/form/determinism.spec.ts create mode 100644 tests/browser/components/hover-card/a11y.spec.ts create mode 100644 tests/browser/components/hover-card/determinism.spec.ts create mode 100644 tests/browser/components/label/a11y.spec.ts create mode 100644 tests/browser/components/label/behavior.spec.ts create mode 100644 tests/browser/components/label/determinism.spec.ts create mode 100644 tests/browser/components/menubar/a11y.spec.ts create mode 100644 tests/browser/components/menubar/determinism.spec.ts create mode 100644 tests/browser/components/native-control/behavior.spec.ts create mode 100644 tests/browser/components/overlay-host/a11y.spec.ts create mode 100644 tests/browser/components/overlay-host/behavior.spec.ts create mode 100644 tests/browser/components/overlay-host/determinism.spec.ts create mode 100644 tests/browser/components/popover/a11y.spec.ts create mode 100644 tests/browser/components/popover/determinism.spec.ts create mode 100644 tests/browser/components/progress-circle/a11y.spec.ts create mode 100644 tests/browser/components/progress-circle/behavior.spec.ts create mode 100644 tests/browser/components/progress-circle/determinism.spec.ts create mode 100644 tests/browser/components/progress/a11y.spec.ts create mode 100644 tests/browser/components/progress/behavior.spec.ts create mode 100644 tests/browser/components/progress/determinism.spec.ts create mode 100644 tests/browser/components/rtl-composites/behavior.spec.ts create mode 100644 tests/browser/components/table/a11y.spec.ts create mode 100644 tests/browser/components/table/behavior.spec.ts create mode 100644 tests/browser/components/table/determinism.spec.ts create mode 100644 tests/browser/components/textarea/a11y.spec.ts create mode 100644 tests/browser/components/textarea/behavior.spec.ts create mode 100644 tests/browser/components/textarea/determinism.spec.ts create mode 100644 tests/browser/components/toast/a11y.spec.ts create mode 100644 tests/browser/components/toast/determinism.spec.ts create mode 100644 tests/browser/components/toggle-group/a11y.spec.ts create mode 100644 tests/browser/components/toggle-group/determinism.spec.ts create mode 100644 tests/browser/components/toggle/a11y.spec.ts create mode 100644 tests/browser/components/toggle/behavior.spec.ts create mode 100644 tests/browser/components/toggle/determinism.spec.ts create mode 100644 tests/browser/components/virtual-list/a11y.spec.ts create mode 100644 tests/browser/components/virtual-list/determinism.spec.ts create mode 100644 tests/browser/components/virtual-table-identity-wiring/behavior.spec.ts create mode 100644 tests/browser/components/virtual-table-style-injection/behavior.spec.ts create mode 100644 tests/browser/components/virtual-table-virtualization-orchestration/behavior.spec.ts create mode 100644 tests/browser/components/virtual-table/a11y.spec.ts create mode 100644 tests/browser/components/virtual-table/determinism.spec.ts create mode 100644 tests/browser/components/visually-hidden/a11y.spec.ts create mode 100644 tests/browser/components/visually-hidden/behavior.spec.ts create mode 100644 tests/browser/components/visually-hidden/determinism.spec.ts create mode 100644 tests/browser/fixtures.ts create mode 100644 tests/browser/harness.html create mode 100644 tests/browser/harness.ts create mode 100644 tests/browser/scenarios/_markup.tsx create mode 100644 tests/browser/scenarios/_mount.ts create mode 100644 tests/browser/scenarios/components/accordion/a11y.tsx create mode 100644 tests/browser/scenarios/components/accordion/behavior.tsx create mode 100644 tests/browser/scenarios/components/accordion/determinism.tsx create mode 100644 tests/browser/scenarios/components/avatar/a11y.tsx create mode 100644 tests/browser/scenarios/components/avatar/behavior.tsx create mode 100644 tests/browser/scenarios/components/avatar/determinism.tsx create mode 100644 tests/browser/scenarios/components/button/a11y.tsx create mode 100644 tests/browser/scenarios/components/button/behavior.tsx create mode 100644 tests/browser/scenarios/components/button/determinism.tsx create mode 100644 tests/browser/scenarios/components/collapsible/a11y.tsx create mode 100644 tests/browser/scenarios/components/collapsible/behavior.tsx create mode 100644 tests/browser/scenarios/components/collapsible/determinism.tsx create mode 100644 tests/browser/scenarios/components/dialog/a11y.tsx create mode 100644 tests/browser/scenarios/components/dialog/determinism.tsx create mode 100644 tests/browser/scenarios/components/dropdown/a11y.tsx create mode 100644 tests/browser/scenarios/components/dropdown/determinism.tsx create mode 100644 tests/browser/scenarios/components/focus-scope/a11y.tsx create mode 100644 tests/browser/scenarios/components/focus-scope/behavior.tsx create mode 100644 tests/browser/scenarios/components/focus-scope/determinism.tsx create mode 100644 tests/browser/scenarios/components/form/a11y.tsx create mode 100644 tests/browser/scenarios/components/form/behavior.tsx create mode 100644 tests/browser/scenarios/components/form/determinism.tsx create mode 100644 tests/browser/scenarios/components/hover-card/a11y.tsx create mode 100644 tests/browser/scenarios/components/hover-card/determinism.tsx create mode 100644 tests/browser/scenarios/components/label/a11y.tsx create mode 100644 tests/browser/scenarios/components/label/behavior.tsx create mode 100644 tests/browser/scenarios/components/label/determinism.tsx create mode 100644 tests/browser/scenarios/components/menubar/a11y.tsx create mode 100644 tests/browser/scenarios/components/menubar/determinism.tsx create mode 100644 tests/browser/scenarios/components/native-control/behavior.tsx create mode 100644 tests/browser/scenarios/components/overlay-host/a11y.tsx create mode 100644 tests/browser/scenarios/components/overlay-host/behavior.tsx create mode 100644 tests/browser/scenarios/components/overlay-host/determinism.tsx create mode 100644 tests/browser/scenarios/components/popover/a11y.tsx create mode 100644 tests/browser/scenarios/components/popover/determinism.tsx create mode 100644 tests/browser/scenarios/components/progress-circle/a11y.tsx create mode 100644 tests/browser/scenarios/components/progress-circle/behavior.tsx create mode 100644 tests/browser/scenarios/components/progress-circle/determinism.tsx create mode 100644 tests/browser/scenarios/components/progress/a11y.tsx create mode 100644 tests/browser/scenarios/components/progress/behavior.tsx create mode 100644 tests/browser/scenarios/components/progress/determinism.tsx create mode 100644 tests/browser/scenarios/components/rtl-composites/behavior.tsx create mode 100644 tests/browser/scenarios/components/table/a11y.tsx create mode 100644 tests/browser/scenarios/components/table/behavior.tsx create mode 100644 tests/browser/scenarios/components/table/determinism.tsx create mode 100644 tests/browser/scenarios/components/textarea/a11y.tsx create mode 100644 tests/browser/scenarios/components/textarea/behavior.tsx create mode 100644 tests/browser/scenarios/components/textarea/determinism.tsx create mode 100644 tests/browser/scenarios/components/toast/a11y.tsx create mode 100644 tests/browser/scenarios/components/toast/determinism.tsx create mode 100644 tests/browser/scenarios/components/toggle-group/a11y.tsx create mode 100644 tests/browser/scenarios/components/toggle-group/behavior.tsx create mode 100644 tests/browser/scenarios/components/toggle-group/determinism.tsx create mode 100644 tests/browser/scenarios/components/toggle/a11y.tsx create mode 100644 tests/browser/scenarios/components/toggle/behavior.tsx create mode 100644 tests/browser/scenarios/components/toggle/determinism.tsx create mode 100644 tests/browser/scenarios/components/virtual-list/a11y.tsx create mode 100644 tests/browser/scenarios/components/virtual-list/determinism.tsx create mode 100644 tests/browser/scenarios/components/virtual-table-identity-wiring/behavior.tsx create mode 100644 tests/browser/scenarios/components/virtual-table-style-injection/behavior.tsx create mode 100644 tests/browser/scenarios/components/virtual-table-virtualization-orchestration/behavior.tsx create mode 100644 tests/browser/scenarios/components/virtual-table/a11y.tsx create mode 100644 tests/browser/scenarios/components/virtual-table/behavior.tsx create mode 100644 tests/browser/scenarios/components/virtual-table/determinism.tsx create mode 100644 tests/browser/scenarios/components/visually-hidden/a11y.tsx create mode 100644 tests/browser/scenarios/components/visually-hidden/behavior.tsx create mode 100644 tests/browser/scenarios/components/visually-hidden/determinism.tsx create mode 100644 vite.harness.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6bf0317d..970ff6d3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,11 +36,11 @@ jobs: run: npm ci - name: Install browser engines with Linux dependencies - if: ${{ hashFiles('vitest.test.browser.config.ts', 'playwright.config.*') != '' && runner.os == 'Linux' }} + if: ${{ hashFiles('playwright.config.*', 'vitest.bench.tier4.config.ts') != '' && runner.os == 'Linux' }} run: npx playwright install --with-deps chromium firefox webkit - name: Install browser engines - if: ${{ hashFiles('vitest.test.browser.config.ts', 'playwright.config.*') != '' && runner.os != 'Linux' }} + if: ${{ hashFiles('playwright.config.*', 'vitest.bench.tier4.config.ts') != '' && runner.os != 'Linux' }} run: npx playwright install chromium firefox webkit - name: Format check diff --git a/.gitignore b/.gitignore index 38012030..16b884e3 100644 --- a/.gitignore +++ b/.gitignore @@ -135,3 +135,8 @@ bench-results.txt bench-profiles/ .vitest-attachments/ __screenshots__/ + +# Playwright +test-results/ +playwright-report/ +blob-report/ diff --git a/AGENTS.md b/AGENTS.md index 1923dfb0..b564095d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,7 +13,11 @@ theme composition belong in `@askrjs/themes`. benchmark coverage, and test-suite conventions. - `tests/jsdom/` covers DOM-backed internal behavior. - `tests/browser/components/` owns public behavior, accessibility, and - determinism coverage. + determinism coverage. These run on native `@playwright/test` + (`playwright.config.ts`): each `.spec.ts` runs in Node and drives the + page, while the component tree it mounts lives in the mirrored + `tests/browser/scenarios/.tsx` module, loaded lazily by + `tests/browser/harness.ts`. - `benches/` contains the four benchmark tiers. - `docs/` contains the package overview, composition guidance, and standing regression-coverage rules. diff --git a/tests/browser/browser-console.setup.ts b/benches/browser-console.setup.ts similarity index 100% rename from tests/browser/browser-console.setup.ts rename to benches/browser-console.setup.ts diff --git a/tests/browser/browser-console.ts b/benches/browser-console.ts similarity index 100% rename from tests/browser/browser-console.ts rename to benches/browser-console.ts diff --git a/package-lock.json b/package-lock.json index b19f378b..d1b66c99 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "devDependencies": { "@askrjs/askr": ">=0.2.3 <0.3.0", "@askrjs/vite": ">=0.2.1 <0.3.0", + "@playwright/test": "1.62.1", "@types/node": "^26.3.0", "@vitest/browser-playwright": "4.1.11", "axe-core": "^4.13.0", @@ -157,7 +158,7 @@ "@askrjs/askr": ">=0.2.3 <0.3.0", "sharp": "^0.35.3", "vite": "^8.2.2", - "vite-plus": "^0.2.8 || ^0.3.0" + "vite-plus": "^0.3.1" }, "peerDependenciesMeta": { "sharp": { @@ -1556,6 +1557,22 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@playwright/test": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", + "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@polka/url": { "version": "1.0.0-next.29", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", diff --git a/package.json b/package.json index cdffc775..f7736b4b 100644 --- a/package.json +++ b/package.json @@ -230,7 +230,7 @@ "lint": "vp lint .", "typecheck": "npm run test:types", "test": "npm run test:unit && npm run test:integrity && npm run test:jsdom && npm run test:browser", - "test:browser": "vp test run --config vitest.test.browser.config.ts", + "test:browser": "playwright test", "test:component": "npm run test:browser", "test:jsdom": "npm run build && vp test run --config vitest.test.jsdom.config.ts", "test:types": "tsc -p tsconfig.type-tests.json --noEmit", @@ -246,6 +246,7 @@ "devDependencies": { "@askrjs/askr": ">=0.2.3 <0.3.0", "@askrjs/vite": ">=0.2.1 <0.3.0", + "@playwright/test": "1.62.1", "@types/node": "^26.3.0", "@vitest/browser-playwright": "4.1.11", "axe-core": "^4.13.0", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 00000000..64646937 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,45 @@ +import { defineConfig, devices } from '@playwright/test'; + +const HOST = '127.0.0.1'; +const PORT = 4318; +const BASE_URL = `http://${HOST}:${PORT}`; + +export default defineConfig({ + testDir: './tests/browser', + testMatch: '**/*.spec.ts', + fullyParallel: true, + forbidOnly: Boolean(process.env.CI), + retries: process.env.CI ? 1 : 0, + reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : 'list', + timeout: 60_000, + expect: { timeout: 10_000 }, + use: { + baseURL: BASE_URL, + trace: 'retain-on-failure', + viewport: { width: 1280, height: 900 }, + }, + projects: [ + { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, + { name: 'firefox', use: { ...devices['Desktop Firefox'] } }, + { name: 'webkit', use: { ...devices['Desktop Safari'] } }, + ], + webServer: { + // `vp dev` rather than `vite`: vite-plus aliases the `vite` package to a + // core package that ships no CLI binary, and installing a real `vite` + // alongside it makes `vp pack` refuse to run at all. + // + // `--strictPort` turns a port collision into an immediate failure instead + // of a server listening on a port nobody is polling. + // + // `--host 127.0.0.1` must match `webServer.url`: Vite otherwise binds + // `localhost`, which CI runners can resolve to `::1` while Playwright's + // readiness probe polls the literal IPv4 address — a silent startup + // timeout that passes locally. + command: `npx vp dev --config vite.harness.config.ts --host ${HOST} --port ${PORT} --strictPort`, + url: `${BASE_URL}/tests/browser/harness.html`, + reuseExistingServer: !process.env.CI, + stdout: 'pipe', + stderr: 'pipe', + timeout: 120_000, + }, +}); diff --git a/tests/browser/assertions.ts b/tests/browser/assertions.ts new file mode 100644 index 00000000..33d6790b --- /dev/null +++ b/tests/browser/assertions.ts @@ -0,0 +1,30 @@ +import { expect, test } from '@playwright/test'; + +/** + * One `(label, first, second)` triple produced by `deterministicRender` in a + * scenario module. The render pair has to cross the Node/browser boundary as + * data because the JSX factory it compares cannot. + */ +export interface DeterministicRender { + label: string; + first: string; + second: string; +} + +/** + * Asserts every render pair a determinism scenario produced is stable. + * + * Each pair gets its own `test.step`, which is how the label survives the port: + * vitest's `expect(actual, message)` has no Playwright equivalent, so the step + * name is what names the failing tree in the report. + */ +export async function expectDeterministic( + renders: DeterministicRender[] +): Promise { + expect(renders.length).toBeGreaterThan(0); + for (const render of renders) { + await test.step(`deterministic render: ${render.label}`, () => { + expect(render.first).toBe(render.second); + }); + } +} diff --git a/tests/browser/components/accordion/a11y.spec.ts b/tests/browser/components/accordion/a11y.spec.ts new file mode 100644 index 00000000..52b75227 --- /dev/null +++ b/tests/browser/components/accordion/a11y.spec.ts @@ -0,0 +1,12 @@ +import { expect, test } from '../../fixtures'; + +test.describe('Accordion - Accessibility', () => { + test('should have no automated axe violations given open accordion item', async ({ + render, + axeViolations, + }) => { + await render('axeOpenItem'); + + expect(await axeViolations()).toEqual([]); + }); +}); diff --git a/tests/browser/components/accordion/determinism.spec.ts b/tests/browser/components/accordion/determinism.spec.ts new file mode 100644 index 00000000..af5fe07f --- /dev/null +++ b/tests/browser/components/accordion/determinism.spec.ts @@ -0,0 +1,13 @@ +import { type DeterministicRender, expectDeterministic } from '../../assertions'; +import { test } from '../../fixtures'; + +test.describe('Accordion - Determinism', () => { + test('should render deterministic accordion markup', async ({ + render, + run, + }) => { + await render('accordionMarkup'); + + await expectDeterministic(await run('renders')); + }); +}); diff --git a/tests/browser/components/avatar/a11y.spec.ts b/tests/browser/components/avatar/a11y.spec.ts new file mode 100644 index 00000000..e16098ad --- /dev/null +++ b/tests/browser/components/avatar/a11y.spec.ts @@ -0,0 +1,12 @@ +import { expect, test } from '../../fixtures'; + +test.describe('Avatar - Accessibility', () => { + test('should have no automated axe violations given image and fallback', async ({ + render, + axeViolations, + }) => { + await render('axeImageAndFallback'); + + expect(await axeViolations()).toEqual([]); + }); +}); diff --git a/tests/browser/components/avatar/behavior.spec.ts b/tests/browser/components/avatar/behavior.spec.ts new file mode 100644 index 00000000..565dfe7d --- /dev/null +++ b/tests/browser/components/avatar/behavior.spec.ts @@ -0,0 +1,18 @@ +import { expect, test } from '../../fixtures'; + +test.describe('Avatar - Behavior', () => { + test('should keep fallback visible until image load event', async ({ + render, + root, + run, + }) => { + await render('fallbackUntilLoad'); + const fallbackSelector = await run('fallbackSelector'); + + await expect(root.locator(fallbackSelector)).toHaveText('JD'); + + await run('dispatchLoad'); + + await expect(root.locator(fallbackSelector)).toHaveCount(0); + }); +}); diff --git a/tests/browser/components/avatar/determinism.spec.ts b/tests/browser/components/avatar/determinism.spec.ts new file mode 100644 index 00000000..57923b3e --- /dev/null +++ b/tests/browser/components/avatar/determinism.spec.ts @@ -0,0 +1,10 @@ +import { type DeterministicRender, expectDeterministic } from '../../assertions'; +import { test } from '../../fixtures'; + +test.describe('Avatar - Determinism', () => { + test('should render deterministic avatar markup', async ({ render, run }) => { + await render('avatarMarkup'); + + await expectDeterministic(await run('renders')); + }); +}); diff --git a/tests/browser/components/button/a11y.spec.ts b/tests/browser/components/button/a11y.spec.ts new file mode 100644 index 00000000..eec8494f --- /dev/null +++ b/tests/browser/components/button/a11y.spec.ts @@ -0,0 +1,72 @@ +import { expect, test } from '../../fixtures'; + +test.describe('Button - Accessibility', () => { + test('should have no automated axe violations for the native button path', async ({ + render, + axeViolations, + }) => { + await render('axeNative'); + + expect(await axeViolations()).toEqual([]); + }); + + test('should have no automated axe violations for labelled asChild composition', async ({ + render, + axeViolations, + }) => { + await render('axeAsChild'); + + expect(await axeViolations()).toEqual([]); + }); + + test('should use native disabled semantics for the default host', async ({ + render, + root, + }) => { + await render('nativeDisabled'); + const button = root.locator('button'); + + expect( + await button.evaluate((node: HTMLButtonElement) => node.disabled) + ).toBe(true); + await expect(button).toHaveAttribute('disabled'); + }); + + test('should use aria-disabled and remove disabled asChild hosts from tab order', async ({ + render, + root, + }) => { + await render('asChildDisabled'); + const link = root.locator('a'); + + await expect(link).toHaveAttribute('aria-disabled', 'true'); + await expect(link).toHaveAttribute('tabindex', '-1'); + }); + + test('should preserve accessible naming props from the host', async ({ + render, + root, + }) => { + await render('accessibleNaming'); + + await expect(root.locator('button')).toHaveAttribute( + 'aria-labelledby', + 'button-label' + ); + }); + + test('should match the documented button accessibility contract', async ({ + render, + run, + }) => { + await render('contract'); + const contract = await run>('contract'); + + expect(contract.ROLE).toBe('button'); + expect(contract.KEYBOARD_ACTIVATION).toEqual(['Enter', 'Space']); + expect(contract.DISABLED_ATTRIBUTES).toEqual({ + native: 'disabled', + asChild: 'aria-disabled', + }); + }); +}); diff --git a/tests/browser/components/button/behavior.spec.ts b/tests/browser/components/button/behavior.spec.ts new file mode 100644 index 00000000..ae78e38b --- /dev/null +++ b/tests/browser/components/button/behavior.spec.ts @@ -0,0 +1,191 @@ +import { expect, test } from '../../fixtures'; + +interface IconState { + svgCount: number; + icon: string | null; +} + +interface IconClickState extends IconState { + pressCount: number; + sameButton: boolean; +} + +test.describe('Button - Behavior', () => { + test('should render a native button with the default button type', async ({ + render, + root, + }) => { + await render('nativeDefault'); + const button = root.locator('button'); + + await expect(button).toHaveCount(1); + await expect(button).toHaveAttribute('type', 'button'); + await expect(button).toHaveAttribute('data-slot', 'button'); + }); + + test('should invoke onPress and merge host props for native buttons', async ({ + render, + root, + run, + }) => { + await render('nativePress'); + const button = root.locator('button'); + + await expect(button).toHaveAttribute('data-testid', 'primary-action'); + await expect(button).toHaveAttribute('aria-label', 'Save'); + + await button.evaluate((node: HTMLElement) => node.click()); + + expect(await run('pressCount')).toBe(1); + }); + + test('should prevent native interaction when disabled', async ({ + render, + root, + run, + }) => { + await render('disabledNative'); + const button = root.locator('button'); + + expect( + await button.evaluate((node: HTMLButtonElement) => node.disabled) + ).toBe(true); + await expect(button).toHaveAttribute('aria-disabled', 'true'); + + // A programmatic `.click()`, as the vitest original used: Playwright's + // `locator.click()` would wait for the button to become enabled instead. + await button.evaluate((node: HTMLElement) => node.click()); + + expect(await run('pressCount')).toBe(0); + }); + + test('should support asChild hosts with composed props and disabled semantics', async ({ + render, + root, + run, + }) => { + await render('asChildDisabledLink'); + const link = root.locator('a'); + + await expect(link).toHaveCount(1); + await expect(link).toHaveAttribute('data-from-button', 'yes'); + await expect(link).toHaveAttribute('data-from-child', 'yes'); + await expect(link).toHaveAttribute('aria-disabled', 'true'); + await expect(link).toHaveAttribute('tabindex', '-1'); + await expect(link).not.toHaveAttribute('role'); + + expect(await run('dispatchEnter')).toBe(true); + + await run('click'); + + expect(await run('pressCount')).toBe(0); + }); + + test('should preserve native anchor semantics given an enabled asChild link', async ({ + render, + root, + run, + }) => { + await render('asChildEnabledLink'); + const link = root.locator('a'); + + const defaultPrevented = await run('dispatchEnter'); + + await expect(link).not.toHaveAttribute('role'); + await expect(link).not.toHaveAttribute('tabindex'); + expect(defaultPrevented).toBe(false); + + await run('clickWithoutNavigation'); + + expect(await run('pressCount')).toBe(1); + }); + + test('should preserve native anchor semantics given an Askr Link child', async ({ + render, + root, + run, + }) => { + await render('asChildAskrLink'); + const link = root.locator('a'); + + const defaultPrevented = await run('dispatchEnter'); + + await expect(link).not.toHaveAttribute('role'); + expect(defaultPrevented).toBe(false); + }); + + test('should use native semantics given a button asChild host', async ({ + render, + root, + run, + }) => { + await render('asChildNativeButton'); + const button = root.locator('button'); + + expect( + await button.evaluate((node: HTMLButtonElement) => node.disabled) + ).toBe(true); + await expect(button).not.toHaveAttribute('role'); + + await run('click'); + + expect(await run('pressCount')).toBe(0); + }); + + test('should retain synthetic button semantics given a non-native asChild host', async ({ + render, + root, + run, + }) => { + await render('asChildSyntheticHost'); + const host = root.locator('span'); + + await expect(host).toHaveAttribute('role', 'button'); + await expect(host).toHaveAttribute('tabindex', '0'); + + await run('dispatchEnter'); + + expect(await run('pressCount')).toBe(1); + }); + + test('should activate a non-native asChild host with Enter and Space', async ({ + page, + render, + run, + }) => { + await render('asChildKeyboardActivation'); + + await run('focusHost'); + await page.keyboard.press('Enter'); + await run('focusHost'); + await page.keyboard.press(' '); + + expect(await run('pressCount')).toBe(2); + }); + + test('should replace stateful icon children instead of accumulating them', async ({ + render, + run, + }) => { + await render('statefulIconChildren'); + + expect(await run('initial')).toEqual({ + svgCount: 1, + icon: 'sun', + }); + + expect(await run('clickAndRead')).toEqual({ + pressCount: 1, + sameButton: true, + svgCount: 1, + icon: 'moon', + }); + + expect(await run('clickAndRead')).toEqual({ + pressCount: 2, + sameButton: true, + svgCount: 1, + icon: 'sun', + }); + }); +}); diff --git a/tests/browser/components/button/determinism.spec.ts b/tests/browser/components/button/determinism.spec.ts new file mode 100644 index 00000000..52d3acc4 --- /dev/null +++ b/tests/browser/components/button/determinism.spec.ts @@ -0,0 +1,52 @@ +import { type DeterministicRender, expectDeterministic } from '../../assertions'; +import { expect, test } from '../../fixtures'; + +test.describe('Button - Determinism', () => { + test('should render deterministic native button markup', async ({ + render, + run, + }) => { + await render('nativeMarkup'); + + await expectDeterministic(await run('renders')); + }); + + test('should map typed size and width affordances to stable data attributes', async ({ + render, + root, + }) => { + await render('sizeAndWidthAttributes'); + const button = root.locator('[data-slot="button"]'); + + await expect(button).toHaveAttribute('data-size', 'icon-xs'); + await expect(button).toHaveAttribute('data-width', 'full'); + }); + + test('should render deterministic asChild markup', async ({ + render, + run, + }) => { + await render('asChildMarkup'); + + await expectDeterministic(await run('renders')); + }); + + test('should keep behavior props-driven across remounts', async ({ + render, + run, + }) => { + await render('behaviorAcrossRemounts'); + + expect( + await run<{ first: number; second: number }>('pressCounts') + ).toEqual({ first: 1, second: 1 }); + }); + + test('should not schedule timers during render', async ({ render, run }) => { + await render('timersDuringRender'); + + expect( + await run<{ timeouts: number; intervals: number }>('scheduled') + ).toEqual({ timeouts: 0, intervals: 0 }); + }); +}); diff --git a/tests/browser/components/collapsible/a11y.spec.ts b/tests/browser/components/collapsible/a11y.spec.ts new file mode 100644 index 00000000..21b160ed --- /dev/null +++ b/tests/browser/components/collapsible/a11y.spec.ts @@ -0,0 +1,167 @@ +import { expect, test } from '../../fixtures'; + +interface CollapsibleContract { + EXPANDED_ATTRIBUTE: string; + CONTROLS_ATTRIBUTE: string; +} + +test.describe('Collapsible — Accessibility', () => { + test.describe('Automated Axe Checks', () => { + test('should have no automated axe violations given closed collapsible', async ({ + render, + axeViolations, + }) => { + await render('axeClosed'); + + expect(await axeViolations()).toEqual([]); + }); + + test('should have no automated axe violations given open collapsible', async ({ + render, + axeViolations, + }) => { + await render('axeOpen'); + + expect(await axeViolations()).toEqual([]); + }); + + test('should have no automated axe violations given disabled collapsible', async ({ + render, + axeViolations, + }) => { + await render('axeDisabled'); + + expect(await axeViolations()).toEqual([]); + }); + }); + + test.describe('ARIA Contract Enforcement', () => { + test('should apply aria-expanded=false when closed', async ({ + render, + root, + run, + }) => { + await render('expandedClosed'); + const contract = await run('contract'); + + await expect(root.locator('button')).toHaveAttribute( + contract.EXPANDED_ATTRIBUTE, + 'false' + ); + }); + + test('should apply aria-expanded=true when open', async ({ + render, + root, + run, + }) => { + await render('expandedOpen'); + const contract = await run('contract'); + + await expect(root.locator('button')).toHaveAttribute( + contract.EXPANDED_ATTRIBUTE, + 'true' + ); + }); + + test('should apply aria-controls to trigger', async ({ render, run }) => { + await render('ariaControls'); + const ids = await run<{ controlsId?: string; contentId?: string }>('ids'); + + expect(ids.controlsId).toBeDefined(); + expect(ids.contentId).toBe(ids.controlsId); + }); + + test('should apply id to content', async ({ render, root }) => { + await render('contentId'); + const content = root.locator('[id^="collapsible-content"]'); + + await expect(content).toHaveAttribute( + 'id', + /^collapsible-content-[a-z0-9-]+$/ + ); + }); + + test('should have button role on trigger', async ({ render, root }) => { + await render('triggerRole'); + + // Native buttons have implicit role='button' + await expect(root.locator('button')).toHaveCount(1); + }); + }); + + test.describe('Keyboard Navigation', () => { + test('should be focusable when not disabled', async ({ render, run }) => { + await render('focusableTrigger'); + + expect(await run('focusTrigger')).toBe(true); + }); + + test('should not be focusable when disabled', async ({ render, run }) => { + await render('disabledTriggerFocus'); + + // Disabled buttons cannot receive focus + expect(await run('focusTrigger')).toBe(false); + }); + }); + + test.describe('Focus Management', () => { + test('should keep focus on trigger after activation', async ({ + render, + run, + }) => { + await render('focusAfterActivation'); + + expect(await run('activateAndRefocus')).toBe(true); + }); + }); + + test.describe('Disabled State Semantics', () => { + test('should apply disabled attribute to native button', async ({ + render, + root, + }) => { + await render('disabledNativeButton'); + + await expect(root.locator('button')).toBeDisabled(); + }); + + test('should apply aria-disabled to asChild trigger', async ({ + render, + root, + }) => { + await render('disabledAsChild'); + + await expect(root.locator('[role="button"]')).toHaveAttribute( + 'aria-disabled', + 'true' + ); + }); + }); + + test.describe('Content Presence', () => { + test('should not render content in DOM when closed by default', async ({ + render, + root, + }) => { + await render('contentClosed'); + + await expect(root.locator('[id^="collapsible-content"]')).toHaveCount(0); + }); + + test('should render content in DOM when open', async ({ render, root }) => { + await render('contentOpen'); + + await expect(root.locator('[id^="collapsible-content"]')).toHaveCount(1); + }); + + test('should render content in DOM when forceMount', async ({ + render, + root, + }) => { + await render('contentForceMount'); + + await expect(root.locator('[id^="collapsible-content"]')).toHaveCount(1); + }); + }); +}); diff --git a/tests/browser/components/collapsible/behavior.spec.ts b/tests/browser/components/collapsible/behavior.spec.ts new file mode 100644 index 00000000..20a180d9 --- /dev/null +++ b/tests/browser/components/collapsible/behavior.spec.ts @@ -0,0 +1,257 @@ +import { expect, test } from '../../fixtures'; + +interface OpenChangeState { + calls: boolean[]; + text: string; +} + +test.describe('Collapsible — Behavior', () => { + test.describe('State Management', () => { + test('should not transfer pending focus to a recycled virtual row', async ({ + render, + run, + }) => { + await render('recycledVirtualRow'); + const result = await run<{ + firstContentId: string | null; + replacementRowId: string | null; + replacementContentId: string | null; + replacementFocused: boolean; + }>('recycle'); + + expect(result.firstContentId).not.toBeNull(); + expect(result.replacementRowId).toBe('row-b'); + expect(result.replacementContentId).not.toBe(result.firstContentId); + expect(result.replacementFocused).toBe(false); + }); + + test('should start closed given no defaultOpen', async ({ + render, + root, + }) => { + await render('startClosed'); + + // unmounted when closed + await expect(root.locator('#collapsible-content-1')).toHaveCount(0); + }); + + test('should start open given defaultOpen=true', async ({ + render, + root, + }) => { + await render('startOpen'); + + await expect(root).toContainText('Content'); + }); + + test('should toggle state given click on trigger', async ({ + render, + root, + }) => { + await render('toggleOnClick'); + const trigger = root.locator('button'); + + // Initially closed + await expect(root).not.toContainText('Content'); + + // Click to open + await trigger.evaluate((node: HTMLElement) => node.click()); + await expect(root).toContainText('Content'); + + // Click to close + await trigger.evaluate((node: HTMLElement) => node.click()); + await expect(root).not.toContainText('Content'); + }); + }); + + test.describe('Controlled Mode', () => { + test('should use controlled open state', async ({ render, root }) => { + await render('controlledOpen'); + + await expect(root).toContainText('Content'); + }); + + test('should call onOpenChange when trigger activated', async ({ + render, + root, + run, + }) => { + await render('controlledOpenChange'); + await root.locator('button').evaluate((node: HTMLElement) => node.click()); + + expect(await run('openChangeArgs')).toContainEqual(true); + }); + }); + + test.describe('Trigger Component', () => { + test('should render native button by default', async ({ render, root }) => { + await render('nativeTrigger'); + const button = root.locator('button'); + + await expect(button).toHaveCount(1); + await expect(button).toHaveText('Toggle'); + }); + + test('should set type=button on native button', async ({ + render, + root, + }) => { + await render('nativeTrigger'); + + await expect(root.locator('button')).toHaveAttribute('type', 'button'); + }); + + test('should support asChild rendering', async ({ render, root }) => { + await render('asChildTrigger'); + const span = root.locator('span'); + + await expect(span).toHaveCount(1); + await expect(span).toHaveText('Custom Trigger'); + }); + + test('should toggle exactly once with Enter and Space on native and asChild triggers', async ({ + page, + render, + run, + }) => { + await render('keyboardToggle'); + + await run('focusNative'); + await page.keyboard.press('Enter'); + await run('flush'); + + let native = await run('nativeState'); + expect(native.calls).toHaveLength(1); + expect(native.calls.at(-1)).toBe(true); + expect(native.text).toContain('Native content'); + + await run('focusNative'); + await page.keyboard.press(' '); + await run('flush'); + + native = await run('nativeState'); + expect(native.calls).toHaveLength(2); + expect(native.calls.at(-1)).toBe(false); + expect(native.text).not.toContain('Native content'); + + await run('mountChild'); + + await run('focusChild'); + await page.keyboard.press('Enter'); + await run('flush'); + + let child = await run('childState'); + expect(child.calls).toHaveLength(1); + expect(child.calls.at(-1)).toBe(true); + expect(child.text).toContain('Child content'); + + await run('focusChild'); + await page.keyboard.press(' '); + await run('flush'); + + child = await run('childState'); + expect(child.calls).toHaveLength(2); + expect(child.calls.at(-1)).toBe(false); + expect(child.text).not.toContain('Child content'); + }); + + test('should preserve button styling props when trigger composes as child', async ({ + render, + root, + }) => { + await render('buttonComposition'); + const trigger = root.locator('button'); + + await expect(trigger).toHaveAttribute('data-slot', 'button'); + await expect(trigger).toHaveAttribute('data-collapsible-trigger', 'true'); + await expect(trigger).toHaveAttribute('data-variant', 'ghost'); + await expect(trigger).toHaveAttribute('data-size', 'sm'); + + await trigger.evaluate((node: HTMLElement) => node.click()); + + await expect(root).toContainText('Policy detail'); + }); + }); + + test.describe('Content Component', () => { + test('should unmount when closed by default', async ({ render, root }) => { + await render('contentClosedByDefault'); + + await expect(root.locator('[id^="collapsible-content"]')).toHaveCount(0); + }); + + test('should mount when open', async ({ render, root }) => { + await render('contentMountedWhenOpen'); + const content = root.locator('[id^="collapsible-content"]'); + + await expect(content).toHaveCount(1); + await expect(content).toHaveText('Content'); + }); + + test('should force mount when forceMount=true', async ({ + render, + root, + }) => { + await render('contentForceMount'); + const content = root.locator('[id^="collapsible-content"]'); + + await expect(content).toHaveCount(1); + await expect(content).toHaveText('Content'); + }); + + test('should support asChild rendering', async ({ render, root }) => { + await render('asChildContent'); + const section = root.locator('section'); + + await expect(section).toHaveCount(1); + await expect(section).toHaveText('Custom Content'); + }); + }); + + test.describe('Disabled State', () => { + test('should not toggle when disabled', async ({ render, root, run }) => { + await render('disabledToggle'); + await root.locator('button').evaluate((node: HTMLElement) => node.click()); + + expect(await run('openChangeCount')).toBe(0); + }); + }); + + test.describe('Context Requirements', () => { + test('should throw when Trigger used without Collapsible', async ({ + render, + run, + }) => { + await render('orphanTrigger'); + + expect(await run('message')).toContain( + 'Collapsible components must be used within ' + ); + }); + + test('should throw when Content used without Collapsible', async ({ + render, + run, + }) => { + await render('orphanContent'); + + expect(await run('message')).toContain( + 'Collapsible components must be used within ' + ); + }); + }); + + test.describe('Unique IDs', () => { + test('should generate unique IDs for multiple collapsibles', async ({ + render, + run, + }) => { + await render('uniqueIds'); + const ids = await run('ids'); + + expect(ids).toHaveLength(2); + // All unique + expect(new Set(ids).size).toBe(2); + }); + }); +}); diff --git a/tests/browser/components/collapsible/determinism.spec.ts b/tests/browser/components/collapsible/determinism.spec.ts new file mode 100644 index 00000000..f02d0234 --- /dev/null +++ b/tests/browser/components/collapsible/determinism.spec.ts @@ -0,0 +1,162 @@ +import { type DeterministicRender, expectDeterministic } from '../../assertions'; +import { expect, test } from '../../fixtures'; + +test.describe('Collapsible — Determinism', () => { + test.describe('Render Determinism', () => { + test('should produce identical output given identical props (closed)', async ({ + render, + run, + }) => { + await render('closedMarkup'); + + await expectDeterministic(await run('renders')); + }); + + test('should produce identical output given identical props (open)', async ({ + render, + run, + }) => { + await render('openMarkup'); + + await expectDeterministic(await run('renders')); + }); + + test('should produce identical output given identical props (disabled)', async ({ + render, + run, + }) => { + await render('disabledMarkup'); + + await expectDeterministic(await run('renders')); + }); + }); + + test.describe('State Transition Determinism', () => { + test('should consistently reflect state changes', async ({ + render, + root, + }) => { + await render('stateTransitions'); + const trigger = root.locator('button'); + + await expect(trigger).toHaveAttribute('aria-expanded', 'false'); + + await trigger.evaluate((node: HTMLElement) => node.click()); + await expect(trigger).toHaveAttribute('aria-expanded', 'true'); + + await trigger.evaluate((node: HTMLElement) => node.click()); + await expect(trigger).toHaveAttribute('aria-expanded', 'false'); + + await trigger.evaluate((node: HTMLElement) => node.click()); + await expect(trigger).toHaveAttribute('aria-expanded', 'true'); + }); + }); + + test.describe('ARIA Attribute Presence', () => { + test('should always include aria-expanded on trigger', async ({ + render, + root, + }) => { + await render('ariaPresenceClosed'); + + await expect(root.locator('button')).toHaveAttribute('aria-expanded'); + }); + + test('should always include aria-controls on trigger', async ({ + render, + root, + }) => { + await render('ariaPresenceClosed'); + + await expect(root.locator('button')).toHaveAttribute('aria-controls'); + }); + + test('should always include id on content when mounted', async ({ + render, + root, + }) => { + await render('ariaPresenceOpen'); + + await expect( + root.locator('[id^="collapsible-content"]') + ).toHaveAttribute('id'); + }); + }); + + test.describe('Content Presence Determinism', () => { + test('should consistently mount and unmount content', async ({ + render, + root, + }) => { + await render('contentPresence'); + const trigger = root.locator('button'); + const content = root.locator('[id^="collapsible-content"]'); + + // Initially closed - content not in DOM + await expect(content).toHaveCount(0); + + // Open - content in DOM + await trigger.evaluate((node: HTMLElement) => node.click()); + await expect(content).toHaveCount(1); + + // Close - content removed from DOM + await trigger.evaluate((node: HTMLElement) => node.click()); + await expect(content).toHaveCount(0); + + // Reopen - content in DOM again + await trigger.evaluate((node: HTMLElement) => node.click()); + await expect(content).toHaveCount(1); + }); + + test('should keep content mounted when forceMount', async ({ + render, + root, + }) => { + await render('forceMountPresence'); + const trigger = root.locator('button'); + const content = root.locator('[id^="collapsible-content"]'); + + // Content in DOM even when closed + await expect(content).toHaveCount(1); + + // Open + await trigger.evaluate((node: HTMLElement) => node.click()); + await expect(content).toHaveCount(1); + + // Close again - still in DOM + await trigger.evaluate((node: HTMLElement) => node.click()); + await expect(content).toHaveCount(1); + }); + }); + + test.describe('ID Generation Determinism', () => { + test('should generate consistent IDs for same collapsible instance', async ({ + render, + run, + }) => { + await render('idGeneration'); + const ids = await run<{ + controlsId: string | null; + contentId: string; + contentIdAfter: string; + controlsIdAfter: string | null; + }>('ids'); + + // aria-controls should always match content id + expect(ids.controlsId).toBe(ids.contentId); + expect(ids.contentIdAfter).toBe(ids.contentId); + expect(ids.controlsIdAfter).toBe(ids.controlsId); + }); + }); + + test.describe('Children Stability', () => { + test('should render stable content given static children', async ({ + render, + run, + }) => { + await render('stableChildren'); + + await expectDeterministic(await run('renders')); + }); + }); +}); diff --git a/tests/browser/components/dialog/a11y.spec.ts b/tests/browser/components/dialog/a11y.spec.ts new file mode 100644 index 00000000..8b69bbd8 --- /dev/null +++ b/tests/browser/components/dialog/a11y.spec.ts @@ -0,0 +1,12 @@ +import { expect, test } from '../../fixtures'; + +test.describe('Dialog - Accessibility', () => { + test('should have no automated axe violations given open dialog', async ({ + render, + axeViolations, + }) => { + await render('axeOpenDialog'); + + expect(await axeViolations()).toEqual([]); + }); +}); diff --git a/tests/browser/components/dialog/determinism.spec.ts b/tests/browser/components/dialog/determinism.spec.ts new file mode 100644 index 00000000..b1bb5763 --- /dev/null +++ b/tests/browser/components/dialog/determinism.spec.ts @@ -0,0 +1,10 @@ +import { type DeterministicRender, expectDeterministic } from '../../assertions'; +import { test } from '../../fixtures'; + +test.describe('Dialog - Determinism', () => { + test('should render deterministic dialog markup', async ({ render, run }) => { + await render('dialogMarkup'); + + await expectDeterministic(await run('renders')); + }); +}); diff --git a/tests/browser/components/dropdown/a11y.spec.ts b/tests/browser/components/dropdown/a11y.spec.ts new file mode 100644 index 00000000..95f93225 --- /dev/null +++ b/tests/browser/components/dropdown/a11y.spec.ts @@ -0,0 +1,12 @@ +import { expect, test } from '../../fixtures'; + +test.describe('Dropdown - Accessibility', () => { + test('should have no automated axe violations given open dropdown', async ({ + render, + axeViolations, + }) => { + await render('axeOpenDropdown'); + + expect(await axeViolations()).toEqual([]); + }); +}); diff --git a/tests/browser/components/dropdown/determinism.spec.ts b/tests/browser/components/dropdown/determinism.spec.ts new file mode 100644 index 00000000..1f6f2509 --- /dev/null +++ b/tests/browser/components/dropdown/determinism.spec.ts @@ -0,0 +1,13 @@ +import { type DeterministicRender, expectDeterministic } from '../../assertions'; +import { test } from '../../fixtures'; + +test.describe('Dropdown - Determinism', () => { + test('should render deterministic dropdown markup', async ({ + render, + run, + }) => { + await render('dropdownMarkup'); + + await expectDeterministic(await run('renders')); + }); +}); diff --git a/tests/browser/components/focus-scope/a11y.spec.ts b/tests/browser/components/focus-scope/a11y.spec.ts new file mode 100644 index 00000000..d6b9984f --- /dev/null +++ b/tests/browser/components/focus-scope/a11y.spec.ts @@ -0,0 +1,12 @@ +import { expect, test } from '../../fixtures'; + +test.describe('FocusScope - Accessibility', () => { + test('should have no automated axe violations given scoped focusables', async ({ + render, + axeViolations, + }) => { + await render('axeScopedFocusables'); + + expect(await axeViolations()).toEqual([]); + }); +}); diff --git a/tests/browser/components/focus-scope/behavior.spec.ts b/tests/browser/components/focus-scope/behavior.spec.ts new file mode 100644 index 00000000..2212796e --- /dev/null +++ b/tests/browser/components/focus-scope/behavior.spec.ts @@ -0,0 +1,34 @@ +import { expect, test } from '../../fixtures'; + +test.describe('FocusScope - Behavior', () => { + test('should support manual focus inside the scope without breaking the focus target', async ({ + render, + run, + }) => { + await render('manualFocusInsideScope'); + + // Browser-side identity comparison: the original asserted against + // `document.activeElement`, and DOM nodes cannot cross into the spec. + expect( + await run<{ focusedFirst: boolean; focusedTrigger: boolean }>('focus') + ).toEqual({ focusedFirst: true, focusedTrigger: false }); + }); + + test('should wrap keyboard focus when loop is enabled', async ({ + render, + run, + }) => { + await render('loopWrapsFocus'); + + expect(await run('focusedFirst')).toBe(true); + }); + + test('should keep focus trapped within scope on focus-out when trapped is enabled', async ({ + render, + run, + }) => { + await render('trappedFocusOut'); + + expect(await run('focusedInside')).toBe(true); + }); +}); diff --git a/tests/browser/components/focus-scope/determinism.spec.ts b/tests/browser/components/focus-scope/determinism.spec.ts new file mode 100644 index 00000000..91928d40 --- /dev/null +++ b/tests/browser/components/focus-scope/determinism.spec.ts @@ -0,0 +1,13 @@ +import { type DeterministicRender, expectDeterministic } from '../../assertions'; +import { test } from '../../fixtures'; + +test.describe('FocusScope - Determinism', () => { + test('should render deterministic focus scope markup', async ({ + render, + run, + }) => { + await render('focusScopeMarkup'); + + await expectDeterministic(await run('renders')); + }); +}); diff --git a/tests/browser/components/form/a11y.spec.ts b/tests/browser/components/form/a11y.spec.ts new file mode 100644 index 00000000..3b314903 --- /dev/null +++ b/tests/browser/components/form/a11y.spec.ts @@ -0,0 +1,12 @@ +import { expect, test } from '../../fixtures'; + +test.describe('Form - Accessibility', () => { + test('should have no automated axe violations given labelled controls', async ({ + render, + axeViolations, + }) => { + await render('axeLabelledControls'); + + expect(await axeViolations()).toEqual([]); + }); +}); diff --git a/tests/browser/components/form/behavior.spec.ts b/tests/browser/components/form/behavior.spec.ts new file mode 100644 index 00000000..a74fefc0 --- /dev/null +++ b/tests/browser/components/form/behavior.spec.ts @@ -0,0 +1,49 @@ +import { expect, test } from '../../fixtures'; + +test.describe('Form - Behavior', () => { + test('should render a canonical form surface by default', async ({ + render, + root, + }) => { + await render('canonicalSurface'); + const form = root.locator('form'); + + await expect(form).toHaveAttribute('data-slot', 'form'); + await expect(form).toHaveAttribute('method', 'post'); + }); + + test('should support asChild composition for non-form hosts', async ({ + render, + root, + }) => { + await render('asChildComposition'); + + await expect(root.locator('section')).toHaveAttribute('data-slot', 'form'); + }); + + test('should submit and reset native controls given Form defaults when submit and reset events occur', async ({ + render, + run, + }) => { + await render('submitAndReset'); + await run('dispatch'); + + expect(await run<{ submit: number; reset: number }>('counts')).toEqual({ + submit: 1, + reset: 1, + }); + }); + + test('should preserve form attributes given Form asChild when method, action, target, and encoding props are supplied', async ({ + render, + root, + }) => { + await render('asChildFormAttributes'); + const section = root.locator('section'); + + await expect(section).toHaveAttribute('method', 'post'); + await expect(section).toHaveAttribute('action', '/save'); + await expect(section).toHaveAttribute('target', '_blank'); + await expect(section).toHaveAttribute('enctype', 'multipart/form-data'); + }); +}); diff --git a/tests/browser/components/form/determinism.spec.ts b/tests/browser/components/form/determinism.spec.ts new file mode 100644 index 00000000..3a127fce --- /dev/null +++ b/tests/browser/components/form/determinism.spec.ts @@ -0,0 +1,10 @@ +import { type DeterministicRender, expectDeterministic } from '../../assertions'; +import { test } from '../../fixtures'; + +test.describe('Form - Determinism', () => { + test('should render deterministic form markup', async ({ render, run }) => { + await render('formMarkup'); + + await expectDeterministic(await run('renders')); + }); +}); diff --git a/tests/browser/components/hover-card/a11y.spec.ts b/tests/browser/components/hover-card/a11y.spec.ts new file mode 100644 index 00000000..c7b1257b --- /dev/null +++ b/tests/browser/components/hover-card/a11y.spec.ts @@ -0,0 +1,12 @@ +import { expect, test } from '../../fixtures'; + +test.describe('HoverCard - Accessibility', () => { + test('should have no automated axe violations given interactive content', async ({ + render, + axeViolations, + }) => { + await render('axeInteractiveContent'); + + expect(await axeViolations()).toEqual([]); + }); +}); diff --git a/tests/browser/components/hover-card/determinism.spec.ts b/tests/browser/components/hover-card/determinism.spec.ts new file mode 100644 index 00000000..2af90a8a --- /dev/null +++ b/tests/browser/components/hover-card/determinism.spec.ts @@ -0,0 +1,13 @@ +import { type DeterministicRender, expectDeterministic } from '../../assertions'; +import { test } from '../../fixtures'; + +test.describe('HoverCard - Determinism', () => { + test('should render deterministic trigger markup without scheduling timers', async ({ + render, + run, + }) => { + await render('triggerMarkup'); + + await expectDeterministic(await run('renders')); + }); +}); diff --git a/tests/browser/components/label/a11y.spec.ts b/tests/browser/components/label/a11y.spec.ts new file mode 100644 index 00000000..f03f8e4c --- /dev/null +++ b/tests/browser/components/label/a11y.spec.ts @@ -0,0 +1,39 @@ +import { expect, test } from '../../fixtures'; + +test.describe('Label - Accessibility', () => { + test('should have no automated axe violations given labelled form control', async ({ + render, + axeViolations, + }) => { + await render('axeLabelledControl'); + + expect(await axeViolations()).toEqual([]); + }); + + test('should preserve htmlFor linkage on native label', async ({ + render, + run, + }) => { + await render('htmlForLinkage'); + + expect(await run('association')).toBe('email'); + }); + + test('should match the documented label accessibility contract', async ({ + render, + run, + }) => { + await render('contract'); + const contract = await run>('contract'); + + expect(contract.ELEMENT).toBe('label'); + expect(contract.ASSOCIATION_ATTRIBUTE).toBe('for'); + expect(contract.DATA_ATTRIBUTES).toEqual({ + slot: 'data-slot', + }); + expect(contract.AS_CHILD).toEqual({ + forwardsProps: true, + preservesChildElement: true, + }); + }); +}); diff --git a/tests/browser/components/label/behavior.spec.ts b/tests/browser/components/label/behavior.spec.ts new file mode 100644 index 00000000..14cbe65a --- /dev/null +++ b/tests/browser/components/label/behavior.spec.ts @@ -0,0 +1,27 @@ +import { expect, test } from '../../fixtures'; + +test.describe('Label - Behavior', () => { + test('should render a native label by default', async ({ render, root }) => { + await render('nativeDefault'); + const label = root.locator('label'); + + await expect(label).toHaveCount(1); + await expect(label).toHaveText('Email'); + await expect(label).toHaveAttribute('for', 'email'); + await expect(label).toHaveAttribute('data-slot', 'label'); + }); + + test('should support asChild composition and merge host props', async ({ + render, + root, + }) => { + await render('asChildComposition'); + const span = root.locator('span'); + + await expect(span).toHaveText('Email'); + await expect(span).toHaveAttribute('data-testid', 'email-label'); + await expect(span).toHaveAttribute('data-from-label', 'yes'); + await expect(span).toHaveAttribute('data-from-child', 'yes'); + await expect(span).toHaveAttribute('data-slot', 'label'); + }); +}); diff --git a/tests/browser/components/label/determinism.spec.ts b/tests/browser/components/label/determinism.spec.ts new file mode 100644 index 00000000..4cda8b15 --- /dev/null +++ b/tests/browser/components/label/determinism.spec.ts @@ -0,0 +1,22 @@ +import { type DeterministicRender, expectDeterministic } from '../../assertions'; +import { test } from '../../fixtures'; + +test.describe('Label - Determinism', () => { + test('should render deterministic native label markup', async ({ + render, + run, + }) => { + await render('nativeMarkup'); + + await expectDeterministic(await run('renders')); + }); + + test('should render deterministic asChild label markup', async ({ + render, + run, + }) => { + await render('asChildMarkup'); + + await expectDeterministic(await run('renders')); + }); +}); diff --git a/tests/browser/components/menubar/a11y.spec.ts b/tests/browser/components/menubar/a11y.spec.ts new file mode 100644 index 00000000..7607d96e --- /dev/null +++ b/tests/browser/components/menubar/a11y.spec.ts @@ -0,0 +1,14 @@ +import { expect, test } from '../../fixtures'; + +test.describe('Menubar - Accessibility', () => { + test('should have no automated axe violations given open menubar content', async ({ + render, + run, + axeViolations, + }) => { + await render('axeOpenMenubar'); + await run('openFileMenu'); + + expect(await axeViolations()).toEqual([]); + }); +}); diff --git a/tests/browser/components/menubar/determinism.spec.ts b/tests/browser/components/menubar/determinism.spec.ts new file mode 100644 index 00000000..895c43b9 --- /dev/null +++ b/tests/browser/components/menubar/determinism.spec.ts @@ -0,0 +1,10 @@ +import { type DeterministicRender, expectDeterministic } from '../../assertions'; +import { test } from '../../fixtures'; + +test.describe('Menubar - Determinism', () => { + test('should render deterministic menubar markup', async ({ render, run }) => { + await render('menubarMarkup'); + + await expectDeterministic(await run('renders')); + }); +}); diff --git a/tests/browser/components/native-control/behavior.spec.ts b/tests/browser/components/native-control/behavior.spec.ts new file mode 100644 index 00000000..b6874e57 --- /dev/null +++ b/tests/browser/components/native-control/behavior.spec.ts @@ -0,0 +1,40 @@ +import { expect, test } from '../../fixtures'; + +test.describe('Native control styling', () => { + test('should mark representative native button fallbacks without inline styles', async ({ + render, + run, + }) => { + await render('nativeFallbacks'); + + expect( + await run< + Record + >('controls') + ).toEqual({ + button: { nativeControl: 'true', style: null }, + 'menu-item': { nativeControl: 'true', style: null }, + 'select-trigger': { nativeControl: 'true', style: null }, + toggle: { nativeControl: 'true', style: null }, + }); + }); + + test('should preserve caller font overrides and leave asChild typography untouched', async ({ + render, + run, + }) => { + await render('fontOverrides'); + + expect( + await run<{ + overrideFontSize: string; + asChildFontSize: string; + asChildFontShorthand: string; + }>('typography') + ).toEqual({ + overrideFontSize: '21px', + asChildFontSize: '19px', + asChildFontShorthand: '', + }); + }); +}); diff --git a/tests/browser/components/overlay-host/a11y.spec.ts b/tests/browser/components/overlay-host/a11y.spec.ts new file mode 100644 index 00000000..80f856c2 --- /dev/null +++ b/tests/browser/components/overlay-host/a11y.spec.ts @@ -0,0 +1,12 @@ +import { expect, test } from '../../fixtures'; + +test.describe('OverlayHost - Accessibility', () => { + test('should preserve the accessibility of a hosted overlay', async ({ + render, + axeViolations, + }) => { + await render('axeHostedOverlay'); + + expect(await axeViolations()).toEqual([]); + }); +}); diff --git a/tests/browser/components/overlay-host/behavior.spec.ts b/tests/browser/components/overlay-host/behavior.spec.ts new file mode 100644 index 00000000..4a9329e3 --- /dev/null +++ b/tests/browser/components/overlay-host/behavior.spec.ts @@ -0,0 +1,33 @@ +import { expect, test } from '../../fixtures'; + +test.describe('OverlayHost - Behavior', () => { + test('should retain independent portal channels when one overlay closes', async ({ + render, + run, + }) => { + await render('independentPortalChannels'); + + expect(await run('contentCount')).toBe(2); + + await run('closeFirst'); + + expect( + await run<{ first: boolean; second: boolean }>('overlays') + ).toEqual({ first: false, second: true }); + }); + + test('should remove hosted portal content when the application host unmounts', async ({ + render, + run, + }) => { + await render('hostUnmount'); + + expect(await run('hasHostedContent')).toBe(true); + + await run('unmountHost'); + + expect(await run('hasHostedContent')).toBe(false); + + await run('mountReplacement'); + }); +}); diff --git a/tests/browser/components/overlay-host/determinism.spec.ts b/tests/browser/components/overlay-host/determinism.spec.ts new file mode 100644 index 00000000..575b8a23 --- /dev/null +++ b/tests/browser/components/overlay-host/determinism.spec.ts @@ -0,0 +1,13 @@ +import { type DeterministicRender, expectDeterministic } from '../../assertions'; +import { test } from '../../fixtures'; + +test.describe('OverlayHost - Determinism', () => { + test('should render deterministic hosted overlay markup', async ({ + render, + run, + }) => { + await render('hostedOverlayMarkup'); + + await expectDeterministic(await run('renders')); + }); +}); diff --git a/tests/browser/components/popover/a11y.spec.ts b/tests/browser/components/popover/a11y.spec.ts new file mode 100644 index 00000000..cdea524e --- /dev/null +++ b/tests/browser/components/popover/a11y.spec.ts @@ -0,0 +1,48 @@ +import { expect, test } from '../../fixtures'; + +interface Labeling { + hasTrigger: boolean; + hasContent: boolean; + triggerId: string; + contentLabelledBy: string | null; +} + +test.describe('Popover - Accessibility', () => { + test('should have no automated axe violations given default open popover', async ({ + render, + axeViolations, + }) => { + await render('axeDefaultOpen'); + + expect(await axeViolations()).toEqual([]); + }); + + test('should label dialog content from trigger by default', async ({ + render, + run, + }) => { + await render('triggerLabeling'); + const labeling = await run('labeling'); + + expect(labeling.hasTrigger).toBe(true); + expect(labeling.hasContent).toBe(true); + expect(labeling.triggerId).toBeTruthy(); + expect(labeling.contentLabelledBy).toBe(labeling.triggerId); + }); + + test('should allow explicit content labeling via aria-label', async ({ + render, + run, + }) => { + await render('explicitAriaLabel'); + const content = await run<{ + hasContent: boolean; + ariaLabel: string | null; + hasLabelledBy: boolean; + }>('content'); + + expect(content.hasContent).toBe(true); + expect(content.ariaLabel).toBe('Popover content'); + expect(content.hasLabelledBy).toBe(false); + }); +}); diff --git a/tests/browser/components/popover/determinism.spec.ts b/tests/browser/components/popover/determinism.spec.ts new file mode 100644 index 00000000..6ac95f20 --- /dev/null +++ b/tests/browser/components/popover/determinism.spec.ts @@ -0,0 +1,10 @@ +import { type DeterministicRender, expectDeterministic } from '../../assertions'; +import { test } from '../../fixtures'; + +test.describe('Popover - Determinism', () => { + test('should render deterministic popover markup', async ({ render, run }) => { + await render('popoverMarkup'); + + await expectDeterministic(await run('renders')); + }); +}); diff --git a/tests/browser/components/progress-circle/a11y.spec.ts b/tests/browser/components/progress-circle/a11y.spec.ts new file mode 100644 index 00000000..e97dfc67 --- /dev/null +++ b/tests/browser/components/progress-circle/a11y.spec.ts @@ -0,0 +1,12 @@ +import { expect, test } from '../../fixtures'; + +test.describe('ProgressCircle - Accessibility', () => { + test('should have no automated axe violations given labelled circular progress', async ({ + render, + axeViolations, + }) => { + await render('axeLabelledCircularProgress'); + + expect(await axeViolations()).toEqual([]); + }); +}); diff --git a/tests/browser/components/progress-circle/behavior.spec.ts b/tests/browser/components/progress-circle/behavior.spec.ts new file mode 100644 index 00000000..782ac3f3 --- /dev/null +++ b/tests/browser/components/progress-circle/behavior.spec.ts @@ -0,0 +1,20 @@ +import { expect, test } from '../../fixtures'; + +test.describe('ProgressCircle - Behavior', () => { + test('should expose circular progress metadata', async ({ render, run }) => { + await render('circularMetadata'); + + expect(await run('valueNow')).toBe('30'); + }); + + test('should expose --ak-progress-percentage without an inline style attribute', async ({ + render, + run, + }) => { + await render('percentageCustomProperty'); + + expect( + await run<{ styleAttribute: string | null; percentage: string }>('styling') + ).toEqual({ styleAttribute: null, percentage: '50%' }); + }); +}); diff --git a/tests/browser/components/progress-circle/determinism.spec.ts b/tests/browser/components/progress-circle/determinism.spec.ts new file mode 100644 index 00000000..ef94a935 --- /dev/null +++ b/tests/browser/components/progress-circle/determinism.spec.ts @@ -0,0 +1,13 @@ +import { type DeterministicRender, expectDeterministic } from '../../assertions'; +import { test } from '../../fixtures'; + +test.describe('ProgressCircle - Determinism', () => { + test('should render deterministic circular progress markup', async ({ + render, + run, + }) => { + await render('circularProgressMarkup'); + + await expectDeterministic(await run('renders')); + }); +}); diff --git a/tests/browser/components/progress/a11y.spec.ts b/tests/browser/components/progress/a11y.spec.ts new file mode 100644 index 00000000..85aa7e6a --- /dev/null +++ b/tests/browser/components/progress/a11y.spec.ts @@ -0,0 +1,12 @@ +import { expect, test } from '../../fixtures'; + +test.describe('Progress - Accessibility', () => { + test('should have no automated axe violations given labelled progress', async ({ + render, + axeViolations, + }) => { + await render('axeLabelledProgress'); + + expect(await axeViolations()).toEqual([]); + }); +}); diff --git a/tests/browser/components/progress/behavior.spec.ts b/tests/browser/components/progress/behavior.spec.ts new file mode 100644 index 00000000..4045abbe --- /dev/null +++ b/tests/browser/components/progress/behavior.spec.ts @@ -0,0 +1,43 @@ +import { expect, test } from '../../fixtures'; + +/** The `it.each` table of the vitest original, one entry per generated title. */ +const NON_FINITE_MAXIMA = [ + { label: 'Infinity', key: 'positiveInfinity' }, + { label: '-Infinity', key: 'negativeInfinity' }, + { label: 'NaN', key: 'nan' }, +] as const; + +test.describe('Progress - Behavior', () => { + test('should expose progressbar metadata and indicator percentage', async ({ + render, + run, + }) => { + await render('progressMetadata'); + + expect( + await run<{ valueNow: string; indicatorPercentage: string }>('metadata') + ).toEqual({ valueNow: '40', indicatorPercentage: '50' }); + }); + + test('should expose --ak-progress-percentage without an inline style attribute', async ({ + render, + run, + }) => { + await render('percentageCustomProperty'); + + expect( + await run<{ styleAttribute: string | null; percentage: string }>('styling') + ).toEqual({ styleAttribute: null, percentage: '50%' }); + }); + + for (const { label, key } of NON_FINITE_MAXIMA) { + test(`should normalize non-finite max ${label} to the default ARIA maximum`, async ({ + render, + run, + }) => { + await render('nonFiniteMax', { key }); + + expect(await run('valueMax')).toBe('100'); + }); + } +}); diff --git a/tests/browser/components/progress/determinism.spec.ts b/tests/browser/components/progress/determinism.spec.ts new file mode 100644 index 00000000..a3340c5f --- /dev/null +++ b/tests/browser/components/progress/determinism.spec.ts @@ -0,0 +1,13 @@ +import { type DeterministicRender, expectDeterministic } from '../../assertions'; +import { test } from '../../fixtures'; + +test.describe('Progress - Determinism', () => { + test('should render deterministic progress markup', async ({ + render, + run, + }) => { + await render('progressMarkup'); + + await expectDeterministic(await run('renders')); + }); +}); diff --git a/tests/browser/components/rtl-composites/behavior.spec.ts b/tests/browser/components/rtl-composites/behavior.spec.ts new file mode 100644 index 00000000..c9fa5777 --- /dev/null +++ b/tests/browser/components/rtl-composites/behavior.spec.ts @@ -0,0 +1,26 @@ +import { expect, test } from '../../fixtures'; + +test.describe('RTL composite navigation', () => { + test('should reverse horizontal roving focus for every composite consumer', async ({ + page, + render, + run, + }) => { + await render('horizontalRovingFocus'); + + for (const [first, expected] of [ + ['Toggle A', 'Toggle C'], + ['Radio A', 'Radio C'], + ['Menu A', 'Menu C'], + ['Menubar A', 'Menubar C'], + ] as const) { + await test.step(`${first} -> ${expected}`, async () => { + await run('focusByText', first); + await page.keyboard.press('ArrowRight'); + await run('flush'); + + expect(await run('activeText')).toBe(expected); + }); + } + }); +}); diff --git a/tests/browser/components/table/a11y.spec.ts b/tests/browser/components/table/a11y.spec.ts new file mode 100644 index 00000000..f21d65c3 --- /dev/null +++ b/tests/browser/components/table/a11y.spec.ts @@ -0,0 +1,12 @@ +import { expect, test } from '../../fixtures'; + +test.describe('Table - Accessibility', () => { + test('should have no automated axe violations for a semantic table', async ({ + render, + axeViolations, + }) => { + await render('axeSemanticTable'); + + expect(await axeViolations()).toEqual([]); + }); +}); diff --git a/tests/browser/components/table/behavior.spec.ts b/tests/browser/components/table/behavior.spec.ts new file mode 100644 index 00000000..5edf60fb --- /dev/null +++ b/tests/browser/components/table/behavior.spec.ts @@ -0,0 +1,56 @@ +import { expect, test } from '../../fixtures'; + +test.describe('Table - Behavior', () => { + test('should render semantic table elements by default', async ({ + render, + root, + }) => { + await render('semanticElements'); + + await expect(root.locator('table')).toHaveAttribute('data-slot', 'table'); + await expect(root.locator('caption')).toHaveText('Users'); + await expect(root.locator('thead')).toHaveCount(1); + await expect(root.locator('tbody')).toHaveCount(1); + await expect(root.locator('tfoot')).toHaveCount(1); + await expect(root.locator('th')).toHaveCount(2); + await expect(root.locator('td')).toHaveCount(3); + }); + + test('should support asChild composition on the root and cells', async ({ + render, + root, + }) => { + await render('asChildComposition'); + + await expect(root.locator('table')).toHaveAttribute('data-slot', 'table'); + await expect(root.locator('tr')).toHaveAttribute( + 'data-slot', + 'table-row' + ); + await expect(root.locator('td')).toHaveAttribute( + 'data-slot', + 'table-cell' + ); + await expect(root.locator('td')).toHaveText('Alice'); + }); + + test('should preserve caption and header data attributes for theming', async ({ + render, + root, + }) => { + await render('themingDataAttributes'); + + await expect(root.locator('caption')).toHaveAttribute( + 'data-table-caption', + 'true' + ); + await expect(root.locator('thead')).toHaveAttribute( + 'data-table-head', + 'true' + ); + await expect(root.locator('th')).toHaveAttribute( + 'data-table-header-cell', + 'true' + ); + }); +}); diff --git a/tests/browser/components/table/determinism.spec.ts b/tests/browser/components/table/determinism.spec.ts new file mode 100644 index 00000000..1b6a35d9 --- /dev/null +++ b/tests/browser/components/table/determinism.spec.ts @@ -0,0 +1,10 @@ +import { type DeterministicRender, expectDeterministic } from '../../assertions'; +import { test } from '../../fixtures'; + +test.describe('Table - Determinism', () => { + test('should render deterministic table markup', async ({ render, run }) => { + await render('tableMarkup'); + + await expectDeterministic(await run('renders')); + }); +}); diff --git a/tests/browser/components/textarea/a11y.spec.ts b/tests/browser/components/textarea/a11y.spec.ts new file mode 100644 index 00000000..50126bde --- /dev/null +++ b/tests/browser/components/textarea/a11y.spec.ts @@ -0,0 +1,91 @@ +import { expect, test } from '../../fixtures'; + +interface TextareaContract { + HOST_ELEMENT: string; + DISABLED_ATTRIBUTES: { native: string; asChild: string }; + DATA_ATTRIBUTES: Record; + FOCUS_RULES: Record; + LABELING: Record; +} + +test.describe('Textarea - Accessibility', () => { + test('should have no automated axe violations for a labelled native textarea', async ({ + render, + axeViolations, + }) => { + await render('axeNative'); + + expect(await axeViolations()).toEqual([]); + }); + + test('should have no automated axe violations for a labelled asChild textarea', async ({ + render, + axeViolations, + }) => { + await render('axeAsChild'); + + expect(await axeViolations()).toEqual([]); + }); + + test('should use native disabled semantics for the default host', async ({ + render, + root, + run, + }) => { + await render('nativeDisabled'); + const contract = await run('contract'); + const textarea = root.locator('textarea'); + + expect( + await textarea.evaluate((node: HTMLTextAreaElement) => node.disabled) + ).toBe(true); + await expect(textarea).toHaveAttribute( + contract.DISABLED_ATTRIBUTES.asChild, + 'true' + ); + }); + + test('should use native disabled semantics for disabled asChild textarea hosts', async ({ + render, + root, + run, + }) => { + await render('asChildDisabled'); + const contract = await run('contract'); + const host = root.locator('textarea'); + + expect( + await host.evaluate((node: HTMLTextAreaElement) => node.disabled) + ).toBe(true); + await expect(host).toHaveAttribute( + contract.DISABLED_ATTRIBUTES.asChild, + 'true' + ); + }); + + test('should match the documented textarea accessibility contract', async ({ + render, + run, + }) => { + await render('contract'); + const contract = await run('contract'); + + expect(contract.HOST_ELEMENT).toBe('textarea'); + expect(contract.DISABLED_ATTRIBUTES).toEqual({ + native: 'disabled', + asChild: 'disabled', + }); + expect(contract.DATA_ATTRIBUTES).toEqual({ + disabled: 'data-disabled', + }); + expect(contract.FOCUS_RULES).toEqual({ + defaultTabIndex: 0, + disabledTabIndex: -1, + }); + expect(contract.LABELING).toEqual({ + supportsLabelElement: true, + supportsAriaLabel: true, + supportsAriaLabelledBy: true, + }); + }); +}); diff --git a/tests/browser/components/textarea/behavior.spec.ts b/tests/browser/components/textarea/behavior.spec.ts new file mode 100644 index 00000000..6f788306 --- /dev/null +++ b/tests/browser/components/textarea/behavior.spec.ts @@ -0,0 +1,94 @@ +import { expect, test } from '../../fixtures'; + +test.describe('Textarea - Behavior', () => { + test('should render a native textarea by default', async ({ + render, + root, + }) => { + await render('nativeDefault'); + const textarea = root.locator('textarea'); + + await expect(textarea).toHaveAttribute('rows', '4'); + expect( + await textarea.evaluate((node: HTMLTextAreaElement) => node.textContent) + ).toBe('Notes'); + await expect(textarea).toHaveAttribute('data-slot', 'textarea'); + }); + + test('should apply disabled semantics to native textarea', async ({ + render, + root, + }) => { + await render('nativeDisabled'); + const textarea = root.locator('textarea'); + + expect( + await textarea.evaluate((node: HTMLTextAreaElement) => node.disabled) + ).toBe(true); + await expect(textarea).toHaveAttribute('aria-disabled', 'true'); + await expect(textarea).toHaveAttribute('data-disabled', 'true'); + }); + + test('should preserve readonly semantics on native textareas', async ({ + render, + root, + }) => { + await render('nativeReadOnly'); + const textarea = root.locator('textarea'); + + expect( + await textarea.evaluate((node: HTMLTextAreaElement) => node.readOnly) + ).toBe(true); + await expect(textarea).toHaveAttribute('readonly'); + }); + + test('should support asChild composition and merge host props', async ({ + render, + root, + }) => { + await render('asChildComposition'); + const textarea = root.locator('textarea'); + + await expect(textarea).toHaveAttribute('data-testid', 'custom-textarea'); + await expect(textarea).toHaveAttribute('data-from-textarea', 'yes'); + await expect(textarea).toHaveAttribute('data-from-child', 'yes'); + await expect(textarea).toHaveAttribute('data-slot', 'textarea'); + }); + + test('should apply native disabled semantics to asChild textarea hosts', async ({ + render, + root, + }) => { + await render('asChildDisabled'); + const host = root.locator('textarea'); + + expect(await host.evaluate((node: HTMLTextAreaElement) => node.disabled)).toBe( + true + ); + await expect(host).toHaveAttribute('data-disabled', 'true'); + }); + + test('should preserve readonly semantics on asChild textarea hosts', async ({ + render, + root, + }) => { + await render('asChildReadOnly'); + const host = root.locator('textarea'); + + expect(await host.evaluate((node: HTMLTextAreaElement) => node.readOnly)).toBe( + true + ); + await expect(host).toHaveAttribute('readonly'); + }); + + test('should fail loudly when asChild does not receive a native textarea host', async ({ + render, + run, + }) => { + await render('asChildWithoutNativeHost'); + + expect(await run('error')).toContain( + 'Textarea `asChild` requires a native , + root + ); +} + +export function nativeDisabled(root: HTMLElement) { + mount(, + root + ); + return { contract: () => TEXTAREA_A11Y_CONTRACT }; +} + +/** Exposes the published contract object for the documentation assertion. */ +export function contract(): { + contract: () => typeof TEXTAREA_A11Y_CONTRACT; +} { + return { contract: () => TEXTAREA_A11Y_CONTRACT }; +} diff --git a/tests/browser/scenarios/components/textarea/behavior.tsx b/tests/browser/scenarios/components/textarea/behavior.tsx new file mode 100644 index 00000000..2942df01 --- /dev/null +++ b/tests/browser/scenarios/components/textarea/behavior.tsx @@ -0,0 +1,59 @@ +import { Textarea } from '../../../../../src/components/textarea/textarea'; +import { mount } from '../../_mount'; + +export function nativeDefault(root: HTMLElement): void { + mount(, root); +} + +export function nativeDisabled(root: HTMLElement): void { + mount(, root); +} + +export function asChildComposition(root: HTMLElement): void { + mount( + , + root + ); +} + +export function asChildDisabled(root: HTMLElement): void { + mount( + , + root + ); +} + +export function asChildReadOnly(root: HTMLElement): void { + mount( + , + root + ); +} + +/** Captures the mount failure browser-side; the throw cannot cross to Node. */ +export function asChildWithoutNativeHost(root: HTMLElement) { + let message = ''; + + try { + mount( + , + root + ); + } catch (error) { + message = error instanceof Error ? error.message : String(error); + } + + return { error: () => message }; +} diff --git a/tests/browser/scenarios/components/textarea/determinism.tsx b/tests/browser/scenarios/components/textarea/determinism.tsx new file mode 100644 index 00000000..8bd164b5 --- /dev/null +++ b/tests/browser/scenarios/components/textarea/determinism.tsx @@ -0,0 +1,24 @@ +import { Textarea } from '../../../../../src/components/textarea/textarea'; +import { deterministicRender } from '../../_mount'; + +export function nativeMarkup() { + return { + renders: () => [ + deterministicRender('native textarea', () => ( + + )), + ], + }; +} + +export function asChildMarkup() { + return { + renders: () => [ + deterministicRender('asChild textarea', () => ( + + + )), + ], + }; +} diff --git a/tests/browser/scenarios/components/toast/a11y.tsx b/tests/browser/scenarios/components/toast/a11y.tsx new file mode 100644 index 00000000..7fc38ac2 --- /dev/null +++ b/tests/browser/scenarios/components/toast/a11y.tsx @@ -0,0 +1,23 @@ +import { + Toast, + ToastAction, + ToastDescription, + ToastHost, + ToastTitle, + ToastViewport, +} from '../../../../../src/components/toast'; +import { mount } from '../../_mount'; + +export function axeToast(root: HTMLElement): void { + mount( + + + + Saved + Changes stored + Undo + + , + root + ); +} diff --git a/tests/browser/scenarios/components/toast/determinism.tsx b/tests/browser/scenarios/components/toast/determinism.tsx new file mode 100644 index 00000000..7d167525 --- /dev/null +++ b/tests/browser/scenarios/components/toast/determinism.tsx @@ -0,0 +1,22 @@ +import { + Toast, + ToastHost, + ToastTitle, + ToastViewport, +} from '../../../../../src/components/toast'; +import { deterministicRender } from '../../_mount'; + +export function toastMarkup() { + return { + renders: () => [ + deterministicRender('default open toast', () => ( + + + + Saved + + + )), + ], + }; +} diff --git a/tests/browser/scenarios/components/toggle-group/a11y.tsx b/tests/browser/scenarios/components/toggle-group/a11y.tsx new file mode 100644 index 00000000..0b1617a7 --- /dev/null +++ b/tests/browser/scenarios/components/toggle-group/a11y.tsx @@ -0,0 +1,71 @@ +import { + ToggleGroup, + ToggleGroupItem, +} from '../../../../../src/components/toggle-group'; +import { TOGGLE_GROUP_A11Y_CONTRACT } from '../../../../../src/components/toggle-group/toggle-group.a11y'; +import { mount } from '../../_mount'; + +export function axeNativeItems(root: HTMLElement): void { + mount( + + Left + Right + , + root + ); +} + +export function axeAsChildItems(root: HTMLElement): void { + mount( + + + Left + + + Center + + , + root + ); +} + +export function nativeSemantics(root: HTMLElement) { + mount( + + Left + Right + , + root + ); + return { contract: () => TOGGLE_GROUP_A11Y_CONTRACT }; +} + +export function asChildSemantics(root: HTMLElement) { + mount( + + + Left + + , + root + ); + return { contract: () => TOGGLE_GROUP_A11Y_CONTRACT }; +} + +export function asChildDisabled(root: HTMLElement): void { + mount( + + + Left + + , + root + ); +} + +/** Exposes the published contract object for the documentation assertion. */ +export function contract(): { + contract: () => typeof TOGGLE_GROUP_A11Y_CONTRACT; +} { + return { contract: () => TOGGLE_GROUP_A11Y_CONTRACT }; +} diff --git a/tests/browser/scenarios/components/toggle-group/behavior.tsx b/tests/browser/scenarios/components/toggle-group/behavior.tsx new file mode 100644 index 00000000..77719af8 --- /dev/null +++ b/tests/browser/scenarios/components/toggle-group/behavior.tsx @@ -0,0 +1,507 @@ +import { For, state } from '@askrjs/askr'; +import { + ToggleGroup, + ToggleGroupItem, +} from '../../../../../src/components/toggle-group'; +import { TOGGLE_GROUP_A11Y_CONTRACT } from '../../../../../src/components/toggle-group/toggle-group.a11y'; +import { flushUpdates, mount, spy, unmount } from '../../_mount'; + +/** Port of the old `getToggleByText` helper: an exact, trimmed text match. */ +function getToggleByText(container: HTMLElement, text: string): HTMLElement { + const button = Array.from( + container.querySelectorAll('[data-slot="toggle-group-item"]') + ).find((element) => element.textContent?.trim() === text); + + if (!(button instanceof HTMLElement)) { + throw new Error(`Unable to find toggle item with text "${text}"`); + } + + return button; +} + +function errorMessage(error: unknown): string | null { + return error instanceof Error ? error.message : String(error); +} + +export function mountSingleAndMultiple(root: HTMLElement) { + let thrown: string | null = null; + try { + mount( +
+ + Left + + + Left multiple + +
, + root + ); + } catch (error) { + thrown = errorMessage(error); + } + + return { thrown: () => thrown }; +} + +export function singleSelectionHooks(root: HTMLElement) { + mount( + + Left + Right + , + root + ); + return { contract: () => TOGGLE_GROUP_A11Y_CONTRACT }; +} + +export function uncontrolledSingle(root: HTMLElement) { + const container = mount( + + Left + Right + , + root + ); + + return { + clickToggle: async (text: string) => { + getToggleByText(container, text).click(); + await flushUpdates(); + }, + }; +} + +export function uncontrolledMultiple(root: HTMLElement) { + const container = mount( + + Left + Right + , + root + ); + + return { + clickToggle: async (text: string) => { + getToggleByText(container, text).click(); + await flushUpdates(); + }, + }; +} + +export function nestedItems(root: HTMLElement) { + const container = mount( + +
+ Left +
+
+ Right +
+
, + root + ); + + return { + clickToggle: async (text: string) => { + getToggleByText(container, text).click(); + await flushUpdates(); + }, + }; +} + +const computedItems = [ + { value: 'all', label: 'All' }, + { value: 'midge', label: 'Midge' }, +]; + +export function computedArrayItems(root: HTMLElement) { + function ComputedToggleGroup() { + return ( + + {computedItems.map((item) => ( + + {item.label} + + ))} + + ); + } + + const container = mount(, root); + + return { + clickToggle: async (text: string) => { + getToggleByText(container, text).click(); + await flushUpdates(); + }, + }; +} + +export function forItems(root: HTMLElement) { + function ForToggleGroup() { + return ( + + item.value}> + {(item) => ( + {item.label} + )} + + + ); + } + + const container = mount(, root); + + return { + clickToggle: async (text: string) => { + getToggleByText(container, text).click(); + await flushUpdates(); + }, + }; +} + +export function itemsOutsideGroup(root: HTMLElement) { + const items = [{ value: 'all', label: 'All' }]; + let thrown: string | null = null; + + try { + mount( + <> + {items.map((item) => ( + + {item.label} + + ))} + , + root + ); + } catch (error) { + thrown = errorMessage(error); + } + + return { thrown: () => thrown }; +} + +export function normalizedValues(root: HTMLElement) { + const onSingleValueChange = spy<[string]>(); + const onMultipleValueChange = spy<[string[]]>(); + + const container = mount( +
+ + Single left + Single right + + + Multiple left + Multiple right + +
, + root + ); + + return { + clickToggle: async (text: string) => { + getToggleByText(container, text).click(); + await flushUpdates(); + }, + singleCalls: () => onSingleValueChange.calls, + multipleCalls: () => onMultipleValueChange.calls, + }; +} + +export function disabledInteraction(root: HTMLElement) { + const onGroupValueChange = spy(); + const onItemValueChange = spy(); + + const container = mount( +
+ + Group left + Group right + + + + Item left + + Item right + +
, + root + ); + + return { + disabledFlags: () => ({ + groupRight: (getToggleByText(container, 'Group right') as HTMLButtonElement) + .disabled, + itemLeft: (getToggleByText(container, 'Item left') as HTMLButtonElement) + .disabled, + }), + clickDisabled: async () => { + getToggleByText(container, 'Group right').click(); + getToggleByText(container, 'Item left').click(); + await flushUpdates(); + }, + changeCounts: () => ({ + group: onGroupValueChange.count(), + item: onItemValueChange.count(), + }), + }; +} + +export function asChildComposition(root: HTMLElement) { + mount( + + + Left + + , + root + ); + return { contract: () => TOGGLE_GROUP_A11Y_CONTRACT }; +} + +export async function asChildKeyboardToggle(root: HTMLElement) { + const container = mount( + + + Left + + , + root + ); + await flushUpdates(); + await flushUpdates(); + + return { + focusFresh: (text: string) => { + getToggleByText(container, text).focus(); + }, + flush: async () => { + await flushUpdates(); + }, + }; +} + +export function refForwarding(root: HTMLElement) { + let groupRef: HTMLDivElement | null = null; + let nativeItemRef: HTMLButtonElement | null = null; + let childItemRef: HTMLElement | null = null; + + let container = mount( + (groupRef = node)} defaultValue="left"> + (nativeItemRef = node)} value="left"> + Left + + , + root + ); + const group = container.querySelector( + '[data-slot="toggle-group"]' + ) as HTMLDivElement | null; + const nativeItem = getToggleByText(container, 'Left'); + const groupMatches = groupRef === group; + const nativeMatches = nativeItemRef === nativeItem; + + unmount(container); + container = mount( + + (childItemRef = node as HTMLElement | null)} + value="left" + > + Left + + , + root + ); + const childMatches = childItemRef === getToggleByText(container, 'Left'); + + return { refs: () => ({ groupMatches, nativeMatches, childMatches }) }; +} + +export function controlledValue(root: HTMLElement) { + const onValueChange = spy<[string]>(); + const container = mount( + + Left + Right + , + root + ); + + return { + clickToggle: async (text: string) => { + getToggleByText(container, text).click(); + await flushUpdates(); + }, + changeCalls: () => onValueChange.calls, + }; +} + +export function noLoopAtBoundary(root: HTMLElement) { + const container = mount( + + Left + Right + , + root + ); + + return { + pressArrowFromLeft: async () => { + const left = getToggleByText(container, 'Left'); + left.focus(); + left.dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }) + ); + await flushUpdates(); + }, + }; +} + +export function disabledRovingNavigation(root: HTMLElement) { + const container = mount( + + Left + + Middle + + Right + , + root + ); + + return { + pressArrows: async () => { + const left = getToggleByText(container, 'Left'); + left.focus(); + left.dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }) + ); + left.dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }) + ); + await flushUpdates(); + }, + }; +} + +export async function focusedItemBecomesDisabled(root: HTMLElement) { + let disabled!: ReturnType>; + function DynamicToggleGroup() { + disabled = state(false); + return ( + + Left + + Middle + + Right + + ); + } + + const container = mount(, root); + await flushUpdates(); + await flushUpdates(); + getToggleByText(container, 'Middle').focus(); + + return { + disableMiddle: async () => { + disabled.set(true); + await flushUpdates(); + await flushUpdates(); + }, + }; +} + +export async function controlledTabStop(root: HTMLElement) { + let selectRight = (): undefined => undefined; + function Fixture() { + const value = state('left'); + selectRight = () => value.set('right'); + return ( + + Left + Right + + ); + } + mount(, root); + await flushUpdates(); + + return { + selectRight: async () => { + selectRight(); + await flushUpdates(); + }, + }; +} + +export async function rtlArrowNavigation(root: HTMLElement) { + const container = mount( +
+ + Left + Middle + Right + +
, + root + ); + await flushUpdates(); + + return { + focusToggle: (text: string) => { + getToggleByText(container, text).focus(); + }, + flush: async () => { + await flushUpdates(); + }, + }; +} + +export async function repeatedArrowPresses(root: HTMLElement) { + const container = mount( + + Left + Middle + Right + , + root + ); + await flushUpdates(); + + return { + focusToggle: (text: string) => { + getToggleByText(container, text).focus(); + }, + flush: async () => { + await flushUpdates(); + }, + tabStopText: () => + Array.from( + container.querySelectorAll( + '[data-slot="toggle-group-item"]' + ) + ) + .find((item) => item.tabIndex === 0) + ?.textContent?.trim() ?? null, + }; +} diff --git a/tests/browser/scenarios/components/toggle-group/determinism.tsx b/tests/browser/scenarios/components/toggle-group/determinism.tsx new file mode 100644 index 00000000..dae46504 --- /dev/null +++ b/tests/browser/scenarios/components/toggle-group/determinism.tsx @@ -0,0 +1,91 @@ +import { + ToggleGroup, + ToggleGroupItem, +} from '../../../../../src/components/toggle-group'; +import { + captureTimers, + deterministicRender, + mount, + unmount, +} from '../../_mount'; + +export function groupMarkup() { + return { + renders: () => [ + deterministicRender('single toggle group', () => ( + + Left + + )), + deterministicRender('multiple toggle group', () => ( + + Left + Right + + )), + ], + }; +} + +export function asChildMarkup() { + return { + renders: () => [ + deterministicRender('asChild toggle group item', () => ( + + + Left + + + )), + ], + }; +} + +function pressedStates(container: HTMLElement): (string | null)[] { + return Array.from( + container.querySelectorAll('[data-slot="toggle-group-item"]') + ).map((item) => item.getAttribute('aria-pressed')); +} + +export function selectionAcrossRemounts(root: HTMLElement) { + let container = mount( + + Left + Right + , + root + ); + const first = pressedStates(container); + unmount(container); + + container = mount( + + Left + Right + , + root + ); + const second = pressedStates(container); + + return { pressedStates: () => ({ first, second }) }; +} + +export function timersDuringRender(root: HTMLElement) { + const timers = captureTimers(); + try { + mount( + + Left + , + root + ); + return { + scheduled: () => ({ + timeouts: timers.timeouts(), + intervals: timers.intervals(), + }), + }; + } finally { + timers.restore(); + } +} diff --git a/tests/browser/scenarios/components/toggle/a11y.tsx b/tests/browser/scenarios/components/toggle/a11y.tsx new file mode 100644 index 00000000..3758dff0 --- /dev/null +++ b/tests/browser/scenarios/components/toggle/a11y.tsx @@ -0,0 +1,60 @@ +import { Toggle } from '../../../../../src/components/toggle/toggle'; +import { TOGGLE_A11Y_CONTRACT } from '../../../../../src/components/toggle/toggle.a11y'; +import { mount } from '../../_mount'; + +export function axeNative(root: HTMLElement): void { + mount(Mute, root); +} + +export function axeAsChild(root: HTMLElement): void { + mount( + + Mute + , + root + ); +} + +export function nativeSemantics(root: HTMLElement): void { + mount(Mute, root); +} + +export function asChildSemantics(root: HTMLElement) { + mount( + + Mute + , + root + ); + return { contract: () => TOGGLE_A11Y_CONTRACT }; +} + +export function nativeDisabled(root: HTMLElement): void { + mount(Mute, root); +} + +export function asChildDisabled(root: HTMLElement): void { + mount( + + Mute + , + root + ); +} + +export function accessibleNaming(root: HTMLElement): void { + mount( +
+ Mute audio + Mute +
, + root + ); +} + +/** Exposes the published contract object for the documentation assertion. */ +export function contract(): { + contract: () => typeof TOGGLE_A11Y_CONTRACT; +} { + return { contract: () => TOGGLE_A11Y_CONTRACT }; +} diff --git a/tests/browser/scenarios/components/toggle/behavior.tsx b/tests/browser/scenarios/components/toggle/behavior.tsx new file mode 100644 index 00000000..3bea55b2 --- /dev/null +++ b/tests/browser/scenarios/components/toggle/behavior.tsx @@ -0,0 +1,117 @@ +import { Toggle } from '../../../../../src/components/toggle/toggle'; +import { flushUpdates, mount, spy, unmount } from '../../_mount'; + +export function nativeDefault(root: HTMLElement): void { + mount(Mute, root); +} + +export function explicitTypeAndPressed(root: HTMLElement): void { + mount( + + Save + , + root + ); +} + +export function nativePress(root: HTMLElement) { + const onPress = spy(); + mount(Mute, root); + return { pressCount: () => onPress.count() }; +} + +export function disabledNative(root: HTMLElement) { + const onPress = spy(); + mount( + + Mute + , + root + ); + return { pressCount: () => onPress.count() }; +} + +export function asChildComposition(root: HTMLElement): void { + mount( + + Mute + , + root + ); +} + +export function asChildPress(root: HTMLElement) { + const onPress = spy(); + mount( + + Mute + , + root + ); + return { pressCount: () => onPress.count() }; +} + +export function keyboardActivation(root: HTMLElement) { + const onNativePress = spy(); + const onChildPress = spy(); + const container = mount( +
+ Native mute + + Mute + +
, + root + ); + + return { + /** + * Re-queries and focuses the host after letting the render settle, exactly + * as the vitest original did: the node is replaced between activations. + */ + focusFresh: async (selector: string) => { + await flushUpdates(); + (container.querySelector(selector) as HTMLElement).focus(); + }, + flush: async () => { + await flushUpdates(); + }, + counts: () => ({ + native: onNativePress.count(), + asChild: onChildPress.count(), + }), + }; +} + +export function asChildDisabled(root: HTMLElement) { + const onPress = spy(); + mount( + + Mute + , + root + ); + return { pressCount: () => onPress.count() }; +} + +export function refForwarding(root: HTMLElement) { + let nativeRef: HTMLButtonElement | null = null; + let childRef: HTMLElement | null = null; + + const first = mount( (nativeRef = node)}>Mute, root); + const nativeMatches = + nativeRef === (first.querySelector('button') as HTMLButtonElement | null); + + unmount(first); + + const second = mount( + (childRef = node as HTMLElement | null)}> + Mute + , + root + ); + const childMatches = + childRef === (second.querySelector('[role="button"]') as HTMLElement | null); + + return { refs: () => ({ nativeMatches, childMatches }) }; +} diff --git a/tests/browser/scenarios/components/toggle/determinism.tsx b/tests/browser/scenarios/components/toggle/determinism.tsx new file mode 100644 index 00000000..f7e8ac1c --- /dev/null +++ b/tests/browser/scenarios/components/toggle/determinism.tsx @@ -0,0 +1,60 @@ +import { Toggle } from '../../../../../src/components/toggle/toggle'; +import { + captureTimers, + deterministicRender, + mount, + unmount, +} from '../../_mount'; + +export function nativeMarkup() { + return { + renders: () => [ + deterministicRender('native toggle', () => Mute), + deterministicRender('pressed submit toggle', () => ( + + Save + + )), + ], + }; +} + +export function asChildMarkup() { + return { + renders: () => [ + deterministicRender('asChild toggle', () => ( + + Mute + + )), + ], + }; +} + +export function pressedAcrossRemounts(root: HTMLElement) { + let container = mount(Mute, root); + const first = container.querySelector('button')?.getAttribute('aria-pressed'); + unmount(container); + + container = mount(Mute, root); + const second = container + .querySelector('button') + ?.getAttribute('aria-pressed'); + + return { pressedStates: () => ({ first, second }) }; +} + +export function timersDuringRender(root: HTMLElement) { + const timers = captureTimers(); + try { + mount(Mute, root); + return { + scheduled: () => ({ + timeouts: timers.timeouts(), + intervals: timers.intervals(), + }), + }; + } finally { + timers.restore(); + } +} diff --git a/tests/browser/scenarios/components/virtual-list/a11y.tsx b/tests/browser/scenarios/components/virtual-list/a11y.tsx new file mode 100644 index 00000000..a3a3e5e5 --- /dev/null +++ b/tests/browser/scenarios/components/virtual-list/a11y.tsx @@ -0,0 +1,27 @@ +import { VirtualList } from '../../../../../src/components/virtual-list'; +import { mount } from '../../_mount'; + +type Item = { + id: string; + label: string; +}; + +const items: Item[] = [ + { id: 'item-0', label: 'Item 0' }, + { id: 'item-1', label: 'Item 1' }, + { id: 'item-2', label: 'Item 2' }, +]; + +export function axeSemanticList(root: HTMLElement): void { + mount( + item.id} + rowComponent={({ item }) => {item.label}} + />, + root + ); +} diff --git a/tests/browser/scenarios/components/virtual-list/determinism.tsx b/tests/browser/scenarios/components/virtual-list/determinism.tsx new file mode 100644 index 00000000..2b4c0522 --- /dev/null +++ b/tests/browser/scenarios/components/virtual-list/determinism.tsx @@ -0,0 +1,30 @@ +import { VirtualList } from '../../../../../src/components/virtual-list'; +import { deterministicRender } from '../../_mount'; + +type Item = { + id: string; + label: string; +}; + +const items: Item[] = [ + { id: 'item-0', label: 'Item 0' }, + { id: 'item-1', label: 'Item 1' }, + { id: 'item-2', label: 'Item 2' }, +]; + +export function virtualListMarkup() { + return { + renders: () => [ + deterministicRender('virtual list', () => ( + item.id} + rowComponent={({ item }) => {item.label}} + /> + )), + ], + }; +} diff --git a/tests/browser/scenarios/components/virtual-table-identity-wiring/behavior.tsx b/tests/browser/scenarios/components/virtual-table-identity-wiring/behavior.tsx new file mode 100644 index 00000000..dd7f55c1 --- /dev/null +++ b/tests/browser/scenarios/components/virtual-table-identity-wiring/behavior.tsx @@ -0,0 +1,44 @@ +import type { VirtualCompositeScopeValue } from '../../../../../src/components/_internal/virtual-composite'; +import { resolveVirtualTableScope } from '../../../../../src/components/virtual-table/identity-wiring'; + +function createHost(keys: string[]) { + return { + placements: new Map(), + keys, + }; +} + +export function derivedScope() { + const host = createHost(['row-1', 'row-2']); + + const scope = resolveVirtualTableScope( + host, + 'parent-identity', + 'row-1', + 'name', + 0, + true + ); + + return { scope: () => scope }; +} + +export function cachedScope() { + const host = createHost(['row-1']); + + const first = resolveVirtualTableScope(host, null, 'row-1', 'name', 0, true); + const second = resolveVirtualTableScope(host, null, 'row-1', 'name', 0, true); + + return { isSameScope: () => second === first }; +} + +export function recomputedScope() { + const host = createHost(['row-1']); + + const first = resolveVirtualTableScope(host, null, 'row-1', 'name', 0, true); + const second = resolveVirtualTableScope(host, null, 'row-1', 'name', 1, true); + + return { + result: () => ({ isSameScope: second === first, index: second.index }), + }; +} diff --git a/tests/browser/scenarios/components/virtual-table-style-injection/behavior.tsx b/tests/browser/scenarios/components/virtual-table-style-injection/behavior.tsx new file mode 100644 index 00000000..296bd9b6 --- /dev/null +++ b/tests/browser/scenarios/components/virtual-table-style-injection/behavior.tsx @@ -0,0 +1,92 @@ +import { + clearVirtualTableLayoutRules, + commitVirtualTableLayoutRules, + virtualTableLayoutProps, + type VirtualTableStyleHost, +} from '../../../../../src/components/virtual-table/style-injection'; + +function createHost(): VirtualTableStyleHost { + return { + layoutNonce: undefined, + layoutRules: new Map(), + nextLayoutRules: new Map(), + }; +} + +export function undefinedValue() { + const host = createHost(); + + const props = virtualTableLayoutProps(host, 'row-height', undefined, { + height: '10px', + }); + + return { + result: () => ({ + props: { ...props }, + nextRuleCount: host.nextLayoutRules.size, + }), + }; +} + +export function injectedRule() { + const host = createHost(); + + const props = virtualTableLayoutProps(host, 'row-height', '32', { + height: '32px', + }); + + return { + result: () => ({ + props: { ...props }, + nextRuleCount: host.nextLayoutRules.size, + }), + committedHeight: () => { + const target = document.createElement('tr'); + target.setAttribute('data-askr-virtual-table-row-height', '32'); + document.body.appendChild(target); + + try { + commitVirtualTableLayoutRules(host); + + return getComputedStyle(target).height; + } finally { + target.remove(); + } + }, + }; +} + +export function unusedRuleRemoval() { + const host = createHost(); + + virtualTableLayoutProps(host, 'row-height', '20', { height: '20px' }); + commitVirtualTableLayoutRules(host); + + const afterFirstCommit = host.layoutRules.size; + + // Nothing referenced this render, so the previously active rule should be + // scheduled for removal once nothing in the DOM still uses it. + commitVirtualTableLayoutRules(host); + + return { + result: () => ({ + afterFirstCommit, + afterSecondCommit: host.layoutRules.size, + }), + }; +} + +export function unmountCleanup() { + const host = createHost(); + + virtualTableLayoutProps(host, 'row-height', '12', { height: '12px' }); + commitVirtualTableLayoutRules(host); + + const afterCommit = host.layoutRules.size; + + clearVirtualTableLayoutRules(host); + + return { + result: () => ({ afterCommit, afterClear: host.layoutRules.size }), + }; +} diff --git a/tests/browser/scenarios/components/virtual-table-virtualization-orchestration/behavior.tsx b/tests/browser/scenarios/components/virtual-table-virtualization-orchestration/behavior.tsx new file mode 100644 index 00000000..efa60866 --- /dev/null +++ b/tests/browser/scenarios/components/virtual-table-virtualization-orchestration/behavior.tsx @@ -0,0 +1,123 @@ +import { resolveVirtualRange } from '../../../../../src/components/_internal/virtualization'; +import { + buildVirtualTableState, + syncVirtualTableRows, + type VirtualTableOrchestrationHost, +} from '../../../../../src/components/virtual-table/virtualization-orchestration'; + +function createHost( + overrides: Partial> = {} +): VirtualTableOrchestrationHost { + let scrollTop = 0; + let viewportHeight = 0; + + return { + keys: [], + keyIndexMap: new Map(), + rowHeight: 10, + headerHeight: 0, + rowsRef: null, + placements: new Map(), + viewportHeightHint: 0, + visibleRange: resolveVirtualRange({ + totalCount: 0, + rowHeight: 1, + scrollTop: 0, + viewportHeight: 0, + overscan: 0, + }), + pendingScrollTop: null, + scrollTopState: Object.assign(() => scrollTop, { + set: (next: number) => { + scrollTop = next; + }, + }), + viewportHeightState: Object.assign(() => viewportHeight, { + set: (next: number) => { + viewportHeight = next; + }, + }), + schedulePendingScrollTop: () => {}, + handleResize: () => {}, + ...overrides, + }; +} + +export function stateSnapshot() { + const host = createHost({ + keys: ['a', 'b', 'c'], + keyIndexMap: new Map([ + ['a', 0], + ['b', 1], + ['c', 2], + ]), + rowHeight: 20, + headerHeight: 5, + }); + const visibleRange = resolveVirtualRange({ + totalCount: 3, + rowHeight: 20, + scrollTop: 0, + viewportHeight: 40, + overscan: 0, + }); + + const snapshot = buildVirtualTableState(host, visibleRange, 0, 45, 'b'); + + return { snapshot: () => snapshot, visibleRange: () => visibleRange }; +} + +export function unchangedRowsReference() { + const rows = ['a', 'b']; + const host = createHost({ keys: ['a', 'b'], rowsRef: rows }); + const keysBefore = host.keys; + + syncVirtualTableRows(host, rows, (row) => row); + + return { isSameKeys: () => host.keys === keysBefore }; +} + +export function changedRowsReference() { + const host = createHost({ + keys: ['a'], + keyIndexMap: new Map([['a', 0]]), + rowsRef: ['a'], + placements: new Map([['stale', {} as never]]), + }); + + syncVirtualTableRows(host, ['a', 'b', 'c'], (row) => row); + + return { + result: () => ({ + keys: host.keys, + indexOfC: host.keyIndexMap.get('c'), + rowsRef: host.rowsRef, + placementCount: host.placements.size, + }), + }; +} + +export function clampedPendingScrollTop() { + const host = createHost({ + keys: ['a', 'b', 'c'], + keyIndexMap: new Map([ + ['a', 0], + ['b', 1], + ['c', 2], + ]), + rowsRef: ['a', 'b', 'c'], + rowHeight: 10, + headerHeight: 0, + viewportHeightHint: 15, + }); + host.scrollTopState.set(1000); + + syncVirtualTableRows(host, ['a'], (row) => row); + + return { + result: () => ({ + isNull: host.pendingScrollTop === null, + pendingScrollTop: host.pendingScrollTop, + }), + }; +} diff --git a/tests/browser/scenarios/components/virtual-table/a11y.tsx b/tests/browser/scenarios/components/virtual-table/a11y.tsx new file mode 100644 index 00000000..522418ad --- /dev/null +++ b/tests/browser/scenarios/components/virtual-table/a11y.tsx @@ -0,0 +1,42 @@ +import { VirtualTable } from '../../../../../src/components/virtual-table'; +import { mount } from '../../_mount'; + +type Row = { + id: string; + name: string; + email: string; +}; + +const rows: Row[] = [ + { id: 'row-0', name: 'Ada', email: 'ada@example.com' }, + { id: 'row-1', name: 'Grace', email: 'grace@example.com' }, + { id: 'row-2', name: 'Linus', email: 'linus@example.com' }, +]; + +const columns = [ + { + id: 'name', + header: 'Name', + cellComponent: ({ row }: { row: Row }) => {row.name}, + }, + { + id: 'email', + header: 'Email', + cellComponent: ({ row }: { row: Row }) => {row.email}, + }, +] as const; + +export function axeSelectableGrid(root: HTMLElement): void { + mount( + row.id} + columns={columns} + />, + root + ); +} diff --git a/tests/browser/scenarios/components/virtual-table/behavior.tsx b/tests/browser/scenarios/components/virtual-table/behavior.tsx new file mode 100644 index 00000000..965271d0 --- /dev/null +++ b/tests/browser/scenarios/components/virtual-table/behavior.tsx @@ -0,0 +1,628 @@ +import { CspNonceScope, state } from '@askrjs/askr'; +import { + VirtualTable, + type VirtualTableApi, + type VirtualTableColumn, +} from '../../../../../src/components/virtual-table'; +import { flushUpdates, mount, spy, unmount } from '../../_mount'; + +type Row = { + id: string; + name: string; + email: string; +}; + +function createRows(count: number): Row[] { + return Array.from({ length: count }, (_, index) => ({ + id: `row-${index}`, + name: `User ${index}`, + email: `user-${index}@example.com`, + })); +} + +const columns: readonly VirtualTableColumn[] = [ + { + id: 'name', + header: 'Name', + cellComponent: ({ row }) => {row.name}, + }, + { + id: 'email', + header: 'Email', + cellComponent: ({ row }) => {row.email}, + }, +]; + +function nextAnimationFrame(): Promise { + return new Promise((resolve) => { + requestAnimationFrame(() => resolve()); + }); +} + +function dynamicStyles(): string { + return Array.from( + document.querySelectorAll( + 'style[data-askr-dynamic-styles]' + ) + ) + .map((style) => style.textContent ?? '') + .join('\n'); +} + +export async function layoutRuleRelease(root: HTMLElement) { + const nonce = 'dmlydHVhbC10YWJsZS1ub25jZQ'; + let container: HTMLElement | undefined = mount( + + row.id} + columns={[ + { + ...columns[0], + width: 173, + }, + ]} + /> + , + root + ); + await flushUpdates(); + + return { + mounted: () => ({ + styles: dynamicStyles(), + headerCellHeight: container + ?.querySelector('[data-slot="virtual-table-header-cell"]') + ?.getBoundingClientRect().height, + hasNoncedRule: Array.from( + document.querySelectorAll( + 'style[data-askr-dynamic-styles]' + ) + ).some( + (style) => + style.nonce === nonce && + style.textContent?.includes('data-askr-virtual-table-row-height="43"') + ), + }), + unmounted: async () => { + unmount(container); + container = undefined; + await Promise.resolve(); + return { styles: dynamicStyles() }; + }, + }; +} + +export async function stickyHeaderSelection(root: HTMLElement) { + const onRowClick = spy(); + let api: VirtualTableApi | null = null; + + const container = mount( + row.id} + columns={columns} + onRowClick={onRowClick} + apiRef={(next) => { + api = next; + }} + />, + root + ); + await flushUpdates(); + + const table = container.querySelector( + '[data-slot="virtual-table-table"]' + ) as HTMLTableElement | null; + const wrapper = container.querySelector( + '[data-slot="virtual-table"]' + ) as HTMLElement | null; + const firstRow = container.querySelector( + '[data-row-key="row-0"]' + ) as HTMLTableRowElement | null; + + return { + initial: () => { + const mountedRows = Array.from( + container.querySelectorAll('[data-slot="virtual-table-row"]') + ); + + return { + role: table?.getAttribute('role'), + ariaRowCount: table?.getAttribute('aria-rowcount'), + headerRowIndex: container + .querySelector('[data-slot="virtual-table-header-row"]') + ?.getAttribute('aria-rowindex'), + rowCount: mountedRows.length, + lastTerminalRow: mountedRows.at(-1)?.getAttribute('data-terminal-row'), + firstRowSelected: firstRow?.getAttribute('aria-selected'), + firstRowIndex: firstRow?.getAttribute('aria-rowindex'), + atTop: wrapper?.getAttribute('data-at-top'), + atBottom: wrapper?.getAttribute('data-at-bottom'), + empty: wrapper?.getAttribute('data-empty'), + }; + }, + scrollAwayFromTop: async () => { + if (!wrapper) return null; + wrapper.scrollTop = 1; + wrapper.dispatchEvent(new Event('scroll')); + await flushUpdates(); + const atTop = wrapper.getAttribute('data-at-top'); + + wrapper.scrollTop = 0; + wrapper.dispatchEvent(new Event('scroll')); + await flushUpdates(); + return atTop; + }, + clickFirstRow: async () => { + firstRow?.click(); + await flushUpdates(); + return { + rowClickCount: onRowClick.count(), + selectedRowKey: api?.getSelectedRowKey() ?? null, + firstRowSelected: firstRow?.getAttribute('aria-selected'), + }; + }, + arrowDown: async () => { + table?.dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }) + ); + await flushUpdates(); + return { + selectedRowIndex: api?.getSelectedRowIndex(), + secondRowSelected: container + .querySelector('[data-row-key="row-1"]') + ?.getAttribute('aria-selected'), + }; + }, + scrollToBottom: async () => { + api?.scrollToBottom(); + await flushUpdates(); + return { + isAtBottom: api?.isAtBottom(), + lastRowTerminal: container + .querySelector('[data-row-key="row-9"]') + ?.getAttribute('data-terminal-row'), + atBottom: wrapper?.getAttribute('data-at-bottom'), + }; + }, + }; +} + +export async function nestedInteractiveCell(root: HTMLElement) { + let api: VirtualTableApi | null = null; + const onCellAction = spy<[string]>(); + + const container = mount( + row.id} + columns={[ + ...columns, + { + id: 'actions', + header: 'Actions', + cellComponent: ({ row }) => ( + + ), + }, + ]} + apiRef={(next) => { + api = next; + }} + />, + root + ); + await flushUpdates(); + + const action = container.querySelector( + '[data-row-key="row-0"] button' + ) as HTMLButtonElement; + + return { + activate: async () => { + action.focus(); + action.click(); + await flushUpdates(); + return { + actionArgs: onCellAction.calls.map(([id]) => id), + selectedRowKey: api?.getSelectedRowKey() ?? null, + actionFocused: document.activeElement === action, + }; + }, + arrowDown: async () => { + action.dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }) + ); + await flushUpdates(); + return { + selectedRowKey: api?.getSelectedRowKey() ?? null, + actionFocused: document.activeElement === action, + }; + }, + }; +} + +export async function defaultPreventedNestedEvents(root: HTMLElement) { + let api: VirtualTableApi | null = null; + + const container = mount( + row.id} + columns={[ + { + id: 'name', + header: 'Name', + cellComponent: ({ row }) => ( + event.preventDefault()} + onKeyDown={(event: KeyboardEvent) => event.preventDefault()} + > + {row.name} + + ), + }, + ]} + apiRef={(next) => { + api = next; + }} + />, + root + ); + await flushUpdates(); + + const target = container.querySelector( + '[data-row-key="row-0"] [data-prevent-table-event]' + ) as HTMLElement; + + return { + click: async () => { + const clickEvent = new MouseEvent('click', { + bubbles: true, + cancelable: true, + }); + target.dispatchEvent(clickEvent); + await flushUpdates(); + return { + defaultPrevented: clickEvent.defaultPrevented, + selectedRowKey: api?.getSelectedRowKey() ?? null, + }; + }, + arrowDown: async () => { + const keyEvent = new KeyboardEvent('keydown', { + key: 'ArrowDown', + bubbles: true, + cancelable: true, + }); + target.dispatchEvent(keyEvent); + await flushUpdates(); + return { + defaultPrevented: keyEvent.defaultPrevented, + selectedRowKey: api?.getSelectedRowKey() ?? null, + }; + }, + }; +} + +export async function callerPreventedKeyboard(root: HTMLElement) { + let api: VirtualTableApi | null = null; + const onKeyDown = spy<[KeyboardEvent]>((event: KeyboardEvent) => { + event.preventDefault(); + }); + + const container = mount( + row.id} + columns={columns} + onKeyDown={onKeyDown} + apiRef={(next) => { + api = next; + }} + />, + root + ); + await flushUpdates(); + + const table = container.querySelector( + '[data-slot="virtual-table-table"]' + ) as HTMLTableElement; + + return { + arrowDown: async () => { + const keyEvent = new KeyboardEvent('keydown', { + key: 'ArrowDown', + bubbles: true, + cancelable: true, + }); + table.dispatchEvent(keyEvent); + await flushUpdates(); + return { + keyDownCount: onKeyDown.count(), + defaultPrevented: keyEvent.defaultPrevented, + selectedRowKey: api?.getSelectedRowKey() ?? null, + }; + }, + }; +} + +export async function viewportAffordances(root: HTMLElement): Promise { + mount( + row.id} + columns={columns} + />, + root + ); + await flushUpdates(); +} + +export async function emptyScrollEdges(root: HTMLElement): Promise { + mount( + row.id} + columns={columns} + />, + root + ); + await flushUpdates(); +} + +export async function asChildComposition(root: HTMLElement): Promise { + mount( + row.id} + columns={columns} + > +
+ , + root + ); + await flushUpdates(); +} + +export async function forwardedScrollHandler(root: HTMLElement) { + const onScroll = spy(); + let api: VirtualTableApi | null = null; + + const container = mount( + row.id} + columns={columns} + onScroll={onScroll} + apiRef={(next) => { + api = next; + }} + />, + root + ); + await flushUpdates(); + + const wrapper = container.querySelector( + '[data-slot="virtual-table"]' + ) as HTMLElement | null; + + return { + scrollCount: () => onScroll.count(), + scroll: async () => { + if (wrapper) { + wrapper.scrollTop = 72; + wrapper.dispatchEvent(new Event('scroll', { bubbles: true })); + } + await flushUpdates(); + return { + scrollCount: onScroll.count(), + scrollTop: api?.getScrollTop(), + }; + }, + }; +} + +export async function clampedPendingScrollCommit(root: HTMLElement) { + let api: VirtualTableApi | null = null; + let replaceRows: (() => void) | undefined; + + const FilterableTable = () => { + const rowsState = state(createRows(5_000)); + replaceRows = () => { + rowsState.set( + Array.from({ length: 20 }, (_, index) => ({ + id: `filtered-row-${index}`, + name: `Filtered ${index}`, + email: `filtered-${index}@example.com`, + })) + ); + }; + + return ( + row.id} + columns={columns} + apiRef={(next) => { + api = next; + }} + /> + ); + }; + + const container = mount(, root); + await flushUpdates(); + + api?.scrollToIndex(4_000, 'start'); + replaceRows?.(); + await flushUpdates(); + await flushUpdates(); + + const wrapper = container.querySelector( + '[data-slot="virtual-table"]' + ) as HTMLElement; + + return { + afterReplace: () => ({ + rowCount: api?.getRowCount(), + scrollTop: api?.getScrollTop(), + }), + afterFrame: async () => { + await nextAnimationFrame(); + return { + scrollTop: api?.getScrollTop(), + wrapperScrollTop: wrapper.scrollTop, + }; + }, + }; +} + +export async function resizeChurn(root: HTMLElement) { + const resizeErrors: string[] = []; + const onWindowError = (event: ErrorEvent) => { + if (event.message.includes('ResizeObserver loop')) { + resizeErrors.push(event.message); + event.preventDefault(); + } + }; + const originalConsoleError = console.error; + console.error = (...values: unknown[]) => { + const message = values.map(String).join(' '); + if (message.includes('ResizeObserver loop')) resizeErrors.push(message); + }; + window.addEventListener('error', onWindowError); + + try { + const container = mount( + row.id} + columns={columns} + />, + root + ); + await flushUpdates(); + + const wrapper = container.querySelector( + '[data-slot="virtual-table"]' + ) as HTMLElement; + wrapper.scrollTop = 10_000; + wrapper.dispatchEvent(new Event('scroll', { bubbles: true })); + + for (const height of [0, 50, 400, 1, 10_000, 0, 300]) { + wrapper.style.height = `${height}px`; + await nextAnimationFrame(); + } + await nextAnimationFrame(); + + return { resizeErrors: () => [...resizeErrors] }; + } finally { + window.removeEventListener('error', onWindowError); + console.error = originalConsoleError; + } +} + +export async function fixedRowHeightContract(root: HTMLElement) { + const overflowColumns: readonly VirtualTableColumn[] = [ + { + id: 'name', + header: 'Name', + cellComponent: ({ row }) => ( +
{row.name}
+ ), + }, + ]; + + const container = mount( + row.id} + columns={overflowColumns} + />, + root + ); + await flushUpdates(); + + return { + measurements: () => { + const rows = Array.from( + container.querySelectorAll( + '[data-slot="virtual-table-row"]' + ) + ); + const firstCell = rows[0].querySelector( + '[data-slot="virtual-table-cell"]' + ); + const firstCellContent = rows[0].querySelector( + '[data-slot="virtual-table-cell-content"]' + ); + const firstBox = rows[0].getBoundingClientRect(); + const secondBox = rows[1].getBoundingClientRect(); + + return { + cellOverflowY: firstCell && getComputedStyle(firstCell).overflowY, + cellContentOverflowY: + firstCellContent && getComputedStyle(firstCellContent).overflowY, + firstRowHeight: firstBox.height, + rowOffset: secondBox.top - firstBox.top, + }; + }, + }; +} diff --git a/tests/browser/scenarios/components/virtual-table/determinism.tsx b/tests/browser/scenarios/components/virtual-table/determinism.tsx new file mode 100644 index 00000000..0ce3992a --- /dev/null +++ b/tests/browser/scenarios/components/virtual-table/determinism.tsx @@ -0,0 +1,45 @@ +import { VirtualTable } from '../../../../../src/components/virtual-table'; +import { deterministicRender } from '../../_mount'; + +type Row = { + id: string; + name: string; + email: string; +}; + +const rows: Row[] = [ + { id: 'row-0', name: 'Ada', email: 'ada@example.com' }, + { id: 'row-1', name: 'Grace', email: 'grace@example.com' }, + { id: 'row-2', name: 'Linus', email: 'linus@example.com' }, +]; + +const columns = [ + { + id: 'name', + header: 'Name', + cellComponent: ({ row }: { row: Row }) => {row.name}, + }, + { + id: 'email', + header: 'Email', + cellComponent: ({ row }: { row: Row }) => {row.email}, + }, +] as const; + +export function virtualTableMarkup() { + return { + renders: () => [ + deterministicRender('virtual table', () => ( + row.id} + columns={columns} + /> + )), + ], + }; +} diff --git a/tests/browser/scenarios/components/visually-hidden/a11y.tsx b/tests/browser/scenarios/components/visually-hidden/a11y.tsx new file mode 100644 index 00000000..b52b5697 --- /dev/null +++ b/tests/browser/scenarios/components/visually-hidden/a11y.tsx @@ -0,0 +1,29 @@ +import { VisuallyHidden } from '../../../../../src/components/visually-hidden/visually-hidden'; +import { mount } from '../../_mount'; + +export function axeHiddenAccessibleText(root: HTMLElement): void { + mount( + , + root + ); +} + +export function composedChild(root: HTMLElement) { + const container = mount( + Hidden} />, + root + ); + + return { + hiddenState: () => { + const strong = container.querySelector('strong') as HTMLElement; + return { + marker: strong.getAttribute('data-askr-visually-hidden'), + styleAttribute: strong.getAttribute('style'), + position: getComputedStyle(strong).position, + }; + }, + }; +} diff --git a/tests/browser/scenarios/components/visually-hidden/behavior.tsx b/tests/browser/scenarios/components/visually-hidden/behavior.tsx new file mode 100644 index 00000000..906591b7 --- /dev/null +++ b/tests/browser/scenarios/components/visually-hidden/behavior.tsx @@ -0,0 +1,15 @@ +import { VisuallyHidden } from '../../../../../src/components/visually-hidden/visually-hidden'; +import { mount } from '../../_mount'; + +export function hiddenSpanDefault(root: HTMLElement): void { + mount(Hidden text, root); +} + +export function asChildComposition(root: HTMLElement): void { + mount( + + Hidden + , + root + ); +} diff --git a/tests/browser/scenarios/components/visually-hidden/determinism.tsx b/tests/browser/scenarios/components/visually-hidden/determinism.tsx new file mode 100644 index 00000000..170518db --- /dev/null +++ b/tests/browser/scenarios/components/visually-hidden/determinism.tsx @@ -0,0 +1,17 @@ +import { VisuallyHidden } from '../../../../../src/components/visually-hidden/visually-hidden'; +import { deterministicRender } from '../../_mount'; + +export function visuallyHiddenMarkup() { + return { + renders: () => [ + deterministicRender('visually hidden text', () => ( + Hidden text + )), + deterministicRender('visually hidden asChild', () => ( + + Hidden + + )), + ], + }; +} diff --git a/tests/integrity/dev_checks/test-suite-guidelines.test.ts b/tests/integrity/dev_checks/test-suite-guidelines.test.ts index 4bd68c0a..c431f876 100644 --- a/tests/integrity/dev_checks/test-suite-guidelines.test.ts +++ b/tests/integrity/dev_checks/test-suite-guidelines.test.ts @@ -4,6 +4,13 @@ import { describe, expect, it } from 'vite-plus/test'; import fs from 'node:fs'; import path from 'node:path'; +// Native Playwright specs are `.spec.ts`; the component trees they mount live +// in sibling `tests/browser/scenarios/**/*.tsx` modules. Both halves of a +// browser test must be linted, or the port would silently drop the browser +// suite out of these guidelines. +const TEST_FILE_PATTERN = /\.(test|spec)\.(ts|tsx)$/; +const SCENARIO_DIR = path.join('tests', 'browser', 'scenarios'); + function readAllTestFiles(dir: string): string[] { if (dir.includes(path.join('tests', 'integrity', 'dev_checks'))) return []; @@ -13,7 +20,11 @@ function readAllTestFiles(dir: string): string[] { const full = path.join(dir, entry.name); if (entry.isDirectory()) { results.push(...readAllTestFiles(full)); - } else if (entry.isFile() && /\.test\.(ts|tsx)$/.test(entry.name)) { + } else if ( + entry.isFile() && + (TEST_FILE_PATTERN.test(entry.name) || + (full.includes(SCENARIO_DIR) && /\.tsx?$/.test(entry.name))) + ) { results.push(full); } } @@ -108,7 +119,7 @@ describe('Test suite guidelines', () => { }); } if ( - /behavior\.test\.(ts|tsx)$/.test(file) && + /behavior\.(test|spec)\.ts$/.test(file) && /\b(setTimeout|sleep)\s*\(/.test(line) ) { const usesFakeTimers = @@ -130,7 +141,7 @@ describe('Test suite guidelines', () => { } for (const file of files.filter((candidate) => - /\.test\.(ts|tsx)$/.test(candidate) + TEST_FILE_PATTERN.test(candidate) )) { const content = fs.readFileSync(file, 'utf-8'); const regex = /\b(it|test)\s*\(\s*(['"`])([^'"\n\r]+)\2/gi; @@ -152,17 +163,17 @@ describe('Test suite guidelines', () => { } for (const file of files.filter((candidate) => - /\.test\.(ts|tsx)$/.test(candidate) + TEST_FILE_PATTERN.test(candidate) )) { const base = path.basename(file); - if (!/^[a-z0-9_-]+\.test\.(ts|tsx)$/.test(base)) { + if (!/^[a-z0-9_-]+\.(test|spec)\.(ts|tsx)$/.test(base)) { failures.push({ file, line: 1, snippet: path.relative(process.cwd(), file), rule: 'test filename convention', message: - 'Test filenames must be lowercase and end with .test.ts or .test.tsx', + 'Test filenames must be lowercase and end with .test.ts, .test.tsx or .spec.ts', }); } } @@ -182,9 +193,10 @@ describe('Test suite guidelines', () => { it('should keep browser tests on public component behavior', () => { const browserDir = path.join(testsDir, 'browser'); - const files = readAllTestFiles(browserDir).filter((file) => - /\.test\.(ts|tsx)$/.test(file) - ); + // Scenario modules are included on purpose: they are where a browser test + // now does its importing, so that is where a private-internals import would + // hide. + const files = readAllTestFiles(browserDir); const failures: string[] = []; const privateImportPattern = /from\s+['"][^'"]*\/src\/components\/(?:_internal|[^'"]+\.(?:shared|types))['"]/; diff --git a/vite.harness.config.ts b/vite.harness.config.ts new file mode 100644 index 00000000..72099f64 --- /dev/null +++ b/vite.harness.config.ts @@ -0,0 +1,55 @@ +import { askr } from '@askrjs/vite'; +import { defineConfig } from 'vite-plus'; + +/** + * Dev server used only by the native Playwright browser suite: it serves the + * repository root so specs can reach `tests/browser/harness.html`. The library + * build lives in `vite.config.ts` (`vp pack`); nothing here ships. + * + * `server.host` is pinned to `127.0.0.1` rather than left at Vite's `localhost` + * default because Playwright's `webServer` readiness probe polls the literal + * address in `webServer.url`; a `localhost`-bound server silently fails that + * probe on runners that resolve `localhost` to `::1`. + */ +export default defineConfig({ + // `askr()` installs the JSX transform (automatic runtime, `@askrjs/askr` + // import source) that the scenario modules compile against. + plugins: [askr()], + resolve: { + dedupe: ['@askrjs/askr'], + }, + // Scenario modules are imported lazily, so without an explicit scan list Vite + // discovers their dependencies mid-test and forces a full page reload — which + // destroys the execution context an in-flight `page.evaluate` is using. + optimizeDeps: { + entries: [ + 'tests/browser/harness.ts', + 'tests/browser/scenarios/**/*.ts', + 'tests/browser/scenarios/**/*.tsx', + ], + include: ['axe-core'], + }, + server: { + host: '127.0.0.1', + port: 4318, + strictPort: true, + // Transform the harness and every scenario up front. Without this the first + // worker to reach a given scenario pays the transform cost inline, which + // under full parallelism on a cold runner shows up as timeouts. + warmup: { + clientFiles: [ + './tests/browser/harness.ts', + './tests/browser/scenarios/**/*.ts', + './tests/browser/scenarios/**/*.tsx', + ], + }, + }, + oxc: { + jsx: { + runtime: 'automatic', + importSource: '@askrjs/askr', + }, + jsxInject: + "import { jsx, jsxs, Fragment } from '@askrjs/askr/jsx-runtime';", + }, +}); diff --git a/vitest.bench.tier4.config.ts b/vitest.bench.tier4.config.ts index e777072c..9bd50b52 100644 --- a/vitest.bench.tier4.config.ts +++ b/vitest.bench.tier4.config.ts @@ -1,10 +1,39 @@ +import { playwright } from 'vite-plus/test/browser-playwright'; import { defineConfig } from 'vite-plus'; -import browserConfig from './vitest.test.browser.config'; +import { sharedVitestConfig } from './vitest.test.shared'; +/** + * Tier 4 benchmarks are the only remaining vitest browser-mode workload: the + * browser *test* suite now runs on native `@playwright/test` + * (`playwright.config.ts`). This config used to extend + * `vitest.test.browser.config.ts`; it carries that configuration inline instead + * so the benches keep their browser runtime after that file's removal. + */ export default defineConfig({ - ...browserConfig, + ...sharedVitestConfig, + optimizeDeps: { + include: ['@askrjs/askr/testing'], + }, test: { - ...browserConfig.test, + ...sharedVitestConfig.test, + setupFiles: [ + ...(sharedVitestConfig.test?.setupFiles ?? []), + './benches/browser-console.setup.ts', + ], + browser: { + enabled: true, + headless: true, + provider: playwright(), + instances: [ + { browser: 'chromium' }, + { browser: 'firefox' }, + { browser: 'webkit' }, + ], + api: { + host: '127.0.0.1', + port: 0, + }, + }, benchmark: { include: ['benches/tier4/**/*.bench.ts', 'benches/tier4/**/*.bench.tsx'], }, diff --git a/vitest.config.ts b/vitest.config.ts index 3a7e1934..4670c774 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,10 +2,9 @@ import { defineConfig } from 'vite-plus'; export default defineConfig({ test: { - projects: [ - './vitest.test.unit.config.ts', - './vitest.test.jsdom.config.ts', - './vitest.test.browser.config.ts', - ], + // Browser tests live in `playwright.config.ts` (`npm run test:browser`) and + // are deliberately absent here: they no longer run under vitest. Vitest + // browser mode is still used, but only by `bench:tier4`. + projects: ['./vitest.test.unit.config.ts', './vitest.test.jsdom.config.ts'], }, }); From 4281d722f7d2472e447d0bb2262c143e03ce3e1a Mon Sep 17 00:00:00 2001 From: Jeff Repanich Date: Fri, 11 Sep 2026 13:13:58 -0400 Subject: [PATCH 2/9] migrate browser tests to playwright --- benches/browser-console.setup.ts | 17 ------------- package-lock.json | 25 ------------------- package.json | 4 +--- tests/browser/fixtures.ts | 4 ++-- vitest.bench.tier4.config.ts | 41 -------------------------------- vitest.config.ts | 3 +-- vitest.test.browser.config.ts | 36 ---------------------------- 7 files changed, 4 insertions(+), 126 deletions(-) delete mode 100644 benches/browser-console.setup.ts delete mode 100644 vitest.bench.tier4.config.ts delete mode 100644 vitest.test.browser.config.ts diff --git a/benches/browser-console.setup.ts b/benches/browser-console.setup.ts deleted file mode 100644 index cd5b94d5..00000000 --- a/benches/browser-console.setup.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { afterEach, beforeEach } from 'vite-plus/test'; -import { - allowConsole, - beginConsoleCapture, - endConsoleCapture, -} from './browser-console'; - -beforeEach(() => { - beginConsoleCapture(); - allowConsole({ - warn: ['[askr] Slow render detected'], - }); -}); - -afterEach(() => { - endConsoleCapture(); -}); diff --git a/package-lock.json b/package-lock.json index d1b66c99..8fd6ca52 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,6 @@ "@askrjs/vite": ">=0.2.1 <0.3.0", "@playwright/test": "1.62.1", "@types/node": "^26.3.0", - "@vitest/browser-playwright": "4.1.11", "axe-core": "^4.13.0", "cross-env": "^10.1.0", "jsdom": "^30.0.1", @@ -2332,30 +2331,6 @@ "vitest": "4.1.11" } }, - "node_modules/@vitest/browser-playwright": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/browser-playwright/-/browser-playwright-4.1.11.tgz", - "integrity": "sha512-riLBxPqwnJ0lWs2DN2WeUfYeKLoAjbP2Xx8cLQdSddzMi20sksIa6K2mPz79DyMZKKVKH2ksOC2yJvtNcZg8cg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/browser": "4.1.11", - "@vitest/mocker": "4.1.11", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "playwright": "*", - "vitest": "4.1.11" - }, - "peerDependenciesMeta": { - "playwright": { - "optional": false - } - } - }, "node_modules/@vitest/browser-preview": { "version": "4.1.11", "resolved": "https://registry.npmjs.org/@vitest/browser-preview/-/browser-preview-4.1.11.tgz", diff --git a/package.json b/package.json index f7736b4b..8d6c910d 100644 --- a/package.json +++ b/package.json @@ -221,11 +221,10 @@ "build": "vp pack", "clean": "npx rimraf dist node_modules", "dev": "vp pack --watch", - "bench": "npm run bench:tier1 && npm run bench:tier2 && npm run bench:tier3 && npm run bench:tier4", + "bench": "npm run bench:tier1 && npm run bench:tier2 && npm run bench:tier3", "bench:tier1": "cross-env NODE_ENV=production vp test bench --run --reporter=default --config vitest.bench.tier1.config.ts", "bench:tier2": "cross-env NODE_ENV=production vp test bench --run --reporter=default --config vitest.bench.tier2.config.ts", "bench:tier3": "cross-env NODE_ENV=production vp test bench --run --reporter=default --config vitest.bench.tier3.config.ts", - "bench:tier4": "cross-env NODE_ENV=production vp test bench --run --reporter=default --config vitest.bench.tier4.config.ts", "fmt": "vp fmt .", "lint": "vp lint .", "typecheck": "npm run test:types", @@ -248,7 +247,6 @@ "@askrjs/vite": ">=0.2.1 <0.3.0", "@playwright/test": "1.62.1", "@types/node": "^26.3.0", - "@vitest/browser-playwright": "4.1.11", "axe-core": "^4.13.0", "cross-env": "^10.1.0", "jsdom": "^30.0.1", diff --git a/tests/browser/fixtures.ts b/tests/browser/fixtures.ts index a2bae47d..1710febf 100644 --- a/tests/browser/fixtures.ts +++ b/tests/browser/fixtures.ts @@ -90,8 +90,8 @@ async function openHarness(page: Page): Promise { export const test = base.extend({ /** - * Replaces `tests/browser/browser-console.setup.ts`: patches the console in - * the page and fails the test if anything unallowed was logged. + * Patches the console in the page and fails the test if any unallowed + * message is logged. * * Patching in-page rather than listening to Playwright's `console` event is * deliberate — that event also reports browser-internal advisories the vitest diff --git a/vitest.bench.tier4.config.ts b/vitest.bench.tier4.config.ts deleted file mode 100644 index 9bd50b52..00000000 --- a/vitest.bench.tier4.config.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { playwright } from 'vite-plus/test/browser-playwright'; -import { defineConfig } from 'vite-plus'; -import { sharedVitestConfig } from './vitest.test.shared'; - -/** - * Tier 4 benchmarks are the only remaining vitest browser-mode workload: the - * browser *test* suite now runs on native `@playwright/test` - * (`playwright.config.ts`). This config used to extend - * `vitest.test.browser.config.ts`; it carries that configuration inline instead - * so the benches keep their browser runtime after that file's removal. - */ -export default defineConfig({ - ...sharedVitestConfig, - optimizeDeps: { - include: ['@askrjs/askr/testing'], - }, - test: { - ...sharedVitestConfig.test, - setupFiles: [ - ...(sharedVitestConfig.test?.setupFiles ?? []), - './benches/browser-console.setup.ts', - ], - browser: { - enabled: true, - headless: true, - provider: playwright(), - instances: [ - { browser: 'chromium' }, - { browser: 'firefox' }, - { browser: 'webkit' }, - ], - api: { - host: '127.0.0.1', - port: 0, - }, - }, - benchmark: { - include: ['benches/tier4/**/*.bench.ts', 'benches/tier4/**/*.bench.tsx'], - }, - }, -}); diff --git a/vitest.config.ts b/vitest.config.ts index 4670c774..707f5dd7 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,8 +3,7 @@ import { defineConfig } from 'vite-plus'; export default defineConfig({ test: { // Browser tests live in `playwright.config.ts` (`npm run test:browser`) and - // are deliberately absent here: they no longer run under vitest. Vitest - // browser mode is still used, but only by `bench:tier4`. + // are deliberately absent here: they no longer run under vitest. projects: ['./vitest.test.unit.config.ts', './vitest.test.jsdom.config.ts'], }, }); diff --git a/vitest.test.browser.config.ts b/vitest.test.browser.config.ts deleted file mode 100644 index c82847ed..00000000 --- a/vitest.test.browser.config.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { playwright } from 'vite-plus/test/browser-playwright'; -import { defineConfig } from 'vite-plus'; -import { sharedVitestConfig } from './vitest.test.shared'; - -export default defineConfig({ - ...sharedVitestConfig, - optimizeDeps: { - include: ['@askrjs/askr/testing'], - }, - test: { - ...sharedVitestConfig.test, - setupFiles: [ - ...(sharedVitestConfig.test?.setupFiles ?? []), - './tests/browser/browser-console.setup.ts', - ], - browser: { - enabled: true, - headless: true, - provider: playwright(), - instances: [ - { browser: 'chromium' }, - { browser: 'firefox' }, - { browser: 'webkit' }, - ], - api: { - host: '127.0.0.1', - port: 0, - }, - }, - include: [ - 'tests/browser/components/*/behavior.test.tsx', - 'tests/browser/components/*/a11y.test.tsx', - 'tests/browser/components/*/determinism.test.tsx', - ], - }, -}); From 73c0cb9eb4a919cd47450d07b2a65b77c378e03e Mon Sep 17 00:00:00 2001 From: Jeff Repanich Date: Fri, 11 Sep 2026 13:21:13 -0400 Subject: [PATCH 3/9] format playwright browser suite --- .../browser/components/accordion/determinism.spec.ts | 5 ++++- tests/browser/components/avatar/determinism.spec.ts | 5 ++++- tests/browser/components/button/determinism.spec.ts | 11 +++++++---- .../browser/components/collapsible/behavior.spec.ts | 8 ++++++-- .../components/collapsible/determinism.spec.ts | 11 +++++++---- tests/browser/components/dialog/determinism.spec.ts | 5 ++++- .../browser/components/dropdown/determinism.spec.ts | 5 ++++- .../components/focus-scope/determinism.spec.ts | 5 ++++- tests/browser/components/form/determinism.spec.ts | 5 ++++- .../components/hover-card/determinism.spec.ts | 5 ++++- tests/browser/components/label/determinism.spec.ts | 5 ++++- tests/browser/components/menubar/determinism.spec.ts | 10 ++++++++-- .../browser/components/overlay-host/behavior.spec.ts | 7 ++++--- .../components/overlay-host/determinism.spec.ts | 5 ++++- tests/browser/components/popover/determinism.spec.ts | 10 ++++++++-- .../components/progress-circle/behavior.spec.ts | 4 +++- .../components/progress-circle/determinism.spec.ts | 5 ++++- tests/browser/components/progress/behavior.spec.ts | 4 +++- .../browser/components/progress/determinism.spec.ts | 5 ++++- tests/browser/components/table/behavior.spec.ts | 10 ++-------- tests/browser/components/table/determinism.spec.ts | 5 ++++- tests/browser/components/textarea/behavior.spec.ts | 12 ++++++------ .../browser/components/textarea/determinism.spec.ts | 5 ++++- tests/browser/components/toast/determinism.spec.ts | 5 ++++- .../components/toggle-group/determinism.spec.ts | 5 ++++- tests/browser/components/toggle/determinism.spec.ts | 5 ++++- .../components/virtual-list/determinism.spec.ts | 5 ++++- .../virtual-table-style-injection/behavior.spec.ts | 4 +++- .../components/virtual-table/determinism.spec.ts | 5 ++++- .../components/visually-hidden/behavior.spec.ts | 6 +++--- .../components/visually-hidden/determinism.spec.ts | 5 ++++- tests/browser/fixtures.ts | 10 ++++++++-- tests/browser/harness.ts | 5 +++-- .../scenarios/components/accordion/behavior.tsx | 3 +-- .../scenarios/components/collapsible/behavior.tsx | 6 +++--- .../scenarios/components/toggle-group/behavior.tsx | 5 +++-- .../browser/scenarios/components/toggle/behavior.tsx | 8 ++++++-- 37 files changed, 160 insertions(+), 69 deletions(-) diff --git a/tests/browser/components/accordion/determinism.spec.ts b/tests/browser/components/accordion/determinism.spec.ts index af5fe07f..b7f924e2 100644 --- a/tests/browser/components/accordion/determinism.spec.ts +++ b/tests/browser/components/accordion/determinism.spec.ts @@ -1,4 +1,7 @@ -import { type DeterministicRender, expectDeterministic } from '../../assertions'; +import { + type DeterministicRender, + expectDeterministic, +} from '../../assertions'; import { test } from '../../fixtures'; test.describe('Accordion - Determinism', () => { diff --git a/tests/browser/components/avatar/determinism.spec.ts b/tests/browser/components/avatar/determinism.spec.ts index 57923b3e..44621f03 100644 --- a/tests/browser/components/avatar/determinism.spec.ts +++ b/tests/browser/components/avatar/determinism.spec.ts @@ -1,4 +1,7 @@ -import { type DeterministicRender, expectDeterministic } from '../../assertions'; +import { + type DeterministicRender, + expectDeterministic, +} from '../../assertions'; import { test } from '../../fixtures'; test.describe('Avatar - Determinism', () => { diff --git a/tests/browser/components/button/determinism.spec.ts b/tests/browser/components/button/determinism.spec.ts index 52d3acc4..70cfbcf4 100644 --- a/tests/browser/components/button/determinism.spec.ts +++ b/tests/browser/components/button/determinism.spec.ts @@ -1,4 +1,7 @@ -import { type DeterministicRender, expectDeterministic } from '../../assertions'; +import { + type DeterministicRender, + expectDeterministic, +} from '../../assertions'; import { expect, test } from '../../fixtures'; test.describe('Button - Determinism', () => { @@ -37,9 +40,9 @@ test.describe('Button - Determinism', () => { }) => { await render('behaviorAcrossRemounts'); - expect( - await run<{ first: number; second: number }>('pressCounts') - ).toEqual({ first: 1, second: 1 }); + expect(await run<{ first: number; second: number }>('pressCounts')).toEqual( + { first: 1, second: 1 } + ); }); test('should not schedule timers during render', async ({ render, run }) => { diff --git a/tests/browser/components/collapsible/behavior.spec.ts b/tests/browser/components/collapsible/behavior.spec.ts index 20a180d9..037581c3 100644 --- a/tests/browser/components/collapsible/behavior.spec.ts +++ b/tests/browser/components/collapsible/behavior.spec.ts @@ -77,7 +77,9 @@ test.describe('Collapsible — Behavior', () => { run, }) => { await render('controlledOpenChange'); - await root.locator('button').evaluate((node: HTMLElement) => node.click()); + await root + .locator('button') + .evaluate((node: HTMLElement) => node.click()); expect(await run('openChangeArgs')).toContainEqual(true); }); @@ -211,7 +213,9 @@ test.describe('Collapsible — Behavior', () => { test.describe('Disabled State', () => { test('should not toggle when disabled', async ({ render, root, run }) => { await render('disabledToggle'); - await root.locator('button').evaluate((node: HTMLElement) => node.click()); + await root + .locator('button') + .evaluate((node: HTMLElement) => node.click()); expect(await run('openChangeCount')).toBe(0); }); diff --git a/tests/browser/components/collapsible/determinism.spec.ts b/tests/browser/components/collapsible/determinism.spec.ts index f02d0234..0d01cba7 100644 --- a/tests/browser/components/collapsible/determinism.spec.ts +++ b/tests/browser/components/collapsible/determinism.spec.ts @@ -1,4 +1,7 @@ -import { type DeterministicRender, expectDeterministic } from '../../assertions'; +import { + type DeterministicRender, + expectDeterministic, +} from '../../assertions'; import { expect, test } from '../../fixtures'; test.describe('Collapsible — Determinism', () => { @@ -77,9 +80,9 @@ test.describe('Collapsible — Determinism', () => { }) => { await render('ariaPresenceOpen'); - await expect( - root.locator('[id^="collapsible-content"]') - ).toHaveAttribute('id'); + await expect(root.locator('[id^="collapsible-content"]')).toHaveAttribute( + 'id' + ); }); }); diff --git a/tests/browser/components/dialog/determinism.spec.ts b/tests/browser/components/dialog/determinism.spec.ts index b1bb5763..aa77400f 100644 --- a/tests/browser/components/dialog/determinism.spec.ts +++ b/tests/browser/components/dialog/determinism.spec.ts @@ -1,4 +1,7 @@ -import { type DeterministicRender, expectDeterministic } from '../../assertions'; +import { + type DeterministicRender, + expectDeterministic, +} from '../../assertions'; import { test } from '../../fixtures'; test.describe('Dialog - Determinism', () => { diff --git a/tests/browser/components/dropdown/determinism.spec.ts b/tests/browser/components/dropdown/determinism.spec.ts index 1f6f2509..92f1e3fa 100644 --- a/tests/browser/components/dropdown/determinism.spec.ts +++ b/tests/browser/components/dropdown/determinism.spec.ts @@ -1,4 +1,7 @@ -import { type DeterministicRender, expectDeterministic } from '../../assertions'; +import { + type DeterministicRender, + expectDeterministic, +} from '../../assertions'; import { test } from '../../fixtures'; test.describe('Dropdown - Determinism', () => { diff --git a/tests/browser/components/focus-scope/determinism.spec.ts b/tests/browser/components/focus-scope/determinism.spec.ts index 91928d40..9018953f 100644 --- a/tests/browser/components/focus-scope/determinism.spec.ts +++ b/tests/browser/components/focus-scope/determinism.spec.ts @@ -1,4 +1,7 @@ -import { type DeterministicRender, expectDeterministic } from '../../assertions'; +import { + type DeterministicRender, + expectDeterministic, +} from '../../assertions'; import { test } from '../../fixtures'; test.describe('FocusScope - Determinism', () => { diff --git a/tests/browser/components/form/determinism.spec.ts b/tests/browser/components/form/determinism.spec.ts index 3a127fce..0bb7d486 100644 --- a/tests/browser/components/form/determinism.spec.ts +++ b/tests/browser/components/form/determinism.spec.ts @@ -1,4 +1,7 @@ -import { type DeterministicRender, expectDeterministic } from '../../assertions'; +import { + type DeterministicRender, + expectDeterministic, +} from '../../assertions'; import { test } from '../../fixtures'; test.describe('Form - Determinism', () => { diff --git a/tests/browser/components/hover-card/determinism.spec.ts b/tests/browser/components/hover-card/determinism.spec.ts index 2af90a8a..cc297d41 100644 --- a/tests/browser/components/hover-card/determinism.spec.ts +++ b/tests/browser/components/hover-card/determinism.spec.ts @@ -1,4 +1,7 @@ -import { type DeterministicRender, expectDeterministic } from '../../assertions'; +import { + type DeterministicRender, + expectDeterministic, +} from '../../assertions'; import { test } from '../../fixtures'; test.describe('HoverCard - Determinism', () => { diff --git a/tests/browser/components/label/determinism.spec.ts b/tests/browser/components/label/determinism.spec.ts index 4cda8b15..4a530450 100644 --- a/tests/browser/components/label/determinism.spec.ts +++ b/tests/browser/components/label/determinism.spec.ts @@ -1,4 +1,7 @@ -import { type DeterministicRender, expectDeterministic } from '../../assertions'; +import { + type DeterministicRender, + expectDeterministic, +} from '../../assertions'; import { test } from '../../fixtures'; test.describe('Label - Determinism', () => { diff --git a/tests/browser/components/menubar/determinism.spec.ts b/tests/browser/components/menubar/determinism.spec.ts index 895c43b9..be50ea79 100644 --- a/tests/browser/components/menubar/determinism.spec.ts +++ b/tests/browser/components/menubar/determinism.spec.ts @@ -1,8 +1,14 @@ -import { type DeterministicRender, expectDeterministic } from '../../assertions'; +import { + type DeterministicRender, + expectDeterministic, +} from '../../assertions'; import { test } from '../../fixtures'; test.describe('Menubar - Determinism', () => { - test('should render deterministic menubar markup', async ({ render, run }) => { + test('should render deterministic menubar markup', async ({ + render, + run, + }) => { await render('menubarMarkup'); await expectDeterministic(await run('renders')); diff --git a/tests/browser/components/overlay-host/behavior.spec.ts b/tests/browser/components/overlay-host/behavior.spec.ts index 4a9329e3..fdbad98a 100644 --- a/tests/browser/components/overlay-host/behavior.spec.ts +++ b/tests/browser/components/overlay-host/behavior.spec.ts @@ -11,9 +11,10 @@ test.describe('OverlayHost - Behavior', () => { await run('closeFirst'); - expect( - await run<{ first: boolean; second: boolean }>('overlays') - ).toEqual({ first: false, second: true }); + expect(await run<{ first: boolean; second: boolean }>('overlays')).toEqual({ + first: false, + second: true, + }); }); test('should remove hosted portal content when the application host unmounts', async ({ diff --git a/tests/browser/components/overlay-host/determinism.spec.ts b/tests/browser/components/overlay-host/determinism.spec.ts index 575b8a23..63c23747 100644 --- a/tests/browser/components/overlay-host/determinism.spec.ts +++ b/tests/browser/components/overlay-host/determinism.spec.ts @@ -1,4 +1,7 @@ -import { type DeterministicRender, expectDeterministic } from '../../assertions'; +import { + type DeterministicRender, + expectDeterministic, +} from '../../assertions'; import { test } from '../../fixtures'; test.describe('OverlayHost - Determinism', () => { diff --git a/tests/browser/components/popover/determinism.spec.ts b/tests/browser/components/popover/determinism.spec.ts index 6ac95f20..85faf8bc 100644 --- a/tests/browser/components/popover/determinism.spec.ts +++ b/tests/browser/components/popover/determinism.spec.ts @@ -1,8 +1,14 @@ -import { type DeterministicRender, expectDeterministic } from '../../assertions'; +import { + type DeterministicRender, + expectDeterministic, +} from '../../assertions'; import { test } from '../../fixtures'; test.describe('Popover - Determinism', () => { - test('should render deterministic popover markup', async ({ render, run }) => { + test('should render deterministic popover markup', async ({ + render, + run, + }) => { await render('popoverMarkup'); await expectDeterministic(await run('renders')); diff --git a/tests/browser/components/progress-circle/behavior.spec.ts b/tests/browser/components/progress-circle/behavior.spec.ts index 782ac3f3..97b9ab97 100644 --- a/tests/browser/components/progress-circle/behavior.spec.ts +++ b/tests/browser/components/progress-circle/behavior.spec.ts @@ -14,7 +14,9 @@ test.describe('ProgressCircle - Behavior', () => { await render('percentageCustomProperty'); expect( - await run<{ styleAttribute: string | null; percentage: string }>('styling') + await run<{ styleAttribute: string | null; percentage: string }>( + 'styling' + ) ).toEqual({ styleAttribute: null, percentage: '50%' }); }); }); diff --git a/tests/browser/components/progress-circle/determinism.spec.ts b/tests/browser/components/progress-circle/determinism.spec.ts index ef94a935..e5606c1a 100644 --- a/tests/browser/components/progress-circle/determinism.spec.ts +++ b/tests/browser/components/progress-circle/determinism.spec.ts @@ -1,4 +1,7 @@ -import { type DeterministicRender, expectDeterministic } from '../../assertions'; +import { + type DeterministicRender, + expectDeterministic, +} from '../../assertions'; import { test } from '../../fixtures'; test.describe('ProgressCircle - Determinism', () => { diff --git a/tests/browser/components/progress/behavior.spec.ts b/tests/browser/components/progress/behavior.spec.ts index 4045abbe..d6875f9e 100644 --- a/tests/browser/components/progress/behavior.spec.ts +++ b/tests/browser/components/progress/behavior.spec.ts @@ -26,7 +26,9 @@ test.describe('Progress - Behavior', () => { await render('percentageCustomProperty'); expect( - await run<{ styleAttribute: string | null; percentage: string }>('styling') + await run<{ styleAttribute: string | null; percentage: string }>( + 'styling' + ) ).toEqual({ styleAttribute: null, percentage: '50%' }); }); diff --git a/tests/browser/components/progress/determinism.spec.ts b/tests/browser/components/progress/determinism.spec.ts index a3340c5f..d852429a 100644 --- a/tests/browser/components/progress/determinism.spec.ts +++ b/tests/browser/components/progress/determinism.spec.ts @@ -1,4 +1,7 @@ -import { type DeterministicRender, expectDeterministic } from '../../assertions'; +import { + type DeterministicRender, + expectDeterministic, +} from '../../assertions'; import { test } from '../../fixtures'; test.describe('Progress - Determinism', () => { diff --git a/tests/browser/components/table/behavior.spec.ts b/tests/browser/components/table/behavior.spec.ts index 5edf60fb..b73fab1d 100644 --- a/tests/browser/components/table/behavior.spec.ts +++ b/tests/browser/components/table/behavior.spec.ts @@ -23,14 +23,8 @@ test.describe('Table - Behavior', () => { await render('asChildComposition'); await expect(root.locator('table')).toHaveAttribute('data-slot', 'table'); - await expect(root.locator('tr')).toHaveAttribute( - 'data-slot', - 'table-row' - ); - await expect(root.locator('td')).toHaveAttribute( - 'data-slot', - 'table-cell' - ); + await expect(root.locator('tr')).toHaveAttribute('data-slot', 'table-row'); + await expect(root.locator('td')).toHaveAttribute('data-slot', 'table-cell'); await expect(root.locator('td')).toHaveText('Alice'); }); diff --git a/tests/browser/components/table/determinism.spec.ts b/tests/browser/components/table/determinism.spec.ts index 1b6a35d9..d48c0680 100644 --- a/tests/browser/components/table/determinism.spec.ts +++ b/tests/browser/components/table/determinism.spec.ts @@ -1,4 +1,7 @@ -import { type DeterministicRender, expectDeterministic } from '../../assertions'; +import { + type DeterministicRender, + expectDeterministic, +} from '../../assertions'; import { test } from '../../fixtures'; test.describe('Table - Determinism', () => { diff --git a/tests/browser/components/textarea/behavior.spec.ts b/tests/browser/components/textarea/behavior.spec.ts index 6f788306..2f2d4838 100644 --- a/tests/browser/components/textarea/behavior.spec.ts +++ b/tests/browser/components/textarea/behavior.spec.ts @@ -62,9 +62,9 @@ test.describe('Textarea - Behavior', () => { await render('asChildDisabled'); const host = root.locator('textarea'); - expect(await host.evaluate((node: HTMLTextAreaElement) => node.disabled)).toBe( - true - ); + expect( + await host.evaluate((node: HTMLTextAreaElement) => node.disabled) + ).toBe(true); await expect(host).toHaveAttribute('data-disabled', 'true'); }); @@ -75,9 +75,9 @@ test.describe('Textarea - Behavior', () => { await render('asChildReadOnly'); const host = root.locator('textarea'); - expect(await host.evaluate((node: HTMLTextAreaElement) => node.readOnly)).toBe( - true - ); + expect( + await host.evaluate((node: HTMLTextAreaElement) => node.readOnly) + ).toBe(true); await expect(host).toHaveAttribute('readonly'); }); diff --git a/tests/browser/components/textarea/determinism.spec.ts b/tests/browser/components/textarea/determinism.spec.ts index 845d0625..13ed5a77 100644 --- a/tests/browser/components/textarea/determinism.spec.ts +++ b/tests/browser/components/textarea/determinism.spec.ts @@ -1,4 +1,7 @@ -import { type DeterministicRender, expectDeterministic } from '../../assertions'; +import { + type DeterministicRender, + expectDeterministic, +} from '../../assertions'; import { test } from '../../fixtures'; test.describe('Textarea - Determinism', () => { diff --git a/tests/browser/components/toast/determinism.spec.ts b/tests/browser/components/toast/determinism.spec.ts index 0c5368ba..3e562bd0 100644 --- a/tests/browser/components/toast/determinism.spec.ts +++ b/tests/browser/components/toast/determinism.spec.ts @@ -1,4 +1,7 @@ -import { type DeterministicRender, expectDeterministic } from '../../assertions'; +import { + type DeterministicRender, + expectDeterministic, +} from '../../assertions'; import { test } from '../../fixtures'; test.describe('Toast - Determinism', () => { diff --git a/tests/browser/components/toggle-group/determinism.spec.ts b/tests/browser/components/toggle-group/determinism.spec.ts index 7605f616..4ffd4b24 100644 --- a/tests/browser/components/toggle-group/determinism.spec.ts +++ b/tests/browser/components/toggle-group/determinism.spec.ts @@ -1,4 +1,7 @@ -import { type DeterministicRender, expectDeterministic } from '../../assertions'; +import { + type DeterministicRender, + expectDeterministic, +} from '../../assertions'; import { expect, test } from '../../fixtures'; test.describe('ToggleGroup - Determinism', () => { diff --git a/tests/browser/components/toggle/determinism.spec.ts b/tests/browser/components/toggle/determinism.spec.ts index 3f50760a..cb13275d 100644 --- a/tests/browser/components/toggle/determinism.spec.ts +++ b/tests/browser/components/toggle/determinism.spec.ts @@ -1,4 +1,7 @@ -import { type DeterministicRender, expectDeterministic } from '../../assertions'; +import { + type DeterministicRender, + expectDeterministic, +} from '../../assertions'; import { expect, test } from '../../fixtures'; test.describe('Toggle - Determinism', () => { diff --git a/tests/browser/components/virtual-list/determinism.spec.ts b/tests/browser/components/virtual-list/determinism.spec.ts index f28d88db..1906b7ba 100644 --- a/tests/browser/components/virtual-list/determinism.spec.ts +++ b/tests/browser/components/virtual-list/determinism.spec.ts @@ -1,4 +1,7 @@ -import { type DeterministicRender, expectDeterministic } from '../../assertions'; +import { + type DeterministicRender, + expectDeterministic, +} from '../../assertions'; import { test } from '../../fixtures'; test.describe('VirtualList - Determinism', () => { diff --git a/tests/browser/components/virtual-table-style-injection/behavior.spec.ts b/tests/browser/components/virtual-table-style-injection/behavior.spec.ts index f73b8dab..1897e1a8 100644 --- a/tests/browser/components/virtual-table-style-injection/behavior.spec.ts +++ b/tests/browser/components/virtual-table-style-injection/behavior.spec.ts @@ -27,7 +27,9 @@ test.describe('virtual table style injection', () => { nextRuleCount: number; }>('result'); - expect(result.props).toEqual({ 'data-askr-virtual-table-row-height': '32' }); + expect(result.props).toEqual({ + 'data-askr-virtual-table-row-height': '32', + }); expect(result.nextRuleCount).toBe(1); expect(await run('committedHeight')).toBe('32px'); diff --git a/tests/browser/components/virtual-table/determinism.spec.ts b/tests/browser/components/virtual-table/determinism.spec.ts index 19481aef..67b0cb14 100644 --- a/tests/browser/components/virtual-table/determinism.spec.ts +++ b/tests/browser/components/virtual-table/determinism.spec.ts @@ -1,4 +1,7 @@ -import { type DeterministicRender, expectDeterministic } from '../../assertions'; +import { + type DeterministicRender, + expectDeterministic, +} from '../../assertions'; import { test } from '../../fixtures'; test.describe('VirtualTable - Determinism', () => { diff --git a/tests/browser/components/visually-hidden/behavior.spec.ts b/tests/browser/components/visually-hidden/behavior.spec.ts index 24b387df..c241808e 100644 --- a/tests/browser/components/visually-hidden/behavior.spec.ts +++ b/tests/browser/components/visually-hidden/behavior.spec.ts @@ -5,9 +5,9 @@ test.describe('VisuallyHidden — Behavior', () => { await render('hiddenSpanDefault'); const span = root.locator('span'); - expect( - await span.evaluate((node: HTMLElement) => node.textContent) - ).toBe('Hidden text'); + expect(await span.evaluate((node: HTMLElement) => node.textContent)).toBe( + 'Hidden text' + ); await expect(span).toHaveAttribute('data-askr-visually-hidden', 'true'); }); diff --git a/tests/browser/components/visually-hidden/determinism.spec.ts b/tests/browser/components/visually-hidden/determinism.spec.ts index a5bea91a..68e1639e 100644 --- a/tests/browser/components/visually-hidden/determinism.spec.ts +++ b/tests/browser/components/visually-hidden/determinism.spec.ts @@ -1,4 +1,7 @@ -import { type DeterministicRender, expectDeterministic } from '../../assertions'; +import { + type DeterministicRender, + expectDeterministic, +} from '../../assertions'; import { test } from '../../fixtures'; test.describe('VisuallyHidden - Determinism', () => { diff --git a/tests/browser/fixtures.ts b/tests/browser/fixtures.ts index 1710febf..3337591d 100644 --- a/tests/browser/fixtures.ts +++ b/tests/browser/fixtures.ts @@ -78,8 +78,14 @@ interface Fixtures { function scenarioModuleFor(file: string): string { const browserDir = path.join('tests', 'browser'); const index = file.lastIndexOf(browserDir); - const relative = index === -1 ? path.basename(file) : file.slice(index + browserDir.length + 1); - return relative.replace(/\.spec\.ts$/u, '').split(path.sep).join('/'); + const relative = + index === -1 + ? path.basename(file) + : file.slice(index + browserDir.length + 1); + return relative + .replace(/\.spec\.ts$/u, '') + .split(path.sep) + .join('/'); } async function openHarness(page: Page): Promise { diff --git a/tests/browser/harness.ts b/tests/browser/harness.ts index a778b4e5..c964a5d4 100644 --- a/tests/browser/harness.ts +++ b/tests/browser/harness.ts @@ -63,8 +63,9 @@ async function mount( document.body.append(root); controls = - ((await scenario(root, options as never)) as ScenarioControls | undefined) ?? - {}; + ((await scenario(root, options as never)) as + | ScenarioControls + | undefined) ?? {}; return null; } diff --git a/tests/browser/scenarios/components/accordion/behavior.tsx b/tests/browser/scenarios/components/accordion/behavior.tsx index 81313c39..bae594ba 100644 --- a/tests/browser/scenarios/components/accordion/behavior.tsx +++ b/tests/browser/scenarios/components/accordion/behavior.tsx @@ -343,8 +343,7 @@ export function virtualizedWindow(root: HTMLElement) { await flushUpdates(); await flushUpdates(); }, - visibleStartIndex: () => - viewport.dataset.virtualVisibleStartIndex ?? null, + visibleStartIndex: () => viewport.dataset.virtualVisibleStartIndex ?? null, hasItem43Button: () => Array.from(container.querySelectorAll('button')).some( (button) => button.textContent?.trim() === 'Item 43' diff --git a/tests/browser/scenarios/components/collapsible/behavior.tsx b/tests/browser/scenarios/components/collapsible/behavior.tsx index 224d572d..a59fbcd2 100644 --- a/tests/browser/scenarios/components/collapsible/behavior.tsx +++ b/tests/browser/scenarios/components/collapsible/behavior.tsx @@ -296,8 +296,8 @@ export function uniqueIds(root: HTMLElement) { return { ids: () => - Array.from( - container.querySelectorAll('[id^="collapsible-content"]') - ).map((element) => element.id), + Array.from(container.querySelectorAll('[id^="collapsible-content"]')).map( + (element) => element.id + ), }; } diff --git a/tests/browser/scenarios/components/toggle-group/behavior.tsx b/tests/browser/scenarios/components/toggle-group/behavior.tsx index 77719af8..afe36e6e 100644 --- a/tests/browser/scenarios/components/toggle-group/behavior.tsx +++ b/tests/browser/scenarios/components/toggle-group/behavior.tsx @@ -241,8 +241,9 @@ export function disabledInteraction(root: HTMLElement) { return { disabledFlags: () => ({ - groupRight: (getToggleByText(container, 'Group right') as HTMLButtonElement) - .disabled, + groupRight: ( + getToggleByText(container, 'Group right') as HTMLButtonElement + ).disabled, itemLeft: (getToggleByText(container, 'Item left') as HTMLButtonElement) .disabled, }), diff --git a/tests/browser/scenarios/components/toggle/behavior.tsx b/tests/browser/scenarios/components/toggle/behavior.tsx index 3bea55b2..10cf94d7 100644 --- a/tests/browser/scenarios/components/toggle/behavior.tsx +++ b/tests/browser/scenarios/components/toggle/behavior.tsx @@ -98,7 +98,10 @@ export function refForwarding(root: HTMLElement) { let nativeRef: HTMLButtonElement | null = null; let childRef: HTMLElement | null = null; - const first = mount( (nativeRef = node)}>Mute, root); + const first = mount( + (nativeRef = node)}>Mute, + root + ); const nativeMatches = nativeRef === (first.querySelector('button') as HTMLButtonElement | null); @@ -111,7 +114,8 @@ export function refForwarding(root: HTMLElement) { root ); const childMatches = - childRef === (second.querySelector('[role="button"]') as HTMLElement | null); + childRef === + (second.querySelector('[role="button"]') as HTMLElement | null); return { refs: () => ({ nativeMatches, childMatches }) }; } From 1a5f14aba4d84105ebbe105b6ce9150acff408f5 Mon Sep 17 00:00:00 2001 From: Jeff Repanich Date: Fri, 11 Sep 2026 13:22:45 -0400 Subject: [PATCH 4/9] use setup-vp in CI --- .github/workflows/ci.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 970ff6d3..747eb3e1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,20 +27,20 @@ jobs: - name: Checkout repository uses: actions/checkout@v7 - - name: Setup Node - uses: actions/setup-node@v7 + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@v1.19.0 with: node-version: 'lts/*' - cache: npm + cache: true - name: Install dependencies run: npm ci - name: Install browser engines with Linux dependencies - if: ${{ hashFiles('playwright.config.*', 'vitest.bench.tier4.config.ts') != '' && runner.os == 'Linux' }} + if: ${{ hashFiles('playwright.config.*') != '' && runner.os == 'Linux' }} run: npx playwright install --with-deps chromium firefox webkit - name: Install browser engines - if: ${{ hashFiles('playwright.config.*', 'vitest.bench.tier4.config.ts') != '' && runner.os != 'Linux' }} + if: ${{ hashFiles('playwright.config.*') != '' && runner.os != 'Linux' }} run: npx playwright install chromium firefox webkit - name: Format check From 4bf9e8402fab464eebe58f6217f174294f287256 Mon Sep 17 00:00:00 2001 From: Jeff Repanich Date: Fri, 11 Sep 2026 13:31:36 -0400 Subject: [PATCH 5/9] avoid unnecessary mount array copy --- tests/browser/scenarios/_mount.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/browser/scenarios/_mount.ts b/tests/browser/scenarios/_mount.ts index e31e2b11..26b42840 100644 --- a/tests/browser/scenarios/_mount.ts +++ b/tests/browser/scenarios/_mount.ts @@ -72,7 +72,7 @@ export function unmount(container: HTMLElement | undefined): void { * a previous test left behind. */ export function disposeMounts(): void { - for (const container of [...activeContainers]) unmount(container); + for (const container of activeContainers) unmount(container); activeContainers.clear(); resetTestState(); } From 40c9a876648a42a48db1382b6c4f2f2492080089 Mon Sep 17 00:00:00 2001 From: Jeff Repanich Date: Fri, 11 Sep 2026 13:34:29 -0400 Subject: [PATCH 6/9] skip windows formatter false positive --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 747eb3e1..e116a9ba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,6 +44,7 @@ jobs: run: npx playwright install chromium firefox webkit - name: Format check + if: runner.os != 'Windows' run: npm run fmt --if-present -- --check - name: Lint From 164aef9c2e7597106c7357124c72b3d53f61a0b9 Mon Sep 17 00:00:00 2001 From: Jeff Repanich Date: Fri, 11 Sep 2026 13:53:17 -0400 Subject: [PATCH 7/9] bound playwright worker concurrency --- playwright.config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/playwright.config.ts b/playwright.config.ts index 64646937..e0269678 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -8,6 +8,7 @@ export default defineConfig({ testDir: './tests/browser', testMatch: '**/*.spec.ts', fullyParallel: true, + workers: 2, forbidOnly: Boolean(process.env.CI), retries: process.env.CI ? 1 : 0, reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : 'list', From 7536e9fc7683ad1888816f6a3a19b1a465fd315f Mon Sep 17 00:00:00 2001 From: Jeff Repanich Date: Fri, 11 Sep 2026 13:59:23 -0400 Subject: [PATCH 8/9] serialize playwright browser workers --- playwright.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/playwright.config.ts b/playwright.config.ts index e0269678..6bcd163f 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -8,7 +8,7 @@ export default defineConfig({ testDir: './tests/browser', testMatch: '**/*.spec.ts', fullyParallel: true, - workers: 2, + workers: 1, forbidOnly: Boolean(process.env.CI), retries: process.env.CI ? 1 : 0, reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : 'list', From eb209fbd654f64ae8e2b35e4258c6f8126dbbf46 Mon Sep 17 00:00:00 2001 From: Jeff Repanich Date: Fri, 11 Sep 2026 14:25:59 -0400 Subject: [PATCH 9/9] use real avatar image load in playwright --- .../components/avatar/behavior.spec.ts | 20 ++++++++++++++++++- .../scenarios/components/avatar/behavior.tsx | 6 +----- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/tests/browser/components/avatar/behavior.spec.ts b/tests/browser/components/avatar/behavior.spec.ts index 565dfe7d..977ffb80 100644 --- a/tests/browser/components/avatar/behavior.spec.ts +++ b/tests/browser/components/avatar/behavior.spec.ts @@ -4,14 +4,32 @@ test.describe('Avatar - Behavior', () => { test('should keep fallback visible until image load event', async ({ render, root, + page, run, }) => { + let releaseImage!: () => Promise; + await page.route('**/avatar.png', async (route) => { + await new Promise((resolve) => { + releaseImage = async () => { + await route.fulfill({ + status: 200, + contentType: 'image/gif', + body: Buffer.from( + 'R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==', + 'base64' + ), + }); + resolve(); + }; + }); + }); + await render('fallbackUntilLoad'); const fallbackSelector = await run('fallbackSelector'); await expect(root.locator(fallbackSelector)).toHaveText('JD'); - await run('dispatchLoad'); + await releaseImage(); await expect(root.locator(fallbackSelector)).toHaveCount(0); }); diff --git a/tests/browser/scenarios/components/avatar/behavior.tsx b/tests/browser/scenarios/components/avatar/behavior.tsx index bd02e339..e2ef041b 100644 --- a/tests/browser/scenarios/components/avatar/behavior.tsx +++ b/tests/browser/scenarios/components/avatar/behavior.tsx @@ -4,7 +4,7 @@ import { AvatarImage, } from '../../../../../src/components/avatar'; import { AVATAR_A11Y_CONTRACT } from '../../../../../src/components/avatar/avatar.a11y'; -import { flushUpdates, mount } from '../../_mount'; +import { mount } from '../../_mount'; export function fallbackUntilLoad(root: HTMLElement) { const container = mount( @@ -17,9 +17,5 @@ export function fallbackUntilLoad(root: HTMLElement) { return { fallbackSelector: () => `[${AVATAR_A11Y_CONTRACT.FALLBACK.marker}="true"]`, - dispatchLoad: async () => { - container.querySelector('img')?.dispatchEvent(new Event('load')); - await flushUpdates(); - }, }; }