diff --git a/.gitignore b/.gitignore index 0c25013..9513a95 100644 --- a/.gitignore +++ b/.gitignore @@ -30,4 +30,7 @@ coverage *.tsbuildinfo /docs -CLAUDE.md \ No newline at end of file +CLAUDE.md + +/test-results/ +/playwright-report/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5dac2c4..6028a89 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,6 +4,7 @@ - [Getting Started](#getting-started) - [Fork and Clone the Repository](#fork-and-clone-the-repository) - [Storybook](#storybook) + - [Tests](#tests) - [How to Contribute](#how-to-contribute) - [Suggesting Features](#suggesting-features) - [Reporting Bugs](#reporting-bugs) @@ -35,6 +36,18 @@ npm run storybook This will start Storybook on your local machine. +### Tests + +Component tests run on [Vitest](https://vitest.dev/) with jsdom, browser tests on [Playwright](https://playwright.dev/): + +``` +npm test +npm run test:e2e +``` + +The browser tests drive the page in `tests/e2e/harness` with the Chrome installed on the machine. +To use the Chromium of Playwright instead, run `npx playwright install chromium` once and then `PW_CHANNEL= npm run test:e2e`. + ## How to Contribute We use GitHub issues to track new features or bugs. diff --git a/package-lock.json b/package-lock.json index 9369c42..fc17c38 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,19 +1,22 @@ { "name": "@skillbill/reactlace", - "version": "1.0.1", + "version": "1.0.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@skillbill/reactlace", - "version": "1.0.1", + "version": "1.0.3", "license": "MIT", "devDependencies": { "@chromatic-com/storybook": "^4.1.3", + "@playwright/test": "^1.63.0", "@storybook/addon-docs": "^10.1.11", "@storybook/addon-links": "^10.1.11", "@storybook/react": "^10.1.11", "@storybook/react-vite": "^10.1.11", + "@testing-library/dom": "^10.4.2", + "@testing-library/react": "^16.3.3", "@types/node": "^18.19.3", "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", @@ -25,12 +28,14 @@ "eslint-plugin-react": "^7.33.0", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-storybook": "^10.1.11", + "jsdom": "^30.1.2", "postcss": "^8.4.33", "storybook": "^10.1.11", "tailwindcss": "^3.4.1", "typescript": "~5.3.0", "vite": "^5.0.10", - "vite-plugin-dts": "^3.7.1" + "vite-plugin-dts": "^3.7.1", + "vitest": "^3.2.7" }, "peerDependencies": { "@mdi/svg": "^7.4.47", @@ -60,6 +65,59 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.1.3.tgz", + "integrity": "sha512-1t1U8Cm3RBl9vh3RAZhjkPaduzLSvQJRToRrxpGR/eDRW99mOREleuu+mWfiFa795bUzjwht7NgphCKTs2ianQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.4.2", + "@csstools/css-color-parser": "^4.2.5", + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2", + "lru-cache": "^11.5.3" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-9.2.4.tgz", + "integrity": "sha512-YnVzxDxLaqL0t7Q4wfcgHZjg55Wm6y5EaO7Bhu3q9J7wq/8iw3PhsnWiOQhYekx4VexnbkgWpTT6n+QIV7kczw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bidi-js": "^1.1.0", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.3" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -299,7 +357,6 @@ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=6.9.0" } @@ -352,6 +409,19 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.3.tgz", + "integrity": "sha512-0GEz8qJWokkUVYQYRukmkSHIFNoBH8QTreeHrAFbvz1XFYc3Y+puhMs8PQ5x8GNugiC2l5jvDBXKLbBIByeSBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.2.1" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, "node_modules/@chromatic-com/storybook": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/@chromatic-com/storybook/-/storybook-4.1.3.tgz", @@ -373,6 +443,146 @@ "storybook": "^0.0.0-0 || ^9.0.0 || ^9.1.0-0 || ^9.2.0-0 || ^10.0.0-0 || ^10.1.0-0 || ^10.2.0-0 || ^10.3.0-0" } }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.2.tgz", + "integrity": "sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.3.tgz", + "integrity": "sha512-iex20d8CHVkyvg6B7UKV7uHnI2Bqo9g+EFfT9E0y+GvTvhZ/DwONJ+9aKb1dlqm0ZiGsL5RXjp0fCoJYnkeDjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.6.tgz", + "integrity": "sha512-iiPQ3iRWwnJkeEn6RIu6SJPr7hYrLz6XZ9s/QZl+2/LI5KQVjpl2fdmDSZKuD4xP6GMmMPgHFFXg6k1Wkz0Trg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.2", + "@csstools/css-calc": "^3.4.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.2.tgz", + "integrity": "sha512-40cSKyMvK+tq4qz6Awrlye2WGuOKt3FwPgtGg6KTfbHOWNw+Rk1rzbAtZnZ6IBhsY491HLRnDXwoyBAijmmILA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.15.tgz", + "integrity": "sha512-J0u7HkVl2nzSlhsiTOp4AmwcUQ3D+mGEEKfBy/7To5/y7F2OHwyLrXfrhR0SMgr4p5Lo+eaMVSeai24zUcBIxA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.2.tgz", + "integrity": "sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@ctrl/tinycolor": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz", @@ -888,6 +1098,24 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz", + "integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@floating-ui/core": { "version": "1.7.3", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", @@ -1296,6 +1524,22 @@ "node": ">= 8" } }, + "node_modules/@playwright/test": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", + "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -2037,12 +2281,11 @@ } }, "node_modules/@testing-library/dom": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", - "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "version": "10.4.2", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.2.tgz", + "integrity": "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -2084,6 +2327,34 @@ "dev": true, "license": "MIT" }, + "node_modules/@testing-library/react": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz", + "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@testing-library/user-event": { "version": "14.6.1", "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", @@ -2110,8 +2381,7 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -2634,6 +2904,77 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner/node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner/node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot/node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@vitest/spy": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", @@ -3142,6 +3483,16 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/bidi-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", + "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -3229,6 +3580,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -3502,6 +3863,20 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, "node_modules/css.escape": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", @@ -3528,6 +3903,20 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/data-urls": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-8.0.0.tgz", + "integrity": "sha512-JQUCCK+/CAGbGGx618PlFZMXH81Jtj7t73CHv9dd/zVWvxgGT2zLFRjnKb+Ng46GIwcy+f7f88qd4duqllK+WQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.2" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -3607,6 +3996,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -3745,8 +4141,7 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/dom-helpers": { "version": "5.2.1", @@ -3935,6 +4330,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -4336,9 +4738,19 @@ "node": ">=0.10.0" } }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "dev": true, "license": "MIT" @@ -4929,6 +5341,19 @@ "he": "bin/he" } }, + "node_modules/html-encoding-sniffer": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-7.0.0.tgz", + "integrity": "sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -5335,6 +5760,13 @@ "node": ">=8" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -5580,6 +6012,56 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "30.1.2", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.1.2.tgz", + "integrity": "sha512-0FFE/jE1rppmVfUrJUgxqXjcwolZYIGtAgj1pTussMhNZxIxi7W/PvfWaMNroGi2B36X1IKHbexpZ9DhkoOPiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^7.1.3", + "@asamuzakjp/dom-selector": "^9.2.4", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.15", + "@exodus/bytes": "^1.16.0", + "css-tree": "^3.2.1", + "data-urls": "^8.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^7.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.3", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "tough-cookie": "^6.0.2", + "undici": "^8.11.2", + "w3c-xmlserializer": "^6.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.2.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -5822,7 +6304,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -5847,6 +6328,13 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -6230,6 +6718,32 @@ "node": ">=6" } }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/path-browserify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", @@ -6301,6 +6815,13 @@ "node": "20 || >=22" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/pathval": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", @@ -6351,6 +6872,35 @@ "node": ">= 6" } }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -6540,7 +7090,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -6556,7 +7105,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -6569,8 +7117,7 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/primereact": { "version": "10.9.7", @@ -6855,6 +7402,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -7073,6 +7630,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -7237,6 +7807,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -7277,6 +7854,20 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -7581,6 +8172,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/sucrase": { "version": "3.35.1", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", @@ -7705,6 +8316,20 @@ "dev": true, "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -7722,6 +8347,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, "node_modules/tinyrainbow": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", @@ -7742,6 +8377,26 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "7.4.16", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.16.tgz", + "integrity": "sha512-QwBER5KMR86IIjpIiO7H/Z3IMJPsZ1A6RKPAqzTTgOyUQUSt9FdnKcqhTaJmkY6HVrgouZHZR0ncK5QxvmnQeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.16" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.16", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.16.tgz", + "integrity": "sha512-MDolfaSJtlSK5Y0A1xl3277ekubZwobpBjugknDizI9O5Rm60a1m8k4ICK+MRsCDzPygT81mp3BBf5RKDlFRfA==", + "dev": true, + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -7755,6 +8410,32 @@ "node": ">=8.0" } }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-7.0.0.tgz", + "integrity": "sha512-BcDSDW+SIGWVOns1Zm6Yg91q9AEj7eaID0QDY9oIqJD1A8dscNwMcl9Sms2DeuWCNov1X0/oXanHGYQBSLwZsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, "node_modules/ts-api-utils": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", @@ -7944,6 +8625,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/undici": { + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.11.2.tgz", + "integrity": "sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/undici-types": { "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", @@ -8105,6 +8796,29 @@ } } }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/vite-plugin-dts": { "version": "3.9.1", "resolved": "https://registry.npmjs.org/vite-plugin-dts/-/vite-plugin-dts-3.9.1.tgz", @@ -8563,6 +9277,174 @@ "@esbuild/win32-x64": "0.21.5" } }, + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest/node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest/node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/vue-template-compiler": { "version": "2.7.16", "resolved": "https://registry.npmjs.org/vue-template-compiler/-/vue-template-compiler-2.7.16.tgz", @@ -8605,6 +9487,29 @@ "node": ">=10" } }, + "node_modules/w3c-xmlserializer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-6.0.0.tgz", + "integrity": "sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, "node_modules/webpack-virtual-modules": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", @@ -8612,6 +9517,31 @@ "dev": true, "license": "MIT" }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "17.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.2.0.tgz", + "integrity": "sha512-IhLIHNcBrzIixC5XXsbr4DxdjajyA5vQ5RwGZMtrUp+GbtE5Nrsge9QKau7jXrC0aTXLYGV0xiW7Tb4YS2aomw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.16.0", + "tr46": "^7.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -8717,6 +9647,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -8857,6 +9804,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index e2a88cd..41809db 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@skillbill/reactlace", - "version": "1.0.2", + "version": "1.0.3", "private": false, "author": "skillbill", "license": "MIT", @@ -24,7 +24,7 @@ ], "type": "module", "types": "./dist/index.d.ts", - "main": "./dist/reactlace.umd.cjs", + "main": "./dist/reactlace.cjs", "module": "./dist/reactlace.js", "exports": { ".": { @@ -32,7 +32,7 @@ "types": "./dist/index.d.ts", "default": "./dist/reactlace.js" }, - "require": "./dist/reactlace.umd.cjs" + "require": "./dist/reactlace.cjs" }, "./styles/reactlace.css": { "import": "./dist/styles/index.css", @@ -51,7 +51,9 @@ "lint": "eslint ./src --ext .tsx,.ts --fix", "storybook": "storybook dev -p 6007 -c .storybook", "build-storybook": "storybook build -c .storybook -o docs", - "build-publish": "vite build --config vite.config.ts && npm publish --access public" + "build-publish": "vite build --config vite.config.ts && npm publish --access public", + "test": "vitest run", + "test:e2e": "playwright test" }, "peerDependencies": { "@mdi/svg": "^7.4.47", @@ -62,10 +64,13 @@ }, "devDependencies": { "@chromatic-com/storybook": "^4.1.3", + "@playwright/test": "^1.63.0", "@storybook/addon-docs": "^10.1.11", "@storybook/addon-links": "^10.1.11", "@storybook/react": "^10.1.11", "@storybook/react-vite": "^10.1.11", + "@testing-library/dom": "^10.4.2", + "@testing-library/react": "^16.3.3", "@types/node": "^18.19.3", "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", @@ -77,11 +82,13 @@ "eslint-plugin-react": "^7.33.0", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-storybook": "^10.1.11", + "jsdom": "^30.1.2", "postcss": "^8.4.33", "storybook": "^10.1.11", "tailwindcss": "^3.4.1", "typescript": "~5.3.0", "vite": "^5.0.10", - "vite-plugin-dts": "^3.7.1" + "vite-plugin-dts": "^3.7.1", + "vitest": "^3.2.7" } } diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..c30769f --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,26 @@ +import { defineConfig } from '@playwright/test' + +// Uses the system Chrome by default; set PW_CHANNEL= (empty) to run on the +// Chromium downloaded by `npx playwright install chromium` +const channel = process.env.PW_CHANNEL ?? 'chrome' + +export default defineConfig({ + testDir: './tests/e2e', + timeout: 45_000, + expect: { timeout: 5_000 }, + fullyParallel: true, + forbidOnly: !!process.env.CI, + reporter: [['list']], + use: { + baseURL: 'http://localhost:5199', + browserName: 'chromium', + channel: channel || undefined, + actionTimeout: 5_000, + viewport: { width: 1280, height: 1600 } + }, + webServer: { + command: 'npx vite --config tests/e2e/vite.config.ts', + url: 'http://localhost:5199', + reuseExistingServer: !process.env.CI + } +}) diff --git a/src/components/RLCrud/RLCrud.tsx b/src/components/RLCrud/RLCrud.tsx index 530efd8..b79c8cd 100644 --- a/src/components/RLCrud/RLCrud.tsx +++ b/src/components/RLCrud/RLCrud.tsx @@ -81,6 +81,22 @@ export const RLCrud = forwardRef( const [lastSelectedItem, setLastSelectedItem] = useState(null) const [fetchOnCloseStatus, setFetchOnCloseStatus] = useState(false) const skipWatchersRef = useRef(false) + // Only the latest request may fill the table: an earlier one answering + // late would show the page or the filters left behind + const lastFetchRef = useRef(0) + // Each opening mounts a new form, also when the previous one is still + // there because its dialog was closed a moment ago + const [dialogOpening, setDialogOpening] = useState(0) + const dialogResetTimerRef = useRef | null>(null) + + const cancelDialogReset = useCallback(() => { + if (dialogResetTimerRef.current !== null) { + clearTimeout(dialogResetTimerRef.current) + dialogResetTimerRef.current = null + } + }, []) + + useEffect(() => cancelDialogReset, [cancelDialogReset]) const filtersRef = useRef(null) @@ -120,9 +136,13 @@ export const RLCrud = forwardRef( ) const fetchData = useCallback(async () => { + const fetchId = ++lastFetchRef.current try { const response = await getItems(currentPage, rowsPerPage, { ...filtersApplied }) + if (fetchId !== lastFetchRef.current) { + return + } if (!response) { onFetchError?.() return @@ -133,7 +153,9 @@ export const RLCrud = forwardRef( } catch (e) { // eslint-disable-next-line no-console console.error(e) - onFetchError?.() + if (fetchId === lastFetchRef.current) { + onFetchError?.() + } } }, [currentPage, rowsPerPage, filtersApplied, getItems, onFetchError]) @@ -159,21 +181,25 @@ export const RLCrud = forwardRef( setLastSelectedItem((data as Record)?.[primary_key]) if (action.component) { + cancelDialogReset() + setDialogOpening((n) => n + 1) setSelectedItem(structuredClone(data as Record)) setDialog(action.name) setDialogProps(action.dialogProperties ?? {}) setShowDialog(true) } }, - [primary_key, fetchData] + [primary_key, fetchData, cancelDialogReset] ) const addItemHandler = useCallback(() => { + cancelDialogReset() + setDialogOpening((n) => n + 1) setSelectedItem({}) setDialog('add') setDialogProps({}) setShowDialog(true) - }, []) + }, [cancelDialogReset]) const onFiltersApplied = useCallback((appliedFilters: Record) => { setFiltersApplied(appliedFilters) @@ -231,13 +257,15 @@ export const RLCrud = forwardRef( i18n_key: editTooltipI18nKey, icon_name: 'pencil', onClick: (data: unknown) => { + cancelDialogReset() + setDialogOpening((n) => n + 1) setSelectedItem(structuredClone(data as Record)) setDialog('edit') setDialogProps({}) setShowDialog(true) } }), - [editTooltipI18nKey] + [editTooltipI18nKey, cancelDialogReset] ) const onEdit = useCallback( @@ -255,10 +283,12 @@ export const RLCrud = forwardRef( setFetchOnCloseStatus(false) fetchData() } - setTimeout(() => { + cancelDialogReset() + dialogResetTimerRef.current = setTimeout(() => { + dialogResetTimerRef.current = null setDialog(null) }, 300) - }, [fetchOnCloseStatus, fetchData]) + }, [fetchOnCloseStatus, fetchData, cancelDialogReset]) const renderActions = useCallback( (data: unknown) => ( @@ -344,7 +374,7 @@ export const RLCrud = forwardRef( > {dialog === 'add' && ( ( {dialog === 'edit' && ( ( const ActionComponent = action.component! return ( ( const handleFieldChange = useCallback( (fieldKey: string, value: RLCrudInputValueType) => { - setModel((prev) => ({ ...prev, [fieldKey]: value })) + // Render before the event returns, as RLCrudForm does: apply and Enter + // can follow the commit of a field before React gets to render it + flushSync(() => { + setModel((prev) => ({ ...prev, [fieldKey]: value })) + }) }, [] ) diff --git a/src/components/RLCrudForm/RLCrudForm.tsx b/src/components/RLCrudForm/RLCrudForm.tsx index e0175f8..3e24408 100644 --- a/src/components/RLCrudForm/RLCrudForm.tsx +++ b/src/components/RLCrudForm/RLCrudForm.tsx @@ -1,10 +1,41 @@ import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react' +import { flushSync } from 'react-dom' import type { RLCrudFormProps, RLCrudFormRef, RLCrudFormFieldType } from './types' import type { RLCrudInputValueType, RLCrudInputRef } from '../RLCrudInput' import { RLCrudInput } from '../RLCrudInput' import { RLButton } from '../RLButton' import type { RLInputRuleType } from '../utils/types' +const isPlainObject = (v: unknown): v is Record => + typeof v === 'object' && v !== null && Object.getPrototypeOf(v) === Object.prototype + +// Copy of the containers only (arrays, plain objects, dates), enough to +// compare later against a value the side effects may have mutated meanwhile +const snapshot = (v: unknown): unknown => { + if (v instanceof Date) return new Date(v.getTime()) + if (Array.isArray(v)) return v.map(snapshot) + if (isPlainObject(v)) { + return Object.fromEntries(Object.entries(v).map(([k, item]) => [k, snapshot(item)])) + } + return v +} + +const sameContent = (a: unknown, b: unknown): boolean => { + if (Object.is(a, b)) return true + if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime() + if (Array.isArray(a) && Array.isArray(b)) { + return a.length === b.length && a.every((item, i) => sameContent(item, b[i])) + } + if (isPlainObject(a) && isPlainObject(b)) { + const keys = Object.keys(a) + return ( + keys.length === Object.keys(b).length && + keys.every((key) => Object.prototype.hasOwnProperty.call(b, key) && sameContent(a[key], b[key])) + ) + } + return false +} + export const RLCrudForm = forwardRef( ( { @@ -28,9 +59,25 @@ export const RLCrudForm = forwardRef( const [model, setModel] = useState<{ [key: string]: RLCrudInputValueType }>({}) const [fields, setFields] = useState<{ [key: string]: RLCrudFormFieldType }>({}) const fieldRefs = useRef>(new Map()) + const lastInitRef = useRef<{ fields: RLCrudFormFieldType[]; value: unknown; content: unknown } | null>(null) // Initialize fields from props useEffect(() => { + // A parent building `value` on each render would otherwise wipe what was + // typed at each of its renders: same fields and same content, nothing to do. + // The same object is the StrictMode run again, after the side effects + // below may have mutated it. + const lastInit = lastInitRef.current + if ( + lastInit && + lastInit.fields === initialFields && + (lastInit.value === value || sameContent(lastInit.content, value)) + ) { + return + } + // Taken before the side effects below, which may mutate `value` + lastInitRef.current = { fields: initialFields, value, content: snapshot(value) } + const fieldsMap = initialFields.reduce( (acc, field) => ({ ...acc, @@ -63,7 +110,8 @@ export const RLCrudForm = forwardRef( const requiredRule: RLInputRuleType = useMemo( () => ({ - validateFn: (v: unknown) => !!v, + // 0 is a value; false stays missing, a required checkbox must be checked + validateFn: (v: unknown) => v === 0 || (Array.isArray(v) ? v.length > 0 : !!v), message: requiredRuleMessage }), [requiredRuleMessage] @@ -97,11 +145,16 @@ export const RLCrudForm = forwardRef( const handleFieldChange = useCallback( (fieldKey: string, fieldValue: RLCrudInputValueType) => { - setModel((prev) => { - const newModel = { ...prev, [fieldKey]: fieldValue } - // Run side effect - fields[fieldKey]?.side_effect?.(newModel, fields) - return newModel + // Render before the event returns. Shoelace events are not batched by + // React as its own: the render would come in a later task, after the + // next key or the click on confirm, which would still see the old model. + flushSync(() => { + setModel((prev) => { + const newModel = { ...prev, [fieldKey]: fieldValue } + // Run side effect + fields[fieldKey]?.side_effect?.(newModel, fields) + return newModel + }) }) }, [fields] @@ -189,6 +242,7 @@ export const RLCrudForm = forwardRef( imgStyle={field.img_style} forceSelection={field.forceSelection} withTime={field.withTime} + step={field.step} value={model[field.value]} onChange={(v) => handleFieldChange(field.value, v)} onError={onError} diff --git a/src/components/RLCrudForm/types.ts b/src/components/RLCrudForm/types.ts index b302856..38aa6fe 100644 --- a/src/components/RLCrudForm/types.ts +++ b/src/components/RLCrudForm/types.ts @@ -23,6 +23,7 @@ export interface RLCrudFormFieldType { multiple?: boolean forceSelection?: boolean withTime?: boolean + step?: number | 'any' side_effect?: ( model: { [key: string]: RLCrudInputValueType }, fields: { diff --git a/src/components/RLCrudInput/RLCrudInput.tsx b/src/components/RLCrudInput/RLCrudInput.tsx index c4cf7c5..3d72aff 100644 --- a/src/components/RLCrudInput/RLCrudInput.tsx +++ b/src/components/RLCrudInput/RLCrudInput.tsx @@ -41,6 +41,7 @@ export const RLCrudInput = forwardRef( multiple = false, forceSelection = true, withTime = false, + step, onError }, ref @@ -98,6 +99,7 @@ export const RLCrudInput = forwardRef( rules={rules} disabled={disabled} required={required} + step={step} /> ) diff --git a/src/components/RLCrudInput/types.ts b/src/components/RLCrudInput/types.ts index a7aae9f..b29353f 100644 --- a/src/components/RLCrudInput/types.ts +++ b/src/components/RLCrudInput/types.ts @@ -34,7 +34,8 @@ export interface RLCrudInputProps { multiple?: boolean forceSelection?: boolean withTime?: boolean - onError?: (error: RLFileInputErrorEvent) => void + step?: number | 'any' + onError?:(error: RLFileInputErrorEvent) => void } export interface RLCrudInputRef { diff --git a/src/components/RLInput/RLInput.tsx b/src/components/RLInput/RLInput.tsx index 4008b96..1b3f6f6 100644 --- a/src/components/RLInput/RLInput.tsx +++ b/src/components/RLInput/RLInput.tsx @@ -1,9 +1,11 @@ import { forwardRef, useImperativeHandle, useCallback, useEffect } from 'react' +import { flushSync } from 'react-dom' import SlInput from '@shoelace-style/shoelace/dist/react/input/index.js' import type SlInputElement from '@shoelace-style/shoelace/dist/components/input/input.js' import type { RLInputProps, RLInputRef } from './types' import { ErrorMessage } from '../utils/ErrorMessage' import { useValidation } from '../../hooks/useValidation' +import { useElementValue } from '../../hooks/useElementValue' export const RLInput = forwardRef( ( @@ -51,6 +53,7 @@ export const RLInput = forwardRef( ref ) => { const { errorMessage, isValid, validate } = useValidation({ rules, externalError: error }) + const { elementRef, commitValue } = useElementValue(value ?? '') useEffect(() => { if (value !== undefined) { @@ -67,11 +70,17 @@ export const RLInput = forwardRef( (event: CustomEvent) => { const target = event.target as SlInputElement const newValue = target?.value ?? '' - validate(newValue) - onChange?.(newValue) + commitValue(newValue) + // Render the parent before the event returns: Shoelace events are not + // batched by React as its own, and a click on a button right after the + // commit would run with a parent that still holds the old value + flushSync(() => { + validate(newValue) + onChange?.(newValue) + }) onSlChange?.(event) }, - [onChange, onSlChange, validate] + [commitValue, onChange, onSlChange, validate] ) const handleBlur = useCallback( @@ -116,8 +125,8 @@ export const RLInput = forwardRef( return (
( ( @@ -44,6 +46,7 @@ export const RLNumberInput = forwardRef( ref ) => { const { errorMessage, isValid, validate } = useValidation({ rules, externalError: error }) + const { elementRef, commitValue } = useElementValue(value?.toString() ?? '') useEffect(() => { if (value !== undefined) { @@ -81,11 +84,17 @@ export const RLNumberInput = forwardRef( const target = event.target as SlInputElement const newValue = target?.value ?? '' const validValue = checkMinMax(newValue) - validate(validValue) - onChange?.(validValue) + commitValue(validValue?.toString() ?? '') + // Render the parent before the event returns: Shoelace events are not + // batched by React as its own, and a click on a button right after the + // commit would run with a parent that still holds the old value + flushSync(() => { + validate(validValue) + onChange?.(validValue) + }) onSlChange?.(event) }, - [checkMinMax, onChange, onSlChange, validate] + [checkMinMax, commitValue, onChange, onSlChange, validate] ) const handleBlur = useCallback( @@ -127,9 +136,9 @@ export const RLNumberInput = forwardRef( return (
( ( @@ -40,6 +42,7 @@ export const RLTextArea = forwardRef( ref ) => { const { errorMessage, isValid, validate } = useValidation({ rules, externalError: error }) + const { elementRef, commitValue } = useElementValue(value ?? '') useEffect(() => { if (value !== undefined) { @@ -56,11 +59,17 @@ export const RLTextArea = forwardRef( (event: CustomEvent) => { const target = event.target as SlTextareaElement const newValue = target?.value ?? '' - validate(newValue) - onChange?.(newValue) + commitValue(newValue) + // Render the parent before the event returns: Shoelace events are not + // batched by React as its own, and a click on a button right after the + // commit would run with a parent that still holds the old value + flushSync(() => { + validate(newValue) + onChange?.(newValue) + }) onSlChange?.(event) }, - [onChange, onSlChange, validate] + [commitValue, onChange, onSlChange, validate] ) const handleBlur = useCallback( @@ -96,8 +105,8 @@ export const RLTextArea = forwardRef( return (
{ + elementRef: React.RefObject + commitValue: (committed: string) => void +} + +/** + * Keeps the `value` of a Shoelace text control in sync with a controlled prop. + * + * The Shoelace React wrappers assign every prop to the element on each render. + * For `value` that means a render landing while the user is typing puts the + * previous text back in the field, since the parent only learns about the new + * one on `sl-change`. Here the element is written only when the prop moves + * away from the last value the two agreed on. + */ +export function useElementValue(value: string): UseElementValueResult { + const elementRef = useRef(null) + const syncedValueRef = useRef(null) + + useLayoutEffect(() => { + const element = elementRef.current + if (!element || syncedValueRef.current === value) { + return + } + syncedValueRef.current = value + if (element.value !== value) { + element.value = value + } + }) + + // To call on `sl-change` with the value handed to the parent. The render that + // echoes it back is then not mistaken for a change coming from outside, which + // would overwrite what the user typed in the meantime. A value that differs + // from the text of the element (a parsed number) is shown right away. + const commitValue = useCallback((committed: string) => { + syncedValueRef.current = committed + const element = elementRef.current + if (element && element.value !== committed) { + element.value = committed + } + }, []) + + return { elementRef, commitValue } +} diff --git a/tests/e2e/crud-form.spec.ts b/tests/e2e/crud-form.spec.ts new file mode 100644 index 0000000..cbb050f --- /dev/null +++ b/tests/e2e/crud-form.spec.ts @@ -0,0 +1,313 @@ +import { expect, test, type Page } from '@playwright/test' +import { + CONDITIONS, + RACY_CONDITIONS, + REGISTERS, + clobbers, + focusField, + openHarness, + pageErrors, + pause, + pick, + settle, + shownValues, + typeSequence, + type Condition +} from './helpers' + +const typed = REGISTERS.map((_, i) => String(2000 + i)) +const typedNumbers = Object.fromEntries(REGISTERS.map((key, i) => [key, 2000 + i])) +const typedShown = Object.fromEntries(REGISTERS.map((key, i) => [key, String(2000 + i)])) +const stored = Object.fromEntries(REGISTERS.map((key, i) => [key, 1000 + i])) + +const open = (page: Page, type: 'add' | 'edit', condition: Condition, extra = '') => + openHarness(page, `scenario=form&type=${type}${extra}`, condition, 'sl-checkbox') + +const confirm = (page: Page) => page.locator('sl-button', { hasText: 'Confirm' }).click() + +const confirmed = (page: Page) => page.evaluate(() => window.__confirmed) + +async function expectConfirmed(page: Page, expected: Record) { + await expect.poll(() => confirmed(page)).toHaveLength(1) + const [data] = await confirmed(page) + expect(pick(data, Object.keys(expected))).toEqual(expected) +} + +test.afterEach(({ page }) => { + expect(pageErrors(page)).toEqual([]) +}) + +for (const condition of CONDITIONS) { + test.describe(`RLCrudForm, ${condition.title}`, () => { + test('add: registers typed in sequence are all kept', async ({ page }) => { + await open(page, 'add', condition) + await focusField(page, 'reg_01') + await typeSequence(page, typed, condition) + await settle(page, condition) + + expect.soft(pick(await shownValues(page), REGISTERS)).toEqual(typedShown) + expect(await clobbers(page)).toEqual([]) + + await focusField(page, 'name') + await typeSequence(page, ['fiber'], condition) + await confirm(page) + await expectConfirmed(page, { name: 'fiber', ...typedNumbers }) + }) + + test('edit: retyped registers replace the stored ones', async ({ page }) => { + await open(page, 'edit', condition) + await expect.poll(async () => pick(await shownValues(page), REGISTERS)).toEqual( + Object.fromEntries(REGISTERS.map((key, i) => [key, String(1000 + i)])) + ) + + await focusField(page, 'reg_01') + await typeSequence(page, typed, condition) + await settle(page, condition) + + expect.soft(pick(await shownValues(page), REGISTERS)).toEqual(typedShown) + expect(await clobbers(page)).toEqual([]) + + await confirm(page) + await expectConfirmed(page, { id: 7, name: 'fiber-7', description: 'stored description', ...typedNumbers }) + }) + + test('add: confirm clicked right after the last key, without leaving the field', async ({ page }) => { + await open(page, 'add', condition) + await focusField(page, 'name') + await typeSequence(page, ['fiber'], condition) + await focusField(page, 'reg_01') + await typeSequence(page, typed, condition, { tabAfterLast: false }) + await confirm(page) + + await expectConfirmed(page, { name: 'fiber', ...typedNumbers }) + }) + + test('edit: confirm clicked right after the last key, without leaving the field', async ({ page }) => { + await open(page, 'edit', condition) + await focusField(page, 'reg_01') + await typeSequence(page, typed, condition, { tabAfterLast: false }) + await confirm(page) + + await expectConfirmed(page, typedNumbers) + }) + + test('add: Enter in the last field submits what was typed', async ({ page }) => { + await open(page, 'add', condition) + await focusField(page, 'name') + await typeSequence(page, ['fiber'], condition) + await focusField(page, 'reg_01') + await typeSequence(page, typed.slice(0, 4), condition, { tabAfterLast: false }) + await page.keyboard.press('Enter') + + await expectConfirmed(page, { name: 'fiber', ...pick(typedNumbers, REGISTERS.slice(0, 4)) }) + }) + + test('add: text, textarea and number fields in one run', async ({ page }) => { + await open(page, 'add', condition) + await focusField(page, 'name') + // name, description, code, code_upper (filled by the side effect of code), then registers + await typeSequence(page, ['fiber one', 'first line', 'abc', '', '2000', '2001', '2002'], condition) + await settle(page, condition) + + const shown = await shownValues(page) + expect(pick(shown, ['name', 'description', 'code', 'code_upper', 'reg_01', 'reg_02', 'reg_03'])).toEqual({ + name: 'fiber one', + description: 'first line', + code: 'abc', + code_upper: 'ABC', + reg_01: '2000', + reg_02: '2001', + reg_03: '2002' + }) + + await confirm(page) + await expectConfirmed(page, { + name: 'fiber one', + description: 'first line', + code: 'abc', + code_upper: 'ABC', + reg_01: 2000, + reg_02: 2001, + reg_03: 2002 + }) + }) + + test('add: going back with Shift+Tab and typing on appends to the field', async ({ page }) => { + await open(page, 'add', condition) + await focusField(page, 'reg_01') + await typeSequence(page, ['2000'], condition) + await page.keyboard.press('Shift+Tab') + await pause(page, condition) + // Tab focus selects the content: move to its end before typing on + await page.keyboard.press('End') + await typeSequence(page, ['5', '2001'], condition) + await settle(page, condition) + + expect(pick(await shownValues(page), ['reg_01', 'reg_02'])).toEqual({ reg_01: '20005', reg_02: '2001' }) + expect(await clobbers(page)).toEqual([]) + }) + + test('edit: untouched fields keep the stored values', async ({ page }) => { + await open(page, 'edit', condition) + await focusField(page, 'reg_03') + await typeSequence(page, ['3333', '4444'], condition) + await settle(page, condition) + await confirm(page) + + await expectConfirmed(page, { ...stored, reg_03: 3333, reg_04: 4444, code: 'abc', code_upper: 'ABC' }) + }) + + test('edit: an emptied register is saved as empty', async ({ page }) => { + await open(page, 'edit', condition) + await focusField(page, 'reg_01') + await page.keyboard.press('Delete') + await page.keyboard.press('Tab') + await pause(page, condition) + await typeSequence(page, ['2001'], condition) + await settle(page, condition) + + expect(pick(await shownValues(page), ['reg_01', 'reg_02'])).toEqual({ reg_01: '', reg_02: '2001' }) + await confirm(page) + await expectConfirmed(page, { reg_01: null, reg_02: 2001, reg_03: 1002 }) + }) + + test('add: numbers are normalized once the field is left', async ({ page }) => { + await open(page, 'add', condition) + await focusField(page, 'reg_01') + await typeSequence(page, ['0012', '1.50', '2002'], condition) + await settle(page, condition) + + expect(pick(await shownValues(page), ['reg_01', 'reg_02', 'reg_03'])).toEqual({ + reg_01: '12', + reg_02: '1.5', + reg_03: '2002' + }) + }) + + test('add: a missing required field blocks the confirm and shows the error', async ({ page }) => { + await open(page, 'add', condition) + await focusField(page, 'reg_01') + await typeSequence(page, typed.slice(0, 3), condition) + await confirm(page) + // The native constraint validation of the required field stops the submit + await expect(page.locator('sl-input[data-user-invalid]')).toHaveCount(1) + expect(await confirmed(page)).toEqual([]) + expect(pick(await shownValues(page), REGISTERS.slice(0, 3))).toEqual(pick(typedShown, REGISTERS.slice(0, 3))) + + await focusField(page, 'name') + await typeSequence(page, ['fiber'], condition) + await confirm(page) + await expectConfirmed(page, { name: 'fiber', ...pick(typedNumbers, REGISTERS.slice(0, 3)), enabled: true }) + }) + + test('add: select and checkbox changes do not disturb the typed fields', async ({ page }) => { + await open(page, 'add', condition) + await focusField(page, 'name') + await typeSequence(page, ['fiber', 'notes'], condition) + await page.locator('sl-select').click() + await page.locator('sl-option', { hasText: 'Edge 2' }).click() + await pause(page, condition) + await focusField(page, 'reg_01') + await typeSequence(page, typed.slice(0, 3), condition, { tabAfterLast: false }) + await page.locator('sl-checkbox [part~="control"]').click() + await settle(page, condition) + + expect(pick(await shownValues(page), ['name', 'description', 'reg_01', 'reg_02', 'reg_03'])).toEqual({ + name: 'fiber', + description: 'notes', + reg_01: '2000', + reg_02: '2001', + reg_03: '2002' + }) + await confirm(page) + await expectConfirmed(page, { + name: 'fiber', + description: 'notes', + edge: 'edge-2', + enabled: false, + reg_01: 2000, + reg_02: 2001, + reg_03: 2002 + }) + }) + }) +} + +for (const condition of CONDITIONS) { + test(`RLCrudForm, ${condition.title}: fields of the other kinds commit next to typed ones`, async ({ page }) => { + await openHarness(page, 'scenario=form&type=add&fields=kinds', condition, 'sl-textarea') + await focusField(page, 'name') + await typeSequence(page, ['fiber'], condition, { tabAfterLast: false }) + + await page.locator('.date-input').click() + await page + .locator('.p-datepicker-calendar td:not(.p-datepicker-other-month) span', { hasText: /^15$/ }) + .click() + await pause(page, condition) + + await focusField(page, 'reg_01') + await typeSequence(page, ['2000'], condition, { tabAfterLast: false }) + await page.locator('input[name="group"]').click() + await page.locator('li', { hasText: 'Group B' }).click() + await pause(page, condition) + + await focusField(page, 'reg_02') + await typeSequence(page, ['2001'], condition, { tabAfterLast: false }) + await page.locator('.p-autocomplete-input').click() + await page.locator('.p-autocomplete-item', { hasText: 'Tag two' }).click() + await pause(page, condition) + + await focusField(page, 'description') + await typeSequence(page, ['some notes'], condition, { tabAfterLast: false }) + await settle(page, condition) + + expect(pick(await shownValues(page), ['name', 'reg_01', 'reg_02', 'description'])).toEqual({ + name: 'fiber', + reg_01: '2000', + reg_02: '2001', + description: 'some notes' + }) + expect((await shownValues(page)).birth).toMatch(/^15-\d{2}-\d{4}$/) + expect(await clobbers(page)).toEqual([]) + + await confirm(page) + await expectConfirmed(page, { + name: 'fiber', + reg_01: 2000, + group: 'b', + reg_02: 2001, + tag: 'two', + description: 'some notes' + }) + const [data] = await confirmed(page) + expect((data.birth as Date).getDate()).toBe(15) + }) +} + +for (const condition of RACY_CONDITIONS) { + test.describe(`RLCrudForm with other renders going on, ${condition.title}`, () => { + for (const type of ['add', 'edit'] as const) { + test(`${type}: renders from the parent do not touch the field being typed`, async ({ page }) => { + await open(page, type, condition, '&tick=15') + await focusField(page, 'reg_01') + // Slow enough for several renders to land inside each field + await typeSequence(page, typed.slice(0, 6), { ...condition, keyDelay: Math.max(condition.keyDelay, 40) }) + await settle(page, condition) + + expect(pick(await shownValues(page), REGISTERS.slice(0, 6))).toEqual(pick(typedShown, REGISTERS.slice(0, 6))) + expect(await clobbers(page)).toEqual([]) + expect(Number(await page.locator('[data-tick]').getAttribute('data-tick'))).toBeGreaterThan(5) + }) + + test(`${type}: StrictMode`, async ({ page }) => { + await open(page, type, condition, '&strict=1') + await focusField(page, 'reg_01') + await typeSequence(page, typed, condition) + await settle(page, condition) + + expect(pick(await shownValues(page), REGISTERS)).toEqual(typedShown) + expect(await clobbers(page)).toEqual([]) + }) + } + }) +} diff --git a/tests/e2e/crud.spec.ts b/tests/e2e/crud.spec.ts new file mode 100644 index 0000000..001284f --- /dev/null +++ b/tests/e2e/crud.spec.ts @@ -0,0 +1,224 @@ +import { expect, test, type Page } from '@playwright/test' +import { + CONDITIONS, + REGISTERS, + clobbers, + focusField, + openHarness, + pageErrors, + pick, + settle, + shownValues, + typeSequence, + type Condition +} from './helpers' + +const DIALOG = 'sl-dialog' +const FILTERS = 'sl-details' + +const typed = REGISTERS.map((_, i) => String(5000 + i)) +const typedNumbers = Object.fromEntries(REGISTERS.map((key, i) => [key, 5000 + i])) +const rowShown = (id: number) => Object.fromEntries(REGISTERS.map((key, i) => [key, String(id * 1000 + i)])) + +const open = (page: Page, condition: Condition) => + openHarness(page, 'scenario=crud', condition, 'tbody tr:nth-child(3)') + +const calls = (page: Page) => page.evaluate(() => window.__calls) + +const dialogButton = (page: Page, label: string) => page.locator(`${DIALOG} sl-button`, { hasText: label }) + +// The dialog moves the focus to its panel when the opening animation ends: +// typing has to start after that, as it does for a user +async function waitForDialog(page: Page, condition: Condition) { + await expect(page.locator(`${DIALOG}[open] form`)).toBeVisible() + await page.waitForTimeout(500) + await settle(page, condition) +} + +async function openAdd(page: Page, condition: Condition) { + await page.locator('sl-button', { hasText: 'button.add_fiber' }).click() + await waitForDialog(page, condition) +} + +async function openEdit(page: Page, row: number, condition: Condition) { + await page.locator('tbody tr').nth(row - 1).locator('sl-icon').click() + await waitForDialog(page, condition) + await expect.poll(async () => pick(await shownValues(page, DIALOG), REGISTERS)).toEqual(rowShown(row)) +} + +async function waitForDialogClosed(page: Page) { + await expect(page.locator(`${DIALOG} form`)).toHaveCount(0) +} + +test.afterEach(({ page }) => { + expect(pageErrors(page)).toEqual([]) +}) + +for (const condition of CONDITIONS) { + test.describe(`RLCrud, ${condition.title}`, () => { + test('add dialog: what is typed is what gets saved', async ({ page }) => { + await open(page, condition) + await openAdd(page, condition) + await focusField(page, 'name', DIALOG) + // name, description, code, code_upper (filled by the side effect of code), then registers + await typeSequence(page, ['fiber', 'a description', 'abc', '', ...typed], condition, { tabAfterLast: false }) + await dialogButton(page, 'button.add').click() + + await expect.poll(async () => (await calls(page)).add).toHaveLength(1) + const [added] = (await calls(page)).add + expect(pick(added, ['name', 'description', 'code', 'code_upper', 'enabled', ...REGISTERS])).toEqual({ + name: 'fiber', + description: 'a description', + code: 'abc', + code_upper: 'ABC', + enabled: true, + ...typedNumbers + }) + expect(await clobbers(page)).toEqual([]) + }) + + test('edit dialog: retyped registers are saved, the rest is untouched', async ({ page }) => { + await open(page, condition) + await openEdit(page, 2, condition) + await focusField(page, 'reg_01', DIALOG) + await typeSequence(page, typed, condition, { tabAfterLast: false, selectAll: true }) + await dialogButton(page, 'button.edit').click() + + await expect.poll(async () => (await calls(page)).edit).toHaveLength(1) + const [edited] = (await calls(page)).edit + expect(pick(edited, ['id', 'name', 'description', 'code', 'edge', ...REGISTERS])).toEqual({ + id: 2, + name: 'fiber-2', + description: 'stored description', + code: 'abc', + edge: 'edge-1', + ...typedNumbers + }) + expect(await clobbers(page)).toEqual([]) + }) + + test('edit dialog: text left in a field does not leak into the next dialog', async ({ page }) => { + await open(page, condition) + await openEdit(page, 1, condition) + await focusField(page, 'reg_01', DIALOG) + await typeSequence(page, ['7777', '8888', '99'], condition, { tabAfterLast: false }) + await page.keyboard.press('Escape') + await waitForDialogClosed(page) + + await openEdit(page, 3, condition) + await dialogButton(page, 'button.cancel').click() + await waitForDialogClosed(page) + + // The cancelled edit of row 1 has left nothing behind + await openEdit(page, 1, condition) + await dialogButton(page, 'button.cancel').click() + await waitForDialogClosed(page) + + await openAdd(page, condition) + const shown = await shownValues(page, DIALOG) + expect(Object.values(shown).filter(Boolean)).toEqual([]) + expect((await calls(page)).edit).toEqual([]) + }) + + test('add dialog: a second add starts from an empty form', async ({ page }) => { + await open(page, condition) + await openAdd(page, condition) + await focusField(page, 'name', DIALOG) + await typeSequence(page, ['first'], condition) + await focusField(page, 'reg_01', DIALOG) + await typeSequence(page, typed.slice(0, 3), condition, { tabAfterLast: false }) + await dialogButton(page, 'button.add').click() + await waitForDialogClosed(page) + + await openAdd(page, condition) + expect(Object.values(await shownValues(page, DIALOG)).filter(Boolean)).toEqual([]) + await focusField(page, 'name', DIALOG) + await typeSequence(page, ['second'], condition) + await focusField(page, 'reg_02', DIALOG) + await typeSequence(page, ['42'], condition, { tabAfterLast: false }) + await dialogButton(page, 'button.add').click() + + await expect.poll(async () => (await calls(page)).add).toHaveLength(2) + const [first, second] = (await calls(page)).add + expect(pick(first, ['name', 'reg_01', 'reg_02', 'reg_03'])).toEqual({ + name: 'first', + reg_01: 5000, + reg_02: 5001, + reg_03: 5002 + }) + expect(pick(second, ['name', 'reg_01', 'reg_02', 'reg_03'])).toEqual({ + name: 'second', + reg_01: undefined, + reg_02: 42, + reg_03: undefined + }) + }) + + test('filters: typed filters are kept and applied', async ({ page }) => { + await open(page, condition) + await page.locator(`${FILTERS} [slot="summary"]`).click() + await expect(page.locator(`${FILTERS}[open]`)).toBeVisible() + await focusField(page, 'name', FILTERS) + await typeSequence(page, ['fib', 'desc', '10', '20'], condition) + await settle(page, condition) + + expect(await shownValues(page, FILTERS)).toEqual({ name: 'fib', description: 'desc', reg_from: '10', reg_to: '20' }) + expect(await clobbers(page)).toEqual([]) + + await page.locator(`${FILTERS} sl-button`, { hasText: 'button.apply' }).click() + await expect + .poll(async () => (await calls(page)).filters.at(-1)) + .toEqual({ name: 'fib', description: 'desc', reg_from: 10, reg_to: 20 }) + }) + + test('filters: apply clicked right after the last key, without leaving the field', async ({ page }) => { + await open(page, condition) + await page.locator(`${FILTERS} [slot="summary"]`).click() + await expect(page.locator(`${FILTERS}[open]`)).toBeVisible() + await focusField(page, 'name', FILTERS) + await typeSequence(page, ['fib', 'desc', '10', '20'], condition, { tabAfterLast: false }) + await page.locator(`${FILTERS} sl-button`, { hasText: 'button.apply' }).click() + + await expect + .poll(async () => (await calls(page)).filters.at(-1)) + .toEqual({ name: 'fib', description: 'desc', reg_from: 10, reg_to: 20 }) + expect(await clobbers(page)).toEqual([]) + }) + + test('filters: Enter in the last field applies what was typed', async ({ page }) => { + await open(page, condition) + await page.locator(`${FILTERS} [slot="summary"]`).click() + await expect(page.locator(`${FILTERS}[open]`)).toBeVisible() + await focusField(page, 'name', FILTERS) + await typeSequence(page, ['fib', 'desc', '10', '20'], condition, { tabAfterLast: false }) + await page.keyboard.press('Enter') + + await expect + .poll(async () => (await calls(page)).filters.at(-1)) + .toEqual({ name: 'fib', description: 'desc', reg_from: 10, reg_to: 20 }) + expect(await clobbers(page)).toEqual([]) + }) + + test('filters: reset empties the fields, including the one being typed', async ({ page }) => { + await open(page, condition) + await page.locator(`${FILTERS} [slot="summary"]`).click() + await expect(page.locator(`${FILTERS}[open]`)).toBeVisible() + await focusField(page, 'name', FILTERS) + await typeSequence(page, ['fib', 'desc', '10', '20'], condition, { tabAfterLast: false }) + await page.locator(`${FILTERS} sl-button`, { hasText: 'button.reset' }).click() + await settle(page, condition) + + expect(await shownValues(page, FILTERS)).toEqual({ name: '', description: '', reg_from: '', reg_to: '' }) + expect((await calls(page)).filters.at(-1)).toEqual({}) + + // The fields are usable again after the reset + await focusField(page, 'name', FILTERS) + await typeSequence(page, ['again', 'more'], condition) + await settle(page, condition) + expect(pick(await shownValues(page, FILTERS), ['name', 'description'])).toEqual({ + name: 'again', + description: 'more' + }) + }) + }) +} diff --git a/tests/e2e/globals.d.ts b/tests/e2e/globals.d.ts new file mode 100644 index 0000000..6e89db6 --- /dev/null +++ b/tests/e2e/globals.d.ts @@ -0,0 +1,17 @@ +// State shared between the harness page and the tests that drive it +interface Window { + /** Delay added by the tests to every React Scheduler task, in ms */ + __schedulerLag?: number + /** Writes over the text of a focused field, recorded by the tests */ + __clobbers: { name: string; shown: string; written: string }[] + /** Data confirmed by the RLCrudForm of the form scenario */ + __confirmed: Record[] + /** Models saved by the save button of the inputs scenario */ + __saved: Record[] + /** Calls received by the store of the crud scenario */ + __calls: { + add: Record[] + edit: Record[] + filters: Record[] + } +} diff --git a/tests/e2e/harness/index.html b/tests/e2e/harness/index.html new file mode 100644 index 0000000..f68c1c7 --- /dev/null +++ b/tests/e2e/harness/index.html @@ -0,0 +1,13 @@ + + + + + + + Reactlace e2e harness + + +
+ + + diff --git a/tests/e2e/harness/main.tsx b/tests/e2e/harness/main.tsx new file mode 100644 index 0000000..f68edb1 --- /dev/null +++ b/tests/e2e/harness/main.tsx @@ -0,0 +1,276 @@ +import { StrictMode, useEffect, useMemo, useState, type ReactNode } from 'react' +import { createRoot } from 'react-dom/client' +import { + ReactlaceProvider, + RLCrud, + RLCrudForm, + RLInput, + RLNumberInput, + RLTextArea, + type RLCrudFormFieldType, + type RLCrudInputValueType +} from '../../../src' + +import 'primereact/resources/themes/lara-light-blue/theme.css' +import 'primereact/resources/primereact.min.css' + +type Model = { [key: string]: RLCrudInputValueType } + +window.__confirmed = [] +window.__saved = [] +window.__calls = { add: [], edit: [], filters: [] } + +const params = new URLSearchParams(window.location.search) +const scenario = params.get('scenario') ?? 'form' +const tick = Number(params.get('tick') ?? 0) +const strict = params.get('strict') === '1' + +export const REGISTER_COUNT = 16 +const registerKeys = Array.from({ length: REGISTER_COUNT }, (_, i) => `reg_${String(i + 1).padStart(2, '0')}`) + +// Shaped like the Efesto optical fiber form: a few descriptive fields and a +// long run of consecutive numeric registers +const formFields: Omit[] = [ + { i18n_key: 'Name', value: 'name', input_type: 'text', required: true }, + { i18n_key: 'Description', value: 'description', input_type: 'textarea' }, + { + i18n_key: 'Code', + value: 'code', + input_type: 'text', + side_effect: (model) => { + const { code } = model as { code?: string } + ;(model as { code_upper?: string }).code_upper = code?.toUpperCase() ?? '' + } + }, + { i18n_key: 'Code upper', value: 'code_upper', input_type: 'text' }, + ...registerKeys.map( + (key): Omit => ({ i18n_key: key, value: key, input_type: 'number' }) + ), + { + i18n_key: 'Edge', + value: 'edge', + input_type: 'select', + options: [ + { value: 'edge-1', text: 'Edge 1' }, + { value: 'edge-2', text: 'Edge 2' } + ] + }, + { i18n_key: 'Enabled', value: 'enabled', input_type: 'checkbox', default_value: true } +] + +// One field of every kind that reports its changes to the form in its own way +const kindsFields: Omit[] = [ + { i18n_key: 'Name', value: 'name', input_type: 'text' }, + { i18n_key: 'Birth', value: 'birth', input_type: 'date' }, + { i18n_key: 'reg_01', value: 'reg_01', input_type: 'number' }, + { + i18n_key: 'Group', + value: 'group', + input_type: 'dropdown', + options: [ + { value: 'a', text: 'Group A' }, + { value: 'b', text: 'Group B' } + ] + }, + { i18n_key: 'reg_02', value: 'reg_02', input_type: 'number' }, + { + i18n_key: 'Tag', + value: 'tag', + input_type: 'autocomplete', + options: [ + { value: 'one', text: 'Tag one' }, + { value: 'two', text: 'Tag two' } + ] + }, + { i18n_key: 'Description', value: 'description', input_type: 'textarea' }, + { i18n_key: 'Color', value: 'color', input_type: 'color' } +] + +const storedItem: Model = { + id: 7, + name: 'fiber-7', + description: 'stored description', + code: 'abc', + code_upper: 'ABC', + ...Object.fromEntries(registerKeys.map((key, i) => [key, 1000 + i])), + edge: 'edge-1', + enabled: true +} + +// Re-renders its subtree on a timer: stands in for renders that do not come +// from the field being typed (validation state, late data, i18n...) +function Ticker({ children }: { children: (count: number) => ReactNode }) { + const [count, setCount] = useState(0) + useEffect(() => { + if (!tick) return + const id = setInterval(() => setCount((c) => c + 1), tick) + return () => clearInterval(id) + }, []) + return <>{children(count)} +} + +function FormScenario() { + const type = params.get('type') === 'edit' ? 'edit' : 'add' + const [confirmed, setConfirmed] = useState(null) + const fields = useMemo( + () => + (params.get('fields') === 'kinds' ? kindsFields : formFields).map((f) => ({ + ...f, + label: f.i18n_key + })) as RLCrudFormFieldType[], + [] + ) + const value = useMemo(() => (type === 'edit' ? { ...storedItem } : undefined), [type]) + + return ( + + {(count) => ( +
+ { + window.__confirmed.push(data) + setConfirmed(data) + }} + /> +
{confirmed ? JSON.stringify(confirmed) : ''}
+
+ )} +
+ ) +} + +const crudRows: Model[] = [1, 2, 3].map((id) => ({ + ...storedItem, + id, + name: `fiber-${id}`, + ...Object.fromEntries(registerKeys.map((key, i) => [key, id * 1000 + i])) +})) + +const crudHeaders = [ + { i18n_key: 'Name', value: 'name' }, + { i18n_key: 'reg_01', value: 'reg_01' } +] + +const crudFilters = [ + { i18n_key: 'Name', value: 'name', input_type: 'text' as const }, + { i18n_key: 'Description', value: 'description', input_type: 'text' as const }, + { i18n_key: 'Register from', value: 'reg_from', input_type: 'number' as const }, + { i18n_key: 'Register to', value: 'reg_to', input_type: 'number' as const } +] + +async function getItems(_page: number, _rows: number, filters: unknown) { + window.__calls.filters.push(filters as Model) + return { + result: crudRows, + page: { currentPage: 0, pageRows: crudRows.length, totalRows: crudRows.length } + } +} + +async function addItem(item: unknown) { + window.__calls.add.push(item as Model) + return { id: 99 } +} + +async function editItem(item: unknown) { + window.__calls.edit.push(item as Model) + return item +} + +function CrudScenario() { + return ( +
+ +
+ ) +} + +function InputsScenario() { + const [text, setText] = useState('') + const [upper, setUpper] = useState('') + const [rejected] = useState('locked') + const [number, setNumber] = useState(null) + const [clamped, setClamped] = useState(50) + const [area, setArea] = useState('') + + return ( + + {(count) => ( +
+ + setUpper(v.toUpperCase())} /> + {}} /> + + + + +
+ + + +
+
{JSON.stringify({ text, upper, rejected, number, clamped, area })}
+
+ )} +
+ ) +} + +const scenarios: { [key: string]: () => ReactNode } = { + form: () => , + crud: () => , + inputs: () => +} + +const app = {(scenarios[scenario] ?? scenarios.form)()} + +createRoot(document.getElementById('app')!).render(strict ? {app} : app) diff --git a/tests/e2e/helpers.ts b/tests/e2e/helpers.ts new file mode 100644 index 0000000..89c83e4 --- /dev/null +++ b/tests/e2e/helpers.ts @@ -0,0 +1,191 @@ +import type { Page } from '@playwright/test' + +export interface Condition { + id: string + title: string + /** Delay between keystrokes, in ms */ + keyDelay: number + /** Pause after each Tab, in ms */ + tabPause?: number + /** Chrome CPU throttling rate */ + cpu?: number + /** Delay added to every React Scheduler task, in ms: a browser too slow to render between keys */ + schedulerLag?: number +} + +// The stale value race needs a key to land between the commit of a field and +// the render that follows. `human` leaves the render time to happen and is +// expected to pass even without the fix; the others open the window. +export const CONDITIONS: Condition[] = [ + { id: 'human', title: 'unhurried typing', keyDelay: 80, tabPause: 120 }, + { id: 'burst', title: 'burst typing', keyDelay: 0 }, + { id: 'cpu6x', title: 'CPU 6x slower, 60 ms per key', keyDelay: 60, cpu: 6 }, + { id: 'lag150', title: 'renders lagging 150 ms, 30 ms per key', keyDelay: 30, schedulerLag: 150 } +] + +export const RACY_CONDITIONS = CONDITIONS.filter((condition) => condition.id !== 'human') + +export const REGISTERS = Array.from({ length: 16 }, (_, i) => `reg_${String(i + 1).padStart(2, '0')}`) + +const pageErrorLog = new WeakMap() + +/** Exceptions and console errors of a page opened with openHarness: React reports its misuse there */ +export const pageErrors = (page: Page) => pageErrorLog.get(page) ?? [] + +/** + * Opens a harness scenario under the given condition. Slowdowns are switched + * on once the page is ready, so that they only affect the interaction. + */ +export async function openHarness(page: Page, query: string, condition: Condition, readySelector: string) { + const errors: string[] = [] + pageErrorLog.set(page, errors) + page.on('pageerror', (error) => errors.push(error.message)) + page.on('console', (message) => { + if (message.type() === 'error') { + errors.push(message.text()) + } + }) + + await page.addInitScript(() => { + // React schedules its renders through a MessageChannel: delaying those + // messages reproduces a render that arrives late + const NativeMessageChannel = window.MessageChannel + window.MessageChannel = class extends NativeMessageChannel { + constructor() { + super() + const port = this.port2 + const postMessage = port.postMessage.bind(port) + port.postMessage = (message: unknown) => { + if (window.__schedulerLag) { + setTimeout(() => postMessage(message), window.__schedulerLag) + } else { + postMessage(message) + } + } + } + } + }) + + await page.goto(`/?${query}`) + await page.locator(readySelector).first().waitFor() + await recordClobbers(page) + await slowDown(page, condition) +} + +export async function slowDown(page: Page, condition: Condition) { + if (condition.cpu) { + const session = await page.context().newCDPSession(page) + await session.send('Emulation.setCPUThrottlingRate', { rate: condition.cpu }) + } + if (condition.schedulerLag) { + await page.evaluate((lag) => { + window.__schedulerLag = lag + }, condition.schedulerLag) + } +} + +/** + * Records every write to the `value` of a focused field that does not match + * what its native control is showing: text put there by the application over + * what the user is typing. + */ +export async function recordClobbers(page: Page) { + await page.evaluate(() => { + window.__clobbers = [] + for (const tag of ['sl-input', 'sl-textarea']) { + let proto = customElements.get(tag)!.prototype + while (!Object.getOwnPropertyDescriptor(proto, 'value')) { + proto = Object.getPrototypeOf(proto) + } + const descriptor = Object.getOwnPropertyDescriptor(proto, 'value')! + Object.defineProperty(proto, 'value', { + ...descriptor, + set(this: HTMLElement & { name: string }, written: string) { + const native = this.shadowRoot?.querySelector('input, textarea') + if (native && document.activeElement === this && native.value !== written) { + window.__clobbers.push({ name: this.name, shown: native.value, written }) + } + descriptor.set!.call(this, written) + } + }) + } + }) +} + +export const clobbers = (page: Page) => page.evaluate(() => window.__clobbers) + +/** Time for lagging renders to land before the page is inspected */ +export async function settle(page: Page, condition: Condition) { + await page.waitForTimeout((condition.schedulerLag ?? 0) * 3 + (condition.cpu ? 500 : 200)) +} + +/** + * Focuses a field the way Tab does: with its content selected. Fields are + * looked up by their `name` property, which Shoelace does not reflect to an + * attribute. + */ +export async function focusField(page: Page, name: string, root = 'body') { + await page.waitForFunction(findField, [root, name] as const) + await page.evaluate( + ([find, args]) => { + const field = new Function(`return (${find})`)()(args) as HTMLElement & { select?: () => void } + field.focus() + field.select?.() + }, + [findField.toString(), [root, name]] as const + ) +} + +function findField([root, name]: readonly [string, string]) { + const fields = document.querySelector(root)?.querySelectorAll('sl-input, sl-textarea') + return [...(fields ?? [])].find((field) => field.name === name) +} + +/** Pause an unhurried user leaves after an action that commits a field */ +export async function pause(page: Page, condition: Condition) { + if (condition.tabPause) { + await page.waitForTimeout(condition.tabPause) + } +} + +/** + * Types each value and leaves the field with Tab, without waiting in between + * unless the condition asks for it. Tab is skipped after the last value when + * `tabAfterLast` is false. With `selectAll` the content of each field is + * selected from the keyboard first: inside a Shoelace dialog Tab is handled by + * its focus trap, which does not select the content as the browser does. + */ +export async function typeSequence( + page: Page, + values: string[], + condition: Condition, + { tabAfterLast = true, selectAll = false }: { tabAfterLast?: boolean; selectAll?: boolean } = {} +) { + for (const [index, value] of values.entries()) { + if (selectAll) { + await page.keyboard.press('Control+A') + } + await page.keyboard.type(value, { delay: condition.keyDelay }) + if (index < values.length - 1 || tabAfterLast) { + await page.keyboard.press('Tab') + await pause(page, condition) + } + } +} + +/** What the user sees in each text field, by field name */ +export const shownValues = (page: Page, root = 'body') => + page.evaluate((selector) => { + const fields = document.querySelector(selector)!.querySelectorAll( + 'sl-input, sl-textarea' + ) + return Object.fromEntries( + [...fields].map((field) => [ + field.name, + field.shadowRoot!.querySelector('input, textarea')!.value + ]) + ) + }, root) + +export const pick = >(source: T, keys: string[]) => + Object.fromEntries(keys.map((key) => [key, source[key]])) diff --git a/tests/e2e/inputs.spec.ts b/tests/e2e/inputs.spec.ts new file mode 100644 index 0000000..5eed3df --- /dev/null +++ b/tests/e2e/inputs.spec.ts @@ -0,0 +1,200 @@ +import { expect, test, type Page } from '@playwright/test' +import { + CONDITIONS, + RACY_CONDITIONS, + clobbers, + focusField, + openHarness, + pageErrors, + pause, + pick, + settle, + shownValues, + typeSequence, + type Condition +} from './helpers' + +const open = (page: Page, condition: Condition, extra = '') => + openHarness(page, `scenario=inputs${extra}`, condition, 'sl-textarea') + +const model = async (page: Page) => JSON.parse((await page.getByTestId('model').textContent()) ?? '{}') + +// Standalone inputs held by a plain useState: a commit renders the parent +// before its event returns, every other render is asynchronous +test.afterEach(({ page }) => { + expect(pageErrors(page)).toEqual([]) +}) + +for (const condition of CONDITIONS) { + test.describe(`Standalone inputs, ${condition.title}`, () => { + test('fields typed in sequence keep what was typed', async ({ page }) => { + await open(page, condition) + await focusField(page, 'text') + // text, upper (uppercased by its parent), rejected (its parent ignores + // changes), uncontrolled (skipped), number, clamped, area + await typeSequence(page, ['hello', 'abc', 'x', '', '2000', '70', 'two words'], condition) + await settle(page, condition) + + const expected = { text: 'hello', upper: 'ABC', rejected: 'locked', number: 2000, clamped: 70, area: 'two words' } + expect(await model(page)).toEqual(expected) + expect(pick(await shownValues(page), Object.keys(expected))).toEqual({ + text: 'hello', + upper: 'ABC', + rejected: 'locked', + number: '2000', + clamped: '70', + area: 'two words' + }) + expect(await clobbers(page)).toEqual([]) + }) + + test('a click on save right after typing saves what was typed', async ({ page }) => { + await open(page, condition) + for (const [name, text] of [['text', 'hello'], ['number', '2001'], ['area', 'two words']]) { + await focusField(page, name) + await typeSequence(page, [text], condition, { tabAfterLast: false }) + // No Tab: the click on save is what leaves the field and commits it + await page.getByTestId('save').click() + await settle(page, condition) + } + + expect(await page.evaluate(() => window.__saved)).toEqual([ + { text: 'hello', number: null, area: '' }, + { text: 'hello', number: 2001, area: '' }, + { text: 'hello', number: 2001, area: 'two words' } + ]) + }) + + test('Enter commits and typing can go on in the same field', async ({ page }) => { + await open(page, condition) + await focusField(page, 'text') + await typeSequence(page, ['abc'], condition, { tabAfterLast: false }) + await page.keyboard.press('Enter') + await pause(page, condition) + await typeSequence(page, ['def'], condition, { tabAfterLast: false }) + await settle(page, condition) + + expect((await shownValues(page)).text).toBe('abcdef') + expect((await model(page)).text).toBe('abc') + expect(await clobbers(page)).toEqual([]) + + await page.keyboard.press('Tab') + await expect.poll(async () => (await model(page)).text).toBe('abcdef') + expect((await shownValues(page)).text).toBe('abcdef') + }) + + test('values set from outside reach the fields, also over text being typed', async ({ page }) => { + await open(page, condition) + await page.getByTestId('set').click() + await expect + .poll(async () => pick(await shownValues(page), ['text', 'upper', 'number', 'clamped', 'area'])) + .toEqual({ text: 'preset', upper: 'PRESET', number: '42', clamped: '7', area: 'preset area' }) + + // Typed and committed, then set again from outside + await focusField(page, 'text') + await typeSequence(page, ['mine'], condition) + await settle(page, condition) + expect((await model(page)).text).toBe('mine') + await page.getByTestId('set').click() + await expect.poll(async () => (await shownValues(page)).text).toBe('preset') + + // Typed and still in the field when the reset comes + await focusField(page, 'number') + await typeSequence(page, ['777'], condition, { tabAfterLast: false }) + await page.getByTestId('reset').click() + await expect + .poll(async () => pick(await shownValues(page), ['text', 'upper', 'number', 'clamped', 'area'])) + .toEqual({ text: '', upper: '', number: '', clamped: '', area: '' }) + expect(await model(page)).toEqual({ text: '', upper: '', rejected: 'locked', number: null, clamped: null, area: '' }) + + await page.getByTestId('set').click() + await expect + .poll(async () => pick(await shownValues(page), ['text', 'number', 'area'])) + .toEqual({ text: 'preset', number: '42', area: 'preset area' }) + }) + + test('a number out of range shows the value it was brought to', async ({ page }) => { + await open(page, condition) + await focusField(page, 'clamped') + await typeSequence(page, ['500'], condition) + await settle(page, condition) + expect((await shownValues(page)).clamped).toBe('100') + expect((await model(page)).clamped).toBe(100) + + // Out of range again: the model does not change, the field still has to + await focusField(page, 'clamped') + await typeSequence(page, ['900'], condition) + await settle(page, condition) + expect((await shownValues(page)).clamped).toBe('100') + expect((await model(page)).clamped).toBe(100) + + await focusField(page, 'clamped') + await typeSequence(page, ['-5'], condition) + await settle(page, condition) + expect((await shownValues(page)).clamped).toBe('0') + expect((await model(page)).clamped).toBe(0) + }) + + test('the clear button empties the field and the model', async ({ page }) => { + await open(page, condition) + await focusField(page, 'text') + await typeSequence(page, ['hello'], condition) + await focusField(page, 'number') + await typeSequence(page, ['42'], condition) + await settle(page, condition) + expect(pick(await model(page), ['text', 'number'])).toEqual({ text: 'hello', number: 42 }) + + for (const index of [0, 0]) { + await page.locator('sl-input [part~="clear-button"]').nth(index).click() + await pause(page, condition) + } + await settle(page, condition) + + expect(pick(await model(page), ['text', 'number'])).toEqual({ text: '', number: null }) + expect(pick(await shownValues(page), ['text', 'number'])).toEqual({ text: '', number: '' }) + + // Cleared fields take new text + await focusField(page, 'text') + await typeSequence(page, ['again', '', '', '', '7'], condition) + await settle(page, condition) + expect(pick(await model(page), ['text', 'number'])).toEqual({ text: 'again', number: 7 }) + expect(pick(await shownValues(page), ['text', 'number'])).toEqual({ text: 'again', number: '7' }) + }) + }) +} + +for (const condition of RACY_CONDITIONS) { + test.describe(`Standalone inputs with other renders going on, ${condition.title}`, () => { + for (const strict of ['', '&strict=1']) { + test(`renders from the parent do not touch the field being typed${strict && ', StrictMode'}`, async ({ page }) => { + await open(page, condition, `&tick=15${strict}`) + await focusField(page, 'text') + // Slow enough for several renders to land inside each field + await typeSequence(page, ['hello world', 'abcdef', '', '', '123456', '55', 'some more text'], { + ...condition, + keyDelay: Math.max(condition.keyDelay, 40) + }) + await settle(page, condition) + + expect(pick(await shownValues(page), ['text', 'upper', 'number', 'clamped', 'area'])).toEqual({ + text: 'hello world', + upper: 'ABCDEF', + number: '123456', + clamped: '55', + area: 'some more text' + }) + expect(await clobbers(page)).toEqual([]) + expect(Number(await page.locator('[data-tick]').getAttribute('data-tick'))).toBeGreaterThan(5) + }) + } + + test('a change the parent ignores is taken back at the next render', async ({ page }) => { + await open(page, condition, '&tick=15') + await focusField(page, 'rejected') + await typeSequence(page, ['other'], condition) + await settle(page, condition) + + expect((await shownValues(page)).rejected).toBe('locked') + }) + }) +} diff --git a/tests/e2e/vite.config.ts b/tests/e2e/vite.config.ts new file mode 100644 index 0000000..f235dc8 --- /dev/null +++ b/tests/e2e/vite.config.ts @@ -0,0 +1,19 @@ +import { fileURLToPath, URL } from 'node:url' +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// Dev server for the e2e harness page: it renders the library sources directly +export default defineConfig({ + root: fileURLToPath(new URL('./harness', import.meta.url)), + plugins: [react()], + css: { + postcss: fileURLToPath(new URL('../..', import.meta.url)) + }, + server: { + port: 5199, + strictPort: true, + fs: { + allow: [fileURLToPath(new URL('../..', import.meta.url))] + } + } +}) diff --git a/tests/tsconfig.json b/tests/tsconfig.json new file mode 100644 index 0000000..4bf69ad --- /dev/null +++ b/tests/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "baseUrl": "..", + "types": ["node"] + }, + "include": ["./**/*.ts", "./**/*.tsx", "../src/**/*.d.ts", "../env.d.ts", "../playwright.config.ts", "../vitest.config.ts"], + "exclude": [] +} diff --git a/tests/unit/crud-filters.test.tsx b/tests/unit/crud-filters.test.tsx new file mode 100644 index 0000000..678822a --- /dev/null +++ b/tests/unit/crud-filters.test.tsx @@ -0,0 +1,133 @@ +import { afterEach, beforeEach, expect, test, vi } from 'vitest' +import { act, fireEvent, render } from '@testing-library/react' +import { RLCrudFilters, type RLCrudFilterType } from '../../src/components/RLCrudFilters' +import { commit, textFields, typeText } from './helpers' + +const filters: RLCrudFilterType[] = [ + { i18n_key: 'name', value: 'name', label: 'Name', input_type: 'text' }, + { i18n_key: 'notes', value: 'notes', label: 'Notes', input_type: 'text', default_value: 'any' }, + { i18n_key: 'from', value: 'from', label: 'From', input_type: 'number' }, + { i18n_key: 'to', value: 'to', label: 'To', input_type: 'number' } +] + +let consoleError: ReturnType + +beforeEach(() => { + consoleError = vi.spyOn(console, 'error') +}) + +afterEach(() => { + expect(consoleError).not.toHaveBeenCalled() +}) + +async function setup() { + const onFiltersApplied = vi.fn() + const view = render() + const elements = await textFields(view.container) + const field = (name: string) => elements.find((element) => element.name === name)! + const button = (label: string) => + [...view.container.querySelectorAll('sl-button')].find((candidate) => candidate.textContent === label)! + const shown = () => Object.fromEntries(elements.map((element) => [element.name, element.value])) + return { ...view, field, button, shown, onFiltersApplied } +} + +test('RLCrudFilters starts from the default values', async () => { + const { shown } = await setup() + expect(shown()).toEqual({ name: '', notes: 'any', from: '', to: '' }) +}) + +test('RLCrudFilters keeps the text typed in the next field before the render of a commit', async () => { + const { field, button, shown, onFiltersApplied } = await setup() + + act(() => { + typeText(field('name'), 'fib') + commit(field('name')) + typeText(field('notes'), 'so') + }) + expect(shown()).toEqual({ name: 'fib', notes: 'so', from: '', to: '' }) + + act(() => { + typeText(field('notes'), 'some') + commit(field('notes')) + typeText(field('from'), '1') + }) + expect(shown()).toEqual({ name: 'fib', notes: 'some', from: '1', to: '' }) + + act(() => { + typeText(field('from'), '10') + commit(field('from')) + }) + act(() => { + fireEvent.click(button('apply')) + }) + + expect(onFiltersApplied).toHaveBeenLastCalledWith({ name: 'fib', notes: 'some', from: 10, to: undefined }) +}) + +test('RLCrudFilters reset empties a field committed right before it', async () => { + const { field, button, shown, onFiltersApplied } = await setup() + + // Reset clicked with the cursor still in the field: the commit of the blur + // and the reset come one after the other + act(() => { + typeText(field('name'), 'fib') + commit(field('name')) + typeText(field('from'), '10') + commit(field('from')) + fireEvent.click(button('reset')) + }) + + expect(shown()).toEqual({ name: '', notes: 'any', from: '', to: '' }) + expect(onFiltersApplied).toHaveBeenLastCalledWith({ name: undefined, notes: 'any', from: undefined, to: undefined }) +}) + +test.each([ + ['a click on apply', (view: Awaited>) => fireEvent.click(view.button('apply'))], + [ + 'Enter', + (view: Awaited>) => + fireEvent.keyUp(view.container.querySelector('sl-details')!, { key: 'Enter' }) + ] +])('RLCrudFilters applies a value committed right before %s', async (_, apply) => { + const view = await setup() + + // The apply follows the commit with no render scheduled by React in between + act(() => { + typeText(view.field('name'), 'fib') + commit(view.field('name')) + typeText(view.field('to'), '20') + commit(view.field('to')) + apply(view) + }) + + expect(view.onFiltersApplied).toHaveBeenLastCalledWith({ name: 'fib', notes: 'any', from: undefined, to: 20 }) +}) + +test('RLCrudFilters reset brings a changed default back', async () => { + const { field, button, shown } = await setup() + + act(() => { + typeText(field('notes'), 'other') + commit(field('notes')) + }) + expect(shown().notes).toBe('other') + + act(() => { + fireEvent.click(button('reset')) + }) + expect(shown().notes).toBe('any') +}) + +test('RLCrudFilters setFilterModel replaces what is in the fields', async () => { + const ref = { current: null as { setFilterModel: (model: Record) => void } | null } + const view = render() + const elements = await textFields(view.container) + const shown = () => Object.fromEntries(elements.map((element) => [element.name, element.value])) + + typeText(elements[0], 'typing') + act(() => { + ref.current!.setFilterModel({ name: 'set', from: 5 }) + }) + + expect(shown()).toEqual({ name: 'set', notes: '', from: '5', to: '' }) +}) diff --git a/tests/unit/crud-form.test.tsx b/tests/unit/crud-form.test.tsx new file mode 100644 index 0000000..1f7b57f --- /dev/null +++ b/tests/unit/crud-form.test.tsx @@ -0,0 +1,464 @@ +import { Fragment, StrictMode } from 'react' +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' +import { act, fireEvent, render } from '@testing-library/react' +import { RLCrudForm, type RLCrudFormFieldType, type RLCrudFormProps } from '../../src/components/RLCrudForm' +import { commit, textField, textFields, typeText, watchOverwrites, type TextElement } from './helpers' + +const fields: RLCrudFormFieldType[] = [ + { i18n_key: 'name', value: 'name', label: 'Name', input_type: 'text', required: true }, + { i18n_key: 'notes', value: 'notes', label: 'Notes', input_type: 'textarea' }, + { + i18n_key: 'code', + value: 'code', + label: 'Code', + input_type: 'text', + side_effect: (model) => { + const { code } = model as { code?: string } + ;(model as { code_upper?: string }).code_upper = code?.toUpperCase() ?? '' + } + }, + { i18n_key: 'code_upper', value: 'code_upper', label: 'Code upper', input_type: 'text' }, + { i18n_key: 'reg_01', value: 'reg_01', label: 'Register 1', input_type: 'number' }, + { i18n_key: 'reg_02', value: 'reg_02', label: 'Register 2', input_type: 'number' }, + { i18n_key: 'reg_03', value: 'reg_03', label: 'Register 3', input_type: 'number' }, + { i18n_key: 'reg_04', value: 'reg_04', label: 'Register 4', input_type: 'number' }, + { i18n_key: 'enabled', value: 'enabled', label: 'Enabled', input_type: 'checkbox', default_value: true } +] + +const stored = { + id: 7, + name: 'fiber-7', + notes: 'stored notes', + code: 'abc', + code_upper: 'ABC', + reg_01: 1000, + reg_02: 1001, + reg_03: 1002, + reg_04: 1003, + enabled: true +} + +let consoleError: ReturnType + +beforeEach(() => { + consoleError = vi.spyOn(console, 'error') +}) + +afterEach(() => { + // No React warning along the way: flushSync complains when it cannot flush + expect(consoleError).not.toHaveBeenCalled() +}) + +describe.each([ + ['', Fragment], + [' in StrictMode', StrictMode] +])('RLCrudForm%s', (_, wrapper) => { + async function setup(props: Partial = {}) { + const onConfirm = vi.fn() + const form = (extra: Partial = {}) => ( + + ) + const view = render(form(), { wrapper }) + const elements = await textFields(view.container) + const field = (name: string) => elements.find((element) => element.name === name)! + const submit = () => fireEvent.submit(view.container.querySelector('form')!) + return { ...view, form, field, elements, submit, onConfirm } + } + + const shown = (elements: TextElement[]) => Object.fromEntries(elements.map((element) => [element.name, element.value])) + + test('add: starts empty and confirms what is typed', async () => { + const { field, elements, submit, onConfirm } = await setup() + expect(Object.values(shown(elements)).filter(Boolean)).toEqual([]) + + for (const [name, text] of [['name', 'fiber'], ['notes', 'two\nlines'], ['reg_01', '2000'], ['reg_02', '2001']]) { + act(() => { + typeText(field(name), text) + commit(field(name)) + }) + } + act(() => submit()) + + expect(onConfirm).toHaveBeenCalledWith({ name: 'fiber', notes: 'two\nlines', reg_01: 2000, reg_02: 2001, enabled: true }) + }) + + test('add: text typed in the next field before the render of a commit is kept', async () => { + const { field, submit, onConfirm } = await setup() + + act(() => { + typeText(field('name'), 'fiber') + commit(field('name')) + typeText(field('reg_01'), '2000') + commit(field('reg_01')) + // First key of the next field, in before React gets to render + typeText(field('reg_02'), '2') + }) + expect(field('reg_02').value).toBe('2') + + act(() => { + typeText(field('reg_02'), '2001') + commit(field('reg_02')) + typeText(field('reg_03'), '2') + }) + expect(field('reg_03').value).toBe('2') + + act(() => { + typeText(field('reg_03'), '2002') + commit(field('reg_03')) + }) + act(() => submit()) + + expect(onConfirm).toHaveBeenCalledWith( + expect.objectContaining({ name: 'fiber', reg_01: 2000, reg_02: 2001, reg_03: 2002 }) + ) + }) + + test('edit: shows the stored values', async () => { + const { elements } = await setup({ type: 'edit', value: stored }) + + expect(shown(elements)).toEqual({ + name: 'fiber-7', + notes: 'stored notes', + code: 'abc', + code_upper: 'ABC', + reg_01: '1000', + reg_02: '1001', + reg_03: '1002', + reg_04: '1003' + }) + }) + + test('edit: text typed in the next field before the render of a commit is not put back to the stored value', async () => { + const { field, submit, onConfirm } = await setup({ type: 'edit', value: stored }) + + act(() => { + typeText(field('reg_01'), '3000') + commit(field('reg_01')) + typeText(field('reg_02'), '3001') + }) + expect(field('reg_02').value).toBe('3001') + + act(() => commit(field('reg_02'))) + act(() => submit()) + + expect(onConfirm).toHaveBeenCalledWith({ ...stored, reg_01: 3000, reg_02: 3001 }) + }) + + test('a commit overwrites no other field', async () => { + const { field, elements } = await setup({ type: 'edit', value: stored }) + const others = elements.filter((element) => element.name !== 'reg_01') + for (const element of others) { + typeText(element, element.name === 'notes' || element.name.startsWith('reg') ? '555' : 'typed') + } + const overwrites = others.map(watchOverwrites) + + act(() => { + typeText(field('reg_01'), '0012') + commit(field('reg_01')) + }) + + expect(overwrites.flat()).toEqual([]) + expect(field('reg_01').value).toBe('12') + expect(field('reg_02').value).toBe('555') + expect(field('name').value).toBe('typed') + }) + + test('renders a commit before its event returns', async () => { + const { field } = await setup() + + let shownInsideTheEvent = '' + act(() => { + typeText(field('code'), 'abc') + commit(field('code')) + // Written by the side effect of code, through a render + shownInsideTheEvent = field('code_upper').value + }) + + expect(shownInsideTheEvent).toBe('ABC') + }) + + test('a submit right after a commit carries the committed value', async () => { + const { field, submit, onConfirm } = await setup({ type: 'edit', value: stored }) + + // Click on confirm with the cursor still in the field, or Enter: the + // submit follows the commit with no render scheduled by React in between + act(() => { + typeText(field('reg_04'), '3003') + commit(field('reg_04')) + submit() + }) + + expect(onConfirm).toHaveBeenCalledWith({ ...stored, reg_04: 3003 }) + }) + + test('a field changed by the side effect of another one shows its new value', async () => { + const { field, submit, onConfirm } = await setup({ type: 'edit', value: stored }) + + act(() => { + typeText(field('code'), 'xyz') + commit(field('code')) + }) + + expect(field('code_upper').value).toBe('XYZ') + act(() => submit()) + expect(onConfirm).toHaveBeenCalledWith({ ...stored, code: 'xyz', code_upper: 'XYZ' }) + }) + + test('a checkbox change leaves the text being typed alone', async () => { + const { field, container, submit, onConfirm } = await setup({ type: 'edit', value: stored }) + const checkbox = container.querySelector('sl-checkbox')! + + typeText(field('reg_01'), '3000') + act(() => { + checkbox.checked = false + checkbox.dispatchEvent(new CustomEvent('sl-change', { bubbles: true })) + }) + expect(field('reg_01').value).toBe('3000') + + act(() => commit(field('reg_01'))) + act(() => submit()) + expect(onConfirm).toHaveBeenCalledWith({ ...stored, reg_01: 3000, enabled: false }) + }) + + test('a render from the parent leaves the text being typed alone', async () => { + const { field, rerender, form } = await setup({ type: 'edit', value: stored }) + + typeText(field('reg_01'), '3000') + typeText(field('name'), 'typed') + rerender(form({ title: 'Other title', confirmLabel: 'Save' })) + + expect(field('reg_01').value).toBe('3000') + expect(field('name').value).toBe('typed') + }) + + test('a missing required value blocks the confirm without touching the fields', async () => { + const { field, submit, onConfirm } = await setup() + + act(() => { + typeText(field('reg_01'), '2000') + commit(field('reg_01')) + }) + typeText(field('reg_02'), '2001') + // Shoelace stops the submit of a form with an empty required control + act(() => submit()) + + expect(onConfirm).not.toHaveBeenCalled() + expect(field('reg_01').value).toBe('2000') + expect(field('reg_02').value).toBe('2001') + + act(() => { + typeText(field('name'), 'fiber') + commit(field('name')) + commit(field('reg_02')) + }) + act(() => submit()) + + expect(onConfirm).toHaveBeenCalledWith(expect.objectContaining({ name: 'fiber', reg_01: 2000, reg_02: 2001 })) + }) + + test('an emptied number is confirmed as null', async () => { + const { field, submit, onConfirm } = await setup({ type: 'edit', value: stored }) + + act(() => { + typeText(field('reg_01'), '') + commit(field('reg_01')) + typeText(field('reg_02'), '3001') + }) + expect(field('reg_01').value).toBe('') + expect(field('reg_02').value).toBe('3001') + + act(() => commit(field('reg_02'))) + act(() => submit()) + expect(onConfirm).toHaveBeenCalledWith({ ...stored, reg_01: null, reg_02: 3001 }) + }) + + test('another item replaces the values, typed or not', async () => { + const { field, elements, rerender, form } = await setup({ type: 'edit', value: stored }) + + act(() => { + typeText(field('reg_01'), '3000') + commit(field('reg_01')) + }) + typeText(field('reg_02'), '3001') + + const other = { ...stored, id: 8, name: 'fiber-8', notes: '', reg_01: 8000, reg_02: 8001, reg_03: null } + rerender(form({ type: 'edit', value: other })) + + expect(shown(elements)).toEqual({ + name: 'fiber-8', + notes: '', + code: 'abc', + code_upper: 'ABC', + reg_01: '8000', + reg_02: '8001', + reg_03: '', + reg_04: '1003' + }) + }) + + test('the same item built again by the parent keeps what was typed', async () => { + const { field, rerender, form, submit, onConfirm } = await setup({ type: 'edit', value: { ...stored } }) + + act(() => { + typeText(field('reg_01'), '3000') + commit(field('reg_01')) + }) + typeText(field('name'), 'typed') + // A parent passing value={toItem(page)}: a new object at each of its renders + rerender(form({ type: 'edit', value: { ...stored } })) + + expect(field('reg_01').value).toBe('3000') + expect(field('name').value).toBe('typed') + act(() => commit(field('name'))) + act(() => submit()) + expect(onConfirm).toHaveBeenCalledWith({ ...stored, name: 'typed', reg_01: 3000 }) + }) + + test('the same item is recognized also after the side effects changed the first one', async () => { + // The side effect of code writes code_upper into the value it is given + const item = () => ({ ...stored, code_upper: '' }) + const { field, rerender, form } = await setup({ type: 'edit', value: item() }) + + act(() => { + typeText(field('reg_01'), '3000') + commit(field('reg_01')) + }) + rerender(form({ type: 'edit', value: item() })) + + expect(field('reg_01').value).toBe('3000') + }) + + test('new fields with the same item start the form again', async () => { + const { field, rerender, form } = await setup({ type: 'edit', value: stored }) + + act(() => { + typeText(field('reg_01'), '3000') + commit(field('reg_01')) + }) + rerender(form({ type: 'edit', value: stored, fields: [...fields] })) + + expect(field('reg_01').value).toBe('1000') + }) + + test('validate() from outside reports the missing value', async () => { + const ref = { current: null as { validate: () => boolean } | null } + const { field, container } = await setup({ ref } as Partial) + + let valid = true + act(() => { + valid = ref.current!.validate() + }) + expect(valid).toBe(false) + expect(container.textContent).toContain('Required') + + act(() => { + typeText(field('name'), 'fiber') + commit(field('name')) + }) + act(() => { + valid = ref.current!.validate() + }) + expect(valid).toBe(true) + }) +}) + +describe('RLCrudForm required fields', () => { + const requiredFields: RLCrudFormFieldType[] = [ + { i18n_key: 'count', value: 'count', label: 'Count', input_type: 'number', required: true }, + { i18n_key: 'accepted', value: 'accepted', label: 'Accepted', input_type: 'checkbox', required: true }, + { + i18n_key: 'tags', + value: 'tags', + label: 'Tags', + input_type: 'select', + required: true, + multiple: true, + options: [{ value: 'a', text: 'A' }] + } + ] + + function validateWith(value: RLCrudFormProps['value']) { + const ref = { current: null as { validate: () => boolean } | null } + const view = render( + + ) + let valid = false + act(() => { + valid = ref.current!.validate() + }) + const errors = view.container.textContent!.split('Required').length - 1 + view.unmount() + return { valid, errors } + } + + test('0 is a value', () => { + expect(validateWith({ count: 0, accepted: true, tags: ['a'] })).toEqual({ valid: true, errors: 0 }) + }) + + test('an unchecked checkbox and an empty list are missing', () => { + expect(validateWith({ count: 1, accepted: false, tags: [] })).toEqual({ valid: false, errors: 2 }) + }) + + test('null and empty text are missing', () => { + expect(validateWith({ count: null, accepted: true, tags: ['a'] }).valid).toBe(false) + expect(validateWith({ count: '', accepted: true, tags: ['a'] }).valid).toBe(false) + }) +}) + +test('RLCrudForm gives the step of a field to its number input', async () => { + const { container } = render( + + ) + const step = async (name: string) => ((await textField(container, name)) as TextElement & { step: unknown }).step + expect(await step('ratio')).toBe('any') + expect(await step('count')).toBe(1) +}) + +test('RLCrudForm keeps a single field by name', async () => { + const { container } = render( + + ) + expect((await textField(container, 'reg_04')).name).toBe('reg_04') + expect(await textFields(container)).toHaveLength(8) +}) diff --git a/tests/unit/crud.test.tsx b/tests/unit/crud.test.tsx new file mode 100644 index 0000000..f91ef3d --- /dev/null +++ b/tests/unit/crud.test.tsx @@ -0,0 +1,93 @@ +import { createRef } from 'react' +import { expect, test, vi } from 'vitest' +import { act, render, waitFor } from '@testing-library/react' +import { RLCrud, type RLCrudProps, type RLCrudRef } from '../../src/components/RLCrud' +import { commit, textField, typeText } from './helpers' + +type Page = Awaited> + +const pageOf = (names: string[]): Page => ({ + result: names.map((name, i) => ({ id: i + 1, name })), + page: { totalRows: names.length } +}) as Page + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((r) => { + resolve = r + }) + return { promise, resolve } +} + +function renderCrud(getItems: RLCrudProps['getItems'], extra: Partial = {}) { + const ref = createRef() + const view = render( + + ) + return { ...view, ref } +} + +const rows = (container: HTMLElement) => + [...container.querySelectorAll('tbody tr td')].map((cell) => cell.textContent).filter(Boolean) + +test('RLCrud shows the answer of the latest request, not the one arriving last', async () => { + const answers = [deferred(), deferred(), deferred()] + let call = 0 + const getItems = vi.fn(() => answers[call++].promise) + const onFetchError = vi.fn() + const { container, ref } = renderCrud(getItems, { onFetchError }) + + await act(async () => { + answers[0].resolve(pageOf(['first'])) + }) + expect(rows(container)).toContain('first') + + act(() => { + void ref.current!.fetchData() + void ref.current!.fetchData() + }) + await act(async () => { + answers[2].resolve(pageOf(['latest'])) + }) + await act(async () => { + answers[1].resolve(undefined as unknown as Page) + }) + + expect(rows(container)).toContain('latest') + expect(rows(container)).not.toContain('first') + expect(onFetchError).not.toHaveBeenCalled() +}) + +test('RLCrud reopened right after closing shows a new, working form', async () => { + const getItems = vi.fn(async () => pageOf(['one'])) + const { container } = renderCrud(getItems) + await waitFor(() => expect(rows(container)).toContain('one')) + + const button = (label: string) => + [...container.querySelectorAll('sl-button')].find((b) => b.textContent?.includes(label)) as HTMLElement + + act(() => button('button.add_fiber').click()) + const first = await textField(container, 'name') + act(() => { + typeText(first, 'left behind') + commit(first) + }) + act(() => button('button.cancel').click()) + // Opened again before the 300 ms after which a closed dialog drops its form + act(() => button('button.add_fiber').click()) + await act(() => new Promise((r) => setTimeout(r, 400))) + + expect(container.querySelector('form')).not.toBeNull() + expect((await textField(container, 'name')).value).toBe('') +}) diff --git a/tests/unit/date-picker.test.tsx b/tests/unit/date-picker.test.tsx new file mode 100644 index 0000000..9f0d150 --- /dev/null +++ b/tests/unit/date-picker.test.tsx @@ -0,0 +1,20 @@ +import { expect, test } from 'vitest' +import { render } from '@testing-library/react' +import { RLDatePicker } from '../../src/components/RLDatePicker' +import { textField } from './helpers' + +// RLDatePicker shows its value through a read-only RLInput +test('RLDatePicker shows the date it is given and follows its changes', async () => { + const { container, rerender } = render() + const field = await textField(container) + expect(field.value).toBe('15-10-2026') + + rerender() + expect(field.value).toBe('02-01-2027') + + rerender() + expect(field.value).toBe('') + + rerender() + expect(field.value).toBe('15-10-2026') +}) diff --git a/tests/unit/helpers.ts b/tests/unit/helpers.ts new file mode 100644 index 0000000..c8f24ad --- /dev/null +++ b/tests/unit/helpers.ts @@ -0,0 +1,71 @@ +export type TextElement = HTMLElement & { + name: string + value: string + disabled: boolean + label: string + updateComplete: Promise +} + +type NativeControl = HTMLInputElement | HTMLTextAreaElement + +export const nativeOf = (element: TextElement) => + element.shadowRoot!.querySelector('input, textarea')! + +/** Text fields of a container once Shoelace has rendered them */ +export async function textFields(container: HTMLElement) { + const fields = [...container.querySelectorAll('sl-input, sl-textarea')] + await Promise.all(fields.map((field) => field.updateComplete)) + return fields +} + +export async function textField(container: HTMLElement, name?: string) { + const fields = await textFields(container) + const field = name ? fields.find((candidate) => candidate.name === name) : fields[0] + if (!field) { + throw new Error(`No text field ${name ?? ''}`) + } + return field +} + +/** + * Puts text in a field the way typing does: in the native control, with the + * input event Shoelace listens to. Nothing is committed. + */ +export function typeText(element: TextElement, text: string) { + const native = nativeOf(element) + native.value = text + native.dispatchEvent(new Event('input', { bubbles: true, composed: true })) +} + +/** What leaving the field or pressing Enter does: the native change event */ +export function commit(element: TextElement) { + nativeOf(element).dispatchEvent(new Event('change', { bubbles: true })) +} + +/** + * Values assigned from now on to the `value` property of an element over a + * different text in its native control. Shoelace mirroring the native text on + * the property, as it does on input and change, is not one of them. + */ +export function watchOverwrites(element: TextElement) { + const overwrites: string[] = [] + let proto = Object.getPrototypeOf(element) + let descriptor: PropertyDescriptor | undefined + while (proto && !(descriptor = Object.getOwnPropertyDescriptor(proto, 'value'))) { + proto = Object.getPrototypeOf(proto) + } + const accessor = descriptor! + Object.defineProperty(element, 'value', { + configurable: true, + get() { + return accessor.get!.call(this) + }, + set(written: string) { + if (written !== nativeOf(element).value) { + overwrites.push(written) + } + accessor.set!.call(this, written) + } + }) + return overwrites +} diff --git a/tests/unit/inputs.test.tsx b/tests/unit/inputs.test.tsx new file mode 100644 index 0000000..2dd71c3 --- /dev/null +++ b/tests/unit/inputs.test.tsx @@ -0,0 +1,478 @@ +import { StrictMode, createRef, useState, type ReactElement } from 'react' +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' +import { act, render } from '@testing-library/react' +import { RLInput, type RLInputRef } from '../../src/components/RLInput' +import { RLNumberInput } from '../../src/components/RLNumberInput' +import { RLTextArea } from '../../src/components/RLTextArea' +import { commit, nativeOf, textField, typeText, watchOverwrites } from './helpers' + +type ModelValue = string | number | null | undefined + +interface FieldProps { + value?: ModelValue + onChange?: (value: ModelValue) => void + [key: string]: unknown +} + +interface FieldCase { + name: string + Field: (props: FieldProps) => ReactElement + /** Two model values and the text each one shows */ + first: [ModelValue, string] + second: [ModelValue, string] + /** Text typed by the user and the model value it commits */ + typed: [string, ModelValue] + /** The same text typed further, after a commit */ + longer: [string, ModelValue] + /** What a parent could turn the committed value into, and the text it shows */ + changed: [(value: ModelValue) => ModelValue, string] +} + +const cases: FieldCase[] = [ + { + name: 'RLInput', + Field: (props) => , + first: ['alpha', 'alpha'], + second: ['beta', 'beta'], + typed: ['typed', 'typed'], + longer: ['typed and more', 'typed and more'], + changed: [(value) => String(value).toUpperCase(), 'TYPED'] + }, + { + name: 'RLNumberInput', + Field: (props) => , + first: [1000, '1000'], + second: [42, '42'], + typed: ['2001', 2001], + longer: ['20015', 20015], + changed: [(value) => Number(value) * 2, '4002'] + }, + { + name: 'RLTextArea', + Field: (props) => , + first: ['line one\nline two', 'line one\nline two'], + second: ['beta', 'beta'], + typed: ['typed', 'typed'], + longer: ['typed\nand more', 'typed\nand more'], + changed: [(value) => String(value).toUpperCase(), 'TYPED'] + } +] + +let consoleError: ReturnType + +beforeEach(() => { + consoleError = vi.spyOn(console, 'error') +}) + +afterEach(() => { + // No React warning along the way + expect(consoleError).not.toHaveBeenCalled() +}) + +describe.each(cases)('$name', ({ Field, first, second, typed, longer, changed }) => { + // A parent holding the value in its state, as applications do + function Controlled({ + initial, + change = (value) => value, + onModel + }: { + initial?: ModelValue + change?: (value: ModelValue) => ModelValue + onModel: (value: ModelValue) => void + }) { + const [value, setValue] = useState(initial) + onModel(value) + return setValue(change(next))} /> + } + + test('shows the value it is given', async () => { + const { container } = render() + const field = await textField(container) + + expect(field.value).toBe(first[1]) + expect(nativeOf(field).value).toBe(first[1]) + }) + + test.each([[undefined], [null], ['']])('shows an empty field for %o', async (empty) => { + const { container } = render() + const field = await textField(container) + + expect(field.value).toBe('') + }) + + test('follows the value when it changes', async () => { + const { container, rerender } = render() + const field = await textField(container) + + rerender() + expect(field.value).toBe(second[1]) + + rerender() + expect(field.value).toBe('') + + rerender() + expect(field.value).toBe(first[1]) + }) + + test('is not overwritten when rendered again with the same value', async () => { + const { container, rerender } = render() + const field = await textField(container) + const overwrites = watchOverwrites(field) + + rerender() + rerender() + + expect(overwrites).toEqual([]) + // The other props did reach the element + expect(field.label).toBe('Other label') + expect(field.disabled).toBe(true) + expect(container.textContent).toContain('Some error') + }) + + test('keeps the text being typed when rendered again', async () => { + const onChange = vi.fn() + const { container, rerender } = render() + const field = await textField(container) + + typeText(field, typed[0]) + rerender() + rerender() + + expect(field.value).toBe(typed[0]) + expect(onChange).not.toHaveBeenCalled() + + act(() => commit(field)) + expect(onChange).toHaveBeenCalledWith(typed[1]) + }) + + test('a click right after a commit finds the parent with the committed value', async () => { + // A save button reading the state of its render, as AppSettingsView in efesto + const saved: ModelValue[] = [] + function WithSave() { + const [value, setValue] = useState(first[0]) + return ( + <> + + + + ) + } + const { container } = render() + const field = await textField(container) + + // Leaving the field with the click on save: the commit, then the click, + // with no render scheduled by React in between + act(() => { + typeText(field, typed[0]) + commit(field) + container.querySelector('button')!.click() + }) + + expect(saved).toEqual([typed[1]]) + }) + + test('reports the committed text once', async () => { + const onChange = vi.fn() + const onSlChange = vi.fn() + const { container } = render() + const field = await textField(container) + + act(() => { + typeText(field, typed[0]) + commit(field) + }) + + expect(onChange).toHaveBeenCalledTimes(1) + expect(onChange).toHaveBeenCalledWith(typed[1]) + expect(onSlChange).toHaveBeenCalledTimes(1) + }) + + test('is not overwritten by the render that brings the committed value back', async () => { + const onModel = vi.fn() + const { container } = render() + const field = await textField(container) + + typeText(field, typed[0]) + const overwrites = watchOverwrites(field) + act(() => commit(field)) + + expect(onModel).toHaveBeenLastCalledWith(typed[1]) + expect(field.value).toBe(typed[0]) + expect(overwrites).toEqual([]) + }) + + test('keeps what is typed between a commit and the render that follows it', async () => { + const onModel = vi.fn() + const { container } = render() + const field = await textField(container) + + // The render of the commit only happens when act returns: the text typed + // in the meantime is what a slow browser would find in the field + act(() => { + typeText(field, typed[0]) + commit(field) + typeText(field, longer[0]) + }) + + expect(onModel).toHaveBeenLastCalledWith(typed[1]) + expect(field.value).toBe(longer[0]) + + act(() => commit(field)) + expect(onModel).toHaveBeenLastCalledWith(longer[1]) + expect(field.value).toBe(longer[0]) + }) + + test('takes a value set from outside over the text being typed', async () => { + const { container, rerender } = render() + const field = await textField(container) + + typeText(field, typed[0]) + rerender() + + expect(field.value).toBe(second[1]) + }) + + test('takes a value set from outside after a commit', async () => { + const { container, rerender } = render() + const field = await textField(container) + + act(() => { + typeText(field, typed[0]) + commit(field) + }) + rerender() + rerender() + expect(field.value).toBe(second[1]) + + rerender() + expect(field.value).toBe(typed[0]) + }) + + test.each([[first[0], first[1]], [undefined, '']])( + 'goes back to %o when the parent does not take the commit', + async (value, shown) => { + const { container, rerender } = render() + const field = await textField(container) + + act(() => { + typeText(field, typed[0]) + commit(field) + }) + rerender() + + expect(field.value).toBe(shown) + } + ) + + test('shows the committed value as changed by the parent', async () => { + const onModel = vi.fn() + const { container } = render() + const field = await textField(container) + + act(() => { + typeText(field, typed[0]) + commit(field) + }) + + expect(onModel).toHaveBeenLastCalledWith(changed[0](typed[1])) + expect(field.value).toBe(changed[1]) + }) + + test('gives the value to an element created later', async () => { + const { container, rerender } = render() + const before = await textField(container) + + rerender() + const after = await textField(container) + + expect(after).not.toBe(before) + expect(after.value).toBe(first[1]) + }) + + test('validates the value of the parent, not the text in the field', async () => { + const ref = createRef() + const rules = [{ validateFn: (value: unknown) => value !== null && value !== undefined && value !== '', message: 'Required' }] + const { container, rerender } = render() + const field = await textField(container) + + typeText(field, typed[0]) + let valid = true + act(() => { + valid = ref.current!.validate() + }) + expect(valid).toBe(false) + expect(container.textContent).toContain('Required') + // The render of the error left the typed text in place + expect(field.value).toBe(typed[0]) + + rerender() + act(() => { + valid = ref.current!.validate() + }) + expect(valid).toBe(true) + expect(container.textContent).not.toContain('Required') + }) + + describe('in StrictMode', () => { + test('shows the value and follows its changes', async () => { + const { container, rerender } = render(, { wrapper: StrictMode }) + const field = await textField(container) + expect(field.value).toBe(first[1]) + + rerender() + expect(field.value).toBe(second[1]) + }) + + test('keeps the text typed around a commit', async () => { + const onModel = vi.fn() + const { container } = render(, { wrapper: StrictMode }) + const field = await textField(container) + + act(() => { + typeText(field, typed[0]) + commit(field) + typeText(field, longer[0]) + }) + + expect(onModel).toHaveBeenLastCalledWith(typed[1]) + expect(field.value).toBe(longer[0]) + }) + }) +}) + +describe('RLNumberInput', () => { + async function setup(props: FieldProps = {}) { + const onChange = vi.fn() + const view = render() + const field = await textField(view.container) + return { ...view, field, onChange } + } + + test.each([ + ['0012', 12, '12'], + ['1.50', 1.5, '1.5'], + ['-7', -7, '-7'], + ['0', 0, '0'], + ['1e3', 1000, '1000'] + ])('commits %s as %d and shows it parsed, before any render', async (text, value, shown) => { + const { field, onChange } = await setup() + + let shownInsideTheEvent = '' + act(() => { + typeText(field, text) + commit(field) + shownInsideTheEvent = field.value + }) + + expect(onChange).toHaveBeenCalledWith(value) + expect(shownInsideTheEvent).toBe(shown) + expect(field.value).toBe(shown) + }) + + test('commits an emptied field as null', async () => { + const { field, onChange } = await setup({ value: 5 }) + + act(() => { + typeText(field, '') + commit(field) + }) + + expect(onChange).toHaveBeenCalledWith(null) + expect(field.value).toBe('') + }) + + test('shows 0', async () => { + const { field } = await setup({ value: 0 }) + expect(field.value).toBe('0') + }) + + test.each([ + ['500', 100], + ['-3', 0] + ])('brings %s into the range and shows the result', async (text, value) => { + const { field, onChange } = await setup({ value: 50, min: 0, max: 100 }) + + act(() => { + typeText(field, text) + commit(field) + }) + + expect(onChange).toHaveBeenCalledWith(value) + expect(field.value).toBe(String(value)) + }) + + test('shows the limit also when the model was already at the limit', async () => { + // The value of the parent does not change: nothing but the commit itself + // can put the limit back in the field + const { field, onChange, rerender } = await setup({ value: 100, min: 0, max: 100 }) + + act(() => { + typeText(field, '900') + commit(field) + }) + rerender() + + expect(onChange).toHaveBeenCalledWith(100) + expect(field.value).toBe('100') + }) + + test('the clear button reports null', async () => { + const onClear = vi.fn() + const { field, onChange } = await setup({ value: 42, clearable: true, onClear }) + + act(() => { + field.shadowRoot!.querySelector('[part~="clear-button"]')!.click() + }) + + expect(onChange).toHaveBeenLastCalledWith(null) + expect(onClear).toHaveBeenCalledTimes(1) + expect(field.value).toBe('') + }) +}) + +describe('RLInput', () => { + test('the clear button reports an empty string', async () => { + const onChange = vi.fn() + const onClear = vi.fn() + const { container } = render() + const field = await textField(container) + + act(() => { + field.shadowRoot!.querySelector('[part~="clear-button"]')!.click() + }) + + expect(onChange).toHaveBeenLastCalledWith('') + expect(onClear).toHaveBeenCalledTimes(1) + expect(field.value).toBe('') + }) + + test('forwards the events of the element', async () => { + const onInput = vi.fn() + const onFocus = vi.fn() + const onBlur = vi.fn() + const { container } = render() + const field = await textField(container) + + typeText(field, 'a') + nativeOf(field).dispatchEvent(new Event('focus')) + nativeOf(field).dispatchEvent(new Event('blur')) + + expect(onInput).toHaveBeenCalledTimes(1) + expect(onFocus).toHaveBeenCalledTimes(1) + expect(onBlur).toHaveBeenCalledTimes(1) + }) + + test('keeps numbers as text when used with type number', async () => { + const onChange = vi.fn() + const { container } = render() + const field = await textField(container) + + act(() => { + typeText(field, '0012') + commit(field) + }) + + expect(onChange).toHaveBeenCalledWith('0012') + expect(field.value).toBe('0012') + }) +}) diff --git a/tests/unit/setup.ts b/tests/unit/setup.ts new file mode 100644 index 0000000..b5c1a4b --- /dev/null +++ b/tests/unit/setup.ts @@ -0,0 +1,24 @@ +import { afterEach } from 'vitest' +import { cleanup } from '@testing-library/react' + +// React only flushes effects synchronously in tests when told it runs in one +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +// Used by Shoelace to size its textarea, missing in jsdom +globalThis.ResizeObserver ??= class { + observe() {} + unobserve() {} + disconnect() {} +} + +// Used by Shoelace to run the animations of dialogs and popups, missing in jsdom +window.matchMedia ??= (query: string) => + ({ matches: false, media: query, addEventListener() {}, removeEventListener() {} }) as unknown as MediaQueryList +Element.prototype.getAnimations ??= () => [] +Element.prototype.animate ??= function () { + return { finished: Promise.resolve(), cancel() {}, addEventListener() {} } as unknown as Animation +} + +afterEach(() => { + cleanup() +}) diff --git a/tests/unit/use-element-value.test.tsx b/tests/unit/use-element-value.test.tsx new file mode 100644 index 0000000..62fb4c0 --- /dev/null +++ b/tests/unit/use-element-value.test.tsx @@ -0,0 +1,150 @@ +import { describe, expect, test } from 'vitest' +import { renderHook } from '@testing-library/react' +import { useElementValue } from '../../src/hooks/useElementValue' + +// Stands in for a Shoelace control: holds a value and counts its writes +function fakeElement(initial = '') { + const writes: string[] = [] + let current = initial + return { + writes, + get value() { + return current + }, + set value(written: string) { + writes.push(written) + current = written + }, + // What typing does: the text changes without the hook being involved + type(text: string) { + current = text + } + } +} + +type FakeElement = ReturnType + +function setup(value: string, element: FakeElement | null = fakeElement()) { + const hook = renderHook( + (props: { value: string }) => { + const result = useElementValue(props.value) + // The ref is attached during render, as React does before layout effects + ;(result.elementRef as { current: FakeElement | null }).current = element + return result + }, + { initialProps: { value } } + ) + return { ...hook, element } +} + +describe('useElementValue', () => { + test('writes the initial value', () => { + const { element } = setup('alpha') + expect(element!.value).toBe('alpha') + expect(element!.writes).toEqual(['alpha']) + }) + + test('does not write an element that already holds the value', () => { + const { element } = setup('', fakeElement('')) + expect(element!.writes).toEqual([]) + }) + + test('does not write again while the value stays the same', () => { + const { element, rerender } = setup('alpha') + rerender({ value: 'alpha' }) + rerender({ value: 'alpha' }) + expect(element!.writes).toEqual(['alpha']) + }) + + test('leaves typed text alone while the value stays the same', () => { + const { element, rerender } = setup('alpha') + element!.type('alpha and more') + rerender({ value: 'alpha' }) + expect(element!.value).toBe('alpha and more') + }) + + test('writes when the value changes', () => { + const { element, rerender } = setup('alpha') + rerender({ value: 'beta' }) + expect(element!.writes).toEqual(['alpha', 'beta']) + }) + + test('a changed value replaces typed text', () => { + const { element, rerender } = setup('alpha') + element!.type('typed') + rerender({ value: 'beta' }) + expect(element!.value).toBe('beta') + }) + + test('a value going back to one seen before is written again', () => { + const { element, rerender } = setup('alpha') + rerender({ value: 'beta' }) + rerender({ value: 'alpha' }) + expect(element!.writes).toEqual(['alpha', 'beta', 'alpha']) + }) + + test('the committed value coming back as a prop is not written', () => { + const { element, rerender, result } = setup('alpha') + element!.type('typed') + result.current.commitValue('typed') + rerender({ value: 'typed' }) + expect(element!.writes).toEqual(['alpha']) + }) + + test('text typed after a commit survives the render that echoes the commit', () => { + const { element, rerender, result } = setup('alpha') + element!.type('typed') + result.current.commitValue('typed') + element!.type('typed and more') + rerender({ value: 'typed' }) + expect(element!.value).toBe('typed and more') + }) + + test('a commit that differs from the text is shown right away', () => { + const { element, result } = setup('') + element!.type('0012') + result.current.commitValue('12') + expect(element!.value).toBe('12') + }) + + test('a commit the parent ignores is taken back at the next render', () => { + const { element, rerender, result } = setup('alpha') + element!.type('typed') + result.current.commitValue('typed') + rerender({ value: 'alpha' }) + expect(element!.value).toBe('alpha') + }) + + test('a commit the parent changes shows the value of the parent', () => { + const { element, rerender, result } = setup('') + element!.type('typed') + result.current.commitValue('typed') + rerender({ value: 'TYPED' }) + expect(element!.value).toBe('TYPED') + }) + + test('works without an element, and syncs one that shows up later', () => { + const element = fakeElement() + const holder: { element: FakeElement | null } = { element: null } + const { rerender, result } = renderHook( + (props: { value: string }) => { + const hook = useElementValue(props.value) + ;(hook.elementRef as { current: FakeElement | null }).current = holder.element + return hook + }, + { initialProps: { value: 'alpha' } } + ) + expect(() => result.current.commitValue('typed')).not.toThrow() + + holder.element = element + rerender({ value: 'alpha' }) + expect(element.value).toBe('alpha') + }) + + test('commitValue keeps its identity across renders', () => { + const { rerender, result } = setup('alpha') + const first = result.current.commitValue + rerender({ value: 'beta' }) + expect(result.current.commitValue).toBe(first) + }) +}) diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..5b7ae8e --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,24 @@ +import { defineConfig } from 'vitest/config' +import react from '@vitejs/plugin-react' + +// Lit and its React wrapper ship a build for Node that renders nothing and +// skips the effects setting the element properties: the tests need the +// browser one, as the applications get +const conditions = ['browser', 'development'] + +export default defineConfig({ + plugins: [react()], + resolve: { conditions }, + ssr: { resolve: { conditions } }, + test: { + environment: 'jsdom', + include: ['tests/unit/**/*.test.{ts,tsx}'], + setupFiles: ['tests/unit/setup.ts'], + restoreMocks: true, + server: { + deps: { + inline: [/@lit\//, /\/lit(-html|-element)?\//, /@lit-labs\//, /@shoelace-style\//] + } + } + } +})