Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
63 changes: 58 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
140 changes: 140 additions & 0 deletions __tests__/main.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -109,5 +119,135 @@ 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();
});
});
});
39 changes: 38 additions & 1 deletion index.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,51 @@
#!/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);
Expand Down Expand Up @@ -134,6 +170,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));
}
Expand Down
7 changes: 6 additions & 1 deletion install.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,15 @@ 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;
Expand Down