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.
This commit is contained in:
Aari 2026-07-26 07:44:40 +08:00 committed by GitHub
parent 37c343fe30
commit 7ea8eac445
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 274 additions and 4 deletions

View File

@ -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

View File

@ -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",

166
frontend/pnpm-lock.yaml generated
View File

@ -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: {}

View File

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

View File

@ -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<typeof nativeAdd>) => {
added += 1;
return nativeAdd(...args);
}) as typeof mql.addEventListener;
mql.removeEventListener = ((...args: Parameters<typeof nativeRemove>) => {
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;
}
});
});