1// Copyright 2020 The Chromium Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5import {AssertionError} from 'chai';
6import * as os from 'os';
7import * as puppeteer from 'puppeteer';
8
9import {reloadDevTools} from '../conductor/hooks.js';
10import {getBrowserAndPages, getHostedModeServerPort} from '../conductor/puppeteer-state.js';
11import {AsyncScope} from './mocha-extensions.js';
12
13declare global {
14  interface Window {
15    __pendingEvents: Map<string, Event[]>;
16  }
17}
18
19export let platform: string;
20switch (os.platform()) {
21  case 'darwin':
22    platform = 'mac';
23    break;
24
25  case 'win32':
26    platform = 'win32';
27    break;
28
29  default:
30    platform = 'linux';
31    break;
32}
33
34// TODO: Remove once Chromium updates its version of Node.js to 12+.
35// eslint-disable-next-line @typescript-eslint/no-explicit-any
36const globalThis: any = global;
37
38/**
39 * Returns an {x, y} position within the element identified by the selector within the root.
40 * By default the position is the center of the bounding box. If the element's bounding box
41 * extends beyond that of a containing element, this position may not correspond to the element.
42 * In this case, specifying maxPixelsFromLeft will constrain the returned point to be close to
43 * the left edge of the bounding box.
44 */
45export const getElementPosition =
46    async (selector: string|puppeteer.JSHandle, root?: puppeteer.JSHandle, maxPixelsFromLeft?: number) => {
47  let element;
48  if (typeof selector === 'string') {
49    element = await waitFor(selector, root);
50  } else {
51    element = selector;
52  }
53
54  const rect = await element.evaluate(element => {
55    if (!element) {
56      return {};
57    }
58
59    const {left, top, width, height} = element.getBoundingClientRect();
60    return {left, top, width, height};
61  });
62
63  if (rect.left === undefined) {
64    throw new Error(`Unable to find element with selector "${selector}"`);
65  }
66
67  let pixelsFromLeft = rect.width * 0.5;
68  if (maxPixelsFromLeft && pixelsFromLeft > maxPixelsFromLeft) {
69    pixelsFromLeft = maxPixelsFromLeft;
70  }
71
72  return {
73    x: rect.left + pixelsFromLeft,
74    y: rect.top + rect.height * 0.5,
75  };
76};
77
78export const click = async (
79    selector: string|puppeteer.JSHandle,
80    options?: {root?: puppeteer.JSHandle; clickOptions?: puppeteer.ClickOptions; maxPixelsFromLeft?: number;}) => {
81  const {frontend} = getBrowserAndPages();
82  const clickableElement =
83      await getElementPosition(selector, options && options.root, options && options.maxPixelsFromLeft);
84
85  if (!clickableElement) {
86    throw new Error(`Unable to locate clickable element "${selector}".`);
87  }
88
89  // Click on the button and wait for the console to load. The reason we use this method
90  // rather than elementHandle.click() is because the frontend attaches the behavior to
91  // a 'mousedown' event (not the 'click' event). To avoid attaching the test behavior
92  // to a specific event we instead locate the button in question and ask Puppeteer to
93  // click on it instead.
94  await frontend.mouse.click(clickableElement.x, clickableElement.y, options && options.clickOptions);
95};
96
97export const doubleClick =
98    async (selector: string, options?: {root?: puppeteer.JSHandle; clickOptions?: puppeteer.ClickOptions}) => {
99  const passedClickOptions = (options && options.clickOptions) || {};
100  const clickOptionsWithDoubleClick: puppeteer.ClickOptions = {
101    ...passedClickOptions,
102    clickCount: 2,
103  };
104  return click(selector, {
105    ...options,
106    clickOptions: clickOptionsWithDoubleClick,
107  });
108};
109
110export const typeText = async (text: string) => {
111  const {frontend} = getBrowserAndPages();
112  await frontend.keyboard.type(text);
113};
114
115export const pressKey = async (key: string, modifiers?: {control?: boolean, alt?: boolean, shift?: boolean}) => {
116  const {frontend} = getBrowserAndPages();
117  if (modifiers) {
118    if (modifiers.control) {
119      if (platform === 'mac') {
120        // Use command key on mac
121        await frontend.keyboard.down('Meta');
122      } else {
123        await frontend.keyboard.down('Control');
124      }
125    }
126    if (modifiers.alt) {
127      await frontend.keyboard.down('Alt');
128    }
129    if (modifiers.shift) {
130      await frontend.keyboard.down('Shift');
131    }
132  }
133  await frontend.keyboard.press(key);
134  if (modifiers) {
135    if (modifiers.shift) {
136      await frontend.keyboard.up('Shift');
137    }
138    if (modifiers.alt) {
139      await frontend.keyboard.up('Alt');
140    }
141    if (modifiers.control) {
142      if (platform === 'mac') {
143        // Use command key on mac
144        await frontend.keyboard.up('Meta');
145      } else {
146        await frontend.keyboard.up('Control');
147      }
148    }
149  }
150};
151
152export const pasteText = async (text: string) => {
153  const {frontend} = getBrowserAndPages();
154  await frontend.keyboard.sendCharacter(text);
155};
156
157// Get a single element handle. Uses `pierce` handler per default for piercing Shadow DOM.
158export const $ = async (selector: string, root?: puppeteer.JSHandle, handler = 'pierce') => {
159  const {frontend} = getBrowserAndPages();
160  const rootElement = root ? root as puppeteer.ElementHandle : frontend;
161  const element = await rootElement.$(`${handler}/${selector}`);
162  return element;
163};
164
165// Get multiple element handles. Uses `pierce` handler per default for piercing Shadow DOM.
166export const $$ = async (selector: string, root?: puppeteer.JSHandle, handler = 'pierce') => {
167  const {frontend} = getBrowserAndPages();
168  const rootElement = root ? root.asElement() || frontend : frontend;
169  const elements = await rootElement.$$(`${handler}/${selector}`);
170  return elements;
171};
172
173/**
174 * Search for an element based on its textContent.
175 *
176 * @param textContent The text content to search for.
177 * @param root The root of the search.
178 */
179export const $textContent = async (textContent: string, root?: puppeteer.JSHandle) => {
180  return $(textContent, root, 'pierceShadowText');
181};
182
183/**
184 * Search for all elements based on their textContent
185 *
186 * @param textContent The text content to search for.
187 * @param root The root of the search.
188 */
189export const $$textContent = async (textContent: string, root?: puppeteer.JSHandle) => {
190  return $$(textContent, root, 'pierceShadowText');
191};
192
193export const timeout = (duration: number) => new Promise(resolve => setTimeout(resolve, duration));
194
195export const waitFor =
196    async (selector: string, root?: puppeteer.JSHandle, asyncScope = new AsyncScope(), handler?: string) => {
197  return await asyncScope.exec(() => waitForFunction(async () => {
198                                 const element = await $(selector, root, handler);
199                                 return (element || undefined);
200                               }, asyncScope));
201};
202
203export const waitForNone =
204    async (selector: string, root?: puppeteer.JSHandle, asyncScope = new AsyncScope(), handler?: string) => {
205  return await asyncScope.exec(() => waitForFunction(async () => {
206                                 const elements = await $$(selector, root, handler);
207                                 if (elements.length === 0) {
208                                   return true;
209                                 }
210                                 return false;
211                               }, asyncScope));
212};
213
214export const waitForAria = (selector: string, root?: puppeteer.JSHandle, asyncScope = new AsyncScope()) => {
215  return waitFor(selector, root, asyncScope, 'aria');
216};
217
218export const waitForElementWithTextContent =
219    (textContent: string, root?: puppeteer.JSHandle, asyncScope = new AsyncScope()) => {
220      return waitFor(textContent, root, asyncScope, 'pierceShadowText');
221    };
222
223export const waitForElementsWithTextContent =
224    (textContent: string, root?: puppeteer.JSHandle, asyncScope = new AsyncScope()) => {
225      return asyncScope.exec(() => waitForFunction(async () => {
226                               const elems = await $$textContent(textContent, root);
227                               if (elems && elems.length) {
228                                 return elems;
229                               }
230
231                               return undefined;
232                             }, asyncScope));
233    };
234
235export const waitForNoElementsWithTextContent =
236    (textContent: string, root?: puppeteer.JSHandle, asyncScope = new AsyncScope()) => {
237      return asyncScope.exec(() => waitForFunction(async () => {
238                               const elems = await $$textContent(textContent, root);
239                               if (elems && elems.length === 0) {
240                                 return true;
241                               }
242
243                               return false;
244                             }, asyncScope));
245    };
246
247export const waitForFunction = async<T>(fn: () => Promise<T|undefined>, asyncScope = new AsyncScope()): Promise<T> => {
248  return await asyncScope.exec(async () => {
249    while (true) {
250      const result = await fn();
251      if (result) {
252        return result;
253      }
254      await timeout(100);
255    }
256  });
257};
258
259export const debuggerStatement = (frontend: puppeteer.Page) => {
260  return frontend.evaluate(() => {
261    // eslint-disable-next-line no-debugger
262    debugger;
263  });
264};
265
266export const logToStdOut = (msg: string) => {
267  if (!process.send) {
268    return;
269  }
270
271  process.send({
272    pid: process.pid,
273    details: msg,
274  });
275};
276
277export const logFailure = () => {
278  if (!process.send) {
279    return;
280  }
281
282  process.send({
283    pid: process.pid,
284    details: 'failure',
285  });
286};
287
288export const enableExperiment = async (
289    experiment: string, options: {selectedPanel?: {name: string, selector?: string}, canDock?: boolean} = {}) => {
290  const {frontend} = getBrowserAndPages();
291  await frontend.evaluate(experiment => {
292    // @ts-ignore
293    globalThis.Root.Runtime.experiments.setEnabled(experiment, true);
294  }, experiment);
295
296  await reloadDevTools(options);
297};
298
299export const goTo = async (url: string) => {
300  const {target} = getBrowserAndPages();
301  await target.goto(url);
302};
303
304export const overridePermissions = async (permissions: puppeteer.Permission[]) => {
305  const {browser} = getBrowserAndPages();
306  await browser.defaultBrowserContext().overridePermissions(
307      `http://localhost:${getHostedModeServerPort()}`, permissions);
308};
309
310export const clearPermissionsOverride = async () => {
311  const {browser} = getBrowserAndPages();
312  await browser.defaultBrowserContext().clearPermissionOverrides();
313};
314
315export const goToResource = async (path: string) => {
316  await goTo(`${getResourcesPath()}/${path}`);
317};
318
319export const getResourcesPath = () => {
320  return `http://localhost:${getHostedModeServerPort()}/test/e2e/resources`;
321};
322
323export const step = async (description: string, step: Function) => {
324  try {
325    // eslint-disable-next-line no-console
326    console.log(`     Running step "${description}"`);
327    return await step();
328  } catch (error) {
329    if (error instanceof AssertionError) {
330      throw new AssertionError(
331          `Unexpected Result in Step "${description}"
332      ${error.message}`,
333          error);
334    } else {
335      error.message += ` in Step "${description}"`;
336      throw error;
337    }
338  }
339};
340
341export const waitForAnimationFrame = async () => {
342  const {target} = getBrowserAndPages();
343
344  await target.waitForFunction(() => {
345    return new Promise(resolve => {
346      requestAnimationFrame(resolve);
347    });
348  });
349};
350
351export const activeElement = async () => {
352  const {target} = getBrowserAndPages();
353
354  await waitForAnimationFrame();
355
356  return target.evaluateHandle(() => {
357    let activeElement = document.activeElement;
358
359    while (activeElement && activeElement.shadowRoot) {
360      activeElement = activeElement.shadowRoot.activeElement;
361    }
362
363    return activeElement;
364  });
365};
366
367export const activeElementTextContent = async () => {
368  const element = await activeElement();
369  return element.evaluate(node => node.textContent);
370};
371
372export const tabForward = async () => {
373  const {target} = getBrowserAndPages();
374
375  await target.keyboard.press('Tab');
376};
377
378export const tabBackward = async () => {
379  const {target} = getBrowserAndPages();
380
381  await target.keyboard.down('Shift');
382  await target.keyboard.press('Tab');
383  await target.keyboard.up('Shift');
384};
385
386export const selectTextFromNodeToNode = async (
387    from: puppeteer.JSHandle|Promise<puppeteer.JSHandle>, to: puppeteer.JSHandle|Promise<puppeteer.JSHandle>,
388    direction: 'up'|'down') => {
389  const {target} = getBrowserAndPages();
390
391  // The clipboard api does not allow you to copy, unless the tab is focused.
392  await target.bringToFront();
393
394  return target.evaluate(async (from, to, direction) => {
395    const selection = from.getRootNode().getSelection();
396    const range = document.createRange();
397    if (direction === 'down') {
398      range.setStartBefore(from);
399      range.setEndAfter(to);
400    } else {
401      range.setStartBefore(to);
402      range.setEndAfter(from);
403    }
404
405    selection.removeAllRanges();
406    selection.addRange(range);
407
408    document.execCommand('copy');
409
410    return navigator.clipboard.readText();
411  }, await from, await to, direction);
412};
413
414export const closePanelTab = async (panelTabSelector: string) => {
415  // Get close button from tab element
416  const selector = `${panelTabSelector} > .tabbed-pane-close-button`;
417  await click(selector);
418  await waitForNone(selector);
419};
420
421export const closeAllCloseableTabs = async () => {
422  // get all closeable tools by looking for the available x buttons on tabs
423  const selector = '.tabbed-pane-close-button';
424  const allCloseButtons = await $$(selector);
425
426  // Get all panel ids
427  const panelTabIds = await Promise.all(allCloseButtons.map(button => {
428    return button.evaluate(button => button.parentElement ? button.parentElement.id : '');
429  }));
430
431  // Close each tab
432  for (const tabId of panelTabIds) {
433    const selector = `#${tabId}`;
434    await closePanelTab(selector);
435  }
436};
437
438// Noisy! Do not leave this in your test but it may be helpful
439// when debugging.
440export const enableCDPLogging = async () => {
441  const {frontend} = getBrowserAndPages();
442  await frontend.evaluate(() => {
443    globalThis.ProtocolClient.test.dumpProtocol = console.log;  // eslint-disable-line no-console
444  });
445};
446
447export const selectOption = async (select: puppeteer.JSHandle<HTMLSelectElement>, value: string) => {
448  await select.evaluate(async (node, _value) => {
449    node.value = _value;
450    const event = document.createEvent('HTMLEvents');
451    event.initEvent('change', false, true);
452    node.dispatchEvent(event);
453  }, value);
454};
455
456export const scrollElementIntoView = async (selector: string, root?: puppeteer.JSHandle) => {
457  const element = await $(selector, root);
458
459  if (!element) {
460    throw new Error(`Unable to find element with selector "${selector}"`);
461  }
462
463  await element.evaluate(el => {
464    el.scrollIntoView();
465  });
466};
467
468export const installEventListener = function(frontend: puppeteer.Page, eventType: string) {
469  return frontend.evaluate(eventType => {
470    if (!('__pendingEvents' in window)) {
471      window.__pendingEvents = new Map();
472    }
473    window.addEventListener(eventType, (e: Event) => {
474      let events = window.__pendingEvents.get(eventType);
475      if (!events) {
476        events = [];
477        window.__pendingEvents.set(eventType, events);
478      }
479      events.push(e);
480    });
481  }, eventType);
482};
483
484export const getPendingEvents = function(frontend: puppeteer.Page, eventType: string) {
485  return frontend.evaluate(eventType => {
486    if (!('__pendingEvents' in window)) {
487      return [];
488    }
489    const pendingEvents = window.__pendingEvents.get(eventType);
490    window.__pendingEvents.set(eventType, []);
491    return pendingEvents || [];
492  }, eventType);
493};
494
495export {getBrowserAndPages, getHostedModeServerPort, reloadDevTools};
496