Skip to content

Commit

Permalink
feat(tests): Add network fixture support for functional tests (#6203)
Browse files Browse the repository at this point in the history
Add mountebank library to record and replay network fixtures so that
you dont need Gate running to exercise the UI.

Network fixtures can be relatively large. For the two simple tests
written so far the fixtures are several hundred kilobytes. To avoid
ballooning Deck's repo size I plan to place fixtures in a publicly
accessible bucket and only have them downloaded when the test runner
needs them.
  • Loading branch information
Scott authored Dec 14, 2018
1 parent c8488c1 commit c92d463
Show file tree
Hide file tree
Showing 6 changed files with 947 additions and 29 deletions.
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@
"@types/react-sortable-hoc": "^0.6.2",
"@types/react-virtualized": "9.7.12",
"@types/react-virtualized-select": "^3.0.3",
"@types/request-promise-native": "^1.0.15",
"@types/webdriverio": "^4.13.0",
"@types/webpack": "4.1.0",
"@types/webpack-env": "^1.13.5",
Expand Down Expand Up @@ -163,6 +164,7 @@
"loader-utils": "^1.1.0",
"md5": "^2.2.1",
"minimist": "^1.2.0",
"mountebank": "^1.15.0",
"ngtemplate-loader": "^1.3.1",
"node-libs-browser": "^2.0.0",
"physical-cpu-count": "^2.0.0",
Expand All @@ -172,6 +174,7 @@
"pretty-quick": "^1.4.1",
"react-addons-test-utils": "15.6.2",
"react-test-renderer": "16.3.2",
"request-promise-native": "^1.0.5",
"rimraf": "^2.5.4",
"style-loader": "^0.20.3",
"thread-loader": "^1.1.5",
Expand Down
80 changes: 80 additions & 0 deletions test/functional/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# Deck Functional Tests

## Recording Network Fixtures

Usage of fixtures goes as follows:

1. Create a mountebank control server. For now this is managed manually but will soon be coordinated by a script:

```
$ node
> require('ts-node/register');
{}
> const { MountebankService } = require('./test/functional/tools/MountebankService.ts');
undefined
> MountebankService.builder().
... mountebankPath(process.cwd() + '/node_modules/.bin/mb').
... onStdOut(data => { console.log('mb stdout: ' + String(data)); }).
... onStdErr(data => { console.log('mb stderr: ' + String(data)); }).
... build().launchServer();
Promise {
<pending>,
domain:
Domain {
domain: null,
_events: { error: [Function: debugDomainError] },
_eventsCount: 1,
_maxListeners: undefined,
members: [] } }
> mb stdout: info: [mb:2525] mountebank v1.15.0 now taking orders - point your browser to http://localhost:2525 for help
```

2. Launch Gate on a different port. We want 8084 to be free for the mitm proxy that will record the network traffic. Open `~/.hal/default/service-settings/gate.yml` and add these contents:

```
port: 18084
```

3. Restart Gate:

```
hal deploy apply --service-names gate
```

4. Record a fixture for a specific test:

```
$ ./node_modules/.bin/wdio wdio.conf.js --record-fixtures --spec test/functional/tests/core/home.spec.ts
DEPRECATION: Setting specFilter directly on Env is deprecated, please use the specFilter option in `configure`
DEPRECATION: Setting stopOnSpecFailure directly is deprecated, please use the failFast option in `configure`
․wrote fixture to ~/dev/spinnaker/deck/test/functional/tests/core/home.spec.ts.mountebank_fixture.json
1 passing (5.60s)
```

5. Kill the Gate process. On Mac this would go something like:

```
kill -15 $(lsof -t -i tcp:18084)
```

6. Run the test again without Gate running, instructing the test runner to create a network imposter:

```
$ ./node_modules/.bin/wdio wdio.conf.js --replay-fixtures --spec test/functional/tests/core/home.spec.ts
DEPRECATION: Setting specFilter directly on Env is deprecated, please use the specFilter option in `configure`
DEPRECATION: Setting stopOnSpecFailure directly is deprecated, please use the failFast option in `configure`
Creating imposter from fixture file ~/dev/spinnaker/deck/test/functional/tests/core/home.spec.ts.mountebank_fixture.json
1 passing (6.00s)
```

The mountebank server will still be running on port 2525 but can easily be exited by calling:

```
kill -15 $(lsof -t -i tcp:2525)
```
14 changes: 14 additions & 0 deletions test/functional/tools/FixtureService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import * as path from 'path';

export class FixtureService {
constructor() {}

public fixtureNameForTestPath(testpath: string) {
const basename = path.basename(testpath);
return basename + '.mountebank_fixture.json';
}

public fixturePathForTestPath(testpath: string) {
return path.join(path.dirname(testpath), this.fixtureNameForTestPath(testpath));
}
}
191 changes: 191 additions & 0 deletions test/functional/tools/MountebankService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import * as fs from 'fs';
import { spawn, ChildProcess } from 'child_process';
import * as request from 'request-promise-native';

const STARTUP_TIMEOUT_MS = 5000;

export class MountebankService {
private process: ChildProcess;

public static builder(): MountebankServiceBuilder {
return new MountebankServiceBuilder();
}

constructor(private options: MountebankServiceOptions) {}

public launchServer(): Promise<any | Error> {
return new Promise((resolve, reject) => {
setTimeout(() => {
reject('mountebank server took too long to start');
}, STARTUP_TIMEOUT_MS);
if (this.process == null) {
this.process = spawn(this.options.mountebankPath, ['--port', String(this.options.mountebankPort)]);
this.process.stdout.on('data', data => {
const str = String(data);
if (str.includes('now taking orders')) {
resolve();
}
this.options.onStdOut(str);
});
this.process.stderr.on('data', data => {
const str = String(data);
reject(str);
this.options.onStdErr(str);
});
this.process.on('close', code => {
this.options.onClose(code);
});
}
});
}

public kill() {
if (this.process) {
this.process.kill();
this.process = null;
}
}

public createImposterFromFixtureFile(filepath: string): request.RequestPromise<any> | Promise<any> {
console.log('Creating imposter from fixture file', filepath);
try {
const rawFixture = fs.readFileSync(filepath, { encoding: 'utf8' });
const fixture = JSON.parse(rawFixture);
if (fixture) {
return request({
method: 'post',
json: true,
uri: `http://localhost:${this.options.mountebankPort}/imposters`,
body: fixture,
});
} else {
throw new Error(`no fixture found: ${filepath}`);
}
} catch (e) {
// Clean up on failure
return this.removeImposters().then(() => {
throw e;
});
}
}

public removeImposters(): request.RequestPromise<any> {
return request({
method: 'delete',
json: true,
uri: `http://localhost:${this.options.mountebankPort}/imposters`,
body: {
port: this.options.gatePort,
protocol: 'http',
stubs: [
{
responses: [
{
proxy: {
to: `http://localhost:${this.options.imposterPort}`,
mode: 'proxyTransparent',
predicateGenerators: [
{
matches: { method: true, path: true, query: true },
},
],
},
},
],
},
],
},
});
}

public beginRecording(): request.RequestPromise<any> {
return request.post({
method: 'post',
json: true,
uri: `http://localhost:${this.options.mountebankPort}/imposters`,
body: {
port: this.options.imposterPort,
protocol: 'http',
stubs: [
{
responses: [
{
proxy: {
to: `http://localhost:${this.options.gatePort}`,
predicateGenerators: [
{
matches: { method: true, path: true, query: true },
caseSensitive: true,
},
],
},
},
],
},
],
},
});
}

public saveRecording(filepath: string): Promise<any> {
const { mountebankPort, imposterPort } = this.options;
return request
.get(`http://localhost:${mountebankPort}/imposters/${imposterPort}?replayable=true&removeProxies=true`)
.then((res: any) => {
fs.writeFileSync(filepath, res);
});
}
}

export class MountebankServiceOptions {
public mountebankPath: string = 'node_modules/.bin/mb';
public mountebankPort: number = 2525; // Mountebank controller runs on this port
public gatePort: number = 18084; // Gate running on this port
public imposterPort: number = 8084; // port Deck will send requests to; Mountebank will insert an imposter here
public onStdOut = (_data: string) => {};
public onStdErr = (_data: string) => {};
public onClose = (_code: number) => {};
}

export class MountebankServiceBuilder {
private options: MountebankServiceOptions = new MountebankServiceOptions();

mountebankPath(p: string): MountebankServiceBuilder {
this.options.mountebankPath = p;
return this;
}

mountebankPort(p: number): MountebankServiceBuilder {
this.options.mountebankPort = p;
return this;
}

gatePort(p: number): MountebankServiceBuilder {
this.options.gatePort = p;
return this;
}

imposterPort(p: number): MountebankServiceBuilder {
this.options.imposterPort = p;
return this;
}

onStdOut(fn: (data: string) => void) {
this.options.onStdOut = fn;
return this;
}

onStdErr(fn: (data: string) => void) {
this.options.onStdErr = fn;
return this;
}

onClose(fn: (code: number) => void) {
this.options.onClose = fn;
return this;
}

build(): MountebankService {
return new MountebankService(this.options);
}
}
42 changes: 42 additions & 0 deletions wdio.conf.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,16 @@ const fs = require('fs');
const path = require('path');
const process = require('process');
const minimist = require('minimist');
const { MountebankService } = require('./test/functional/tools/MountebankService');
const { FixtureService } = require('./test/functional/tools/FixtureService');

const flags = minimist(process.argv.slice(2), {
default: {
'replay-fixtures': false,
'record-fixtures': false,
'mountebank-port': 2525,
'gate-port': 18084,
'imposter-port': 8084,
browser: 'chrome',
headless: false,
savelogs: false,
Expand All @@ -18,6 +25,15 @@ if (flags.savelogs && flags.browser !== 'chrome') {
flags.savelogs = false;
}

const mountebankService = MountebankService.builder()
.mountebankPath(path.resolve(__dirname, './node_modules/.bin/mb'))
.mountebankPort(flags['mountebank-port'])
.gatePort(flags['gate-port'])
.imposterPort(flags['imposter-port'])
.build();

let testRun = null;

const config = {
specs: ['test/functional/tests/**/*.spec.ts'],
maxInstances: 1,
Expand Down Expand Up @@ -50,9 +66,35 @@ const config = {

beforeTest: function(test) {
browser.windowHandleSize({ width: 1280, height: 1024 });
if (!flags['replay-fixtures'] && !flags['record-fixtures']) {
return;
}
const fixtureService = new FixtureService();
testRun = { fixtureFile: fixtureService.fixturePathForTestPath(test.file) };
return mountebankService.removeImposters().then(() => {
if (flags['record-fixtures']) {
return mountebankService.beginRecording();
} else {
return mountebankService.createImposterFromFixtureFile(testRun.fixtureFile);
}
});
},

afterTest: function(test) {
if (flags['record-fixtures']) {
if (test.passed) {
mountebankService
.saveRecording(testRun.fixtureFile)
.then(() => {
console.log(`wrote fixture to ${testRun.fixtureFile}`);
})
.catch(err => {
console.log(`error saving recording: ${err}`);
});
} else {
console.log(`test failed: "${test.fullName}"; network fixture will not be saved.`);
}
}
if (flags.savelogs && browser.sessionId) {
const outPath = path.resolve(__dirname, './' + browser.sessionId + '.browser.log');
fs.writeFileSync(outPath, JSON.stringify(browser.log('browser'), null, 4));
Expand Down
Loading

0 comments on commit c92d463

Please sign in to comment.