Skip to content

Commit

Permalink
e2e-test: add data explorer editor action bar test (#6019)
Browse files Browse the repository at this point in the history
### Summary
Adding test coverage for the editor action bar in data explorer view.
Test verifies that following behaviors:
* split editor
* open new window
* display left/right
* _note: sorting is already handled in other test_

for the following scenarios:
* load data frame via variables pane (r script)
* load data frame via variables pane (python script)
* open parquet file via duck db
* open csv file via duck db

### QA Notes

✅ New test ran on all platforms on this PR.

@:editor-action-bar @:win @:web
  • Loading branch information
midleman authored Jan 21, 2025
1 parent a268bc8 commit 6fcb67e
Show file tree
Hide file tree
Showing 32 changed files with 534 additions and 282 deletions.
8 changes: 4 additions & 4 deletions test/e2e/infra/fixtures/interpreter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,8 @@ export class Interpreter {

if (waitForReady) {
interpreterType === 'Python'
? await this.console.waitForReady('>>>', 30000)
: await this.console.waitForReady('>', 30000);
? await this.console.waitForReadyAndStarted('>>>', 30000)
: await this.console.waitForReadyAndStarted('>', 30000);
}
});
}
Expand Down Expand Up @@ -306,8 +306,8 @@ export class Interpreter {
await this.console.waitForConsoleContents('restarted');

interpreterType === 'Python'
? await this.console.waitForReady('>>>', 10000)
: await this.console.waitForReady('>', 10000);
? await this.console.waitForReadyAndStarted('>>>', 10000)
: await this.console.waitForReadyAndStarted('>', 10000);
});
}

Expand Down
2 changes: 1 addition & 1 deletion test/e2e/infra/test-runner/test-tags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,13 @@

export enum TestTags {
// feature tags
EDITOR_ACTION_BAR = '@:editor-action-bar',
APPS = '@:apps',
CONNECTIONS = '@:connections',
CONSOLE = '@:console',
CRITICAL = '@:critical',
DATA_EXPLORER = '@:data-explorer',
DUCK_DB = '@:duck-db',
EDITOR_ACTION_BAR = '@:editor-action-bar',
HELP = '@:help',
HTML = '@:html',
INTERPRETER = '@:interpreter',
Expand Down
3 changes: 3 additions & 0 deletions test/e2e/infra/workbench.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { Clipboard } from '../pages/clipboard';
import { QuickInput } from '../pages/quickInput';
import { Extensions } from '../pages/extensions';
import { Settings } from '../pages/settings';
import { EditorActionBar } from '../pages/editorActionBar';

export interface Commands {
runCommand(command: string, options?: { exactLabelMatch?: boolean }): Promise<any>;
Expand Down Expand Up @@ -65,6 +66,7 @@ export class Workbench {
readonly extensions: Extensions;
readonly editors: Editors;
readonly settings: Settings;
readonly editorActionBar: EditorActionBar;

constructor(code: Code) {

Expand Down Expand Up @@ -96,6 +98,7 @@ export class Workbench {
this.clipboard = new Clipboard(code);
this.extensions = new Extensions(code, this.quickaccess);
this.settings = new Settings(code, this.editors, this.editor, this.quickaccess);
this.editorActionBar = new EditorActionBar(code.driver.page, this.viewer, this.quickaccess);
}
}

58 changes: 34 additions & 24 deletions test/e2e/pages/console.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
*--------------------------------------------------------------------------------------------*/


import { expect, Locator } from '@playwright/test';
import test, { expect, Locator } from '@playwright/test';
import { Code } from '../infra/code';
import { QuickAccess } from './quickaccess';
import { QuickInput } from './quickInput';
Expand Down Expand Up @@ -71,37 +71,39 @@ export class Console {

if (waitForReady) {
desiredInterpreterType === InterpreterType.Python
? await this.waitForReady('>>>', 40000)
: await this.waitForReady('>', 40000);
? await this.waitForReadyAndStarted('>>>', 40000)
: await this.waitForReadyAndStarted('>', 40000);
}
return;
}

async executeCode(languageName: string, code: string, prompt: string): Promise<void> {
async executeCode(languageName: 'Python' | 'R', code: string): Promise<void> {
await test.step(`Execute ${languageName} code in console: ${code}`, async () => {

await expect(async () => {
// Kind of hacky, but activate console in case focus was previously lost
await this.activeConsole.click();
await this.quickaccess.runCommand('workbench.action.executeCode.console', { keepOpen: true });
await expect(async () => {
// Kind of hacky, but activate console in case focus was previously lost
await this.activeConsole.click();
await this.quickaccess.runCommand('workbench.action.executeCode.console', { keepOpen: true });

}).toPass();
}).toPass();

await this.quickinput.waitForQuickInputOpened();
await this.quickinput.type(languageName);
await this.quickinput.waitForQuickInputElements(e => e.length === 1 && e[0] === languageName);
await this.code.driver.page.keyboard.press('Enter');
await this.quickinput.waitForQuickInputOpened();
await this.quickinput.type(languageName);
await this.quickinput.waitForQuickInputElements(e => e.length === 1 && e[0] === languageName);
await this.code.driver.page.keyboard.press('Enter');

await this.quickinput.waitForQuickInputOpened();
const unescapedCode = code
.replace(/\n/g, '\\n')
.replace(/\r/g, '\\r');
await this.quickinput.type(unescapedCode);
await this.code.driver.page.keyboard.press('Enter');
await this.quickinput.waitForQuickInputClosed();
await this.quickinput.waitForQuickInputOpened();
const unescapedCode = code
.replace(/\n/g, '\\n')
.replace(/\r/g, '\\r');
await this.quickinput.type(unescapedCode);
await this.code.driver.page.keyboard.press('Enter');
await this.quickinput.waitForQuickInputClosed();

// The console will show the prompt after the code is done executing.
await this.waitForReady(prompt);
await this.maximizeConsole();
// The console will show the prompt after the code is done executing.
await this.waitForReady(languageName === 'Python' ? '>>>' : '>');
await this.maximizeConsole();
});
}

async logConsoleContents() {
Expand Down Expand Up @@ -129,11 +131,19 @@ export class Console {

async waitForReady(prompt: string, timeout = 30000): Promise<void> {
const activeLine = this.code.driver.page.locator(`${ACTIVE_CONSOLE_INSTANCE} .active-line-number`);

await expect(activeLine).toHaveText(prompt, { timeout });
}

async waitForReadyAndStarted(prompt: string, timeout = 30000): Promise<void> {
await this.waitForReady(prompt, timeout);
await this.waitForConsoleContents('started', { timeout });
}

async waitForReadyAndRestarted(prompt: string, timeout = 30000): Promise<void> {
await this.waitForReady(prompt, timeout);
await this.waitForConsoleContents('restarted', { timeout });
}

/**
* Check if the console is ready with Python or R, or if no interpreter is running.
* @throws An error if the console is not ready after the retry count.
Expand Down
3 changes: 2 additions & 1 deletion test/e2e/pages/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import { expect, FrameLocator, Locator } from '@playwright/test';
import { Code } from '../infra/code';

// currently a dupe of declaration in ../editor.ts but trying not to modifiy that file
// currently a dupe of declaration in ../editor.ts but trying not to modify that file
const EDITOR = (filename: string) => `.monaco-editor[data-uri$="${filename}"]`;
const CURRENT_LINE = '.view-overlays .current-line';
const PLAY_BUTTON = '.codicon-play';
Expand All @@ -17,6 +17,7 @@ const INNER_FRAME = '#active-frame';
export class Editor {

viewerFrame = this.code.driver.page.frameLocator(OUTER_FRAME).frameLocator(INNER_FRAME);
playButton = this.code.driver.page.locator(PLAY_BUTTON);

getEditorViewerLocator(locator: string,): Locator {
return this.viewerFrame.locator(locator);
Expand Down
184 changes: 184 additions & 0 deletions test/e2e/pages/editorActionBar.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
/*---------------------------------------------------------------------------------------------
* Copyright (C) 2024 Posit Software, PBC. All rights reserved.
* Licensed under the Elastic License 2.0. See LICENSE.txt for license information.
*--------------------------------------------------------------------------------------------*/

import test, { expect, Page } from '@playwright/test';
import { Viewer } from './viewer';
import { QuickAccess } from './quickaccess';


export class EditorActionBar {

previewButton = this.page.getByLabel('Preview', { exact: true });
openChangesButton = this.page.getByLabel('Open Changes');
splitEditorRightButton = this.page.getByLabel('Split Editor Right', { exact: true });
splitEditorDownButton = this.page.getByLabel('Split Editor Down', { exact: true });
openInViewerButton = this.page.getByLabel('Open in Viewer');

constructor(private page: Page, private viewer: Viewer, private quickaccess: QuickAccess) {
}

// --- Actions ---

/**
* Action: Click the "Split Editor" button. Handles pressing the 'Alt' key for 'down' direction.
* @param direction 'down' or 'right'
*/
async clickSplitEditorButton(direction: 'down' | 'right') {
if (direction === 'down') {
await this.page.keyboard.down('Alt');
await this.page.getByLabel('Split Editor Down').click();
await this.page.keyboard.up('Alt');
}
else {
await this.splitEditorRightButton.click();
}
}

/**
* Action: Set the summary position to the specified side.
* @param isWeb whether the test is running in the web or desktop app
* @param position select 'Left' or 'Right' to position the summary
*/
async selectSummaryOn(isWeb: boolean, position: 'Left' | 'Right') {
if (isWeb) {
await this.page.getByLabel('More actions', { exact: true }).click();
await this.page.getByRole('menuitemcheckbox', { name: `Summary on ${position}` }).hover();
await this.page.keyboard.press('Enter');
}
else {
await this.quickaccess.runCommand(`workbench.action.positronDataExplorer.summaryOn${position}`);
}
}

/**
* Action: Click a menu item in the "Customize Notebook" dropdown.
* @param menuItem a menu item to click in the "Customize Notebook" dropdown
*/
async clickCustomizeNotebookMenuItem(menuItem: string) {
const role = menuItem.includes('Line Numbers') ? 'menuitemcheckbox' : 'menuitem';
const dropdownButton = this.page.getByLabel('Customize Notebook...');
await dropdownButton.evaluate((button) => {
(button as HTMLElement).dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
});

const toggleMenuItem = this.page.getByRole(role, { name: menuItem });
await toggleMenuItem.hover();
await this.page.waitForTimeout(500);
await toggleMenuItem.click();
}

// --- Verifications ---

/**
* Verify: Check that the editor is split in the specified direction (on the correct plane)
* @param direction the direction the editor was split
* @param tabName the name of the tab to verify
*/
async verifySplitEditor(direction: 'down' | 'right', tabName: string,) {
await test.step(`Verify split editor: ${direction}`, async () => {
// Verify 2 tabs
await expect(this.page.getByRole('tab', { name: tabName })).toHaveCount(2);
const splitTabs = this.page.getByRole('tab', { name: tabName });
const firstTabBox = await splitTabs.nth(0).boundingBox();
const secondTabBox = await splitTabs.nth(1).boundingBox();

if (direction === 'right') {
// Verify tabs are on the same X plane
expect(firstTabBox).not.toBeNull();
expect(secondTabBox).not.toBeNull();
expect(firstTabBox!.y).toBeCloseTo(secondTabBox!.y, 1);
expect(firstTabBox!.x).not.toBeCloseTo(secondTabBox!.x, 1);
}
else {
// Verify tabs are on the same Y plane
expect(firstTabBox).not.toBeNull();
expect(secondTabBox).not.toBeNull();
expect(firstTabBox!.x).toBeCloseTo(secondTabBox!.x, 1);
expect(firstTabBox!.y).not.toBeCloseTo(secondTabBox!.y, 1);
}

// Close one tab
await splitTabs.first().getByLabel('Close').click();
});
}

/**
* Verify: Check that the "open in new window" contains the specified text
* @param isWeb whether the test is running in the web or desktop app
* @param text the text to verify in the new window
*/
async verifyOpenInNewWindow(isWeb: boolean, text: string | RegExp, exact = true) {
if (!isWeb) {
await test.step(`Verify "open new window" contains: ${text}`, async () => {
const [newPage] = await Promise.all([
this.page.context().waitForEvent('page'),
this.page.getByLabel('Move into new window').first().click(),
]);
await newPage.waitForLoadState('load');
exact
? await expect(newPage.getByText(text, { exact: true })).toBeVisible()
: await expect(newPage.getByText(text)).toBeVisible();
});
}
}

/**
* Verify: Check that the preview renders the specified heading
* @param heading the heading to verify in the preview
*/
async verifyPreviewRendersHtml(heading: string) {
await test.step('Verify "preview" renders html', async () => {
await this.page.getByLabel('Preview', { exact: true }).click();
const viewerFrame = this.viewer.getViewerFrame().frameLocator('iframe');
await expect(viewerFrame.getByRole('heading', { name: heading })).toBeVisible({ timeout: 30000 });
});
}

/**
* Verify: Check that the "open in viewer" renders the specified title
* @param isWeb whether the test is running in the web or desktop app
* @param title the title to verify in the viewer
*/
async verifyOpenViewerRendersHtml(isWeb: boolean, title: string) {
await test.step('verify "open in viewer" renders html', async () => {
const viewerFrame = this.page.locator('iframe.webview').contentFrame().locator('#active-frame').contentFrame();
const cellLocator = isWeb
? viewerFrame.frameLocator('iframe').getByRole('cell', { name: title })
: viewerFrame.getByRole('cell', { name: title });

await expect(cellLocator).toBeVisible({ timeout: 30000 });
});
}

/**
* Verify: Check that the summary is positioned on the specified side
* @param position the side to verify the summary is positioned
*/
async verifySummaryPosition(position: 'Left' | 'Right') {
await test.step(`Verify summary position: ${position}`, async () => {
// Get the summary and table locators.
const summaryLocator = this.page.locator('div.column-summary').first();
const tableLocator = this.page.locator('div.data-grid-column-headers');

// Ensure both the summary and table elements are visible
await expect(summaryLocator).toBeVisible();
await expect(tableLocator).toBeVisible();

// Get the bounding boxes for both elements
const summaryBox = await summaryLocator.boundingBox();
const tableBox = await tableLocator.boundingBox();

// Validate bounding boxes are available
if (!summaryBox || !tableBox) {
throw new Error('Bounding boxes could not be retrieved for summary or table.');
}

// Validate positions based on the expected position
position === 'Left'
? expect(summaryBox.x).toBeLessThan(tableBox.x)
: expect(summaryBox.x).toBeGreaterThan(tableBox.x);
});
}
}
1 change: 1 addition & 0 deletions test/e2e/pages/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export class Settings {
async addUserSettings(settings: [key: string, value: string][]): Promise<void> {
await this.openUserSettingsFile();
const file = 'settings.json';
await this.editors.saveOpenedFile();
await this.code.driver.page.keyboard.press('ArrowRight');
await this.editor.waitForTypeInEditor(file, settings.map(v => `"${v[0]}": ${v[1]},`).join(''));
await this.editors.saveOpenedFile();
Expand Down
3 changes: 1 addition & 2 deletions test/e2e/pages/utils/packageManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,8 @@ export class PackageManager {
await test.step(`${action}: ${packageName}`, async () => {
const command = this.getCommand(packageInfo.type, packageName, action);
const expectedOutput = this.getExpectedOutput(packageName, action);
const prompt = packageInfo.type === 'Python' ? '>>> ' : '> ';

await this.app.workbench.console.executeCode(packageInfo.type, command, prompt);
await this.app.workbench.console.executeCode(packageInfo.type, command);
await expect(this.app.code.driver.page.getByText(expectedOutput)).toBeVisible();
});
}
Expand Down
Loading

0 comments on commit 6fcb67e

Please sign in to comment.