UNPKG

@ngx-playwright/test

Version:
500 lines (439 loc) 12 kB
import {TestKey, getNoKeysSpecifiedError} from "@ngx-playwright/harness"; import {innerText} from "composed-dom"; /** @typedef {import('@ngx-playwright/harness').ElementDimensions} ElementDimensions */ /** @typedef {import('@ngx-playwright/harness').EventData} EventData */ /** @typedef {import('@ngx-playwright/harness').ModifierKeys} ModifierKeys */ /** @typedef {import('@ngx-playwright/harness').TestElement} TestElement */ /** @typedef {import('@ngx-playwright/harness').TextOptions} TextOptions */ /** @template [T=Node] @typedef {import('@playwright/test').ElementHandle<T>} ElementHandle */ /** @typedef {import('@playwright/test').Locator} Locator */ /** @typedef {import('@playwright/test').Page} Page */ /** @typedef {import('@angular/cdk/testing').TestKey} _AngularTestKey */ /** @typedef {boolean extends _AngularTestKey ? never : _AngularTestKey} AngularTestKey */ import * as contentScripts from "./browser.js"; /** * @type {Map<TestKey, [method: 'type' | 'press', key: string]>} */ const keyMap = new Map([ [TestKey.BACKSPACE, ["press", "Backspace"]], [TestKey.TAB, ["press", "Tab"]], [TestKey.ENTER, ["press", "Enter"]], [TestKey.SHIFT, ["press", "Shift"]], [TestKey.CONTROL, ["press", "Control"]], [TestKey.ALT, ["press", "Alt"]], [TestKey.ESCAPE, ["press", "Escape"]], [TestKey.PAGE_UP, ["press", "PageUp"]], [TestKey.PAGE_DOWN, ["press", "PageDown"]], [TestKey.END, ["press", "End"]], [TestKey.HOME, ["press", "Home"]], [TestKey.LEFT_ARROW, ["press", "ArrowLeft"]], [TestKey.UP_ARROW, ["press", "ArrowUp"]], [TestKey.RIGHT_ARROW, ["press", "ArrowRight"]], [TestKey.DOWN_ARROW, ["press", "ArrowDown"]], [TestKey.INSERT, ["press", "Insert"]], [TestKey.DELETE, ["press", "Delete"]], [TestKey.F1, ["press", "F1"]], [TestKey.F2, ["press", "F2"]], [TestKey.F3, ["press", "F3"]], [TestKey.F4, ["press", "F4"]], [TestKey.F5, ["press", "F5"]], [TestKey.F6, ["press", "F6"]], [TestKey.F7, ["press", "F7"]], [TestKey.F8, ["press", "F8"]], [TestKey.F9, ["press", "F9"]], [TestKey.F10, ["press", "F10"]], [TestKey.F11, ["press", "F11"]], [TestKey.F12, ["press", "F12"]], [TestKey.META, ["press", "Meta"]], [TestKey.COMMA, ["type", ","]], ]); const modifierMapping = /** @type {const} */ ([ ["alt", "Alt"], ["shift", "Shift"], ["meta", "Meta"], ["control", "Control"], ]); /** * @param {ModifierKeys} modifiers */ function getModifiers(modifiers) { return modifierMapping .filter(([modifier]) => modifiers[modifier]) .map(([, modifier]) => modifier); } /** * @param {(string | TestKey | AngularTestKey)[] | [ModifierKeys, ...(string | TestKey | AngularTestKey)[]]} keys * @returns {keys is [ModifierKeys, ...(string | TestKey | AngularTestKey)[]]} */ function hasModifiers(keys) { return typeof keys[0] === "object"; } /** * @template {unknown[]} T * @param {T} args * @returns {args is T & ['center', ...unknown[]]} */ function isCenterClick(args) { return args[0] === "center"; } /** * @param {ClickParameters} args * @returns {args is [number, number, ModifierKeys?]} */ function isPositionedClick(args) { return typeof args[0] === "number"; } /** * @typedef {[ModifierKeys?] | ['center', ModifierKeys?] | [number, number, ModifierKeys?]} ClickParameters */ /** * * @param {ElementHandle<unknown> | Locator} handleOrLocator * @returns {handleOrLocator is Locator} */ export function isLocator(handleOrLocator) { return !("$$" in handleOrLocator); } /** * `TestElement` implementation backed by playwright's `ElementHandle` * * @internal * @implements TestElement */ export class PlaywrightElement { /** * The environment the element is in * * @readonly * @type {import('./environment.js').PlaywrightHarnessEnvironment} */ #environment; /** * Awaits for the angular app to become stable * * This function has to be called after every manipulation and before any query * * @readonly * @type {<T>(fn: (handle: Locator | ElementHandle<HTMLElement | SVGElement>) => Promise<T>) => Promise<T>} */ #query; /** * Awaits for the angular app to become stable * * This function has to be called after every manipulation and before any query * * @readonly * @type {(fn: (handle: Locator | ElementHandle<HTMLElement | SVGElement>) => Promise<void>) => Promise<void>} */ #perform; /** * Execute the given script * * @readonly * @type {Locator['evaluate']} */ #evaluate; /** * @param {import('./environment.js').PlaywrightHarnessEnvironment} environment * @param {ElementHandle<HTMLElement | SVGElement> | Locator} handleOrLocator * @param {() => Promise<void>} whenStable */ constructor(environment, handleOrLocator, whenStable) { this.#environment = environment; this.#query = async (fn) => { await whenStable(); return fn(handleOrLocator); }; this.#perform = async (fn) => { try { return await fn(handleOrLocator); } finally { await whenStable(); } }; this.#evaluate = /** @type {Locator} */ (handleOrLocator).evaluate.bind( handleOrLocator, ); } /** * * @param {ClickParameters} args * @returns {Promise<Parameters<ElementHandle['click']>[0]>} */ #toClickOptions = async (...args) => { /** @type {Parameters<ElementHandle['click']>[0]} */ const clickOptions = {}; /** @type {ModifierKeys | undefined} */ let modifierKeys; if (isCenterClick(args)) { const size = await this.getDimensions(); clickOptions.position = { x: size.width / 2, y: size.height / 2, }; modifierKeys = args[1]; } else if (isPositionedClick(args)) { clickOptions.position = {x: args[0], y: args[1]}; modifierKeys = args[2]; } else { modifierKeys = args[0]; } if (modifierKeys) { clickOptions.modifiers = getModifiers(modifierKeys); } return clickOptions; }; /** * @returns {Promise<void>} */ blur() { // Playwright exposes a `focus` function but no `blur` function, so we have // to resort to executing a function ourselves. return this.#perform(() => this.#evaluate(contentScripts.blur)); } /** * @returns {Promise<void>} */ clear() { return this.#perform((handle) => handle.fill("")); } /** * @param {ClickParameters} args * @returns {Promise<void>} */ click(...args) { return this.#perform(async (handle) => handle.click(await this.#toClickOptions(...args)), ); } /** * @param {ClickParameters} args * @returns {Promise<void>} */ rightClick(...args) { return this.#perform(async (handle) => handle.click({ ...(await this.#toClickOptions(...args)), button: "right", }), ); } /** * @param {string} name * @param {Record<string, EventData>=} data * @returns {Promise<void>} */ dispatchEvent(name, data) { // ElementHandle#dispatchEvent executes the equivalent of // `element.dispatchEvent(new CustomEvent(name, {detail: data}))` // which doesn't match what angular wants: `data` are properties to be // placed on the event directly rather than on the `details` property return this.#perform(() => // Cast to `any` needed because of infinite type instantiation this.#evaluate( contentScripts.dispatchEvent, /** @type {[string, any]} */ ([name, data]), ), ); } /** * @returns {Promise<void>} */ focus() { return this.#perform((handle) => handle.focus()); } /** * @param {string} property * @returns {Promise<string>} */ async getCssValue(property) { return this.#query(() => this.#evaluate(contentScripts.getStyleProperty, property), ); } /** * @returns {Promise<void>} */ async hover() { return this.#perform((handle) => handle.hover()); } /** * @returns {Promise<void>} */ async mouseAway() { const {left, top} = await this.#query(async (handle) => { let {left, top} = await this.#evaluate( contentScripts.getBoundingClientRect, ); if (left < 0 && top < 0) { await handle.scrollIntoViewIfNeeded(); ({left, top} = await this.#evaluate( contentScripts.getBoundingClientRect, )); } return {left, top}; }); return this.#perform(() => this.#environment.page.mouse.move( Math.max(0, left - 1), Math.max(0, top - 1), ), ); } /** * * @param {...number} optionIndexes * @returns {Promise<void>} */ selectOptions(...optionIndexes) { // ElementHandle#selectOption supports selecting multiple options at once, // but that triggers only one change event. // So we select options as if we're a user: one at a time return this.#perform(async (handle) => { /** @type {{index: number}[]} */ const selections = []; for (const index of optionIndexes) { selections.push({index}); await handle.selectOption(selections); } }); } /** * * @param {(string | TestKey | AngularTestKey)[] | [ModifierKeys, ...(string | TestKey | AngularTestKey)[]]} input * @returns {Promise<void>} */ sendKeys(...input) { return this.#perform(async (handle) => { /** @type {string | undefined} */ let modifiers; let keys; if (hasModifiers(input)) { /** @type {ModifierKeys} */ let modifiersObject; [modifiersObject, ...keys] = input; modifiers = getModifiers(modifiersObject).join("+"); } else { keys = input; } if (!keys.some((key) => key !== "")) { throw getNoKeysSpecifiedError(); } await handle.focus(); const {keyboard} = this.#environment.page; if (modifiers) { await keyboard.down(modifiers); } try { for (const key of /** @type {(string | TestKey)[]} */ (keys)) { if (typeof key === "string") { await keyboard.type(key); } else if (keyMap.has(key)) { const [method, argument] = /** @type {[method: "type" | "press", key: string]} */ ( keyMap.get(key) ); await keyboard[method](argument); } else { throw new Error(`Unknown key: ${TestKey[key] ?? key}`); } } } finally { if (modifiers) { await keyboard.up(modifiers); } } }); } /** * @param {string} value * @returns {Promise<void>} */ setInputValue(value) { return this.#perform((handle) => handle.fill(value)); } /** * @param {TextOptions=} options * @returns {Promise<string>} */ text(options) { return this.#query((handle) => { if (this.#environment.innerTextWithShadows) { return this.#evaluate(innerText, options?.exclude); } if (options?.exclude) { return this.#evaluate( contentScripts.nativeInnerTextWithExcludedElements, options.exclude, ); } return handle.innerText(); }); } /** * @param {string} value * @returns {Promise<void>} */ setContenteditableValue(value) { return this.#perform(() => this.#evaluate(contentScripts.setContenteditableValue, value), ); } /** * @param {string} name * @returns {Promise<string | null>} */ getAttribute(name) { return this.#query((handle) => handle.getAttribute(name)); } /** * @param {string} name * @returns {Promise<boolean>} */ async hasClass(name) { const classes = (await this.#query((handle) => handle.getAttribute("class")))?.split( /\s+/, ) ?? []; return classes.includes(name); } /** * @returns {Promise<ElementDimensions>} */ async getDimensions() { return this.#query(() => this.#evaluate(contentScripts.getBoundingClientRect), ); } /** * @param {string} name * @returns {Promise<any>} */ async getProperty(name) { const property = await this.#query(async (handle) => { if (isLocator(handle)) { return handle.evaluateHandle(contentScripts.getProperty, name); } else { return handle.getProperty(name); } }); try { return await property.jsonValue(); } finally { await property.dispose(); } } /** * @param {string} selector * @returns {Promise<boolean>} */ async matchesSelector(selector) { return this.#query(() => this.#evaluate(contentScripts.matches, selector)); } /** * @returns {Promise<boolean>} */ async isFocused() { return this.matchesSelector(":focus"); } }