From 78fc5a320f1c3e0af6ef0c73cfd482bed3fdd6bf Mon Sep 17 00:00:00 2001 From: Johannes Horn Date: Wed, 16 Sep 2026 21:02:55 +0200 Subject: [PATCH 01/13] test: set up Vitest with React Testing Library Add vitest, jsdom and Testing Library as dev dependencies and a `test` block in vite.config.ts (jsdom environment, setup file, mock reset and readable CSS module class names in snapshots). src/test/setup.ts registers the jest-dom matchers and cleans up the DOM and localStorage after each test. src/test/renderWithQueryClient.tsx renders components inside a QueryClientProvider, and src/test/fixtures/books.ts holds sample books for component tests. New scripts: `pnpm test` (run once) and `pnpm test:watch`. Refs #24 Co-Authored-By: Claude Fable 5.1 --- package.json | 12 +- pnpm-lock.yaml | 711 ++++++++++++++++++++++++++++- src/test/fixtures/books.ts | 19 + src/test/renderWithQueryClient.tsx | 32 ++ src/test/setup.ts | 13 + vite.config.ts | 13 +- 6 files changed, 792 insertions(+), 8 deletions(-) create mode 100644 src/test/fixtures/books.ts create mode 100644 src/test/renderWithQueryClient.tsx create mode 100644 src/test/setup.ts diff --git a/package.json b/package.json index fbb0c6a..5b75949 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,9 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@tanstack/react-query": "^5.102.8", @@ -22,6 +24,10 @@ "@babel/core": "^7.29.7", "@eslint/js": "^10.0.1", "@rolldown/plugin-babel": "^0.2.3", + "@testing-library/dom": "^10.4.2", + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.3", + "@testing-library/user-event": "^14.6.7", "@types/babel__core": "^7.20.5", "@types/node": "^24.13.3", "@types/react": "^19.2.18", @@ -32,9 +38,11 @@ "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.4", "globals": "^17.11.0", + "jsdom": "^30.0.1", "prettier": "3.9.6", "typescript": "~6.0.2", "typescript-eslint": "^8.67.0", - "vite": "^8.2.2" + "vite": "^8.2.2", + "vitest": "^5.0.1" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ec6a838..f4dda81 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,7 +26,19 @@ importers: version: 10.0.1(eslint@10.10.0) '@rolldown/plugin-babel': specifier: ^0.2.3 - version: 0.2.4(@babel/core@7.29.7)(rolldown@1.2.8)(vite@8.3.0(@types/node@24.13.4)) + version: 0.2.4(@babel/core@7.29.7)(@babel/runtime@7.29.7)(rolldown@1.2.8)(vite@8.3.0(@types/node@24.13.4)) + '@testing-library/dom': + specifier: ^10.4.2 + version: 10.4.2 + '@testing-library/jest-dom': + specifier: ^7.0.1 + version: 7.0.1(@testing-library/dom@10.4.2)(vitest@5.0.1(@types/node@24.13.4)(jsdom@30.0.1)(vite@8.3.0(@types/node@24.13.4))) + '@testing-library/react': + specifier: ^16.3.3 + version: 16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@testing-library/user-event': + specifier: ^14.6.7 + version: 14.6.7(@testing-library/dom@10.4.2) '@types/babel__core': specifier: ^7.20.5 version: 7.20.5 @@ -41,7 +53,7 @@ importers: version: 19.3.0(@types/react@19.3.0) '@vitejs/plugin-react': specifier: ^6.1.0 - version: 6.1.1(@rolldown/plugin-babel@0.2.4(@babel/core@7.29.7)(rolldown@1.2.8)(vite@8.3.0(@types/node@24.13.4)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.0(@types/node@24.13.4)) + version: 6.1.1(@rolldown/plugin-babel@0.2.4(@babel/core@7.29.7)(@babel/runtime@7.29.7)(rolldown@1.2.8)(vite@8.3.0(@types/node@24.13.4)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.0(@types/node@24.13.4)) babel-plugin-react-compiler: specifier: ^1.0.0 version: 1.0.0 @@ -57,6 +69,9 @@ importers: globals: specifier: ^17.11.0 version: 17.12.0 + jsdom: + specifier: ^30.0.1 + version: 30.0.1 prettier: specifier: 3.9.6 version: 3.9.6 @@ -69,9 +84,23 @@ importers: vite: specifier: ^8.2.2 version: 8.3.0(@types/node@24.13.4) + vitest: + specifier: ^5.0.1 + version: 5.0.1(@types/node@24.13.4)(jsdom@30.0.1)(vite@8.3.0(@types/node@24.13.4)) packages: + '@adobe/css-tools@4.5.0': + resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} + + '@asamuzakjp/css-color@6.0.7': + resolution: {integrity: sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==} + engines: {node: ^22.13.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@8.3.2': + resolution: {integrity: sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==} + engines: {node: ^22.13.0 || >=24.0.0} + '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -127,6 +156,10 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + '@babel/template@7.29.7': resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} engines: {node: '>=6.9.0'} @@ -139,12 +172,52 @@ packages: resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} engines: {node: '>=6.9.0'} + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + '@cacheable/memory@2.2.0': resolution: {integrity: sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==} '@cacheable/utils@2.5.0': resolution: {integrity: sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==} + '@csstools/color-helpers@6.1.1': + resolution: {integrity: sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.4.0': + resolution: {integrity: sha512-XQKj5B7QiZcHiegCOCAzcAOJdhGgWOHbbu62h5e5mkHnn8lWcfiJhllkqWmxu5zWR9jucPHuo1iTB56P033hcg==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.2.3': + resolution: {integrity: sha512-y4LpL+lmpuyKDiEFq2PnZUVFdAjsoB/qQJod79yLNokXyW7jewi+/WJ69EfItj8A2unWtxXnGjw6LYXgXu5ZjA==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.14': + resolution: {integrity: sha512-HpbVXyrofRXpHpgkNIjU/3EWR4WJvOkO3emNK/L6X/mTJU7bGUI3AkkpoTNXznQLp0KRjLHELTGeKI5dIkI9JQ==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + '@eslint-community/eslint-utils@4.10.1': resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -184,6 +257,15 @@ packages: resolution: {integrity: sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@exodus/bytes@1.15.1': + resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==} + 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 + '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} @@ -356,6 +438,44 @@ packages: peerDependencies: react: ^18 || ^19 + '@testing-library/dom@10.4.2': + resolution: {integrity: sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@7.0.1': + resolution: {integrity: sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==} + engines: {node: '>=22', npm: '>=6', yarn: '>=1'} + peerDependencies: + '@testing-library/dom': '>=10 <11' + vitest: '>= 0.32' + peerDependenciesMeta: + vitest: + optional: true + + '@testing-library/react@16.3.3': + resolution: {integrity: sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@testing-library/user-event@14.6.7': + resolution: {integrity: sha512-MPCpX8bxe8zS+JmmTwLp8jd0dy1rAm60Te/SL8JrQM3qvQJcBOs1d7IefJMyZzqM3EWBrDn/LWDt1BCGu4ASfg==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -368,6 +488,12 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/esrecurse@4.3.1': resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} @@ -463,6 +589,20 @@ packages: oxc-transform-react: optional: true + '@vitest/mocker@5.0.1': + resolution: {integrity: sha512-6K1DoBNAPGvuOcSsGA4D6x+5zEEff/KmOOP3uetT2TrGpVfI+HRHRnJJfKi5ib/g1vx8IYHQD8s0pbJz8WQI7Q==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/spy@5.0.1': + resolution: {integrity: sha512-rbto/mF/SGERxEgYOek7Xm6B9b+y+mVoo+f4b2LymYO8zM1b7uB5nHuhVMTP2hxdzgxvGiZYGxGIaMvL5y180Q==} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -476,6 +616,25 @@ packages: ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + babel-plugin-react-compiler@1.0.0: resolution: {integrity: sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==} @@ -488,6 +647,9 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + bidi-js@1.1.0: + resolution: {integrity: sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==} + brace-expansion@5.0.9: resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} engines: {node: 20 || >=22} @@ -503,6 +665,10 @@ packages: caniuse-lite@1.0.30001810: resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -510,9 +676,20 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -522,16 +699,36 @@ packages: supports-color: optional: true + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + electron-to-chromium@1.5.427: resolution: {integrity: sha512-n14zb3FdsChZ2BNobqNHAJMcP3ifFv4paox2LvCrfVAQcqGiSURgbJl+PfMpHVCNFkStnNc+RRVtPBTVW5PDgw==} + entities@8.1.0: + resolution: {integrity: sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==} + engines: {node: '>=20.19.0'} + + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -589,10 +786,17 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -657,6 +861,10 @@ packages: hookified@2.2.0: resolution: {integrity: sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -669,6 +877,10 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -677,12 +889,24 @@ packages: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + jsdom@30.0.1: + resolution: {integrity: sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + peerDependencies: + canvas: ^3.2.3 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -784,9 +1008,27 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + + magic-string@1.4.1: + resolution: {integrity: sha512-8lyCu36ErXR0J9uaGKlKQoiLZKmtI63YGLE8G2o9jyRPdr4X47LusSOwgOJOzcVtp81fTAAjxR7BwKz682Jhow==} + + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + minimatch@10.2.6: resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} engines: {node: 18 || 20 || >=22} @@ -806,6 +1048,10 @@ packages: resolution: {integrity: sha512-mIrE/Cw9y+9Au6dS5vDKDhQza9YvG6w+ZrS6X+ZzA7yFW/soAeaups4Qzn1bL6g5FVy8WtP79+0j82oPIbqRjQ==} engines: {node: '>=18'} + obug@2.2.1: + resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==} + engines: {node: '>=12.20.0'} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -818,6 +1064,9 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -846,6 +1095,10 @@ packages: engines: {node: '>=14'} hasBin: true + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -859,15 +1112,30 @@ packages: peerDependencies: react: ^19.3.0 + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react@19.3.0: resolution: {integrity: sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==} engines: {node: '>=0.10.0'} + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + rolldown@1.2.8: resolution: {integrity: sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.28.0: resolution: {integrity: sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==} @@ -888,14 +1156,53 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + tinybench@6.1.4: + resolution: {integrity: sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==} + engines: {node: '>=20.0.0'} + + tinyexec@1.3.0: + resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==} + engines: {node: '>=18'} + tinyglobby@0.2.17: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} + tldts-core@7.4.13: + resolution: {integrity: sha512-mbYsrih5FRtGxs3Usvl/PqwJsNpp+jsmrdFviiK02teHDG0/HebBG/pqCylje3kzgXYzuLoHJF/0mz9W53t8Xg==} + + tldts@7.4.13: + resolution: {integrity: sha512-iHtaIWWIbMDkCeJdTBzZFGgbluE5J+oHlb2g7+oAz1S1gpuVpabRZdQyd471Vl8UUkcz2vXSL8xZH2kyCe8tfA==} + hasBin: true + + tough-cookie@6.0.2: + resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + ts-api-utils@2.5.0: resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} engines: {node: '>=18.12'} @@ -921,6 +1228,10 @@ packages: undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + undici@8.10.2: + resolution: {integrity: sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==} + engines: {node: '>=22.19.0'} + update-browserslist-db@1.3.3: resolution: {integrity: sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==} hasBin: true @@ -973,15 +1284,88 @@ packages: yaml: optional: true + vitest@5.0.1: + resolution: {integrity: sha512-iA95lQbKEkvrtTkdAgnWbXfbipWiiWe/hDl2P5tMi6WFwD76G0NxXAGp/M9EOcYupeGJRr6wppMc7CoA41TQjg==} + engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 5.0.1 + '@vitest/browser-preview': 5.0.1 + '@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0 + '@vitest/coverage-istanbul': 5.0.1 + '@vitest/coverage-v8': 5.0.1 + '@vitest/ui': 5.0.1 + happy-dom: '*' + jsdom: '*' + vite: ^6.4.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 + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + whatwg-url@17.1.1: + resolution: {integrity: sha512-ohjk1mdUebJVadRt3bAhQhx8lSnISq+GDttK79LFl8EHQkAPvzwctoasC4hs8tBt6kLAncBWWyq1N52qEfKvDw==} + engines: {node: ^22.14.0 || >=24.0.0} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -1000,6 +1384,23 @@ packages: snapshots: + '@adobe/css-tools@4.5.0': {} + + '@asamuzakjp/css-color@6.0.7': + dependencies: + '@csstools/css-calc': 3.4.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.2.3(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + lru-cache: 11.5.2 + + '@asamuzakjp/dom-selector@8.3.2': + dependencies: + bidi-js: 1.1.0 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.2 + '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -1077,6 +1478,8 @@ snapshots: dependencies: '@babel/types': 7.29.8 + '@babel/runtime@7.29.7': {} + '@babel/template@7.29.7': dependencies: '@babel/code-frame': 7.29.7 @@ -1100,6 +1503,10 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + '@cacheable/memory@2.2.0': dependencies: '@cacheable/utils': 2.5.0 @@ -1112,6 +1519,30 @@ snapshots: hashery: 1.5.1 keyv: 5.6.0 + '@csstools/color-helpers@6.1.1': {} + + '@csstools/css-calc@3.4.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.2.3(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.1.1 + '@csstools/css-calc': 3.4.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.14(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + '@eslint-community/eslint-utils@4.10.1(eslint@10.10.0)': dependencies: eslint: 10.10.0 @@ -1146,6 +1577,8 @@ snapshots: '@eslint/core': 1.2.1 levn: 0.4.1 + '@exodus/bytes@1.15.1': {} + '@humanfs/core@0.19.2': dependencies: '@humanfs/types': 0.15.0 @@ -1236,12 +1669,13 @@ snapshots: '@rolldown/binding-win32-x64-msvc@1.2.8': optional: true - '@rolldown/plugin-babel@0.2.4(@babel/core@7.29.7)(rolldown@1.2.8)(vite@8.3.0(@types/node@24.13.4))': + '@rolldown/plugin-babel@0.2.4(@babel/core@7.29.7)(@babel/runtime@7.29.7)(rolldown@1.2.8)(vite@8.3.0(@types/node@24.13.4))': dependencies: '@babel/core': 7.29.7 picomatch: 4.0.7 rolldown: 1.2.8 optionalDependencies: + '@babel/runtime': 7.29.7 vite: 8.3.0(@types/node@24.13.4) '@rolldown/pluginutils@1.0.1': {} @@ -1253,6 +1687,45 @@ snapshots: '@tanstack/query-core': 5.102.8 react: 19.3.0 + '@testing-library/dom@10.4.2': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@7.0.1(@testing-library/dom@10.4.2)(vitest@5.0.1(@types/node@24.13.4)(jsdom@30.0.1)(vite@8.3.0(@types/node@24.13.4)))': + dependencies: + '@adobe/css-tools': 4.5.0 + '@testing-library/dom': 10.4.2 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + optionalDependencies: + vitest: 5.0.1(@types/node@24.13.4)(jsdom@30.0.1)(vite@8.3.0(@types/node@24.13.4)) + + '@testing-library/react@16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@babel/runtime': 7.29.7 + '@testing-library/dom': 10.4.2 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@testing-library/user-event@14.6.7(@testing-library/dom@10.4.2)': + dependencies: + '@testing-library/dom': 10.4.2 + + '@types/aria-query@5.0.4': {} + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.29.8 @@ -1274,6 +1747,13 @@ snapshots: dependencies: '@babel/types': 7.29.8 + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + '@types/esrecurse@4.3.1': {} '@types/estree@1.0.9': {} @@ -1383,14 +1863,25 @@ snapshots: '@typescript-eslint/types': 8.70.0 eslint-visitor-keys: 5.0.1 - '@vitejs/plugin-react@6.1.1(@rolldown/plugin-babel@0.2.4(@babel/core@7.29.7)(rolldown@1.2.8)(vite@8.3.0(@types/node@24.13.4)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.0(@types/node@24.13.4))': + '@vitejs/plugin-react@6.1.1(@rolldown/plugin-babel@0.2.4(@babel/core@7.29.7)(@babel/runtime@7.29.7)(rolldown@1.2.8)(vite@8.3.0(@types/node@24.13.4)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.0(@types/node@24.13.4))': dependencies: '@rolldown/pluginutils': 1.0.1 vite: 8.3.0(@types/node@24.13.4) optionalDependencies: - '@rolldown/plugin-babel': 0.2.4(@babel/core@7.29.7)(rolldown@1.2.8)(vite@8.3.0(@types/node@24.13.4)) + '@rolldown/plugin-babel': 0.2.4(@babel/core@7.29.7)(@babel/runtime@7.29.7)(rolldown@1.2.8)(vite@8.3.0(@types/node@24.13.4)) babel-plugin-react-compiler: 1.0.0 + '@vitest/mocker@5.0.1(vite@8.3.0(@types/node@24.13.4))': + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@vitest/spy': 5.0.1 + estree-walker: 3.0.3 + magic-string: 1.4.1 + optionalDependencies: + vite: 8.3.0(@types/node@24.13.4) + + '@vitest/spy@5.0.1': {} + acorn-jsx@5.3.2(acorn@8.18.0): dependencies: acorn: 8.18.0 @@ -1404,6 +1895,18 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ansi-regex@5.0.1: {} + + ansi-styles@5.2.0: {} + + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + aria-query@5.3.2: {} + + assertion-error@2.0.1: {} + babel-plugin-react-compiler@1.0.0: dependencies: '@babel/types': 7.29.8 @@ -1412,6 +1915,10 @@ snapshots: baseline-browser-mapping@2.11.23: {} + bidi-js@1.1.0: + dependencies: + require-from-string: 2.0.2 + brace-expansion@5.0.9: dependencies: balanced-match: 4.0.4 @@ -1434,6 +1941,8 @@ snapshots: caniuse-lite@1.0.30001810: {} + chai@6.2.2: {} + convert-source-map@2.0.0: {} cross-spawn@7.0.6: @@ -1442,18 +1951,44 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + + css.escape@1.5.1: {} + csstype@3.2.3: {} + data-urls@7.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + transitivePeerDependencies: + - '@noble/hashes' + debug@4.4.3: dependencies: ms: 2.1.3 + decimal.js@10.6.0: {} + deep-is@0.1.4: {} + dequal@2.0.3: {} + detect-libc@2.1.2: {} + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + electron-to-chromium@1.5.427: {} + entities@8.1.0: {} + + es-module-lexer@2.3.2: {} + escalade@3.2.0: {} escape-string-regexp@4.0.0: {} @@ -1535,8 +2070,14 @@ snapshots: estraverse@5.3.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + esutils@2.0.3: {} + expect-type@1.4.0: {} + fast-deep-equal@3.1.3: {} fast-json-stable-stringify@2.1.0: {} @@ -1589,22 +2130,58 @@ snapshots: hookified@2.2.0: {} + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.15.1 + transitivePeerDependencies: + - '@noble/hashes' + ignore@5.3.2: {} ignore@7.0.9: {} imurmurhash@0.1.4: {} + indent-string@4.0.0: {} + is-extglob@2.1.1: {} is-glob@4.0.3: dependencies: is-extglob: 2.1.1 + is-potential-custom-element-name@1.0.1: {} + isexe@2.0.0: {} js-tokens@4.0.0: {} + jsdom@30.0.1: + dependencies: + '@asamuzakjp/css-color': 6.0.7 + '@asamuzakjp/dom-selector': 8.3.2 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.14(css-tree@3.2.1) + '@exodus/bytes': 1.15.1 + 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.5.2 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.2 + undici: 8.10.2 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 17.1.1 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + jsesc@3.1.0: {} json-schema-traverse@0.4.1: {} @@ -1675,10 +2252,22 @@ snapshots: dependencies: p-locate: 5.0.0 + lru-cache@11.5.2: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 + lz-string@1.5.0: {} + + magic-string@1.4.1: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + mdn-data@2.27.1: {} + + min-indent@1.0.1: {} + minimatch@10.2.6: dependencies: brace-expansion: 5.0.9 @@ -1691,6 +2280,8 @@ snapshots: node-releases@2.0.55: {} + obug@2.2.1: {} + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -1708,6 +2299,10 @@ snapshots: dependencies: p-limit: 3.1.0 + parse5@8.0.1: + dependencies: + entities: 8.1.0 + path-exists@4.0.0: {} path-key@3.1.1: {} @@ -1726,6 +2321,12 @@ snapshots: prettier@3.9.6: {} + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + punycode@2.3.1: {} qified@0.10.1: @@ -1737,8 +2338,17 @@ snapshots: react: 19.3.0 scheduler: 0.28.0 + react-is@17.0.2: {} + react@19.3.0: {} + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + + require-from-string@2.0.2: {} + rolldown@1.2.8: dependencies: '@oxc-project/types': 0.149.0 @@ -1760,6 +2370,10 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.2.8 '@rolldown/binding-win32-x64-msvc': 1.2.8 + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.28.0: {} semver@6.3.1: {} @@ -1772,13 +2386,43 @@ snapshots: shebang-regex@3.0.0: {} + siginfo@2.0.0: {} + source-map-js@1.2.1: {} + stackback@0.0.2: {} + + std-env@4.2.0: {} + + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + + symbol-tree@3.2.4: {} + + tinybench@6.1.4: {} + + tinyexec@1.3.0: {} + tinyglobby@0.2.17: dependencies: fdir: 6.5.0(picomatch@4.0.7) picomatch: 4.0.7 + tldts-core@7.4.13: {} + + tldts@7.4.13: + dependencies: + tldts-core: 7.4.13 + + tough-cookie@6.0.2: + dependencies: + tldts: 7.4.13 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + ts-api-utils@2.5.0(typescript@6.0.3): dependencies: typescript: 6.0.3 @@ -1802,6 +2446,8 @@ snapshots: undici-types@7.18.2: {} + undici@8.10.2: {} + update-browserslist-db@1.3.3(browserslist@4.28.9): dependencies: browserslist: 4.28.9 @@ -1823,12 +2469,67 @@ snapshots: '@types/node': 24.13.4 fsevents: 2.3.3 + vitest@5.0.1(@types/node@24.13.4)(jsdom@30.0.1)(vite@8.3.0(@types/node@24.13.4)): + dependencies: + '@types/chai': 5.2.3 + '@vitest/mocker': 5.0.1(vite@8.3.0(@types/node@24.13.4)) + chai: 6.2.2 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 + magic-string: 1.4.1 + obug: 2.2.1 + picomatch: 4.0.7 + std-env: 4.2.0 + tinybench: 6.1.4 + tinyexec: 1.3.0 + tinyglobby: 0.2.17 + vite: 8.3.0(@types/node@24.13.4) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.13.4 + jsdom: 30.0.1 + transitivePeerDependencies: + - msw + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@8.0.1: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1: + dependencies: + '@exodus/bytes': 1.15.1 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + + whatwg-url@17.1.1: + dependencies: + '@exodus/bytes': 1.15.1 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + which@2.0.2: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + word-wrap@1.2.5: {} + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + yallist@3.1.1: {} yocto-queue@0.1.0: {} diff --git a/src/test/fixtures/books.ts b/src/test/fixtures/books.ts new file mode 100644 index 0000000..e3d5b46 --- /dev/null +++ b/src/test/fixtures/books.ts @@ -0,0 +1,19 @@ +import type { Book } from "../../api/types"; + +// Sample books for component tests. Matches the first doc in searchResponse.json. +const bookWithCover: Book = { + id: "/works/OL8400950W", + title: "The Blade Itself", + author: ["Joe Abercrombie"], + publishedYear: 2001, + coverImage: + "https://covers.openlibrary.org/b/id/14543422-M.jpg?default=false", +}; + +const bookWithoutCover: Book = { + id: "/works/OL0000000W", + title: "Book Without Cover", + author: ["Unknown author"], +}; + +export { bookWithCover, bookWithoutCover }; diff --git a/src/test/renderWithQueryClient.tsx b/src/test/renderWithQueryClient.tsx new file mode 100644 index 0000000..bfc4ba6 --- /dev/null +++ b/src/test/renderWithQueryClient.tsx @@ -0,0 +1,32 @@ +import type { PropsWithChildren, ReactElement } from "react"; +import { render } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +// Components that use TanStack Query (via useBooks) must be rendered inside a +// QueryClientProvider. Every test gets a fresh client so cached data never leaks +// between tests. retry is off so error tests fail immediately instead of +// waiting for retries. staleTime matches main.tsx so cached data is reused. +function createQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { retry: false, staleTime: 10 * 60 * 1000 }, + }, + }); +} + +// Wrapper for renderHook(), e.g. renderHook(() => useBooks(...), { wrapper: createQueryClientWrapper() }) +function createQueryClientWrapper() { + const queryClient = createQueryClient(); + return function QueryClientWrapper({ children }: PropsWithChildren) { + return ( + {children} + ); + }; +} + +// Drop-in replacement for render() from Testing Library. +function renderWithQueryClient(ui: ReactElement) { + return render(ui, { wrapper: createQueryClientWrapper() }); +} + +export { createQueryClientWrapper, renderWithQueryClient }; diff --git a/src/test/setup.ts b/src/test/setup.ts new file mode 100644 index 0000000..51d9b5a --- /dev/null +++ b/src/test/setup.ts @@ -0,0 +1,13 @@ +// Runs before every test file (configured in vite.config.ts -> test.setupFiles). + +// Adds matchers like toBeInTheDocument() and toHaveAttribute() to expect(). +import "@testing-library/jest-dom/vitest"; +import { afterEach } from "vitest"; +import { cleanup } from "@testing-library/react"; + +afterEach(() => { + // Unmount everything rendered by the previous test. + cleanup(); + // FavoriteButton stores favorites in localStorage; every test starts clean. + localStorage.clear(); +}); diff --git a/vite.config.ts b/vite.config.ts index f2ac363..82d9cf0 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,8 +1,19 @@ import react, { reactCompilerPreset } from "@vitejs/plugin-react"; import babel from "@rolldown/plugin-babel"; -import { defineConfig } from "vite"; +import { defineConfig } from "vitest/config"; // https://vite.dev/config/ export default defineConfig({ plugins: [react(), babel({ presets: [reactCompilerPreset()] })], + // https://vitest.dev/config/ + test: { + // jsdom simulates a browser DOM so React components can render in Node. + environment: "jsdom", + // Runs before every test file: adds jest-dom matchers and cleans up between tests. + setupFiles: ["./src/test/setup.ts"], + // Reset mock state and implementations before each test so tests stay independent. + mockReset: true, + // Keep CSS module class names readable (e.g. "bookCard") in snapshots. + css: { modules: { classNameStrategy: "non-scoped" } }, + }, }); From 980ec5975bb1870368ab3228562d08bc65eee279 Mon Sep 17 00:00:00 2001 From: Johannes Horn Date: Wed, 16 Sep 2026 21:02:56 +0200 Subject: [PATCH 02/13] test: add unit tests for the Open Library API layer - toBook: mapping from an Open Library doc to Book, including fallbacks for missing author, year and cover. - searchBooks: mocks the global fetch with vi.spyOn so no request reaches openlibrary.org, checks the query parameters and the error handling. - useBooks: mocks searchBooks with vi.mock and checks that the hook returns mapped books. Refs #24 Co-Authored-By: Claude Fable 5.1 --- src/api/openLibrary.test.ts | 69 +++++++++++++++++++++++++++++++++++++ src/api/useBooks.test.tsx | 40 +++++++++++++++++++++ 2 files changed, 109 insertions(+) create mode 100644 src/api/openLibrary.test.ts create mode 100644 src/api/useBooks.test.tsx diff --git a/src/api/openLibrary.test.ts b/src/api/openLibrary.test.ts new file mode 100644 index 0000000..edffa92 --- /dev/null +++ b/src/api/openLibrary.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it, vi } from "vitest"; +import { searchBooks, toBook } from "./openLibrary"; +import type { OpenLibraryDoc } from "./types"; +import searchResponse from "../test/fixtures/searchResponse.json"; + +describe("toBook", () => { + it("maps an Open Library doc to our Book type", () => { + const doc: OpenLibraryDoc = { + key: "/works/OL8400950W", + title: "The Blade Itself", + author_name: ["Joe Abercrombie"], + first_publish_year: 2001, + cover_i: 14543422, + }; + + expect(toBook(doc)).toEqual({ + id: "/works/OL8400950W", + title: "The Blade Itself", + author: ["Joe Abercrombie"], + publishedYear: 2001, + coverImage: + "https://covers.openlibrary.org/b/id/14543422-M.jpg?default=false", + }); + }); + + it("uses fallbacks when author, year and cover are missing", () => { + const doc: OpenLibraryDoc = { key: "/works/OL1W", title: "No extras" }; + + const book = toBook(doc); + + expect(book.author).toEqual(["Unknown author"]); + expect(book.publishedYear).toBeUndefined(); + expect(book.coverImage).toBeUndefined(); + }); +}); + +describe("searchBooks", () => { + it("requests the search endpoint with query, limit and fields", async () => { + // Replace the real fetch so nothing is sent to openlibrary.org. + // The mock answers with a response we saved earlier as a fixture. + const fetchMock = vi + .spyOn(globalThis, "fetch") + .mockResolvedValue(Response.json(searchResponse)); + + const data = await searchBooks({ query: "subject:fantasy" }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const url = new URL(String(fetchMock.mock.calls[0][0])); + expect(url.origin + url.pathname).toBe( + "https://openlibrary.org/search.json", + ); + expect(url.searchParams.get("q")).toBe("subject:fantasy"); + expect(url.searchParams.get("limit")).toBe("12"); + expect(url.searchParams.get("fields")).toBe( + "key,title,author_name,first_publish_year,cover_i", + ); + expect(data.docs).toHaveLength(5); + }); + + it("throws when the API answers with an error status", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(null, { status: 429 }), + ); + + await expect(searchBooks({ query: "subject:fantasy" })).rejects.toThrow( + "Open Library search failed with status 429", + ); + }); +}); diff --git a/src/api/useBooks.test.tsx b/src/api/useBooks.test.tsx new file mode 100644 index 0000000..d61d633 --- /dev/null +++ b/src/api/useBooks.test.tsx @@ -0,0 +1,40 @@ +import { describe, expect, it, vi } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import { useBooks } from "./useBooks"; +import { searchBooks } from "./openLibrary"; +import { createQueryClientWrapper } from "../test/renderWithQueryClient"; +import searchResponse from "../test/fixtures/searchResponse.json"; + +// Replace only searchBooks (the function that talks to the network). +// Everything else in the module, like toBook, keeps its real implementation. +vi.mock(import("./openLibrary"), async (importOriginal) => ({ + ...(await importOriginal()), + searchBooks: vi.fn(), +})); + +describe("useBooks", () => { + it("returns books mapped from the search response", async () => { + vi.mocked(searchBooks).mockResolvedValue(searchResponse); + + const { result } = renderHook( + () => useBooks({ query: "subject:fantasy" }), + { wrapper: createQueryClientWrapper() }, + ); + + expect(result.current.isPending).toBe(true); + + // waitFor retries the assertion until it passes (or times out). + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(searchBooks).toHaveBeenCalledWith({ query: "subject:fantasy" }); + expect(result.current.data).toHaveLength(5); + expect(result.current.data?.[0]).toEqual({ + id: "/works/OL8400950W", + title: "The Blade Itself", + author: ["Joe Abercrombie"], + publishedYear: 2001, + coverImage: + "https://covers.openlibrary.org/b/id/14543422-M.jpg?default=false", + }); + }); +}); From 5b275702e42a77ff9b8d6c4604b8a3888045003d Mon Sep 17 00:00:00 2001 From: Johannes Horn Date: Wed, 16 Sep 2026 21:02:56 +0200 Subject: [PATCH 03/13] test: add component and snapshot tests Component tests (props, state and user interaction): - BookCard: shows book data, calls onBookSelect when clicked - BookCover: image with alt text, or placeholder without cover - BookDetails: shows book data, calls onGoBack - FavoriteButton: toggles aria-pressed and favorites in localStorage - BookGrid: loading, error and empty states with a mocked searchBooks - App: selecting a book opens details, going back reuses cached data Snapshot tests for BookCard, Header and Footer. Refs #24 Co-Authored-By: Claude Fable 5.1 --- src/App.test.tsx | 39 ++++++++++++++ src/components/BookCard/BookCard.test.tsx | 41 +++++++++++++++ .../__snapshots__/BookCard.test.tsx.snap | 42 +++++++++++++++ src/components/BookCover/BookCover.test.tsx | 22 ++++++++ .../BookDetails/BookDetails.test.tsx | 27 ++++++++++ src/components/BookGrid/BookGrid.test.tsx | 51 +++++++++++++++++++ .../FavoriteButton/FavoriteButton.test.tsx | 43 ++++++++++++++++ src/components/Footer/Footer.test.tsx | 19 +++++++ .../Footer/__snapshots__/Footer.test.tsx.snap | 16 ++++++ src/components/Header/Header.test.tsx | 20 ++++++++ .../Header/__snapshots__/Header.test.tsx.snap | 20 ++++++++ 11 files changed, 340 insertions(+) create mode 100644 src/App.test.tsx create mode 100644 src/components/BookCard/BookCard.test.tsx create mode 100644 src/components/BookCard/__snapshots__/BookCard.test.tsx.snap create mode 100644 src/components/BookCover/BookCover.test.tsx create mode 100644 src/components/BookDetails/BookDetails.test.tsx create mode 100644 src/components/BookGrid/BookGrid.test.tsx create mode 100644 src/components/FavoriteButton/FavoriteButton.test.tsx create mode 100644 src/components/Footer/Footer.test.tsx create mode 100644 src/components/Footer/__snapshots__/Footer.test.tsx.snap create mode 100644 src/components/Header/Header.test.tsx create mode 100644 src/components/Header/__snapshots__/Header.test.tsx.snap diff --git a/src/App.test.tsx b/src/App.test.tsx new file mode 100644 index 0000000..6c3dd58 --- /dev/null +++ b/src/App.test.tsx @@ -0,0 +1,39 @@ +import { describe, expect, it, vi } from "vitest"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { App } from "./App"; +import { searchBooks } from "./api/openLibrary"; +import { renderWithQueryClient } from "./test/renderWithQueryClient"; +import searchResponse from "./test/fixtures/searchResponse.json"; + +vi.mock(import("./api/openLibrary"), async (importOriginal) => ({ + ...(await importOriginal()), + searchBooks: vi.fn(), +})); + +describe("App", () => { + it("opens a book's details from the grid and goes back", async () => { + vi.mocked(searchBooks).mockResolvedValue(searchResponse); + const user = userEvent.setup(); + renderWithQueryClient(); + + await user.click( + await screen.findByRole("button", { + name: "View details for The Blade Itself", + }), + ); + + expect( + screen.getByRole("heading", { name: "The Blade Itself" }), + ).toBeInTheDocument(); + expect(screen.queryByRole("list")).not.toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Go back" })); + + expect(screen.getByRole("list")).toBeInTheDocument(); + expect(screen.getAllByRole("article")).toHaveLength(5); + // The test client uses the same staleTime as main.tsx, so going back reuses + // the cached result instead of calling the API again. + expect(searchBooks).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/BookCard/BookCard.test.tsx b/src/components/BookCard/BookCard.test.tsx new file mode 100644 index 0000000..644982d --- /dev/null +++ b/src/components/BookCard/BookCard.test.tsx @@ -0,0 +1,41 @@ +import { describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { BookCard } from "./BookCard"; +import { bookWithCover as book } from "../../test/fixtures/books"; + +describe("BookCard", () => { + it("shows title, authors and year from the book prop", () => { + render( {}} />); + + expect( + screen.getByRole("heading", { name: "The Blade Itself" }), + ).toBeInTheDocument(); + expect(screen.getByText("Joe Abercrombie")).toBeInTheDocument(); + expect(screen.getByText("2001")).toBeInTheDocument(); + }); + + it("calls onBookSelect with the book when clicked", async () => { + const user = userEvent.setup(); + const onBookSelect = vi.fn(); + render(); + + await user.click( + screen.getByRole("button", { name: "View details for The Blade Itself" }), + ); + + expect(onBookSelect).toHaveBeenCalledTimes(1); + expect(onBookSelect).toHaveBeenCalledWith(book); + }); + + // Snapshot test: the rendered HTML is saved in __snapshots__/ the first time + // the test runs. Later runs fail if the markup changes unexpectedly. + // Run `pnpm test -u` to update the snapshot when a change is intended. + it("matches the snapshot", () => { + const { container } = render( + {}} />, + ); + + expect(container).toMatchSnapshot(); + }); +}); diff --git a/src/components/BookCard/__snapshots__/BookCard.test.tsx.snap b/src/components/BookCard/__snapshots__/BookCard.test.tsx.snap new file mode 100644 index 0000000..42239e8 --- /dev/null +++ b/src/components/BookCard/__snapshots__/BookCard.test.tsx.snap @@ -0,0 +1,42 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`BookCard > matches the snapshot 1`] = ` +
+
+ + +
+
+`; diff --git a/src/components/BookCover/BookCover.test.tsx b/src/components/BookCover/BookCover.test.tsx new file mode 100644 index 0000000..015b41f --- /dev/null +++ b/src/components/BookCover/BookCover.test.tsx @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { BookCover } from "./BookCover"; +import { bookWithCover, bookWithoutCover } from "../../test/fixtures/books"; + +describe("BookCover", () => { + it("renders the cover image with a descriptive alt text", () => { + render(); + + const image = screen.getByRole("img", { + name: "Book cover of The Blade Itself", + }); + expect(image).toHaveAttribute("src", bookWithCover.coverImage); + }); + + it("renders a placeholder when the book has no cover", () => { + render(); + + expect(screen.queryByRole("img")).not.toBeInTheDocument(); + expect(screen.getByText("No cover available")).toBeInTheDocument(); + }); +}); diff --git a/src/components/BookDetails/BookDetails.test.tsx b/src/components/BookDetails/BookDetails.test.tsx new file mode 100644 index 0000000..f778c76 --- /dev/null +++ b/src/components/BookDetails/BookDetails.test.tsx @@ -0,0 +1,27 @@ +import { describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { BookDetails } from "./BookDetails"; +import { bookWithCover as book } from "../../test/fixtures/books"; + +describe("BookDetails", () => { + it("shows title, authors and year from the book prop", () => { + render( {}} />); + + expect( + screen.getByRole("heading", { name: "The Blade Itself" }), + ).toBeInTheDocument(); + expect(screen.getByText("Joe Abercrombie")).toBeInTheDocument(); + expect(screen.getByText("2001")).toBeInTheDocument(); + }); + + it("calls onGoBack when the back button is clicked", async () => { + const user = userEvent.setup(); + const onGoBack = vi.fn(); + render(); + + await user.click(screen.getByRole("button", { name: "Go back" })); + + expect(onGoBack).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/BookGrid/BookGrid.test.tsx b/src/components/BookGrid/BookGrid.test.tsx new file mode 100644 index 0000000..1e19b75 --- /dev/null +++ b/src/components/BookGrid/BookGrid.test.tsx @@ -0,0 +1,51 @@ +import { describe, expect, it, vi } from "vitest"; +import { screen } from "@testing-library/react"; +import { BookGrid } from "./BookGrid"; +import { searchBooks } from "../../api/openLibrary"; +import { renderWithQueryClient } from "../../test/renderWithQueryClient"; +import searchResponse from "../../test/fixtures/searchResponse.json"; + +// Mock the API call so the component gets fixture data instead of fetching. +vi.mock(import("../../api/openLibrary"), async (importOriginal) => ({ + ...(await importOriginal()), + searchBooks: vi.fn(), +})); + +describe("BookGrid", () => { + it("shows a loading message and then one card per book", async () => { + vi.mocked(searchBooks).mockResolvedValue(searchResponse); + + renderWithQueryClient( {}} />); + + expect(screen.getByRole("status")).toHaveTextContent("Loading books..."); + + // findBy* waits until the element appears, i.e. when the mocked request resolves. + expect(await screen.findByText("The Blade Itself")).toBeInTheDocument(); + expect(screen.getAllByRole("article")).toHaveLength(5); + expect(searchBooks).toHaveBeenCalledTimes(1); + }); + + it("shows an error message when the request fails", async () => { + vi.mocked(searchBooks).mockRejectedValue( + new Error("Open Library search failed with status 500"), + ); + + renderWithQueryClient( {}} />); + + expect( + await screen.findByText( + "Error: Open Library search failed with status 500", + ), + ).toBeInTheDocument(); + }); + + it("shows a message when no books match the search", async () => { + vi.mocked(searchBooks).mockResolvedValue({ numFound: 0, docs: [] }); + + renderWithQueryClient( {}} />); + + expect( + await screen.findByText("No books found for this search"), + ).toBeInTheDocument(); + }); +}); diff --git a/src/components/FavoriteButton/FavoriteButton.test.tsx b/src/components/FavoriteButton/FavoriteButton.test.tsx new file mode 100644 index 0000000..f31e7cc --- /dev/null +++ b/src/components/FavoriteButton/FavoriteButton.test.tsx @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { FavoriteButton } from "./FavoriteButton"; +import { bookWithCover as book } from "../../test/fixtures/books"; + +describe("FavoriteButton", () => { + it("is not pressed when the book is not a favorite", () => { + render(); + + const button = screen.getByRole("button", { + name: "Add The Blade Itself to favorites", + }); + expect(button).toHaveAttribute("aria-pressed", "false"); + }); + + it("adds and removes the book in localStorage when clicked", async () => { + const user = userEvent.setup(); + render(); + const button = screen.getByRole("button"); + + await user.click(button); + + expect(button).toHaveAttribute("aria-pressed", "true"); + expect(button).toHaveAccessibleName( + "Remove The Blade Itself from favorites", + ); + expect(JSON.parse(localStorage.getItem("favorites")!)).toEqual([book.id]); + + await user.click(button); + + expect(button).toHaveAttribute("aria-pressed", "false"); + expect(JSON.parse(localStorage.getItem("favorites")!)).toEqual([]); + }); + + it("reads favorites saved in localStorage when it mounts", () => { + localStorage.setItem("favorites", JSON.stringify([book.id])); + + render(); + + expect(screen.getByRole("button")).toHaveAttribute("aria-pressed", "true"); + }); +}); diff --git a/src/components/Footer/Footer.test.tsx b/src/components/Footer/Footer.test.tsx new file mode 100644 index 0000000..864463d --- /dev/null +++ b/src/components/Footer/Footer.test.tsx @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { Footer } from "./Footer"; + +describe("Footer", () => { + it("names the course, group and project", () => { + render(