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 };