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 0000000000..20a721beec --- /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 0000000000..25b96c7c4b --- /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 5b6390c8a9..0000000000 --- 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 0000000000..1ac4af2b3d --- /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 0000000000..c5ea7ee1a4 --- /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 58c9517232..8d65b94fd4 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 aa006cbbc7..82400bb898 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 42510f0876..8459d8571b 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 68717ca1e9..5665be4c5f 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;