From 7ea8eac44574c6d2246e1528c1bd68548e52ec4e Mon Sep 17 00:00:00 2001 From: Aari Date: Sun, 26 Jul 2026 07:44:40 +0800 Subject: [PATCH] test(frontend): run hook-level tests in a DOM environment (#4453) Frontend unit tests run in a plain node environment, so nothing that renders can be covered and no hook can be tested under real React. The workaround that forces is already in the tree: the use-global-shortcuts test mocks out react itself, so its useEffect never re-runs on a dependency change and never goes through React's commit/cleanup ordering -- it pins the stub rather than the hook. Split the suite into two rstest projects: *.test.ts(x) keeps running on node, *.dom.test.ts(x) runs on happy-dom. A global DOM environment was measured first and rejected -- it takes the same 743 tests from 3.3s to 9.5s -- and rstest has no per-file environment docblock, so projects is the only way to charge that cost to the tests that need it. useIsMobile is the first consumer: it had no tests and cannot have any without a document, since it reads window.matchMedia. --- frontend/AGENTS.md | 2 + frontend/package.json | 3 + frontend/pnpm-lock.yaml | 166 +++++++++++++++++- frontend/rstest.config.ts | 25 ++- .../tests/unit/hooks/use-mobile.dom.test.ts | 82 +++++++++ 5 files changed, 274 insertions(+), 4 deletions(-) create mode 100644 frontend/tests/unit/hooks/use-mobile.dom.test.ts diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 19944de56..ca6c2ca3c 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -32,6 +32,8 @@ DeerFlow Frontend is a Next.js 16 web interface for an AI agent system. It commu Unit tests live under `tests/unit/` and mirror the `src/` layout (e.g., `tests/unit/core/api/stream-mode.test.ts` tests `src/core/api/stream-mode.ts`). Powered by Rstest; import source modules via the `@/` path alias. +Rstest runs them as two projects (`rstest.config.ts`). `*.test.ts` / `*.test.tsx` run in a plain **node** environment — that is nearly the whole suite, and it is the default for anything that is pure logic. `*.dom.test.ts` / `*.dom.test.tsx` run in **happy-dom**, for tests that need a document: hooks driven through `renderHook` from `@testing-library/react`, and components. Keep the split — a DOM environment costs roughly 3x the runtime of the node suite, so tests that do not render should not opt into it. A hook whose behavior only exists under real React (effect ordering, cleanup on unmount, re-render on store change) belongs in a `.dom.test.*` file rather than a node test that mocks `react` itself. + E2E tests live under `tests/e2e/` and use Playwright with Chromium. They mock all backend APIs via `page.route()` network interception and test real page interactions (navigation, chat input, streaming responses). Config: `playwright.config.ts`. ## Architecture diff --git a/frontend/package.json b/frontend/package.json index f0297280c..bbb8e579a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -102,12 +102,15 @@ "@rsbuild/plugin-react": "^2.1.0", "@rstest/core": "^0.10.6", "@tailwindcss/postcss": "^4.0.15", + "@testing-library/dom": "^10.4.1", + "@testing-library/react": "^16.3.2", "@types/gsap": "^3.0.0", "@types/node": "^20.14.10", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "eslint": "^9.23.0", "eslint-config-next": "^15.2.3", + "happy-dom": "^20.11.1", "postcss": "^8.5.3", "prettier": "^3.5.3", "prettier-plugin-tailwindcss": "^0.6.11", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index a79934bda..6922567d9 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -248,10 +248,16 @@ importers: version: 2.1.0(@rsbuild/core@2.0.15)(@rspack/core@2.0.8(@swc/helpers@0.5.23)) '@rstest/core': specifier: ^0.10.6 - version: 0.10.6 + version: 0.10.6(happy-dom@20.11.1) '@tailwindcss/postcss': specifier: ^4.0.15 version: 4.1.18 + '@testing-library/dom': + specifier: ^10.4.1 + version: 10.4.1 + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@types/gsap': specifier: ^3.0.0 version: 3.0.0 @@ -270,6 +276,9 @@ importers: eslint-config-next: specifier: ^15.2.3 version: 15.5.12(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + happy-dom: + specifier: ^20.11.1 + version: 20.11.1 postcss: specifier: ^8.5.3 version: 8.5.6 @@ -317,6 +326,10 @@ packages: '@antfu/install-pkg@1.1.0': resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.29.7': resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} engines: {node: '>=6.9.0'} @@ -2207,6 +2220,25 @@ packages: '@tanstack/virtual-core@3.13.23': resolution: {integrity: sha512-zSz2Z2HNyLjCplANTDyl3BcdQJc2k1+yyFoKhNRmCr7V7dY8o8q5m8uFTI1/Pg1kL+Hgrz6u3Xo6eFUB7l66cg==} + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + 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 + '@theguild/remark-mermaid@0.3.0': resolution: {integrity: sha512-Fy1J4FSj8totuHsHFpaeWyWRaRSIvpzGTRoEfnNJc1JmLV9uV70sYE3zcT+Jj5Yw20Xq4iCsiT+3Ho49BBZcBQ==} peerDependencies: @@ -2233,6 +2265,9 @@ packages: '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -2398,6 +2433,12 @@ packages: '@types/uuid@10.0.0': resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==} + '@types/whatwg-mimetype@3.0.2': + resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} + + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@typescript-eslint/eslint-plugin@8.55.0': resolution: {integrity: sha512-1y/MVSz0NglV1ijHC8OT49mPJ4qhPYjiK08YUQVbIOyu+5k862LKUHFkpKHWu//zmr7hDR2rhwUm6gnCGNmGBQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2683,6 +2724,10 @@ packages: ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} @@ -2705,6 +2750,9 @@ packages: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} 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'} @@ -2815,6 +2863,10 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} + buffer-image-size@0.6.4: + resolution: {integrity: sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==} + engines: {node: '>=4.0'} + c12@3.3.3: resolution: {integrity: sha512-750hTRvgBy5kcMNPdh95Qo+XUBeGo8C7nsKSmedDmaQI+E0r82DwHeM6vBewDe4rGFbnxoa4V9pw+sPh5+Iz8Q==} peerDependencies: @@ -3249,6 +3301,9 @@ packages: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + dompurify@3.3.1: resolution: {integrity: sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==} @@ -3690,6 +3745,10 @@ packages: hachure-fill@0.5.2: resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} + happy-dom@20.11.1: + resolution: {integrity: sha512-XSt8tMzbW9ymE7687xztkO1ckR7qJNQ3LywY9vlYGhGi3zXrGBHuUo2Cl1ztZaICW+1eAGdkLbj6iwVqDT33kg==} + engines: {node: '>=20.0.0'} + has-bigints@1.1.0: resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} engines: {node: '>= 0.4'} @@ -4204,6 +4263,10 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -4871,6 +4934,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} + pretty-ms@9.3.0: resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} engines: {node: '>=18'} @@ -4910,6 +4977,9 @@ packages: react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-medium-image-zoom@5.4.1: resolution: {integrity: sha512-DD2iZYaCfAwiQGR8AN62r/cDJYoXhezlYJc5HY4TzBUGuGge43CptG0f7m0PEIM72aN6GfpjohvY1yYdtCJB7g==} peerDependencies: @@ -5736,6 +5806,10 @@ packages: webpack-virtual-modules@0.6.2: resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -5764,6 +5838,18 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + ws@8.21.1: + resolution: {integrity: sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + yaml@2.8.3: resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==} engines: {node: '>= 14.6'} @@ -5852,6 +5938,12 @@ snapshots: package-manager-detector: 1.6.0 tinyexec: 1.0.2 + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + '@babel/helper-string-parser@7.29.7': {} '@babel/helper-validator-identifier@7.29.7': {} @@ -7540,10 +7632,12 @@ snapshots: optionalDependencies: '@rspack/core': 2.0.8(@swc/helpers@0.5.23) - '@rstest/core@0.10.6': + '@rstest/core@0.10.6(happy-dom@20.11.1)': dependencies: '@rsbuild/core': 2.0.15 '@types/chai': 5.2.3 + optionalDependencies: + happy-dom: 20.11.1 transitivePeerDependencies: - '@module-federation/runtime-tools' - core-js @@ -7754,6 +7848,27 @@ snapshots: '@tanstack/virtual-core@3.13.23': {} + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.28.6 + '@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/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@babel/runtime': 7.28.6 + '@testing-library/dom': 10.4.1 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.13 + '@types/react-dom': 19.2.3(@types/react@19.2.13) + '@theguild/remark-mermaid@0.3.0(react@19.2.4)': dependencies: mermaid: 11.12.2 @@ -7791,6 +7906,8 @@ snapshots: tslib: 2.8.1 optional: true + '@types/aria-query@5.0.4': {} + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -7976,6 +8093,12 @@ snapshots: '@types/uuid@10.0.0': {} + '@types/whatwg-mimetype@3.0.2': {} + + '@types/ws@8.18.1': + dependencies: + '@types/node': 20.19.33 + '@typescript-eslint/eslint-plugin@8.55.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -8315,6 +8438,8 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ansi-regex@5.0.1: {} + ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 @@ -8334,6 +8459,10 @@ snapshots: dependencies: tslib: 2.8.1 + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + aria-query@5.3.2: {} array-buffer-byte-length@1.0.2: @@ -8457,6 +8586,10 @@ snapshots: dependencies: fill-range: 7.1.1 + buffer-image-size@0.6.4: + dependencies: + '@types/node': 20.19.33 + c12@3.3.3: dependencies: chokidar: 5.0.0 @@ -8915,6 +9048,8 @@ snapshots: dependencies: esutils: 2.0.3 + dom-accessibility-api@0.5.16: {} + dompurify@3.3.1: optionalDependencies: '@types/trusted-types': 2.0.7 @@ -9561,6 +9696,19 @@ snapshots: hachure-fill@0.5.2: {} + happy-dom@20.11.1: + dependencies: + '@types/node': 20.19.33 + '@types/whatwg-mimetype': 3.0.2 + '@types/ws': 8.18.1 + buffer-image-size: 0.6.4 + entities: 7.0.1 + whatwg-mimetype: 3.0.0 + ws: 8.21.1 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + has-bigints@1.1.0: {} has-flag@4.0.0: {} @@ -10113,6 +10261,8 @@ snapshots: dependencies: react: 19.2.4 + lz-string@1.5.0: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -11121,6 +11271,12 @@ snapshots: prettier@3.8.1: {} + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + pretty-ms@9.3.0: dependencies: parse-ms: 4.0.0 @@ -11160,6 +11316,8 @@ snapshots: react-is@16.13.1: {} + react-is@17.0.2: {} + react-medium-image-zoom@5.4.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: react: 19.2.4 @@ -12196,6 +12354,8 @@ snapshots: webpack-virtual-modules@0.6.2: {} + whatwg-mimetype@3.0.0: {} + which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0 @@ -12245,6 +12405,8 @@ snapshots: word-wrap@1.2.5: {} + ws@8.21.1: {} + yaml@2.8.3: {} yocto-queue@0.1.0: {} diff --git a/frontend/rstest.config.ts b/frontend/rstest.config.ts index 1e9cded4d..a67b270ff 100644 --- a/frontend/rstest.config.ts +++ b/frontend/rstest.config.ts @@ -3,7 +3,9 @@ import { resolve } from "path"; import { pluginReact } from "@rsbuild/plugin-react"; import { defineConfig } from "@rstest/core"; -export default defineConfig({ +// Build wiring is identical across projects; only the environment and which +// files each one claims differ. +const shared = { plugins: [pluginReact()], resolve: { alias: { @@ -15,5 +17,24 @@ export default defineConfig({ // Rsbuild processes that CSS import instead of Node trying to load it. bundleDependencies: ["streamdown", "katex"], }, - include: ["tests/unit/**/*.test.ts"], +}; + +export default defineConfig({ + projects: [ + { + ...shared, + name: "node", + include: ["tests/unit/**/*.test.ts", "tests/unit/**/*.test.tsx"], + // A DOM environment costs roughly 3x the runtime of this suite, so the + // pure-logic tests that make up nearly all of it stay on node and only + // `*.dom.test.*` pays for a document. + exclude: { patterns: ["**/*.dom.test.*"], override: false }, + }, + { + ...shared, + name: "dom", + testEnvironment: "happy-dom", + include: ["tests/unit/**/*.dom.test.ts", "tests/unit/**/*.dom.test.tsx"], + }, + ], }); diff --git a/frontend/tests/unit/hooks/use-mobile.dom.test.ts b/frontend/tests/unit/hooks/use-mobile.dom.test.ts new file mode 100644 index 000000000..f2ef77040 --- /dev/null +++ b/frontend/tests/unit/hooks/use-mobile.dom.test.ts @@ -0,0 +1,82 @@ +import { afterEach, describe, expect, it } from "@rstest/core"; +import { act, cleanup, renderHook } from "@testing-library/react"; + +import { useIsMobile } from "@/hooks/use-mobile"; + +type HappyDOMWindow = typeof window & { + happyDOM: { setViewport: (viewport: { width?: number }) => void }; +}; + +function setViewportWidth(width: number) { + (window as HappyDOMWindow).happyDOM.setViewport({ width }); +} + +afterEach(() => { + cleanup(); + setViewportWidth(1024); +}); + +describe("useIsMobile", () => { + it("reports the viewport it mounted into", () => { + setViewportWidth(1024); + expect(renderHook(() => useIsMobile()).result.current).toBe(false); + cleanup(); + + setViewportWidth(500); + expect(renderHook(() => useIsMobile()).result.current).toBe(true); + }); + + it("re-renders when the viewport crosses the breakpoint", () => { + setViewportWidth(1024); + const { result } = renderHook(() => useIsMobile()); + expect(result.current).toBe(false); + + act(() => setViewportWidth(500)); + expect(result.current).toBe(true); + + act(() => setViewportWidth(1024)); + expect(result.current).toBe(false); + }); + + it("switches at 768px", () => { + setViewportWidth(767); + expect(renderHook(() => useIsMobile()).result.current).toBe(true); + cleanup(); + + setViewportWidth(768); + expect(renderHook(() => useIsMobile()).result.current).toBe(false); + }); + + it("drops its media-query listener on unmount", () => { + // The subscribe callback creates its own MediaQueryList, so the only way to + // observe the leak is to count through the factory it calls. + const nativeMatchMedia = window.matchMedia.bind(window); + let added = 0; + let removed = 0; + window.matchMedia = (query: string) => { + const mql = nativeMatchMedia(query); + const nativeAdd = mql.addEventListener.bind(mql); + const nativeRemove = mql.removeEventListener.bind(mql); + mql.addEventListener = ((...args: Parameters) => { + added += 1; + return nativeAdd(...args); + }) as typeof mql.addEventListener; + mql.removeEventListener = ((...args: Parameters) => { + removed += 1; + return nativeRemove(...args); + }) as typeof mql.removeEventListener; + return mql; + }; + + try { + const { unmount } = renderHook(() => useIsMobile()); + expect(added).toBeGreaterThan(0); + expect(removed).toBe(0); + + unmount(); + expect(removed).toBe(added); + } finally { + window.matchMedia = nativeMatchMedia; + } + }); +});