Skip to content

e2e-test: add r debug test #7753

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
May 20, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 14 additions & 5 deletions test/e2e/pages/console.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const EMPTY_CONSOLE = '.positron-console .empty-console';
const INTERRUPT_RUNTIME = 'div.action-bar-button-face .codicon-positron-interrupt-runtime';
const SUGGESTION_LIST = '.suggest-widget .monaco-list-row';
const CONSOLE_LINES = `${ACTIVE_CONSOLE_INSTANCE} div span`;
const ERROR = '.activity-error-message';

/*
* Reuseable Positron console functionality for tests to leverage. Includes the ability to select an interpreter and execute code which
Expand All @@ -30,6 +31,7 @@ export class Console {
activeConsole: Locator;
suggestionList: Locator;
private consoleTab: Locator;
private error: Locator;

get emptyConsole() {
return this.code.driver.page.locator(EMPTY_CONSOLE).getByText('There is no interpreter running');
Expand All @@ -43,6 +45,7 @@ export class Console {
this.activeConsole = this.code.driver.page.locator(ACTIVE_CONSOLE_INSTANCE);
this.suggestionList = this.code.driver.page.locator(SUGGESTION_LIST);
this.consoleTab = this.code.driver.page.getByRole('tab', { name: 'Console', exact: true });
this.error = this.code.driver.page.locator(ERROR);
}


Expand Down Expand Up @@ -141,23 +144,23 @@ export class Console {
}

async waitForConsoleContents(
consoleText: string,
consoleTextOrRegex: string | RegExp,
options: {
timeout?: number;
expectedCount?: number;
exact?: boolean;
} = {}
): Promise<string[]> {
return await test.step(`Verify console contains: ${consoleText}`, async () => {
return await test.step(`Verify console contains: ${consoleTextOrRegex}`, async () => {
const { timeout = 15000, expectedCount = 1, exact = false } = options;

if (expectedCount === 0) {
const startTime = Date.now();
while (Date.now() - startTime < timeout) {
const errorMessage = `Expected text "${consoleText}" to not appear, but it did.`;
const errorMessage = `Expected text "${consoleTextOrRegex}" to not appear, but it did.`;

try {
const matchingLines = this.code.driver.page.locator(CONSOLE_LINES).getByText(consoleText);
const matchingLines = this.code.driver.page.locator(CONSOLE_LINES).getByText(consoleTextOrRegex);
const count = await matchingLines.count();

if (count > 0) {
Expand All @@ -175,7 +178,7 @@ export class Console {
}

// Normal case: waiting for `expectedCount` occurrences
const matchingLines = this.code.driver.page.locator(CONSOLE_LINES).getByText(consoleText, { exact });
const matchingLines = this.code.driver.page.locator(CONSOLE_LINES).getByText(consoleTextOrRegex, { exact });

await expect(matchingLines).toHaveCount(expectedCount, { timeout });
return expectedCount ? matchingLines.allTextContents() : [];
Expand Down Expand Up @@ -303,4 +306,10 @@ export class Console {
await this.code.driver.page.locator('.suggest-widget').getByLabel(label).isVisible();
});
}

async expectConsoleToContainError(error: string): Promise<void> {
await test.step(`Expect console to contain error: ${error}`, async () => {
await expect(this.error).toContainText(error);
});
}
}
68 changes: 58 additions & 10 deletions test/e2e/pages/debug.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* Licensed under the Elastic License 2.0. See LICENSE.txt for license information.
*--------------------------------------------------------------------------------------------*/

import { expect } from '@playwright/test';
import test, { expect } from '@playwright/test';
import { Code } from '../infra/code';


Expand Down Expand Up @@ -36,14 +36,18 @@ export class Debug {
}

async setBreakpointOnLine(lineNumber: number): Promise<void> {
await expect(this.code.driver.page.locator(`${GLYPH_AREA}(${lineNumber})`)).toBeVisible();
await this.code.driver.page.locator(`${GLYPH_AREA}(${lineNumber})`).click({ position: { x: 5, y: 5 } });
await expect(this.code.driver.page.locator(BREAKPOINT_GLYPH)).toBeVisible();
await test.step(`Debug: Set breakpoint on line ${lineNumber}`, async () => {
await expect(this.code.driver.page.locator(`${GLYPH_AREA}(${lineNumber})`)).toBeVisible();
await this.code.driver.page.locator(`${GLYPH_AREA}(${lineNumber})`).click({ position: { x: 5, y: 5 } });
await expect(this.code.driver.page.locator(BREAKPOINT_GLYPH)).toBeVisible();
});
}

async startDebugging(): Promise<void> {
await this.code.driver.page.keyboard.press('F5');
await expect(this.code.driver.page.locator(STOP)).toBeVisible();
await test.step('Debug: Start', async () => {
await this.code.driver.page.keyboard.press('F5');
await expect(this.code.driver.page.locator(STOP)).toBeVisible();
});
}

async getVariables(): Promise<string[]> {
Expand All @@ -61,19 +65,27 @@ export class Debug {
}

async stepOver(): Promise<any> {
await this.code.driver.page.locator(STEP_OVER).click();
await test.step('Debug: Step over', async () => {
await this.code.driver.page.locator(STEP_OVER).click();
});
}

async stepInto(): Promise<any> {
await this.code.driver.page.locator(STEP_INTO).click();
await test.step('Debug: Step into', async () => {
await this.code.driver.page.locator(STEP_INTO).click();
});
}

async stepOut(): Promise<any> {
await this.code.driver.page.locator(STEP_OUT).click();
await test.step('Debug: Step out', async () => {
await this.code.driver.page.locator(STEP_OUT).click();
});
}

async continue(): Promise<any> {
await this.code.driver.page.locator(CONTINUE).click();
await test.step('Debug: Continue', async () => {
await this.code.driver.page.locator(CONTINUE).click();
});
}

async getStack(): Promise<IStackFrame[]> {
Expand All @@ -89,4 +101,40 @@ export class Debug {

return stack;
}

/**
* Verify: The debug pane is visible and contains the specified variable
*
* @param variableLabel The label of the variable to check in the debug pane
*/
async expectDebugPaneToContain(variableLabel: string): Promise<void> {
await test.step(`Verify debug pane contains: ${variableLabel}`, async () => {
await expect(this.code.driver.page.getByRole('button', { name: 'Debug Variables Section' })).toBeVisible();
await expect(this.code.driver.page.getByLabel(variableLabel)).toBeVisible();
});
}

/**
* Verify: The call stack is visible and contains the specified item
*
* @param item The item to check in the call stack
*/
async expectCallStackToContain(item: string): Promise<void> {
await test.step(`Verify call stack contains: ${item}`, async () => {
await expect(this.code.driver.page.getByRole('button', { name: 'Call Stack Section' })).toBeVisible();
const debugCallStack = this.code.driver.page.locator('.debug-call-stack');
await expect(debugCallStack.getByText(item)).toBeVisible();
});
}

/**
* Verify: In browser mode at the specified frame
*
* @param number The frame number to check in the browser mode
*/
async expectBrowserModeFrame(number: number): Promise<void> {
await test.step(`Verify in browser mode: frame ${number}`, async () => {
await expect(this.code.driver.page.getByText(`Browse[${number}]>`)).toBeVisible();
});
}
}
14 changes: 11 additions & 3 deletions test/e2e/tests/_test.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,8 +209,12 @@ export const test = base.extend<TestFixtures & CurrentsFixtures, WorkerFixtures

// ex: await executeCode('Python', 'print("Hello, world!")');
executeCode: async ({ app }, use) => {
await use(async (language: 'Python' | 'R', code: string) => {
await app.workbench.console.executeCode(language, code);
await use(async (language: 'Python' | 'R', code: string, options?: {
timeout?: number;
waitForReady?: boolean;
maximizeConsole?: boolean;
}) => {
await app.workbench.console.executeCode(language, code, options);
});
},

Expand Down Expand Up @@ -470,7 +474,11 @@ interface TestFixtures {
openDataFile: (filePath: string) => Promise<void>;
openFolder: (folderPath: string) => Promise<void>;
runCommand: (command: string, options?: { keepOpen?: boolean; exactMatch?: boolean }) => Promise<void>;
executeCode: (language: 'Python' | 'R', code: string) => Promise<void>;
executeCode: (language: 'Python' | 'R', code: string, options?: {
timeout?: number;
waitForReady?: boolean;
maximizeConsole?: boolean;
}) => Promise<void>;
hotKeys: HotKeys;
cleanup: TestTeardown;
}
Expand Down
193 changes: 193 additions & 0 deletions test/e2e/tests/debug/r-debug.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
/*---------------------------------------------------------------------------------------------
* Copyright (C) 2025 Posit Software, PBC. All rights reserved.
* Licensed under the Elastic License 2.0. See LICENSE.txt for license information.
*--------------------------------------------------------------------------------------------*/

/**
* R Debugging Feature
*
* This feature supports multiple debugging mechanisms for R-based code:
* - Browser-based debugging with `browser()` and `debugonce()`
* - Error recovery with `options(error = recover)`
* - Full integration with Positron's debugging UI, call stack, and variables view
*
* Debugging flow:
* 1. User sets breakpoints using R's native debugging functions (`browser()`, `debugonce()`, etc.)
* 2. When execution reaches these points, Positron enters debug mode
* 3. User can inspect variables, step through code, and control execution flow
* 4. Debugging can be controlled via console commands (s/n/c/Q) or Positron's debugging UI
* 5. Variables can be inspected in the console or in the Variables debugging pane
*/

import { Page } from '@playwright/test';
import { Application, SessionMetaData } from '../../infra/index.js';
import { test, tags, expect } from '../_test.setup';

let session: SessionMetaData;

test.use({ suiteId: __filename });

test.describe('R Debugging', {
tag: [tags.DEBUG, tags.WEB, tags.WIN]
}, () => {

test.beforeAll('Setup fruit data', async ({ executeCode, sessions }) => {
session = await sessions.start('r');
await executeCode('R', `dat <- data.frame(
blackberry = c(4, 9, 6),
blueberry = c(1, 2, 8),
peach = c(59, 150, 10),
plum = c(30, 78, 5)
)
rownames(dat) <- c("calories", "weight", "yumminess")`);
});

test.afterEach('Reset for next test', async ({ hotKeys, app }) => {
await hotKeys.closeAllEditors();
await app.workbench.console.clearButton.click();
});

test('R - Verify debugging with `browser()` via console', async ({ app, page, openFile, runCommand, executeCode }) => {
const { debug, console } = app.workbench;

await openFile(`workspaces/r-debugging/fruit_avg_browser.r`);
await runCommand('r.sourceCurrentFile');

// Trigger the breakpoint
await executeCode('R', `fruit_avg(dat, "berry")`, { waitForReady: false });
await debug.expectBrowserModeFrame(1);

// Verify the debug pane, call stack, and console variables
await verifyDebugPane(app);
await verifyCallStack(app);
await verifyVariableInConsole(page, 'pattern', '[1] "berry"');
await verifyVariableInConsole(page, 'names(dat)', '[1] "blackberry" "blueberry" "peach" "plum"');

// Step into the next line using 's'
await page.keyboard.type('s');
await page.keyboard.press('Enter');
await console.waitForConsoleContents(/debug at .*#3: cols <- grep\(pattern, names\(dat\)\)/);

// Step over to next line using 'n'
await page.keyboard.type('n');
await page.keyboard.press('Enter');
await console.waitForConsoleContents(/debug at .*#4: mini_dat <- dat\[, cols\]/);

// Continue execution with 'c'
await page.keyboard.type('c');
await page.keyboard.press('Enter');
await console.waitForConsoleContents('Found 2 fruits!');
});

test('R - Verify debugging with `browser()` via debugging UI tools', async ({ app, page, openFile, runCommand, executeCode }) => {
const { debug, console } = app.workbench;

await openFile(`workspaces/r-debugging/fruit_avg_browser.r`);
await runCommand('r.sourceCurrentFile');

// Trigger the breakpoint
await executeCode('R', `fruit_avg(dat, "berry")`, { waitForReady: false });
await debug.expectBrowserModeFrame(1);

// Verify the debug pane and call stack
await verifyDebugPane(app);
await verifyCallStack(app);
await verifyVariableInConsole(page, 'pattern', '[1] "berry"');
await verifyVariableInConsole(page, 'names(dat)', '[1] "blackberry" "blueberry" "peach" "plum"');

// Step into using the debugger UI controls
await debug.stepInto();
await console.waitForConsoleContents(/debug at .*#3: cols <- grep\(pattern, names\(dat\)\)/);

// Step over using the debugger UI controls
await debug.stepOver();
await console.waitForConsoleContents(/debug at .*#4: mini_dat <- dat\[, cols\]/);

// Continue execution and check final message
await debug.continue();
await console.waitForConsoleContents('Found 2 fruits!');
});

test('R - Verify debugging with `debugonce()` pauses only once', async ({ app, page, executeCode, openFile, runCommand }) => {
const { debug, console } = app.workbench;

await openFile('workspaces/r-debugging/fruit_avg.r');
await runCommand('r.sourceCurrentFile');

// Trigger the function to be debugged (just once)
await executeCode('R', 'debugonce(fruit_avg)');
await executeCode('R', 'fruit_avg(dat, "berry")', { waitForReady: false });

// First call should pause at debug prompt
// Note: In R 4.3 browser "overcounts" the context depth but it is fixed in R 4.4
const frameNumber = session.name.startsWith('R 4.3.') ? 2 : 1;
await debug.expectBrowserModeFrame(frameNumber);

// Continue execution
await page.keyboard.type('c');
await page.keyboard.press('Enter');
await console.waitForConsoleContents('Found 2 fruits!', { expectedCount: 1 });

// Call again — should not pause this time
await executeCode('R', 'fruit_avg(dat, "berry")', { waitForReady: false });
await console.waitForConsoleContents('Found 2 fruits!', { expectedCount: 2 });
});

test('R - Verify debugging with `options(error = recover)` interactive recovery mode', async ({ app, page, openFile, runCommand, executeCode }) => {
const { console } = app.workbench;

await openFile('workspaces/r-debugging/fruit_avg.r');
await runCommand('r.sourceCurrentFile');

// Enable recovery mode so errors trigger the interactive debugger
await executeCode('R', 'options(error = recover)');

// Trigger an error: this should throw an error inside rowMeans(mini_dat)
await executeCode('R', 'fruit_avg(dat, "black")', { waitForReady: false });

// Confirm recovery prompt appears and frame selection is offered
await console.waitForConsoleContents('Enter a frame number, or 0 to exit');
await console.waitForConsoleContents('1: fruit_avg(dat, "black")');

// Select the inner function frame
await console.waitForConsoleContents('Selection:');
await page.keyboard.type('1');
await page.keyboard.press('Enter');

// Confirm error message appears in sidebar
await console.expectConsoleToContainError("'x' must be an array of at least two dimensions");

// Check the contents of mini_dat in the console
await console.focus();
await verifyVariableInConsole(page, 'mini_dat', '[1] 4 9 6');

// Quit the debugger
await page.keyboard.type('Q');
await page.keyboard.press('Enter');
await console.waitForReady('>');
});
});


async function verifyDebugPane(app: Application) {
const { debug } = app.workbench;

await debug.expectDebugPaneToContain('pattern, value "berry"');
await debug.expectDebugPaneToContain('dat, value dat');
}

async function verifyCallStack(app: Application) {
const { debug } = app.workbench;

await debug.expectCallStackToContain('fruit_avg()fruit_avg()2:');
await debug.expectCallStackToContain('<global>fruit_avg(dat, "berry")');
}

async function verifyVariableInConsole(page: Page, name: string, expectedText: string) {
await test.step(`Verify variable in console: ${name}`, async () => {
await page.keyboard.type(name);
await page.keyboard.press('Enter');
await expect(page.getByText(expectedText)).toBeVisible({ timeout: 30000 });
});
}