From 0bbf86fe63f3f47ab4147466d552eb53f9f7e83d Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Tue, 8 Sep 2026 21:30:19 +0800 Subject: [PATCH] Run the unit tests in Chromium instead of jsdom Vitest browser mode replaces the jsdom environment for the client project. All 118 existing tests pass unchanged, in about the same time, at the same coverage. - Delete `test/setup.ts`. It only existed to stub `IntersectionObserver`, which jsdom does not implement. - Drop the `jsdom` dependency and add `@vitest/browser-playwright`. - Add unit tests for `inView`. They need a real `IntersectionObserver`, so the prop had no client-side test before. - Raise the coverage thresholds to match the new numbers. jsdom also implements no Web Animations API, so animated values were never really interpolated and several tests worked around its quirks. Those comments are corrected. A browser resolves an unset `opacity` to `1` rather than `0`, reading computed style off a detached node no longer throws, and real layout exists, so the layout stub is now for determinism rather than necessity. Building a `PointerEvent` by hand still defaults to `isPrimary: false`, so that workaround stays. Only Chromium runs here. The Playwright suite already covers Firefox and WebKit at the app level, which is where engine differences matter. Running the unit tests across all three surfaces two WebKit failures, in drag pointer capture and in focus-visible, that are better chased there. CI installs Chromium before the test job. The Node 22 pin stays, but its reason is now Vite 8 rather than the undici problem that came with jsdom. 135 tests pass. Lint, types, build, e2e and Prettier are clean. Co-Authored-By: Claude Opus 5 --- .github/workflows/test.yml | 9 +- .gitignore | 2 + .prettierignore | 2 + README.md | 10 +-- e2e/diagnostics.spec.ts | 4 +- package.json | 2 +- pnpm-lock.yaml | 168 +++++++++++++++++++++++++++++++------ test/drag.test.tsx | 6 +- test/engine.test.tsx | 101 +++++++++++++++++++++- test/layout.test.tsx | 8 +- test/presence.test.tsx | 8 +- test/primitives.test.tsx | 6 +- test/setup.ts | 26 ------ vitest.config.ts | 40 ++++----- 14 files changed, 294 insertions(+), 98 deletions(-) delete mode 100644 test/setup.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 444839b..2b93198 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -17,10 +17,7 @@ jobs: - uses: pnpm/action-setup@v4 - # Node 22, not 20: jsdom 30 pulls in undici, which reads - # `markAsUncloneable` off node:worker_threads unconditionally. That only - # exists from Node 22.10, so on Node 20 every jsdom test worker dies at - # startup with "[vitest-pool]: Failed to start forks worker". + # Vite 8 requires ^20.19.0 || >=22.12.0. - name: Setup Node.js environment uses: actions/setup-node@v4 with: @@ -33,6 +30,10 @@ jobs: - name: Build package run: pnpm run build + # The unit tests run in a real Chromium through Vitest browser mode. + - name: Install Playwright Chromium + run: pnpm exec playwright install --with-deps chromium + - name: Test run: pnpm run test diff --git a/.gitignore b/.gitignore index 0fe4f90..8543340 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ playwright-report coverage storybook-static +.vitest-attachments +test/__screenshots__ diff --git a/.prettierignore b/.prettierignore index d34c7b0..94e3100 100644 --- a/.prettierignore +++ b/.prettierignore @@ -4,3 +4,5 @@ pnpm-lock.yaml test-results playwright-report coverage +.vitest-attachments +test/__screenshots__ diff --git a/README.md b/README.md index 182aa80..cc6bf9f 100644 --- a/README.md +++ b/README.md @@ -644,17 +644,17 @@ That serves an index of every demo; each one is also reachable directly at `?dem ## Testing ```bash -pnpm test # Vitest: the state machine, in jsdom and in SSR +pnpm test # Vitest: the state machine, in Chromium and in SSR pnpm run test:coverage -pnpm run test:e2e # Playwright: real browsers, across Chromium, Firefox and WebKit +pnpm run test:e2e # Playwright: the app, across Chromium, Firefox and WebKit ``` +Both suites need a browser. Install them once with `pnpm exec playwright install chromium firefox webkit`. + The e2e suite serves the playground on port 5173. If that port is already taken — Vite's default, so it often is — set `PLAYWRIGHT_PORT` to something free, otherwise Playwright reuses whatever is already listening there and every test times out: ```bash PLAYWRIGHT_PORT=5199 pnpm run test:e2e ``` -Browsers are installed separately, once: `pnpm exec playwright install chromium firefox webkit`. - -Vitest covers the engine's own logic. Playwright covers everything jsdom cannot reach: real animation interpolation through the Web Animations API, real `IntersectionObserver` for `inView`, real pointer input for `hover`/`press`, and real scrolling for `useScroll`. +Vitest runs the unit tests in a real Chromium through browser mode, so they get real animation interpolation, a real `IntersectionObserver` and real computed styles. Playwright drives the whole playground app instead, across three engines. diff --git a/e2e/diagnostics.spec.ts b/e2e/diagnostics.spec.ts index 752a227..52376cb 100644 --- a/e2e/diagnostics.spec.ts +++ b/e2e/diagnostics.spec.ts @@ -3,8 +3,8 @@ import {expect, test} from "@playwright/test" /* Solid 2 reports reactivity mistakes as dev-mode console diagnostics rather than as failures — an unowned effect, a flush that is a silent no-op, a top-level -prop read that will never update. None of those surface in jsdom or in an -assertion, so this walks every playground demo in a real dev build and fails on +prop read that will never update. None of those surface as an assertion +failure, so this walks every playground demo in a real dev build and fails on any of them. The demo list is scraped from the playground's own index page rather than diff --git a/package.json b/package.json index cda4101..9d44eb1 100644 --- a/package.json +++ b/package.json @@ -63,11 +63,11 @@ "@types/node": "^22.12.0", "@typescript-eslint/eslint-plugin": "^6.17.0", "@typescript-eslint/parser": "^6.17.0", + "@vitest/browser-playwright": "4.1.11", "@vitest/coverage-v8": "^4.1.11", "eslint": "^8.56.0", "eslint-plugin-eslint-comments": "^3.2.0", "eslint-plugin-no-only-tests": "^3.1.0", - "jsdom": "^30.0.1", "prettier": "^3.1.1", "solid-js": "2.0.0-rc.5", "storybook": "^10.6.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 35a0f94..f8079ad 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -55,9 +55,12 @@ importers: '@typescript-eslint/parser': specifier: ^6.17.0 version: 6.21.0(eslint@8.57.1)(typescript@5.8.3) + '@vitest/browser-playwright': + specifier: 4.1.11 + version: 4.1.11(playwright@1.62.1)(vite@8.2.2(@types/node@22.20.1)(esbuild@0.25.3))(vitest@4.1.11) '@vitest/coverage-v8': specifier: ^4.1.11 - version: 4.1.11(vitest@4.1.11) + version: 4.1.11(@vitest/browser@4.1.11)(vitest@4.1.11) eslint: specifier: ^8.56.0 version: 8.57.1 @@ -67,9 +70,6 @@ importers: eslint-plugin-no-only-tests: specifier: ^3.1.0 version: 3.3.0 - jsdom: - specifier: ^30.0.1 - version: 30.0.1 prettier: specifier: ^3.1.1 version: 3.5.3 @@ -99,7 +99,7 @@ importers: version: 3.0.0-next.27(@solidjs/web@2.0.0-rc.5(solid-js@2.0.0-rc.5))(@testing-library/jest-dom@6.9.1)(solid-js@2.0.0-rc.5)(vite@8.2.2(@types/node@22.20.1)(esbuild@0.25.3)) vitest: specifier: ^4.1.11 - version: 4.1.11(@types/node@22.20.1)(@vitest/coverage-v8@4.1.11)(jsdom@30.0.1)(vite@8.2.2(@types/node@22.20.1)(esbuild@0.25.3)) + version: 4.1.11(@types/node@22.20.1)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(jsdom@30.0.1)(vite@8.2.2(@types/node@22.20.1)(esbuild@0.25.3)) packages: @@ -272,6 +272,9 @@ packages: resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} + '@blazediff/core@1.9.1': + resolution: {integrity: sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA==} + '@bramus/specificity@2.4.2': resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} hasBin: true @@ -810,6 +813,9 @@ packages: engines: {node: '>=20'} hasBin: true + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + '@rolldown/binding-android-arm-eabi@1.2.6': resolution: {integrity: sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1225,6 +1231,17 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + '@vitest/browser-playwright@4.1.11': + resolution: {integrity: sha512-riLBxPqwnJ0lWs2DN2WeUfYeKLoAjbP2Xx8cLQdSddzMi20sksIa6K2mPz79DyMZKKVKH2ksOC2yJvtNcZg8cg==} + peerDependencies: + playwright: '*' + vitest: 4.1.11 + + '@vitest/browser@4.1.11': + resolution: {integrity: sha512-bwMovvAeuTFOK5kIFevw4VEf+1gVEICv4SYK4k3knJOxl6b1zEWud8mYKD73e1B0odAn174h1MofURy2TPWf3w==} + peerDependencies: + vitest: 4.1.11 + '@vitest/coverage-v8@4.1.11': resolution: {integrity: sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==} peerDependencies: @@ -2030,6 +2047,10 @@ packages: motion-utils@13.0.0: resolution: {integrity: sha512-7DnN7TmbLcYXcG4RVadXIihWlyuM9afoUww8Y5Agg431kGKiuL2/OMyP4mJ5wLz+pvN3t5ySClLOaVXJ+wekRQ==} + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -2153,6 +2174,10 @@ packages: engines: {node: '>=20'} hasBin: true + pngjs@7.0.0: + resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} + engines: {node: '>=14.19.0'} + postcss-load-config@6.0.1: resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} engines: {node: '>= 18'} @@ -2298,6 +2323,10 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + sirv@3.0.2: + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} + engines: {node: '>=18'} + slash@3.0.0: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} @@ -2443,6 +2472,10 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + tough-cookie@6.0.2: resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==} engines: {node: '>=16'} @@ -2735,6 +2768,7 @@ snapshots: '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 lru-cache: 11.5.2 + optional: true '@asamuzakjp/dom-selector@8.3.2': dependencies: @@ -2742,6 +2776,7 @@ snapshots: css-tree: 3.2.1 is-potential-custom-element-name: 1.0.1 lru-cache: 11.5.2 + optional: true '@babel/code-frame@7.26.2': dependencies: @@ -2950,16 +2985,21 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} + '@blazediff/core@1.9.1': {} + '@bramus/specificity@2.4.2': dependencies: css-tree: 3.2.1 + optional: true - '@csstools/color-helpers@6.1.1': {} + '@csstools/color-helpers@6.1.1': + optional: true '@csstools/css-calc@3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': dependencies: '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 + optional: true '@csstools/css-color-parser@4.2.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': dependencies: @@ -2967,16 +3007,20 @@ snapshots: '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 + optional: true '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': dependencies: '@csstools/css-tokenizer': 4.0.0 + optional: true '@csstools/css-syntax-patches-for-csstree@1.1.12(css-tree@3.2.1)': optionalDependencies: css-tree: 3.2.1 + optional: true - '@csstools/css-tokenizer@4.0.0': {} + '@csstools/css-tokenizer@4.0.0': + optional: true '@dom-expressions/babel-plugin-jsx@0.50.0-next.44(@babel/core@7.26.10)': dependencies: @@ -3134,7 +3178,8 @@ snapshots: '@eslint/js@8.57.1': {} - '@exodus/bytes@1.15.1': {} + '@exodus/bytes@1.15.1': + optional: true '@humanwhocodes/config-array@0.13.0': dependencies: @@ -3350,6 +3395,8 @@ snapshots: dependencies: playwright: 1.62.1 + '@polka/url@1.0.0-next.29': {} + '@rolldown/binding-android-arm-eabi@1.2.6': optional: true @@ -3728,7 +3775,37 @@ snapshots: '@ungap/structured-clone@1.3.0': {} - '@vitest/coverage-v8@4.1.11(vitest@4.1.11)': + '@vitest/browser-playwright@4.1.11(playwright@1.62.1)(vite@8.2.2(@types/node@22.20.1)(esbuild@0.25.3))(vitest@4.1.11)': + dependencies: + '@vitest/browser': 4.1.11(vite@8.2.2(@types/node@22.20.1)(esbuild@0.25.3))(vitest@4.1.11) + '@vitest/mocker': 4.1.11(vite@8.2.2(@types/node@22.20.1)(esbuild@0.25.3)) + playwright: 1.62.1 + tinyrainbow: 3.1.1 + vitest: 4.1.11(@types/node@22.20.1)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(jsdom@30.0.1)(vite@8.2.2(@types/node@22.20.1)(esbuild@0.25.3)) + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + + '@vitest/browser@4.1.11(vite@8.2.2(@types/node@22.20.1)(esbuild@0.25.3))(vitest@4.1.11)': + dependencies: + '@blazediff/core': 1.9.1 + '@vitest/mocker': 4.1.11(vite@8.2.2(@types/node@22.20.1)(esbuild@0.25.3)) + '@vitest/utils': 4.1.11 + magic-string: 0.30.21 + pngjs: 7.0.0 + sirv: 3.0.2 + tinyrainbow: 3.1.1 + vitest: 4.1.11(@types/node@22.20.1)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(jsdom@30.0.1)(vite@8.2.2(@types/node@22.20.1)(esbuild@0.25.3)) + ws: 8.21.3 + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + + '@vitest/coverage-v8@4.1.11(@vitest/browser@4.1.11)(vitest@4.1.11)': dependencies: '@bcoe/v8-coverage': 1.0.2 '@vitest/utils': 4.1.11 @@ -3740,7 +3817,9 @@ snapshots: obug: 2.1.4 std-env: 4.2.0 tinyrainbow: 3.1.1 - vitest: 4.1.11(@types/node@22.20.1)(@vitest/coverage-v8@4.1.11)(jsdom@30.0.1)(vite@8.2.2(@types/node@22.20.1)(esbuild@0.25.3)) + vitest: 4.1.11(@types/node@22.20.1)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(jsdom@30.0.1)(vite@8.2.2(@types/node@22.20.1)(esbuild@0.25.3)) + optionalDependencies: + '@vitest/browser': 4.1.11(vite@8.2.2(@types/node@22.20.1)(esbuild@0.25.3))(vitest@4.1.11) '@vitest/expect@3.2.4': dependencies: @@ -3878,6 +3957,7 @@ snapshots: bidi-js@1.0.3: dependencies: require-from-string: 2.0.2 + optional: true brace-expansion@1.1.11: dependencies: @@ -3959,6 +4039,7 @@ snapshots: dependencies: mdn-data: 2.27.1 source-map-js: 1.2.1 + optional: true css.escape@1.5.1: {} @@ -3970,12 +4051,14 @@ snapshots: whatwg-url: 16.0.1 transitivePeerDependencies: - '@noble/hashes' + optional: true debug@4.4.0: dependencies: ms: 2.1.3 - decimal.js@10.6.0: {} + decimal.js@10.6.0: + optional: true deep-eql@5.0.2: {} @@ -4016,7 +4099,8 @@ snapshots: entities@6.0.0: {} - entities@8.0.0: {} + entities@8.0.0: + optional: true es-module-lexer@2.3.2: {} @@ -4268,6 +4352,7 @@ snapshots: '@exodus/bytes': 1.15.1 transitivePeerDependencies: - '@noble/hashes' + optional: true html-entities@2.3.3: {} @@ -4309,7 +4394,8 @@ snapshots: is-path-inside@3.0.3: {} - is-potential-custom-element-name@1.0.1: {} + is-potential-custom-element-name@1.0.1: + optional: true is-what@4.1.16: {} @@ -4373,6 +4459,7 @@ snapshots: xml-name-validator: 5.0.0 transitivePeerDependencies: - '@noble/hashes' + optional: true jsesc@3.1.0: {} @@ -4462,7 +4549,8 @@ snapshots: lru-cache@10.4.3: {} - lru-cache@11.5.2: {} + lru-cache@11.5.2: + optional: true lru-cache@5.1.1: dependencies: @@ -4484,7 +4572,8 @@ snapshots: dependencies: semver: 7.7.1 - mdn-data@2.27.1: {} + mdn-data@2.27.1: + optional: true merge-anything@5.1.7: dependencies: @@ -4519,6 +4608,8 @@ snapshots: motion-utils@13.0.0: {} + mrmime@2.0.1: {} + ms@2.1.3: {} mz@2.7.0: @@ -4625,6 +4716,7 @@ snapshots: parse5@8.0.1: dependencies: entities: 8.0.0 + optional: true path-browserify@1.0.1: {} @@ -4663,6 +4755,8 @@ snapshots: optionalDependencies: fsevents: 2.3.2 + pngjs@7.0.0: {} + postcss-load-config@6.0.1(postcss@8.5.26): dependencies: lilconfig: 3.1.3 @@ -4710,7 +4804,8 @@ snapshots: regenerator-runtime@0.14.1: {} - require-from-string@2.0.2: {} + require-from-string@2.0.2: + optional: true resolve-from@4.0.0: {} @@ -4778,6 +4873,7 @@ snapshots: saxes@6.0.0: dependencies: xmlchars: 2.2.0 + optional: true semver@6.3.1: {} @@ -4801,6 +4897,12 @@ snapshots: signal-exit@4.1.0: {} + sirv@3.0.2: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + slash@3.0.0: {} solid-js@2.0.0-rc.5: @@ -4904,7 +5006,8 @@ snapshots: dependencies: has-flag: 4.0.0 - symbol-tree@3.2.4: {} + symbol-tree@3.2.4: + optional: true text-table@0.2.0: {} @@ -4940,19 +5043,24 @@ snapshots: tinyspy@4.0.6: {} - tldts-core@7.4.11: {} + tldts-core@7.4.11: + optional: true tldts@7.4.11: dependencies: tldts-core: 7.4.11 + optional: true to-regex-range@5.0.1: dependencies: is-number: 7.0.0 + totalist@3.0.1: {} + tough-cookie@6.0.2: dependencies: tldts: 7.4.11 + optional: true tr46@1.0.1: dependencies: @@ -4961,6 +5069,7 @@ snapshots: tr46@6.0.0: dependencies: punycode: 2.3.1 + optional: true tree-kill@1.2.2: {} @@ -5022,7 +5131,8 @@ snapshots: undici-types@6.21.0: {} - undici@8.10.1: {} + undici@8.10.1: + optional: true update-browserslist-db@1.1.3(browserslist@4.24.4): dependencies: @@ -5067,7 +5177,7 @@ snapshots: optionalDependencies: vite: 8.2.2(@types/node@22.20.1)(esbuild@0.25.3) - vitest@4.1.11(@types/node@22.20.1)(@vitest/coverage-v8@4.1.11)(jsdom@30.0.1)(vite@8.2.2(@types/node@22.20.1)(esbuild@0.25.3)): + vitest@4.1.11(@types/node@22.20.1)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(jsdom@30.0.1)(vite@8.2.2(@types/node@22.20.1)(esbuild@0.25.3)): dependencies: '@vitest/expect': 4.1.11 '@vitest/mocker': 4.1.11(vite@8.2.2(@types/node@22.20.1)(esbuild@0.25.3)) @@ -5091,7 +5201,8 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.20.1 - '@vitest/coverage-v8': 4.1.11(vitest@4.1.11) + '@vitest/browser-playwright': 4.1.11(playwright@1.62.1)(vite@8.2.2(@types/node@22.20.1)(esbuild@0.25.3))(vitest@4.1.11) + '@vitest/coverage-v8': 4.1.11(@vitest/browser@4.1.11)(vitest@4.1.11) jsdom: 30.0.1 transitivePeerDependencies: - msw @@ -5101,12 +5212,15 @@ snapshots: w3c-xmlserializer@5.0.0: dependencies: xml-name-validator: 5.0.0 + optional: true webidl-conversions@4.0.2: {} - webidl-conversions@8.0.1: {} + webidl-conversions@8.0.1: + optional: true - whatwg-mimetype@5.0.0: {} + whatwg-mimetype@5.0.0: + optional: true whatwg-url@16.0.1: dependencies: @@ -5115,6 +5229,7 @@ snapshots: webidl-conversions: 8.0.1 transitivePeerDependencies: - '@noble/hashes' + optional: true whatwg-url@17.1.0: dependencies: @@ -5123,6 +5238,7 @@ snapshots: webidl-conversions: 8.0.1 transitivePeerDependencies: - '@noble/hashes' + optional: true whatwg-url@7.1.0: dependencies: @@ -5161,9 +5277,11 @@ snapshots: dependencies: is-wsl: 3.1.1 - xml-name-validator@5.0.0: {} + xml-name-validator@5.0.0: + optional: true - xmlchars@2.2.0: {} + xmlchars@2.2.0: + optional: true yallist@3.1.1: {} diff --git a/test/drag.test.tsx b/test/drag.test.tsx index cd55dda..aaf2982 100644 --- a/test/drag.test.tsx +++ b/test/drag.test.tsx @@ -6,9 +6,9 @@ import type {CustomDragEvent} from "../src/index.jsx" const sleep = (ms: number): Promise => new Promise(resolve => setTimeout(resolve, ms)) /* -motion-dom filters out non-primary pointers, and jsdom's PointerEvent defaults -to an empty `pointerType` with `isPrimary: false`. These spell out a plain -left-button mouse drag. +motion-dom filters out non-primary pointers. A PointerEvent built by hand +defaults to an empty `pointerType` with `isPrimary: false`, which that filter +rejects. These spell out a plain left-button mouse drag. */ function pointer(type: string, x: number, y: number): PointerEvent { return new PointerEvent(type, { diff --git a/test/engine.test.tsx b/test/engine.test.tsx index 5847597..2981009 100644 --- a/test/engine.test.tsx +++ b/test/engine.test.tsx @@ -4,8 +4,8 @@ import type {MotionEvent, Target} from "../src/index.jsx" const sleep = (ms: number): Promise => new Promise(resolve => setTimeout(resolve, ms)) /* -motion-dom filters out non-primary pointers, and jsdom's PointerEvent -defaults to an empty `pointerType` with `isPrimary: false`, which the filter +motion-dom filters out non-primary pointers. A PointerEvent built by hand +defaults to an empty `pointerType` with `isPrimary: false`, which that filter rejects. These spell out a plain left-button mouse press. */ const pointer = (type: string): PointerEvent => @@ -510,3 +510,100 @@ describe("createMotionState", () => { unmount() }) }) + +/* +These need a real IntersectionObserver, so they were unreachable while the +suite ran in jsdom. The Playwright suite covers `inView` at the app level. +These cover it at the layer level, where it has to compose with the others. +*/ +describe("inView", () => { + /** A tall filler element, so the box under test starts out of view. */ + function spacer(): HTMLElement { + const el = document.createElement("div") + el.style.height = "150vh" + document.body.appendChild(el) + return el + } + + test("Animates when the element scrolls into view", async () => { + const filler = spacer() + const el = mounted() + el.style.height = "80px" + + const state = createMotionState({ + animate: {opacity: 1}, + inView: {opacity: 0.2}, + transition: {duration: 0.05}, + }) + const unmount = state.mount(el) + + let entry: unknown + el.addEventListener("viewenter", e => { + entry = (e as CustomEvent).detail.originalEntry + }) + + el.scrollIntoView() + await sleep(200) + + // the handler is handed the observer entry, not the element + expect((entry as IntersectionObserverEntry).isIntersecting).toBe(true) + expect(Number(window.getComputedStyle(el).opacity)).toBeCloseTo(0.2, 1) + + unmount() + filler.remove() + }) + + test("Reverts when the element leaves the viewport", async () => { + const filler = spacer() + const el = mounted() + el.style.height = "80px" + + const state = createMotionState({ + animate: {opacity: 1}, + inView: {opacity: 0.2}, + transition: {duration: 0.05}, + }) + const unmount = state.mount(el) + + el.scrollIntoView() + await sleep(200) + expect(Number(window.getComputedStyle(el).opacity)).toBeCloseTo(0.2, 1) + + window.scrollTo(0, 0) + await sleep(200) + expect(Number(window.getComputedStyle(el).opacity)).toBeCloseTo(1, 1) + + unmount() + filler.remove() + }) + + /* + inView sits below hover in the layer order, so a hover must merge over it + rather than replace it. + */ + test("Keeps the inView values underneath a hover", async () => { + const filler = spacer() + const el = mounted() + el.style.height = "80px" + + const state = createMotionState({ + animate: {opacity: 1}, + inView: {x: 120}, + hover: {opacity: 0.5}, + transition: {duration: 0.05}, + }) + const unmount = state.mount(el) + + el.scrollIntoView() + await sleep(200) + expect(el.style.transform).toContain("120") + + el.dispatchEvent(pointer("pointerenter")) + await sleep(200) + expect(Number(window.getComputedStyle(el).opacity)).toBeCloseTo(0.5, 1) + expect(el.style.transform).toContain("120") + + unmount() + filler.remove() + }) +}) diff --git a/test/layout.test.tsx b/test/layout.test.tsx index fdaef03..a5722d0 100644 --- a/test/layout.test.tsx +++ b/test/layout.test.tsx @@ -8,10 +8,10 @@ const sleep = (ms: number): Promise => new Promise(resolve => setTimeout(r const frames = (): Promise => sleep(80) /* -jsdom has no layout: every getBoundingClientRect() is zeroes, so there is no -movement for FLIP to notice. Stubbing the box per element is what makes the -geometry testable at all — and it is only geometry, which is exactly the part -that has to be right. Real measurement is covered by the Playwright suite. +Stubbing the box per element pins the geometry FLIP reads, so a test can move +an element by an exact amount and assert the exact transform. Otherwise the +numbers would depend on how the test page happens to lay out. Real measurement +is covered by the Playwright suite. */ function stubBox(el: Element, box: {left: number; top: number}): void { Object.defineProperty(el, "getBoundingClientRect", { diff --git a/test/presence.test.tsx b/test/presence.test.tsx index e2e7892..8bd5dc5 100644 --- a/test/presence.test.tsx +++ b/test/presence.test.tsx @@ -16,10 +16,10 @@ advancing animation time. const tick = (): Promise => sleep(0) /* -jsdom reports an unset `opacity` as `0`, so animating to `{opacity: 0}` from the -browser default is a zero-length animation that lands in a frame or two whatever -its `duration` says. Tests below that need a *real* exit animation therefore -give the element an explicit `initial={{opacity: 1}}`. +Tests below that need a real exit animation give the element an explicit +`initial={{opacity: 1}}`, so the animation always has a full 1 to 0 to cover +and its `duration` is what decides how long that takes. A browser resolves an +unset `opacity` to `1` anyway, so this is belt and braces. */ const TestComponent = ( diff --git a/test/primitives.test.tsx b/test/primitives.test.tsx index 02486c5..9758df2 100644 --- a/test/primitives.test.tsx +++ b/test/primitives.test.tsx @@ -45,9 +45,9 @@ describe("motion ref factory", () => { const [opacity, setOpacity] = createSignal(0.5) /* - Rendered into the document rather than built in a bare createRoot: - Motion reads computed style off the element, and jsdom throws on that - for a node with no owner document. + Rendered into the document rather than built in a bare createRoot. + Motion reads computed style off the element, which only resolves once + the element is in a document. */ let ref!: HTMLDivElement render(() => ( diff --git a/test/setup.ts b/test/setup.ts deleted file mode 100644 index fda2a9f..0000000 --- a/test/setup.ts +++ /dev/null @@ -1,26 +0,0 @@ -/* -jsdom implements neither IntersectionObserver nor the Web Animations API that -Motion drives its animations with. These are minimal stubs so the unit tests -can exercise the library's own state machine. - -`inView` behavior and real animation interpolation are covered by the -Playwright suite in `e2e/`, which runs against a real browser. -*/ - -/* eslint-disable @typescript-eslint/no-empty-function -- the stub does nothing by design */ -class IntersectionObserverStub implements IntersectionObserver { - readonly root = null - readonly rootMargin = "" - readonly thresholds: readonly number[] = [] - observe(): void {} - unobserve(): void {} - disconnect(): void {} - takeRecords(): IntersectionObserverEntry[] { - return [] - } -} - -if (typeof globalThis.IntersectionObserver === "undefined") { - globalThis.IntersectionObserver = - IntersectionObserverStub as unknown as typeof IntersectionObserver -} diff --git a/vitest.config.ts b/vitest.config.ts index b297b03..b64c312 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,37 +1,39 @@ import {defineConfig} from "vitest/config" import solid from "@solidjs/vite-plugin" +import {playwright} from "@vitest/browser-playwright" /* Two projects, because the library has two compilation targets and the Solid -JSX transform has to be configured differently for each: +JSX transform has to be configured differently for each. -- `client` compiles the DOM transform and runs in jsdom. -- `ssr` compiles the string-rendering transform and runs in plain node, so - the server build is never loaded alongside jsdom. +- `client` compiles the DOM transform and runs in a real Chromium. +- `ssr` compiles the string-rendering transform and runs in plain node. -The old jest setup did this with two babel transformers and an `SSR=true` -environment variable that selected between two config objects. Vitest runs -both in one pass instead, so `pnpm test` covers client and server together. +The client project used to run in jsdom, which implements neither the Web +Animations API that Motion drives its animations with nor +IntersectionObserver. Both had to be stubbed, so `inView` had no unit test at +all and animated values were never really interpolated. A real browser needs +no stubs, runs in about the same time, and reaches the same coverage. + +Only Chromium is used here. The Playwright suite in `e2e/` is what covers +Firefox and WebKit, at the level where engine differences actually matter. */ export default defineConfig({ test: { projects: [ { plugins: [solid()], - resolve: { - /* - Picks the "browser" exports condition so @solidjs/web resolves - its DOM build rather than the server one. - */ - conditions: ["browser", "development"], - }, test: { name: "client", - environment: "jsdom", include: ["test/**/*.test.{ts,tsx}"], exclude: ["test/ssr.test.tsx"], - setupFiles: ["test/setup.ts"], globals: true, + browser: { + enabled: true, + headless: true, + provider: playwright(), + instances: [{browser: "chromium"}], + }, }, }, { @@ -57,10 +59,10 @@ export default defineConfig({ */ exclude: ["src/index.tsx", "src/types.ts"], thresholds: { - statements: 95, + statements: 96, branches: 92, - functions: 92, - lines: 95, + functions: 96, + lines: 97, }, }, },