From fc68981ba823af810598971558dc36b8d12bfc4c Mon Sep 17 00:00:00 2001 From: Philippe Serhal Date: Wed, 11 Mar 2026 16:54:12 -0400 Subject: [PATCH 1/8] fix(deps): replace gh-release-fetch with modern-tar + built-in fetch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `gh-release-fetch` is unmaintained (last published ~3y ago) and now carries 9 moderate-severity `file-type` vulnerabilities through its `@xhmikosr/downloader` → `@xhmikosr/decompress` dependency chain that cannot be resolved without a breaking change to gh-release-fetch itself. It also transitively depends on the deprecated `node-domexception` (via node-fetch → fetch-blob → node-domexception), which prints a deprecation warning to stdout. It also pulls in 93(!) transitive dependencies totaling ~17 MB(!) of node_modules for what amounts to: fetch a GitHub release asset, extract it, and compare semver versions. This replaces it with: - Node built-in `fetch` for GitHub API and asset download - `modern-tar` (zero deps, 85 KB, modern, maintained) for `tar.gz` extraction via its Node.js `unpackTar` streaming API + built-in `zlib.createGunzip()` - `semver` (already a dependency) for version comparison - Node built-in `child_process.execFile` instead of the `execa` wrapper (we still have plenty of `execa` uses, but... while I was here) The implementation follows the same API contract as `gh-release-fetch`: - `resolveLatestTag`: `GET /repos/{owner}/{repo}/releases/latest` - `downloadAndExtract`: `GET /releases/download/{version}/{asset}`, stream to temp file, extract, clean up - `newerVersion`/`updateAvailable`: `semver.gt` with `v`-prefix stripping For better confidence that this preserves existing behaviour, this adds unit tests for `startLiveTunnel` and `getLiveTunnelSlug` (previously untested) and for the tar.gz download+extract code path. --- package-lock.json | 570 ++------------------------- package.json | 6 +- src/lib/exec-fetcher.ts | 108 +++-- tests/unit/lib/exec-fetcher.test.ts | 253 ++++++++---- tests/unit/utils/live-tunnel.test.ts | 306 ++++++++++++++ 5 files changed, 601 insertions(+), 642 deletions(-) create mode 100644 tests/unit/utils/live-tunnel.test.ts diff --git a/package-lock.json b/package-lock.json index fecfad9ab26..041e5d80794 100644 --- a/package-lock.json +++ b/package-lock.json @@ -61,7 +61,6 @@ "folder-walker": "3.2.0", "fuzzy": "0.1.3", "get-port": "5.1.1", - "gh-release-fetch": "4.0.3", "git-repo-info": "2.1.1", "gitconfiglocal": "2.1.0", "http-proxy": "1.18.1", @@ -81,6 +80,7 @@ "log-update": "7.2.0", "maxstache": "1.0.7", "maxstache-stream": "1.0.4", + "modern-tar": "^0.7.5", "multiparty": "4.2.3", "nanospinner": "1.2.2", "netlify-redirector": "0.5.0", @@ -6618,16 +6618,6 @@ "version": "0.4.1", "license": "MIT" }, - "node_modules/@sindresorhus/is": { - "version": "5.6.0", - "license": "MIT", - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sindresorhus/is?sponsor=1" - } - }, "node_modules/@sindresorhus/merge-streams": { "version": "2.3.0", "license": "MIT", @@ -6684,20 +6674,6 @@ "acorn": "^8.9.0" } }, - "node_modules/@szmarczak/http-timer": { - "version": "5.0.1", - "license": "MIT", - "dependencies": { - "defer-to-connect": "^2.0.1" - }, - "engines": { - "node": ">=14.16" - } - }, - "node_modules/@tokenizer/token": { - "version": "0.3.0", - "license": "MIT" - }, "node_modules/@tsconfig/node10": { "version": "1.0.11", "license": "MIT" @@ -6838,10 +6814,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/http-cache-semantics": { - "version": "4.0.4", - "license": "MIT" - }, "node_modules/@types/http-errors": { "version": "2.0.5", "dev": true, @@ -8218,174 +8190,6 @@ "node": ">=18.0.0" } }, - "node_modules/@xhmikosr/archive-type": { - "version": "6.0.1", - "license": "MIT", - "dependencies": { - "file-type": "^18.5.0" - }, - "engines": { - "node": "^14.14.0 || >=16.0.0" - } - }, - "node_modules/@xhmikosr/decompress": { - "version": "9.0.1", - "license": "MIT", - "dependencies": { - "@xhmikosr/decompress-tar": "^7.0.0", - "@xhmikosr/decompress-tarbz2": "^7.0.0", - "@xhmikosr/decompress-targz": "^7.0.0", - "@xhmikosr/decompress-unzip": "^6.0.0", - "graceful-fs": "^4.2.11", - "make-dir": "^4.0.0", - "strip-dirs": "^3.0.0" - }, - "engines": { - "node": "^14.14.0 || >=16.0.0" - } - }, - "node_modules/@xhmikosr/decompress-tar": { - "version": "7.0.0", - "license": "MIT", - "dependencies": { - "file-type": "^18.5.0", - "is-stream": "^3.0.0", - "tar-stream": "^3.1.4" - }, - "engines": { - "node": "^14.14.0 || >=16.0.0" - } - }, - "node_modules/@xhmikosr/decompress-tar/node_modules/is-stream": { - "version": "3.0.0", - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@xhmikosr/decompress-tarbz2": { - "version": "7.0.0", - "license": "MIT", - "dependencies": { - "@xhmikosr/decompress-tar": "^7.0.0", - "file-type": "^18.5.0", - "is-stream": "^3.0.0", - "seek-bzip": "^1.0.6", - "unbzip2-stream": "^1.4.3" - }, - "engines": { - "node": "^14.14.0 || >=16.0.0" - } - }, - "node_modules/@xhmikosr/decompress-tarbz2/node_modules/is-stream": { - "version": "3.0.0", - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@xhmikosr/decompress-targz": { - "version": "7.0.0", - "license": "MIT", - "dependencies": { - "@xhmikosr/decompress-tar": "^7.0.0", - "file-type": "^18.5.0", - "is-stream": "^3.0.0" - }, - "engines": { - "node": "^14.14.0 || >=16.0.0" - } - }, - "node_modules/@xhmikosr/decompress-targz/node_modules/is-stream": { - "version": "3.0.0", - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@xhmikosr/decompress-unzip": { - "version": "6.0.0", - "license": "MIT", - "dependencies": { - "file-type": "^18.5.0", - "get-stream": "^6.0.1", - "yauzl": "^2.10.0" - }, - "engines": { - "node": "^14.14.0 || >=16.0.0" - } - }, - "node_modules/@xhmikosr/decompress-unzip/node_modules/get-stream": { - "version": "6.0.1", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@xhmikosr/downloader": { - "version": "13.0.1", - "license": "MIT", - "dependencies": { - "@xhmikosr/archive-type": "^6.0.1", - "@xhmikosr/decompress": "^9.0.1", - "content-disposition": "^0.5.4", - "ext-name": "^5.0.0", - "file-type": "^18.5.0", - "filenamify": "^5.1.1", - "get-stream": "^6.0.1", - "got": "^12.6.1", - "merge-options": "^3.0.4", - "p-event": "^5.0.1" - }, - "engines": { - "node": "^14.14.0 || >=16.0.0" - } - }, - "node_modules/@xhmikosr/downloader/node_modules/get-stream": { - "version": "6.0.1", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@xhmikosr/downloader/node_modules/p-event": { - "version": "5.0.1", - "license": "MIT", - "dependencies": { - "p-timeout": "^5.0.2" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@xhmikosr/downloader/node_modules/p-timeout": { - "version": "5.1.0", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/abbrev": { "version": "3.0.1", "license": "ISC", @@ -9302,39 +9106,6 @@ "node": ">=8" } }, - "node_modules/cacheable-lookup": { - "version": "7.0.0", - "license": "MIT", - "engines": { - "node": ">=14.16" - } - }, - "node_modules/cacheable-request": { - "version": "10.2.14", - "license": "MIT", - "dependencies": { - "@types/http-cache-semantics": "^4.0.2", - "get-stream": "^6.0.1", - "http-cache-semantics": "^4.1.1", - "keyv": "^4.5.3", - "mimic-response": "^4.0.0", - "normalize-url": "^8.0.0", - "responselike": "^3.0.0" - }, - "engines": { - "node": ">=14.16" - } - }, - "node_modules/cacheable-request/node_modules/get-stream": { - "version": "6.0.1", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "license": "MIT", @@ -10044,6 +9815,7 @@ }, "node_modules/content-disposition": { "version": "0.5.4", + "dev": true, "license": "MIT", "dependencies": { "safe-buffer": "5.2.1" @@ -10310,6 +10082,7 @@ }, "node_modules/decompress-response": { "version": "6.0.0", + "dev": true, "license": "MIT", "dependencies": { "mimic-response": "^3.1.0" @@ -10323,6 +10096,7 @@ }, "node_modules/decompress-response/node_modules/mimic-response": { "version": "3.1.0", + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -10409,6 +10183,7 @@ }, "node_modules/defer-to-connect": { "version": "2.0.1", + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -11822,27 +11597,6 @@ "node": ">= 0.6" } }, - "node_modules/ext-list": { - "version": "2.2.2", - "license": "MIT", - "dependencies": { - "mime-db": "^1.28.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/ext-name": { - "version": "5.0.0", - "license": "MIT", - "dependencies": { - "ext-list": "^2.0.0", - "sort-keys-length": "^1.0.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/extend": { "version": "3.0.2", "dev": true, @@ -12137,50 +11891,10 @@ "node": ">=16.0.0" } }, - "node_modules/file-type": { - "version": "18.7.0", - "license": "MIT", - "dependencies": { - "readable-web-to-node-stream": "^3.0.2", - "strtok3": "^7.0.0", - "token-types": "^5.0.1" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sindresorhus/file-type?sponsor=1" - } - }, "node_modules/file-uri-to-path": { "version": "1.0.0", "license": "MIT" }, - "node_modules/filename-reserved-regex": { - "version": "3.0.0", - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/filenamify": { - "version": "5.1.1", - "license": "MIT", - "dependencies": { - "filename-reserved-regex": "^3.0.0", - "strip-outer": "^2.0.0", - "trim-repeated": "^2.0.0" - }, - "engines": { - "node": ">=12.20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/fill-range": { "version": "7.1.1", "license": "MIT", @@ -12370,13 +12084,6 @@ "node": ">= 6" } }, - "node_modules/form-data-encoder": { - "version": "2.1.4", - "license": "MIT", - "engines": { - "node": ">= 14.17" - } - }, "node_modules/formdata-polyfill": { "version": "4.0.10", "license": "MIT", @@ -12578,18 +12285,6 @@ "assert-plus": "^1.0.0" } }, - "node_modules/gh-release-fetch": { - "version": "4.0.3", - "license": "MIT", - "dependencies": { - "@xhmikosr/downloader": "^13.0.0", - "node-fetch": "^3.3.1", - "semver": "^7.5.3" - }, - "engines": { - "node": "^14.18.0 || ^16.13.0 || >=18.0.0" - } - }, "node_modules/git-repo-info": { "version": "2.1.1", "license": "MIT", @@ -12742,29 +12437,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/got": { - "version": "12.6.1", - "license": "MIT", - "dependencies": { - "@sindresorhus/is": "^5.2.0", - "@szmarczak/http-timer": "^5.0.1", - "cacheable-lookup": "^7.0.0", - "cacheable-request": "^10.2.8", - "decompress-response": "^6.0.0", - "form-data-encoder": "^2.1.2", - "get-stream": "^6.0.1", - "http2-wrapper": "^2.1.10", - "lowercase-keys": "^3.0.0", - "p-cancelable": "^3.0.0", - "responselike": "^3.0.0" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sindresorhus/got?sponsor=1" - } - }, "node_modules/got-cjs": { "version": "12.5.4", "dev": true, @@ -12905,16 +12577,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/got/node_modules/get-stream": { - "version": "6.0.1", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/graceful-fs": { "version": "4.2.11", "license": "ISC" @@ -13076,6 +12738,7 @@ }, "node_modules/http-cache-semantics": { "version": "4.2.0", + "dev": true, "license": "BSD-2-Clause" }, "node_modules/http-errors": { @@ -13147,6 +12810,7 @@ }, "node_modules/http2-wrapper": { "version": "2.2.1", + "dev": true, "license": "MIT", "dependencies": { "quick-lru": "^5.1.1", @@ -13588,13 +13252,6 @@ "node": ">=8" } }, - "node_modules/inspect-with-kind": { - "version": "1.0.5", - "license": "ISC", - "dependencies": { - "kind-of": "^6.0.2" - } - }, "node_modules/ipaddr.js": { "version": "1.9.1", "license": "MIT", @@ -14073,6 +13730,7 @@ }, "node_modules/json-buffer": { "version": "3.0.1", + "dev": true, "license": "MIT" }, "node_modules/json-parse-even-better-errors": { @@ -14241,18 +13899,12 @@ }, "node_modules/keyv": { "version": "4.5.4", + "dev": true, "license": "MIT", "dependencies": { "json-buffer": "3.0.1" } }, - "node_modules/kind-of": { - "version": "6.0.3", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/kuler": { "version": "2.0.0", "license": "MIT" @@ -14762,16 +14414,6 @@ "node": ">=4" } }, - "node_modules/lowercase-keys": { - "version": "3.0.0", - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/lru-cache": { "version": "5.1.1", "dev": true, @@ -14817,6 +14459,7 @@ }, "node_modules/make-dir": { "version": "4.0.0", + "dev": true, "license": "MIT", "dependencies": { "semver": "^7.5.3" @@ -15007,6 +14650,7 @@ }, "node_modules/mime-db": { "version": "1.52.0", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -15043,16 +14687,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/mimic-response": { - "version": "4.0.0", - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/minimatch": { "version": "9.0.5", "license": "ISC", @@ -15111,6 +14745,15 @@ "ufo": "^1.6.1" } }, + "node_modules/modern-tar": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/modern-tar/-/modern-tar-0.7.5.tgz", + "integrity": "sha512-YTefgdpKKFgoTDbEUqXqgUJct2OG6/4hs4XWLsxcHkDLj/x/V8WmKIRppPnXP5feQ7d1vuYWSp3qKkxfwaFaxA==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/module-definition": { "version": "6.0.1", "license": "MIT", @@ -15384,16 +15027,6 @@ "node": ">=0.10.0" } }, - "node_modules/normalize-url": { - "version": "8.1.1", - "license": "MIT", - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/npm-normalize-package-bin": { "version": "4.0.0", "dev": true, @@ -15715,13 +15348,6 @@ "dev": true, "license": "MIT" }, - "node_modules/p-cancelable": { - "version": "3.0.0", - "license": "MIT", - "engines": { - "node": ">=12.20" - } - }, "node_modules/p-event": { "version": "6.0.1", "license": "MIT", @@ -16093,17 +15719,6 @@ "node": ">= 14.16" } }, - "node_modules/peek-readable": { - "version": "5.4.2", - "license": "MIT", - "engines": { - "node": ">=14.16" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Borewit" - } - }, "node_modules/peek-stream": { "version": "1.1.3", "dev": true, @@ -16640,6 +16255,7 @@ }, "node_modules/quick-lru": { "version": "5.1.1", + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -16892,20 +16508,6 @@ "ieee754": "^1.2.1" } }, - "node_modules/readable-web-to-node-stream": { - "version": "3.0.4", - "license": "MIT", - "dependencies": { - "readable-stream": "^4.7.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Borewit" - } - }, "node_modules/readdir-glob": { "version": "1.1.3", "license": "Apache-2.0", @@ -17037,6 +16639,7 @@ }, "node_modules/resolve-alpn": { "version": "1.2.1", + "dev": true, "license": "MIT" }, "node_modules/resolve-from": { @@ -17054,19 +16657,6 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, - "node_modules/responselike": { - "version": "3.0.0", - "license": "MIT", - "dependencies": { - "lowercase-keys": "^3.0.0" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/restore-cursor": { "version": "3.1.0", "license": "MIT", @@ -17288,21 +16878,6 @@ ], "license": "BSD-3-Clause" }, - "node_modules/seek-bzip": { - "version": "1.0.6", - "license": "MIT", - "dependencies": { - "commander": "^2.8.1" - }, - "bin": { - "seek-bunzip": "bin/seek-bunzip", - "seek-table": "bin/seek-bzip-table" - } - }, - "node_modules/seek-bzip/node_modules/commander": { - "version": "2.20.3", - "license": "MIT" - }, "node_modules/semver": { "version": "7.7.2", "license": "ISC", @@ -17595,33 +17170,6 @@ "atomic-sleep": "^1.0.0" } }, - "node_modules/sort-keys": { - "version": "1.1.2", - "license": "MIT", - "dependencies": { - "is-plain-obj": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/sort-keys-length": { - "version": "1.0.1", - "license": "MIT", - "dependencies": { - "sort-keys": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/sort-keys/node_modules/is-plain-obj": { - "version": "1.1.0", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/source-map": { "version": "0.6.1", "license": "BSD-3-Clause", @@ -17854,21 +17402,6 @@ "node": ">=8" } }, - "node_modules/strip-dirs": { - "version": "3.0.0", - "license": "ISC", - "dependencies": { - "inspect-with-kind": "^1.0.5", - "is-plain-obj": "^1.1.0" - } - }, - "node_modules/strip-dirs/node_modules/is-plain-obj": { - "version": "1.1.0", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/strip-final-newline": { "version": "2.0.0", "license": "MIT", @@ -17903,31 +17436,6 @@ "dev": true, "license": "MIT" }, - "node_modules/strip-outer": { - "version": "2.0.0", - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/strtok3": { - "version": "7.1.1", - "license": "MIT", - "dependencies": { - "@tokenizer/token": "^0.3.0", - "peek-readable": "^5.1.3" - }, - "engines": { - "node": ">=16" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Borewit" - } - }, "node_modules/stubborn-fs": { "version": "1.2.5" }, @@ -18302,21 +17810,6 @@ "node": ">=0.6" } }, - "node_modules/token-types": { - "version": "5.0.1", - "license": "MIT", - "dependencies": { - "@tokenizer/token": "^0.3.0", - "ieee754": "^1.2.1" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Borewit" - } - }, "node_modules/toml": { "version": "3.0.0", "license": "MIT" @@ -18363,16 +17856,6 @@ "tree-kill": "cli.js" } }, - "node_modules/trim-repeated": { - "version": "2.0.0", - "license": "MIT", - "dependencies": { - "escape-string-regexp": "^5.0.0" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/triple-beam": { "version": "1.4.1", "license": "MIT", @@ -18597,14 +18080,6 @@ "ulid": "dist/cli.js" } }, - "node_modules/unbzip2-stream": { - "version": "1.4.3", - "license": "MIT", - "dependencies": { - "buffer": "^5.2.1", - "through": "^2.3.8" - } - }, "node_modules/uncrypto": { "version": "0.1.3", "license": "MIT" @@ -18642,6 +18117,7 @@ }, "node_modules/unix-dgram": { "version": "2.0.7", + "hasInstallScript": true, "license": "ISC", "optional": true, "dependencies": { diff --git a/package.json b/package.json index 1dd6eed6b45..c355b8dfae8 100644 --- a/package.json +++ b/package.json @@ -108,7 +108,6 @@ "folder-walker": "3.2.0", "fuzzy": "0.1.3", "get-port": "5.1.1", - "gh-release-fetch": "4.0.3", "git-repo-info": "2.1.1", "gitconfiglocal": "2.1.0", "http-proxy": "1.18.1", @@ -128,6 +127,7 @@ "log-update": "7.2.0", "maxstache": "1.0.7", "maxstache-stream": "1.0.4", + "modern-tar": "^0.7.5", "multiparty": "4.2.3", "nanospinner": "1.2.2", "netlify-redirector": "0.5.0", @@ -138,8 +138,8 @@ "p-map": "7.0.3", "p-wait-for": "6.0.0", "parallel-transform": "1.2.0", - "pg": "^8.11.0", "parse-github-url": "1.0.3", + "pg": "^8.11.0", "prettyjson": "1.2.5", "raw-body": "3.0.1", "read-package-up": "12.0.0", @@ -183,8 +183,8 @@ "@types/node": "22.18.11", "@types/node-fetch": "2.6.13", "@types/parallel-transform": "1.1.4", - "@types/pg": "^8.11.0", "@types/parse-github-url": "1.0.3", + "@types/pg": "^8.11.0", "@types/picomatch": "4.0.2", "@types/prettyjson": "0.0.33", "@types/semver": "7.7.1", diff --git a/src/lib/exec-fetcher.ts b/src/lib/exec-fetcher.ts index 8c861f9b764..4beeecc7144 100644 --- a/src/lib/exec-fetcher.ts +++ b/src/lib/exec-fetcher.ts @@ -1,11 +1,19 @@ +import { execFile as execFileCb } from 'child_process' +import { createReadStream, createWriteStream } from 'fs' +import { mkdir, unlink } from 'fs/promises' import path from 'path' +import { pipeline } from 'stream/promises' import process from 'process' +import { promisify } from 'util' +import { createGunzip } from 'zlib' -import { fetchLatest, fetchVersion, newerVersion, updateAvailable } from 'gh-release-fetch' import { isexe } from 'isexe' +import { unpackTar } from 'modern-tar/fs' +import semver from 'semver' import { NETLIFYDEVWARN, logAndThrowError, getTerminalLink, log } from '../utils/command-helpers.js' -import execa from '../utils/execa.js' + +const execFile = promisify(execFileCb) const isWindows = () => process.platform === 'win32' @@ -13,12 +21,74 @@ const getRepository = ({ packageName }: { packageName: string }) => `netlify/${p export const getExecName = ({ execName }: { execName: string }) => (isWindows() ? `${execName}.exe` : execName) -const getOptions = () => { - // this is used in out CI tests to avoid hitting GitHub API limit - // when calling gh-release-fetch +const getGitHubHeaders = (): Record => { + const headers: Record = { Accept: 'application/vnd.github.v3+json' } if (process.env.NETLIFY_TEST_GITHUB_TOKEN) { - return { - headers: { Authorization: `token ${process.env.NETLIFY_TEST_GITHUB_TOKEN}` }, + headers.Authorization = `token ${process.env.NETLIFY_TEST_GITHUB_TOKEN}` + } + return headers +} + +const resolveLatestTag = async (repository: string): Promise => { + const response = await fetch(`https://api.github.com/repos/${repository}/releases/latest`, { + headers: getGitHubHeaders(), + }) + if (!response.ok) { + const text = await response.text() + if (response.status === 403 && text.includes('API rate limit exceeded')) { + throw new Error('GitHub API rate limit exceeded') + } + throw new Error(`Failed to fetch latest release for ${repository}: ${String(response.status)} ${text}`) + } + const data = (await response.json()) as { tag_name: string } + return data.tag_name +} + +const newerVersion = (latestVersion: string, currentVersion: string): boolean => { + if (!latestVersion) return false + if (!currentVersion) return true + const latest = latestVersion.replace(/^v/, '') + const current = currentVersion.replace(/^v/, '') + return semver.gt(latest, current) +} + +const updateAvailable = async (repository: string, currentVersion: string): Promise => { + const latestTag = await resolveLatestTag(repository) + return newerVersion(latestTag, currentVersion) +} + +const downloadAndExtract = async (url: string, destination: string): Promise => { + const response = await fetch(url, { + headers: getGitHubHeaders(), + redirect: 'follow', + }) + if (!response.ok) { + throw Object.assign(new Error(`Download failed: ${String(response.status)}`), { statusCode: response.status }) + } + if (!response.body) { + throw new Error('Empty response body') + } + + await mkdir(destination, { recursive: true }) + + if (url.endsWith('.zip')) { + const tmpFile = path.join(destination, '_download.zip') + const fileStream = createWriteStream(tmpFile) + await pipeline(response.body, fileStream) + try { + await execFile('unzip', ['-o', tmpFile, '-d', destination]) + } finally { + await unlink(tmpFile) + } + } else { + const tmpFile = path.join(destination, '_download.tar.gz') + const fileStream = createWriteStream(tmpFile) + await pipeline(response.body, fileStream) + try { + const extractStream = unpackTar(destination) + await pipeline(createReadStream(tmpFile), createGunzip(), extractStream) + } finally { + await unlink(tmpFile) } } } @@ -35,9 +105,7 @@ const isVersionOutdated = async ({ if (latestVersion) { return newerVersion(latestVersion, currentVersion) } - const options = getOptions() - const outdated = await updateAvailable(getRepository({ packageName }), currentVersion, options) - return outdated + return await updateAvailable(getRepository({ packageName }), currentVersion) } export const shouldFetchLatestVersion = async ({ @@ -62,7 +130,7 @@ export const shouldFetchLatestVersion = async ({ return true } - const { stdout } = await execa(execPath, execArgs) + const { stdout } = await execFile(execPath, execArgs) if (!stdout) { return false @@ -118,23 +186,13 @@ export const fetchLatestVersion = async ({ const arch = getArch() const platform = win ? 'windows' : process.platform const pkgName = `${execName}-${platform}-${arch}.${extension}` + const repository = getRepository({ packageName }) - const release = { - repository: getRepository({ packageName }), - package: pkgName, - destination, - extract: true, - } - - const options = getOptions() - const fetch = latestVersion - ? // @ts-expect-error(serhalp) -- Either `gh-release-fetch` is not typed correctly or `options.headers` is useless - fetchVersion({ ...release, version: latestVersion }, options) - : // @ts-expect-error(serhalp) -- Either `gh-release-fetch` is not typed correctly or `options.version` should be passed - fetchLatest(release, options) + const version = latestVersion ?? (await resolveLatestTag(repository)) + const url = `https://github.com/${repository}/releases/download/${version}/${pkgName}` try { - await fetch + await downloadAndExtract(url, destination) } catch (error_) { if (error_ != null && typeof error_ === 'object' && 'statusCode' in error_ && error_.statusCode === 404) { const createIssueLink = new URL('https://github.com/netlify/cli/issues/new') diff --git a/tests/unit/lib/exec-fetcher.test.ts b/tests/unit/lib/exec-fetcher.test.ts index 7b604800ca8..e42e95f6c67 100644 --- a/tests/unit/lib/exec-fetcher.test.ts +++ b/tests/unit/lib/exec-fetcher.test.ts @@ -1,19 +1,14 @@ +import { readFile } from 'fs/promises' +import os from 'os' +import path from 'path' import process from 'process' +import { gzipSync } from 'zlib' -import { fetchLatest } from 'gh-release-fetch' -import { afterAll, afterEach, beforeAll, expect, test, vi, type MockInstance } from 'vitest' +import { packTar } from 'modern-tar' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi, type MockInstance } from 'vitest' import { fetchLatestVersion, getArch, getExecName } from '../../../src/lib/exec-fetcher.js' -vi.mock('gh-release-fetch', async () => { - const actual = await vi.importActual('gh-release-fetch') - - return { - ...actual, - fetchLatest: vi.fn(actual.fetchLatest), - } -}) - let processArchSpy: MockInstance<() => typeof process.arch> let processPlatformSpy: MockInstance<() => typeof process.platform> @@ -22,8 +17,13 @@ beforeAll(() => { processPlatformSpy = vi.spyOn(process, 'platform', 'get') }) +beforeEach(() => { + vi.stubGlobal('fetch', vi.fn()) +}) + afterEach(() => { vi.clearAllMocks() + vi.unstubAllGlobals() processArchSpy.mockReset() processPlatformSpy.mockReset() }) @@ -60,62 +60,181 @@ test(`should not append anything on linux to executable`, () => { expect(getExecName({ execName })).toBe(execName) }) -test('should test if an error is thrown if the cpu architecture and the os are not available', async () => { - processArchSpy.mockReturnValue('x64') - processPlatformSpy.mockReturnValue('win32') - // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors - vi.mocked(fetchLatest).mockReturnValue(Promise.reject({ statusCode: 404 })) - - await expect( - fetchLatestVersion({ - packageName: 'traffic-mesh-agent', - execName: 'traffic-mesh', - destination: '', - extension: 'zip', - }), - ).rejects.toThrowError(/The operating system windows with the CPU architecture amd64 is currently not supported!/) -}) - -test('should provide the error if it is not a 404', async () => { - const error = new Error('Got Rate limited for example') - - vi.mocked(fetchLatest).mockReturnValue(Promise.reject(error)) - - await expect( - fetchLatestVersion({ - packageName: 'traffic-mesh-agent', - execName: 'traffic-mesh', - destination: '', - extension: 'zip', - }), - ).rejects.toThrowError(error.message) -}) - -test('should map linux x64 to amd64 arch', async () => { - processArchSpy.mockReturnValue('x64') - processPlatformSpy.mockReturnValue('linux') - // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors - vi.mocked(fetchLatest).mockReturnValue(Promise.reject({ statusCode: 404 })) - - await expect( - fetchLatestVersion({ - packageName: 'traffic-mesh-agent', - execName: 'traffic-mesh', - destination: '', - extension: 'zip', - }), - ).rejects.toThrowError(/The operating system linux with the CPU architecture amd64 is currently not supported!/) -}) - -test('should not throw when the request passes', async () => { - vi.mocked(fetchLatest).mockReturnValue(Promise.resolve(undefined)) - - await expect( - fetchLatestVersion({ +describe('fetchLatestVersion', () => { + test('should throw a user-friendly error on 404', async () => { + processArchSpy.mockReturnValue('x64') + processPlatformSpy.mockReturnValue('win32') + vi.mocked(fetch).mockResolvedValue({ + ok: false, + status: 404, + body: null, + text: () => Promise.resolve('Not Found'), + } as unknown as Response) + + await expect( + fetchLatestVersion({ + packageName: 'traffic-mesh-agent', + execName: 'traffic-mesh', + destination: '', + extension: 'zip', + latestVersion: 'v1.0.0', + }), + ).rejects.toThrowError(/The operating system windows with the CPU architecture amd64 is currently not supported!/) + }) + + test('should propagate non-404 errors', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: false, + status: 500, + body: null, + text: () => Promise.resolve('Internal Server Error'), + } as unknown as Response) + + await expect( + fetchLatestVersion({ + packageName: 'traffic-mesh-agent', + execName: 'traffic-mesh', + destination: '', + extension: 'zip', + latestVersion: 'v1.0.0', + }), + ).rejects.toThrowError(/Download failed: 500/) + }) + + test('should map linux x64 to amd64 arch', async () => { + processArchSpy.mockReturnValue('x64') + processPlatformSpy.mockReturnValue('linux') + vi.mocked(fetch).mockResolvedValue({ + ok: false, + status: 404, + body: null, + text: () => Promise.resolve('Not Found'), + } as unknown as Response) + + await expect( + fetchLatestVersion({ + packageName: 'traffic-mesh-agent', + execName: 'traffic-mesh', + destination: '', + extension: 'zip', + latestVersion: 'v1.0.0', + }), + ).rejects.toThrowError(/The operating system linux with the CPU architecture amd64 is currently not supported!/) + }) + + test('should resolve latest tag from GitHub API when latestVersion is not provided', async () => { + vi.mocked(fetch) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ tag_name: 'v2.0.0' }), + } as unknown as Response) + .mockResolvedValueOnce({ + ok: false, + status: 404, + body: null, + text: () => Promise.resolve('Not Found'), + } as unknown as Response) + + await expect( + fetchLatestVersion({ + packageName: 'traffic-mesh-agent', + execName: 'traffic-mesh', + destination: '', + extension: 'tar.gz', + }), + ).rejects.toThrowError() + + expect(vi.mocked(fetch)).toHaveBeenCalledWith( + 'https://api.github.com/repos/netlify/traffic-mesh-agent/releases/latest', + expect.objectContaining({ headers: expect.any(Object) as unknown }), + ) + expect(vi.mocked(fetch)).toHaveBeenCalledWith( + expect.stringContaining('/releases/download/v2.0.0/'), + expect.any(Object) as unknown, + ) + }) + + test('should throw on GitHub API rate limit', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: false, + status: 403, + text: () => Promise.resolve('{"message":"API rate limit exceeded for ..."}'), + } as unknown as Response) + + await expect( + fetchLatestVersion({ + packageName: 'traffic-mesh-agent', + execName: 'traffic-mesh', + destination: '', + extension: 'tar.gz', + }), + ).rejects.toThrowError(/GitHub API rate limit exceeded/) + }) + + test('should download and extract a tar.gz release', async () => { + const destination = path.join(os.tmpdir(), `exec-fetcher-test-${String(Date.now())}`) + const fileContent = 'hello from test binary' + + const tarBuffer = await packTar([{ header: { name: 'test-binary', size: fileContent.length }, body: fileContent }]) + const gzipped = gzipSync(Buffer.from(tarBuffer)) + + const body = new ReadableStream({ + start(controller) { + controller.enqueue(gzipped) + controller.close() + }, + }) + vi.mocked(fetch).mockResolvedValue({ + ok: true, + body, + } as unknown as Response) + + await fetchLatestVersion({ packageName: 'traffic-mesh-agent', execName: 'traffic-mesh', - destination: '', - extension: 'zip', - }), - ).resolves.not.toThrowError() + destination, + extension: 'tar.gz', + latestVersion: 'v1.0.0', + }) + + const extracted = await readFile(path.join(destination, 'test-binary'), 'utf-8') + expect(extracted).toBe(fileContent) + }) + + test('should include auth header when NETLIFY_TEST_GITHUB_TOKEN is set', async () => { + const originalToken: string | undefined = process.env.NETLIFY_TEST_GITHUB_TOKEN + process.env.NETLIFY_TEST_GITHUB_TOKEN = 'test-token-123' + + try { + vi.mocked(fetch).mockResolvedValue({ + ok: false, + status: 500, + body: null, + text: () => Promise.resolve('error'), + } as unknown as Response) + + await expect( + fetchLatestVersion({ + packageName: 'traffic-mesh-agent', + execName: 'traffic-mesh', + destination: '', + extension: 'tar.gz', + latestVersion: 'v1.0.0', + }), + ).rejects.toThrowError() + + expect(vi.mocked(fetch)).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: 'token test-token-123' }) as unknown, + }), + ) + } finally { + if (originalToken === undefined) { + delete process.env.NETLIFY_TEST_GITHUB_TOKEN + } else { + process.env.NETLIFY_TEST_GITHUB_TOKEN = originalToken + } + } + }) }) diff --git a/tests/unit/utils/live-tunnel.test.ts b/tests/unit/utils/live-tunnel.test.ts new file mode 100644 index 00000000000..dc7153bf950 --- /dev/null +++ b/tests/unit/utils/live-tunnel.test.ts @@ -0,0 +1,306 @@ +import { EventEmitter } from 'events' + +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' + +import { startLiveTunnel, getLiveTunnelSlug } from '../../../src/utils/live-tunnel.js' + +vi.mock('node-fetch', () => ({ + default: vi.fn(), +})) + +vi.mock('../../../src/lib/exec-fetcher.js', () => ({ + shouldFetchLatestVersion: vi.fn().mockResolvedValue(false), + fetchLatestVersion: vi.fn().mockResolvedValue(undefined), +})) + +vi.mock('../../../src/lib/settings.js', () => ({ + getPathInHome: vi.fn((...args: string[][]) => `/mock/home/${args.flat().join('/')}`), +})) + +const createMockProcess = () => { + const ps = new EventEmitter() + return ps +} + +vi.mock('../../../src/utils/execa.js', () => ({ + default: vi.fn(() => createMockProcess()), +})) + +vi.mock('../../../src/utils/command-helpers.js', async () => { + const actual = await vi.importActual( + '../../../src/utils/command-helpers.js', + ) + return { + ...actual, + exit: vi.fn((code?: number) => { + throw new Error(`process.exit(${String(code)})`) + }), + log: vi.fn(), + } +}) + +vi.mock('p-wait-for', () => ({ + default: vi.fn(async (fn: () => Promise) => { + let ready = false + while (!ready) { + ready = await fn() + } + }), +})) + +beforeEach(() => { + vi.clearAllMocks() +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('startLiveTunnel', () => { + test('should create a live tunnel session and return the session URL', async () => { + const { default: fetch } = await import('node-fetch') + + vi.mocked(fetch) + .mockResolvedValueOnce({ + status: 201, + json: () => + Promise.resolve({ + id: 'session-123', + session_url: 'https://test--my-site.netlify.live', + state: 'connecting', + }), + } as never) + .mockResolvedValueOnce({ + status: 200, + json: () => + Promise.resolve({ id: 'session-123', session_url: 'https://test--my-site.netlify.live', state: 'online' }), + } as never) + + const result = await startLiveTunnel({ + siteId: 'site-456', + netlifyApiToken: 'fake-token', + localPort: 8888, + slug: 'test', + }) + + expect(result).toBe('https://test--my-site.netlify.live') + + expect(vi.mocked(fetch)).toHaveBeenCalledWith( + 'https://api.netlify.com/api/v1/live_sessions?site_id=site-456&slug=test', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + Authorization: 'Bearer fake-token', + }) as unknown, + }), + ) + }) + + test('should poll until the session is online', async () => { + const { default: fetch } = await import('node-fetch') + + vi.mocked(fetch) + .mockResolvedValueOnce({ + status: 201, + json: () => + Promise.resolve({ id: 'session-123', session_url: 'https://test.netlify.live', state: 'connecting' }), + } as never) + .mockResolvedValueOnce({ + status: 200, + json: () => Promise.resolve({ id: 'session-123', state: 'booting' }), + } as never) + .mockResolvedValueOnce({ + status: 200, + json: () => Promise.resolve({ id: 'session-123', state: 'online' }), + } as never) + + await startLiveTunnel({ + siteId: 'site-456', + netlifyApiToken: 'fake-token', + localPort: 8888, + slug: 'test', + }) + + expect(vi.mocked(fetch)).toHaveBeenCalledTimes(3) + }) + + test('should spawn the tunnel client binary with correct args', async () => { + const { default: fetch } = await import('node-fetch') + const { default: execa } = await import('../../../src/utils/execa.js') + + vi.mocked(fetch) + .mockResolvedValueOnce({ + status: 201, + json: () => + Promise.resolve({ id: 'session-abc', session_url: 'https://test.netlify.live', state: 'connecting' }), + } as never) + .mockResolvedValueOnce({ + status: 200, + json: () => Promise.resolve({ id: 'session-abc', state: 'online' }), + } as never) + + await startLiveTunnel({ + siteId: 'site-456', + netlifyApiToken: 'fake-token', + localPort: 3000, + slug: 'test', + }) + + expect(vi.mocked(execa)).toHaveBeenCalledWith( + '/mock/home/tunnel/bin/live-tunnel-client', + ['connect', '-s', 'session-abc', '-t', 'fake-token', '-l', '3000'], + { stdio: 'inherit' }, + ) + }) + + test('should install the tunnel client if shouldFetchLatestVersion returns true', async () => { + const { default: fetch } = await import('node-fetch') + const { shouldFetchLatestVersion, fetchLatestVersion } = await import('../../../src/lib/exec-fetcher.js') + + vi.mocked(shouldFetchLatestVersion).mockResolvedValueOnce(true) + + vi.mocked(fetch) + .mockResolvedValueOnce({ + status: 201, + json: () => + Promise.resolve({ id: 'session-123', session_url: 'https://test.netlify.live', state: 'connecting' }), + } as never) + .mockResolvedValueOnce({ + status: 200, + json: () => Promise.resolve({ id: 'session-123', state: 'online' }), + } as never) + + await startLiveTunnel({ + siteId: 'site-456', + netlifyApiToken: 'fake-token', + localPort: 8888, + slug: 'test', + }) + + expect(vi.mocked(fetchLatestVersion)).toHaveBeenCalledWith( + expect.objectContaining({ + packageName: 'live-tunnel-client', + execName: 'live-tunnel-client', + }), + ) + }) + + test('should not install the tunnel client if shouldFetchLatestVersion returns false', async () => { + const { default: fetch } = await import('node-fetch') + const { fetchLatestVersion } = await import('../../../src/lib/exec-fetcher.js') + + vi.mocked(fetch) + .mockResolvedValueOnce({ + status: 201, + json: () => + Promise.resolve({ id: 'session-123', session_url: 'https://test.netlify.live', state: 'connecting' }), + } as never) + .mockResolvedValueOnce({ + status: 200, + json: () => Promise.resolve({ id: 'session-123', state: 'online' }), + } as never) + + await startLiveTunnel({ + siteId: 'site-456', + netlifyApiToken: 'fake-token', + localPort: 8888, + slug: 'test', + }) + + expect(vi.mocked(fetchLatestVersion)).not.toHaveBeenCalled() + }) + + test('should exit with error if siteId is missing', async () => { + await expect( + startLiveTunnel({ + siteId: undefined, + netlifyApiToken: 'fake-token', + localPort: 8888, + slug: 'test', + }), + ).rejects.toThrowError('process.exit(1)') + }) + + test('should exit with error if netlifyApiToken is missing', async () => { + await expect( + startLiveTunnel({ + siteId: 'site-456', + netlifyApiToken: null, + localPort: 8888, + slug: 'test', + }), + ).rejects.toThrowError('process.exit(1)') + }) + + test('should throw if session creation fails', async () => { + const { default: fetch } = await import('node-fetch') + + vi.mocked(fetch).mockResolvedValueOnce({ + status: 422, + json: () => Promise.resolve({ message: 'Slug already taken' }), + } as never) + + await expect( + startLiveTunnel({ + siteId: 'site-456', + netlifyApiToken: 'fake-token', + localPort: 8888, + slug: 'taken-slug', + }), + ).rejects.toThrowError('Slug already taken') + }) + + test('should throw if polling returns a non-200 status', async () => { + const { default: fetch } = await import('node-fetch') + + vi.mocked(fetch) + .mockResolvedValueOnce({ + status: 201, + json: () => + Promise.resolve({ id: 'session-123', session_url: 'https://test.netlify.live', state: 'connecting' }), + } as never) + .mockResolvedValueOnce({ + status: 500, + json: () => Promise.resolve({ message: 'Internal server error' }), + } as never) + + await expect( + startLiveTunnel({ + siteId: 'site-456', + netlifyApiToken: 'fake-token', + localPort: 8888, + slug: 'test', + }), + ).rejects.toThrowError('Internal server error') + }) +}) + +describe('getLiveTunnelSlug', () => { + test('should return override if provided', () => { + const getMock = vi.fn() + const state = { get: getMock, set: vi.fn() } as unknown as Parameters[0] + expect(getLiveTunnelSlug(state, 'custom-slug')).toBe('custom-slug') + expect(getMock).not.toHaveBeenCalled() + }) + + test('should return existing slug from state', () => { + const state = { + get: vi.fn().mockReturnValue('existing-slug'), + set: vi.fn(), + } as unknown as Parameters[0] + + expect(getLiveTunnelSlug(state, undefined)).toBe('existing-slug') + }) + + test('should generate and persist a new slug if none exists', () => { + const setMock = vi.fn() + const state = { + get: vi.fn().mockReturnValue(undefined), + set: setMock, + } as unknown as Parameters[0] + + const slug = getLiveTunnelSlug(state, undefined) + expect(slug).toMatch(/^[\da-f]{8}$/) + expect(setMock).toHaveBeenCalledWith('liveTunnelSlug', slug) + }) +}) From bf553537f795708939518fba041079976934a9d4 Mon Sep 17 00:00:00 2001 From: Philippe Serhal Date: Thu, 12 Mar 2026 08:43:51 -0400 Subject: [PATCH 2/8] fix: support Windows out of the box MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Windows, zip extraction now uses PowerShell’s `Expand-Archive`. Expand-Archive ships with PowerShell 5.0+, which is built into Windows 10 RTM (2015) and Server 2016+, well below the Node 20.12.2 requirement’s floor of Windows 10 1809 / Server 2019, so this is not a breaking change. --- src/lib/exec-fetcher.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/lib/exec-fetcher.ts b/src/lib/exec-fetcher.ts index 4beeecc7144..283abc14d17 100644 --- a/src/lib/exec-fetcher.ts +++ b/src/lib/exec-fetcher.ts @@ -76,7 +76,15 @@ const downloadAndExtract = async (url: string, destination: string): Promise Date: Thu, 12 Mar 2026 08:50:59 -0400 Subject: [PATCH 3/8] fix: remove unnecessary over-specific check --- src/lib/exec-fetcher.ts | 6 +----- tests/unit/lib/exec-fetcher.test.ts | 5 ++--- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/src/lib/exec-fetcher.ts b/src/lib/exec-fetcher.ts index 283abc14d17..f0ea595ef22 100644 --- a/src/lib/exec-fetcher.ts +++ b/src/lib/exec-fetcher.ts @@ -34,11 +34,7 @@ const resolveLatestTag = async (repository: string): Promise => { headers: getGitHubHeaders(), }) if (!response.ok) { - const text = await response.text() - if (response.status === 403 && text.includes('API rate limit exceeded')) { - throw new Error('GitHub API rate limit exceeded') - } - throw new Error(`Failed to fetch latest release for ${repository}: ${String(response.status)} ${text}`) + throw new Error(`Failed to fetch latest release for ${repository}: ${String(response.status)}`) } const data = (await response.json()) as { tag_name: string } return data.tag_name diff --git a/tests/unit/lib/exec-fetcher.test.ts b/tests/unit/lib/exec-fetcher.test.ts index e42e95f6c67..9703f7cca0f 100644 --- a/tests/unit/lib/exec-fetcher.test.ts +++ b/tests/unit/lib/exec-fetcher.test.ts @@ -154,11 +154,10 @@ describe('fetchLatestVersion', () => { ) }) - test('should throw on GitHub API rate limit', async () => { + test('should throw on GitHub API error (e.g. rate limit)', async () => { vi.mocked(fetch).mockResolvedValueOnce({ ok: false, status: 403, - text: () => Promise.resolve('{"message":"API rate limit exceeded for ..."}'), } as unknown as Response) await expect( @@ -168,7 +167,7 @@ describe('fetchLatestVersion', () => { destination: '', extension: 'tar.gz', }), - ).rejects.toThrowError(/GitHub API rate limit exceeded/) + ).rejects.toThrowError(/Failed to fetch latest release.*403/) }) test('should download and extract a tar.gz release', async () => { From d71eab335f133895e12d81bd3180749f3587b5bc Mon Sep 17 00:00:00 2001 From: Philippe Serhal Date: Thu, 12 Mar 2026 08:53:18 -0400 Subject: [PATCH 4/8] chore: use vi.stubEnv --- tests/unit/lib/exec-fetcher.test.ts | 55 ++++++++++++----------------- 1 file changed, 23 insertions(+), 32 deletions(-) diff --git a/tests/unit/lib/exec-fetcher.test.ts b/tests/unit/lib/exec-fetcher.test.ts index 9703f7cca0f..f8eb1a10dc2 100644 --- a/tests/unit/lib/exec-fetcher.test.ts +++ b/tests/unit/lib/exec-fetcher.test.ts @@ -201,39 +201,30 @@ describe('fetchLatestVersion', () => { }) test('should include auth header when NETLIFY_TEST_GITHUB_TOKEN is set', async () => { - const originalToken: string | undefined = process.env.NETLIFY_TEST_GITHUB_TOKEN - process.env.NETLIFY_TEST_GITHUB_TOKEN = 'test-token-123' + vi.stubEnv('NETLIFY_TEST_GITHUB_TOKEN', 'test-token-123') - try { - vi.mocked(fetch).mockResolvedValue({ - ok: false, - status: 500, - body: null, - text: () => Promise.resolve('error'), - } as unknown as Response) + vi.mocked(fetch).mockResolvedValue({ + ok: false, + status: 500, + body: null, + text: () => Promise.resolve('error'), + } as unknown as Response) - await expect( - fetchLatestVersion({ - packageName: 'traffic-mesh-agent', - execName: 'traffic-mesh', - destination: '', - extension: 'tar.gz', - latestVersion: 'v1.0.0', - }), - ).rejects.toThrowError() - - expect(vi.mocked(fetch)).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - headers: expect.objectContaining({ Authorization: 'token test-token-123' }) as unknown, - }), - ) - } finally { - if (originalToken === undefined) { - delete process.env.NETLIFY_TEST_GITHUB_TOKEN - } else { - process.env.NETLIFY_TEST_GITHUB_TOKEN = originalToken - } - } + await expect( + fetchLatestVersion({ + packageName: 'traffic-mesh-agent', + execName: 'traffic-mesh', + destination: '', + extension: 'tar.gz', + latestVersion: 'v1.0.0', + }), + ).rejects.toThrowError() + + expect(vi.mocked(fetch)).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: 'token test-token-123' }) as unknown, + }), + ) }) }) From c68b58d6cbe7ed772c8ad2420f6ce05af2926a0e Mon Sep 17 00:00:00 2001 From: Philippe Serhal Date: Thu, 12 Mar 2026 08:55:28 -0400 Subject: [PATCH 5/8] refactor: remove unnecessary node-fetch in live tunnel --- src/utils/live-tunnel.ts | 1 - tests/unit/utils/live-tunnel.test.ts | 17 ++--------------- 2 files changed, 2 insertions(+), 16 deletions(-) diff --git a/src/utils/live-tunnel.ts b/src/utils/live-tunnel.ts index 4708bd41647..39918666deb 100644 --- a/src/utils/live-tunnel.ts +++ b/src/utils/live-tunnel.ts @@ -1,6 +1,5 @@ import { platform } from 'process' -import fetch from 'node-fetch' import pWaitFor from 'p-wait-for' import { v4 as uuidv4 } from 'uuid' diff --git a/tests/unit/utils/live-tunnel.test.ts b/tests/unit/utils/live-tunnel.test.ts index dc7153bf950..e2b04fd89bf 100644 --- a/tests/unit/utils/live-tunnel.test.ts +++ b/tests/unit/utils/live-tunnel.test.ts @@ -4,10 +4,6 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' import { startLiveTunnel, getLiveTunnelSlug } from '../../../src/utils/live-tunnel.js' -vi.mock('node-fetch', () => ({ - default: vi.fn(), -})) - vi.mock('../../../src/lib/exec-fetcher.js', () => ({ shouldFetchLatestVersion: vi.fn().mockResolvedValue(false), fetchLatestVersion: vi.fn().mockResolvedValue(undefined), @@ -50,16 +46,16 @@ vi.mock('p-wait-for', () => ({ beforeEach(() => { vi.clearAllMocks() + vi.stubGlobal('fetch', vi.fn()) }) afterEach(() => { vi.restoreAllMocks() + vi.unstubAllGlobals() }) describe('startLiveTunnel', () => { test('should create a live tunnel session and return the session URL', async () => { - const { default: fetch } = await import('node-fetch') - vi.mocked(fetch) .mockResolvedValueOnce({ status: 201, @@ -97,8 +93,6 @@ describe('startLiveTunnel', () => { }) test('should poll until the session is online', async () => { - const { default: fetch } = await import('node-fetch') - vi.mocked(fetch) .mockResolvedValueOnce({ status: 201, @@ -125,7 +119,6 @@ describe('startLiveTunnel', () => { }) test('should spawn the tunnel client binary with correct args', async () => { - const { default: fetch } = await import('node-fetch') const { default: execa } = await import('../../../src/utils/execa.js') vi.mocked(fetch) @@ -154,7 +147,6 @@ describe('startLiveTunnel', () => { }) test('should install the tunnel client if shouldFetchLatestVersion returns true', async () => { - const { default: fetch } = await import('node-fetch') const { shouldFetchLatestVersion, fetchLatestVersion } = await import('../../../src/lib/exec-fetcher.js') vi.mocked(shouldFetchLatestVersion).mockResolvedValueOnce(true) @@ -186,7 +178,6 @@ describe('startLiveTunnel', () => { }) test('should not install the tunnel client if shouldFetchLatestVersion returns false', async () => { - const { default: fetch } = await import('node-fetch') const { fetchLatestVersion } = await import('../../../src/lib/exec-fetcher.js') vi.mocked(fetch) @@ -233,8 +224,6 @@ describe('startLiveTunnel', () => { }) test('should throw if session creation fails', async () => { - const { default: fetch } = await import('node-fetch') - vi.mocked(fetch).mockResolvedValueOnce({ status: 422, json: () => Promise.resolve({ message: 'Slug already taken' }), @@ -251,8 +240,6 @@ describe('startLiveTunnel', () => { }) test('should throw if polling returns a non-200 status', async () => { - const { default: fetch } = await import('node-fetch') - vi.mocked(fetch) .mockResolvedValueOnce({ status: 201, From 4c4df910fd18404bdb3d42cc43b17d75963d046f Mon Sep 17 00:00:00 2001 From: Philippe Serhal Date: Thu, 12 Mar 2026 09:09:50 -0400 Subject: [PATCH 6/8] chore: simplify tests --- tests/unit/lib/exec-fetcher.test.ts | 158 ++++++---------- tests/unit/utils/live-tunnel.test.ts | 273 ++++++++------------------- 2 files changed, 140 insertions(+), 291 deletions(-) diff --git a/tests/unit/lib/exec-fetcher.test.ts b/tests/unit/lib/exec-fetcher.test.ts index f8eb1a10dc2..6121ca2e952 100644 --- a/tests/unit/lib/exec-fetcher.test.ts +++ b/tests/unit/lib/exec-fetcher.test.ts @@ -1,4 +1,4 @@ -import { readFile } from 'fs/promises' +import { readFile, rm } from 'fs/promises' import os from 'os' import path from 'path' import process from 'process' @@ -32,36 +32,41 @@ afterAll(() => { vi.restoreAllMocks() }) -test(`should use 386 if process architecture is ia32`, () => { +const FETCH_ARGS = { + packageName: 'traffic-mesh-agent', + execName: 'traffic-mesh', + destination: '', + extension: 'tar.gz', + latestVersion: 'v1.0.0', +} as const + +test('getArch maps ia32 to 386', () => { processArchSpy.mockReturnValue('ia32') expect(getArch()).toBe('386') }) -test(`should use amd64 if process architecture is x64`, () => { +test('getArch maps x64 to amd64', () => { processArchSpy.mockReturnValue('x64') expect(getArch()).toBe('amd64') }) -test(`should append .exe on windows for the executable name`, () => { +test('getExecName appends .exe on Windows', () => { processPlatformSpy.mockReturnValue('win32') - const execName = 'some-binary-file' - expect(getExecName({ execName })).toBe(`${execName}.exe`) + expect(getExecName({ execName: 'some-binary' })).toBe('some-binary.exe') }) -test(`should not append anything on darwin to executable`, () => { +test('getExecName leaves the name unchanged on macOS', () => { processPlatformSpy.mockReturnValue('darwin') - const execName = 'some-binary-file' - expect(getExecName({ execName })).toBe(execName) + expect(getExecName({ execName: 'some-binary' })).toBe('some-binary') }) -test(`should not append anything on linux to executable`, () => { +test('getExecName leaves the name unchanged on Linux', () => { processPlatformSpy.mockReturnValue('linux') - const execName = 'some-binary-file' - expect(getExecName({ execName })).toBe(execName) + expect(getExecName({ execName: 'some-binary' })).toBe('some-binary') }) describe('fetchLatestVersion', () => { - test('should throw a user-friendly error on 404', async () => { + test('throws a user-friendly error on 404 mentioning OS and arch', async () => { processArchSpy.mockReturnValue('x64') processPlatformSpy.mockReturnValue('win32') vi.mocked(fetch).mockResolvedValue({ @@ -71,18 +76,12 @@ describe('fetchLatestVersion', () => { text: () => Promise.resolve('Not Found'), } as unknown as Response) - await expect( - fetchLatestVersion({ - packageName: 'traffic-mesh-agent', - execName: 'traffic-mesh', - destination: '', - extension: 'zip', - latestVersion: 'v1.0.0', - }), - ).rejects.toThrowError(/The operating system windows with the CPU architecture amd64 is currently not supported!/) + await expect(fetchLatestVersion({ ...FETCH_ARGS, extension: 'zip' })).rejects.toThrowError( + /The operating system windows with the CPU architecture amd64 is currently not supported!/, + ) }) - test('should propagate non-404 errors', async () => { + test('throws the HTTP status on non-404 download errors', async () => { vi.mocked(fetch).mockResolvedValue({ ok: false, status: 500, @@ -90,18 +89,10 @@ describe('fetchLatestVersion', () => { text: () => Promise.resolve('Internal Server Error'), } as unknown as Response) - await expect( - fetchLatestVersion({ - packageName: 'traffic-mesh-agent', - execName: 'traffic-mesh', - destination: '', - extension: 'zip', - latestVersion: 'v1.0.0', - }), - ).rejects.toThrowError(/Download failed: 500/) + await expect(fetchLatestVersion(FETCH_ARGS)).rejects.toThrowError(/Download failed: 500/) }) - test('should map linux x64 to amd64 arch', async () => { + test('includes the platform and arch in the 404 error for linux-x64', async () => { processArchSpy.mockReturnValue('x64') processPlatformSpy.mockReturnValue('linux') vi.mocked(fetch).mockResolvedValue({ @@ -111,23 +102,14 @@ describe('fetchLatestVersion', () => { text: () => Promise.resolve('Not Found'), } as unknown as Response) - await expect( - fetchLatestVersion({ - packageName: 'traffic-mesh-agent', - execName: 'traffic-mesh', - destination: '', - extension: 'zip', - latestVersion: 'v1.0.0', - }), - ).rejects.toThrowError(/The operating system linux with the CPU architecture amd64 is currently not supported!/) + await expect(fetchLatestVersion(FETCH_ARGS)).rejects.toThrowError( + /The operating system linux with the CPU architecture amd64 is currently not supported!/, + ) }) - test('should resolve latest tag from GitHub API when latestVersion is not provided', async () => { + test('resolves the latest tag from GitHub when latestVersion is omitted', async () => { vi.mocked(fetch) - .mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({ tag_name: 'v2.0.0' }), - } as unknown as Response) + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ tag_name: 'v2.0.0' }) } as unknown as Response) .mockResolvedValueOnce({ ok: false, status: 404, @@ -135,14 +117,7 @@ describe('fetchLatestVersion', () => { text: () => Promise.resolve('Not Found'), } as unknown as Response) - await expect( - fetchLatestVersion({ - packageName: 'traffic-mesh-agent', - execName: 'traffic-mesh', - destination: '', - extension: 'tar.gz', - }), - ).rejects.toThrowError() + await expect(fetchLatestVersion({ ...FETCH_ARGS, latestVersion: undefined })).rejects.toThrowError() expect(vi.mocked(fetch)).toHaveBeenCalledWith( 'https://api.github.com/repos/netlify/traffic-mesh-agent/releases/latest', @@ -154,53 +129,44 @@ describe('fetchLatestVersion', () => { ) }) - test('should throw on GitHub API error (e.g. rate limit)', async () => { - vi.mocked(fetch).mockResolvedValueOnce({ - ok: false, - status: 403, - } as unknown as Response) + test('throws when the GitHub releases API returns an error', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ ok: false, status: 403 } as unknown as Response) - await expect( - fetchLatestVersion({ - packageName: 'traffic-mesh-agent', - execName: 'traffic-mesh', - destination: '', - extension: 'tar.gz', - }), - ).rejects.toThrowError(/Failed to fetch latest release.*403/) + await expect(fetchLatestVersion({ ...FETCH_ARGS, latestVersion: undefined })).rejects.toThrowError( + /Failed to fetch latest release.*403/, + ) }) - test('should download and extract a tar.gz release', async () => { + test('downloads and extracts a tar.gz release to the destination', async () => { const destination = path.join(os.tmpdir(), `exec-fetcher-test-${String(Date.now())}`) const fileContent = 'hello from test binary' - const tarBuffer = await packTar([{ header: { name: 'test-binary', size: fileContent.length }, body: fileContent }]) - const gzipped = gzipSync(Buffer.from(tarBuffer)) + try { + const tarBuffer = await packTar([ + { header: { name: 'test-binary', size: fileContent.length }, body: fileContent }, + ]) + const gzipped = gzipSync(Buffer.from(tarBuffer)) - const body = new ReadableStream({ - start(controller) { - controller.enqueue(gzipped) - controller.close() - }, - }) - vi.mocked(fetch).mockResolvedValue({ - ok: true, - body, - } as unknown as Response) + vi.mocked(fetch).mockResolvedValue({ + ok: true, + body: new ReadableStream({ + start(controller) { + controller.enqueue(gzipped) + controller.close() + }, + }), + } as unknown as Response) - await fetchLatestVersion({ - packageName: 'traffic-mesh-agent', - execName: 'traffic-mesh', - destination, - extension: 'tar.gz', - latestVersion: 'v1.0.0', - }) + await fetchLatestVersion({ ...FETCH_ARGS, destination }) - const extracted = await readFile(path.join(destination, 'test-binary'), 'utf-8') - expect(extracted).toBe(fileContent) + const extracted = await readFile(path.join(destination, 'test-binary'), 'utf-8') + expect(extracted).toBe(fileContent) + } finally { + await rm(destination, { recursive: true, force: true }) + } }) - test('should include auth header when NETLIFY_TEST_GITHUB_TOKEN is set', async () => { + test('sends an Authorization header when NETLIFY_TEST_GITHUB_TOKEN is set', async () => { vi.stubEnv('NETLIFY_TEST_GITHUB_TOKEN', 'test-token-123') vi.mocked(fetch).mockResolvedValue({ @@ -210,15 +176,7 @@ describe('fetchLatestVersion', () => { text: () => Promise.resolve('error'), } as unknown as Response) - await expect( - fetchLatestVersion({ - packageName: 'traffic-mesh-agent', - execName: 'traffic-mesh', - destination: '', - extension: 'tar.gz', - latestVersion: 'v1.0.0', - }), - ).rejects.toThrowError() + await expect(fetchLatestVersion(FETCH_ARGS)).rejects.toThrowError() expect(vi.mocked(fetch)).toHaveBeenCalledWith( expect.any(String), diff --git a/tests/unit/utils/live-tunnel.test.ts b/tests/unit/utils/live-tunnel.test.ts index e2b04fd89bf..3cf67b6f3c4 100644 --- a/tests/unit/utils/live-tunnel.test.ts +++ b/tests/unit/utils/live-tunnel.test.ts @@ -3,6 +3,7 @@ import { EventEmitter } from 'events' import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' import { startLiveTunnel, getLiveTunnelSlug } from '../../../src/utils/live-tunnel.js' +import type { LocalState } from '../../../src/utils/types.js' vi.mock('../../../src/lib/exec-fetcher.js', () => ({ shouldFetchLatestVersion: vi.fn().mockResolvedValue(false), @@ -13,27 +14,19 @@ vi.mock('../../../src/lib/settings.js', () => ({ getPathInHome: vi.fn((...args: string[][]) => `/mock/home/${args.flat().join('/')}`), })) -const createMockProcess = () => { - const ps = new EventEmitter() - return ps -} - vi.mock('../../../src/utils/execa.js', () => ({ - default: vi.fn(() => createMockProcess()), + default: vi.fn(() => new EventEmitter()), })) -vi.mock('../../../src/utils/command-helpers.js', async () => { - const actual = await vi.importActual( +vi.mock('../../../src/utils/command-helpers.js', async () => ({ + ...(await vi.importActual( '../../../src/utils/command-helpers.js', - ) - return { - ...actual, - exit: vi.fn((code?: number) => { - throw new Error(`process.exit(${String(code)})`) - }), - log: vi.fn(), - } -}) + )), + exit: vi.fn((code?: number) => { + throw new Error(`process.exit(${String(code)})`) + }), + log: vi.fn(), +})) vi.mock('p-wait-for', () => ({ default: vi.fn(async (fn: () => Promise) => { @@ -44,6 +37,16 @@ vi.mock('p-wait-for', () => ({ }), })) +const mockFetchResponse = (status: number, body: Record) => + ({ status, json: () => Promise.resolve(body) } as never) + +const TUNNEL_ARGS = { + siteId: 'site-456', + netlifyApiToken: 'fake-token', + localPort: 8888, + slug: 'test', +} as const + beforeEach(() => { vi.clearAllMocks() vi.stubGlobal('fetch', vi.fn()) @@ -55,89 +58,52 @@ afterEach(() => { }) describe('startLiveTunnel', () => { - test('should create a live tunnel session and return the session URL', async () => { + const mockSessionCreatedThenOnline = (sessionId = 'session-123', sessionUrl = 'https://test.netlify.live') => { vi.mocked(fetch) - .mockResolvedValueOnce({ - status: 201, - json: () => - Promise.resolve({ - id: 'session-123', - session_url: 'https://test--my-site.netlify.live', - state: 'connecting', - }), - } as never) - .mockResolvedValueOnce({ - status: 200, - json: () => - Promise.resolve({ id: 'session-123', session_url: 'https://test--my-site.netlify.live', state: 'online' }), - } as never) - - const result = await startLiveTunnel({ - siteId: 'site-456', - netlifyApiToken: 'fake-token', - localPort: 8888, - slug: 'test', - }) + .mockResolvedValueOnce(mockFetchResponse(201, { id: sessionId, session_url: sessionUrl, state: 'connecting' })) + .mockResolvedValueOnce(mockFetchResponse(200, { id: sessionId, state: 'online' })) + } + + test('returns the session URL', async () => { + mockSessionCreatedThenOnline('session-123', 'https://test--my-site.netlify.live') + + const result = await startLiveTunnel(TUNNEL_ARGS) expect(result).toBe('https://test--my-site.netlify.live') + }) + + test('creates a session via the Netlify API with auth', async () => { + mockSessionCreatedThenOnline() + + await startLiveTunnel(TUNNEL_ARGS) expect(vi.mocked(fetch)).toHaveBeenCalledWith( 'https://api.netlify.com/api/v1/live_sessions?site_id=site-456&slug=test', expect.objectContaining({ method: 'POST', - headers: expect.objectContaining({ - Authorization: 'Bearer fake-token', - }) as unknown, + headers: expect.objectContaining({ Authorization: 'Bearer fake-token' }) as unknown, }), ) }) - test('should poll until the session is online', async () => { + test('polls the session until it is online', async () => { vi.mocked(fetch) - .mockResolvedValueOnce({ - status: 201, - json: () => - Promise.resolve({ id: 'session-123', session_url: 'https://test.netlify.live', state: 'connecting' }), - } as never) - .mockResolvedValueOnce({ - status: 200, - json: () => Promise.resolve({ id: 'session-123', state: 'booting' }), - } as never) - .mockResolvedValueOnce({ - status: 200, - json: () => Promise.resolve({ id: 'session-123', state: 'online' }), - } as never) - - await startLiveTunnel({ - siteId: 'site-456', - netlifyApiToken: 'fake-token', - localPort: 8888, - slug: 'test', - }) + .mockResolvedValueOnce( + mockFetchResponse(201, { id: 'session-123', session_url: 'https://test.netlify.live', state: 'connecting' }), + ) + .mockResolvedValueOnce(mockFetchResponse(200, { id: 'session-123', state: 'booting' })) + .mockResolvedValueOnce(mockFetchResponse(200, { id: 'session-123', state: 'online' })) + + await startLiveTunnel(TUNNEL_ARGS) expect(vi.mocked(fetch)).toHaveBeenCalledTimes(3) }) - test('should spawn the tunnel client binary with correct args', async () => { + test('spawns the tunnel client binary with the session ID and port', async () => { const { default: execa } = await import('../../../src/utils/execa.js') + mockSessionCreatedThenOnline('session-abc') - vi.mocked(fetch) - .mockResolvedValueOnce({ - status: 201, - json: () => - Promise.resolve({ id: 'session-abc', session_url: 'https://test.netlify.live', state: 'connecting' }), - } as never) - .mockResolvedValueOnce({ - status: 200, - json: () => Promise.resolve({ id: 'session-abc', state: 'online' }), - } as never) - - await startLiveTunnel({ - siteId: 'site-456', - netlifyApiToken: 'fake-token', - localPort: 3000, - slug: 'test', - }) + await startLiveTunnel({ ...TUNNEL_ARGS, localPort: 3000 }) expect(vi.mocked(execa)).toHaveBeenCalledWith( '/mock/home/tunnel/bin/live-tunnel-client', @@ -146,148 +112,73 @@ describe('startLiveTunnel', () => { ) }) - test('should install the tunnel client if shouldFetchLatestVersion returns true', async () => { + test('installs the tunnel client when a new version is available', async () => { const { shouldFetchLatestVersion, fetchLatestVersion } = await import('../../../src/lib/exec-fetcher.js') - vi.mocked(shouldFetchLatestVersion).mockResolvedValueOnce(true) + mockSessionCreatedThenOnline() - vi.mocked(fetch) - .mockResolvedValueOnce({ - status: 201, - json: () => - Promise.resolve({ id: 'session-123', session_url: 'https://test.netlify.live', state: 'connecting' }), - } as never) - .mockResolvedValueOnce({ - status: 200, - json: () => Promise.resolve({ id: 'session-123', state: 'online' }), - } as never) - - await startLiveTunnel({ - siteId: 'site-456', - netlifyApiToken: 'fake-token', - localPort: 8888, - slug: 'test', - }) + await startLiveTunnel(TUNNEL_ARGS) expect(vi.mocked(fetchLatestVersion)).toHaveBeenCalledWith( - expect.objectContaining({ - packageName: 'live-tunnel-client', - execName: 'live-tunnel-client', - }), + expect.objectContaining({ packageName: 'live-tunnel-client', execName: 'live-tunnel-client' }), ) }) - test('should not install the tunnel client if shouldFetchLatestVersion returns false', async () => { + test('skips installing the tunnel client when already up to date', async () => { const { fetchLatestVersion } = await import('../../../src/lib/exec-fetcher.js') + mockSessionCreatedThenOnline() - vi.mocked(fetch) - .mockResolvedValueOnce({ - status: 201, - json: () => - Promise.resolve({ id: 'session-123', session_url: 'https://test.netlify.live', state: 'connecting' }), - } as never) - .mockResolvedValueOnce({ - status: 200, - json: () => Promise.resolve({ id: 'session-123', state: 'online' }), - } as never) - - await startLiveTunnel({ - siteId: 'site-456', - netlifyApiToken: 'fake-token', - localPort: 8888, - slug: 'test', - }) + await startLiveTunnel(TUNNEL_ARGS) expect(vi.mocked(fetchLatestVersion)).not.toHaveBeenCalled() }) - test('should exit with error if siteId is missing', async () => { - await expect( - startLiveTunnel({ - siteId: undefined, - netlifyApiToken: 'fake-token', - localPort: 8888, - slug: 'test', - }), - ).rejects.toThrowError('process.exit(1)') + test('exits when siteId is missing', async () => { + await expect(startLiveTunnel({ ...TUNNEL_ARGS, siteId: undefined })).rejects.toThrowError('process.exit(1)') }) - test('should exit with error if netlifyApiToken is missing', async () => { - await expect( - startLiveTunnel({ - siteId: 'site-456', - netlifyApiToken: null, - localPort: 8888, - slug: 'test', - }), - ).rejects.toThrowError('process.exit(1)') + test('exits when netlifyApiToken is missing', async () => { + await expect(startLiveTunnel({ ...TUNNEL_ARGS, netlifyApiToken: null })).rejects.toThrowError('process.exit(1)') }) - test('should throw if session creation fails', async () => { - vi.mocked(fetch).mockResolvedValueOnce({ - status: 422, - json: () => Promise.resolve({ message: 'Slug already taken' }), - } as never) - - await expect( - startLiveTunnel({ - siteId: 'site-456', - netlifyApiToken: 'fake-token', - localPort: 8888, - slug: 'taken-slug', - }), - ).rejects.toThrowError('Slug already taken') + test('throws the API error message when session creation fails', async () => { + vi.mocked(fetch).mockResolvedValueOnce(mockFetchResponse(422, { message: 'Slug already taken' })) + + await expect(startLiveTunnel({ ...TUNNEL_ARGS, slug: 'taken-slug' })).rejects.toThrowError('Slug already taken') }) - test('should throw if polling returns a non-200 status', async () => { + test('throws the API error message when polling fails', async () => { vi.mocked(fetch) - .mockResolvedValueOnce({ - status: 201, - json: () => - Promise.resolve({ id: 'session-123', session_url: 'https://test.netlify.live', state: 'connecting' }), - } as never) - .mockResolvedValueOnce({ - status: 500, - json: () => Promise.resolve({ message: 'Internal server error' }), - } as never) - - await expect( - startLiveTunnel({ - siteId: 'site-456', - netlifyApiToken: 'fake-token', - localPort: 8888, - slug: 'test', - }), - ).rejects.toThrowError('Internal server error') + .mockResolvedValueOnce( + mockFetchResponse(201, { id: 'session-123', session_url: 'https://test.netlify.live', state: 'connecting' }), + ) + .mockResolvedValueOnce(mockFetchResponse(500, { message: 'Internal server error' })) + + await expect(startLiveTunnel(TUNNEL_ARGS)).rejects.toThrowError('Internal server error') }) }) describe('getLiveTunnelSlug', () => { - test('should return override if provided', () => { - const getMock = vi.fn() - const state = { get: getMock, set: vi.fn() } as unknown as Parameters[0] + const createMockState = (slug?: string) => { + const state = { get: vi.fn().mockReturnValue(slug), set: vi.fn() } + return state as typeof state & LocalState + } + + test('uses the override slug without reading state', () => { + const state = createMockState() expect(getLiveTunnelSlug(state, 'custom-slug')).toBe('custom-slug') - expect(getMock).not.toHaveBeenCalled() + expect(state.get).not.toHaveBeenCalled() }) - test('should return existing slug from state', () => { - const state = { - get: vi.fn().mockReturnValue('existing-slug'), - set: vi.fn(), - } as unknown as Parameters[0] - + test('returns the existing slug from state', () => { + const state = createMockState('existing-slug') expect(getLiveTunnelSlug(state, undefined)).toBe('existing-slug') }) - test('should generate and persist a new slug if none exists', () => { - const setMock = vi.fn() - const state = { - get: vi.fn().mockReturnValue(undefined), - set: setMock, - } as unknown as Parameters[0] - + test('generates a hex slug and persists it when none exists', () => { + const state = createMockState(undefined) const slug = getLiveTunnelSlug(state, undefined) expect(slug).toMatch(/^[\da-f]{8}$/) - expect(setMock).toHaveBeenCalledWith('liveTunnelSlug', slug) + expect(state.set).toHaveBeenCalledWith('liveTunnelSlug', slug) }) }) From 8dc37b4957c89724c6a14a886947ba7a52f1520d Mon Sep 17 00:00:00 2001 From: Philippe Serhal Date: Thu, 12 Mar 2026 09:26:02 -0400 Subject: [PATCH 7/8] chore: simplify mock responses --- tests/unit/lib/exec-fetcher.test.ts | 49 +++++----------------------- tests/unit/utils/live-tunnel.test.ts | 19 +++++------ 2 files changed, 17 insertions(+), 51 deletions(-) diff --git a/tests/unit/lib/exec-fetcher.test.ts b/tests/unit/lib/exec-fetcher.test.ts index 6121ca2e952..131f6cff8ef 100644 --- a/tests/unit/lib/exec-fetcher.test.ts +++ b/tests/unit/lib/exec-fetcher.test.ts @@ -69,12 +69,7 @@ describe('fetchLatestVersion', () => { test('throws a user-friendly error on 404 mentioning OS and arch', async () => { processArchSpy.mockReturnValue('x64') processPlatformSpy.mockReturnValue('win32') - vi.mocked(fetch).mockResolvedValue({ - ok: false, - status: 404, - body: null, - text: () => Promise.resolve('Not Found'), - } as unknown as Response) + vi.mocked(fetch).mockResolvedValue(new Response('Not Found', { status: 404 })) await expect(fetchLatestVersion({ ...FETCH_ARGS, extension: 'zip' })).rejects.toThrowError( /The operating system windows with the CPU architecture amd64 is currently not supported!/, @@ -82,12 +77,7 @@ describe('fetchLatestVersion', () => { }) test('throws the HTTP status on non-404 download errors', async () => { - vi.mocked(fetch).mockResolvedValue({ - ok: false, - status: 500, - body: null, - text: () => Promise.resolve('Internal Server Error'), - } as unknown as Response) + vi.mocked(fetch).mockResolvedValue(new Response('Internal Server Error', { status: 500 })) await expect(fetchLatestVersion(FETCH_ARGS)).rejects.toThrowError(/Download failed: 500/) }) @@ -95,12 +85,7 @@ describe('fetchLatestVersion', () => { test('includes the platform and arch in the 404 error for linux-x64', async () => { processArchSpy.mockReturnValue('x64') processPlatformSpy.mockReturnValue('linux') - vi.mocked(fetch).mockResolvedValue({ - ok: false, - status: 404, - body: null, - text: () => Promise.resolve('Not Found'), - } as unknown as Response) + vi.mocked(fetch).mockResolvedValue(new Response('Not Found', { status: 404 })) await expect(fetchLatestVersion(FETCH_ARGS)).rejects.toThrowError( /The operating system linux with the CPU architecture amd64 is currently not supported!/, @@ -109,13 +94,8 @@ describe('fetchLatestVersion', () => { test('resolves the latest tag from GitHub when latestVersion is omitted', async () => { vi.mocked(fetch) - .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ tag_name: 'v2.0.0' }) } as unknown as Response) - .mockResolvedValueOnce({ - ok: false, - status: 404, - body: null, - text: () => Promise.resolve('Not Found'), - } as unknown as Response) + .mockResolvedValueOnce(new Response(JSON.stringify({ tag_name: 'v2.0.0' }))) + .mockResolvedValueOnce(new Response('Not Found', { status: 404 })) await expect(fetchLatestVersion({ ...FETCH_ARGS, latestVersion: undefined })).rejects.toThrowError() @@ -130,7 +110,7 @@ describe('fetchLatestVersion', () => { }) test('throws when the GitHub releases API returns an error', async () => { - vi.mocked(fetch).mockResolvedValueOnce({ ok: false, status: 403 } as unknown as Response) + vi.mocked(fetch).mockResolvedValueOnce(new Response(null, { status: 403 })) await expect(fetchLatestVersion({ ...FETCH_ARGS, latestVersion: undefined })).rejects.toThrowError( /Failed to fetch latest release.*403/, @@ -147,15 +127,7 @@ describe('fetchLatestVersion', () => { ]) const gzipped = gzipSync(Buffer.from(tarBuffer)) - vi.mocked(fetch).mockResolvedValue({ - ok: true, - body: new ReadableStream({ - start(controller) { - controller.enqueue(gzipped) - controller.close() - }, - }), - } as unknown as Response) + vi.mocked(fetch).mockResolvedValue(new Response(gzipped)) await fetchLatestVersion({ ...FETCH_ARGS, destination }) @@ -169,12 +141,7 @@ describe('fetchLatestVersion', () => { test('sends an Authorization header when NETLIFY_TEST_GITHUB_TOKEN is set', async () => { vi.stubEnv('NETLIFY_TEST_GITHUB_TOKEN', 'test-token-123') - vi.mocked(fetch).mockResolvedValue({ - ok: false, - status: 500, - body: null, - text: () => Promise.resolve('error'), - } as unknown as Response) + vi.mocked(fetch).mockResolvedValue(new Response('error', { status: 500 })) await expect(fetchLatestVersion(FETCH_ARGS)).rejects.toThrowError() diff --git a/tests/unit/utils/live-tunnel.test.ts b/tests/unit/utils/live-tunnel.test.ts index 3cf67b6f3c4..5c1e215901c 100644 --- a/tests/unit/utils/live-tunnel.test.ts +++ b/tests/unit/utils/live-tunnel.test.ts @@ -37,8 +37,7 @@ vi.mock('p-wait-for', () => ({ }), })) -const mockFetchResponse = (status: number, body: Record) => - ({ status, json: () => Promise.resolve(body) } as never) +const jsonResponse = (status: number, body: Record) => new Response(JSON.stringify(body), { status }) const TUNNEL_ARGS = { siteId: 'site-456', @@ -60,8 +59,8 @@ afterEach(() => { describe('startLiveTunnel', () => { const mockSessionCreatedThenOnline = (sessionId = 'session-123', sessionUrl = 'https://test.netlify.live') => { vi.mocked(fetch) - .mockResolvedValueOnce(mockFetchResponse(201, { id: sessionId, session_url: sessionUrl, state: 'connecting' })) - .mockResolvedValueOnce(mockFetchResponse(200, { id: sessionId, state: 'online' })) + .mockResolvedValueOnce(jsonResponse(201, { id: sessionId, session_url: sessionUrl, state: 'connecting' })) + .mockResolvedValueOnce(jsonResponse(200, { id: sessionId, state: 'online' })) } test('returns the session URL', async () => { @@ -89,10 +88,10 @@ describe('startLiveTunnel', () => { test('polls the session until it is online', async () => { vi.mocked(fetch) .mockResolvedValueOnce( - mockFetchResponse(201, { id: 'session-123', session_url: 'https://test.netlify.live', state: 'connecting' }), + jsonResponse(201, { id: 'session-123', session_url: 'https://test.netlify.live', state: 'connecting' }), ) - .mockResolvedValueOnce(mockFetchResponse(200, { id: 'session-123', state: 'booting' })) - .mockResolvedValueOnce(mockFetchResponse(200, { id: 'session-123', state: 'online' })) + .mockResolvedValueOnce(jsonResponse(200, { id: 'session-123', state: 'booting' })) + .mockResolvedValueOnce(jsonResponse(200, { id: 'session-123', state: 'online' })) await startLiveTunnel(TUNNEL_ARGS) @@ -142,7 +141,7 @@ describe('startLiveTunnel', () => { }) test('throws the API error message when session creation fails', async () => { - vi.mocked(fetch).mockResolvedValueOnce(mockFetchResponse(422, { message: 'Slug already taken' })) + vi.mocked(fetch).mockResolvedValueOnce(jsonResponse(422, { message: 'Slug already taken' })) await expect(startLiveTunnel({ ...TUNNEL_ARGS, slug: 'taken-slug' })).rejects.toThrowError('Slug already taken') }) @@ -150,9 +149,9 @@ describe('startLiveTunnel', () => { test('throws the API error message when polling fails', async () => { vi.mocked(fetch) .mockResolvedValueOnce( - mockFetchResponse(201, { id: 'session-123', session_url: 'https://test.netlify.live', state: 'connecting' }), + jsonResponse(201, { id: 'session-123', session_url: 'https://test.netlify.live', state: 'connecting' }), ) - .mockResolvedValueOnce(mockFetchResponse(500, { message: 'Internal server error' })) + .mockResolvedValueOnce(jsonResponse(500, { message: 'Internal server error' })) await expect(startLiveTunnel(TUNNEL_ARGS)).rejects.toThrowError('Internal server error') }) From 3bd58241b62a10140c6819d65ebdeeb495113163 Mon Sep 17 00:00:00 2001 From: Philippe Serhal Date: Mon, 16 Mar 2026 07:23:02 -0400 Subject: [PATCH 8/8] =?UTF-8?q?fix:=20address=20=F0=9F=90=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/exec-fetcher.ts | 2 +- tests/unit/lib/exec-fetcher.test.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/lib/exec-fetcher.ts b/src/lib/exec-fetcher.ts index f0ea595ef22..e30efff17d9 100644 --- a/src/lib/exec-fetcher.ts +++ b/src/lib/exec-fetcher.ts @@ -76,7 +76,7 @@ const downloadAndExtract = async (url: string, destination: string): Promise { afterEach(() => { vi.clearAllMocks() vi.unstubAllGlobals() + vi.unstubAllEnvs() processArchSpy.mockReset() processPlatformSpy.mockReset() })