async function browserSideWait(milliseconds) {
    // The last argument (arguments[1] here) is the callback to selenium
    await driver.executeAsyncScript(
        "window.setTimeout(arguments[1], arguments[0]);",
        milliseconds);
}
/*.js"],
                    exclude: ["src/unicodeSymbols.js"],
                }]],
            },
        };
    }
    const config = {
        ...webpackConfig.devServer,
        static: [{directory: process.cwd(), watch: false}],
        port: katexPort,
        hot: false,
        liveReload: false,
        client: false,
    };
    const compiler = webpack(webpackConfig);
    devServer = new WebpackDevServer(config, compiler);
    await devServer.start(katexPort);
}

// Start Browserstack Local connection
async function startBrowserstackLocal() {
    // unique identifier for the session
    const localIdentifier = process.env.CIRCLE_BUILD_NUM || "p" + katexPort;
    opts.seleniumCapabilities["browserstack.localIdentifier"] = localIdentifier;

    bsLocal = new browserstack.Local();
    await new Promise((resolve, reject) => {
        bsLocal.start({localIdentifier}, err => {
            if (err) {
                reject(err);
            } else {
                resolve();
            }
        });
    });
}

//////////////////////////////////////////////////////////////////////
// Wait for container to become ready

async function tryConnect() {
    return new Promise((resolve, reject) => {
        const sock = net.connect({
            host: seleniumIP,
            port: +seleniumPort,
        });
        sock.on("connect", function() {
            sock.end();
            resolve();
        }).on("error", function(err) {
            reject(err);
        });
    });
}

//////////////////////////////////////////////////////////////////////
// Build the web driver

let driver;
let driverReady = false;
function buildDriver() {
    const builder = new selenium.Builder().forBrowser(opts.browser);
    if (opts.browser === "firefox") {
        const ffOptions = new firefox.Options();
        ffOptions.setPreference(
            "browser.startup.homepage_override.mstone", "ignore");
        ffOptions.setPreference("browser.startup.page", 0);
        builder.setFirefoxOptions(ffOptions);
    } else if (opts.browser === "chrome") {
        // https://stackoverflow.com/questions/48450594/selenium-timed-out-receiving-message-from-renderer
        const chrOptions = new chrome.Options().addArguments("--disable-gpu");
        builder.setChromeOptions(chrOptions);
    }
    if (seleniumURL) {
        builder.usingServer(seleniumURL);
    }
    if (opts.seleniumCapabilities) {
        // TODO: withCapabilities is deprecated
        builder.withCapabilities(opts.seleniumCapabilities);
    }
    return builder.build();
}

async function getProxyDriver() {
    const {body} = await got.post(opts.seleniumProxy, {
        json: {
            browserstack: opts.browserstack,
            capabilities: opts.seleniumCapabilities,
            seleniumURL,
        },
        responseType: 'json',
    });
    const session = new selenium.Session(body.id, body.capabilities);
    const client = Promise.resolve(new seleniumHttp.HttpClient(seleniumURL));
    const executor = new seleniumHttp.Executor(client);
    return new selenium.WebDriver(session, executor);
}

async function setupDriver() {
    await driver.manage().setTimeouts({script: 5000});

    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);
    await driver.get(html);

    await setSize(targetW, targetH);
}

//////////////////////////////////////////////////////////////////////
// Set the screen size

const targetW = 1024;
const targetH = 768;
let attempts = 0;
async function setSize(width, height) {
    await driver.manage().window().setRect({width, height});
    let img = await driver.takeScreenshot();
    img = imageDimensions(img);
    const actualW = img.width;
    const actualH = img.height;
    if (actualW === targetW && actualH === targetH) {
        return;
    }
    if (++attempts > opts.attempts) {
        throw new Error("Failed to set window size correctly.");
    }
    return setSize(targetW + width - actualW, targetH + height - actualH);
}

function imageDimensions(img) {
    const buf = Buffer.from(img, "base64");
    return {
        buf: buf,
        width: buf.readUInt32BE(16),
        height: buf.readUInt32BE(20),
    };
}

//////////////////////////////////////////////////////////////////////
// Work out how to connect to host KaTeX server

async function findHostIP() {
    if (!katexIP) {
        katexIP = "localhost";
    }
    if (katexIP !== "*any*" || katexURL) {
        if (!katexURL) {
            katexURL = "http://" + katexIP + ":" + katexPort + "/";
            console.log("KaTeX URL is " + katexURL);
        }
        return;
    }

    // Now we need to find an IP the container can connect to.
    // First, install a server component to get notified of successful connects
    const connect = new Promise((resolve) => {
        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);
            }
            res.setHeader("Content-Type", "text/javascript");
            res.send("//OK");
            resolve();
        });
    });

    // 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);
    await driver.get(html);
    await connect;
}

//////////////////////////////////////////////////////////////////////
// Take the screenshots

let exitStatus = 0;
const listOfFailed = [];

async function takeScreenshots() {
    for (const key of listOfCases) {
        await takeScreenshot(key);
    }

    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) {
        await collectCoverage();
        const context = istanbulLibReport.createContext({coverageMap});
        ['json', 'text', 'lcov'].forEach(fmt => {
            const report = istanbulReports.create(fmt);
            report.execute(context);
        });
    }
}

async function takeScreenshot(key) {
    const itm = data[key];
    if (!itm) {
        console.error("Test case " + key + " not known!");
        listOfFailed.push(key);
        if (exitStatus === 0) {
            exitStatus = 1;
        }
        return;
    }

    let file = path.join(dstDir, key + "-" + opts.browser + ".png");
    let retry = 0;
    let expected = null;
    if (opts.verify && await fs.pathExists(file)) {
        expected = await fs.readFile(file);
    }
    const url = katexURL + "test/screenshotter/test.html?" + itm.query;
    let buf;

    while (++retry <= opts.attempts) {
        if (!opts.reload && driverReady) {
            await driver.executeScript(
                    "handle_search_string(" +
                    JSON.stringify("?" + itm.query) + ");");
        } else {
            if (opts.coverage) {
                // collect coverage before reloading
                await collectCoverage();
            }
            await driver.get(url);
            try {
                await driver.executeAsyncScript(
                        "var callback = arguments[arguments.length - 1]; " +
                        "load_fonts_and_images(callback);");
            } catch (e) {
                console.error(e);
            }
            driverReady = true;
        }
        if (opts.wait) {
            await browserSideWait(1000 * opts.wait);
        }
        let img = await driver.takeScreenshot();
        img = imageDimensions(img);
        if (img.width !== targetW || img.height !== targetH) {
            console.error("Expected " + targetW + " x " + targetH +
                          ", got " + img.width + "x" + img.height);
            await setSize(targetW, targetH);
        }
        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.