tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

perftest_record.js (17146B)


      1 /* This Source Code Form is subject to the terms of the Mozilla Public
      2 * License, v. 2.0. If a copy of the MPL was not distributed with this
      3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
      4 /* eslint-env node */
      5 "use strict";
      6 
      7 const fs = require("fs");
      8 const http = require("http");
      9 
     10 const URL = "/secrets/v1/secret/project/perftest/gecko/level-";
     11 const SECRET = "/perftest-login";
     12 const DEFAULT_SERVER = "https://firefox-ci-tc.services.mozilla.com";
     13 
     14 const SCM_1_LOGIN_SITES = ["facebook", "netflix"];
     15 
     16 /**
     17 * This function obtains the perftest secret from Taskcluster.
     18 *
     19 * It will NOT work locally. Please see the get_logins function, you
     20 * will need to define a JSON file and set the RAPTOR_LOGINS
     21 * env variable to its path.
     22 */
     23 async function get_tc_secrets(context) {
     24  const MOZ_AUTOMATION = process.env.MOZ_AUTOMATION;
     25  if (!MOZ_AUTOMATION) {
     26    throw Error(
     27      "Not running in CI. Set RAPTOR_LOGINS to a JSON file containing the logins."
     28    );
     29  }
     30 
     31  let TASKCLUSTER_PROXY_URL = process.env.TASKCLUSTER_PROXY_URL
     32    ? process.env.TASKCLUSTER_PROXY_URL
     33    : DEFAULT_SERVER;
     34 
     35  let MOZ_SCM_LEVEL = process.env.MOZ_SCM_LEVEL ? process.env.MOZ_SCM_LEVEL : 1;
     36 
     37  const url = TASKCLUSTER_PROXY_URL + URL + MOZ_SCM_LEVEL + SECRET;
     38 
     39  const data = await new Promise((resolve, reject) => {
     40    context.log.info("Obtaining secrets for login...");
     41 
     42    http.get(
     43      url,
     44      {
     45        headers: {
     46          "Content-Type": "application/json",
     47          Accept: "application/json",
     48        },
     49      },
     50      res => {
     51        let data = "";
     52        context.log.info(`Secret status code: ${res.statusCode}`);
     53 
     54        res.on("data", d => {
     55          data += d.toString();
     56        });
     57 
     58        res.on("end", () => {
     59          resolve(data);
     60        });
     61 
     62        res.on("error", error => {
     63          context.log.error(error);
     64          reject(error);
     65        });
     66      }
     67    );
     68  });
     69 
     70  return JSON.parse(data);
     71 }
     72 
     73 /**
     74 * This function gets the login information required.
     75 *
     76 * It starts by looking for a local file whose path is defined
     77 * within RAPTOR_LOGINS. If we don't find this file, then we'll
     78 * attempt to get the login information from our Taskcluster secret.
     79 * If MOZ_AUTOMATION is undefined, then the test will fail, Taskcluster
     80 * secrets can only be obtained in CI.
     81 */
     82 async function get_logins(context) {
     83  let logins;
     84 
     85  let RAPTOR_LOGINS = process.env.RAPTOR_LOGINS;
     86  if (RAPTOR_LOGINS) {
     87    // Get logins from a local file
     88    if (!RAPTOR_LOGINS.endsWith(".json")) {
     89      throw Error(
     90        `File given for logins does not end in '.json': ${RAPTOR_LOGINS}`
     91      );
     92    }
     93 
     94    let logins_file = null;
     95    try {
     96      logins_file = await fs.readFileSync(RAPTOR_LOGINS, "utf8");
     97    } catch (err) {
     98      throw Error(`Failed to read the file ${RAPTOR_LOGINS}: ${err}`);
     99    }
    100 
    101    logins = await JSON.parse(logins_file);
    102  } else {
    103    // Get logins from a perftest Taskcluster secret
    104    logins = await get_tc_secrets(context);
    105  }
    106 
    107  return logins;
    108 }
    109 
    110 /**
    111 * This function returns the type of login to do.
    112 *
    113 * This function returns "single-form" when we find a single form. If we only
    114 * find a single input field, we assume that there is one page per input
    115 * and return "multi-page". Otherwise, we return null.
    116 */
    117 async function get_login_type(context, commands) {
    118  /*
    119    Determine if there's a password field visible with this
    120    query selector. Some sites use `tabIndex` to hide the password
    121    field behind other elements. In this case, we are searching
    122    for any password-type field that has a tabIndex of 0 or undefined and
    123    is not hidden.
    124  */
    125  let input_length = await commands.js.run(`
    126    return document.querySelectorAll(
    127      "input[type=password][tabIndex='0']:not([type=hidden])," +
    128      "input[type=password]:not([tabIndex]):not([type=hidden])"
    129    ).length;
    130  `);
    131  if (input_length == 0) {
    132    context.log.info("Found a multi-page login");
    133    return multi_page_login;
    134  } else if (input_length == 1) {
    135    context.log.info("Found a single-page login");
    136    return single_page_login;
    137  }
    138 
    139  if (
    140    (await commands.js.run(
    141      `return document.querySelectorAll("form").length;`
    142    )) >= 1
    143  ) {
    144    context.log.info("Found a single-form login");
    145    return single_form_login;
    146  }
    147 
    148  return null;
    149 }
    150 
    151 /**
    152 * This function sets up the login for a single form.
    153 *
    154 * The username field is defined as the field which immediately precedes
    155 * the password field. We have to do this in two steps because we need
    156 * to make sure that the event we emit from the change has the `isTrusted`
    157 * field set to `true`. Otherwise, some websites will ignore the input and
    158 * the form submission.
    159 */
    160 async function single_page_login(login_info, context, commands, prefix = "") {
    161  // Get the first input field in the form that is not hidden and add the
    162  // username. Assumes that email/username is always the first input field.
    163  await commands.addText.bySelector(
    164    login_info.username,
    165    `${prefix}input:not([type=hidden]):not([type=password])`
    166  );
    167 
    168  // Get the password field and ensure it's not hidden.
    169  await commands.addText.bySelector(
    170    login_info.password,
    171    `${prefix}input[type=password]:not([type=hidden])`
    172  );
    173 
    174  return undefined;
    175 }
    176 
    177 /**
    178 * See single_page_login.
    179 */
    180 async function single_form_login(login_info, context, commands) {
    181  return single_page_login(login_info, context, commands, "form ");
    182 }
    183 
    184 /**
    185 * Login to a website that uses multiple pages for the login.
    186 *
    187 * WARNING: Assumes that the first page is for the username.
    188 */
    189 async function multi_page_login(login_info, context, commands) {
    190  const driver = context.selenium.driver;
    191  const webdriver = context.selenium.webdriver;
    192 
    193  const username_field = await driver.findElement(
    194    webdriver.By.css(`input:not([type=hidden]):not([type=password])`)
    195  );
    196  await username_field.sendKeys(login_info.username);
    197  await username_field.sendKeys(webdriver.Key.ENTER);
    198  await commands.wait.byTime(5000);
    199 
    200  let password_field;
    201  try {
    202    password_field = await driver.findElement(
    203      webdriver.By.css(`input[type=password]:not([type=hidden])`)
    204    );
    205  } catch (err) {
    206    if (err.toString().includes("NoSuchElementError")) {
    207      // Sometimes we're suspicious (i.e. they think we're a bot/bad-actor)
    208      let name_field = await driver.findElement(
    209        webdriver.By.css(`input:not([type=hidden]):not([type=password])`)
    210      );
    211      await name_field.sendKeys(login_info.suspicious_answer);
    212      await name_field.sendKeys(webdriver.Key.ENTER);
    213      await commands.wait.byTime(5000);
    214 
    215      // Try getting the password field again
    216      password_field = await driver.findElement(
    217        webdriver.By.css(`input[type=password]:not([type=hidden])`)
    218      );
    219    } else {
    220      throw err;
    221    }
    222  }
    223 
    224  await password_field.sendKeys(login_info.password);
    225 
    226  return async function () {
    227    password_field.sendKeys(webdriver.Key.ENTER);
    228    await commands.wait.byTime(5000);
    229  };
    230 }
    231 
    232 /**
    233 * This function sets up the login.
    234 *
    235 * This is done by first the login type, and then performing the
    236 * actual login setup. The return is a possible button to click
    237 * to perform the login.
    238 */
    239 async function setup_login(login_info, context, commands) {
    240  let login_func = await get_login_type(context, commands);
    241  if (!login_func) {
    242    throw Error("Could not determine the type of login page.");
    243  }
    244 
    245  try {
    246    return await login_func(login_info, context, commands);
    247  } catch (err) {
    248    throw Error(`Could not setup login information: ${err}`);
    249  }
    250 }
    251 
    252 /**
    253 * This function performs the login.
    254 *
    255 * It does this by either clicking on a button with a type
    256 * of "sumbit", or running a final_button function that was
    257 * obtained from the setup_login function. Some pages also ask
    258 * questions about setting up 2FA or other information. Generally,
    259 * these contain the "skip" text.
    260 */
    261 async function login(context, commands, final_button) {
    262  try {
    263    if (!final_button) {
    264      // The mouse double click emits an event with `evt.isTrusted=true`
    265      await commands.mouse.doubleClick.bySelector("button[type=submit]");
    266      await commands.wait.byTime(10000);
    267    } else {
    268      // In some cases, it's preferable to be given a function for the final button
    269      await final_button();
    270    }
    271 
    272    // Some pages ask to setup 2FA, skip this based on the text
    273    const XPATHS = [
    274      "//a[contains(text(), 'skip')]",
    275      "//button[contains(text(), 'skip')]",
    276      "//input[contains(text(), 'skip')]",
    277      "//div[contains(text(), 'skip')]",
    278    ];
    279 
    280    for (let xpath of XPATHS) {
    281      try {
    282        await commands.mouse.doubleClick.byXpath(xpath);
    283      } catch (err) {
    284        if (err.toString().includes("not double click")) {
    285          context.log.info(`Can't find a button with the text: ${xpath}`);
    286        } else {
    287          throw err;
    288        }
    289      }
    290    }
    291  } catch (err) {
    292    throw Error(
    293      `Could not login to website as we could not find the submit button/input: ${err}`
    294    );
    295  }
    296 }
    297 
    298 /**
    299 * Grab the base URL from the browsertime url.
    300 *
    301 * This is a necessary step for getting the login values from the Taskcluster
    302 * secrets, which are hashed by the base URL.
    303 *
    304 * The first entry is the protocal, third is the top-level domain (or host)
    305 */
    306 function get_base_URL(fullUrl) {
    307  let pathAsArray = fullUrl.split("/");
    308  return pathAsArray[0] + "//" + pathAsArray[2];
    309 }
    310 
    311 /**
    312 * This function attempts the login-login sequence for a live pageload recording
    313 */
    314 async function perform_live_login(context, commands) {
    315  let testUrl = context.options.browsertime.url;
    316 
    317  let logins = await get_logins(context);
    318  const baseUrl = get_base_URL(testUrl);
    319 
    320  await commands.navigate("about:blank");
    321 
    322  let login_info = logins.secret[baseUrl];
    323  try {
    324    await commands.navigate(login_info.login_url);
    325  } catch (err) {
    326    context.log.info("Unable to acquire login information");
    327    throw err;
    328  }
    329  await commands.wait.byTime(5000);
    330 
    331  let final_button = await setup_login(login_info, context, commands);
    332  await login(context, commands, final_button);
    333 }
    334 
    335 async function dismissCookiePrompt(input_cmds, context, commands) {
    336  context.log.info("Searching for cookie prompt elements...");
    337  let cmds = input_cmds.split(";;;");
    338  for (let cmdstr of cmds) {
    339    let [cmd, ...args] = cmdstr.split(":::");
    340    context.log.info(cmd, args);
    341    let result = await commands.js.run(
    342      `return document.evaluate("` +
    343        args +
    344        `", document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;`
    345    );
    346    if (result) {
    347      context.log.info("Element found, clicking on it.");
    348      await run_command(cmdstr, context, commands);
    349    } else {
    350      context.log.info(
    351        "Element not found! The cookie prompt may have not appeared, please check the screenshots."
    352      );
    353    }
    354  }
    355 }
    356 
    357 async function pageload_test(context, commands) {
    358  let testUrl = context.options.browsertime.url;
    359  let secondaryUrl = context.options.browsertime.secondary_url;
    360  let testName = context.options.browsertime.testName;
    361  let dismissPrompt = context.options.browsertime.dismiss_cookie_prompt || "";
    362  context.log.info(context.options.browsertime);
    363 
    364  // Wait for browser to settle
    365  await commands.wait.byTime(1000);
    366 
    367  // If the user has RAPTOR_LOGINS configured correctly, a local login pageload
    368  // test can be attempted. Otherwise if attempting it in CI, only sites with the
    369  // associated MOZ_SCM_LEVEL will be attempted (e.g. Try = 1, autoland = 3)
    370  if (context.options.browsertime.login) {
    371    if (context.options.browsertime.manual_login) {
    372      // Perform a manual login using the value given in manual_login
    373      // as the amount of time to wait
    374      await commands.navigate(testUrl);
    375      context.log.info(
    376        `Waiting ${context.options.browsertime.manual_login}ms for login...`
    377      );
    378      await commands.wait.byTime(context.options.browsertime.manual_login);
    379    } else if (
    380      process.env.RAPTOR_LOGINS ||
    381      process.env.MOZ_SCM_LEVEL == 3 ||
    382      SCM_1_LOGIN_SITES.includes(testName)
    383    ) {
    384      try {
    385        await perform_live_login(context, commands);
    386      } catch (err) {
    387        context.log.info(
    388          "Unable to login. Acquiring a recording without logging in"
    389        );
    390        context.log.info("Error:" + err);
    391      }
    392    } else {
    393      context.log.info(`
    394        NOTE: This is a login test but a manual login was not requested, and
    395        we cannot find any logins defined in RAPTOR_LOGINS.
    396      `);
    397    }
    398  }
    399 
    400  await commands.measure.start(testUrl);
    401  await commands.wait.byTime(40000);
    402  if (dismissPrompt) {
    403    await dismissCookiePrompt(dismissPrompt, context, commands);
    404  }
    405  commands.screenshot.take("test_url_" + testName);
    406 
    407  if (secondaryUrl !== null) {
    408    // Wait for browser to settle
    409    await commands.wait.byTime(1000);
    410 
    411    await commands.measure.start(secondaryUrl);
    412    commands.screenshot.take("secondary_url_" + testName);
    413  }
    414 
    415  // Wait for browser to settle
    416  await commands.wait.byTime(1000);
    417 }
    418 
    419 /**
    420 * Converts a string such as `measure.start` into the
    421 * actual function that is found in the `commands` module.
    422 *
    423 * XX: Find a way to share this function between
    424 * perftest_record.js and browsertime_interactive.js
    425 */
    426 async function get_command_function(cmd, commands) {
    427  if (cmd == "") {
    428    throw new Error("A blank command was given.");
    429  } else if (cmd.endsWith(".")) {
    430    throw new Error(
    431      "An extra `.` was found at the end of this command: " + cmd
    432    );
    433  }
    434 
    435  // `func` will hold the actual method that needs to be called,
    436  // and the `parent_mod` is the context required to run the `func`
    437  // method. Without that context, `this` becomes undefined in the browsertime
    438  // classes.
    439  let func = null;
    440  let parent_mod = null;
    441  for (let func_part of cmd.split(".")) {
    442    if (func_part == "") {
    443      throw new Error(
    444        "An empty function part was found in the command: " + cmd
    445      );
    446    }
    447 
    448    if (func === null) {
    449      parent_mod = commands;
    450      func = commands[func_part];
    451    } else if (func !== undefined) {
    452      parent_mod = func;
    453      func = func[func_part];
    454    } else {
    455      break;
    456    }
    457  }
    458 
    459  if (func == undefined) {
    460    throw new Error(
    461      "The given command could not be found as a function: " + cmd
    462    );
    463  }
    464 
    465  return [func, parent_mod];
    466 }
    467 
    468 /**
    469 * Performs an interactive test.
    470 *
    471 * These tests are interactive as the entire test is defined
    472 * through a set of browsertime commands. This allows users
    473 * to build arbitrary tests. Furthermore, interactive tests
    474 * provide the ability to login to websites.
    475 */
    476 async function interactive_test(input_cmds, context, commands) {
    477  let cmds = input_cmds.split(";;;");
    478 
    479  let logins;
    480  if (context.options.browsertime.login) {
    481    logins = await get_logins(context);
    482  }
    483 
    484  await commands.navigate("about:blank");
    485 
    486  let user_setup = false;
    487  let final_button = null;
    488  for (let cmdstr of cmds) {
    489    let [cmd, ...args] = cmdstr.split(":::");
    490 
    491    if (cmd == "setup_login") {
    492      if (!logins) {
    493        throw Error(
    494          "This test is not specified as a `login` test so no login information is available."
    495        );
    496      }
    497      if (args.length < 1 || args[0] == "") {
    498        throw Error(
    499          `No URL given, can't tell where to setup the login. We only accept: ${logins.keys()}`
    500        );
    501      }
    502      /* Structure for logins is:
    503          {
    504              "username": ...,
    505              "password": ...,
    506              "suspicious_answer": ...,
    507              "login_url": ...,
    508          }
    509      */
    510      let login_info = logins.secret[args[0]];
    511 
    512      await commands.navigate(login_info.login_url);
    513      await commands.wait.byTime(5000);
    514 
    515      final_button = await setup_login(login_info, context, commands);
    516      user_setup = true;
    517    } else if (cmd == "login") {
    518      if (!user_setup) {
    519        throw Error("setup_login needs to be called before the login command");
    520      }
    521      await login(context, commands, final_button);
    522    } else {
    523      await run_command(cmdstr, context, commands);
    524    }
    525  }
    526 }
    527 
    528 async function run_command(cmdstr, context, commands) {
    529  let [cmd, ...args] = cmdstr.split(":::");
    530  let [func, parent_mod] = await get_command_function(cmd, commands);
    531 
    532  try {
    533    await func.call(parent_mod, ...args);
    534  } catch (e) {
    535    context.log.info(
    536      `Exception found while running \`commands.${cmd}(${args})\`: ` + e
    537    );
    538  }
    539 }
    540 
    541 async function test(context, commands) {
    542  let input_cmds = context.options.browsertime.commands;
    543  let test_type = context.options.browsertime.testType;
    544  if (test_type == "interactive") {
    545    await interactive_test(input_cmds, context, commands);
    546  } else {
    547    await pageload_test(context, commands);
    548  }
    549  return true;
    550 }
    551 
    552 module.exports = {
    553  test,
    554  owner: "Bebe fstrugariu@mozilla.com",
    555  name: "Mozproxy recording generator",
    556  component: "raptor",
    557  description: ` This test generates fresh MozProxy recordings. It iterates through a list of 
    558      websites provided in *_sites.json and for each one opens a browser and 
    559      records all the associated HTTP traffic`,
    560  usage:
    561    "mach perftest --proxy --hooks testing/raptor/recorder/hooks.py testing/raptor/recorder/perftest_record.js",
    562 };