From 77c1d010255322907586f1f9014063fc2cf2c1b9 Mon Sep 17 00:00:00 2001 From: Starystars67 Date: Thu, 8 Oct 2026 21:42:28 +0100 Subject: [PATCH] Add Vitest tests for the languages, router, footer and pages, and a lint:check script --- eslint.config.js | 20 + package-lock.json | 1181 +++++++++++++++++++++++++++++++++++- package.json | 8 +- tests/footer.test.js | 86 +++ tests/helpers.js | 90 +++ tests/locale-utils.test.js | 67 ++ tests/locales.test.js | 135 +++++ tests/pages.test.js | 274 +++++++++ tests/router.test.js | 127 ++++ tests/setup.js | 88 +++ vitest.config.js | 25 + 11 files changed, 2095 insertions(+), 6 deletions(-) create mode 100644 tests/footer.test.js create mode 100644 tests/helpers.js create mode 100644 tests/locale-utils.test.js create mode 100644 tests/locales.test.js create mode 100644 tests/pages.test.js create mode 100644 tests/router.test.js create mode 100644 tests/setup.js create mode 100644 vitest.config.js diff --git a/eslint.config.js b/eslint.config.js index 16523d4..764afd7 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -72,4 +72,24 @@ export default [ 'no-var': 'error', }, }, + // Tests: Vitest's globals (vitest.config.js sets `globals: true`) and the + // browser classes jsdom provides. + { + files: ['tests/**/*.js', 'vitest.config.js'], + languageOptions: { + globals: { + describe: 'readonly', + test: 'readonly', + it: 'readonly', + expect: 'readonly', + vi: 'readonly', + beforeEach: 'readonly', + afterEach: 'readonly', + beforeAll: 'readonly', + afterAll: 'readonly', + globalThis: 'readonly', + HTMLCanvasElement: 'readonly', + }, + }, + }, ] diff --git a/package-lock.json b/package-lock.json index c53e743..2e5cce4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,15 +26,69 @@ "devDependencies": { "@vitejs/plugin-vue": "^6.0.2", "@vue/eslint-config-prettier": "^10.2.0", + "@vue/test-utils": "^2.5.1", "eslint": "^9.39.1", "eslint-config-prettier": "^10.1.8", "eslint-plugin-prettier": "^5.5.4", "eslint-plugin-vue": "^10.6.2", + "jsdom": "^29.1.1", "prettier": "^3.7.3", "tw-animate-css": "^1.4.0", - "vite": "^8.0.16" + "vite": "^8.0.16", + "vitest": "^4.1.11" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/helper-string-parser": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", @@ -77,6 +131,159 @@ "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/@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/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -239,6 +446,24 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.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", @@ -474,6 +699,13 @@ "@emnapi/runtime": "^1.7.1" } }, + "node_modules/@one-ini/wasm": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.2.1.tgz", + "integrity": "sha512-TUqERXGNTifZ9y2g3wPxQrw3HpHv/02DsW3D90T9x0hhonrL1ZqpSmNrU2XkoIq0fP1N6gZfVQzy2Fw1ZvGBNg==", + "dev": true, + "license": "MIT" + }, "node_modules/@oxc-project/types": { "version": "0.133.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", @@ -726,6 +958,13 @@ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@swc/helpers": { "version": "0.5.17", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz", @@ -1032,6 +1271,24 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1065,6 +1322,129 @@ "vue": "^3.2.25" } }, + "node_modules/@vitest/expect": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.11", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/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/@vitest/pretty-format": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.11", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@vue/compiler-core": { "version": "3.5.25", "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.25.tgz", @@ -1175,6 +1555,27 @@ "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.25.tgz", "integrity": "sha512-AbOPdQQnAnzs58H2FrrDxYj/TJfmeS2jdfEEhgiKINy+bnOANmVizIEgq1r+C5zsbs6l1CCQxtcj71rwNQ4jWg==" }, + "node_modules/@vue/test-utils": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@vue/test-utils/-/test-utils-2.5.1.tgz", + "integrity": "sha512-V/4a99odJ7hapr0bRt35XxljGk8hefq31gSOnlKTcEq2fUeoj2qb+FcpJYfQu4qfYhW9Ug+O2+CZ3QmZ7gXDzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-beautify": "^2.0.0", + "vue-component-type-helpers": "^3.0.0" + }, + "peerDependencies": { + "@vue/compiler-dom": "3.x", + "@vue/server-renderer": "3.x", + "vue": "3.x" + }, + "peerDependenciesMeta": { + "@vue/server-renderer": { + "optional": true + } + } + }, "node_modules/@vueuse/core": { "version": "14.1.0", "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.1.0.tgz", @@ -1210,6 +1611,16 @@ "vue": "^3.5.0" } }, + "node_modules/abbrev": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-5.0.0.tgz", + "integrity": "sha512-/XrFJgzQQQHpti1raDJC6m4ws6aNktmjBlhk8Fdlk7LwCEuDoieEJJY9OFHjfiFJFFRM2tK+Ky/IsfbbmlMu1w==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -1280,12 +1691,32 @@ "node": ">=10" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, + "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/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -1312,6 +1743,16 @@ "node": ">=6" } }, + "node_modules/chai": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -1365,12 +1806,40 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, + "node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "dev": true }, + "node_modules/config-chain": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", + "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "^1.3.4", + "proto-list": "~1.2.1" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -1385,6 +1854,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/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -1401,6 +1884,20 @@ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==" }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1418,6 +1915,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-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -1438,6 +1942,64 @@ "node": ">=8" } }, + "node_modules/editorconfig": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-3.0.2.tgz", + "integrity": "sha512-T0ix8GhtxyKVfUFEcvdNDt3YGqlwkFHbD4/5bgFUDgFmxhI/cSRAeJ87/Sz//Cq8Eam6JX/e23RkoFO71P7aAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@one-ini/wasm": "0.2.1", + "commander": "^14.0.3", + "minimatch": "~10.2.4", + "semver": "^7.7.4" + }, + "bin": { + "editorconfig": "bin/editorconfig" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/editorconfig/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/editorconfig/node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/editorconfig/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/enhanced-resolve": { "version": "5.21.6", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", @@ -1461,6 +2023,13 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -1700,6 +2269,16 @@ "node": ">=0.10.0" } }, + "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", @@ -1801,6 +2380,24 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -1813,6 +2410,45 @@ "node": ">=10.13.0" } }, + "node_modules/glob/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/globals": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", @@ -1839,6 +2475,19 @@ "node": ">=8" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -1873,6 +2522,13 @@ "node": ">=0.8.19" } }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -1894,6 +2550,13 @@ "node": ">=0.10.0" } }, + "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/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -1908,6 +2571,35 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/js-beautify": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-2.0.3.tgz", + "integrity": "sha512-cyFbh3tkPhknnTD/0bLf0T0yy2ZIbqL05mttzbt4y1Zfr7NxqXQZ62dkBLKs3oHH/lpjmDRAnciJiSUyOy8XwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "config-chain": "^1.1.13", + "editorconfig": "^3.0.2", + "glob": "^13.0.6", + "js-cookie": "^3.0.8", + "nopt": "^10.0.1" + }, + "bin": { + "css-beautify": "js/bin/css-beautify.js", + "html-beautify": "js/bin/html-beautify.js", + "js-beautify": "js/bin/js-beautify.js" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/js-cookie": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.8.tgz", + "integrity": "sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw==", + "dev": true, + "license": "MIT" + }, "node_modules/js-yaml": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", @@ -1931,6 +2623,57 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/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/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -2229,6 +2972,16 @@ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true }, + "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/lucide-vue-next": { "version": "0.555.0", "resolved": "https://registry.npmjs.org/lucide-vue-next/-/lucide-vue-next-0.555.0.tgz", @@ -2256,6 +3009,13 @@ "node": ">= 20" } }, + "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/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -2269,6 +3029,16 @@ "node": "*" } }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -2299,6 +3069,22 @@ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true }, + "node_modules/nopt": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-10.0.1.tgz", + "integrity": "sha512-df3sBr/6ax9hSGuC3CspvLlbnX8cP5L5nZwXF8cGN8l0zSWR6BvzmQ6jPUKjvo6+/xdpkNvEcucBNUdBeeV13g==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^5.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, "node_modules/nth-check": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", @@ -2311,6 +3097,20 @@ "url": "https://github.com/fb55/nth-check?sponsor=1" } }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/ohash": { "version": "2.0.11", "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", @@ -2375,6 +3175,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-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -2393,6 +3219,30 @@ "node": ">=8" } }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "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/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2487,6 +3337,13 @@ "node": ">=6.0.0" } }, + "node_modules/proto-list": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", + "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", + "dev": true, + "license": "ISC" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -2549,6 +3406,16 @@ "url": "https://github.com/sponsors/antfu" } }, + "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-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -2590,11 +3457,25 @@ "@rolldown/binding-win32-x64-msvc": "1.0.3" } }, - "node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "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/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", "bin": { "semver": "bin/semver.js" }, @@ -2623,6 +3504,13 @@ "node": ">=8" } }, + "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/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2631,6 +3519,20 @@ "node": ">=0.10.0" } }, + "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": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", + "dev": true, + "license": "MIT" + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -2655,6 +3557,13 @@ "node": ">=8" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/synckit": { "version": "0.11.11", "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.11.tgz", @@ -2704,6 +3613,23 @@ "url": "https://opencollective.com/webpack" } }, + "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": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -2719,6 +3645,62 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.2.0.tgz", + "integrity": "sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.18", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.18.tgz", + "integrity": "sha512-somxA8xKAUzlAxl0TX/jMSYIbRNFIVGnI96pVGJPeMc5BsHmfVXn6mOJT6U++ELnl04eGfzpfcEuZe8WiASkXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.18" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.18", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.18.tgz", + "integrity": "sha512-KI2PKvCmvQI/xD6ypPqkU0i7oxNXR/O58RLRaHR4svsgVUQ2JE/XC8BtMbG6PSehD27I8sZJRcC1ASnCy6lp3g==", + "dev": true, + "license": "MIT" + }, + "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": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -2745,6 +3727,16 @@ "node": ">= 0.8.0" } }, + "node_modules/undici": { + "version": "7.30.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.0.tgz", + "integrity": "sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -2835,6 +3827,96 @@ } } }, + "node_modules/vitest": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, "node_modules/vue": { "version": "3.5.25", "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.25.tgz", @@ -2855,6 +3937,13 @@ } } }, + "node_modules/vue-component-type-helpers": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-3.3.12.tgz", + "integrity": "sha512-HwCqmgTfLFskQc4P5TRnbduCMyPat2Ezu0elLWOGn0dD/6BKHP15B5bxce6aDi+E4kblPtru8amIBQzu0Di3qQ==", + "dev": true, + "license": "MIT" + }, "node_modules/vue-eslint-parser": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-10.2.0.tgz", @@ -2912,6 +4001,64 @@ "vue": "^3.5.0" } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/w3c-xmlserializer/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/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/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": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -2927,6 +4074,23 @@ "node": ">= 8" } }, + "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", @@ -2945,6 +4109,13 @@ "node": ">=12" } }, + "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/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 304a841..86689cd 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,9 @@ "build": "vite build", "preview": "vite preview", "lint": "eslint . --fix", + "lint:check": "eslint .", + "test": "vitest run", + "test:watch": "vitest", "format": "prettier --write \"src/**/*.{js,vue,css,json}\"" }, "dependencies": { @@ -29,12 +32,15 @@ "devDependencies": { "@vitejs/plugin-vue": "^6.0.2", "@vue/eslint-config-prettier": "^10.2.0", + "@vue/test-utils": "^2.5.1", "eslint": "^9.39.1", "eslint-config-prettier": "^10.1.8", "eslint-plugin-prettier": "^5.5.4", "eslint-plugin-vue": "^10.6.2", + "jsdom": "^29.1.1", "prettier": "^3.7.3", "tw-animate-css": "^1.4.0", - "vite": "^8.0.16" + "vite": "^8.0.16", + "vitest": "^4.1.11" } } diff --git a/tests/footer.test.js b/tests/footer.test.js new file mode 100644 index 0000000..4d1d2fc --- /dev/null +++ b/tests/footer.test.js @@ -0,0 +1,86 @@ +/** + * The footer is on every page: the social links, the Patreon link, and the + * legal links. A wrong address or a missing "opens in a new tab" safety + * attribute here is shipped to every visitor. + */ + +import { mount, flushPromises } from '@vue/test-utils' +import { installI18n } from './helpers' +import router from '@/routes' +import AppFooter from '@/components/AppFooter.vue' + +async function mountFooter() { + const i18n = installI18n('en') + await router.push('/en') + await router.isReady() + const wrapper = mount(AppFooter, { global: { plugins: [i18n, router] } }) + await flushPromises() + return wrapper +} + +const SOCIAL = { + GitHub: 'https://github.com/BeamMP', + Discord: 'https://discord.gg/beammp', + YouTube: 'https://www.youtube.com/@beammpofficial', + X: 'https://x.com/beammpofficial', + Reddit: 'https://www.reddit.com/r/BeamMP', + Bluesky: 'https://bsky.app/profile/beammp.com', + Twitch: 'https://www.twitch.tv/beammpofficial', + Instagram: 'https://www.instagram.com/beammpofficial', + TikTok: 'https://www.tiktok.com/@beammpofficial', + Facebook: 'https://www.facebook.com/BeamMPTeam', +} + +test('shows each social link, in order, with the right address', async () => { + const wrapper = await mountFooter() + const links = wrapper.findAll('a[aria-label]') + expect(links.map((a) => a.attributes('aria-label'))).toEqual(Object.keys(SOCIAL)) + for (const a of links) { + expect(a.attributes('href'), a.attributes('aria-label')).toBe( + SOCIAL[a.attributes('aria-label')] + ) + } +}) + +test('Twitch and Bluesky are both there, side by side', async () => { + const wrapper = await mountFooter() + const labels = wrapper.findAll('a[aria-label]').map((a) => a.attributes('aria-label')) + expect(labels.indexOf('Twitch')).toBe(labels.indexOf('Bluesky') + 1) +}) + +test('every link that leaves the site opens in a new tab without handing over the opener', async () => { + const wrapper = await mountFooter() + const external = wrapper + .findAll('a') + .filter((a) => /^https?:\/\//.test(a.attributes('href') || '')) + expect(external.length).toBeGreaterThanOrEqual(Object.keys(SOCIAL).length + 3) + for (const a of external) { + expect(a.attributes('target'), a.attributes('href')).toBe('_blank') + expect(a.attributes('rel'), a.attributes('href')).toContain('noopener') + } +}) + +test('every social icon is a labelled link with a drawn icon', async () => { + const wrapper = await mountFooter() + for (const a of wrapper.findAll('a[aria-label]')) { + expect(a.find('svg').exists(), a.attributes('aria-label')).toBe(true) + } +}) + +test('has the Patreon link and the legal links, and this year in the copyright', async () => { + const wrapper = await mountFooter() + const hrefs = wrapper.findAll('a').map((a) => a.attributes('href')) + expect(hrefs).toContain('https://www.patreon.com/BeamMP') + expect(hrefs).toContain('https://beammp.com/privacy') + expect(hrefs).toContain('https://beammp.com/terms') + expect(hrefs).toContain('/en/about') + expect(wrapper.text()).toContain(`2019 - ${new Date().getFullYear()}`) +}) + +test('the About link follows the language', async () => { + const i18n = installI18n('en') + await router.push('/de') + const wrapper = mount(AppFooter, { global: { plugins: [i18n, router] } }) + await flushPromises() + expect(wrapper.findAll('a').map((a) => a.attributes('href'))).toContain('/de/about') +}) diff --git a/tests/helpers.js b/tests/helpers.js new file mode 100644 index 0000000..d7a499b --- /dev/null +++ b/tests/helpers.js @@ -0,0 +1,90 @@ +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { flushPromises, mount } from '@vue/test-utils' +import { setupI18n } from '@/i18n' +import en from '@/locales/en.json' +import router from '@/routes' +import App from '@/App.vue' + +export const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +export const publicFile = (name) => fs.readFileSync(path.join(root, 'public', name), 'utf8') + +// The same i18n the app builds in main.js, with English loaded. The router's +// guard reads window.i18n to switch language, so it is set here too. +export function installI18n(locale = 'en') { + const i18n = setupI18n({ legacy: false, locale, fallbackLocale: 'en', messages: { en } }) + window.i18n = i18n + return i18n +} + +const reply = (body, ok = true, status = ok ? 200 : 503) => ({ + ok, + status, + json: async () => (typeof body === 'string' ? JSON.parse(body) : body), + text: async () => (typeof body === 'string' ? body : JSON.stringify(body)), +}) + +/** + * Stands in for fetch. Local files come from the repo's public folder; the + * backend feeds can be given as data, or left to answer 503 ("backend down"). + * Nothing reaches the network, and a URL nobody planned for fails the test. + */ +export function stubFetch({ servers, metrics } = {}) { + const calls = [] + const stub = async (url) => { + const address = String(url) + calls.push(address) + if ( + address.startsWith('/docs/') || + address === '/partners.json' || + address === '/servers.json' + ) { + return reply(publicFile(address.slice(1))) + } + if (address === 'https://backend.beammp.com/servers-info') + return servers ? reply(servers) : reply('', false) + if (address === 'https://api.beammp.com/metrics') + return metrics ? reply(metrics) : reply('', false) + // The statistics page's release and stats feeds: down unless a test says otherwise. + if ( + /^https:\/\/(api|backend)\.beammp\.com\//.test(address) || + address.startsWith('https://api.github.com/') + ) + return reply('', false) + throw new Error(`Unplanned fetch to ${address}`) + } + stub.calls = calls + vi.stubGlobal('fetch', stub) + return stub +} + +/** + * Runs `action` (a navigation) and fails it if the router's language guard + * keeps redirecting. A runaway redirect would otherwise freeze the test run + * the way it freezes the page. The guard reads the saved language on every + * redirect, which is what is counted. + */ +export async function withRedirectLimit(action, limit = 5) { + const real = localStorage.getItem.bind(localStorage) + let passes = 0 + localStorage.getItem = (key) => { + if (key === 'lang' && ++passes > limit) throw new Error('still redirecting') + return real(key) + } + try { + return await action() + } finally { + localStorage.getItem = real + } +} + +/** Opens a page of the real app (router, layout, nav and footer) and lets it finish loading. */ +export async function openPage(pathname) { + const i18n = installI18n('en') + await withRedirectLimit(() => router.push(pathname)) + await router.isReady() + const wrapper = mount(App, { attachTo: document.body, global: { plugins: [i18n, router] } }) + await flushPromises() + return wrapper +} diff --git a/tests/locale-utils.test.js b/tests/locale-utils.test.js new file mode 100644 index 0000000..e8a51e6 --- /dev/null +++ b/tests/locale-utils.test.js @@ -0,0 +1,67 @@ +/** + * The helpers that build and switch the //... paths. Every link on + * the site goes through these, so a slip here breaks navigation everywhere. + */ + +import { getCurrentLocale, getLocalizedPath, switchLocale } from '@/utils/locale' + +afterEach(() => { + delete window.i18n +}) + +describe('getLocalizedPath', () => { + test('puts the language first, with or without a leading slash on the page', () => { + expect(getLocalizedPath('servers', 'de')).toBe('/de/servers') + expect(getLocalizedPath('/servers', 'de')).toBe('/de/servers') + }) + + test('never doubles a slash', () => { + expect(getLocalizedPath('//servers', 'fr')).toBe('/fr/servers') + expect(getLocalizedPath('', 'fr')).toBe('/fr/') + }) + + test('uses the saved language when none is given, and English if nothing is saved', () => { + expect(getLocalizedPath('about')).toBe('/en/about') + localStorage.setItem('lang', 'it') + expect(getLocalizedPath('about')).toBe('/it/about') + }) + + test('the language the app is showing wins over the saved one', () => { + localStorage.setItem('lang', 'it') + window.i18n = { global: { locale: { value: 'ru' } } } + expect(getLocalizedPath('about')).toBe('/ru/about') + }) +}) + +describe('getCurrentLocale', () => { + test('reads a plain string locale as well as a ref', () => { + window.i18n = { global: { locale: 'es' } } + expect(getCurrentLocale()).toBe('es') + window.i18n = { global: { locale: { value: 'zh' } } } + expect(getCurrentLocale()).toBe('zh') + }) +}) + +describe('switchLocale', () => { + test('swaps the language and keeps the page', () => { + expect(switchLocale('de', '/en/servers')).toBe('/de/servers') + expect(switchLocale('fr', '/ru/about')).toBe('/fr/about') + }) + + test('keeps deeper paths', () => { + expect(switchLocale('de', '/en/docs/getting-started')).toBe('/de/docs/getting-started') + }) + + test('the home page of one language is the home page of the next', () => { + expect(switchLocale('es', '/en')).toBe('/es') + expect(switchLocale('es', '/en/')).toBe('/es') + }) + + test('drops the old query string rather than carrying it across', () => { + expect(switchLocale('de', '/en/servers?search=drift')).toBe('/de/servers') + }) + + test('adds a language to a path that has none', () => { + expect(switchLocale('de', '/servers')).toBe('/de/servers') + }) +}) diff --git a/tests/locales.test.js b/tests/locales.test.js new file mode 100644 index 0000000..8f78e41 --- /dev/null +++ b/tests/locales.test.js @@ -0,0 +1,135 @@ +/** + * The site is shown in seven languages from seven JSON files. Nothing else + * notices when one of them falls behind, so this does: every language has + * every English text, nothing is blank, translations keep the same {placeholders} + * as English, and every text a page asks for exists. + */ + +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { LANGUAGES, SUPPORT_LOCALES } from '@/i18n' + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const localesDir = path.join(root, 'src/locales') +const read = (code) => JSON.parse(fs.readFileSync(path.join(localesDir, `${code}.json`), 'utf8')) + +function flatten(obj, prefix = '') { + const out = {} + for (const [key, value] of Object.entries(obj)) { + const full = prefix ? `${prefix}.${key}` : key + if (value && typeof value === 'object' && !Array.isArray(value)) + Object.assign(out, flatten(value, full)) + else out[full] = value + } + return out +} + +// Texts that are known to be untranslated, so the test can pass today and still +// catch anything new. Translate one and delete it from here: the "still +// missing" test below fails if an entry is left behind. +const KNOWN_UNTRANSLATED = { + 'message.nav.store': ['de', 'es', 'fr', 'it', 'ru'], +} + +const english = flatten(read('en')) +const others = SUPPORT_LOCALES.filter((code) => code !== 'en') + +describe('the language list', () => { + test('every supported language has a file, and every file is a supported language', () => { + const files = fs + .readdirSync(localesDir) + .filter((f) => f.endsWith('.json')) + .map((f) => f.replace('.json', '')) + expect(files.sort()).toEqual([...SUPPORT_LOCALES].sort()) + }) + + test('every supported language has a name and a flag for the picker', () => { + for (const code of SUPPORT_LOCALES) { + expect(LANGUAGES[code]).toMatchObject({ + code, + name: expect.any(String), + flag: expect.any(String), + }) + } + }) +}) + +describe.each(others)('%s translation', (code) => { + const messages = flatten(read(code)) + const allowedMissing = (key) => (KNOWN_UNTRANSLATED[key] || []).includes(code) + + test('has every English text', () => { + const missing = Object.keys(english).filter((key) => !(key in messages) && !allowedMissing(key)) + expect(missing).toEqual([]) + }) + + test('has no text English does not have (a leftover or a typo in the name)', () => { + expect(Object.keys(messages).filter((key) => !(key in english))).toEqual([]) + }) + + test('has no blank texts', () => { + const blank = Object.entries(messages).filter( + ([, value]) => typeof value !== 'string' || !value.trim() + ) + expect(blank.map(([key]) => key)).toEqual([]) + }) + + test('keeps the same {placeholders} as English', () => { + const placeholders = (text) => [...String(text).matchAll(/\{(\w+)\}/g)].map((m) => m[1]).sort() + const mismatched = Object.keys(messages) + .filter((key) => key in english) + .filter( + (key) => + JSON.stringify(placeholders(messages[key])) !== JSON.stringify(placeholders(english[key])) + ) + expect(mismatched).toEqual([]) + }) +}) + +test('every entry in the known-untranslated list is still untranslated', () => { + const stale = [] + for (const [key, codes] of Object.entries(KNOWN_UNTRANSLATED)) { + if (!(key in english)) stale.push(`${key} (not in English)`) + for (const code of codes) + if (key in flatten(read(code))) stale.push(`${key} (${code} has it now)`) + } + expect(stale).toEqual([]) +}) + +describe('texts the pages ask for', () => { + const sourceFiles = [] + const walk = (dir) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name) + if (entry.isDirectory()) walk(full) + else if (/\.(vue|js)$/.test(entry.name)) sourceFiles.push(full) + } + } + walk(path.join(root, 'src')) + + const used = new Map() + const dynamic = [] + for (const file of sourceFiles) { + const source = fs.readFileSync(file, 'utf8') + for (const m of source.matchAll(/(? { + expect(used.size).toBeGreaterThan(50) + }) + + test('every text a page asks for exists in English', () => { + const missing = [...used].filter( + ([key]) => !(key in english) && !Object.keys(english).some((k) => k.startsWith(`${key}.`)) + ) + expect(missing.map(([key, file]) => `${key} (${file})`)).toEqual([]) + }) + + test('no page builds a text name on the fly, which this check could not see', () => { + expect(dynamic).toEqual([]) + }) +}) diff --git a/tests/pages.test.js b/tests/pages.test.js new file mode 100644 index 0000000..7dee5cb --- /dev/null +++ b/tests/pages.test.js @@ -0,0 +1,274 @@ +/** + * Opens each page of the real app (router, navigation, page, footer) with + * fetch stubbed, the way a visitor gets it. A page that throws while loading, + * logs a Vue warning, or goes blank when the backend is down shows up here. + * The checks are about what a page says, not how it looks. + */ + +import { openPage, publicFile, stubFetch } from './helpers' + +const PAGES = [ + ['home', '/en'], + ['about', '/en/about'], + ['communities', '/en/communities'], + ['servers', '/en/servers'], + ['statistics', '/en/stats'], + ['partners', '/en/partners'], + ['privacy', '/en/privacy'], + ['terms', '/en/terms'], +] + +let wrapper +let warnings + +beforeEach(() => { + warnings = [] + vi.spyOn(console, 'warn').mockImplementation((...args) => warnings.push(args.join(' '))) + vi.spyOn(console, 'error').mockImplementation((...args) => warnings.push(args.join(' '))) + vi.spyOn(console, 'log').mockImplementation(() => {}) +}) + +afterEach(() => { + wrapper?.unmount() + wrapper = undefined + document.body.innerHTML = '' + vi.unstubAllGlobals() + vi.restoreAllMocks() +}) + +describe.each(PAGES)('the %s page', (_name, address) => { + test('opens with the navigation and footer around it, and no Vue warnings', async () => { + stubFetch() + wrapper = await openPage(address) + expect(wrapper.find('nav, header').exists()).toBe(true) + expect(wrapper.find('footer').exists()).toBe(true) + expect(wrapper.find('main').text().length).toBeGreaterThan(0) + expect(warnings.filter((w) => w.includes('[Vue warn]'))).toEqual([]) + }) + + test('still opens, and says something, when the backend is down', async () => { + stubFetch() + wrapper = await openPage(address) + expect(wrapper.find('main').text().trim()).not.toBe('') + expect(wrapper.find('footer').exists()).toBe(true) + }) +}) + +describe('home', () => { + test('shows the live numbers from the metrics feed', async () => { + stubFetch({ + metrics: 'beammp_players_online 2783\nbeammp_public_servers 2959\nbeammp_all_servers 4663\n', + }) + wrapper = await openPage('/en') + const text = wrapper.text() + expect(text).toContain('2783') + expect(text).toContain('2959') + expect(text).toContain('4663') + }) + + test('shows N/A rather than a broken number when the metrics are down', async () => { + vi.stubGlobal('fetch', async () => { + throw new Error('network down') + }) + wrapper = await openPage('/en') + expect(wrapper.text()).toContain('N/A') + expect(wrapper.text()).not.toContain('undefined') + }) +}) + +describe('the docs links on the home page', () => { + test('point at the docs in the language of the page', async () => { + stubFetch() + wrapper = await openPage('/en') + const english = wrapper.findAll('a').map((a) => a.attributes('href')) + expect(english).toContain('https://docs.beammp.com/') + expect(english).toContain('https://docs.beammp.com/scripting/mod-reference/') + wrapper.unmount() + + wrapper = await openPage('/de') + const german = wrapper.findAll('a').map((a) => a.attributes('href')) + expect(german).toContain('https://docs.beammp.com/de/') + expect(german).toContain('https://docs.beammp.com/de/scripting/mod-reference/') + }) +}) + +describe('servers', () => { + const servers = JSON.parse(publicFile('servers.json')).slice(0, 25) + + test('lists the servers the backend returns', async () => { + stubFetch({ servers }) + wrapper = await openPage('/en/servers') + const text = wrapper.find('main').text() + expect(text).toContain('MTRTR Christmas') + expect(text).not.toContain('Failed to load servers') + }) + + // A server's name is set by whoever hosts it and is shown as HTML so the + // colour codes can apply, so the page escapes it itself. (The API also strips + // every `<` at heartbeat, which is why the real feed has none: this is the + // second layer, and it holds even if the first ever slips.) + test('a server name with HTML in it is shown as text, not turned into page elements', async () => { + const hostile = { + ...servers[0], + sname: '^1Evil bold', + } + stubFetch({ servers: [hostile] }) + wrapper = await openPage('/en/servers') + expect(document.querySelector('img[src="x-probe"]')).toBeNull() + expect(document.querySelector('#probe-b')).toBeNull() + expect(wrapper.find('main').text()).toContain('bold') + }) + + test('names with & and quotes still read as written, and keep their colours', async () => { + const named = [ + { ...servers[0], ident: 'a1', sname: '^1C&B\'s ^2"Banger" League' }, + { ...servers[1], ident: 'a2', sname: 'Plain & simple' }, + ] + stubFetch({ servers: named }) + wrapper = await openPage('/en/servers') + const text = wrapper.find('main').text() + expect(text).toContain('C&B\'s "Banger" League') + expect(text).toContain('Plain & simple') + expect(text).not.toContain('&') + // ^1 is dark blue: the colour codes are not shown, and the colour is applied + expect(text).not.toContain('^1') + expect(wrapper.find('.name-cell span[style*="color"]').exists()).toBe(true) + }) + + test('text that looks like an HTML entity is shown exactly as typed', async () => { + stubFetch({ + servers: [{ ...servers[0], ident: 'a1', sname: 'Tags <b> and & shown as typed' }], + }) + wrapper = await openPage('/en/servers') + expect(wrapper.find('main').text()).toContain('Tags <b> and & shown as typed') + }) + + describe('searching', () => { + const named = () => [ + { + ...servers[0], + ident: 'a1', + sname: "^1C&B's ^2Banger League", + sdesc: 'drift night', + owner: 'zed', + map: '/levels/west_coast_usa/info.json', + }, + { + ...servers[1], + ident: 'a2', + sname: '^4Quiet Valley', + sdesc: 'relaxed driving', + owner: 'amy', + map: '/levels/italy/info.json', + }, + ] + const search = async (query) => { + await wrapper.find('input.search-input').setValue(query) + return wrapper.findAll('.name-cell').map((cell) => cell.text()) + } + + test('finds a name containing & or an apostrophe by what you type', async () => { + stubFetch({ servers: named() }) + wrapper = await openPage('/en/servers') + expect(await search("c&b's")).toEqual([expect.stringContaining("C&B's Banger League")]) + expect(await search('banger league')).toHaveLength(1) + }) + + test('does not match on the colour markup behind the name', async () => { + stubFetch({ servers: named() }) + wrapper = await openPage('/en/servers') + expect(await search('span')).toEqual([]) + expect(await search('color')).toEqual([]) + }) + + test('still finds by description and owner', async () => { + stubFetch({ servers: named() }) + wrapper = await openPage('/en/servers') + expect(await search('relaxed')).toEqual([expect.stringContaining('Quiet Valley')]) + expect(await search('zed')).toEqual([expect.stringContaining("C&B's")]) + }) + }) + + test('says so when the servers cannot be loaded', async () => { + stubFetch() + wrapper = await openPage('/en/servers') + expect(wrapper.find('main').text()).toContain('Failed to load servers') + }) +}) + +describe('the policy pages', () => { + test.each([ + ['privacy', '/en/privacy', 'docs/privacy_policy.md'], + ['terms', '/en/terms', 'docs/terms.md'], + ])('%s shows the text of its document', async (_name, address, file) => { + stubFetch() + wrapper = await openPage(address) + const firstHeading = publicFile(file) + .split('\n') + .find((line) => /^#\s+\S/.test(line)) + .replace(/^#\s+/, '') + .trim() + expect(wrapper.find('main').text()).toContain(firstHeading) + }) +}) + +describe('partners', () => { + test('shows each partner in the partners file', async () => { + stubFetch() + wrapper = await openPage('/en/partners') + const partners = JSON.parse(publicFile('partners.json')) + const list = Array.isArray(partners) ? partners : Object.values(partners).flat() + expect(list.length).toBeGreaterThan(0) + const names = list.map((p) => p.name).filter(Boolean) + expect(names.length).toBeGreaterThan(0) + for (const name of names) expect(wrapper.find('main').text(), name).toContain(name) + }) +}) + +describe('getting around', () => { + // The Communities, Stats and forum entries sit in dropdown menus that only + // draw their links once opened, so they are not on a freshly loaded page. + test('the home page links to the main pages in the current language', async () => { + stubFetch() + wrapper = await openPage('/en') + const hrefs = wrapper.findAll('a').map((a) => a.attributes('href')) + for (const page of ['/en/', '/en/communities', '/en/partners', '/en/servers', '/en/about']) { + expect(hrefs, page).toContain(page) + } + }) + + test('the navigation bar links out to the docs, the store, GitHub and Patreon', async () => { + stubFetch() + wrapper = await openPage('/en') + const hrefs = wrapper.findAll('nav a, header a').map((a) => a.attributes('href')) + for (const url of [ + 'https://docs.beammp.com', + 'https://store.beammp.com', + 'https://github.com/BeamMP/BeamMP', + 'https://www.patreon.com/BeamMP', + ]) { + expect(hrefs, url).toContain(url) + } + }) + + test('the language is part of every internal link', async () => { + stubFetch() + wrapper = await openPage('/en') + const internal = wrapper + .findAll('a') + .map((a) => a.attributes('href')) + .filter((h) => h.startsWith('/') && !h.startsWith('/installer')) + expect(internal.length).toBeGreaterThan(4) + for (const href of internal) expect(href).toMatch(/^\/en(\/|$)/) + }) +}) + +describe('the not-found page', () => { + test('opens at a missing address, with its message and a way back', async () => { + stubFetch() + wrapper = await openPage('/en/this-page-does-not-exist') + expect(wrapper.find('main').text()).toContain('404') + expect(wrapper.find('main a').exists()).toBe(true) + expect(warnings.filter((w) => w.includes('[Vue warn]'))).toEqual([]) + }) +}) diff --git a/tests/router.test.js b/tests/router.test.js new file mode 100644 index 0000000..2386e4b --- /dev/null +++ b/tests/router.test.js @@ -0,0 +1,127 @@ +/** + * Every address on the site starts with the language: /de/servers. The router + * adds one when it is missing, remembers the one in use, switches the page's + * language and sets the title. The real router and its guard run here. + */ + +import { installI18n, withRedirectLimit } from './helpers' +import router from '@/routes' + +// Pushing the address the router is already on does nothing (and so skips the +// guard), so every test starts from the same neutral page. +beforeEach(async () => { + installI18n('en') + await router.push('/en/privacy') + localStorage.clear() + installI18n('en') + document.title = '' +}) + +describe('addresses without a language', () => { + test('the home address goes to the language you used last, or English', async () => { + await router.push('/') + expect(router.currentRoute.value.fullPath).toBe('/en') + expect(router.currentRoute.value.name).toBe('Home') + + localStorage.setItem('lang', 'fr') + await router.push('/') + expect(router.currentRoute.value.fullPath).toBe('/fr') + }) + + test('a page address gets the language put in front of it', async () => { + await router.push('/servers') + expect(router.currentRoute.value.fullPath).toBe('/en/servers') + expect(router.currentRoute.value.name).toBe('Servers') + + localStorage.setItem('lang', 'de') + await router.push('/about') + expect(router.currentRoute.value.fullPath).toBe('/de/about') + }) +}) + +describe('opening a page in a language', () => { + test('sets the page language, remembers it, and sets the title', async () => { + await router.push('/de/about') + expect(router.currentRoute.value.name).toBe('About') + expect(document.documentElement.getAttribute('lang')).toBe('de') + expect(localStorage.getItem('lang')).toBe('de') + expect(document.title).toBe('About - BeamMP') + }) + + test('loads a language the first time it is used', async () => { + expect(window.i18n.global.availableLocales).toEqual(['en']) + await router.push('/ru/about') + expect(window.i18n.global.availableLocales).toContain('ru') + }) + + test('every language in the picker opens', async () => { + for (const code of ['en', 'es', 'fr', 'de', 'it', 'ru', 'zh']) { + await router.push(`/${code}/partners`) + expect(router.currentRoute.value.fullPath).toBe(`/${code}/partners`) + expect(document.documentElement.getAttribute('lang')).toBe(code) + } + }) +}) + +describe('the pages', () => { + const pages = ['', 'about', 'communities', 'servers', 'stats', 'partners', 'privacy', 'terms'] + + test.each(pages)('/en/%s opens its page', async (page) => { + await router.push(`/en/${page}`) + expect(router.currentRoute.value.matched.length).toBeGreaterThan(0) + expect(router.currentRoute.value.name).not.toBe('NotFound') + }) + + test('every page has its own title and description for search engines and tabs', () => { + const named = router.getRoutes().filter((r) => r.name && r.name !== 'NotFound') + expect(named.length).toBeGreaterThanOrEqual(pages.length) + for (const route of named) { + expect(route.meta.title, `${String(route.name)} title`).toMatch(/ - BeamMP$/) + expect(route.meta.description, `${String(route.name)} description`).toBeTruthy() + } + const titles = named.map((r) => r.meta.title) + expect(new Set(titles).size).toBe(titles.length) + }) +}) + +// This used to never finish: the not-found route matches anything, so it has +// no `locale` param even for /en/nope. The guard took that to mean "no +// language", redirected to /en/en/nope, which also has none, and so on - the +// page froze instead of showing the not-found page. withRedirectLimit stops a +// runaway redirect so a regression fails the test instead of hanging it. +describe('an address the site does not have', () => { + const openWithRedirectLimit = (address) => withRedirectLimit(() => router.push(address)) + + test.each([ + ['/en/does-not-exist', '/en/does-not-exist'], + ['/de/does-not-exist', '/de/does-not-exist'], + ['/en/servers/nope/deeper', '/en/servers/nope/deeper'], + ])('%s shows the not-found page where it is', async (address, expected) => { + await openWithRedirectLimit(address) + expect(router.currentRoute.value.name).toBe('NotFound') + expect(router.currentRoute.value.fullPath).toBe(expected) + }) + + test('one with no language gets the language you last used, then the not-found page', async () => { + await openWithRedirectLimit('/does-not-exist') + expect(router.currentRoute.value.fullPath).toBe('/en/does-not-exist') + expect(router.currentRoute.value.name).toBe('NotFound') + + localStorage.setItem('lang', 'fr') + await openWithRedirectLimit('/also-missing') + expect(router.currentRoute.value.fullPath).toBe('/fr/also-missing') + expect(router.currentRoute.value.name).toBe('NotFound') + }) + + test('a language the site does not have is treated as part of the missing address', async () => { + await openWithRedirectLimit('/xx/about') + expect(router.currentRoute.value.fullPath).toBe('/en/xx/about') + expect(router.currentRoute.value.name).toBe('NotFound') + }) + + test('the not-found page is shown in the language of the address', async () => { + await openWithRedirectLimit('/de/does-not-exist') + expect(document.documentElement.getAttribute('lang')).toBe('de') + expect(localStorage.getItem('lang')).toBe('de') + }) +}) diff --git a/tests/setup.js b/tests/setup.js new file mode 100644 index 0000000..bc5d3f4 --- /dev/null +++ b/tests/setup.js @@ -0,0 +1,88 @@ +// Runs before every test file (see vitest.config.js). + +// jsdom has no matchMedia, which the theme toggle reads. +if (!window.matchMedia) { + window.matchMedia = (query) => ({ + matches: false, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + }) +} + +// jsdom has no IntersectionObserver / ResizeObserver, which some views use. +for (const name of ['IntersectionObserver', 'ResizeObserver']) { + if (!window[name]) { + window[name] = class { + observe() {} + unobserve() {} + disconnect() {} + takeRecords() { + return [] + } + } + } +} + +// jsdom cannot draw: canvas.getContext() is null, and the statistics charts call +// ctx.scale() straight away. A stand-in whose every method does nothing lets +// the page run; what it would have drawn is not what is tested. +HTMLCanvasElement.prototype.getContext = function getContext() { + const noop = () => {} + return new Proxy( + { canvas: this }, + { + get: (target, prop) => + prop in target ? target[prop] : prop === 'measureText' ? () => ({ width: 0 }) : noop, + set: (target, prop, value) => { + target[prop] = value + return true + }, + } + ) +} + +// Node 22+ has its own `localStorage` global that wins over jsdom's and does +// nothing useful unless node is started with --localstorage-file. A plain +// in-memory one means the tests do not depend on the node version or a flag. +class MemoryStorage { + #map = new Map() + getItem(key) { + return this.#map.has(key) ? this.#map.get(key) : null + } + setItem(key, value) { + this.#map.set(key, String(value)) + } + removeItem(key) { + this.#map.delete(key) + } + clear() { + this.#map.clear() + } + key(index) { + return Array.from(this.#map.keys())[index] ?? null + } + get length() { + return this.#map.size + } +} + +const memoryStorage = new MemoryStorage() +Object.defineProperty(globalThis, 'localStorage', { + value: memoryStorage, + configurable: true, + writable: true, +}) +Object.defineProperty(window, 'localStorage', { + value: memoryStorage, + configurable: true, + writable: true, +}) + +beforeEach(() => { + localStorage.clear() +}) diff --git a/vitest.config.js b/vitest.config.js new file mode 100644 index 0000000..c02cbdd --- /dev/null +++ b/vitest.config.js @@ -0,0 +1,25 @@ +import { defineConfig } from 'vite' +import { fileURLToPath, URL } from 'node:url' +import vue from '@vitejs/plugin-vue' + +// Tailwind's plugin is left out on purpose: the tests check what a page says +// and links to, not how it looks. +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + }, + }, + test: { + environment: 'jsdom', + environmentOptions: { + jsdom: { url: 'http://localhost:5173' }, + }, + globals: true, + setupFiles: ['./tests/setup.js'], + // The first test to mount a big view pays for compiling it too. + testTimeout: 20000, + hookTimeout: 20000, + }, +})