Skip to content

Commit

Permalink
feat: Add network conditions cli args with sensible defaults (#383)
Browse files Browse the repository at this point in the history
* add network conditions cli args with defaults

* adjust network condition strings

* move network conditions to synthetics/metadata

* set network conditions on new page

* adjust flag documentation

* update cli args

* adjust types and tests

* adjust cli args

* adjust types

* adjust tests

* address feedback
  • Loading branch information
dominiqueclarke authored Oct 5, 2021
1 parent 8a84711 commit 565de1c
Show file tree
Hide file tree
Showing 9 changed files with 248 additions and 9 deletions.
98 changes: 96 additions & 2 deletions __tests__/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
import { ChildProcess, spawn } from 'child_process';
import { join } from 'path';
import { Server } from './utils/server';
import { megabytesToBytes, DEFAULT_NETWORK_CONDITIONS } from '../src/helpers';

describe('CLI', () => {
let server: Server;
Expand Down Expand Up @@ -350,6 +351,99 @@ describe('CLI', () => {
);
});
});

describe('throttling', () => {
let cliArgs: Array<string>;

beforeAll(async () => {
cliArgs = [
join(FIXTURES_DIR, 'example.journey.ts'),
'--params',
JSON.stringify(serverParams),
'--reporter',
'json',
];
});

it('applies --no-throttling', async () => {
const cli = new CLIMock()
.args(cliArgs.concat(['--no-throttling']))
.run();
await cli.waitFor('synthetics/metadata');
const journeyStartOutput = JSON.parse(cli.output());
expect(await cli.exitCode).toBe(0);
expect(journeyStartOutput.payload).toBeUndefined();
});

it('applies default throttling', async () => {
const cli = new CLIMock()
.args(cliArgs)
.run();
await cli.waitFor('synthetics/metadata');
const journeyStartOutput = JSON.parse(cli.output());
expect(await cli.exitCode).toBe(0);
expect(journeyStartOutput.payload).toHaveProperty('network_conditions', DEFAULT_NETWORK_CONDITIONS);
});

it('applies custom throttling', async () => {
const downloadThroughput = megabytesToBytes(3);
const uploadThroughput = megabytesToBytes(1);
const latency = 30;
const cli = new CLIMock()
.args(cliArgs.concat([
'--throttling',
'3d/1u/30l',
]))
.run();
await cli.waitFor('synthetics/metadata');
const journeyStartOutput = JSON.parse(cli.output());
expect(await cli.exitCode).toBe(0);
expect(journeyStartOutput.payload).toHaveProperty('network_conditions', {
downloadThroughput,
latency,
offline: false,
uploadThroughput
});
});

it('applies custom throttling order agnostic', async () => {
const downloadThroughput = megabytesToBytes(3);
const uploadThroughput = megabytesToBytes(1);
const latency = 30;
const cli = new CLIMock()
.args(cliArgs.concat([
'--throttling',
'1u/30l/3d',
]))
.run();
await cli.waitFor('synthetics/metadata');
const journeyStartOutput = JSON.parse(cli.output());
expect(await cli.exitCode).toBe(0);
expect(journeyStartOutput.payload).toHaveProperty('network_conditions', {
...DEFAULT_NETWORK_CONDITIONS,
downloadThroughput,
latency,
uploadThroughput
});
});

it('uses default throttling when specific params are not provided', async () => {
const downloadThroughput = megabytesToBytes(2);
const cli = new CLIMock()
.args(cliArgs.concat([
'--throttling',
'2d',
]))
.run();
await cli.waitFor('synthetics/metadata');
const journeyStartOutput = JSON.parse(cli.output());
expect(await cli.exitCode).toBe(0);
expect(journeyStartOutput.payload).toHaveProperty('network_conditions', {
...DEFAULT_NETWORK_CONDITIONS,
downloadThroughput,
});
});
});
});

class CLIMock {
Expand Down Expand Up @@ -404,9 +498,9 @@ class CLIMock {

this.exitCode = new Promise(res => {
// Uncomment to debug stderr
//this.process.stderr.on('data', data => {
// this.process.stderr.on('data', data => {
// console.log('climock.stderr: ', data.toString());
//});
// });
this.process.on('exit', code => res(code));
});

Expand Down
47 changes: 47 additions & 0 deletions __tests__/core/gatherer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,4 +116,51 @@ describe('Gatherer', () => {
await Gatherer.stop();
});
});

describe('Network emulation', () => {
const networkConditions = {
downloadThroughput: 1024 * 1024 * 0.05, // slow 3g speeds, 0.4 Mbits
uploadThroughput: 1024 * 1024 * 0.02,
latency: 20,
offline: false,
}
it('applies network throttling', async () => {
const driver = await Gatherer.setupDriver({
wsEndpoint,
networkConditions,
});
// @ts-ignore
// Experimental browser API https://developer.mozilla.org/en-US/docs/Web/API/NetworkInformation/downlink
const downlink = await driver.page.evaluate(() => navigator.connection.downlink);

expect(0.5 > downlink && downlink > 0.3).toBe(true);
await Gatherer.dispose(driver);
await Gatherer.stop();
});

it('works with popup window', async () => {
const driver = await Gatherer.setupDriver({
wsEndpoint,
networkConditions,
});
const { page, context } = driver;
await page.goto(server.TEST_PAGE);
await page.setContent(
'<a target=_blank rel=noopener href="/popup.html">popup</a>'
);
const [page1] = await Promise.all([
context.waitForEvent('page'),
page.click('a'),
]);
await page1.waitForLoadState();

// @ts-ignore
// Experimental browser API https://developer.mozilla.org/en-US/docs/Web/API/NetworkInformation/downlink
const downlink = await page1.evaluate(() => navigator.connection.downlink);

expect(0.5 > downlink && downlink > 0.3).toBe(true);
await Gatherer.dispose(driver);
await Gatherer.stop();
});
});
});
3 changes: 3 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
isDepInstalled,
isDirectory,
totalist,
parseNetworkConditions,
} from './helpers';
import { run } from './';
import { readConfig } from './config';
Expand Down Expand Up @@ -184,8 +185,10 @@ async function prepareSuites(inputs: string[]) {
chromiumSandbox: options.sandbox,
ignoreHTTPSErrors: options.ignoreHttpsErrors,
});

const results = await run({
params: Object.freeze(params),
networkConditions: options.throttling ? parseNetworkConditions(options.throttling as string) : undefined,
environment,
playwrightOptions,
...options,
Expand Down
7 changes: 7 additions & 0 deletions src/common_types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ import { reporters } from './reporters';

export type VoidCallback = () => void;
export type Params = Record<string, any>;
export type NetworkConditions = {
offline: boolean;
downloadThroughput: number;
uploadThroughput: number;
latency: number;
};
export type HooksArgs = {
env: string;
params: Params;
Expand Down Expand Up @@ -157,6 +163,7 @@ export type CliArgs = {
debug?: boolean;
ignoreHttpsErrors?: boolean;
params?: Params;
throttling?: boolean | string;
/**
* @deprecated use params instead
*/
Expand Down
21 changes: 19 additions & 2 deletions src/core/gatherer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
*
*/

import { chromium, ChromiumBrowser } from 'playwright-chromium';
import { chromium, ChromiumBrowser, BrowserContext } from 'playwright-chromium';
import { PluginManager } from '../plugins';
import { RunOptions } from './runner';
import { log } from './logger';
Expand All @@ -37,7 +37,12 @@ export class Gatherer {
static browser: ChromiumBrowser;

static async setupDriver(options: RunOptions): Promise<Driver> {
const { wsEndpoint, playwrightOptions } = options;
const {
wsEndpoint,
playwrightOptions,
networkConditions
} = options;

if (Gatherer.browser == null) {
if (wsEndpoint) {
log(`Gatherer: connecting to WS endpoint: ${wsEndpoint}`);
Expand All @@ -50,6 +55,8 @@ export class Gatherer {
...playwrightOptions,
userAgent: await Gatherer.getUserAgent(),
});
await Gatherer.setNetworkConditions(context, networkConditions);

const page = await context.newPage();
const client = await context.newCDPSession(page);
return { browser: Gatherer.browser, context, page, client };
Expand All @@ -61,6 +68,16 @@ export class Gatherer {
return userAgent + ' Elastic/Synthetics';
}

static async setNetworkConditions(context: BrowserContext, networkConditions: RunOptions['networkConditions']) {
if (networkConditions) {
context.on('page', async (page) => {
const context = page.context();
const client = await context.newCDPSession(page);
await client.send('Network.emulateNetworkConditions', networkConditions);
});
}
}

/**
* Starts recording all events related to the v8 devtools protocol
* https://chromedevtools.github.io/devtools-protocol/v8/
Expand Down
12 changes: 9 additions & 3 deletions src/core/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import {
StatusValue,
HooksCallback,
Params,
NetworkConditions,
PluginOutput,
CliArgs,
HooksArgs,
Expand All @@ -64,9 +65,11 @@ export type RunOptions = Omit<
| 'capability'
| 'sandbox'
| 'headless'
| 'throttling'
> & {
environment?: string;
playwrightOptions?: PlaywrightOptions;
networkConditions?: NetworkConditions;
reporter?: CliArgs['reporter'] | Reporter;
};

Expand Down Expand Up @@ -104,7 +107,10 @@ type HookType = 'beforeAll' | 'afterAll';
export type SuiteHooks = Record<HookType, Array<HooksCallback>>;

interface Events {
start: { numJourneys: number };
start: {
numJourneys: number,
networkConditions?: NetworkConditions;
};
'journey:register': {
journey: Journey;
};
Expand Down Expand Up @@ -419,7 +425,7 @@ export default class Runner extends EventEmitter {
}

async init(options: RunOptions) {
const { reporter, outfd } = options;
const { reporter, outfd, networkConditions } = options;
/**
* Set up the corresponding reporter and fallback
*/
Expand All @@ -428,7 +434,7 @@ export default class Runner extends EventEmitter {
? reporter
: reporters[reporter] || reporters['default'];
new Reporter(this, { fd: outfd });
this.emit('start', { numJourneys: this.journeys.length });
this.emit('start', { numJourneys: this.journeys.length, networkConditions });
/**
* Set up the directory for caching screenshots
*/
Expand Down
56 changes: 55 additions & 1 deletion src/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import { resolve, join, dirname } from 'path';
import fs from 'fs';
import { promisify } from 'util';
import { performance } from 'perf_hooks';
import { HooksArgs, HooksCallback } from './common_types';
import { HooksArgs, HooksCallback, NetworkConditions } from './common_types';

const lstatAsync = promisify(fs.lstat);
const readdirAsync = promisify(fs.readdir);
Expand Down Expand Up @@ -257,3 +257,57 @@ export const CACHE_PATH = join(cwd, '.synthetics', process.pid.toString());
export function getDurationInUs(duration: number) {
return Math.trunc(duration * 1e6);
}

export function megabytesToBytes(megabytes: number) {
return megabytes * 1024 * 1024;
}

export function bytesToMegabytes(bytes: number) {
return bytes / 1024 / 1024;
}

export const DEFAULT_NETWORK_CONDITIONS: NetworkConditions = {
downloadThroughput: megabytesToBytes(5), // megabytes/second
uploadThroughput: megabytesToBytes(3), // megabytes/second
latency: 20, // milliseconds,
offline: false,
}

export function formatNetworkConditionsArgs(networkConditions: NetworkConditions) {
const d = bytesToMegabytes(networkConditions.downloadThroughput);
const u = bytesToMegabytes(networkConditions.uploadThroughput);
const l = networkConditions.latency;
return `${d}d/${u}u/${l}l`;
}

export const DEFAULT_NETWORK_CONDITIONS_ARG = formatNetworkConditionsArgs(DEFAULT_NETWORK_CONDITIONS);

export function parseNetworkConditions(args: string): NetworkConditions {
const uploadToken = 'u';
const downloadToken = 'd';
const latencyToken = 'l';
const networkConditions = {
...DEFAULT_NETWORK_CONDITIONS,
};

const conditions = args.split('/');

conditions.forEach(condition => {
const value = condition.slice(0, condition.length - 1);
const token = condition.slice(-1);

switch (token) {
case uploadToken:
networkConditions.uploadThroughput = megabytesToBytes(Number(value));
break;
case downloadToken:
networkConditions.downloadThroughput = megabytesToBytes(Number(value));
break;
case latencyToken:
networkConditions.latency = Number(value);
break;
}
});

return networkConditions;
}
6 changes: 6 additions & 0 deletions src/parse_args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
import { program, Option } from 'commander';
import { CliArgs } from './common_types';
import { reporters } from './reporters';
import { DEFAULT_NETWORK_CONDITIONS_ARG } from './helpers';

/* eslint-disable-next-line @typescript-eslint/no-var-requires */
const { name, version } = require('../package.json');
Expand Down Expand Up @@ -104,6 +105,11 @@ program
'--quiet-exit-code',
'always return 0 as an exit code status, regardless of test pass / fail. Only return > 0 exit codes on internal errors where the suite could not be run'
)
.addOption(
new Option('--throttling <d/u/l>', 'List of options to throttle network conditions for download throughput (d) in megabytes/second, upload throughput (u) in megabytes/second and latency (l) in milliseconds.')
.default(DEFAULT_NETWORK_CONDITIONS_ARG)
)
.option('--no-throttling', 'Turns off default network throttling.')
.version(version)
.description('Run synthetic tests');

Expand Down
Loading

0 comments on commit 565de1c

Please sign in to comment.