From 809aeba8fce380e04aa1056954748ab87dc3dc3f Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Mon, 20 Dec 2021 09:12:19 -0500 Subject: [PATCH] Use ReactDOM Test Selector API in DevTools e2e tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds on top of the existing Playwright tests to plug in the test selector API. My goals in doing this are to... 1. Experiment with the new API to see what works and what doesn't. 2. Add some test selector attributes (and remove DOM-structure based selectors). 3. Focus the tests on DevTools itself (rather than the test app). I also took this opportunity to add a few new test types like named hooks, component search, and profiler– just to play around with the Playwright API. --- .../__tests__/__e2e__/components.test.js | 206 ++++++++++++++++++ .../__tests__/__e2e__/devtools-utils.js | 83 +++++++ .../__e2e__/inspecting-props.test.js | 52 ----- .../__tests__/__e2e__/list-app-utils.js | 25 +++ .../__tests__/__e2e__/profiler.test.js | 104 +++++++++ .../playwright.config.js | 4 +- .../src/devtools/views/Button.js | 7 +- .../views/Components/ComponentSearchInput.js | 1 + .../devtools/views/Components/EditableName.js | 1 + .../views/Components/EditableValue.js | 1 + .../src/devtools/views/Components/Element.js | 5 +- .../views/Components/InspectedElement.js | 2 +- .../Components/InspectedElementHooksTree.js | 5 +- .../Components/InspectedElementPropsTree.js | 4 +- .../views/Components/InspectedElementView.js | 6 +- .../NativeStyleEditor/AutoSizeInput.js | 3 + .../devtools/views/Profiler/RecordToggle.js | 1 + .../views/Profiler/SnapshotSelector.js | 9 +- .../src/devtools/views/SearchInput.js | 12 +- .../src/devtools/views/TabBar.js | 1 + .../src/devtools/views/Toggle.js | 3 + packages/react-devtools-shell/src/e2e/app.js | 17 +- .../src/e2e/apps/ListApp.js | 48 ++++ .../react-devtools-shell/src/e2e/devtools.js | 23 +- 24 files changed, 549 insertions(+), 74 deletions(-) create mode 100644 packages/react-devtools-inline/__tests__/__e2e__/components.test.js create mode 100644 packages/react-devtools-inline/__tests__/__e2e__/devtools-utils.js delete mode 100644 packages/react-devtools-inline/__tests__/__e2e__/inspecting-props.test.js create mode 100644 packages/react-devtools-inline/__tests__/__e2e__/list-app-utils.js create mode 100644 packages/react-devtools-inline/__tests__/__e2e__/profiler.test.js create mode 100644 packages/react-devtools-shell/src/e2e/apps/ListApp.js diff --git a/packages/react-devtools-inline/__tests__/__e2e__/components.test.js b/packages/react-devtools-inline/__tests__/__e2e__/components.test.js new file mode 100644 index 00000000000..20a721beec6 --- /dev/null +++ b/packages/react-devtools-inline/__tests__/__e2e__/components.test.js @@ -0,0 +1,206 @@ +/** @flow */ + +'use strict'; + +const listAppUtils = require('./list-app-utils'); +const devToolsUtils = require('./devtools-utils'); +const {test, expect} = require('@playwright/test'); +const config = require('../../playwright.config'); +test.use(config); +test.describe('Components', () => { + let page; + + test.beforeEach(async ({browser}) => { + page = await browser.newPage(); + + await page.goto('http://localhost:8080/e2e.html', { + waitUntil: 'domcontentloaded', + }); + + await page.waitForSelector('#iframe'); + + await devToolsUtils.clickButton(page, 'TabBarButton-components'); + }); + + test('Should display initial React components', async () => { + const appRowCount = await page.evaluate(() => { + const {createTestNameSelector, findAllNodes} = window.REACT_DOM_APP; + const container = document.getElementById('iframe').contentDocument; + const rows = findAllNodes(container, [ + createTestNameSelector('ListItem'), + ]); + return rows.length; + }); + expect(appRowCount).toBe(3); + + const devToolsRowCount = await devToolsUtils.getElementCount( + page, + 'ListItem' + ); + expect(devToolsRowCount).toBe(3); + }); + + test('Should display newly added React components', async () => { + await listAppUtils.addItem(page, 'four'); + + const count = await devToolsUtils.getElementCount(page, 'ListItem'); + expect(count).toBe(4); + }); + + test('Should allow elements to be inspected', async () => { + // Select the first list item in DevTools. + await devToolsUtils.selectElement(page, 'ListItem', 'List\nApp'); + + // Then read the inspected values. + const [propName, propValue, sourceText] = await page.evaluate(() => { + const {createTestNameSelector, findAllNodes} = window.REACT_DOM_DEVTOOLS; + const container = document.getElementById('devtools'); + + const editableName = findAllNodes(container, [ + createTestNameSelector('InspectedElementPropsTree'), + createTestNameSelector('EditableName'), + ])[0]; + const editableValue = findAllNodes(container, [ + createTestNameSelector('InspectedElementPropsTree'), + createTestNameSelector('EditableValue'), + ])[0]; + const source = findAllNodes(container, [ + createTestNameSelector('InspectedElementView-Source'), + ])[0]; + + return [editableName.value, editableValue.value, source.innerText]; + }); + + expect(propName).toBe('label'); + expect(propValue).toBe('"one"'); + expect(sourceText).toContain('ListApp.js'); + }); + + test('should allow props to be edited', async () => { + // Select the first list item in DevTools. + await devToolsUtils.selectElement(page, 'ListItem', 'List\nApp'); + + // Then edit the label prop. + await page.evaluate(() => { + const {createTestNameSelector, focusWithin} = window.REACT_DOM_DEVTOOLS; + const container = document.getElementById('devtools'); + + focusWithin(container, [ + createTestNameSelector('InspectedElementPropsTree'), + createTestNameSelector('EditableValue'), + ]); + }); + + page.keyboard.press('Backspace'); // " + page.keyboard.press('Backspace'); // e + page.keyboard.press('Backspace'); // n + page.keyboard.press('Backspace'); // o + page.keyboard.insertText('new"'); + page.keyboard.press('Enter'); + + await page.waitForFunction(() => { + const {createTestNameSelector, findAllNodes} = window.REACT_DOM_APP; + const container = document.getElementById('iframe').contentDocument; + const rows = findAllNodes(container, [ + createTestNameSelector('ListItem'), + ])[0]; + return rows.innerText === 'new'; + }); + }); + + test('should load and parse hook names for the inspected element', async () => { + // Select the List component DevTools. + await devToolsUtils.selectElement(page, 'List', 'App'); + + // Then click to load and parse hook names. + await devToolsUtils.clickButton(page, 'LoadHookNamesButton'); + + // Make sure the expected hook names are parsed and displayed eventually. + await page.waitForFunction( + hookNames => { + const { + createTestNameSelector, + findAllNodes, + } = window.REACT_DOM_DEVTOOLS; + const container = document.getElementById('devtools'); + + const hooksTree = findAllNodes(container, [ + createTestNameSelector('InspectedElementHooksTree'), + ])[0]; + + if (!hooksTree) { + return false; + } + + const hooksTreeText = hooksTree.innerText; + + for (let i = 0; i < hookNames.length; i++) { + if (!hooksTreeText.includes(hookNames[i])) { + return false; + } + } + + return true; + }, + ['State(items)', 'Ref(inputRef)'] + ); + }); + + test('should allow searching for component by name', async () => { + async function getComponentSearchResultsCount() { + return await page.evaluate(() => { + const { + createTestNameSelector, + findAllNodes, + } = window.REACT_DOM_DEVTOOLS; + const container = document.getElementById('devtools'); + + const element = findAllNodes(container, [ + createTestNameSelector('ComponentSearchInput-ResultsCount'), + ])[0]; + return element.innerText; + }); + } + + await page.evaluate(() => { + const {createTestNameSelector, focusWithin} = window.REACT_DOM_DEVTOOLS; + const container = document.getElementById('devtools'); + + focusWithin(container, [ + createTestNameSelector('ComponentSearchInput-Input'), + ]); + }); + + page.keyboard.insertText('List'); + let count = await getComponentSearchResultsCount(); + expect(count).toBe('1 | 4'); + + page.keyboard.insertText('Item'); + count = await getComponentSearchResultsCount(); + expect(count).toBe('1 | 3'); + + page.keyboard.press('Enter'); + count = await getComponentSearchResultsCount(); + expect(count).toBe('2 | 3'); + + page.keyboard.press('Enter'); + count = await getComponentSearchResultsCount(); + expect(count).toBe('3 | 3'); + + page.keyboard.press('Enter'); + count = await getComponentSearchResultsCount(); + expect(count).toBe('1 | 3'); + + page.keyboard.press('Shift+Enter'); + count = await getComponentSearchResultsCount(); + expect(count).toBe('3 | 3'); + + page.keyboard.press('Shift+Enter'); + count = await getComponentSearchResultsCount(); + expect(count).toBe('2 | 3'); + + page.keyboard.press('Shift+Enter'); + count = await getComponentSearchResultsCount(); + expect(count).toBe('1 | 3'); + }); +}); diff --git a/packages/react-devtools-inline/__tests__/__e2e__/devtools-utils.js b/packages/react-devtools-inline/__tests__/__e2e__/devtools-utils.js new file mode 100644 index 00000000000..25b96c7c4bf --- /dev/null +++ b/packages/react-devtools-inline/__tests__/__e2e__/devtools-utils.js @@ -0,0 +1,83 @@ +'use strict'; + +/** @flow */ + +async function clickButton(page, buttonTestName) { + await page.evaluate(testName => { + const {createTestNameSelector, findAllNodes} = window.REACT_DOM_DEVTOOLS; + const container = document.getElementById('devtools'); + + const button = findAllNodes(container, [ + createTestNameSelector(testName), + ])[0]; + button.click(); + }, buttonTestName); +} + +async function getElementCount(page, displayName) { + return await page.evaluate(listItemText => { + const { + createTestNameSelector, + createTextSelector, + findAllNodes, + } = window.REACT_DOM_DEVTOOLS; + const container = document.getElementById('devtools'); + const rows = findAllNodes(container, [ + createTestNameSelector('ComponentTreeListItem'), + createTextSelector(listItemText), + ]); + return rows.length; + }, displayName); +} + +async function selectElement(page, displayName, waitForOwnersText) { + await page.evaluate(listItemText => { + const { + createTestNameSelector, + createTextSelector, + findAllNodes, + } = window.REACT_DOM_DEVTOOLS; + const container = document.getElementById('devtools'); + + const listItem = findAllNodes(container, [ + createTestNameSelector('ComponentTreeListItem'), + createTextSelector(listItemText), + ])[0]; + listItem.click(); + }, displayName); + + if (waitForOwnersText) { + // Wait for selected element's props to load. + await page.waitForFunction( + ({titleText, ownersListText}) => { + const { + createTestNameSelector, + findAllNodes, + } = window.REACT_DOM_DEVTOOLS; + const container = document.getElementById('devtools'); + + const title = findAllNodes(container, [ + createTestNameSelector('InspectedElement-Title'), + ])[0]; + + const ownersList = findAllNodes(container, [ + createTestNameSelector('InspectedElementView-Owners'), + ])[0]; + + return ( + title && + title.innerText.includes(titleText) && + ownersList && + ownersList.innerText.includes(ownersListText) + ); + }, + {titleText: displayName, ownersListText: waitForOwnersText} + ); + } +} + +module.exports = { + clickButton, + getElementCount, + selectElement, +}; diff --git a/packages/react-devtools-inline/__tests__/__e2e__/inspecting-props.test.js b/packages/react-devtools-inline/__tests__/__e2e__/inspecting-props.test.js deleted file mode 100644 index 5b6390c8a93..00000000000 --- a/packages/react-devtools-inline/__tests__/__e2e__/inspecting-props.test.js +++ /dev/null @@ -1,52 +0,0 @@ -'use strict'; - -const {test, expect} = require('@playwright/test'); -const config = require('../../playwright.config'); -test.use(config); - -test.describe('Testing Todo-List App', () => { - let page, frameElementHandle, frame; - test.beforeAll(async ({browser}) => { - page = await browser.newPage(); - await page.goto('http://localhost:8080/e2e.html', { - waitUntil: 'domcontentloaded', - }); - await page.waitForSelector('iframe#iframe'); - frameElementHandle = await page.$('#iframe'); - frame = await frameElementHandle.contentFrame(); - }); - - test('The Todo List should contain 3 items by default', async () => { - const list = frame.locator('.listitem'); - await expect(list).toHaveCount(3); - }); - - test('Add another item Fourth to list', async () => { - await frame.type('.input', 'Fourth'); - await frame.click('button.iconbutton'); - const listItems = await frame.locator('.label'); - await expect(listItems).toHaveText(['First', 'Second', 'Third', 'Fourth']); - }); - - test('Inspecting list elements with devtools', async () => { - // Component props are used as string in devtools. - const listItemsProps = [ - '', - '{id: 1, isComplete: true, text: "First"}', - '{id: 2, isComplete: true, text: "Second"}', - '{id: 3, isComplete: false, text: "Third"}', - '{id: 4, isComplete: false, text: "Fourth"}', - ]; - const countOfItems = await frame.$$eval('.listitem', el => el.length); - // For every item in list click on devtools inspect icon - // click on the list item to quickly navigate to the list item component in devtools - // comparing displayed props with the array of props. - for (let i = 1; i <= countOfItems; ++i) { - await page.click('[class^=ToggleContent]', {delay: 100}); - await frame.click(`.listitem:nth-child(${i})`, {delay: 50}); - await page.waitForSelector('span[class^=Value]'); - const text = await page.innerText('span[class^=Value]'); - await expect(text).toEqual(listItemsProps[i]); - } - }); -}); diff --git a/packages/react-devtools-inline/__tests__/__e2e__/list-app-utils.js b/packages/react-devtools-inline/__tests__/__e2e__/list-app-utils.js new file mode 100644 index 00000000000..1ac4af2b3d9 --- /dev/null +++ b/packages/react-devtools-inline/__tests__/__e2e__/list-app-utils.js @@ -0,0 +1,25 @@ +'use strict'; + +/** @flow */ + +async function addItem(page, newItemText) { + await page.evaluate(text => { + const {createTestNameSelector, findAllNodes} = window.REACT_DOM_APP; + const container = document.getElementById('iframe').contentDocument; + + const input = findAllNodes(container, [ + createTestNameSelector('AddItemInput'), + ])[0]; + input.value = text; + + const button = findAllNodes(container, [ + createTestNameSelector('AddItemButton'), + ])[0]; + + button.click(); + }, newItemText); +} + +module.exports = { + addItem, +}; diff --git a/packages/react-devtools-inline/__tests__/__e2e__/profiler.test.js b/packages/react-devtools-inline/__tests__/__e2e__/profiler.test.js new file mode 100644 index 00000000000..c5ea7ee1a44 --- /dev/null +++ b/packages/react-devtools-inline/__tests__/__e2e__/profiler.test.js @@ -0,0 +1,104 @@ +/** @flow */ + +'use strict'; + +const listAppUtils = require('./list-app-utils'); +const devToolsUtils = require('./devtools-utils'); +const {test, expect} = require('@playwright/test'); +const config = require('../../playwright.config'); +test.use(config); +test.describe('Profiler', () => { + let page; + + test.beforeEach(async ({browser}) => { + page = await browser.newPage(); + + await page.goto('http://localhost:8080/e2e.html', { + waitUntil: 'domcontentloaded', + }); + + await page.waitForSelector('#iframe'); + + await devToolsUtils.clickButton(page, 'TabBarButton-profiler'); + }); + + test('should record renders and commits when active', async () => { + async function getSnapshotSelectorText() { + return await page.evaluate(() => { + const { + createTestNameSelector, + findAllNodes, + } = window.REACT_DOM_DEVTOOLS; + const container = document.getElementById('devtools'); + + const input = findAllNodes(container, [ + createTestNameSelector('SnapshotSelector-Input'), + ])[0]; + const label = findAllNodes(container, [ + createTestNameSelector('SnapshotSelector-Label'), + ])[0]; + return `${input.value}${label.innerText}`; + }); + } + + async function clickButtonAndVerifySnapshotSelecetorText( + buttonTagName, + expectedText + ) { + await devToolsUtils.clickButton(page, buttonTagName); + const text = await getSnapshotSelectorText(); + expect(text).toBe(expectedText); + } + + await devToolsUtils.clickButton(page, 'ProfilerToggleButton'); + + await listAppUtils.addItem(page, 'four'); + await listAppUtils.addItem(page, 'five'); + await listAppUtils.addItem(page, 'six'); + + await devToolsUtils.clickButton(page, 'ProfilerToggleButton'); + + await page.waitForFunction(() => { + const {createTestNameSelector, findAllNodes} = window.REACT_DOM_DEVTOOLS; + const container = document.getElementById('devtools'); + + const input = findAllNodes(container, [ + createTestNameSelector('SnapshotSelector-Input'), + ]); + + return input.length === 1; + }); + + const text = await getSnapshotSelectorText(); + expect(text).toBe('1 / 3'); + + await clickButtonAndVerifySnapshotSelecetorText( + 'SnapshotSelector-NextButton', + '2 / 3' + ); + await clickButtonAndVerifySnapshotSelecetorText( + 'SnapshotSelector-NextButton', + '3 / 3' + ); + await clickButtonAndVerifySnapshotSelecetorText( + 'SnapshotSelector-NextButton', + '1 / 3' + ); + await clickButtonAndVerifySnapshotSelecetorText( + 'SnapshotSelector-PreviousButton', + '3 / 3' + ); + await clickButtonAndVerifySnapshotSelecetorText( + 'SnapshotSelector-PreviousButton', + '2 / 3' + ); + await clickButtonAndVerifySnapshotSelecetorText( + 'SnapshotSelector-PreviousButton', + '1 / 3' + ); + await clickButtonAndVerifySnapshotSelecetorText( + 'SnapshotSelector-PreviousButton', + '3 / 3' + ); + }); +}); diff --git a/packages/react-devtools-inline/playwright.config.js b/packages/react-devtools-inline/playwright.config.js index 58c95172327..8d65b94fd48 100644 --- a/packages/react-devtools-inline/playwright.config.js +++ b/packages/react-devtools-inline/playwright.config.js @@ -1,8 +1,10 @@ const config = { use: { - headless: false, + headless: true, browserName: 'chromium', launchOptions: { + // This bit of delay gives async React time to render + // and DevTools operations to be sent across the bridge. slowMo: 100, }, }, diff --git a/packages/react-devtools-shared/src/devtools/views/Button.js b/packages/react-devtools-shared/src/devtools/views/Button.js index aa006cbbc72..82400bb8982 100644 --- a/packages/react-devtools-shared/src/devtools/views/Button.js +++ b/packages/react-devtools-shared/src/devtools/views/Button.js @@ -15,6 +15,7 @@ import Tooltip from './Components/reach-ui/tooltip'; type Props = { children: React$Node, className?: string, + testName?: ?string, title: React$Node, ... }; @@ -22,11 +23,15 @@ type Props = { export default function Button({ children, className = '', + testName, title, ...rest }: Props) { let button = ( - diff --git a/packages/react-devtools-shared/src/devtools/views/Profiler/SnapshotSelector.js b/packages/react-devtools-shared/src/devtools/views/Profiler/SnapshotSelector.js index 42510f08761..8459d8571be 100644 --- a/packages/react-devtools-shared/src/devtools/views/Profiler/SnapshotSelector.js +++ b/packages/react-devtools-shared/src/devtools/views/Profiler/SnapshotSelector.js @@ -122,6 +122,7 @@ export default function SnapshotSelector(_: Props) { const input = ( - {label} + + {label} + + + + ); +} + +function ListItem({label}) { + return
  • {label}
  • ; +} diff --git a/packages/react-devtools-shell/src/e2e/devtools.js b/packages/react-devtools-shell/src/e2e/devtools.js index 68717ca1e92..5665be4c5f1 100644 --- a/packages/react-devtools-shell/src/e2e/devtools.js +++ b/packages/react-devtools-shell/src/e2e/devtools.js @@ -1,11 +1,21 @@ import * as React from 'react'; -import {createRoot} from 'react-dom'; +import * as ReactDOM from 'react-dom'; import { activate as activateBackend, initialize as initializeBackend, } from 'react-devtools-inline/backend'; import {initialize as createDevTools} from 'react-devtools-inline/frontend'; +// This is a pretty gross hack to make the runtime loaded named-hooks-code work. +// TODO (Webpack 5) Hoepfully we can remove this once we upgrade to Webpack 5. +// $FlowFixMe +__webpack_public_path__ = '/dist/'; // eslint-disable-line no-undef + +// TODO (Webpack 5) Hopefully we can remove this prop after the Webpack 5 migration. +function hookNamesModuleLoaderFunction() { + return import('react-devtools-inline/hookNames'); +} + function inject(contentDocument, sourcePath, callback) { const script = contentDocument.createElement('script'); script.onload = callback; @@ -22,7 +32,13 @@ function init(appIframe, devtoolsContainer, appSource) { const DevTools = createDevTools(contentWindow); inject(contentDocument, appSource, () => { - createRoot(devtoolsContainer).render(); + // $FlowFixMe Flow doesn't know about createRoot() yet. + ReactDOM.createRoot(devtoolsContainer).render( + , + ); }); activateBackend(contentWindow); @@ -32,3 +48,6 @@ const iframe = document.getElementById('iframe'); const devtoolsContainer = document.getElementById('devtools'); init(iframe, devtoolsContainer, 'dist/e2e-app.js'); + +// ReactDOM Test Selector APIs used by Playwright e2e tests +window.parent.REACT_DOM_DEVTOOLS = ReactDOM;