Skip to content
Open
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