Bug 1856942: part 5) Factor async loading of a sheet out of `Loader::LoadSheet`....
[gecko.git] / testing / performance / perftest_record.js
bloba0bfa6d1344374a21a9ccd5a618831f31ac981fa
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";
7 const fs = require("fs");
8 const http = require("http");
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";
14 const SCM_1_LOGIN_SITES = ["facebook", "netflix"];
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   }
31   let TASKCLUSTER_PROXY_URL = process.env.TASKCLUSTER_PROXY_URL
32     ? process.env.TASKCLUSTER_PROXY_URL
33     : DEFAULT_SERVER;
35   let MOZ_SCM_LEVEL = process.env.MOZ_SCM_LEVEL ? process.env.MOZ_SCM_LEVEL : 1;
37   const url = TASKCLUSTER_PROXY_URL + URL + MOZ_SCM_LEVEL + SECRET;
39   const data = await new Promise((resolve, reject) => {
40     context.log.info("Obtaining secrets for login...");
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}`);
54         res.on("data", d => {
55           data += d.toString();
56         });
58         res.on("end", () => {
59           resolve(data);
60         });
62         res.on("error", error => {
63           context.log.error(error);
64           reject(error);
65         });
66       }
67     );
68   });
70   return JSON.parse(data);
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;
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     }
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     }
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   }
107   return logins;
111  * This function returns the type of login to do.
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   }
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   }
148   return null;
152  * This function sets up the login for a single form.
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   );
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   );
174   return undefined;
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 ");
185  * Login to a website that uses multiple pages for the login.
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;
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);
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);
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   }
224   await password_field.sendKeys(login_info.password);
226   return async function () {
227     password_field.sendKeys(webdriver.Key.ENTER);
228     await commands.wait.byTime(5000);
229   };
233  * This function sets up the login.
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   }
245   try {
246     return await login_func(login_info, context, commands);
247   } catch (err) {
248     throw Error(`Could not setup login information: ${err}`);
249   }
253  * This function performs the login.
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     }
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     ];
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   }
299  * Grab the base URL from the browsertime url.
301  * This is a necessary step for getting the login values from the Taskcluster
302  * secrets, which are hashed by the base URL.
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];
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;
317   let logins = await get_logins(context);
318   const baseUrl = get_base_URL(testUrl);
320   await commands.navigate("about:blank");
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);
331   let final_button = await setup_login(login_info, context, commands);
332   await login(context, commands, final_button);
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   }
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);
364   // Wait for browser to settle
365   await commands.wait.byTime(1000);
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   }
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);
407   if (secondaryUrl !== null) {
408     // Wait for browser to settle
409     await commands.wait.byTime(1000);
411     await commands.measure.start(secondaryUrl);
412     commands.screenshot.take("secondary_url_" + testName);
413   }
415   // Wait for browser to settle
416   await commands.wait.byTime(1000);
420  * Converts a string such as `measure.start` into the
421  * actual function that is found in the `commands` module.
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   }
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     }
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   }
459   if (func == undefined) {
460     throw new Error(
461       "The given command could not be found as a function: " + cmd
462     );
463   }
465   return [func, parent_mod];
469  * Performs an interactive test.
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(";;;");
479   let logins;
480   if (context.options.browsertime.login) {
481     logins = await get_logins(context);
482   }
484   await commands.navigate("about:blank");
486   let user_setup = false;
487   let final_button = null;
488   for (let cmdstr of cmds) {
489     let [cmd, ...args] = cmdstr.split(":::");
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]];
512       await commands.navigate(login_info.login_url);
513       await commands.wait.byTime(5000);
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   }
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);
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   }
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;
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",