diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..44e22d4 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,24 @@ +name: Tests + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + # Skip postinstall (electron-builder install-app-deps) — not needed for unit tests + - run: npm ci --ignore-scripts + + - run: npm test diff --git a/package-lock.json b/package-lock.json index 892d262..07a62af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "supercmd", - "version": "1.0.0", + "version": "1.0.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "supercmd", - "version": "1.0.0", + "version": "1.0.2", "hasInstallScript": true, "license": "ISC", "dependencies": { @@ -34,6 +34,7 @@ "tailwindcss": "^3.4.1", "typescript": "^5.3.3", "vite": "^5.0.11", + "vitest": "^4.0.18", "wait-on": "^7.2.0" } }, @@ -2661,6 +2662,13 @@ "url": "https://github.com/sindresorhus/is?sponsor=1" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@szmarczak/http-timer": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", @@ -2741,6 +2749,17 @@ "@types/responselike": "^1.0.0" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -2750,6 +2769,13 @@ "@types/ms": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2892,6 +2918,90 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/expect": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", + "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", + "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", + "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.18", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", + "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", + "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", + "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@xmldom/xmldom": { "version": "0.8.11", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz", @@ -3217,6 +3327,16 @@ "node": ">=0.8" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/astral-regex": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", @@ -3643,6 +3763,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -4826,6 +4956,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -4933,6 +5070,26 @@ "node": ">=4" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/extract-zip": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", @@ -6138,6 +6295,16 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/matcher": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", @@ -6477,6 +6644,17 @@ "node": ">= 0.4" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -6611,6 +6789,13 @@ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "license": "ISC" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/pend": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", @@ -7365,6 +7550,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -7502,6 +7694,13 @@ "license": "BSD-3-Clause", "optional": true }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/stat-mode": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/stat-mode/-/stat-mode-1.0.0.tgz", @@ -7511,6 +7710,13 @@ "node": ">= 6" } }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -7801,6 +8007,23 @@ "integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -7846,6 +8069,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tmp": { "version": "0.2.5", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", @@ -8520,39 +8753,751 @@ "@esbuild/win32-x64": "0.21.5" } }, - "node_modules/wait-on": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-7.2.0.tgz", - "integrity": "sha512-wCQcHkRazgjG5XoAq9jbTMLpNIjoSlZslrJ2+N9MxDsGEv1HnFoVjOCexL0ESva7Y9cu350j+DWADdk54s4AFQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "axios": "^1.6.1", - "joi": "^17.11.0", - "lodash": "^4.17.21", - "minimist": "^1.2.8", - "rxjs": "^7.8.1" + "node_modules/vitest": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", + "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.0.18", + "@vitest/mocker": "4.0.18", + "@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.2.2", + "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": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" }, "bin": { - "wait-on": "bin/wait-on" + "vitest": "vitest.mjs" }, "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" }, - "bin": { - "node-which": "bin/node-which" + "funding": { + "url": "https://opencollective.com/vitest" + }, + "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 + } + } + }, + "node_modules/vitest/node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], "engines": { - "node": ">= 8" + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", + "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.18", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/vitest/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest/node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "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 + } + } + }, + "node_modules/wait-on": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-7.2.0.tgz", + "integrity": "sha512-wCQcHkRazgjG5XoAq9jbTMLpNIjoSlZslrJ2+N9MxDsGEv1HnFoVjOCexL0ESva7Y9cu350j+DWADdk54s4AFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "axios": "^1.6.1", + "joi": "^17.11.0", + "lodash": "^4.17.21", + "minimist": "^1.2.8", + "rxjs": "^7.8.1" + }, + "bin": { + "wait-on": "bin/wait-on" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" } }, "node_modules/widest-line": { diff --git a/package.json b/package.json index 40f4fa5..b24ce3d 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,9 @@ "build:native": "mkdir -p dist/native && swiftc -O -o dist/native/color-picker src/native/color-picker.swift -framework AppKit && swiftc -O -o dist/native/snippet-expander src/native/snippet-expander.swift -framework AppKit && swiftc -O -o dist/native/hotkey-hold-monitor src/native/hotkey-hold-monitor.swift -framework CoreGraphics -framework AppKit -framework Carbon && swiftc -O -o dist/native/speech-recognizer src/native/speech-recognizer.swift -framework Speech -framework AVFoundation && swiftc -O -o dist/native/microphone-access src/native/microphone-access.swift -framework AVFoundation && swiftc -O -o dist/native/input-monitoring-request src/native/input-monitoring-request.swift -framework CoreGraphics", "postinstall": "electron-builder install-app-deps", "start": "electron .", - "package": "npm run build && electron-builder" + "package": "npm run build && electron-builder", + "test": "vitest run", + "test:watch": "vitest" }, "license": "ISC", "devDependencies": { @@ -34,6 +36,7 @@ "tailwindcss": "^3.4.1", "typescript": "^5.3.3", "vite": "^5.0.11", + "vitest": "^4.0.18", "wait-on": "^7.2.0" }, "dependencies": { diff --git a/src/main/__tests__/ai-provider.test.ts b/src/main/__tests__/ai-provider.test.ts new file mode 100644 index 0000000..201f5e4 --- /dev/null +++ b/src/main/__tests__/ai-provider.test.ts @@ -0,0 +1,400 @@ +/** + * ai-provider.test.ts + * + * Unit tests for the critical paths in ai-provider.ts: + * - isAIAvailable — determines whether AI features are active + * - resolveModel — routes model key/prefix to provider + modelId + * - resolveCompatibleChatUrl — normalises OpenAI-compatible base URLs + * - parseSSE — parses streaming SSE payloads + * - parseNDJSON — parses streaming NDJSON payloads (Ollama) + * - resolveUploadMeta — maps MIME types to upload filenames/content-types + */ + +import { describe, it, expect } from 'vitest'; +import { Readable } from 'stream'; +import type { AISettings } from '../settings-store'; + +import { + isAIAvailable, + resolveModel, + resolveCompatibleChatUrl, + parseSSE, + parseNDJSON, + resolveUploadMeta, +} from '../ai-provider'; + +// ─── Helpers ───────────────────────────────────────────────────────── + +/** Build a minimal AISettings object, overriding only the fields you care about. */ +function cfg(overrides: Partial = {}): AISettings { + return { + provider: 'openai', + enabled: true, + openaiApiKey: '', + anthropicApiKey: '', + elevenlabsApiKey: '', + supermemoryApiKey: '', + supermemoryClient: '', + supermemoryBaseUrl: '', + supermemoryLocalMode: false, + ollamaBaseUrl: '', + defaultModel: '', + speechCorrectionModel: '', + speechToTextModel: 'native', + speechLanguage: 'en-US', + textToSpeechModel: 'edge-tts', + edgeTtsVoice: '', + speechCorrectionEnabled: true, + openaiCompatibleBaseUrl: '', + openaiCompatibleApiKey: '', + openaiCompatibleModel: '', + ...overrides, + }; +} + +/** Collect all yielded values from an async generator. */ +async function collect(gen: AsyncGenerator): Promise { + const out: T[] = []; + for await (const v of gen) out.push(v); + return out; +} + +/** + * Build a mock readable stream from raw string chunks. + * parseSSE / parseNDJSON only do `for await...of` on the response, + * so any async iterable (including Readable) satisfies the contract. + */ +function makeStream(chunks: string[]): NodeJS.ReadableStream { + return Readable.from(chunks); +} + +// ─── isAIAvailable ─────────────────────────────────────────────────── + +describe('isAIAvailable', () => { + it('returns false when AI is disabled, regardless of credentials', () => { + expect(isAIAvailable(cfg({ enabled: false, provider: 'openai', openaiApiKey: 'sk-abc' }))).toBe(false); + expect(isAIAvailable(cfg({ enabled: false, provider: 'anthropic', anthropicApiKey: 'sk-ant-abc' }))).toBe(false); + }); + + describe('openai provider', () => { + it('returns true when api key is present', () => { + expect(isAIAvailable(cfg({ provider: 'openai', openaiApiKey: 'sk-abc' }))).toBe(true); + }); + it('returns false when api key is empty', () => { + expect(isAIAvailable(cfg({ provider: 'openai', openaiApiKey: '' }))).toBe(false); + }); + }); + + describe('anthropic provider', () => { + it('returns true when api key is present', () => { + expect(isAIAvailable(cfg({ provider: 'anthropic', anthropicApiKey: 'sk-ant-abc' }))).toBe(true); + }); + it('returns false when api key is empty', () => { + expect(isAIAvailable(cfg({ provider: 'anthropic', anthropicApiKey: '' }))).toBe(false); + }); + }); + + describe('ollama provider', () => { + it('returns true when base URL is present', () => { + expect(isAIAvailable(cfg({ provider: 'ollama', ollamaBaseUrl: 'http://localhost:11434' }))).toBe(true); + }); + it('returns false when base URL is empty', () => { + expect(isAIAvailable(cfg({ provider: 'ollama', ollamaBaseUrl: '' }))).toBe(false); + }); + }); + + describe('openai-compatible provider', () => { + it('returns true when both baseUrl and apiKey are present', () => { + expect(isAIAvailable(cfg({ + provider: 'openai-compatible', + openaiCompatibleBaseUrl: 'https://api.groq.com/openai/v1', + openaiCompatibleApiKey: 'gsk-abc', + }))).toBe(true); + }); + it('returns false when only baseUrl is present', () => { + expect(isAIAvailable(cfg({ + provider: 'openai-compatible', + openaiCompatibleBaseUrl: 'https://api.groq.com/openai/v1', + openaiCompatibleApiKey: '', + }))).toBe(false); + }); + it('returns false when only apiKey is present', () => { + expect(isAIAvailable(cfg({ + provider: 'openai-compatible', + openaiCompatibleBaseUrl: '', + openaiCompatibleApiKey: 'gsk-abc', + }))).toBe(false); + }); + it('returns false when both are empty', () => { + expect(isAIAvailable(cfg({ provider: 'openai-compatible' }))).toBe(false); + }); + }); +}); + +// ─── resolveModel ──────────────────────────────────────────────────── + +describe('resolveModel', () => { + describe('known model table keys', () => { + it('routes openai-gpt-4o correctly', () => { + expect(resolveModel('openai-gpt-4o', cfg())).toEqual({ provider: 'openai', modelId: 'gpt-4o' }); + }); + it('routes openai-gpt-4o-mini correctly', () => { + expect(resolveModel('openai-gpt-4o-mini', cfg())).toEqual({ provider: 'openai', modelId: 'gpt-4o-mini' }); + }); + it('routes anthropic-claude-haiku correctly', () => { + expect(resolveModel('anthropic-claude-haiku', cfg())).toEqual({ + provider: 'anthropic', + modelId: 'claude-haiku-4-5-20251001', + }); + }); + it('routes anthropic-claude-sonnet correctly', () => { + expect(resolveModel('anthropic-claude-sonnet', cfg())).toEqual({ + provider: 'anthropic', + modelId: 'claude-sonnet-4-20250514', + }); + }); + it('routes ollama-llama3 correctly', () => { + expect(resolveModel('ollama-llama3', cfg())).toEqual({ provider: 'ollama', modelId: 'llama3' }); + }); + }); + + describe('prefix stripping for unknown model strings', () => { + it('strips openai- prefix for unknown openai models', () => { + expect(resolveModel('openai-gpt-5', cfg())).toEqual({ provider: 'openai', modelId: 'gpt-5' }); + }); + it('strips anthropic- prefix for unknown anthropic models', () => { + expect(resolveModel('anthropic-claude-opus-4', cfg())).toEqual({ + provider: 'anthropic', + modelId: 'claude-opus-4', + }); + }); + it('strips ollama- prefix for dynamic ollama models like llama3.2', () => { + expect(resolveModel('ollama-llama3.2', cfg())).toEqual({ provider: 'ollama', modelId: 'llama3.2' }); + }); + it('strips openai-compatible- prefix correctly', () => { + expect(resolveModel('openai-compatible-llama-3.1-8b-instant', cfg({ provider: 'openai-compatible' }))).toEqual({ + provider: 'openai-compatible', + modelId: 'llama-3.1-8b-instant', + }); + }); + it('openai-compatible- prefix is matched before openai- (order matters)', () => { + // A model like "openai-compatible-openai-thing" must route to openai-compatible, not openai + const result = resolveModel('openai-compatible-openai-thing', cfg({ provider: 'openai-compatible' })); + expect(result.provider).toBe('openai-compatible'); + expect(result.modelId).toBe('openai-thing'); + }); + }); + + describe('fallback to defaultModel', () => { + it('uses known defaultModel from table when no model arg given', () => { + expect(resolveModel(undefined, cfg({ defaultModel: 'openai-gpt-4o' }))).toEqual({ + provider: 'openai', + modelId: 'gpt-4o', + }); + }); + it('strips prefix from dynamic defaultModel like ollama-llama3.2', () => { + expect(resolveModel(undefined, cfg({ defaultModel: 'ollama-llama3.2' }))).toEqual({ + provider: 'ollama', + modelId: 'llama3.2', + }); + }); + it('strips openai-compatible- prefix from defaultModel', () => { + expect(resolveModel(undefined, cfg({ + provider: 'openai-compatible', + defaultModel: 'openai-compatible-mixtral-8x7b', + }))).toEqual({ provider: 'openai-compatible', modelId: 'mixtral-8x7b' }); + }); + }); + + describe('provider defaults when no model at all', () => { + it('defaults openai provider to gpt-4o-mini', () => { + expect(resolveModel(undefined, cfg({ provider: 'openai' }))).toEqual({ + provider: 'openai', + modelId: 'gpt-4o-mini', + }); + }); + it('defaults anthropic provider to claude-haiku', () => { + expect(resolveModel(undefined, cfg({ provider: 'anthropic' }))).toEqual({ + provider: 'anthropic', + modelId: 'claude-haiku-4-5-20251001', + }); + }); + it('defaults ollama provider to llama3', () => { + expect(resolveModel(undefined, cfg({ provider: 'ollama' }))).toEqual({ + provider: 'ollama', + modelId: 'llama3', + }); + }); + it('defaults openai-compatible to openaiCompatibleModel setting when set', () => { + expect(resolveModel(undefined, cfg({ + provider: 'openai-compatible', + openaiCompatibleModel: 'my-custom-model', + }))).toEqual({ provider: 'openai-compatible', modelId: 'my-custom-model' }); + }); + it('defaults openai-compatible to gpt-4o when openaiCompatibleModel is empty', () => { + expect(resolveModel(undefined, cfg({ + provider: 'openai-compatible', + openaiCompatibleModel: '', + }))).toEqual({ provider: 'openai-compatible', modelId: 'gpt-4o' }); + }); + }); +}); + +// ─── resolveCompatibleChatUrl ───────────────────────────────────────── + +describe('resolveCompatibleChatUrl', () => { + it('appends /chat/completions when base URL already ends with /v1', () => { + expect(resolveCompatibleChatUrl('https://api.groq.com/openai/v1')) + .toBe('https://api.groq.com/openai/v1/chat/completions'); + }); + it('strips trailing slash then appends /chat/completions for /v1 base', () => { + expect(resolveCompatibleChatUrl('https://api.groq.com/openai/v1/')) + .toBe('https://api.groq.com/openai/v1/chat/completions'); + }); + it('inserts /v1 when base URL has no /v1 suffix', () => { + expect(resolveCompatibleChatUrl('https://api.together.xyz')) + .toBe('https://api.together.xyz/v1/chat/completions'); + }); + it('handles openrouter style URL correctly', () => { + expect(resolveCompatibleChatUrl('https://openrouter.ai/api/v1')) + .toBe('https://openrouter.ai/api/v1/chat/completions'); + }); + it('handles localhost with port and no /v1', () => { + expect(resolveCompatibleChatUrl('http://localhost:11434')) + .toBe('http://localhost:11434/v1/chat/completions'); + }); + it('does not double-insert /v1 when already present', () => { + const result = resolveCompatibleChatUrl('https://api.groq.com/openai/v1'); + expect(result).not.toContain('/v1/v1'); + }); +}); + +// ─── parseSSE ──────────────────────────────────────────────────────── + +describe('parseSSE', () => { + const openaiExtract = (data: string): string | null => { + if (data === '[DONE]') return null; + try { + const p = JSON.parse(data); + return p.choices?.[0]?.delta?.content || null; + } catch { return null; } + }; + + it('yields text from a single well-formed SSE chunk', async () => { + const payload = `data: ${JSON.stringify({ choices: [{ delta: { content: 'hello' } }] })}\n\n`; + const stream = makeStream([payload]) as any; + expect(await collect(parseSSE(stream, openaiExtract))).toEqual(['hello']); + }); + + it('yields nothing for [DONE] sentinel', async () => { + const stream = makeStream(['data: [DONE]\n\n']) as any; + expect(await collect(parseSSE(stream, openaiExtract))).toEqual([]); + }); + + it('handles multiple data lines in one chunk', async () => { + const line = (text: string) => + `data: ${JSON.stringify({ choices: [{ delta: { content: text } }] })}`; + const payload = [line('foo'), line('bar'), 'data: [DONE]', ''].join('\n'); + const stream = makeStream([payload]) as any; + expect(await collect(parseSSE(stream, openaiExtract))).toEqual(['foo', 'bar']); + }); + + it('reassembles a line split across two raw chunks', async () => { + const full = JSON.stringify({ choices: [{ delta: { content: 'split' } }] }); + // Split the SSE line mid-payload between two network chunks + const half = Math.floor(full.length / 2); + const chunk1 = `data: ${full.slice(0, half)}`; + const chunk2 = `${full.slice(half)}\n\n`; + const stream = makeStream([chunk1, chunk2]) as any; + expect(await collect(parseSSE(stream, openaiExtract))).toEqual(['split']); + }); + + it('skips empty lines and non-data lines', async () => { + const payload = '\n: comment\nevent: ping\ndata: [DONE]\n\n'; + const stream = makeStream([payload]) as any; + expect(await collect(parseSSE(stream, openaiExtract))).toEqual([]); + }); + + it('skips malformed JSON without throwing', async () => { + const stream = makeStream(['data: {not json}\n\n']) as any; + expect(await collect(parseSSE(stream, openaiExtract))).toEqual([]); + }); + + it('processes remaining buffer after stream ends', async () => { + // No trailing newline — the last line stays in the buffer until stream ends + const payload = `data: ${JSON.stringify({ choices: [{ delta: { content: 'tail' } }] })}`; + const stream = makeStream([payload]) as any; + expect(await collect(parseSSE(stream, openaiExtract))).toEqual(['tail']); + }); +}); + +// ─── parseNDJSON ────────────────────────────────────────────────────── + +describe('parseNDJSON', () => { + const ollamaExtract = (obj: any): string | null => obj.response || null; + + it('yields text from a single NDJSON line', async () => { + const stream = makeStream([JSON.stringify({ response: 'hi' }) + '\n']) as any; + expect(await collect(parseNDJSON(stream, ollamaExtract))).toEqual(['hi']); + }); + + it('yields text from multiple NDJSON lines in one chunk', async () => { + const payload = [ + JSON.stringify({ response: 'a' }), + JSON.stringify({ response: 'b' }), + JSON.stringify({ done: true }), + ].join('\n') + '\n'; + const stream = makeStream([payload]) as any; + expect(await collect(parseNDJSON(stream, ollamaExtract))).toEqual(['a', 'b']); + }); + + it('skips malformed JSON lines without throwing', async () => { + const payload = '{bad json}\n' + JSON.stringify({ response: 'ok' }) + '\n'; + const stream = makeStream([payload]) as any; + expect(await collect(parseNDJSON(stream, ollamaExtract))).toEqual(['ok']); + }); + + it('processes remaining buffer when stream ends without trailing newline', async () => { + const stream = makeStream([JSON.stringify({ response: 'last' })]) as any; + expect(await collect(parseNDJSON(stream, ollamaExtract))).toEqual(['last']); + }); + + it('reassembles a line split across two chunks', async () => { + const full = JSON.stringify({ response: 'reassembled' }); + const half = Math.floor(full.length / 2); + const stream = makeStream([full.slice(0, half), full.slice(half) + '\n']) as any; + expect(await collect(parseNDJSON(stream, ollamaExtract))).toEqual(['reassembled']); + }); +}); + +// ─── resolveUploadMeta ──────────────────────────────────────────────── + +describe('resolveUploadMeta', () => { + it('maps audio/wav to audio.wav', () => { + expect(resolveUploadMeta('audio/wav')).toEqual({ filename: 'audio.wav', contentType: 'audio/wav' }); + }); + it('maps audio/mpeg to audio.mp3', () => { + expect(resolveUploadMeta('audio/mpeg')).toEqual({ filename: 'audio.mp3', contentType: 'audio/mpeg' }); + }); + it('maps audio/mp3 to audio.mp3', () => { + expect(resolveUploadMeta('audio/mp3')).toEqual({ filename: 'audio.mp3', contentType: 'audio/mpeg' }); + }); + it('maps audio/mp4 to audio.m4a', () => { + expect(resolveUploadMeta('audio/mp4')).toEqual({ filename: 'audio.m4a', contentType: 'audio/mp4' }); + }); + it('maps audio/ogg to audio.ogg', () => { + expect(resolveUploadMeta('audio/ogg')).toEqual({ filename: 'audio.ogg', contentType: 'audio/ogg' }); + }); + it('maps audio/flac to audio.flac', () => { + expect(resolveUploadMeta('audio/flac')).toEqual({ filename: 'audio.flac', contentType: 'audio/flac' }); + }); + it('defaults to audio.webm for unknown type', () => { + expect(resolveUploadMeta('audio/unknown')).toEqual({ filename: 'audio.webm', contentType: 'audio/webm' }); + }); + it('defaults to audio.webm when mimeType is undefined', () => { + expect(resolveUploadMeta(undefined)).toEqual({ filename: 'audio.webm', contentType: 'audio/webm' }); + }); + it('defaults to audio.webm when mimeType is empty string', () => { + expect(resolveUploadMeta('')).toEqual({ filename: 'audio.webm', contentType: 'audio/webm' }); + }); +}); diff --git a/src/main/ai-provider.ts b/src/main/ai-provider.ts index 87b5479..330f38e 100644 --- a/src/main/ai-provider.ts +++ b/src/main/ai-provider.ts @@ -42,7 +42,7 @@ const MODEL_ROUTES: Record = { 'ollama-codellama': { provider: 'ollama', modelId: 'codellama' }, }; -function resolveModel(model: string | undefined, config: AISettings): ModelRoute { +export function resolveModel(model: string | undefined, config: AISettings): ModelRoute { if (model && MODEL_ROUTES[model]) { return MODEL_ROUTES[model]; } @@ -174,6 +174,14 @@ async function* streamOpenAI( // ─── OpenAI-Compatible (Generic) ────────────────────────────────────── +/** Resolves a base URL (with or without /v1) to the full chat completions path. */ +export function resolveCompatibleChatUrl(baseUrl: string): string { + const normalized = baseUrl.replace(/\/$/, ''); + return normalized.endsWith('/v1') + ? `${normalized}/chat/completions` + : `${normalized}/v1/chat/completions`; +} + async function* streamOpenAICompatible( baseUrl: string, apiKey: string, @@ -194,11 +202,7 @@ async function* streamOpenAICompatible( stream: true, }); - // Ensure baseUrl ends with /v1 and append /chat/completions - const normalizedBaseUrl = baseUrl.replace(/\/$/, ''); - const chatUrl = normalizedBaseUrl.endsWith('/v1') - ? `${normalizedBaseUrl}/chat/completions` - : `${normalizedBaseUrl}/v1/chat/completions`; + const chatUrl = resolveCompatibleChatUrl(baseUrl); const url = new URL(chatUrl); const useHttps = url.protocol === 'https:'; @@ -368,7 +372,7 @@ function httpRequest(opts: HttpRequestOptions): Promise { }); } -async function* parseSSE( +export async function* parseSSE( response: http.IncomingMessage, extractChunk: (data: string) => string | null ): AsyncGenerator { @@ -400,7 +404,7 @@ async function* parseSSE( } } -async function* parseNDJSON( +export async function* parseNDJSON( response: http.IncomingMessage, extractChunk: (obj: any) => string | null ): AsyncGenerator { @@ -445,7 +449,7 @@ export interface TranscribeOptions { signal?: AbortSignal; } -function resolveUploadMeta(mimeType?: string): { filename: string; contentType: string } { +export function resolveUploadMeta(mimeType?: string): { filename: string; contentType: string } { const normalized = String(mimeType || '').toLowerCase(); if (normalized.includes('wav')) return { filename: 'audio.wav', contentType: 'audio/wav' }; if (normalized.includes('mpeg') || normalized.includes('mp3')) return { filename: 'audio.mp3', contentType: 'audio/mpeg' }; diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..7eeb3f8 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + include: ['src/**/*.test.ts'], + }, +});