KaTeX/dockers/Screenshotter/screenshotter.js
Martin von Gagern be96695586 Improved Docker for Mac support (#568)
* Exit cleanly after invalid screenshot test name

This avoids waiting forever.

* Auto-detect host IP in Docker for Mac environment

This checks all available network addresses to find one which the Selenium
container can connect to.  That way we don't have to analyze network
settings or similar to figure out the main public IP address of the machine.

* Make server less sensitive to current working directory

That way it becomes possible to run screenshotter.js from within the
Screenshotter directory, and still server all files as intended.
2016-11-27 18:57:53 -05:00

459 lines
14 KiB
JavaScript

/* eslint no-console:0, prefer-spread:0 */
"use strict";
var childProcess = require("child_process");
var fs = require("fs");
var http = require("http");
var jspngopt = require("jspngopt");
var net = require("net");
var os = require("os");
var pako = require("pako");
var path = require("path");
var selenium = require("selenium-webdriver");
var firefox = require("selenium-webdriver/firefox");
var app = require("../../server");
var data = require("../../test/screenshotter/ss_data");
var dstDir = path.normalize(
path.join(__dirname, "..", "..", "test", "screenshotter", "images"));
//////////////////////////////////////////////////////////////////////
// Process command line arguments
var opts = require("nomnom")
.option("browser", {
abbr: "b",
"default": "firefox",
help: "Name of the browser to use",
})
.option("container", {
abbr: "c",
type: "string",
help: "Name or ID of a running docker container to contact",
})
.option("seleniumURL", {
full: "selenium-url",
help: "Full URL of the Selenium web driver",
})
.option("seleniumIP", {
full: "selenium-ip",
help: "IP address of the Selenium web driver",
})
.option("seleniumPort", {
full: "selenium-port",
"default": 4444,
help: "Port number of the Selenium web driver",
})
.option("katexURL", {
full: "katex-url",
help: "Full URL of the KaTeX development server",
})
.option("katexIP", {
full: "katex-ip",
help: "Full URL of the KaTeX development server",
})
.option("katexPort", {
full: "katex-port",
help: "Port number of the KaTeX development server",
})
.option("include", {
abbr: "i",
help: "Comma-separated list of test cases to process",
})
.option("exclude", {
abbr: "x",
help: "Comma-separated list of test cases to exclude",
})
.option("verify", {
flag: true,
help: "Check whether screenshot matches current file content",
})
.option("wait", {
help: "Wait this many seconds between page load and screenshot",
})
.parse();
var listOfCases;
if (opts.include) {
listOfCases = opts.include.split(",");
} else {
listOfCases = Object.keys(data);
}
if (opts.exclude) {
var exclude = opts.exclude.split(",");
listOfCases = listOfCases.filter(function(key) {
return exclude.indexOf(key) === -1;
});
}
var seleniumURL = opts.seleniumURL;
var seleniumIP = opts.seleniumIP;
var seleniumPort = opts.seleniumPort;
var katexURL = opts.katexURL;
var katexIP = opts.katexIP;
var 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() {
var args = Array.prototype.slice.call(arguments);
var cmd = args.shift();
return childProcess.execFileSync(
cmd, args, { encoding: "utf-8" }).replace(/\n$/, "");
}
function guessDockerIPs() {
if (process.env.DOCKER_MACHINE_NAME) {
var 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");
var 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
var 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);
var attempts = 0;
//////////////////////////////////////////////////////////////////////
// Start up development server
var devServer = null;
var minPort = 32768;
var maxPort = 61000;
function startServer() {
if (katexURL || katexPort) {
process.nextTick(tryConnect);
return;
}
var port = Math.floor(Math.random() * (maxPort - minPort)) + minPort;
var server = http.createServer(app).listen(port);
server.once("listening", function() {
devServer = server;
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;
}
var 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
var driver;
function buildDriver() {
var builder = new selenium.Builder().forBrowser(opts.browser);
var ffProfile = new firefox.Profile();
ffProfile.setPreference(
"browser.startup.homepage_override.mstone", "ignore");
ffProfile.setPreference("browser.startup.page", 0);
var ffOptions = new firefox.Options().setProfile(ffProfile);
builder.setFirefoxOptions(ffOptions);
if (seleniumURL) {
builder.usingServer(seleniumURL);
}
driver = builder.build();
driver.manage().timeouts().setScriptTimeout(3000).then(function() {
setSize(targetW, targetH);
});
}
//////////////////////////////////////////////////////////////////////
// Set the screen size
var targetW = 1024;
var targetH = 768;
function setSize(reqW, reqH) {
return driver.manage().window().setSize(reqW, reqH).then(function() {
return driver.takeScreenshot();
}).then(function(img) {
img = imageDimensions(img);
var actualW = img.width;
var actualH = img.height;
if (actualW === targetW && actualH === targetH) {
findHostIP();
return;
}
if (++attempts > 5) {
throw new Error("Failed to set window size correctly.");
}
return setSize(targetW + reqW - actualW, targetH + reqH - actualH);
}, check);
}
function imageDimensions(img) {
var 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
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
var ips = [];
var devs = os.networkInterfaces();
for (var dev in devs) {
if (devs.hasOwnProperty(dev)) {
var addrs = devs[dev];
for (var i = 0; i < addrs.length; ++i) {
var 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
var 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
var countdown = listOfCases.length;
var exitStatus = 0;
var listOfFailed = [];
function takeScreenshots() {
listOfCases.forEach(takeScreenshot);
}
function takeScreenshot(key) {
var itm = data[key];
if (!itm) {
console.error("Test case " + key + " not known!");
listOfFailed.push(key);
if (exitStatus === 0) {
exitStatus = 1;
}
oneDone();
return;
}
var file = path.join(dstDir, key + "-" + opts.browser + ".png");
var retry = 0;
var loadExpected = null;
if (opts.verify) {
loadExpected = promisify(fs.readFile, file);
}
var url = katexURL + "test/screenshotter/test.html?" + itm.query;
driver.get(url);
if (opts.wait) {
browserSideWait(1000 * opts.wait);
}
driver.takeScreenshot().then(haveScreenshot).then(oneDone, check);
function haveScreenshot(img) {
img = imageDimensions(img);
if (img.width !== targetW || img.height !== targetH) {
throw new Error("Excpected " + 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 = promisify(fs.readFile, file);
}
}
var opt = new jspngopt.Optimizer({
pako: pako,
});
var buf = opt.bufferSync(img.buf);
if (loadExpected) {
return loadExpected.then(function(expected) {
if (!buf.equals(expected)) {
if (++retry === 5) {
console.error("FAIL! " + key);
listOfFailed.push(key);
exitStatus = 3;
} else {
console.log("error " + key);
driver.get(url);
browserSideWait(500 * retry);
return driver.takeScreenshot().then(haveScreenshot);
}
} else {
console.log("* ok " + key);
}
});
} else {
return promisify(fs.writeFile, file, buf).then(function() {
console.log(key);
});
}
}
function oneDone() {
if (--countdown === 0) {
if (listOfFailed.length) {
console.error("Failed: " + listOfFailed.join(" "));
}
// devServer.close(cb) will take too long.
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);
}
// Turn node callback style into a call returning a promise,
// like Q.nfcall but using Selenium promises instead of Q ones.
// Second and later arguments are passed to the function named in the
// first argument, and a callback is added as last argument.
function promisify(f) {
var args = Array.prototype.slice.call(arguments, 1);
var deferred = new selenium.promise.Deferred();
args.push(function(err, val) {
if (err) {
deferred.reject(err);
} else {
deferred.fulfill(val);
}
});
f.apply(null, args);
return deferred.promise;
}