UNPKG

chrome-devtools-frontend

Version:
1,509 lines (1,494 loc) • 64.1 kB
/** * @license * Copyright 2022 Google LLC * SPDX-License-Identifier: Apache-2.0 */ var SelectorType; (function (SelectorType) { SelectorType["CSS"] = "css"; SelectorType["ARIA"] = "aria"; SelectorType["Text"] = "text"; SelectorType["XPath"] = "xpath"; SelectorType["Pierce"] = "pierce"; })(SelectorType || (SelectorType = {})); var StepType; (function (StepType) { StepType["Change"] = "change"; StepType["Click"] = "click"; StepType["Close"] = "close"; StepType["CustomStep"] = "customStep"; StepType["DoubleClick"] = "doubleClick"; StepType["EmulateNetworkConditions"] = "emulateNetworkConditions"; StepType["Hover"] = "hover"; StepType["KeyDown"] = "keyDown"; StepType["KeyUp"] = "keyUp"; StepType["Navigate"] = "navigate"; StepType["Scroll"] = "scroll"; StepType["SetViewport"] = "setViewport"; StepType["WaitForElement"] = "waitForElement"; StepType["WaitForExpression"] = "waitForExpression"; })(StepType || (StepType = {})); var AssertedEventType; (function (AssertedEventType) { AssertedEventType["Navigation"] = "navigation"; })(AssertedEventType || (AssertedEventType = {})); var Schema = /*#__PURE__*/Object.freeze({ __proto__: null, get AssertedEventType () { return AssertedEventType; }, get SelectorType () { return SelectorType; }, get StepType () { return StepType; } }); /** * @license * Copyright 2022 Google LLC * SPDX-License-Identifier: Apache-2.0 */ function assertAllStepTypesAreHandled(s) { throw new Error(`Unknown step type: ${s.type}`); } const typeableInputTypes = new Set([ 'textarea', 'text', 'url', 'tel', 'search', 'password', 'number', 'email', ]); const pointerDeviceTypes = new Set([ 'mouse', 'pen', 'touch', ]); const mouseButtonMap = new Map([ ['primary', 'left'], ['auxiliary', 'middle'], ['secondary', 'right'], ['back', 'back'], ['forward', 'forward'], ]); function hasProperty(data, prop) { // TODO: use Object.hasOwn once types are available https://github.com/microsoft/TypeScript/issues/44253 if (!Object.prototype.hasOwnProperty.call(data, prop)) { return false; } const keyedData = data; return keyedData[prop] !== undefined; } function isObject(data) { return typeof data === 'object' && data !== null; } function isString(data) { return typeof data === 'string'; } function isNumber(data) { return typeof data === 'number'; } function isArray(data) { return Array.isArray(data); } function isBoolean(data) { return typeof data === 'boolean'; } function isIntegerArray(data) { return isArray(data) && data.every((item) => Number.isInteger(item)); } function isKnownDeviceType(data) { return typeof data === 'string' && pointerDeviceTypes.has(data); } function isKnownMouseButton(data) { return typeof data === 'string' && mouseButtonMap.has(data); } function parseTarget(step) { if (hasProperty(step, 'target') && isString(step.target)) { return step.target; } return undefined; } function parseFrame(step) { if (hasProperty(step, 'frame')) { if (isIntegerArray(step.frame)) { return step.frame; } throw new Error('Step `frame` is not an integer array'); } return undefined; } function parseNumber(step, prop) { if (hasProperty(step, prop)) { const maybeNumber = step[prop]; if (isNumber(maybeNumber)) { return maybeNumber; } } throw new Error(`Step.${prop} is not a number`); } function parseBoolean(step, prop) { if (hasProperty(step, prop)) { const maybeBoolean = step[prop]; if (isBoolean(maybeBoolean)) { return maybeBoolean; } } throw new Error(`Step.${prop} is not a boolean`); } function parseOptionalNumber(step, prop) { if (hasProperty(step, prop)) { return parseNumber(step, prop); } return undefined; } function parseOptionalString(step, prop) { if (hasProperty(step, prop)) { return parseString(step, prop); } return undefined; } function parseOptionalBoolean(step, prop) { if (hasProperty(step, prop)) { return parseBoolean(step, prop); } return undefined; } function parseString(step, prop) { if (hasProperty(step, prop)) { const maybeString = step[prop]; if (isString(maybeString)) { return maybeString; } } throw new Error(`Step.${prop} is not a string`); } function parseSelectors(step) { if (!hasProperty(step, 'selectors')) { throw new Error('Step does not have required selectors'); } if (!isArray(step.selectors)) { throw new Error('Step selectors are not an array'); } if (step.selectors.length === 0) { throw new Error('Step does not have required selectors'); } return step.selectors.map((s) => { if (!isString(s) && !isArray(s)) { throw new Error('Selector is not an array or string'); } if (isArray(s)) { return s.map((sub) => { if (!isString(sub)) { throw new Error('Selector element is not a string'); } return sub; }); } return s; }); } function parseOptionalSelectors(step) { if (!hasProperty(step, 'selectors')) { return undefined; } return parseSelectors(step); } function parseAssertedEvent(event) { if (!isObject(event)) { throw new Error('Asserted event is not an object'); } if (!hasProperty(event, 'type')) { throw new Error('Asserted event is missing type'); } if (event.type === AssertedEventType.Navigation) { return { type: AssertedEventType.Navigation, url: parseOptionalString(event, 'url'), title: parseOptionalString(event, 'title'), }; } throw new Error('Unknown assertedEvent type'); } function parseAssertedEvents(events) { if (!isArray(events)) { return undefined; } return events.map(parseAssertedEvent); } function parseBaseStep(type, step) { if (hasProperty(step, 'timeout') && isNumber(step.timeout) && !validTimeout(step.timeout)) { throw new Error(timeoutErrorMessage); } return { type, assertedEvents: hasProperty(step, 'assertedEvents') ? parseAssertedEvents(step.assertedEvents) : undefined, timeout: hasProperty(step, 'timeout') && isNumber(step.timeout) ? step.timeout : undefined, }; } function parseStepWithTarget(type, step) { return { ...parseBaseStep(type, step), target: parseTarget(step), }; } function parseStepWithFrame(type, step) { return { ...parseStepWithTarget(type, step), frame: parseFrame(step), }; } function parseStepWithSelectors(type, step) { return { ...parseStepWithFrame(type, step), selectors: parseSelectors(step), }; } function parseClickAttributes(step) { const attributes = { offsetX: parseNumber(step, 'offsetX'), offsetY: parseNumber(step, 'offsetY'), duration: parseOptionalNumber(step, 'duration'), }; const deviceType = parseOptionalString(step, 'deviceType'); if (deviceType) { if (!isKnownDeviceType(deviceType)) { throw new Error(`'deviceType' for click steps must be one of the following: ${[ ...pointerDeviceTypes, ].join(', ')}`); } attributes.deviceType = deviceType; } const button = parseOptionalString(step, 'button'); if (button) { if (!isKnownMouseButton(button)) { throw new Error(`'button' for click steps must be one of the following: ${[ ...mouseButtonMap.keys(), ].join(', ')}`); } attributes.button = button; } return attributes; } function parseClickStep(step) { return { ...parseStepWithSelectors(StepType.Click, step), ...parseClickAttributes(step), type: StepType.Click, }; } function parseDoubleClickStep(step) { return { ...parseStepWithSelectors(StepType.DoubleClick, step), ...parseClickAttributes(step), type: StepType.DoubleClick, }; } function parseHoverStep(step) { return { ...parseStepWithSelectors(StepType.Hover, step), type: StepType.Hover, }; } function parseChangeStep(step) { return { ...parseStepWithSelectors(StepType.Change, step), type: StepType.Change, value: parseString(step, 'value'), }; } function parseKeyDownStep(step) { return { ...parseStepWithTarget(StepType.KeyDown, step), type: StepType.KeyDown, // TODO: type-check keys. key: parseString(step, 'key'), }; } function parseKeyUpStep(step) { return { ...parseStepWithTarget(StepType.KeyUp, step), type: StepType.KeyUp, // TODO: type-check keys. key: parseString(step, 'key'), }; } function parseEmulateNetworkConditionsStep(step) { return { ...parseStepWithTarget(StepType.EmulateNetworkConditions, step), type: StepType.EmulateNetworkConditions, download: parseNumber(step, 'download'), upload: parseNumber(step, 'upload'), latency: parseNumber(step, 'latency'), }; } function parseCloseStep(step) { return { ...parseStepWithTarget(StepType.Close, step), type: StepType.Close, }; } function parseSetViewportStep(step) { return { ...parseStepWithTarget(StepType.SetViewport, step), type: StepType.SetViewport, width: parseNumber(step, 'width'), height: parseNumber(step, 'height'), deviceScaleFactor: parseNumber(step, 'deviceScaleFactor'), isMobile: parseBoolean(step, 'isMobile'), hasTouch: parseBoolean(step, 'hasTouch'), isLandscape: parseBoolean(step, 'isLandscape'), }; } function parseScrollStep(step) { return { ...parseStepWithFrame(StepType.Scroll, step), type: StepType.Scroll, x: parseOptionalNumber(step, 'x'), y: parseOptionalNumber(step, 'y'), selectors: parseOptionalSelectors(step), }; } function parseNavigateStep(step) { return { ...parseStepWithTarget(StepType.Navigate, step), type: StepType.Navigate, target: parseTarget(step), url: parseString(step, 'url'), }; } function parseWaitForElementStep(step) { const operator = parseOptionalString(step, 'operator'); if (operator && operator !== '>=' && operator !== '==' && operator !== '<=') { throw new Error("WaitForElement step's operator is not one of '>=','==','<='"); } if (hasProperty(step, 'attributes')) { if (!isObject(step.attributes) || Object.values(step.attributes).some((attribute) => typeof attribute !== 'string')) { throw new Error("WaitForElement step's attribute is not a dictionary of strings"); } } if (hasProperty(step, 'properties')) { if (!isObject(step.properties)) { throw new Error("WaitForElement step's attribute is not an object"); } } return { ...parseStepWithSelectors(StepType.WaitForElement, step), type: StepType.WaitForElement, operator: operator, count: parseOptionalNumber(step, 'count'), visible: parseOptionalBoolean(step, 'visible'), attributes: hasProperty(step, 'attributes') ? step.attributes : undefined, properties: hasProperty(step, 'properties') ? step.properties : undefined, }; } function parseWaitForExpressionStep(step) { if (!hasProperty(step, 'expression')) { throw new Error('waitForExpression step is missing `expression`'); } return { ...parseStepWithFrame(StepType.WaitForExpression, step), type: StepType.WaitForExpression, expression: parseString(step, 'expression'), }; } function parseCustomStep(step) { if (!hasProperty(step, 'name')) { throw new Error('customStep is missing name'); } if (!isString(step.name)) { throw new Error("customStep's name is not a string"); } return { ...parseStepWithFrame(StepType.CustomStep, step), type: StepType.CustomStep, name: step.name, parameters: hasProperty(step, 'parameters') ? step.parameters : undefined, }; } function parseStep(step, idx) { if (!isObject(step)) { throw new Error(idx ? `Step ${idx} is not an object` : 'Step is not an object'); } if (!hasProperty(step, 'type')) { throw new Error(idx ? `Step ${idx} does not have a type` : 'Step does not have a type'); } if (!isString(step.type)) { throw new Error(idx ? `Type of the step ${idx} is not a string` : 'Type of the step is not a string'); } switch (step.type) { case StepType.Click: return parseClickStep(step); case StepType.DoubleClick: return parseDoubleClickStep(step); case StepType.Hover: return parseHoverStep(step); case StepType.Change: return parseChangeStep(step); case StepType.KeyDown: return parseKeyDownStep(step); case StepType.KeyUp: return parseKeyUpStep(step); case StepType.EmulateNetworkConditions: return parseEmulateNetworkConditionsStep(step); case StepType.Close: return parseCloseStep(step); case StepType.SetViewport: return parseSetViewportStep(step); case StepType.Scroll: return parseScrollStep(step); case StepType.Navigate: return parseNavigateStep(step); case StepType.CustomStep: return parseCustomStep(step); case StepType.WaitForElement: return parseWaitForElementStep(step); case StepType.WaitForExpression: return parseWaitForExpressionStep(step); default: throw new Error(`Step type ${step.type} is not supported`); } } function parseSteps(steps) { const result = []; if (!isArray(steps)) { throw new Error('Recording `steps` is not an array'); } for (const [idx, step] of steps.entries()) { result.push(parseStep(step, idx)); } return result; } function cleanUndefined(json) { return JSON.parse(JSON.stringify(json)); } const minTimeout = 1; const maxTimeout = 30000; const timeoutErrorMessage = `Timeout is not between ${minTimeout} and ${maxTimeout} milliseconds`; function validTimeout(timeout) { return timeout >= minTimeout && timeout <= maxTimeout; } function parse(data) { if (!isObject(data)) { throw new Error('Recording is not an object'); } if (!hasProperty(data, 'title')) { throw new Error('Recording is missing `title`'); } if (!isString(data.title)) { throw new Error('Recording `title` is not a string'); } if (hasProperty(data, 'timeout') && !isNumber(data.timeout)) { throw new Error('Recording `timeout` is not a number'); } if (!hasProperty(data, 'steps')) { throw new Error('Recording is missing `steps`'); } if (hasProperty(data, 'timeout') && isNumber(data.timeout) && !validTimeout(data.timeout)) { throw new Error(timeoutErrorMessage); } return cleanUndefined({ title: data.title, timeout: hasProperty(data, 'timeout') && isNumber(data.timeout) ? data.timeout : undefined, selectorAttribute: hasProperty(data, 'selectorAttribute') && isString(data.selectorAttribute) ? data.selectorAttribute : undefined, steps: parseSteps(data.steps), }); } /** * Detects what type of a selector the string contains. For example, * `aria/Label` is a SelectorType.ARIA. * * Note that CSS selectors are special and usually don't require a prefix, * therefore, SelectorType.CSS is the default type if other types didn't match. */ function getSelectorType(selector) { for (const value of Object.values(SelectorType)) { if (selector.startsWith(`${value}/`)) { return value; } } return SelectorType.CSS; } /** * Converts a selector or an array of selector parts into a Puppeteer selector. * * @see https://pptr.dev/guides/query-selectors#p-elements */ function selectorToPElementSelector(selector) { if (!Array.isArray(selector)) { selector = [selector]; } function escape(input) { return input.replace(/['"()]/g, `\\$&`); } const result = selector.map((s) => { switch (getSelectorType(s)) { case SelectorType.ARIA: return `::-p-aria(${escape(s.substring(SelectorType.ARIA.length + 1))})`; case SelectorType.CSS: return s; case SelectorType.XPath: return `::-p-xpath(${escape(s.substring(SelectorType.XPath.length + 1))})`; case SelectorType.Pierce: return `:scope >>> ${s.substring(SelectorType.Pierce.length + 1)}`; case SelectorType.Text: return `::-p-text(${escape(s.substring(SelectorType.Text.length + 1))})`; } }); return result.join(' >>>> '); } /** * @license * Copyright 2022 Google LLC * SPDX-License-Identifier: Apache-2.0 */ class StringifyExtension { async beforeAllSteps(out, flow) { } async afterAllSteps(out, flow) { } async beforeEachStep(out, step, flow) { } async stringifyStep(out, step, flow) { } async afterEachStep(out, step, flow) { } } /** * @license * Copyright 2022 Google LLC * SPDX-License-Identifier: Apache-2.0 */ /** * Stringifies a user flow to JSON with source maps. * * You probably want to strip the source map because not all * parsers support comments in JSON. */ class JSONStringifyExtension extends StringifyExtension { async beforeAllSteps(out, flow) { const copy = { ...flow, steps: undefined, }; // Stringify top-level attributes. const text = JSON.stringify(copy, null, out.getIndent()); const lines = text.split('\n'); lines.pop(); lines[lines.length - 1] += ','; lines.push(out.getIndent() + `"steps": [`); out.appendLine(lines.join('\n')).startBlock().startBlock(); } async afterAllSteps(out) { out .endBlock() .endBlock() .appendLine(out.getIndent() + `]`) .appendLine('}'); } async stringifyStep(out, step, flow) { const stepText = JSON.stringify(step, null, out.getIndent()); if (!flow) { out.appendLine(stepText); return; } const separator = flow.steps.lastIndexOf(step) === flow.steps.length - 1 ? '' : ','; out.appendLine(stepText + separator); } } /** * @license * Copyright 2022 Google LLC * SPDX-License-Identifier: Apache-2.0 */ class InMemoryLineWriter { #indentation; #currentIndentation = 0; #lines = []; constructor(indentation) { this.#indentation = indentation; } appendLine(line) { const lines = line.split('\n').map((line) => { const indentedLine = line ? this.#indentation.repeat(this.#currentIndentation) + line.trimEnd() : ''; return indentedLine; }); this.#lines.push(...lines); return this; } startBlock() { this.#currentIndentation++; return this; } endBlock() { this.#currentIndentation--; if (this.#currentIndentation < 0) { throw new Error('Extra endBlock'); } return this; } toString() { // Scripts should end with a final blank line. return this.#lines.join('\n') + '\n'; } getIndent() { return this.#indentation; } getSize() { return this.#lines.length; } } /** * @license * Copyright 2022 Google LLC * SPDX-License-Identifier: Apache-2.0 */ /** * Copyright (c) 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. */ function formatJSONAsJS(json, indent) { const buffer = []; format(json, buffer, 1, indent); return buffer.join(''); } function format(json, buffer = [], level = 1, indent = ' ') { switch (typeof json) { case 'bigint': case 'symbol': case 'function': case 'undefined': throw new Error('Invalid JSON'); case 'number': case 'boolean': buffer.push(String(json)); break; case 'string': buffer.push(formatAsJSLiteral(json)); break; case 'object': { if (json === null) { buffer.push('null'); } else if (Array.isArray(json)) { buffer.push('[\n'); for (let i = 0; i < json.length; i++) { buffer.push(indent.repeat(level)); format(json[i], buffer, level + 1, indent); if (i !== json.length - 1) { buffer.push(','); } buffer.push('\n'); } buffer.push(indent.repeat(level - 1) + ']'); } else { buffer.push('{\n'); const keys = Object.keys(json); for (let i = 0; i < keys.length; i++) { const key = keys[i]; const value = json[key]; if (value === undefined) { continue; } buffer.push(indent.repeat(level)); buffer.push(key); buffer.push(': '); format(value, buffer, level + 1, indent); if (i !== keys.length - 1) { buffer.push(','); } buffer.push('\n'); } buffer.push(indent.repeat(level - 1) + '}'); } break; } default: throw new Error('Unknown object type'); } return buffer; } // Taken from https://source.chromium.org/chromium/chromium/src/+/main:third_party/devtools-frontend/src/front_end/core/platform/string-utilities.ts;l=29;drc=111134437ee51d74433829bed0088f7239e18867. const toHexadecimal = (charCode, padToLength) => { return charCode.toString(16).toUpperCase().padStart(padToLength, '0'); }; // Remember to update the third group in the regexps patternsToEscape and // patternsToEscapePlusSingleQuote when adding new entries in this map. const escapedReplacements = new Map([ ['\b', '\\b'], ['\f', '\\f'], ['\n', '\\n'], ['\r', '\\r'], ['\t', '\\t'], ['\v', '\\v'], ["'", "\\'"], ['\\', '\\\\'], ['<!--', '\\x3C!--'], ['<script', '\\x3Cscript'], ['</script', '\\x3C/script'], ]); const formatAsJSLiteral = (content) => { const patternsToEscape = /(\\|<(?:!--|\/?script))|(\p{Control})|(\p{Surrogate})/gu; const patternsToEscapePlusSingleQuote = /(\\|'|<(?:!--|\/?script))|(\p{Control})|(\p{Surrogate})/gu; const escapePattern = (match, pattern, controlChar, loneSurrogate) => { if (controlChar) { if (escapedReplacements.has(controlChar)) { // @ts-ignore https://github.com/microsoft/TypeScript/issues/13086 return escapedReplacements.get(controlChar); } const twoDigitHex = toHexadecimal(controlChar.charCodeAt(0), 2); return '\\x' + twoDigitHex; } if (loneSurrogate) { const fourDigitHex = toHexadecimal(loneSurrogate.charCodeAt(0), 4); return '\\u' + fourDigitHex; } if (pattern) { return escapedReplacements.get(pattern) || ''; } return match; }; let escapedContent = ''; let quote = ''; if (!content.includes("'")) { quote = "'"; escapedContent = content.replace(patternsToEscape, escapePattern); } else if (!content.includes('"')) { quote = '"'; escapedContent = content.replace(patternsToEscape, escapePattern); } else if (!content.includes('`') && !content.includes('${')) { quote = '`'; escapedContent = content.replace(patternsToEscape, escapePattern); } else { quote = "'"; escapedContent = content.replace(patternsToEscapePlusSingleQuote, escapePattern); } return `${quote}${escapedContent}${quote}`; }; /** * @license * Copyright 2022 Google LLC * SPDX-License-Identifier: Apache-2.0 */ class PuppeteerStringifyExtension extends StringifyExtension { #shouldAppendWaitForElementHelper = false; #targetBrowser; constructor(targetBrowser = 'chrome') { super(); this.#targetBrowser = targetBrowser; } async beforeAllSteps(out, flow) { out.appendLine("import { Locator, launch } from 'puppeteer'; // v25.0.0 or later"); out.appendLine(''); if (this.#targetBrowser === 'firefox') { out.appendLine(`const browser = await launch({browser: 'firefox'});`); } else { out.appendLine('const browser = await launch();'); } out.appendLine('const page = await browser.newPage();'); out.appendLine(`const timeout = ${flow.timeout || defaultTimeout};`); out.appendLine('page.setDefaultTimeout(timeout);'); out.appendLine(''); this.#shouldAppendWaitForElementHelper = false; } async afterAllSteps(out, flow) { out.appendLine(''); out.appendLine('await browser.close();'); out.appendLine(''); if (this.#shouldAppendWaitForElementHelper) { for (const line of waitForElementHelper.split('\n')) { out.appendLine(line); } } } async stringifyStep(out, step, flow) { out.appendLine('{').startBlock(); if (step.timeout !== undefined) { out.appendLine(`const timeout = ${step.timeout};`); } this.#appendContext(out, step); const waitForEvents = step.assertedEvents && step.type !== StepType.Navigate; if (waitForEvents) { out.appendLine('const promises = [];'); out.appendLine('const startWaitingForEvents = () => {').startBlock(); for (const event of step.assertedEvents) { switch (event.type) { case AssertedEventType.Navigation: { out.appendLine(`promises.push(${'frame' in step && step.frame ? 'frame' : 'targetPage'}.waitForNavigation());`); break; } default: throw new Error(`Event type ${event.type} is not supported`); } } out.endBlock().appendLine('}'); } this.#appendStepType(out, step); if (waitForEvents) { out.appendLine('await Promise.all(promises);'); } out.endBlock().appendLine('}'); } #appendTarget(out, target) { if (target === 'main') { out.appendLine('const targetPage = page;'); } else { out.appendLine(`const target = await browser.waitForTarget(t => t.url() === ${formatJSONAsJS(target, out.getIndent())}, { timeout });`); out.appendLine('const targetPage = await target.page();'); out.appendLine('targetPage.setDefaultTimeout(timeout);'); } } #appendFrame(out, path) { out.appendLine('let frame = targetPage.mainFrame();'); for (const index of path) { out.appendLine(`frame = frame.childFrames()[${index}];`); } } #appendContext(out, step) { // TODO fix optional target: should it be main? this.#appendTarget(out, step.target || 'main'); // TODO fix optional frame: should it be required? if (step.frame) { this.#appendFrame(out, step.frame); } } #appendLocators(out, step, action) { out.appendLine('await Locator.race([').startBlock(); out.appendLine(step.selectors .map((s) => { return `${step.frame ? 'frame' : 'targetPage'}.locator(${formatJSONAsJS(selectorToPElementSelector(s), out.getIndent())})`; }) .join(',\n')); out.endBlock().appendLine('])'); out.startBlock().appendLine('.setTimeout(timeout)'); if (step.assertedEvents?.length) { out.appendLine(`.on('action', () => startWaitingForEvents())`); } action(); out.endBlock(); } #appendClickStep(out, step) { this.#appendLocators(out, step, () => { out.appendLine('.click({'); if (step.duration) { out.appendLine(` delay: ${step.duration},`); } if (step.button) { out.appendLine(` button: '${mouseButtonMap.get(step.button)}',`); } out.appendLine(' offset: {'); out.appendLine(` x: ${step.offsetX},`); out.appendLine(` y: ${step.offsetY},`); out.appendLine(' },'); out.appendLine('});'); }); } #appendDoubleClickStep(out, step) { this.#appendLocators(out, step, () => { out.appendLine('.click({'); out.appendLine(` count: 2,`); if (step.duration) { out.appendLine(` delay: ${step.duration},`); } if (step.button) { out.appendLine(` button: '${mouseButtonMap.get(step.button)}',`); } out.appendLine(' offset: {'); out.appendLine(` x: ${step.offsetX},`); out.appendLine(` y: ${step.offsetY},`); out.appendLine(' },'); out.appendLine('});'); }); } #appendHoverStep(out, step) { this.#appendLocators(out, step, () => { out.appendLine('.hover();'); }); } #appendChangeStep(out, step) { this.#appendLocators(out, step, () => { out.appendLine(`.fill(${formatJSONAsJS(step.value, out.getIndent())});`); }); } #appendEmulateNetworkConditionsStep(out, step) { out.appendLine('await targetPage.emulateNetworkConditions({'); out.appendLine(` offline: ${!step.download && !step.upload},`); out.appendLine(` downloadThroughput: ${step.download},`); out.appendLine(` uploadThroughput: ${step.upload},`); out.appendLine(` latency: ${step.latency},`); out.appendLine('});'); } #appendKeyDownStep(out, step) { out.appendLine(`await targetPage.keyboard.down(${formatJSONAsJS(step.key, out.getIndent())});`); } #appendKeyUpStep(out, step) { out.appendLine(`await targetPage.keyboard.up(${formatJSONAsJS(step.key, out.getIndent())});`); } #appendCloseStep(out, step) { out.appendLine('await targetPage.close()'); } #appendViewportStep(out, step) { out.appendLine(`await targetPage.setViewport(${formatJSONAsJS({ width: step.width, height: step.height, }, out.getIndent())})`); } #appendScrollStep(out, step) { if ('selectors' in step) { this.#appendLocators(out, step, () => { out.appendLine(`.scroll({ scrollTop: ${step.y}, scrollLeft: ${step.x}});`); }); } else { out.appendLine(`await targetPage.evaluate((x, y) => { window.scroll(x, y); }, ${step.x}, ${step.y})`); } } #appendStepType(out, step) { switch (step.type) { case StepType.Click: return this.#appendClickStep(out, step); case StepType.DoubleClick: return this.#appendDoubleClickStep(out, step); case StepType.Hover: return this.#appendHoverStep(out, step); case StepType.Change: return this.#appendChangeStep(out, step); case StepType.EmulateNetworkConditions: return this.#appendEmulateNetworkConditionsStep(out, step); case StepType.KeyDown: return this.#appendKeyDownStep(out, step); case StepType.KeyUp: return this.#appendKeyUpStep(out, step); case StepType.Close: return this.#appendCloseStep(out, step); case StepType.SetViewport: return this.#appendViewportStep(out, step); case StepType.Scroll: return this.#appendScrollStep(out, step); case StepType.Navigate: return this.#appendNavigationStep(out, step); case StepType.WaitForElement: return this.#appendWaitForElementStep(out, step); case StepType.WaitForExpression: return this.#appendWaitExpressionStep(out, step); case StepType.CustomStep: return; // TODO: implement these default: return assertAllStepTypesAreHandled(step); } } #appendNavigationStep(out, step) { out.appendLine(`await targetPage.goto(${formatJSONAsJS(step.url, out.getIndent())});`); } #appendWaitExpressionStep(out, step) { out.appendLine(`await ${step.frame ? 'frame' : 'targetPage'}.waitForFunction(${formatJSONAsJS(step.expression, out.getIndent())}, { timeout });`); } #appendWaitForElementStep(out, step) { this.#shouldAppendWaitForElementHelper = true; out.appendLine(`await waitForElement(${formatJSONAsJS(step, out.getIndent())}, ${step.frame ? 'frame' : 'targetPage'}, timeout);`); } } const defaultTimeout = 5000; const waitForElementHelper = `async function waitForElement(step, frame, timeout) { const { count = 1, operator = '>=', visible = true, properties, attributes, } = step; const compFn = { '==': (a, b) => a === b, '>=': (a, b) => a >= b, '<=': (a, b) => a <= b, }[operator]; await waitForFunction(async () => { const elements = await querySelectorsAll(step.selectors, frame); let result = compFn(elements.length, count); const elementsHandle = await frame.evaluateHandle((...elements) => { return elements; }, ...elements); await Promise.all(elements.map((element) => element.dispose())); if (result && (properties || attributes)) { result = await elementsHandle.evaluate( (elements, properties, attributes) => { for (const element of elements) { if (attributes) { for (const [name, value] of Object.entries(attributes)) { if (element.getAttribute(name) !== value) { return false; } } } if (properties) { if (!isDeepMatch(properties, element)) { return false; } } } return true; function isDeepMatch(a, b) { if (a === b) { return true; } if ((a && !b) || (!a && b)) { return false; } if (!(a instanceof Object) || !(b instanceof Object)) { return false; } for (const [key, value] of Object.entries(a)) { if (!isDeepMatch(value, b[key])) { return false; } } return true; } }, properties, attributes ); } await elementsHandle.dispose(); return result === visible; }, timeout); } async function querySelectorsAll(selectors, frame) { for (const selector of selectors) { const result = await querySelectorAll(selector, frame); if (result.length) { return result; } } return []; } async function querySelectorAll(selector, frame) { if (!Array.isArray(selector)) { selector = [selector]; } if (!selector.length) { throw new Error('Empty selector provided to querySelectorAll'); } let elements = []; for (let i = 0; i < selector.length; i++) { const part = selector[i]; if (i === 0) { elements = await frame.$$(part); } else { const tmpElements = elements; elements = []; for (const el of tmpElements) { elements.push(...(await el.$$(part))); } } if (elements.length === 0) { return []; } if (i < selector.length - 1) { const tmpElements = []; for (const el of elements) { const newEl = (await el.evaluateHandle(el => el.shadowRoot ? el.shadowRoot : el)).asElement(); if (newEl) { tmpElements.push(newEl); } } elements = tmpElements; } } return elements; } async function waitForFunction(fn, timeout) { let isActive = true; const timeoutId = setTimeout(() => { isActive = false; }, timeout); while (isActive) { const result = await fn(); if (result) { clearTimeout(timeoutId); return; } await new Promise(resolve => setTimeout(resolve, 100)); } throw new Error('Timed out'); }`; /** * @license * Copyright 2022 Google LLC * SPDX-License-Identifier: Apache-2.0 */ const alpha = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; const charToIdx = alpha.split('').reduce((acc, char, idx) => { acc.set(char, idx); return acc; }, new Map()); const LEAST_5_BIT_MASK = 0b011111; const CONTINUATION_BIT_MASK = 0b100000; const MAX_INT = 2147483647; /** * Encoding variable length integer into base64 (6-bit): * * 1 N N N N N | 0 N N N N N * * The first bit indicates if there is more data for the int. */ function encodeInt(num) { if (num < 0) { throw new Error('Only postive integers and zero are supported'); } if (num > MAX_INT) { throw new Error('Only integers between 0 and ' + MAX_INT + ' are supported'); } const result = []; do { let payload = num & LEAST_5_BIT_MASK; num >>>= 5; if (num > 0) payload |= CONTINUATION_BIT_MASK; result.push(alpha[payload]); } while (num !== 0); return result.join(''); } function encode(nums) { const parts = []; for (const num of nums) { parts.push(encodeInt(num)); } return parts.join(''); } function decode(str) { const results = []; const chrs = str.split(''); let result = 0; let shift = 0; for (const ch of chrs) { const num = charToIdx.get(ch); result |= (num & LEAST_5_BIT_MASK) << shift; shift += 5; const hasMore = num & CONTINUATION_BIT_MASK; if (!hasMore) { results.push(result); result = 0; shift = 0; } } return results; } /** * @license * Copyright 2022 Google LLC * SPDX-License-Identifier: Apache-2.0 */ const SOURCE_MAP_PREFIX = '//# recorderSourceMap='; /** * Stringifes an entire recording. The following hooks are invoked with the `flow` parameter containing the entire flow: * - `beforeAllSteps` (once) * - `beforeEachStep` (per step) * - `stringifyStep` (per step) * - `afterEachStep` (per step) * - `afterAllSteps` (once) */ async function stringify(flow, opts) { if (!opts) { opts = {}; } const ext = opts.extension ?? new PuppeteerStringifyExtension(); const out = opts.writer ?? new InMemoryLineWriter(opts.indentation ?? ' '); await ext.beforeAllSteps?.(out, flow); const sourceMap = [1]; // The first int indicates the version. for (const step of flow.steps) { const firstLine = out.getSize(); await ext.beforeEachStep?.(out, step, flow); await ext.stringifyStep(out, step, flow); await ext.afterEachStep?.(out, step, flow); const lastLine = out.getSize(); sourceMap.push(...[firstLine, lastLine - firstLine]); } await ext.afterAllSteps?.(out, flow); out.appendLine(SOURCE_MAP_PREFIX + encode(sourceMap)); return out.toString(); } /** * Stringifes a single step. Only the following hooks are invoked with the `flow` parameter as undefined: * - `beforeEachStep` * - `stringifyStep` * - `afterEachStep` */ async function stringifyStep(step, opts) { if (!opts) { opts = {}; } let ext = opts.extension; if (!ext) { ext = new PuppeteerStringifyExtension(); } if (!opts.indentation) { opts.indentation = ' '; } const out = opts.writer ?? new InMemoryLineWriter(opts.indentation ?? ' '); await ext.beforeEachStep?.(out, step); await ext.stringifyStep(out, step); await ext.afterEachStep?.(out, step); return out.toString(); } function isSourceMapLine(line) { return line.trim().startsWith(SOURCE_MAP_PREFIX); } /** * Extracts a source map from a text. */ function parseSourceMap(text) { const lines = text.split('\n'); for (let i = lines.length - 1; i >= 0; i--) { const line = lines[i]; if (isSourceMapLine(line)) { return decode(line.trim().substring(SOURCE_MAP_PREFIX.length)); } } return; } function stripSourceMap(text) { const lines = text.split('\n'); return lines.filter((line) => !isSourceMapLine(line)).join('\n'); } /** * @license * Copyright 2022 Google LLC * SPDX-License-Identifier: Apache-2.0 */ class RunnerExtension { async beforeAllSteps(flow) { } async afterAllSteps(flow) { } async beforeEachStep(step, flow) { } async runStep(step, flow) { } async afterEachStep(step, flow) { } } const comparators = { '==': (a, b) => a === b, '>=': (a, b) => a >= b, '<=': (a, b) => a <= b, }; function waitForTimeout(timeout) { return new Promise((resolve) => { setTimeout(resolve, timeout); }); } class PuppeteerRunnerExtension extends RunnerExtension { browser; page; timeout; constructor(browser, page, opts) { super(); this.browser = browser; this.page = page; this.timeout = opts?.timeout || 5000; } async #ensureAutomationEmulatation(pageOrFrame) { try { await pageOrFrame ._client() .send('Emulation.setAutomationOverride', { enabled: true }); } catch { // ignore errors as not all versions support this command. } } #getTimeoutForStep(step, flow) { return step.timeout || flow?.timeout || this.timeout; } async runStep(step, flow) { const timeout = this.#getTimeoutForStep(step, flow); const page = this.page; const browser = this.browser; const targetPage = await getTargetPageForStep(browser, page, step, timeout); let targetFrame = null; if (!targetPage && step.target) { targetFrame = await page.waitForFrame(step.target, { timeout }); } const targetPageOrFrame = targetFrame || targetPage; if (!targetPageOrFrame) { throw new Error('Target is not found for step: ' + JSON.stringify(step)); } await this.#ensureAutomationEmulatation(targetPageOrFrame); const localFrame = await getFrame(targetPageOrFrame, step); await this.runStepInFrame(step, page, targetPageOrFrame, localFrame, timeout); } /** * @internal */ async runStepInFrame(step, mainPage, targetPageOrFrame, localFrame, timeout) { let assertedEventsPromise = null; const startWaitingForEvents = () => { assertedEventsPromise = waitForEvents(localFrame, step, timeout); }; const locatorRace = this.page.locatorRace; switch (step.type) { case StepType.DoubleClick: await locatorRace(step.selectors.map((selector) => { return localFrame.locator(selectorToPElementSelector(selector)); })) .setTimeout(timeout) .on('action', () => startWaitingForEvents()) .click({ count: 2, button: step.button && mouseButtonMap.get(step.button), delay: step.duration, offset: { x: step.offsetX, y: step.offsetY, }, }); break; case StepType.Click: await locatorRace(step.selectors.map((selector) => { return localFrame.locator(selectorToPElementSelector(selector)); })) .setTimeout(timeout) .on('action', () => startWaitingForEvents()) .click({ delay: step.duration, button: step.button && mouseButtonMap.get(step.button), offset: { x: step.offsetX, y: step.offsetY, }, }); break; case StepType.Hover: await locatorRace(step.selectors.map((selector) => { return localFrame.locator(selectorToPElementSelector(selector)); })) .setTimeout(timeout) .on('action', () => startWaitingForEvents()) .hover(); break; case StepType.EmulateNetworkConditions: { startWaitingForEvents(); const { download, upload, latency } = step; await mainPage.emulateNetworkConditions({ offline: !download && !upload, download, upload, latency, }); } break; case StepType.KeyDown: { startWaitingForEvents(); await mainPage.keyboard.down(step.key); await waitForTimeout(100); } break; case StepType.KeyUp: { startWaitingForEvents(); await mainPage.keyboard.up(step.key); await waitForTimeout(100); } break; case StepType.Close: { if ('close' in targetPageOrFrame) { startWaitingForEvents(); await targetPageOrFrame.close(); } } break; case StepType.Change: await locatorRace(step.selectors.map((selector) => { return localFrame.locator(selectorToPElementSelector(selector)); })) .on('action', () => startWaitingForEvents()) .setTimeout(timeout) .fill(step.value); break; case StepType.SetViewport: { if ('setViewport' in targetPageOrFrame) { startWaitingForEvents(); await targetPageOrFrame.setViewport(step); } break; } case StepType.Scroll: { if ('selectors' in step) { await locatorRace(step.selectors.map((selector) => { return localFrame.locator(selectorToPElementSelector(selector)); })) .on('action', () => startWaitingForEvents()) .setTimeout(timeout) .scroll({ scrollLeft: step.x || 0, scrollTop: step.y || 0, }); } else { startWaitingForEvents(); await localFrame.evaluate((x, y) => { /* c8 ignore start */ window.scroll(x, y); /* c8 ignore stop */ }, step.x || 0, step.y || 0); } break; } case StepType.Navigate: { startWaitingForEvents(); await localFrame.goto(step.url); break; } case StepType.WaitForElement: { try { startWaitingForEvents(); await waitForElement(step, localFrame, timeout); } catch (err) { if (err.message === 'Timed out') { throw new Error('waitForElement timed out. The element(s) could not be found.'); } else { throw err; } } break; } case StepType.WaitForExpression: { startWaitingForEvents(); await localFrame.waitForFunction(step.expression, { timeout, }); break; } case StepType.CustomStep: { // TODO implement these steps break; } default: assertAllStepTypesAreHandled(step); } await assertedEventsPromise; } } class PuppeteerRunnerOwningBrowserExtension extends PuppeteerRunnerExtension { asyn