Skip to content

Commit daa9b1c

Browse files
authored
e2e: add outline for copy as code test & refactor popups/modals/dialogs POMs (#8581)
### Summary We were previously mixing the responsibilities of popups, modals, dialogs, and toasts into a single POM, which made it unclear which methods applied to which UI components. This PR separates them into dedicated, focused POMs: * `dialog-modals.ts` * `dialog-popups.ts` * `dialog-toasts.ts` Also, added a basic outline for the `Copy as Code` Data Explorer test. The test is currently skipped as the feature is still under development. * `data-explorer/copy-code.test.ts` ### QA Notes @:console @:web Running full suite: https://github.com/posit-dev/positron/actions/runs/16378500737
1 parent 1e51481 commit daa9b1c

25 files changed

+552
-375
lines changed

test/e2e/infra/index.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@ export * from './test-teardown.js';
1212

1313
// pages
1414
export * from '../pages/console';
15-
export * from '../pages/popups';
15+
export * from '../pages/dialog-modals';
16+
export * from '../pages/dialog-toasts';
17+
export * from '../pages/dialog-popups.js';
1618
export * from '../pages/variables';
1719
export * from '../pages/dataExplorer';
1820
export * from '../pages/sideBar';
@@ -47,6 +49,6 @@ export * from '../pages/hotKeys';
4749

4850
// utils
4951
export * from '../pages/utils/aws';
50-
export * from '../pages/utils/contextMenu';
52+
export * from '../pages/dialog-contextMenu';
5153
export * from '../pages/utils/vscodeSettings';
5254
export { getDevElectronPath, getBuildElectronPath, getBuildVersion } from './electron';

test/e2e/infra/workbench.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44
*--------------------------------------------------------------------------------------------*/
55

66
import { Code } from './code';
7-
import { Popups } from '../pages/popups';
7+
import { Modals } from '../pages/dialog-modals';
8+
import { Toasts } from '../pages/dialog-toasts';
9+
import { Popups } from '../pages/dialog-popups.js';
810
import { Console } from '../pages/console';
911
import { Variables } from '../pages/variables';
1012
import { DataExplorer } from '../pages/dataExplorer';
@@ -49,6 +51,8 @@ export interface Commands {
4951

5052
export class Workbench {
5153

54+
readonly modals: Modals;
55+
readonly toasts: Toasts;
5256
readonly popups: Popups;
5357
readonly console: Console;
5458
readonly variables: Variables;
@@ -90,6 +94,8 @@ export class Workbench {
9094

9195
constructor(code: Code) {
9296
this.hotKeys = new HotKeys(code);
97+
this.toasts = new Toasts(code);
98+
this.modals = new Modals(code, this.toasts);
9399
this.popups = new Popups(code);
94100
this.variables = new Variables(code, this.hotKeys);
95101
this.dataExplorer = new DataExplorer(code, this);
@@ -112,7 +118,7 @@ export class Workbench {
112118
this.notebooksPositron = new PositronNotebooks(code, this.quickInput, this.quickaccess);
113119
this.welcome = new Welcome(code);
114120
this.clipboard = new Clipboard(code, this.hotKeys);
115-
this.terminal = new Terminal(code, this.quickaccess, this.clipboard, this.popups);
121+
this.terminal = new Terminal(code, this.quickaccess, this.clipboard);
116122
this.viewer = new Viewer(code);
117123
this.editor = new Editor(code);
118124
this.testExplorer = new TestExplorer(code);

test/e2e/pages/console.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { Code } from '../infra/code';
88
import { QuickInput } from './quickInput';
99
import { HotKeys } from './hotKeys.js';
1010
import { availableRuntimes, SessionRuntimes } from './sessions.js';
11-
import { ContextMenu } from './utils/contextMenu.js';
11+
import { ContextMenu } from './dialog-contextMenu.js';
1212
import { QuickAccess } from './quickaccess.js';
1313

1414
const CONSOLE_INPUT = '.console-input';

test/e2e/pages/dataExplorer.ts

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -45,14 +45,20 @@ export class DataExplorer {
4545
selectColumnButton: Locator;
4646
selectConditionButton: Locator;
4747
applyFilterButton: Locator;
48+
selectFilterModalValue: (value: string) => Locator;
49+
filteringMenu: Locator;
50+
menuItemClearFilters: Locator;
4851

4952
constructor(private code: Code, private workbench: Workbench) {
5053
this.clearSortingButton = this.code.driver.page.locator(CLEAR_SORTING_BUTTON);
5154
this.clearFilterButton = this.code.driver.page.locator(CLEAR_FILTER_BUTTON);
5255
this.addFilterButton = this.code.driver.page.getByRole('button', { name: 'Add Filter' });
5356
this.selectColumnButton = this.code.driver.page.getByRole('button', { name: 'Select Column' });
5457
this.selectConditionButton = this.code.driver.page.getByRole('button', { name: 'Select Condition' });
58+
this.selectFilterModalValue = (value: string) => this.code.driver.page.locator('.positron-modal-popup').getByRole('button', { name: value });
5559
this.applyFilterButton = this.code.driver.page.getByRole('button', { name: 'Apply Filter' });
60+
this.filteringMenu = this.code.driver.page.getByRole('button', { name: 'Filtering' });
61+
this.menuItemClearFilters = this.code.driver.page.getByRole('button', { name: 'Clear Filters' });
5662
}
5763

5864
async clearAllFilters() {
@@ -109,20 +115,23 @@ export class DataExplorer {
109115
/*
110116
* Add a filter to the data explorer. Only works for a single filter at the moment.
111117
*/
112-
async addFilter(columnName: string, functionText: string, filterValue: string) {
113-
await test.step(`Add filter: ${columnName} ${functionText} ${filterValue}`, async () => {
118+
async addFilter(columnName: string, condition: string, value?: string) {
119+
await test.step(`Add filter: ${columnName} ${condition} ${value}`, async () => {
114120
await this.addFilterButton.click();
115121

116122
// select column
117123
await this.selectColumnButton.click();
118-
await this.code.driver.page.getByRole('button', { name: columnName }).click();
124+
await this.selectFilterModalValue(columnName).click();
119125

120126
// select condition
121127
await this.selectConditionButton.click();
122-
await this.code.driver.page.getByRole('button', { name: functionText, exact: true }).click();
128+
await this.selectFilterModalValue(condition).click();
123129

124130
// enter value
125-
await this.code.driver.page.getByRole('textbox', { name: 'value' }).fill(filterValue);
131+
if (value) {
132+
await this.code.driver.page.getByRole('textbox', { name: 'value' }).fill(value);
133+
}
134+
126135
await this.applyFilterButton.click();
127136
});
128137
}
@@ -285,23 +294,40 @@ export class DataExplorer {
285294
});
286295
}
287296

288-
async verifyTableData(expectedData: Array<{ [key: string]: string }>, timeout = 60000) {
297+
async verifyTableData(expectedData: Array<{ [key: string]: string | number }>, timeout = 60000) {
289298
await test.step('Verify data explorer data', async () => {
290299
await expect(async () => {
291300
const tableData = await this.getDataExplorerTableData();
292-
293301
expect(tableData.length).toBe(expectedData.length);
294302

295303
for (let i = 0; i < expectedData.length; i++) {
296304
const row = expectedData[i];
297-
for (const [key, value] of Object.entries(row)) {
298-
expect(tableData[i][key]).toBe(value);
305+
for (const [key, expectedValue] of Object.entries(row)) {
306+
const actualValue = tableData[i][key];
307+
expect(this.normalize(actualValue)).toBe(this.normalize(expectedValue));
299308
}
300309
}
301310
}).toPass({ timeout });
302311
});
303312
}
304313

314+
private normalize(value: unknown): string {
315+
const str = String(value).trim().toUpperCase();
316+
317+
// Handle true missing values only
318+
if (value === null || value === undefined || ['NA', 'NAN', 'NULL'].includes(str)) {
319+
return '__MISSING__';
320+
}
321+
322+
// If value is numeric (e.g., '25.0'), normalize precision
323+
const num = Number(value);
324+
if (!isNaN(num)) {
325+
return String(num);
326+
}
327+
328+
return String(value).trim();
329+
}
330+
305331
async verifyTableDataLength(expectedLength: number) {
306332
await test.step('Verify data explorer table data length', async () => {
307333
await expect(async () => {
@@ -394,4 +420,8 @@ export class DataExplorer {
394420
await cellLocator.click();
395421
});
396422
}
423+
424+
async clickCopyAsCodeButton() {
425+
await this.workbench.editorActionBar.clickButton('Copy as Code');
426+
}
397427
}

test/e2e/pages/utils/contextMenu.ts renamed to test/e2e/pages/dialog-contextMenu.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
* Licensed under the Elastic License 2.0. See LICENSE.txt for license information.
44
*--------------------------------------------------------------------------------------------*/
55

6-
import { Code } from '../../infra/code.js';
6+
import { Code } from '../infra/code.js';
77
import test, { Locator } from '@playwright/test';
88

99
export class ContextMenu {

test/e2e/pages/dialog-modals.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (C) 2025 Posit Software, PBC. All rights reserved.
3+
* Licensed under the Elastic License 2.0. See LICENSE.txt for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import test, { expect } from '@playwright/test';
7+
import { Code } from '../infra/code.js';
8+
import { Toasts } from './dialog-toasts.js';
9+
10+
export class Modals {
11+
public modalBox = this.code.driver.page.locator('.positron-modal-dialog-box');
12+
public modalTitle = this.modalBox.locator('.simple-title-bar-title');
13+
public modalMessage = this.code.driver.page.locator('.dialog-box .message');
14+
public okButton = this.modalBox.getByRole('button', { name: 'OK' });
15+
public cancelButton = this.modalBox.getByRole('button', { name: 'Cancel' });
16+
public button = (label: string | RegExp) => this.modalBox.getByRole('button', { name: label });
17+
18+
constructor(private readonly code: Code, private toasts: Toasts) { }
19+
20+
// --- Actions ---
21+
22+
async clickOk() {
23+
await test.step('Click `OK` on modal dialog box', async () => {
24+
await this.okButton.click();
25+
});
26+
}
27+
28+
async clickCancel() {
29+
await test.step('Click `Cancel` on modal dialog box', async () => {
30+
await this.cancelButton.click();
31+
});
32+
}
33+
34+
async clickButton(label: string | RegExp) {
35+
await test.step(`Click button in modal dialog box: ${label}`, async () => {
36+
await this.button(label).click();
37+
});
38+
}
39+
40+
async installIPyKernel() {
41+
42+
try {
43+
this.code.logger.log('Checking for modal dialog box');
44+
// fail fast if the modal is not present
45+
await this.expectToBeVisible();
46+
await this.clickOk();
47+
this.code.logger.log('Installing ipykernel');
48+
await this.toasts.expectToBeVisible();
49+
await this.toasts.expectNotToBeVisible();
50+
this.code.logger.log('Installed ipykernel');
51+
// after toast disappears console may not yet be refreshed (still on old interpreter)
52+
// TODO: make this smart later, perhaps by getting the console state from the API
53+
await this.code.wait(5000);
54+
} catch {
55+
this.code.logger.log('Did not find modal dialog box for ipykernel install');
56+
}
57+
}
58+
59+
/**
60+
* Interacts with the Renv install modal dialog box. This dialog box appears when a user opts to
61+
* use Renv in the New Folder Flow and creates a new folder, but Renv is not installed.
62+
* @param action The action to take on the modal dialog box. Either 'install' or 'cancel'.
63+
*/
64+
async installRenvModal(action: 'install' | 'cancel') {
65+
try {
66+
await expect(this.code.driver.page.locator('.simple-title-bar').filter({ hasText: 'Missing R package' })).toBeVisible({ timeout: 30000 });
67+
68+
if (action === 'install') {
69+
this.code.logger.log('Install Renv modal detected: clicking `Install now`');
70+
await this.button('Install now').click();
71+
} else if (action === 'cancel') {
72+
this.code.logger.log('Install Renv modal detected: clicking `Cancel`');
73+
await this.button('Cancel').click();
74+
}
75+
} catch (error) {
76+
this.code.logger.log('No Renv modal detected');
77+
if (process.env.CI) {
78+
throw new Error('Renv modal not detected');
79+
}
80+
}
81+
}
82+
83+
// --- Verifications ---
84+
85+
async expectMessageToContain(text: string | RegExp) {
86+
await test.step(`Verify modal dialog box contains text: ${text}`, async () => {
87+
await expect(this.modalMessage).toContainText(text);
88+
});
89+
}
90+
91+
async expectToBeVisible(title?: string) {
92+
await test.step(`Verify modal dialog box is visible${title ? ` : ${title}` : ''}`, async () => {
93+
await expect(this.modalBox).toBeVisible({ timeout: 30000 });
94+
if (title) {
95+
await expect(this.modalTitle).toHaveText(title, { timeout: 30000 });
96+
}
97+
});
98+
}
99+
}

test/e2e/pages/dialog-popups.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (C) 2025 Posit Software, PBC. All rights reserved.
3+
* Licensed under the Elastic License 2.0. See LICENSE.txt for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import test from '@playwright/test';
7+
import { Code } from '../infra/code.js';
8+
9+
export class Popups {
10+
public popupBox = this.code.driver.page.locator('.positron-modal-popup');
11+
public popupItem = (label: string | RegExp) => this.popupBox.locator('.positron-welcome-menu-item').getByText(label);
12+
13+
constructor(private readonly code: Code) { }
14+
15+
// --- Actions ---
16+
17+
async clickItem(label: string | RegExp) {
18+
await test.step(`Click item in popup dialog box: ${label}`, async () => {
19+
await this.popupItem(label).click();
20+
});
21+
}
22+
}

test/e2e/pages/dialog-toasts.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (C) 2025 Posit Software, PBC. All rights reserved.
3+
* Licensed under the Elastic License 2.0. See LICENSE.txt for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import test, { expect } from '@playwright/test';
7+
import { Code } from '../infra/code.js';
8+
9+
export class Toasts {
10+
11+
public toastNotification = this.code.driver.page.locator('.notification-toast');
12+
public closeButton = this.toastNotification.locator('.codicon-notifications-clear');
13+
public optionButton = (button: string) => this.toastNotification.getByRole('button', { name: button });
14+
15+
constructor(private readonly code: Code) { }
16+
17+
// --- Actions ---
18+
19+
async waitForAppear(timeout = 20000) {
20+
await this.toastNotification.waitFor({ state: 'attached', timeout });
21+
}
22+
23+
async waitForDisappear(timeout = 20000) {
24+
await this.toastNotification.waitFor({ state: 'detached', timeout });
25+
}
26+
27+
async clickButton(button: string) {
28+
await test.step(`Click toast button: ${button}`, async () => {
29+
await this.optionButton(button).click();
30+
});
31+
}
32+
33+
async closeAll() {
34+
const count = await this.toastNotification.count();
35+
for (let i = 0; i < count; i++) {
36+
try {
37+
await this.toastNotification.nth(i).hover();
38+
await this.closeButton.nth(i).click();
39+
} catch {
40+
this.code.logger.log(`Toast ${i} already closed`);
41+
}
42+
}
43+
}
44+
45+
async closeWithText(message: string) {
46+
try {
47+
const toast = this.toastNotification.filter({ hasText: message });
48+
await toast.hover();
49+
await this.closeButton.filter({ hasText: message }).click();
50+
} catch {
51+
this.code.logger.log(`Toast "${message}" not found`);
52+
}
53+
}
54+
55+
// --- Verifications ---
56+
57+
async expectToBeVisible(title?: string | RegExp, timeoutMs = 3000) {
58+
await test.step(`Verify toast ${title ? `visible: ${title}` : 'visible'}`, async () => {
59+
if (title) {
60+
await expect(this.toastNotification.filter({ hasText: title })).toBeVisible({ timeout: timeoutMs });
61+
} else {
62+
await expect(this.toastNotification).toBeVisible({ timeout: timeoutMs });
63+
}
64+
});
65+
}
66+
67+
async expectImportSettingsToastToBeVisible(visible = true) {
68+
await test.step(`Verify import settings toast is ${visible ? '' : 'NOT'} visible`, async () => {
69+
const buttons = [
70+
this.toastNotification.getByRole('button', { name: 'Compare settings' }),
71+
this.toastNotification.getByRole('button', { name: 'Later' }),
72+
this.toastNotification.getByRole('button', { name: "Don't Show Again" }),
73+
];
74+
75+
for (const btn of buttons) {
76+
visible ? await expect(btn).toBeVisible() : await expect(btn).not.toBeVisible();
77+
}
78+
});
79+
}
80+
81+
async expectNotToBeVisible(timeoutMs = 3000) {
82+
const end = Date.now() + timeoutMs;
83+
while (Date.now() < end) {
84+
if (await this.toastNotification.count() > 0) {
85+
throw new Error('Toast appeared unexpectedly');
86+
}
87+
await this.code.driver.page.waitForTimeout(1000);
88+
}
89+
}
90+
}

0 commit comments

Comments
 (0)