123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570 |
- /* eslint no-console:0, prefer-spread:0 */
- "use strict";
- const childProcess = require("child_process");
- const fs = require("fs-extra");
- const jspngopt = require("jspngopt");
- const net = require("net");
- const os = require("os");
- const pako = require("pako");
- const path = require("path");
- const selenium = require("selenium-webdriver");
- const firefox = require("selenium-webdriver/firefox");
- const istanbulApi = require('istanbul-api');
- const istanbulLibCoverage = require('istanbul-lib-coverage');
- const webpack = require('webpack');
- const WebpackDevServer = require("webpack-dev-server");
- const webpackConfig = require("../../webpack.dev")[0];
- const data = require("../../test/screenshotter/ss_data");
- // Change to KaTeX root directory so that webpack (in particular
- // babel-plugin-version-inline) runs correctly.
- process.chdir(path.join(__dirname, "..", ".."));
- const dstDir = path.normalize(path.join("test", "screenshotter", "images"));
- const diffDir = path.normalize(path.join("test", "screenshotter", "diff"));
- const newDir = path.normalize(path.join("test", "screenshotter", "new"));
- //////////////////////////////////////////////////////////////////////
- // Process command line arguments
- const opts = require("commander")
- .option("-b, --browser <firefox|chrome|ie|edge|safari>",
- "Name of the browser to use", "firefox")
- .option("-c, --container <id>",
- "Name or ID of a running docker container to contact")
- .option("--selenium-url <url>", "Full URL of the Selenium web driver")
- .option("--selenium-ip <ip>", "IP address of the Selenium web driver")
- .option("--selenium-port <n>",
- "Port number of the Selenium web driver", 4444, parseInt)
- .option("--selenium-capabilities <JSON>",
- "Desired capabilities of the Selenium web driver", JSON.parse)
- .option("--katex-url <url>", "Full URL of the KaTeX development server")
- .option("--katex-ip <ip>", "IP address of the KaTeX development server")
- .option("--katex-port <n>",
- "Port number of the KaTeX development server", parseInt)
- .option("-i, --include <tests>",
- "Comma-separated list of test cases to process")
- .option("-x, --exclude <tests>",
- "Comma-separated list of test cases to exclude")
- .option("--reload", "Reload page for each test")
- .option("--verify", "Check whether screenshot matches current file content")
- .option("--diff", "With `--verify`, produce image diffs when match fails")
- .option("--new",
- "With `--verify`, generate new screenshots when match fails")
- .option("--coverage", "Collect and report test coverage information")
- .option("--attempts <n>",
- "Retry this many times before reporting failure", 5, parseInt)
- .option("--wait <secs>",
- "Wait this many seconds between page load and screenshot", parseFloat)
- .parse(process.argv);
- let listOfCases;
- if (opts.include) {
- listOfCases = opts.include.split(",");
- } else {
- listOfCases = Object.keys(data);
- }
- if (opts.exclude) {
- const exclude = opts.exclude.split(",");
- listOfCases = listOfCases.filter(function(key) {
- return exclude.indexOf(key) === -1;
- });
- }
- let seleniumURL = opts.seleniumUrl;
- let seleniumIP = opts.seleniumIp;
- let seleniumPort = opts.seleniumPort;
- let katexURL = opts.katexUrl;
- let katexIP = opts.katexIp;
- let katexPort = opts.katexPort;
- //////////////////////////////////////////////////////////////////////
- // Work out connection to selenium docker container
- function check(err) {
- if (!err) {
- return;
- }
- console.error(err);
- console.error(err.stack);
- process.exit(1);
- }
- function cmd() {
- const args = Array.prototype.slice.call(arguments);
- const cmd = args.shift();
- return childProcess.execFileSync(
- cmd, args, {encoding: "utf-8"}).replace(/\n$/, "");
- }
- function guessDockerIPs() {
- if (process.env.DOCKER_MACHINE_NAME) {
- const machine = process.env.DOCKER_MACHINE_NAME;
- seleniumIP = seleniumIP || cmd("docker-machine", "ip", machine);
- katexIP = katexIP || cmd("docker-machine", "ssh", machine,
- "echo ${SSH_CONNECTION%% *}");
- return;
- }
- try {
- // When using boot2docker, seleniumIP and katexIP are distinct.
- seleniumIP = seleniumIP || cmd("boot2docker", "ip");
- let config = cmd("boot2docker", "config");
- config = (/^HostIP = "(.*)"$/m).exec(config);
- if (!config) {
- console.error("Failed to find HostIP");
- process.exit(2);
- }
- katexIP = katexIP || config[1];
- return;
- } catch (e) {
- // Apparently no boot2docker, continue
- }
- if (!process.env.DOCKER_HOST && os.type() === "Darwin") {
- // Docker for Mac
- seleniumIP = seleniumIP || "localhost";
- katexIP = katexIP || "*any*"; // see findHostIP
- return;
- }
- // Native Docker on Linux or remote Docker daemon or similar
- const gatewayIP = cmd("docker", "inspect",
- "-f", "{{.NetworkSettings.Gateway}}", opts.container);
- seleniumIP = seleniumIP || gatewayIP;
- katexIP = katexIP || gatewayIP;
- }
- if (!seleniumURL && opts.container) {
- if (!seleniumIP || !katexIP) {
- guessDockerIPs();
- }
- seleniumPort = cmd("docker", "port", opts.container, seleniumPort);
- seleniumPort = seleniumPort.replace(/^.*:/, "");
- }
- if (!seleniumURL && seleniumIP) {
- seleniumURL = "http://" + seleniumIP + ":" + seleniumPort + "/wd/hub";
- }
- if (seleniumURL) {
- console.log("Selenium driver at " + seleniumURL);
- } else {
- console.log("Selenium driver in local session");
- }
- process.nextTick(startServer);
- let attempts = 0;
- //////////////////////////////////////////////////////////////////////
- // Start up development server
- let devServer = null;
- let coverageMap;
- const minPort = 32768;
- const maxPort = 61000;
- function startServer() {
- if (katexURL || katexPort) {
- process.nextTick(tryConnect);
- return;
- }
- const port = Math.floor(Math.random() * (maxPort - minPort)) + minPort;
- if (opts.coverage) {
- coverageMap = istanbulLibCoverage.createCoverageMap({});
- webpackConfig.module.rules[0].use = {
- loader: 'babel-loader',
- options: {
- plugins: [['istanbul', {
- include: ["src/**/*.js"],
- exclude: ["src/unicodeMake.js"],
- }]],
- },
- };
- }
- const compiler = webpack(webpackConfig);
- const wds = new WebpackDevServer(compiler, webpackConfig.devServer);
- const server = wds.listen(port);
- server.once("listening", function() {
- devServer = wds;
- katexPort = port;
- attempts = 0;
- process.nextTick(tryConnect);
- });
- server.on("error", function(err) {
- if (devServer !== null) { // error after we started listening
- throw err;
- } else if (++attempts > 50) {
- throw new Error("Failed to start up dev server");
- } else {
- process.nextTick(startServer);
- }
- });
- }
- //////////////////////////////////////////////////////////////////////
- // Wait for container to become ready
- function tryConnect() {
- if (!seleniumIP) {
- process.nextTick(buildDriver);
- return;
- }
- const sock = net.connect({
- host: seleniumIP,
- port: +seleniumPort,
- });
- sock.on("connect", function() {
- sock.end();
- attempts = 0;
- process.nextTick(buildDriver);
- }).on("error", function() {
- if (++attempts > 50) {
- throw new Error("Failed to connect selenium server.");
- }
- setTimeout(tryConnect, 200);
- });
- }
- //////////////////////////////////////////////////////////////////////
- // Build the web driver
- let driver;
- let driverReady = false;
- function buildDriver() {
- const builder = new selenium.Builder().forBrowser(opts.browser);
- if (opts.browser === "firefox") {
- const ffProfile = new firefox.Profile();
- ffProfile.setPreference(
- "browser.startup.homepage_override.mstone", "ignore");
- ffProfile.setPreference("browser.startup.page", 0);
- const ffOptions = new firefox.Options().setProfile(ffProfile);
- builder.setFirefoxOptions(ffOptions);
- }
- if (seleniumURL) {
- builder.usingServer(seleniumURL);
- }
- if (opts.seleniumCapabilities) {
- builder.withCapabilities(opts.seleniumCapabilities);
- }
- driver = builder.build();
- driver.manage().timeouts().setScriptTimeout(3000).then(function() {
- let html = '<!DOCTYPE html>' +
- '<html><head><style type="text/css">html,body{' +
- 'width:100%;height:100%;margin:0;padding:0;overflow:hidden;' +
- '}</style></head><body><p>Test</p></body></html>';
- html = "data:text/html," + encodeURIComponent(html);
- return driver.get(html);
- }).then(function() {
- setSize(targetW, targetH);
- });
- }
- //////////////////////////////////////////////////////////////////////
- // Set the screen size
- const targetW = 1024;
- const targetH = 768;
- function setSize(reqW, reqH) {
- return driver.manage().window().setSize(reqW, reqH).then(function() {
- return driver.takeScreenshot();
- }).then(function(img) {
- img = imageDimensions(img);
- const actualW = img.width;
- const actualH = img.height;
- if (actualW === targetW && actualH === targetH) {
- findHostIP();
- return;
- }
- if (++attempts > opts.attempts) {
- throw new Error("Failed to set window size correctly.");
- }
- return setSize(targetW + reqW - actualW, targetH + reqH - actualH);
- }, check);
- }
- function imageDimensions(img) {
- const buf = new Buffer(img, "base64");
- return {
- buf: buf,
- width: buf.readUInt32BE(16),
- height: buf.readUInt32BE(20),
- };
- }
- //////////////////////////////////////////////////////////////////////
- // Work out how to connect to host KaTeX server
- function findHostIP() {
- if (!katexIP) {
- katexIP = "localhost";
- }
- if (katexIP !== "*any*" || katexURL) {
- if (!katexURL) {
- katexURL = "http://" + katexIP + ":" + katexPort + "/";
- console.log("KaTeX URL is " + katexURL);
- }
- process.nextTick(takeScreenshots);
- return;
- }
- // Now we need to find an IP the container can connect to.
- // First, install a server component to get notified of successful connects
- devServer.app.get("/ss-connect.js", function(req, res, next) {
- if (!katexURL) {
- katexIP = req.query.ip;
- katexURL = "http://" + katexIP + ":" + katexPort + "/";
- console.log("KaTeX URL is " + katexURL);
- process.nextTick(takeScreenshots);
- }
- res.setHeader("Content-Type", "text/javascript");
- res.send("//OK");
- });
- // Next, enumerate all network addresses
- const ips = [];
- const devs = os.networkInterfaces();
- for (const dev in devs) {
- if (devs.hasOwnProperty(dev)) {
- const addrs = devs[dev];
- for (let i = 0; i < addrs.length; ++i) {
- let addr = addrs[i].address;
- if (/:/.test(addr)) {
- addr = "[" + addr + "]";
- }
- ips.push(addr);
- }
- }
- }
- console.log("Looking for host IP among " + ips.join(", "));
- // Load a data: URI document which attempts to contact each of these IPs
- let html = "<!doctype html>\n<html><body>\n";
- html += ips.map(function(ip) {
- return '<script src="http://' + ip + ':' + katexPort +
- '/ss-connect.js?ip=' + encodeURIComponent(ip) +
- '" defer></script>';
- }).join("\n");
- html += "\n</body></html>";
- html = "data:text/html," + encodeURIComponent(html);
- driver.get(html);
- }
- //////////////////////////////////////////////////////////////////////
- // Take the screenshots
- let countdown = listOfCases.length;
- let exitStatus = 0;
- const listOfFailed = [];
- function takeScreenshots() {
- listOfCases.forEach(takeScreenshot);
- }
- function takeScreenshot(key) {
- const itm = data[key];
- if (!itm) {
- console.error("Test case " + key + " not known!");
- listOfFailed.push(key);
- if (exitStatus === 0) {
- exitStatus = 1;
- }
- oneDone();
- return;
- }
- let file = path.join(dstDir, key + "-" + opts.browser + ".png");
- let retry = 0;
- let loadExpected = null;
- if (opts.verify) {
- loadExpected = fs.readFile(file);
- }
- const url = katexURL + "test/screenshotter/test.html?" + itm.query;
- driver.call(loadMath);
- function loadMath() {
- if (!opts.reload && driverReady) {
- driver.executeAsyncScript(
- "var callback = arguments[arguments.length - 1]; " +
- "handle_search_string(" +
- JSON.stringify("?" + itm.query) + ", callback);")
- .then(waitThenScreenshot);
- } else if (opts.coverage) {
- // collect coverage before reloading
- collectCoverage().then(function() {
- return driver.get(url).then(waitThenScreenshot);
- });
- } else {
- driver.get(url).then(waitThenScreenshot);
- }
- }
- function collectCoverage() {
- return driver.executeScript('return window.__coverage__;')
- .then(function(result) {
- if (result) {
- coverageMap.merge(result);
- }
- });
- }
- function waitThenScreenshot() {
- driverReady = true;
- if (opts.wait) {
- browserSideWait(1000 * opts.wait);
- }
- const promise = driver.takeScreenshot().then(haveScreenshot);
- if (retry === 0) {
- // The `oneDone` promise remains outstanding if we retry, so
- // don't re-add it
- promise.then(oneDone, check);
- }
- }
- function haveScreenshot(img) {
- img = imageDimensions(img);
- if (img.width !== targetW || img.height !== targetH) {
- throw new Error("Expected " + targetW + " x " + targetH +
- ", got " + img.width + "x" + img.height);
- }
- if (key === "Lap" && opts.browser === "firefox" &&
- img.buf[0x32] === 0xf8) {
- /* There is some strange non-determinism with this case,
- * causing slight vertical shifts. The first difference
- * is at offset 0x32, where one file has byte 0xf8 and
- * the other has something else. By using a different
- * output file name for one of these cases, we accept both.
- */
- key += "_alt";
- file = path.join(dstDir, key + "-" + opts.browser + ".png");
- if (loadExpected) {
- loadExpected = fs.readFile(file);
- }
- }
- const opt = new jspngopt.Optimizer({
- pako: pako,
- });
- const buf = opt.bufferSync(img.buf);
- if (loadExpected) {
- return loadExpected.then(function(expected) {
- if (!buf.equals(expected)) {
- if (++retry >= opts.attempts) {
- console.error("FAIL! " + key);
- listOfFailed.push(key);
- exitStatus = 3;
- if (opts.diff || opts.new) {
- return saveFailedScreenshot(key, buf);
- }
- } else {
- console.log("error " + key);
- browserSideWait(300 * retry);
- if (retry > 1) {
- driverReady = false; // reload fully
- }
- return driver.call(loadMath);
- }
- } else {
- console.log("* ok " + key);
- }
- });
- } else {
- return fs.writeFile(file, buf).then(function() {
- console.log(key);
- });
- }
- }
- function saveFailedScreenshot(key, buf) {
- const filenamePrefix = key + "-" + opts.browser;
- const outputDir = opts.new ? newDir : diffDir;
- const baseFile = path.join(dstDir, filenamePrefix + ".png");
- const diffFile = path.join(diffDir, filenamePrefix + "-diff.png");
- const bufFile = path.join(outputDir, filenamePrefix + ".png");
- let promise = fs.ensureDir(outputDir)
- .then(function() {
- return fs.writeFile(bufFile, buf);
- });
- if (opts.diff) {
- promise = promise.then(fs.ensureDir(diffDir))
- .then(function() {
- return execFile("convert", [
- "-fill", "white",
- // First image: saved screenshot in red
- "(", baseFile, "-colorize", "100,0,0", ")",
- // Second image: new screenshot in green
- "(", bufFile, "-colorize", "0,80,0", ")",
- // Composite them
- "-compose", "darken", "-composite",
- "-trim", // remove everything with the same color as
- // the corners
- diffFile, // output file name
- ]);
- });
- }
- if (!opts.new) {
- promise = promise.then(function() {
- return fs.unlink(bufFile);
- });
- }
- return promise;
- }
- function oneDone() {
- if (--countdown === 0) {
- if (listOfFailed.length) {
- console.error("Failed: " + listOfFailed.join(" "));
- }
- if (opts.diff) {
- console.log("Diffs have been generated in: " + diffDir);
- }
- if (opts.new) {
- console.log("New screenshots have been generated in: " + newDir);
- }
- if (opts.coverage) {
- collectCoverage().then(function() {
- const reporter = istanbulApi.createReporter();
- reporter.addAll(['json', 'text', 'lcov']);
- reporter.write(coverageMap);
- done();
- });
- return;
- }
- done();
- }
- }
- function done() {
- // devServer.close(cb) will take too long.
- driver.quit().then(() => {
- process.exit(exitStatus);
- });
- }
- }
- // Wait using a timeout call in the browser, to ensure that the wait
- // time doesn't start before the page has reportedly been loaded.
- function browserSideWait(milliseconds) {
- // The last argument (arguments[1] here) is the callback to selenium
- return driver.executeAsyncScript(
- "window.setTimeout(arguments[1], arguments[0]);",
- milliseconds);
- }
- // Execute a given command, and return a promise to its output.
- function execFile(cmd, args, opts) {
- return new Promise(function(resolve, reject) {
- childProcess.execFile(cmd, args, opts, function(err, stdout, stderr) {
- if (err) {
- console.error("Error executing " + cmd + " " + args.join(" "));
- console.error(stdout + stderr);
- err.stdout = stdout;
- err.stderr = stderr;
- reject(err);
- } else {
- resolve(stdout);
- }
- });
- });
- }
|