From 2ac73d0aa9932f760edc884a91a17755343341df Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 16 Mar 2026 11:20:37 -0400 Subject: [PATCH 1/3] feat(testing): add Vitest testing infrastructure with example tests --- .changeset/feat-testing-setup.md | 5 + .github/workflows/quality-checks.yml | 18 + .gitignore | 1 + CONTRIBUTING.md | 5 +- docs/TESTING.md | 137 +++++ knip.json | 4 +- package.json | 11 + pnpm-lock.yaml | 850 +++++++++++++++++++++++++++ src/app/utils/colorMXID.test.ts | 36 ++ src/app/utils/common.test.ts | 37 ++ src/app/utils/findAndReplace.test.ts | 59 ++ src/app/utils/sort.test.ts | 99 ++++ src/test/setup.ts | 1 + tsconfig.json | 2 +- vitest.config.ts | 51 ++ 15 files changed, 1313 insertions(+), 3 deletions(-) create mode 100644 .changeset/feat-testing-setup.md create mode 100644 docs/TESTING.md create mode 100644 src/app/utils/colorMXID.test.ts create mode 100644 src/app/utils/common.test.ts create mode 100644 src/app/utils/findAndReplace.test.ts create mode 100644 src/app/utils/sort.test.ts create mode 100644 src/test/setup.ts create mode 100644 vitest.config.ts diff --git a/.changeset/feat-testing-setup.md b/.changeset/feat-testing-setup.md new file mode 100644 index 000000000..51f7ad573 --- /dev/null +++ b/.changeset/feat-testing-setup.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Add Vitest testing infrastructure with example tests and contributor documentation diff --git a/.github/workflows/quality-checks.yml b/.github/workflows/quality-checks.yml index 6928af6ea..a9ac43708 100644 --- a/.github/workflows/quality-checks.yml +++ b/.github/workflows/quality-checks.yml @@ -79,6 +79,24 @@ jobs: - name: Run Knip run: pnpm run knip + tests: + name: Tests + runs-on: ubuntu-latest + if: github.head_ref != 'release' + permissions: + contents: read + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Setup app + uses: ./.github/actions/setup + + - name: Run tests + run: pnpm run test:run + build: name: Build runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 414b4a31e..3e561c641 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ experiment dist +coverage node_modules devAssets diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9bf1e3cae..c417e2071 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -37,12 +37,15 @@ It is not always possible to phrase every change in such a manner, but it is des Also, we use [ESLint](https://eslint.org/) for clean and stylistically consistent code syntax, so make sure your pull request follow it. -**Pull requests are not merged unless all quality checks are passing.** At minimum, `format`, `lint`, `typecheck`, and `knip` must all be green before a pull request can be merged. Run these locally before opening or updating a pull request: +**Pull requests are not merged unless all quality checks are passing.** At minimum, `format`, `lint`, `typecheck`, `knip`, and `tests` must all be green before a pull request can be merged. Run these locally before opening or updating a pull request: - `pnpm run fmt:check` - `pnpm run lint` - `pnpm run typecheck` - `pnpm run knip` +- `pnpm run test:run` + +If your change touches logic with testable behaviour, please include tests. See [docs/TESTING.md](./docs/TESTING.md) for a guide on how to write them. ## Restrictions on Generative AI Usage diff --git a/docs/TESTING.md b/docs/TESTING.md new file mode 100644 index 000000000..c5907c059 --- /dev/null +++ b/docs/TESTING.md @@ -0,0 +1,137 @@ +# Testing Guide + +Sable uses [Vitest](https://vitest.dev/) as its test runner and [React Testing Library](https://testing-library.com/docs/react-testing-library/intro/) for component tests. Tests run in a [jsdom](https://github.com/jsdom/jsdom) environment and coverage is collected via V8. + +--- + +## Running tests + +```sh +# Watch mode — reruns affected tests on file save (recommended during development) +pnpm test + +# Single run — equivalent to what CI runs +pnpm test:run + +# With browser UI (interactive results viewer) +pnpm test:ui + +# With coverage report +pnpm test:coverage +``` + +Coverage reports are written to `coverage/`. Open `coverage/index.html` in your browser for the full HTML report. + +--- + +## Writing tests + +### Where to put test files + +Place test files next to the source file they cover, with a `.test.ts` or `.test.tsx` suffix: + +``` +src/app/utils/colorMXID.ts +src/app/utils/colorMXID.test.ts + +src/app/features/room/RoomTimeline.tsx +src/app/features/room/RoomTimeline.test.tsx +``` + +### Testing plain utility functions + +For pure functions in `src/app/utils/`, no special setup is needed — just import and assert: + +```ts +import { describe, it, expect } from 'vitest'; +import { bytesToSize } from './common'; + +describe('bytesToSize', () => { + it('converts bytes to KB', () => { + expect(bytesToSize(1500)).toBe('1.5 KB'); + }); +}); +``` + +### Testing React components + +Use `@testing-library/react` to render components inside the jsdom environment. Query by accessible role/text rather than CSS classes or implementation details: + +```tsx +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MyButton } from './MyButton'; + +describe('MyButton', () => { + it('calls onClick when pressed', async () => { + const user = userEvent.setup(); + const onClick = vi.fn(); + + render(Click me); + + await user.click(screen.getByRole('button', { name: 'Click me' })); + + expect(onClick).toHaveBeenCalledOnce(); + }); +}); +``` + +### Testing hooks + +Use `renderHook` from `@testing-library/react`: + +```ts +import { describe, it, expect } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useMyHook } from './useMyHook'; + +describe('useMyHook', () => { + it('returns the expected initial value', () => { + const { result } = renderHook(() => useMyHook()); + expect(result.current.value).toBe(0); + }); +}); +``` + +### Mocking + +Vitest has Jest-compatible mocking APIs: + +```ts +import { vi } from 'vitest'; + +// Mock a module +vi.mock('./someModule', () => ({ doThing: vi.fn(() => 'mocked') })); + +// Spy on a method +const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + +// Restore after test +afterEach(() => vi.restoreAllMocks()); +``` + +### Path aliases + +All the project's path aliases work inside tests — you can import using `$utils/`, `$components/`, `$features/`, etc., just like in application code. + +--- + +## What to test + +Not every file needs tests. Focus on logic that would be painful to debug when broken: + +| Worth testing | Less valuable | +| ----------------------------------------------- | ---------------------------------------------- | +| Pure utility functions (`src/app/utils/`) | Purely presentational components with no logic | +| Custom hooks with non-trivial state transitions | Thin wrappers around third-party APIs | +| State atoms and reducers | Generated or declarative config | +| Data transformation / formatting functions | | + +When you fix a bug, consider adding a regression test that would have caught it — the description in the test is useful documentation. + +--- + +## CI + +`pnpm test:run` is part of the required quality checks and runs on every pull request alongside `lint`, `typecheck`, and `knip`. A PR with failing tests cannot be merged. diff --git a/knip.json b/knip.json index 3f415d940..02c8ee9ff 100644 --- a/knip.json +++ b/knip.json @@ -8,7 +8,9 @@ "ignoreDependencies": [ "buffer", "@element-hq/element-call-embedded", - "@matrix-org/matrix-sdk-crypto-wasm" + "@matrix-org/matrix-sdk-crypto-wasm", + "@testing-library/react", + "@testing-library/user-event" ], "ignoreBinaries": ["knope"], "rules": { diff --git a/package.json b/package.json index 371e7a975..3304ddef1 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,10 @@ "fmt": "prettier --write .", "fmt:check": "prettier --check .", "typecheck": "tsc", + "test": "vitest", + "test:ui": "vitest --ui", + "test:run": "vitest run", + "test:coverage": "vitest run --coverage", "knip": "knip", "knope": "knope", "document-change": "knope document-change", @@ -95,6 +99,9 @@ "@eslint/js": "9.39.3", "@rollup/plugin-inject": "^5.0.5", "@rollup/plugin-wasm": "^6.2.2", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/chroma-js": "^3.1.2", "@types/file-saver": "^2.0.7", "@types/is-hotkey": "^0.1.10", @@ -106,12 +113,15 @@ "@types/sanitize-html": "^2.16.0", "@types/ua-parser-js": "^0.7.39", "@vitejs/plugin-react": "^5.1.4", + "@vitest/coverage-v8": "^4.1.0", + "@vitest/ui": "^4.1.0", "buffer": "^6.0.3", "eslint": "9.39.3", "eslint-config-airbnb-extended": "3.0.1", "eslint-config-prettier": "10.1.8", "eslint-plugin-prettier": "5.5.5", "globals": "17.3.0", + "jsdom": "^29.0.0", "knip": "5.85.0", "prettier": "3.8.1", "typescript": "^5.9.3", @@ -121,6 +131,7 @@ "vite-plugin-static-copy": "^3.2.0", "vite-plugin-svgr": "4.5.0", "vite-plugin-top-level-await": "^1.6.0", + "vitest": "^4.1.0", "wrangler": "^4.70.0" } } \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e0d64afd3..19dcb85ee 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -218,6 +218,15 @@ importers: '@rollup/plugin-wasm': specifier: ^6.2.2 version: 6.2.2(rollup@4.59.0) + '@testing-library/jest-dom': + specifier: ^6.9.1 + version: 6.9.1 + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@testing-library/user-event': + specifier: ^14.6.1 + version: 14.6.1(@testing-library/dom@10.4.1) '@types/chroma-js': specifier: ^3.1.2 version: 3.1.2 @@ -251,6 +260,12 @@ importers: '@vitejs/plugin-react': specifier: ^5.1.4 version: 5.1.4(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(terser@5.46.0)(yaml@2.8.2)) + '@vitest/coverage-v8': + specifier: ^4.1.0 + version: 4.1.0(vitest@4.1.0) + '@vitest/ui': + specifier: ^4.1.0 + version: 4.1.0(vitest@4.1.0) buffer: specifier: ^6.0.3 version: 6.0.3 @@ -269,6 +284,9 @@ importers: globals: specifier: 17.3.0 version: 17.3.0 + jsdom: + specifier: ^29.0.0 + version: 29.0.0 knip: specifier: 5.85.0 version: 5.85.0(@types/node@24.10.13)(typescript@5.9.3) @@ -296,18 +314,35 @@ importers: vite-plugin-top-level-await: specifier: ^1.6.0 version: 1.6.0(@swc/helpers@0.5.19)(rollup@4.59.0)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(terser@5.46.0)(yaml@2.8.2)) + vitest: + specifier: ^4.1.0 + version: 4.1.0(@types/node@24.10.13)(@vitest/ui@4.1.0)(jsdom@29.0.0)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(terser@5.46.0)(yaml@2.8.2)) wrangler: specifier: ^4.70.0 version: 4.72.0 packages: + '@adobe/css-tools@4.4.4': + resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} + '@apideck/better-ajv-errors@0.3.6': resolution: {integrity: sha512-P+ZygBLZtkp0qqOAJJVX4oX/sFo5JR3eBWwwuqHHhK0GIgQOKWrAfiAaWX0aArHkRWHMuggFEgAZNxVPwPZYaA==} engines: {node: '>=10'} peerDependencies: ajv: '>=8' + '@asamuzakjp/css-color@5.0.1': + resolution: {integrity: sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@7.0.3': + resolution: {integrity: sha512-Q6mU0Z6bfj6YvnX2k9n0JxiIwrCFN59x/nWmYQnAqP000ruX/yV+5bp/GRcF5T8ncvfwJQ7fgfP74DlpKExILA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@atlaskit/pragmatic-drag-and-drop-auto-scroll@1.4.0': resolution: {integrity: sha512-5GoikoTSW13UX76F9TDeWB8x3jbbGlp/Y+3aRkHe1MOBMkrWkwNpJ42MIVhhX/6NSeaZiPumP0KbGJVs2tOWSQ==} @@ -830,6 +865,14 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + '@cloudflare/kv-asset-handler@0.4.2': resolution: {integrity: sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ==} engines: {node: '>=18.0.0'} @@ -883,6 +926,42 @@ packages: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} + '@csstools/color-helpers@6.0.2': + resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.1.1': + resolution: {integrity: sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.0.2': + resolution: {integrity: sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==} + 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.1': + resolution: {integrity: sha512-BvqN0AMWNAnLk9G8jnUT77D+mUbY/H2b3uDTvg2isJkHaOufUE2R3AOwxWo7VBQKT1lOdwdvorddo2B/lk64+w==} + 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'} + '@element-hq/element-call-embedded@0.16.3': resolution: {integrity: sha512-OViKJonDaDNVBUW9WdV9mk78/Ruh34C7XsEgt3O8D9z+64C39elbIgllHSoH5S12IRlv9RYrrV37FZLo6QWsDQ==} @@ -1110,6 +1189,15 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@exodus/bytes@1.15.0': + resolution: {integrity: sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==} + 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 + '@fontsource-variable/nunito@5.2.7': resolution: {integrity: sha512-2N8QhatkyKgSUbAGZO2FYLioxA32+RyI1EplVLawbpkGjUeui9Qg9VMrpkCaik1ydjFjfLV+kzQ0cGEsMrMenQ==} @@ -1553,6 +1641,9 @@ packages: resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + '@poppinss/colors@4.1.6': resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==} @@ -2329,6 +2420,9 @@ packages: '@speed-highlight/core@1.2.14': resolution: {integrity: sha512-G4ewlBNhUtlLvrJTb88d2mdy2KRijzs4UhnlrOSRT4bmjh/IqNElZa3zkrZ+TC47TwtlDWzVLFADljF1Ijp5hA==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@stylistic/eslint-plugin@5.10.0': resolution: {integrity: sha512-nPK52ZHvot8Ju/0A4ucSX1dcPV2/1clx0kLcH5wDmrE4naKso7TUC/voUyU1O9OTKTrR6MYip6LP0ogEMQ9jPQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2517,9 +2611,41 @@ packages: '@tanstack/virtual-core@3.13.21': resolution: {integrity: sha512-ww+fmLHyCbPSf7JNbWZP3g7wl6SdNo3ah5Aiw+0e9FDErkVHLKprYUrwTm7dF646FtEkN/KkAKPYezxpmvOjxw==} + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@6.9.1': + resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + + '@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 + + '@testing-library/user-event@14.6.1': + resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + '@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/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -2532,9 +2658,15 @@ 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/chroma-js@3.1.2': resolution: {integrity: sha512-YBTQqArPN8A0niHXCwrO1z5x++a+6l0mLBykncUpr23oIPW7L4h39s6gokdK/bDrPmSh8+TjMmrhBPnyiaWPmQ==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree@0.0.39': resolution: {integrity: sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==} @@ -2789,6 +2921,49 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + '@vitest/coverage-v8@4.1.0': + resolution: {integrity: sha512-nDWulKeik2bL2Va/Wl4x7DLuTKAXa906iRFooIRPR+huHkcvp9QDkPQ2RJdmjOFrqOqvNfoSQLF68deE3xC3CQ==} + peerDependencies: + '@vitest/browser': 4.1.0 + vitest: 4.1.0 + peerDependenciesMeta: + '@vitest/browser': + optional: true + + '@vitest/expect@4.1.0': + resolution: {integrity: sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA==} + + '@vitest/mocker@4.1.0': + resolution: {integrity: sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.0': + resolution: {integrity: sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A==} + + '@vitest/runner@4.1.0': + resolution: {integrity: sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ==} + + '@vitest/snapshot@4.1.0': + resolution: {integrity: sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg==} + + '@vitest/spy@4.1.0': + resolution: {integrity: sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw==} + + '@vitest/ui@4.1.0': + resolution: {integrity: sha512-sTSDtVM1GOevRGsCNhp1mBUHKo9Qlc55+HCreFT4fe99AHxl1QQNXSL3uj4Pkjh5yEuWZIx8E2tVC94nnBZECQ==} + peerDependencies: + vitest: 4.1.0 + + '@vitest/utils@4.1.0': + resolution: {integrity: sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -2816,6 +2991,10 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + anymatch@3.1.3: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} @@ -2823,6 +3002,9 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + 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'} @@ -2859,9 +3041,16 @@ packages: resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} engines: {node: '>= 0.4'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + ast-types-flow@0.0.8: resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} + ast-v8-to-istanbul@1.0.0: + resolution: {integrity: sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==} + async-function@1.0.0: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} @@ -2922,6 +3111,9 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + binary-extensions@2.3.0: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} @@ -2987,6 +3179,10 @@ packages: caniuse-lite@1.0.30001777: resolution: {integrity: sha512-tmN+fJxroPndC74efCdp12j+0rk0RHwV5Jwa1zWaFVyw2ZxAuPeG8ZgWC3Wz7uSjT3qMRQ5XHZ4COgQmsCMJAQ==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -3070,10 +3266,17 @@ packages: resolution: {integrity: sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==} 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-what@6.2.2: resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} engines: {node: '>= 6'} + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} @@ -3085,6 +3288,10 @@ packages: damerau-levenshtein@1.0.8: resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + data-view-buffer@1.0.2: resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} engines: {node: '>= 0.4'} @@ -3146,6 +3353,10 @@ packages: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} + 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'} @@ -3158,6 +3369,12 @@ packages: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + dom-serializer@2.0.0: resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} @@ -3208,6 +3425,10 @@ packages: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + entities@7.0.1: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} @@ -3237,6 +3458,9 @@ packages: es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-module-lexer@2.0.0: + resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -3445,6 +3669,9 @@ packages: estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + 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'} @@ -3460,6 +3687,10 @@ packages: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -3498,6 +3729,9 @@ packages: picomatch: optional: true + fflate@0.8.2: + resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} @@ -3520,6 +3754,9 @@ packages: resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} engines: {node: '>=16'} + flatted@3.4.0: + resolution: {integrity: sha512-kC6Bb+ooptOIvWj5B63EQWkF0FEnNjV2ZNkLMLZRDDduIiWeFF4iKnslwhiWxjAdbg4NzTNo6h0qLuvFrcx+Sw==} + flatted@3.4.1: resolution: {integrity: sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==} @@ -3700,6 +3937,13 @@ packages: html-dom-parser@5.1.8: resolution: {integrity: sha512-MCIUng//mF2qTtGHXJWr6OLfHWmg3Pm8ezpfiltF83tizPWY17JxT4dRLE8lykJ5bChJELoY3onQKPbufJHxYA==} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + html-parse-stringify@3.0.1: resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} @@ -3756,6 +4000,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'} + inline-style-parser@0.2.2: resolution: {integrity: sha512-EcKzdTHVe8wFVOGEYXiW9WmJXPjqi1T+234YpJr98RiFYKHV3cdy1+3mkTE+KHTHxFFLH51SfaGOoUdW+v7ViQ==} @@ -3862,6 +4110,9 @@ packages: resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==} engines: {node: '>=0.10.0'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-regex@1.2.1: resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} engines: {node: '>= 0.4'} @@ -3912,6 +4163,18 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + iterator.prototype@1.1.5: resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} engines: {node: '>= 0.4'} @@ -3950,6 +4213,9 @@ packages: react: optional: true + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -3957,6 +4223,15 @@ packages: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true + jsdom@29.0.0: + resolution: {integrity: sha512-9FshNB6OepopZ08unmmGpsF7/qCjxGPbo3NbgfJAnPeHXnsODE9WWffXZtRFRFe0ntzaAOcSKNJFz8wiyvF1jQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -4080,15 +4355,30 @@ packages: resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==} engines: {node: 20 || >=22} + lru-cache@11.2.7: + resolution: {integrity: sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==} + 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@0.25.9: resolution: {integrity: sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==} magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magicast@0.5.2: + resolution: {integrity: sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -4103,6 +4393,9 @@ packages: matrix-widget-api@1.13.0: resolution: {integrity: sha512-+LrvwkR1izL4h2euX8PDrvG/3PZZDEd6As+lmnR3jAVwbFJtU5iTnwmZGnCca9ddngCvXvAHkcpJBEPyPTZneQ==} + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + media-query-parser@2.0.2: resolution: {integrity: sha512-1N4qp+jE0pL5Xv4uEcwVUhIkwdUO3S/9gML90nqKA7v7FcOS5vUtatfzok9S9U1EJU8dHWlcv95WLnKmmxZI9w==} @@ -4118,6 +4411,10 @@ packages: resolution: {integrity: sha512-H/E3J6t+DQs/F2YgfDhxUVZz/dF8JXPPKTLHL/yHCcLZLtCXJDUaqvhJXQwqOVBvbyNn4T0WjLpIHd7PAw7fBA==} hasBin: true + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + miniflare@4.20260310.0: resolution: {integrity: sha512-uC5vNPenFpDSj5aUU3wGSABG6UUqMr+Xs1m4AkCrTHo37F4Z6xcQw5BXqViTfPDVT/zcYH1UgTVoXhr1l6ZMXw==} engines: {node: '>=18.0.0'} @@ -4146,6 +4443,10 @@ packages: motion-utils@12.29.2: resolution: {integrity: sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A==} + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -4220,6 +4521,9 @@ packages: resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} engines: {node: '>= 0.4'} + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + oidc-client-ts@3.4.1: resolution: {integrity: sha512-jNdst/U28Iasukx/L5MP6b274Vr7ftQs6qAhPBCvz6Wt5rPCA+Q/tUmCzfCHHWweWw5szeMy2Gfrm1rITwUKrw==} engines: {node: '>=18'} @@ -4265,6 +4569,9 @@ packages: parse-srcset@1.0.2: resolution: {integrity: sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==} + parse5@8.0.0: + resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -4337,6 +4644,10 @@ packages: resolution: {integrity: sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==} engines: {node: ^14.13.1 || >=16.0.0} + 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} + prismjs@1.30.0: resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==} engines: {node: '>=6'} @@ -4411,6 +4722,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-property@2.0.2: resolution: {integrity: sha512-+PbtI3VuDV0l6CleQMsx2gtK0JZbZKbpdu5ynr+lbsuvtmgbNcS3VM0tuY2QjFNOcWxvXeHjDpy42RO+4U2rug==} @@ -4445,6 +4759,10 @@ packages: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + reflect.getprototypeof@1.0.10: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} @@ -4526,6 +4844,10 @@ packages: sanitize-html@2.17.1: resolution: {integrity: sha512-ehFCW+q1a4CSOWRAdX97BX/6/PDEkCqw7/0JXZAGQV57FQB3YOkTa/rrzHPeJ+Aghy4vZAFfWMYyfxIiB7F/gw==} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.23.2: resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} @@ -4589,10 +4911,17 @@ packages: resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} engines: {node: '>= 0.4'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + sirv@3.0.2: + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} + engines: {node: '>=18'} + slate-dom@0.123.0: resolution: {integrity: sha512-OUinp4tvSrAlt64JL9y20Xin08jgnnj1gJmIuPdGvU5MELKXRNZh17a7EKKNOS6OZPAE8Dk9NI1MAIS/Qz0YBw==} peerDependencies: @@ -4649,6 +4978,12 @@ packages: resolution: {integrity: sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==} engines: {node: '>=12.0.0'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.0.0: + resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==} + stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} @@ -4696,6 +5031,10 @@ packages: resolution: {integrity: sha512-ZprKx+bBLXv067WTCALv8SSz5l2+XhpYCsVtSqlMnkAXMWDq+/ekVbl1ghqP9rUHTzv6sm/DwCOiYutU/yp1fw==} engines: {node: '>=10'} + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} @@ -4725,6 +5064,9 @@ packages: svg-parser@2.0.4: resolution: {integrity: sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + synckit@0.11.12: resolution: {integrity: sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==} engines: {node: ^14.18.0 || >=16.0.0} @@ -4755,20 +5097,50 @@ packages: tiny-invariant@1.3.1: resolution: {integrity: sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.0.4: + resolution: {integrity: sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==} + engines: {node: '>=18'} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + + tldts-core@7.0.25: + resolution: {integrity: sha512-ZjCZK0rppSBu7rjHYDYsEaMOIbbT+nWF57hKkv4IUmZWBNrBWBOjIElc0mKRgLM8bm7x/BBlof6t2gi/Oq/Asw==} + + tldts@7.0.25: + resolution: {integrity: sha512-keinCnPbwXEUG3ilrWQZU+CqcTTzHq9m2HhoUP2l7Xmi8l1LuijAXLpAJ5zRW+ifKTNscs4NdCkfkDCBYm352w==} + hasBin: true + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + + tough-cookie@6.0.1: + resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} + engines: {node: '>=16'} + tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} tr46@1.0.1: resolution: {integrity: sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==} + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + ts-api-utils@2.4.0: resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==} engines: {node: '>=18.12'} @@ -4840,6 +5212,10 @@ packages: resolution: {integrity: sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw==} engines: {node: '>=20.18.1'} + undici@7.24.3: + resolution: {integrity: sha512-eJdUmK/Wrx2d+mnWWmwwLRyA7OQCkLap60sk3dOK4ViZR7DKwwptwuIvFBg2HaiP9ESaEdhtpSymQPvytpmkCA==} + engines: {node: '>=20.18.1'} + unenv@2.0.0-rc.24: resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} @@ -4975,10 +5351,49 @@ packages: yaml: optional: true + vitest@4.1.0: + resolution: {integrity: sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.0 + '@vitest/browser-preview': 4.1.0 + '@vitest/browser-webdriverio': 4.1.0 + '@vitest/ui': 4.1.0 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.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/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + void-elements@3.1.0: resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} engines: {node: '>=0.10.0'} + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + walk-up-path@4.0.0: resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==} engines: {node: 20 || >=22} @@ -4989,6 +5404,18 @@ packages: webidl-conversions@4.0.2: resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==} + 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@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} @@ -5016,6 +5443,11 @@ packages: 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'} @@ -5100,6 +5532,13 @@ packages: utf-8-validate: optional: true + 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==} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -5141,6 +5580,8 @@ packages: snapshots: + '@adobe/css-tools@4.4.4': {} + '@apideck/better-ajv-errors@0.3.6(ajv@8.18.0)': dependencies: ajv: 8.18.0 @@ -5148,6 +5589,24 @@ snapshots: jsonpointer: 5.0.1 leven: 3.1.0 + '@asamuzakjp/css-color@5.0.1': + dependencies: + '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.0.2(@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.2.7 + + '@asamuzakjp/dom-selector@7.0.3': + dependencies: + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.2.7 + + '@asamuzakjp/nwsapi@2.3.9': {} + '@atlaskit/pragmatic-drag-and-drop-auto-scroll@1.4.0': dependencies: '@atlaskit/pragmatic-drag-and-drop': 1.7.9 @@ -5833,6 +6292,12 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@bcoe/v8-coverage@1.0.2': {} + + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + '@cloudflare/kv-asset-handler@0.4.2': {} '@cloudflare/unenv-preset@2.15.0(unenv@2.0.0-rc.24)(workerd@1.20260310.1)': @@ -5873,6 +6338,30 @@ snapshots: dependencies: '@jridgewell/trace-mapping': 0.3.9 + '@csstools/color-helpers@6.0.2': {} + + '@csstools/css-calc@3.1.1(@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.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.0.2 + '@csstools/css-calc': 3.1.1(@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.1(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + '@element-hq/element-call-embedded@0.16.3': {} '@emnapi/core@1.8.1': @@ -6031,6 +6520,8 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 + '@exodus/bytes@1.15.0': {} + '@fontsource-variable/nunito@5.2.7': {} '@fontsource/space-mono@5.2.9': {} @@ -6362,6 +6853,8 @@ snapshots: '@pkgr/core@0.2.9': {} + '@polka/url@1.0.0-next.29': {} + '@poppinss/colors@4.1.6': dependencies: kleur: 4.1.5 @@ -7488,6 +7981,8 @@ snapshots: '@speed-highlight/core@1.2.14': {} + '@standard-schema/spec@1.1.0': {} + '@stylistic/eslint-plugin@5.10.0(eslint@9.39.3(jiti@2.6.1))': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.3(jiti@2.6.1)) @@ -7657,11 +8152,47 @@ snapshots: '@tanstack/virtual-core@3.13.21': {} + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.0 + '@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/jest-dom@6.9.1': + dependencies: + '@adobe/css-tools': 4.4.4 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.28.6 + '@testing-library/dom': 10.4.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + + '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': + dependencies: + '@testing-library/dom': 10.4.1 + '@tybys/wasm-util@0.10.1': dependencies: tslib: 2.8.1 optional: true + '@types/aria-query@5.0.4': {} + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.29.0 @@ -7683,8 +8214,15 @@ snapshots: dependencies: '@babel/types': 7.29.0 + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + '@types/chroma-js@3.1.2': {} + '@types/deep-eql@4.0.2': {} + '@types/estree@0.0.39': {} '@types/estree@1.0.8': {} @@ -7985,6 +8523,72 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitest/coverage-v8@4.1.0(vitest@4.1.0)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.1.0 + ast-v8-to-istanbul: 1.0.0 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.2 + obug: 2.1.1 + std-env: 4.0.0 + tinyrainbow: 3.1.0 + vitest: 4.1.0(@types/node@24.10.13)(@vitest/ui@4.1.0)(jsdom@29.0.0)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(terser@5.46.0)(yaml@2.8.2)) + + '@vitest/expect@4.1.0': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.0 + '@vitest/utils': 4.1.0 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.0(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(terser@5.46.0)(yaml@2.8.2))': + dependencies: + '@vitest/spy': 4.1.0 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(terser@5.46.0)(yaml@2.8.2) + + '@vitest/pretty-format@4.1.0': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.0': + dependencies: + '@vitest/utils': 4.1.0 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.0': + dependencies: + '@vitest/pretty-format': 4.1.0 + '@vitest/utils': 4.1.0 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.0': {} + + '@vitest/ui@4.1.0(vitest@4.1.0)': + dependencies: + '@vitest/utils': 4.1.0 + fflate: 0.8.2 + flatted: 3.4.0 + pathe: 2.0.3 + sirv: 3.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.1.0 + vitest: 4.1.0(@types/node@24.10.13)(@vitest/ui@4.1.0)(jsdom@29.0.0)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(terser@5.46.0)(yaml@2.8.2)) + + '@vitest/utils@4.1.0': + dependencies: + '@vitest/pretty-format': 4.1.0 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + acorn-jsx@5.3.2(acorn@8.16.0): dependencies: acorn: 8.16.0 @@ -8013,6 +8617,8 @@ snapshots: dependencies: color-convert: 2.0.1 + ansi-styles@5.2.0: {} + anymatch@3.1.3: dependencies: normalize-path: 3.0.0 @@ -8020,6 +8626,10 @@ snapshots: argparse@2.0.1: {} + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + aria-query@5.3.2: {} array-buffer-byte-length@1.0.2: @@ -8089,8 +8699,16 @@ snapshots: get-intrinsic: 1.3.0 is-array-buffer: 3.0.5 + assertion-error@2.0.1: {} + ast-types-flow@0.0.8: {} + ast-v8-to-istanbul@1.0.0: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + async-function@1.0.0: {} async@3.2.6: {} @@ -8141,6 +8759,10 @@ snapshots: baseline-browser-mapping@2.10.0: {} + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + binary-extensions@2.3.0: {} bind-event-listener@3.0.0: {} @@ -8203,6 +8825,8 @@ snapshots: caniuse-lite@1.0.30001777: {} + chai@6.2.2: {} + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -8283,14 +8907,28 @@ snapshots: crypto-random-string@2.0.0: {} + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + css-what@6.2.2: {} + css.escape@1.5.1: {} + cssesc@3.0.0: {} csstype@3.2.3: {} damerau-levenshtein@1.0.8: {} + data-urls@7.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + transitivePeerDependencies: + - '@noble/hashes' + data-view-buffer@1.0.2: dependencies: call-bound: 1.0.4 @@ -8341,6 +8979,8 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 + dequal@2.0.3: {} + detect-libc@2.1.2: {} direction@1.0.4: {} @@ -8349,6 +8989,10 @@ snapshots: dependencies: esutils: 2.0.3 + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + dom-serializer@2.0.0: dependencies: domelementtype: 2.3.0 @@ -8401,6 +9045,8 @@ snapshots: entities@4.5.0: {} + entities@6.0.1: {} + entities@7.0.1: {} error-ex@1.3.4: @@ -8491,6 +9137,8 @@ snapshots: es-module-lexer@1.7.0: {} + es-module-lexer@2.0.0: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -8821,6 +9469,10 @@ snapshots: estree-walker@2.0.2: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + esutils@2.0.3: {} eval@0.1.8: @@ -8832,6 +9484,8 @@ snapshots: events@3.3.0: {} + expect-type@1.3.0: {} + fast-deep-equal@3.1.3: {} fast-diff@1.3.0: {} @@ -8870,6 +9524,8 @@ snapshots: optionalDependencies: picomatch: 4.0.3 + fflate@0.8.2: {} + file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 @@ -8894,6 +9550,8 @@ snapshots: flatted: 3.4.1 keyv: 4.5.4 + flatted@3.4.0: {} + flatted@3.4.1: {} focus-trap-react@10.3.1(prop-types@15.8.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): @@ -9073,6 +9731,14 @@ snapshots: domhandler: 5.0.3 htmlparser2: 10.1.0 + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.15.0 + transitivePeerDependencies: + - '@noble/hashes' + + html-escaper@2.0.2: {} + html-parse-stringify@3.0.1: dependencies: void-elements: 3.1.0 @@ -9139,6 +9805,8 @@ snapshots: imurmurhash@0.1.4: {} + indent-string@4.0.0: {} + inline-style-parser@0.2.2: {} internal-slot@1.1.0: @@ -9245,6 +9913,8 @@ snapshots: is-plain-object@5.0.0: {} + is-potential-custom-element-name@1.0.1: {} + is-regex@1.2.1: dependencies: call-bound: 1.0.4 @@ -9292,6 +9962,19 @@ snapshots: isexe@2.0.0: {} + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + iterator.prototype@1.1.5: dependencies: define-data-property: 1.1.4 @@ -9322,12 +10005,40 @@ snapshots: '@types/react': 18.3.28 react: 18.3.1 + js-tokens@10.0.0: {} + js-tokens@4.0.0: {} js-yaml@4.1.1: dependencies: argparse: 2.0.1 + jsdom@29.0.0: + dependencies: + '@asamuzakjp/css-color': 5.0.1 + '@asamuzakjp/dom-selector': 7.0.3 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.1(css-tree@3.2.1) + '@exodus/bytes': 1.15.0 + 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.2.7 + parse5: 8.0.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.1 + undici: 7.24.3 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + jsesc@3.1.0: {} json-buffer@3.0.1: {} @@ -9436,10 +10147,14 @@ snapshots: lru-cache@11.2.6: {} + lru-cache@11.2.7: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 + lz-string@1.5.0: {} + magic-string@0.25.9: dependencies: sourcemap-codec: 1.4.8 @@ -9448,6 +10163,16 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magicast@0.5.2: + dependencies: + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.7.4 + math-intrinsics@1.1.0: {} matrix-events-sdk@0.0.1: {} @@ -9474,6 +10199,8 @@ snapshots: '@types/events': 3.0.3 events: 3.3.0 + mdn-data@2.27.1: {} + media-query-parser@2.0.2: dependencies: '@babel/runtime': 7.28.6 @@ -9489,6 +10216,8 @@ snapshots: dependencies: yargs: 17.7.2 + min-indent@1.0.1: {} + miniflare@4.20260310.0: dependencies: '@cspotcode/source-map-support': 0.8.1 @@ -9524,6 +10253,8 @@ snapshots: motion-utils@12.29.2: {} + mrmime@2.0.1: {} + ms@2.1.3: {} nanoid@3.3.11: {} @@ -9597,6 +10328,8 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 + obug@2.1.1: {} + oidc-client-ts@3.4.1: dependencies: jwt-decode: 4.0.0 @@ -9668,6 +10401,10 @@ snapshots: parse-srcset@1.0.2: {} + parse5@8.0.0: + dependencies: + entities: 6.0.1 + path-exists@4.0.0: {} path-key@3.1.1: {} @@ -9722,6 +10459,12 @@ snapshots: pretty-bytes@6.1.1: {} + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + prismjs@1.30.0: {} prop-types@15.8.1: @@ -9829,6 +10572,8 @@ snapshots: react-is@16.13.1: {} + react-is@17.0.2: {} + react-property@2.0.2: {} react-range@1.10.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): @@ -9858,6 +10603,11 @@ snapshots: dependencies: picomatch: 2.3.1 + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + reflect.getprototypeof@1.0.10: dependencies: call-bind: 1.0.8 @@ -9989,6 +10739,10 @@ snapshots: parse-srcset: 1.0.2 postcss: 8.5.8 + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.23.2: dependencies: loose-envify: 1.4.0 @@ -10092,8 +10846,16 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} + signal-exit@4.1.0: {} + sirv@3.0.2: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + slate-dom@0.123.0(slate@0.123.0): dependencies: '@juggle/resize-observer': 3.4.0 @@ -10151,6 +10913,10 @@ snapshots: stable-hash-x@0.2.0: {} + stackback@0.0.2: {} + + std-env@4.0.0: {} + stop-iteration-iterator@1.1.0: dependencies: es-errors: 1.3.0 @@ -10226,6 +10992,10 @@ snapshots: strip-comments@2.0.1: {} + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + strip-json-comments@3.1.1: {} strip-json-comments@5.0.3: {} @@ -10248,6 +11018,8 @@ snapshots: svg-parser@2.0.4: {} + symbol-tree@3.2.4: {} + synckit@0.11.12: dependencies: '@pkgr/core': 0.2.9 @@ -10276,21 +11048,43 @@ snapshots: tiny-invariant@1.3.1: {} + tinybench@2.9.0: {} + + tinyexec@1.0.4: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tinyrainbow@3.1.0: {} + + tldts-core@7.0.25: {} + + tldts@7.0.25: + dependencies: + tldts-core: 7.0.25 + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 + totalist@3.0.1: {} + + tough-cookie@6.0.1: + dependencies: + tldts: 7.0.25 + tr46@0.0.3: {} tr46@1.0.1: dependencies: punycode: 2.3.1 + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + ts-api-utils@2.4.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -10376,6 +11170,8 @@ snapshots: undici@7.18.2: {} + undici@7.24.3: {} + unenv@2.0.0-rc.24: dependencies: pathe: 2.0.3 @@ -10527,14 +11323,59 @@ snapshots: terser: 5.46.0 yaml: 2.8.2 + vitest@4.1.0(@types/node@24.10.13)(@vitest/ui@4.1.0)(jsdom@29.0.0)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(terser@5.46.0)(yaml@2.8.2)): + dependencies: + '@vitest/expect': 4.1.0 + '@vitest/mocker': 4.1.0(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(terser@5.46.0)(yaml@2.8.2)) + '@vitest/pretty-format': 4.1.0 + '@vitest/runner': 4.1.0 + '@vitest/snapshot': 4.1.0 + '@vitest/spy': 4.1.0 + '@vitest/utils': 4.1.0 + es-module-lexer: 2.0.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 4.0.0 + tinybench: 2.9.0 + tinyexec: 1.0.4 + tinyglobby: 0.2.15 + tinyrainbow: 3.1.0 + vite: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(terser@5.46.0)(yaml@2.8.2) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.10.13 + '@vitest/ui': 4.1.0(vitest@4.1.0) + jsdom: 29.0.0 + transitivePeerDependencies: + - msw + void-elements@3.1.0: {} + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + walk-up-path@4.0.0: {} webidl-conversions@3.0.1: {} webidl-conversions@4.0.2: {} + webidl-conversions@8.0.1: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1: + dependencies: + '@exodus/bytes': 1.15.0 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + whatwg-url@5.0.0: dependencies: tr46: 0.0.3 @@ -10591,6 +11432,11 @@ snapshots: 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: {} workbox-background-sync@7.4.0: @@ -10738,6 +11584,10 @@ snapshots: ws@8.18.0: {} + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + y18n@5.0.8: {} yallist@3.1.1: {} diff --git a/src/app/utils/colorMXID.test.ts b/src/app/utils/colorMXID.test.ts new file mode 100644 index 000000000..c73e04fa5 --- /dev/null +++ b/src/app/utils/colorMXID.test.ts @@ -0,0 +1,36 @@ +// Example: testing pure utility functions. +// These are the simplest tests to write — no mocking or DOM needed. +import { describe, it, expect } from 'vitest'; +import colorMXID, { cssColorMXID } from './colorMXID'; + +describe('colorMXID', () => { + it('returns a valid hsl() string', () => { + expect(colorMXID('@alice:example.com')).toMatch(/^hsl\(\d+, 65%, 80%\)$/); + }); + + it('is deterministic', () => { + expect(colorMXID('@alice:example.com')).toBe(colorMXID('@alice:example.com')); + }); + + it('produces different colors for different users', () => { + expect(colorMXID('@alice:example.com')).not.toBe(colorMXID('@bob:example.com')); + }); + + it('handles undefined without throwing', () => { + expect(colorMXID(undefined)).toBe('hsl(0, 65%, 80%)'); + }); +}); + +describe('cssColorMXID', () => { + it('returns a CSS variable in the --mx-uc-1 to --mx-uc-8 range', () => { + // Run many users through it so we cover the full 0-7 modulo range + const results = ['@a', '@b', '@c', '@d', '@e', '@f', '@g', '@h'].map(cssColorMXID); + results.forEach((v) => { + expect(v).toMatch(/^--mx-uc-[1-8]$/); + }); + }); + + it('handles undefined without throwing', () => { + expect(cssColorMXID(undefined)).toBe('--mx-uc-1'); + }); +}); diff --git a/src/app/utils/common.test.ts b/src/app/utils/common.test.ts new file mode 100644 index 000000000..2a3916705 --- /dev/null +++ b/src/app/utils/common.test.ts @@ -0,0 +1,37 @@ +// Example: testing a utility file with multiple related exports. +// Uses it.each for table-driven tests — good for exhaustive format coverage. +import { describe, it, expect } from 'vitest'; +import { bytesToSize, millisecondsToMinutesAndSeconds, secondsToMinutesAndSeconds } from './common'; + +describe('bytesToSize', () => { + it.each([ + [0, '0KB'], + [1_500, '1.5 KB'], + [2_500_000, '2.5 MB'], + [3_200_000_000, '3.2 GB'], + ])('bytesToSize(%i) → %s', (input, expected) => { + expect(bytesToSize(input)).toBe(expected); + }); +}); + +describe('millisecondsToMinutesAndSeconds', () => { + it.each([ + [0, '0:00'], + [5_000, '0:05'], + [60_000, '1:00'], + [90_000, '1:30'], + [3_661_000, '61:01'], + ])('%ims → %s', (ms, expected) => { + expect(millisecondsToMinutesAndSeconds(ms)).toBe(expected); + }); +}); + +describe('secondsToMinutesAndSeconds', () => { + it.each([ + [0, '0:00'], + [9, '0:09'], + [125, '2:05'], + ])('%is → %s', (s, expected) => { + expect(secondsToMinutesAndSeconds(s)).toBe(expected); + }); +}); diff --git a/src/app/utils/findAndReplace.test.ts b/src/app/utils/findAndReplace.test.ts new file mode 100644 index 000000000..6eda8e373 --- /dev/null +++ b/src/app/utils/findAndReplace.test.ts @@ -0,0 +1,59 @@ +// Example: testing an algorithmic utility with multiple interaction scenarios. +// Shows how to test functions that return arrays, and how to test edge cases +// like empty input, no matches, and back-to-back matches. +import { describe, it, expect } from 'vitest'; +import { findAndReplace } from './findAndReplace'; + +// Helpers that mirror what a real caller would pass +const asText = (text: string) => ({ type: 'text', text }) as const; +const asMatch = (match: string) => ({ type: 'match', match }) as const; + +describe('findAndReplace', () => { + it('returns the original text when there are no matches', () => { + const result = findAndReplace('hello world', /xyz/g, (m) => asMatch(m[0]), asText); + expect(result).toEqual([asText('hello world')]); + }); + + it('splits text around a single match', () => { + const result = findAndReplace('say hello there', /hello/g, (m) => asMatch(m[0]), asText); + expect(result).toEqual([asText('say '), asMatch('hello'), asText(' there')]); + }); + + it('handles multiple matches in sequence', () => { + const result = findAndReplace('a b a', /a/g, (m) => asMatch(m[0]), asText); + expect(result).toEqual([asText(''), asMatch('a'), asText(' b '), asMatch('a'), asText('')]); + }); + + it('handles a match at the very start', () => { + const result = findAndReplace('helloworld', /hello/g, (m) => asMatch(m[0]), asText); + expect(result).toEqual([asText(''), asMatch('hello'), asText('world')]); + }); + + it('handles a match at the very end', () => { + const result = findAndReplace('worldhello', /hello/g, (m) => asMatch(m[0]), asText); + expect(result).toEqual([asText('world'), asMatch('hello'), asText('')]); + }); + + it('handles an empty input string', () => { + const result = findAndReplace('', /hello/g, (m) => asMatch(m[0]), asText); + expect(result).toEqual([asText('')]); + }); + + it('passes the correct pushIndex to callbacks', () => { + const indices: number[] = []; + findAndReplace( + 'a b', + /[ab]/g, + (m, i) => { + indices.push(i); + return asMatch(m[0]); + }, + (t, i) => { + indices.push(i); + return asText(t); + } + ); + // indices are assigned in push order: text(''), match('a'), text(' '), match('b'), text('') + expect(indices).toEqual([0, 1, 2, 3, 4]); + }); +}); diff --git a/src/app/utils/sort.test.ts b/src/app/utils/sort.test.ts new file mode 100644 index 000000000..bda66e233 --- /dev/null +++ b/src/app/utils/sort.test.ts @@ -0,0 +1,99 @@ +// Example: testing factory functions (functions that return functions). +// Shows how to build lightweight fakes/stubs instead of using a full mock library — +// for factoryRoomIdByActivity and factoryRoomIdByAtoZ the MatrixClient is stubbed +// with a plain object, keeping tests readable without heavy setup. +import { describe, it, expect } from 'vitest'; +import type { MatrixClient } from '$types/matrix-sdk'; +import { + byOrderKey, + byTsOldToNew, + factoryRoomIdByActivity, + factoryRoomIdByAtoZ, + factoryRoomIdByUnreadCount, +} from './sort'; + +// Minimal stub that satisfies the MatrixClient shape needed by these sort functions. +function makeClient(rooms: Record): MatrixClient { + return { + getRoom: (id: string) => { + const r = rooms[id]; + if (!r) return null; + return { name: r.name, getLastActiveTimestamp: () => r.ts } as any; + }, + } as unknown as MatrixClient; +} + +describe('byTsOldToNew', () => { + it('sorts ascending by timestamp', () => { + expect([300, 100, 200].sort(byTsOldToNew)).toEqual([100, 200, 300]); + }); +}); + +describe('byOrderKey', () => { + it('sorts defined keys lexicographically', () => { + expect(['c', 'a', 'b'].sort(byOrderKey)).toEqual(['a', 'b', 'c']); + }); + + it('puts undefined keys after defined keys', () => { + expect([undefined, 'a', undefined, 'b'].sort(byOrderKey)).toEqual([ + 'a', + 'b', + undefined, + undefined, + ]); + }); +}); + +describe('factoryRoomIdByActivity', () => { + it('sorts rooms most-recently-active first', () => { + const mx = makeClient({ + '!old:h': { name: 'Old', ts: 1000 }, + '!new:h': { name: 'New', ts: 9000 }, + '!mid:h': { name: 'Mid', ts: 5000 }, + }); + const sort = factoryRoomIdByActivity(mx); + expect(['!old:h', '!new:h', '!mid:h'].sort(sort)).toEqual(['!new:h', '!mid:h', '!old:h']); + }); + + it('places unknown room IDs last', () => { + const mx = makeClient({ '!known:h': { name: 'Known', ts: 1000 } }); + const sort = factoryRoomIdByActivity(mx); + expect(['!unknown:h', '!known:h'].sort(sort)).toEqual(['!known:h', '!unknown:h']); + }); +}); + +describe('factoryRoomIdByAtoZ', () => { + it('sorts room names case-insensitively A→Z', () => { + const mx = makeClient({ + '!c:h': { name: 'Charlie', ts: 0 }, + '!a:h': { name: 'Alice', ts: 0 }, + '!b:h': { name: 'bob', ts: 0 }, + }); + const sort = factoryRoomIdByAtoZ(mx); + expect(['!c:h', '!a:h', '!b:h'].sort(sort)).toEqual(['!a:h', '!b:h', '!c:h']); + }); + + it('strips leading # before comparing', () => { + const mx = makeClient({ + '!hash:h': { name: '#alpha', ts: 0 }, + '!plain:h': { name: 'beta', ts: 0 }, + }); + const sort = factoryRoomIdByAtoZ(mx); + // #alpha → "alpha" sorts before "beta" + expect(['!plain:h', '!hash:h'].sort(sort)).toEqual(['!hash:h', '!plain:h']); + }); +}); + +describe('factoryRoomIdByUnreadCount', () => { + it('sorts rooms with more unreads first', () => { + const counts: Record = { '!a:h': 5, '!b:h': 20, '!c:h': 1 }; + const sort = factoryRoomIdByUnreadCount((id) => counts[id] ?? 0); + expect(['!a:h', '!b:h', '!c:h'].sort(sort)).toEqual(['!b:h', '!a:h', '!c:h']); + }); + + it('treats missing counts as 0', () => { + const sort = factoryRoomIdByUnreadCount(() => 0); + const result = ['!a:h', '!b:h'].sort(sort); + expect(result).toHaveLength(2); + }); +}); diff --git a/src/test/setup.ts b/src/test/setup.ts new file mode 100644 index 000000000..7b0828bfa --- /dev/null +++ b/src/test/setup.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom'; diff --git a/tsconfig.json b/tsconfig.json index 839ef9d31..a18b41f91 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -32,5 +32,5 @@ "lib": ["ES2022", "DOM"] }, "exclude": ["node_modules", "dist"], - "include": ["src", "vite.config.ts"] + "include": ["src", "vite.config.ts", "vitest.config.ts"] } diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 000000000..fedea1151 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,51 @@ +import { defineConfig } from 'vitest/config'; +import react from '@vitejs/plugin-react'; +import { vanillaExtractPlugin } from '@vanilla-extract/vite-plugin'; +import path from 'path'; + +// Standalone Vitest config — intentionally excludes Cloudflare, PWA, compression, +// and other production-only Vite plugins that don't apply to unit tests. +export default defineConfig({ + plugins: [react(), vanillaExtractPlugin()], + resolve: { + alias: { + $hooks: path.resolve(__dirname, 'src/app/hooks'), + $plugins: path.resolve(__dirname, 'src/app/plugins'), + $components: path.resolve(__dirname, 'src/app/components'), + $features: path.resolve(__dirname, 'src/app/features'), + $state: path.resolve(__dirname, 'src/app/state'), + $styles: path.resolve(__dirname, 'src/app/styles'), + $utils: path.resolve(__dirname, 'src/app/utils'), + $pages: path.resolve(__dirname, 'src/app/pages'), + $types: path.resolve(__dirname, 'src/types'), + $public: path.resolve(__dirname, 'public'), + $client: path.resolve(__dirname, 'src/client'), + }, + }, + define: { + APP_VERSION: JSON.stringify('test'), + BUILD_HASH: JSON.stringify(''), + IS_RELEASE_TAG: JSON.stringify(false), + }, + test: { + environment: 'jsdom', + globals: true, + setupFiles: ['./src/test/setup.ts'], + include: ['src/**/*.{test,spec}.{ts,tsx}'], + coverage: { + provider: 'v8', + reporter: ['text', 'html', 'lcov'], + include: ['src/**/*.{ts,tsx}'], + exclude: [ + 'src/**/*.d.ts', + 'src/index.tsx', + 'src/sw.ts', + 'src/sw-session.ts', + 'src/instrument.ts', + 'src/test/**', + 'src/**/*.test.{ts,tsx}', + 'src/**/*.spec.{ts,tsx}', + ], + }, + }, +}); From bfe766ead95567be77cc9bbb4a23351aa53435a3 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 16 Mar 2026 13:34:02 -0400 Subject: [PATCH 2/3] test: add unit and integration tests for utils and hooks --- knip.json | 1 - src/app/hooks/useAsyncCallback.test.tsx | 126 ++++++++++++++++++++++ src/app/hooks/usePreviousValue.test.tsx | 51 +++++++++ src/app/hooks/useTimeoutToggle.test.tsx | 101 +++++++++++++++++ src/app/utils/common.test.ts | 138 +++++++++++++++++++++++- src/app/utils/sanitize.test.ts | 125 +++++++++++++++++++++ src/app/utils/time.test.ts | 99 +++++++++++++++++ 7 files changed, 639 insertions(+), 2 deletions(-) create mode 100644 src/app/hooks/useAsyncCallback.test.tsx create mode 100644 src/app/hooks/usePreviousValue.test.tsx create mode 100644 src/app/hooks/useTimeoutToggle.test.tsx create mode 100644 src/app/utils/sanitize.test.ts create mode 100644 src/app/utils/time.test.ts diff --git a/knip.json b/knip.json index 02c8ee9ff..eb88c8d66 100644 --- a/knip.json +++ b/knip.json @@ -9,7 +9,6 @@ "buffer", "@element-hq/element-call-embedded", "@matrix-org/matrix-sdk-crypto-wasm", - "@testing-library/react", "@testing-library/user-event" ], "ignoreBinaries": ["knope"], diff --git a/src/app/hooks/useAsyncCallback.test.tsx b/src/app/hooks/useAsyncCallback.test.tsx new file mode 100644 index 000000000..c27d06478 --- /dev/null +++ b/src/app/hooks/useAsyncCallback.test.tsx @@ -0,0 +1,126 @@ +// Integration tests: renderHook exercises the full React lifecycle including +// useAlive (cleanup on unmount) and retry-race-condition logic. +import { describe, it, expect } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useAsyncCallback, AsyncStatus } from './useAsyncCallback'; + +describe('useAsyncCallback', () => { + it('starts in Idle state', () => { + const { result } = renderHook(() => useAsyncCallback(async () => 'value')); + const [state] = result.current; + expect(state.status).toBe(AsyncStatus.Idle); + }); + + it('transitions to Success with returned data', async () => { + const { result } = renderHook(() => useAsyncCallback(async () => 42)); + + await act(async () => { + await result.current[1](); + }); + + expect(result.current[0]).toEqual({ status: AsyncStatus.Success, data: 42 }); + }); + + it('transitions to Error when the async function throws', async () => { + const boom = new Error('boom'); + const { result } = renderHook(() => + useAsyncCallback(async () => { + throw boom; + }) + ); + + await act(async () => { + await result.current[1]().catch(() => {}); + }); + + expect(result.current[0]).toEqual({ status: AsyncStatus.Error, error: boom }); + }); + + it('ignores the result of a stale (superseded) request', async () => { + // Two calls are made. The first resolves AFTER the second — its result should + // be discarded so the final state reflects only the second call. + let resolveFirst!: (v: string) => void; + let resolveSecond!: (v: string) => void; + let callCount = 0; + + const { result } = renderHook(() => + useAsyncCallback(async () => { + callCount += 1; + if (callCount === 1) { + return new Promise((res) => { + resolveFirst = res; + }); + } + return new Promise((res) => { + resolveSecond = res; + }); + }) + ); + + // Fire both requests before either resolves + act(() => { + result.current[1](); + }); + act(() => { + result.current[1](); + }); + + // Resolve the stale first request — its result should be ignored + await act(async () => { + resolveFirst('stale'); + await Promise.resolve(); + }); + + // Resolve the fresh second request — this should be the final state + await act(async () => { + resolveSecond('fresh'); + await Promise.resolve(); + }); + + const successStates = result.current[0]; + expect(successStates.status).toBe(AsyncStatus.Success); + if (successStates.status === AsyncStatus.Success) { + expect(successStates.data).toBe('fresh'); + } + }); + + it('does not call setState after the component unmounts', async () => { + let resolveAfterUnmount!: (v: string) => void; + const stateChanges: string[] = []; + + const { result, unmount } = renderHook(() => + useAsyncCallback( + async () => + new Promise((res) => { + resolveAfterUnmount = res; + }) + ) + ); + + // Track state changes via the third returned setter + const [, callback, setState] = result.current; + const originalSetState = setState; + // Patch setState to record calls + result.current[2] = (s) => { + stateChanges.push(typeof s === 'function' ? 'fn' : s.status); + originalSetState(s); + }; + + act(() => { + callback(); + }); + + unmount(); + + // Resolve after unmount — alive() returns false, so state should NOT be updated + await act(async () => { + resolveAfterUnmount('late'); + await Promise.resolve(); + }); + + // Only the Loading state (queued before unmount) may have been emitted; + // Success must not appear after unmount. + const successCalls = stateChanges.filter((s) => s === AsyncStatus.Success); + expect(successCalls).toHaveLength(0); + }); +}); diff --git a/src/app/hooks/usePreviousValue.test.tsx b/src/app/hooks/usePreviousValue.test.tsx new file mode 100644 index 000000000..9c3479750 --- /dev/null +++ b/src/app/hooks/usePreviousValue.test.tsx @@ -0,0 +1,51 @@ +// Integration tests: renders a real React component tree via renderHook. +import { describe, it, expect } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import { usePreviousValue } from './usePreviousValue'; + +describe('usePreviousValue', () => { + it('returns the initial value on the first render', () => { + const { result } = renderHook(() => usePreviousValue('current', 'initial')); + expect(result.current).toBe('initial'); + }); + + it('returns the previous value after a prop update', () => { + const { result, rerender } = renderHook( + ({ value }: { value: string }) => usePreviousValue(value, 'initial'), + { initialProps: { value: 'first' } } + ); + + // Before any update: returns initial + expect(result.current).toBe('initial'); + + rerender({ value: 'second' }); + expect(result.current).toBe('first'); + + rerender({ value: 'third' }); + expect(result.current).toBe('second'); + }); + + it('works with numeric values', () => { + const { result, rerender } = renderHook(({ n }: { n: number }) => usePreviousValue(n, 0), { + initialProps: { n: 1 }, + }); + + expect(result.current).toBe(0); + rerender({ n: 42 }); + expect(result.current).toBe(1); + }); + + it('works with object values (reference equality)', () => { + const a = { x: 1 }; + const b = { x: 2 }; + + const { result, rerender } = renderHook( + ({ obj }: { obj: { x: number } }) => usePreviousValue(obj, a), + { initialProps: { obj: a } } + ); + + expect(result.current).toBe(a); + rerender({ obj: b }); + expect(result.current).toBe(a); + }); +}); diff --git a/src/app/hooks/useTimeoutToggle.test.tsx b/src/app/hooks/useTimeoutToggle.test.tsx new file mode 100644 index 000000000..9fa3a6579 --- /dev/null +++ b/src/app/hooks/useTimeoutToggle.test.tsx @@ -0,0 +1,101 @@ +// Integration tests: uses fake timers to control setTimeout behaviour. +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useTimeoutToggle } from './useTimeoutToggle'; + +describe('useTimeoutToggle', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('starts with the default initial value of false', () => { + const { result } = renderHook(() => useTimeoutToggle()); + expect(result.current[0]).toBe(false); + }); + + it('becomes true after trigger() is called', () => { + const { result } = renderHook(() => useTimeoutToggle()); + act(() => { + result.current[1](); + }); + expect(result.current[0]).toBe(true); + }); + + it('resets to false after the default 1500ms duration', () => { + const { result } = renderHook(() => useTimeoutToggle()); + + act(() => { + result.current[1](); + }); + expect(result.current[0]).toBe(true); + + act(() => { + vi.advanceTimersByTime(1500); + }); + expect(result.current[0]).toBe(false); + }); + + it('does not reset before the duration has elapsed', () => { + const { result } = renderHook(() => useTimeoutToggle(500)); + + act(() => { + result.current[1](); + }); + + act(() => { + vi.advanceTimersByTime(499); + }); + expect(result.current[0]).toBe(true); + + act(() => { + vi.advanceTimersByTime(1); + }); + expect(result.current[0]).toBe(false); + }); + + it('re-triggering before timeout resets the countdown', () => { + const { result } = renderHook(() => useTimeoutToggle(1000)); + + act(() => { + result.current[1](); // t=0: trigger + }); + + act(() => { + vi.advanceTimersByTime(800); // t=800 + }); + + act(() => { + result.current[1](); // t=800: re-trigger, timer resets + }); + + act(() => { + vi.advanceTimersByTime(800); // t=1600 — only 800ms since re-trigger + }); + expect(result.current[0]).toBe(true); // still active + + act(() => { + vi.advanceTimersByTime(200); // t=1800 — 1000ms since re-trigger + }); + expect(result.current[0]).toBe(false); + }); + + it('supports a custom initial value of true (inverted toggle)', () => { + const { result } = renderHook(() => useTimeoutToggle(1500, true)); + + expect(result.current[0]).toBe(true); + + act(() => { + result.current[1](); // trigger → false + }); + expect(result.current[0]).toBe(false); + + act(() => { + vi.advanceTimersByTime(1500); // resets back to true + }); + expect(result.current[0]).toBe(true); + }); +}); diff --git a/src/app/utils/common.test.ts b/src/app/utils/common.test.ts index 2a3916705..073181a16 100644 --- a/src/app/utils/common.test.ts +++ b/src/app/utils/common.test.ts @@ -1,7 +1,19 @@ // Example: testing a utility file with multiple related exports. // Uses it.each for table-driven tests — good for exhaustive format coverage. import { describe, it, expect } from 'vitest'; -import { bytesToSize, millisecondsToMinutesAndSeconds, secondsToMinutesAndSeconds } from './common'; +import { + binarySearch, + bytesToSize, + millisecondsToMinutesAndSeconds, + nameInitials, + parseGeoUri, + secondsToMinutesAndSeconds, + splitWithSpace, + suffixRename, + trimLeadingSlash, + trimSlash, + trimTrailingSlash, +} from './common'; describe('bytesToSize', () => { it.each([ @@ -35,3 +47,127 @@ describe('secondsToMinutesAndSeconds', () => { expect(secondsToMinutesAndSeconds(s)).toBe(expected); }); }); + +// binarySearch: match fn returns 0=found, 1=go left (item too large), -1=go right (item too small) +describe('binarySearch', () => { + const nums = [1, 3, 5, 7, 9, 11, 13]; + const matcherFor = + (target: number) => + (n: number): -1 | 0 | 1 => { + if (n === target) return 0; + if (n > target) return 1; + return -1; + }; + + it('finds a value in the middle', () => { + expect(binarySearch(nums, matcherFor(7))).toBe(7); + }); + + it('finds the first element', () => { + expect(binarySearch(nums, matcherFor(1))).toBe(1); + }); + + it('finds the last element', () => { + expect(binarySearch(nums, matcherFor(13))).toBe(13); + }); + + it('returns undefined when value is not present', () => { + expect(binarySearch(nums, matcherFor(6))).toBeUndefined(); + }); + + it('returns undefined for an empty array', () => { + expect(binarySearch([], matcherFor(1))).toBeUndefined(); + }); +}); + +describe('parseGeoUri', () => { + it('parses a basic geo URI', () => { + expect(parseGeoUri('geo:51.5074,-0.1278')).toEqual({ + latitude: '51.5074', + longitude: '-0.1278', + }); + }); + + it('ignores the uncertainty parameter after the semicolon', () => { + expect(parseGeoUri('geo:48.8566,2.3522;u=20')).toEqual({ + latitude: '48.8566', + longitude: '2.3522', + }); + }); + + it('returns undefined for an empty string', () => { + expect(parseGeoUri('')).toBeUndefined(); + }); + + it('returns undefined when there is no colon separator', () => { + expect(parseGeoUri('no-colon-here')).toBeUndefined(); + }); + + it('returns undefined when coordinates are missing', () => { + expect(parseGeoUri('geo:')).toBeUndefined(); + }); +}); + +describe('nameInitials', () => { + it.each<[string | null | undefined, number, string]>([ + ['Alice', 1, 'A'], + ['Bob Smith', 2, 'Bo'], + ['', 1, ''], + [null, 1, ''], + [undefined, 1, ''], + ['😀Emoji', 1, '😀'], + ])('nameInitials(%s, %i) → %s', (str, len, expected) => { + expect(nameInitials(str, len)).toBe(expected); + }); +}); + +describe('suffixRename', () => { + it('appends suffix 1 when the name is immediately valid', () => { + expect(suffixRename('room', () => false)).toBe('room1'); + }); + + it('increments the suffix until the validator returns false', () => { + const taken = new Set(['room1', 'room2', 'room3']); + expect(suffixRename('room', (n) => taken.has(n))).toBe('room4'); + }); +}); + +describe('splitWithSpace', () => { + it.each([ + ['hello world', ['hello', 'world']], + [' leading', ['leading']], + ['trailing ', ['trailing']], + ['', []], + [' ', []], + ['one', ['one']], + ])('splitWithSpace(%s)', (input, expected) => { + expect(splitWithSpace(input)).toEqual(expected); + }); +}); + +describe('trimLeadingSlash / trimTrailingSlash / trimSlash', () => { + it.each([ + ['///foo/bar', 'foo/bar'], + ['foo/bar', 'foo/bar'], + ['/', ''], + ])('trimLeadingSlash(%s) → %s', (input, expected) => { + expect(trimLeadingSlash(input)).toBe(expected); + }); + + it.each([ + ['foo/bar///', 'foo/bar'], + ['foo/bar', 'foo/bar'], + ['/', ''], + ])('trimTrailingSlash(%s) → %s', (input, expected) => { + expect(trimTrailingSlash(input)).toBe(expected); + }); + + it.each([ + ['///foo/bar///', 'foo/bar'], + ['/a/', 'a'], + ['', ''], + ['/', ''], + ])('trimSlash(%s) → %s', (input, expected) => { + expect(trimSlash(input)).toBe(expected); + }); +}); diff --git a/src/app/utils/sanitize.test.ts b/src/app/utils/sanitize.test.ts new file mode 100644 index 000000000..f957b56ec --- /dev/null +++ b/src/app/utils/sanitize.test.ts @@ -0,0 +1,125 @@ +// Tests for sanitizeCustomHtml — security-critical: strips dangerous content from +// user-supplied Matrix message HTML before rendering. +import { describe, it, expect } from 'vitest'; +import { sanitizeCustomHtml } from './sanitize'; + +describe('sanitizeCustomHtml – tag allowlist', () => { + it('passes through permitted tags', () => { + expect(sanitizeCustomHtml('bold')).toBe('bold'); + expect(sanitizeCustomHtml('italic')).toBe('italic'); + expect(sanitizeCustomHtml('snippet')).toBe('snippet'); + }); + + it('strips disallowed tags but keeps their text content', () => { + const result = sanitizeCustomHtml('text'); + expect(result).not.toContain(' and its content entirely', () => { + const result = sanitizeCustomHtml('quoted messageremaining'); + expect(result).not.toContain('quoted message'); + expect(result).toContain('remaining'); + }); +}); + +describe('sanitizeCustomHtml – XSS prevention', () => { + it('strips "); + expect(result).not.toContain(' { + const result = sanitizeCustomHtml('click me'); + expect(result).not.toContain('onclick'); + expect(result).toContain('click me'); + }); + + it('strips javascript: href on anchor tags', () => { + // eslint-disable-next-line no-script-url + const result = sanitizeCustomHtml('link'); + expect(result).not.toMatch(/javascript:/); + }); + + it('strips data: href on anchor tags', () => { + const result = sanitizeCustomHtml( + 'link' + ); + expect(result).not.toContain('data:'); + }); + + it('strips vbscript: href', () => { + const result = sanitizeCustomHtml('link'); + expect(result).not.toContain('vbscript:'); + }); +}); + +describe('sanitizeCustomHtml – link transformer', () => { + it('adds rel and target to http links', () => { + const result = sanitizeCustomHtml('link'); + expect(result).toContain('rel="noreferrer noopener"'); + expect(result).toContain('target="_blank"'); + }); + + it('passes through existing href for http links', () => { + const result = sanitizeCustomHtml('link'); + expect(result).toContain('href="https://example.com"'); + }); +}); + +describe('sanitizeCustomHtml – image transformer', () => { + it('keeps tags with mxc:// src', () => { + const result = sanitizeCustomHtml('img'); + expect(result).toContain(' with https:// src to a safe link', () => { + const result = sanitizeCustomHtml('photo'); + expect(result).not.toContain(' { + // The span transformer unconditionally overwrites the style attribute with + // values derived from data-mx-color / data-mx-bg-color. Inline CSS is always + // discarded; colors must come from the data-mx-* attributes. + it('converts data-mx-color to a CSS color style on span', () => { + const result = sanitizeCustomHtml('text'); + // sanitize-html may normalise whitespace around the colon + expect(result).toMatch(/color:\s*#ff0000/); + }); + + it('discards plain inline style on span (use data-mx-color instead)', () => { + const result = sanitizeCustomHtml('text'); + // The transformer replaces style with data-mx-* values; no data-mx-color + // present here, so style ends up stripped by the allowedStyles check. + expect(result).not.toContain('color: #ff0000'); + }); + + it('strips non-hex values from data-mx-color', () => { + const result = sanitizeCustomHtml('text'); + expect(result).not.toContain('color: red'); + }); + + it('strips disallowed CSS properties', () => { + const result = sanitizeCustomHtml('text'); + expect(result).not.toContain('position'); + }); +}); + +describe('sanitizeCustomHtml – code block class handling', () => { + it('preserves language class on code blocks', () => { + const result = sanitizeCustomHtml('const x = 1;'); + expect(result).toContain('class="language-typescript"'); + }); + + it('strips arbitrary classes not matching language-*', () => { + const result = sanitizeCustomHtml('code'); + expect(result).not.toContain('evil-class'); + }); +}); diff --git a/src/app/utils/time.test.ts b/src/app/utils/time.test.ts new file mode 100644 index 000000000..2f2418bfb --- /dev/null +++ b/src/app/utils/time.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect } from 'vitest'; +import { + daysToMs, + hour12to24, + hour24to12, + hoursToMs, + inSameDay, + minuteDifference, + minutesToMs, + secondsToMs, +} from './time'; + +describe('hour24to12', () => { + it.each([ + [0, 12], // midnight → 12 AM + [1, 1], + [11, 11], + [12, 12], // noon → 12 PM + [13, 1], + [23, 11], + ])('hour24to12(%i) → %i', (input, expected) => { + expect(hour24to12(input)).toBe(expected); + }); +}); + +describe('hour12to24', () => { + it.each([ + [12, true, 12], // 12 PM → 12 + [12, false, 0], // 12 AM → 0 (midnight) + [1, true, 13], // 1 PM → 13 + [1, false, 1], // 1 AM → 1 + [11, true, 23], // 11 PM → 23 + [11, false, 11], // 11 AM → 11 + ])('hour12to24(%i, pm=%s) → %i', (hour, pm, expected) => { + expect(hour12to24(hour, pm)).toBe(expected); + }); +}); + +describe('inSameDay', () => { + // Use noon UTC for all timestamps so the local calendar date is unambiguous + // in any timezone (avoids midnight UTC being the previous day locally). + const base = new Date('2024-01-15T12:00:00Z').getTime(); + const sameDay = new Date('2024-01-15T14:00:00Z').getTime(); + const nextDay = new Date('2024-01-16T12:00:00Z').getTime(); + + it('returns true for two timestamps on the same day', () => { + expect(inSameDay(base, sameDay)).toBe(true); + }); + + it('returns false for timestamps on different days', () => { + expect(inSameDay(base, nextDay)).toBe(false); + }); + + it('returns true when both timestamps are identical', () => { + expect(inSameDay(base, base)).toBe(true); + }); +}); + +describe('minuteDifference', () => { + it.each([ + [0, 60_000, 1], // 1 minute + [0, 3_600_000, 60], // 1 hour = 60 minutes + [0, 90_000, 2], // 1.5 minutes rounds to 2 + [5_000, 0, 0], // less than a minute → 0 + [0, 0, 0], // same timestamp + ])('minuteDifference(%i, %i) → %i', (ts1, ts2, expected) => { + expect(minuteDifference(ts1, ts2)).toBe(expected); + }); + + it('is symmetric (absolute difference)', () => { + expect(minuteDifference(3_600_000, 0)).toBe(minuteDifference(0, 3_600_000)); + }); +}); + +describe('unit conversion helpers', () => { + it('secondsToMs converts seconds to milliseconds', () => { + expect(secondsToMs(1)).toBe(1_000); + expect(secondsToMs(60)).toBe(60_000); + }); + + it('minutesToMs converts minutes to milliseconds', () => { + expect(minutesToMs(1)).toBe(60_000); + expect(minutesToMs(60)).toBe(3_600_000); + }); + + it('hoursToMs converts hours to milliseconds', () => { + expect(hoursToMs(1)).toBe(3_600_000); + expect(hoursToMs(24)).toBe(86_400_000); + }); + + it('daysToMs converts days to milliseconds', () => { + expect(daysToMs(1)).toBe(86_400_000); + expect(daysToMs(7)).toBe(604_800_000); + }); + + it('conversion chain is consistent: daysToMs(1) === hoursToMs(24)', () => { + expect(daysToMs(1)).toBe(hoursToMs(24)); + }); +}); From b2696f4f31198397c8b9666deb7eee73ba269b04 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 16 Mar 2026 13:46:44 -0400 Subject: [PATCH 3/3] test: add tests for regex, mimeTypes, pronouns, useDebounce, useThrottle --- src/app/hooks/useDebounce.test.tsx | 100 +++++++++++++++++++++++++++++ src/app/hooks/useThrottle.test.tsx | 92 ++++++++++++++++++++++++++ src/app/utils/mimeTypes.test.ts | 78 ++++++++++++++++++++++ src/app/utils/pronouns.test.ts | 89 +++++++++++++++++++++++++ src/app/utils/regex.test.ts | 95 +++++++++++++++++++++++++++ 5 files changed, 454 insertions(+) create mode 100644 src/app/hooks/useDebounce.test.tsx create mode 100644 src/app/hooks/useThrottle.test.tsx create mode 100644 src/app/utils/mimeTypes.test.ts create mode 100644 src/app/utils/pronouns.test.ts create mode 100644 src/app/utils/regex.test.ts diff --git a/src/app/hooks/useDebounce.test.tsx b/src/app/hooks/useDebounce.test.tsx new file mode 100644 index 000000000..5287f4b1a --- /dev/null +++ b/src/app/hooks/useDebounce.test.tsx @@ -0,0 +1,100 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useDebounce } from './useDebounce'; + +beforeEach(() => { + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('useDebounce', () => { + it('does not call callback before wait time elapses', () => { + const fn = vi.fn(); + const { result } = renderHook(() => useDebounce(fn, { wait: 200 })); + + act(() => { + result.current('a'); + }); + + act(() => { + vi.advanceTimersByTime(199); + }); + + expect(fn).not.toHaveBeenCalled(); + }); + + it('calls callback after wait time elapses', () => { + const fn = vi.fn(); + const { result } = renderHook(() => useDebounce(fn, { wait: 200 })); + + act(() => { + result.current('a'); + }); + + act(() => { + vi.advanceTimersByTime(200); + }); + + expect(fn).toHaveBeenCalledOnce(); + expect(fn).toHaveBeenCalledWith('a'); + }); + + it('resets the timer on each successive call', () => { + const fn = vi.fn(); + const { result } = renderHook(() => useDebounce(fn, { wait: 200 })); + + act(() => { + result.current('first'); + }); + + act(() => { + vi.advanceTimersByTime(150); + result.current('second'); + }); + + // 150ms into the reset timer — should not have fired yet + act(() => { + vi.advanceTimersByTime(150); + }); + + expect(fn).not.toHaveBeenCalled(); + + // Complete the 200ms wait after the second call + act(() => { + vi.advanceTimersByTime(50); + }); + + expect(fn).toHaveBeenCalledOnce(); + expect(fn).toHaveBeenCalledWith('second'); + }); + + it('only fires once after rapid successive calls', () => { + const fn = vi.fn(); + const { result } = renderHook(() => useDebounce(fn, { wait: 100 })); + + act(() => { + result.current(1); + result.current(2); + result.current(3); + vi.advanceTimersByTime(100); + }); + + expect(fn).toHaveBeenCalledOnce(); + expect(fn).toHaveBeenCalledWith(3); + }); + + it('fires immediately on first call when immediate option is set', () => { + const fn = vi.fn(); + const { result } = renderHook(() => useDebounce(fn, { wait: 200, immediate: true })); + + act(() => { + result.current('go'); + }); + + expect(fn).toHaveBeenCalledOnce(); + expect(fn).toHaveBeenCalledWith('go'); + }); +}); diff --git a/src/app/hooks/useThrottle.test.tsx b/src/app/hooks/useThrottle.test.tsx new file mode 100644 index 000000000..db7a15d3c --- /dev/null +++ b/src/app/hooks/useThrottle.test.tsx @@ -0,0 +1,92 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useThrottle } from './useThrottle'; + +beforeEach(() => { + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('useThrottle', () => { + it('fires once after the wait period even when called multiple times', () => { + const fn = vi.fn(); + const { result } = renderHook(() => useThrottle(fn, { wait: 200 })); + + act(() => { + result.current('a'); + result.current('b'); + result.current('c'); + }); + + expect(fn).not.toHaveBeenCalled(); + + act(() => { + vi.advanceTimersByTime(200); + }); + + expect(fn).toHaveBeenCalledOnce(); + }); + + it('fires with the latest args when called multiple times within the wait', () => { + const fn = vi.fn(); + const { result } = renderHook(() => useThrottle(fn, { wait: 200 })); + + act(() => { + result.current('first'); + result.current('second'); + result.current('third'); + }); + + act(() => { + vi.advanceTimersByTime(200); + }); + + expect(fn).toHaveBeenCalledWith('third'); + }); + + it('does not fire before the wait period ends', () => { + const fn = vi.fn(); + const { result } = renderHook(() => useThrottle(fn, { wait: 300 })); + + act(() => { + result.current('x'); + vi.advanceTimersByTime(299); + }); + + expect(fn).not.toHaveBeenCalled(); + }); + + it('allows a new invocation after the wait period resets', () => { + const fn = vi.fn(); + const { result } = renderHook(() => useThrottle(fn, { wait: 100 })); + + act(() => { + result.current('first-burst'); + vi.advanceTimersByTime(100); + }); + + act(() => { + result.current('second-burst'); + vi.advanceTimersByTime(100); + }); + + expect(fn).toHaveBeenCalledTimes(2); + expect(fn).toHaveBeenNthCalledWith(1, 'first-burst'); + expect(fn).toHaveBeenNthCalledWith(2, 'second-burst'); + }); + + it('fires immediately on first call when immediate option is set', () => { + const fn = vi.fn(); + const { result } = renderHook(() => useThrottle(fn, { wait: 200, immediate: true })); + + act(() => { + result.current('now'); + }); + + expect(fn).toHaveBeenCalledOnce(); + expect(fn).toHaveBeenCalledWith('now'); + }); +}); diff --git a/src/app/utils/mimeTypes.test.ts b/src/app/utils/mimeTypes.test.ts new file mode 100644 index 000000000..0feaf8391 --- /dev/null +++ b/src/app/utils/mimeTypes.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect } from 'vitest'; +import { + getBlobSafeMimeType, + mimeTypeToExt, + getFileNameExt, + getFileNameWithoutExt, + FALLBACK_MIMETYPE, +} from './mimeTypes'; + +describe('getBlobSafeMimeType', () => { + it('passes through known image types', () => { + expect(getBlobSafeMimeType('image/jpeg')).toBe('image/jpeg'); + expect(getBlobSafeMimeType('image/png')).toBe('image/png'); + expect(getBlobSafeMimeType('image/webp')).toBe('image/webp'); + }); + + it('passes through known video and audio types', () => { + expect(getBlobSafeMimeType('video/mp4')).toBe('video/mp4'); + expect(getBlobSafeMimeType('audio/mpeg')).toBe('audio/mpeg'); + }); + + it('converts video/quicktime to video/mp4', () => { + expect(getBlobSafeMimeType('video/quicktime')).toBe('video/mp4'); + }); + + it('returns fallback for unknown mime types', () => { + expect(getBlobSafeMimeType('application/x-unknown')).toBe(FALLBACK_MIMETYPE); + expect(getBlobSafeMimeType('image/bmp')).toBe(FALLBACK_MIMETYPE); + }); + + it('strips charset parameter before checking allowlist', () => { + expect(getBlobSafeMimeType('text/plain; charset=utf-8')).toBe('text/plain'); + }); + + it('returns fallback for non-string input', () => { + // @ts-expect-error — testing runtime safety for external data + expect(getBlobSafeMimeType(null)).toBe(FALLBACK_MIMETYPE); + // @ts-expect-error + expect(getBlobSafeMimeType(42)).toBe(FALLBACK_MIMETYPE); + }); +}); + +describe('mimeTypeToExt', () => { + it.each([ + ['image/jpeg', 'jpeg'], + ['image/png', 'png'], + ['video/mp4', 'mp4'], + ['audio/ogg', 'ogg'], + ['application/pdf', 'pdf'], + ['text/plain', 'plain'], + ])('%s → %s', (mimeType, expected) => { + expect(mimeTypeToExt(mimeType)).toBe(expected); + }); +}); + +describe('getFileNameExt', () => { + it.each([ + ['photo.jpg', 'jpg'], + ['archive.tar.gz', 'gz'], + ['readme.MD', 'MD'], + // No dot: lastIndexOf returns -1, slice(0) returns the full string + ['noextension', 'noextension'], + ])('%s → "%s"', (filename, expected) => { + expect(getFileNameExt(filename)).toBe(expected); + }); +}); + +describe('getFileNameWithoutExt', () => { + it.each([ + ['photo.jpg', 'photo'], + ['archive.tar.gz', 'archive.tar'], + ['noextension', 'noextension'], + ['.gitignore', '.gitignore'], + ['.hidden.txt', '.hidden'], + ])('%s → "%s"', (filename, expected) => { + expect(getFileNameWithoutExt(filename)).toBe(expected); + }); +}); diff --git a/src/app/utils/pronouns.test.ts b/src/app/utils/pronouns.test.ts new file mode 100644 index 000000000..e0471ce64 --- /dev/null +++ b/src/app/utils/pronouns.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect } from 'vitest'; +import { parsePronounsInput, filterPronounsByLanguage } from './pronouns'; + +describe('parsePronounsInput', () => { + it('parses a single pronoun without a language prefix', () => { + expect(parsePronounsInput('he/him')).toEqual([{ summary: 'he/him', language: 'en' }]); + }); + + it('parses multiple comma-separated pronouns', () => { + expect(parsePronounsInput('he/him,she/her')).toEqual([ + { summary: 'he/him', language: 'en' }, + { summary: 'she/her', language: 'en' }, + ]); + }); + + it('parses a pronoun with a language prefix', () => { + expect(parsePronounsInput('de:er/ihm')).toEqual([{ language: 'de', summary: 'er/ihm' }]); + }); + + it('trims whitespace around entries', () => { + expect(parsePronounsInput(' he/him , she/her ')).toEqual([ + { summary: 'he/him', language: 'en' }, + { summary: 'she/her', language: 'en' }, + ]); + }); + + it('truncates summary to 16 characters', () => { + const longSummary = 'this/is/way/too/long'; + const result = parsePronounsInput(longSummary); + expect(result[0]?.summary).toHaveLength(16); + expect(result[0]?.summary).toBe('this/is/way/too/'); + }); + + it('falls back to "en" when language prefix is empty', () => { + expect(parsePronounsInput(':he/him')).toEqual([{ language: 'en', summary: 'he/him' }]); + }); + + it('returns empty array for empty string', () => { + expect(parsePronounsInput('')).toEqual([]); + }); + + it.each([null, undefined, 42 as unknown as string])( + 'returns empty array for non-string input: %s', + (input) => { + expect(parsePronounsInput(input as string)).toEqual([]); + } + ); +}); + +describe('filterPronounsByLanguage', () => { + const pronouns = [ + { summary: 'he/him', language: 'en' }, + { summary: 'er/ihm', language: 'de' }, + { summary: 'il/lui', language: 'fr' }, + ]; + + it('returns all pronouns when filtering is disabled', () => { + const result = filterPronounsByLanguage(pronouns, false, ['en']); + expect(result).toHaveLength(3); + }); + + it('filters to matching language when enabled', () => { + const result = filterPronounsByLanguage(pronouns, true, ['de']); + expect(result).toHaveLength(1); + expect(result[0]?.language).toBe('de'); + }); + + it('returns all pronouns when no entries match (fallthrough)', () => { + const result = filterPronounsByLanguage(pronouns, true, ['ja']); + expect(result).toHaveLength(3); + }); + + it('matches multiple languages', () => { + const result = filterPronounsByLanguage(pronouns, true, ['en', 'fr']); + expect(result).toHaveLength(2); + expect(result.map((p) => p.language)).toEqual(['en', 'fr']); + }); + + it('is case-insensitive for language matching', () => { + const result = filterPronounsByLanguage(pronouns, true, ['EN']); + expect(result).toHaveLength(1); + expect(result[0]?.language).toBe('en'); + }); + + it('returns empty array for non-array input', () => { + // @ts-expect-error — testing runtime safety + expect(filterPronounsByLanguage(null, true, ['en'])).toEqual([]); + }); +}); diff --git a/src/app/utils/regex.test.ts b/src/app/utils/regex.test.ts new file mode 100644 index 000000000..80c65876d --- /dev/null +++ b/src/app/utils/regex.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect } from 'vitest'; +import { sanitizeForRegex, EMAIL_REGEX, URL_REG } from './regex'; + +describe('sanitizeForRegex', () => { + it('returns normal alphanumeric strings unchanged', () => { + expect(sanitizeForRegex('hello123')).toBe('hello123'); + }); + + it.each([ + ['|', '\\|'], + ['\\', '\\\\'], + ['{', '\\{'], + ['}', '\\}'], + ['(', '\\('], + [')', '\\)'], + ['[', '\\['], + [']', '\\]'], + ['^', '\\^'], + ['$', '\\$'], + ['+', '\\+'], + ['*', '\\*'], + ['?', '\\?'], + ['.', '\\.'], + ['-', '\\x2d'], + ])('escapes special char %s', (input, expected) => { + expect(sanitizeForRegex(input)).toBe(expected); + }); + + it('escapes all special chars in a complex string', () => { + const result = sanitizeForRegex('a.b+c?d'); + expect(result).toBe('a\\.b\\+c\\?d'); + }); + + it('produces a valid regex that matches the original string literally', () => { + const input = 'foo.bar (baz)+'; + const safePattern = sanitizeForRegex(input); + const reg = new RegExp(safePattern); + expect(reg.test(input)).toBe(true); + // Without escaping, `.` would match any char — make sure it's literal + expect(reg.test('fooXbar (baz)+')).toBe(false); + }); + + it('handles empty string', () => { + expect(sanitizeForRegex('')).toBe(''); + }); +}); + +describe('EMAIL_REGEX', () => { + it.each([ + 'user@example.com', + 'user.name+tag@subdomain.example.org', + 'x@y.io', + 'user123@domain.co.uk', + ])('matches valid email: %s', (email) => { + expect(EMAIL_REGEX.test(email)).toBe(true); + }); + + it.each(['notanemail', '@nodomain.com', 'missing-at-sign.com', 'two@@at.com'])( + 'rejects invalid email: %s', + (email) => { + expect(EMAIL_REGEX.test(email)).toBe(false); + } + ); +}); + +describe('URL_REG', () => { + it('matches a simple http URL', () => { + const matches = 'visit http://example.com today'.match(URL_REG); + expect(matches).not.toBeNull(); + expect(matches?.[0]).toBe('http://example.com'); + }); + + it('matches a simple https URL', () => { + const matches = 'go to https://example.com/path?q=1'.match(URL_REG); + expect(matches?.[0]).toBe('https://example.com/path?q=1'); + }); + + it('finds multiple URLs in a string', () => { + const matches = 'https://one.com and https://two.org'.match(URL_REG); + expect(matches).toHaveLength(2); + expect(matches?.[0]).toBe('https://one.com'); + expect(matches?.[1]).toBe('https://two.org'); + }); + + it('does not match plain text without a scheme', () => { + const matches = 'just some text without a link'.match(URL_REG); + expect(matches).toBeNull(); + }); + + it('strips trailing punctuation from matched URL', () => { + // The pattern uses a negative lookbehind to exclude trailing punctuation + const matches = 'see https://example.com.'.match(URL_REG); + expect(matches?.[0]).not.toMatch(/\.$/); + }); +});