diff --git a/.gitignore b/.gitignore index 3eef307..d4541df 100644 --- a/.gitignore +++ b/.gitignore @@ -21,4 +21,7 @@ data/ .idea/ **/.env -**/.ccpa \ No newline at end of file +**/.ccpa + +# Worktrees +.worktrees/ \ No newline at end of file diff --git a/package.json b/package.json index b440660..0009422 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,10 @@ "typecheck": "tsc --noEmit", "lint": "biome check src", "lint:fix": "biome check --write src", + "test": "vitest", + "test:run": "vitest run", + "test:ui": "vitest --ui", + "test:coverage": "vitest run --coverage", "prepublishOnly": "pnpm run build", "prepare": "husky" }, @@ -46,9 +50,11 @@ "devDependencies": { "@biomejs/biome": "^2.3.11", "@types/node": "^25.0.8", + "@vitest/ui": "^4.0.18", "husky": "^9.1.7", "tsx": "^4.21.0", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "vitest": "^4.0.18" }, "engines": { "node": ">=18" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ced207a..197ab69 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -30,6 +30,9 @@ importers: '@types/node': specifier: ^25.0.8 version: 25.0.8 + '@vitest/ui': + specifier: ^4.0.18 + version: 4.0.18(vitest@4.0.18) husky: specifier: ^9.1.7 version: 9.1.7 @@ -39,6 +42,9 @@ importers: typescript: specifier: ^5.9.3 version: 5.9.3 + vitest: + specifier: ^4.0.18 + version: 4.0.18(@types/node@25.0.8)(@vitest/ui@4.0.18)(tsx@4.21.0) packages: @@ -254,6 +260,9 @@ packages: '@grammyjs/types@3.23.0': resolution: {integrity: sha512-D3jQ4UWERPsyR3op/YFudMMIPNTU47vy7L51uO9/73tMELmjO/+LX5N36/Y0CG5IQfIsz43MxiHI5rgsK0/k+g==} + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -269,13 +278,191 @@ packages: '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + + '@rollup/rollup-android-arm-eabi@4.59.0': + resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.59.0': + resolution: {integrity: sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.59.0': + resolution: {integrity: sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.59.0': + resolution: {integrity: sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.59.0': + resolution: {integrity: sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.59.0': + resolution: {integrity: sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.59.0': + resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.59.0': + resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.59.0': + resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.59.0': + resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.59.0': + resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.59.0': + resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.59.0': + resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.59.0': + resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.59.0': + resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.59.0': + resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.59.0': + resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.59.0': + resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.59.0': + resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.59.0': + resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.59.0': + resolution: {integrity: sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.59.0': + resolution: {integrity: sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.59.0': + resolution: {integrity: sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.59.0': + resolution: {integrity: sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.59.0': + resolution: {integrity: sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==} + cpu: [x64] + os: [win32] + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@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.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/node@25.0.8': resolution: {integrity: sha512-powIePYMmC3ibL0UJ2i2s0WIbq6cg6UyVFQxSCpaPxxzAaziRfimGivjdF943sSGV6RADVbk0Nvlm5P/FB44Zg==} + '@vitest/expect@4.0.18': + resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==} + + '@vitest/mocker@4.0.18': + resolution: {integrity: sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.0.18': + resolution: {integrity: sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==} + + '@vitest/runner@4.0.18': + resolution: {integrity: sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==} + + '@vitest/snapshot@4.0.18': + resolution: {integrity: sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==} + + '@vitest/spy@4.0.18': + resolution: {integrity: sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==} + + '@vitest/ui@4.0.18': + resolution: {integrity: sha512-CGJ25bc8fRi8Lod/3GHSvXRKi7nBo3kxh0ApW4yCjmrWmRmlT53B5E08XRSZRliygG0aVNxLrBEqPYdz/KcCtQ==} + peerDependencies: + vitest: 4.0.18 + + '@vitest/utils@4.0.18': + resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} + abort-controller@3.0.0: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} engines: {node: '>=6.5'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + atomic-sleep@1.0.0: resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} engines: {node: '>=8.0.0'} @@ -284,6 +471,10 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -301,11 +492,17 @@ packages: resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==} engines: {node: '>=12'} + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + esbuild@0.27.2: resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==} engines: {node: '>=18'} hasBin: true + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + event-target-shim@5.0.1: resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} engines: {node: '>=6'} @@ -314,6 +511,10 @@ packages: resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} engines: {node: '>=10'} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + fast-glob@3.3.3: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} @@ -321,10 +522,25 @@ packages: fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fflate@0.8.2: + resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + flatted@3.3.4: + resolution: {integrity: sha512-3+mMldrTAPdta5kjX2G2J7iX4zxtnwpdA8Tr2ZSjkyPSanvbZAcy6flmtnXbEybHrDcU9641lxrMfFuUxVz9vA==} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -373,6 +589,9 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} @@ -388,9 +607,18 @@ packages: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} + 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.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + node-fetch@2.7.0: resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} engines: {node: 4.x || >=6.0.0} @@ -408,6 +636,9 @@ packages: resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} engines: {node: '>=8'} + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + on-exit-leak-free@2.1.2: resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} engines: {node: '>=14.0.0'} @@ -420,10 +651,20 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + picomatch@2.3.1: resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} engines: {node: '>=8.6'} + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + pino-abstract-transport@3.0.0: resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} @@ -434,6 +675,10 @@ packages: resolution: {integrity: sha512-NFnZqUliT+OHkRXVSf8vdOr13N1wv31hRryVjqbreVh/SDCNaI6mnRDDq89HVRCbem1SAl7yj04OANeqP0nT6A==} hasBin: true + postcss@8.5.8: + resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} + engines: {node: ^10 || ^12 || >=14} + process-warning@5.0.0: resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} @@ -458,6 +703,11 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rollup@4.59.0: + resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -477,16 +727,33 @@ packages: resolution: {integrity: sha512-Jex+xw5Mg2qMZL3qnzXIfaxEtBaC4n7xifqaqtrZDdlheR70OGkydrPJWT0V1cA1k3nanC86x9FwAmQl6w3Klw==} engines: {node: '>=18'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + sirv@3.0.2: + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} + engines: {node: '>=18'} + sonic-boom@4.2.0: resolution: {integrity: sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==} + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + split2@4.2.0: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + strip-final-newline@2.0.0: resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} engines: {node: '>=6'} @@ -495,10 +762,29 @@ packages: resolution: {integrity: sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==} engines: {node: '>=20'} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.0.2: + resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} + engines: {node: '>=18'} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.0.3: + resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} + engines: {node: '>=14.0.0'} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} @@ -515,6 +801,80 @@ packages: undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + vite@7.3.1: + resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.0.18: + resolution: {integrity: sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==} + 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.0.18 + '@vitest/browser-preview': 4.0.18 + '@vitest/browser-webdriverio': 4.0.18 + '@vitest/ui': 4.0.18 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} @@ -526,6 +886,11 @@ packages: engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + zod@4.3.5: resolution: {integrity: sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==} @@ -646,6 +1011,8 @@ snapshots: '@grammyjs/types@3.23.0': {} + '@jridgewell/sourcemap-codec@1.5.5': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -660,20 +1027,162 @@ snapshots: '@pinojs/redact@0.4.0': {} + '@polka/url@1.0.0-next.29': {} + + '@rollup/rollup-android-arm-eabi@4.59.0': + optional: true + + '@rollup/rollup-android-arm64@4.59.0': + optional: true + + '@rollup/rollup-darwin-arm64@4.59.0': + optional: true + + '@rollup/rollup-darwin-x64@4.59.0': + optional: true + + '@rollup/rollup-freebsd-arm64@4.59.0': + optional: true + + '@rollup/rollup-freebsd-x64@4.59.0': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.59.0': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.59.0': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-x64-musl@4.59.0': + optional: true + + '@rollup/rollup-openbsd-x64@4.59.0': + optional: true + + '@rollup/rollup-openharmony-arm64@4.59.0': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.59.0': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.59.0': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.59.0': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.59.0': + optional: true + + '@standard-schema/spec@1.1.0': {} + + '@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.8': {} + '@types/node@25.0.8': dependencies: undici-types: 7.16.0 + '@vitest/expect@4.0.18': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.0.18 + '@vitest/utils': 4.0.18 + chai: 6.2.2 + tinyrainbow: 3.0.3 + + '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@25.0.8)(tsx@4.21.0))': + dependencies: + '@vitest/spy': 4.0.18 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.1(@types/node@25.0.8)(tsx@4.21.0) + + '@vitest/pretty-format@4.0.18': + dependencies: + tinyrainbow: 3.0.3 + + '@vitest/runner@4.0.18': + dependencies: + '@vitest/utils': 4.0.18 + pathe: 2.0.3 + + '@vitest/snapshot@4.0.18': + dependencies: + '@vitest/pretty-format': 4.0.18 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.0.18': {} + + '@vitest/ui@4.0.18(vitest@4.0.18)': + dependencies: + '@vitest/utils': 4.0.18 + fflate: 0.8.2 + flatted: 3.3.4 + pathe: 2.0.3 + sirv: 3.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.0.3 + vitest: 4.0.18(@types/node@25.0.8)(@vitest/ui@4.0.18)(tsx@4.21.0) + + '@vitest/utils@4.0.18': + dependencies: + '@vitest/pretty-format': 4.0.18 + tinyrainbow: 3.0.3 + abort-controller@3.0.0: dependencies: event-target-shim: 5.0.1 + assertion-error@2.0.1: {} + atomic-sleep@1.0.0: {} braces@3.0.3: dependencies: fill-range: 7.1.1 + chai@6.2.2: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -686,6 +1195,8 @@ snapshots: dotenv@17.2.3: {} + es-module-lexer@1.7.0: {} + esbuild@0.27.2: optionalDependencies: '@esbuild/aix-ppc64': 0.27.2 @@ -715,6 +1226,10 @@ snapshots: '@esbuild/win32-ia32': 0.27.2 '@esbuild/win32-x64': 0.27.2 + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + event-target-shim@5.0.1: {} execa@5.1.1: @@ -729,6 +1244,8 @@ snapshots: signal-exit: 3.0.7 strip-final-newline: 2.0.0 + expect-type@1.3.0: {} + fast-glob@3.3.3: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -741,10 +1258,18 @@ snapshots: dependencies: reusify: 1.1.0 + fdir@6.5.0(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + + fflate@0.8.2: {} + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 + flatted@3.3.4: {} + fsevents@2.3.3: optional: true @@ -784,6 +1309,10 @@ snapshots: isexe@2.0.0: {} + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + merge-stream@2.0.0: {} merge2@1.4.1: {} @@ -795,8 +1324,12 @@ snapshots: mimic-fn@2.1.0: {} + mrmime@2.0.1: {} + ms@2.1.3: {} + nanoid@3.3.11: {} + node-fetch@2.7.0: dependencies: whatwg-url: 5.0.0 @@ -810,6 +1343,8 @@ snapshots: dependencies: path-key: 3.1.1 + obug@2.1.1: {} + on-exit-leak-free@2.1.2: {} onetime@5.1.2: @@ -818,8 +1353,14 @@ snapshots: path-key@3.1.1: {} + pathe@2.0.3: {} + + picocolors@1.1.1: {} + picomatch@2.3.1: {} + picomatch@4.0.3: {} + pino-abstract-transport@3.0.0: dependencies: split2: 4.2.0 @@ -840,6 +1381,12 @@ snapshots: sonic-boom: 4.2.0 thread-stream: 4.0.0 + postcss@8.5.8: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + process-warning@5.0.0: {} queue-microtask@1.2.3: {} @@ -854,6 +1401,37 @@ snapshots: reusify@1.1.0: {} + rollup@4.59.0: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.59.0 + '@rollup/rollup-android-arm64': 4.59.0 + '@rollup/rollup-darwin-arm64': 4.59.0 + '@rollup/rollup-darwin-x64': 4.59.0 + '@rollup/rollup-freebsd-arm64': 4.59.0 + '@rollup/rollup-freebsd-x64': 4.59.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.59.0 + '@rollup/rollup-linux-arm-musleabihf': 4.59.0 + '@rollup/rollup-linux-arm64-gnu': 4.59.0 + '@rollup/rollup-linux-arm64-musl': 4.59.0 + '@rollup/rollup-linux-loong64-gnu': 4.59.0 + '@rollup/rollup-linux-loong64-musl': 4.59.0 + '@rollup/rollup-linux-ppc64-gnu': 4.59.0 + '@rollup/rollup-linux-ppc64-musl': 4.59.0 + '@rollup/rollup-linux-riscv64-gnu': 4.59.0 + '@rollup/rollup-linux-riscv64-musl': 4.59.0 + '@rollup/rollup-linux-s390x-gnu': 4.59.0 + '@rollup/rollup-linux-x64-gnu': 4.59.0 + '@rollup/rollup-linux-x64-musl': 4.59.0 + '@rollup/rollup-openbsd-x64': 4.59.0 + '@rollup/rollup-openharmony-arm64': 4.59.0 + '@rollup/rollup-win32-arm64-msvc': 4.59.0 + '@rollup/rollup-win32-ia32-msvc': 4.59.0 + '@rollup/rollup-win32-x64-gnu': 4.59.0 + '@rollup/rollup-win32-x64-msvc': 4.59.0 + fsevents: 2.3.3 + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -871,24 +1449,51 @@ snapshots: execa: 5.1.1 fast-glob: 3.3.3 + siginfo@2.0.0: {} + signal-exit@3.0.7: {} + sirv@3.0.2: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + sonic-boom@4.2.0: dependencies: atomic-sleep: 1.0.0 + source-map-js@1.2.1: {} + split2@4.2.0: {} + stackback@0.0.2: {} + + std-env@3.10.0: {} + strip-final-newline@2.0.0: {} thread-stream@4.0.0: dependencies: real-require: 0.2.0 + tinybench@2.9.0: {} + + tinyexec@1.0.2: {} + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + + tinyrainbow@3.0.3: {} + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 + totalist@3.0.1: {} + tr46@0.0.3: {} tsx@4.21.0: @@ -902,6 +1507,57 @@ snapshots: undici-types@7.16.0: {} + vite@7.3.1(@types/node@25.0.8)(tsx@4.21.0): + dependencies: + esbuild: 0.27.2 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.8 + rollup: 4.59.0 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 25.0.8 + fsevents: 2.3.3 + tsx: 4.21.0 + + vitest@4.0.18(@types/node@25.0.8)(@vitest/ui@4.0.18)(tsx@4.21.0): + dependencies: + '@vitest/expect': 4.0.18 + '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.0.8)(tsx@4.21.0)) + '@vitest/pretty-format': 4.0.18 + '@vitest/runner': 4.0.18 + '@vitest/snapshot': 4.0.18 + '@vitest/spy': 4.0.18 + '@vitest/utils': 4.0.18 + es-module-lexer: 1.7.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 1.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.0.3 + vite: 7.3.1(@types/node@25.0.8)(tsx@4.21.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 25.0.8 + '@vitest/ui': 4.0.18(vitest@4.0.18) + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - yaml + webidl-conversions@3.0.1: {} whatwg-url@5.0.0: @@ -913,4 +1569,9 @@ snapshots: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + zod@4.3.5: {} diff --git a/src/__tests__/basic.test.ts b/src/__tests__/basic.test.ts new file mode 100644 index 0000000..cd20fc2 --- /dev/null +++ b/src/__tests__/basic.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; + +describe("Testing Framework Verification", () => { + it("should run basic test successfully", () => { + expect(2 + 2).toBe(4); + }); + + it("should handle async operations", async () => { + const result = await Promise.resolve("test"); + expect(result).toBe("test"); + }); + + it("should work with Date objects (timestamp validation)", () => { + const timestamp = 1709520000; + const date = new Date(timestamp * 1000); + const isoString = date.toISOString(); + + expect(isoString).toBe("2024-03-04T02:40:00.000Z"); + expect(timestamp).toBe(1709520000); + }); +}); diff --git a/src/bot/__tests__/handlers.timestamp.test.ts b/src/bot/__tests__/handlers.timestamp.test.ts new file mode 100644 index 0000000..599ae11 --- /dev/null +++ b/src/bot/__tests__/handlers.timestamp.test.ts @@ -0,0 +1,565 @@ +import { exec } from "node:child_process"; +import { unlink, writeFile } from "node:fs/promises"; +import type { Context } from "grammy"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { executeClaudeQuery } from "../../claude/executor.js"; +import { parseClaudeOutput } from "../../claude/parser.js"; +import { getConfig } from "../../config.js"; +import { getLogger } from "../../logger.js"; +import { sendChunkedResponse } from "../../telegram/chunker.js"; +import { sendDownloadFiles } from "../../telegram/fileSender.js"; +import { transcribeAudio } from "../../transcription/whisper.js"; +import { + ensureUserSetup, + getDownloadsPath, + getSessionId, + getUploadsPath, + saveSessionId, +} from "../../user/setup.js"; +import { documentHandler } from "../handlers/document.js"; +import { photoHandler } from "../handlers/photo.js"; +import { textHandler } from "../handlers/text.js"; +import { voiceHandler } from "../handlers/voice.js"; + +// Mock all dependencies +vi.mock("../../claude/executor.js"); +vi.mock("../../config.js"); +vi.mock("../../logger.js"); +vi.mock("../../user/setup.js"); +vi.mock("../../telegram/chunker.js"); +vi.mock("../../telegram/fileSender.js"); +vi.mock("../../transcription/whisper.js"); +vi.mock("../../claude/parser.js"); +vi.mock("node:fs/promises"); +vi.mock("node:child_process"); + +describe("Message Handlers - Timestamp Extraction", () => { + beforeEach(() => { + vi.clearAllMocks(); + + // Mock config + vi.mocked(getConfig).mockReturnValue({ + dataDir: "/test/data", + telegram: { + botToken: "test-bot-token", + }, + transcription: { + showTranscription: false, + }, + } as any); + + // Mock logger + vi.mocked(getLogger).mockReturnValue({ + info: vi.fn(), + debug: vi.fn(), + error: vi.fn(), + } as any); + + // Mock user setup functions + vi.mocked(ensureUserSetup).mockResolvedValue(); + vi.mocked(getDownloadsPath).mockReturnValue("/test/downloads"); + vi.mocked(getUploadsPath).mockReturnValue("/test/uploads"); + vi.mocked(getSessionId).mockResolvedValue("test-session"); + vi.mocked(saveSessionId).mockResolvedValue(); + + // Mock executor + vi.mocked(executeClaudeQuery).mockResolvedValue({ + success: true, + output: "Test response", + sessionId: "test-session-new", + }); + + // Mock telegram functions + vi.mocked(sendChunkedResponse).mockResolvedValue(); + vi.mocked(sendDownloadFiles).mockResolvedValue(0); + + // Mock additional functions for other handlers + vi.mocked(transcribeAudio).mockResolvedValue({ text: "Transcribed text" }); + vi.mocked(parseClaudeOutput).mockReturnValue({ + text: "Parsed response", + sessionId: "parsed-session-id", + }); + + // Mock file operations + vi.mocked(writeFile).mockResolvedValue(); + vi.mocked(unlink).mockResolvedValue(); + + // Mock child_process (for ffmpeg in voice handler) + vi.mocked(exec).mockImplementation((...args: any[]) => { + // Get the callback which is typically the last argument + const callback = args[args.length - 1]; + if (typeof callback === "function") { + // Simulate successful ffmpeg execution + process.nextTick(() => callback(null, "ffmpeg success", "")); + } + return {} as any; + }); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + describe("Text Handler - Timestamp Extraction", () => { + it("should extract timestamp from ctx.message.date and pass to executor", async () => { + const testTimestamp = 1709520000; // March 4, 2024 + + const mockContext = { + from: { id: 123, username: "testuser", first_name: "Test" }, + message: { + text: "Test message", + date: testTimestamp, + }, + chat: { id: 456 }, + reply: vi.fn().mockResolvedValue({ message_id: 789 }), + api: { + editMessageText: vi.fn().mockResolvedValue({}), + deleteMessage: vi.fn().mockResolvedValue({}), + }, + } as unknown as Context; + + await textHandler(mockContext); + + // Verify executeClaudeQuery was called with the timestamp + expect(executeClaudeQuery).toHaveBeenCalledWith( + expect.objectContaining({ + messageTimestamp: testTimestamp, + }), + ); + }); + + it("should handle undefined timestamp gracefully", async () => { + const mockContext = { + from: { id: 123, username: "testuser", first_name: "Test" }, + message: { + text: "Test message", + // date is undefined + }, + chat: { id: 456 }, + reply: vi.fn().mockResolvedValue({ message_id: 789 }), + api: { + editMessageText: vi.fn().mockResolvedValue({}), + deleteMessage: vi.fn().mockResolvedValue({}), + }, + } as unknown as Context; + + await textHandler(mockContext); + + // Verify executeClaudeQuery was called with undefined timestamp + expect(executeClaudeQuery).toHaveBeenCalledWith( + expect.objectContaining({ + messageTimestamp: undefined, + }), + ); + }); + + it("should handle zero timestamp", async () => { + const mockContext = { + from: { id: 123, username: "testuser", first_name: "Test" }, + message: { + text: "Test message", + date: 0, // Zero timestamp + }, + chat: { id: 456 }, + reply: vi.fn().mockResolvedValue({ message_id: 789 }), + api: { + editMessageText: vi.fn().mockResolvedValue({}), + deleteMessage: vi.fn().mockResolvedValue({}), + }, + } as unknown as Context; + + await textHandler(mockContext); + + // Verify executeClaudeQuery was called with zero timestamp + expect(executeClaudeQuery).toHaveBeenCalledWith( + expect.objectContaining({ + messageTimestamp: 0, + }), + ); + }); + + it("should pass all required parameters along with timestamp", async () => { + const testTimestamp = 1709520000; + + const mockContext = { + from: { id: 123, username: "testuser", first_name: "Test" }, + message: { + text: "Test message for Claude", + date: testTimestamp, + }, + chat: { id: 456 }, + reply: vi.fn().mockResolvedValue({ message_id: 789 }), + api: { + editMessageText: vi.fn().mockResolvedValue({}), + deleteMessage: vi.fn().mockResolvedValue({}), + }, + } as unknown as Context; + + await textHandler(mockContext); + + // Verify all parameters are passed correctly + expect(executeClaudeQuery).toHaveBeenCalledWith({ + prompt: "Test message for Claude", + userDir: expect.stringMatching(/\/test\/data\/123$/), + downloadsPath: "/test/downloads", + sessionId: "test-session", + onProgress: expect.any(Function), + messageTimestamp: testTimestamp, + }); + }); + + it("should not call executor when message text is missing", async () => { + const mockContext = { + from: { id: 123 }, + message: { + date: 1709520000, + // text is undefined + }, + chat: { id: 456 }, + } as unknown as Context; + + await textHandler(mockContext); + + // Should not call executor when text is missing + expect(executeClaudeQuery).not.toHaveBeenCalled(); + }); + + it("should not call executor when user ID is missing", async () => { + const mockContext = { + // from is undefined + message: { + text: "Test message", + date: 1709520000, + }, + chat: { id: 456 }, + } as unknown as Context; + + await textHandler(mockContext); + + // Should not call executor when user ID is missing + expect(executeClaudeQuery).not.toHaveBeenCalled(); + }); + }); + + describe("Edge Cases and Error Scenarios", () => { + it("should handle negative timestamps correctly", async () => { + const negativeTimestamp = -86400; // One day before Unix epoch + + const mockContext = { + from: { id: 123 }, + message: { + text: "Test message", + date: negativeTimestamp, + }, + chat: { id: 456 }, + reply: vi.fn().mockResolvedValue({ message_id: 789 }), + api: { + editMessageText: vi.fn().mockResolvedValue({}), + deleteMessage: vi.fn().mockResolvedValue({}), + }, + } as unknown as Context; + + await textHandler(mockContext); + + expect(executeClaudeQuery).toHaveBeenCalledWith( + expect.objectContaining({ + messageTimestamp: negativeTimestamp, + }), + ); + }); + + it("should handle very large timestamps", async () => { + const largeTimestamp = 4102444800; // Year 2099 + + const mockContext = { + from: { id: 123 }, + message: { + text: "Test message", + date: largeTimestamp, + }, + chat: { id: 456 }, + reply: vi.fn().mockResolvedValue({ message_id: 789 }), + api: { + editMessageText: vi.fn().mockResolvedValue({}), + deleteMessage: vi.fn().mockResolvedValue({}), + }, + } as unknown as Context; + + await textHandler(mockContext); + + expect(executeClaudeQuery).toHaveBeenCalledWith( + expect.objectContaining({ + messageTimestamp: largeTimestamp, + }), + ); + }); + + it("should continue normal execution flow even with timestamp extraction", async () => { + const testTimestamp = 1709520000; + + const mockContext = { + from: { id: 123, username: "testuser" }, + message: { + text: "Test message", + date: testTimestamp, + }, + chat: { id: 456 }, + reply: vi.fn().mockResolvedValue({ message_id: 789 }), + api: { + editMessageText: vi.fn().mockResolvedValue({}), + deleteMessage: vi.fn().mockResolvedValue({}), + }, + } as unknown as Context; + + await textHandler(mockContext); + + // Verify full execution flow + expect(ensureUserSetup).toHaveBeenCalled(); + expect(getSessionId).toHaveBeenCalled(); + expect(executeClaudeQuery).toHaveBeenCalled(); + expect(sendChunkedResponse).toHaveBeenCalledWith( + mockContext, + "Test response", + ); + expect(sendDownloadFiles).toHaveBeenCalled(); + expect(saveSessionId).toHaveBeenCalledWith( + expect.any(String), + "test-session-new", + ); + }); + }); + + describe("Error Handling in Handler", () => { + it("should handle errors gracefully even when timestamp is present", async () => { + vi.mocked(executeClaudeQuery).mockRejectedValue(new Error("Test error")); + + const mockContext = { + from: { id: 123 }, + message: { + text: "Test message", + date: 1709520000, + }, + chat: { id: 456 }, + reply: vi.fn().mockResolvedValue({ message_id: 789 }), + api: { + editMessageText: vi.fn().mockResolvedValue({}), + deleteMessage: vi.fn().mockResolvedValue({}), + }, + } as unknown as Context; + + await textHandler(mockContext); + + // Should have tried to call executor with timestamp + expect(executeClaudeQuery).toHaveBeenCalledWith( + expect.objectContaining({ + messageTimestamp: 1709520000, + }), + ); + + // Should have sent error message to user + expect(mockContext.reply).toHaveBeenCalledWith( + "An error occurred: Test error", + ); + }); + }); + + describe("Voice Handler - Timestamp Extraction", () => { + it("should extract timestamp from voice message and pass to executor", async () => { + const testTimestamp = 1709520000; // March 4, 2024 + + const mockContext = { + from: { id: 123, username: "testuser", first_name: "Test" }, + message: { + voice: { file_id: "voice123", duration: 10, file_size: 5000 }, + date: testTimestamp, + }, + chat: { id: 456 }, + reply: vi.fn().mockResolvedValue({ message_id: 789 }), + api: { + getFile: vi.fn().mockResolvedValue({ file_path: "voice/test.oga" }), + editMessageText: vi.fn().mockResolvedValue({}), + deleteMessage: vi.fn().mockResolvedValue({}), + }, + } as unknown as Context; + + // Mock global fetch + global.fetch = vi.fn().mockResolvedValue({ + arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)), + } as any); + + await voiceHandler(mockContext); + + // Verify executeClaudeQuery was called with the timestamp + expect(executeClaudeQuery).toHaveBeenCalledWith( + expect.objectContaining({ + messageTimestamp: testTimestamp, + }), + ); + }); + + it("should handle undefined timestamp in voice message", async () => { + const mockContext = { + from: { id: 123 }, + message: { + voice: { file_id: "voice123", duration: 10, file_size: 5000 }, + // date is undefined + }, + chat: { id: 456 }, + reply: vi.fn().mockResolvedValue({ message_id: 789 }), + api: { + getFile: vi.fn().mockResolvedValue({ file_path: "voice/test.oga" }), + editMessageText: vi.fn().mockResolvedValue({}), + deleteMessage: vi.fn().mockResolvedValue({}), + }, + } as unknown as Context; + + global.fetch = vi.fn().mockResolvedValue({ + arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)), + } as any); + + await voiceHandler(mockContext); + + expect(executeClaudeQuery).toHaveBeenCalledWith( + expect.objectContaining({ + messageTimestamp: undefined, + }), + ); + }); + }); + + describe("Document Handler - Timestamp Extraction", () => { + it("should extract timestamp from document message and pass to executor", async () => { + const testTimestamp = 1709520000; // March 4, 2024 + + const mockContext = { + from: { id: 123 }, + message: { + document: { + file_id: "doc123", + file_name: "test.pdf", + mime_type: "application/pdf", + }, + caption: "Analyze this document", + date: testTimestamp, + }, + chat: { id: 456 }, + reply: vi.fn().mockResolvedValue({ message_id: 789 }), + api: { + getFile: vi.fn().mockResolvedValue({ file_path: "docs/test.pdf" }), + editMessageText: vi.fn().mockResolvedValue({}), + deleteMessage: vi.fn().mockResolvedValue({}), + }, + } as unknown as Context; + + global.fetch = vi.fn().mockResolvedValue({ + arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)), + } as any); + + await documentHandler(mockContext); + + expect(executeClaudeQuery).toHaveBeenCalledWith( + expect.objectContaining({ + messageTimestamp: testTimestamp, + }), + ); + }); + + it("should handle undefined timestamp in document message", async () => { + const mockContext = { + from: { id: 123 }, + message: { + document: { + file_id: "doc123", + file_name: "test.pdf", + mime_type: "application/pdf", + }, + caption: "Analyze this document", + // date is undefined + }, + chat: { id: 456 }, + reply: vi.fn().mockResolvedValue({ message_id: 789 }), + api: { + getFile: vi.fn().mockResolvedValue({ file_path: "docs/test.pdf" }), + editMessageText: vi.fn().mockResolvedValue({}), + deleteMessage: vi.fn().mockResolvedValue({}), + }, + } as unknown as Context; + + global.fetch = vi.fn().mockResolvedValue({ + arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)), + } as any); + + await documentHandler(mockContext); + + expect(executeClaudeQuery).toHaveBeenCalledWith( + expect.objectContaining({ + messageTimestamp: undefined, + }), + ); + }); + }); + + describe("Photo Handler - Timestamp Extraction", () => { + it("should extract timestamp from photo message and pass to executor", async () => { + const testTimestamp = 1709520000; // March 4, 2024 + + const mockContext = { + from: { id: 123 }, + message: { + photo: [{ file_id: "photo123", width: 1920, height: 1080 }], + caption: "What's in this image?", + date: testTimestamp, + }, + chat: { id: 456 }, + reply: vi.fn().mockResolvedValue({ message_id: 789 }), + api: { + getFile: vi.fn().mockResolvedValue({ file_path: "photos/test.jpg" }), + editMessageText: vi.fn().mockResolvedValue({}), + deleteMessage: vi.fn().mockResolvedValue({}), + }, + } as unknown as Context; + + global.fetch = vi.fn().mockResolvedValue({ + arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)), + } as any); + + await photoHandler(mockContext); + + expect(executeClaudeQuery).toHaveBeenCalledWith( + expect.objectContaining({ + messageTimestamp: testTimestamp, + }), + ); + }); + + it("should handle undefined timestamp in photo message", async () => { + const mockContext = { + from: { id: 123 }, + message: { + photo: [{ file_id: "photo123", width: 1920, height: 1080 }], + caption: "What's in this image?", + // date is undefined + }, + chat: { id: 456 }, + reply: vi.fn().mockResolvedValue({ message_id: 789 }), + api: { + getFile: vi.fn().mockResolvedValue({ file_path: "photos/test.jpg" }), + editMessageText: vi.fn().mockResolvedValue({}), + deleteMessage: vi.fn().mockResolvedValue({}), + }, + } as unknown as Context; + + global.fetch = vi.fn().mockResolvedValue({ + arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)), + } as any); + + await photoHandler(mockContext); + + expect(executeClaudeQuery).toHaveBeenCalledWith( + expect.objectContaining({ + messageTimestamp: undefined, + }), + ); + }); + }); +}); diff --git a/src/bot/handlers/document.ts b/src/bot/handlers/document.ts index 447a0ab..80fe440 100644 --- a/src/bot/handlers/document.ts +++ b/src/bot/handlers/document.ts @@ -135,12 +135,14 @@ export async function documentHandler(ctx: Context): Promise { }; const downloadsPath = getDownloadsPath(userDir); + const messageTimestamp = ctx.message?.date; logger.debug("Executing Claude query with document"); const result = await executeClaudeQuery({ prompt, userDir, downloadsPath, + messageTimestamp, sessionId, onProgress, }); diff --git a/src/bot/handlers/photo.ts b/src/bot/handlers/photo.ts index 6bd54e9..297e41c 100644 --- a/src/bot/handlers/photo.ts +++ b/src/bot/handlers/photo.ts @@ -86,12 +86,14 @@ export async function photoHandler(ctx: Context): Promise { }; const downloadsPath = getDownloadsPath(userDir); + const messageTimestamp = ctx.message?.date; logger.debug("Executing Claude query with image"); const result = await executeClaudeQuery({ prompt, userDir, downloadsPath, + messageTimestamp, sessionId, onProgress, }); diff --git a/src/bot/handlers/text.ts b/src/bot/handlers/text.ts index 9334ab4..99b8a2c 100644 --- a/src/bot/handlers/text.ts +++ b/src/bot/handlers/text.ts @@ -75,12 +75,14 @@ export async function textHandler(ctx: Context): Promise { }; const downloadsPath = getDownloadsPath(userDir); + const messageTimestamp = ctx.message?.date; logger.debug("Executing Claude query"); const result = await executeClaudeQuery({ prompt: messageText, userDir, downloadsPath, + messageTimestamp, sessionId, onProgress, }); diff --git a/src/bot/handlers/voice.ts b/src/bot/handlers/voice.ts index 058747a..66ef3a8 100644 --- a/src/bot/handlers/voice.ts +++ b/src/bot/handlers/voice.ts @@ -162,6 +162,7 @@ export async function voiceHandler(ctx: Context): Promise { }; const downloadsPath = getDownloadsPath(userDir); + const messageTimestamp = ctx.message?.date; logger.debug( { transcription: transcription.text }, @@ -171,6 +172,7 @@ export async function voiceHandler(ctx: Context): Promise { prompt: transcription.text, userDir, downloadsPath, + messageTimestamp, sessionId, onProgress, }); diff --git a/src/claude/__tests__/executor.test.ts b/src/claude/__tests__/executor.test.ts new file mode 100644 index 0000000..443cc6e --- /dev/null +++ b/src/claude/__tests__/executor.test.ts @@ -0,0 +1,406 @@ +import type { ChildProcess } from "node:child_process"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { getConfig, getWorkingDirectory } from "../../config.js"; +import { getLogger } from "../../logger.js"; +import { executeClaudeQuery } from "../executor.js"; + +// Mock dependencies +vi.mock("node:child_process"); +vi.mock("../../config.js"); +vi.mock("../../logger.js"); + +// Create a mock child process +const createMockChildProcess = () => { + const mockProcess = { + stdout: { on: vi.fn() }, + stderr: { on: vi.fn() }, + on: vi.fn(), + kill: vi.fn(), + } as unknown as ChildProcess; + + return mockProcess; +}; + +describe("executeClaudeQuery - Timestamp Functionality", () => { + beforeEach(() => { + vi.clearAllMocks(); + + // Mock config + vi.mocked(getConfig).mockReturnValue({ + claude: { command: "claude-test" }, + dataDir: "/test/data", + } as any); + + // Mock working directory + vi.mocked(getWorkingDirectory).mockReturnValue("/test/cwd"); + + // Mock logger + vi.mocked(getLogger).mockReturnValue({ + info: vi.fn(), + debug: vi.fn(), + error: vi.fn(), + } as any); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + describe("System Context Building", () => { + it("should include timestamp in system context when messageTimestamp is provided", async () => { + const { spawn } = await import("node:child_process"); + const mockProcess = createMockChildProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess); + + // Setup process to return successful result + mockProcess.on = vi.fn((event, callback) => { + if (event === "close") { + setTimeout(() => callback(0), 0); + } + return mockProcess; + }); + + const testTimestamp = 1709520000; // Unix timestamp in seconds + const expectedIsoString = "2024-03-04T02:40:00.000Z"; + + executeClaudeQuery({ + prompt: "Test prompt", + userDir: "/test/user", + messageTimestamp: testTimestamp, + }); + + // Wait for spawn to be called + await new Promise((resolve) => setTimeout(resolve, 1)); + + // Check that spawn was called with correct arguments + expect(spawn).toHaveBeenCalledWith( + "claude-test", + expect.arrayContaining([ + "-p", + `Test prompt\n\n[System: Message timestamp: ${expectedIsoString} (${testTimestamp})]`, + "--output-format", + "stream-json", + "--verbose", + ]), + expect.any(Object), + ); + }); + + it("should include both downloads path and timestamp when both are provided", async () => { + const { spawn } = await import("node:child_process"); + const mockProcess = createMockChildProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess); + + mockProcess.on = vi.fn((event, callback) => { + if (event === "close") { + setTimeout(() => callback(0), 0); + } + return mockProcess; + }); + + const testTimestamp = 1709520000; + const expectedIsoString = "2024-03-04T02:40:00.000Z"; + const downloadsPath = "/test/downloads"; + + executeClaudeQuery({ + prompt: "Test prompt", + userDir: "/test/user", + downloadsPath, + messageTimestamp: testTimestamp, + }); + + await new Promise((resolve) => setTimeout(resolve, 1)); + + expect(spawn).toHaveBeenCalledWith( + "claude-test", + expect.arrayContaining([ + "-p", + `Test prompt\n\n[System: To send files to the user, write them to: ${downloadsPath} | Message timestamp: ${expectedIsoString} (${testTimestamp})]`, + ]), + expect.any(Object), + ); + }); + + it("should not include timestamp in system context when messageTimestamp is undefined", async () => { + const { spawn } = await import("node:child_process"); + const mockProcess = createMockChildProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess); + + mockProcess.on = vi.fn((event, callback) => { + if (event === "close") { + setTimeout(() => callback(0), 0); + } + return mockProcess; + }); + + executeClaudeQuery({ + prompt: "Test prompt", + userDir: "/test/user", + // messageTimestamp is undefined + }); + + await new Promise((resolve) => setTimeout(resolve, 1)); + + expect(spawn).toHaveBeenCalledWith( + "claude-test", + expect.arrayContaining([ + "-p", + "Test prompt", // No system context appended + ]), + expect.any(Object), + ); + }); + + it("should not include timestamp when messageTimestamp is 0", async () => { + const { spawn } = await import("node:child_process"); + const mockProcess = createMockChildProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess); + + mockProcess.on = vi.fn((event, callback) => { + if (event === "close") { + setTimeout(() => callback(0), 0); + } + return mockProcess; + }); + + executeClaudeQuery({ + prompt: "Test prompt", + userDir: "/test/user", + messageTimestamp: 0, // Falsy value + }); + + await new Promise((resolve) => setTimeout(resolve, 1)); + + expect(spawn).toHaveBeenCalledWith( + "claude-test", + expect.arrayContaining([ + "-p", + "Test prompt", // No system context for timestamp + ]), + expect.any(Object), + ); + }); + }); + + describe("Timestamp Conversion and Formatting", () => { + it("should correctly convert Unix timestamp to ISO string", async () => { + const { spawn } = await import("node:child_process"); + const mockProcess = createMockChildProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess); + + mockProcess.on = vi.fn((event, callback) => { + if (event === "close") { + setTimeout(() => callback(0), 0); + } + return mockProcess; + }); + + // Test various timestamp values + const testCases = [ + { + timestamp: 1709520000, + expectedIso: "2024-03-04T02:40:00.000Z", + description: "Standard timestamp", + }, + { + timestamp: 0, + expectedIso: "1970-01-01T00:00:00.000Z", + description: "Unix epoch", + }, + { + timestamp: 1893456000, + expectedIso: "2030-01-01T00:00:00.000Z", + description: "Future timestamp", + }, + ]; + + for (const testCase of testCases) { + vi.clearAllMocks(); + + // Skip timestamp 0 since it's handled as falsy + if (testCase.timestamp === 0) continue; + + executeClaudeQuery({ + prompt: "Test prompt", + userDir: "/test/user", + messageTimestamp: testCase.timestamp, + }); + + await new Promise((resolve) => setTimeout(resolve, 1)); + + expect(spawn).toHaveBeenCalledWith( + "claude-test", + expect.arrayContaining([ + "-p", + expect.stringContaining( + `Message timestamp: ${testCase.expectedIso} (${testCase.timestamp})`, + ), + ]), + expect.any(Object), + ); + } + }); + + it("should handle edge case timestamps correctly", async () => { + const { spawn } = await import("node:child_process"); + const mockProcess = createMockChildProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess); + + mockProcess.on = vi.fn((event, callback) => { + if (event === "close") { + setTimeout(() => callback(0), 0); + } + return mockProcess; + }); + + // Test very large timestamp (year 2099) + const largeTimestamp = 4102358400; // 2099-12-31 + executeClaudeQuery({ + prompt: "Test prompt", + userDir: "/test/user", + messageTimestamp: largeTimestamp, + }); + + await new Promise((resolve) => setTimeout(resolve, 1)); + + expect(spawn).toHaveBeenCalledWith( + "claude-test", + expect.arrayContaining([ + "-p", + expect.stringContaining( + `Message timestamp: 2099-12-31T00:00:00.000Z (${largeTimestamp})`, + ), + ]), + expect.any(Object), + ); + }); + }); + + describe("Error Handling and Edge Cases", () => { + it("should handle negative timestamps", async () => { + const { spawn } = await import("node:child_process"); + const mockProcess = createMockChildProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess); + + mockProcess.on = vi.fn((event, callback) => { + if (event === "close") { + setTimeout(() => callback(0), 0); + } + return mockProcess; + }); + + // Negative timestamp should still work (before Unix epoch) + const negativeTimestamp = -86400; // One day before epoch + executeClaudeQuery({ + prompt: "Test prompt", + userDir: "/test/user", + messageTimestamp: negativeTimestamp, + }); + + await new Promise((resolve) => setTimeout(resolve, 1)); + + // Should still create ISO string (1969-12-31T00:00:00.000Z) + expect(spawn).toHaveBeenCalledWith( + "claude-test", + expect.arrayContaining([ + "-p", + expect.stringContaining( + "Message timestamp: 1969-12-31T00:00:00.000Z", + ), + ]), + expect.any(Object), + ); + }); + + it("should handle very small positive timestamps", async () => { + const { spawn } = await import("node:child_process"); + const mockProcess = createMockChildProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess); + + mockProcess.on = vi.fn((event, callback) => { + if (event === "close") { + setTimeout(() => callback(0), 0); + } + return mockProcess; + }); + + // Small positive timestamp should work + const smallTimestamp = 1; + executeClaudeQuery({ + prompt: "Test prompt", + userDir: "/test/user", + messageTimestamp: smallTimestamp, + }); + + await new Promise((resolve) => setTimeout(resolve, 1)); + + expect(spawn).toHaveBeenCalledWith( + "claude-test", + expect.arrayContaining([ + "-p", + expect.stringContaining( + "Message timestamp: 1970-01-01T00:00:01.000Z (1)", + ), + ]), + expect.any(Object), + ); + }); + }); + + describe("Integration with Other Options", () => { + it("should maintain all other executor functionality when timestamp is provided", async () => { + const { spawn } = await import("node:child_process"); + const mockProcess = createMockChildProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess); + + mockProcess.on = vi.fn((event, callback) => { + if (event === "close") { + setTimeout(() => callback(0), 0); + } + return mockProcess; + }); + + const options = { + prompt: "Test prompt", + userDir: "/test/user", + downloadsPath: "/test/downloads", + sessionId: "test-session-123", + messageTimestamp: 1709520000, + onProgress: vi.fn(), + }; + + executeClaudeQuery(options); + + await new Promise((resolve) => setTimeout(resolve, 1)); + + // Verify all CLI arguments are present + expect(spawn).toHaveBeenCalledWith( + "claude-test", + [ + "-p", + expect.stringContaining("Test prompt"), + "--output-format", + "stream-json", + "--verbose", + "--resume", + "test-session-123", + ], + expect.objectContaining({ + cwd: "/test/cwd", + env: process.env, + stdio: ["ignore", "pipe", "pipe"], + }), + ); + + // Verify prompt includes both downloads path and timestamp + const callArgs = vi.mocked(spawn).mock.calls[0][1]; + const promptArg = callArgs[callArgs.indexOf("-p") + 1]; + expect(promptArg).toContain( + "To send files to the user, write them to: /test/downloads", + ); + expect(promptArg).toContain( + "Message timestamp: 2024-03-04T02:40:00.000Z (1709520000)", + ); + }); + }); +}); diff --git a/src/claude/executor.ts b/src/claude/executor.ts index 69fb311..b9c07a4 100644 --- a/src/claude/executor.ts +++ b/src/claude/executor.ts @@ -6,6 +6,7 @@ export interface ExecuteOptions { prompt: string; userDir: string; downloadsPath?: string; + messageTimestamp?: number; sessionId?: string | null; onProgress?: (message: string) => void; } @@ -23,13 +24,31 @@ export interface ExecuteResult { export async function executeClaudeQuery( options: ExecuteOptions, ): Promise { - const { prompt, downloadsPath, sessionId, onProgress } = options; + const { prompt, downloadsPath, messageTimestamp, sessionId, onProgress } = + options; const logger = getLogger(); - // Append downloads path info to prompt if provided - const fullPrompt = downloadsPath - ? `${prompt}\n\n[System: To send files to the user, write them to: ${downloadsPath}]` - : prompt; + // Build system context parts + const systemParts: string[] = []; + + // Add downloads path if provided + if (downloadsPath) { + systemParts.push( + `To send files to the user, write them to: ${downloadsPath}`, + ); + } + + // Add timestamp if provided + if (messageTimestamp) { + const isoString = new Date(messageTimestamp * 1000).toISOString(); + systemParts.push(`Message timestamp: ${isoString} (${messageTimestamp})`); + } + + // Append system context to prompt if any parts exist + const fullPrompt = + systemParts.length > 0 + ? `${prompt}\n\n[System: ${systemParts.join(" | ")}]` + : prompt; const args: string[] = [ "-p", diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..143cd34 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,31 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + globals: true, + clearMocks: true, + mockReset: true, + restoreMocks: true, + include: ['src/**/*.test.ts', 'tests/**/*.test.ts'], + exclude: ['node_modules', 'dist', 'examples'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + exclude: [ + 'node_modules/**', + 'dist/**', + 'examples/**', + '**/*.test.ts', + '**/*.config.ts', + 'src/cli.ts', // CLI entry point + 'src/index.ts' // Library entry point + ] + } + }, + resolve: { + alias: { + '@': './src' + } + } +}); \ No newline at end of file