diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml index cc02e81b55af..d5e89a12bc6f 100644 --- a/.github/workflows/unit.yml +++ b/.github/workflows/unit.yml @@ -46,6 +46,7 @@ jobs: - run: yarn install --frozen-lockfile --network-timeout 1000000 - run: yarn build-report + - run: yarn reset-link # Run pptr tests using ToT Chrome instead of stable default. - name: Define ToT chrome path diff --git a/cli/test/fixtures/flow/index.html b/cli/test/fixtures/flow/index.html new file mode 100644 index 000000000000..aa84a8829606 --- /dev/null +++ b/cli/test/fixtures/flow/index.html @@ -0,0 +1,22 @@ + + + +
+

Landing page for our user flows!

+ + Link to new page! + + + diff --git a/cli/test/fixtures/flow/next.html b/cli/test/fixtures/flow/next.html new file mode 100644 index 000000000000..df9141d18f43 --- /dev/null +++ b/cli/test/fixtures/flow/next.html @@ -0,0 +1,15 @@ + + + +

New page after clicking the link!

+
+ Hidden content here: + Hello there! +
+ + + diff --git a/core/fraggle-rock/replay/stringify-extension.js b/core/fraggle-rock/replay/stringify-extension.js new file mode 100644 index 000000000000..31e1f3ad5f8e --- /dev/null +++ b/core/fraggle-rock/replay/stringify-extension.js @@ -0,0 +1,107 @@ +/** + * @license Copyright 2022 The Lighthouse Authors. All Rights Reserved. + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + */ +'use strict'; + +import * as PuppeteerReplay from '@puppeteer/replay'; + +/** + * @param {PuppeteerReplay.Schema.Step} step + * @return {boolean} + */ +function isNavigationStep(step) { + return Boolean( + step.type === 'navigate' || + step.assertedEvents?.some(event => event.type === 'navigation') + ); +} + +class LighthouseStringifyExtension extends PuppeteerReplay.PuppeteerStringifyExtension { + #isProcessingTimespan = false; + + /** + * @override + * @param {PuppeteerReplay.LineWriter} out + * @param {PuppeteerReplay.Schema.UserFlow} flow + */ + async beforeAllSteps(out, flow) { + out.appendLine(`const fs = require('fs');`); + + let isMobile = true; + for (const step of flow.steps) { + if (step.type !== 'setViewport') continue; + isMobile = step.isMobile; + } + + await super.beforeAllSteps(out, flow); + + const configContext = { + settingsOverrides: { + screenEmulation: { + disabled: true, + }, + }, + }; + out.appendLine(`const configContext = ${JSON.stringify(configContext)}`); + if (isMobile) { + out.appendLine(`const config = undefined;`); + } else { + // eslint-disable-next-line max-len + out.appendLine(`const config = (await import('lighthouse/core/config/desktop-config.js')).default;`); + } + + out.appendLine(`const lhApi = await import('lighthouse/core/fraggle-rock/api.js');`); + // eslint-disable-next-line max-len + out.appendLine(`const lhFlow = await lhApi.startFlow(page, {name: ${JSON.stringify(flow.title)}, config, configContext});`); + } + + /** + * @override + * @param {PuppeteerReplay.LineWriter} out + * @param {PuppeteerReplay.Schema.Step} step + * @param {PuppeteerReplay.Schema.UserFlow} flow + */ + async stringifyStep(out, step, flow) { + if (step.type === 'setViewport') { + await super.stringifyStep(out, step, flow); + return; + } + + const isNavigation = isNavigationStep(step); + + if (isNavigation) { + if (this.#isProcessingTimespan) { + out.appendLine(`await lhFlow.endTimespan();`); + this.#isProcessingTimespan = false; + } + out.appendLine(`await lhFlow.startNavigation();`); + } else if (!this.#isProcessingTimespan) { + out.appendLine(`await lhFlow.startTimespan();`); + this.#isProcessingTimespan = true; + } + + await super.stringifyStep(out, step, flow); + + if (isNavigation) { + out.appendLine(`await lhFlow.endNavigation();`); + } + } + + /** + * @override + * @param {PuppeteerReplay.LineWriter} out + * @param {PuppeteerReplay.Schema.UserFlow} flow + */ + async afterAllSteps(out, flow) { + if (this.#isProcessingTimespan) { + out.appendLine(`await lhFlow.endTimespan();`); + } + out.appendLine(`const lhFlowReport = await lhFlow.generateReport();`); + out.appendLine(`fs.writeFileSync(__dirname + '/flow.report.html', lhFlowReport)`); + await super.afterAllSteps(out, flow); + } +} + +export default LighthouseStringifyExtension; diff --git a/core/test/fixtures/fraggle-rock/replay/desktop-test-flow.json b/core/test/fixtures/fraggle-rock/replay/desktop-test-flow.json new file mode 100644 index 000000000000..59664e8857d0 --- /dev/null +++ b/core/test/fixtures/fraggle-rock/replay/desktop-test-flow.json @@ -0,0 +1,102 @@ +{ + "title": "Test Flow on Desktop", + "steps": [ + { + "type": "setViewport", + "width": 757, + "height": 988, + "deviceScaleFactor": 1, + "isMobile": false, + "hasTouch": false, + "isLandscape": false + }, + { + "type": "navigate", + "url": "http://localhost:10200/flow/index.html", + "assertedEvents": [ + { + "type": "navigation", + "url": "http://localhost:10200/flow/index.html", + "title": "" + } + ] + }, + { + "type": "click", + "target": "main", + "selectors": [ + [ + "aria/Add item" + ], + [ + "#add-item" + ] + ], + "offsetY": 13.5625, + "offsetX": 61 + }, + { + "type": "click", + "target": "main", + "selectors": [ + [ + "aria/Add item" + ], + [ + "#add-item" + ] + ], + "offsetY": 4.125, + "offsetX": 42 + }, + { + "type": "click", + "target": "main", + "selectors": [ + [ + "aria/Add item" + ], + [ + "#add-item" + ] + ], + "offsetY": 8.40625, + "offsetX": 50 + }, + { + "type": "click", + "target": "main", + "selectors": [ + [ + "aria/Link to new page!" + ], + [ + "#link" + ] + ], + "offsetY": 3.6875, + "offsetX": 53.390625, + "assertedEvents": [ + { + "type": "navigation", + "url": "http://localhost:10200/flow/next.html", + "title": "" + } + ] + }, + { + "type": "click", + "target": "main", + "selectors": [ + [ + "aria/Hidden content here:" + ], + [ + "#content > summary" + ] + ], + "offsetY": 10.5625, + "offsetX": 6 + } + ] +} diff --git a/core/test/fixtures/fraggle-rock/replay/mobile-test-flow.json b/core/test/fixtures/fraggle-rock/replay/mobile-test-flow.json new file mode 100644 index 000000000000..feb17ab7deb0 --- /dev/null +++ b/core/test/fixtures/fraggle-rock/replay/mobile-test-flow.json @@ -0,0 +1,102 @@ +{ + "title": "Test Flow on Mobile", + "steps": [ + { + "type": "setViewport", + "width": 300, + "height": 600, + "deviceScaleFactor": 3, + "isMobile": true, + "hasTouch": true, + "isLandscape": false + }, + { + "type": "navigate", + "url": "http://localhost:10200/flow/index.html", + "assertedEvents": [ + { + "type": "navigation", + "url": "http://localhost:10200/flow/index.html", + "title": "" + } + ] + }, + { + "type": "click", + "target": "main", + "selectors": [ + [ + "aria/Add item" + ], + [ + "#add-item" + ] + ], + "offsetY": 13.5625, + "offsetX": 61 + }, + { + "type": "click", + "target": "main", + "selectors": [ + [ + "aria/Add item" + ], + [ + "#add-item" + ] + ], + "offsetY": 4.125, + "offsetX": 42 + }, + { + "type": "click", + "target": "main", + "selectors": [ + [ + "aria/Add item" + ], + [ + "#add-item" + ] + ], + "offsetY": 8.40625, + "offsetX": 50 + }, + { + "type": "click", + "target": "main", + "selectors": [ + [ + "aria/Link to new page!" + ], + [ + "#link" + ] + ], + "offsetY": 3.6875, + "offsetX": 53.390625, + "assertedEvents": [ + { + "type": "navigation", + "url": "http://localhost:10200/flow/next.html", + "title": "" + } + ] + }, + { + "type": "click", + "target": "main", + "selectors": [ + [ + "aria/Hidden content here:" + ], + [ + "#content > summary" + ] + ], + "offsetY": 10.5625, + "offsetX": 6 + } + ] +} diff --git a/core/test/fraggle-rock/replay/__snapshots__/stringify-extension-test.js.snap b/core/test/fraggle-rock/replay/__snapshots__/stringify-extension-test.js.snap new file mode 100644 index 000000000000..4c630d90c889 --- /dev/null +++ b/core/test/fraggle-rock/replay/__snapshots__/stringify-extension-test.js.snap @@ -0,0 +1,151 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`LighthouseStringifyExtension handles ending navigation 1`] = ` +"const fs = require('fs'); +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); + + const configContext = {\\"settingsOverrides\\":{\\"screenEmulation\\":{\\"disabled\\":true}}} + const config = undefined; + const lhApi = await import('lighthouse/core/fraggle-rock/api.js'); + const lhFlow = await lhApi.startFlow(page, {name: \\"Test Flow\\", config, configContext}); + { + const targetPage = page; + await targetPage.setViewport({\\"width\\":757,\\"height\\":988}) + } + await lhFlow.startNavigation(); + { + const targetPage = page; + const promises = []; + promises.push(targetPage.waitForNavigation()); + await targetPage.goto(\\"https://example.com\\"); + await Promise.all(promises); + } + await lhFlow.endNavigation(); + await lhFlow.startTimespan(); + { + const targetPage = page; + const element = await waitForSelectors([[\\"#button\\"]], targetPage, { timeout, visible: true }); + await scrollIntoViewIfNeeded(element, timeout); + await element.click({ + offset: { + x: 61, + y: 13.5625, + }, + }); + } + await lhFlow.endTimespan(); + await lhFlow.startNavigation(); + { + const targetPage = page; + const promises = []; + promises.push(targetPage.waitForNavigation()); + await targetPage.goto(\\"https://example.com/page/\\"); + await Promise.all(promises); + } + await lhFlow.endNavigation(); + const lhFlowReport = await lhFlow.generateReport(); + fs.writeFileSync(__dirname + '/flow.report.html', lhFlowReport) + + await " +`; + +exports[`LighthouseStringifyExtension handles ending timespan 1`] = ` +"const fs = require('fs'); +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); + + const configContext = {\\"settingsOverrides\\":{\\"screenEmulation\\":{\\"disabled\\":true}}} + const config = undefined; + const lhApi = await import('lighthouse/core/fraggle-rock/api.js'); + const lhFlow = await lhApi.startFlow(page, {name: \\"Test Flow\\", config, configContext}); + { + const targetPage = page; + await targetPage.setViewport({\\"width\\":757,\\"height\\":988}) + } + await lhFlow.startNavigation(); + { + const targetPage = page; + const promises = []; + promises.push(targetPage.waitForNavigation()); + await targetPage.goto(\\"https://example.com\\"); + await Promise.all(promises); + } + await lhFlow.endNavigation(); + await lhFlow.startTimespan(); + { + const targetPage = page; + const element = await waitForSelectors([[\\"#button\\"]], targetPage, { timeout, visible: true }); + await scrollIntoViewIfNeeded(element, timeout); + await element.click({ + offset: { + x: 61, + y: 13.5625, + }, + }); + } + await lhFlow.endTimespan(); + const lhFlowReport = await lhFlow.generateReport(); + fs.writeFileSync(__dirname + '/flow.report.html', lhFlowReport) + + await " +`; + +exports[`LighthouseStringifyExtension handles multiple sequential navigations 1`] = ` +"const fs = require('fs'); +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); + + const configContext = {\\"settingsOverrides\\":{\\"screenEmulation\\":{\\"disabled\\":true}}} + const config = undefined; + const lhApi = await import('lighthouse/core/fraggle-rock/api.js'); + const lhFlow = await lhApi.startFlow(page, {name: \\"Test Flow\\", config, configContext}); + { + const targetPage = page; + await targetPage.setViewport({\\"width\\":757,\\"height\\":988}) + } + await lhFlow.startNavigation(); + { + const targetPage = page; + const promises = []; + promises.push(targetPage.waitForNavigation()); + await targetPage.goto(\\"https://example.com\\"); + await Promise.all(promises); + } + await lhFlow.endNavigation(); + await lhFlow.startNavigation(); + { + const targetPage = page; + const promises = []; + promises.push(targetPage.waitForNavigation()); + const element = await waitForSelectors([[\\"#link\\"]], targetPage, { timeout, visible: true }); + await scrollIntoViewIfNeeded(element, timeout); + await element.click({ + offset: { + x: 61, + y: 13.5625, + }, + }); + await Promise.all(promises); + } + await lhFlow.endNavigation(); + const lhFlowReport = await lhFlow.generateReport(); + fs.writeFileSync(__dirname + '/flow.report.html', lhFlowReport) + + await " +`; diff --git a/core/test/fraggle-rock/replay/stringify-extension-test.js b/core/test/fraggle-rock/replay/stringify-extension-test.js new file mode 100644 index 000000000000..5d3a290ae48c --- /dev/null +++ b/core/test/fraggle-rock/replay/stringify-extension-test.js @@ -0,0 +1,167 @@ +/** + * @license Copyright 2022 The Lighthouse Authors. All Rights Reserved. + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + */ + +import {stringify} from '@puppeteer/replay'; + +import LighthouseStringifyExtension from '../../../fraggle-rock/replay/stringify-extension.js'; + +describe('LighthouseStringifyExtension', () => { + it('handles ending timespan', async () => { + /** @type {import('@puppeteer/replay').Schema.UserFlow} */ + const flowJson = { + title: 'Test Flow', + steps: [ + { + type: 'setViewport', + width: 757, + height: 988, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + { + type: 'navigate', + url: 'https://example.com', + assertedEvents: [ + { + type: 'navigation', + url: 'https://example.com', + title: '', + }, + ], + }, + { + type: 'click', + target: 'main', + selectors: [['#button']], + offsetY: 13.5625, + offsetX: 61, + }, + ], + }; + + const scriptContents = await stringify(flowJson, { + extension: new LighthouseStringifyExtension(), + }); + + // Trim the output to the relevant stuff + const endIndex = scriptContents.indexOf('browser.close'); + const relevantOutput = scriptContents.substring(0, endIndex); + + expect(relevantOutput).toMatchSnapshot(); + }); + + it('handles ending navigation', async () => { + /** @type {import('@puppeteer/replay').Schema.UserFlow} */ + const flowJson = { + title: 'Test Flow', + steps: [ + { + type: 'setViewport', + width: 757, + height: 988, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + { + type: 'navigate', + url: 'https://example.com', + assertedEvents: [ + { + type: 'navigation', + url: 'https://example.com', + title: '', + }, + ], + }, + { + type: 'click', + target: 'main', + selectors: [['#button']], + offsetY: 13.5625, + offsetX: 61, + }, + { + type: 'navigate', + url: 'https://example.com/page/', + assertedEvents: [ + { + type: 'navigation', + url: 'https://example.com/page/', + title: '', + }, + ], + }, + ], + }; + + const scriptContents = await stringify(flowJson, { + extension: new LighthouseStringifyExtension(), + }); + + // Trim the output to the relevant stuff + const endIndex = scriptContents.indexOf('browser.close'); + const relevantOutput = scriptContents.substring(0, endIndex); + + expect(relevantOutput).toMatchSnapshot(); + }); + + it('handles multiple sequential navigations', async () => { + /** @type {import('@puppeteer/replay').Schema.UserFlow} */ + const flowJson = { + title: 'Test Flow', + steps: [ + { + type: 'setViewport', + width: 757, + height: 988, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + { + type: 'navigate', + url: 'https://example.com', + assertedEvents: [ + { + type: 'navigation', + url: 'https://example.com', + title: '', + }, + ], + }, + { + type: 'click', + target: 'main', + selectors: [['#link']], + offsetY: 13.5625, + offsetX: 61, + assertedEvents: [ + { + type: 'navigation', + url: 'https://example.com/page', + title: '', + }, + ], + }, + ], + }; + + const scriptContents = await stringify(flowJson, { + extension: new LighthouseStringifyExtension(), + }); + + // Trim the output to the relevant stuff + const endIndex = scriptContents.indexOf('browser.close'); + const relevantOutput = scriptContents.substring(0, endIndex); + + expect(relevantOutput).toMatchSnapshot(); + }); +}); diff --git a/core/test/fraggle-rock/scenarios/__snapshots__/stringified-replay-test-pptr.js.snap b/core/test/fraggle-rock/scenarios/__snapshots__/stringified-replay-test-pptr.js.snap new file mode 100644 index 000000000000..b6f16d23b515 --- /dev/null +++ b/core/test/fraggle-rock/scenarios/__snapshots__/stringified-replay-test-pptr.js.snap @@ -0,0 +1,485 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Running the stringified output script generates a valid desktop flow report 1`] = ` +"const fs = require('fs'); +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); + + const configContext = {\\"settingsOverrides\\":{\\"screenEmulation\\":{\\"disabled\\":true}}} + const config = (await import('lighthouse/core/config/desktop-config.js')).default; + const lhApi = await import('lighthouse/core/fraggle-rock/api.js'); + const lhFlow = await lhApi.startFlow(page, {name: \\"Test Flow on Desktop\\", config, configContext}); + { + const targetPage = page; + await targetPage.setViewport({\\"width\\":757,\\"height\\":988}) + } + await lhFlow.startNavigation(); + { + const targetPage = page; + const promises = []; + promises.push(targetPage.waitForNavigation()); + await targetPage.goto(\\"http://localhost:10200/flow/index.html\\"); + await Promise.all(promises); + } + await lhFlow.endNavigation(); + await lhFlow.startTimespan(); + { + const targetPage = page; + const element = await waitForSelectors([[\\"aria/Add item\\"],[\\"#add-item\\"]], targetPage, { timeout, visible: true }); + await scrollIntoViewIfNeeded(element, timeout); + await element.click({ + offset: { + x: 61, + y: 13.5625, + }, + }); + } + { + const targetPage = page; + const element = await waitForSelectors([[\\"aria/Add item\\"],[\\"#add-item\\"]], targetPage, { timeout, visible: true }); + await scrollIntoViewIfNeeded(element, timeout); + await element.click({ + offset: { + x: 42, + y: 4.125, + }, + }); + } + { + const targetPage = page; + const element = await waitForSelectors([[\\"aria/Add item\\"],[\\"#add-item\\"]], targetPage, { timeout, visible: true }); + await scrollIntoViewIfNeeded(element, timeout); + await element.click({ + offset: { + x: 50, + y: 8.40625, + }, + }); + } + await lhFlow.endTimespan(); + await lhFlow.startNavigation(); + { + const targetPage = page; + const promises = []; + promises.push(targetPage.waitForNavigation()); + const element = await waitForSelectors([[\\"aria/Link to new page!\\"],[\\"#link\\"]], targetPage, { timeout, visible: true }); + await scrollIntoViewIfNeeded(element, timeout); + await element.click({ + offset: { + x: 53.390625, + y: 3.6875, + }, + }); + await Promise.all(promises); + } + await lhFlow.endNavigation(); + await lhFlow.startTimespan(); + { + const targetPage = page; + const element = await waitForSelectors([[\\"aria/Hidden content here:\\"],[\\"#content > summary\\"]], targetPage, { timeout, visible: true }); + await scrollIntoViewIfNeeded(element, timeout); + await element.click({ + offset: { + x: 6, + y: 10.5625, + }, + }); + } + await lhFlow.endTimespan(); + const lhFlowReport = await lhFlow.generateReport(); + fs.writeFileSync(__dirname + '/flow.report.html', lhFlowReport) + + await browser.close(); + + 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'); + } +})(); +" +`; + +exports[`Running the stringified output script generates a valid mobile flow report 1`] = ` +"const fs = require('fs'); +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); + + const configContext = {\\"settingsOverrides\\":{\\"screenEmulation\\":{\\"disabled\\":true}}} + const config = undefined; + const lhApi = await import('lighthouse/core/fraggle-rock/api.js'); + const lhFlow = await lhApi.startFlow(page, {name: \\"Test Flow on Mobile\\", config, configContext}); + { + const targetPage = page; + await targetPage.setViewport({\\"width\\":300,\\"height\\":600}) + } + await lhFlow.startNavigation(); + { + const targetPage = page; + const promises = []; + promises.push(targetPage.waitForNavigation()); + await targetPage.goto(\\"http://localhost:10200/flow/index.html\\"); + await Promise.all(promises); + } + await lhFlow.endNavigation(); + await lhFlow.startTimespan(); + { + const targetPage = page; + const element = await waitForSelectors([[\\"aria/Add item\\"],[\\"#add-item\\"]], targetPage, { timeout, visible: true }); + await scrollIntoViewIfNeeded(element, timeout); + await element.click({ + offset: { + x: 61, + y: 13.5625, + }, + }); + } + { + const targetPage = page; + const element = await waitForSelectors([[\\"aria/Add item\\"],[\\"#add-item\\"]], targetPage, { timeout, visible: true }); + await scrollIntoViewIfNeeded(element, timeout); + await element.click({ + offset: { + x: 42, + y: 4.125, + }, + }); + } + { + const targetPage = page; + const element = await waitForSelectors([[\\"aria/Add item\\"],[\\"#add-item\\"]], targetPage, { timeout, visible: true }); + await scrollIntoViewIfNeeded(element, timeout); + await element.click({ + offset: { + x: 50, + y: 8.40625, + }, + }); + } + await lhFlow.endTimespan(); + await lhFlow.startNavigation(); + { + const targetPage = page; + const promises = []; + promises.push(targetPage.waitForNavigation()); + const element = await waitForSelectors([[\\"aria/Link to new page!\\"],[\\"#link\\"]], targetPage, { timeout, visible: true }); + await scrollIntoViewIfNeeded(element, timeout); + await element.click({ + offset: { + x: 53.390625, + y: 3.6875, + }, + }); + await Promise.all(promises); + } + await lhFlow.endNavigation(); + await lhFlow.startTimespan(); + { + const targetPage = page; + const element = await waitForSelectors([[\\"aria/Hidden content here:\\"],[\\"#content > summary\\"]], targetPage, { timeout, visible: true }); + await scrollIntoViewIfNeeded(element, timeout); + await element.click({ + offset: { + x: 6, + y: 10.5625, + }, + }); + } + await lhFlow.endTimespan(); + const lhFlowReport = await lhFlow.generateReport(); + fs.writeFileSync(__dirname + '/flow.report.html', lhFlowReport) + + await browser.close(); + + 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'); + } +})(); +" +`; diff --git a/core/test/fraggle-rock/scenarios/pptr-test-utils.js b/core/test/fraggle-rock/scenarios/pptr-test-utils.js index a42446ec8579..83f06ff1bbe5 100644 --- a/core/test/fraggle-rock/scenarios/pptr-test-utils.js +++ b/core/test/fraggle-rock/scenarios/pptr-test-utils.js @@ -36,14 +36,30 @@ function createTestState() { serverBaseUrl: '', secondaryServerBaseUrl: '', - installSetupAndTeardownHooks() { + /** + * @param {number=} port + * @param {number=} secondaryPort + */ + installServerHooks(port = 0, secondaryPort = 0) { before(async () => { this.server = new Server(); this.secondaryServer = new Server(); - await this.server.listen(0, '127.0.0.1'); - await this.secondaryServer.listen(0, '127.0.0.1'); + await this.server.listen(port, '127.0.0.1'); + await this.secondaryServer.listen(secondaryPort, '127.0.0.1'); this.serverBaseUrl = `http://localhost:${this.server.getPort()}`; this.secondaryServerBaseUrl = `http://localhost:${this.secondaryServer.getPort()}`; + }); + + after(async () => { + await this.server.close(); + await this.secondaryServer.close(); + }); + }, + + installSetupAndTeardownHooks() { + this.installServerHooks(); + + before(async () => { this.browser = await puppeteer.launch({ headless: true, executablePath: getChromePath(), @@ -61,8 +77,6 @@ function createTestState() { after(async () => { await this.browser.close(); - await this.server.close(); - await this.secondaryServer.close(); }); }, }; diff --git a/core/test/fraggle-rock/scenarios/stringified-replay-test-pptr.js b/core/test/fraggle-rock/scenarios/stringified-replay-test-pptr.js new file mode 100644 index 000000000000..f533a6c43b1f --- /dev/null +++ b/core/test/fraggle-rock/scenarios/stringified-replay-test-pptr.js @@ -0,0 +1,123 @@ +/** + * @license Copyright 2022 The Lighthouse Authors. All Rights Reserved. + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + */ + +import fs from 'fs'; +import {promisify} from 'util'; +import {execFile} from 'child_process'; + +import {stringify} from '@puppeteer/replay'; + +import {LH_ROOT} from '../../../../root.js'; +import LighthouseStringifyExtension from '../../../fraggle-rock/replay/stringify-extension.js'; +import {getAuditsBreakdown, createTestState} from './pptr-test-utils.js'; +import {readJson} from '../../test-utils.js'; + +const execFileAsync = promisify(execFile); +const desktopReplayJson = readJson('core/test/fixtures/fraggle-rock/replay/desktop-test-flow.json'); +const mobileReplayJson = readJson('core/test/fixtures/fraggle-rock/replay/mobile-test-flow.json'); +const FLOW_JSON_REGEX = /window\.__LIGHTHOUSE_FLOW_JSON__ = (.*);<\/script>/; + +describe('Running the stringified output script', function() { + // eslint-disable-next-line no-invalid-this + this.timeout(60_000); + + // Flow JSON specifies port 10200 so we have to use that for the server. + const state = createTestState(); + state.installServerHooks(10200); + + const tmpDir = `${LH_ROOT}/.tmp/replay`; + let testTmpDir = ''; + let scriptPath = ''; + + before(() => { + fs.mkdirSync(tmpDir, {recursive: true}); + }); + + beforeEach(() => { + testTmpDir = fs.mkdtempSync(`${tmpDir}/replay-`); + scriptPath = `${testTmpDir}/stringified.cjs`; + }); + + after(() => { + fs.rmSync(tmpDir, {recursive: true, force: true}); + }); + + it('generates a valid desktop flow report', async () => { + const scriptContents = await stringify(desktopReplayJson, { + extension: new LighthouseStringifyExtension(), + }); + + expect(scriptContents).toMatchSnapshot(); + fs.writeFileSync(scriptPath, scriptContents); + + const {stdout, stderr} = await execFileAsync('node', [scriptPath], {timeout: 50_000}); + + // Ensure script didn't quietly report an issue. + expect(stdout).toEqual(''); + expect(stderr).toEqual(''); + + const reportHtml = fs.readFileSync(`${testTmpDir}/flow.report.html`, 'utf-8'); + const flowResultJson = FLOW_JSON_REGEX.exec(reportHtml)?.[1]; + if (!flowResultJson) throw new Error('Could not find flow json'); + + /** @type {LH.FlowResult} */ + const flowResult = JSON.parse(flowResultJson); + expect(flowResult.name).toEqual(desktopReplayJson.title); + expect(flowResult.steps.map(step => step.lhr.gatherMode)).toEqual([ + 'navigation', + 'timespan', + 'navigation', + 'timespan', + ]); + + for (const {lhr} of flowResult.steps) { + expect(lhr.configSettings.formFactor).toEqual('desktop'); + expect(lhr.configSettings.screenEmulation.disabled).toBeTruthy(); + + const {auditResults, erroredAudits} = getAuditsBreakdown(lhr); + expect(auditResults.length).toBeGreaterThanOrEqual(10); + expect(erroredAudits.length).toStrictEqual(0); + } + }); + + it('generates a valid mobile flow report', async () => { + const scriptContents = await stringify(mobileReplayJson, { + extension: new LighthouseStringifyExtension(), + }); + + expect(scriptContents).toMatchSnapshot(); + fs.writeFileSync(scriptPath, scriptContents); + + const {stdout, stderr} = await execFileAsync('node', [scriptPath], {timeout: 50_000}); + + // Ensure script didn't quietly report an issue. + expect(stdout).toEqual(''); + expect(stderr).toEqual(''); + + const reportHtml = fs.readFileSync(`${testTmpDir}/flow.report.html`, 'utf-8'); + const flowResultJson = FLOW_JSON_REGEX.exec(reportHtml)?.[1]; + if (!flowResultJson) throw new Error('Could not find flow json'); + + /** @type {LH.FlowResult} */ + const flowResult = JSON.parse(flowResultJson); + expect(flowResult.name).toEqual(mobileReplayJson.title); + expect(flowResult.steps.map(step => step.lhr.gatherMode)).toEqual([ + 'navigation', + 'timespan', + 'navigation', + 'timespan', + ]); + + for (const {lhr} of flowResult.steps) { + expect(lhr.configSettings.formFactor).toEqual('mobile'); + expect(lhr.configSettings.screenEmulation.disabled).toBeTruthy(); + + const {auditResults, erroredAudits} = getAuditsBreakdown(lhr); + expect(auditResults.length).toBeGreaterThanOrEqual(10); + expect(erroredAudits.length).toStrictEqual(0); + } + }); +}); diff --git a/package.json b/package.json index 52276edb7057..5d3dd21be31f 100644 --- a/package.json +++ b/package.json @@ -98,6 +98,7 @@ "@build-tracker/cli": "^1.0.0-beta.15", "@esbuild-kit/esm-loader": "^2.1.1", "@jest/fake-timers": "^28.1.0", + "@puppeteer/replay": "^0.6.1", "@rollup/plugin-alias": "^3.1.2", "@rollup/plugin-commonjs": "^20.0.0", "@rollup/plugin-dynamic-import-vars": "^1.1.1", diff --git a/yarn.lock b/yarn.lock index 3604895a51e0..14fdb6ea92fb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -474,6 +474,11 @@ "@types/yargs" "^15.0.0" yargs "^15.0.0" +"@colors/colors@1.5.0": + version "1.5.0" + resolved "https://registry.yarnpkg.com/@colors/colors/-/colors-1.5.0.tgz#bb504579c1cae923e6576a4f5da43d25f97bdbd9" + integrity sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ== + "@digitalbazaar/http-client@^1.1.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@digitalbazaar/http-client/-/http-client-1.1.0.tgz#cac383b24ace04b18b919deab773462b03d3d7b0" @@ -1127,6 +1132,15 @@ resolved "https://registry.yarnpkg.com/@protobufjs/utf8/-/utf8-1.1.0.tgz#a777360b5b39a1a2e5106f8e858f2fd2d060c570" integrity sha1-p3c2C1s5oaLlEG+OhY8v0tBgxXA= +"@puppeteer/replay@^0.6.1": + version "0.6.1" + resolved "https://registry.yarnpkg.com/@puppeteer/replay/-/replay-0.6.1.tgz#3debcca8b4551164858065ee14fd289ef116b0e1" + integrity sha512-FQHKi/J8M7WssRfqm080as3E+lGYYhWyMagiZPFkPDlBoTwtQGQwJhj3nVcmaMfbWZs0dYjAGukFyIA9Nmypxw== + dependencies: + cli-table3 "^0.6.2" + colorette "^2.0.19" + yargs "17.5.1" + "@rollup/plugin-alias@^3.1.2": version "3.1.2" resolved "https://registry.yarnpkg.com/@rollup/plugin-alias/-/plugin-alias-3.1.2.tgz#c585b05be4a7782d269c69d13def56f44e417772" @@ -2412,6 +2426,15 @@ clean-stack@^2.0.0: resolved "https://registry.yarnpkg.com/clean-stack/-/clean-stack-2.2.0.tgz#ee8472dbb129e727b31e8a10a427dee9dfe4008b" integrity sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A== +cli-table3@^0.6.2: + version "0.6.2" + resolved "https://registry.yarnpkg.com/cli-table3/-/cli-table3-0.6.2.tgz#aaf5df9d8b5bf12634dc8b3040806a0c07120d2a" + integrity sha512-QyavHCaIC80cMivimWu4aWHilIpiDpfm3hGmqAmXVL1UsnbLuBSMd21hTX6VY4ZSDSM73ESLeF8TOYId3rBTbw== + dependencies: + string-width "^4.2.0" + optionalDependencies: + "@colors/colors" "1.5.0" + cliui@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/cliui/-/cliui-6.0.0.tgz#511d702c0c4e41ca156d7d0e96021f23e13225b1" @@ -2462,6 +2485,11 @@ color-name@~1.1.4: resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== +colorette@^2.0.19: + version "2.0.19" + resolved "https://registry.yarnpkg.com/colorette/-/colorette-2.0.19.tgz#cdf044f47ad41a0f4b56b3a0d5b4e6e1a2d5a798" + integrity sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ== + combined-stream@^1.0.6, combined-stream@~1.0.6: version "1.0.7" resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.7.tgz#2d1d24317afb8abe95d6d2c0b07b57813539d828" @@ -7612,6 +7640,19 @@ yargs@16.2.0, yargs@^16.1.1, yargs@^16.2.0: y18n "^5.0.5" yargs-parser "^20.2.2" +yargs@17.5.1: + version "17.5.1" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.5.1.tgz#e109900cab6fcb7fd44b1d8249166feb0b36e58e" + integrity sha512-t6YAJcxDkNX7NFYiVtKvWUz8l+PaKTLiL63mJYWR2GnHq2gjEWISzsLp9wg3aY36dY1j+gfIEL3pIF+XlJJfbA== + dependencies: + cliui "^7.0.2" + escalade "^3.1.1" + get-caller-file "^2.0.5" + require-directory "^2.1.1" + string-width "^4.2.3" + y18n "^5.0.5" + yargs-parser "^21.0.0" + yargs@^15.0.0: version "15.4.1" resolved "https://registry.yarnpkg.com/yargs/-/yargs-15.4.1.tgz#0d87a16de01aee9d8bec2bfbf74f67851730f4f8"