Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Wait for tunnel to be ready before starting tests #21

Merged
merged 11 commits into from
Oct 10, 2024
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,5 @@ Optional environment variables:
- `SAUCE_TAGS` - A comma separated list of tags to apply to all jobs.
- `SAUCE_REGION` - The Sauce Labs region. Valid values are `us-west-1` (default) or `eu-central-1`.
- `SAUCE_SCREEN_RESOLUTION` - The desktop browser screen resolution (not applicable to mobile). The format is `1920x1080`.
- `SAUCE_TUNNEL_WAIT_SEC` - The amount of time to wait, in seconds, for
the tunnel defined by `SAUCE_TUNNEL_NAME` to be ready. Default is "30".
62 changes: 61 additions & 1 deletion src/api.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import axios from 'axios';
import axios, { isAxiosError } from 'axios';

export interface Platform {
/**
Expand Down Expand Up @@ -29,6 +29,18 @@ export interface Platform {
os: string;
}

export interface Tunnel {
id: string;
owner: string;
status: string;
tunnel_identifier: string;
}

export type ApiResult<T, E> =
| { kind: 'ok'; data: T }
| { kind: 'err'; error: E }
| { kind: 'unauthorized' };

export async function getPlatforms(params: {
username: string;
accessKey: string;
Expand All @@ -46,3 +58,51 @@ export async function getPlatforms(params: {

return resp;
}

export async function getTunnels(params: {
username: string;
accessKey: string;
region: string;
filter: string;
}): Promise<ApiResult<{ [key: string]: Tunnel[] }, Error>> {
const { username, accessKey, region, filter } = params;
try {
const resp = await axios.get<{ [key: string]: Tunnel[] }>(
`https://api.${region}.saucelabs.com/rest/v1/${username}/tunnels`,
{
auth: {
username,
password: accessKey,
},
params: {
full: true,
all: true,
filter: filter !== '' ? filter : undefined,
},
},
);

return {
kind: 'ok',
data: resp.data,
};
} catch (e) {
if (isAxiosError(e)) {
if (e.response?.status === 401) {
return {
kind: 'unauthorized',
};
}
return {
kind: 'err',
error: new Error(
`unexpected response (${e.status}) fetching tunnels: ${e.message}`,
),
};
}
return {
kind: 'err',
error: new Error(`unknown error fetching tunnels: ${e}`),
};
}
}
7 changes: 7 additions & 0 deletions src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@ export class TunnelNameError extends Error {
}
}

export class TunnelNotReadyError extends Error {
constructor() {
super('Timed out waiting for a tunnel to be ready.');
this.name = 'TunnelNotReadyError';
}
}

export class CreateSessionError extends Error {
constructor() {
super('Failed to run test on Sauce Labs: no session id returned');
Expand Down
20 changes: 20 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ import {
AuthError,
InvalidRegionError,
TunnelNameError,
TunnelNotReadyError,
WindowSizeRangeError,
} from './errors';
import { getPlatforms } from './api';
import { rcompareOses, rcompareVersions } from './sort';
import { isDevice } from './device';
import { waitForTunnel } from './tunnel';

type Browser = string;
type Version = string;
Expand Down Expand Up @@ -45,6 +47,7 @@ module.exports = {
const username = process.env.SAUCE_USERNAME;
const accessKey = process.env.SAUCE_ACCESS_KEY;
const tunnelName = process.env.SAUCE_TUNNEL_NAME;
const tunnelWait = Number(process.env.SAUCE_TUNNEL_WAIT_SEC) || 30;
const build = process.env.SAUCE_BUILD;
const tags = (process.env.SAUCE_TAGS || '').split(',');
const region = process.env.SAUCE_REGION || 'us-west-1';
Expand All @@ -64,6 +67,23 @@ module.exports = {
throw new InvalidRegionError();
}

console.log(
`Waiting up to ${tunnelWait}s for tunnel "${tunnelName}" to be ready...`,
);
const tunnelStatus = await waitForTunnel(
username,
accessKey,
region,
tunnelName,
tunnelWait,
);
if (tunnelStatus === 'notready') {
throw new TunnelNotReadyError();
} else if (tunnelStatus === 'unauthorized') {
throw new AuthError();
}
console.log('Tunnel is ready');

sauceDriver = new SauceDriver(
username,
accessKey,
Expand Down
54 changes: 54 additions & 0 deletions src/tunnel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { getTunnels } from './api';

async function sleep(delay: number) {
return new Promise((resolve) => setTimeout(resolve, delay));
}

export async function waitForTunnel(
username: string,
accessKey: string,
region: string,
tunnelName: string,
wait: number,
): Promise<'ok' | 'notready' | 'unauthorized'> {
return await Promise.race([
(async function (): Promise<'ok' | 'unauthorized'> {
// eslint-disable-next-line no-constant-condition
while (true) {
const result = await getTunnels({
username,
accessKey,
region,
filter: tunnelName,
});
if (result.kind === 'unauthorized') {
return 'unauthorized';
}
if (result.kind !== 'ok') {
await sleep(2000);
continue;
}

const allTunnels = result.data;
for (const owner in allTunnels) {
const tunnels = allTunnels[owner];
if (
tunnels.some(
(t) =>
t.owner === username &&
(t.tunnel_identifier === tunnelName || t.id === tunnelName) &&
t.status === 'running',
)
) {
return 'ok';
}
}
await sleep(1000);
}
})(),
(async function (): Promise<'notready'> {
await sleep(wait * 1000);
return 'notready';
})(),
]);
}