From d05706f1f8fc9d88cfcbcf936aa2f155107ba8c5 Mon Sep 17 00:00:00 2001 From: Diego Molina Date: Thu, 5 Mar 2026 17:43:51 +0100 Subject: [PATCH 1/3] Add support for local binary installation in binWrapper function --- README.md | 63 +++++++++++++++++++++-- __tests__/main.test.js | 111 +++++++++++++++++++++++++++++++++++++++++ index.js | 33 +++++++++++- install.js | 3 +- 4 files changed, 203 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 411ec98..219f126 100644 --- a/README.md +++ b/README.md @@ -16,30 +16,83 @@ The command should be globally available: ```sh $ saucectl -v -saucectl version 0.4.0 -(build 7468a24c788b4ca4d67d50372c839edf03e5df6a) +saucectl version 0.197.2 +(build a301436d5b178bcfda8db106c50bfb78ac5be679) ``` __Note:__ When you run the command for the first time, it will initially download the binary. This only happens once. -__Note:__ `saucectl` installation is disabled on Sauce Labs Cloud. If you wish to force the installation, set the `SAUCECTL_FORCE_INSTALL` environment variable to `true`. +__Note:__ `saucectl` installation is disabled on Sauce Labs Cloud. If you wish to force the installation, set +the `SAUCECTL_FORCE_INSTALL` environment variable to `true`. ### Install Binary from a Specified Source If you want the installer to download `saucectl` from a specific source, set the following environment variable: -``` +**macOS/Linux:** +```bash export SAUCECTL_INSTALL_BINARY=http://localhost:9000/saucectl_0.32.2_mac_64-bit.tar.gz ``` +**Windows (PowerShell):** +```powershell +$env:SAUCECTL_INSTALL_BINARY = "http://localhost:9000/saucectl_0.32.2_win_64-bit.zip" +``` + +**Windows (Command Prompt):** +```cmd +set SAUCECTL_INSTALL_BINARY=http://localhost:9000/saucectl_0.32.2_win_64-bit.zip +``` + ### Install Binary from a Mirror Site -Override the default download site by setting the `SAUCECTL_INSTALL_BINARY_MIRROR` environment variable to a custom URL. The default site is [Sauce Labs saucectl releases](https://github.com/saucelabs/saucectl/releases/download). +Override the default download site by setting the `SAUCECTL_INSTALL_BINARY_MIRROR` environment variable to a +custom URL. The default site is [Sauce Labs saucectl releases](https://github.com/saucelabs/saucectl/releases/download). +**macOS/Linux:** ```bash SAUCECTL_INSTALL_BINARY_MIRROR=https://your-mirror-download-site.com/foo/bar npm i -g saucectl ``` +**Windows (PowerShell):** +```powershell +$env:SAUCECTL_INSTALL_BINARY_MIRROR = "https://your-mirror-download-site.com/foo/bar" +npm i -g saucectl +``` + +**Windows (Command Prompt):** +```cmd +set SAUCECTL_INSTALL_BINARY_MIRROR=https://your-mirror-download-site.com/foo/bar +npm i -g saucectl +``` + +### Use a Local Binary + +If you already have a `saucectl` binary on your machine, you can point the installer directly to it by setting +the `SAUCECTL_INSTALL_BINARY_LOCAL` environment variable to the absolute (or relative) path of the binary. +No download will occur. The binary will be copied into the package's `bin` directory. + +**macOS/Linux:** +```bash +export SAUCECTL_INSTALL_BINARY_LOCAL=/usr/local/bin/saucectl +npm i -g saucectl +``` + +**Windows (PowerShell):** +```powershell +$env:SAUCECTL_INSTALL_BINARY_LOCAL = "C:\tools\saucectl.exe" +npm i -g saucectl +``` + +**Windows (Command Prompt):** +```cmd +set SAUCECTL_INSTALL_BINARY_LOCAL=C:\tools\saucectl.exe +npm i -g saucectl +``` + +> **Note:** `SAUCECTL_INSTALL_BINARY_LOCAL` takes precedence over `SAUCECTL_INSTALL_BINARY` and +> `SAUCECTL_INSTALL_BINARY_MIRROR` if multiple variables are set simultaneously. + --- For more information about `saucectl`, visit its main repository: [saucelabs/saucectl](https://github.com/saucelabs/saucectl). diff --git a/__tests__/main.test.js b/__tests__/main.test.js index c6627a9..488ec2f 100644 --- a/__tests__/main.test.js +++ b/__tests__/main.test.js @@ -3,9 +3,12 @@ const main = require('../'); const childProcess = require('child_process'); const { EventEmitter } = require('events'); const packageJson = require('../package.json'); +const path = require('path'); +const fs = require('fs'); const { BinWrapper } = require('@saucelabs/bin-wrapper'); jest.mock('@saucelabs/bin-wrapper'); +jest.mock('fs'); let mockSrc; @@ -36,7 +39,14 @@ describe('main', function () { dest: jest.fn(), src: mockSrc, run: jest.fn(), + httpOptions: jest.fn(), }); + + fs.existsSync.mockReturnValue(true); + fs.statSync.mockReturnValue({ isFile: () => true }); + fs.mkdirSync.mockReturnValue(undefined); + fs.copyFileSync.mockReturnValue(undefined); + fs.chmodSync.mockReturnValue(undefined); }); afterEach(() => { jest.clearAllMocks(); @@ -109,5 +119,106 @@ describe('main', function () { ], ]); }); + + test('should use the local binary when SAUCECTL_INSTALL_BINARY_LOCAL is set', async function () { + const localPath = '/usr/local/bin/saucectl'; + const mockDest = jest.fn(); + const mockUse = jest.fn(); + BinWrapper.mockReturnValueOnce({ + use: mockUse, + dest: mockDest, + src: mockSrc, + run: jest.fn(), + httpOptions: jest.fn(), + }); + + const bw = await main.binWrapper(null, null, localPath); + + expect(bw).toBeDefined(); + expect(BinWrapper).toHaveBeenCalled(); + // should copy into the bin dir, not the original location + expect(fs.mkdirSync).toHaveBeenCalledWith(expect.stringContaining('bin'), { recursive: true }); + expect(fs.copyFileSync).toHaveBeenCalledWith( + path.resolve(localPath), + expect.stringContaining('saucectl'), + ); + expect(mockDest).toHaveBeenCalledWith(expect.stringContaining('bin')); + expect(mockUse).toHaveBeenCalledWith('saucectl'); + // src should never be called — no download + expect(mockSrc).not.toHaveBeenCalled(); + }); + + test('should return undefined and log an error when SAUCECTL_INSTALL_BINARY_LOCAL path does not exist', async function () { + fs.existsSync.mockReturnValue(false); + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + const bw = await main.binWrapper(null, null, '/nonexistent/saucectl'); + + expect(bw).toBeUndefined(); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('SAUCECTL_INSTALL_BINARY_LOCAL')); + consoleSpy.mockRestore(); + }); + + test('should return undefined and log an error when SAUCECTL_INSTALL_BINARY_LOCAL points to a directory', async function () { + fs.existsSync.mockReturnValue(true); + fs.statSync.mockReturnValue({ isFile: () => false }); + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + const bw = await main.binWrapper(null, null, '/some/directory'); + + expect(bw).toBeUndefined(); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('SAUCECTL_INSTALL_BINARY_LOCAL')); + consoleSpy.mockRestore(); + }); + + test('should warn when SAUCECTL_INSTALL_BINARY_LOCAL is set alongside SAUCECTL_INSTALL_BINARY', async function () { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + const bw = await main.binWrapper('http://some-url', null, '/usr/local/bin/saucectl'); + + expect(bw).toBeDefined(); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('SAUCECTL_INSTALL_BINARY_LOCAL')); + warnSpy.mockRestore(); + }); + + test('should return undefined and log an error when SAUCECTL_INSTALL_BINARY is an invalid URL', async function () { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + const bw = await main.binWrapper('not-a-valid-url'); + + expect(bw).toBeUndefined(); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('saucectl binary source is valid')); + consoleSpy.mockRestore(); + }); + + test('should return undefined and log an error when SAUCECTL_INSTALL_BINARY_MIRROR is an invalid URL', async function () { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + const bw = await main.binWrapper(null, 'not-a-valid-mirror'); + + expect(bw).toBeUndefined(); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('saucectl binary source mirror is valid')); + consoleSpy.mockRestore(); + }); + + test('should reject when no binary matches the current platform/arch', async function () { + Object.defineProperty(process, 'platform', { value: 'freebsd' }); + Object.defineProperty(process, 'arch', { value: 'mips' }); + + await expect(main.binWrapper()).rejects.toThrow('No binary found matching your system'); + }); + + test('should handle preRun failure and exit with non-zero code', async function () { + const bin = { + run: jest.fn().mockResolvedValue(1), + path: jest.fn().mockReturnValue('/bin/saucectl'), + }; + const consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + + await main(bin, []); + + expect(exitSpy).toHaveBeenCalledWith(1); + consoleSpy.mockRestore(); + }); }); }); diff --git a/index.js b/index.js index 0c96d2f..a861eb1 100755 --- a/index.js +++ b/index.js @@ -1,15 +1,45 @@ #!/usr/bin/env node const { spawn } = require('child_process'); const path = require('path'); +const fs = require('fs'); const { BinWrapper } = require('@saucelabs/bin-wrapper'); const { Writable } = require('stream'); const version = '0.202.0'; const defaultBinInstallBase = 'https://github.com/saucelabs/saucectl/releases/download'; -const binWrapper = (binInstallURL = null, binInstallBase = null) => { +const binWrapper = (binInstallURL = null, binInstallBase = null, binLocalPath = null) => { const bw = new BinWrapper(); + if (binLocalPath) { + const resolvedPath = path.resolve(binLocalPath); + if (!fs.existsSync(resolvedPath)) { + console.error( + `Please ensure the path provided by SAUCECTL_INSTALL_BINARY_LOCAL exists: ${resolvedPath}`, + ); + return; + } + if (!fs.statSync(resolvedPath).isFile()) { + console.error( + `Please ensure the path provided by SAUCECTL_INSTALL_BINARY_LOCAL points to a file, not a directory: ${resolvedPath}`, + ); + return; + } + if (binInstallURL || binInstallBase) { + console.warn( + 'SAUCECTL_INSTALL_BINARY_LOCAL is set alongside other binary source environment variables. The local path takes precedence.', + ); + } + const binName = process.platform.startsWith('win') ? 'saucectl.exe' : 'saucectl'; + const binDir = path.join(__dirname, 'bin'); + fs.mkdirSync(binDir, { recursive: true }); + fs.copyFileSync(resolvedPath, path.join(binDir, binName)); + fs.chmodSync(path.join(binDir, binName), 0o755); + bw.dest(binDir); + bw.use(binName); + return bw; + } + if (binInstallURL) { try { new URL(binInstallURL); @@ -134,6 +164,7 @@ if (require.main === module) { const bw = binWrapper( process.env.SAUCECTL_INSTALL_BINARY, process.env.SAUCECTL_INSTALL_BINARY_MIRROR, + process.env.SAUCECTL_INSTALL_BINARY_LOCAL, ); main(bw, process.argv.slice(2)); } diff --git a/install.js b/install.js index f7eee6e..b61e033 100644 --- a/install.js +++ b/install.js @@ -23,10 +23,11 @@ async function install() { return; } } - console.info('Fetching saucectl binary'); + console.info(process.env.SAUCECTL_INSTALL_BINARY_LOCAL ? 'Locating local saucectl binary' : 'Fetching saucectl binary'); const bw = binWrapper( process.env.SAUCECTL_INSTALL_BINARY, process.env.SAUCECTL_INSTALL_BINARY_MIRROR, + process.env.SAUCECTL_INSTALL_BINARY_LOCAL, ); if (!bw) { return; From 14c3b2e6b69de639d44a8b5c04215914770eb031 Mon Sep 17 00:00:00 2001 From: Diego Molina Date: Thu, 5 Mar 2026 18:06:03 +0100 Subject: [PATCH 2/3] Remove unnecessary blank lines in test.yml --- .github/workflows/test.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 505331e..5c07fcb 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,17 +12,14 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 - - name: Setup Node uses: actions/setup-node@v4 with: node-version-file: '.nvmrc' - - name: Install Dependencies run: npm install env: GITHUB_TOKEN: ${{ github.token }} - - name: Lint run: npm run lint From bb87feaf28087b681f41d5841a603b2d7b030fe3 Mon Sep 17 00:00:00 2001 From: Diego Molina Date: Thu, 5 Mar 2026 20:24:43 +0100 Subject: [PATCH 3/3] Fixing linting errors --- __tests__/main.test.js | 55 ++++++++++++++++++++++++++++++++---------- index.js | 10 ++++++-- install.js | 6 ++++- 3 files changed, 55 insertions(+), 16 deletions(-) diff --git a/__tests__/main.test.js b/__tests__/main.test.js index 488ec2f..4a8bfac 100644 --- a/__tests__/main.test.js +++ b/__tests__/main.test.js @@ -137,7 +137,10 @@ describe('main', function () { expect(bw).toBeDefined(); expect(BinWrapper).toHaveBeenCalled(); // should copy into the bin dir, not the original location - expect(fs.mkdirSync).toHaveBeenCalledWith(expect.stringContaining('bin'), { recursive: true }); + expect(fs.mkdirSync).toHaveBeenCalledWith( + expect.stringContaining('bin'), + { recursive: true }, + ); expect(fs.copyFileSync).toHaveBeenCalledWith( path.resolve(localPath), expect.stringContaining('saucectl'), @@ -150,54 +153,76 @@ describe('main', function () { test('should return undefined and log an error when SAUCECTL_INSTALL_BINARY_LOCAL path does not exist', async function () { fs.existsSync.mockReturnValue(false); - const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + const consoleSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); const bw = await main.binWrapper(null, null, '/nonexistent/saucectl'); expect(bw).toBeUndefined(); - expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('SAUCECTL_INSTALL_BINARY_LOCAL')); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('SAUCECTL_INSTALL_BINARY_LOCAL'), + ); consoleSpy.mockRestore(); }); test('should return undefined and log an error when SAUCECTL_INSTALL_BINARY_LOCAL points to a directory', async function () { fs.existsSync.mockReturnValue(true); fs.statSync.mockReturnValue({ isFile: () => false }); - const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + const consoleSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); const bw = await main.binWrapper(null, null, '/some/directory'); expect(bw).toBeUndefined(); - expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('SAUCECTL_INSTALL_BINARY_LOCAL')); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('SAUCECTL_INSTALL_BINARY_LOCAL'), + ); consoleSpy.mockRestore(); }); test('should warn when SAUCECTL_INSTALL_BINARY_LOCAL is set alongside SAUCECTL_INSTALL_BINARY', async function () { const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); - const bw = await main.binWrapper('http://some-url', null, '/usr/local/bin/saucectl'); + const bw = await main.binWrapper( + 'http://some-url', + null, + '/usr/local/bin/saucectl', + ); expect(bw).toBeDefined(); - expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('SAUCECTL_INSTALL_BINARY_LOCAL')); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('SAUCECTL_INSTALL_BINARY_LOCAL'), + ); warnSpy.mockRestore(); }); test('should return undefined and log an error when SAUCECTL_INSTALL_BINARY is an invalid URL', async function () { - const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + const consoleSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); const bw = await main.binWrapper('not-a-valid-url'); expect(bw).toBeUndefined(); - expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('saucectl binary source is valid')); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('saucectl binary source is valid'), + ); consoleSpy.mockRestore(); }); test('should return undefined and log an error when SAUCECTL_INSTALL_BINARY_MIRROR is an invalid URL', async function () { - const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + const consoleSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); const bw = await main.binWrapper(null, 'not-a-valid-mirror'); expect(bw).toBeUndefined(); - expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('saucectl binary source mirror is valid')); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('saucectl binary source mirror is valid'), + ); consoleSpy.mockRestore(); }); @@ -205,7 +230,9 @@ describe('main', function () { Object.defineProperty(process, 'platform', { value: 'freebsd' }); Object.defineProperty(process, 'arch', { value: 'mips' }); - await expect(main.binWrapper()).rejects.toThrow('No binary found matching your system'); + await expect(main.binWrapper()).rejects.toThrow( + 'No binary found matching your system', + ); }); test('should handle preRun failure and exit with non-zero code', async function () { @@ -213,7 +240,9 @@ describe('main', function () { run: jest.fn().mockResolvedValue(1), path: jest.fn().mockReturnValue('/bin/saucectl'), }; - const consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + const consoleSpy = jest + .spyOn(console, 'log') + .mockImplementation(() => {}); await main(bin, []); diff --git a/index.js b/index.js index a861eb1..6120412 100755 --- a/index.js +++ b/index.js @@ -8,7 +8,11 @@ const { Writable } = require('stream'); const version = '0.202.0'; const defaultBinInstallBase = 'https://github.com/saucelabs/saucectl/releases/download'; -const binWrapper = (binInstallURL = null, binInstallBase = null, binLocalPath = null) => { +const binWrapper = ( + binInstallURL = null, + binInstallBase = null, + binLocalPath = null, +) => { const bw = new BinWrapper(); if (binLocalPath) { @@ -30,7 +34,9 @@ const binWrapper = (binInstallURL = null, binInstallBase = null, binLocalPath = 'SAUCECTL_INSTALL_BINARY_LOCAL is set alongside other binary source environment variables. The local path takes precedence.', ); } - const binName = process.platform.startsWith('win') ? 'saucectl.exe' : 'saucectl'; + const binName = process.platform.startsWith('win') + ? 'saucectl.exe' + : 'saucectl'; const binDir = path.join(__dirname, 'bin'); fs.mkdirSync(binDir, { recursive: true }); fs.copyFileSync(resolvedPath, path.join(binDir, binName)); diff --git a/install.js b/install.js index b61e033..81aab9f 100644 --- a/install.js +++ b/install.js @@ -23,7 +23,11 @@ async function install() { return; } } - console.info(process.env.SAUCECTL_INSTALL_BINARY_LOCAL ? 'Locating local saucectl binary' : 'Fetching saucectl binary'); + console.info( + process.env.SAUCECTL_INSTALL_BINARY_LOCAL + ? 'Locating local saucectl binary' + : 'Fetching saucectl binary', + ); const bw = binWrapper( process.env.SAUCECTL_INSTALL_BINARY, process.env.SAUCECTL_INSTALL_BINARY_MIRROR,