Repository: saucelabs/node-saucelabs
Pull request #284 (2025-02-07 11:51:51)

Re-introduce support for node.js 16
## Description
With the introduction of SC5 (https://github.com/saucelabs/node-saucelabs/pull/265), the fetch module was used for making HTTP requests. Since this module is supported from Node.js 18.x onward, we unintentionally dropped support for Node.js 16.x.

This PR replaces fetch with the http module to restore compatibility. 

## Types of Changes
_What types of changes does your code introduce? Keep the ones that apply:_

- Bug fix (non-breaking change which fixes an issue)


Patch:
diff --git a/src/sauceConnectHealthcheck.js b/src/sauceConnectHealthcheck.js
index ae5cd83..3bfd30b 100644
--- a/src/sauceConnectHealthcheck.js
+++ b/src/sauceConnectHealthcheck.js
@@ -1,12 +1,28 @@
+import http from 'http';
 import {SC_HEALTHCHECK_TIMEOUT} from './constants';
 
 export default class SauceConnectHealthCheck {
   async perform(apiAddress) {
-    const response = await fetch(`${apiAddress}/readyz`, {
-      signal: AbortSignal.timeout(SC_HEALTHCHECK_TIMEOUT),
+    return new Promise((resolve, reject) => {
+      const request = http.get(
+        `${apiAddress}/readyz`,
+        {timeout: SC_HEALTHCHECK_TIMEOUT},
+        (response) => {
+          if (response.statusCode !== 200) {
+            reject(
+              new Error(`response status code ${response.statusCode} != 200`)
+            );
+          } else {
+            resolve();
+          }
+        }
+      );
+
+      request.on('error', reject);
+      request.on('timeout', () => {
+        request.destroy();
+        reject(new Error('Request timed out'));
+      });
     });
-    if (response.status !== 200) {
-      throw new TypeError(`response status code ${response.status} != 200`);
-    }
   }
 }
diff --git a/src/sauceConnectManager.js b/src/sauceConnectManager.js
index 8b1fd7d..b3746b2 100644
--- a/src/sauceConnectManager.js
+++ b/src/sauceConnectManager.js
@@ -15,6 +15,7 @@ export class SauceConnectManager {
   }
 
   waitForReady(apiAddress) {
+    let lastHealthcheckErr = null;
     apiAddress = this._parseApiAddress(apiAddress);
 
     return new Promise((resolve, reject) => {
@@ -47,17 +48,19 @@ export class SauceConnectManager {
             resolve();
           })
           .catch((err) => {
-            if (err.name !== 'TypeError') {
-              clearInterval(this._healthcheckInterval);
-              clearTimeout(this._readyTimeout);
-              reject(err);
-            }
+            lastHealthcheckErr = err;
           });
       }, SC_HEALTHCHECK_TIMEOUT);
 
       this._readyTimeout = setTimeout(() => {
         clearInterval(this._healthcheckInterval);
-        reject(new Error('Timeout waiting for healthcheck endpoint'));
+        console.error(
+          `Timeout waiting for healthcheck endpoint, err=${lastHealthcheckErr?.message}, code=${lastHealthcheckErr?.code}`
+        );
+        reject(
+          lastHealthcheckErr ||
+            new Error('Timeout waiting for healthcheck endpoint')
+        );
       }, SC_READY_TIMEOUT);
     });
   }


Test patch:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 26620da..6c4df86 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -11,7 +11,7 @@ jobs:
     runs-on: ubuntu-latest
     strategy:
       matrix:
-        node-version: [18.x, 20.x, 22.x]
+        node-version: [16.x, 18.x, 20.x, 22.x]
     steps:
       - name: Clone Repository
         uses: actions/checkout@v4
diff --git a/tests/sauceConnectHealthcheck.test.js b/tests/sauceConnectHealthcheck.test.js
index 536e641..a488abe 100644
--- a/tests/sauceConnectHealthcheck.test.js
+++ b/tests/sauceConnectHealthcheck.test.js
@@ -1,29 +1,32 @@
 import SauceConnectHealthCheck from '../src/sauceConnectHealthcheck';
-
-const fetchMock = jest.fn();
-global.fetch = fetchMock;
+import http from 'http';
 
 describe('SauceConnectHealthcheck', () => {
+  let mockHttpGet;
   beforeEach(() => {
-    fetchMock.mockClear();
+    mockHttpGet = jest.spyOn(http, 'get');
   });
 
   describe('perform', () => {
     test('raises error if response code != 200', async () => {
-      fetchMock.mockImplementation(async () => ({
-        status: 503,
-      }));
+      mockHttpGet.mockImplementation((url, options, callback) => {
+        callback({statusCode: 503});
+        return {on: jest.fn()};
+      });
       const healthcheck = new SauceConnectHealthCheck();
-      const error = await healthcheck.perform(':8042').catch((err) => err);
+      const error = await healthcheck
+        .perform('http://localhost:8042')
+        .catch((err) => err);
       expect(error.message).toBe('response status code 503 != 200');
     });
 
     test('all good when response code == 200', async () => {
-      fetchMock.mockImplementation(async () => ({
-        status: 200,
-      }));
+      mockHttpGet.mockImplementation((url, options, callback) => {
+        callback({statusCode: 200});
+        return {on: jest.fn()};
+      });
       const healthcheck = new SauceConnectHealthCheck();
-      const result = await healthcheck.perform(':8042');
+      const result = await healthcheck.perform('http://localhost:8042');
       expect(result).toBe(undefined);
     });
   });
diff --git a/tests/sauceConnectManager.test.js b/tests/sauceConnectManager.test.js
index 0367e32..6247280 100644
--- a/tests/sauceConnectManager.test.js
+++ b/tests/sauceConnectManager.test.js
@@ -19,51 +19,45 @@ describe('SauceConnectManager', () => {
       );
     });
 
-    test('error when requesting healthcheck', async () => {
-      const manager = new SauceConnectManager(
-        {
-          stderr: {
-            on: jest.fn(),
-          },
-          stdout: {
-            on: jest.fn(),
-          },
-        },
-        undefined,
-        {
-          async perform() {
-            throw new Error('custom error');
-          },
-        }
-      );
-      const error = await manager.waitForReady(':8042').catch((err) => err);
-      expect(error.message).toBe('custom error');
-    });
-
-    describe('waiting too long for healthcheck', () => {
-      let origTimeout = constants.SC_READY_TIMEOUT;
+    describe('short ready timeout', () => {
+      let origHealthcheckTimeout = constants.SC_HEALTHCHECK_TIMEOUT;
+      let origReadyTimeout = constants.SC_READY_TIMEOUT;
       beforeEach(() => {
         // eslint-disable-next-line no-import-assign
-        Object.defineProperty(constants, 'SC_READY_TIMEOUT', {value: 10});
+        Object.defineProperty(constants, 'SC_HEALTHCHECK_TIMEOUT', {value: 10});
+        // eslint-disable-next-line no-import-assign
+        Object.defineProperty(constants, 'SC_READY_TIMEOUT', {value: 100});
       });
 
-      test('waiting too long for healthcheck', async () => {
-        const manager = new SauceConnectManager({
-          stderr: {
-            on: jest.fn(),
-          },
-          stdout: {
-            on: jest.fn(),
+      test('error when requesting healthcheck', async () => {
+        const manager = new SauceConnectManager(
+          {
+            stderr: {
+              on: jest.fn(),
+            },
+            stdout: {
+              on: jest.fn(),
+            },
           },
-        });
+          undefined,
+          {
+            async perform() {
+              throw new Error('custom error');
+            },
+          }
+        );
         const error = await manager.waitForReady(':8042').catch((err) => err);
-        expect(error.message).toBe('Timeout waiting for healthcheck endpoint');
+        expect(error.message).toBe('custom error');
       });
 
       afterEach(() => {
+        // eslint-disable-next-line no-import-assign
+        Object.defineProperty(constants, 'SC_HEALTHCHECK_TIMEOUT', {
+          value: origHealthcheckTimeout,
+        });
         // eslint-disable-next-line no-import-assign
         Object.defineProperty(constants, 'SC_READY_TIMEOUT', {
-          value: origTimeout,
+          value: origReadyTimeout,
         });
       });
     });