diff --git a/__snapshots__/PuppeteerStringifyExtension_test.ts.js b/__snapshots__/PuppeteerStringifyExtension_test.ts.js new file mode 100644 index 00000000..f04b5403 --- /dev/null +++ b/__snapshots__/PuppeteerStringifyExtension_test.ts.js @@ -0,0 +1,101 @@ +exports[ + 'PuppeteerStringifyExtension should print the correct script for a click step 1' +] = ` +{ + const targetPage = page; + const element = await waitForSelectors(["aria/Test"], targetPage, { timeout, visible: true }); + await scrollIntoViewIfNeeded(element, timeout); + await element.click({ + offset: { + x: 1, + y: 1, + }, + }); +} + +`; + +exports[ + 'PuppeteerStringifyExtension should print the correct script for asserted events 1' +] = ` +{ + const targetPage = page; + const promises = []; + promises.push(targetPage.waitForNavigation()); + const element = await waitForSelectors(["aria/Test"], targetPage, { timeout, visible: true }); + await scrollIntoViewIfNeeded(element, timeout); + await element.click({ + offset: { + x: 1, + y: 1, + }, + }); + await Promise.all(promises); +} + +`; + +exports[ + 'PuppeteerStringifyExtension should print the correct script with a chain selector 1' +] = ` +{ + const targetPage = page; + const element = await waitForSelectors([["aria/Test","aria/Test2"]], targetPage, { timeout, visible: true }); + await scrollIntoViewIfNeeded(element, timeout); + await element.click({ + offset: { + x: 1, + y: 1, + }, + }); +} + +`; + +exports[ + 'PuppeteerStringifyExtension should print the correct script for a change step 1' +] = ` +{ + const targetPage = page; + const element = await waitForSelectors(["aria/Test"], targetPage, { timeout, visible: true }); + await scrollIntoViewIfNeeded(element, timeout); + const type = await element.evaluate(el => el.type); + if (["select-one"].includes(type)) { + await element.select("Hello World"); + } else if (["textarea","text","url","tel","search","password","number","email"].includes(type)) { + await element.type("Hello World"); + } else { + await element.focus(); + await element.evaluate((el, value) => { + el.value = value; + el.dispatchEvent(new Event('input', { bubbles: true })); + el.dispatchEvent(new Event('change', { bubbles: true })); + }, "Hello World"); + } +} + +`; + +exports[ + 'PuppeteerStringifyExtension should print the correct script for a change step for non-text inputs 1' +] = ` +{ + const targetPage = page; + const element = await waitForSelectors(["aria/Test"], targetPage, { timeout, visible: true }); + await scrollIntoViewIfNeeded(element, timeout); + const type = await element.evaluate(el => el.type); + if (["select-one"].includes(type)) { + await element.select("#333333"); + } else if (["textarea","text","url","tel","search","password","number","email"].includes(type)) { + await element.type("#333333"); + } else { + await element.focus(); + await element.evaluate((el, value) => { + el.value = value; + el.dispatchEvent(new Event('input', { bubbles: true })); + el.dispatchEvent(new Event('change', { bubbles: true })); + }, "#333333"); + } +} + +`; diff --git a/__snapshots__/stringify_test.ts.js b/__snapshots__/stringify_test.ts.js new file mode 100644 index 00000000..e6090753 --- /dev/null +++ b/__snapshots__/stringify_test.ts.js @@ -0,0 +1,1659 @@ +exports['stringify should print the correct script for a navigate step 1'] = ` +const puppeteer = require('puppeteer'); // v13.0.0 or later + +(async () => { + const browser = await puppeteer.launch(); + const page = await browser.newPage(); + const timeout = 5000; + page.setDefaultTimeout(timeout); + + async function waitForSelectors(selectors, frame, options) { + for (const selector of selectors) { + try { + return await waitForSelector(selector, frame, options); + } catch (err) { + console.error(err); + } + } + throw new Error('Could not find element for selectors: ' + JSON.stringify(selectors)); + } + + async function scrollIntoViewIfNeeded(element, timeout) { + await waitForConnected(element, timeout); + const isInViewport = await element.isIntersectingViewport({threshold: 0}); + if (isInViewport) { + return; + } + await element.evaluate(element => { + element.scrollIntoView({ + block: 'center', + inline: 'center', + behavior: 'auto', + }); + }); + await waitForInViewport(element, timeout); + } + + async function waitForConnected(element, timeout) { + await waitForFunction(async () => { + return await element.getProperty('isConnected'); + }, timeout); + } + + async function waitForInViewport(element, timeout) { + await waitForFunction(async () => { + return await element.isIntersectingViewport({threshold: 0}); + }, timeout); + } + + async function waitForSelector(selector, frame, options) { + if (!Array.isArray(selector)) { + selector = [selector]; + } + if (!selector.length) { + throw new Error('Empty selector provided to waitForSelector'); + } + let element = null; + for (let i = 0; i < selector.length; i++) { + const part = selector[i]; + if (element) { + element = await element.waitForSelector(part, options); + } else { + element = await frame.waitForSelector(part, options); + } + if (!element) { + throw new Error('Could not find element: ' + selector.join('>>')); + } + if (i < selector.length - 1) { + element = (await element.evaluateHandle(el => el.shadowRoot ? el.shadowRoot : el)).asElement(); + } + } + if (!element) { + throw new Error('Could not find element: ' + selector.join('|')); + } + return element; + } + + async function waitForElement(step, frame, timeout) { + const count = step.count || 1; + const operator = step.operator || '>='; + const comp = { + '==': (a, b) => a === b, + '>=': (a, b) => a >= b, + '<=': (a, b) => a <= b, + }; + const compFn = comp[operator]; + await waitForFunction(async () => { + const elements = await querySelectorsAll(step.selectors, frame); + return compFn(elements.length, count); + }, 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; + setTimeout(() => { + isActive = false; + }, timeout); + while (isActive) { + const result = await fn(); + if (result) { + return; + } + await new Promise(resolve => setTimeout(resolve, 100)); + } + throw new Error('Timed out'); + } + { + const targetPage = page; + await targetPage.goto("https://localhost/"); + } + + await browser.close(); +})(); + +`; + +exports[ + 'stringify should print the correct script for a emulateNetworkCondition step 1' +] = ` +const puppeteer = require('puppeteer'); // v13.0.0 or later + +(async () => { + const browser = await puppeteer.launch(); + const page = await browser.newPage(); + const timeout = 5000; + page.setDefaultTimeout(timeout); + + async function waitForSelectors(selectors, frame, options) { + for (const selector of selectors) { + try { + return await waitForSelector(selector, frame, options); + } catch (err) { + console.error(err); + } + } + throw new Error('Could not find element for selectors: ' + JSON.stringify(selectors)); + } + + async function scrollIntoViewIfNeeded(element, timeout) { + await waitForConnected(element, timeout); + const isInViewport = await element.isIntersectingViewport({threshold: 0}); + if (isInViewport) { + return; + } + await element.evaluate(element => { + element.scrollIntoView({ + block: 'center', + inline: 'center', + behavior: 'auto', + }); + }); + await waitForInViewport(element, timeout); + } + + async function waitForConnected(element, timeout) { + await waitForFunction(async () => { + return await element.getProperty('isConnected'); + }, timeout); + } + + async function waitForInViewport(element, timeout) { + await waitForFunction(async () => { + return await element.isIntersectingViewport({threshold: 0}); + }, timeout); + } + + async function waitForSelector(selector, frame, options) { + if (!Array.isArray(selector)) { + selector = [selector]; + } + if (!selector.length) { + throw new Error('Empty selector provided to waitForSelector'); + } + let element = null; + for (let i = 0; i < selector.length; i++) { + const part = selector[i]; + if (element) { + element = await element.waitForSelector(part, options); + } else { + element = await frame.waitForSelector(part, options); + } + if (!element) { + throw new Error('Could not find element: ' + selector.join('>>')); + } + if (i < selector.length - 1) { + element = (await element.evaluateHandle(el => el.shadowRoot ? el.shadowRoot : el)).asElement(); + } + } + if (!element) { + throw new Error('Could not find element: ' + selector.join('|')); + } + return element; + } + + async function waitForElement(step, frame, timeout) { + const count = step.count || 1; + const operator = step.operator || '>='; + const comp = { + '==': (a, b) => a === b, + '>=': (a, b) => a >= b, + '<=': (a, b) => a <= b, + }; + const compFn = comp[operator]; + await waitForFunction(async () => { + const elements = await querySelectorsAll(step.selectors, frame); + return compFn(elements.length, count); + }, 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; + setTimeout(() => { + isActive = false; + }, timeout); + while (isActive) { + const result = await fn(); + if (result) { + return; + } + await new Promise(resolve => setTimeout(resolve, 100)); + } + throw new Error('Timed out'); + } + { + const targetPage = page; + await targetPage.emulateNetworkConditions({ + offline: false, + downloadThroughput: 100, + uploadThroughput: 100, + latency: 999, + }); + } + + await browser.close(); +})(); + +`; + +exports[ + 'stringify should print the correct script if the target is not the main page 1' +] = ` +const puppeteer = require('puppeteer'); // v13.0.0 or later + +(async () => { + const browser = await puppeteer.launch(); + const page = await browser.newPage(); + const timeout = 5000; + page.setDefaultTimeout(timeout); + + async function waitForSelectors(selectors, frame, options) { + for (const selector of selectors) { + try { + return await waitForSelector(selector, frame, options); + } catch (err) { + console.error(err); + } + } + throw new Error('Could not find element for selectors: ' + JSON.stringify(selectors)); + } + + async function scrollIntoViewIfNeeded(element, timeout) { + await waitForConnected(element, timeout); + const isInViewport = await element.isIntersectingViewport({threshold: 0}); + if (isInViewport) { + return; + } + await element.evaluate(element => { + element.scrollIntoView({ + block: 'center', + inline: 'center', + behavior: 'auto', + }); + }); + await waitForInViewport(element, timeout); + } + + async function waitForConnected(element, timeout) { + await waitForFunction(async () => { + return await element.getProperty('isConnected'); + }, timeout); + } + + async function waitForInViewport(element, timeout) { + await waitForFunction(async () => { + return await element.isIntersectingViewport({threshold: 0}); + }, timeout); + } + + async function waitForSelector(selector, frame, options) { + if (!Array.isArray(selector)) { + selector = [selector]; + } + if (!selector.length) { + throw new Error('Empty selector provided to waitForSelector'); + } + let element = null; + for (let i = 0; i < selector.length; i++) { + const part = selector[i]; + if (element) { + element = await element.waitForSelector(part, options); + } else { + element = await frame.waitForSelector(part, options); + } + if (!element) { + throw new Error('Could not find element: ' + selector.join('>>')); + } + if (i < selector.length - 1) { + element = (await element.evaluateHandle(el => el.shadowRoot ? el.shadowRoot : el)).asElement(); + } + } + if (!element) { + throw new Error('Could not find element: ' + selector.join('|')); + } + return element; + } + + async function waitForElement(step, frame, timeout) { + const count = step.count || 1; + const operator = step.operator || '>='; + const comp = { + '==': (a, b) => a === b, + '>=': (a, b) => a >= b, + '<=': (a, b) => a <= b, + }; + const compFn = comp[operator]; + await waitForFunction(async () => { + const elements = await querySelectorsAll(step.selectors, frame); + return compFn(elements.length, count); + }, 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; + setTimeout(() => { + isActive = false; + }, timeout); + while (isActive) { + const result = await fn(); + if (result) { + return; + } + await new Promise(resolve => setTimeout(resolve, 100)); + } + throw new Error('Timed out'); + } + { + const target = await browser.waitForTarget(t => t.url() === "https://localhost/test", { timeout }); + const targetPage = await target.page(); + targetPage.setDefaultTimeout(timeout); + const element = await waitForSelectors(["aria/Test"], targetPage, { timeout, visible: true }); + await scrollIntoViewIfNeeded(element, timeout); + await element.click({ + offset: { + x: 1, + y: 1, + }, + }); + } + + await browser.close(); +})(); + +`; + +exports['stringify should use step and flow timeouts 1'] = ` +const puppeteer = require('puppeteer'); // v13.0.0 or later + +(async () => { + const browser = await puppeteer.launch(); + const page = await browser.newPage(); + const timeout = 10000; + page.setDefaultTimeout(timeout); + + async function waitForSelectors(selectors, frame, options) { + for (const selector of selectors) { + try { + return await waitForSelector(selector, frame, options); + } catch (err) { + console.error(err); + } + } + throw new Error('Could not find element for selectors: ' + JSON.stringify(selectors)); + } + + async function scrollIntoViewIfNeeded(element, timeout) { + await waitForConnected(element, timeout); + const isInViewport = await element.isIntersectingViewport({threshold: 0}); + if (isInViewport) { + return; + } + await element.evaluate(element => { + element.scrollIntoView({ + block: 'center', + inline: 'center', + behavior: 'auto', + }); + }); + await waitForInViewport(element, timeout); + } + + async function waitForConnected(element, timeout) { + await waitForFunction(async () => { + return await element.getProperty('isConnected'); + }, timeout); + } + + async function waitForInViewport(element, timeout) { + await waitForFunction(async () => { + return await element.isIntersectingViewport({threshold: 0}); + }, timeout); + } + + async function waitForSelector(selector, frame, options) { + if (!Array.isArray(selector)) { + selector = [selector]; + } + if (!selector.length) { + throw new Error('Empty selector provided to waitForSelector'); + } + let element = null; + for (let i = 0; i < selector.length; i++) { + const part = selector[i]; + if (element) { + element = await element.waitForSelector(part, options); + } else { + element = await frame.waitForSelector(part, options); + } + if (!element) { + throw new Error('Could not find element: ' + selector.join('>>')); + } + if (i < selector.length - 1) { + element = (await element.evaluateHandle(el => el.shadowRoot ? el.shadowRoot : el)).asElement(); + } + } + if (!element) { + throw new Error('Could not find element: ' + selector.join('|')); + } + return element; + } + + async function waitForElement(step, frame, timeout) { + const count = step.count || 1; + const operator = step.operator || '>='; + const comp = { + '==': (a, b) => a === b, + '>=': (a, b) => a >= b, + '<=': (a, b) => a <= b, + }; + const compFn = comp[operator]; + await waitForFunction(async () => { + const elements = await querySelectorsAll(step.selectors, frame); + return compFn(elements.length, count); + }, 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; + setTimeout(() => { + isActive = false; + }, timeout); + while (isActive) { + const result = await fn(); + if (result) { + return; + } + await new Promise(resolve => setTimeout(resolve, 100)); + } + throw new Error('Timed out'); + } + { + const timeout = 20000; + const target = await browser.waitForTarget(t => t.url() === "https://localhost/test", { timeout }); + const targetPage = await target.page(); + targetPage.setDefaultTimeout(timeout); + const element = await waitForSelectors(["aria/Test"], targetPage, { timeout, visible: true }); + await scrollIntoViewIfNeeded(element, timeout); + await element.click({ + offset: { + x: 1, + y: 1, + }, + }); + } + + await browser.close(); +})(); + +`; + +exports[ + 'stringify should print the correct script if the step is within an iframe 1' +] = ` +const puppeteer = require('puppeteer'); // v13.0.0 or later + +(async () => { + const browser = await puppeteer.launch(); + const page = await browser.newPage(); + const timeout = 5000; + page.setDefaultTimeout(timeout); + + async function waitForSelectors(selectors, frame, options) { + for (const selector of selectors) { + try { + return await waitForSelector(selector, frame, options); + } catch (err) { + console.error(err); + } + } + throw new Error('Could not find element for selectors: ' + JSON.stringify(selectors)); + } + + async function scrollIntoViewIfNeeded(element, timeout) { + await waitForConnected(element, timeout); + const isInViewport = await element.isIntersectingViewport({threshold: 0}); + if (isInViewport) { + return; + } + await element.evaluate(element => { + element.scrollIntoView({ + block: 'center', + inline: 'center', + behavior: 'auto', + }); + }); + await waitForInViewport(element, timeout); + } + + async function waitForConnected(element, timeout) { + await waitForFunction(async () => { + return await element.getProperty('isConnected'); + }, timeout); + } + + async function waitForInViewport(element, timeout) { + await waitForFunction(async () => { + return await element.isIntersectingViewport({threshold: 0}); + }, timeout); + } + + async function waitForSelector(selector, frame, options) { + if (!Array.isArray(selector)) { + selector = [selector]; + } + if (!selector.length) { + throw new Error('Empty selector provided to waitForSelector'); + } + let element = null; + for (let i = 0; i < selector.length; i++) { + const part = selector[i]; + if (element) { + element = await element.waitForSelector(part, options); + } else { + element = await frame.waitForSelector(part, options); + } + if (!element) { + throw new Error('Could not find element: ' + selector.join('>>')); + } + if (i < selector.length - 1) { + element = (await element.evaluateHandle(el => el.shadowRoot ? el.shadowRoot : el)).asElement(); + } + } + if (!element) { + throw new Error('Could not find element: ' + selector.join('|')); + } + return element; + } + + async function waitForElement(step, frame, timeout) { + const count = step.count || 1; + const operator = step.operator || '>='; + const comp = { + '==': (a, b) => a === b, + '>=': (a, b) => a >= b, + '<=': (a, b) => a <= b, + }; + const compFn = comp[operator]; + await waitForFunction(async () => { + const elements = await querySelectorsAll(step.selectors, frame); + return compFn(elements.length, count); + }, 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; + setTimeout(() => { + isActive = false; + }, timeout); + while (isActive) { + const result = await fn(); + if (result) { + return; + } + await new Promise(resolve => setTimeout(resolve, 100)); + } + throw new Error('Timed out'); + } + { + const targetPage = page; + let frame = targetPage.mainFrame(); + frame = frame.childFrames()[1]; + frame = frame.childFrames()[1]; + const element = await waitForSelectors(["aria/Test"], frame, { timeout, visible: true }); + await scrollIntoViewIfNeeded(element, timeout); + await element.click({ + offset: { + x: 1, + y: 1, + }, + }); + } + + await browser.close(); +})(); + +`; + +exports['stringify should print the correct script for a keydown step 1'] = ` +const puppeteer = require('puppeteer'); // v13.0.0 or later + +(async () => { + const browser = await puppeteer.launch(); + const page = await browser.newPage(); + const timeout = 5000; + page.setDefaultTimeout(timeout); + + async function waitForSelectors(selectors, frame, options) { + for (const selector of selectors) { + try { + return await waitForSelector(selector, frame, options); + } catch (err) { + console.error(err); + } + } + throw new Error('Could not find element for selectors: ' + JSON.stringify(selectors)); + } + + async function scrollIntoViewIfNeeded(element, timeout) { + await waitForConnected(element, timeout); + const isInViewport = await element.isIntersectingViewport({threshold: 0}); + if (isInViewport) { + return; + } + await element.evaluate(element => { + element.scrollIntoView({ + block: 'center', + inline: 'center', + behavior: 'auto', + }); + }); + await waitForInViewport(element, timeout); + } + + async function waitForConnected(element, timeout) { + await waitForFunction(async () => { + return await element.getProperty('isConnected'); + }, timeout); + } + + async function waitForInViewport(element, timeout) { + await waitForFunction(async () => { + return await element.isIntersectingViewport({threshold: 0}); + }, timeout); + } + + async function waitForSelector(selector, frame, options) { + if (!Array.isArray(selector)) { + selector = [selector]; + } + if (!selector.length) { + throw new Error('Empty selector provided to waitForSelector'); + } + let element = null; + for (let i = 0; i < selector.length; i++) { + const part = selector[i]; + if (element) { + element = await element.waitForSelector(part, options); + } else { + element = await frame.waitForSelector(part, options); + } + if (!element) { + throw new Error('Could not find element: ' + selector.join('>>')); + } + if (i < selector.length - 1) { + element = (await element.evaluateHandle(el => el.shadowRoot ? el.shadowRoot : el)).asElement(); + } + } + if (!element) { + throw new Error('Could not find element: ' + selector.join('|')); + } + return element; + } + + async function waitForElement(step, frame, timeout) { + const count = step.count || 1; + const operator = step.operator || '>='; + const comp = { + '==': (a, b) => a === b, + '>=': (a, b) => a >= b, + '<=': (a, b) => a <= b, + }; + const compFn = comp[operator]; + await waitForFunction(async () => { + const elements = await querySelectorsAll(step.selectors, frame); + return compFn(elements.length, count); + }, 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; + setTimeout(() => { + isActive = false; + }, timeout); + while (isActive) { + const result = await fn(); + if (result) { + return; + } + await new Promise(resolve => setTimeout(resolve, 100)); + } + throw new Error('Timed out'); + } + { + const targetPage = page; + await targetPage.keyboard.down("E"); + } + + await browser.close(); +})(); + +`; + +exports['stringify should print the correct script for a keyup step 1'] = ` +const puppeteer = require('puppeteer'); // v13.0.0 or later + +(async () => { + const browser = await puppeteer.launch(); + const page = await browser.newPage(); + const timeout = 5000; + page.setDefaultTimeout(timeout); + + async function waitForSelectors(selectors, frame, options) { + for (const selector of selectors) { + try { + return await waitForSelector(selector, frame, options); + } catch (err) { + console.error(err); + } + } + throw new Error('Could not find element for selectors: ' + JSON.stringify(selectors)); + } + + async function scrollIntoViewIfNeeded(element, timeout) { + await waitForConnected(element, timeout); + const isInViewport = await element.isIntersectingViewport({threshold: 0}); + if (isInViewport) { + return; + } + await element.evaluate(element => { + element.scrollIntoView({ + block: 'center', + inline: 'center', + behavior: 'auto', + }); + }); + await waitForInViewport(element, timeout); + } + + async function waitForConnected(element, timeout) { + await waitForFunction(async () => { + return await element.getProperty('isConnected'); + }, timeout); + } + + async function waitForInViewport(element, timeout) { + await waitForFunction(async () => { + return await element.isIntersectingViewport({threshold: 0}); + }, timeout); + } + + async function waitForSelector(selector, frame, options) { + if (!Array.isArray(selector)) { + selector = [selector]; + } + if (!selector.length) { + throw new Error('Empty selector provided to waitForSelector'); + } + let element = null; + for (let i = 0; i < selector.length; i++) { + const part = selector[i]; + if (element) { + element = await element.waitForSelector(part, options); + } else { + element = await frame.waitForSelector(part, options); + } + if (!element) { + throw new Error('Could not find element: ' + selector.join('>>')); + } + if (i < selector.length - 1) { + element = (await element.evaluateHandle(el => el.shadowRoot ? el.shadowRoot : el)).asElement(); + } + } + if (!element) { + throw new Error('Could not find element: ' + selector.join('|')); + } + return element; + } + + async function waitForElement(step, frame, timeout) { + const count = step.count || 1; + const operator = step.operator || '>='; + const comp = { + '==': (a, b) => a === b, + '>=': (a, b) => a >= b, + '<=': (a, b) => a <= b, + }; + const compFn = comp[operator]; + await waitForFunction(async () => { + const elements = await querySelectorsAll(step.selectors, frame); + return compFn(elements.length, count); + }, 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; + setTimeout(() => { + isActive = false; + }, timeout); + while (isActive) { + const result = await fn(); + if (result) { + return; + } + await new Promise(resolve => setTimeout(resolve, 100)); + } + throw new Error('Timed out'); + } + { + const targetPage = page; + await targetPage.keyboard.up("E"); + } + + await browser.close(); +})(); + +`; + +exports['stringify should print the correct script for scroll events 1'] = ` +const puppeteer = require('puppeteer'); // v13.0.0 or later + +(async () => { + const browser = await puppeteer.launch(); + const page = await browser.newPage(); + const timeout = 5000; + page.setDefaultTimeout(timeout); + + async function waitForSelectors(selectors, frame, options) { + for (const selector of selectors) { + try { + return await waitForSelector(selector, frame, options); + } catch (err) { + console.error(err); + } + } + throw new Error('Could not find element for selectors: ' + JSON.stringify(selectors)); + } + + async function scrollIntoViewIfNeeded(element, timeout) { + await waitForConnected(element, timeout); + const isInViewport = await element.isIntersectingViewport({threshold: 0}); + if (isInViewport) { + return; + } + await element.evaluate(element => { + element.scrollIntoView({ + block: 'center', + inline: 'center', + behavior: 'auto', + }); + }); + await waitForInViewport(element, timeout); + } + + async function waitForConnected(element, timeout) { + await waitForFunction(async () => { + return await element.getProperty('isConnected'); + }, timeout); + } + + async function waitForInViewport(element, timeout) { + await waitForFunction(async () => { + return await element.isIntersectingViewport({threshold: 0}); + }, timeout); + } + + async function waitForSelector(selector, frame, options) { + if (!Array.isArray(selector)) { + selector = [selector]; + } + if (!selector.length) { + throw new Error('Empty selector provided to waitForSelector'); + } + let element = null; + for (let i = 0; i < selector.length; i++) { + const part = selector[i]; + if (element) { + element = await element.waitForSelector(part, options); + } else { + element = await frame.waitForSelector(part, options); + } + if (!element) { + throw new Error('Could not find element: ' + selector.join('>>')); + } + if (i < selector.length - 1) { + element = (await element.evaluateHandle(el => el.shadowRoot ? el.shadowRoot : el)).asElement(); + } + } + if (!element) { + throw new Error('Could not find element: ' + selector.join('|')); + } + return element; + } + + async function waitForElement(step, frame, timeout) { + const count = step.count || 1; + const operator = step.operator || '>='; + const comp = { + '==': (a, b) => a === b, + '>=': (a, b) => a >= b, + '<=': (a, b) => a <= b, + }; + const compFn = comp[operator]; + await waitForFunction(async () => { + const elements = await querySelectorsAll(step.selectors, frame); + return compFn(elements.length, count); + }, 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; + setTimeout(() => { + isActive = false; + }, timeout); + while (isActive) { + const result = await fn(); + if (result) { + return; + } + await new Promise(resolve => setTimeout(resolve, 100)); + } + throw new Error('Timed out'); + } + { + const targetPage = page; + const element = await waitForSelectors(["body > div:nth-child(1)"], targetPage, { timeout, visible: true }); + await scrollIntoViewIfNeeded(element, timeout); + await element.evaluate((el, x, y) => { el.scrollTop = y; el.scrollLeft = x; }, 0, 40); + } + { + const targetPage = page; + await targetPage.evaluate((x, y) => { window.scroll(x, y); }, 40, 40) + } + + await browser.close(); +})(); + +`; + +exports[ + 'stringify should print the correct script for waitForElement steps 1' +] = ` +const puppeteer = require('puppeteer'); // v13.0.0 or later + +(async () => { + const browser = await puppeteer.launch(); + const page = await browser.newPage(); + const timeout = 5000; + page.setDefaultTimeout(timeout); + + async function waitForSelectors(selectors, frame, options) { + for (const selector of selectors) { + try { + return await waitForSelector(selector, frame, options); + } catch (err) { + console.error(err); + } + } + throw new Error('Could not find element for selectors: ' + JSON.stringify(selectors)); + } + + async function scrollIntoViewIfNeeded(element, timeout) { + await waitForConnected(element, timeout); + const isInViewport = await element.isIntersectingViewport({threshold: 0}); + if (isInViewport) { + return; + } + await element.evaluate(element => { + element.scrollIntoView({ + block: 'center', + inline: 'center', + behavior: 'auto', + }); + }); + await waitForInViewport(element, timeout); + } + + async function waitForConnected(element, timeout) { + await waitForFunction(async () => { + return await element.getProperty('isConnected'); + }, timeout); + } + + async function waitForInViewport(element, timeout) { + await waitForFunction(async () => { + return await element.isIntersectingViewport({threshold: 0}); + }, timeout); + } + + async function waitForSelector(selector, frame, options) { + if (!Array.isArray(selector)) { + selector = [selector]; + } + if (!selector.length) { + throw new Error('Empty selector provided to waitForSelector'); + } + let element = null; + for (let i = 0; i < selector.length; i++) { + const part = selector[i]; + if (element) { + element = await element.waitForSelector(part, options); + } else { + element = await frame.waitForSelector(part, options); + } + if (!element) { + throw new Error('Could not find element: ' + selector.join('>>')); + } + if (i < selector.length - 1) { + element = (await element.evaluateHandle(el => el.shadowRoot ? el.shadowRoot : el)).asElement(); + } + } + if (!element) { + throw new Error('Could not find element: ' + selector.join('|')); + } + return element; + } + + async function waitForElement(step, frame, timeout) { + const count = step.count || 1; + const operator = step.operator || '>='; + const comp = { + '==': (a, b) => a === b, + '>=': (a, b) => a >= b, + '<=': (a, b) => a <= b, + }; + const compFn = comp[operator]; + await waitForFunction(async () => { + const elements = await querySelectorsAll(step.selectors, frame); + return compFn(elements.length, count); + }, 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; + setTimeout(() => { + isActive = false; + }, timeout); + while (isActive) { + const result = await fn(); + if (result) { + return; + } + await new Promise(resolve => setTimeout(resolve, 100)); + } + throw new Error('Timed out'); + } + { + const targetPage = page; + await waitForElement({"type":"waitForElement","selectors":["body > div:nth-child(1)"]}, targetPage, timeout); + } + + await browser.close(); +})(); + +`; + +exports[ + 'stringify should print the correct script for waitForExpression steps 1' +] = ` +const puppeteer = require('puppeteer'); // v13.0.0 or later + +(async () => { + const browser = await puppeteer.launch(); + const page = await browser.newPage(); + const timeout = 5000; + page.setDefaultTimeout(timeout); + + async function waitForSelectors(selectors, frame, options) { + for (const selector of selectors) { + try { + return await waitForSelector(selector, frame, options); + } catch (err) { + console.error(err); + } + } + throw new Error('Could not find element for selectors: ' + JSON.stringify(selectors)); + } + + async function scrollIntoViewIfNeeded(element, timeout) { + await waitForConnected(element, timeout); + const isInViewport = await element.isIntersectingViewport({threshold: 0}); + if (isInViewport) { + return; + } + await element.evaluate(element => { + element.scrollIntoView({ + block: 'center', + inline: 'center', + behavior: 'auto', + }); + }); + await waitForInViewport(element, timeout); + } + + async function waitForConnected(element, timeout) { + await waitForFunction(async () => { + return await element.getProperty('isConnected'); + }, timeout); + } + + async function waitForInViewport(element, timeout) { + await waitForFunction(async () => { + return await element.isIntersectingViewport({threshold: 0}); + }, timeout); + } + + async function waitForSelector(selector, frame, options) { + if (!Array.isArray(selector)) { + selector = [selector]; + } + if (!selector.length) { + throw new Error('Empty selector provided to waitForSelector'); + } + let element = null; + for (let i = 0; i < selector.length; i++) { + const part = selector[i]; + if (element) { + element = await element.waitForSelector(part, options); + } else { + element = await frame.waitForSelector(part, options); + } + if (!element) { + throw new Error('Could not find element: ' + selector.join('>>')); + } + if (i < selector.length - 1) { + element = (await element.evaluateHandle(el => el.shadowRoot ? el.shadowRoot : el)).asElement(); + } + } + if (!element) { + throw new Error('Could not find element: ' + selector.join('|')); + } + return element; + } + + async function waitForElement(step, frame, timeout) { + const count = step.count || 1; + const operator = step.operator || '>='; + const comp = { + '==': (a, b) => a === b, + '>=': (a, b) => a >= b, + '<=': (a, b) => a <= b, + }; + const compFn = comp[operator]; + await waitForFunction(async () => { + const elements = await querySelectorsAll(step.selectors, frame); + return compFn(elements.length, count); + }, 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; + setTimeout(() => { + isActive = false; + }, timeout); + while (isActive) { + const result = await fn(); + if (result) { + return; + } + await new Promise(resolve => setTimeout(resolve, 100)); + } + throw new Error('Timed out'); + } + { + const targetPage = page; + await targetPage.waitForFunction("1 + 2", { timeout }); + } + + await browser.close(); +})(); + +`; diff --git a/package.json b/package.json index 4b2a25b7..9e7ff37a 100644 --- a/package.json +++ b/package.json @@ -38,22 +38,23 @@ "devDependencies": { "@types/chai": "4.3.1", "@types/mocha": "9.1.1", - "@types/node": "17.0.26", - "@typescript-eslint/eslint-plugin": "5.20.0", - "@typescript-eslint/parser": "5.20.0", + "@types/node": "17.0.29", + "@typescript-eslint/eslint-plugin": "5.21.0", + "@typescript-eslint/parser": "5.21.0", "c8": "7.11.2", "chai": "4.3.6", "cross-env": "7.0.3", "eslint": "8.14.0", "eslint-config-google": "0.14.0", - "eslint-config-prettier": "^8.5.0", + "eslint-config-prettier": "8.5.0", "eslint-plugin-prettier": "4.0.0", "eslint-plugin-tsdoc": "0.2.16", "mime": "3.0.0", "mocha": "9.2.2", "prettier": "2.6.2", - "puppeteer": "13.6.0", + "puppeteer": "13.7.0", "rimraf": "3.0.2", + "snap-shot-it": "7.9.6", "standard-version": "git+https://github.com:conventional-changelog/standard-version.git#v9.4.0", "ts-node": "10.7.0", "typedoc": "0.22.15", @@ -61,7 +62,7 @@ "typescript": "4.6.3" }, "peerDependencies": { - "puppeteer": "^13.6.0" + "puppeteer": "^13.7.0" }, "peerDependenciesMeta": { "puppeteer": { diff --git a/src/PuppeteerRunnerExtension.ts b/src/PuppeteerRunnerExtension.ts index 9877571a..2e111138 100644 --- a/src/PuppeteerRunnerExtension.ts +++ b/src/PuppeteerRunnerExtension.ts @@ -25,6 +25,7 @@ import { } from './Schema.js'; import { assertAllStepTypesAreHandled, + mouseButtonMap, typeableInputTypes, } from './SchemaUtils.js'; @@ -106,6 +107,28 @@ export class PuppeteerRunnerExtension extends RunnerExtension { }; switch (step.type) { + case 'doubleClick': + { + const element = await waitForSelectors(step.selectors, localFrame, { + timeout, + visible: waitForVisible, + }); + if (!element) { + throw new Error('Could not find element: ' + step.selectors[0]); + } + await scrollIntoViewIfNeeded(element, timeout); + startWaitingForEvents(); + await element.click({ + clickCount: 2, + button: step.button && mouseButtonMap.get(step.button), + offset: { + x: step.offsetX, + y: step.offsetY, + }, + }); + await element.dispose(); + } + break; case 'click': { const element = await waitForSelectors(step.selectors, localFrame, { @@ -118,6 +141,8 @@ export class PuppeteerRunnerExtension extends RunnerExtension { await scrollIntoViewIfNeeded(element, timeout); startWaitingForEvents(); await element.click({ + delay: step.duration, + button: step.button && mouseButtonMap.get(step.button), offset: { x: step.offsetX, y: step.offsetY, @@ -657,6 +682,9 @@ interface ElementHandle isIntersectingViewport(opts: { threshold: number }): Promise; dispose(): Promise; click(opts: { + delay?: number; + button?: 'left' | 'right' | 'middle' | 'back' | 'forward'; + clickCount?: number; offset: { x: number; y: number; diff --git a/src/PuppeteerStringifyExtension.ts b/src/PuppeteerStringifyExtension.ts index 6ae48f78..68524962 100644 --- a/src/PuppeteerStringifyExtension.ts +++ b/src/PuppeteerStringifyExtension.ts @@ -31,11 +31,13 @@ import type { NavigateStep, WaitForElementStep, WaitForExpressionStep, + DoubleClickStep, } from './Schema.js'; import { StringifyExtension } from './StringifyExtension.js'; import { assertAllStepTypesAreHandled, + mouseButtonMap, typeableInputTypes, } from './SchemaUtils.js'; @@ -137,9 +139,32 @@ export class PuppeteerStringifyExtension extends StringifyExtension { #appendClickStep(out: LineWriter, step: ClickStep): void { this.#appendWaitForSelector(out, step); - out.appendLine( - `await element.click({ offset: { x: ${step.offsetX}, y: ${step.offsetY}} });` - ); + out.appendLine('await element.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: LineWriter, step: DoubleClickStep): void { + this.#appendWaitForSelector(out, step); + out.appendLine('await element.click({'); + out.appendLine(` clickCount: 2,`); + 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('});'); } #appendChangeStep(out: LineWriter, step: ChangeStep): void { @@ -221,6 +246,8 @@ export class PuppeteerStringifyExtension extends StringifyExtension { switch (step.type) { case 'click': return this.#appendClickStep(out, step); + case 'doubleClick': + return this.#appendDoubleClickStep(out, step); case 'change': return this.#appendChangeStep(out, step); case 'emulateNetworkConditions': diff --git a/src/Schema.ts b/src/Schema.ts index 03b55c35..15335116 100644 --- a/src/Schema.ts +++ b/src/Schema.ts @@ -51,18 +51,40 @@ export interface StepWithSelectors extends StepWithFrame { selectors: Selector[]; } -export interface ClickStep extends StepWithSelectors { - type: 'click'; +export interface ClickAttributes { + /** + * Pointer type for the event. Defaults to 'mouse'. + */ + deviceType?: 'mouse' | 'pen' | 'touch'; + /** + * Defaults to 'primary' if the device type is a mouse. + */ + button?: 'primary' | 'auxiliary' | 'secondary' | 'back' | 'forward'; /** - * in px, relative to the top-left corner of the element content box. Defaults to the center of the element + * in px, relative to the top-left corner of the element content box. Defaults + * to the center of the element */ offsetX: number; /** - * in px, relative to the top-left corner of the element content box. Defaults to the center of the element + * in px, relative to the top-left corner of the element content box. Defaults + * to the center of the element */ offsetY: number; } +export interface DoubleClickStep extends ClickAttributes, StepWithSelectors { + type: 'doubleClick'; +} + +export interface ClickStep extends ClickAttributes, StepWithSelectors { + type: 'click'; + /** + * Delay (in ms) between the mouse up and mouse down of the click. Defaults to + * 50ms. + */ + duration?: number; +} + export interface ChangeStep extends StepWithSelectors { type: 'change'; value: string; @@ -133,20 +155,21 @@ export type CustomStep = | (CustomStepParams & StepWithFrame); export type UserStep = - | ClickStep | ChangeStep + | ClickStep + | CloseStep + | CustomStep + | DoubleClickStep | EmulateNetworkConditionsStep | KeyDownStep | KeyUpStep - | CloseStep - | SetViewportStep - | ScrollStep | NavigateStep - | CustomStep; + | ScrollStep + | SetViewportStep; /** - * `waitForElement` allows waiting for the presence (or absence) of the number of - * elements identified by the selector. + * `waitForElement` allows waiting for the presence (or absence) of the number + * of elements identified by the selector. * * For example, the following step would wait for less than three elements * to be on the page that match the selector `.my-class`. @@ -173,15 +196,17 @@ export interface WaitForElementStep extends StepWithSelectors { } /** - * `waitForExpression` allows for a JavaScript expression to resolve to truthy value. + * `waitForExpression` allows for a JavaScript expression to resolve to truthy + * value. * - * For example, the following step pauses for two seconds and then resolves to true - * allowing the replay to continue. + * For example, the following step pauses for two seconds and then resolves to + * true allowing the replay to continue. * * ``` * { * "type": "waitForElement", - * "expression": "new Promise(resole => setTimeout(() => resolve(true), 2000))", + * "expression": "new Promise(resolve => setTimeout(() => resolve(true), + * 2000))", * } * ``` */ diff --git a/src/SchemaUtils.ts b/src/SchemaUtils.ts index e7609f75..784d4e56 100644 --- a/src/SchemaUtils.ts +++ b/src/SchemaUtils.ts @@ -38,6 +38,8 @@ import type { StepWithTarget, Target, UserFlow, + ClickAttributes, + DoubleClickStep, } from './Schema.js'; export function assertAllStepTypesAreHandled(s: never): never; @@ -56,6 +58,19 @@ export const typeableInputTypes = new Set([ 'email', ]); +export const pointerDeviceTypes = new Set(['mouse', 'pen', 'touch']); + +export const mouseButtonMap = new Map< + string, + 'left' | 'middle' | 'right' | 'back' | 'forward' +>([ + ['primary', 'left'], + ['auxiliary', 'middle'], + ['secondary', 'right'], + ['back', 'back'], + ['forward', 'forward'], +]); + function hasProperty( data: object, prop: KeyType @@ -92,6 +107,18 @@ function isIntegerArray(data: unknown): data is number[] { return isArray(data) && data.every((item) => Number.isInteger(item)); } +function isKnownDeviceType( + data: unknown +): data is Required['deviceType'] { + return typeof data === 'string' && pointerDeviceTypes.has(data); +} + +function isKnownMouseButton( + data: unknown +): data is Required['button'] { + return typeof data === 'string' && mouseButtonMap.has(data); +} + function parseTarget(step: object): Target | undefined { if (hasProperty(step, 'target') && isString(step.target)) { return step.target; @@ -249,12 +276,50 @@ function parseStepWithSelectors(type: string, step: object): StepWithSelectors { }; } +function parseClickAttributes(step: object): ClickAttributes { + const attributes: ClickAttributes = { + offsetX: parseNumber(step, 'offsetX'), + offsetY: parseNumber(step, 'offsetY'), + }; + 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: object): ClickStep { return { ...parseStepWithSelectors('click', step), + ...parseClickAttributes(step), type: 'click', - offsetX: parseNumber(step, 'offsetX'), - offsetY: parseNumber(step, 'offsetY'), + duration: parseOptionalNumber(step, 'duration'), + }; +} + +function parseDoubleClickStep(step: object): DoubleClickStep { + return { + ...parseStepWithSelectors('doubleClick', step), + ...parseClickAttributes(step), + type: 'doubleClick', }; } @@ -397,6 +462,8 @@ export function parseStep(step: unknown, idx?: number): Step { switch (step.type) { case 'click': return parseClickStep(step); + case 'doubleClick': + return parseDoubleClickStep(step); case 'change': return parseChangeStep(step); case 'keyDown': diff --git a/test/PuppeteerStringifyExtension_test.ts b/test/PuppeteerStringifyExtension_test.ts index fc554a2b..27bc4253 100644 --- a/test/PuppeteerStringifyExtension_test.ts +++ b/test/PuppeteerStringifyExtension_test.ts @@ -14,9 +14,9 @@ limitations under the License. */ +import snapshot from 'snap-shot-it'; import { LineWriterImpl } from '../src/LineWriterImpl.js'; import { PuppeteerStringifyExtension } from '../src/PuppeteerStringifyExtension.js'; -import { assert } from 'chai'; describe('PuppeteerStringifyExtension', () => { const ext = new PuppeteerStringifyExtension(); @@ -33,16 +33,7 @@ describe('PuppeteerStringifyExtension', () => { const writer = new LineWriterImpl(' '); await ext.stringifyStep(writer, step, flow); - assert.deepEqual( - writer.toString(), - `{ - const targetPage = page; - const element = await waitForSelectors(["aria/Test"], targetPage, { timeout, visible: true }); - await scrollIntoViewIfNeeded(element, timeout); - await element.click({ offset: { x: 1, y: 1} }); -} -` - ); + snapshot(writer.toString()); }); it('should print the correct script for asserted events', async () => { @@ -58,19 +49,7 @@ describe('PuppeteerStringifyExtension', () => { const writer = new LineWriterImpl(' '); await ext.stringifyStep(writer, step, flow); - assert.deepEqual( - writer.toString(), - `{ - const targetPage = page; - const promises = []; - promises.push(targetPage.waitForNavigation()); - const element = await waitForSelectors(["aria/Test"], targetPage, { timeout, visible: true }); - await scrollIntoViewIfNeeded(element, timeout); - await element.click({ offset: { x: 1, y: 1} }); - await Promise.all(promises); -} -` - ); + snapshot(writer.toString()); }); it('should print the correct script with a chain selector', async () => { @@ -85,16 +64,7 @@ describe('PuppeteerStringifyExtension', () => { const writer = new LineWriterImpl(' '); await ext.stringifyStep(writer, step, flow); - assert.deepEqual( - writer.toString(), - `{ - const targetPage = page; - const element = await waitForSelectors([["aria/Test","aria/Test2"]], targetPage, { timeout, visible: true }); - await scrollIntoViewIfNeeded(element, timeout); - await element.click({ offset: { x: 1, y: 1} }); -} -` - ); + snapshot(writer.toString()); }); it('should print the correct script for a change step', async () => { @@ -108,28 +78,7 @@ describe('PuppeteerStringifyExtension', () => { const writer = new LineWriterImpl(' '); await ext.stringifyStep(writer, step, flow); - assert.deepEqual( - writer.toString(), - `{ - const targetPage = page; - const element = await waitForSelectors(["aria/Test"], targetPage, { timeout, visible: true }); - await scrollIntoViewIfNeeded(element, timeout); - const type = await element.evaluate(el => el.type); - if (["select-one"].includes(type)) { - await element.select("Hello World"); - } else if (["textarea","text","url","tel","search","password","number","email"].includes(type)) { - await element.type("Hello World"); - } else { - await element.focus(); - await element.evaluate((el, value) => { - el.value = value; - el.dispatchEvent(new Event('input', { bubbles: true })); - el.dispatchEvent(new Event('change', { bubbles: true })); - }, "Hello World"); - } -} -` - ); + snapshot(writer.toString()); }); it('should print the correct script for a change step for non-text inputs', async () => { @@ -143,27 +92,6 @@ describe('PuppeteerStringifyExtension', () => { const writer = new LineWriterImpl(' '); await ext.stringifyStep(writer, step, flow); - assert.deepEqual( - writer.toString(), - `{ - const targetPage = page; - const element = await waitForSelectors(["aria/Test"], targetPage, { timeout, visible: true }); - await scrollIntoViewIfNeeded(element, timeout); - const type = await element.evaluate(el => el.type); - if (["select-one"].includes(type)) { - await element.select("#333333"); - } else if (["textarea","text","url","tel","search","password","number","email"].includes(type)) { - await element.type("#333333"); - } else { - await element.focus(); - await element.evaluate((el, value) => { - el.value = value; - el.dispatchEvent(new Event('input', { bubbles: true })); - el.dispatchEvent(new Event('change', { bubbles: true })); - }, "#333333"); - } -} -` - ); + snapshot(writer.toString()); }); }); diff --git a/test/SchemaUtils_test.ts b/test/SchemaUtils_test.ts index 025abae3..39f36272 100644 --- a/test/SchemaUtils_test.ts +++ b/test/SchemaUtils_test.ts @@ -49,6 +49,19 @@ describe('SchemaUtils', () => { ['aria/Google'], ['body > div.L3eUgb > div.o3j99.LLD4me.yr19Zb.LS8OJ > div > img'], ], + duration: 10, + button: 'auxiliary', + target: 'main', + offsetX: 213.5, + offsetY: 46, + }, + { + type: 'doubleClick', + selectors: [ + ['aria/Google'], + ['body > div.L3eUgb > div.o3j99.LLD4me.yr19Zb.LS8OJ > div > img'], + ], + button: 'auxiliary', target: 'main', offsetX: 213.5, offsetY: 46, @@ -108,6 +121,19 @@ describe('SchemaUtils', () => { ['aria/Google'], ['body > div.L3eUgb > div.o3j99.LLD4me.yr19Zb.LS8OJ > div > img'], ], + duration: 10, + button: 'auxiliary', + target: 'main', + offsetX: 213.5, + offsetY: 46, + }, + { + type: 'doubleClick', + selectors: [ + ['aria/Google'], + ['body > div.L3eUgb > div.o3j99.LLD4me.yr19Zb.LS8OJ > div > img'], + ], + button: 'auxiliary', target: 'main', offsetX: 213.5, offsetY: 46, diff --git a/test/resources/main.html b/test/resources/main.html index 48bd4556..026ab0d3 100644 --- a/test/resources/main.html +++ b/test/resources/main.html @@ -31,6 +31,13 @@