Skip to content

Commit 329067b

Browse files
committed
Use sc api for healthcheck
1 parent cff8d0b commit 329067b

9 files changed

+248
-91
lines changed

jest.config.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ module.exports = {
66
coverageThreshold: {
77
global: {
88
branches: 93.5,
9-
functions: 98,
9+
functions: 97,
1010
lines: 97,
1111
statements: 97,
1212
},

src/constants.js

+2-3
Original file line numberDiff line numberDiff line change
@@ -329,7 +329,6 @@ export const SC_PARAMS_TO_STRIP = [
329329
...SAUCE_CONNECT_CLI_PARAM_ALIASES,
330330
];
331331

332-
export const SC_READY_MESSAGE = 'Sauce Connect is up, you may start your tests';
333-
export const SC_FAILURE_MESSAGES = ['fatal error exiting'];
334-
export const SC_CLOSE_MESSAGE = 'tunnel was shutdown';
335332
export const SC_CLOSE_TIMEOUT = 5000;
333+
export const SC_READY_TIMEOUT = 30000;
334+
export const SC_HEALTHCHECK_TIMEOUT = 1000;

src/index.js

+20-59
Original file line numberDiff line numberDiff line change
@@ -30,15 +30,12 @@ import {
3030
SYMBOL_ITERATOR,
3131
TO_STRING_TAG,
3232
SC_PARAMS_TO_STRIP,
33-
SC_READY_MESSAGE,
34-
SC_CLOSE_MESSAGE,
35-
SC_CLOSE_TIMEOUT,
3633
DEFAULT_SAUCE_CONNECT_VERSION,
37-
SC_FAILURE_MESSAGES,
3834
SC_BOOLEAN_CLI_PARAMS,
3935
DEFAULT_RUNNER_NAME,
4036
} from './constants';
4137
import SauceConnectLoader from './sauceConnectLoader';
38+
import {SauceConnectManager} from './sauceConnectManager';
4239

4340
export default class SauceLabs {
4441
constructor(options) {
@@ -259,6 +256,7 @@ export default class SauceLabs {
259256
![
260257
'_',
261258
'$0',
259+
'api-address',
262260
'metadata',
263261
'sc-version',
264262
'sc-upstream-proxy',
@@ -294,14 +292,17 @@ export default class SauceLabs {
294292
}
295293

296294
// Provide a default runner name. It's used for identifying the tunnel's initiation method.
297-
let metadata = argv.metadata || "";
298-
if (!metadata.includes("runner=")) {
295+
let metadata = argv.metadata || '';
296+
if (!metadata.includes('runner=')) {
299297
metadata = [metadata, `runner=${DEFAULT_RUNNER_NAME}`]
300298
.filter(Boolean)
301-
.join(',')
299+
.join(',');
302300
}
303301
args.push(`--metadata=${metadata}`);
304302

303+
const apiAddress = argv.apiAddress || ':8032';
304+
args.push(`--api-address=${apiAddress}`);
305+
305306
const region = argv.region || this.region;
306307
if (region) {
307308
const scRegion = getRegionSubDomain({region});
@@ -338,60 +339,20 @@ export default class SauceLabs {
338339
args.unshift('run');
339340
}
340341

342+
const logger = fromCLI
343+
? process.stdout.write.bind(process.stdout)
344+
: argv.logger;
341345
const cp = spawn(scLoader.path, args);
342-
return new Promise((resolve, reject) => {
343-
const close = () =>
344-
new Promise((resolveClose) => {
345-
process.kill(cp.pid, 'SIGINT');
346-
const timeout = setTimeout(resolveClose, SC_CLOSE_TIMEOUT);
347-
cp.stdout.on('data', (data) => {
348-
const output = data.toString();
349-
if (output.includes(SC_CLOSE_MESSAGE)) {
350-
clearTimeout(timeout);
351-
return resolveClose(returnObj);
352-
}
353-
});
354-
});
355-
const returnObj = {cp, close};
356-
357-
cp.stderr.on('data', (data) => {
358-
const output = data.toString();
359-
return reject(new Error(output));
360-
});
361-
cp.stdout.on('data', (data) => {
362-
const logger = fromCLI
363-
? process.stdout.write.bind(process.stdout)
364-
: argv.logger;
365-
const output = data.toString();
366-
/**
367-
* print to stdout if called via CLI
368-
*/
369-
if (typeof logger === 'function') {
370-
logger(output);
371-
}
372-
373-
/**
374-
* fail if SauceConnect could not establish a connection
375-
*/
376-
if (
377-
SC_FAILURE_MESSAGES.find((msg) =>
378-
escape(output).includes(escape(msg))
379-
)
380-
) {
381-
return reject(new Error(output));
382-
}
346+
const manager = new SauceConnectManager(cp, logger);
347+
process.on('SIGINT', () => manager.close());
383348

384-
/**
385-
* continue if connection was established
386-
*/
387-
if (output.includes(SC_READY_MESSAGE)) {
388-
return resolve(returnObj);
389-
}
390-
});
391-
392-
process.on('SIGINT', close);
393-
return returnObj;
394-
});
349+
try {
350+
await manager.waitForReady(apiAddress);
351+
return {cp, close: () => manager.close()};
352+
} catch (err) {
353+
await manager.close();
354+
throw err;
355+
}
395356
}
396357

397358
/**

src/sauceConnectHealthcheck.js

+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import {SC_HEALTHCHECK_TIMEOUT} from './constants';
2+
3+
export default class SauceConnectHealthCheck {
4+
async perform(apiAddress) {
5+
const response = await fetch(`${apiAddress}/readyz`, {
6+
signal: AbortSignal.timeout(SC_HEALTHCHECK_TIMEOUT),
7+
});
8+
if (response.status !== 200) {
9+
throw new Error(`response status code ${response.status} != 200`);
10+
}
11+
}
12+
}

src/sauceConnectManager.js

+92
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import {
2+
SC_CLOSE_TIMEOUT,
3+
SC_HEALTHCHECK_TIMEOUT,
4+
SC_READY_TIMEOUT,
5+
} from './constants';
6+
import SauceConnectHealthCheck from './sauceConnectHealthcheck';
7+
8+
export class SauceConnectManager {
9+
constructor(cp, logger) {
10+
this.cp = cp;
11+
this.logger = logger || (() => {});
12+
this.healthcheck = new SauceConnectHealthCheck();
13+
this._healthcheckInterval = null;
14+
this._readyTimeout = null;
15+
}
16+
17+
waitForReady(apiAddress) {
18+
apiAddress = this._parseApiAddress(apiAddress);
19+
20+
return new Promise((resolve, reject) => {
21+
this.cp.stderr.on('data', (data) => {
22+
const output = data.toString();
23+
return reject(new Error(output));
24+
});
25+
26+
this.cp.stdout.on('data', (data) => {
27+
const logger = this.logger;
28+
if (typeof logger === 'function') {
29+
logger(data.toString());
30+
}
31+
});
32+
33+
this._healthcheckInterval = setInterval(async () => {
34+
if (this.cp.exitCode !== null && this.cp.exitCode !== undefined) {
35+
clearInterval(this._healthcheckInterval);
36+
clearTimeout(this._readyTimeout);
37+
return reject(new Error('Sauce Connect exited before ready'));
38+
}
39+
40+
this.healthcheck
41+
.perform(apiAddress)
42+
.then(() => clearInterval(this._healthcheckInterval))
43+
.then(() => clearTimeout(this._readyTimeout))
44+
.then(resolve)
45+
.catch(() => {});
46+
}, SC_HEALTHCHECK_TIMEOUT);
47+
48+
this._readyTimeout = setTimeout(() => {
49+
clearInterval(this._healthcheckInterval);
50+
reject(new Error('Timeout waiting for healthcheck endpoint'));
51+
}, SC_READY_TIMEOUT);
52+
});
53+
}
54+
55+
close() {
56+
this.logger('Terminating Sauce Connect...');
57+
58+
return new Promise((resolve) => {
59+
if (this.cp.exitCode !== null && this.cp.exitCode !== undefined) {
60+
return resolve();
61+
}
62+
63+
const timeout = setTimeout(() => {
64+
this.logger('Forcefully terminating Sauce Connect...');
65+
killSilently(this.cp.pid, 'SIGKILL');
66+
resolve();
67+
}, SC_CLOSE_TIMEOUT);
68+
69+
this.cp.on('exit', () => {
70+
clearTimeout(timeout);
71+
resolve();
72+
});
73+
74+
clearInterval(this._healthcheckInterval);
75+
clearTimeout(this._readyTimeout);
76+
killSilently(this.cp.pid, 'SIGINT');
77+
});
78+
}
79+
80+
_parseApiAddress(value) {
81+
const [host, port] = value.split(':');
82+
return `http://${host || 'localhost'}:${port}`;
83+
}
84+
}
85+
86+
function killSilently(pid, signal) {
87+
try {
88+
process.kill(pid, signal);
89+
} catch (err) {
90+
// ignore
91+
}
92+
}

tests/__snapshots__/index.test.js.snap

+3-1
Original file line numberDiff line numberDiff line change
@@ -161,11 +161,13 @@ exports[`startSauceConnect should start sauce connect with proper parsed args 1`
161161
[
162162
"run",
163163
"--proxy-tunnel=abc",
164-
"--metadata=runner=example",
165164
"--verbose=true",
166165
"--username=foo",
167166
"--access-key=bar",
168167
"--proxy=http://example.com:8080",
168+
"--proxy-sauce=http://example.com",
169+
"--metadata=runner=example",
170+
"--api-address=:8032",
169171
"--region=eu-central-1",
170172
"--tunnel-name=my-tunnel",
171173
],

tests/index.test.js

+21-27
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,21 @@ jest.mock('../src/sauceConnectLoader.js', () => {
4747
return SauceConnectLoaderMock;
4848
});
4949

50+
jest.mock('../src/sauceConnectHealthcheck.js', () => {
51+
class SauceConnectHealthcheckMock {
52+
constructor() {
53+
this.perform = jest.fn().mockResolvedValue(undefined);
54+
}
55+
}
56+
57+
return SauceConnectHealthcheckMock;
58+
});
59+
60+
jest.mock('../src/constants.js', () => ({
61+
...jest.requireActual('../src/constants.js'),
62+
SC_CLOSE_TIMEOUT: 10,
63+
}));
64+
5065
const stdoutEmitter = spawn().stdout;
5166
const stderrEmitter = spawn().stderr;
5267
const origKill = process.kill;
@@ -55,6 +70,7 @@ beforeEach(() => {
5570
process.kill = jest.fn();
5671
// clean instances array
5772
instances.splice(0, instances.length);
73+
// scHealthcheckPerformMock.mockRejectedValue(new Error("failure"))
5874
});
5975
test('should be inspectable', () => {
6076
const api = new SauceLabs({user: 'foo', key: 'bar'});
@@ -482,7 +498,11 @@ test('should contain expected windows download link', async () => {
482498
describe('startSauceConnect', () => {
483499
it('should start sauce connect with proper parsed args', async () => {
484500
const logs = [];
485-
const api = new SauceLabs({user: 'foo', key: 'bar'});
501+
const api = new SauceLabs({
502+
user: 'foo',
503+
key: 'bar',
504+
proxy: 'http://example.com',
505+
});
486506
setTimeout(
487507
() =>
488508
stdoutEmitter.emit(
@@ -587,35 +607,9 @@ describe('startSauceConnect', () => {
587607
});
588608
});
589609

590-
it('should properly fail on fatal error', async () => {
591-
const errMessage = 'fatal error exiting: any error message';
592-
const api = new SauceLabs({user: 'foo', key: 'bar'});
593-
setTimeout(() => stdoutEmitter.emit('data', errMessage), 50);
594-
const err = await api
595-
.startSauceConnect({
596-
scVersion: '1.2.3',
597-
tunnelName: 'my-tunnel',
598-
'proxy-tunnel': 'abc',
599-
})
600-
.catch((err) => err);
601-
expect(err.message).toBe(errMessage);
602-
});
603-
604610
it('should close sauce connect', async () => {
605611
const api = new SauceLabs({user: 'foo', key: 'bar'});
606-
setTimeout(
607-
() =>
608-
stdoutEmitter.emit(
609-
'data',
610-
'Sauce Connect is up, you may start your tests'
611-
),
612-
50
613-
);
614612
const sc = await api.startSauceConnect({tunnelName: 'my-tunnel'}, true);
615-
setTimeout(() => {
616-
sc.cp.stdout.emit('data', 'Some other message');
617-
sc.cp.stdout.emit('data', 'tunnel was shutdown');
618-
}, 100);
619613
await sc.close();
620614
expect(process.kill).toBeCalledWith(123, 'SIGINT');
621615
});

tests/sauceConnectHealthcheck.test.js

+30
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import SauceConnectHealthCheck from '../src/sauceConnectHealthcheck';
2+
3+
const fetchMock = jest.fn();
4+
global.fetch = fetchMock;
5+
6+
describe('SauceConnectHealthcheck', () => {
7+
beforeEach(() => {
8+
fetchMock.mockClear();
9+
});
10+
11+
describe('perform', () => {
12+
test('raises error if response code != 200', async () => {
13+
fetchMock.mockImplementation(async () => ({
14+
status: 503,
15+
}));
16+
const healthcheck = new SauceConnectHealthCheck();
17+
const error = await healthcheck.perform(':8042').catch((err) => err);
18+
expect(error.message).toBe('response status code 503 != 200');
19+
});
20+
21+
test('all good when response code == 200', async () => {
22+
fetchMock.mockImplementation(async () => ({
23+
status: 200,
24+
}));
25+
const healthcheck = new SauceConnectHealthCheck();
26+
const result = await healthcheck.perform(':8042');
27+
expect(result).toBe(undefined);
28+
});
29+
});
30+
});

0 commit comments

Comments
 (0)