diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 2335904..c7b00cc 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -9,6 +9,7 @@ ## How it was verified - [ ] `pnpm compile` passes +- [ ] `pnpm test` passes (add/adjust tests when touching `utils/`) - [ ] `pnpm build` produces a clean bundle - [ ] Tested in DevTools (extension reloaded + DevTools reopened) - [ ] If output format changed: example TOON export attached below diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d939ba0 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,27 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +jobs: + checks: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 10 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - run: pnpm install --frozen-lockfile + - run: pnpm wxt prepare # pnpm 10 blocks postinstall scripts + - run: pnpm compile # tsc --noEmit + - run: pnpm test # vitest run (42 tests over utils/) + - run: pnpm build # the extension must still bundle cleanly diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a34aa5b..2bc18e9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -33,11 +33,12 @@ Two invariants to respect: ```bash pnpm compile # tsc --noEmit, must pass +pnpm test # vitest, covers everything under utils/ pnpm build # must produce a clean .output/chrome-mv3 pnpm landing # optional: rebuilds the landing demo from the fresh bundle ``` -There is no test runner wired up yet; if you add logic to `utils/`, a small standalone repro in the PR description is appreciated. +CI runs the same three checks on every pull request. If you touch `utils/`, add or adjust the matching `*.test.ts` next to it: the compaction markers, redaction rules and sourcemap resolution are exactly the kind of logic that regresses silently. ## Pull requests diff --git a/package.json b/package.json index 785207c..6793218 100644 --- a/package.json +++ b/package.json @@ -15,11 +15,14 @@ "zip": "wxt zip", "zip:firefox": "wxt zip -b firefox", "compile": "tsc --noEmit", - "postinstall": "wxt prepare" + "postinstall": "wxt prepare", + "test": "vitest run" }, "devDependencies": { "@resvg/resvg-js": "^2.6.2", + "esbuild": "^0.28.1", "typescript": "^5.9.3", + "vitest": "^4.1.8", "wxt": "^0.20.26" }, "dependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a7be2bb..8b88235 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,9 +15,15 @@ importers: '@resvg/resvg-js': specifier: ^2.6.2 version: 2.6.2 + esbuild: + specifier: ^0.28.1 + version: 0.28.1 typescript: specifier: ^5.9.3 version: 5.9.3 + vitest: + specifier: ^4.1.8 + version: 4.1.8(@types/node@25.9.3)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)) wxt: specifier: ^0.20.26 version: 0.20.26(@types/node@25.9.3)(jiti@2.7.0)(rolldown@1.0.3) @@ -90,156 +96,312 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/android-arm64@0.27.7': resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} engines: {node: '>=18'} cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm@0.27.7': resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} engines: {node: '>=18'} cpu: [arm] os: [android] + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-x64@0.27.7': resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} engines: {node: '>=18'} cpu: [x64] os: [android] + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/darwin-arm64@0.27.7': resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-x64@0.27.7': resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} engines: {node: '>=18'} cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/freebsd-arm64@0.27.7': resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-x64@0.27.7': resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/linux-arm64@0.27.7': resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} engines: {node: '>=18'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm@0.27.7': resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} engines: {node: '>=18'} cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-ia32@0.27.7': resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} engines: {node: '>=18'} cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-loong64@0.27.7': resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} engines: {node: '>=18'} cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-mips64el@0.27.7': resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-ppc64@0.27.7': resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-riscv64@0.27.7': resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-s390x@0.27.7': resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} engines: {node: '>=18'} cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-x64@0.27.7': resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} engines: {node: '>=18'} cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/netbsd-arm64@0.27.7': resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-x64@0.27.7': resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/openbsd-arm64@0.27.7': resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-x64@0.27.7': resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openharmony-arm64@0.27.7': resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/sunos-x64@0.27.7': resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} engines: {node: '>=18'} cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/win32-arm64@0.27.7': resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} engines: {node: '>=18'} cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-ia32@0.27.7': resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} engines: {node: '>=18'} cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-x64@0.27.7': resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} engines: {node: '>=18'} cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -445,12 +607,21 @@ packages: '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@toon-format/toon@2.3.0': resolution: {integrity: sha512-/Ew9etdRQKVMnm9fDaCG0JjyAOK/O7T0M97oum1aW4W+UR8ZhVVPBanIV7oWgHBiGlnVxV9M55PWQCHofDV07w==} '@tybys/wasm-util@0.10.2': resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} @@ -472,6 +643,35 @@ packages: '@types/webextension-polyfill@0.12.5': resolution: {integrity: sha512-uKSAv6LgcVdINmxXMKBuVIcg/2m5JZugoZO8x20g7j2bXJkPIl/lVGQcDlbV+aXAiTyXT2RA5U5mI4IGCDMQeg==} + '@vitest/expect@4.1.8': + resolution: {integrity: sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==} + + '@vitest/mocker@4.1.8': + resolution: {integrity: sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.8': + resolution: {integrity: sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==} + + '@vitest/runner@4.1.8': + resolution: {integrity: sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==} + + '@vitest/snapshot@4.1.8': + resolution: {integrity: sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==} + + '@vitest/spy@4.1.8': + resolution: {integrity: sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==} + + '@vitest/utils@4.1.8': + resolution: {integrity: sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==} + '@webext-core/fake-browser@1.5.2': resolution: {integrity: sha512-nkDQwOJ23X5Q7cEtN6LRuBtVFf1KVOFi5GoQAro0lzqdh59F5E+K350j1isbnqYbzsXRh1NJtboudIcHfZtvOQ==} @@ -527,6 +727,10 @@ packages: resolution: {integrity: sha512-1OvF9IbWwaeiM9VhzYXVQacMibxpXOMYVNIvMtKRyX9SImBXpKcFr8XvFDeEslCyuH/t6KRt7HEO94AlP8Iatw==} engines: {node: '>=12'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + async-mutex@0.5.0: resolution: {integrity: sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==} @@ -586,6 +790,10 @@ packages: resolution: {integrity: sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==} engines: {node: '>=16'} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + chalk@5.6.2: resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} @@ -661,6 +869,9 @@ packages: resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} engines: {node: ^14.18.0 || >=16.10.0} + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} @@ -793,6 +1004,11 @@ packages: engines: {node: '>=18'} hasBin: true + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -815,6 +1031,10 @@ packages: eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + exsolve@1.0.8: resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} @@ -1448,6 +1668,9 @@ packages: shellwords@0.1.1: resolution: {integrity: sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww==} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} @@ -1491,6 +1714,12 @@ packages: split@1.0.1: resolution: {integrity: sha512-mTyOoPbrivtXnwnIxZRFYRrPNtEFKlpB2fvjSnCQUiAA6qAZzqwna5envK4uk6OIeP17CsdF3rSBGYVBsU0Tkg==} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -1541,6 +1770,9 @@ packages: through@2.3.8: resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + tinyexec@1.2.4: resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} engines: {node: '>=18'} @@ -1549,6 +1781,10 @@ packages: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + tmp@0.2.5: resolution: {integrity: sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==} engines: {node: '>=14.14'} @@ -1665,6 +1901,47 @@ packages: yaml: optional: true + vitest@4.1.8: + resolution: {integrity: sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.8 + '@vitest/browser-preview': 4.1.8 + '@vitest/browser-webdriverio': 4.1.8 + '@vitest/coverage-istanbul': 4.1.8 + '@vitest/coverage-v8': 4.1.8 + '@vitest/ui': 4.1.8 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + watchpack@2.4.4: resolution: {integrity: sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA==} engines: {node: '>=10.13.0'} @@ -1691,6 +1968,11 @@ packages: engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + widest-line@5.0.0: resolution: {integrity: sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==} engines: {node: '>=18'} @@ -1824,81 +2106,159 @@ snapshots: '@esbuild/aix-ppc64@0.27.7': optional: true + '@esbuild/aix-ppc64@0.28.1': + optional: true + '@esbuild/android-arm64@0.27.7': optional: true + '@esbuild/android-arm64@0.28.1': + optional: true + '@esbuild/android-arm@0.27.7': optional: true + '@esbuild/android-arm@0.28.1': + optional: true + '@esbuild/android-x64@0.27.7': optional: true + '@esbuild/android-x64@0.28.1': + optional: true + '@esbuild/darwin-arm64@0.27.7': optional: true + '@esbuild/darwin-arm64@0.28.1': + optional: true + '@esbuild/darwin-x64@0.27.7': optional: true + '@esbuild/darwin-x64@0.28.1': + optional: true + '@esbuild/freebsd-arm64@0.27.7': optional: true + '@esbuild/freebsd-arm64@0.28.1': + optional: true + '@esbuild/freebsd-x64@0.27.7': optional: true + '@esbuild/freebsd-x64@0.28.1': + optional: true + '@esbuild/linux-arm64@0.27.7': optional: true + '@esbuild/linux-arm64@0.28.1': + optional: true + '@esbuild/linux-arm@0.27.7': optional: true + '@esbuild/linux-arm@0.28.1': + optional: true + '@esbuild/linux-ia32@0.27.7': optional: true + '@esbuild/linux-ia32@0.28.1': + optional: true + '@esbuild/linux-loong64@0.27.7': optional: true + '@esbuild/linux-loong64@0.28.1': + optional: true + '@esbuild/linux-mips64el@0.27.7': optional: true + '@esbuild/linux-mips64el@0.28.1': + optional: true + '@esbuild/linux-ppc64@0.27.7': optional: true + '@esbuild/linux-ppc64@0.28.1': + optional: true + '@esbuild/linux-riscv64@0.27.7': optional: true + '@esbuild/linux-riscv64@0.28.1': + optional: true + '@esbuild/linux-s390x@0.27.7': optional: true + '@esbuild/linux-s390x@0.28.1': + optional: true + '@esbuild/linux-x64@0.27.7': optional: true + '@esbuild/linux-x64@0.28.1': + optional: true + '@esbuild/netbsd-arm64@0.27.7': optional: true + '@esbuild/netbsd-arm64@0.28.1': + optional: true + '@esbuild/netbsd-x64@0.27.7': optional: true + '@esbuild/netbsd-x64@0.28.1': + optional: true + '@esbuild/openbsd-arm64@0.27.7': optional: true + '@esbuild/openbsd-arm64@0.28.1': + optional: true + '@esbuild/openbsd-x64@0.27.7': optional: true + '@esbuild/openbsd-x64@0.28.1': + optional: true + '@esbuild/openharmony-arm64@0.27.7': optional: true + '@esbuild/openharmony-arm64@0.28.1': + optional: true + '@esbuild/sunos-x64@0.27.7': optional: true + '@esbuild/sunos-x64@0.28.1': + optional: true + '@esbuild/win32-arm64@0.27.7': optional: true + '@esbuild/win32-arm64@0.28.1': + optional: true + '@esbuild/win32-ia32@0.27.7': optional: true + '@esbuild/win32-ia32@0.28.1': + optional: true + '@esbuild/win32-x64@0.27.7': optional: true + '@esbuild/win32-x64@0.28.1': + optional: true + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -2041,6 +2401,8 @@ snapshots: '@rolldown/pluginutils@1.0.1': {} + '@standard-schema/spec@1.1.0': {} + '@toon-format/toon@2.3.0': {} '@tybys/wasm-util@0.10.2': @@ -2048,6 +2410,13 @@ snapshots: tslib: 2.8.1 optional: true + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + '@types/estree@1.0.9': {} '@types/filesystem@0.0.36': @@ -2066,6 +2435,47 @@ snapshots: '@types/webextension-polyfill@0.12.5': {} + '@vitest/expect@4.1.8': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.8 + '@vitest/utils': 4.1.8 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.8(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0))': + dependencies: + '@vitest/spy': 4.1.8 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0) + + '@vitest/pretty-format@4.1.8': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.8': + dependencies: + '@vitest/utils': 4.1.8 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.8': + dependencies: + '@vitest/pretty-format': 4.1.8 + '@vitest/utils': 4.1.8 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.8': {} + + '@vitest/utils@4.1.8': + dependencies: + '@vitest/pretty-format': 4.1.8 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + '@webext-core/fake-browser@1.5.2': dependencies: '@types/webextension-polyfill': 0.12.5 @@ -2114,6 +2524,8 @@ snapshots: array-union@3.0.1: {} + assertion-error@2.0.1: {} + async-mutex@0.5.0: dependencies: tslib: 2.8.1 @@ -2180,6 +2592,8 @@ snapshots: camelcase@8.0.0: {} + chai@6.2.2: {} + chalk@5.6.2: {} chokidar@5.0.0: @@ -2255,6 +2669,8 @@ snapshots: consola@3.4.2: {} + convert-source-map@2.0.0: {} + core-util-is@1.0.3: {} css-select@5.2.2: @@ -2381,6 +2797,35 @@ snapshots: '@esbuild/win32-ia32': 0.27.7 '@esbuild/win32-x64': 0.27.7 + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + escalade@3.2.0: {} escape-goat@4.0.0: {} @@ -2395,6 +2840,8 @@ snapshots: eventemitter3@5.0.4: {} + expect-type@1.3.0: {} + exsolve@1.0.8: {} fast-redact@3.5.0: {} @@ -2999,6 +3446,8 @@ snapshots: shellwords@0.1.1: {} + siginfo@2.0.0: {} + signal-exit@4.1.0: {} sisteransi@1.0.5: {} @@ -3039,6 +3488,10 @@ snapshots: dependencies: through: 2.3.8 + stackback@0.0.2: {} + + std-env@4.1.0: {} + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -3090,6 +3543,8 @@ snapshots: through@2.3.8: {} + tinybench@2.9.0: {} + tinyexec@1.2.4: {} tinyglobby@0.2.17: @@ -3097,6 +3552,8 @@ snapshots: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 + tinyrainbow@3.1.0: {} + tmp@0.2.5: {} tslib@2.8.1: {} @@ -3198,6 +3655,46 @@ snapshots: fsevents: 2.3.3 jiti: 2.7.0 + vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.15 + rolldown: 1.0.3 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 25.9.3 + esbuild: 0.28.1 + fsevents: 2.3.3 + jiti: 2.7.0 + + vitest@4.1.8(@types/node@25.9.3)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)): + dependencies: + '@vitest/expect': 4.1.8 + '@vitest/mocker': 4.1.8(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)) + '@vitest/pretty-format': 4.1.8 + '@vitest/runner': 4.1.8 + '@vitest/snapshot': 4.1.8 + '@vitest/spy': 4.1.8 + '@vitest/utils': 4.1.8 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.2 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 25.9.3 + transitivePeerDependencies: + - msw + watchpack@2.4.4: dependencies: glob-to-regexp: 0.4.1 @@ -3243,6 +3740,11 @@ snapshots: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + widest-line@5.0.0: dependencies: string-width: 7.2.0 diff --git a/utils/apimap.test.ts b/utils/apimap.test.ts new file mode 100644 index 0000000..9855af2 --- /dev/null +++ b/utils/apimap.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest'; +import { buildApiMap } from './apimap'; +import type { CompactEntry } from './compact'; + +const entry = (over: Partial): CompactEntry => ({ + startedDateTime: '2026-06-12T00:00:00.000Z', + time: 1, + type: 'fetch', + method: 'GET', + url: 'https://api.x.app/api/x', + status: 200, + mimeType: 'application/json', + requestBody: null, + responseBody: null, + ...over, +}); + +describe('buildApiMap', () => { + it('normalizes ids, merges calls and collects statuses + query keys', () => { + const map = buildApiMap([ + entry({ url: 'https://api.x.app/api/articles/42?page=2', count: 3, responseBody: { id: 42 } }), + entry({ url: 'https://api.x.app/api/articles/17', status: 404, responseBody: { error: 'nf' } }), + entry({ method: 'POST', url: 'https://api.x.app/api/login', status: 401, requestBody: { email: 'a' }, responseBody: { m: 'no' } }), + ]); + expect(map).toHaveLength(2); + const articles = map.find((e) => e.endpoint.includes(':id'))!; + expect(articles.calls).toBe(4); + expect(articles.statuses).toEqual([200, 404]); + expect(articles.query).toContain('page'); + }); + + it('prefers a success sample for the response example', () => { + const map = buildApiMap([ + entry({ url: 'https://api.x.app/api/items/3', status: 500, responseBody: { error: 'boom' } }), + entry({ url: 'https://api.x.app/api/items/4', status: 200, responseBody: { id: 4 } }), + ]); + expect((map[0].response as Record).id).toBe(4); + }); + + it('skips markers and non-API assets, but keeps API errors', () => { + const map = buildApiMap([ + entry({ type: 'marker', url: 'clicked Save' }), + entry({ url: 'https://x.app/ciel.webp', mimeType: 'image/webp', responseBody: 'UklGR…' }), + entry({ url: 'https://x.app/api/fail', status: 500, mimeType: 'text/html', responseBody: 'err' }), + entry({ url: 'https://x.app/api/items/3', responseBody: { id: 3 } }), + ]); + const endpoints = map.map((m) => m.endpoint); + expect(endpoints.some((e) => e.includes('ciel.webp'))).toBe(false); + expect(endpoints.some((e) => e.includes('/api/fail'))).toBe(true); + expect(endpoints.some((e) => e.includes('/api/items/:id'))).toBe(true); + }); + + it('normalizes uuids and long hashes', () => { + const map = buildApiMap([ + entry({ url: 'https://x.app/api/jobs/0b66ecb9-4a5f-4c89-9c57-f1ca6f448b04' }), + entry({ url: 'https://x.app/api/blobs/0123456789abcdef0123' }), + ]); + expect(map.map((m) => m.endpoint).sort()).toEqual([ + 'https://x.app/api/blobs/:hash', + 'https://x.app/api/jobs/:uuid', + ]); + }); +}); diff --git a/utils/bridge.test.ts b/utils/bridge.test.ts new file mode 100644 index 0000000..4113d57 --- /dev/null +++ b/utils/bridge.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest'; +import { hostOf, isApiLike, isSameSite, isToolingNoise } from './bridge'; + +describe('hostOf', () => { + it('extracts hostnames without port or credentials', () => { + expect(hostOf('https://tpk.api.example.app/api/x?y=1')).toBe('tpk.api.example.app'); + expect(hostOf('http://127.0.0.1:8001/api/articles')).toBe('127.0.0.1'); + expect(hostOf('not a url')).toBeNull(); + }); +}); + +describe('isSameSite', () => { + it('tolerates subdomains of the inspected page', () => { + expect(isSameSite('https://tpk.api.example.app/api/x', 'tpk.example.app')).toBe(true); + expect(isSameSite('https://example.app/x', 'tpk.example.app')).toBe(true); + }); + + it('excludes third parties', () => { + expect(isSameSite('https://cdn.cookielaw.org/consent/x.json', 'tpk.example.app')).toBe(false); + expect(isSameSite('https://geolocation.onetrust.com/x', 'tpk.example.app')).toBe(false); + }); + + it('uses strict equality for IPs and localhost', () => { + expect(isSameSite('http://127.0.0.1:8001/api/x', '127.0.0.1')).toBe(true); + expect(isSameSite('https://cdn.cookielaw.org/x', '127.0.0.1')).toBe(false); + expect(isSameSite('http://localhost:3000/x', 'localhost')).toBe(true); + }); + + it('lets everything through when the host is unknown', () => { + expect(isSameSite('https://anything.dev/x', null)).toBe(true); + }); +}); + +describe('isApiLike', () => { + it('keeps API content types and bodyless responses', () => { + expect(isApiLike('application/json')).toBe(true); + expect(isApiLike('application/json; charset=utf-8')).toBe(true); + expect(isApiLike(undefined)).toBe(true); + }); + + it('rejects assets even when loaded via fetch', () => { + for (const mime of ['image/webp', 'font/woff2', 'text/html', 'text/plain', 'text/x-component']) { + expect(isApiLike(mime)).toBe(false); + } + }); +}); + +describe('isToolingNoise', () => { + it('drops source maps, RSC payloads and manifests at capture time', () => { + expect(isToolingNoise('http://localhost:3000/_next/static/chunks/_c3d8c4a5._.js.map')).toBe(true); + expect(isToolingNoise('http://localhost:3000/?_rsc=15c8q')).toBe(true); + expect(isToolingNoise('http://localhost:3000/manifest.json')).toBe(true); + expect(isToolingNoise('http://x/y', 'text/x-component')).toBe(true); + }); + + it('keeps real API calls, including business "map" endpoints', () => { + expect(isToolingNoise('http://127.0.0.1:8001/api/articles?perPage=200', 'application/json')).toBe(false); + expect(isToolingNoise('http://127.0.0.1:8001/api/maps/geo.json', 'application/json')).toBe(false); + }); +}); diff --git a/utils/compact.test.ts b/utils/compact.test.ts new file mode 100644 index 0000000..c8db09e --- /dev/null +++ b/utils/compact.test.ts @@ -0,0 +1,200 @@ +import { describe, expect, it } from 'vitest'; +import { + CAPTURE_LIMITS, + DETAIL_LEVELS, + compactBody, + compactValue, + recompactEntry, + recompactValue, + type CompactEntry, +} from './compact'; + +const capture = (v: unknown) => compactValue(v, CAPTURE_LIMITS); +const { S, M, L } = DETAIL_LEVELS; + +const baseEntry: CompactEntry = { + startedDateTime: '2026-06-12T00:00:00.000Z', + time: 1, + type: 'fetch', + method: 'GET', + url: 'https://api.example.dev/api/x', + status: 200, + requestBody: null, + responseBody: null, +}; + +describe('capture compaction', () => { + it('truncates long strings and keeps the true total', () => { + const out = capture({ text: 'x'.repeat(2000) }) as Record; + expect(out.text.startsWith('x'.repeat(1024))).toBe(true); + expect(out.text).toContain('…[truncated, 2000 chars total]'); + }); + + it('cuts arrays at 8 items with a total marker', () => { + const out = capture(Array.from({ length: 50 }, (_, i) => i)) as unknown[]; + expect(out).toHaveLength(9); + expect(out[8]).toBe('…[+42 items, 50 total]'); + }); + + it('shrinks base64 content hard', () => { + const body = compactBody('UklGRmIcAABXRUJQVlA4WAoA'.repeat(500), 'base64') as string; + expect(body).toContain('…[base64 binary,'); + expect(body.length).toBeLessThan(150); + }); + + it('parses JSON bodies and falls back to truncated text', () => { + expect(compactBody('{"a":1}')).toEqual({ a: 1 }); + expect(compactBody('a=1&b=2')).toBe('a=1&b=2'); + expect(compactBody(null)).toBeNull(); + }); + + it('keeps every object key up to the 500-key guard', () => { + const all = capture(Object.fromEntries(Array.from({ length: 456 }, (_, i) => [`k${i}`, i]))); + expect(Object.keys(all as object)).toHaveLength(456); + const guarded = capture(Object.fromEntries(Array.from({ length: 600 }, (_, i) => [`k${i}`, i]))) as Record< + string, + unknown + >; + expect(Object.keys(guarded)).toHaveLength(501); + expect(guarded['…']).toBe('[+100 keys, 600 total]'); + }); +}); + +describe('secret redaction (always on)', () => { + it('redacts sensitive keys but not lookalikes', () => { + const out = capture({ + password: 'hunter2', + api_key: 'abc', + author: 'Victor Hugo', + session_duration: 3600, + chat_token_quota_monthly: 500000, + access_token: 'abc', + tokens: ['a', 'b'], + session_id: 'x', + vapid_private_key: 'k', + nested: { Authorization: 'Bearer xyz' }, + }) as Record; + expect(out.password).toBe('***'); + expect(out.api_key).toBe('***'); + expect(out.access_token).toBe('***'); + expect(out.tokens).toBe('***'); + expect(out.session_id).toBe('***'); + expect(out.vapid_private_key).toBe('***'); + expect((out.nested as Record).Authorization).toBe('***'); + expect(out.author).toBe('Victor Hugo'); + expect(out.session_duration).toBe(3600); + expect(out.chat_token_quota_monthly).toBe(500000); + }); + + it('redacts JWT-looking values under innocent keys and form-encoded secrets', () => { + const jwt = capture({ data: 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.sig' }) as Record; + expect(jwt.data).toBe('***'); + expect(compactBody('user=jo&password=secret123&x=1')).toBe('user=jo&password=***&x=1'); + }); +}); + +describe('marker-aware re-truncation', () => { + it('keeps original totals across successive passes', () => { + const captured = compactBody( + JSON.stringify({ items: Array.from({ length: 50 }, (_, i) => ({ id: i, text: 'y'.repeat(2000) })) }), + ) as { items: unknown[] }; + expect(captured.items).toHaveLength(9); + + const m = recompactValue(captured, M) as { items: unknown[] }; + expect(m.items).toHaveLength(4); + expect(m.items[3]).toBe('…[+47 items, 50 total]'); + expect((m.items[0] as Record).text).toContain('2000 chars total'); + + const s = recompactValue(m, S) as { items: unknown[] }; + expect(s.items[1]).toBe('…[+49 items, 50 total]'); + }); + + it('replaces content beyond the depth limit with descriptive stubs', () => { + const deep = capture({ data: { items: [{ contact: { societe: { champs: { a: 1, b: 2 }, nom: 'X' } } }] } }); + const m = recompactValue(deep, M) as any; + expect(m.data.items[0].contact.societe.champs).toBe('…[object, 2 keys]'); + expect(m.data.items[0].contact.societe.nom).toBe('X'); + const s = recompactValue(deep, S) as any; + expect(s.data.items[0]).toBe('…[object, 1 keys]'); + }); +}); + +describe('sibling dedup and item diff', () => { + it('collapses identical sibling objects to a reference', () => { + const batch = capture({ + data: Object.fromEntries( + Array.from({ length: 10 }, (_, i) => [`44${23 + i}`, { total: 0, todo: 0, inProgress: 0, completed: 0, links: [] }]), + ), + }); + const out = recompactValue(batch, M) as { data: Record }; + const values = Object.values(out.data); + expect(typeof values[0]).toBe('object'); + expect(values.slice(1).every((v) => v === '…[same as "4423"]')).toBe(true); + }); + + it('never collapses scalars', () => { + const out = recompactValue(capture({ a: 0, b: 0, c: 0 }), M) as Record; + expect([out.a, out.b, out.c]).toEqual([0, 0, 0]); + }); + + it('diffs array items against item 0', () => { + const item = (id: number, label: string) => ({ + id, + type: 71, + label, + order: id, + isAdmin: false, + createdBy: 'LP', + updatedBy: 'SV', + createdAt: null, + updatedAt: null, + }); + const out = recompactValue(capture({ items: [item(1, 'CP'), item(2, 'AM'), item(3, 'RTT')] }), L) as { + items: Record[]; + }; + expect(Object.keys(out.items[0])).toHaveLength(9); + expect(out.items[1].id).toBe(2); + expect(out.items[1]).not.toHaveProperty('type'); + expect(out.items[1]).not.toHaveProperty('createdBy'); + expect(out.items[1]['…']).toBe('[6 keys same as item 0]'); + }); + + it('keeps heterogeneous items whole', () => { + const out = recompactValue(capture([{ a: 1, b: 2, c: 3 }, { x: 9, y: 8, z: 7 }]), M) as Record[]; + expect(out[1]).toHaveProperty('x'); + expect(out[1]).not.toHaveProperty('…'); + }); +}); + +describe('export entries', () => { + it('keeps headers only when asked', () => { + const entry = { ...baseEntry, requestHeaders: { etag: 'x' } }; + expect(recompactEntry(entry, M, false)).not.toHaveProperty('requestHeaders'); + expect((recompactEntry(entry, M, true) as Record).requestHeaders).toEqual({ etag: 'x' }); + }); + + it('exports markers as minimal steps', () => { + const out = recompactEntry( + { ...baseEntry, type: 'marker', url: 'clicked Save' }, + M, + false, + ) as Record; + expect(out).toEqual({ type: 'marker', label: 'clicked Save', at: baseEntry.startedDateTime }); + }); + + it('flattens the initiator to a single line, preferring the app frame', () => { + const withVia = recompactEntry( + { ...baseEntry, initiator: { kind: 'script', at: '/chunk.js:1:1 (request)', via: 'src/hooks/useX.ts:12:9 (useX)' } }, + M, + false, + ) as Record; + expect(withVia.initiator).toBe('src/hooks/useX.ts:12:9 (useX)'); + const atOnly = recompactEntry( + { ...baseEntry, initiator: { kind: 'script', at: '/chunk.js:1:1 (request)' } }, + M, + false, + ) as Record; + expect(atOnly.initiator).toBe('/chunk.js:1:1 (request)'); + expect(recompactEntry(baseEntry, M, false)).not.toHaveProperty('initiator'); + }); +}); diff --git a/utils/initiator.test.ts b/utils/initiator.test.ts new file mode 100644 index 0000000..f7020d5 --- /dev/null +++ b/utils/initiator.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest'; +import { buildInitiator, flattenFrames, makeSnippet, pickFrames, type HarInitiator } from './initiator'; + +const LIB = 'http://localhost:3000/_next/static/chunks/libs_http_e8bb._.js'; +const HOOK = 'http://localhost:3000/_next/static/chunks/app_hooks_useUsers_ts.js'; + +// real-world shape: the sync frames sit in the HTTP wrapper, the hook lives in +// the async parent chain +const realStack: HarInitiator = { + type: 'script', + stack: { + callFrames: [ + { functionName: 'request', url: LIB, lineNumber: 1525, columnNumber: 32 }, + { functionName: 'request', url: LIB, lineNumber: 1600, columnNumber: 24 }, + ], + parent: { + description: 'await', + callFrames: [ + { functionName: 'getMe', url: LIB, lineNumber: 1700, columnNumber: 10 }, + { functionName: 'useUsersMe', url: HOOK, lineNumber: 11, columnNumber: 8 }, + ], + }, + }, +}; + +describe('frame picking', () => { + it('flattens async parent chains', () => { + expect(flattenFrames(realStack.stack)).toHaveLength(4); + }); + + it('prefers React-hook-named frames as the app frame', () => { + const { top, app } = pickFrames(realStack); + expect(top?.functionName).toBe('request'); + expect(app?.functionName).toBe('useUsersMe'); + }); + + it('falls back to the first frame from another file', () => { + const init = buildInitiator({ + type: 'script', + stack: { + callFrames: [ + { functionName: 'request', url: LIB, lineNumber: 1, columnNumber: 1 }, + { functionName: 'loadDashboard', url: HOOK, lineNumber: 40, columnNumber: 2 }, + ], + }, + })!; + expect(init.via).toContain('loadDashboard'); + }); + + it('omits via when nothing is distinctive', () => { + const init = buildInitiator({ + type: 'script', + stack: { callFrames: [{ functionName: 'a', url: LIB, lineNumber: 1, columnNumber: 1 }] }, + })!; + expect(init.via).toBeUndefined(); + }); +}); + +describe('buildInitiator labels', () => { + it('formats 1-based file:line:col with the function name, path only', () => { + const init = buildInitiator(realStack)!; + expect(init.at).toBe('/_next/static/chunks/libs_http_e8bb._.js:1526:33 (request)'); + expect(init.via).toBe('/_next/static/chunks/app_hooks_useUsers_ts.js:12:9 (useUsersMe)'); + }); + + it('handles parser initiators and empty ones', () => { + expect(buildInitiator({ type: 'parser', url: 'https://x.app/index.html', lineNumber: 12 })).toEqual({ + kind: 'parser', + at: '/index.html:13', + }); + expect(buildInitiator({ type: 'other' })).toBeUndefined(); + expect(buildInitiator(undefined)).toBeUndefined(); + }); +}); + +describe('makeSnippet', () => { + it('returns 3 lines with the call line marked', () => { + const snippet = makeSnippet('function a(){\n let n = await fetch(l(e, i));\n if (p(w)) return n;\n}', 1, 17); + const lines = snippet.split('\n'); + expect(lines).toHaveLength(3); + expect(lines[0].startsWith(' 1│')).toBe(true); + expect(lines[1]).toContain('>2│ let n = await fetch'); + }); + + it('windows minified one-liners around the call column', () => { + const minified = 'x'.repeat(5000) + 'await fetch("/api/articles")' + 'y'.repeat(5000); + const snippet = makeSnippet(minified, 0, 5000); + expect(snippet).toContain('await fetch'); + expect(snippet).toContain('…'); + expect(snippet.length).toBeLessThan(260); + }); + + it('is safe on out-of-range lines', () => { + expect(makeSnippet('one line', 5, 0)).toBe(''); + }); +}); diff --git a/utils/sourcemap.test.ts b/utils/sourcemap.test.ts new file mode 100644 index 0000000..2611745 --- /dev/null +++ b/utils/sourcemap.test.ts @@ -0,0 +1,44 @@ +import { transformSync } from 'esbuild'; +import { describe, expect, it } from 'vitest'; +import { cleanSourcePath, findSourceMappingURL, resolveInMap } from './sourcemap'; + +describe('resolveInMap', () => { + it('maps a minified position back to the original file and line (real esbuild map)', () => { + const src = 'export function useUsersMe(){\n const q = init();\n return fetch("/api/users/me");\n}\n'; + const out = transformSync(src, { + minify: true, + sourcemap: true, + sourcefile: 'turbopack://[project]/src/hooks/useUsers.ts', + }); + const map = JSON.parse(out.map); + const genCol = out.code.indexOf('fetch('); + expect(genCol).toBeGreaterThan(0); + + const hit = resolveInMap(map, 0, genCol)!; + expect(hit).toBeTruthy(); + expect(map.sources[hit.srcIdx]).toContain('useUsers.ts'); + expect(hit.origLine).toBe(2); // 0-based: the fetch sits on source line 3 + expect(map.sourcesContent?.[hit.srcIdx]).toContain('useUsersMe'); + }); + + it('returns null on unmappable input', () => { + expect(resolveInMap({ version: 3, sources: [], mappings: '' }, 0, 10)).toBeNull(); + }); +}); + +describe('findSourceMappingURL', () => { + it('finds file references and huge inline data URIs', () => { + expect(findSourceMappingURL('code;\n//# sourceMappingURL=chunk.js.map')).toBe('chunk.js.map'); + const data = findSourceMappingURL('x;\n//# sourceMappingURL=data:application/json;base64,' + 'A'.repeat(5000)); + expect(data?.startsWith('data:application/json')).toBe(true); + expect(data!.length).toBeGreaterThan(5000); + expect(findSourceMappingURL('no map here')).toBeNull(); + }); +}); + +describe('cleanSourcePath', () => { + it('strips bundler prefixes', () => { + expect(cleanSourcePath('turbopack://[project]/src/hooks/useUsers.ts')).toBe('src/hooks/useUsers.ts'); + expect(cleanSourcePath('webpack://_N_E/./src/x.ts')).toBe('src/x.ts'); + }); +});