www

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | README

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 }