| // Copyright 2020 The Chromium Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| import {assert} from 'chai'; |
| import * as fs from 'fs'; |
| import * as path from 'path'; |
| import type * as puppeteer from 'puppeteer-core'; |
| |
| import {GEN_DIR} from '../../conductor/paths.js'; |
| import { |
| $, |
| $$, |
| assertNotNullOrUndefined, |
| click, |
| clickElement, |
| clickMoreTabsButton, |
| getBrowserAndPages, |
| getPendingEvents, |
| getTestServerPort, |
| goToResource, |
| pasteText, |
| platform, |
| pressKey, |
| setCheckBox, |
| step, |
| timeout, |
| typeText, |
| waitFor, |
| waitForAria, |
| waitForFunction, |
| waitForFunctionWithTries, |
| waitForVisible, |
| } from '../../shared/helper.js'; |
| |
| import {openSoftContextMenuAndClickOnItem} from './context-menu-helpers.js'; |
| import {reloadDevTools} from './cross-tool-helper.js'; |
| import {veImpression} from './visual-logging-helpers.js'; |
| |
| export const ACTIVE_LINE = '.CodeMirror-activeline > pre > span'; |
| export const PAUSE_BUTTON = '[aria-label="Pause script execution"]'; |
| export const RESUME_BUTTON = '[aria-label="Resume script execution"]'; |
| export const SOURCES_LINES_SELECTOR = '.CodeMirror-code > div'; |
| export const PAUSE_INDICATOR_SELECTOR = '.paused-status'; |
| export const CODE_LINE_COLUMN_SELECTOR = '.cm-lineNumbers'; |
| export const CODE_LINE_SELECTOR = '.cm-lineNumbers .cm-gutterElement'; |
| export const SCOPE_LOCAL_VALUES_SELECTOR = 'li[aria-label="Local"] + ol'; |
| export const THREADS_SELECTOR = '[aria-label="Threads"]'; |
| export const SELECTED_THREAD_SELECTOR = 'div.thread-item.selected > div.thread-item-title'; |
| export const STEP_INTO_BUTTON = '[aria-label="Step into next function call"]'; |
| export const STEP_OVER_BUTTON = '[aria-label="Step over next function call"]'; |
| export const STEP_OUT_BUTTON = '[aria-label="Step out of current function"]'; |
| export const TURNED_OFF_PAUSE_BUTTON_SELECTOR = 'button.toolbar-state-off'; |
| export const TURNED_ON_PAUSE_BUTTON_SELECTOR = 'button.toolbar-state-on'; |
| export const DEBUGGER_PAUSED_EVENT = 'DevTools.DebuggerPaused'; |
| const WATCH_EXPRESSION_VALUE_SELECTOR = '.watch-expression-tree-item .object-value-string.value'; |
| export const OVERRIDES_TAB_SELECTOR = '[aria-label="Overrides"]'; |
| export const ENABLE_OVERRIDES_SELECTOR = '[aria-label="Select folder for overrides"]'; |
| const CLEAR_CONFIGURATION_SELECTOR = '[aria-label="Clear configuration"]'; |
| export const PAUSE_ON_UNCAUGHT_EXCEPTION_SELECTOR = '.pause-on-uncaught-exceptions'; |
| export const BREAKPOINT_ITEM_SELECTOR = '.breakpoint-item'; |
| |
| export async function toggleNavigatorSidebar(frontend: puppeteer.Page) { |
| const modifierKey = platform === 'mac' ? 'Meta' : 'Control'; |
| await frontend.keyboard.down(modifierKey); |
| await frontend.keyboard.down('Shift'); |
| await frontend.keyboard.press('y'); |
| await frontend.keyboard.up('Shift'); |
| await frontend.keyboard.up(modifierKey); |
| } |
| |
| export async function toggleDebuggerSidebar(frontend: puppeteer.Page) { |
| const modifierKey = platform === 'mac' ? 'Meta' : 'Control'; |
| await frontend.keyboard.down(modifierKey); |
| await frontend.keyboard.down('Shift'); |
| await frontend.keyboard.press('h'); |
| await frontend.keyboard.up('Shift'); |
| await frontend.keyboard.up(modifierKey); |
| } |
| |
| export async function getLineNumberElement(lineNumber: number|string) { |
| const visibleLines = await $$(CODE_LINE_SELECTOR); |
| for (let i = 0; i < visibleLines.length; i++) { |
| const lineValue = await visibleLines[i].evaluate(node => node.textContent); |
| if (lineValue === `${lineNumber}`) { |
| return visibleLines[i]; |
| } |
| } |
| return null; |
| } |
| |
| export async function doubleClickSourceTreeItem(selector: string) { |
| await click(selector, {clickOptions: {clickCount: 2, offset: {x: 40, y: 10}}}); |
| } |
| |
| export async function waitForSourcesPanel(): Promise<void> { |
| // Wait for the navigation panel to show up |
| await waitFor('.navigator-file-tree-item'); |
| } |
| |
| export async function openSourcesPanel() { |
| // Locate the button for switching to the sources tab. |
| await click('#tab-sources'); |
| |
| await waitForSourcesPanel(); |
| } |
| |
| export async function openFileInSourcesPanel(testInput: string) { |
| await goToResource(`sources/${testInput}`); |
| |
| await openSourcesPanel(); |
| } |
| |
| export async function openRecorderSubPane() { |
| const root = await waitFor('.navigator-tabbed-pane'); |
| await clickMoreTabsButton(root); |
| await click('[aria-label="Recordings"]'); |
| await waitFor('[aria-label="Add recording"]'); |
| } |
| |
| export async function createNewRecording(recordingName: string) { |
| const {frontend} = getBrowserAndPages(); |
| |
| await click('[aria-label="Add recording"]'); |
| await waitFor('[aria-label^="Recording"]'); |
| |
| await typeText(recordingName); |
| |
| await frontend.keyboard.press('Enter'); |
| } |
| |
| export async function openSnippetsSubPane() { |
| const root = await waitFor('.navigator-tabbed-pane'); |
| await clickMoreTabsButton(root); |
| await click('[aria-label="Snippets"]'); |
| await waitFor('[aria-label="New snippet"]'); |
| } |
| |
| /** |
| * Creates a new snippet, optionally pre-filling it with the provided content. |
| * `snippetName` must not contain spaces or special characters, otherwise |
| * `createNewSnippet` will time out. |
| * DevTools uses the escaped snippet name for the ARIA label. `createNewSnippet` |
| * doesn't mirror the escaping so it won't be able to wait for the snippet |
| * entry in the navigation tree to appear. |
| */ |
| export async function createNewSnippet(snippetName: string, content?: string) { |
| const {frontend} = getBrowserAndPages(); |
| |
| await click('[aria-label="New snippet"]'); |
| await waitFor('[aria-label^="Script snippet"]'); |
| |
| await typeText(snippetName); |
| |
| await frontend.keyboard.press('Enter'); |
| await waitFor(`[aria-label*="${snippetName}"]`); |
| |
| if (content) { |
| await pasteText(content); |
| await pressKey('s', {control: true}); |
| } |
| } |
| |
| export async function openWorkspaceSubPane() { |
| const root = await waitFor('.navigator-tabbed-pane'); |
| await click('[aria-label="Workspace"]', {root}); |
| await waitFor('[aria-label="Workspace panel"]'); |
| } |
| |
| export async function openOverridesSubPane() { |
| const root = await waitFor('.navigator-tabbed-pane'); |
| await clickMoreTabsButton(root); |
| await click('[aria-label="Overrides"]'); |
| await waitFor('[aria-label="Overrides panel"]'); |
| } |
| |
| export async function openFileInEditor(sourceFile: string) { |
| await waitForSourceFiles( |
| SourceFileEvents.SourceFileLoaded, files => files.some(f => f.endsWith(sourceFile)), |
| // Open a particular file in the editor |
| () => doubleClickSourceTreeItem(`[aria-label="${sourceFile}, file"]`)); |
| } |
| |
| export async function openSourceCodeEditorForFile(sourceFile: string, testInput: string) { |
| await openFileInSourcesPanel(testInput); |
| await openFileInEditor(sourceFile); |
| } |
| |
| export async function getSelectedSource(): Promise<string> { |
| const sourceTabPane = await waitFor('#sources-panel-sources-view .tabbed-pane'); |
| const sourceTabs = await waitFor('.tabbed-pane-header-tab.selected', sourceTabPane); |
| return sourceTabs.evaluate(node => node.getAttribute('aria-label')) as Promise<string>; |
| } |
| |
| export async function getBreakpointHitLocation() { |
| const breakpointHitHandle = await waitFor('.breakpoint-item.hit'); |
| const locationHandle = await waitFor('.location', breakpointHitHandle); |
| const locationText = await locationHandle.evaluate(location => location.textContent); |
| |
| const groupHandle = await breakpointHitHandle.evaluateHandle(x => x.parentElement); |
| const groupHeaderTitleHandle = await waitFor('.group-header-title', groupHandle); |
| const groupHeaderTitle = await groupHeaderTitleHandle?.evaluate(header => header.textContent); |
| |
| return `${groupHeaderTitle}:${locationText}`; |
| } |
| |
| export async function getOpenSources() { |
| const sourceTabPane = await waitFor('#sources-panel-sources-view .tabbed-pane'); |
| const sourceTabs = await waitFor('.tabbed-pane-header-tabs', sourceTabPane); |
| const openSources = |
| await sourceTabs.$$eval('.tabbed-pane-header-tab', nodes => nodes.map(n => n.getAttribute('aria-label'))); |
| return openSources; |
| } |
| |
| export async function waitForHighlightedLine(lineNumber: number) { |
| await waitForFunction(async () => { |
| const selectedLine = await waitFor('.cm-highlightedLine'); |
| const currentlySelectedLineNumber = await selectedLine.evaluate(line => { |
| return [...line.parentElement?.childNodes || []].indexOf(line); |
| }); |
| const lineNumbers = await waitFor('.cm-lineNumbers'); |
| const text = await lineNumbers.evaluate( |
| (node, lineNumber) => node.childNodes[lineNumber].textContent, currentlySelectedLineNumber + 1); |
| return Number(text) === lineNumber; |
| }); |
| } |
| |
| export async function getToolbarText() { |
| const toolbar = await waitFor('.sources-toolbar'); |
| if (!toolbar) { |
| return []; |
| } |
| const textNodes = await $$('.toolbar-text', toolbar); |
| return Promise.all(textNodes.map(node => node.evaluate(node => node.textContent, node))); |
| } |
| |
| export async function addBreakpointForLine(frontend: puppeteer.Page, index: number|string) { |
| const breakpointLine = await getLineNumberElement(index); |
| assertNotNullOrUndefined(breakpointLine); |
| |
| await waitForFunction(async () => !(await isBreakpointSet(index))); |
| await clickElement(breakpointLine); |
| |
| await waitForFunction(async () => await isBreakpointSet(index)); |
| } |
| |
| export async function removeBreakpointForLine(frontend: puppeteer.Page, index: number|string) { |
| const breakpointLine = await getLineNumberElement(index); |
| assertNotNullOrUndefined(breakpointLine); |
| |
| await waitForFunction(async () => await isBreakpointSet(index)); |
| await clickElement(breakpointLine); |
| await waitForFunction(async () => !(await isBreakpointSet(index))); |
| } |
| |
| export async function addLogpointForLine(index: number, condition: string) { |
| const {frontend} = getBrowserAndPages(); |
| const breakpointLine = await getLineNumberElement(index); |
| assertNotNullOrUndefined(breakpointLine); |
| |
| await waitForFunction(async () => !(await isBreakpointSet(index))); |
| await clickElement(breakpointLine, {clickOptions: {button: 'right'}}); |
| |
| await click('aria/Add logpoint…'); |
| |
| const editDialog = await waitFor('.sources-edit-breakpoint-dialog'); |
| const conditionEditor = await waitForAria('Code editor', editDialog); |
| await conditionEditor.focus(); |
| |
| await typeText(condition); |
| await frontend.keyboard.press('Enter'); |
| |
| await waitForFunction(async () => await isBreakpointSet(index)); |
| } |
| |
| export async function isBreakpointSet(lineNumber: number|string) { |
| const lineNumberElement = await getLineNumberElement(lineNumber); |
| const breakpointLineParentClasses = await lineNumberElement?.evaluate(n => n.className); |
| return breakpointLineParentClasses?.includes('cm-breakpoint'); |
| } |
| |
| /** |
| * @param lineNumber 1-based line number |
| * @param index 1-based index of the inline breakpoint in the given line |
| */ |
| export async function enableInlineBreakpointForLine(line: number, index: number) { |
| const {frontend} = getBrowserAndPages(); |
| const decorationSelector = `pierce/.cm-content > :nth-child(${line}) > :nth-child(${index} of .cm-inlineBreakpoint)`; |
| await click(decorationSelector); |
| await waitForFunction( |
| () => frontend.$eval(decorationSelector, element => !element.classList.contains('cm-inlineBreakpoint-disabled'))); |
| } |
| |
| /** |
| * @param lineNumber 1-based line number |
| * @param index 1-based index of the inline breakpoint in the given line |
| * @param expectNoBreakpoint If we should wait for the line to not have any inline breakpoints after |
| * the click instead of a disabled one. |
| */ |
| export async function disableInlineBreakpointForLine(line: number, index: number, expectNoBreakpoint: boolean = false) { |
| const {frontend} = getBrowserAndPages(); |
| const decorationSelector = `pierce/.cm-content > :nth-child(${line}) > :nth-child(${index} of .cm-inlineBreakpoint)`; |
| await click(decorationSelector); |
| if (expectNoBreakpoint) { |
| await waitForFunction( |
| () => frontend.$$eval( |
| `pierce/.cm-content > :nth-child(${line}) > .cm-inlineBreakpoint`, elements => elements.length === 0)); |
| } else { |
| await waitForFunction( |
| () => |
| frontend.$eval(decorationSelector, element => element.classList.contains('cm-inlineBreakpoint-disabled'))); |
| } |
| } |
| |
| export async function checkBreakpointDidNotActivate() { |
| await step('check that the script did not pause', async () => { |
| // TODO(almuthanna): make sure this check happens at a point where the pause indicator appears if it was active |
| const pauseIndicators = await $$(PAUSE_INDICATOR_SELECTOR); |
| const breakpointIndicator = await Promise.all(pauseIndicators.map(elements => { |
| return elements.evaluate(el => el.className); |
| })); |
| assert.deepEqual(breakpointIndicator.length, 0, 'script had been paused'); |
| }); |
| } |
| |
| export async function getBreakpointDecorators(disabledOnly = false) { |
| const selector = `.cm-breakpoint${disabledOnly ? '-disabled' : ''}`; |
| const breakpointDecorators = await $$(selector); |
| return await Promise.all( |
| breakpointDecorators.map(breakpointDecorator => breakpointDecorator.evaluate(n => Number(n.textContent)))); |
| } |
| |
| export async function getNonBreakableLines() { |
| const selector = '.cm-nonBreakableLine'; |
| await waitFor(selector); |
| const unbreakableLines = await $$(selector); |
| return await Promise.all( |
| unbreakableLines.map(unbreakableLine => unbreakableLine.evaluate(n => Number(n.textContent)))); |
| } |
| |
| export async function executionLineHighlighted() { |
| return await waitFor('.cm-executionLine'); |
| } |
| |
| export async function getCallFrameNames() { |
| const selector = '.call-frame-item:not(.hidden) .call-frame-item-title'; |
| await waitFor(selector); |
| const items = await $$(selector); |
| const promises = items.map(handle => handle.evaluate(el => el.textContent as string)); |
| const results = []; |
| for (const promise of promises) { |
| results.push(await promise); |
| } |
| return results; |
| } |
| |
| export async function getCallFrameLocations() { |
| const selector = '.call-frame-item:not(.hidden) .call-frame-location'; |
| await waitFor(selector); |
| const items = await $$(selector); |
| const promises = items.map(handle => handle.evaluate(el => el.textContent as string)); |
| const results = []; |
| for (const promise of promises) { |
| results.push(await promise); |
| } |
| return results; |
| } |
| |
| export async function switchToCallFrame(index: number) { |
| const selector = `.call-frame-item[aria-posinset="${index}"]`; |
| await click(selector); |
| await waitFor(selector + '[aria-selected="true"]'); |
| } |
| |
| export async function retrieveTopCallFrameScriptLocation(script: string, target: puppeteer.Page) { |
| // The script will run into a breakpoint, which means that it will not actually |
| // finish the evaluation, until we continue executing. |
| // Thus, we have to await it at a later point, while stepping through the code. |
| const scriptEvaluation = target.evaluate(script); |
| |
| // Wait for the evaluation to be paused and shown in the UI |
| // and retrieve the top level call frame script location name |
| const scriptLocation = await retrieveTopCallFrameWithoutResuming(); |
| |
| // Resume the evaluation |
| await click(RESUME_BUTTON); |
| |
| // Make sure to await the context evaluate before asserting |
| // Otherwise the Puppeteer process might crash on a failure assertion, |
| // as its execution context is destroyed |
| await scriptEvaluation; |
| |
| return scriptLocation; |
| } |
| |
| export async function retrieveTopCallFrameWithoutResuming() { |
| // Wait for the evaluation to be paused and shown in the UI |
| await waitFor(PAUSE_INDICATOR_SELECTOR); |
| |
| // Retrieve the top level call frame script location name |
| const locationHandle = await waitFor('.call-frame-location'); |
| const scriptLocation = await locationHandle.evaluate(location => location.textContent); |
| |
| return scriptLocation; |
| } |
| |
| export async function waitForStackTopMatch(matcher: RegExp) { |
| // The call stack is updated asynchronously, so let us wait until we see the correct one |
| // (or report the last one we have seen before timeout). |
| let stepLocation = '<no call stack>'; |
| await waitForFunctionWithTries(async () => { |
| stepLocation = await retrieveTopCallFrameWithoutResuming() ?? '<invalid>'; |
| return stepLocation?.match(matcher); |
| }, {tries: 10}); |
| return stepLocation; |
| } |
| |
| export async function setEventListenerBreakpoint(groupName: string, eventName: string) { |
| const {frontend} = getBrowserAndPages(); |
| const eventListenerBreakpointsSection = await waitForAria('Event Listener Breakpoints'); |
| const expanded = await eventListenerBreakpointsSection.evaluate(el => el.getAttribute('aria-expanded')); |
| if (expanded !== 'true') { |
| await click('[aria-label="Event Listener Breakpoints"]'); |
| await waitFor('[aria-label="Event Listener Breakpoints"][aria-expanded="true"]'); |
| } |
| |
| const eventSelector = `input[type="checkbox"][title="${eventName}"]`; |
| const groupSelector = `input[type="checkbox"][title="${groupName}"]`; |
| const groupCheckbox = await waitFor(groupSelector); |
| await waitForVisible(groupSelector); |
| const eventCheckbox = await waitFor(eventSelector); |
| if (!(await eventCheckbox.evaluate(x => x.checkVisibility()))) { |
| // Unfortunately the shadow DOM makes it hard to find the expander element |
| // we are attempting to click on, so we click to the left of the checkbox |
| // bounding box. |
| const rectData = await groupCheckbox.evaluate(element => { |
| const {left, top, width, height} = element.getBoundingClientRect(); |
| return {left, top, width, height}; |
| }); |
| |
| await frontend.mouse.click(rectData.left - 10, rectData.top + rectData.height * .5); |
| await waitForVisible(eventSelector); |
| } |
| |
| await setCheckBox(eventSelector, true); |
| } |
| |
| declare global { |
| // eslint-disable-next-line @typescript-eslint/no-unused-vars |
| interface Window { |
| /* eslint-disable @typescript-eslint/naming-convention */ |
| __sourceFileEvents: Map<number, {files: string[], handler: (e: Event) => void}>; |
| /* eslint-enable @typescript-eslint/naming-convention */ |
| } |
| } |
| |
| export const enum SourceFileEvents { |
| SourceFileLoaded = 'source-file-loaded', |
| AddedToSourceTree = 'source-tree-file-added', |
| } |
| |
| let nextEventHandlerId = 0; |
| export async function waitForSourceFiles<T>( |
| eventName: SourceFileEvents, waitCondition: (files: string[]) => boolean | Promise<boolean>, |
| action: () => T): Promise<T> { |
| const {frontend} = getBrowserAndPages(); |
| const eventHandlerId = nextEventHandlerId++; |
| |
| // Install new listener for the event |
| await frontend.evaluate((eventName, eventHandlerId) => { |
| if (!window.__sourceFileEvents) { |
| window.__sourceFileEvents = new Map(); |
| } |
| const handler = (event: Event) => { |
| const {detail} = event as CustomEvent<string>; |
| if (!detail.includes('pptr:')) { |
| window.__sourceFileEvents.get(eventHandlerId)?.files.push(detail); |
| } |
| }; |
| window.__sourceFileEvents.set(eventHandlerId, {files: [], handler}); |
| window.addEventListener(eventName, handler); |
| }, eventName, eventHandlerId); |
| |
| const result = await action(); |
| |
| await waitForFunction(async () => { |
| const files = |
| await frontend.evaluate(eventHandlerId => window.__sourceFileEvents.get(eventHandlerId)?.files, eventHandlerId); |
| assertNotNullOrUndefined(files); |
| return await waitCondition(files); |
| }); |
| |
| await frontend.evaluate((eventName, eventHandlerId) => { |
| const handler = window.__sourceFileEvents.get(eventHandlerId); |
| if (!handler) { |
| throw new Error('handler unexpectandly unregistered'); |
| } |
| window.__sourceFileEvents.delete(eventHandlerId); |
| window.removeEventListener(eventName, handler.handler); |
| }, eventName, eventHandlerId); |
| |
| return result; |
| } |
| |
| export async function captureAddedSourceFiles(count: number, action: () => Promise<void>): Promise<string[]> { |
| let capturedFileNames!: string[]; |
| await waitForSourceFiles(SourceFileEvents.AddedToSourceTree, files => { |
| capturedFileNames = files; |
| return files.length >= count; |
| }, action); |
| return capturedFileNames.map(f => new URL(`http://${f}`).pathname); |
| } |
| |
| export async function reloadPageAndWaitForSourceFile(target: puppeteer.Page, sourceFile: string) { |
| await waitForSourceFiles( |
| SourceFileEvents.SourceFileLoaded, files => files.some(f => f.endsWith(sourceFile)), () => target.reload()); |
| } |
| |
| export function isEqualOrAbbreviation(abbreviated: string, full: string): boolean { |
| const split = abbreviated.split('…'); |
| if (split.length === 1) { |
| return abbreviated === full; |
| } |
| assert.lengthOf(split, 2); |
| return full.startsWith(split[0]) && full.endsWith(split[1]); |
| } |
| |
| // Helpers for navigating the file tree. |
| export type NestedFileSelector = { |
| rootSelector: string, |
| domainSelector: string, |
| folderSelector?: string, fileSelector: string, |
| }; |
| |
| export function createSelectorsForWorkerFile( |
| workerName: string, folderName: string, fileName: string, workerIndex = 1): NestedFileSelector { |
| const rootSelector = new Array(workerIndex).fill(`[aria-label="${workerName}, worker"]`).join(' ~ '); |
| const domainSelector = `${rootSelector} + ol > [aria-label="localhost:${getTestServerPort()}, domain"]`; |
| const folderSelector = `${domainSelector} + ol > [aria-label^="${folderName}, "]`; |
| const fileSelector = `${folderSelector} + ol > [aria-label="${fileName}, file"]`; |
| |
| return { |
| rootSelector, |
| domainSelector, |
| folderSelector, |
| fileSelector, |
| }; |
| } |
| |
| async function isExpanded(sourceTreeItem: puppeteer.ElementHandle<Element>): Promise<boolean> { |
| return await sourceTreeItem.evaluate(element => { |
| return element.getAttribute('aria-expanded') === 'true'; |
| }); |
| } |
| |
| export async function expandSourceTreeItem(selector: string) { |
| // FIXME(crbug/1112692): Refactor test to remove the timeout. |
| await timeout(50); |
| const sourceTreeItem = await waitFor(selector); |
| if (!await isExpanded(sourceTreeItem)) { |
| // FIXME(crbug/1112692): Refactor test to remove the timeout. |
| await timeout(50); |
| await doubleClickSourceTreeItem(selector); |
| } |
| } |
| |
| export async function expandFileTree(selectors: NestedFileSelector) { |
| await expandSourceTreeItem(selectors.rootSelector); |
| await expandSourceTreeItem(selectors.domainSelector); |
| if (selectors.folderSelector) { |
| await expandSourceTreeItem(selectors.folderSelector); |
| } |
| // FIXME(crbug/1112692): Refactor test to remove the timeout. |
| await timeout(50); |
| return await waitFor(selectors.fileSelector); |
| } |
| |
| export async function readSourcesTreeView(): Promise<string[]> { |
| const items = await $$('.navigator-folder-tree-item,.navigator-file-tree-item'); |
| const promises = items.map(handle => handle.evaluate(el => el.textContent as string)); |
| const results = await Promise.all(promises); |
| return results.map(item => item.replace(/localhost:[0-9]+/, 'localhost:XXXX')); |
| } |
| |
| export async function readIgnoreListedSources(): Promise<string[]> { |
| const items = await $$('.navigator-folder-tree-item.is-ignore-listed,.navigator-file-tree-item.is-ignore-listed'); |
| const promises = items.map(handle => handle.evaluate(el => el.textContent as string)); |
| const results = await Promise.all(promises); |
| return results.map(item => item.replace(/localhost:[0-9]+/, 'localhost:XXXX')); |
| } |
| |
| async function hasPausedEvents(frontend: puppeteer.Page): Promise<boolean> { |
| const events = await getPendingEvents(frontend, DEBUGGER_PAUSED_EVENT); |
| return Boolean(events && events.length); |
| } |
| |
| export async function stepThroughTheCode() { |
| const {frontend} = getBrowserAndPages(); |
| await getPendingEvents(frontend, DEBUGGER_PAUSED_EVENT); |
| await frontend.keyboard.press('F9'); |
| await waitForFunction(() => hasPausedEvents(frontend)); |
| await waitFor(PAUSE_INDICATOR_SELECTOR); |
| } |
| |
| export async function stepIn() { |
| const {frontend} = getBrowserAndPages(); |
| await getPendingEvents(frontend, DEBUGGER_PAUSED_EVENT); |
| await frontend.keyboard.press('F11'); |
| await waitForFunction(() => hasPausedEvents(frontend)); |
| await waitFor(PAUSE_INDICATOR_SELECTOR); |
| } |
| |
| export async function stepOver() { |
| const {frontend} = getBrowserAndPages(); |
| await getPendingEvents(frontend, DEBUGGER_PAUSED_EVENT); |
| await frontend.keyboard.press('F10'); |
| await waitForFunction(() => hasPausedEvents(frontend)); |
| await waitFor(PAUSE_INDICATOR_SELECTOR); |
| } |
| |
| export async function stepOut() { |
| const {frontend} = getBrowserAndPages(); |
| await getPendingEvents(frontend, DEBUGGER_PAUSED_EVENT); |
| await frontend.keyboard.down('Shift'); |
| await frontend.keyboard.press('F11'); |
| await frontend.keyboard.up('Shift'); |
| await waitForFunction(() => hasPausedEvents(frontend)); |
| |
| await waitFor(PAUSE_INDICATOR_SELECTOR); |
| } |
| |
| export async function openNestedWorkerFile(selectors: NestedFileSelector) { |
| await expandFileTree(selectors); |
| // FIXME(crbug/1112692): Refactor test to remove the timeout. |
| await timeout(50); |
| await click(selectors.fileSelector); |
| } |
| |
| export async function inspectMemory(variableName: string) { |
| await openSoftContextMenuAndClickOnItem( |
| `[data-object-property-name-for-test="${variableName}"]`, 'Reveal in Memory inspector panel'); |
| } |
| |
| export async function typeIntoSourcesAndSave(text: string) { |
| const pane = await waitFor('.sources'); |
| await pane.type(text); |
| |
| await pressKey('s', {control: true}); |
| } |
| |
| export async function getScopeNames() { |
| const scopeElements = await $$('.scope-chain-sidebar-pane-section-title'); |
| const scopeNames = await Promise.all(scopeElements.map(nodes => nodes.evaluate(n => n.textContent))); |
| return scopeNames; |
| } |
| |
| export async function getValuesForScope(scope: string, expandCount: number, waitForNoOfValues: number) { |
| const scopeSelector = `[aria-label="${scope}"]`; |
| await waitFor(scopeSelector); |
| for (let i = 0; i < expandCount; i++) { |
| await click(`${scopeSelector} + ol li[aria-expanded=false]`); |
| } |
| const valueSelector = `${scopeSelector} + ol .name-and-value`; |
| const valueSelectorElements = await waitForFunction(async () => { |
| const elements = await $$(valueSelector); |
| if (elements.length >= waitForNoOfValues) { |
| return elements; |
| } |
| return undefined; |
| }); |
| const values = await Promise.all(valueSelectorElements.map(elem => elem.evaluate(n => n.textContent as string))); |
| return values; |
| } |
| |
| export async function getPausedMessages() { |
| const {frontend} = getBrowserAndPages(); |
| const messageElement = await frontend.waitForSelector('.paused-message'); |
| if (!messageElement) { |
| assert.fail('getPausedMessages: did not find .paused-message element.'); |
| } |
| const statusMain = await waitFor('.status-main', messageElement); |
| const statusSub = await waitFor('.status-sub', messageElement); |
| return { |
| statusMain: await statusMain.evaluate(x => x.textContent), |
| statusSub: await statusSub.evaluate(x => x.textContent), |
| }; |
| } |
| |
| export async function getWatchExpressionsValues() { |
| const {frontend} = getBrowserAndPages(); |
| await waitForFunction(async () => { |
| const expandedOption = await $('[aria-label="Watch"].expanded'); |
| if (expandedOption) { |
| return true; |
| } |
| await click('[aria-label="Watch"]'); |
| // Wait for the click event to settle. |
| await timeout(100); |
| return expandedOption !== null; |
| }); |
| await frontend.keyboard.press('ArrowRight'); |
| const watchExpressionValue = await $(WATCH_EXPRESSION_VALUE_SELECTOR); |
| if (!watchExpressionValue) { |
| return null; |
| } |
| const values = await $$(WATCH_EXPRESSION_VALUE_SELECTOR) as puppeteer.ElementHandle<HTMLElement>[]; |
| return await Promise.all(values.map(value => value.evaluate(element => element.innerText))); |
| } |
| |
| export async function runSnippet() { |
| const {frontend} = getBrowserAndPages(); |
| const modifierKey = platform === 'mac' ? 'Meta' : 'Control'; |
| await frontend.keyboard.down(modifierKey); |
| await frontend.keyboard.press('Enter'); |
| await frontend.keyboard.up(modifierKey); |
| } |
| |
| export async function evaluateSelectedTextInConsole() { |
| const {frontend} = getBrowserAndPages(); |
| const modifierKey = platform === 'mac' ? 'Meta' : 'Control'; |
| await frontend.keyboard.down(modifierKey); |
| await frontend.keyboard.down('Shift'); |
| await frontend.keyboard.press('E'); |
| await frontend.keyboard.up(modifierKey); |
| await frontend.keyboard.up('Shift'); |
| } |
| |
| export async function addSelectedTextToWatches() { |
| const {frontend} = getBrowserAndPages(); |
| const modifierKey = platform === 'mac' ? 'Meta' : 'Control'; |
| await frontend.keyboard.down(modifierKey); |
| await frontend.keyboard.down('Shift'); |
| await frontend.keyboard.press('A'); |
| await frontend.keyboard.up(modifierKey); |
| await frontend.keyboard.up('Shift'); |
| } |
| |
| export async function refreshDevToolsAndRemoveBackendState(target: puppeteer.Page) { |
| // Navigate to a different site to make sure that back-end state will be removed. |
| await target.goto('about:blank'); |
| await reloadDevTools({selectedPanel: {name: 'sources'}}); |
| } |
| |
| export async function enableLocalOverrides() { |
| await clickMoreTabsButton(); |
| await click(OVERRIDES_TAB_SELECTOR); |
| await click(ENABLE_OVERRIDES_SELECTOR); |
| await waitFor(CLEAR_CONFIGURATION_SELECTOR); |
| } |
| |
| export type LabelMapping = { |
| label: string, |
| moduleOffset: number, |
| bytecode: number, |
| sourceLine: number, |
| labelLine: number, |
| labelColumn: number, |
| }; |
| |
| export class WasmLocationLabels { |
| readonly #mappings: Map<string, LabelMapping[]>; |
| readonly #source: string; |
| readonly #wasm: string; |
| constructor(source: string, wasm: string, mappings: Map<string, LabelMapping[]>) { |
| this.#mappings = mappings; |
| this.#source = source; |
| this.#wasm = wasm; |
| } |
| |
| static load(source: string, wasm: string): WasmLocationLabels { |
| const mapFileName = path.join(GEN_DIR, 'test', 'e2e', 'resources', `${wasm}.map.json`); |
| const mapFile = JSON.parse(fs.readFileSync(mapFileName, {encoding: 'utf-8'})) as Array<{ |
| source: string, |
| generatedLine: number, |
| generatedColumn: number, |
| bytecodeOffset: number, |
| originalLine: number, |
| originalColumn: number, |
| }>; |
| const sourceFileName = path.join(GEN_DIR, 'test', 'e2e', 'resources', source); |
| const sourceFile = fs.readFileSync(sourceFileName, {encoding: 'utf-8'}); |
| const labels = new Map<string, number>(); |
| for (const [index, line] of sourceFile.split('\n').entries()) { |
| if (line.trim().startsWith(';;@')) { |
| const label = line.trim().substr(3).trim(); |
| assert.isFalse(labels.has(label), `Label ${label} must be unique`); |
| labels.set(label, index + 1); |
| } |
| } |
| const mappings = new Map<string, LabelMapping[]>(); |
| for (const m of mapFile) { |
| const entry = mappings.get(m.source) ?? []; |
| if (entry.length === 0) { |
| mappings.set(m.source, entry); |
| } |
| const labelLine = m.originalLine as number; |
| const labelColumn = m.originalColumn as number; |
| const sourceLine = labels.get(`${m.source}:${labelLine}:${labelColumn}`); |
| assertNotNullOrUndefined(sourceLine); |
| entry.push({ |
| label: m.source, |
| moduleOffset: m.generatedColumn, |
| bytecode: m.bytecodeOffset, |
| sourceLine, |
| labelLine, |
| labelColumn, |
| }); |
| } |
| return new WasmLocationLabels(source, wasm, mappings); |
| } |
| |
| async checkLocationForLabel(label: string) { |
| const pauseLocation = await retrieveTopCallFrameWithoutResuming(); |
| const pausedLine = this.#mappings.get(label)!.find( |
| line => pauseLocation === `${path.basename(this.#wasm)}:0x${line.moduleOffset.toString(16)}` || |
| pauseLocation === `${path.basename(this.#source)}:${line.sourceLine}`); |
| assertNotNullOrUndefined(pausedLine); |
| return pausedLine; |
| } |
| |
| async addBreakpointsForLabelInSource(label: string) { |
| const {frontend} = getBrowserAndPages(); |
| await openFileInEditor(path.basename(this.#source)); |
| await Promise.all(this.#mappings.get(label)!.map(({sourceLine}) => addBreakpointForLine(frontend, sourceLine))); |
| } |
| |
| async addBreakpointsForLabelInWasm(label: string) { |
| const {frontend} = getBrowserAndPages(); |
| await openFileInEditor(path.basename(this.#wasm)); |
| const visibleLines = await $$(CODE_LINE_SELECTOR); |
| const lineNumbers = await Promise.all(visibleLines.map(line => line.evaluate(node => node.textContent))); |
| const lineNumberLabels = new Map(lineNumbers.map(label => [Number(label), label])); |
| await Promise.all(this.#mappings.get(label)!.map( |
| ({moduleOffset}) => addBreakpointForLine(frontend, lineNumberLabels.get(moduleOffset)!))); |
| } |
| |
| async setBreakpointInSourceAndRun(label: string, script: string) { |
| const {target} = getBrowserAndPages(); |
| await this.addBreakpointsForLabelInSource(label); |
| |
| target.evaluate(script); |
| await this.checkLocationForLabel(label); |
| } |
| |
| async setBreakpointInWasmAndRun(label: string, script: string) { |
| const {target} = getBrowserAndPages(); |
| await this.addBreakpointsForLabelInWasm(label); |
| |
| target.evaluate(script); |
| await this.checkLocationForLabel(label); |
| } |
| |
| async continueAndCheckForLabel(label: string) { |
| await click(RESUME_BUTTON); |
| await this.checkLocationForLabel(label); |
| } |
| |
| getMappingsForPlugin(): LabelMapping[] { |
| return Array.from(this.#mappings.values()).flat(); |
| } |
| } |
| |
| export async function retrieveCodeMirrorEditorContent(): Promise<Array<string>> { |
| const editor = await waitFor('[aria-label="Code editor"]'); |
| return await editor.evaluate( |
| node => [...node.querySelectorAll('.cm-line')].map(node => node.textContent || '') || []); |
| } |
| |
| export async function waitForLines(lineCount: number): Promise<void> { |
| await waitFor(new Array(lineCount).fill('.cm-line').join(' ~ ')); |
| } |
| |
| export async function isPrettyPrinted(): Promise<boolean> { |
| const prettyButton = await waitFor('[aria-label="Pretty print"]'); |
| const isPretty = await prettyButton.evaluate(e => e.ariaPressed); |
| return isPretty === 'true'; |
| } |
| |
| export function veImpressionForSourcesPanel() { |
| return veImpression('Panel', 'sources', [ |
| veImpression( |
| 'Toolbar', 'debug', |
| [ |
| veImpression('Toggle', 'debugger.toggle-pause'), |
| veImpression('Action', 'debugger.step-over'), |
| veImpression('Action', 'debugger.step-into'), |
| veImpression('Action', 'debugger.step-out'), |
| veImpression('Action', 'debugger.step'), |
| veImpression('Toggle', 'debugger.toggle-breakpoints-active'), |
| ]), |
| veImpression( |
| 'Pane', 'debug', |
| [ |
| veImpression('SectionHeader', 'sources.watch'), |
| veImpression('SectionHeader', 'sources.js-breakpoints'), |
| veImpression('SectionHeader', 'sources.scope-chain'), |
| veImpression('SectionHeader', 'sources.callstack'), |
| veImpression('SectionHeader', 'sources.xhr-breakpoints'), |
| veImpression('SectionHeader', 'sources.dom-breakpoints'), |
| veImpression('SectionHeader', 'sources.global-listeners'), |
| veImpression('SectionHeader', 'sources.event-listener-breakpoints'), |
| veImpression('SectionHeader', 'sources.csp-violation-breakpoints'), |
| veImpression('Section', 'sources.scope-chain'), |
| veImpression('Section', 'sources.callstack'), |
| veImpression( |
| 'Section', 'sources.js-breakpoints', |
| [ |
| veImpression('Toggle', 'pause-uncaught'), |
| veImpression('Toggle', 'pause-on-caught-exception'), |
| ]), |
| ]), |
| veImpression( |
| 'Pane', 'editor', |
| [ |
| veImpression('Toolbar', 'bottom'), |
| veImpression( |
| 'Toolbar', 'top', |
| [ |
| veImpression('ToggleSubpane', 'navigator'), |
| veImpression('ToggleSubpane', 'debugger'), |
| ]), |
| ]), |
| veImpression( |
| 'Toolbar', 'navigator', |
| [ |
| veImpression('DropDown', 'more-tabs'), |
| veImpression('PanelTabHeader', 'navigator-network'), |
| veImpression('PanelTabHeader', 'navigator-files'), |
| veImpression('DropDown', 'more-options'), |
| ]), |
| veImpression( |
| 'Pane', 'navigator-network', |
| [ |
| veImpression( |
| 'Tree', undefined, |
| [ |
| veImpression( |
| 'TreeItem', 'frame', |
| [ |
| veImpression('Expand'), |
| veImpression( |
| 'TreeItem', 'domain', |
| [ |
| veImpression('Expand'), |
| veImpression('TreeItem', 'document', [ |
| veImpression('Value', 'title'), |
| ]), |
| ]), |
| ]), |
| ]), |
| ]), |
| ]); |
| } |