| // Copyright 2023 The Chromium Authors |
| // 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 type {ElementHandle} from 'puppeteer-core'; |
| import {TestConfig} from 'test/conductor/test_config.js'; |
| import { |
| CONSOLE_TAB_SELECTOR, |
| focusConsolePrompt, |
| typeIntoConsoleAndWaitForResult, |
| } from 'test/e2e/helpers/console-helpers.js'; |
| import { |
| addBreakpointForLine, |
| CODE_LINE_SELECTOR, |
| openFileInEditor, |
| openSourcesPanel, |
| PAUSE_INDICATOR_SELECTOR, |
| removeBreakpointForLine, |
| RESUME_BUTTON, |
| retrieveTopCallFrameWithoutResuming, |
| SELECTED_THREAD_SELECTOR, |
| } from 'test/e2e/helpers/sources-helpers.js'; |
| import {getBrowserAndPagesWrappers} from 'test/shared/non_hosted_wrappers.js'; |
| |
| import { |
| type Action, |
| loadTests, |
| openTestSuiteResourceInSourcesPanel, |
| } from './cxx-debugging-extension-helpers.js'; |
| |
| const STEP_OVER_BUTTON = '[aria-label="Step over next function call"]'; |
| const STEP_OUT_BUTTON = '[aria-label="Step out of current function"]'; |
| const STEP_INTO_BUTTON = '[aria-label="Step into next function call"]'; |
| |
| function pausedReasonText(reason: string) { |
| switch (reason) { |
| case 'breakpoint': |
| return 'Paused on breakpoint'; |
| case 'step': |
| return 'Debugger paused'; |
| } |
| return; |
| } |
| |
| describe('CXX Debugging Extension Test Suite', function() { |
| for (const {name, test, script} of loadTests()) { |
| if (!script) { |
| continue; |
| } |
| it(name, async () => { |
| const {devToolsPage} = getBrowserAndPagesWrappers(); |
| try { |
| await openTestSuiteResourceInSourcesPanel(test); |
| await devToolsPage.installEventListener('DevTools.DebuggerPaused'); |
| |
| if (script === null || script.length === 0) { |
| return; |
| } |
| |
| for (const paused of script) { |
| const {file, line, reason, variables, evaluations, thread, actions} = paused; |
| if (reason === 'setup') { |
| if (paused !== script[0]) { |
| throw new Error('`setup` actions can only be the first step'); |
| } |
| |
| if (!actions) { |
| throw new Error('The `setup` step must define actions'); |
| } |
| |
| // Perform initial setup |
| await doActions({actions, reason}); |
| continue; |
| } |
| |
| await devToolsPage.waitForFunction( |
| async () => ((await devToolsPage.getPendingEvents('DevTools.DebuggerPaused')) || []).length > 0); |
| |
| const stopped = await devToolsPage.waitFor(PAUSE_INDICATOR_SELECTOR); |
| const stoppedText = |
| await devToolsPage.waitForFunction(async () => await stopped.evaluate(node => node.textContent)); |
| |
| assert.strictEqual(stoppedText, pausedReasonText(reason)); |
| |
| const pausedLocation = await retrieveTopCallFrameWithoutResuming(); |
| if (pausedLocation?.includes('…')) { |
| const pausedLocationSplit = pausedLocation.split('…'); |
| assert.isTrue( |
| `${file}:${line}`.startsWith(pausedLocationSplit[0]), |
| `expected ${file}:${line} to start with ${pausedLocationSplit[0]}`); |
| assert.isTrue( |
| `${file}:${line}`.endsWith(pausedLocationSplit[1]), |
| `expected ${file}:${line} to end with ${pausedLocationSplit[1]}`); |
| } else { |
| assert.deepEqual(pausedLocation, `${file}:${line}`); |
| } |
| |
| if (variables) { |
| for (const {name, type: variableType, value} of variables) { |
| const [scope, ...variableFields] = name.split('.'); |
| const scopeViewEntry = await readScopeView(scope, variableFields); |
| assert.isAbove(scopeViewEntry.length, 0); |
| const scopeVariable = scopeViewEntry[scopeViewEntry.length - 1]; |
| const variableName = variableFields[variableFields.length - 1]; |
| |
| if (variableName.startsWith('$')) { |
| if (variableType) { |
| assert.isTrue(scopeVariable?.endsWith(`: ${variableType}`)); |
| } else if (value) { |
| assert.isTrue(scopeVariable?.endsWith(`: ${value}`)); |
| } |
| } else if (variableType) { |
| assert.strictEqual(scopeVariable, `${variableName}: ${variableType}`); |
| } else if (value) { |
| assert.strictEqual(scopeVariable, `${variableName}: ${value}`); |
| } |
| } |
| } |
| |
| if (evaluations) { |
| // TODO(jarin) Without waiting here, the FE often misses the click on the console tab. |
| await devToolsPage.timeout(500); |
| await devToolsPage.click(CONSOLE_TAB_SELECTOR); |
| await focusConsolePrompt(); |
| |
| for (const {expression, value} of evaluations) { |
| await typeIntoConsoleAndWaitForResult(expression); |
| const evaluateResults = await devToolsPage.evaluate(() => { |
| return Array.from(document.querySelectorAll('.console-user-command-result')) |
| .map(node => node.textContent); |
| }); |
| const result = evaluateResults[evaluateResults.length - 1]; |
| assert.strictEqual(result, value.toString()); |
| } |
| |
| await openSourcesPanel(); |
| } |
| |
| if (thread) { |
| const threadElement = await devToolsPage.waitFor(SELECTED_THREAD_SELECTOR); |
| const threadText = |
| await devToolsPage.waitForFunction(async () => await threadElement.evaluate(node => node.textContent)); |
| assert.include(threadText, thread, 'selected thread is not as expected'); |
| } |
| |
| // Run actions or resume |
| await doActions(paused); |
| } |
| } catch (e) { |
| console.error(e.toString()); |
| if (TestConfig.debug) { |
| await devToolsPage.timeout(100000); |
| } |
| throw e; |
| } |
| }); |
| } |
| }); |
| |
| async function readScopeView(scope: string, variable: string[]) { |
| const {devToolsPage} = getBrowserAndPagesWrappers(); |
| const scopeElement = await devToolsPage.waitFor(`[aria-label="${scope}"]`); |
| if (scopeElement === null) { |
| throw new Error(`Scope entry for ${scope} not found`); |
| } |
| |
| let parentNode = await scopeElement.evaluateHandle(n => n.nextElementSibling!); |
| assert(parentNode, 'Scope element has no siblings'); |
| |
| const result = []; |
| for (const node of variable) { |
| const elementHandle = await getMember(node, parentNode); |
| const isExpanded = await elementHandle.evaluate((node: Element) => { |
| node.scrollIntoView(); |
| return node.getAttribute('aria-expanded'); |
| }); |
| |
| const name = await elementHandle.$('.name-and-value'); |
| if (isExpanded === 'false') { |
| // Clicking on an expandable element with the memory icon can result in |
| // unintentional click on the icon. This opens the memory viewer but does |
| // not propagate the click event, so the element does not expand. |
| // Selecting a child element instead eliminates this issue. |
| if (name) { |
| await devToolsPage.clickElement(name); |
| } else { |
| await devToolsPage.clickElement(elementHandle); |
| } |
| } |
| |
| if (name) { |
| result.push(await name.evaluate(node => node.textContent)); |
| } |
| |
| parentNode = await elementHandle.evaluateHandle(n => n.nextElementSibling!); |
| assert(parentNode, 'Element has no siblings'); |
| } |
| return result; |
| |
| async function getMember(name: string, parentNode: ElementHandle): Promise<ElementHandle<Element>> { |
| if (name.startsWith('$')) { |
| const index = parseInt(name.slice(1), 10); |
| if (!isNaN(index)) { |
| const members = await devToolsPage.waitForFunction(async () => { |
| const elements = await devToolsPage.$$('li', parentNode); |
| if (elements.length > index) { |
| return elements; |
| } |
| return undefined; |
| }); |
| return members[index]; |
| } |
| } |
| const elementHandle: ElementHandle<Element> = |
| await devToolsPage.waitFor(`[data-object-property-name-for-test="${name}"]`, parentNode); |
| return elementHandle; |
| } |
| } |
| |
| async function scrollToLine(lineNumber: number): Promise<void> { |
| const {devToolsPage} = getBrowserAndPagesWrappers(); |
| await devToolsPage.waitForFunction(async () => { |
| const visibleLines = await devToolsPage.$$(CODE_LINE_SELECTOR); |
| assert.exists(visibleLines[0]); |
| const lineNumbers = await Promise.all(visibleLines.map(v => v.evaluate(e => Number(e.textContent ?? '')))); |
| if (lineNumbers.includes(lineNumber)) { |
| return true; |
| } |
| // CM has some extra lines at the beginning and end, so pick the middle line to determine scrolling direction |
| const mid = lineNumbers[Math.floor(lineNumbers.length / 2)]; |
| await visibleLines[0].press(mid < lineNumber ? 'PageDown' : 'PageUp'); |
| return false; |
| }); |
| } |
| |
| async function doActions({actions, reason}: {actions?: Action[], reason: string}) { |
| const {inspectedPage, devToolsPage} = getBrowserAndPagesWrappers(); |
| let continuation; |
| if (actions) { |
| for (const step of actions) { |
| const {action} = step; |
| switch (action) { |
| case 'set_breakpoint': { |
| const {file, breakpoint} = step; |
| if (!file) { |
| throw new Error('Invalid breakpoint spec: missing `file`'); |
| } |
| if (!breakpoint) { |
| throw new Error('Invalid breakpoint spec: missing `breakpoint`'); |
| } |
| await openFileInEditor(file); |
| await scrollToLine(Number(breakpoint)); |
| await addBreakpointForLine(breakpoint); |
| break; |
| } |
| case 'remove_breakpoint': { |
| const {breakpoint} = step; |
| if (!breakpoint) { |
| throw new Error('Invalid breakpoint spec: missing `breakpoint`'); |
| } |
| await scrollToLine(Number(breakpoint)); |
| await removeBreakpointForLine(breakpoint); |
| break; |
| } |
| case 'step_over': |
| case 'step_out': |
| case 'step_into': |
| case 'resume': |
| case 'reload': |
| if (reason === 'setup') { |
| throw new Error(`The 'setup' reason cannot contain a continue action such as '${action}'`); |
| } |
| continuation = action; |
| break; |
| default: |
| throw new Error(`Unknown action "${action}"`); |
| } |
| } |
| } |
| |
| if (reason === 'setup') { |
| continuation = 'reload'; |
| } |
| |
| switch (continuation) { |
| case 'step_over': |
| await devToolsPage.click(STEP_OVER_BUTTON); |
| break; |
| case 'step_out': |
| await devToolsPage.click(STEP_OUT_BUTTON); |
| break; |
| case 'step_into': |
| await devToolsPage.click(STEP_INTO_BUTTON); |
| break; |
| case 'reload': |
| await inspectedPage.reload(); |
| break; |
| default: |
| await devToolsPage.waitFor(RESUME_BUTTON); |
| await devToolsPage.click(RESUME_BUTTON); |
| break; |
| } |
| } |