diff --git a/package-lock.json b/package-lock.json index af6f8cf..299fad4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,12 +12,262 @@ }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^7.2.0", + "@testing-library/jest-dom": "^7.0.0", + "@testing-library/svelte": "^5.4.2", "@tsconfig/svelte": "^5.0.4", "@types/node": "^26.1.2", + "jsdom": "^29.1.1", "svelte": "^5.56.8", "svelte-check": "^4.3.3", "typescript": "^5.9.3", - "vite": "^8.1.5" + "vite": "^8.1.5", + "vitest": "^4.1.10" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, + "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/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", + "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.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", + "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.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.10.tgz", + "integrity": "sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==", + "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.0", + "@csstools/css-calc": "^3.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "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.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.7.tgz", + "integrity": "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==", + "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.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "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": { @@ -57,6 +307,24 @@ "tslib": "^2.4.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "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/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -455,6 +723,13 @@ "dev": true, "license": "MIT" }, + "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/@sveltejs/acorn-typescript": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.11.tgz", @@ -495,6 +770,106 @@ "vite": "^8.0.0-beta.7 || ^8.0.0" } }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/dom/node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.0.tgz", + "integrity": "sha512-HKAH9C6mBo5yBG6yRO5i43L2iisencAo5z+o5P/saHUoY+miC5ivXRxHBJcFyB5ypPNxHJdK3BoF/3O4DIptMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=22", + "npm": ">=6", + "yarn": ">=1" + }, + "peerDependencies": { + "@testing-library/dom": ">=10 <11" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/svelte": { + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/@testing-library/svelte/-/svelte-5.4.2.tgz", + "integrity": "sha512-4o31E4HGo5BU5KwPkulNRocEden+7Tt9JYm9uhln5ajF7DULeyFA46BBWVfKJ8Ms9B3JmOFPTIiVamH7n3KpuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@testing-library/dom": "9.x.x || 10.x.x", + "@testing-library/svelte-core": "1.1.3" + }, + "engines": { + "node": ">= 10" + }, + "peerDependencies": { + "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0", + "vite": "*", + "vitest": "*" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + }, + "vitest": { + "optional": true + } + } + }, + "node_modules/@testing-library/svelte-core": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@testing-library/svelte-core/-/svelte-core-1.1.3.tgz", + "integrity": "sha512-KkMAvXeWorxN2Yn0kdC1lfoAItxpoj4uOWzxK5leDrNxonLvS5nwBFvztrroyTszQ0Wf/EU6iLT8JhY5qcn22g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0" + } + }, "node_modules/@tsconfig/svelte": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/@tsconfig/svelte/-/svelte-5.0.8.tgz", @@ -513,6 +888,31 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, + "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.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -537,6 +937,119 @@ "devOptional": true, "license": "MIT" }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "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/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", @@ -550,6 +1063,29 @@ "node": ">=0.4.0" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/aria-query": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", @@ -560,6 +1096,16 @@ "node": ">= 0.4" } }, + "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/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -570,6 +1116,26 @@ "node": ">= 0.4" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", @@ -596,6 +1162,55 @@ "node": ">=6" } }, + "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/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", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "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/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/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", @@ -606,6 +1221,16 @@ "node": ">=0.10.0" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -623,6 +1248,13 @@ "dev": true, "license": "MIT" }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, "node_modules/dompurify": { "version": "3.4.12", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz", @@ -632,6 +1264,26 @@ "@types/trusted-types": "^2.0.7" } }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, "node_modules/esm-env": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", @@ -657,6 +1309,26 @@ } } }, + "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/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/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -690,6 +1362,36 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "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/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "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-reference": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", @@ -700,6 +1402,54 @@ "@types/estree": "^1.0.6" } }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "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/lightningcss": { "version": "1.33.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", @@ -980,6 +1730,26 @@ "dev": true, "license": "MIT" }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -990,6 +1760,23 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "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/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", @@ -1033,6 +1820,26 @@ "node": ">=12.20.0" } }, + "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/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", @@ -1082,6 +1889,38 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -1096,6 +1935,30 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "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/rolldown": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", @@ -1143,6 +2006,26 @@ "node": ">=6" } }, + "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/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", @@ -1153,6 +2036,33 @@ "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.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/svelte": { "version": "5.56.8", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.8.tgz", @@ -1206,6 +2116,30 @@ "typescript": "^5.0.0 || ^6.0.0" } }, + "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/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.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "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", @@ -1223,6 +2157,62 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.9", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.9.tgz", + "integrity": "sha512-3kZ8wQQ/k5DrChD4X4FVvr2D7E5uoRgAqkPyLpSCGUvqOvqu+JEdr3mwMUaVWb+vMHZaKhF5fp2PBigKsui7hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.9" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.9", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.9.tgz", + "integrity": "sha512-DxKfPBI52p2msTEu7MPhdpdDTBhhVQg1a/8PjQckeyAvO13eMYElX545grIp6nnTGIMZlRvFZPvFhvI/WIz2Vg==", + "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", @@ -1245,6 +2235,16 @@ "node": ">=14.17" } }, + "node_modules/undici": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", @@ -1350,6 +2350,178 @@ } } }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "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.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "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/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/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/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/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/zimmerframe": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", diff --git a/package.json b/package.json index 1d4ec31..138c545 100644 --- a/package.json +++ b/package.json @@ -9,16 +9,22 @@ "build": "svelte-check --tsconfig ./tsconfig.json && vite build", "build:only": "vite build", "preview": "vite preview", - "check": "svelte-check --tsconfig ./tsconfig.json" + "check": "svelte-check --tsconfig ./tsconfig.json", + "test": "vitest run", + "test:watch": "vitest" }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^7.2.0", + "@testing-library/jest-dom": "^7.0.0", + "@testing-library/svelte": "^5.4.2", "@tsconfig/svelte": "^5.0.4", "@types/node": "^26.1.2", + "jsdom": "^29.1.1", "svelte": "^5.56.8", "svelte-check": "^4.3.3", "typescript": "^5.9.3", - "vite": "^8.1.5" + "vite": "^8.1.5", + "vitest": "^4.1.10" }, "dependencies": { "dompurify": "^3.4.12" diff --git a/src/App.svelte b/src/App.svelte index 6279e2d..29e6e9e 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -2,8 +2,7 @@ /** * Shell: restore the session, then render chrome plus whichever route matched. */ - import { router } from '$lib/router.svelte' - import { session } from '$lib/stores/session.svelte' + import { useAppServices } from '$lib/app-services' import SiteHeader from '$components/chrome/SiteHeader.svelte' import SiteNav from '$components/chrome/SiteNav.svelte' import SiteFooter from '$components/chrome/SiteFooter.svelte' @@ -20,11 +19,15 @@ import NotFound from '$routes/NotFound.svelte' import type { TimelineKind } from '$lib/api/endpoints' + const { router, session } = useAppServices() + let booted = $state(false) $effect(() => { + let active = true void (async () => { const landing = await session.restore() + if (!active) return booted = true if (landing) { @@ -34,6 +37,9 @@ router.replace('#/login') } })() + return () => { + active = false + } }) const route = $derived(router.current) diff --git a/src/components/blog/BlogEntry.svelte b/src/components/blog/BlogEntry.svelte index 6a66ba6..499253d 100644 --- a/src/components/blog/BlogEntry.svelte +++ b/src/components/blog/BlogEntry.svelte @@ -11,8 +11,7 @@ * a federated round trip is slow enough that waiting feels broken. */ import type { Status } from '$lib/api/types' - import { session } from '$lib/stores/session.svelte' - import { favouriteStatus, reblogStatus, deleteStatus } from '$lib/api/endpoints' + import { useAppServices } from '$lib/app-services' import { displayNameOf, formatCount, fullHandle, profilePath } from '$lib/util/profile' import { renderDisplayName } from '$lib/util/html' import { isoDate, longDate, stampDate } from '$lib/util/time' @@ -36,6 +35,7 @@ let { status, onupdate, ondelete, compact = false, longFormDate = false }: Props = $props() + const { endpoints, session } = useAppServices() /** The status actually being displayed; a boost renders its target. */ const entry = $derived(status.reblog ?? status) const booster = $derived(status.reblog ? status.account : null) @@ -73,7 +73,7 @@ onupdate?.(applyLocal(status, { favourited: next, favourites_count: entry.favourites_count + (next ? 1 : -1) })) try { - const updated = await favouriteStatus(session.api, entry.id, next) + const updated = await endpoints.favouriteStatus(session.api, entry.id, next) onupdate?.(rewrap(status, updated)) } catch (cause) { onupdate?.(status) @@ -92,7 +92,7 @@ onupdate?.(applyLocal(status, { reblogged: next, reblogs_count: entry.reblogs_count + (next ? 1 : -1) })) try { - const updated = await reblogStatus(session.api, entry.id, next) + const updated = await endpoints.reblogStatus(session.api, entry.id, next) // Reblogging returns the *wrapper* status; unwrap to the original. onupdate?.(rewrap(status, updated.reblog ?? updated)) } catch (cause) { @@ -108,7 +108,7 @@ if (!confirm('Delete this entry? This cannot be undone.')) return busy = true try { - await deleteStatus(session.api, entry.id) + await endpoints.deleteStatus(session.api, entry.id) ondelete?.(status.id) } catch (cause) { actionError = cause instanceof Error ? cause.message : 'Could not delete that.' diff --git a/src/components/blog/Composer.svelte b/src/components/blog/Composer.svelte index 1910011..5d8d97b 100644 --- a/src/components/blog/Composer.svelte +++ b/src/components/blog/Composer.svelte @@ -8,14 +8,14 @@ */ import { untrack } from 'svelte' import type { MediaAttachment, Status, StatusVisibility } from '$lib/api/types' - import { session } from '$lib/stores/session.svelte' - import { postStatus, uploadMedia } from '$lib/api/endpoints' + import { useAppServices } from '$lib/app-services' interface Props { /** Set to reply to an existing entry. */ inReplyTo?: Status | null /** Prefilled body, e.g. the mentions of the entry being replied to. */ initialText?: string + initialVisibility?: StatusVisibility placeholder?: string submitLabel?: string onposted?: (status: Status) => void @@ -24,16 +24,20 @@ let { inReplyTo = null, initialText = '', + initialVisibility = 'public', placeholder = 'What are you up to?', submitLabel = 'Post Entry', onposted, }: Props = $props() + const { endpoints, session } = useAppServices() // Seeded once from the prop; afterwards the textarea owns the value. let text = $state(untrack(() => initialText)) let warning = $state('') let showWarning = $state(false) - let visibility = $state('public') + let visibility = $state( + untrack(() => inReplyTo?.visibility ?? initialVisibility), + ) let attachments = $state([]) let busy = $state(false) let uploading = $state(false) @@ -46,12 +50,6 @@ !busy && !uploading && remaining >= 0 && (text.trim().length > 0 || attachments.length > 0), ) - // Default replies to the visibility of what they answer, so a private thread - // doesn't accidentally get a public reply. - $effect(() => { - if (inReplyTo) visibility = inReplyTo.visibility - }) - async function onFiles(event: Event): Promise { const input = event.currentTarget as HTMLInputElement const files = Array.from(input.files ?? []) @@ -61,7 +59,7 @@ error = null try { for (const file of files.slice(0, maxAttachments - attachments.length)) { - const media = await uploadMedia(session.api, file) + const media = await endpoints.uploadMedia(session.api, file) attachments = [...attachments, media] } } catch (cause) { @@ -83,7 +81,7 @@ busy = true error = null try { - const created = await postStatus(session.api, { + const created = await endpoints.postStatus(session.api, { status: text, in_reply_to_id: inReplyTo?.id ?? null, visibility, diff --git a/src/components/blog/Composer.test.ts b/src/components/blog/Composer.test.ts new file mode 100644 index 0000000..2c63f87 --- /dev/null +++ b/src/components/blog/Composer.test.ts @@ -0,0 +1,27 @@ +import { fireEvent, render, waitFor } from '@testing-library/svelte' +import { describe, expect, it, vi } from 'vitest' +import { APP_SERVICES } from '$lib/app-services' +import { account, session, status, testServices } from '$test/fixtures' +import Composer from './Composer.svelte' + +describe('Composer', () => { + it('posts through an in-memory endpoint double', async () => { + const postStatus = vi.fn().mockResolvedValue(status()) + const services = testServices({ + session: session({ token: 'token', me: account(), signedIn: true }), + endpoints: { postStatus }, + }) + const view = render(Composer, { + props: { initialText: 'A backend-free component test' }, + context: new Map([[APP_SERVICES, services]]), + }) + + await fireEvent.click(view.getByRole('button', { name: 'Post Entry' })) + + await waitFor(() => expect(postStatus).toHaveBeenCalledOnce()) + expect(postStatus.mock.calls[0][1]).toMatchObject({ + status: 'A backend-free component test', + visibility: 'public', + }) + }) +}) diff --git a/src/components/chrome/SiteFooter.svelte b/src/components/chrome/SiteFooter.svelte index 79e4ad1..141040d 100644 --- a/src/components/chrome/SiteFooter.svelte +++ b/src/components/chrome/SiteFooter.svelte @@ -1,8 +1,10 @@ diff --git a/src/components/chrome/SiteHeader.svelte b/src/components/chrome/SiteHeader.svelte index 7e8c6bb..5614221 100644 --- a/src/components/chrome/SiteHeader.svelte +++ b/src/components/chrome/SiteHeader.svelte @@ -2,13 +2,15 @@ /** * The navy utility bar and the boxed logo strip beneath it. */ - import { session } from '$lib/stores/session.svelte' - import { router, routeTo } from '$lib/router.svelte' + import { routeTo } from '$lib/router.svelte' + import { useAppServices } from '$lib/app-services' import { instanceDomain } from '$lib/api/endpoints' // Imported rather than referenced by path so Vite fingerprints it and the // relative `base` still resolves when hosted from a subdirectory. import logoUrl from '../../assets/plspace-logo.webp' + const { router, session } = useAppServices() + let query = $state('') const domain = $derived(session.host ? instanceDomain(session.instance, session.host) : '') diff --git a/src/components/chrome/SiteNav.svelte b/src/components/chrome/SiteNav.svelte index 49c6d75..9ae8042 100644 --- a/src/components/chrome/SiteNav.svelte +++ b/src/components/chrome/SiteNav.svelte @@ -5,10 +5,11 @@ * Entries that need a token disappear when browsing logged out rather than * erroring on click. */ - import { session } from '$lib/stores/session.svelte' - import { router } from '$lib/router.svelte' + import { useAppServices } from '$lib/app-services' import { profilePath } from '$lib/util/profile' + const { router, session } = useAppServices() + interface NavItem { label: string href: string diff --git a/src/components/common/Avatar.svelte b/src/components/common/Avatar.svelte index 544820f..7fb2da6 100644 --- a/src/components/common/Avatar.svelte +++ b/src/components/common/Avatar.svelte @@ -34,6 +34,14 @@ for (let index = 0; index < seed.length; index += 1) hash = (hash * 31 + seed.charCodeAt(index)) >>> 0 return hash % 360 }) + + // A keyed list usually preserves account identity, but standalone usages can + // receive a different account. A failure from the previous URL must not hide + // the next account's valid image. + $effect(() => { + src + failed = false + }) {#snippet image()} diff --git a/src/components/common/Pager.svelte b/src/components/common/Pager.svelte index cedc633..cdc7ee2 100644 --- a/src/components/common/Pager.svelte +++ b/src/components/common/Pager.svelte @@ -28,6 +28,9 @@ {/if} diff --git a/src/components/people/PersonRow.svelte b/src/components/people/PersonRow.svelte index 61f5e96..48bb7ba 100644 --- a/src/components/people/PersonRow.svelte +++ b/src/components/people/PersonRow.svelte @@ -4,7 +4,8 @@ import { displayNameOf, formatCount, fullHandle, profilePath } from '$lib/util/profile' import { renderDisplayName } from '$lib/util/html' import { relativeTime } from '$lib/util/time' - import { session } from '$lib/stores/session.svelte' + import { useAppServices } from '$lib/app-services' + import Avatar from '../common/Avatar.svelte' import RichText from '../common/RichText.svelte' interface Props { @@ -15,18 +16,13 @@ let { account, actions }: Props = $props() + const { session } = useAppServices() const name = $derived(renderDisplayName(displayNameOf(account), account.emojis))
  • - +
    diff --git a/src/components/profile/ContactBox.svelte b/src/components/profile/ContactBox.svelte index 271857b..36f3bda 100644 --- a/src/components/profile/ContactBox.svelte +++ b/src/components/profile/ContactBox.svelte @@ -7,8 +7,7 @@ * rather than disappearing, so the box keeps its shape. */ import type { Account, Relationship } from '$lib/api/types' - import { session } from '$lib/stores/session.svelte' - import { blockAccount, followAccount, unblockAccount, unfollowAccount } from '$lib/api/endpoints' + import { useAppServices } from '$lib/app-services' import { displayNameOf } from '$lib/util/profile' import Module from '../common/Module.svelte' @@ -20,6 +19,7 @@ let { account, relationship, onrelationship }: Props = $props() + const { endpoints, session } = useAppServices() let busy = $state(false) let error = $state(null) @@ -56,17 +56,19 @@ function toggleFollow(): void { if (blocking) { - void run(() => unblockAccount(session.api, account.id)) + void run(() => endpoints.unblockAccount(session.api, account.id)) } else if (following || requested) { - void run(() => unfollowAccount(session.api, account.id)) + void run(() => endpoints.unfollowAccount(session.api, account.id)) } else { - void run(() => followAccount(session.api, account.id)) + void run(() => endpoints.followAccount(session.api, account.id)) } } function toggleBlock(): void { void run(() => - blocking ? unblockAccount(session.api, account.id) : blockAccount(session.api, account.id), + blocking + ? endpoints.unblockAccount(session.api, account.id) + : endpoints.blockAccount(session.api, account.id), ) } diff --git a/src/components/profile/DetailsTable.svelte b/src/components/profile/DetailsTable.svelte index e283ef8..34d6d4e 100644 --- a/src/components/profile/DetailsTable.svelte +++ b/src/components/profile/DetailsTable.svelte @@ -21,7 +21,7 @@ - {#each fields as field (field.name)} + {#each fields as field, index (`${field.name}:${index}`)}
    {field.name} diff --git a/src/components/profile/FriendSpace.svelte b/src/components/profile/FriendSpace.svelte index 0273d36..226f33a 100644 --- a/src/components/profile/FriendSpace.svelte +++ b/src/components/profile/FriendSpace.svelte @@ -9,6 +9,7 @@ import type { Account } from '$lib/api/types' import { displayNameOf, formatCount, profilePath } from '$lib/util/profile' import Module from '../common/Module.svelte' + import Avatar from '../common/Avatar.svelte' interface Props { title: string @@ -66,13 +67,7 @@
  • {displayNameOf(friend)} - +
  • {/each} diff --git a/src/components/profile/InterestsTable.svelte b/src/components/profile/InterestsTable.svelte index 352eb37..c1bb45d 100644 --- a/src/components/profile/InterestsTable.svelte +++ b/src/components/profile/InterestsTable.svelte @@ -20,7 +20,7 @@ - {#each interests as entry (entry.row)} + {#each interests as entry, index (`${entry.row}:${index}`)}
    {entry.row} diff --git a/src/components/profile/ProfileIdentity.svelte b/src/components/profile/ProfileIdentity.svelte index 538bbd8..cdfcab7 100644 --- a/src/components/profile/ProfileIdentity.svelte +++ b/src/components/profile/ProfileIdentity.svelte @@ -12,7 +12,7 @@ import { displayNameOf, fullHandle } from '$lib/util/profile' import { renderDisplayName } from '$lib/util/html' import { relativeTime, shortDate, yearsSince } from '$lib/util/time' - import { session } from '$lib/stores/session.svelte' + import { useAppServices } from '$lib/app-services' interface Props { profile: ProfileView @@ -20,6 +20,7 @@ let { profile }: Props = $props() + const { session } = useAppServices() const account = $derived(profile.account) const name = $derived(renderDisplayName(displayNameOf(account), account.emojis)) const handle = $derived(fullHandle(account, session.host)) diff --git a/src/components/profile/ProfileTables.test.ts b/src/components/profile/ProfileTables.test.ts new file mode 100644 index 0000000..3f44144 --- /dev/null +++ b/src/components/profile/ProfileTables.test.ts @@ -0,0 +1,32 @@ +import { render } from '@testing-library/svelte' +import { describe, expect, it } from 'vitest' +import DetailsTable from './DetailsTable.svelte' +import InterestsTable from './InterestsTable.svelte' + +describe('profile tables', () => { + it('renders duplicate interest aliases without duplicate keyed-each failures', () => { + const view = render(InterestsTable, { + title: 'Interests', + interests: [ + { row: 'Music', value: 'Synthpop' }, + { row: 'Music', value: 'Shoegaze' }, + ], + }) + + expect(view.getByText('Synthpop')).toBeInTheDocument() + expect(view.getByText('Shoegaze')).toBeInTheDocument() + }) + + it('renders repeated free-form field names', () => { + const view = render(DetailsTable, { + title: 'Details', + fields: [ + { name: 'Website', value: 'One', verified: false }, + { name: 'Website', value: 'Two', verified: true }, + ], + }) + + expect(view.getByText('One')).toBeInTheDocument() + expect(view.getByText('Two')).toBeInTheDocument() + }) +}) diff --git a/src/lib/app-services.ts b/src/lib/app-services.ts new file mode 100644 index 0000000..12b7163 --- /dev/null +++ b/src/lib/app-services.ts @@ -0,0 +1,84 @@ +/** + * Runtime dependencies used by Svelte components. + * + * Components read these through context instead of importing backend functions + * and global stores directly. Production gets the real implementations by + * default; tests can replace only the pieces they exercise. + */ +import { getContext } from 'svelte' +import type { ApiClient } from './api/client' +import type { CredentialAccount, InstanceInfo } from './api/types' +import * as endpointImplementations from './api/endpoints' +import { router as defaultRouter, type RouteMatch } from './router.svelte' +import { session as defaultSession } from './stores/session.svelte' +import { theme as defaultTheme } from './stores/theme.svelte' + +export interface SessionService { + host: string + token: string | null + me: CredentialAccount | null + instance: InstanceInfo | null + loading: boolean + error: string | null + readonly api: ApiClient + readonly signedIn: boolean + readonly connected: boolean + restore(): Promise + connect(host: string): Promise + login(host: string, returnTo?: string): Promise + logout(): Promise + disconnect(): void +} + +export interface RouterService { + current: RouteMatch + go(to: string): void + replace(to: string): void +} + +export interface ThemeService { + viewerCss: string + allowProfileCss: boolean + setViewerCss(css: string): void + setAllowProfileCss(allow: boolean): void + applyProfileCss(css: string | null | undefined): void + clearProfileCss(): void +} + +export interface AppServices { + session: SessionService + router: RouterService + theme: ThemeService + endpoints: typeof endpointImplementations +} + +export const APP_SERVICES = Symbol('plspace.app-services') + +export const defaultAppServices: AppServices = { + session: defaultSession, + router: defaultRouter, + theme: defaultTheme, + endpoints: endpointImplementations, +} + +/** Read dependencies supplied by a parent/test, falling back to production. */ +export function useAppServices(): AppServices { + return getContext(APP_SERVICES) ?? defaultAppServices +} + +export interface AppServiceOverrides { + session?: SessionService + router?: RouterService + theme?: ThemeService + endpoints?: Partial +} + +/** Build a complete service object from small test doubles. */ +export function createAppServices(overrides: AppServiceOverrides = {}): AppServices { + return { + session: overrides.session ?? defaultAppServices.session, + router: overrides.router ?? defaultAppServices.router, + theme: overrides.theme ?? defaultAppServices.theme, + endpoints: { ...defaultAppServices.endpoints, ...overrides.endpoints }, + } +} diff --git a/src/lib/router.svelte.ts b/src/lib/router.svelte.ts index 24aa4bd..cdfc608 100644 --- a/src/lib/router.svelte.ts +++ b/src/lib/router.svelte.ts @@ -56,7 +56,12 @@ function matchPattern(pattern: string, path: string): Record | n if (prefixed) { const [, prefix, name] = prefixed if (prefix && !actual.startsWith(prefix)) return null - const value = decodeURIComponent(actual.slice(prefix.length)) + let value: string + try { + value = decodeURIComponent(actual.slice(prefix.length)) + } catch { + return null + } if (!value) return null params[name.slice(1)] = value continue diff --git a/src/lib/router.test.ts b/src/lib/router.test.ts new file mode 100644 index 0000000..f597d82 --- /dev/null +++ b/src/lib/router.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest' +import { parseHash } from './router.svelte' + +describe('parseHash', () => { + it('decodes valid route parameters', () => { + expect(parseHash('#/@alice%40remote.test').params.acct).toBe('alice@remote.test') + }) + + it('treats malformed percent escapes as not found', () => { + expect(() => parseHash('#/@broken%ZZ')).not.toThrow() + expect(parseHash('#/@broken%ZZ').name).toBe('notfound') + }) +}) diff --git a/src/routes/Browse.svelte b/src/routes/Browse.svelte index 8f218c0..cb25ec7 100644 --- a/src/routes/Browse.svelte +++ b/src/routes/Browse.svelte @@ -8,13 +8,13 @@ */ import { untrack } from 'svelte' import type { Account } from '$lib/api/types' - import { session } from '$lib/stores/session.svelte' + import { useAppServices } from '$lib/app-services' import { Feed } from '$lib/stores/feed.svelte' - import { fetchDirectory, instanceDomain } from '$lib/api/endpoints' + import { instanceDomain } from '$lib/api/endpoints' import Module from '$components/common/Module.svelte' import PersonList from '$components/people/PersonList.svelte' - import { router } from '$lib/router.svelte' + const { endpoints, router, session } = useAppServices() let order = $state<'active' | 'new'>('active') let localOnly = $state(true) @@ -35,7 +35,7 @@ feed = new Feed(async (cursor) => { if (!cursor.max_id) offset = 0 const limit = cursor.limit ?? 20 - const items = await fetchDirectory(session.api, { + const items = await endpoints.fetchDirectory(session.api, { offset, limit, order: currentOrder, diff --git a/src/routes/Compose.svelte b/src/routes/Compose.svelte index e1b7890..c8006de 100644 --- a/src/routes/Compose.svelte +++ b/src/routes/Compose.svelte @@ -1,7 +1,6 @@ @@ -26,12 +26,15 @@
    - router.go(`#/blog/${status.id}`)} - /> + {#key to} + router.go(`#/blog/${status.id}`)} + /> + {/key} {#if !session.signedIn} diff --git a/src/routes/Compose.test.ts b/src/routes/Compose.test.ts new file mode 100644 index 0000000..3dc7fd1 --- /dev/null +++ b/src/routes/Compose.test.ts @@ -0,0 +1,25 @@ +import { render } from '@testing-library/svelte' +import { describe, expect, it } from 'vitest' +import { APP_SERVICES } from '$lib/app-services' +import { account, session, testServices } from '$test/fixtures' +import Compose from './Compose.svelte' + +describe('Compose route', () => { + it('resets addressing and defaults to direct when the recipient changes', async () => { + const services = testServices({ + session: session({ token: 'token', me: account(), signedIn: true }), + }) + const view = render(Compose, { + props: { to: 'alice@example.test' }, + context: new Map([[APP_SERVICES, services]]), + }) + + expect(view.getByRole('textbox', { name: 'Entry text' })).toHaveValue('@alice@example.test ') + expect(view.getByRole('combobox', { name: 'Who can see this' })).toHaveValue('direct') + + await view.rerender({ to: 'bob@example.test' }) + + expect(view.getByRole('textbox', { name: 'Entry text' })).toHaveValue('@bob@example.test ') + expect(view.getByRole('combobox', { name: 'Who can see this' })).toHaveValue('direct') + }) +}) diff --git a/src/routes/Home.svelte b/src/routes/Home.svelte index 6526608..78a5d1f 100644 --- a/src/routes/Home.svelte +++ b/src/routes/Home.svelte @@ -8,11 +8,8 @@ * what its local timeline looks like right now. */ import type { Account, Notification, Status } from '$lib/api/types' - import { session } from '$lib/stores/session.svelte' + import { useAppServices } from '$lib/app-services' import { - fetchFollowing, - fetchNotifications, - fetchTimeline, instanceDomain, instanceStats, instanceThumbnail, @@ -25,6 +22,8 @@ import RichText from '$components/common/RichText.svelte' import Composer from '$components/blog/Composer.svelte' + const { endpoints, session } = useAppServices() + let friendStatus = $state([]) let bulletins = $state([]) let following = $state([]) @@ -38,6 +37,7 @@ */ let friendStatusError = $state(null) let bulletinError = $state(null) + let loadGeneration = 0 const me = $derived(session.me) const domain = $derived(session.host ? instanceDomain(session.instance, session.host) : '') @@ -65,13 +65,18 @@ const host = session.host const signedIn = session.signedIn if (!host) { + loadGeneration += 1 loading = false return } - void load(signedIn) + const generation = ++loadGeneration + void load(signedIn, host, generation) + return () => { + if (generation === loadGeneration) loadGeneration += 1 + } }) - async function load(signedIn: boolean): Promise { + async function load(signedIn: boolean, host = session.host, generation = ++loadGeneration): Promise { loading = true error = null friendStatusError = null @@ -81,32 +86,49 @@ // Everything here is independent; a failure in one shouldn't blank the page. const [statusPage, bulletinPage] = await Promise.all([ signedIn - ? fetchTimeline(session.api, 'home', { limit: 10 }).catch((cause) => { - friendStatusError = messageOf(cause) + ? endpoints.fetchTimeline(session.api, 'home', { limit: 10 }).catch((cause) => { + if (generation === loadGeneration) friendStatusError = messageOf(cause) return { items: [], links: {} } }) : Promise.resolve({ items: [], links: {} }), - fetchTimeline(session.api, 'local', { limit: 10 }).catch((cause) => { - bulletinError = messageOf(cause) + endpoints.fetchTimeline(session.api, 'local', { limit: 10 }).catch((cause) => { + if (generation === loadGeneration) bulletinError = messageOf(cause) return { items: [], links: {} } }), ]) + if (generation !== loadGeneration || session.host !== host) return friendStatus = statusPage.items bulletins = bulletinPage.items if (signedIn && session.me) { - void fetchFollowing(session.api, session.me.id, { limit: 12 }) - .then((page) => (following = page.items)) - .catch(() => (following = [])) - void fetchNotifications(session.api, { limit: 40 }) - .then((page) => (notifications = page.items)) - .catch(() => (notifications = [])) + const accountId = session.me.id + void endpoints + .fetchFollowing(session.api, accountId, { limit: 12 }) + .then((page) => { + if (generation === loadGeneration) following = page.items + }) + .catch(() => { + if (generation === loadGeneration) following = [] + }) + void endpoints + .fetchNotifications(session.api, { limit: 40 }) + .then((page) => { + if (generation === loadGeneration) notifications = page.items + }) + .catch(() => { + if (generation === loadGeneration) notifications = [] + }) + } else { + following = [] + notifications = [] } } catch (cause) { - error = cause instanceof Error ? cause.message : 'Could not load your home page.' + if (generation === loadGeneration) { + error = cause instanceof Error ? cause.message : 'Could not load your home page.' + } } finally { - loading = false + if (generation === loadGeneration) loading = false } } @@ -214,7 +236,11 @@
    {#if session.signedIn} - void load(true)} /> + void load(true)} + /> {/if} diff --git a/src/routes/Login.svelte b/src/routes/Login.svelte index c8866ae..5d0e74e 100644 --- a/src/routes/Login.svelte +++ b/src/routes/Login.svelte @@ -6,11 +6,12 @@ * server and redirects to its consent screen, while "just look around" only * needs the host and works on any server that allows anonymous API reads. */ - import { session } from '$lib/stores/session.svelte' + import { useAppServices } from '$lib/app-services' import { normalizeHost } from '$lib/api/client' - import { router } from '$lib/router.svelte' import Module from '$components/common/Module.svelte' + const { router, session } = useAppServices() + let host = $state(session.host) let busy = $state(false) let error = $state(null) diff --git a/src/routes/Mail.svelte b/src/routes/Mail.svelte index c687081..6322508 100644 --- a/src/routes/Mail.svelte +++ b/src/routes/Mail.svelte @@ -9,19 +9,14 @@ */ import { untrack } from 'svelte' import type { Account, Notification } from '$lib/api/types' - import { session } from '$lib/stores/session.svelte' + import { useAppServices } from '$lib/app-services' import { Feed } from '$lib/stores/feed.svelte' - import { - authorizeFollowRequest, - fetchFollowRequests, - fetchNotifications, - rejectFollowRequest, - } from '$lib/api/endpoints' import { displayNameOf, fullHandle, profilePath } from '$lib/util/profile' import { toPlainText } from '$lib/util/html' import { stampDate } from '$lib/util/time' import Module from '$components/common/Module.svelte' import Pager from '$components/common/Pager.svelte' + import Avatar from '$components/common/Avatar.svelte' interface Props { folder?: string @@ -29,6 +24,7 @@ let { folder = 'inbox' }: Props = $props() + const { endpoints, session } = useAppServices() interface Folder { key: string label: string @@ -73,11 +69,11 @@ untrack(() => { if (key === 'requests') { - requests = new Feed((cursor) => fetchFollowRequests(session.api, cursor)) + requests = new Feed((cursor) => endpoints.fetchFollowRequests(session.api, cursor)) void requests.reload() } else { notifications = new Feed((cursor) => - fetchNotifications(session.api, cursor, types.length > 0 ? types : undefined), + endpoints.fetchNotifications(session.api, cursor, types.length > 0 ? types : undefined), ) void notifications.reload() } @@ -90,7 +86,10 @@ busyIds = { ...busyIds, [account.id]: true } actionError = null try { - await (approve ? authorizeFollowRequest : rejectFollowRequest)(session.api, account.id) + await (approve ? endpoints.authorizeFollowRequest : endpoints.rejectFollowRequest)( + session.api, + account.id, + ) requests.remove(account.id) } catch (cause) { actionError = cause instanceof Error ? cause.message : 'That didn’t work.' @@ -156,7 +155,7 @@
    - + @@ -210,7 +209,7 @@ {stampDate(item.created_at)} - + diff --git a/src/routes/Profile.svelte b/src/routes/Profile.svelte index 6c920e8..08edc7f 100644 --- a/src/routes/Profile.svelte +++ b/src/routes/Profile.svelte @@ -11,15 +11,9 @@ */ import { untrack } from 'svelte' import type { Account, Relationship, Status } from '$lib/api/types' - import { session } from '$lib/stores/session.svelte' + import { useAppServices } from '$lib/app-services' import { Feed } from '$lib/stores/feed.svelte' - import { theme, profileCssFromFields } from '$lib/stores/theme.svelte' - import { - fetchAccountStatuses, - fetchFollowers, - fetchRelationship, - lookupAccount, - } from '$lib/api/endpoints' + import { profileCssFromFields } from '$lib/stores/theme.svelte' import { buildProfileView, displayNameOf, @@ -46,6 +40,7 @@ let { acct, view = 'profile' }: Props = $props() + const { endpoints, session, theme } = useAppServices() let account = $state(null) let relationship = $state(null) let loading = $state(true) @@ -53,6 +48,7 @@ let friends = $state([]) let friendsLoading = $state(false) + let loadGeneration = 0 // Recreated whenever the account changes, so the feed never shows one // person's entries under another's name. @@ -77,20 +73,32 @@ const host = session.host if (!host) return - untrack(() => void load(handle, currentView)) + const generation = ++loadGeneration + theme.clearProfileCss() + untrack(() => void load(handle, currentView, host, generation)) + return () => { + if (generation === loadGeneration) loadGeneration += 1 + theme.clearProfileCss() + } }) - async function load(handle: string, currentView: Props['view']): Promise { + async function load( + handle: string, + currentView: Props['view'], + host: string, + generation: number, + ): Promise { loading = true error = null account = null relationship = null friends = [] + friendsLoading = false try { - const found = await lookupAccount(session.api, handle) + const found = await endpoints.lookupAccount(session.api, handle) // A newer navigation won the race. - if (acct !== handle) return + if (generation !== loadGeneration || acct !== handle || session.host !== host) return account = found document.title = `${displayNameOf(found)} | plspace` @@ -99,7 +107,7 @@ entries = new Feed( (cursor) => - fetchAccountStatuses(session.api, found.id, cursor, { + endpoints.fetchAccountStatuses(session.api, found.id, cursor, { // The profile page mirrors "Latest Blog Entries": top-level posts. exclude_replies: currentView !== 'blog', }), @@ -107,43 +115,45 @@ ) void entries.reload() - void loadFriends(found, currentView) - void loadRelationship(found) + void loadFriends(found, currentView, generation) + void loadRelationship(found, generation) } catch (cause) { - if (acct !== handle) return + if (generation !== loadGeneration) return error = cause instanceof Error ? cause.message : 'Could not load that profile.' } finally { - if (acct === handle) loading = false + if (generation === loadGeneration) loading = false } } - async function loadFriends(target: Account, currentView: Props['view']): Promise { + async function loadFriends( + target: Account, + currentView: Props['view'], + generation: number, + ): Promise { if (followersHidden(target)) return friendsLoading = true try { - const page = await fetchFollowers(session.api, target.id, { + const page = await endpoints.fetchFollowers(session.api, target.id, { limit: currentView === 'friends' ? 40 : FRIEND_PREVIEW, }) - if (account?.id === target.id) friends = page.items + if (generation === loadGeneration && account?.id === target.id) friends = page.items } catch { // Hidden or unavailable follower lists are normal; the count still shows. - if (account?.id === target.id) friends = [] + if (generation === loadGeneration && account?.id === target.id) friends = [] } finally { - friendsLoading = false + if (generation === loadGeneration) friendsLoading = false } } - async function loadRelationship(target: Account): Promise { + async function loadRelationship(target: Account, generation: number): Promise { try { - const found = await fetchRelationship(session.api, target.id) - if (account?.id === target.id) relationship = found + const found = await endpoints.fetchRelationship(session.api, target.id) + if (generation === loadGeneration && account?.id === target.id) relationship = found } catch { /* relationships need auth; absence is fine */ } } - $effect(() => () => theme.clearProfileCss()) - /** One line of an entry, for the headline list on the profile page. */ function teaserFor(entry: Status): string { const text = toPlainText(entry.spoiler_text || entry.content) diff --git a/src/routes/Profile.test.ts b/src/routes/Profile.test.ts new file mode 100644 index 0000000..5fc650e --- /dev/null +++ b/src/routes/Profile.test.ts @@ -0,0 +1,27 @@ +import { act, render } from '@testing-library/svelte' +import { describe, expect, it, vi } from 'vitest' +import { APP_SERVICES } from '$lib/app-services' +import type { Account } from '$lib/api/types' +import { account, deferred, session, testServices, theme } from '$test/fixtures' +import Profile from './Profile.svelte' + +describe('Profile', () => { + it('does not apply profile CSS after it has unmounted', async () => { + const lookup = deferred() + const applyProfileCss = vi.fn() + const services = testServices({ + session: session(), + theme: theme({ applyProfileCss }), + endpoints: { lookupAccount: vi.fn(() => lookup.promise) }, + }) + const view = render(Profile, { + props: { acct: 'alice' }, + context: new Map([[APP_SERVICES, services]]), + }) + + view.unmount() + await act(() => lookup.resolve(account({ fields: [{ name: 'css', value: '.x { color: red }' }] }))) + + expect(applyProfileCss).not.toHaveBeenCalled() + }) +}) diff --git a/src/routes/Search.svelte b/src/routes/Search.svelte index db94423..9bbdb02 100644 --- a/src/routes/Search.svelte +++ b/src/routes/Search.svelte @@ -7,9 +7,8 @@ */ import { untrack } from 'svelte' import type { SearchResults } from '$lib/api/types' - import { session } from '$lib/stores/session.svelte' - import { search } from '$lib/api/endpoints' - import { router, routeTo } from '$lib/router.svelte' + import { useAppServices } from '$lib/app-services' + import { routeTo } from '$lib/router.svelte' import Module from '$components/common/Module.svelte' import PersonRow from '$components/people/PersonRow.svelte' import BlogEntry from '$components/blog/BlogEntry.svelte' @@ -21,32 +20,43 @@ let { q = '' }: Props = $props() + const { endpoints, router, session } = useAppServices() // Seeded from the route, then kept in sync by the effect below. let input = $state(untrack(() => q)) let results = $state(null) let loading = $state(false) let error = $state(null) + let generation = 0 $effect(() => { const query = q input = query if (!query || !session.host) { + generation += 1 results = null + loading = false + error = null return } untrack(() => void run(query)) + return () => { + generation += 1 + } }) async function run(query: string): Promise { + const currentGeneration = ++generation loading = true error = null try { - const found = await search(session.api, query, { limit: 20 }) - if (q === query) results = found + const found = await endpoints.search(session.api, query, { limit: 20 }) + if (currentGeneration === generation) results = found } catch (cause) { - if (q === query) error = cause instanceof Error ? cause.message : 'Search failed.' + if (currentGeneration === generation) { + error = cause instanceof Error ? cause.message : 'Search failed.' + } } finally { - if (q === query) loading = false + if (currentGeneration === generation) loading = false } } diff --git a/src/routes/Search.test.ts b/src/routes/Search.test.ts new file mode 100644 index 0000000..a37bf55 --- /dev/null +++ b/src/routes/Search.test.ts @@ -0,0 +1,26 @@ +import { render } from '@testing-library/svelte' +import { describe, expect, it, vi } from 'vitest' +import { APP_SERVICES } from '$lib/app-services' +import type { SearchResults } from '$lib/api/types' +import { deferred, session, testServices } from '$test/fixtures' +import Search from './Search.svelte' + +describe('Search', () => { + it('returns to the empty state when a pending query is cleared', async () => { + const pending = deferred() + const services = testServices({ + session: session(), + endpoints: { search: vi.fn(() => pending.promise) }, + }) + const view = render(Search, { + props: { q: 'alice' }, + context: new Map([[APP_SERVICES, services]]), + }) + + expect(await view.findByText('Searching…')).toBeInTheDocument() + await view.rerender({ q: '' }) + + expect(view.getByText('Enter something to search for.')).toBeInTheDocument() + expect(view.queryByText('Searching…')).not.toBeInTheDocument() + }) +}) diff --git a/src/routes/Settings.svelte b/src/routes/Settings.svelte index 62b5bf3..1e3b192 100644 --- a/src/routes/Settings.svelte +++ b/src/routes/Settings.svelte @@ -7,13 +7,15 @@ * locally), and whether the CSS other people publish on their profiles is * honoured when you visit them. */ - import { session } from '$lib/stores/session.svelte' - import { theme, CSS_FIELD_NAMES, PROFILE_SCOPE } from '$lib/stores/theme.svelte' + import { useAppServices } from '$lib/app-services' + import { CSS_FIELD_NAMES, PROFILE_SCOPE } from '$lib/stores/theme.svelte' import { PRESETS, EXAMPLE_CSS } from '$lib/themes' import { instanceDomain } from '$lib/api/endpoints' import { displayNameOf, profilePath } from '$lib/util/profile' import Module from '$components/common/Module.svelte' + const { session, theme } = useAppServices() + let draft = $state(theme.viewerCss) let saved = $state(false) diff --git a/src/routes/StatusPage.svelte b/src/routes/StatusPage.svelte index eea52bd..4dfa805 100644 --- a/src/routes/StatusPage.svelte +++ b/src/routes/StatusPage.svelte @@ -8,8 +8,7 @@ */ import { untrack } from 'svelte' import type { Status } from '$lib/api/types' - import { session } from '$lib/stores/session.svelte' - import { fetchContext, fetchStatus } from '$lib/api/endpoints' + import { useAppServices } from '$lib/app-services' import { displayNameOf, profilePath } from '$lib/util/profile' import { stampDate, isoDate } from '$lib/util/time' import { toPlainText } from '$lib/util/html' @@ -25,11 +24,13 @@ let { id }: Props = $props() + const { endpoints, session } = useAppServices() let status = $state(null) let ancestors = $state([]) let descendants = $state([]) let loading = $state(true) let error = $state(null) + let loadGeneration = 0 interface ThreadedReply { status: Status @@ -80,28 +81,32 @@ const currentId = id const host = session.host if (!host) return - untrack(() => void load(currentId)) + const generation = ++loadGeneration + untrack(() => void load(currentId, host, generation)) + return () => { + if (generation === loadGeneration) loadGeneration += 1 + } }) - async function load(currentId: string): Promise { + async function load(currentId: string, host: string, generation: number): Promise { loading = true error = null try { const [entry, context] = await Promise.all([ - fetchStatus(session.api, currentId), - fetchContext(session.api, currentId).catch(() => ({ ancestors: [], descendants: [] })), + endpoints.fetchStatus(session.api, currentId), + endpoints.fetchContext(session.api, currentId).catch(() => ({ ancestors: [], descendants: [] })), ]) - if (id !== currentId) return + if (generation !== loadGeneration || id !== currentId || session.host !== host) return status = entry ancestors = context.ancestors descendants = context.descendants document.title = `${toPlainText(entry.content).slice(0, 60)} | plspace` } catch (cause) { - if (id !== currentId) return + if (generation !== loadGeneration) return error = cause instanceof Error ? cause.message : 'Could not load that entry.' } finally { - if (id === currentId) loading = false + if (generation === loadGeneration) loading = false } } diff --git a/src/routes/Timeline.svelte b/src/routes/Timeline.svelte index e347bd4..41e95d8 100644 --- a/src/routes/Timeline.svelte +++ b/src/routes/Timeline.svelte @@ -6,9 +6,9 @@ import { untrack } from 'svelte' import type { Status } from '$lib/api/types' import type { TimelineKind } from '$lib/api/endpoints' - import { session } from '$lib/stores/session.svelte' + import { useAppServices } from '$lib/app-services' import { Feed } from '$lib/stores/feed.svelte' - import { fetchTimeline, instanceDomain } from '$lib/api/endpoints' + import { instanceDomain } from '$lib/api/endpoints' import Module from '$components/common/Module.svelte' import TabBar from '$components/common/TabBar.svelte' import BlogList from '$components/blog/BlogList.svelte' @@ -21,6 +21,7 @@ let { kind, tag }: Props = $props() + const { endpoints, session } = useAppServices() let feed = $state>(new Feed(async () => ({ items: [], links: {} }))) const domain = $derived(session.host ? instanceDomain(session.instance, session.host) : '') @@ -54,7 +55,7 @@ // Home needs a token; fall back rather than showing a 401. const resolved: TimelineKind = currentKind === 'home' && !authed ? 'local' : currentKind feed = new Feed((cursor) => - fetchTimeline(session.api, resolved, cursor, { tag: currentTag }), + endpoints.fetchTimeline(session.api, resolved, cursor, { tag: currentTag }), ) void feed.reload() document.title = `${title} | plspace` diff --git a/src/test/README.md b/src/test/README.md new file mode 100644 index 0000000..1a1b585 --- /dev/null +++ b/src/test/README.md @@ -0,0 +1,21 @@ +# Component tests + +Components obtain session, router, theme, and endpoint dependencies from +`$lib/app-services`. Production falls back to the real services. Tests render a +component with a context map and replace only the dependencies involved: + +```ts +const services = testServices({ + session: session({ signedIn: true }), + endpoints: { search: vi.fn().mockResolvedValue(results) }, +}) + +render(Search, { + props: { q: 'alice' }, + context: new Map([[APP_SERVICES, services]]), +}) +``` + +This keeps component tests deterministic and prevents accidental network +requests without requiring module-level mocks. Any endpoint that was not +explicitly supplied throws immediately with its name. diff --git a/src/test/fixtures.ts b/src/test/fixtures.ts new file mode 100644 index 0000000..93909da --- /dev/null +++ b/src/test/fixtures.ts @@ -0,0 +1,126 @@ +import { ApiClient } from '$lib/api/client' +import type { Account, Status } from '$lib/api/types' +import { + createAppServices, + type AppServices, + type AppServiceOverrides, + type SessionService, + type ThemeService, +} from '$lib/app-services' + +export function account(overrides: Partial = {}): Account { + return { + id: 'account-1', + username: 'alice', + acct: 'alice', + display_name: 'Alice', + note: '', + url: 'https://example.test/@alice', + avatar: '', + avatar_static: '', + header: '', + header_static: '', + locked: false, + created_at: '2020-01-01T00:00:00.000Z', + statuses_count: 0, + followers_count: 0, + following_count: 0, + fields: [], + emojis: [], + ...overrides, + } +} + +export function status(overrides: Partial = {}): Status { + return { + id: 'status-1', + uri: 'https://example.test/statuses/1', + created_at: '2026-01-01T00:00:00.000Z', + account: account(), + content: '

    Hello

    ', + visibility: 'public', + sensitive: false, + spoiler_text: '', + in_reply_to_id: null, + in_reply_to_account_id: null, + replies_count: 0, + reblogs_count: 0, + favourites_count: 0, + media_attachments: [], + mentions: [], + tags: [], + emojis: [], + reblog: null, + ...overrides, + } +} + +export function session(overrides: Partial = {}): SessionService { + const host = overrides.host ?? 'example.test' + return { + host, + token: null, + me: null, + instance: null, + loading: false, + error: null, + api: new ApiClient(host), + signedIn: false, + connected: Boolean(host), + restore: async () => null, + connect: async () => {}, + login: async () => {}, + logout: async () => {}, + disconnect: () => {}, + ...overrides, + } +} + +export function theme(overrides: Partial = {}): ThemeService { + return { + viewerCss: '', + allowProfileCss: true, + setViewerCss: () => {}, + setAllowProfileCss: () => {}, + applyProfileCss: () => {}, + clearProfileCss: () => {}, + ...overrides, + } +} + +/** + * Test services fail fast on any endpoint that was not explicitly faked. This + * turns an accidental network request into a local, descriptive test failure. + */ +export function testServices(overrides: AppServiceOverrides = {}): AppServices { + const services = createAppServices({ + ...overrides, + session: overrides.session ?? session(), + }) + const configured = overrides.endpoints ?? {} + services.endpoints = new Proxy(services.endpoints, { + get(target, property, receiver) { + if (property in configured) return Reflect.get(target, property, receiver) + const value = Reflect.get(target, property, receiver) + if (typeof value !== 'function') return value + return () => { + throw new Error(`Test attempted an unfaked endpoint: ${String(property)}`) + } + }, + }) + return services +} + +export function deferred(): { + promise: Promise + resolve(value: T): void + reject(cause: unknown): void +} { + let resolve!: (value: T) => void + let reject!: (cause: unknown) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} diff --git a/src/test/setup.ts b/src/test/setup.ts new file mode 100644 index 0000000..e9ab882 --- /dev/null +++ b/src/test/setup.ts @@ -0,0 +1,22 @@ +import '@testing-library/jest-dom/vitest' + +const values = new Map() +const memoryStorage: Storage = { + get length() { + return values.size + }, + clear: () => values.clear(), + getItem: (key) => values.get(key) ?? null, + key: (index) => [...values.keys()][index] ?? null, + removeItem: (key) => { + values.delete(key) + }, + setItem: (key, value) => { + values.set(key, String(value)) + }, +} + +Object.defineProperty(globalThis, 'localStorage', { + configurable: true, + value: memoryStorage, +}) diff --git a/tsconfig.json b/tsconfig.json index f66cb6e..e7c88cd 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -20,7 +20,8 @@ "paths": { "$lib/*": ["src/lib/*"], "$components/*": ["src/components/*"], - "$routes/*": ["src/routes/*"] + "$routes/*": ["src/routes/*"], + "$test/*": ["src/test/*"] } }, "include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte", "vite.config.ts"], diff --git a/vite.config.ts b/vite.config.ts index cf3a5ef..cbd0719 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,21 +1,27 @@ -import { defineConfig } from 'vite' +import { defineConfig } from 'vitest/config' import { svelte } from '@sveltejs/vite-plugin-svelte' +import { svelteTesting } from '@testing-library/svelte/vite' import { fileURLToPath, URL } from 'node:url' // Fully static output: the app talks to a Mastodon/Pleroma server directly from // the browser, so `dist/` can be dropped on any static host (or file://-ish CDN). export default defineConfig({ base: './', - plugins: [svelte()], + plugins: [svelte(), svelteTesting()], resolve: { alias: { $lib: fileURLToPath(new URL('./src/lib', import.meta.url)), $components: fileURLToPath(new URL('./src/components', import.meta.url)), $routes: fileURLToPath(new URL('./src/routes', import.meta.url)), + $test: fileURLToPath(new URL('./src/test', import.meta.url)), }, }, build: { target: 'es2022', cssCodeSplit: false, }, + test: { + environment: 'jsdom', + setupFiles: ['./src/test/setup.ts'], + }, })