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