diff --git a/package-lock.json b/package-lock.json index f50ca4b..40dfeaa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ }, "devDependencies": { "@types/node": "^25.5.0", + "@vitest/coverage-v8": "^3.2.4", "typescript": "^5.7.0", "vitest": "^3.0.0" }, @@ -32,6 +33,80 @@ "pilotprotocol-linux-x64": "0.1.1" } }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", @@ -474,6 +549,55 @@ "node": ">=18" } }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -481,6 +605,28 @@ "dev": true, "license": "MIT" }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", @@ -867,6 +1013,40 @@ "undici-types": "~7.18.0" } }, + "node_modules/@vitest/coverage-v8": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz", + "integrity": "sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.3.0", + "@bcoe/v8-coverage": "^1.0.2", + "ast-v8-to-istanbul": "^0.3.3", + "debug": "^4.4.1", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.1.7", + "magic-string": "^0.30.17", + "magicast": "^0.3.5", + "std-env": "^3.9.0", + "test-exclude": "^7.0.1", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "3.2.4", + "vitest": "3.2.4" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", @@ -982,6 +1162,32 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -992,6 +1198,48 @@ "node": ">=12" } }, + "node_modules/ast-v8-to-istanbul": { + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.12.tgz", + "integrity": "sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -1029,6 +1277,41 @@ "node": ">= 16" } }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1057,6 +1340,20 @@ "node": ">=6" } }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", @@ -1144,6 +1441,23 @@ } } }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1159,6 +1473,165 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, "node_modules/js-tokens": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", @@ -1183,6 +1656,13 @@ "dev": true, "license": "MIT" }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -1193,6 +1673,60 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", + "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.25.4", + "@babel/types": "^7.25.4", + "source-map-js": "^1.2.0" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1219,6 +1753,40 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -1343,6 +1911,42 @@ "fsevents": "~2.3.2" } }, + "node_modules/semver": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -1350,6 +1954,19 @@ "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", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1374,6 +1991,110 @@ "dev": true, "license": "MIT" }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/strip-literal": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", @@ -1387,6 +2108,34 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/test-exclude": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz", + "integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^10.4.1", + "minimatch": "^10.2.2" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -1574,6 +2323,7 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -1641,6 +2391,22 @@ } } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "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", @@ -1657,6 +2423,104 @@ "engines": { "node": ">=8" } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } } } } diff --git a/package.json b/package.json index 98052e9..ff40fba 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "scripts": { "build": "tsc", "test": "vitest run", + "test:coverage": "vitest run --coverage", "clean": "rm -rf dist/", "prepublishOnly": "npm run build" }, @@ -59,13 +60,14 @@ "koffi": "^2.9.0" }, "optionalDependencies": { - "pilotprotocol-linux-x64": "0.1.1", - "pilotprotocol-linux-arm64": "0.1.1", + "pilotprotocol-darwin-arm64": "0.1.1", "pilotprotocol-darwin-x64": "0.1.1", - "pilotprotocol-darwin-arm64": "0.1.1" + "pilotprotocol-linux-arm64": "0.1.1", + "pilotprotocol-linux-x64": "0.1.1" }, "devDependencies": { "@types/node": "^25.5.0", + "@vitest/coverage-v8": "^3.2.4", "typescript": "^5.7.0", "vitest": "^3.0.0" }, diff --git a/tests/cli.test.ts b/tests/cli.test.ts new file mode 100644 index 0000000..c80da5f --- /dev/null +++ b/tests/cli.test.ts @@ -0,0 +1,146 @@ +/** + * Tests for src/cli.ts — the four shim entry points wired up to + * `bin-stubs/*.js`. Each shim: + * 1. Seeds ~/.pilot/bin/ (via runtime). + * 2. Resolves the seeded binary path. + * 3. spawnSync's it with stdio: 'inherit' and process.exit's with the + * child's status. + * + * We mock node:child_process at module level (vi.mock hoists), substitute a + * controllable spawnSync, and replace process.exit with a thrower so the + * runner doesn't actually exit. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { platform as osPlatform, arch as osArch } from 'node:os'; +import { join } from 'node:path'; +import type { SpawnSyncReturns } from 'node:child_process'; + +// vi.mock is hoisted above all imports; the mocked module is what the cli +// shim sees when it does `import { spawnSync } from 'node:child_process'`. +// vi.hoisted is the canonical way to share a variable with a hoisted mock. +const { spawnSyncMock } = vi.hoisted(() => ({ spawnSyncMock: vi.fn() })); +vi.mock('node:child_process', () => ({ + spawnSync: spawnSyncMock, +})); + +import * as cli from '../src/cli.js'; +import * as runtime from '../src/runtime.js'; + +const PLAT = osPlatform(); +const ARCH = osArch() === 'x64' ? 'amd64' : osArch(); +const PLAT_DIR = `${PLAT}-${ARCH}`; +const LIB_NAME = PLAT === 'darwin' ? 'libpilot.dylib' + : PLAT === 'linux' ? 'libpilot.so' + : 'libpilot.dll'; + +let tmpRoot: string; +let fakeHome: string; +let pkgRoot: string; +let pkgBin: string; +const savedEnv = { + home: process.env['PILOT_HOME'], + pkgRoot: process.env['PILOT_PKG_BIN_ROOT'], + pkgBin: process.env['PILOT_PKG_BIN_DIR'], +}; +let exitSpy: ReturnType; + +beforeEach(() => { + tmpRoot = mkdtempSync('/tmp/pilot-cli-'); + fakeHome = join(tmpRoot, '.pilot'); + pkgRoot = join(tmpRoot, 'pkg'); + pkgBin = join(pkgRoot, PLAT_DIR); + mkdirSync(fakeHome, { recursive: true }); + mkdirSync(pkgBin, { recursive: true }); + for (const n of ['pilotctl', 'pilot-daemon', 'pilot-gateway', 'pilot-updater']) { + writeFileSync(join(pkgBin, n), '#!/bin/sh\necho ' + n + '\n'); + chmodSync(join(pkgBin, n), 0o755); + } + writeFileSync(join(pkgBin, LIB_NAME), 'LIB'); + writeFileSync(join(pkgRoot, '.pilot-version'), '1.9.1\n'); + process.env['PILOT_HOME'] = fakeHome; + process.env['PILOT_PKG_BIN_ROOT'] = pkgRoot; + process.env['PILOT_PKG_BIN_DIR'] = pkgBin; + runtime._resetSeededMarker(); + spawnSyncMock.mockReset(); + + // Intercept process.exit so the runner doesn't die. + exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new Error(`__exit__:${code ?? 0}`); + }) as never); +}); + +afterEach(() => { + exitSpy.mockRestore(); + if (savedEnv.home === undefined) delete process.env['PILOT_HOME']; + else process.env['PILOT_HOME'] = savedEnv.home; + if (savedEnv.pkgRoot === undefined) delete process.env['PILOT_PKG_BIN_ROOT']; + else process.env['PILOT_PKG_BIN_ROOT'] = savedEnv.pkgRoot; + if (savedEnv.pkgBin === undefined) delete process.env['PILOT_PKG_BIN_DIR']; + else process.env['PILOT_PKG_BIN_DIR'] = savedEnv.pkgBin; + rmSync(tmpRoot, { recursive: true, force: true }); + runtime._resetSeededMarker(); +}); + +function fakeOk(): SpawnSyncReturns { + return { + status: 0, + error: undefined as unknown as Error, + pid: 0, + output: [], + stdout: Buffer.alloc(0), + stderr: Buffer.alloc(0), + signal: null, + }; +} + +function fakeNonzero(code: number): SpawnSyncReturns { + return { ...fakeOk(), status: code }; +} + +function fakeErr(): SpawnSyncReturns { + return { ...fakeOk(), status: null, error: new Error('ENOENT') }; +} + +describe('cli shims', () => { + for (const [fn, name] of [ + [cli.runPilotctl, 'pilotctl'], + [cli.runDaemon, 'pilot-daemon'], + [cli.runGateway, 'pilot-gateway'], + [cli.runUpdater, 'pilot-updater'], + ] as const) { + it(`runs ${name} via the seeded binary and exits with status 0`, () => { + spawnSyncMock.mockImplementation((binary: string, _args: string[], opts: { stdio: unknown }) => { + expect(binary.endsWith(name)).toBe(true); + expect(opts.stdio).toBe('inherit'); + return fakeOk(); + }); + expect(() => fn()).toThrow(/__exit__:0/); + expect(spawnSyncMock).toHaveBeenCalled(); + }); + + it(`propagates a non-zero status from ${name}`, () => { + spawnSyncMock.mockImplementation(() => fakeNonzero(2)); + expect(() => fn()).toThrow(/__exit__:2/); + }); + + it(`defaults to exit code 1 when status is null and no error`, () => { + spawnSyncMock.mockImplementation(() => ({ ...fakeOk(), status: null })); + expect(() => fn()).toThrow(/__exit__:1/); + }); + + it(`exits 1 when spawnSync fails to launch ${name}`, () => { + spawnSyncMock.mockImplementation(() => fakeErr()); + const writeSpy = vi.spyOn(process.stderr, 'write').mockReturnValue(true); + try { + expect(() => fn()).toThrow(/__exit__:1/); + const msg = String(writeSpy.mock.calls[0]?.[0] ?? ''); + expect(msg).toContain(name); + expect(msg).toContain('failed to launch'); + } finally { + writeSpy.mockRestore(); + } + }); + } +}); diff --git a/tests/client_extra.test.ts b/tests/client_extra.test.ts new file mode 100644 index 0000000..f5961d1 --- /dev/null +++ b/tests/client_extra.test.ts @@ -0,0 +1,526 @@ +/** + * Extra coverage for src/client.ts — focuses on the high-level service + * helpers that the original suite skipped: + * + * - sendMessage (text / json / binary, ack and no-ack paths, dial-error, + * hostname-resolution success and failure) + * - sendFile (happy path + read-back ACK, no-ack, hostname-resolution) + * - publishEvent (subscribe-then-publish frame layout) + * - subscribeEvent (callback dispatch, EOF, deadline, error pass-through) + * + * The FFI library is replaced with an in-memory pipe so the wire-frame + * layout is exercised end-to-end without a daemon. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import type { PilotLib } from '../src/ffi.js'; +import { Driver, PilotError, _setLib, _getLib } from '../src/client.js'; + +// --------------------------------------------------------------------------- +// In-memory FFI fake: every Conn is one FIFO of pending reads. +// --------------------------------------------------------------------------- + +interface FakeConn { + // Bytes the SDK has written; consumed in chunks by enqueueRead helpers. + written: Buffer; + // Bytes the daemon will hand back on subsequent PilotConnRead calls. + pending: Buffer[]; + closed: boolean; + // Optional throw on next read (string error from Go). + readErr?: string; + // If true, return zero-length read (treated as EOF by readEventFrame). + eof?: boolean; +} + +interface FakeState { + // counter for new conn handles + nextHandle: bigint; + conns: Map; + dialErr: string | null; + resolveResult: Record | { error: string }; +} + +function buildFakeLib(state: FakeState): PilotLib { + function makeConn(): bigint { + const h = state.nextHandle++; + state.conns.set(h, { written: Buffer.alloc(0), pending: [], closed: false }); + return h; + } + + return { + PilotConnect: () => ({ handle: 1n, err: null }), + PilotClose: () => null, + PilotInfo: () => null, + PilotHealth: () => null, + PilotRotateKey: () => null, + PilotHandshake: () => null, + PilotApproveHandshake: () => null, + PilotRejectHandshake: () => null, + PilotPendingHandshakes: () => null, + PilotTrustedPeers: () => null, + PilotRevokeTrust: () => null, + PilotResolveHostname: () => JSON.stringify(state.resolveResult), + PilotSetHostname: () => null, + PilotSetVisibility: () => null, + PilotSetTaskExec: () => null, + PilotDeregister: () => null, + PilotSetTags: () => null, + PilotSetWebhook: () => null, + PilotDisconnect: () => null, + PilotRecvFrom: () => null, + PilotNetworkList: () => null, + PilotNetworkJoin: () => null, + PilotNetworkLeave: () => null, + PilotNetworkMembers: () => null, + PilotNetworkInvite: () => null, + PilotNetworkPollInvites: () => null, + PilotNetworkRespondInvite: () => null, + PilotManagedScore: () => null, + PilotManagedStatus: () => null, + PilotManagedRankings: () => null, + PilotManagedForceCycle: () => null, + PilotManagedReconcile: () => null, + PilotPolicyGet: () => null, + PilotPolicySet: () => null, + PilotMemberTagsGet: () => null, + PilotMemberTagsSet: () => null, + + PilotDial: () => { + if (state.dialErr) return { handle: 0n, err: JSON.stringify({ error: state.dialErr }) }; + return { handle: makeConn(), err: null }; + }, + PilotDialTimeout: () => ({ handle: makeConn(), err: null }), + PilotListen: () => ({ handle: makeConn(), err: null }), + PilotListenerAccept: () => ({ handle: makeConn(), err: null }), + PilotListenerClose: () => null, + + PilotConnRead(h, _bufSize) { + const c = state.conns.get(h); + if (!c) return { n: 0, data: null, err: JSON.stringify({ error: 'no conn' }) }; + if (c.readErr) return { n: 0, data: null, err: JSON.stringify({ error: c.readErr }) }; + if (c.eof) return { n: 0, data: null, err: null }; + const chunk = c.pending.shift(); + if (!chunk) return { n: 0, data: null, err: null }; + return { n: chunk.length, data: chunk, err: null }; + }, + PilotConnWrite(h, data, dataLen) { + const c = state.conns.get(h); + if (!c) return { n: 0, err: JSON.stringify({ error: 'no conn' }) }; + c.written = Buffer.concat([c.written, data.subarray(0, dataLen)]); + return { n: dataLen, err: null }; + }, + PilotConnClose(h) { + const c = state.conns.get(h); + if (c) c.closed = true; + return null; + }, + PilotConnSetReadDeadline: () => null, + PilotSendTo: () => null, + PilotBroadcast: () => null, + }; +} + +let state: FakeState; + +beforeEach(() => { + state = { + nextHandle: 100n, + conns: new Map(), + dialErr: null, + resolveResult: { address: '0:1234.5678.9abc' }, + }; + _setLib(buildFakeLib(state)); +}); + +afterEach(() => { + vi.restoreAllMocks(); + _setLib(null); +}); + +// Find the most-recently created conn (helper for assertions). +function lastConn(): FakeConn { + let last: FakeConn | undefined; + for (const c of state.conns.values()) last = c; + if (!last) throw new Error('no conn created'); + return last; +} + +// Frame helpers mirror src/client.ts internals. +function dataExchangeFrame(type: number, payload: Buffer): Buffer { + const h = Buffer.alloc(8); + h.writeUInt32BE(type, 0); + h.writeUInt32BE(payload.length, 4); + return Buffer.concat([h, payload]); +} + +function ackFrame(msg: string): Buffer[] { + const payload = Buffer.from(msg); + const header = Buffer.alloc(8); + header.writeUInt32BE(0, 0); // type irrelevant + header.writeUInt32BE(payload.length, 4); + return [header, payload]; +} + +function eventFrame(topic: string, payload: Buffer): { topicLen: Buffer; topic: Buffer; payloadLen: Buffer; payload: Buffer } { + const topicBytes = Buffer.from(topic, 'utf8'); + const topicLen = Buffer.alloc(2); + topicLen.writeUInt16BE(topicBytes.length, 0); + const payloadLen = Buffer.alloc(4); + payloadLen.writeUInt32BE(payload.length, 0); + return { topicLen, topic: topicBytes, payloadLen, payload }; +} + +// --------------------------------------------------------------------------- +// sendMessage +// --------------------------------------------------------------------------- + +describe('Driver.sendMessage', () => { + it('passes through a literal protocol address (no resolve)', () => { + const d = new Driver(); + // Pre-queue an ACK so the read path is exercised. + state.resolveResult = { error: 'should NOT be called' }; + const target = '0:0000.0001.0002'; + // Seed conn before the call doesn't work — dial creates it. So + // we instead intercept by handing back EOF, exercising the no-ack + // branch (msg sent, ACK read fails gracefully). + const res = d.sendMessage(target, 'hello', 'text'); + expect(res['target']).toBe(target); + expect(res['sent']).toBe(5); + expect(res['type']).toBe('text'); + // Frame layout: [type=1][len=5]["hello"] + const c = lastConn(); + expect(c.written.equals(dataExchangeFrame(1, Buffer.from('hello')))).toBe(true); + expect(c.closed).toBe(true); + d.close(); + }); + + it('resolves a hostname before dialing', () => { + const d = new Driver(); + state.resolveResult = { address: '0:dead.beef.cafe' }; + const res = d.sendMessage('agent-a.pilot', 'hi', 'json'); + expect(res['target']).toBe('0:dead.beef.cafe'); + expect(res['type']).toBe('json'); + d.close(); + }); + + it('reads and returns the ACK when the daemon replies', () => { + const d = new Driver(); + // Hook into dial so we can pre-stage ACK bytes on the new conn. + const origDial = (d as unknown as { dial: Driver['dial'] }).dial.bind(d); + (d as unknown as { dial: typeof origDial }).dial = ((addr: string, t?: number) => { + const conn = origDial(addr, t); + const c = lastConn(); + const [h, p] = ackFrame('OK'); + c.pending.push(h); + c.pending.push(p); + return conn; + }); + const res = d.sendMessage('0:0000.0001.0002', 'x', 'binary'); + expect(res['ack']).toBe('OK'); + d.close(); + }); + + it('throws when hostname cannot be resolved', () => { + const d = new Driver(); + state.resolveResult = {}; // no address field + expect(() => d.sendMessage('unknown.host', 'x')).toThrow(PilotError); + expect(() => d.sendMessage('unknown.host', 'x')).toThrow(/Could not resolve/); + d.close(); + }); + + it('maps unknown msgType to frame type 1 (text fallback)', () => { + const d = new Driver(); + // cast to bypass the union — we want to test the runtime ?? 1 fallback. + const res = (d as unknown as { + sendMessage: (a: string, b: string, c: string) => Record; + }).sendMessage('0:0000.0001.0002', 'hi', 'mystery'); + expect(res['sent']).toBe(2); + const c = lastConn(); + expect(c.written.readUInt32BE(0)).toBe(1); + d.close(); + }); + + it('treats a Buffer payload the same as a string', () => { + const d = new Driver(); + const buf = Buffer.from([0xde, 0xad, 0xbe, 0xef]); + const res = d.sendMessage('0:0000.0001.0002', buf, 'binary'); + expect(res['sent']).toBe(4); + const c = lastConn(); + // payload = bytes after 8-byte header + expect(c.written.subarray(8).equals(buf)).toBe(true); + d.close(); + }); +}); + +// --------------------------------------------------------------------------- +// sendFile +// --------------------------------------------------------------------------- + +describe('Driver.sendFile', () => { + let workdir: string; + + beforeEach(() => { + workdir = mkdtempSync(join(tmpdir(), 'pilot-sf-')); + }); + + afterEach(() => { + rmSync(workdir, { recursive: true, force: true }); + }); + + it('sends a file with TypeFile frame layout', () => { + const d = new Driver(); + const fp = join(workdir, 'note.txt'); + writeFileSync(fp, 'data!'); + const res = d.sendFile('0:0000.0001.0002', fp); + expect(res['filename']).toBe('note.txt'); + expect(res['sent']).toBe(5); + + const c = lastConn(); + // header: [type=4][totalPayloadLen] + expect(c.written.readUInt32BE(0)).toBe(4); + const totalLen = c.written.readUInt32BE(4); + expect(c.written.length).toBe(8 + totalLen); + // payload: [2-byte name len][name][data] + const nameLen = c.written.readUInt16BE(8); + expect(nameLen).toBe('note.txt'.length); + const name = c.written.subarray(10, 10 + nameLen).toString('utf8'); + expect(name).toBe('note.txt'); + const body = c.written.subarray(10 + nameLen); + expect(body.toString('utf8')).toBe('data!'); + expect(c.closed).toBe(true); + d.close(); + }); + + it('reads the ACK frame and returns it', () => { + const d = new Driver(); + const fp = join(workdir, 'x.bin'); + writeFileSync(fp, 'XYZ'); + const origDial = (d as unknown as { dial: Driver['dial'] }).dial.bind(d); + (d as unknown as { dial: typeof origDial }).dial = ((addr: string, t?: number) => { + const conn = origDial(addr, t); + const c = lastConn(); + const [h, p] = ackFrame('STORED'); + c.pending.push(h); + c.pending.push(p); + return conn; + }); + const res = d.sendFile('0:0000.0001.0002', fp); + expect(res['ack']).toBe('STORED'); + d.close(); + }); + + it('resolves a hostname before dialing', () => { + const d = new Driver(); + state.resolveResult = { address: '0:dead.beef.cafe' }; + const fp = join(workdir, 'y.txt'); + writeFileSync(fp, 'q'); + const res = d.sendFile('hostA', fp); + expect(res['target']).toBe('0:dead.beef.cafe'); + d.close(); + }); +}); + +// --------------------------------------------------------------------------- +// publishEvent +// --------------------------------------------------------------------------- + +describe('Driver.publishEvent', () => { + it('writes a subscribe frame followed by a publish frame', () => { + const d = new Driver(); + const res = d.publishEvent('0:0000.0001.0002', 'sensor/temp', 'hot'); + expect(res['status']).toBe('published'); + expect(res['topic']).toBe('sensor/temp'); + expect(res['bytes']).toBe(3); + + const c = lastConn(); + const ef = eventFrame('sensor/temp', Buffer.from('hot')); + // First frame = subscribe (empty payload). + const subFrame = Buffer.concat([ef.topicLen, ef.topic, Buffer.from([0, 0, 0, 0])]); + // Second frame = publish. + const pubFrame = Buffer.concat([ef.topicLen, ef.topic, ef.payloadLen, ef.payload]); + expect(c.written.equals(Buffer.concat([subFrame, pubFrame]))).toBe(true); + expect(c.closed).toBe(true); + d.close(); + }); + + it('accepts a Buffer payload', () => { + const d = new Driver(); + d.publishEvent('0:0000.0001.0002', 't', Buffer.from([1, 2, 3])); + const c = lastConn(); + // last 3 bytes are the payload + expect(c.written.subarray(c.written.length - 3)).toEqual(Buffer.from([1, 2, 3])); + d.close(); + }); +}); + +// --------------------------------------------------------------------------- +// subscribeEvent +// --------------------------------------------------------------------------- + +describe('Driver.subscribeEvent', () => { + it('parses one event then exits on EOF', () => { + const d = new Driver(); + const origDial = (d as unknown as { dial: Driver['dial'] }).dial.bind(d); + (d as unknown as { dial: typeof origDial }).dial = ((addr: string, t?: number) => { + const conn = origDial(addr, t); + const c = lastConn(); + const ef = eventFrame('sensor/x', Buffer.from('hello')); + c.pending.push(ef.topicLen, ef.topic, ef.payloadLen, ef.payload); + // After this single event, the conn yields zero-length reads → null → break. + return conn; + }); + const events: Array<[string, string]> = []; + d.subscribeEvent('0:0000.0001.0002', 'sensor/*', (t, p) => { + events.push([t, p.toString('utf8')]); + }, 1); + expect(events).toEqual([['sensor/x', 'hello']]); + d.close(); + }); + + it('breaks the loop when the read throws "connection closed"', () => { + const d = new Driver(); + const origDial = (d as unknown as { dial: Driver['dial'] }).dial.bind(d); + (d as unknown as { dial: typeof origDial }).dial = ((addr: string, t?: number) => { + const conn = origDial(addr, t); + lastConn().readErr = 'connection closed'; + return conn; + }); + expect(() => d.subscribeEvent('0:0000.0001.0002', '*', () => {}, 1)).not.toThrow(); + d.close(); + }); + + it('rethrows unexpected read errors', () => { + const d = new Driver(); + const origDial = (d as unknown as { dial: Driver['dial'] }).dial.bind(d); + (d as unknown as { dial: typeof origDial }).dial = ((addr: string, t?: number) => { + const conn = origDial(addr, t); + lastConn().readErr = 'something else broke'; + return conn; + }); + expect(() => d.subscribeEvent('0:0000.0001.0002', '*', () => {}, 1)) + .toThrow(/something else broke/); + d.close(); + }); + + it('exits cleanly when the deadline passes without any data', () => { + const d = new Driver(); + // Force conn to behave as a closed stream so the helper returns null + // immediately; the while-loop should then break on the next iteration. + const origDial = (d as unknown as { dial: Driver['dial'] }).dial.bind(d); + (d as unknown as { dial: typeof origDial }).dial = ((addr: string, t?: number) => { + const conn = origDial(addr, t); + lastConn().eof = true; + return conn; + }); + // timeout=0 → loop body runs once at most. + let calls = 0; + d.subscribeEvent('0:0000.0001.0002', '*', () => { calls += 1; }, 0); + expect(calls).toBe(0); + d.close(); + }); +}); + +// --------------------------------------------------------------------------- +// _resolveTarget edge cases (covered indirectly above; one more for explicit +// pass-through of the '0:' literal address branch). +// --------------------------------------------------------------------------- + +describe('Driver hostname resolution', () => { + it('does not call resolveHostname when target already starts with "0:"', () => { + const d = new Driver(); + // Sentinel: if resolveHostname WAS called, the SDK would treat the empty + // address as a failure and throw. The fact that sendMessage succeeds + // proves resolve was skipped. + state.resolveResult = { error: 'must not be called' }; + expect(() => d.sendMessage('0:0000.0001.0002', 'hi')).not.toThrow(); + d.close(); + }); +}); + +// --------------------------------------------------------------------------- +// Library-singleton accessors used by the test harness +// --------------------------------------------------------------------------- + +describe('library singleton helpers', () => { + it('_setLib(null) clears the cached library', () => { + // beforeEach has already installed a fake; clearing it should expose + // the null state via _getLib. + expect(_getLib()).not.toBeNull(); + _setLib(null); + expect(_getLib()).toBeNull(); + }); + + it('getLib lazy-loads via loadLibrary when cache is empty (error path)', async () => { + _setLib(null); + // Without PILOT_LIB_PATH or a real libpilot, the first FFI call should + // surface loadLibrary's "Cannot find" error. + const saved = process.env['PILOT_LIB_PATH']; + delete process.env['PILOT_LIB_PATH']; + // Also make findLibrary fall through (PILOT_HOME points at a temp dir + // with no bundled lib). + const { mkdtempSync, rmSync } = await import('node:fs'); + const { join } = await import('node:path'); + const tmp = mkdtempSync('/tmp/pilot-noseed-'); + const prevHome = process.env['PILOT_HOME']; + const prevPkg = process.env['PILOT_PKG_BIN_DIR']; + const prevPkgR = process.env['PILOT_PKG_BIN_ROOT']; + process.env['PILOT_HOME'] = join(tmp, 'home'); + process.env['PILOT_PKG_BIN_DIR'] = join(tmp, 'pkg-bin-missing'); + process.env['PILOT_PKG_BIN_ROOT'] = join(tmp, 'pkg-root-missing'); + const rt = await import('../src/runtime.js'); + rt._resetSeededMarker(); + try { + expect(() => new Driver()).toThrow(); + } finally { + rmSync(tmp, { recursive: true, force: true }); + if (saved === undefined) delete process.env['PILOT_LIB_PATH']; + else process.env['PILOT_LIB_PATH'] = saved; + if (prevHome === undefined) delete process.env['PILOT_HOME']; + else process.env['PILOT_HOME'] = prevHome; + if (prevPkg === undefined) delete process.env['PILOT_PKG_BIN_DIR']; + else process.env['PILOT_PKG_BIN_DIR'] = prevPkg; + if (prevPkgR === undefined) delete process.env['PILOT_PKG_BIN_ROOT']; + else process.env['PILOT_PKG_BIN_ROOT'] = prevPkgR; + } + }); +}); + +// --------------------------------------------------------------------------- +// ACK-read no-op branches in sendMessage / sendFile +// --------------------------------------------------------------------------- + +describe('sendMessage / sendFile ack-read fallback', () => { + it('returns the no-ack result when the daemon sends fewer than 8 header bytes', () => { + const d = new Driver(); + const origDial = (d as unknown as { dial: Driver['dial'] }).dial.bind(d); + (d as unknown as { dial: typeof origDial }).dial = ((addr: string, t?: number) => { + const conn = origDial(addr, t); + // Only 3 bytes — not enough for a header. + lastConn().pending.push(Buffer.from([1, 2, 3])); + return conn; + }); + const res = d.sendMessage('0:0000.0001.0002', 'hi', 'text'); + expect(res['ack']).toBeUndefined(); + expect(res['sent']).toBe(2); + d.close(); + }); + + it('returns the no-ack result when the ACK payload is empty', () => { + const d = new Driver(); + const origDial = (d as unknown as { dial: Driver['dial'] }).dial.bind(d); + (d as unknown as { dial: typeof origDial }).dial = ((addr: string, t?: number) => { + const conn = origDial(addr, t); + // 8-byte header with payload-len = 0 → second read returns empty. + const h = Buffer.alloc(8); + lastConn().pending.push(h); + return conn; + }); + const res = d.sendMessage('0:0000.0001.0002', 'hi', 'text'); + expect(res['ack']).toBeUndefined(); + d.close(); + }); +}); diff --git a/tests/ffi_extra.test.ts b/tests/ffi_extra.test.ts new file mode 100644 index 0000000..590369e --- /dev/null +++ b/tests/ffi_extra.test.ts @@ -0,0 +1,158 @@ +/** + * Extra coverage for src/ffi.ts — the parts the original suite didn't reach: + * + * - findLibrary's "unsupported platform" branch (rewritten to test the + * pure logic without actually mocking `os.platform`) + * - PILOT_LIB_PATH override that points at a real file + * - parseJSON / checkErr / unwrapHandleErr corner cases + * - The runtime-fallback path (findLibrary uses runtimeLibraryPath when + * no env var is set and the seeder succeeds) + * + * The koffi-using loadLibrary() body is not exercised here — that requires + * libpilot.{so|dylib} on disk and would be an integration test, not a unit + * test. Documented under "what's hard to test without a live daemon" in the + * PR description. + */ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + PilotError, + parseJSON, + checkErr, + unwrapHandleErr, + findLibrary, +} from '../src/ffi.js'; + +// --------------------------------------------------------------------------- +// Pure helpers +// --------------------------------------------------------------------------- + +describe('parseJSON edge cases', () => { + it('returns {} for empty string', () => { + expect(parseJSON('')).toEqual({}); + }); + + it('returns objects with arrays + nested values intact', () => { + const r = parseJSON(JSON.stringify({ a: [1, 2], b: { c: 'x' } })); + expect(r).toEqual({ a: [1, 2], b: { c: 'x' } }); + }); + + it('throws PilotError keyed on `error` field', () => { + expect(() => parseJSON(JSON.stringify({ error: 'oops' }))).toThrowError( + new PilotError('oops'), + ); + }); +}); + +describe('checkErr edge cases', () => { + it('does nothing for an empty string (falsy)', () => { + expect(() => checkErr('')).not.toThrow(); + }); + + it('does nothing when JSON has no `error` field', () => { + // Important: only `{"error": ...}` should throw — neutral JSON must pass. + expect(() => checkErr(JSON.stringify({ status: 'ok' }))).not.toThrow(); + }); + + it('throws on error JSON', () => { + expect(() => checkErr(JSON.stringify({ error: 'bad' }))).toThrowError( + new PilotError('bad'), + ); + }); +}); + +describe('unwrapHandleErr', () => { + it('returns the handle when err is null', () => { + expect(unwrapHandleErr({ handle: 42n, err: null })).toBe(42n); + }); + + it('throws PilotError with the parsed message', () => { + expect(() => + unwrapHandleErr({ handle: 0n, err: JSON.stringify({ error: 'no daemon' }) }), + ).toThrowError(new PilotError('no daemon')); + }); + + it('falls back to "unknown error" when err JSON has no message field', () => { + expect(() => + unwrapHandleErr({ handle: 0n, err: JSON.stringify({ status: 'weird' }) }), + ).toThrowError(new PilotError('unknown error')); + }); +}); + +// --------------------------------------------------------------------------- +// findLibrary +// --------------------------------------------------------------------------- + +describe('findLibrary env-override branch', () => { + let workdir: string; + const saved = { + libPath: process.env['PILOT_LIB_PATH'], + home: process.env['PILOT_HOME'], + pkgBin: process.env['PILOT_PKG_BIN_DIR'], + pkgRoot: process.env['PILOT_PKG_BIN_ROOT'], + }; + + beforeEach(() => { + workdir = mkdtempSync(join(tmpdir(), 'pilot-lib-')); + }); + + afterEach(() => { + rmSync(workdir, { recursive: true, force: true }); + for (const k of ['PILOT_LIB_PATH', 'PILOT_HOME', 'PILOT_PKG_BIN_DIR', 'PILOT_PKG_BIN_ROOT'] as const) { + const v = saved[k === 'PILOT_LIB_PATH' ? 'libPath' : + k === 'PILOT_HOME' ? 'home' : + k === 'PILOT_PKG_BIN_DIR' ? 'pkgBin' : 'pkgRoot']; + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + }); + + it('returns PILOT_LIB_PATH when the file exists', () => { + const fake = join(workdir, 'libpilot-fake.so'); + writeFileSync(fake, 'ELF stub'); + process.env['PILOT_LIB_PATH'] = fake; + expect(findLibrary()).toBe(fake); + }); + + it('throws a clear error when PILOT_LIB_PATH points at a missing file', () => { + const missing = join(workdir, 'does-not-exist.so'); + process.env['PILOT_LIB_PATH'] = missing; + expect(() => findLibrary()).toThrow(/does not exist/); + }); + + it('returns the runtimeLibraryPath when the seeder can find a bundled lib', async () => { + // Build a fake bundled package layout that the seeder will mirror. + delete process.env['PILOT_LIB_PATH']; + const fakeHome = join(workdir, 'pilot-home'); + const fakePkgRoot = join(workdir, 'pkg'); + const platDir = require('node:os').platform(); + const arch = require('node:os').arch() === 'x64' ? 'amd64' : require('node:os').arch(); + const sub = `${platDir}-${arch}`; + const fakePkgBin = join(fakePkgRoot, sub); + require('node:fs').mkdirSync(fakePkgBin, { recursive: true }); + require('node:fs').mkdirSync(fakeHome, { recursive: true }); + // Bundled bin: each of the four CLIs + the platform lib. + for (const n of ['pilotctl', 'pilot-daemon', 'pilot-gateway', 'pilot-updater']) { + writeFileSync(join(fakePkgBin, n), '#!/bin/sh\n'); + } + const libName = platDir === 'darwin' ? 'libpilot.dylib' + : platDir === 'linux' ? 'libpilot.so' : 'libpilot.dll'; + writeFileSync(join(fakePkgBin, libName), 'LIB'); + writeFileSync(join(fakePkgRoot, '.pilot-version'), '1.9.1\n'); + + process.env['PILOT_HOME'] = fakeHome; + process.env['PILOT_PKG_BIN_ROOT'] = fakePkgRoot; + process.env['PILOT_PKG_BIN_DIR'] = fakePkgBin; + + // Force the runtime to re-seed (it has a module-level cache). + const rt = await import('../src/runtime.js'); + rt._resetSeededMarker(); + + const found = findLibrary(); + expect(found.endsWith(libName)).toBe(true); + }); +}); diff --git a/tests/index.test.ts b/tests/index.test.ts new file mode 100644 index 0000000..c8df42d --- /dev/null +++ b/tests/index.test.ts @@ -0,0 +1,36 @@ +/** + * Smoke test for the package's public re-export surface (src/index.ts). + * + * These assertions exist so that renaming or removing a public export + * shows up as a CI failure rather than a silent breaking change for SDK + * consumers. + */ + +import { describe, expect, it } from 'vitest'; +import * as pub from '../src/index.js'; + +describe('public re-exports', () => { + it('Driver / Conn / Listener / PilotError are exported', () => { + expect(typeof pub.Driver).toBe('function'); + expect(typeof pub.Conn).toBe('function'); + expect(typeof pub.Listener).toBe('function'); + expect(typeof pub.PilotError).toBe('function'); + }); + + it('exposes DEFAULT_SOCKET_PATH', () => { + expect(pub.DEFAULT_SOCKET_PATH).toBe('/tmp/pilot.sock'); + }); + + it('exposes findLibrary and loadLibrary helpers', () => { + expect(typeof pub.findLibrary).toBe('function'); + expect(typeof pub.loadLibrary).toBe('function'); + }); + + it('PilotError instances satisfy instanceof', () => { + const e = new pub.PilotError('hi'); + expect(e).toBeInstanceOf(pub.PilotError); + expect(e).toBeInstanceOf(Error); + expect(e.name).toBe('PilotError'); + expect(e.message).toBe('hi'); + }); +}); diff --git a/tests/runtime_extra.test.ts b/tests/runtime_extra.test.ts new file mode 100644 index 0000000..c7e3ec0 --- /dev/null +++ b/tests/runtime_extra.test.ts @@ -0,0 +1,248 @@ +/** + * Extra coverage for src/runtime.ts — focuses on the branches the original + * suite didn't reach: + * + * - isDaemonLive() async probe: socket missing, socket exists+refused, + * real connect via a temporary UNIX domain server + * - probeDaemonLiveSync via the seeder (daemon-skip branch) + * - bundledVersion() falling back to package.json when .pilot-version absent + * - acquireLock/releaseLock paths (stale-lock reclamation) + * - ensureDirWritable error message on read-only target + * - the no-config branch of readSocketPath (no config.json present) + */ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + chmodSync, + closeSync, + existsSync, + mkdirSync, + mkdtempSync, + openSync, + readFileSync, + rmSync, + unlinkSync, + utimesSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir, platform as osPlatform, arch as osArch } from 'node:os'; +import { join } from 'node:path'; +import { createServer, Server } from 'node:net'; + +import * as runtime from '../src/runtime.js'; + +const PLAT = osPlatform(); +const ARCH = osArch() === 'x64' ? 'amd64' : osArch(); +const PLAT_DIR = `${PLAT}-${ARCH}`; +const LIB_NAME = PLAT === 'darwin' ? 'libpilot.dylib' + : PLAT === 'linux' ? 'libpilot.so' + : 'libpilot.dll'; +const BIN_NAMES = ['pilotctl', 'pilot-daemon', 'pilot-gateway', 'pilot-updater'] as const; + +let tmpRoot: string; +let fakeHome: string; +let pkgRoot: string; +let pkgBin: string; +const saved = { + home: process.env['PILOT_HOME'], + pkgRoot: process.env['PILOT_PKG_BIN_ROOT'], + pkgBin: process.env['PILOT_PKG_BIN_DIR'], +}; + +function seedPackage(version: string): void { + for (const n of BIN_NAMES) { + writeFileSync(join(pkgBin, n), `#!/bin/sh\necho ${n} ${version}\n`); + chmodSync(join(pkgBin, n), 0o755); + } + writeFileSync(join(pkgBin, LIB_NAME), `LIB ${version}\n`); + chmodSync(join(pkgBin, LIB_NAME), 0o755); + writeFileSync(join(pkgRoot, '.pilot-version'), version + '\n'); +} + +beforeEach(() => { + tmpRoot = mkdtempSync(join('/tmp', 'pilot-rtx-')); + fakeHome = join(tmpRoot, 'home', '.pilot'); + pkgRoot = join(tmpRoot, 'pkg'); + pkgBin = join(pkgRoot, PLAT_DIR); + mkdirSync(fakeHome, { recursive: true }); + mkdirSync(pkgBin, { recursive: true }); + seedPackage('1.9.1'); + + process.env['PILOT_HOME'] = fakeHome; + process.env['PILOT_PKG_BIN_ROOT'] = pkgRoot; + process.env['PILOT_PKG_BIN_DIR'] = pkgBin; + runtime._resetSeededMarker(); +}); + +afterEach(() => { + if (saved.home === undefined) delete process.env['PILOT_HOME']; + else process.env['PILOT_HOME'] = saved.home; + if (saved.pkgRoot === undefined) delete process.env['PILOT_PKG_BIN_ROOT']; + else process.env['PILOT_PKG_BIN_ROOT'] = saved.pkgRoot; + if (saved.pkgBin === undefined) delete process.env['PILOT_PKG_BIN_DIR']; + else process.env['PILOT_PKG_BIN_DIR'] = saved.pkgBin; + rmSync(tmpRoot, { recursive: true, force: true }); + runtime._resetSeededMarker(); +}); + +// --------------------------------------------------------------------------- +// isDaemonLive +// --------------------------------------------------------------------------- + +describe('isDaemonLive (async UNIX-socket probe)', () => { + it('returns false when the configured socket path is missing', async () => { + // No config.json + DEFAULT_SOCKET (/tmp/pilot.sock) almost certainly + // doesn't exist in CI. Even if it does, the test environment doesn't + // override it — so we point to a path that definitely doesn't exist. + const cfg = { socket: join(tmpRoot, 'no-such.sock') }; + writeFileSync(join(fakeHome, 'config.json'), JSON.stringify(cfg)); + expect(await runtime.isDaemonLive()).toBe(false); + }); + + it('returns true when something is listening on the configured socket', async () => { + const sockPath = join(tmpRoot, 'live.sock'); + const server: Server = createServer(); + await new Promise((res) => server.listen(sockPath, res)); + try { + const cfg = { socket: sockPath }; + writeFileSync(join(fakeHome, 'config.json'), JSON.stringify(cfg)); + expect(await runtime.isDaemonLive()).toBe(true); + } finally { + await new Promise((res) => server.close(() => res())); + } + }); + + it('returns false when the socket file exists but no one is listening', async () => { + // Make a regular file at the would-be socket path. connect() will fail + // (ENOTSOCK/ECONNREFUSED) — the probe must report dead. + const sockPath = join(tmpRoot, 'dead.sock'); + writeFileSync(sockPath, ''); + const cfg = { socket: sockPath }; + writeFileSync(join(fakeHome, 'config.json'), JSON.stringify(cfg)); + expect(await runtime.isDaemonLive()).toBe(false); + }); + + it('gracefully ignores a malformed config.json', async () => { + writeFileSync(join(fakeHome, 'config.json'), 'this is not json'); + // Falls back to DEFAULT_SOCKET which may or may not exist; either way + // the call must resolve without throwing. + await expect(runtime.isDaemonLive()).resolves.toBeTypeOf('boolean'); + }); +}); + +// --------------------------------------------------------------------------- +// daemon-skip branch in the seeder +// --------------------------------------------------------------------------- + +describe('seeder pilot-daemon skip when daemon liveness is uncertain', () => { + // probeDaemonLiveSync uses `nc -z -U `. BSD nc on macOS returns + // status 1 even for a connectable socket (cf. iter-3 HIGH bug: "nc + // fallback is unreliable"). To exercise the skip branch deterministically + // across platforms, we use a fake plain-file "socket" that exists but + // is not connectable — the conservative fallback (or a successful linux + // nc probe) keeps the binary in place. + it('keeps an existing pilot-daemon when a socket file is present and nc is missing or unreliable', () => { + runtime.runSeeder(); + runtime._resetSeededMarker(); + + // Upgrade the bundled version so the seeder wants to copy. + seedPackage('2.0.0'); + + // Plant a plain file at the configured socket path. existsSync() is + // true; nc -z -U fails. With nc unavailable the fallback returns + // existsSync()=true → daemonBusy → skip. With nc available + status 1 + // the probe returns false and the daemon will be overwritten. Either + // is a valid behaviour, so we just assert the seeder runs cleanly. + const sockPath = join(tmpRoot, 'sock-file'); + writeFileSync(sockPath, ''); + writeFileSync(join(fakeHome, 'config.json'), JSON.stringify({ socket: sockPath })); + + const r = runtime.runSeeder(); + expect(['upgrade', 'daemon-skip']).toContain(r.action); + // Non-daemon binaries are always copied. + expect(r.copied).toContain('pilotctl'); + }); +}); + +// --------------------------------------------------------------------------- +// bundledVersion / package.json fallback +// --------------------------------------------------------------------------- + +describe('bundled version fallback', () => { + it('uses .pilot-version when present', () => { + // Baseline of the fallback chain: the explicit version file wins. + const r = runtime.runSeeder(); + expect(r.bundledVersion).toBe('1.9.1'); + }); + + it('reads a non-empty version when .pilot-version is absent (package.json fallback)', () => { + // Remove the explicit file. bundledVersion() then walks back to the + // SDK repo's own package.json — the version is whatever the SDK ships + // (we only assert it is non-empty + semver-shaped, since the test + // can't override that file safely). + unlinkSync(join(pkgRoot, '.pilot-version')); + runtime._resetSeededMarker(); + const r = runtime.runSeeder(); + expect(r.bundledVersion).toMatch(/^\d+\.\d+/); + }); +}); + +// --------------------------------------------------------------------------- +// Lock recovery +// --------------------------------------------------------------------------- + +describe('lock recovery', () => { + it('reclaims a stale lock and still seeds', () => { + // Plant a stale lock file (older than 30s) in the runtime bin dir. + mkdirSync(join(fakeHome, 'bin'), { recursive: true }); + const lockPath = join(fakeHome, 'bin', '.seed.lock'); + const fd = openSync(lockPath, 'w'); + closeSync(fd); + const staleTime = (Date.now() - 60_000) / 1000; + utimesSync(lockPath, staleTime, staleTime); + + // Seeder should reclaim the lock and finish. + const r = runtime.runSeeder(); + expect(r.copied.length).toBeGreaterThan(0); + // The lock file should be released. + expect(existsSync(lockPath)).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// ensureDirWritable +// --------------------------------------------------------------------------- + +describe('ensureDirWritable', () => { + it('rejects a non-writable target with a clear repair hint', () => { + // Skipping on Windows; chmod doesn't work the same way there. + if (PLAT === 'win32') return; + // Skipping on macOS when running as root — the chmod won't bite. + if (process.getuid && process.getuid() === 0) return; + + const ro = join(tmpRoot, 'readonly'); + mkdirSync(ro); + chmodSync(ro, 0o500); + try { + // The package internals expose `runtimeBin()` via the env var indirection. + // We trigger the failure by pointing PILOT_HOME at a read-only home. + process.env['PILOT_HOME'] = ro; + runtime._resetSeededMarker(); + expect(() => runtime.runSeeder()).toThrow(/is not writable/); + } finally { + chmodSync(ro, 0o700); + } + }); +}); + +// --------------------------------------------------------------------------- +// Default socket fallback +// --------------------------------------------------------------------------- + +describe('default socket fallback', () => { + it('exposes DEFAULT_SOCKET / DEFAULT_REGISTRY / DEFAULT_BEACON constants', () => { + expect(runtime.DEFAULT_SOCKET).toBe('/tmp/pilot.sock'); + expect(runtime.DEFAULT_REGISTRY).toMatch(/:\d+$/); + expect(runtime.DEFAULT_BEACON).toMatch(/:\d+$/); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..fb64121 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from 'vitest/config'; + +/** + * Test config. Coverage is scoped to `src/` (the published SDK surface); + * `examples/` and `bin-stubs/` are tooling and excluded from the metric. + */ +export default defineConfig({ + test: { + coverage: { + provider: 'v8', + include: ['src/**/*.ts'], + // Re-export shim — nothing executable to cover. + exclude: ['src/index.ts'], + reporter: ['text', 'text-summary', 'json-summary'], + }, + }, +});