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, }, }, },