diff --git a/package-lock.json b/package-lock.json index 5301f4b..1be1b9e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,13 @@ { "name": "antigravity-manager", - "version": "0.0.1", + "version": "0.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "antigravity-manager", - "version": "0.1.0", - "license": "MIT", + "version": "0.1.1", + "license": "CC-BY-NC-SA-4.0", "dependencies": { "@grpc/grpc-js": "^1.14.3", "@grpc/proto-loader": "^0.8.0", @@ -52,6 +52,7 @@ "i18next": "^25.6.3", "i18next-browser-languagedetector": "^8.2.0", "keytar": "^7.9.0", + "lodash-es": "^4.17.22", "lucide-react": "^0.553.0", "react": "^19.2.0", "react-dom": "^19.2.0", @@ -89,6 +90,7 @@ "@types/better-sqlite3": "^7.6.12", "@types/electron-squirrel-startup": "^1.0.2", "@types/eslint-config-prettier": "^6.11.3", + "@types/lodash-es": "^4.17.12", "@types/node": "^24.10.1", "@types/react": "^19.2.2", "@types/react-dom": "^19.2.2", @@ -945,9 +947,9 @@ } }, "node_modules/@electron-forge/cli": { - "version": "7.10.2", - "resolved": "https://registry.npmjs.org/@electron-forge/cli/-/cli-7.10.2.tgz", - "integrity": "sha512-X1RtS5IqNgzGDS2rr1q0Y74wU/m3DbU4vSgllNun1ZQv1BfMpDcKLhnKi3aeetoA0huLTpMVU9eWJ7bziI9fxA==", + "version": "7.11.1", + "resolved": "https://registry.npmjs.org/@electron-forge/cli/-/cli-7.11.1.tgz", + "integrity": "sha512-pk8AoLsr7t7LBAt0cFD06XFA6uxtPdvtLx06xeal7O9o7GHGCbj29WGwFoJ8Br/ENM0Ho868S3PrAn1PtBXt5g==", "dev": true, "funding": [ { @@ -961,9 +963,9 @@ ], "license": "MIT", "dependencies": { - "@electron-forge/core": "7.10.2", - "@electron-forge/core-utils": "7.10.2", - "@electron-forge/shared-types": "7.10.2", + "@electron-forge/core": "7.11.1", + "@electron-forge/core-utils": "7.11.1", + "@electron-forge/shared-types": "7.11.1", "@electron/get": "^3.0.0", "@inquirer/prompts": "^6.0.1", "@listr2/prompt-adapter-inquirer": "^2.0.22", @@ -984,10 +986,39 @@ "node": ">= 16.4.0" } }, + "node_modules/@electron-forge/cli/node_modules/@electron-forge/shared-types": { + "version": "7.11.1", + "resolved": "https://registry.npmjs.org/@electron-forge/shared-types/-/shared-types-7.11.1.tgz", + "integrity": "sha512-vvBWdAEh53UJlDGUevpaJk1+sqDMQibfrbHR+0IPA4MPyQex7/Uhv3vYH9oGHujBVAChQahjAuJt0fG6IJBLZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron-forge/tracer": "7.11.1", + "@electron/packager": "^18.3.5", + "@electron/rebuild": "^3.7.0", + "listr2": "^7.0.2" + }, + "engines": { + "node": ">= 16.4.0" + } + }, + "node_modules/@electron-forge/cli/node_modules/@electron-forge/tracer": { + "version": "7.11.1", + "resolved": "https://registry.npmjs.org/@electron-forge/tracer/-/tracer-7.11.1.tgz", + "integrity": "sha512-tiB6cglVQFcSw9N8GRwVwZUeB9u0DOx2Mj7aFXBUsFLUYQapvVGv51tUSy/UAW5lvmubGscYIILuVko+II3+NA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chrome-trace-event": "^1.0.3" + }, + "engines": { + "node": ">= 14.17.5" + } + }, "node_modules/@electron-forge/core": { - "version": "7.10.2", - "resolved": "https://registry.npmjs.org/@electron-forge/core/-/core-7.10.2.tgz", - "integrity": "sha512-HAIuOtpOfGjA0cd55tbEV2gAv+A7tSZg9bonmVDYFEe6dBgbLk8a3+/1fJUdWW8fyFkg1wa8zK7pjP751bAXsA==", + "version": "7.11.1", + "resolved": "https://registry.npmjs.org/@electron-forge/core/-/core-7.11.1.tgz", + "integrity": "sha512-YtuPLzggPKPabFAD2rOZFE0s7f4KaUTpGRduhSMbZUqpqD1TIPyfoDBpYiZvao3Ht8pyZeOJjbzcC0LpFs9gIQ==", "dev": true, "funding": [ { @@ -1001,17 +1032,17 @@ ], "license": "MIT", "dependencies": { - "@electron-forge/core-utils": "7.10.2", - "@electron-forge/maker-base": "7.10.2", - "@electron-forge/plugin-base": "7.10.2", - "@electron-forge/publisher-base": "7.10.2", - "@electron-forge/shared-types": "7.10.2", - "@electron-forge/template-base": "7.10.2", - "@electron-forge/template-vite": "7.10.2", - "@electron-forge/template-vite-typescript": "7.10.2", - "@electron-forge/template-webpack": "7.10.2", - "@electron-forge/template-webpack-typescript": "7.10.2", - "@electron-forge/tracer": "7.10.2", + "@electron-forge/core-utils": "7.11.1", + "@electron-forge/maker-base": "7.11.1", + "@electron-forge/plugin-base": "7.11.1", + "@electron-forge/publisher-base": "7.11.1", + "@electron-forge/shared-types": "7.11.1", + "@electron-forge/template-base": "7.11.1", + "@electron-forge/template-vite": "7.11.1", + "@electron-forge/template-vite-typescript": "7.11.1", + "@electron-forge/template-webpack": "7.11.1", + "@electron-forge/template-webpack-typescript": "7.11.1", + "@electron-forge/tracer": "7.11.1", "@electron/get": "^3.0.0", "@electron/packager": "^18.3.5", "@electron/rebuild": "^3.7.0", @@ -1041,13 +1072,13 @@ } }, "node_modules/@electron-forge/core-utils": { - "version": "7.10.2", - "resolved": "https://registry.npmjs.org/@electron-forge/core-utils/-/core-utils-7.10.2.tgz", - "integrity": "sha512-JXrk2hWR4q8KgZFABpojjuqql3tYeVIH6qmtbkNEkZEQq7YIxajJBCct7J7bWfNQTmHotsQ3k5KLknhyhTaBMw==", + "version": "7.11.1", + "resolved": "https://registry.npmjs.org/@electron-forge/core-utils/-/core-utils-7.11.1.tgz", + "integrity": "sha512-9UxRWVsfcziBsbAA2MS0Oz4yYovQCO2BhnGIfsbKNTBtMc/RcVSxAS0NMyymce44i43p1ZC/FqWhnt1XqYw3bQ==", "dev": true, "license": "MIT", "dependencies": { - "@electron-forge/shared-types": "7.10.2", + "@electron-forge/shared-types": "7.11.1", "@electron/rebuild": "^3.7.0", "@malept/cross-spawn-promise": "^2.0.0", "chalk": "^4.0.0", @@ -1062,6 +1093,105 @@ "node": ">= 16.4.0" } }, + "node_modules/@electron-forge/core-utils/node_modules/@electron-forge/shared-types": { + "version": "7.11.1", + "resolved": "https://registry.npmjs.org/@electron-forge/shared-types/-/shared-types-7.11.1.tgz", + "integrity": "sha512-vvBWdAEh53UJlDGUevpaJk1+sqDMQibfrbHR+0IPA4MPyQex7/Uhv3vYH9oGHujBVAChQahjAuJt0fG6IJBLZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron-forge/tracer": "7.11.1", + "@electron/packager": "^18.3.5", + "@electron/rebuild": "^3.7.0", + "listr2": "^7.0.2" + }, + "engines": { + "node": ">= 16.4.0" + } + }, + "node_modules/@electron-forge/core-utils/node_modules/@electron-forge/tracer": { + "version": "7.11.1", + "resolved": "https://registry.npmjs.org/@electron-forge/tracer/-/tracer-7.11.1.tgz", + "integrity": "sha512-tiB6cglVQFcSw9N8GRwVwZUeB9u0DOx2Mj7aFXBUsFLUYQapvVGv51tUSy/UAW5lvmubGscYIILuVko+II3+NA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chrome-trace-event": "^1.0.3" + }, + "engines": { + "node": ">= 14.17.5" + } + }, + "node_modules/@electron-forge/core/node_modules/@electron-forge/maker-base": { + "version": "7.11.1", + "resolved": "https://registry.npmjs.org/@electron-forge/maker-base/-/maker-base-7.11.1.tgz", + "integrity": "sha512-yhZrCGoN6bDeiB5DHFaueZ1h84AReElEj+f0hl2Ph4UbZnO0cnLpbx+Bs+XfMLAiA+beC8muB5UDK5ysfuT9BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron-forge/shared-types": "7.11.1", + "fs-extra": "^10.0.0", + "which": "^2.0.2" + }, + "engines": { + "node": ">= 16.4.0" + } + }, + "node_modules/@electron-forge/core/node_modules/@electron-forge/plugin-base": { + "version": "7.11.1", + "resolved": "https://registry.npmjs.org/@electron-forge/plugin-base/-/plugin-base-7.11.1.tgz", + "integrity": "sha512-lKpSOV1GA3FoYiD9k05i6v4KaQVmojnRgCr7d6VL1bFp13QOtXSaAWhFI9mtSY7rGElOacX6Zt7P7rPoB8T9eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron-forge/shared-types": "7.11.1" + }, + "engines": { + "node": ">= 16.4.0" + } + }, + "node_modules/@electron-forge/core/node_modules/@electron-forge/publisher-base": { + "version": "7.11.1", + "resolved": "https://registry.npmjs.org/@electron-forge/publisher-base/-/publisher-base-7.11.1.tgz", + "integrity": "sha512-rXE9oMFGMtdQrixnumWYH5TTGsp99iPHZb3jI74YWq518ctCh6DlIgWlhf6ok2X0+lhWovcIb45KJucUFAQ13w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron-forge/shared-types": "7.11.1" + }, + "engines": { + "node": ">= 16.4.0" + } + }, + "node_modules/@electron-forge/core/node_modules/@electron-forge/shared-types": { + "version": "7.11.1", + "resolved": "https://registry.npmjs.org/@electron-forge/shared-types/-/shared-types-7.11.1.tgz", + "integrity": "sha512-vvBWdAEh53UJlDGUevpaJk1+sqDMQibfrbHR+0IPA4MPyQex7/Uhv3vYH9oGHujBVAChQahjAuJt0fG6IJBLZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron-forge/tracer": "7.11.1", + "@electron/packager": "^18.3.5", + "@electron/rebuild": "^3.7.0", + "listr2": "^7.0.2" + }, + "engines": { + "node": ">= 16.4.0" + } + }, + "node_modules/@electron-forge/core/node_modules/@electron-forge/tracer": { + "version": "7.11.1", + "resolved": "https://registry.npmjs.org/@electron-forge/tracer/-/tracer-7.11.1.tgz", + "integrity": "sha512-tiB6cglVQFcSw9N8GRwVwZUeB9u0DOx2Mj7aFXBUsFLUYQapvVGv51tUSy/UAW5lvmubGscYIILuVko+II3+NA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chrome-trace-event": "^1.0.3" + }, + "engines": { + "node": ">= 14.17.5" + } + }, "node_modules/@electron-forge/maker-base": { "version": "7.10.2", "resolved": "https://registry.npmjs.org/@electron-forge/maker-base/-/maker-base-7.10.2.tgz", @@ -1262,14 +1392,14 @@ } }, "node_modules/@electron-forge/template-base": { - "version": "7.10.2", - "resolved": "https://registry.npmjs.org/@electron-forge/template-base/-/template-base-7.10.2.tgz", - "integrity": "sha512-D9DbEx3rtikIhUyn4tcz2pJqHNU/+FXKNnzSvmrJoJ9LusR3C42OU9GtbU8oT3nawpnCGgPFIOGXrzexFPp6DA==", + "version": "7.11.1", + "resolved": "https://registry.npmjs.org/@electron-forge/template-base/-/template-base-7.11.1.tgz", + "integrity": "sha512-XpTaEf+EfQw+0BlSAtSpZKYIKYvKu4raNzSGHZZoSYHp+HDC7R+MlpFQmSJiGdYQzQ14C+uxO42tVjgM0DMbpw==", "dev": true, "license": "MIT", "dependencies": { - "@electron-forge/core-utils": "7.10.2", - "@electron-forge/shared-types": "7.10.2", + "@electron-forge/core-utils": "7.11.1", + "@electron-forge/shared-types": "7.11.1", "@malept/cross-spawn-promise": "^2.0.0", "debug": "^4.3.1", "fs-extra": "^10.0.0", @@ -1280,15 +1410,44 @@ "node": ">= 16.4.0" } }, - "node_modules/@electron-forge/template-vite": { - "version": "7.10.2", - "resolved": "https://registry.npmjs.org/@electron-forge/template-vite/-/template-vite-7.10.2.tgz", - "integrity": "sha512-hR9HBOM902yq7zhFl8bO3w5ufMgitdd5ZwDzAdKITFh2ttZemHy9ha5S0K+R+4GoXHz8t7hUTHk8+iPy09qrpA==", + "node_modules/@electron-forge/template-base/node_modules/@electron-forge/shared-types": { + "version": "7.11.1", + "resolved": "https://registry.npmjs.org/@electron-forge/shared-types/-/shared-types-7.11.1.tgz", + "integrity": "sha512-vvBWdAEh53UJlDGUevpaJk1+sqDMQibfrbHR+0IPA4MPyQex7/Uhv3vYH9oGHujBVAChQahjAuJt0fG6IJBLZg==", "dev": true, "license": "MIT", "dependencies": { - "@electron-forge/shared-types": "7.10.2", - "@electron-forge/template-base": "7.10.2", + "@electron-forge/tracer": "7.11.1", + "@electron/packager": "^18.3.5", + "@electron/rebuild": "^3.7.0", + "listr2": "^7.0.2" + }, + "engines": { + "node": ">= 16.4.0" + } + }, + "node_modules/@electron-forge/template-base/node_modules/@electron-forge/tracer": { + "version": "7.11.1", + "resolved": "https://registry.npmjs.org/@electron-forge/tracer/-/tracer-7.11.1.tgz", + "integrity": "sha512-tiB6cglVQFcSw9N8GRwVwZUeB9u0DOx2Mj7aFXBUsFLUYQapvVGv51tUSy/UAW5lvmubGscYIILuVko+II3+NA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chrome-trace-event": "^1.0.3" + }, + "engines": { + "node": ">= 14.17.5" + } + }, + "node_modules/@electron-forge/template-vite": { + "version": "7.11.1", + "resolved": "https://registry.npmjs.org/@electron-forge/template-vite/-/template-vite-7.11.1.tgz", + "integrity": "sha512-Or8Lxf4awoeUZoMTKJEw5KQDIhqOFs24WhVka3yZXxc6VgVWN79KmYKYM6uM/YMQttmafhsBhY2t1Lxo1WR/ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron-forge/shared-types": "7.11.1", + "@electron-forge/template-base": "7.11.1", "fs-extra": "^10.0.0" }, "engines": { @@ -1296,29 +1455,87 @@ } }, "node_modules/@electron-forge/template-vite-typescript": { - "version": "7.10.2", - "resolved": "https://registry.npmjs.org/@electron-forge/template-vite-typescript/-/template-vite-typescript-7.10.2.tgz", - "integrity": "sha512-df7rpxxIOIyZn0RfQ1GIlLW7dXhxkerc9uZ3ozO4C7zfvip3z0Mg+wS1synktPfr4WISaPktIdnj3mVu6Uu7Mw==", + "version": "7.11.1", + "resolved": "https://registry.npmjs.org/@electron-forge/template-vite-typescript/-/template-vite-typescript-7.11.1.tgz", + "integrity": "sha512-Us4AHXFb+4z+gXgZImSqMBS63oKnsQWLOhqRg321xiDzu2UcQPlwgWNb4rAEKNVC1e7LXrUNDHuBiTrQkvWXbg==", "dev": true, "license": "MIT", "dependencies": { - "@electron-forge/shared-types": "7.10.2", - "@electron-forge/template-base": "7.10.2", + "@electron-forge/shared-types": "7.11.1", + "@electron-forge/template-base": "7.11.1", "fs-extra": "^10.0.0" }, "engines": { "node": ">= 16.4.0" } }, - "node_modules/@electron-forge/template-webpack": { - "version": "7.10.2", - "resolved": "https://registry.npmjs.org/@electron-forge/template-webpack/-/template-webpack-7.10.2.tgz", - "integrity": "sha512-VIUXA+XHM5SLjg7fIpOOmBsgi0LstkjrEz4gUzVL0AaITM7e+BCziIHld1ceXLbQ1FnKtrUGnQ9X/cHYxYvhHg==", + "node_modules/@electron-forge/template-vite-typescript/node_modules/@electron-forge/shared-types": { + "version": "7.11.1", + "resolved": "https://registry.npmjs.org/@electron-forge/shared-types/-/shared-types-7.11.1.tgz", + "integrity": "sha512-vvBWdAEh53UJlDGUevpaJk1+sqDMQibfrbHR+0IPA4MPyQex7/Uhv3vYH9oGHujBVAChQahjAuJt0fG6IJBLZg==", "dev": true, "license": "MIT", "dependencies": { - "@electron-forge/shared-types": "7.10.2", - "@electron-forge/template-base": "7.10.2", + "@electron-forge/tracer": "7.11.1", + "@electron/packager": "^18.3.5", + "@electron/rebuild": "^3.7.0", + "listr2": "^7.0.2" + }, + "engines": { + "node": ">= 16.4.0" + } + }, + "node_modules/@electron-forge/template-vite-typescript/node_modules/@electron-forge/tracer": { + "version": "7.11.1", + "resolved": "https://registry.npmjs.org/@electron-forge/tracer/-/tracer-7.11.1.tgz", + "integrity": "sha512-tiB6cglVQFcSw9N8GRwVwZUeB9u0DOx2Mj7aFXBUsFLUYQapvVGv51tUSy/UAW5lvmubGscYIILuVko+II3+NA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chrome-trace-event": "^1.0.3" + }, + "engines": { + "node": ">= 14.17.5" + } + }, + "node_modules/@electron-forge/template-vite/node_modules/@electron-forge/shared-types": { + "version": "7.11.1", + "resolved": "https://registry.npmjs.org/@electron-forge/shared-types/-/shared-types-7.11.1.tgz", + "integrity": "sha512-vvBWdAEh53UJlDGUevpaJk1+sqDMQibfrbHR+0IPA4MPyQex7/Uhv3vYH9oGHujBVAChQahjAuJt0fG6IJBLZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron-forge/tracer": "7.11.1", + "@electron/packager": "^18.3.5", + "@electron/rebuild": "^3.7.0", + "listr2": "^7.0.2" + }, + "engines": { + "node": ">= 16.4.0" + } + }, + "node_modules/@electron-forge/template-vite/node_modules/@electron-forge/tracer": { + "version": "7.11.1", + "resolved": "https://registry.npmjs.org/@electron-forge/tracer/-/tracer-7.11.1.tgz", + "integrity": "sha512-tiB6cglVQFcSw9N8GRwVwZUeB9u0DOx2Mj7aFXBUsFLUYQapvVGv51tUSy/UAW5lvmubGscYIILuVko+II3+NA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chrome-trace-event": "^1.0.3" + }, + "engines": { + "node": ">= 14.17.5" + } + }, + "node_modules/@electron-forge/template-webpack": { + "version": "7.11.1", + "resolved": "https://registry.npmjs.org/@electron-forge/template-webpack/-/template-webpack-7.11.1.tgz", + "integrity": "sha512-15lbXxi+er461MPk6sbwAOyjofAHwmQjTvxNCiNpaU2naEwbj3t0SlLq/BMr5HxnVOaMmA7+lKV9afkIom+d4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron-forge/shared-types": "7.11.1", + "@electron-forge/template-base": "7.11.1", "fs-extra": "^10.0.0" }, "engines": { @@ -1326,14 +1543,14 @@ } }, "node_modules/@electron-forge/template-webpack-typescript": { - "version": "7.10.2", - "resolved": "https://registry.npmjs.org/@electron-forge/template-webpack-typescript/-/template-webpack-typescript-7.10.2.tgz", - "integrity": "sha512-JtrLUAFbxxWJ1kU7b8MNyL5SO9/rY5UeNz1b9hvMvilW8GxyMWUen58dafgdnx3OpKLNZnhOOhgRagNppEzJOA==", + "version": "7.11.1", + "resolved": "https://registry.npmjs.org/@electron-forge/template-webpack-typescript/-/template-webpack-typescript-7.11.1.tgz", + "integrity": "sha512-6ExfFnFkHBz8rvRFTFg5HVGTC12uJpbVk4q8DVg0R8rhhxhqiVNh8lF2UPtZ2yT2UtGWjXNVlyP3Y3T6q6E3GQ==", "dev": true, "license": "MIT", "dependencies": { - "@electron-forge/shared-types": "7.10.2", - "@electron-forge/template-base": "7.10.2", + "@electron-forge/shared-types": "7.11.1", + "@electron-forge/template-base": "7.11.1", "fs-extra": "^10.0.0", "typescript": "~5.4.5", "webpack": "^5.69.1" @@ -1342,6 +1559,35 @@ "node": ">= 16.4.0" } }, + "node_modules/@electron-forge/template-webpack-typescript/node_modules/@electron-forge/shared-types": { + "version": "7.11.1", + "resolved": "https://registry.npmjs.org/@electron-forge/shared-types/-/shared-types-7.11.1.tgz", + "integrity": "sha512-vvBWdAEh53UJlDGUevpaJk1+sqDMQibfrbHR+0IPA4MPyQex7/Uhv3vYH9oGHujBVAChQahjAuJt0fG6IJBLZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron-forge/tracer": "7.11.1", + "@electron/packager": "^18.3.5", + "@electron/rebuild": "^3.7.0", + "listr2": "^7.0.2" + }, + "engines": { + "node": ">= 16.4.0" + } + }, + "node_modules/@electron-forge/template-webpack-typescript/node_modules/@electron-forge/tracer": { + "version": "7.11.1", + "resolved": "https://registry.npmjs.org/@electron-forge/tracer/-/tracer-7.11.1.tgz", + "integrity": "sha512-tiB6cglVQFcSw9N8GRwVwZUeB9u0DOx2Mj7aFXBUsFLUYQapvVGv51tUSy/UAW5lvmubGscYIILuVko+II3+NA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chrome-trace-event": "^1.0.3" + }, + "engines": { + "node": ">= 14.17.5" + } + }, "node_modules/@electron-forge/template-webpack-typescript/node_modules/typescript": { "version": "5.4.5", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", @@ -1356,6 +1602,35 @@ "node": ">=14.17" } }, + "node_modules/@electron-forge/template-webpack/node_modules/@electron-forge/shared-types": { + "version": "7.11.1", + "resolved": "https://registry.npmjs.org/@electron-forge/shared-types/-/shared-types-7.11.1.tgz", + "integrity": "sha512-vvBWdAEh53UJlDGUevpaJk1+sqDMQibfrbHR+0IPA4MPyQex7/Uhv3vYH9oGHujBVAChQahjAuJt0fG6IJBLZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron-forge/tracer": "7.11.1", + "@electron/packager": "^18.3.5", + "@electron/rebuild": "^3.7.0", + "listr2": "^7.0.2" + }, + "engines": { + "node": ">= 16.4.0" + } + }, + "node_modules/@electron-forge/template-webpack/node_modules/@electron-forge/tracer": { + "version": "7.11.1", + "resolved": "https://registry.npmjs.org/@electron-forge/tracer/-/tracer-7.11.1.tgz", + "integrity": "sha512-tiB6cglVQFcSw9N8GRwVwZUeB9u0DOx2Mj7aFXBUsFLUYQapvVGv51tUSy/UAW5lvmubGscYIILuVko+II3+NA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chrome-trace-event": "^1.0.3" + }, + "engines": { + "node": ">= 14.17.5" + } + }, "node_modules/@electron-forge/tracer": { "version": "7.10.2", "resolved": "https://registry.npmjs.org/@electron-forge/tracer/-/tracer-7.10.2.tgz", @@ -8110,6 +8385,23 @@ "@types/node": "*" } }, + "node_modules/@types/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, "node_modules/@types/mute-stream": { "version": "0.0.4", "resolved": "https://registry.npmjs.org/@types/mute-stream/-/mute-stream-0.0.4.tgz", @@ -8562,9 +8854,9 @@ } }, "node_modules/@vscode/sudo-prompt": { - "version": "9.3.1", - "resolved": "https://registry.npmjs.org/@vscode/sudo-prompt/-/sudo-prompt-9.3.1.tgz", - "integrity": "sha512-9ORTwwS74VaTn38tNbQhsA5U44zkJfcb0BdTSyyG6frP4e8KMtHuTXYmwefe5dpL8XB1aGSIVTaLjD3BbWb5iA==", + "version": "9.3.2", + "resolved": "https://registry.npmjs.org/@vscode/sudo-prompt/-/sudo-prompt-9.3.2.tgz", + "integrity": "sha512-gcXoCN00METUNFeQOFJ+C9xUI0DKB+0EGMVg7wbVYRHBw2Eq3fKisDZOkRdOz3kqXRKOENMfShPOmypw1/8nOw==", "dev": true, "license": "MIT" }, @@ -10738,9 +11030,9 @@ "license": "MIT" }, "node_modules/diff": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.2.tgz", - "integrity": "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==", + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.3.tgz", + "integrity": "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -11374,9 +11666,9 @@ } }, "node_modules/electron/node_modules/@types/node": { - "version": "20.19.25", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.25.tgz", - "integrity": "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==", + "version": "20.19.30", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.30.tgz", + "integrity": "sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==", "dev": true, "license": "MIT", "dependencies": { @@ -15618,6 +15910,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash-es": { + "version": "4.17.22", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.22.tgz", + "integrity": "sha512-XEawp1t0gxSi9x01glktRZ5HDy0HXqrM0x5pXQM98EaI0NxO6jVM7omDOxsuEo5UIASAnm2bRp1Jt/e0a2XU8Q==", + "license": "MIT" + }, "node_modules/lodash.camelcase": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", @@ -20523,9 +20821,9 @@ } }, "node_modules/undici": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.16.0.tgz", - "integrity": "sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==", + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.18.2.tgz", + "integrity": "sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw==", "license": "MIT", "engines": { "node": ">=20.18.1" diff --git a/package.json b/package.json index 4630ef7..493340d 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "@types/better-sqlite3": "^7.6.12", "@types/electron-squirrel-startup": "^1.0.2", "@types/eslint-config-prettier": "^6.11.3", + "@types/lodash-es": "^4.17.12", "@types/node": "^24.10.1", "@types/react": "^19.2.2", "@types/react-dom": "^19.2.2", @@ -120,6 +121,7 @@ "i18next": "^25.6.3", "i18next-browser-languagedetector": "^8.2.0", "keytar": "^7.9.0", + "lodash-es": "^4.17.22", "lucide-react": "^0.553.0", "react": "^19.2.0", "react-dom": "^19.2.0", @@ -132,11 +134,5 @@ "update-electron-app": "^3.1.1", "uuid": "^13.0.0", "zod": "^4.1.12" - }, - "ts-node": { - "transpileOnly": true, - "compilerOptions": { - "module": "commonjs" - } } } diff --git a/src/lib/antigravity/ClaudeResponseMapper.ts b/src/lib/antigravity/ClaudeResponseMapper.ts index 2ea985d..fd3f9c7 100644 --- a/src/lib/antigravity/ClaudeResponseMapper.ts +++ b/src/lib/antigravity/ClaudeResponseMapper.ts @@ -7,6 +7,7 @@ import { Usage, GroundingMetadata, } from './types'; +import { decodeSignature } from './signature-utils'; /** * Non-streaming response processor (Gemini -> Claude) @@ -55,7 +56,7 @@ class NonStreamingProcessor { } private processPart(part: GeminiPart) { - const signature = part.thoughtSignature || null; + const signature = decodeSignature(part.thoughtSignature) || null; // 1. Handle FunctionCall if (part.functionCall) { diff --git a/src/lib/antigravity/ClaudeStreamingMapper.ts b/src/lib/antigravity/ClaudeStreamingMapper.ts index a313366..71e049a 100644 --- a/src/lib/antigravity/ClaudeStreamingMapper.ts +++ b/src/lib/antigravity/ClaudeStreamingMapper.ts @@ -1,5 +1,6 @@ -import { GeminiPart, Usage, UsageMetadata, GroundingMetadata } from './types'; +import { GeminiPart, Usage, UsageMetadata } from './types'; import { SignatureStore } from './SignatureStore'; +import { decodeSignature } from './signature-utils'; type BlockType = 'None' | 'Text' | 'Thinking' | 'Function'; @@ -43,6 +44,8 @@ export class StreamingState { public webSearchQuery: string | null = null; public groundingChunks: any[] | null = null; + private parseErrorCount: number = 0; + constructor() {} public emit(eventType: string, data: any): string { @@ -242,6 +245,56 @@ export class StreamingState { public storeSignature(signature?: string) { this.signatures.store(signature); } + public handleParseError(rawData: string): string[] { + const chunks: string[] = []; + this.parseErrorCount++; + + console.warn( + `[SSE-Parser] Parse error #${this.parseErrorCount}. Raw data length: ${rawData.length}`, + ); + + // Safely close current block + if (this.blockType !== 'None') { + chunks.push(...this.endBlock()); + } + + // Emit error event if too many errors + if (this.parseErrorCount > 3) { + console.error( + `[SSE-Parser] High error rate (${this.parseErrorCount} errors). Stream may be corrupted.`, + ); + chunks.push( + this.emit('error', { + type: 'error', + error: { + type: 'network_error', + message: 'Unstable network connection. Please check your network or proxy settings.', + code: 'stream_decode_error', + details: { + error_count: this.parseErrorCount, + suggestion: 'Check network connection', + }, + }, + }), + ); + } + + return chunks; + } + + /** + * Reset error state (call after recovery) + */ + public resetErrorState(): void { + this.parseErrorCount = 0; + } + + /** + * Get current error count (for monitoring) + */ + public getErrorCount(): number { + return this.parseErrorCount; + } } /** @@ -252,7 +305,7 @@ export class PartProcessor { public process(part: GeminiPart): string[] { const chunks: string[] = []; - const signature = part.thoughtSignature || undefined; + const signature = decodeSignature(part.thoughtSignature); // 1. Handle FunctionCall if (part.functionCall) { diff --git a/src/lib/antigravity/retry-utils.ts b/src/lib/antigravity/retry-utils.ts new file mode 100644 index 0000000..bf24a52 --- /dev/null +++ b/src/lib/antigravity/retry-utils.ts @@ -0,0 +1,49 @@ +/** Jitter factor: ±20% */ +const JITTER_FACTOR = 0.2; + +/** Maximum retry delay in milliseconds (30 seconds) */ +const MAX_DELAY_MS = 30000; + +/** + * Apply jitter to a delay value to prevent thundering herd + * Returns delay ± JITTER_FACTOR (e.g., 1000ms ± 20% = 800-1200ms) + * + * @param delayMs - Base delay in milliseconds + * @returns Jittered delay (minimum 1ms) + */ +export function applyJitter(delayMs: number): number { + if (delayMs <= 0) { + return 1; + } + + const jitterRange = delayMs * JITTER_FACTOR; + const jitter = (Math.random() * 2 - 1) * jitterRange; + return Math.max(1, Math.round(delayMs + jitter)); +} + +/** + * Calculate retry delay with exponential backoff and jitter + * + * @param attempt - Current attempt number (0-indexed) + * @param baseMs - Base delay for first retry (default 1000ms) + * @returns Delay in milliseconds with jitter applied + */ +export function calculateRetryDelay(attempt: number, baseMs: number = 1000): number { + // Exponential backoff: 1s, 2s, 4s, 8s, ... + const exponentialDelay = baseMs * Math.pow(2, attempt); + + // Cap at maximum delay + const cappedDelay = Math.min(exponentialDelay, MAX_DELAY_MS); + + // Apply jitter + return applyJitter(cappedDelay); +} + +/** + * Sleep for specified milliseconds + * + * @param ms - Milliseconds to sleep + */ +export function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/src/lib/antigravity/signature-utils.ts b/src/lib/antigravity/signature-utils.ts new file mode 100644 index 0000000..ef9f19a --- /dev/null +++ b/src/lib/antigravity/signature-utils.ts @@ -0,0 +1,32 @@ +import { isEmpty } from 'lodash-es'; +/** + * Decode Base64-encoded signature if present + * Gemini sends Base64, Claude expects raw string + * + * @param signature - The signature string (may be Base64 or raw) + * @returns Decoded signature or undefined if input is empty + */ +export function decodeSignature(signature: string | undefined | null): string | undefined { + if (isEmpty(signature)) { + return undefined; + } + + try { + const decoded = Buffer.from(signature!, 'base64').toString('utf-8'); + + // Validate: Check if it looks like valid UTF-8 text + // If decoding produces replacement characters, it wasn't valid Base64 + if (decoded && !decoded.includes('\ufffd') && decoded.length > 0) { + // Additional check: re-encode and compare to detect false positives + const reEncoded = Buffer.from(decoded).toString('base64'); + if (reEncoded === signature) { + return decoded; + } + } + } catch { + // Not valid Base64, return as-is + } + + // Return original if not Base64 + return signature!; +} diff --git a/src/lib/antigravity/stream-error-utils.ts b/src/lib/antigravity/stream-error-utils.ts new file mode 100644 index 0000000..a24e380 --- /dev/null +++ b/src/lib/antigravity/stream-error-utils.ts @@ -0,0 +1,75 @@ +/** + * Stream Error Utilities + * Classifies network errors and formats user-friendly messages + * + * Problem: Technical error messages confuse users + * Solution: Classify errors and provide localized, actionable messages + */ + +export interface ClassifiedError { + type: string; + message: string; +} + +/** + * Classify a stream error and return user-friendly message + * + * @param error - The error object + * @returns Classified error with type and localized message + */ +export function classifyStreamError(error: Error): ClassifiedError { + const msg = error.message.toLowerCase(); + + if (msg.includes('timeout') || msg.includes('timedout')) { + return { + type: 'timeout_error', + message: 'Request timed out. Please check your network connection or try again later.', + }; + } + + if (msg.includes('econnrefused') || msg.includes('connect')) { + return { + type: 'connection_error', + message: 'Unable to connect to server. Please check your network.', + }; + } + + if (msg.includes('enotfound') || msg.includes('network') || msg.includes('dns')) { + return { + type: 'network_error', + message: 'Network connection failed. Please check your network settings.', + }; + } + + if (msg.includes('socket') || msg.includes('aborted') || msg.includes('reset')) { + return { + type: 'stream_error', + message: 'Data transfer interrupted. Please try again later.', + }; + } + + return { + type: 'unknown_error', + message: `Unknown error occurred: ${error.message.substring(0, 100)}`, + }; +} + +/** + * Format error as SSE event for Claude protocol + * + * @param errorType - Error type string + * @param message - User-friendly message + * @returns SSE-formatted error event string + */ +export function formatErrorForSSE(errorType: string, message: string): string { + const errorEvent = { + type: 'error', + error: { + type: errorType, + message: message, + code: 'stream_error', + }, + }; + + return `event: error\ndata: ${JSON.stringify(errorEvent)}\n\n`; +} diff --git a/src/lib/antigravity/thinking-utils.ts b/src/lib/antigravity/thinking-utils.ts new file mode 100644 index 0000000..2cde648 --- /dev/null +++ b/src/lib/antigravity/thinking-utils.ts @@ -0,0 +1,116 @@ +/** + * Thinking Block Utilities + * Handles validation and graceful degradation of thinking blocks + * + * Problem: Invalid signatures cause thinking blocks to be dropped, losing content + * Solution: Validate and convert invalid blocks to text instead of dropping + */ + +/** Minimum length for a valid thought signature */ +const MIN_SIGNATURE_LENGTH = 10; + +export interface ThinkingBlock { + type: 'thinking'; + thinking: string; + signature?: string; +} + +export interface TextBlock { + type: 'text'; + text: string; +} + +export type ContentBlock = ThinkingBlock | TextBlock | { type: string; [key: string]: any }; + +/** + * Check if a thinking block has a valid signature + * + * Rules: + * - Empty thinking + any signature = valid (trailing signature case) + * - Non-empty thinking + signature >= MIN_SIGNATURE_LENGTH = valid + * - Non-empty thinking + no/short signature = invalid + * - Non-thinking blocks = always valid + * + * @param block - Content block to validate + * @returns true if block is valid or not a thinking block + */ +export function hasValidSignature(block: ContentBlock): boolean { + if (block.type !== 'thinking') { + return true; // Non-thinking blocks are always valid + } + + const thinkingBlock = block as ThinkingBlock; + + // Empty thinking + any signature = valid (trailing signature) + if (!thinkingBlock.thinking || thinkingBlock.thinking.length === 0) { + return ( + thinkingBlock.signature !== undefined && + thinkingBlock.signature !== null && + thinkingBlock.signature.length >= MIN_SIGNATURE_LENGTH + ); + } + + // Non-empty thinking needs a long enough signature + return ( + thinkingBlock.signature !== undefined && + thinkingBlock.signature !== null && + thinkingBlock.signature.length >= MIN_SIGNATURE_LENGTH + ); +} + +/** + * Gracefully degrade an invalid thinking block + * + * - Valid blocks: return as-is + * - Invalid with content: convert to text block + * - Invalid without content: return null (should be dropped) + * + * @param block - Content block to process + * @returns Processed block or null if should be dropped + */ +export function gracefulDegradeThinking(block: ContentBlock): ContentBlock | null { + if (block.type !== 'thinking') { + return block; + } + + const thinkingBlock = block as ThinkingBlock; + + if (hasValidSignature(block)) { + return block; + } + + // Invalid signature - check if we have content to preserve + if (thinkingBlock.thinking && thinkingBlock.thinking.length > 0) { + // Convert to text block to preserve content + console.info( + `[Thinking-Utils] Converting invalid thinking block to text. Content length: ${thinkingBlock.thinking.length}`, + ); + return { + type: 'text', + text: thinkingBlock.thinking, + }; + } + + // Empty content + invalid signature = drop + console.debug('[Thinking-Utils] Dropping empty thinking block with invalid signature'); + return null; +} + +/** + * Process an array of content blocks, applying graceful degradation + * + * @param blocks - Array of content blocks + * @returns Processed array with invalid thinking blocks handled + */ +export function processThinkingBlocks(blocks: ContentBlock[]): ContentBlock[] { + const result: ContentBlock[] = []; + + for (const block of blocks) { + const processed = gracefulDegradeThinking(block); + if (processed !== null) { + result.push(processed); + } + } + + return result; +} diff --git a/src/server/modules/proxy/proxy.service.ts b/src/server/modules/proxy/proxy.service.ts index 7d1ed4b..bc3925e 100644 --- a/src/server/modules/proxy/proxy.service.ts +++ b/src/server/modules/proxy/proxy.service.ts @@ -7,6 +7,11 @@ import { transformClaudeRequestIn } from '../../../lib/antigravity/ClaudeRequest import { transformResponse } from '../../../lib/antigravity/ClaudeResponseMapper'; import { StreamingState, PartProcessor } from '../../../lib/antigravity/ClaudeStreamingMapper'; import { ClaudeRequest } from '../../../lib/antigravity/types'; +import { calculateRetryDelay, sleep } from '../../../lib/antigravity/retry-utils'; +import { + classifyStreamError, + formatErrorForSSE, +} from '../../../lib/antigravity/stream-error-utils'; import { OpenAIChatRequest, AnthropicChatRequest, @@ -45,6 +50,13 @@ export class ProxyService { const maxRetries = 3; for (let i = 0; i < maxRetries; i++) { + // Fix #6: Add jitter delay for retries (skip first attempt) + if (i > 0) { + const delay = calculateRetryDelay(i - 1); + this.logger.log(`Retry attempt ${i + 1}/${maxRetries}, waiting ${delay}ms (jittered)`); + await sleep(delay); + } + const token = await this.tokenManager.getNextToken(); if (!token) { throw new Error('No available accounts'); @@ -172,7 +184,11 @@ export class ProxyService { let lastFinishReason: string | undefined; let lastUsageMetadata: any | undefined; + // Fix #1: Track if we received any valid data + let receivedData = false; + upstreamStream.on('data', (chunk: Buffer) => { + receivedData = true; // Mark that we got data buffer += decoder.decode(chunk, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop() || ''; @@ -205,21 +221,40 @@ export class ProxyService { const chunks = processor.process(part as any); chunks.forEach((c) => subscriber.next(c)); } + + // Reset error state on successful parse + state.resetErrorState(); } catch (e) { + // Fix #3: Use handleParseError for graceful degradation this.logger.error('Stream parse error', e); + const errorChunks = state.handleParseError(dataStr); + errorChunks.forEach((c) => subscriber.next(c)); } } }); upstreamStream.on('end', () => { + // Fix #1: Check for empty stream + if (!receivedData) { + this.logger.warn('Empty response stream detected'); + subscriber.error(new Error('Empty response stream')); + return; + } + const finishChunks = state.emitFinish(lastFinishReason, lastUsageMetadata); finishChunks.forEach((c) => subscriber.next(c)); subscriber.complete(); }); upstreamStream.on('error', (err: any) => { - // Convert to clean Error to avoid circular reference issues (socket objects) - const cleanError = err instanceof Error ? new Error(err.message) : new Error(String(err)); + // Fix #2: Classify error and send friendly message + const cleanError = err instanceof Error ? err : new Error(String(err)); + const { type, message } = classifyStreamError(cleanError); + + this.logger.error(`Stream error: ${type} - ${cleanError.message}`); + + // Send SSE error event before closing + subscriber.next(formatErrorForSSE(type, message)); subscriber.error(cleanError); }); }); diff --git a/src/tests/unit/proxy-retry-mock.test.ts b/src/tests/unit/proxy-retry-mock.test.ts new file mode 100644 index 0000000..fa14983 --- /dev/null +++ b/src/tests/unit/proxy-retry-mock.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect, vi } from 'vitest'; +import { EventEmitter } from 'events'; +import { ProxyService } from '../../server/modules/proxy/proxy.service'; +import { Observable } from 'rxjs'; + +// Mock dependencies +const mockTokenManager = { getNextToken: vi.fn(), markAsRateLimited: vi.fn() }; +const mockGeminiClient = { streamGenerateInternal: vi.fn(), generateInternal: vi.fn() }; + +// Subclass to access private method +class TestableProxyService extends ProxyService { + constructor() { + super(mockTokenManager as any, mockGeminiClient as any); + } + + public testProcessStream(stream: any, model: string = 'model'): Observable { + // Access private method using type assertion + return (this as any).processAnthropicInternalStream(stream, model); + } +} + +describe('ProxyService Empty Stream Retry Logic', () => { + it('should emit error when stream ends without data', async () => { + const service = new TestableProxyService(); + const stream = new EventEmitter(); + + const resultObservable = service.testProcessStream(stream); + + let errorReceived: Error | undefined; + + const promise = new Promise((resolve) => { + resultObservable.subscribe({ + next: () => {}, + error: (err) => { + errorReceived = err; + resolve(); + }, + complete: () => resolve(), + }); + }); + + // Simulate empty stream: straight to end + setTimeout(() => stream.emit('end'), 10); + + await promise; + + expect(errorReceived).toBeDefined(); + expect(errorReceived?.message).toBe('Empty response stream'); + }); + + it('should NOT emit error when stream has data', async () => { + const service = new TestableProxyService(); + const stream = new EventEmitter(); + + const resultObservable = service.testProcessStream(stream); + + let errorReceived: Error | undefined; + const receivedChunks: string[] = []; + + const promise = new Promise((resolve) => { + resultObservable.subscribe({ + next: (c) => receivedChunks.push(c), + error: (err) => { + errorReceived = err; + resolve(); + }, + complete: () => resolve(), + }); + }); + + // Simulate valid data stream + setTimeout(() => { + const validJson = JSON.stringify({ + candidates: [ + { + content: { parts: [{ text: 'hello' }] }, + finishReason: 'STOP', + }, + ], + }); + stream.emit('data', Buffer.from(`data: ${validJson}\n\n`)); + stream.emit('end'); + }, 10); + + await promise; + + expect(errorReceived).toBeUndefined(); + // It should produce chunks (though exact number depends on mapper logic, at least it shouldn't error) + // Actually our mapper might produce "message_start", "content_block_start" etc. + // We just care that it didn't error with "Empty response stream" + }); +}); diff --git a/src/tests/unit/retry-utils.test.ts b/src/tests/unit/retry-utils.test.ts new file mode 100644 index 0000000..981bebf --- /dev/null +++ b/src/tests/unit/retry-utils.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from 'vitest'; +import { applyJitter, calculateRetryDelay } from '../../lib/antigravity/retry-utils'; + +describe('applyJitter', () => { + it('should return values within ±20% range', () => { + const baseDelay = 1000; + const results: number[] = []; + + for (let i = 0; i < 100; i++) { + results.push(applyJitter(baseDelay)); + } + + // All values should be within 800-1200 range (±20%) + results.forEach((r) => { + expect(r).toBeGreaterThanOrEqual(800); + expect(r).toBeLessThanOrEqual(1200); + }); + }); + + it('should produce different values (randomness)', () => { + const results = new Set(); + for (let i = 0; i < 20; i++) { + results.add(applyJitter(1000)); + } + // 20 calls should produce multiple distinct values + expect(results.size).toBeGreaterThan(5); + }); + + it('should guarantee minimum value of 1 for small inputs', () => { + expect(applyJitter(1)).toBeGreaterThanOrEqual(1); + expect(applyJitter(0)).toBe(1); + }); +}); + +describe('calculateRetryDelay', () => { + it('should implement exponential backoff', () => { + // First attempt (index 0): 1s ± 20% = 800-1200ms + const delay0 = calculateRetryDelay(0); + expect(delay0).toBeGreaterThanOrEqual(800); + expect(delay0).toBeLessThanOrEqual(1200); + + // Second attempt (index 1): 2s ± 20% = 1600-2400ms + const delay1 = calculateRetryDelay(1); + expect(delay1).toBeGreaterThanOrEqual(1600); + expect(delay1).toBeLessThanOrEqual(2400); + }); + + it('should have maximum delay limit', () => { + // Even with high retry count, should not exceed 30s + 20% jitter = 36s + const delay = calculateRetryDelay(10); + expect(delay).toBeLessThanOrEqual(36000); + }); + + it('should accept custom base delay', () => { + const delay = calculateRetryDelay(0, 500); + expect(delay).toBeGreaterThanOrEqual(400); // 500 - 20% + expect(delay).toBeLessThanOrEqual(600); // 500 + 20% + }); +}); diff --git a/src/tests/unit/signature-utils.test.ts b/src/tests/unit/signature-utils.test.ts new file mode 100644 index 0000000..dfe7f63 --- /dev/null +++ b/src/tests/unit/signature-utils.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect } from 'vitest'; +import { decodeSignature } from '../../lib/antigravity/signature-utils'; + +describe('decodeSignature', () => { + describe('Base64 decoding', () => { + it('should correctly decode valid Base64 signature', () => { + const original = 'valid-thinking-signature-12345'; + const base64 = Buffer.from(original).toString('base64'); + expect(decodeSignature(base64)).toBe(original); + }); + + it('should decode signature with special characters', () => { + const original = 'sig+with/special=chars=='; + const base64 = Buffer.from(original).toString('base64'); + expect(decodeSignature(base64)).toBe(original); + }); + + it('should decode UTF-8 Chinese signature', () => { + const original = '思考签名-测试'; + const base64 = Buffer.from(original).toString('base64'); + expect(decodeSignature(base64)).toBe(original); + }); + }); + + describe('Non-Base64 passthrough', () => { + it('should return non-Base64 strings as-is', () => { + expect(decodeSignature('plain-text-signature')).toBe('plain-text-signature'); + }); + + it('should handle strings with invalid Base64 characters', () => { + expect(decodeSignature('not!valid@base64#')).toBe('not!valid@base64#'); + }); + }); + + describe('Empty value handling', () => { + it('should return undefined for null input', () => { + expect(decodeSignature(null)).toBeUndefined(); + }); + + it('should return undefined for undefined input', () => { + expect(decodeSignature(undefined)).toBeUndefined(); + }); + + it('should return undefined for empty string', () => { + expect(decodeSignature('')).toBeUndefined(); + }); + }); +}); diff --git a/src/tests/unit/stream-error-utils.test.ts b/src/tests/unit/stream-error-utils.test.ts new file mode 100644 index 0000000..c4c239f --- /dev/null +++ b/src/tests/unit/stream-error-utils.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from 'vitest'; +import { classifyStreamError, formatErrorForSSE } from '../../lib/antigravity/stream-error-utils'; + +describe('classifyStreamError', () => { + it('should identify timeout errors', () => { + const error = new Error('timeout of 30000ms exceeded'); + const { type, message } = classifyStreamError(error); + expect(type).toBe('timeout_error'); + expect(message).toContain('timed out'); + }); + + it('should identify connection errors', () => { + const error = new Error('connect ECONNREFUSED 127.0.0.1:8080'); + const { type, message } = classifyStreamError(error); + expect(type).toBe('connection_error'); + expect(message).toContain('connect'); + }); + + it('should identify network DNS errors', () => { + const error = new Error('getaddrinfo ENOTFOUND googleapis.com'); + const { type, message } = classifyStreamError(error); + expect(type).toBe('network_error'); + expect(message).toContain('Network'); + }); + + it('should identify socket errors', () => { + const error = new Error('socket hang up'); + const { type, message } = classifyStreamError(error); + expect(type).toBe('stream_error'); + expect(message).toContain('interrupted'); + }); + + it('should return generic message for unknown errors', () => { + const error = new Error('some random error'); + const { type, message } = classifyStreamError(error); + expect(type).toBe('unknown_error'); + expect(message).toBeTruthy(); + expect(message).toContain('some random error'); + }); +}); + +describe('formatErrorForSSE', () => { + it('should format as Claude SSE error event', () => { + const result = formatErrorForSSE('network_error', 'Network connection failed'); + expect(result).toContain('event: error'); + expect(result).toContain('"type":"error"'); + expect(result).toContain('network_error'); + expect(result).toContain('Network connection failed'); + }); + + it('should end with newlines', () => { + const result = formatErrorForSSE('test', 'test'); + expect(result.endsWith('\n\n')).toBe(true); + }); + + it('should be valid SSE format', () => { + const result = formatErrorForSSE('timeout_error', 'Request timed out'); + expect(result.startsWith('event: ')).toBe(true); + expect(result).toContain('\ndata: '); + }); +}); diff --git a/src/tests/unit/streaming-state.test.ts b/src/tests/unit/streaming-state.test.ts new file mode 100644 index 0000000..5582ff4 --- /dev/null +++ b/src/tests/unit/streaming-state.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { StreamingState } from '../../lib/antigravity/ClaudeStreamingMapper'; + +describe('StreamingState', () => { + let state: StreamingState; + + beforeEach(() => { + state = new StreamingState(); + }); + + describe('handleParseError', () => { + it('should return empty array on first error', () => { + const chunks = state.handleParseError('invalid json'); + expect(chunks).toEqual([]); + }); + + it('should emit error event when error count exceeds 3', () => { + // Simulate 4 parse errors + state.handleParseError('error 1'); + state.handleParseError('error 2'); + state.handleParseError('error 3'); + const chunks = state.handleParseError('error 4'); + + expect(chunks.length).toBeGreaterThan(0); + expect(chunks[0]).toContain('network_error'); + expect(chunks[0]).toContain('Unstable network'); + }); + + it('should safely close active block on error', () => { + // Start a text block first + state.startBlock('Text', { type: 'text', text: '' }); + + const chunks = state.handleParseError('error during block'); + + // Should contain content_block_stop event + expect(chunks.some((c) => c.includes('content_block_stop'))).toBe(true); + }); + }); + + describe('resetErrorState', () => { + it('should reset error counter', () => { + state.handleParseError('error 1'); + state.handleParseError('error 2'); + state.resetErrorState(); + + // After reset, should start counting from 0 + const chunks = state.handleParseError('error after reset'); + expect(chunks).toEqual([]); + }); + }); + + describe('getErrorCount', () => { + it('should return current error count', () => { + expect(state.getErrorCount()).toBe(0); + state.handleParseError('error 1'); + expect(state.getErrorCount()).toBe(1); + state.handleParseError('error 2'); + expect(state.getErrorCount()).toBe(2); + }); + }); +}); diff --git a/src/tests/unit/thinking-utils.test.ts b/src/tests/unit/thinking-utils.test.ts new file mode 100644 index 0000000..542722a --- /dev/null +++ b/src/tests/unit/thinking-utils.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect } from 'vitest'; +import { + hasValidSignature, + gracefulDegradeThinking, + processThinkingBlocks, +} from '../../lib/antigravity/thinking-utils'; + +describe('hasValidSignature', () => { + it('should return true for empty thinking + valid signature', () => { + const block = { type: 'thinking' as const, thinking: '', signature: 'valid-signature-long' }; + expect(hasValidSignature(block)).toBe(true); + }); + + it('should return true for content + long signature', () => { + const block = { + type: 'thinking' as const, + thinking: 'some thought', + signature: 'long-enough-signature', + }; + expect(hasValidSignature(block)).toBe(true); + }); + + it('should return false for content + short signature', () => { + const block = { type: 'thinking' as const, thinking: 'some thought', signature: 'short' }; + expect(hasValidSignature(block)).toBe(false); + }); + + it('should return false for content + no signature', () => { + const block = { type: 'thinking' as const, thinking: 'some thought', signature: undefined }; + expect(hasValidSignature(block)).toBe(false); + }); + + it('should return true for non-thinking types', () => { + const block = { type: 'text' as const, text: 'hello' }; + expect(hasValidSignature(block)).toBe(true); + }); +}); + +describe('gracefulDegradeThinking', () => { + it('should convert invalid signature thinking to text', () => { + const block = { type: 'thinking' as const, thinking: 'important content', signature: 'bad' }; + const result = gracefulDegradeThinking(block); + expect(result?.type).toBe('text'); + expect((result as any).text).toBe('important content'); + }); + + it('should keep valid thinking block unchanged', () => { + const block = { + type: 'thinking' as const, + thinking: 'content', + signature: 'valid-long-signature', + }; + const result = gracefulDegradeThinking(block); + expect(result?.type).toBe('thinking'); + }); + + it('should drop empty content + invalid signature block', () => { + const block = { type: 'thinking' as const, thinking: '', signature: 'bad' }; + const result = gracefulDegradeThinking(block); + expect(result).toBeNull(); + }); + + it('should keep empty content + valid signature trailing block', () => { + const block = { type: 'thinking' as const, thinking: '', signature: 'any-valid-sig' }; + const result = gracefulDegradeThinking(block); + expect(result?.type).toBe('thinking'); + }); +}); + +describe('processThinkingBlocks', () => { + it('should filter out invalid empty thinking blocks', () => { + const blocks = [ + { type: 'text' as const, text: 'hello' }, + { type: 'thinking' as const, thinking: '', signature: 'bad' }, + { type: 'text' as const, text: 'world' }, + ]; + const result = processThinkingBlocks(blocks); + expect(result.length).toBe(2); + expect(result[0].type).toBe('text'); + expect(result[1].type).toBe('text'); + }); + + it('should convert content with invalid signature to text', () => { + const blocks = [{ type: 'thinking' as const, thinking: 'keep me', signature: 'x' }]; + const result = processThinkingBlocks(blocks); + expect(result.length).toBe(1); + expect(result[0].type).toBe('text'); + expect((result[0] as any).text).toBe('keep me'); + }); +}); diff --git a/vitest.config.mjs b/vitest.config.mjs new file mode 100644 index 0000000..af44232 --- /dev/null +++ b/vitest.config.mjs @@ -0,0 +1,18 @@ +import { defineConfig } from 'vitest/config'; +import path from 'path'; + +export default defineConfig({ + resolve: { + alias: { + '@': path.resolve(process.cwd(), './src'), + // Mock native modules that fail to load in test environment + keytar: path.resolve(process.cwd(), './src/mocks/empty.ts'), + 'better-sqlite3': path.resolve(process.cwd(), './src/mocks/empty.ts'), + }, + }, + test: { + globals: true, + environment: 'happy-dom', + include: ['src/tests/unit/**/*.test.ts'], + }, +}); diff --git a/vitest.config.mts b/vitest.config.mts deleted file mode 100644 index b345fe8..0000000 --- a/vitest.config.mts +++ /dev/null @@ -1,31 +0,0 @@ -import * as path from 'path'; -import react from '@vitejs/plugin-react'; -import { defineConfig } from 'vitest/config'; - -export default defineConfig({ - plugins: [react() as any], - resolve: { - alias: { - '@': path.resolve(__dirname, './src'), - }, - }, - test: { - dir: './src/tests/unit', - globals: true, - environment: 'node', - setupFiles: './src/tests/unit/setup.ts', - css: true, - reporters: ['verbose'], - coverage: { - provider: 'v8', - reporter: ['text', 'json', 'html'], - include: ['src/**/*'], - exclude: [], - }, - server: { - deps: { - inline: ['jsdom'], - }, - }, - }, -});