diff --git a/README.md b/README.md index e4a5f7e..374ca39 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,82 @@ In order to prepare a production-ready version of the app, run: pnpm build # or: pnpm run build ``` +## Testing + +Tests are written with [Vitest](https://vitest.dev/) and +[React Testing Library](https://testing-library.com/docs/react-testing-library/intro/), +and run in a simulated browser DOM ([jsdom](https://github.com/jsdom/jsdom)). + +Run all tests once: + +```sh +pnpm test +``` + +Run tests in watch mode while developing: + +```sh +pnpm test:watch +``` + +Test files live next to the code they test (`*.test.ts` / `*.test.tsx`). +Shared helpers and fixtures live in `src/test/`: + +- `setup.ts` adds the [jest-dom](https://github.com/testing-library/jest-dom) + matchers (e.g. `toBeInTheDocument`) and clears the DOM and `localStorage` + after every test. +- `renderWithQueryClient.tsx` renders components inside a + `QueryClientProvider`, which components that use `useBooks` need. +- `fixtures/` holds saved Open Library responses (a search and a work) and a + sample book. + +### What is tested + +We test where there is logic, state or several parts working together. Static +markup (like `Header` and `Footer`) is not tested: such tests break on every +intended change without catching real bugs. + +- **Unit tests** for the API layer (`src/api/openLibrary.test.ts`): `toBook` + and `toDescription` map Open Library data to our types (with fallbacks for + missing fields), and `searchBooks` and `fetchWork` build the right request + and throw on HTTP errors. +- **Component tests** for `BookCard`, `BookDetails`, `FavoriteButton`, + `Pagination`, `SearchBar` and `BookGrid`: rendering based on props, user + interaction (clicks, typing) and state (favorites in `localStorage`; loading, + error and empty states; paging; loading the description). +- **Snapshot test** for `BookCard`. The rendered HTML is stored in + `__snapshots__/` next to the test and compared on every run. If a markup + change is intended, update the snapshot with `pnpm test -u`. +- **Integration test** for `App`: selecting a book shows its details and + description, and going back shows the grid again without a new API request. + +### Mocking + +No test sends requests to openlibrary.org: + +- `openLibrary.test.ts` replaces the global `fetch` with a mock (`vi.spyOn`) + that answers with the saved responses in `src/test/fixtures/`. +- The `App` and `BookDetails` tests replace `searchBooks` and `fetchWork` + with mocks (`vi.mock`), so the components receive the fixture data + (`searchResponse.json`, `workResponse.json`) right away. +- `BookGrid` does not fetch anything itself; its tests pass a fake query + result in as a prop. + +### Browsers and devices + +The application is checked manually in the browsers and screen sizes below. +The grid should show 4/3/2/1 columns as the viewport gets narrower, and the +details view should stack vertically below 900px. + +| Browser / device | Viewport | Result | Date | +| --------------------------------------- | ----------------------------- | ------------------------------------------------------------------------------------------------------------- | ---------- | +| Chromium (Claude Code built-in browser) | 1280 × 800 (desktop) | OK: 4-column grid, details side by side | 2026-09-16 | +| Chromium (Claude Code built-in browser) | 768 × 1024 (tablet, emulated) | OK: 2-column grid | 2026-09-16 | +| Chromium (Claude Code built-in browser) | 375 × 812 (phone, emulated) | Grid and details stack in one column. **Bug:** the header title is cut off by the "Mine favorittbøker" button | 2026-09-16 | +| Safari (macOS) | | Not yet tested | | +| Firefox | | Not yet tested | | +| iPhone / Android (real device) | | Not yet tested | | + ## Working in this repository `main` is protected — you cannot push to it directly. Work on a branch and 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/App.test.tsx b/src/App.test.tsx new file mode 100644 index 0000000..3cf8e3b --- /dev/null +++ b/src/App.test.tsx @@ -0,0 +1,45 @@ +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 { fetchWork, searchBooks } from "./api/openLibrary"; +import { renderWithQueryClient } from "./test/renderWithQueryClient"; +import searchResponse from "./test/fixtures/searchResponse.json"; +import workResponse from "./test/fixtures/workResponse.json"; + +// Mock both API functions so nothing is fetched from openlibrary.org. +vi.mock(import("./api/openLibrary"), async (importOriginal) => ({ + ...(await importOriginal()), + searchBooks: vi.fn(), + fetchWork: vi.fn(), +})); + +describe("App", () => { + it("opens a book's details from the grid and goes back", async () => { + vi.mocked(searchBooks).mockResolvedValue(searchResponse); + vi.mocked(fetchWork).mockResolvedValue(workResponse); + 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(await screen.findByText(/Logen Ninefingers/)).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); + expect(fetchWork).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/api/openLibrary.test.ts b/src/api/openLibrary.test.ts new file mode 100644 index 0000000..8540c03 --- /dev/null +++ b/src/api/openLibrary.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it, vi } from "vitest"; +import { searchBooks, toBook, fetchWork, toDescription } from "./openLibrary"; +import type { OpenLibraryDoc, OpenLibraryWork } from "./types"; +import searchResponse from "../test/fixtures/searchResponse.json"; +import workResponse from "../test/fixtures/workResponse.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("toDescription", () => { + it("returns the description when it is a value property", () => { + const work: OpenLibraryWork = { + key: "/works/OL8400950W", + title: "The Blade Itself", + description: { + type: "/type/text", + value: "This is a description from a value property", + }, + }; + expect(toDescription(work)).toBe( + "This is a description from a value property", + ); + }); + + it("returns the description when it is a string", () => { + const work: OpenLibraryWork = { + key: "/works/OL8400950W", + title: "The Blade Itself", + description: "This is a description from a string", + }; + expect(toDescription(work)).toBe("This is a description from a string"); + }); + + it("returns undefined when the description is missing", () => { + const work: OpenLibraryWork = { + key: "/works/OL8400950W", + title: "The Blade Itself", + }; + expect(toDescription(work)).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("adds lang and page defaults, and sort and limit only when given", async () => { + // A Response body can only be read once, so create a new one per call. + const fetchMock = vi + .spyOn(globalThis, "fetch") + .mockImplementation(async () => Response.json(searchResponse)); + + await searchBooks({ query: "subject:fantasy" }); + const defaults = new URL(String(fetchMock.mock.calls[0][0])); + expect(defaults.searchParams.get("lang")).toBe("en"); + expect(defaults.searchParams.get("page")).toBe("1"); + expect(defaults.searchParams.has("sort")).toBe(false); + + await searchBooks({ + query: "subject:fantasy", + page: 3, + limit: 5, + sort: "new", + }); + const custom = new URL(String(fetchMock.mock.calls[1][0])); + expect(custom.searchParams.get("page")).toBe("3"); + expect(custom.searchParams.get("limit")).toBe("5"); + expect(custom.searchParams.get("sort")).toBe("new"); + }); + + 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", + ); + }); +}); + +describe("fetchWork", () => { + it("requests the work endpoint with the given key", async () => { + const fetchMock = vi + .spyOn(globalThis, "fetch") + .mockResolvedValue(Response.json(workResponse)); + + const data = await fetchWork("/works/OL8400950W"); + expect(String(fetchMock.mock.calls[0][0])).toBe( + "https://openlibrary.org/works/OL8400950W.json", + ); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(data).toEqual(workResponse); + }); + + it("throws when the API answers with an error status", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(null, { status: 429 }), + ); + + await expect(fetchWork("/works/OL8400950W")).rejects.toThrow( + "Failed to fetch work /works/OL8400950W with status 429", + ); + }); +}); 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/BookDetails/BookDetails.test.tsx b/src/components/BookDetails/BookDetails.test.tsx new file mode 100644 index 0000000..d7af8f2 --- /dev/null +++ b/src/components/BookDetails/BookDetails.test.tsx @@ -0,0 +1,52 @@ +import { describe, expect, it, vi } from "vitest"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { BookDetails } from "./BookDetails"; +import { fetchWork } from "../../api/openLibrary"; +import { renderWithQueryClient } from "../../test/renderWithQueryClient"; +import { bookWithCover as book } from "../../test/fixtures/books"; +import workResponse from "../../test/fixtures/workResponse.json"; + +// BookDetails loads the description through useBookDescription -> fetchWork. +// Mock fetchWork so the component gets the saved work from the fixture. +vi.mock(import("../../api/openLibrary"), async (importOriginal) => ({ + ...(await importOriginal()), + fetchWork: vi.fn(), +})); + +describe("BookDetails", () => { + it("shows the description once it has loaded", async () => { + vi.mocked(fetchWork).mockResolvedValue(workResponse); + + renderWithQueryClient( + {}} + onBookSelect={() => {}} + />, + ); + + expect(screen.getByText("Loading description...")).toBeInTheDocument(); + expect(await screen.findByText(/Logen Ninefingers/)).toBeInTheDocument(); + expect(fetchWork).toHaveBeenCalledWith(book.id); + }); + + it("calls onGoBack when the back button is clicked", async () => { + vi.mocked(fetchWork).mockResolvedValue(workResponse); + const user = userEvent.setup(); + const onGoBack = vi.fn(); + renderWithQueryClient( + {}} + />, + ); + + 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..106aa8a --- /dev/null +++ b/src/components/BookGrid/BookGrid.test.tsx @@ -0,0 +1,102 @@ +import type { ComponentProps } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { BookGrid } from "./BookGrid"; +import { toBook } from "../../api/openLibrary"; +import { renderWithQueryClient } from "../../test/renderWithQueryClient"; +import searchResponse from "../../test/fixtures/searchResponse.json"; +import { useBooks } from "../../api/useBooks"; + +// Helper to create mock booksQuery results +function createMockBooksQuery(overrides = {}) { + return { + data: { numFound: 118610, books: searchResponse.docs.map(toBook) }, + isPending: false, + isError: false, + error: null, + ...overrides, + } as ReturnType; +} + +// Renders BookGrid with sensible defaults; tests override only what they need. +function renderBookGrid(props: Partial> = {}) { + return renderWithQueryClient( + {}} + onPageChange={() => {}} + onSearchChange={() => {}} + search={{ + query: "", + }} + booksQuery={createMockBooksQuery()} + {...props} + />, + ); +} + +describe("BookGrid", () => { + it("shows a loading message when data is pending", () => { + renderBookGrid({ + booksQuery: createMockBooksQuery({ isPending: true }), + }); + + expect(screen.getByRole("status")).toHaveTextContent("Loading books..."); + }); + + it("shows one card per book when data loads", () => { + renderBookGrid({ + booksQuery: createMockBooksQuery({ isPending: false }), + }); + + expect(screen.getByText("The Blade Itself")).toBeInTheDocument(); + expect(screen.getAllByRole("article")).toHaveLength(5); + }); + + it("shows the correct page count", () => { + renderBookGrid({ page: 3 }); + + // 118610 books in the fixture / 12 per page = 9885 pages + expect(screen.getByText("3 of 9885")).toBeInTheDocument(); + }); + + it("calls onPageChange when the Next button is clicked", async () => { + // jsdom cannot scroll; replace window.scrollTo so Pagination does not warn. + vi.spyOn(window, "scrollTo").mockImplementation(() => {}); + const user = userEvent.setup(); + const onPageChange = vi.fn(); + + renderBookGrid({ onPageChange }); + + await user.click(screen.getByRole("button", { name: "Next" })); + + expect(onPageChange).toHaveBeenCalledWith(2); + }); + + it("shows an error message when an error occurs", () => { + renderBookGrid({ + booksQuery: createMockBooksQuery({ + isPending: false, + isError: true, + error: new Error("Open Library search failed with status 500"), + }), + }); + + expect( + screen.getByText("Error: Open Library search failed with status 500"), + ).toBeInTheDocument(); + }); + + it("shows a message when no books match the search", () => { + renderBookGrid({ + booksQuery: createMockBooksQuery({ + data: { numFound: 0, books: [] }, + }), + }); + + expect( + screen.getByText("No books found for this search"), + ).toBeInTheDocument(); + }); +}); diff --git a/src/components/BookGrid/useDebounce.ts b/src/components/BookGrid/useDebounce.ts index 0d94079..123ac19 100644 --- a/src/components/BookGrid/useDebounce.ts +++ b/src/components/BookGrid/useDebounce.ts @@ -3,19 +3,19 @@ import { useEffect, useState } from "react"; // Debounce pattern largely adapted from this Stack Overflow post: // https://stackoverflow.com/questions/75556418/how-to-use-debounce-hooks-in-react function useDebounce(value: T, delay: number): T { - const [debouncedValue, setDebouncedValue] = useState(value); + const [debouncedValue, setDebouncedValue] = useState(value); - useEffect(() => { - const timeoutId = window.setTimeout(() => { - setDebouncedValue(value); - }, delay); + useEffect(() => { + const timeoutId = window.setTimeout(() => { + setDebouncedValue(value); + }, delay); - return () => { - window.clearTimeout(timeoutId); - }; - }, [value, delay]); + return () => { + window.clearTimeout(timeoutId); + }; + }, [value, delay]); - return debouncedValue; + return debouncedValue; } export { useDebounce }; 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/Pagination/Pagination.test.tsx b/src/components/Pagination/Pagination.test.tsx new file mode 100644 index 0000000..6958340 --- /dev/null +++ b/src/components/Pagination/Pagination.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 { Pagination } from "./Pagination"; + +describe("Pagination", () => { + it("disables Previous on the first page and Next on the last page", () => { + const { rerender } = render( + {}} />, + ); + + expect(screen.getByRole("button", { name: "Previous" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Next" })).toBeEnabled(); + + // rerender updates the props of the component that is already rendered. + rerender( + {}} />, + ); + + expect(screen.getByRole("button", { name: "Previous" })).toBeEnabled(); + expect(screen.getByRole("button", { name: "Next" })).toBeDisabled(); + }); + + it("calls onPageChange with the next or previous page and scrolls to the top", async () => { + // jsdom cannot scroll; replace window.scrollTo so it does not warn. + const scrollTo = vi.spyOn(window, "scrollTo").mockImplementation(() => {}); + const user = userEvent.setup(); + const onPageChange = vi.fn(); + render( + , + ); + + await user.click(screen.getByRole("button", { name: "Next" })); + expect(onPageChange).toHaveBeenCalledWith(4); + + await user.click(screen.getByRole("button", { name: "Previous" })); + expect(onPageChange).toHaveBeenCalledWith(2); + + expect(scrollTo).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/components/SearchBar/SearchBar.test.tsx b/src/components/SearchBar/SearchBar.test.tsx new file mode 100644 index 0000000..6f33a5f --- /dev/null +++ b/src/components/SearchBar/SearchBar.test.tsx @@ -0,0 +1,37 @@ +import { describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { SearchBar } from "./SearchBar"; + +describe("SearchBar", () => { + it("shows the query it is given", () => { + render( + {}} + onFilterChange={() => {}} + />, + ); + + expect( + screen.getByRole("searchbox", { name: "Search for books" }), + ).toHaveValue("The Blade Itself"); + }); + + it("calls onQueryChange with the new value when the user types", async () => { + const user = userEvent.setup(); + const onQueryChange = vi.fn(); + render( + {}} + />, + ); + + await user.type(screen.getByRole("searchbox"), "T"); + + expect(onQueryChange).toHaveBeenCalledTimes(1); + expect(onQueryChange).toHaveBeenCalledWith("T"); + }); +}); diff --git a/src/test/fixtures/books.ts b/src/test/fixtures/books.ts new file mode 100644 index 0000000..0011013 --- /dev/null +++ b/src/test/fixtures/books.ts @@ -0,0 +1,14 @@ +import type { Book } from "../../api/types"; + +// Sample book for component tests. Matches the first doc in searchResponse.json +// and the work in workResponse.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", +}; + +export { bookWithCover }; 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..69ba838 --- /dev/null +++ b/src/test/setup.ts @@ -0,0 +1,15 @@ +// 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 and App reads filters + // from sessionStorage; every test starts clean. + localStorage.clear(); + sessionStorage.clear(); +}); diff --git a/vite.config.ts b/vite.config.ts index 9456693..4fd7442 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,6 +1,6 @@ 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({ @@ -11,4 +11,16 @@ export default defineConfig({ localsConvention: "camelCaseOnly", }, }, + // 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, + // CSS is not processed in tests. CSS module keys are returned as written in + // the component (styles.bookCard -> "bookCard") so snapshots stay readable. + css: { modules: { classNameStrategy: "non-scoped" } }, + }, });