diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..4b0dfd1 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,56 @@ +name: Deploy to GitHub Pages + +on: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build + run: pnpm build + + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@v3 + with: + path: dist + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7dbbeb6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +.vite/ +coverage/ +*.local diff --git a/README.md b/README.md index 04e41b0..3f4cf64 100644 --- a/README.md +++ b/README.md @@ -1 +1,81 @@ -# replay \ No newline at end of file +# replay + +A single-page web app that loads a JSON recording produced by `jsPsych.getSessionRecording()` (`record_session: true`) and reconstructs a visual replay of the participant's session. + +## Overview + +The replayer is purely observational — it applies recorded DOM mutations and visualizes recorded input events. It does not re-run plugin code. + +**Schema contract:** recordings with `schema_version: 1` are supported. Any other version is rejected. + +## Features + +- **Load recordings** via file picker or drag-and-drop +- **Trial sidebar** — list of all trials with plugin name; click to jump +- **Trial data viewer** — see the recorded `trial_data` JSON for the selected trial +- **DOM replay** — reconstructs `initial_dom` at each trial's `on_load` state +- **Event playback** — applies mutations, mouse/touch/keyboard input events in real time +- **Player controls** — play, pause, restart, prev/next trial +- **Scrub bar** — seek anywhere within a trial (applies events synchronously up to the target) +- **Speed control** — 0.25×, 0.5×, 1×, 2×, 4× +- **Cursor overlay** — tracks `mouse.move` / `touch` positions +- **Keystroke indicator** — last 3 keys shown on-screen +- **Focus/blur overlay** — shows when the window was blurred during the session +- **Viewport resizing** — iframe resizes to match `viewport` / `viewport_changes` + +## Getting Started + +```bash +pnpm install +pnpm dev +``` + +Open `http://localhost:5173`. The sample recording in `public/examples/sample-recording.json` loads automatically. + +## Build + +```bash +pnpm build +``` + +Output is in `dist/` — a static SPA that can be served from any web server or opened locally. + +## Tests + +```bash +pnpm test +``` + +Unit tests (vitest) cover: +- `validate()` — schema version checking, field validation, JSON string input +- `instantiateDom()` — element/text/comment node creation, id map registration, security (no `on*` attrs), recursive children +- `ReplayEngine.applyEventsSync()` — `dom.text`, `dom.attr`, security, empty events + +## Architecture + +``` +src/ +├── main.ts # bootstrap: file picker, wires sidebar + player +├── schema/ +│ └── types.ts # schema types + validate() +├── replay/ +│ ├── dom.ts # instantiateDom(), removeFromMap() +│ ├── engine.ts # ReplayEngine: scheduling + event dispatch +│ └── viewport.ts # ViewportManager: iframe sizing +└── ui/ + ├── player.ts # Player: play/pause/seek/speed/trial nav + ├── sidebar.ts # Sidebar: trial list + trial_data viewer + └── overlay.ts # cursor dot + keystroke indicator + focus overlay +``` + +The replay stage is an ` +
+
+
+
Window blurred
+
+ + + + + + + + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..9596bea --- /dev/null +++ b/package.json @@ -0,0 +1,23 @@ +{ + "name": "jspsych-replay", + "version": "0.1.0", + "description": "Visual replay of jsPsych session recordings", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "typescript": "^5.4.0", + "vite": "^5.2.0", + "vitest": "^1.6.0", + "@vitest/coverage-v8": "^1.6.0", + "jsdom": "^24.0.0", + "@vitest/browser": "^1.6.0" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..2696625 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,1952 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@types/node': + specifier: ^20.0.0 + version: 20.19.39 + '@vitest/browser': + specifier: ^1.6.0 + version: 1.6.1(vitest@1.6.1) + '@vitest/coverage-v8': + specifier: ^1.6.0 + version: 1.6.1(vitest@1.6.1) + jsdom: + specifier: ^24.0.0 + version: 24.1.3 + typescript: + specifier: ^5.4.0 + version: 5.9.3 + vite: + specifier: ^5.2.0 + version: 5.4.21(@types/node@20.19.39) + vitest: + specifier: ^1.6.0 + version: 1.6.1(@types/node@20.19.39)(@vitest/browser@1.6.1)(jsdom@24.1.3) + +packages: + + '@ampproject/remapping@2.3.0': + resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} + engines: {node: '>=6.0.0'} + + '@asamuzakjp/css-color@3.2.0': + resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.3': + resolution: {integrity: sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@0.2.3': + resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + + '@csstools/color-helpers@5.1.0': + resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} + engines: {node: '>=18'} + + '@csstools/css-calc@2.1.4': + resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-color-parser@3.1.0': + resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} + + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@istanbuljs/schema@0.1.6': + resolution: {integrity: sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==} + engines: {node: '>=8'} + + '@jest/schemas@29.6.3': + resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + + '@rollup/rollup-android-arm-eabi@4.60.2': + resolution: {integrity: sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.60.2': + resolution: {integrity: sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.60.2': + resolution: {integrity: sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.60.2': + resolution: {integrity: sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.60.2': + resolution: {integrity: sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.60.2': + resolution: {integrity: sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.60.2': + resolution: {integrity: sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.60.2': + resolution: {integrity: sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.60.2': + resolution: {integrity: sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.60.2': + resolution: {integrity: sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.60.2': + resolution: {integrity: sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.60.2': + resolution: {integrity: sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.60.2': + resolution: {integrity: sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.60.2': + resolution: {integrity: sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.60.2': + resolution: {integrity: sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.60.2': + resolution: {integrity: sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.60.2': + resolution: {integrity: sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.60.2': + resolution: {integrity: sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.60.2': + resolution: {integrity: sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.60.2': + resolution: {integrity: sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.60.2': + resolution: {integrity: sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.60.2': + resolution: {integrity: sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.60.2': + resolution: {integrity: sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.60.2': + resolution: {integrity: sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.60.2': + resolution: {integrity: sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==} + cpu: [x64] + os: [win32] + + '@sinclair/typebox@0.27.10': + resolution: {integrity: sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/node@20.19.39': + resolution: {integrity: sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==} + + '@vitest/browser@1.6.1': + resolution: {integrity: sha512-9ZYW6KQ30hJ+rIfJoGH4wAub/KAb4YrFzX0kVLASvTm7nJWVC5EAv5SlzlXVl3h3DaUq5aqHlZl77nmOPnALUQ==} + peerDependencies: + playwright: '*' + safaridriver: '*' + vitest: 1.6.1 + webdriverio: '*' + peerDependenciesMeta: + playwright: + optional: true + safaridriver: + optional: true + webdriverio: + optional: true + + '@vitest/coverage-v8@1.6.1': + resolution: {integrity: sha512-6YeRZwuO4oTGKxD3bijok756oktHSIm3eczVVzNe3scqzuhLwltIF3S9ZL/vwOVIpURmU6SnZhziXXAfw8/Qlw==} + peerDependencies: + vitest: 1.6.1 + + '@vitest/expect@1.6.1': + resolution: {integrity: sha512-jXL+9+ZNIJKruofqXuuTClf44eSpcHlgj3CiuNihUF3Ioujtmc0zIa3UJOW5RjDK1YLBJZnWBlPuqhYycLioog==} + + '@vitest/runner@1.6.1': + resolution: {integrity: sha512-3nSnYXkVkf3mXFfE7vVyPmi3Sazhb/2cfZGGs0JRzFsPFvAMBEcrweV1V1GsrstdXeKCTXlJbvnQwGWgEIHmOA==} + + '@vitest/snapshot@1.6.1': + resolution: {integrity: sha512-WvidQuWAzU2p95u8GAKlRMqMyN1yOJkGHnx3M1PL9Raf7AQ1kwLKg04ADlCa3+OXUZE7BceOhVZiuWAbzCKcUQ==} + + '@vitest/spy@1.6.1': + resolution: {integrity: sha512-MGcMmpGkZebsMZhbQKkAf9CX5zGvjkBTqf8Zx3ApYWXr3wG+QvEu2eXWfnIIWYSJExIp4V9FCKDEeygzkYrXMw==} + + '@vitest/utils@1.6.1': + resolution: {integrity: sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g==} + + acorn-walk@8.3.5: + resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==} + engines: {node: '>=0.4.0'} + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + assertion-error@1.1.0: + resolution: {integrity: sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + brace-expansion@1.1.14: + resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==} + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + chai@4.5.0: + resolution: {integrity: sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==} + engines: {node: '>=4'} + + check-error@1.0.3: + resolution: {integrity: sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + cssstyle@4.6.0: + resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} + engines: {node: '>=18'} + + data-urls@5.0.0: + resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} + engines: {node: '>=18'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + deep-eql@4.1.4: + resolution: {integrity: sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==} + engines: {node: '>=6'} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + diff-sequences@29.6.3: + resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + execa@8.0.1: + resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==} + engines: {node: '>=16.17'} + + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-func-name@2.0.2: + resolution: {integrity: sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-stream@8.0.1: + resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==} + engines: {node: '>=16'} + + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.3: + resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} + engines: {node: '>= 0.4'} + + html-encoding-sniffer@4.0.0: + resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} + engines: {node: '>=18'} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + human-signals@5.0.0: + resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} + engines: {node: '>=16.17.0'} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + is-stream@3.0.0: + resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + 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-lib-source-maps@5.0.6: + resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + jsdom@24.1.3: + resolution: {integrity: sha512-MyL55p3Ut3cXbeBEG7Hcv0mVM8pp8PBNWxRqchZnSfAiES1v1mRnMeFfaHWIPULpwsYfvO+ZmMZz5tGCnjzDUQ==} + engines: {node: '>=18'} + peerDependencies: + canvas: ^2.11.2 + peerDependenciesMeta: + canvas: + optional: true + + local-pkg@0.5.1: + resolution: {integrity: sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==} + engines: {node: '>=14'} + + loupe@2.3.7: + resolution: {integrity: sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + magicast@0.3.5: + resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} + + 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'} + + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mimic-fn@4.0.0: + resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} + engines: {node: '>=12'} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + mlly@1.8.2: + resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + npm-run-path@5.3.0: + resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + nwsapi@2.2.23: + resolution: {integrity: sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + onetime@6.0.0: + resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} + engines: {node: '>=12'} + + p-limit@5.0.0: + resolution: {integrity: sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==} + engines: {node: '>=18'} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} + engines: {node: '>=12'} + + pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@1.1.1: + resolution: {integrity: sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + postcss@8.5.13: + resolution: {integrity: sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==} + engines: {node: ^10 || ^12 || >=14} + + pretty-format@29.7.0: + resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + psl@1.15.0: + resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + querystringify@2.2.0: + resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} + + react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + + requires-port@1.0.0: + resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + + rollup@4.60.2: + resolution: {integrity: sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + rrweb-cssom@0.7.1: + resolution: {integrity: sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==} + + rrweb-cssom@0.8.0: + resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + sirv@2.0.4: + resolution: {integrity: sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==} + engines: {node: '>= 10'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + strip-final-newline@3.0.0: + resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} + engines: {node: '>=12'} + + strip-literal@2.1.1: + resolution: {integrity: sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + test-exclude@6.0.0: + resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} + engines: {node: '>=8'} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinypool@0.8.4: + resolution: {integrity: sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==} + engines: {node: '>=14.0.0'} + + tinyspy@2.2.1: + resolution: {integrity: sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==} + engines: {node: '>=14.0.0'} + + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + + tough-cookie@4.1.4: + resolution: {integrity: sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==} + engines: {node: '>=6'} + + tr46@5.1.1: + resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} + engines: {node: '>=18'} + + type-detect@4.1.0: + resolution: {integrity: sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==} + engines: {node: '>=4'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + ufo@1.6.4: + resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + universalify@0.2.0: + resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==} + engines: {node: '>= 4.0.0'} + + url-parse@1.5.10: + resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} + + vite-node@1.6.1: + resolution: {integrity: sha512-YAXkfvGtuTzwWbDSACdJSg4A4DZiAqckWe90Zapc/sEX3XvHcw1NdurM/6od8J207tSDqNbSsgdCacBgvJKFuA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + vitest@1.6.1: + resolution: {integrity: sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': 1.6.1 + '@vitest/ui': 1.6.1 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + whatwg-url@14.2.0: + resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} + engines: {node: '>=18'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + ws@8.20.0: + resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + 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==} + + yocto-queue@1.2.2: + resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} + engines: {node: '>=12.20'} + +snapshots: + + '@ampproject/remapping@2.3.0': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@asamuzakjp/css-color@3.2.0': + dependencies: + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + lru-cache: 10.4.3 + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/parser@7.29.3': + dependencies: + '@babel/types': 7.29.0 + + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@bcoe/v8-coverage@0.2.3': {} + + '@csstools/color-helpers@5.1.0': {} + + '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/color-helpers': 5.1.0 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-tokenizer@3.0.4': {} + + '@esbuild/aix-ppc64@0.21.5': + optional: true + + '@esbuild/android-arm64@0.21.5': + optional: true + + '@esbuild/android-arm@0.21.5': + optional: true + + '@esbuild/android-x64@0.21.5': + optional: true + + '@esbuild/darwin-arm64@0.21.5': + optional: true + + '@esbuild/darwin-x64@0.21.5': + optional: true + + '@esbuild/freebsd-arm64@0.21.5': + optional: true + + '@esbuild/freebsd-x64@0.21.5': + optional: true + + '@esbuild/linux-arm64@0.21.5': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + + '@esbuild/linux-riscv64@0.21.5': + optional: true + + '@esbuild/linux-s390x@0.21.5': + optional: true + + '@esbuild/linux-x64@0.21.5': + optional: true + + '@esbuild/netbsd-x64@0.21.5': + optional: true + + '@esbuild/openbsd-x64@0.21.5': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + + '@esbuild/win32-arm64@0.21.5': + optional: true + + '@esbuild/win32-ia32@0.21.5': + optional: true + + '@esbuild/win32-x64@0.21.5': + optional: true + + '@istanbuljs/schema@0.1.6': {} + + '@jest/schemas@29.6.3': + dependencies: + '@sinclair/typebox': 0.27.10 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@polka/url@1.0.0-next.29': {} + + '@rollup/rollup-android-arm-eabi@4.60.2': + optional: true + + '@rollup/rollup-android-arm64@4.60.2': + optional: true + + '@rollup/rollup-darwin-arm64@4.60.2': + optional: true + + '@rollup/rollup-darwin-x64@4.60.2': + optional: true + + '@rollup/rollup-freebsd-arm64@4.60.2': + optional: true + + '@rollup/rollup-freebsd-x64@4.60.2': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.60.2': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.60.2': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.60.2': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.60.2': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.60.2': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.60.2': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.60.2': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.60.2': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.60.2': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.60.2': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.60.2': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.60.2': + optional: true + + '@rollup/rollup-linux-x64-musl@4.60.2': + optional: true + + '@rollup/rollup-openbsd-x64@4.60.2': + optional: true + + '@rollup/rollup-openharmony-arm64@4.60.2': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.60.2': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.60.2': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.60.2': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.60.2': + optional: true + + '@sinclair/typebox@0.27.10': {} + + '@types/estree@1.0.8': {} + + '@types/node@20.19.39': + dependencies: + undici-types: 6.21.0 + + '@vitest/browser@1.6.1(vitest@1.6.1)': + dependencies: + '@vitest/utils': 1.6.1 + magic-string: 0.30.21 + sirv: 2.0.4 + vitest: 1.6.1(@types/node@20.19.39)(@vitest/browser@1.6.1)(jsdom@24.1.3) + + '@vitest/coverage-v8@1.6.1(vitest@1.6.1)': + dependencies: + '@ampproject/remapping': 2.3.0 + '@bcoe/v8-coverage': 0.2.3 + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.6 + istanbul-reports: 3.2.0 + magic-string: 0.30.21 + magicast: 0.3.5 + picocolors: 1.1.1 + std-env: 3.10.0 + strip-literal: 2.1.1 + test-exclude: 6.0.0 + vitest: 1.6.1(@types/node@20.19.39)(@vitest/browser@1.6.1)(jsdom@24.1.3) + transitivePeerDependencies: + - supports-color + + '@vitest/expect@1.6.1': + dependencies: + '@vitest/spy': 1.6.1 + '@vitest/utils': 1.6.1 + chai: 4.5.0 + + '@vitest/runner@1.6.1': + dependencies: + '@vitest/utils': 1.6.1 + p-limit: 5.0.0 + pathe: 1.1.2 + + '@vitest/snapshot@1.6.1': + dependencies: + magic-string: 0.30.21 + pathe: 1.1.2 + pretty-format: 29.7.0 + + '@vitest/spy@1.6.1': + dependencies: + tinyspy: 2.2.1 + + '@vitest/utils@1.6.1': + dependencies: + diff-sequences: 29.6.3 + estree-walker: 3.0.3 + loupe: 2.3.7 + pretty-format: 29.7.0 + + acorn-walk@8.3.5: + dependencies: + acorn: 8.16.0 + + acorn@8.16.0: {} + + agent-base@7.1.4: {} + + ansi-styles@5.2.0: {} + + assertion-error@1.1.0: {} + + asynckit@0.4.0: {} + + balanced-match@1.0.2: {} + + brace-expansion@1.1.14: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + cac@6.7.14: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + chai@4.5.0: + dependencies: + assertion-error: 1.1.0 + check-error: 1.0.3 + deep-eql: 4.1.4 + get-func-name: 2.0.2 + loupe: 2.3.7 + pathval: 1.1.1 + type-detect: 4.1.0 + + check-error@1.0.3: + dependencies: + get-func-name: 2.0.2 + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + concat-map@0.0.1: {} + + confbox@0.1.8: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + cssstyle@4.6.0: + dependencies: + '@asamuzakjp/css-color': 3.2.0 + rrweb-cssom: 0.8.0 + + data-urls@5.0.0: + dependencies: + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decimal.js@10.6.0: {} + + deep-eql@4.1.4: + dependencies: + type-detect: 4.1.0 + + delayed-stream@1.0.0: {} + + diff-sequences@29.6.3: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + entities@6.0.1: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.3 + + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + execa@8.0.1: + dependencies: + cross-spawn: 7.0.6 + get-stream: 8.0.1 + human-signals: 5.0.0 + is-stream: 3.0.0 + merge-stream: 2.0.0 + npm-run-path: 5.3.0 + onetime: 6.0.0 + signal-exit: 4.1.0 + strip-final-newline: 3.0.0 + + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.3 + mime-types: 2.1.35 + + fs.realpath@1.0.0: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + get-func-name@2.0.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.3 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + get-stream@8.0.1: {} + + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.5 + once: 1.4.0 + path-is-absolute: 1.0.1 + + gopd@1.2.0: {} + + has-flag@4.0.0: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.3: + dependencies: + function-bind: 1.1.2 + + html-encoding-sniffer@4.0.0: + dependencies: + whatwg-encoding: 3.1.1 + + html-escaper@2.0.2: {} + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + human-signals@5.0.0: {} + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.4: {} + + is-potential-custom-element-name@1.0.1: {} + + is-stream@3.0.0: {} + + 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-lib-source-maps@5.0.6: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + transitivePeerDependencies: + - supports-color + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + js-tokens@9.0.1: {} + + jsdom@24.1.3: + dependencies: + cssstyle: 4.6.0 + data-urls: 5.0.0 + decimal.js: 10.6.0 + form-data: 4.0.5 + html-encoding-sniffer: 4.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.23 + parse5: 7.3.0 + rrweb-cssom: 0.7.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 4.1.4 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 3.1.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + ws: 8.20.0 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + local-pkg@0.5.1: + dependencies: + mlly: 1.8.2 + pkg-types: 1.3.1 + + loupe@2.3.7: + dependencies: + get-func-name: 2.0.2 + + lru-cache@10.4.3: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + magicast@0.3.5: + dependencies: + '@babel/parser': 7.29.3 + '@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: {} + + merge-stream@2.0.0: {} + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mimic-fn@4.0.0: {} + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.14 + + mlly@1.8.2: + dependencies: + acorn: 8.16.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.4 + + mrmime@2.0.1: {} + + ms@2.1.3: {} + + nanoid@3.3.12: {} + + npm-run-path@5.3.0: + dependencies: + path-key: 4.0.0 + + nwsapi@2.2.23: {} + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + onetime@6.0.0: + dependencies: + mimic-fn: 4.0.0 + + p-limit@5.0.0: + dependencies: + yocto-queue: 1.2.2 + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + + path-is-absolute@1.0.1: {} + + path-key@3.1.1: {} + + path-key@4.0.0: {} + + pathe@1.1.2: {} + + pathe@2.0.3: {} + + pathval@1.1.1: {} + + picocolors@1.1.1: {} + + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.2 + pathe: 2.0.3 + + postcss@8.5.13: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + pretty-format@29.7.0: + dependencies: + '@jest/schemas': 29.6.3 + ansi-styles: 5.2.0 + react-is: 18.3.1 + + psl@1.15.0: + dependencies: + punycode: 2.3.1 + + punycode@2.3.1: {} + + querystringify@2.2.0: {} + + react-is@18.3.1: {} + + requires-port@1.0.0: {} + + rollup@4.60.2: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.60.2 + '@rollup/rollup-android-arm64': 4.60.2 + '@rollup/rollup-darwin-arm64': 4.60.2 + '@rollup/rollup-darwin-x64': 4.60.2 + '@rollup/rollup-freebsd-arm64': 4.60.2 + '@rollup/rollup-freebsd-x64': 4.60.2 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.2 + '@rollup/rollup-linux-arm-musleabihf': 4.60.2 + '@rollup/rollup-linux-arm64-gnu': 4.60.2 + '@rollup/rollup-linux-arm64-musl': 4.60.2 + '@rollup/rollup-linux-loong64-gnu': 4.60.2 + '@rollup/rollup-linux-loong64-musl': 4.60.2 + '@rollup/rollup-linux-ppc64-gnu': 4.60.2 + '@rollup/rollup-linux-ppc64-musl': 4.60.2 + '@rollup/rollup-linux-riscv64-gnu': 4.60.2 + '@rollup/rollup-linux-riscv64-musl': 4.60.2 + '@rollup/rollup-linux-s390x-gnu': 4.60.2 + '@rollup/rollup-linux-x64-gnu': 4.60.2 + '@rollup/rollup-linux-x64-musl': 4.60.2 + '@rollup/rollup-openbsd-x64': 4.60.2 + '@rollup/rollup-openharmony-arm64': 4.60.2 + '@rollup/rollup-win32-arm64-msvc': 4.60.2 + '@rollup/rollup-win32-ia32-msvc': 4.60.2 + '@rollup/rollup-win32-x64-gnu': 4.60.2 + '@rollup/rollup-win32-x64-msvc': 4.60.2 + fsevents: 2.3.3 + + rrweb-cssom@0.7.1: {} + + rrweb-cssom@0.8.0: {} + + safer-buffer@2.1.2: {} + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + semver@7.7.4: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + siginfo@2.0.0: {} + + signal-exit@4.1.0: {} + + sirv@2.0.4: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + strip-final-newline@3.0.0: {} + + strip-literal@2.1.1: + dependencies: + js-tokens: 9.0.1 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + symbol-tree@3.2.4: {} + + test-exclude@6.0.0: + dependencies: + '@istanbuljs/schema': 0.1.6 + glob: 7.2.3 + minimatch: 3.1.5 + + tinybench@2.9.0: {} + + tinypool@0.8.4: {} + + tinyspy@2.2.1: {} + + totalist@3.0.1: {} + + tough-cookie@4.1.4: + dependencies: + psl: 1.15.0 + punycode: 2.3.1 + universalify: 0.2.0 + url-parse: 1.5.10 + + tr46@5.1.1: + dependencies: + punycode: 2.3.1 + + type-detect@4.1.0: {} + + typescript@5.9.3: {} + + ufo@1.6.4: {} + + undici-types@6.21.0: {} + + universalify@0.2.0: {} + + url-parse@1.5.10: + dependencies: + querystringify: 2.2.0 + requires-port: 1.0.0 + + vite-node@1.6.1(@types/node@20.19.39): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + pathe: 1.1.2 + picocolors: 1.1.1 + vite: 5.4.21(@types/node@20.19.39) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + vite@5.4.21(@types/node@20.19.39): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.13 + rollup: 4.60.2 + optionalDependencies: + '@types/node': 20.19.39 + fsevents: 2.3.3 + + vitest@1.6.1(@types/node@20.19.39)(@vitest/browser@1.6.1)(jsdom@24.1.3): + dependencies: + '@vitest/expect': 1.6.1 + '@vitest/runner': 1.6.1 + '@vitest/snapshot': 1.6.1 + '@vitest/spy': 1.6.1 + '@vitest/utils': 1.6.1 + acorn-walk: 8.3.5 + chai: 4.5.0 + debug: 4.4.3 + execa: 8.0.1 + local-pkg: 0.5.1 + magic-string: 0.30.21 + pathe: 1.1.2 + picocolors: 1.1.1 + std-env: 3.10.0 + strip-literal: 2.1.1 + tinybench: 2.9.0 + tinypool: 0.8.4 + vite: 5.4.21(@types/node@20.19.39) + vite-node: 1.6.1(@types/node@20.19.39) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 20.19.39 + '@vitest/browser': 1.6.1(vitest@1.6.1) + jsdom: 24.1.3 + transitivePeerDependencies: + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@7.0.0: {} + + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@4.0.0: {} + + whatwg-url@14.2.0: + dependencies: + tr46: 5.1.1 + webidl-conversions: 7.0.0 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + wrappy@1.0.2: {} + + ws@8.20.0: {} + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + + yocto-queue@1.2.2: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..49c0ad7 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +allowBuilds: + esbuild: false diff --git a/public/examples/sample-recording.json b/public/examples/sample-recording.json new file mode 100644 index 0000000..1ddd718 --- /dev/null +++ b/public/examples/sample-recording.json @@ -0,0 +1,215 @@ +{ + "schema_version": 1, + "jspsych_version": "8.0.0", + "recording_started_at": "2024-06-01T10:00:00.000Z", + "recording_started_at_perf": 0, + "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36", + "viewport": { "w": 800, "h": 600, "dpr": 2, "scale": 1, "offset_x": 0, "offset_y": 0 }, + "rng": { "seed": "jspsych-replay-sample", "math_random_patched": true }, + "display_element_id": "jspsych-content", + "viewport_changes": [], + "rng_calls": [ + { "t": 12.5, "fn": "Math.random", "args": [], "result": 0.4782 }, + { "t": 13.1, "fn": "Math.random", "args": [], "result": 0.1093 } + ], + "ended_at_perf": 4200, + "end_reason": "finished", + "trials": [ + { + "trial_index": 0, + "t_start": 10, + "t_dom_ready": 50, + "t_end": 800, + "plugin": "html-keyboard-response", + "trial_data": { + "rt": 750, + "response": "f", + "trial_type": "html-keyboard-response", + "trial_index": 0, + "time_elapsed": 800 + }, + "initial_dom": { + "id": 1, + "kind": "element", + "tag": "div", + "attrs": { "id": "jspsych-html-keyboard-response-stimulus" }, + "children": [ + { + "id": 2, + "kind": "element", + "tag": "p", + "attrs": { "style": "font-size: 48px; text-align: center;" }, + "children": [{ "id": 3, "kind": "text", "text": "+" }] + } + ] + }, + "events": [ + { "type": "mouse.move", "t": 100, "x": 420, "y": 310 }, + { "type": "mouse.move", "t": 200, "x": 400, "y": 300 }, + { "type": "key.down", "t": 750, "key": "f", "code": "KeyF", "mods": { "ctrl": false, "shift": false, "alt": false, "meta": false }, "repeat": false, "target": null }, + { "type": "key.up", "t": 800, "key": "f", "code": "KeyF", "mods": { "ctrl": false, "shift": false, "alt": false, "meta": false }, "repeat": false, "target": null } + ] + }, + { + "trial_index": 1, + "t_start": 810, + "t_dom_ready": 850, + "t_end": 1600, + "plugin": "html-keyboard-response", + "trial_data": { + "rt": 750, + "response": "j", + "trial_type": "html-keyboard-response", + "trial_index": 1, + "time_elapsed": 1600 + }, + "initial_dom": { + "id": 10, + "kind": "element", + "tag": "div", + "attrs": { "id": "jspsych-html-keyboard-response-stimulus" }, + "children": [ + { + "id": 11, + "kind": "element", + "tag": "p", + "attrs": { "style": "font-size: 48px; text-align: center;" }, + "children": [{ "id": 12, "kind": "text", "text": "▶" }] + } + ] + }, + "events": [ + { "type": "mouse.move", "t": 900, "x": 390, "y": 295 }, + { "type": "mouse.move", "t": 1000, "x": 401, "y": 300 }, + { "type": "key.down", "t": 1550, "key": "j", "code": "KeyJ", "mods": { "ctrl": false, "shift": false, "alt": false, "meta": false }, "repeat": false, "target": null }, + { "type": "key.up", "t": 1600, "key": "j", "code": "KeyJ", "mods": { "ctrl": false, "shift": false, "alt": false, "meta": false }, "repeat": false, "target": null } + ] + }, + { + "trial_index": 2, + "t_start": 1620, + "t_dom_ready": 1660, + "t_end": 2900, + "plugin": "survey-text", + "trial_data": { + "rt": 1240, + "response": { "Q0": "hello world" }, + "trial_type": "survey-text", + "trial_index": 2, + "time_elapsed": 2900 + }, + "initial_dom": { + "id": 20, + "kind": "element", + "tag": "div", + "attrs": { "id": "jspsych-survey-text", "class": "jspsych-survey-text-form" }, + "children": [ + { + "id": 21, + "kind": "element", + "tag": "p", + "attrs": {}, + "children": [{ "id": 22, "kind": "text", "text": "Please describe what you saw:" }] + }, + { + "id": 23, + "kind": "element", + "tag": "input", + "attrs": { "type": "text", "id": "jspsych-survey-text-response-0", "class": "jspsych-survey-text-input" }, + "children": [] + }, + { + "id": 24, + "kind": "element", + "tag": "button", + "attrs": { "id": "jspsych-survey-text-next", "class": "jspsych-btn" }, + "children": [{ "id": 25, "kind": "text", "text": "Continue" }] + } + ] + }, + "events": [ + { "type": "mouse.move", "t": 1800, "x": 400, "y": 350 }, + { "type": "mouse.click", "t": 1850, "x": 400, "y": 350, "button": 0, "target": 23 }, + { "type": "dom.attr", "t": 1851, "node": 23, "name": "value", "value": "h" }, + { "type": "key.down", "t": 1855, "key": "h", "code": "KeyH", "mods": { "ctrl": false, "shift": false, "alt": false, "meta": false }, "repeat": false, "target": 23 }, + { "type": "dom.attr", "t": 1950, "node": 23, "name": "value", "value": "he" }, + { "type": "key.down", "t": 1960, "key": "e", "code": "KeyE", "mods": { "ctrl": false, "shift": false, "alt": false, "meta": false }, "repeat": false, "target": 23 }, + { "type": "dom.attr", "t": 2050, "node": 23, "name": "value", "value": "hel" }, + { "type": "key.down", "t": 2060, "key": "l", "code": "KeyL", "mods": { "ctrl": false, "shift": false, "alt": false, "meta": false }, "repeat": false, "target": 23 }, + { "type": "dom.attr", "t": 2150, "node": 23, "name": "value", "value": "hell" }, + { "type": "key.down", "t": 2160, "key": "l", "code": "KeyL", "mods": { "ctrl": false, "shift": false, "alt": false, "meta": false }, "repeat": false, "target": 23 }, + { "type": "dom.attr", "t": 2250, "node": 23, "name": "value", "value": "hello" }, + { "type": "key.down", "t": 2260, "key": "o", "code": "KeyO", "mods": { "ctrl": false, "shift": false, "alt": false, "meta": false }, "repeat": false, "target": 23 }, + { "type": "dom.attr", "t": 2350, "node": 23, "name": "value", "value": "hello " }, + { "type": "key.down", "t": 2360, "key": " ", "code": "Space", "mods": { "ctrl": false, "shift": false, "alt": false, "meta": false }, "repeat": false, "target": 23 }, + { "type": "dom.attr", "t": 2550, "node": 23, "name": "value", "value": "hello world" }, + { "type": "mouse.move", "t": 2700, "x": 400, "y": 430 }, + { "type": "mouse.click", "t": 2800, "x": 400, "y": 430, "button": 0, "target": 24 } + ] + }, + { + "trial_index": 3, + "t_start": 2920, + "t_dom_ready": 2960, + "t_end": 4100, + "plugin": "html-button-response", + "trial_data": { + "rt": 1140, + "response": 0, + "trial_type": "html-button-response", + "trial_index": 3, + "time_elapsed": 4100 + }, + "initial_dom": { + "id": 30, + "kind": "element", + "tag": "div", + "attrs": { "id": "jspsych-html-button-response-stimulus" }, + "children": [ + { + "id": 31, + "kind": "element", + "tag": "p", + "attrs": { "style": "text-align: center;" }, + "children": [{ "id": 32, "kind": "text", "text": "Thanks for participating! How difficult was this task?" }] + }, + { + "id": 33, + "kind": "element", + "tag": "div", + "attrs": { "id": "jspsych-html-button-response-btngroup", "style": "display: flex; gap: 8px; justify-content: center; margin-top: 16px;" }, + "children": [ + { + "id": 34, + "kind": "element", + "tag": "button", + "attrs": { "class": "jspsych-btn", "data-choice": "0" }, + "children": [{ "id": 35, "kind": "text", "text": "Easy" }] + }, + { + "id": 36, + "kind": "element", + "tag": "button", + "attrs": { "class": "jspsych-btn", "data-choice": "1" }, + "children": [{ "id": 37, "kind": "text", "text": "Medium" }] + }, + { + "id": 38, + "kind": "element", + "tag": "button", + "attrs": { "class": "jspsych-btn", "data-choice": "2" }, + "children": [{ "id": 39, "kind": "text", "text": "Hard" }] + } + ] + } + ] + }, + "events": [ + { "type": "mouse.move", "t": 3200, "x": 300, "y": 400 }, + { "type": "mouse.move", "t": 3400, "x": 320, "y": 420 }, + { "type": "mouse.move", "t": 3700, "x": 340, "y": 430 }, + { "type": "mouse.click", "t": 4090, "x": 340, "y": 430, "button": 0, "target": 34 } + ] + } + ] +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..c7c631b --- /dev/null +++ b/src/main.ts @@ -0,0 +1,148 @@ +import { validate } from "./schema/types.js"; +import type { SessionRecording } from "./schema/types.js"; +import { Sidebar } from "./ui/sidebar.js"; +import { Player } from "./ui/player.js"; +import { ViewportManager } from "./replay/viewport.js"; +import { createOverlay } from "./ui/overlay.js"; + +// ── DOM element references ────────────────────────────────────────────────── + +const loadBtn = document.getElementById("load-btn") as HTMLButtonElement; +const fileInput = document.getElementById("file-input") as HTMLInputElement; +const errorBanner = document.getElementById("error-banner") as HTMLDivElement; +const trialListEl = document.getElementById("trial-list") as HTMLDivElement; +const trialDataContentEl = document.getElementById("trial-data-content") as HTMLPreElement; +const replayFrame = document.getElementById("replay-frame") as HTMLIFrameElement; +const cursorDot = document.getElementById("cursor-dot") as HTMLDivElement; +const keystrokeIndicator = document.getElementById("keystroke-indicator") as HTMLDivElement; +const focusOverlayEl = document.getElementById("focus-overlay") as HTMLDivElement; + +const playPauseBtn = document.getElementById("play-pause-btn") as HTMLButtonElement; +const restartBtn = document.getElementById("restart-btn") as HTMLButtonElement; +const prevTrialBtn = document.getElementById("prev-trial-btn") as HTMLButtonElement; +const nextTrialBtn = document.getElementById("next-trial-btn") as HTMLButtonElement; +const scrubBar = document.getElementById("scrub-bar") as HTMLInputElement; +const timeDisplay = document.getElementById("time-display") as HTMLSpanElement; +const speedSelect = document.getElementById("speed-select") as HTMLSelectElement; +const trialSelect = document.getElementById("trial-select") as HTMLSelectElement; + +// ── State ─────────────────────────────────────────────────────────────────── + +let player: Player | null = null; +let sidebar: Sidebar | null = null; + +// ── Event wiring ───────────────────────────────────────────────────────────── + +loadBtn.addEventListener("click", () => fileInput.click()); + +fileInput.addEventListener("change", () => { + const file = fileInput.files?.[0]; + if (!file) return; + loadFile(file); + // Reset so the same file can be re-loaded + fileInput.value = ""; +}); + +// Drag and drop support +document.addEventListener("dragover", (e) => e.preventDefault()); +document.addEventListener("drop", (e) => { + e.preventDefault(); + const file = e.dataTransfer?.files[0]; + if (file) loadFile(file); +}); + +// ── File loading ────────────────────────────────────────────────────────────── + +function loadFile(file: File): void { + clearError(); + const reader = new FileReader(); + reader.onload = () => { + try { + const recording = validate(reader.result as string); + initReplay(recording); + } catch (err) { + showError((err as Error).message); + } + }; + reader.onerror = () => showError("Failed to read file"); + reader.readAsText(file); +} + +function initReplay(recording: SessionRecording): void { + // Stop any current playback + player?.stop(); + + // Mark body as loaded + document.body.classList.add("has-recording"); + + // Ensure iframe is ready + const vp = new ViewportManager(replayFrame, recording); + vp.ensureContent(); + + // Overlay + const overlay = createOverlay(cursorDot, keystrokeIndicator, focusOverlayEl); + + // Sidebar + sidebar = new Sidebar(trialListEl, trialDataContentEl, { + onTrialSelect: (idx) => { + player?.selectTrial(idx); + sidebar?.setActive(idx); + sidebar?.scrollToActive(); + }, + }); + sidebar.setTrials(recording.trials); + + // Player + player = new Player(recording, vp, overlay, { + onTrialChange: (idx) => { + sidebar?.setActive(idx); + sidebar?.scrollToActive(); + }, + onTick: (_elapsed, _duration) => { + // Additional tick handling if needed + }, + }, { + playPauseBtn, + restartBtn, + prevBtn: prevTrialBtn, + nextBtn: nextTrialBtn, + scrubBar, + timeDisplay, + speedSelect, + trialSelect, + }); + + // Auto-select first trial if available + if (recording.trials.length > 0) { + player.selectTrial(0); + sidebar.setActive(0); + } +} + +// ── Error display ───────────────────────────────────────────────────────────── + +function showError(msg: string): void { + errorBanner.textContent = `Error: ${msg}`; + errorBanner.style.display = "block"; +} + +function clearError(): void { + errorBanner.textContent = ""; + errorBanner.style.display = "none"; +} + +// ── Try loading sample recording if available ───────────────────────────────── + +async function tryLoadSample(): Promise { + try { + const resp = await fetch("./examples/sample-recording.json"); + if (!resp.ok) return; + const text = await resp.text(); + const recording = validate(text); + initReplay(recording); + } catch { + // Sample not available; user will load manually + } +} + +tryLoadSample(); diff --git a/src/replay/dom.ts b/src/replay/dom.ts new file mode 100644 index 0000000..9064183 --- /dev/null +++ b/src/replay/dom.ts @@ -0,0 +1,93 @@ +import type { DomNode, ElementNode } from "../schema/types.js"; + +/** + * Instantiate a recorded DomNode tree into real DOM nodes. + * Populates idMap with recorded id → live Node entries. + * Returns the live Node created. + */ +export function instantiateDom( + node: DomNode, + parent: Node, + idMap: Map, + doc: Document = document +): Node { + let liveNode: Node; + + if (node.kind === "element") { + const el = doc.createElement(node.tag); + + for (const [name, value] of Object.entries(node.attrs)) { + // Skip event handler attributes for security — the iframe is sandboxed + // without allow-scripts, but defence-in-depth is still valuable. + if (name.startsWith("on")) continue; + try { + el.setAttribute(name, value); + } catch { + // Ignore invalid attribute names (e.g. namespace prefixes) + } + } + + // For canvas elements: resize to recorded dimensions (content is blank) + if (node.tag.toLowerCase() === "canvas" && node.canvas_size) { + (el as HTMLCanvasElement).width = node.canvas_size.w; + (el as HTMLCanvasElement).height = node.canvas_size.h; + } + + // For media elements: restore src so the element has the right shape + // (actual playback is out of scope for v0) + if ( + (node.tag.toLowerCase() === "video" || node.tag.toLowerCase() === "audio") && + node.media_src + ) { + // Don't autoplay; just note the src for reference + (el as HTMLMediaElement).src = node.media_src; + } + + idMap.set(node.id, el); + liveNode = el; + + for (const child of node.children) { + instantiateDom(child, el, idMap, doc); + } + } else if (node.kind === "text") { + liveNode = doc.createTextNode(node.text); + idMap.set(node.id, liveNode); + } else { + // comment + liveNode = doc.createComment(node.text); + idMap.set(node.id, liveNode); + } + + parent.appendChild(liveNode); + return liveNode; +} + +/** + * Remove all descendants of a node from idMap. + */ +export function removeFromMap(node: Node, idMap: Map): void { + // Walk the idMap and remove all nodes that are descendants of `node` + // This is O(n) in the map size but maps are typically small (< few thousand) + for (const [id, liveNode] of idMap) { + if (node.contains(liveNode) || liveNode === node) { + idMap.delete(id); + } + } +} + +/** + * Given a DomNode tree, collect all ids (including the root). + */ +export function collectIds(node: DomNode): Set { + const ids = new Set(); + function walk(n: DomNode) { + ids.add(n.id); + if (n.kind === "element") { + for (const child of (n as ElementNode).children) { + walk(child); + } + } + } + walk(node); + return ids; +} diff --git a/src/replay/engine.ts b/src/replay/engine.ts new file mode 100644 index 0000000..577032c --- /dev/null +++ b/src/replay/engine.ts @@ -0,0 +1,285 @@ +import type { RecordedEvent, DomNode } from "../schema/types.js"; +import { instantiateDom, removeFromMap } from "./dom.js"; +import type { OverlayController } from "../ui/overlay.js"; + +export interface EngineCallbacks { + overlay: OverlayController; + /** Called when all events have fired (reached t_end) */ + onComplete: () => void; + /** Called each tick with current elapsed time in ms */ + onTick: (elapsed: number) => void; +} + +/** + * Manages scheduling and dispatching of recorded events for a single trial. + */ +export class ReplayEngine { + private timeouts: ReturnType[] = []; + private rafHandle: number | null = null; + private playing = false; + private startWallTime = 0; + private startElapsed = 0; + private speed = 1; + + private readonly idMap: Map; + private readonly iframeDoc: Document; + private readonly callbacks: EngineCallbacks; + + constructor(iframeDoc: Document, idMap: Map, callbacks: EngineCallbacks) { + this.iframeDoc = iframeDoc; + this.idMap = idMap; + this.callbacks = callbacks; + } + + /** Schedule all events from `fromElapsed` onwards, playing at `speed`. */ + scheduleEvents( + events: RecordedEvent[], + duration: number, + fromElapsed: number, + speed: number + ): void { + this.cancelAll(); + this.speed = speed; + this.startElapsed = fromElapsed; + this.startWallTime = performance.now(); + this.playing = true; + + for (const ev of events) { + if (ev.t < fromElapsed) continue; + const delay = (ev.t - fromElapsed) / speed; + const handle = setTimeout(() => { + this.applyEvent(ev); + this.callbacks.onTick(this.currentElapsed()); + }, delay); + this.timeouts.push(handle); + } + + // Schedule end + const endDelay = (duration - fromElapsed) / speed; + const endHandle = setTimeout(() => { + this.playing = false; + this.callbacks.onTick(duration); + this.callbacks.onComplete(); + }, endDelay); + this.timeouts.push(endHandle); + + // RAF ticker for smooth scrub bar updates + this.scheduleTick(); + } + + private scheduleTick(): void { + if (!this.playing) return; + this.rafHandle = requestAnimationFrame(() => { + if (!this.playing) return; + this.callbacks.onTick(this.currentElapsed()); + this.scheduleTick(); + }); + } + + currentElapsed(): number { + if (!this.playing) return this.startElapsed; + const wall = performance.now() - this.startWallTime; + return this.startElapsed + wall * this.speed; + } + + pause(): void { + if (!this.playing) return; + this.startElapsed = this.currentElapsed(); + this.playing = false; + this.cancelAll(); + } + + resume(events: RecordedEvent[], duration: number, speed: number): void { + this.scheduleEvents(events, duration, this.startElapsed, speed); + } + + cancelAll(): void { + for (const h of this.timeouts) clearTimeout(h); + this.timeouts = []; + if (this.rafHandle !== null) { + cancelAnimationFrame(this.rafHandle); + this.rafHandle = null; + } + this.playing = false; + } + + isPlaying(): boolean { + return this.playing; + } + + /** + * Synchronously apply all events with t <= targetMs. + * Used for seeking: re-instantiate the DOM then call this to fast-forward + * to the seek point without scheduling timeouts. + * After this call the engine is paused at targetMs. + */ + applyEventsSync(events: RecordedEvent[], targetMs: number): void { + this.cancelAll(); + this.startElapsed = targetMs; + for (const ev of events) { + if (ev.t <= targetMs) { + this.applyEvent(ev); + } + } + } + + private applyEvent(ev: RecordedEvent): void { + try { + this.dispatchEvent(ev); + } catch (err) { + console.warn("[replay] Failed to apply event:", ev.type, err); + } + } + + private dispatchEvent(ev: RecordedEvent): void { + switch (ev.type) { + case "dom.add": { + const parentNode = this.idMap.get(ev.parent); + if (!parentNode) { + console.warn(`[replay] dom.add: parent node ${ev.parent} not found`); + return; + } + // Insert before sibling, or append + let refNode: Node | null = null; + if (ev.before !== null) { + refNode = this.idMap.get(ev.before) ?? null; + } + const newNode = this.createNode(ev.node); + if (refNode) { + parentNode.insertBefore(newNode, refNode); + } else { + parentNode.appendChild(newNode); + } + break; + } + + case "dom.remove": { + const nodeToRemove = this.idMap.get(ev.node); + if (!nodeToRemove) return; + removeFromMap(nodeToRemove, this.idMap); + nodeToRemove.parentNode?.removeChild(nodeToRemove); + break; + } + + case "dom.attr": { + const el = this.idMap.get(ev.node) as Element | undefined; + if (!el || !(el instanceof Element)) return; + if (ev.name.startsWith("on")) return; // safety + if (ev.value === null) { + el.removeAttribute(ev.name); + } else { + try { + el.setAttribute(ev.name, ev.value); + } catch { + // ignore invalid attribute names + } + } + break; + } + + case "dom.text": { + const textNode = this.idMap.get(ev.node); + if (!textNode) return; + if (textNode.nodeType === Node.TEXT_NODE || textNode.nodeType === Node.COMMENT_NODE) { + textNode.nodeValue = ev.text; + } + break; + } + + case "mouse.move": + this.callbacks.overlay.moveCursor(ev.x, ev.y); + break; + + case "mouse.down": + case "mouse.up": + case "mouse.click": + this.callbacks.overlay.moveCursor(ev.x, ev.y); + if (ev.type === "mouse.click") { + this.callbacks.overlay.showClick(); + } + break; + + case "touch.start": + case "touch.move": + case "touch.end": + if (ev.touches.length > 0) { + this.callbacks.overlay.moveCursor(ev.touches[0].x, ev.touches[0].y); + } + break; + + case "key.down": { + const mods: string[] = []; + if (ev.mods.ctrl) mods.push("⌃"); + if (ev.mods.shift) mods.push("⇧"); + if (ev.mods.alt) mods.push("⌥"); + if (ev.mods.meta) mods.push("⌘"); + const label = [...mods, ev.key].join(""); + this.callbacks.overlay.showKey(label); + break; + } + + case "key.up": + break; + + case "scroll.window": + try { + this.iframeDoc.defaultView?.scrollTo(ev.x, ev.y); + } catch { + // may fail in sandboxed context + } + break; + + case "scroll.element": { + const el = this.idMap.get(ev.node) as HTMLElement | undefined; + if (el && el instanceof HTMLElement) { + el.scrollLeft = ev.x; + el.scrollTop = ev.y; + } + break; + } + + case "focus": + this.callbacks.overlay.setBlurred(false); + break; + + case "blur": + this.callbacks.overlay.setBlurred(true); + break; + + case "fullscreen.enter": + case "fullscreen.exit": + // Visual note only + break; + + case "clipboard.copy": + case "clipboard.cut": + case "clipboard.paste": + // Log to console; sidebar integration is out of scope for v0 + console.info(`[replay] clipboard event: ${ev.type}`, ev.text ?? ""); + break; + + case "media.play": + case "media.pause": + case "media.ended": + case "media.seeked": + case "media.time": + // Log only in v0; actual media replay is out of scope + console.info(`[replay] media event: ${ev.type}`, ev); + break; + + default: + console.warn("[replay] Unknown event type:", (ev as RecordedEvent).type); + break; + } + } + + /** + * Instantiate a DomNode and register all ids in idMap (recursive). + */ + private createNode(node: DomNode): Node { + const frag = this.iframeDoc.createDocumentFragment(); + instantiateDom(node, frag, this.idMap, this.iframeDoc); + // instantiateDom appended to frag; return the first child + return frag.firstChild!; + } +} diff --git a/src/replay/viewport.ts b/src/replay/viewport.ts new file mode 100644 index 0000000..46b5f74 --- /dev/null +++ b/src/replay/viewport.ts @@ -0,0 +1,92 @@ +import type { SessionRecording, ViewportState } from "../schema/types.js"; + +/** + * Manage the replay iframe: sizing, document preparation, viewport changes. + */ +export class ViewportManager { + private readonly iframe: HTMLIFrameElement; + private readonly recording: SessionRecording; + + constructor(iframe: HTMLIFrameElement, recording: SessionRecording) { + this.iframe = iframe; + this.recording = recording; + this.applyViewport(recording.viewport); + } + + /** + * Get the viewport state active at time `t` (ms, relative to recording_started_at_perf). + * Falls back to the top-level viewport if no changes precede `t`. + */ + viewportAt(t: number): ViewportState { + let active = this.recording.viewport; + for (const change of this.recording.viewport_changes) { + if (change.t <= t) { + active = change; + } else { + break; + } + } + return active; + } + + applyViewport(vp: ViewportState): void { + this.iframe.width = String(vp.w); + this.iframe.height = String(vp.h); + const wrapper = this.iframe.parentElement; + if (wrapper) { + wrapper.style.width = `${vp.w}px`; + wrapper.style.height = `${vp.h}px`; + } + } + + /** + * Apply the viewport that was active at time `t` for the given trial. + */ + applyViewportAt(t: number): void { + this.applyViewport(this.viewportAt(t)); + } + + /** + * Ensure the iframe document is ready and contains #jspsych-content. + * Returns the container element. + */ + ensureContent(): HTMLElement { + const doc = this.iframe.contentDocument; + if (!doc) throw new Error("iframe document not accessible"); + + // Write a minimal HTML shell if the document is empty + if (!doc.getElementById("jspsych-content")) { + doc.open(); + doc.write(` + + + + + +
+`); + doc.close(); + } + + return doc.getElementById("jspsych-content") as HTMLElement; + } + + /** + * Clear #jspsych-content and return the empty container. + */ + clearContent(): HTMLElement { + const container = this.ensureContent(); + container.innerHTML = ""; + return container; + } + + get iframeDoc(): Document { + const doc = this.iframe.contentDocument; + if (!doc) throw new Error("iframe document not accessible"); + return doc; + } +} diff --git a/src/schema/types.ts b/src/schema/types.ts new file mode 100644 index 0000000..10a48e6 --- /dev/null +++ b/src/schema/types.ts @@ -0,0 +1,190 @@ +// --------------------------------------------------------------------------- +// Schema types for jsPsych session recordings (schema_version: 1) +// Copied from jspsych/jspsych packages/jspsych/src/modules/recording.ts +// --------------------------------------------------------------------------- + +export type JsonValue = + | null + | boolean + | number + | string + | JsonValue[] + | { [key: string]: JsonValue }; + +export interface SessionRecording { + schema_version: 1; + jspsych_version: string; + recording_started_at: string; + recording_started_at_perf: number; + user_agent: string; + viewport: ViewportState; + rng: { seed: string | null; math_random_patched: boolean }; + display_element_id: string; + trials: TrialRecording[]; + viewport_changes: ViewportChange[]; + rng_calls: RngCall[]; + ended_at_perf: number | null; + end_reason: "finished" | "aborted" | "unload" | null; +} + +export interface ViewportState { + w: number; + h: number; + dpr: number; + scale: number; + offset_x: number; + offset_y: number; +} + +export interface ViewportChange extends ViewportState { + t: number; +} + +export interface TrialRecording { + trial_index: number; + t_start: number; + t_dom_ready: number | null; + t_end: number | null; + plugin: string; + initial_dom: DomNode | null; + events: RecordedEvent[]; + trial_data: JsonValue; +} + +export type DomNode = ElementNode | TextNode | CommentNode; + +export interface ElementNode { + id: number; + kind: "element"; + tag: string; + attrs: Record; + children: DomNode[]; + canvas_size?: { w: number; h: number }; + media_src?: string; +} + +export interface TextNode { + id: number; + kind: "text"; + text: string; +} + +export interface CommentNode { + id: number; + kind: "comment"; + text: string; +} + +export type RecordedEvent = + | DomMutation + | InputRecord + | ClipboardRecord + | MediaRecord + | FocusRecord + | ScrollRecord; + +export type DomMutation = + | { type: "dom.add"; t: number; parent: number; before: number | null; node: DomNode } + | { type: "dom.remove"; t: number; node: number } + | { type: "dom.attr"; t: number; node: number; name: string; value: string | null } + | { type: "dom.text"; t: number; node: number; text: string }; + +export type InputRecord = + | { type: "mouse.move"; t: number; x: number; y: number } + | { + type: "mouse.down" | "mouse.up" | "mouse.click"; + t: number; + x: number; + y: number; + button: number; + target: number | null; + } + | { + type: "touch.start" | "touch.move" | "touch.end"; + t: number; + touches: { id: number; x: number; y: number }[]; + } + | { + type: "key.down" | "key.up"; + t: number; + key: string; + code: string; + mods: { ctrl: boolean; shift: boolean; alt: boolean; meta: boolean }; + repeat: boolean; + target: number | null; + }; + +export interface ClipboardRecord { + type: "clipboard.copy" | "clipboard.cut" | "clipboard.paste"; + t: number; + text: string | null; + html: string | null; + target: number | null; +} + +export type MediaRecord = { + type: "media.play" | "media.pause" | "media.ended" | "media.seeked" | "media.time"; + t: number; + node: number; + current_time: number; +}; + +export interface FocusRecord { + type: "focus" | "blur" | "fullscreen.enter" | "fullscreen.exit"; + t: number; +} + +export type ScrollRecord = + | { type: "scroll.window"; t: number; x: number; y: number } + | { type: "scroll.element"; t: number; node: number; x: number; y: number }; + +export interface RngCall { + t: number; + fn: string; + args: JsonValue; + result: JsonValue; +} + +// --------------------------------------------------------------------------- +// Validation +// --------------------------------------------------------------------------- + +/** + * Parse and validate a JSON string or object as a SessionRecording. + * Throws a descriptive Error if validation fails. + */ +export function validate(input: unknown): SessionRecording { + if (typeof input === "string") { + try { + input = JSON.parse(input); + } catch (e) { + throw new Error(`Invalid JSON: ${(e as Error).message}`); + } + } + + if (typeof input !== "object" || input === null || Array.isArray(input)) { + throw new Error("Recording must be a JSON object"); + } + + const obj = input as Record; + + if (!("schema_version" in obj)) { + throw new Error("Missing required field: schema_version"); + } + + if (obj["schema_version"] !== 1) { + throw new Error( + `Unsupported schema_version: ${String(obj["schema_version"])}. This replayer only supports version 1.` + ); + } + + if (!Array.isArray(obj["trials"])) { + throw new Error("Missing or invalid field: trials (expected array)"); + } + + if (typeof obj["jspsych_version"] !== "string") { + throw new Error("Missing or invalid field: jspsych_version (expected string)"); + } + + return input as SessionRecording; +} diff --git a/src/ui/overlay.ts b/src/ui/overlay.ts new file mode 100644 index 0000000..b9a396c --- /dev/null +++ b/src/ui/overlay.ts @@ -0,0 +1,76 @@ +/** + * Overlay controller: cursor dot and keystroke indicator rendered on top of + * the iframe stage. + */ +export interface OverlayController { + moveCursor(x: number, y: number): void; + showClick(): void; + showKey(label: string): void; + setBlurred(blurred: boolean): void; + hide(): void; + show(): void; +} + +const MAX_KEYS = 3; +const KEY_DISPLAY_MS = 1000; + +export function createOverlay( + cursorEl: HTMLElement, + keystrokeEl: HTMLElement, + focusOverlayEl: HTMLElement +): OverlayController { + let clickTimeout: ReturnType | null = null; + + return { + moveCursor(x: number, y: number) { + cursorEl.style.display = "block"; + cursorEl.style.left = `${x}px`; + cursorEl.style.top = `${y}px`; + }, + + showClick() { + cursorEl.classList.add("click"); + if (clickTimeout !== null) clearTimeout(clickTimeout); + clickTimeout = setTimeout(() => { + cursorEl.classList.remove("click"); + clickTimeout = null; + }, 200); + }, + + showKey(label: string) { + // Remove excess badges + while (keystrokeEl.children.length >= MAX_KEYS) { + keystrokeEl.removeChild(keystrokeEl.firstChild!); + } + const badge = document.createElement("div"); + badge.className = "key-badge"; + badge.textContent = label; + keystrokeEl.appendChild(badge); + + // Auto-remove after animation + setTimeout(() => { + if (badge.parentNode === keystrokeEl) { + keystrokeEl.removeChild(badge); + } + }, KEY_DISPLAY_MS); + }, + + setBlurred(blurred: boolean) { + if (blurred) { + focusOverlayEl.classList.add("visible"); + } else { + focusOverlayEl.classList.remove("visible"); + } + }, + + hide() { + cursorEl.style.display = "none"; + keystrokeEl.innerHTML = ""; + focusOverlayEl.classList.remove("visible"); + }, + + show() { + // cursor visibility is controlled by moveCursor; nothing to do here + }, + }; +} diff --git a/src/ui/player.ts b/src/ui/player.ts new file mode 100644 index 0000000..9a14151 --- /dev/null +++ b/src/ui/player.ts @@ -0,0 +1,268 @@ +import type { TrialRecording, SessionRecording } from "../schema/types.js"; +import { ReplayEngine } from "../replay/engine.js"; +import { ViewportManager } from "../replay/viewport.js"; +import { instantiateDom } from "../replay/dom.js"; +import type { OverlayController } from "./overlay.js"; + +export interface PlayerCallbacks { + onTrialChange: (listIndex: number) => void; + onTick: (elapsed: number, duration: number) => void; +} + +/** + * Player controller: manages play/pause/seek/trial navigation. + */ +export class Player { + private readonly recording: SessionRecording; + private readonly viewport: ViewportManager; + private readonly overlay: OverlayController; + private readonly callbacks: PlayerCallbacks; + + private trials: TrialRecording[]; + private currentListIndex = 0; + private engine: ReplayEngine | null = null; + private currentIdMap: Map = new Map(); + private speed = 1; + + // UI elements + private readonly playPauseBtn: HTMLButtonElement; + private readonly restartBtn: HTMLButtonElement; + private readonly prevBtn: HTMLButtonElement; + private readonly nextBtn: HTMLButtonElement; + private readonly scrubBar: HTMLInputElement; + private readonly timeDisplay: HTMLElement; + private readonly speedSelect: HTMLSelectElement; + private readonly trialSelect: HTMLSelectElement; + + constructor( + recording: SessionRecording, + viewport: ViewportManager, + overlay: OverlayController, + callbacks: PlayerCallbacks, + elements: { + playPauseBtn: HTMLButtonElement; + restartBtn: HTMLButtonElement; + prevBtn: HTMLButtonElement; + nextBtn: HTMLButtonElement; + scrubBar: HTMLInputElement; + timeDisplay: HTMLElement; + speedSelect: HTMLSelectElement; + trialSelect: HTMLSelectElement; + } + ) { + this.recording = recording; + this.viewport = viewport; + this.overlay = overlay; + this.callbacks = callbacks; + this.trials = recording.trials; + + this.playPauseBtn = elements.playPauseBtn; + this.restartBtn = elements.restartBtn; + this.prevBtn = elements.prevBtn; + this.nextBtn = elements.nextBtn; + this.scrubBar = elements.scrubBar; + this.timeDisplay = elements.timeDisplay; + this.speedSelect = elements.speedSelect; + this.trialSelect = elements.trialSelect; + + this.bindEvents(); + this.populateTrialSelect(); + } + + private bindEvents(): void { + this.playPauseBtn.addEventListener("click", () => this.togglePlayPause()); + this.restartBtn.addEventListener("click", () => this.restartCurrentTrial()); + this.prevBtn.addEventListener("click", () => this.jumpTrial(-1)); + this.nextBtn.addEventListener("click", () => this.jumpTrial(1)); + + this.trialSelect.addEventListener("change", () => { + const idx = Number(this.trialSelect.value); + this.selectTrial(idx); + }); + + this.speedSelect.addEventListener("change", () => { + this.speed = Number(this.speedSelect.value); + if (this.engine?.isPlaying()) { + const elapsed = this.engine.currentElapsed(); + const trial = this.currentTrial(); + if (!trial) return; + const duration = this.trialDuration(trial); + this.engine.cancelAll(); + this.engine.scheduleEvents(trial.events, duration, elapsed, this.speed); + } + }); + + this.scrubBar.addEventListener("input", () => { + const pct = Number(this.scrubBar.value) / 1000; + const trial = this.currentTrial(); + if (!trial) return; + const duration = this.trialDuration(trial); + const target = pct * duration; + this.seekTo(target); + }); + } + + private populateTrialSelect(): void { + this.trialSelect.innerHTML = ""; + for (let i = 0; i < this.trials.length; i++) { + const t = this.trials[i]; + const opt = document.createElement("option"); + opt.value = String(i); + opt.textContent = `Trial ${t.trial_index}: ${t.plugin || "?"}`; + this.trialSelect.appendChild(opt); + } + } + + /** Stop all playback. */ + stop(): void { + this.engine?.cancelAll(); + } + + /** Load a trial by list index (not trial_index). */ + selectTrial(listIndex: number): void { + if (listIndex < 0 || listIndex >= this.trials.length) return; + + this.engine?.cancelAll(); + this.overlay.hide(); + + this.currentListIndex = listIndex; + this.trialSelect.value = String(listIndex); + + const trial = this.trials[listIndex]; + + // Apply viewport at trial start + if (trial.t_start != null) { + this.viewport.applyViewportAt(trial.t_start); + } else { + this.viewport.applyViewport(this.recording.viewport); + } + + // Reconstruct initial DOM + this.currentIdMap = new Map(); + const container = this.viewport.clearContent(); + + if (trial.initial_dom !== null) { + instantiateDom(trial.initial_dom, container, this.currentIdMap, this.viewport.iframeDoc); + } + + // Set up engine + this.engine = new ReplayEngine(this.viewport.iframeDoc, this.currentIdMap, { + overlay: this.overlay, + onComplete: () => { + this.setPlayPauseIcon(false); + }, + onTick: (elapsed) => { + const duration = this.trialDuration(trial); + this.updateScrub(elapsed, duration); + this.callbacks.onTick(elapsed, duration); + }, + }); + + const duration = this.trialDuration(trial); + this.updateScrub(0, duration); + this.setPlayPauseIcon(false); + this.updateNavButtons(); + this.callbacks.onTrialChange(listIndex); + } + + private trialDuration(trial: TrialRecording): number { + if (trial.t_dom_ready == null || trial.t_end == null) return 0; + return trial.t_end - trial.t_dom_ready; + } + + private currentTrial(): TrialRecording | null { + return this.trials[this.currentListIndex] ?? null; + } + + private togglePlayPause(): void { + if (!this.engine) return; + const trial = this.currentTrial(); + if (!trial) return; + const duration = this.trialDuration(trial); + + if (this.engine.isPlaying()) { + this.engine.pause(); + this.setPlayPauseIcon(false); + } else { + const elapsed = this.engine.currentElapsed(); + // If at end, restart + const from = elapsed >= duration ? 0 : elapsed; + if (from === 0) { + this.selectTrial(this.currentListIndex); + this.engine!.scheduleEvents(trial.events, duration, 0, this.speed); + } else { + this.engine.scheduleEvents(trial.events, duration, from, this.speed); + } + this.setPlayPauseIcon(true); + } + } + + private restartCurrentTrial(): void { + const trial = this.currentTrial(); + if (!trial) return; + this.selectTrial(this.currentListIndex); + const duration = this.trialDuration(trial); + this.engine!.scheduleEvents(trial.events, duration, 0, this.speed); + this.setPlayPauseIcon(true); + } + + private jumpTrial(delta: number): void { + const next = this.currentListIndex + delta; + if (next >= 0 && next < this.trials.length) { + this.selectTrial(next); + } + } + + seekTo(targetMs: number): void { + const trial = this.currentTrial(); + if (!trial) return; + const duration = this.trialDuration(trial); + const clamped = Math.max(0, Math.min(targetMs, duration)); + + this.engine?.cancelAll(); + + // Re-instantiate initial DOM + this.currentIdMap = new Map(); + const container = this.viewport.clearContent(); + if (trial.initial_dom !== null) { + instantiateDom(trial.initial_dom, container, this.currentIdMap, this.viewport.iframeDoc); + } + + // Re-create engine with fresh id map + this.engine = new ReplayEngine(this.viewport.iframeDoc, this.currentIdMap, { + overlay: this.overlay, + onComplete: () => this.setPlayPauseIcon(false), + onTick: (elapsed) => { + this.updateScrub(elapsed, duration); + this.callbacks.onTick(elapsed, duration); + }, + }); + + // Apply all events up to the seek target synchronously + this.engine.applyEventsSync(trial.events, clamped); + + // Engine keeps startElapsed at clamped; it's paused + this.updateScrub(clamped, duration); + this.callbacks.onTick(clamped, duration); + this.setPlayPauseIcon(false); + } + + private updateScrub(elapsed: number, duration: number): void { + const pct = duration > 0 ? Math.min(elapsed / duration, 1) : 0; + this.scrubBar.value = String(Math.round(pct * 1000)); + this.timeDisplay.textContent = `${(elapsed / 1000).toFixed(1)}s / ${(duration / 1000).toFixed(1)}s`; + } + + private setPlayPauseIcon(playing: boolean): void { + this.playPauseBtn.innerHTML = playing ? "⏸" : "▶"; + } + + private updateNavButtons(): void { + this.prevBtn.disabled = this.currentListIndex <= 0; + this.nextBtn.disabled = this.currentListIndex >= this.trials.length - 1; + } + + getCurrentListIndex(): number { + return this.currentListIndex; + } +} diff --git a/src/ui/sidebar.ts b/src/ui/sidebar.ts new file mode 100644 index 0000000..cf41765 --- /dev/null +++ b/src/ui/sidebar.ts @@ -0,0 +1,75 @@ +import type { TrialRecording } from "../schema/types.js"; + +export interface SidebarCallbacks { + onTrialSelect: (index: number) => void; +} + +/** + * Renders the trial list in the sidebar and manages trial selection. + */ +export class Sidebar { + private readonly listEl: HTMLElement; + private readonly dataContentEl: HTMLElement; + private readonly callbacks: SidebarCallbacks; + private trials: TrialRecording[] = []; + + constructor(listEl: HTMLElement, dataContentEl: HTMLElement, callbacks: SidebarCallbacks) { + this.listEl = listEl; + this.dataContentEl = dataContentEl; + this.callbacks = callbacks; + } + + setTrials(trials: TrialRecording[]): void { + this.trials = trials; + this.renderList(); + } + + private renderList(): void { + this.listEl.innerHTML = ""; + for (let i = 0; i < this.trials.length; i++) { + const trial = this.trials[i]; + const item = document.createElement("div"); + item.className = "trial-item"; + item.dataset["index"] = String(i); + + const indexSpan = document.createElement("span"); + indexSpan.className = "trial-index"; + indexSpan.textContent = `#${trial.trial_index}`; + + const pluginSpan = document.createElement("span"); + pluginSpan.className = "trial-plugin"; + pluginSpan.textContent = trial.plugin || "(unknown plugin)"; + + item.appendChild(indexSpan); + item.appendChild(pluginSpan); + + item.addEventListener("click", () => { + this.setActive(i); + this.callbacks.onTrialSelect(i); + }); + + this.listEl.appendChild(item); + } + } + + setActive(listIndex: number): void { + for (const item of this.listEl.querySelectorAll(".trial-item")) { + const idx = Number(item.dataset["index"]); + item.classList.toggle("active", idx === listIndex); + } + // Update trial data viewer + if (listIndex >= 0 && listIndex < this.trials.length) { + const trial = this.trials[listIndex]; + this.dataContentEl.textContent = JSON.stringify(trial.trial_data, null, 2); + } else { + this.dataContentEl.textContent = "(select a trial)"; + } + } + + scrollToActive(): void { + const active = this.listEl.querySelector(".trial-item.active"); + if (active) { + active.scrollIntoView({ block: "nearest" }); + } + } +} diff --git a/tests/engine.test.ts b/tests/engine.test.ts new file mode 100644 index 0000000..efbfafc --- /dev/null +++ b/tests/engine.test.ts @@ -0,0 +1,346 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { validate } from "../src/schema/types"; +import type { SessionRecording, DomNode } from "../src/schema/types"; +import { instantiateDom } from "../src/replay/dom"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeMinimalRecording(overrides: Partial = {}): SessionRecording { + return { + schema_version: 1, + jspsych_version: "8.0.0", + recording_started_at: "2024-01-01T00:00:00.000Z", + recording_started_at_perf: 0, + user_agent: "test", + viewport: { w: 800, h: 600, dpr: 1, scale: 1, offset_x: 0, offset_y: 0 }, + rng: { seed: "abc", math_random_patched: true }, + display_element_id: "jspsych-content", + trials: [], + viewport_changes: [], + rng_calls: [], + ended_at_perf: 1000, + end_reason: "finished", + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// validate() +// --------------------------------------------------------------------------- + +describe("validate()", () => { + it("accepts a valid schema_version 1 recording object", () => { + const rec = makeMinimalRecording(); + expect(() => validate(rec)).not.toThrow(); + const result = validate(rec); + expect(result.schema_version).toBe(1); + }); + + it("accepts a valid recording as a JSON string", () => { + const rec = makeMinimalRecording(); + const json = JSON.stringify(rec); + const result = validate(json); + expect(result.schema_version).toBe(1); + }); + + it("throws on invalid JSON string", () => { + expect(() => validate("not valid json {")).toThrow(/Invalid JSON/); + }); + + it("throws on schema_version 2", () => { + expect(() => + validate({ ...makeMinimalRecording(), schema_version: 2 as unknown as 1 }) + ).toThrow(/Unsupported schema_version/); + }); + + it("throws when schema_version is missing", () => { + const obj: Record = { ...makeMinimalRecording() }; + delete obj["schema_version"]; + expect(() => validate(obj)).toThrow(/schema_version/); + }); + + it("throws when trials field is not an array", () => { + expect(() => + validate({ ...makeMinimalRecording(), trials: null as unknown as [] }) + ).toThrow(/trials/); + }); + + it("throws on a non-object input", () => { + expect(() => validate(42)).toThrow(/Recording must be a JSON object/); + }); + + it("throws on null input", () => { + expect(() => validate(null)).toThrow(/Recording must be a JSON object/); + }); + + it("throws on array input", () => { + expect(() => validate([])).toThrow(/Recording must be a JSON object/); + }); + + it("preserves all fields on a valid recording", () => { + const rec = makeMinimalRecording({ + trials: [ + { + trial_index: 0, + t_start: 100, + t_dom_ready: 150, + t_end: 500, + plugin: "html-keyboard-response", + initial_dom: null, + events: [], + trial_data: { rt: 350, response: "a" }, + }, + ], + }); + const result = validate(rec); + expect(result.trials).toHaveLength(1); + expect(result.trials[0].plugin).toBe("html-keyboard-response"); + }); +}); + +// --------------------------------------------------------------------------- +// instantiateDom() +// --------------------------------------------------------------------------- + +describe("instantiateDom()", () => { + let doc: Document; + let container: HTMLElement; + let idMap: Map; + + beforeEach(() => { + doc = document.implementation.createHTMLDocument("test"); + container = doc.createElement("div"); + doc.body.appendChild(container); + idMap = new Map(); + }); + + it("creates an element node and registers its id", () => { + const node: DomNode = { + id: 1, + kind: "element", + tag: "p", + attrs: { class: "prompt" }, + children: [], + }; + instantiateDom(node, container, idMap, doc); + expect(container.children).toHaveLength(1); + expect(container.firstElementChild?.tagName.toLowerCase()).toBe("p"); + expect(container.firstElementChild?.getAttribute("class")).toBe("prompt"); + expect(idMap.get(1)).toBe(container.firstElementChild); + }); + + it("creates a text node and registers its id", () => { + const node: DomNode = { id: 2, kind: "text", text: "Hello world" }; + instantiateDom(node, container, idMap, doc); + expect(container.childNodes).toHaveLength(1); + expect(container.firstChild?.nodeType).toBe(Node.TEXT_NODE); + expect(container.firstChild?.nodeValue).toBe("Hello world"); + expect(idMap.get(2)).toBe(container.firstChild); + }); + + it("creates a comment node and registers its id", () => { + const node: DomNode = { id: 3, kind: "comment", text: "a comment" }; + instantiateDom(node, container, idMap, doc); + expect(container.childNodes).toHaveLength(1); + expect(container.firstChild?.nodeType).toBe(Node.COMMENT_NODE); + expect(idMap.get(3)).toBe(container.firstChild); + }); + + it("recursively creates children and registers all ids", () => { + const node: DomNode = { + id: 10, + kind: "element", + tag: "div", + attrs: {}, + children: [ + { id: 11, kind: "element", tag: "span", attrs: {}, children: [] }, + { id: 12, kind: "text", text: "text" }, + ], + }; + instantiateDom(node, container, idMap, doc); + expect(idMap.size).toBe(3); + expect(idMap.get(10)).toBeTruthy(); + expect(idMap.get(11)).toBeTruthy(); + expect(idMap.get(12)).toBeTruthy(); + + const divEl = idMap.get(10) as HTMLElement; + expect(divEl.children).toHaveLength(1); + expect(divEl.childNodes).toHaveLength(2); + }); + + it("skips on* attributes for security", () => { + const node: DomNode = { + id: 20, + kind: "element", + tag: "button", + attrs: { onclick: "alert(1)", class: "btn" }, + children: [], + }; + instantiateDom(node, container, idMap, doc); + const btn = idMap.get(20) as HTMLButtonElement; + expect(btn.getAttribute("onclick")).toBeNull(); + expect(btn.getAttribute("class")).toBe("btn"); + }); + + it("produces matching outerHTML for a simple fixture", () => { + const node: DomNode = { + id: 30, + kind: "element", + tag: "div", + attrs: { id: "jspsych-content" }, + children: [ + { + id: 31, + kind: "element", + tag: "p", + attrs: {}, + children: [{ id: 32, kind: "text", text: "Press any key to continue." }], + }, + ], + }; + instantiateDom(node, container, idMap, doc); + const div = idMap.get(30) as HTMLElement; + expect(div.outerHTML).toBe( + '

Press any key to continue.

' + ); + }); + + it("handles empty children array", () => { + const node: DomNode = { + id: 40, + kind: "element", + tag: "div", + attrs: {}, + children: [], + }; + instantiateDom(node, container, idMap, doc); + expect((idMap.get(40) as HTMLElement).innerHTML).toBe(""); + }); +}); + +// --------------------------------------------------------------------------- +// ReplayEngine event dispatch +// --------------------------------------------------------------------------- + +describe("ReplayEngine event dispatch", () => { + it("applies dom.text event to a text node", async () => { + const { ReplayEngine } = await import("../src/replay/engine"); + + const doc = document.implementation.createHTMLDocument("test"); + const container = doc.createElement("div"); + doc.body.appendChild(container); + const idMap = new Map(); + const textNode = doc.createTextNode("original"); + container.appendChild(textNode); + idMap.set(1, textNode); + + const overlay = { + moveCursor: () => {}, + showClick: () => {}, + showKey: () => {}, + setBlurred: () => {}, + hide: () => {}, + show: () => {}, + }; + + const completeCalls: number[] = []; + const engine = new ReplayEngine(doc, idMap, { + overlay, + onComplete: () => completeCalls.push(1), + onTick: () => {}, + }); + + engine.applyEventsSync([{ type: "dom.text", t: 0, node: 1, text: "updated" }], 10); + expect(textNode.nodeValue).toBe("updated"); + }); + + it("applies dom.attr event to set an attribute", async () => { + const { ReplayEngine } = await import("../src/replay/engine"); + + const doc = document.implementation.createHTMLDocument("test"); + const el = doc.createElement("div"); + doc.body.appendChild(el); + const idMap = new Map(); + idMap.set(1, el); + + const overlay = { + moveCursor: () => {}, + showClick: () => {}, + showKey: () => {}, + setBlurred: () => {}, + hide: () => {}, + show: () => {}, + }; + + const engine = new ReplayEngine(doc, idMap, { + overlay, + onComplete: () => {}, + onTick: () => {}, + }); + + engine.applyEventsSync( + [{ type: "dom.attr", t: 0, node: 1, name: "data-test", value: "hello" }], + 10 + ); + expect(el.getAttribute("data-test")).toBe("hello"); + + engine.applyEventsSync( + [{ type: "dom.attr", t: 0, node: 1, name: "data-test", value: null }], + 10 + ); + expect(el.getAttribute("data-test")).toBeNull(); + }); + + it("does not apply on* attrs via dom.attr for security", async () => { + const { ReplayEngine } = await import("../src/replay/engine"); + + const doc = document.implementation.createHTMLDocument("test"); + const el = doc.createElement("div"); + doc.body.appendChild(el); + const idMap = new Map(); + idMap.set(1, el); + + const overlay = { + moveCursor: () => {}, + showClick: () => {}, + showKey: () => {}, + setBlurred: () => {}, + hide: () => {}, + show: () => {}, + }; + + const engine = new ReplayEngine(doc, idMap, { + overlay, + onComplete: () => {}, + onTick: () => {}, + }); + + engine.applyEventsSync( + [{ type: "dom.attr", t: 0, node: 1, name: "onclick", value: "alert(1)" }], + 10 + ); + expect(el.getAttribute("onclick")).toBeNull(); + }); + + it("handles empty events array gracefully", async () => { + const { ReplayEngine } = await import("../src/replay/engine"); + const doc = document.implementation.createHTMLDocument("test"); + const idMap = new Map(); + const overlay = { + moveCursor: () => {}, + showClick: () => {}, + showKey: () => {}, + setBlurred: () => {}, + hide: () => {}, + show: () => {}, + }; + const engine = new ReplayEngine(doc, idMap, { + overlay, + onComplete: () => {}, + onTick: () => {}, + }); + expect(() => engine.applyEventsSync([], 0)).not.toThrow(); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..29dbe60 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src", "tests"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..d887a35 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "vite"; + +export default defineConfig({ + base: process.env["NODE_ENV"] === "production" ? "/replay/" : "./", + build: { + outDir: "dist", + target: "es2020", + }, + test: { + environment: "jsdom", + globals: true, + }, +});