forked from mirrors/gecko-dev
		
	
		
			
				
	
	
		
			562 lines
		
	
	
	
		
			17 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			562 lines
		
	
	
	
		
			17 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
| /* This Source Code Form is subject to the terms of the Mozilla Public
 | |
|  * License, v. 2.0. If a copy of the MPL was not distributed with this
 | |
|  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 | |
| /* eslint-env node */
 | |
| "use strict";
 | |
| 
 | |
| const fs = require("fs");
 | |
| const http = require("http");
 | |
| 
 | |
| const URL = "/secrets/v1/secret/project/perftest/gecko/level-";
 | |
| const SECRET = "/perftest-login";
 | |
| const DEFAULT_SERVER = "https://firefox-ci-tc.services.mozilla.com";
 | |
| 
 | |
| const SCM_1_LOGIN_SITES = ["facebook", "netflix"];
 | |
| 
 | |
| /**
 | |
|  * This function obtains the perftest secret from Taskcluster.
 | |
|  *
 | |
|  * It will NOT work locally. Please see the get_logins function, you
 | |
|  * will need to define a JSON file and set the RAPTOR_LOGINS
 | |
|  * env variable to its path.
 | |
|  */
 | |
| async function get_tc_secrets(context) {
 | |
|   const MOZ_AUTOMATION = process.env.MOZ_AUTOMATION;
 | |
|   if (!MOZ_AUTOMATION) {
 | |
|     throw Error(
 | |
|       "Not running in CI. Set RAPTOR_LOGINS to a JSON file containing the logins."
 | |
|     );
 | |
|   }
 | |
| 
 | |
|   let TASKCLUSTER_PROXY_URL = process.env.TASKCLUSTER_PROXY_URL
 | |
|     ? process.env.TASKCLUSTER_PROXY_URL
 | |
|     : DEFAULT_SERVER;
 | |
| 
 | |
|   let MOZ_SCM_LEVEL = process.env.MOZ_SCM_LEVEL ? process.env.MOZ_SCM_LEVEL : 1;
 | |
| 
 | |
|   const url = TASKCLUSTER_PROXY_URL + URL + MOZ_SCM_LEVEL + SECRET;
 | |
| 
 | |
|   const data = await new Promise((resolve, reject) => {
 | |
|     context.log.info("Obtaining secrets for login...");
 | |
| 
 | |
|     http.get(
 | |
|       url,
 | |
|       {
 | |
|         headers: {
 | |
|           "Content-Type": "application/json",
 | |
|           Accept: "application/json",
 | |
|         },
 | |
|       },
 | |
|       res => {
 | |
|         let data = "";
 | |
|         context.log.info(`Secret status code: ${res.statusCode}`);
 | |
| 
 | |
|         res.on("data", d => {
 | |
|           data += d.toString();
 | |
|         });
 | |
| 
 | |
|         res.on("end", () => {
 | |
|           resolve(data);
 | |
|         });
 | |
| 
 | |
|         res.on("error", error => {
 | |
|           context.log.error(error);
 | |
|           reject(error);
 | |
|         });
 | |
|       }
 | |
|     );
 | |
|   });
 | |
| 
 | |
|   return JSON.parse(data);
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * This function gets the login information required.
 | |
|  *
 | |
|  * It starts by looking for a local file whose path is defined
 | |
|  * within RAPTOR_LOGINS. If we don't find this file, then we'll
 | |
|  * attempt to get the login information from our Taskcluster secret.
 | |
|  * If MOZ_AUTOMATION is undefined, then the test will fail, Taskcluster
 | |
|  * secrets can only be obtained in CI.
 | |
|  */
 | |
| async function get_logins(context) {
 | |
|   let logins;
 | |
| 
 | |
|   let RAPTOR_LOGINS = process.env.RAPTOR_LOGINS;
 | |
|   if (RAPTOR_LOGINS) {
 | |
|     // Get logins from a local file
 | |
|     if (!RAPTOR_LOGINS.endsWith(".json")) {
 | |
|       throw Error(
 | |
|         `File given for logins does not end in '.json': ${RAPTOR_LOGINS}`
 | |
|       );
 | |
|     }
 | |
| 
 | |
|     let logins_file = null;
 | |
|     try {
 | |
|       logins_file = await fs.readFileSync(RAPTOR_LOGINS, "utf8");
 | |
|     } catch (err) {
 | |
|       throw Error(`Failed to read the file ${RAPTOR_LOGINS}: ${err}`);
 | |
|     }
 | |
| 
 | |
|     logins = await JSON.parse(logins_file);
 | |
|   } else {
 | |
|     // Get logins from a perftest Taskcluster secret
 | |
|     logins = await get_tc_secrets(context);
 | |
|   }
 | |
| 
 | |
|   return logins;
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * This function returns the type of login to do.
 | |
|  *
 | |
|  * This function returns "single-form" when we find a single form. If we only
 | |
|  * find a single input field, we assume that there is one page per input
 | |
|  * and return "multi-page". Otherwise, we return null.
 | |
|  */
 | |
| async function get_login_type(context, commands) {
 | |
|   /*
 | |
|     Determine if there's a password field visible with this
 | |
|     query selector. Some sites use `tabIndex` to hide the password
 | |
|     field behind other elements. In this case, we are searching
 | |
|     for any password-type field that has a tabIndex of 0 or undefined and
 | |
|     is not hidden.
 | |
|   */
 | |
|   let input_length = await commands.js.run(`
 | |
|     return document.querySelectorAll(
 | |
|       "input[type=password][tabIndex='0']:not([type=hidden])," +
 | |
|       "input[type=password]:not([tabIndex]):not([type=hidden])"
 | |
|     ).length;
 | |
|   `);
 | |
|   if (input_length == 0) {
 | |
|     context.log.info("Found a multi-page login");
 | |
|     return multi_page_login;
 | |
|   } else if (input_length == 1) {
 | |
|     context.log.info("Found a single-page login");
 | |
|     return single_page_login;
 | |
|   }
 | |
| 
 | |
|   if (
 | |
|     (await commands.js.run(
 | |
|       `return document.querySelectorAll("form").length;`
 | |
|     )) >= 1
 | |
|   ) {
 | |
|     context.log.info("Found a single-form login");
 | |
|     return single_form_login;
 | |
|   }
 | |
| 
 | |
|   return null;
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * This function sets up the login for a single form.
 | |
|  *
 | |
|  * The username field is defined as the field which immediately precedes
 | |
|  * the password field. We have to do this in two steps because we need
 | |
|  * to make sure that the event we emit from the change has the `isTrusted`
 | |
|  * field set to `true`. Otherwise, some websites will ignore the input and
 | |
|  * the form submission.
 | |
|  */
 | |
| async function single_page_login(login_info, context, commands, prefix = "") {
 | |
|   // Get the first input field in the form that is not hidden and add the
 | |
|   // username. Assumes that email/username is always the first input field.
 | |
|   await commands.addText.bySelector(
 | |
|     login_info.username,
 | |
|     `${prefix}input:not([type=hidden]):not([type=password])`
 | |
|   );
 | |
| 
 | |
|   // Get the password field and ensure it's not hidden.
 | |
|   await commands.addText.bySelector(
 | |
|     login_info.password,
 | |
|     `${prefix}input[type=password]:not([type=hidden])`
 | |
|   );
 | |
| 
 | |
|   return undefined;
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * See single_page_login.
 | |
|  */
 | |
| async function single_form_login(login_info, context, commands) {
 | |
|   return single_page_login(login_info, context, commands, "form ");
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Login to a website that uses multiple pages for the login.
 | |
|  *
 | |
|  * WARNING: Assumes that the first page is for the username.
 | |
|  */
 | |
| async function multi_page_login(login_info, context, commands) {
 | |
|   const driver = context.selenium.driver;
 | |
|   const webdriver = context.selenium.webdriver;
 | |
| 
 | |
|   const username_field = await driver.findElement(
 | |
|     webdriver.By.css(`input:not([type=hidden]):not([type=password])`)
 | |
|   );
 | |
|   await username_field.sendKeys(login_info.username);
 | |
|   await username_field.sendKeys(webdriver.Key.ENTER);
 | |
|   await commands.wait.byTime(5000);
 | |
| 
 | |
|   let password_field;
 | |
|   try {
 | |
|     password_field = await driver.findElement(
 | |
|       webdriver.By.css(`input[type=password]:not([type=hidden])`)
 | |
|     );
 | |
|   } catch (err) {
 | |
|     if (err.toString().includes("NoSuchElementError")) {
 | |
|       // Sometimes we're suspicious (i.e. they think we're a bot/bad-actor)
 | |
|       let name_field = await driver.findElement(
 | |
|         webdriver.By.css(`input:not([type=hidden]):not([type=password])`)
 | |
|       );
 | |
|       await name_field.sendKeys(login_info.suspicious_answer);
 | |
|       await name_field.sendKeys(webdriver.Key.ENTER);
 | |
|       await commands.wait.byTime(5000);
 | |
| 
 | |
|       // Try getting the password field again
 | |
|       password_field = await driver.findElement(
 | |
|         webdriver.By.css(`input[type=password]:not([type=hidden])`)
 | |
|       );
 | |
|     } else {
 | |
|       throw err;
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   await password_field.sendKeys(login_info.password);
 | |
| 
 | |
|   return async function () {
 | |
|     password_field.sendKeys(webdriver.Key.ENTER);
 | |
|     await commands.wait.byTime(5000);
 | |
|   };
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * This function sets up the login.
 | |
|  *
 | |
|  * This is done by first the login type, and then performing the
 | |
|  * actual login setup. The return is a possible button to click
 | |
|  * to perform the login.
 | |
|  */
 | |
| async function setup_login(login_info, context, commands) {
 | |
|   let login_func = await get_login_type(context, commands);
 | |
|   if (!login_func) {
 | |
|     throw Error("Could not determine the type of login page.");
 | |
|   }
 | |
| 
 | |
|   try {
 | |
|     return await login_func(login_info, context, commands);
 | |
|   } catch (err) {
 | |
|     throw Error(`Could not setup login information: ${err}`);
 | |
|   }
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * This function performs the login.
 | |
|  *
 | |
|  * It does this by either clicking on a button with a type
 | |
|  * of "sumbit", or running a final_button function that was
 | |
|  * obtained from the setup_login function. Some pages also ask
 | |
|  * questions about setting up 2FA or other information. Generally,
 | |
|  * these contain the "skip" text.
 | |
|  */
 | |
| async function login(context, commands, final_button) {
 | |
|   try {
 | |
|     if (!final_button) {
 | |
|       // The mouse double click emits an event with `evt.isTrusted=true`
 | |
|       await commands.mouse.doubleClick.bySelector("button[type=submit]");
 | |
|       await commands.wait.byTime(10000);
 | |
|     } else {
 | |
|       // In some cases, it's preferable to be given a function for the final button
 | |
|       await final_button();
 | |
|     }
 | |
| 
 | |
|     // Some pages ask to setup 2FA, skip this based on the text
 | |
|     const XPATHS = [
 | |
|       "//a[contains(text(), 'skip')]",
 | |
|       "//button[contains(text(), 'skip')]",
 | |
|       "//input[contains(text(), 'skip')]",
 | |
|       "//div[contains(text(), 'skip')]",
 | |
|     ];
 | |
| 
 | |
|     for (let xpath of XPATHS) {
 | |
|       try {
 | |
|         await commands.mouse.doubleClick.byXpath(xpath);
 | |
|       } catch (err) {
 | |
|         if (err.toString().includes("not double click")) {
 | |
|           context.log.info(`Can't find a button with the text: ${xpath}`);
 | |
|         } else {
 | |
|           throw err;
 | |
|         }
 | |
|       }
 | |
|     }
 | |
|   } catch (err) {
 | |
|     throw Error(
 | |
|       `Could not login to website as we could not find the submit button/input: ${err}`
 | |
|     );
 | |
|   }
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Grab the base URL from the browsertime url.
 | |
|  *
 | |
|  * This is a necessary step for getting the login values from the Taskcluster
 | |
|  * secrets, which are hashed by the base URL.
 | |
|  *
 | |
|  * The first entry is the protocal, third is the top-level domain (or host)
 | |
|  */
 | |
| function get_base_URL(fullUrl) {
 | |
|   let pathAsArray = fullUrl.split("/");
 | |
|   return pathAsArray[0] + "//" + pathAsArray[2];
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * This function attempts the login-login sequence for a live pageload recording
 | |
|  */
 | |
| async function perform_live_login(context, commands) {
 | |
|   let testUrl = context.options.browsertime.url;
 | |
| 
 | |
|   let logins = await get_logins(context);
 | |
|   const baseUrl = get_base_URL(testUrl);
 | |
| 
 | |
|   await commands.navigate("about:blank");
 | |
| 
 | |
|   let login_info = logins.secret[baseUrl];
 | |
|   try {
 | |
|     await commands.navigate(login_info.login_url);
 | |
|   } catch (err) {
 | |
|     context.log.info("Unable to acquire login information");
 | |
|     throw err;
 | |
|   }
 | |
|   await commands.wait.byTime(5000);
 | |
| 
 | |
|   let final_button = await setup_login(login_info, context, commands);
 | |
|   await login(context, commands, final_button);
 | |
| }
 | |
| 
 | |
| async function dismissCookiePrompt(input_cmds, context, commands) {
 | |
|   context.log.info("Searching for cookie prompt elements...");
 | |
|   let cmds = input_cmds.split(";;;");
 | |
|   for (let cmdstr of cmds) {
 | |
|     let [cmd, ...args] = cmdstr.split(":::");
 | |
|     context.log.info(cmd, args);
 | |
|     let result = await commands.js.run(
 | |
|       `return document.evaluate("` +
 | |
|         args +
 | |
|         `", document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;`
 | |
|     );
 | |
|     if (result) {
 | |
|       context.log.info("Element found, clicking on it.");
 | |
|       await run_command(cmdstr, context, commands);
 | |
|     } else {
 | |
|       context.log.info(
 | |
|         "Element not found! The cookie prompt may have not appeared, please check the screenshots."
 | |
|       );
 | |
|     }
 | |
|   }
 | |
| }
 | |
| 
 | |
| async function pageload_test(context, commands) {
 | |
|   let testUrl = context.options.browsertime.url;
 | |
|   let secondaryUrl = context.options.browsertime.secondary_url;
 | |
|   let testName = context.options.browsertime.testName;
 | |
|   let dismissPrompt = context.options.browsertime.dismiss_cookie_prompt || "";
 | |
|   context.log.info(context.options.browsertime);
 | |
| 
 | |
|   // Wait for browser to settle
 | |
|   await commands.wait.byTime(1000);
 | |
| 
 | |
|   // If the user has RAPTOR_LOGINS configured correctly, a local login pageload
 | |
|   // test can be attempted. Otherwise if attempting it in CI, only sites with the
 | |
|   // associated MOZ_SCM_LEVEL will be attempted (e.g. Try = 1, autoland = 3)
 | |
|   if (context.options.browsertime.login) {
 | |
|     if (context.options.browsertime.manual_login) {
 | |
|       // Perform a manual login using the value given in manual_login
 | |
|       // as the amount of time to wait
 | |
|       await commands.navigate(testUrl);
 | |
|       context.log.info(
 | |
|         `Waiting ${context.options.browsertime.manual_login}ms for login...`
 | |
|       );
 | |
|       await commands.wait.byTime(context.options.browsertime.manual_login);
 | |
|     } else if (
 | |
|       process.env.RAPTOR_LOGINS ||
 | |
|       process.env.MOZ_SCM_LEVEL == 3 ||
 | |
|       SCM_1_LOGIN_SITES.includes(testName)
 | |
|     ) {
 | |
|       try {
 | |
|         await perform_live_login(context, commands);
 | |
|       } catch (err) {
 | |
|         context.log.info(
 | |
|           "Unable to login. Acquiring a recording without logging in"
 | |
|         );
 | |
|         context.log.info("Error:" + err);
 | |
|       }
 | |
|     } else {
 | |
|       context.log.info(`
 | |
|         NOTE: This is a login test but a manual login was not requested, and
 | |
|         we cannot find any logins defined in RAPTOR_LOGINS.
 | |
|       `);
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   await commands.measure.start(testUrl);
 | |
|   await commands.wait.byTime(40000);
 | |
|   if (dismissPrompt) {
 | |
|     await dismissCookiePrompt(dismissPrompt, context, commands);
 | |
|   }
 | |
|   commands.screenshot.take("test_url_" + testName);
 | |
| 
 | |
|   if (secondaryUrl !== null) {
 | |
|     // Wait for browser to settle
 | |
|     await commands.wait.byTime(1000);
 | |
| 
 | |
|     await commands.measure.start(secondaryUrl);
 | |
|     commands.screenshot.take("secondary_url_" + testName);
 | |
|   }
 | |
| 
 | |
|   // Wait for browser to settle
 | |
|   await commands.wait.byTime(1000);
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Converts a string such as `measure.start` into the
 | |
|  * actual function that is found in the `commands` module.
 | |
|  *
 | |
|  * XX: Find a way to share this function between
 | |
|  * perftest_record.js and browsertime_interactive.js
 | |
|  */
 | |
| async function get_command_function(cmd, commands) {
 | |
|   if (cmd == "") {
 | |
|     throw new Error("A blank command was given.");
 | |
|   } else if (cmd.endsWith(".")) {
 | |
|     throw new Error(
 | |
|       "An extra `.` was found at the end of this command: " + cmd
 | |
|     );
 | |
|   }
 | |
| 
 | |
|   // `func` will hold the actual method that needs to be called,
 | |
|   // and the `parent_mod` is the context required to run the `func`
 | |
|   // method. Without that context, `this` becomes undefined in the browsertime
 | |
|   // classes.
 | |
|   let func = null;
 | |
|   let parent_mod = null;
 | |
|   for (let func_part of cmd.split(".")) {
 | |
|     if (func_part == "") {
 | |
|       throw new Error(
 | |
|         "An empty function part was found in the command: " + cmd
 | |
|       );
 | |
|     }
 | |
| 
 | |
|     if (func === null) {
 | |
|       parent_mod = commands;
 | |
|       func = commands[func_part];
 | |
|     } else if (func !== undefined) {
 | |
|       parent_mod = func;
 | |
|       func = func[func_part];
 | |
|     } else {
 | |
|       break;
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   if (func == undefined) {
 | |
|     throw new Error(
 | |
|       "The given command could not be found as a function: " + cmd
 | |
|     );
 | |
|   }
 | |
| 
 | |
|   return [func, parent_mod];
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Performs an interactive test.
 | |
|  *
 | |
|  * These tests are interactive as the entire test is defined
 | |
|  * through a set of browsertime commands. This allows users
 | |
|  * to build arbitrary tests. Furthermore, interactive tests
 | |
|  * provide the ability to login to websites.
 | |
|  */
 | |
| async function interactive_test(input_cmds, context, commands) {
 | |
|   let cmds = input_cmds.split(";;;");
 | |
| 
 | |
|   let logins;
 | |
|   if (context.options.browsertime.login) {
 | |
|     logins = await get_logins(context);
 | |
|   }
 | |
| 
 | |
|   await commands.navigate("about:blank");
 | |
| 
 | |
|   let user_setup = false;
 | |
|   let final_button = null;
 | |
|   for (let cmdstr of cmds) {
 | |
|     let [cmd, ...args] = cmdstr.split(":::");
 | |
| 
 | |
|     if (cmd == "setup_login") {
 | |
|       if (!logins) {
 | |
|         throw Error(
 | |
|           "This test is not specified as a `login` test so no login information is available."
 | |
|         );
 | |
|       }
 | |
|       if (args.length < 1 || args[0] == "") {
 | |
|         throw Error(
 | |
|           `No URL given, can't tell where to setup the login. We only accept: ${logins.keys()}`
 | |
|         );
 | |
|       }
 | |
|       /* Structure for logins is:
 | |
|           {
 | |
|               "username": ...,
 | |
|               "password": ...,
 | |
|               "suspicious_answer": ...,
 | |
|               "login_url": ...,
 | |
|           }
 | |
|       */
 | |
|       let login_info = logins.secret[args[0]];
 | |
| 
 | |
|       await commands.navigate(login_info.login_url);
 | |
|       await commands.wait.byTime(5000);
 | |
| 
 | |
|       final_button = await setup_login(login_info, context, commands);
 | |
|       user_setup = true;
 | |
|     } else if (cmd == "login") {
 | |
|       if (!user_setup) {
 | |
|         throw Error("setup_login needs to be called before the login command");
 | |
|       }
 | |
|       await login(context, commands, final_button);
 | |
|     } else {
 | |
|       await run_command(cmdstr, context, commands);
 | |
|     }
 | |
|   }
 | |
| }
 | |
| 
 | |
| async function run_command(cmdstr, context, commands) {
 | |
|   let [cmd, ...args] = cmdstr.split(":::");
 | |
|   let [func, parent_mod] = await get_command_function(cmd, commands);
 | |
| 
 | |
|   try {
 | |
|     await func.call(parent_mod, ...args);
 | |
|   } catch (e) {
 | |
|     context.log.info(
 | |
|       `Exception found while running \`commands.${cmd}(${args})\`: ` + e
 | |
|     );
 | |
|   }
 | |
| }
 | |
| 
 | |
| async function test(context, commands) {
 | |
|   let input_cmds = context.options.browsertime.commands;
 | |
|   let test_type = context.options.browsertime.testType;
 | |
|   if (test_type == "interactive") {
 | |
|     await interactive_test(input_cmds, context, commands);
 | |
|   } else {
 | |
|     await pageload_test(context, commands);
 | |
|   }
 | |
|   return true;
 | |
| }
 | |
| 
 | |
| module.exports = {
 | |
|   test,
 | |
|   owner: "Bebe fstrugariu@mozilla.com",
 | |
|   name: "Mozproxy recording generator",
 | |
|   component: "raptor",
 | |
|   description: ` This test generates fresh MozProxy recordings. It iterates through a list of 
 | |
|       websites provided in *_sites.json and for each one opens a browser and 
 | |
|       records all the associated HTTP traffic`,
 | |
|   usage:
 | |
|     "mach perftest --proxy --hooks testing/raptor/recorder/hooks.py testing/raptor/recorder/perftest_record.js",
 | |
| };
 | 
