screenshotter.js (14723B)
1 /* eslint no-console:0, prefer-spread:0 */ 2 "use strict"; 3 4 const childProcess = require("child_process"); 5 const fs = require("fs"); 6 const http = require("http"); 7 const jspngopt = require("jspngopt"); 8 const net = require("net"); 9 const os = require("os"); 10 const pako = require("pako"); 11 const path = require("path"); 12 const selenium = require("selenium-webdriver"); 13 const firefox = require("selenium-webdriver/firefox"); 14 15 const app = require("../../server"); 16 const data = require("../../test/screenshotter/ss_data"); 17 18 const dstDir = path.normalize( 19 path.join(__dirname, "..", "..", "test", "screenshotter", "images")); 20 21 ////////////////////////////////////////////////////////////////////// 22 // Process command line arguments 23 24 const opts = require("nomnom") 25 .option("browser", { 26 abbr: "b", 27 "default": "firefox", 28 help: "Name of the browser to use", 29 }) 30 .option("container", { 31 abbr: "c", 32 type: "string", 33 help: "Name or ID of a running docker container to contact", 34 }) 35 .option("seleniumURL", { 36 full: "selenium-url", 37 help: "Full URL of the Selenium web driver", 38 }) 39 .option("seleniumIP", { 40 full: "selenium-ip", 41 help: "IP address of the Selenium web driver", 42 }) 43 .option("seleniumPort", { 44 full: "selenium-port", 45 "default": 4444, 46 help: "Port number of the Selenium web driver", 47 }) 48 .option("katexURL", { 49 full: "katex-url", 50 help: "Full URL of the KaTeX development server", 51 }) 52 .option("katexIP", { 53 full: "katex-ip", 54 help: "Full URL of the KaTeX development server", 55 }) 56 .option("katexPort", { 57 full: "katex-port", 58 help: "Port number of the KaTeX development server", 59 }) 60 .option("include", { 61 abbr: "i", 62 help: "Comma-separated list of test cases to process", 63 }) 64 .option("exclude", { 65 abbr: "x", 66 help: "Comma-separated list of test cases to exclude", 67 }) 68 .option("verify", { 69 flag: true, 70 help: "Check whether screenshot matches current file content", 71 }) 72 .option("wait", { 73 help: "Wait this many seconds between page load and screenshot", 74 }) 75 .parse(); 76 77 let listOfCases; 78 if (opts.include) { 79 listOfCases = opts.include.split(","); 80 } else { 81 listOfCases = Object.keys(data); 82 } 83 if (opts.exclude) { 84 const exclude = opts.exclude.split(","); 85 listOfCases = listOfCases.filter(function(key) { 86 return exclude.indexOf(key) === -1; 87 }); 88 } 89 90 let seleniumURL = opts.seleniumURL; 91 let seleniumIP = opts.seleniumIP; 92 let seleniumPort = opts.seleniumPort; 93 let katexURL = opts.katexURL; 94 let katexIP = opts.katexIP; 95 let katexPort = opts.katexPort; 96 97 ////////////////////////////////////////////////////////////////////// 98 // Work out connection to selenium docker container 99 100 function check(err) { 101 if (!err) { 102 return; 103 } 104 console.error(err); 105 console.error(err.stack); 106 process.exit(1); 107 } 108 109 function cmd() { 110 const args = Array.prototype.slice.call(arguments); 111 const cmd = args.shift(); 112 return childProcess.execFileSync( 113 cmd, args, { encoding: "utf-8" }).replace(/\n$/, ""); 114 } 115 116 function guessDockerIPs() { 117 if (process.env.DOCKER_MACHINE_NAME) { 118 const machine = process.env.DOCKER_MACHINE_NAME; 119 seleniumIP = seleniumIP || cmd("docker-machine", "ip", machine); 120 katexIP = katexIP || cmd("docker-machine", "ssh", machine, 121 "echo ${SSH_CONNECTION%% *}"); 122 return; 123 } 124 try { 125 // When using boot2docker, seleniumIP and katexIP are distinct. 126 seleniumIP = seleniumIP || cmd("boot2docker", "ip"); 127 let config = cmd("boot2docker", "config"); 128 config = (/^HostIP = "(.*)"$/m).exec(config); 129 if (!config) { 130 console.error("Failed to find HostIP"); 131 process.exit(2); 132 } 133 katexIP = katexIP || config[1]; 134 return; 135 } catch (e) { 136 // Apparently no boot2docker, continue 137 } 138 if (!process.env.DOCKER_HOST && os.type() === "Darwin") { 139 // Docker for Mac 140 seleniumIP = seleniumIP || "localhost"; 141 katexIP = katexIP || "*any*"; // see findHostIP 142 return; 143 } 144 // Native Docker on Linux or remote Docker daemon or similar 145 const gatewayIP = cmd("docker", "inspect", 146 "-f", "{{.NetworkSettings.Gateway}}", opts.container); 147 seleniumIP = seleniumIP || gatewayIP; 148 katexIP = katexIP || gatewayIP; 149 } 150 151 if (!seleniumURL && opts.container) { 152 if (!seleniumIP || !katexIP) { 153 guessDockerIPs(); 154 } 155 seleniumPort = cmd("docker", "port", opts.container, seleniumPort); 156 seleniumPort = seleniumPort.replace(/^.*:/, ""); 157 } 158 if (!seleniumURL && seleniumIP) { 159 seleniumURL = "http://" + seleniumIP + ":" + seleniumPort + "/wd/hub"; 160 } 161 if (seleniumURL) { 162 console.log("Selenium driver at " + seleniumURL); 163 } else { 164 console.log("Selenium driver in local session"); 165 } 166 167 process.nextTick(startServer); 168 let attempts = 0; 169 170 ////////////////////////////////////////////////////////////////////// 171 // Start up development server 172 173 let devServer = null; 174 const minPort = 32768; 175 const maxPort = 61000; 176 177 function startServer() { 178 if (katexURL || katexPort) { 179 process.nextTick(tryConnect); 180 return; 181 } 182 const port = Math.floor(Math.random() * (maxPort - minPort)) + minPort; 183 const server = http.createServer(app).listen(port); 184 server.once("listening", function() { 185 devServer = server; 186 katexPort = port; 187 attempts = 0; 188 process.nextTick(tryConnect); 189 }); 190 server.on("error", function(err) { 191 if (devServer !== null) { // error after we started listening 192 throw err; 193 } else if (++attempts > 50) { 194 throw new Error("Failed to start up dev server"); 195 } else { 196 process.nextTick(startServer); 197 } 198 }); 199 } 200 201 ////////////////////////////////////////////////////////////////////// 202 // Wait for container to become ready 203 204 function tryConnect() { 205 if (!seleniumIP) { 206 process.nextTick(buildDriver); 207 return; 208 } 209 const sock = net.connect({ 210 host: seleniumIP, 211 port: +seleniumPort, 212 }); 213 sock.on("connect", function() { 214 sock.end(); 215 attempts = 0; 216 process.nextTick(buildDriver); 217 }).on("error", function() { 218 if (++attempts > 50) { 219 throw new Error("Failed to connect selenium server."); 220 } 221 setTimeout(tryConnect, 200); 222 }); 223 } 224 225 ////////////////////////////////////////////////////////////////////// 226 // Build the web driver 227 228 let driver; 229 function buildDriver() { 230 const builder = new selenium.Builder().forBrowser(opts.browser); 231 const ffProfile = new firefox.Profile(); 232 ffProfile.setPreference( 233 "browser.startup.homepage_override.mstone", "ignore"); 234 ffProfile.setPreference("browser.startup.page", 0); 235 const ffOptions = new firefox.Options().setProfile(ffProfile); 236 builder.setFirefoxOptions(ffOptions); 237 if (seleniumURL) { 238 builder.usingServer(seleniumURL); 239 } 240 driver = builder.build(); 241 driver.manage().timeouts().setScriptTimeout(3000).then(function() { 242 let html = '<!DOCTYPE html>' + 243 '<html><head><style type="text/css">html,body{' + 244 'width:100%;height:100%;margin:0;padding:0;overflow:hidden;' + 245 '}</style></head><body><p>Test</p></body></html>'; 246 html = "data:text/html," + encodeURIComponent(html); 247 return driver.get(html); 248 }).then(function() { 249 setSize(targetW, targetH); 250 }); 251 } 252 253 ////////////////////////////////////////////////////////////////////// 254 // Set the screen size 255 256 const targetW = 1024; 257 const targetH = 768; 258 function setSize(reqW, reqH) { 259 return driver.manage().window().setSize(reqW, reqH).then(function() { 260 return driver.takeScreenshot(); 261 }).then(function(img) { 262 img = imageDimensions(img); 263 const actualW = img.width; 264 const actualH = img.height; 265 if (actualW === targetW && actualH === targetH) { 266 findHostIP(); 267 return; 268 } 269 if (++attempts > 5) { 270 throw new Error("Failed to set window size correctly."); 271 } 272 return setSize(targetW + reqW - actualW, targetH + reqH - actualH); 273 }, check); 274 } 275 276 function imageDimensions(img) { 277 const buf = new Buffer(img, "base64"); 278 return { 279 buf: buf, 280 width: buf.readUInt32BE(16), 281 height: buf.readUInt32BE(20), 282 }; 283 } 284 285 ////////////////////////////////////////////////////////////////////// 286 // Work out how to connect to host KaTeX server 287 288 function findHostIP() { 289 if (!katexIP) { 290 katexIP = "localhost"; 291 } 292 if (katexIP !== "*any*" || katexURL) { 293 if (!katexURL) { 294 katexURL = "http://" + katexIP + ":" + katexPort + "/babel/"; 295 console.log("KaTeX URL is " + katexURL); 296 } 297 process.nextTick(takeScreenshots); 298 return; 299 } 300 301 // Now we need to find an IP the container can connect to. 302 // First, install a server component to get notified of successful connects 303 app.get("/ss-connect.js", function(req, res, next) { 304 if (!katexURL) { 305 katexIP = req.query.ip; 306 katexURL = "http://" + katexIP + ":" + katexPort + "/babel/"; 307 console.log("KaTeX URL is " + katexURL); 308 process.nextTick(takeScreenshots); 309 } 310 res.setHeader("Content-Type", "text/javascript"); 311 res.send("//OK"); 312 }); 313 314 // Next, enumerate all network addresses 315 const ips = []; 316 const devs = os.networkInterfaces(); 317 for (const dev in devs) { 318 if (devs.hasOwnProperty(dev)) { 319 const addrs = devs[dev]; 320 for (let i = 0; i < addrs.length; ++i) { 321 let addr = addrs[i].address; 322 if (/:/.test(addr)) { 323 addr = "[" + addr + "]"; 324 } 325 ips.push(addr); 326 } 327 } 328 } 329 console.log("Looking for host IP among " + ips.join(", ")); 330 331 // Load a data: URI document which attempts to contact each of these IPs 332 let html = "<!doctype html>\n<html><body>\n"; 333 html += ips.map(function(ip) { 334 return '<script src="http://' + ip + ':' + katexPort + 335 '/ss-connect.js?ip=' + encodeURIComponent(ip) + 336 '" defer></script>'; 337 }).join("\n"); 338 html += "\n</body></html>"; 339 html = "data:text/html," + encodeURIComponent(html); 340 driver.get(html); 341 } 342 343 ////////////////////////////////////////////////////////////////////// 344 // Take the screenshots 345 346 let countdown = listOfCases.length; 347 348 let exitStatus = 0; 349 const listOfFailed = []; 350 351 function takeScreenshots() { 352 listOfCases.forEach(takeScreenshot); 353 } 354 355 function takeScreenshot(key) { 356 const itm = data[key]; 357 if (!itm) { 358 console.error("Test case " + key + " not known!"); 359 listOfFailed.push(key); 360 if (exitStatus === 0) { 361 exitStatus = 1; 362 } 363 oneDone(); 364 return; 365 } 366 367 let file = path.join(dstDir, key + "-" + opts.browser + ".png"); 368 let retry = 0; 369 let loadExpected = null; 370 if (opts.verify) { 371 loadExpected = promisify(fs.readFile, file); 372 } 373 374 const url = katexURL + "test/screenshotter/test.html?" + itm.query; 375 driver.get(url); 376 if (opts.wait) { 377 browserSideWait(1000 * opts.wait); 378 } 379 driver.takeScreenshot().then(haveScreenshot).then(oneDone, check); 380 381 function haveScreenshot(img) { 382 img = imageDimensions(img); 383 if (img.width !== targetW || img.height !== targetH) { 384 throw new Error("Excpected " + targetW + " x " + targetH + 385 ", got " + img.width + "x" + img.height); 386 } 387 if (key === "Lap" && opts.browser === "firefox" && 388 img.buf[0x32] === 0xf8) { 389 /* There is some strange non-determinism with this case, 390 * causing slight vertical shifts. The first difference 391 * is at offset 0x32, where one file has byte 0xf8 and 392 * the other has something else. By using a different 393 * output file name for one of these cases, we accept both. 394 */ 395 key += "_alt"; 396 file = path.join(dstDir, key + "-" + opts.browser + ".png"); 397 if (loadExpected) { 398 loadExpected = promisify(fs.readFile, file); 399 } 400 } 401 const opt = new jspngopt.Optimizer({ 402 pako: pako, 403 }); 404 const buf = opt.bufferSync(img.buf); 405 if (loadExpected) { 406 return loadExpected.then(function(expected) { 407 if (!buf.equals(expected)) { 408 if (++retry === 5) { 409 console.error("FAIL! " + key); 410 listOfFailed.push(key); 411 exitStatus = 3; 412 } else { 413 console.log("error " + key); 414 driver.get(url); 415 browserSideWait(500 * retry); 416 return driver.takeScreenshot().then(haveScreenshot); 417 } 418 } else { 419 console.log("* ok " + key); 420 } 421 }); 422 } else { 423 return promisify(fs.writeFile, file, buf).then(function() { 424 console.log(key); 425 }); 426 } 427 } 428 429 function oneDone() { 430 if (--countdown === 0) { 431 if (listOfFailed.length) { 432 console.error("Failed: " + listOfFailed.join(" ")); 433 } 434 // devServer.close(cb) will take too long. 435 process.exit(exitStatus); 436 } 437 } 438 } 439 440 // Wait using a timeout call in the browser, to ensure that the wait 441 // time doesn't start before the page has reportedly been loaded. 442 function browserSideWait(milliseconds) { 443 // The last argument (arguments[1] here) is the callback to selenium 444 return driver.executeAsyncScript( 445 "window.setTimeout(arguments[1], arguments[0]);", 446 milliseconds); 447 } 448 449 // Turn node callback style into a call returning a promise, 450 // like Q.nfcall but using Selenium promises instead of Q ones. 451 // Second and later arguments are passed to the function named in the 452 // first argument, and a callback is added as last argument. 453 function promisify(f) { 454 const args = Array.prototype.slice.call(arguments, 1); 455 const deferred = new selenium.promise.Deferred(); 456 args.push(function(err, val) { 457 if (err) { 458 deferred.reject(err); 459 } else { 460 deferred.fulfill(val); 461 } 462 }); 463 f.apply(null, args); 464 return deferred.promise; 465 }