| // Copyright 2020 The Chromium Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| import {assert, AssertionError} from 'chai'; |
| import * as os from 'os'; |
| import * as puppeteer from 'puppeteer'; |
| |
| import {getDevToolsFrontendHostname, reloadDevTools} from '../conductor/hooks.js'; |
| import {getBrowserAndPages, getTestServerPort} from '../conductor/puppeteer-state.js'; |
| import {getTestRunnerConfigSetting} from '../conductor/test_runner_config.js'; |
| |
| import {AsyncScope} from './async-scope.js'; |
| |
| declare global { |
| // eslint-disable-next-line @typescript-eslint/no-unused-vars |
| interface Window { |
| // eslint-disable-next-line @typescript-eslint/naming-convention |
| __pendingEvents: Map<string, Event[]>; |
| |
| // eslint-disable-next-line @typescript-eslint/naming-convention |
| __getRenderCoordinatorPendingFrames(): number; |
| } |
| } |
| |
| export type Platform = 'mac'|'win32'|'linux'; |
| export let platform: Platform; |
| switch (os.platform()) { |
| case 'darwin': |
| platform = 'mac'; |
| break; |
| |
| case 'win32': |
| platform = 'win32'; |
| break; |
| |
| default: |
| platform = 'linux'; |
| break; |
| } |
| |
| // TODO: Remove once Chromium updates its version of Node.js to 12+. |
| // eslint-disable-next-line @typescript-eslint/no-explicit-any |
| const globalThis: any = global; |
| |
| /** |
| * Returns an {x, y} position within the element identified by the selector within the root. |
| * By default the position is the center of the bounding box. If the element's bounding box |
| * extends beyond that of a containing element, this position may not correspond to the element. |
| * In this case, specifying maxPixelsFromLeft will constrain the returned point to be close to |
| * the left edge of the bounding box. |
| */ |
| export const getElementPosition = |
| async (selector: string|puppeteer.ElementHandle, root?: puppeteer.JSHandle, maxPixelsFromLeft?: number) => { |
| const rectData = await waitForFunction(async () => { |
| let element: puppeteer.ElementHandle; |
| if (typeof selector === 'string') { |
| element = await waitFor(selector, root); |
| } else { |
| element = selector; |
| } |
| |
| return element.evaluate((element: Element) => { |
| if (!element) { |
| return {}; |
| } |
| |
| if (!element.isConnected) { |
| return undefined; |
| } |
| |
| const {left, top, width, height} = element.getBoundingClientRect(); |
| return {left, top, width, height}; |
| }); |
| }); |
| |
| if (rectData.left === undefined) { |
| throw new Error(`Unable to find element with selector "${selector}"`); |
| } |
| |
| let pixelsFromLeft = rectData.width * 0.5; |
| if (maxPixelsFromLeft && pixelsFromLeft > maxPixelsFromLeft) { |
| pixelsFromLeft = maxPixelsFromLeft; |
| } |
| |
| return { |
| x: rectData.left + pixelsFromLeft, |
| y: rectData.top + rectData.height * 0.5, |
| }; |
| }; |
| |
| export interface ClickOptions { |
| root?: puppeteer.JSHandle; |
| clickOptions?: PuppeteerClickOptions; |
| maxPixelsFromLeft?: number; |
| } |
| |
| interface PuppeteerClickOptions extends puppeteer.ClickOptions { |
| modifier?: 'ControlOrMeta'; |
| } |
| |
| export const click = async (selector: string|puppeteer.ElementHandle, options?: ClickOptions) => { |
| const {frontend} = getBrowserAndPages(); |
| const clickableElement = |
| await getElementPosition(selector, options && options.root, options && options.maxPixelsFromLeft); |
| |
| if (!clickableElement) { |
| throw new Error(`Unable to locate clickable element "${selector}".`); |
| } |
| |
| const modifier = platform === 'mac' ? 'Meta' : 'Control'; |
| if (options?.clickOptions?.modifier) { |
| await frontend.keyboard.down(modifier); |
| } |
| |
| // Click on the button and wait for the console to load. The reason we use this method |
| // rather than elementHandle.click() is because the frontend attaches the behavior to |
| // a 'mousedown' event (not the 'click' event). To avoid attaching the test behavior |
| // to a specific event we instead locate the button in question and ask Puppeteer to |
| // click on it instead. |
| await frontend.mouse.click(clickableElement.x, clickableElement.y, options && options.clickOptions); |
| if (options?.clickOptions?.modifier) { |
| await frontend.keyboard.up(modifier); |
| } |
| }; |
| |
| export const doubleClick = |
| async (selector: string, options?: {root?: puppeteer.JSHandle, clickOptions?: puppeteer.ClickOptions}) => { |
| const passedClickOptions = (options && options.clickOptions) || {}; |
| const clickOptionsWithDoubleClick: puppeteer.ClickOptions = { |
| ...passedClickOptions, |
| clickCount: 2, |
| }; |
| return click(selector, { |
| ...options, |
| clickOptions: clickOptionsWithDoubleClick, |
| }); |
| }; |
| |
| export const typeText = async (text: string) => { |
| const {frontend} = getBrowserAndPages(); |
| await frontend.keyboard.type(text); |
| }; |
| |
| export const pressKey = |
| async (key: puppeteer.KeyInput, modifiers?: {control?: boolean, alt?: boolean, shift?: boolean}) => { |
| const {frontend} = getBrowserAndPages(); |
| if (modifiers) { |
| if (modifiers.control) { |
| if (platform === 'mac') { |
| // Use command key on mac |
| await frontend.keyboard.down('Meta'); |
| } else { |
| await frontend.keyboard.down('Control'); |
| } |
| } |
| if (modifiers.alt) { |
| await frontend.keyboard.down('Alt'); |
| } |
| if (modifiers.shift) { |
| await frontend.keyboard.down('Shift'); |
| } |
| } |
| await frontend.keyboard.press(key); |
| if (modifiers) { |
| if (modifiers.shift) { |
| await frontend.keyboard.up('Shift'); |
| } |
| if (modifiers.alt) { |
| await frontend.keyboard.up('Alt'); |
| } |
| if (modifiers.control) { |
| if (platform === 'mac') { |
| // Use command key on mac |
| await frontend.keyboard.up('Meta'); |
| } else { |
| await frontend.keyboard.up('Control'); |
| } |
| } |
| } |
| }; |
| |
| export const pasteText = async (text: string) => { |
| const {frontend} = getBrowserAndPages(); |
| await frontend.keyboard.sendCharacter(text); |
| }; |
| |
| // Get a single element handle. Uses `pierce` handler per default for piercing Shadow DOM. |
| export const $ = |
| async<ElementType extends Element = Element>(selector: string, root?: puppeteer.JSHandle, handler = 'pierce') => { |
| const {frontend} = getBrowserAndPages(); |
| const rootElement = root ? root as puppeteer.ElementHandle : frontend; |
| const element = await rootElement.$<ElementType>(`${handler}/${selector}`); |
| return element; |
| }; |
| |
| // Get multiple element handles. Uses `pierce` handler per default for piercing Shadow DOM. |
| export const $$ = |
| async<ElementType extends Element = Element>(selector: string, root?: puppeteer.JSHandle, handler = 'pierce') => { |
| const {frontend} = getBrowserAndPages(); |
| const rootElement = root ? root.asElement() || frontend : frontend; |
| const elements = await rootElement.$$<ElementType>(`${handler}/${selector}`); |
| return elements; |
| }; |
| |
| /** |
| * Search for an element based on its textContent. |
| * |
| * @param textContent The text content to search for. |
| * @param root The root of the search. |
| */ |
| export const $textContent = async (textContent: string, root?: puppeteer.JSHandle) => { |
| return $(textContent, root, 'pierceShadowText'); |
| }; |
| |
| /** |
| * Search for all elements based on their textContent |
| * |
| * @param textContent The text content to search for. |
| * @param root The root of the search. |
| */ |
| export const $$textContent = async (textContent: string, root?: puppeteer.JSHandle) => { |
| return $$(textContent, root, 'pierceShadowText'); |
| }; |
| |
| export const timeout = (duration: number) => new Promise(resolve => setTimeout(resolve, duration)); |
| |
| export const waitFor = async<ElementType extends Element = Element>( |
| selector: string, root?: puppeteer.JSHandle, asyncScope = new AsyncScope(), handler?: string) => { |
| return await asyncScope.exec(() => waitForFunction(async () => { |
| const element = await $<ElementType>(selector, root, handler); |
| return (element || undefined); |
| }, asyncScope)); |
| }; |
| |
| export const waitForMany = async ( |
| selector: string, count: number, root?: puppeteer.JSHandle, asyncScope = new AsyncScope(), handler?: string) => { |
| return await asyncScope.exec(() => waitForFunction(async () => { |
| const elements = await $$(selector, root, handler); |
| return elements.length >= count ? elements : undefined; |
| }, asyncScope)); |
| }; |
| |
| export const waitForNone = |
| async (selector: string, root?: puppeteer.JSHandle, asyncScope = new AsyncScope(), handler?: string) => { |
| return await asyncScope.exec(() => waitForFunction(async () => { |
| const elements = await $$(selector, root, handler); |
| if (elements.length === 0) { |
| return true; |
| } |
| return false; |
| }, asyncScope)); |
| }; |
| |
| export const waitForAria = (selector: string, root?: puppeteer.JSHandle, asyncScope = new AsyncScope()) => { |
| return waitFor(selector, root, asyncScope, 'aria'); |
| }; |
| |
| export const waitForAriaNone = (selector: string, root?: puppeteer.JSHandle, asyncScope = new AsyncScope()) => { |
| return waitForNone(selector, root, asyncScope, 'aria'); |
| }; |
| |
| export const waitForElementWithTextContent = |
| (textContent: string, root?: puppeteer.JSHandle, asyncScope = new AsyncScope()) => { |
| return waitFor(textContent, root, asyncScope, 'pierceShadowText'); |
| }; |
| |
| export const waitForElementsWithTextContent = |
| (textContent: string, root?: puppeteer.JSHandle, asyncScope = new AsyncScope()) => { |
| return asyncScope.exec(() => waitForFunction(async () => { |
| const elems = await $$textContent(textContent, root); |
| if (elems && elems.length) { |
| return elems; |
| } |
| |
| return undefined; |
| }, asyncScope)); |
| }; |
| |
| export const waitForNoElementsWithTextContent = |
| (textContent: string, root?: puppeteer.JSHandle, asyncScope = new AsyncScope()) => { |
| return asyncScope.exec(() => waitForFunction(async () => { |
| const elems = await $$textContent(textContent, root); |
| if (elems && elems.length === 0) { |
| return true; |
| } |
| |
| return false; |
| }, asyncScope)); |
| }; |
| |
| export const waitForFunction = async<T>(fn: () => Promise<T|undefined>, asyncScope = new AsyncScope()): Promise<T> => { |
| return await asyncScope.exec(async () => { |
| while (true) { |
| if (asyncScope.isCanceled()) { |
| throw new Error('Test timed out'); |
| } |
| const result = await fn(); |
| if (result) { |
| return result; |
| } |
| await timeout(100); |
| } |
| }); |
| }; |
| |
| export const waitForFunctionWithTries = async<T>( |
| fn: () => Promise<T|undefined>, options: {tries: number} = { |
| tries: Number.MAX_SAFE_INTEGER, |
| }, |
| asyncScope = new AsyncScope()): Promise<T|undefined> => { |
| return await asyncScope.exec(async () => { |
| let tries = 0; |
| while (tries++ < options.tries) { |
| const result = await fn(); |
| if (result) { |
| return result; |
| } |
| await timeout(100); |
| } |
| return undefined; |
| }); |
| }; |
| |
| export const waitForWithTries = async ( |
| selector: string, root?: puppeteer.JSHandle, options: {tries: number} = { |
| tries: Number.MAX_SAFE_INTEGER, |
| }, |
| asyncScope = new AsyncScope(), handler?: string) => { |
| return await asyncScope.exec(() => waitForFunctionWithTries(async () => { |
| const element = await $(selector, root, handler); |
| return (element || undefined); |
| }, options, asyncScope)); |
| }; |
| |
| export const debuggerStatement = (frontend: puppeteer.Page) => { |
| return frontend.evaluate(() => { |
| // eslint-disable-next-line no-debugger |
| debugger; |
| }); |
| }; |
| |
| export const logToStdOut = (msg: string) => { |
| if (!process.send) { |
| return; |
| } |
| |
| process.send({ |
| pid: process.pid, |
| details: msg, |
| }); |
| }; |
| |
| export const logFailure = () => { |
| if (!process.send) { |
| return; |
| } |
| |
| process.send({ |
| pid: process.pid, |
| details: 'failure', |
| }); |
| }; |
| |
| export const enableExperiment = async ( |
| experiment: string, options: {selectedPanel?: {name: string, selector?: string}, canDock?: boolean} = {}) => { |
| const {frontend} = getBrowserAndPages(); |
| await frontend.evaluate(experiment => { |
| // @ts-ignore |
| globalThis.Root.Runtime.experiments.setEnabled(experiment, true); |
| }, experiment); |
| |
| await reloadDevTools(options); |
| }; |
| |
| export const setDevToolsSettings = async (settings: Record<string, string>) => { |
| const {frontend} = getBrowserAndPages(); |
| await frontend.evaluate(settings => { |
| for (const name in settings) { |
| globalThis.InspectorFrontendHost.setPreference(name, JSON.stringify(settings[name])); |
| } |
| }, settings); |
| await reloadDevTools(); |
| }; |
| |
| export const goTo = async (url: string) => { |
| const {target} = getBrowserAndPages(); |
| await target.goto(url); |
| }; |
| |
| export const overridePermissions = async (permissions: puppeteer.Permission[]) => { |
| const {browser} = getBrowserAndPages(); |
| await browser.defaultBrowserContext().overridePermissions(`https://localhost:${getTestServerPort()}`, permissions); |
| }; |
| |
| export const clearPermissionsOverride = async () => { |
| const {browser} = getBrowserAndPages(); |
| await browser.defaultBrowserContext().clearPermissionOverrides(); |
| }; |
| |
| export const goToResource = async (path: string) => { |
| await goTo(`${getResourcesPath()}/${path}`); |
| }; |
| |
| export const goToResourceWithCustomHost = async (host: string, path: string) => { |
| assert.isTrue(host.endsWith('.test'), 'Only custom hosts with a .test domain are allowed.'); |
| await goTo(`${getResourcesPath(host)}/${path}`); |
| }; |
| |
| export const getResourcesPath = (host: string = 'localhost') => { |
| let resourcesPath = getTestRunnerConfigSetting('hosted-server-e2e-resources-path', '/test/e2e/resources'); |
| if (!resourcesPath.startsWith('/')) { |
| resourcesPath = `/${resourcesPath}`; |
| } |
| return `https://${host}:${getTestServerPort()}${resourcesPath}`; |
| }; |
| |
| export const step = async (description: string, step: Function) => { |
| try { |
| return await step(); |
| } catch (error) { |
| if (error instanceof AssertionError) { |
| throw new AssertionError( |
| `Unexpected Result in Step "${description}" |
| ${error.message}`, |
| error); |
| } else { |
| error.message += ` in Step "${description}"`; |
| throw error; |
| } |
| } |
| }; |
| |
| export const waitForAnimationFrame = async () => { |
| const {frontend} = getBrowserAndPages(); |
| |
| await frontend.waitForFunction(() => { |
| return new Promise(resolve => { |
| requestAnimationFrame(resolve); |
| }); |
| }); |
| }; |
| |
| export const activeElement = async(): Promise<puppeteer.ElementHandle> => { |
| const {frontend} = getBrowserAndPages(); |
| |
| await waitForAnimationFrame(); |
| |
| return frontend.evaluateHandle(() => { |
| let activeElement = document.activeElement; |
| |
| while (activeElement && activeElement.shadowRoot) { |
| activeElement = activeElement.shadowRoot.activeElement; |
| } |
| |
| return activeElement; |
| }); |
| }; |
| |
| export const activeElementTextContent = async () => { |
| const element = await activeElement(); |
| return element.evaluate(node => node.textContent); |
| }; |
| |
| export const activeElementAccessibleName = async () => { |
| const element = await activeElement(); |
| return element.evaluate(node => node.getAttribute('aria-label')); |
| }; |
| |
| export const tabForward = async (page?: puppeteer.Page) => { |
| let targetPage: puppeteer.Page; |
| if (page) { |
| targetPage = page; |
| } else { |
| const {frontend} = getBrowserAndPages(); |
| targetPage = frontend; |
| } |
| |
| await targetPage.keyboard.press('Tab'); |
| }; |
| |
| export const tabBackward = async (page?: puppeteer.Page) => { |
| let targetPage: puppeteer.Page; |
| if (page) { |
| targetPage = page; |
| } else { |
| const {frontend} = getBrowserAndPages(); |
| targetPage = frontend; |
| } |
| |
| await targetPage.keyboard.down('Shift'); |
| await targetPage.keyboard.press('Tab'); |
| await targetPage.keyboard.up('Shift'); |
| }; |
| |
| export const selectTextFromNodeToNode = async ( |
| from: puppeteer.JSHandle|Promise<puppeteer.JSHandle>, to: puppeteer.JSHandle|Promise<puppeteer.JSHandle>, |
| direction: 'up'|'down') => { |
| const {target} = getBrowserAndPages(); |
| |
| // The clipboard api does not allow you to copy, unless the tab is focused. |
| await target.bringToFront(); |
| |
| return target.evaluate(async (from, to, direction) => { |
| const selection = from.getRootNode().getSelection(); |
| const range = document.createRange(); |
| if (direction === 'down') { |
| range.setStartBefore(from); |
| range.setEndAfter(to); |
| } else { |
| range.setStartBefore(to); |
| range.setEndAfter(from); |
| } |
| |
| selection.removeAllRanges(); |
| selection.addRange(range); |
| |
| document.execCommand('copy'); |
| |
| return navigator.clipboard.readText(); |
| }, await from, await to, direction); |
| }; |
| |
| export const closePanelTab = async (panelTabSelector: string) => { |
| // Get close button from tab element |
| const selector = `${panelTabSelector} > .tabbed-pane-close-button`; |
| await click(selector); |
| await waitForNone(selector); |
| }; |
| |
| export const closeAllCloseableTabs = async () => { |
| // get all closeable tools by looking for the available x buttons on tabs |
| const selector = '.tabbed-pane-close-button'; |
| const allCloseButtons = await $$(selector); |
| |
| // Get all panel ids |
| const panelTabIds = await Promise.all(allCloseButtons.map(button => { |
| return button.evaluate(button => button.parentElement ? button.parentElement.id : ''); |
| })); |
| |
| // Close each tab |
| for (const tabId of panelTabIds) { |
| const selector = `#${tabId}`; |
| await closePanelTab(selector); |
| } |
| }; |
| |
| // Noisy! Do not leave this in your test but it may be helpful |
| // when debugging. |
| export const enableCDPLogging = async () => { |
| const {frontend} = getBrowserAndPages(); |
| await frontend.evaluate(() => { |
| globalThis.ProtocolClient.test.dumpProtocol = console.log; // eslint-disable-line no-console |
| }); |
| }; |
| |
| export const enableCDPTracking = async () => { |
| const {frontend} = getBrowserAndPages(); |
| await frontend.evaluate(() => { |
| globalThis.__messageMapForTest = new Map(); |
| globalThis.ProtocolClient.test.onMessageSent = (message: {method: string, id: number}) => { |
| globalThis.__messageMapForTest.set(message.id, message.method); |
| }; |
| globalThis.ProtocolClient.test.onMessageReceived = (message: {id?: number}) => { |
| if (message.id) { |
| globalThis.__messageMapForTest.delete(message.id); |
| } |
| }; |
| }); |
| }; |
| |
| export const logOutstandingCDP = async () => { |
| const {frontend} = getBrowserAndPages(); |
| await frontend.evaluate(() => { |
| for (const entry of globalThis.__messageMapForTest) { |
| console.error(entry); |
| } |
| }); |
| }; |
| |
| export const selectOption = async (select: puppeteer.ElementHandle<HTMLSelectElement>, value: string) => { |
| await select.evaluate(async (node: HTMLSelectElement, _value: string) => { |
| node.value = _value; |
| const event = document.createEvent('HTMLEvents'); |
| event.initEvent('change', false, true); |
| node.dispatchEvent(event); |
| }, value); |
| }; |
| |
| export const scrollElementIntoView = async (selector: string, root?: puppeteer.JSHandle) => { |
| const element = await $(selector, root); |
| |
| if (!element) { |
| throw new Error(`Unable to find element with selector "${selector}"`); |
| } |
| |
| await element.evaluate(el => { |
| el.scrollIntoView(); |
| }); |
| }; |
| |
| export const installEventListener = function(frontend: puppeteer.Page, eventType: string) { |
| return frontend.evaluate(eventType => { |
| if (!('__pendingEvents' in window)) { |
| window.__pendingEvents = new Map(); |
| } |
| window.addEventListener(eventType, (e: Event) => { |
| let events = window.__pendingEvents.get(eventType); |
| if (!events) { |
| events = []; |
| window.__pendingEvents.set(eventType, events); |
| } |
| events.push(e); |
| }); |
| }, eventType); |
| }; |
| |
| export const getPendingEvents = function(frontend: puppeteer.Page, eventType: string): Promise<Event[]|undefined> { |
| return frontend.evaluate(eventType => { |
| if (!('__pendingEvents' in window)) { |
| return undefined; |
| } |
| const pendingEvents = window.__pendingEvents.get(eventType); |
| window.__pendingEvents.set(eventType, []); |
| return pendingEvents; |
| }, eventType); |
| }; |
| |
| export const hasClass = async(element: puppeteer.ElementHandle<Element>, classname: string): Promise<boolean> => { |
| return await element.evaluate((el, classname) => el.classList.contains(classname), classname); |
| }; |
| |
| export const waitForClass = async(element: puppeteer.ElementHandle<Element>, classname: string): Promise<void> => { |
| await waitForFunction(async () => { |
| return hasClass(element, classname); |
| }); |
| }; |
| |
| /** |
| * This is useful to keep TypeScript happy in a test - if you have a value |
| * that's potentially `null` you can use this function to assert that it isn't, |
| * and satisfy TypeScript that the value is present. |
| */ |
| export function assertNotNullOrUndefined<T>(val: T): asserts val is NonNullable<T> { |
| if (val === null || val === undefined) { |
| throw new Error(`Expected given value to not be null/undefined but it was: ${val}`); |
| } |
| } |
| |
| // We export Puppeteer so other test utils can import it from here and not rely |
| // on Node modules resolution to import it. |
| export {getBrowserAndPages, getDevToolsFrontendHostname, getTestServerPort, reloadDevTools, puppeteer}; |
| |
| export function matchString(actual: string, expected: string|RegExp): true|string { |
| if (typeof expected === 'string') { |
| if (actual !== expected) { |
| return `Expected item "${actual}" to equal "${expected}"`; |
| } |
| } else if (!expected.test(actual)) { |
| return `Expected item "${actual}" to match "${expected}"`; |
| } |
| return true; |
| } |
| |
| export function matchArray<A, E>( |
| actual: A[], expected: E[], comparator: (actual: A, expected: E) => true | string): true|string { |
| if (actual.length !== expected.length) { |
| return `Expected [${actual.map(x => `"${x}"`).join(', ')}] to have length ${expected.length}`; |
| } |
| |
| for (let i = 0; i < expected.length; ++i) { |
| const result = comparator(actual[i], expected[i]); |
| if (result !== true) { |
| return `Mismatch in row ${i}: ${result}`; |
| } |
| } |
| return true; |
| } |
| |
| export function assertOk<Args extends unknown[]>(check: (...args: Args) => true | string) { |
| return (...args: Args) => { |
| const result = check(...args); |
| if (result !== true) { |
| throw new AssertionError(result); |
| } |
| }; |
| } |
| |
| export function matchTable<A, E>( |
| actual: A[][], expected: E[][], comparator: (actual: A, expected: E) => true | string) { |
| return matchArray(actual, expected, (actual, expected) => matchArray<A, E>(actual, expected, comparator)); |
| } |
| |
| export const matchStringArray = (actual: string[], expected: (string|RegExp)[]) => |
| matchArray(actual, expected, matchString); |
| |
| export const assertMatchArray = assertOk(matchStringArray); |
| |
| export const matchStringTable = (actual: string[][], expected: (string|RegExp)[][]) => |
| matchTable(actual, expected, matchString); |
| |
| export async function renderCoordinatorQueueEmpty(): Promise<void> { |
| const {frontend} = getBrowserAndPages(); |
| await frontend.evaluate(() => { |
| return new Promise<void>(resolve => { |
| const pendingFrames = globalThis.__getRenderCoordinatorPendingFrames(); |
| if (pendingFrames < 1) { |
| resolve(); |
| return; |
| } |
| globalThis.addEventListener('renderqueueempty', resolve, {once: true}); |
| }); |
| }); |
| } |
| |
| export async function setCheckBox(selector: string, wantChecked: boolean): Promise<void> { |
| const checkbox = await waitFor(selector); |
| const checked = await checkbox.evaluate(box => (box as HTMLInputElement).checked); |
| if (checked !== wantChecked) { |
| await click(`${selector} + label`); |
| } |
| assert.strictEqual(await checkbox.evaluate(box => (box as HTMLInputElement).checked), wantChecked); |
| } |