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" } },
+ },
});