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

Add remote docker chrome target for CI server #129

Open
wants to merge 12 commits into
base: master
Choose a base branch
from
3 changes: 3 additions & 0 deletions docs/command-line-arguments.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ yarn loki test -- --port 9009
| **`--difference`** | Path to image diff folder | `./.loki/difference` |
| **`--diffingEngine`** | What diffing engine to use, currently supported are `looks-same` and `gm` | `gm` if available |
| **`--chromeConcurrency`** | How many stories to test in parallel when using chrome | `4` |
| **`--chromeDockerUseExisting`**| Whether to use an already running container wiht headless chrome or spin up a new one. See also `--chromeDockerHost` and `--chromeDockerHost` | `false` |
| **`--chromeDockerHost`** | At which host to look for a docker container running headless chrome. Only relevant if chromeDockerUseExisting is set | `localhost` |
| **`--chromeDockerPort`** | At what port to look for a docker container running headless chrome. Only relevant if chromeDockerUseExisting is set | `9222` |
| **`--chromeDockerImage`** | What docker image to use when running chrome | `yukinying/chrome-headless:63.0.3230.2` |
| **`--chromeEnableAnimations`** | Enable CSS transitions and animations. | `false` |
| **`--chromeFlags`** | Custom chrome flags. | `--headless --disable-gpu --hide-scrollbars` |
Expand Down
5 changes: 5 additions & 0 deletions docs/continuous-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,8 @@ build-storybook && loki --requireReference --reactUri file:./storybook-static
```

See the [loki react example project](https://github.com/oblador/loki/tree/master/examples/react) for a reference implementation of this approach.

## Using Docker

Some CIs like e. g. [CircleCI](http://circleci.com/) run the code in a Docker container already, so setting up a Chrome headless docker container inside the container isn't a viable solution. Instead it is possible to spin up a headless-chrome container in the CI and connect it to the test.
To do this you can run loki with `--chromeDockerUseExisting`. This way loki will look for an already running container at `localhost:9222` (or any other location you specify via `--chromeDockerHost` and `--chromeDockerHost`). We recommend to use the exact same image for headless chrome on your CI that you use for local testing to avoid discrepancies in rendering.
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,21 +44,25 @@
},
"license": "MIT",
"dependencies": {
"bluebird": "^3.5.3",
"chalk": "^2.4.1",
"chrome-launcher": "^0.10.5",
"chrome-remote-interface": "^0.27.0",
"ci-info": "^1.6.0",
"debug": "^4.1.0",
"execa": "^1.0.0",
"express": "^4.16.4",
"fs-extra": "^7.0.1",
"get-port": "^4.0.0",
"gm": "^1.23.1",
"hoist-non-react-statics": "^3.2.1",
"lighthouse": "^2.2.1",
"listr": "^0.14.3",
"looks-same": "^4.0.0",
"minimist": "^1.2.0",
"osnap": "^1.1.0",
"ramda": "^0.26.1",
"serve-handler": "^5.0.7",
"shelljs": "^0.8.3",
"transliteration": "^1.6.6",
"wait-on": "^3.2.0",
Expand Down
2 changes: 2 additions & 0 deletions src/commands/test/default-options.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
{
"chromeConcurrency": "4",
"chromeDockerHost": "localhost",
"chromeDockerPort": "9222",
"chromeDockerImage": "yukinying/chrome-headless:63.0.3230.2",
"chromeFlags": "--headless --disable-gpu --hide-scrollbars",
"chromeLoadTimeout": "60000",
Expand Down
6 changes: 5 additions & 1 deletion src/commands/test/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,11 @@ async function test(args) {
}

try {
await runTests(configurations, options);
await runTests(configurations, options)
.then(() => process.exit(0))
.catch((err) => {
throw err;
});
} catch (err) {
if (err.name === 'ListrError') {
const imageErrors = err.errors.filter(
Expand Down
5 changes: 4 additions & 1 deletion src/commands/test/parse-options.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ const getAbsoluteURL = require('./get-absolute-url');

function parseOptions(args, config) {
const argv = minimist(args, {
boolean: ['requireReference', 'chromeEnableAnimations', 'verboseRenderer'],
boolean: ['requireReference', 'chromeEnableAnimations', 'chromeDockerUseExisting', 'verboseRenderer'],
});

const $ = key => argv[key] || config[key] || defaults[key];
Expand All @@ -21,6 +21,9 @@ function parseOptions(args, config) {
`http://${$('host')}:${argv.port || $('reactPort')}`,
reactNativeUri: `ws://${$('host')}:${argv.port || $('reactNativePort')}`,
chromeConcurrency: parseInt($('chromeConcurrency'), 10),
chromeDockerUseExisting: $('chromeDockerUseExisting'),
chromeDockerHost: $('chromeDockerHost'),
chromeDockerPort: $('chromeDockerPort'),
chromeDockerImage: $('chromeDockerImage'),
chromeEnableAnimations: $('chromeEnableAnimations'),
chromeFlags: $('chromeFlags').split(' '),
Expand Down
5 changes: 4 additions & 1 deletion src/commands/test/run-tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,10 @@ async function runTests(flatConfigurations, options) {
createChromeDockerTarget({
baseUrl: options.reactUri,
chromeDockerImage: options.chromeDockerImage,
chromeFlags: options.chromeFlags,
chromeDockerUseExisting: options.chromeDockerUseExisting,
chromeDockerHost: options.chromeDockerHost,
chromeDockerPort: options.chromeDockerPort,
chromeFlags: options.chromeFlags
}),
configurations,
options.chromeConcurrency,
Expand Down
8 changes: 5 additions & 3 deletions src/targets/chrome/create-chrome-target.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ const { FetchingURLsError, ServerError } = require('../../errors');

const LOADING_STORIES_TIMEOUT = 60000;
const CAPTURING_SCREENSHOT_TIMEOUT = 30000;
const REQUEST_STABILIZATION_TIMEOUT = 100;
const REQUEST_STABILIZATION_TIMEOUT = 1000;

function createChromeTarget(
start,
Expand Down Expand Up @@ -149,8 +149,10 @@ function createChromeTarget(
}

debug(`Navigating to ${url}`);
await Promise.all([Page.navigate({ url }), awaitRequestsFinished()]);

await Promise.all([
Page.navigate({ url, transitionType: 'auto_subframe' }),
awaitRequestsFinished()
]);
debug('Awaiting runtime setup');
await executeFunctionWithWindow(awaitLokiReady);

Expand Down
144 changes: 144 additions & 0 deletions src/targets/chrome/create-existing-docker-target.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
const debug = require('debug')('loki:chrome:existing');
const CDP = require('chrome-remote-interface');
const serveHandler = require('serve-handler');
const http = require('http');
const Promise = require('bluebird');
const waitOnCDPAvailable = require('./helpers/wait-on-CDP-available');
const getLocalIPAddress = require('./helpers/get-local-ip-address');
const createChromeTarget = require('./create-chrome-target');

const SERVER_STARTUP_TIMEOUT = 10000; /* ms */

function createChromeDockerTarget({
baseUrl = 'http://localhost:6006',
chromeFlags = ['--headless', '--disable-gpu', '--hide-scrollbars'],
chromeDockerHost = 'localhost',
chromeDockerPort = '9222'
}) {
let storybookUrl = baseUrl;

const RUN_MODES = {
local: 'local',
remote: 'remote',
file: 'file'
};

let runMode = RUN_MODES.remote;
if (baseUrl.indexOf('http://localhost') === 0) {
runMode = RUN_MODES.local;
} else if (baseUrl.indexOf('file:') === 0) {
runMode = RUN_MODES.file;
}
debug(`Running in ${runMode} mode`);

let server;
let storybookPort;
let storybookHost;
if (runMode === RUN_MODES.file) {
const staticPath = baseUrl.substr('file:'.length);
storybookPort = 8080;
const ip = getLocalIPAddress();
if (!ip) {
throw new Error(
'Unable to detect local IP address, try passing --host argument'
);
}
storybookUrl = `http://${ip}:${storybookPort}`;
storybookHost = ip;
server = http.createServer((req, res) =>
serveHandler(req, res, {
public: staticPath,
cleanUrls: false,
renderSingle: true
})
);
debug(`Serving files from ${staticPath}`);
}

if (runMode === RUN_MODES.local) {
const ip = getLocalIPAddress();
if (!ip) {
throw new Error(
'Unable to detect local IP address, try passing --host argument'
);
}
storybookUrl = baseUrl.replace('localhost', ip);
}

debug(`Looking for storybook at ${storybookUrl}`);

let dockerHost = chromeDockerHost;
if (chromeDockerHost.indexOf('localhost') === 0) {
const ip = getLocalIPAddress();
if (!ip) {
throw new Error(
'Unable to detect local IP address, try passing --host argument'
);
}
dockerHost = chromeDockerHost.replace('localhost', ip);
}

async function start() {
debug(
`Trying to connect to Chrome at http://${dockerHost}:${chromeDockerPort}`
);
if (server) {
debug(`Serving storybook at http://${storybookHost}:${storybookPort}`);
server.listen({ host: storybookHost, port: storybookPort });
await Promise.all([
waitOnCDPAvailable(dockerHost, chromeDockerPort),
new Promise((resolve, reject) => {
server.addListener('listening', resolve);
server.addListener('error', reject);
}).timeout(SERVER_STARTUP_TIMEOUT)
]);
debug('Set up complete');
} else {
await waitOnCDPAvailable(dockerHost, chromeDockerPort);
}
}

async function stop() {
if (server) {
const serverClosed = new Promise((resolve, reject) => {
server.on('close', resolve);
server.on('error', reject);
});
server.close();
await serverClosed;
}
}

async function createNewDebuggerInstance() {
debug(
`Launching new tab with debugger at port ${dockerHost}:${chromeDockerPort}`
);
const target = await CDP.New({ host: dockerHost, port: chromeDockerPort });
debug(`Launched with target id ${target.id}`);
const client = await CDP({
host: dockerHost,
port: chromeDockerPort,
target
});

client.close = () => {
debug('New closing tab');
return CDP.Close({
host: dockerHost,
port: chromeDockerPort,
id: target.id
});
};

return client;
}

return createChromeTarget(
start,
stop,
createNewDebuggerInstance,
storybookUrl
);
}

module.exports = createChromeDockerTarget;
53 changes: 16 additions & 37 deletions src/targets/chrome/docker.js
Original file line number Diff line number Diff line change
@@ -1,44 +1,14 @@
const debug = require('debug')('loki:chrome:docker');
const os = require('os');
const { execSync } = require('child_process');
const execa = require('execa');
const waitOn = require('wait-on');
const getLocalIPAddress = require('./helpers/get-local-ip-address');
const waitOnCDPAvailable = require('./helpers/wait-on-CDP-available');
const CDP = require('chrome-remote-interface');
const fs = require('fs-extra');
const getRandomPort = require('get-port');
const { ensureDependencyAvailable } = require('../../dependency-detection');
const createChromeTarget = require('./create-chrome-target');

const getLocalIPAddress = () => {
const interfaces = os.networkInterfaces();
const ips = Object.keys(interfaces)
.map(key =>
interfaces[key]
.filter(({ family, internal }) => family === 'IPv4' && !internal)
.map(({ address }) => address)
)
.reduce((acc, current) => acc.concat(current), []);
return ips[0];
};

const waitOnCDPAvailable = (host, port) =>
new Promise((resolve, reject) => {
waitOn(
{
resources: [`tcp:${host}:${port}`],
delay: 50,
interval: 100,
timeout: 5000,
},
err => {
if (err) {
reject(err);
} else {
resolve();
}
}
);
});
const createExistingDockerTarget = require('./create-existing-docker-target');

const getNetworkHost = async dockerId => {
let host = '127.0.0.1';
Expand Down Expand Up @@ -72,11 +42,20 @@ function createChromeDockerTarget({
baseUrl = 'http://localhost:6006',
chromeDockerImage = 'yukinying/chrome-headless',
chromeFlags = ['--headless', '--disable-gpu', '--hide-scrollbars'],
chromeDockerUseExisting = false,
chromeDockerHost = 'localhost',
chromeDockerPort = '9222',
}) {
if (chromeDockerUseExisting) {
return createExistingDockerTarget({
baseUrl, chromeFlags, chromeDockerHost, chromeDockerPort
})
}

let port;
let dockerId;
let host;
let dockerUrl = baseUrl;
let storybookUrl = baseUrl;
const dockerPath = 'docker';
const runArgs = ['run', '--rm', '-d', '-P'];

Expand All @@ -91,13 +70,13 @@ function createChromeDockerTarget({
'Unable to detect local IP address, try passing --host argument'
);
}
dockerUrl = baseUrl.replace('localhost', ip);
storybookUrl = baseUrl.replace('localhost', ip);
} else if (baseUrl.indexOf('file:') === 0) {
const staticPath = baseUrl.substr('file:'.length);
const staticMountPath = '/var/loki';
runArgs.push('-v');
runArgs.push(`${staticPath}:${staticMountPath}`);
dockerUrl = `file://${staticMountPath}`;
storybookUrl = `file://${staticMountPath}`;
}

async function getIsImageDownloaded(imageName) {
Expand Down Expand Up @@ -188,7 +167,7 @@ function createChromeDockerTarget({
start,
stop,
createNewDebuggerInstance,
dockerUrl,
storybookUrl,
ensureImageDownloaded
);
}
Expand Down
17 changes: 17 additions & 0 deletions src/targets/chrome/helpers/get-local-ip-address.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
const os = require('os');

/**
* Returns the first external IPv4 address this machine can be reached at.
* @returns {String} The IPv4 address
*/
module.exports = function getLocalIPAddress () {
const interfaces = os.networkInterfaces();
const ips = Object.keys(interfaces)
.map(key =>
interfaces[key]
.filter(({ family, internal }) => family === 'IPv4' && !internal)
.map(({ address }) => address)
)
.reduce((acc, current) => acc.concat(current), []);
return ips[0];
};
Loading