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..eb88c8d66 100644
--- a/knip.json
+++ b/knip.json
@@ -8,7 +8,8 @@
"ignoreDependencies": [
"buffer",
"@element-hq/element-call-embedded",
- "@matrix-org/matrix-sdk-crypto-wasm"
+ "@matrix-org/matrix-sdk-crypto-wasm",
+ "@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/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/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/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/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/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/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..073181a16
--- /dev/null
+++ b/src/app/utils/common.test.ts
@@ -0,0 +1,173 @@
+// 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 {
+ binarySearch,
+ bytesToSize,
+ millisecondsToMinutesAndSeconds,
+ nameInitials,
+ parseGeoUri,
+ secondsToMinutesAndSeconds,
+ splitWithSpace,
+ suffixRename,
+ trimLeadingSlash,
+ trimSlash,
+ trimTrailingSlash,
+} 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);
+ });
+});
+
+// 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/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/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(/\.$/);
+ });
+});
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('');
+ expect(result).not.toContain('