diff --git a/src/vs/workbench/services/dialogs/common/dialogService.ts b/src/vs/workbench/services/dialogs/common/dialogService.ts index 45c82286ee54..8a42d8106b2f 100644 --- a/src/vs/workbench/services/dialogs/common/dialogService.ts +++ b/src/vs/workbench/services/dialogs/common/dialogService.ts @@ -52,9 +52,9 @@ export class DialogService extends Disposable implements IDialogService { prompt(prompt: IPromptWithDefaultCancel): Promise>; prompt(prompt: IPrompt): Promise>; async prompt(prompt: IPrompt | IPromptWithCustomCancel | IPromptWithDefaultCancel): Promise | IPromptResultWithCancel> { - if (this.skipDialogs()) { - throw new Error(`DialogService: refused to show dialog in tests. Contents: ${prompt.message}`); - } + // if (this.skipDialogs()) { + // throw new Error(`DialogService: refused to show dialog in tests. Contents: ${prompt.message}`); + // } const handle = this.model.show({ promptArgs: { prompt } }); diff --git a/test/e2e/infra/fixtures/keyboard.ts b/test/e2e/infra/fixtures/keyboard.ts new file mode 100644 index 000000000000..0f4204214319 --- /dev/null +++ b/test/e2e/infra/fixtures/keyboard.ts @@ -0,0 +1,48 @@ +import test, { Page } from '@playwright/test'; + +export enum Hotkeys { + COPY = 'Cmd+C', + PASTE = 'Cmd+V', + CUT = 'Cmd+X', + SELECT_ALL = 'Cmd+A', + SAVE = 'Cmd+S', + UNDO = 'Cmd+Z', + OPEN_FILE = 'Cmd+O', + FIND = 'Cmd+F', + CLOSE_TAB = 'Cmd+W', + FIRST_TAB = 'Cmd+1', + SWITCH_TAB_LEFT = 'Cmd+Shift+[', + SWITCH_TAB_RIGHT = 'Cmd+Shift+]', + CLOSE_ALL_EDITORS = 'Cmd+K Cmd+W', // space indicates a sequence of keys + VISUAL_MODE = 'Cmd+Shift+F4', +} + +export class Keyboard { + constructor(private page: Page) { } + + private getModifierKey(): string { + return process.platform === 'darwin' ? 'Meta' : 'Control'; + } + + async hotKeys(action: Hotkeys) { + await test.step(`Press hotkeys: ${action}`, async () => { + const modifierKey = this.getModifierKey(); + + // Split command if there are multiple sequential key presses + const keySequences = action.split(' ').map(keys => keys.replace(/Cmd/g, modifierKey)); + + for (const key of keySequences) { + await this.page.keyboard.press(key); + } + }) + } + + async press(keys: string) { + await this.page.keyboard.press(keys); + } + + async type(text: string) { + await this.page.keyboard.type(text); + } +} + diff --git a/test/e2e/infra/index.ts b/test/e2e/infra/index.ts index 3aa7873e6646..435e089bdc32 100644 --- a/test/e2e/infra/index.ts +++ b/test/e2e/infra/index.ts @@ -43,6 +43,7 @@ export * from '../pages/scm'; // fixtures export * from './fixtures/userSettings'; export * from './fixtures/interpreter'; +export * from './fixtures/keyboard'; // test-runner export * from './test-runner'; diff --git a/test/e2e/infra/test-runner/test-tags.ts b/test/e2e/infra/test-runner/test-tags.ts index 6a05255bf02f..bb3ccd91c472 100644 --- a/test/e2e/infra/test-runner/test-tags.ts +++ b/test/e2e/infra/test-runner/test-tags.ts @@ -40,14 +40,15 @@ export enum TestTags { OUTPUT = '@:output', PLOTS = '@:plots', PROBLEMS = '@:problems', - REFERENCES = '@:references', R_MARKDOWN = '@:r-markdown', R_PKG_DEVELOPMENT = '@:r-pkg-development', + REFERENCES = '@:references', RETICULATE = '@:reticulate', SCM = '@:scm', TEST_EXPLORER = '@:test-explorer', TOP_ACTION_BAR = '@:top-action-bar', VARIABLES = '@:variables', + VISUAL_MODE = '@:visual-mode', WELCOME = '@:welcome', // platform tags diff --git a/test/e2e/infra/workbench.ts b/test/e2e/infra/workbench.ts index 153d2c1098e5..a0ea7479c6cb 100644 --- a/test/e2e/infra/workbench.ts +++ b/test/e2e/infra/workbench.ts @@ -5,6 +5,7 @@ import { Code } from './code'; import { Interpreter } from '../infra/fixtures/interpreter'; +import { Keyboard } from '../infra/fixtures/keyboard'; import { Popups } from '../pages/popups'; import { Console } from '../pages/console'; import { Variables } from '../pages/variables'; @@ -75,6 +76,7 @@ export class Workbench { readonly problems: Problems; readonly references: References; readonly scm: SCM; + readonly keyboard: Keyboard; constructor(code: Code) { @@ -111,6 +113,7 @@ export class Workbench { this.problems = new Problems(code, this.quickaccess); this.references = new References(code); this.scm = new SCM(code, this.layouts); + this.keyboard = new Keyboard(code.driver.page); } } diff --git a/test/e2e/pages/editorActionBar.ts b/test/e2e/pages/editorActionBar.ts index e697587706ef..548b1372afe9 100644 --- a/test/e2e/pages/editorActionBar.ts +++ b/test/e2e/pages/editorActionBar.ts @@ -134,7 +134,6 @@ export class EditorActionBar { */ 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 }); }); diff --git a/test/e2e/pages/quickaccess.ts b/test/e2e/pages/quickaccess.ts index 7f7fcc6dfe1c..07d2a86b73ed 100644 --- a/test/e2e/pages/quickaccess.ts +++ b/test/e2e/pages/quickaccess.ts @@ -91,28 +91,28 @@ export class QuickAccess { this.code.logger.log('QuickAccess: File search succeeded.'); } - async openFile(path: string): Promise { - await test.step('Open file', async () => { - if (!isAbsolute(path)) { - // we require absolute paths to get a single - // result back that is unique and avoid hitting - // the search process to reduce chances of - // search needing longer. - throw new Error('QuickAccess.openFile requires an absolute path'); - } + async openFile(path: string, waitForFocus = true): Promise { + if (!isAbsolute(path)) { + // we require absolute paths to get a single + // result back that is unique and avoid hitting + // the search process to reduce chances of + // search needing longer. + throw new Error('QuickAccess.openFile requires an absolute path'); + } - const fileName = basename(path); + const fileName = basename(path); - // quick access shows files with the basename of the path - await this.openFileQuickAccessAndWait(path, basename(path)); + // quick access shows files with the basename of the path + await this.openFileQuickAccessAndWait(path, basename(path)); - // open first element - await this.quickInput.selectQuickInputElement(0); + // open first element + await this.quickInput.selectQuickInputElement(0); - // wait for editor being focused + // wait for editor being focused + if (waitForFocus) { await this.editors.waitForActiveTab(fileName); await this.editors.selectTab(fileName); - }); + } } private async openQuickAccessWithRetry(kind: QuickAccessKind, value?: string): Promise { diff --git a/test/e2e/tests/_test.setup.ts b/test/e2e/tests/_test.setup.ts index d75b59b2b5cf..ff70e5113e65 100644 --- a/test/e2e/tests/_test.setup.ts +++ b/test/e2e/tests/_test.setup.ts @@ -21,8 +21,9 @@ import { randomUUID } from 'crypto'; import archiver from 'archiver'; // Local imports -import { Application, Logger, UserSetting, UserSettingsFixtures, createLogger, createApp, TestTags } from '../infra'; +import { Application, Logger, UserSetting, UserSettingsFixtures, createLogger, createApp, TestTags, } from '../infra'; import { PackageManager } from '../pages/utils/packageManager'; +import { Keyboard } from '../infra/fixtures/keyboard'; // Constants const TEMP_DIR = `temp-${randomUUID()}`; @@ -151,9 +152,9 @@ export const test = base.extend({ // ex: await openFile('workspaces/basic-rmd-file/basicRmd.rmd'); openFile: async ({ app }, use) => { - await use(async (filePath: string) => { + await use(async (filePath: string, waitForFocus = true) => { await test.step(`Open file: ${path.basename(filePath)}`, async () => { - await app.workbench.quickaccess.openFile(path.join(app.workspacePathOrFolder, filePath)); + await app.workbench.quickaccess.openFile(path.join(app.workspacePathOrFolder, filePath), waitForFocus); }); }); }, @@ -181,6 +182,13 @@ export const test = base.extend({ }); }, + // ex: await keyboard.hotKeys(HotKeys.COPY); + // ex: await keyboard.press('Enter'); + keyboard: async ({ page }, use) => { + const keyboard = new Keyboard(page); + await use(keyboard); + }, + // ex: await userSettings.set([['editor.actionBar.enabled', 'true']], false); userSettings: [async ({ app }, use) => { const userSettings = new UserSettingsFixtures(app); @@ -318,7 +326,7 @@ export const test = base.extend({ // Runs once per worker. If a worker handles multiple specs, these hooks only run for the first spec. // However, we are using `suiteId` to ensure each suite gets a new worker (and a fresh app // instance). This also ensures these before/afterAll hooks will run for EACH spec -test.beforeAll(async ({ logger }, testInfo) => { +test.beforeAll('Mark test start in logger', async ({ logger }, testInfo) => { // since the worker doesn't know or have access to the spec name when it starts, // we store the spec name in a global variable. this ensures logs are written // to the correct folder even when the app is scoped to "worker". @@ -331,7 +339,7 @@ test.beforeAll(async ({ logger }, testInfo) => { logger.log(''); }); -test.afterAll(async function ({ logger }, testInfo) { +test.afterAll('Mark test end in logger', async function ({ logger }, testInfo) { try { logger.log(''); logger.log(`>>> Suite end: '${testInfo.titlePath[0] ?? 'unknown'}' <<<`); @@ -384,10 +392,11 @@ interface TestFixtures { packages: PackageManager; autoTestFixture: any; devTools: void; - openFile: (filePath: string) => Promise; + openFile: (filePath: string, waitForFocus?: boolean) => Promise; openDataFile: (filePath: string) => Promise; runCommand: (command: string) => Promise; executeCode: (language: 'Python' | 'R', code: string) => Promise; + keyboard: Keyboard; } interface WorkerFixtures { diff --git a/test/e2e/tests/editor-action-bar/document-files.test.ts b/test/e2e/tests/editor-action-bar/document-files.test.ts index c064917749d5..c81e05b572ae 100644 --- a/test/e2e/tests/editor-action-bar/document-files.test.ts +++ b/test/e2e/tests/editor-action-bar/document-files.test.ts @@ -6,7 +6,7 @@ import { expect, Page } from '@playwright/test'; import { test, tags } from '../_test.setup'; import { EditorActionBar } from '../../pages/editorActionBar'; -import { Application } from '../../infra'; +import { Application, Hotkeys, Keyboard } from '../../infra'; let editorActionBar: EditorActionBar; @@ -93,15 +93,13 @@ async function verifyOpenViewerRendersHtml(app: Application, title: string) { } async function verifyOpenChanges(page: Page) { + const keyboard = new Keyboard(page); await test.step('verify "open changes" shows diff', async () => { - async function bindPlatformHotkey(page: Page, key: string) { - await page.keyboard.press(process.platform === 'darwin' ? `Meta+${key}` : `Control+${key}`); - } // make change & save await page.getByText('date', { exact: true }).click(); await page.keyboard.press('X'); - await bindPlatformHotkey(page, 'S'); + await keyboard.hotKeys(Hotkeys.SAVE); // click open changes & verify await editorActionBar.clickButton('Open Changes'); @@ -110,8 +108,8 @@ async function verifyOpenChanges(page: Page) { await page.getByRole('tab', { name: 'quarto_basic.qmd (Working' }).getByLabel('Close').click(); // undo changes & save - await bindPlatformHotkey(page, 'Z'); - await bindPlatformHotkey(page, 'S'); + await keyboard.hotKeys(Hotkeys.UNDO); + await keyboard.hotKeys(Hotkeys.SAVE); }); } diff --git a/test/e2e/tests/problems/problems.test.ts b/test/e2e/tests/problems/problems.test.ts index 4a3ff1888ed6..a65c07cf9be7 100644 --- a/test/e2e/tests/problems/problems.test.ts +++ b/test/e2e/tests/problems/problems.test.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { expect } from '@playwright/test'; -import { Problems, ProblemSeverity } from '../../infra'; +import { Hotkeys, Problems, ProblemSeverity } from '../../infra'; import { test, tags } from '../_test.setup'; import { join } from 'path'; @@ -16,7 +16,7 @@ test.describe('Problems', { tag: [tags.PROBLEMS, tags.WEB, tags.WIN] }, () => { - test('Python - Verify Problems Functionality', async function ({ app, python, openFile }) { + test('Python - Verify Problems Functionality', async function ({ app, python, openFile, keyboard }) { await test.step('Open file and replace "rows" on line 9 with exclamation point', async () => { await openFile(join('workspaces', 'chinook-db-py', 'chinook-sqlite.py')); @@ -43,10 +43,7 @@ test.describe('Problems', { }).toPass({ timeout: 20000 }); }); - await test.step('Revert error', async () => { - await app.code.driver.page.keyboard.press(process.platform === 'darwin' ? 'Meta+Z' : 'Control+Z'); - - }); + await keyboard.hotKeys(Hotkeys.UNDO); await test.step('Verify File Squiggly Is Gone', async () => { const fileSquiggly = Problems.getSelectorInEditor(ProblemSeverity.ERROR); @@ -64,7 +61,7 @@ test.describe('Problems', { }); - test('R - Verify Problems Functionality', async function ({ app, r, openFile }) { + test('R - Verify Problems Functionality', async function ({ app, r, openFile, keyboard }) { await test.step('Open file and replace "albums" on line 5 with exclamation point', async () => { await openFile(join('workspaces', 'chinook-db-r', 'chinook-sqlite.r')); @@ -89,10 +86,7 @@ test.describe('Problems', { expect(errorLocators.length).toBe(1); }); - await test.step('Revert error', async () => { - await app.code.driver.page.keyboard.press(process.platform === 'darwin' ? 'Meta+Z' : 'Control+Z'); - - }); + await keyboard.hotKeys(Hotkeys.UNDO); await test.step('Verify File Squiggly Is Gone', async () => { const fileSquiggly = Problems.getSelectorInEditor(ProblemSeverity.ERROR); diff --git a/test/e2e/tests/scm/scm.test.ts b/test/e2e/tests/scm/scm.test.ts index 3517946a6337..bbeaf7a8e805 100644 --- a/test/e2e/tests/scm/scm.test.ts +++ b/test/e2e/tests/scm/scm.test.ts @@ -3,6 +3,7 @@ * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. *--------------------------------------------------------------------------------------------*/ +import { Hotkeys } from '../../infra'; import { test, tags } from '../_test.setup'; import { join } from 'path'; @@ -14,7 +15,7 @@ test.describe('Source Content Management', { tag: [tags.SCM, tags.WEB, tags.WIN] }, () => { - test('Verify SCM Tracks File Modifications, Staging, and Commit Actions', async function ({ app, openFile }) { + test('Verify SCM Tracks File Modifications, Staging, and Commit Actions', async function ({ app, openFile, keyboard }) { const file = 'chinook-sqlite.py'; await test.step('Open file and add a new line to it', async () => { @@ -28,7 +29,7 @@ test.describe('Source Content Management', { await app.code.driver.page.keyboard.type('print(df)'); - await app.code.driver.page.keyboard.press(process.platform === 'darwin' ? 'Meta+S' : 'Control+S'); + await keyboard.hotKeys(Hotkeys.SAVE); }); diff --git a/test/e2e/tests/visual-mode/visual-mode.test.ts b/test/e2e/tests/visual-mode/visual-mode.test.ts new file mode 100644 index 000000000000..5373ec04439f --- /dev/null +++ b/test/e2e/tests/visual-mode/visual-mode.test.ts @@ -0,0 +1,198 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2025 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import { expect, Page } from '@playwright/test'; +import { test, tags } from '../_test.setup'; +import { Application } from '../../infra'; +import { Keyboard, Hotkeys } from '../../infra/fixtures/keyboard'; + +test.use({ + suiteId: __filename +}); + +const testCases = [ + { + title: 'R Markdown', + filePath: 'workspaces/visual-mode/visual-mode.rmd', + tags: [tags.WEB, tags.VISUAL_MODE, tags.EDITOR, tags.R_MARKDOWN] + }, + { + title: 'Quarto Markdown', + filePath: 'workspaces/visual-mode/visual-mode.qmd', + tags: [tags.WEB, tags.VISUAL_MODE, tags.EDITOR, tags.QUARTO] + }, + { + title: 'Markdown', + filePath: 'workspaces/visual-mode/visual-mode.md', + tags: [tags.WEB, tags.VISUAL_MODE, tags.EDITOR] + }, + +]; + +test.beforeAll('Check project name', async function ({ }, testInfo) { + if (testInfo.project.name !== 'e2e-browser') { + test.skip(); + } +}); + +test.beforeAll('Trigger and accept visual mode dialog', async function ({ openFile, runCommand, page, keyboard }) { + await openFile(testCases[0].filePath, false); + await runCommand('edit in visual mode'); + await page.getByText('Use Visual Mode').click(); + await keyboard.hotKeys(Hotkeys.CLOSE_ALL_EDITORS); +}); + + +for (const { title, filePath, tags } of testCases) { + test.describe(`Visual Mode: ${title} file`, { tag: tags }, () => { + test.beforeEach(`Open file: ${filePath}`, async function ({ openFile }) { + await openFile(filePath, false); + }); + + test.afterEach('close all editors', async function ({ app, keyboard }) { + await keyboard.hotKeys(Hotkeys.CLOSE_ALL_EDITORS); + }); + + test('Verify Markdown Syntax Rendering', async function ({ page, app }) { + await changeEditMode(page, 'Visual'); + await verifyMarkdownSyntaxRendering(page, title); + }); + + test('Verify Mode Content Sync', async function ({ app, page, keyboard }) { + await verifyModeContentSync(app); + await test.step('Clean up file edits', async () => { + try { + await page.getByText('YOLO').dblclick(); + await keyboard.press('Backspace'); + await keyboard.press('Backspace'); + await changeEditMode(page, 'Visual'); + } catch (error) { + // ignore + } + }); + }); + + if (filePath.match(/\.(qmd|rmd)$/)) { + test('Verify Code Block Execution', async function ({ app, page }) { + await changeEditMode(page, 'Visual'); + await verifyCodeExecution(app); + }); + } + + test('Verify Outline', async function ({ }) { + // Add outline test logic if needed + }); + }); +} + + +// Helper functions + +async function verifyMarkdownSyntaxRendering(page: Page, title: string) { + await test.step('Verify markdown syntax rendering', async () => { + const viewerFrame = page.frameLocator('.webview').frameLocator('#active-frame'); + + // verify heading + await expect(viewerFrame.getByRole('heading', { name: `${title} Testing Document` })).toBeVisible(); + + // verify bold text + const boldElement = viewerFrame.getByText('bold'); + await expect(boldElement).toHaveCSS('font-weight', '700'); + + // verify italic text + const italicElement = viewerFrame.getByText('italic'); + const fontStyle = await italicElement.evaluate(el => window.getComputedStyle(el).fontStyle); + expect(fontStyle).toBe('italic'); + + // verify hyperlink + const hyperlinkElement = viewerFrame.getByText('link'); + await expect(hyperlinkElement).toHaveAttribute('href', '#0'); + + // verify bullet list: top-level bullet list is present + const bulletList = viewerFrame.locator('ul.pm-bullet-list').nth(0); + await expect(bulletList).toBeVisible(); + await expect(bulletList.locator('> li')).toHaveCount(2); // Ensures only top-level items are counted + + // verify bullet list: "Item 2" contains a nested bullet list + const nestedList = viewerFrame.locator('ul.pm-bullet-list > li:nth-of-type(2) ul.pm-bullet-list'); + await expect(nestedList).toBeVisible(); + await expect(nestedList.locator('> li')).toHaveCount(2); // Only count direct children of nested list + + // verify bullet: bullets are visible via CSS + const listStyle = await bulletList.evaluate(el => window.getComputedStyle(el).listStyleType); + expect(listStyle).not.toBe('none'); + + // verify bullet list: list item text at both levels + await expect(viewerFrame.locator('ul.pm-bullet-list > li')).toContainText(['Item 1', 'Item 2']); // Top-level items + await expect(nestedList.locator('> li')).toContainText(['Sub-item 2.1', 'Sub-item 2.2']); // Nested items + }); +} + +async function changeEditMode(page: Page, mode: 'Source' | 'Visual') { + await test.step(`Change edit mode to ${mode}`, async () => { + const keyboard = new Keyboard(page); + + try { + // if we are in mode 'source' we should see line numbers + await expect(page.locator('div.line-numbers').first()).toBeVisible({ timeout: 2500 }); + if (mode === 'Visual') { + await keyboard.hotKeys(Hotkeys.VISUAL_MODE); + } + } catch (error) { + // only get here if we are currently in visual mode + if (mode === 'Source') { + await keyboard.hotKeys(Hotkeys.VISUAL_MODE); + } + } + + const viewerFrame = page.frameLocator('.webview').frameLocator('#active-frame'); + + // validate we are in correct mode + mode === 'Source' + ? await expect(page.locator('div.line-numbers').first()).toBeVisible() + : await expect(viewerFrame.getByRole('button', { name: /Show Outline/ })).toBeVisible(); + }); +} + +async function verifyModeContentSync(app: Application): Promise { + const page = app.code.driver.page; + const testText = 'YOLO '; + const viewerFrame = page.frameLocator('.webview').frameLocator('#active-frame'); + + await test.step('Edit content in source mode', async () => { + await changeEditMode(page, 'Source'); + await page.getByText("synchronization").click(); + await page.keyboard.type(testText); + }); + + await test.step('Verify content in visual mode', async () => { + await changeEditMode(page, 'Visual'); + await expect(viewerFrame.getByText(testText)).toBeVisible(); + }); + + await test.step('Verify content in source mode', async () => { + await changeEditMode(page, 'Source'); + await expect(page.getByText(testText)).toBeVisible(); + }); +} + +async function verifyCodeExecution(app: Application) { + const page = app.code.driver.page; + const viewerFrame = page.frameLocator('.webview').frameLocator('#active-frame'); + + await test.step('Verify Python run cell button', async () => { + await viewerFrame.getByText('{python}# A simple Python').click(); + await expect(viewerFrame.getByTitle('Run Cell', { exact: true })).toBeVisible(); + }); + + await test.step('Verify R cell code execution', async () => { + await viewerFrame.getByText('{r}# A simple R').click(); + await viewerFrame.getByTitle('Run Cell', { exact: true }).click(); + await app.workbench.plots.waitForCurrentPlot(); + }); +} + +// await verifyYamlRendering(); +// await verifyEquationRendering();