diff --git a/.github/workflows/linuxUI.yml b/.github/workflows/linuxUI.yml index b8c5f837..cb09c4f5 100644 --- a/.github/workflows/linuxUI.yml +++ b/.github/workflows/linuxUI.yml @@ -44,35 +44,13 @@ jobs: - name: Build VSIX file run: vsce package - - name: UI Test - continue-on-error: true - id: test - run: DISPLAY=:99 npm run test-ui + - name: E2E Test (Playwright) + run: DISPLAY=:99 npm run test-e2e - - name: Retry UI Test 1 - continue-on-error: true - if: steps.test.outcome=='failure' - id: retry1 - run: | - git reset --hard - git clean -fd - DISPLAY=:99 npm run test-ui - - - name: Retry UI Test 2 - continue-on-error: true - if: steps.retry1.outcome=='failure' - id: retry2 - run: | - git reset --hard - git clean -fd - DISPLAY=:99 npm run test-ui - - - name: Set test status - if: ${{ steps.test.outcome=='failure' && steps.retry1.outcome=='failure' && steps.retry2.outcome=='failure' }} - run: | - echo "Tests failed" - exit 1 - - - name: Print language server Log - if: ${{ failure() }} - run: find ./test-resources/settings/User/workspaceStorage/*/redhat.java/jdt_ws/.metadata/.log -print -exec cat '{}' \;; + - name: Upload test results + if: ${{ always() }} + uses: actions/upload-artifact@v4 + with: + name: e2e-results-linux + path: test-results/ + retention-days: 7 diff --git a/.github/workflows/windowsUI.yml b/.github/workflows/windowsUI.yml index a952e1a7..aad11d73 100644 --- a/.github/workflows/windowsUI.yml +++ b/.github/workflows/windowsUI.yml @@ -44,35 +44,13 @@ jobs: - name: Build VSIX file run: vsce package - - name: UI Test - continue-on-error: true - id: test - run: npm run test-ui + - name: E2E Test (Playwright) + run: npm run test-e2e - - name: Retry UI Test 1 - continue-on-error: true - if: steps.test.outcome=='failure' - id: retry1 - run: | - git reset --hard - git clean -fd - npm run test-ui - - - name: Retry UI Test 2 - continue-on-error: true - if: steps.retry1.outcome=='failure' - id: retry2 - run: | - git reset --hard - git clean -fd - npm run test-ui - - - name: Set test status - if: ${{ steps.test.outcome=='failure' && steps.retry1.outcome=='failure' && steps.retry2.outcome=='failure' }} - run: | - echo "Tests failed" - exit 1 - - - name: Print language server Log if job failed - if: ${{ failure() }} - run: Get-ChildItem -Path ./test-resources/settings/User/workspaceStorage/*/redhat.java/jdt_ws/.metadata/.log | cat + - name: Upload test results + if: ${{ always() }} + uses: actions/upload-artifact@v4 + with: + name: e2e-results-windows + path: test-results/ + retention-days: 7 diff --git a/package.json b/package.json index b43d2647..e8d02977 100644 --- a/package.json +++ b/package.json @@ -1263,6 +1263,7 @@ "watch": "webpack --mode development --watch", "test": "tsc -p . && webpack --config webpack.config.js --mode development && node ./dist/test/index.js", "test-ui": "tsc -p . && webpack --config webpack.config.js --mode development && node ./dist/test/ui/index.js", + "test-e2e": "npx playwright test --config test/e2e/playwright.config.ts", "build-server": "node scripts/buildJdtlsExt.js", "vscode:prepublish": "tsc -p ./ && webpack --mode production", "tslint": "tslint -t verbose --project tsconfig.json" @@ -1284,6 +1285,7 @@ "tslint": "^6.1.3", "typescript": "^4.9.4", "vscode-extension-tester": "^8.23.0", + "@playwright/test": "^1.50.0", "webpack": "^5.105.0", "webpack-cli": "^4.10.0" }, diff --git a/test/e2e/fixtures/baseTest.ts b/test/e2e/fixtures/baseTest.ts new file mode 100644 index 00000000..faf06313 --- /dev/null +++ b/test/e2e/fixtures/baseTest.ts @@ -0,0 +1,195 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +/** + * Playwright test fixture that launches VS Code via Electron, + * opens a temporary copy of a test project, and tears everything + * down after the test. + * + * Usage in test files: + * + * import { test, expect } from "../fixtures/baseTest"; + * + * test("my test", async ({ page }) => { + * // `page` is a Playwright Page attached to VS Code + * }); + */ + +import { _electron, test as base, type Page } from "@playwright/test"; +import { downloadAndUnzipVSCode, resolveCliArgsFromVSCodeExecutablePath } from "@vscode/test-electron"; +import * as fs from "fs-extra"; +import * as os from "os"; +import * as path from "path"; + +export { expect } from "@playwright/test"; + +// Root of the extension source tree +const EXTENSION_ROOT = path.join(__dirname, "..", "..", ".."); +// Root of the test data projects +const TEST_DATA_ROOT = path.join(EXTENSION_ROOT, "test"); + +export type TestOptions = { + /** VS Code version to download, default "stable" */ + vscodeVersion: string; + /** Relative path under `test/` to the project to open (e.g. "maven") */ + testProjectDir: string; +}; + +type TestFixtures = TestOptions & { + /** Playwright Page connected to the VS Code Electron window */ + page: Page; +}; + +export const test = base.extend({ + vscodeVersion: [process.env.VSCODE_VERSION || "stable", { option: true }], + testProjectDir: ["maven", { option: true }], + + page: async ({ vscodeVersion, testProjectDir }, use, testInfo) => { + // 1. Create a temp directory and copy the test project into it. + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "java-dep-e2e-")); + const projectName = path.basename(testProjectDir); + const projectDir = path.join(tmpDir, projectName); + fs.copySync(path.join(TEST_DATA_ROOT, testProjectDir), projectDir); + + // Write VS Code settings to suppress telemetry prompts and notification noise + const vscodeDir = path.join(projectDir, ".vscode"); + fs.ensureDirSync(vscodeDir); + const settingsPath = path.join(vscodeDir, "settings.json"); + let existingSettings: Record = {}; + if (fs.existsSync(settingsPath)) { + // settings.json may contain JS-style comments (JSONC), strip them before parsing + const raw = fs.readFileSync(settingsPath, "utf-8"); + const stripped = raw.replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, ""); + try { + existingSettings = JSON.parse(stripped); + } catch { + // If still invalid, start fresh — our injected settings are more important + existingSettings = {}; + } + } + const mergedSettings = { + ...existingSettings, + "telemetry.telemetryLevel": "off", + "redhat.telemetry.enabled": false, + "workbench.colorTheme": "Default Dark Modern", + "update.mode": "none", + "extensions.ignoreRecommendations": true, + }; + fs.writeFileSync(settingsPath, JSON.stringify(mergedSettings, null, 4)); + + // 2. Resolve VS Code executable. + const vscodePath = await downloadAndUnzipVSCode(vscodeVersion); + const [, ...cliArgs] = resolveCliArgsFromVSCodeExecutablePath(vscodePath); + + // 3. Launch VS Code as an Electron app. + const electronApp = await _electron.launch({ + executablePath: vscodePath, + env: { ...process.env, NODE_ENV: "development" }, + args: [ + "--no-sandbox", + "--disable-gpu-sandbox", + "--disable-updates", + "--skip-welcome", + "--skip-release-notes", + "--disable-workspace-trust", + "--password-store=basic", + // Suppress notifications that block UI interactions + "--disable-telemetry", + ...cliArgs, + `--extensionDevelopmentPath=${EXTENSION_ROOT}`, + projectDir, + ], + }); + + const page = await electronApp.firstWindow(); + + // Auto-dismiss Electron native dialogs (e.g. redhat.java refactoring + // confirmation, delete file confirmation). These dialogs are outside + // the renderer DOM and cannot be handled via Playwright Page API. + // Monkey-patch dialog.showMessageBox to find and click the confirm + // button by label, falling back to the first button. + await electronApp.evaluate(({ dialog }) => { + const confirmLabels = /^(OK|Delete|Move to Recycle Bin|Move to Trash)$/i; + dialog.showMessageBox = async (_win: any, opts: any) => { + const options = opts || _win; + const buttons: string[] = options?.buttons || []; + let idx = buttons.findIndex((b: string) => confirmLabels.test(b)); + if (idx < 0) idx = 0; + return { response: idx, checkboxChecked: true }; + }; + dialog.showMessageBoxSync = (_win: any, opts: any) => { + const options = opts || _win; + const buttons: string[] = options?.buttons || []; + let idx = buttons.findIndex((b: string) => confirmLabels.test(b)); + if (idx < 0) idx = 0; + return idx; + }; + }); + + // Dismiss any startup notifications/dialogs before handing off to tests + await page.waitForTimeout(3_000); + await dismissAllNotifications(page); + + // 4. Optional tracing + if (testInfo.retry > 0 || !process.env.CI) { + await page.context().tracing.start({ screenshots: true, snapshots: true, title: testInfo.title }); + } + + // ---- hand off to the test ---- + await use(page); + + // ---- teardown ---- + // Save trace on failure/retry + if (testInfo.status !== "passed" || testInfo.retry > 0) { + const tracePath = testInfo.outputPath("trace.zip"); + try { + await page.context().tracing.stop({ path: tracePath }); + testInfo.attachments.push({ name: "trace", path: tracePath, contentType: "application/zip" }); + } catch { + // Tracing may not have been started + } + } + + await electronApp.close(); + + // Clean up temp directory + try { + fs.rmSync(tmpDir, { force: true, recursive: true }); + } catch (e) { + console.warn(`Warning: failed to clean up ${tmpDir}: ${e}`); + } + }, +}); + +/** + * Dismiss all VS Code notification toasts (telemetry prompts, theme suggestions, etc.). + * These notifications can steal focus and block Quick Open / Command Palette interactions. + */ +async function dismissAllNotifications(page: Page): Promise { + try { + // Click "Clear All Notifications" if the notification center button is visible + const clearAll = page.locator(".notifications-toasts .codicon-notifications-clear-all, .notification-toast .codicon-close"); + let count = await clearAll.count().catch(() => 0); + while (count > 0) { + await clearAll.first().click(); + await page.waitForTimeout(500); + count = await clearAll.count().catch(() => 0); + } + + // Also try the command palette approach as a fallback + const notificationToasts = page.locator(".notification-toast"); + if (await notificationToasts.count().catch(() => 0) > 0) { + // Use keyboard shortcut to clear all notifications + await page.keyboard.press("Control+Shift+P"); + const input = page.locator(".quick-input-widget input.input"); + if (await input.isVisible({ timeout: 3_000 }).catch(() => false)) { + await input.fill("Notifications: Clear All Notifications"); + await page.waitForTimeout(500); + await input.press("Enter"); + await page.waitForTimeout(500); + } + } + } catch { + // Best effort + } +} diff --git a/test/e2e/globalSetup.ts b/test/e2e/globalSetup.ts new file mode 100644 index 00000000..a35f626b --- /dev/null +++ b/test/e2e/globalSetup.ts @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +import { downloadAndUnzipVSCode, resolveCliArgsFromVSCodeExecutablePath } from "@vscode/test-electron"; +import * as childProcess from "child_process"; +import * as path from "path"; + +/** + * Global setup runs once before all test files. + * It downloads VS Code, then installs the redhat.java extension and our own + * VSIX so that every test run starts from an identical, pre-provisioned state. + */ +export default async function globalSetup(): Promise { + // Download VS Code stable (or the version configured via VSCODE_VERSION env). + const vscodeVersion = process.env.VSCODE_VERSION || "stable"; + console.log(`[globalSetup] Downloading VS Code ${vscodeVersion}…`); + const vscodePath = await downloadAndUnzipVSCode(vscodeVersion); + const [cli, ...cliArgs] = resolveCliArgsFromVSCodeExecutablePath(vscodePath); + + // On Windows, the CLI is a .cmd batch file which requires shell: true. + const isWindows = process.platform === "win32"; + const execOptions: childProcess.ExecFileSyncOptions = { + encoding: "utf-8", + stdio: "inherit", + timeout: 120_000, + shell: isWindows, + }; + + // Install the Language Support for Java extension from the Marketplace. + console.log("[globalSetup] Installing redhat.java extension…"); + childProcess.execFileSync(cli, [...cliArgs, "--install-extension", "redhat.java"], execOptions); + + // Install our own VSIX if one exists (built by `vsce package`). + const vsixGlob = path.join(__dirname, "..", "..", "*.vsix"); + const glob = require("glob"); + const vsixFiles: string[] = glob.sync(vsixGlob); + if (vsixFiles.length > 0) { + const vsix = vsixFiles[0]; + console.log(`[globalSetup] Installing VSIX ${path.basename(vsix)}…`); + childProcess.execFileSync(cli, [...cliArgs, "--install-extension", vsix], { + ...execOptions, + timeout: 60_000, + }); + } else { + console.log("[globalSetup] No VSIX found — extension will be loaded via extensionDevelopmentPath"); + } +} diff --git a/test/e2e/playwright.config.ts b/test/e2e/playwright.config.ts new file mode 100644 index 00000000..ff5f3762 --- /dev/null +++ b/test/e2e/playwright.config.ts @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +import { defineConfig } from "@playwright/test"; +import * as path from "path"; + +export default defineConfig({ + testDir: path.join(__dirname, "tests"), + reporter: process.env.CI + ? [["list"], ["junit", { outputFile: path.join(__dirname, "..", "..", "test-results", "e2e-results.xml") }]] + : "list", + // Java Language Server can take 2-3 minutes to fully index on first run. + timeout: 180_000, + // Run tests sequentially — launching multiple VS Code instances is too resource-heavy. + workers: 1, + // Allow one retry in CI to handle transient environment issues. + retries: process.env.CI ? 1 : 0, + expect: { + timeout: 30_000, + }, + globalSetup: path.join(__dirname, "globalSetup.ts"), + use: { + // Automatically take a screenshot when a test fails. + screenshot: "only-on-failure", + // Capture full trace on retry for deep debugging (includes screenshots, DOM snapshots, network). + trace: "on-first-retry", + }, + outputDir: path.join(__dirname, "..", "..", "test-results", "e2e"), +}); diff --git a/test/e2e/tests/fileOperations.test.ts b/test/e2e/tests/fileOperations.test.ts new file mode 100644 index 00000000..1a103936 --- /dev/null +++ b/test/e2e/tests/fileOperations.test.ts @@ -0,0 +1,132 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +/** + * E2E tests for file / resource operations in the Java Projects view. + * + * Covers: + * - java.view.package.newJavaClass + * - java.view.package.newPackage + * - java.view.package.renameFile + * - java.view.package.moveFileToTrash + */ + +import * as fs from "fs-extra"; +import * as path from "path"; +import { test, expect } from "../fixtures/baseTest"; +import { Timeout, VSCode } from "../utils/constants"; +import JavaOperator from "../utils/javaOperator"; +import VscodeOperator from "../utils/vscodeOperator"; + +test.describe("File Operations", () => { + + test.use({ testProjectDir: "maven" }); + + test.beforeEach(async ({ page }) => { + await VscodeOperator.dismissModalDialog(page); + await JavaOperator.openFile(page, "App.java"); + await JavaOperator.waitForJavaLSReady(page); + await JavaOperator.focusJavaProjects(page); + }); + + test("create new Java class", async ({ page }) => { + // Trigger New... on the project node + await JavaOperator.triggerNewResource(page, "my-app"); + + // Select "Java Class" (first item) + await VscodeOperator.selectQuickPickIndex(page, 0); + + // Select source folder "src/main/java" + await VscodeOperator.selectQuickPickItem(page, "src/main/java"); + + // Type class name and confirm + await VscodeOperator.fillQuickInput(page, "App2"); + + // Editor should open with the new file + const tabFound = await VscodeOperator.waitForEditorTab(page, "App2.java"); + expect(tabFound).toBeTruthy(); + }); + + test("create new package", async ({ page }) => { + await JavaOperator.triggerNewResource(page, "my-app"); + + // Select "Package" + await VscodeOperator.selectQuickPickItem(page, "Package"); + + // Select source folder + await VscodeOperator.selectQuickPickItem(page, "src/main/java"); + + // Type package name and confirm + await VscodeOperator.fillQuickInput(page, "com.mycompany.newpkg"); + + // Wait briefly for directory creation + await page.waitForTimeout(Timeout.TREE_EXPAND); + }); + + test("rename Java file", async ({ page }) => { + await JavaOperator.collapseFileExplorer(page); + + // Expand to AppToRename + await JavaOperator.expandTreePath(page, "my-app", "src/main/java", "com.mycompany.app"); + + // Right-click AppToRename to select it AND open the context menu. + // We do NOT left-click first because that opens the file in the editor + // and steals focus away from the tree view. + const appToRename = page.getByRole(VSCode.TREE_ITEM_ROLE, { name: "AppToRename" }).first(); + await VscodeOperator.selectContextMenuItem(page, appToRename, /^Rename/); + + // The extension shows a showInputBox (quick-input) for the new name + await VscodeOperator.fillQuickInput(page, "AppRenamed"); + + // Handle extension's own rename confirmation dialog if it appears. + // The Electron native refactoring dialog from redhat.java is + // auto-dismissed by the showMessageBox monkey-patch in baseTest.ts. + try { + await VscodeOperator.clickDialogButton(page, "OK", 5_000); + } catch { + // Dialog may not appear in all cases + } + + // On Linux, if the refactoring dialog resolved to "Show Preview", + // VS Code shows a Refactor Preview panel with "Apply" / "Discard" + // buttons. Click "Apply" to complete the rename. + try { + const applyBtn = page.getByRole(VSCode.BUTTON_ROLE, { name: "Apply" }); + if (await applyBtn.isVisible({ timeout: 3_000 }).catch(() => false)) { + await applyBtn.click(); + await page.waitForTimeout(Timeout.CLICK); + } + } catch { + // No refactor preview + } + + // Editor should open with renamed file + const tabFound = await VscodeOperator.waitForEditorTab(page, "AppRenamed.java"); + expect(tabFound).toBeTruthy(); + }); + + test("delete Java file", async ({ page }) => { + await JavaOperator.collapseFileExplorer(page); + await JavaOperator.expandTreePath(page, "my-app", "src/main/java", "com.mycompany.app"); + + // Right-click AppToDelete directly (no left-click to avoid opening + // the file and losing tree focus). + const appToDelete = page.getByRole(VSCode.TREE_ITEM_ROLE, { name: "AppToDelete" }).first(); + await VscodeOperator.selectContextMenuItem(page, appToDelete, /^Delete/); + + // Confirm deletion in dialog + try { + const dialog = page.locator(".monaco-dialog-box"); + await dialog.waitFor({ state: "visible", timeout: 5_000 }); + const confirmBtn = dialog.getByRole(VSCode.BUTTON_ROLE) + .filter({ hasText: /Move to Trash|Move to Recycle Bin|Delete|OK/ }); + await confirmBtn.first().click(); + } catch { + // Dialog may not appear + } + + // Wait for tree item to disappear + const gone = await VscodeOperator.waitForTreeItemGone(page, "AppToDelete"); + expect(gone).toBeTruthy(); + }); +}); diff --git a/test/e2e/tests/libraries.test.ts b/test/e2e/tests/libraries.test.ts new file mode 100644 index 00000000..16203892 --- /dev/null +++ b/test/e2e/tests/libraries.test.ts @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +/** + * E2E tests for library management and project creation. + * + * Covers: + * - java.project.addLibraries + * - java.project.addLibraryFolders + * - java.project.create + */ + +import * as fs from "fs-extra"; +import * as os from "os"; +import * as path from "path"; +import { test, expect } from "../fixtures/baseTest"; +import { Timeout, VSCode } from "../utils/constants"; +import JavaOperator from "../utils/javaOperator"; +import VscodeOperator from "../utils/vscodeOperator"; + +test.describe("Libraries & Project Creation", () => { + + test.describe("invisible project library management", () => { + + test.use({ testProjectDir: "invisible" }); + + test.beforeEach(async ({ page }) => { + await VscodeOperator.dismissModalDialog(page); + await JavaOperator.openFile(page, "App.java"); + await JavaOperator.waitForJavaLSReady(page); + await JavaOperator.focusJavaProjects(page); + }); + + test.skip("add and remove JAR library", async ({ page }) => { + // Skip: the addLibraries command opens a native OS file dialog + // (vscode.window.showOpenDialog) which Playwright cannot automate. + // This test requires Electron dialog mocking support. + }); + }); + + test.describe("create new project", () => { + + test.use({ testProjectDir: "invisible" }); + + test.skip("java.project.create with no build tools", async ({ page }) => { + // Skip: after selecting "No build tools", scaffoldSimpleProject() + // calls vscode.window.showOpenDialog() which opens a native OS file + // dialog that Playwright cannot automate. This test requires + // Electron dialog mocking support. + await VscodeOperator.dismissModalDialog(page); + await JavaOperator.openFile(page, "App.java"); + await JavaOperator.waitForJavaLSReady(page); + + // Create a temp folder for the new project + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "java-new-project-")); + + await VscodeOperator.executeCommand(page, "Java: Create Java Project"); + // The build-tool quick pick may take a moment to appear + await page.waitForTimeout(Timeout.TREE_EXPAND); + + // Select "No build tools" + await VscodeOperator.selectQuickPickItem(page, "No build tools"); + + // The project location dialog uses a native file picker on some platforms. + // Enter the project name when prompted. + await VscodeOperator.fillQuickInput(page, "helloworld"); + + // Wait for project files to be created + await page.waitForTimeout(Timeout.TREE_EXPAND * 2); + + // Clean up + try { + fs.rmSync(tmpDir, { force: true, recursive: true }); + } catch { + // Ignore cleanup errors + } + }); + }); +}); diff --git a/test/e2e/tests/projectExplorer.test.ts b/test/e2e/tests/projectExplorer.test.ts new file mode 100644 index 00000000..b32a58df --- /dev/null +++ b/test/e2e/tests/projectExplorer.test.ts @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +/** + * E2E tests for the Java Projects explorer view. + * + * Covers: + * - javaProjectExplorer.focus + * - java.view.package.linkWithFolderExplorer + * - java.view.package.unlinkWithFolderExplorer + */ + +import { test, expect } from "../fixtures/baseTest"; +import { Timeout, VSCode } from "../utils/constants"; +import JavaOperator from "../utils/javaOperator"; +import VscodeOperator from "../utils/vscodeOperator"; + +test.describe("Project Explorer", () => { + + test.use({ testProjectDir: "maven" }); + + test.beforeEach(async ({ page }) => { + await VscodeOperator.dismissModalDialog(page); + // Open a Java file so the language server activates + await JavaOperator.openFile(page, "App.java"); + await JavaOperator.waitForJavaLSReady(page); + await JavaOperator.focusJavaProjects(page); + }); + + test("javaProjectExplorer.focus shows Java Projects section", async ({ page }) => { + // beforeEach already focuses Java Projects; verify the tree is populated + const found = await VscodeOperator.waitForTreeItem(page, "my-app", 15_000); + expect(found).toBeTruthy(); + }); + + test("linkWithFolderExplorer reveals active file in tree", async ({ page }) => { + // Expand project to source level + await JavaOperator.expandTreePath(page, "my-app", "src/main/java"); + + // The package node should expand and reveal the class + const packageVisible = await VscodeOperator.waitForTreeItem(page, "com.mycompany.app", 15_000); + expect(packageVisible).toBeTruthy(); + + // Use exact match to avoid matching "app", "App.java", "AppToDelete" etc. + const classVisible = await page.getByRole(VSCode.TREE_ITEM_ROLE, { name: "App", exact: true }) + .isVisible().catch(() => false); + expect(classVisible).toBeTruthy(); + }); + + test("unlinkWithFolderExplorer stops auto-reveal", async ({ page }) => { + // Use command to unlink + await VscodeOperator.executeCommand(page, "Java: Unlink with Editor"); + await page.waitForTimeout(Timeout.CLICK); + + // Open a different file — tree should NOT auto-expand + await JavaOperator.openFile(page, "AppToRename.java"); + await page.waitForTimeout(Timeout.TREE_EXPAND); + + // Re-link for cleanup + await VscodeOperator.executeCommand(page, "Java: Link with Editor"); + }); +}); diff --git a/test/e2e/utils/constants.ts b/test/e2e/utils/constants.ts new file mode 100644 index 00000000..8a409e74 --- /dev/null +++ b/test/e2e/utils/constants.ts @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +/** + * Centralized constants for VS Code E2E tests. + * Keep ARIA role strings, timeouts, and selector patterns here + * so that tests and operators stay tidy. + */ + +// --------------------------------------------------------------------------- +// Timeouts (milliseconds) +// --------------------------------------------------------------------------- + +export const Timeout = { + /** Short pause after a click or keystroke */ + CLICK: 1_000, + /** Longer pause after expanding a tree item (DOM needs time to render children) */ + TREE_EXPAND: 3_000, + /** Wait before the first assertion in a test (let VS Code settle) */ + PREPARE: 5_000, + /** Wait for a heavy extension to activate */ + EXTENSION_ACTIVATE: 10_000, + /** Maximum wait for Java Language Server to report "Ready" */ + JAVA_LS_READY: 180_000, + /** Interval between polls when waiting for LS readiness */ + JAVA_LS_POLL_INTERVAL: 2_000, +} as const; + +// --------------------------------------------------------------------------- +// VS Code ARIA roles & selectors +// --------------------------------------------------------------------------- + +export const VSCode = { + // Command palette + CMD_PALETTE_KEY: "F1", + CMD_PALETTE_ROLE: "combobox" as const, + CMD_PALETTE_INPUT_NAME: "INPUT", + OPTION_ROLE: "option" as const, + LISTBOX_ROLE: "listbox" as const, + // Side bar / activity bar + TAB_ROLE: "tab" as const, + // Tree view + TREE_ITEM_ROLE: "treeitem" as const, + // Buttons & toolbars + BUTTON_ROLE: "button" as const, + TOOLBAR_ROLE: "toolbar" as const, + // Keys + ENTER: "Enter", + ESCAPE: "Escape", + // Elements + LINK: "a", +} as const; + +// --------------------------------------------------------------------------- +// Java-specific +// --------------------------------------------------------------------------- + +export const Java = { + JAVA_PROJECTS_SECTION: "Java Projects", + JAVA_LS_STATUS_LABEL: "Language Status", +} as const; diff --git a/test/e2e/utils/javaOperator.ts b/test/e2e/utils/javaOperator.ts new file mode 100644 index 00000000..67d2657f --- /dev/null +++ b/test/e2e/utils/javaOperator.ts @@ -0,0 +1,145 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +/** + * Java-specific helpers for E2E tests. + * + * The most important one is `waitForJavaLSReady()` which polls the status bar + * until Language Support for Java reports a "ready" state, using Playwright's + * `expect.poll` so that the test automatically retries and fails cleanly if + * the LS never reaches readiness. + */ + +import { expect, Page } from "@playwright/test"; +import { Timeout, VSCode } from "./constants"; +import VscodeOperator from "./vscodeOperator"; + +export default class JavaOperator { + + /** + * Waits for the Java Language Server to finish indexing. + * + * Strategy: poll the status bar for a button whose accessible name + * contains "Java: Ready" (e.g. "coffee Java: Ready, Show Java status menu"). + * This is more reliable than clicking a hover because it doesn't depend + * on internal VS Code DOM IDs that vary across versions. + */ + static async waitForJavaLSReady(page: Page, timeoutMs = Timeout.JAVA_LS_READY): Promise { + // Give the extension a moment to register its status bar item + await page.waitForTimeout(Timeout.EXTENSION_ACTIVATE); + + await expect.poll(async () => { + try { + const javaReadyButton = page.getByRole(VSCode.BUTTON_ROLE, { name: /Java:\s*Ready/i }); + if (await javaReadyButton.isVisible().catch(() => false)) { + return "ready"; + } + return "not-ready"; + } catch { + return "not-ready"; + } + }, { + message: "Java Language Server did not become ready in time", + timeout: timeoutMs, + intervals: [Timeout.JAVA_LS_POLL_INTERVAL], + }).toBe("ready"); + } + + /** + * Focuses the Java Projects view and waits for it to render. + * + * Directly clicks the "Java Projects Section" button in the Explorer + * sidebar rather than going through the command palette, which may + * fail to find the view-focus command by its ID. + */ + static async focusJavaProjects(page: Page): Promise { + const sectionButton = page.getByRole(VSCode.BUTTON_ROLE, { name: /Java Projects Section/i }); + if (await sectionButton.isVisible({ timeout: 5_000 }).catch(() => false)) { + // Only click to expand if the section is currently collapsed + const expanded = await sectionButton.getAttribute("aria-expanded"); + if (expanded !== "true") { + await sectionButton.click(); + } + } else { + // Fallback: try via command palette + await VscodeOperator.executeCommand(page, "Java Projects: Focus on Java Projects View"); + } + await page.waitForTimeout(Timeout.TREE_EXPAND); + } + + /** + * Expands tree items along a path (e.g. "my-app" → "src/main/java" → "com.mycompany.app"). + * + * Waits for each item's `aria-expanded` attribute to appear before clicking, + * because VS Code only sets it after the tree data provider's `getChildren()` + * has returned — until then the node is treated as a leaf. + */ + static async expandTreePath(page: Page, ...labels: string[]): Promise { + for (const label of labels) { + const item = page.getByRole(VSCode.TREE_ITEM_ROLE, { name: label }).first(); + await item.waitFor({ state: "visible", timeout: 15_000 }); + + // Wait for the node to become expandable (aria-expanded present). + await expect.poll(async () => { + return await item.getAttribute("aria-expanded"); + }, { + message: `Tree item "${label}" did not become expandable`, + timeout: 15_000, + }).toBeTruthy(); + + const expanded = await item.getAttribute("aria-expanded"); + if (expanded !== "true") { + await item.click(); + await page.waitForTimeout(Timeout.TREE_EXPAND); + } + } + } + + /** + * Collapses the default file explorer section so that tree items in the + * Java Projects view are within the viewport. + */ + static async collapseFileExplorer(page: Page): Promise { + try { + // Try to collapse any expanded section above Java Projects + const sections = page.locator(".split-view-view .pane-header[aria-expanded='true']"); + const count = await sections.count(); + if (count > 0) { + await sections.first().click(); + await page.waitForTimeout(Timeout.CLICK); + } + } catch { + // Best-effort + } + } + + /** + * Opens a file in the editor via Quick Open (Ctrl+P). + */ + static async openFile(page: Page, filePath: string): Promise { + // Use Ctrl+P directly instead of going through command palette + await page.keyboard.press("Control+P"); + const input = page.locator(".quick-input-widget input.input"); + await input.waitFor({ state: "visible", timeout: 10_000 }); + await input.fill(filePath); + await page.waitForTimeout(Timeout.CLICK); + // Wait for file matches to appear, then select the first one + const firstMatch = page.locator(".quick-input-widget .quick-input-list .monaco-list-row").first(); + if (await firstMatch.isVisible({ timeout: 3_000 }).catch(() => false)) { + await firstMatch.click(); + } else { + await input.press(VSCode.ENTER); + } + await page.waitForTimeout(Timeout.TREE_EXPAND); + } + + /** + * Triggers the "New..." action on a project node. + * This opens the resource-type quick-pick. + */ + static async triggerNewResource(page: Page, projectName: string): Promise { + await JavaOperator.collapseFileExplorer(page); + await VscodeOperator.clickTreeItem(page, projectName); + await VscodeOperator.clickTreeItemAction(page, projectName, "New..."); + } +} diff --git a/test/e2e/utils/vscodeOperator.ts b/test/e2e/utils/vscodeOperator.ts new file mode 100644 index 00000000..0e0c7457 --- /dev/null +++ b/test/e2e/utils/vscodeOperator.ts @@ -0,0 +1,282 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +/** + * Generic VS Code UI helpers built on Playwright Page. + * + * All methods use ARIA roles / labels rather than CSS classes so that they + * survive VS Code version upgrades. + */ + +import { Page } from "@playwright/test"; +import { Timeout, VSCode } from "./constants"; + +export default class VscodeOperator { + + // ----------------------------------------------------------------------- + // Command palette + // ----------------------------------------------------------------------- + + /** + * Opens the command palette, types the given command, and runs it. + */ + static async executeCommand(page: Page, command: string): Promise { + await page.keyboard.press(VSCode.CMD_PALETTE_KEY); + // Wait for the quick-input widget to appear + const input = page.locator(".quick-input-widget input.input"); + await input.waitFor({ state: "visible", timeout: 10_000 }); + // F1 opens command palette with ">" prefix; fill() must preserve it + // so VS Code searches commands rather than files. + await input.fill(">" + command); + await page.waitForTimeout(Timeout.CLICK); + // Press Enter on the first matching option in the list + const firstOption = page.locator(".quick-input-widget .quick-input-list .monaco-list-row").first(); + if (await firstOption.isVisible({ timeout: 3_000 }).catch(() => false)) { + await firstOption.click(); + } else { + await input.press(VSCode.ENTER); + } + await page.waitForTimeout(Timeout.CLICK); + } + + /** + * Select a quick-pick option by its visible label text. + */ + static async selectQuickPickItem(page: Page, label: string): Promise { + const option = page.locator(".quick-input-widget .quick-input-list .monaco-list-row", { hasText: label }); + await option.first().waitFor({ state: "visible", timeout: 10_000 }); + await option.first().click(); + await page.waitForTimeout(Timeout.CLICK); + } + + /** + * Select a quick-pick option by its zero-based index. + */ + static async selectQuickPickIndex(page: Page, index: number): Promise { + const option = page.locator(".quick-input-widget .quick-input-list .monaco-list-row").nth(index); + await option.waitFor({ state: "visible", timeout: 10_000 }); + await option.click(); + await page.waitForTimeout(Timeout.CLICK); + } + + // ----------------------------------------------------------------------- + // Quick input box + // ----------------------------------------------------------------------- + + /** + * Waits for the quick-input box to appear and returns the input locator. + */ + static async getQuickInput(page: Page): Promise> { + const input = page.locator(".quick-input-widget input.input"); + await input.waitFor({ state: "visible", timeout: 10_000 }); + return input; + } + + /** + * Types text into the quick-input and confirms with Enter. + */ + static async fillQuickInput(page: Page, text: string): Promise { + const input = await VscodeOperator.getQuickInput(page); + await input.fill(text); + await page.waitForTimeout(Timeout.CLICK); + await input.press(VSCode.ENTER); + await page.waitForTimeout(Timeout.CLICK); + } + + // ----------------------------------------------------------------------- + // Side bar / Activity bar + // ----------------------------------------------------------------------- + + /** + * Clicks a side-bar tab by its accessibility label (e.g. "Explorer", "Java Projects"). + */ + static async activateSideTab(page: Page, tabName: string, timeout = Timeout.CLICK): Promise { + await page.getByRole(VSCode.TAB_ROLE, { name: tabName }).locator(VSCode.LINK).click(); + await page.waitForTimeout(timeout); + } + + static async isSideTabVisible(page: Page, tabName: string): Promise { + return page.getByRole(VSCode.TAB_ROLE, { name: tabName }).isVisible(); + } + + // ----------------------------------------------------------------------- + // Tree items + // ----------------------------------------------------------------------- + + /** + * Returns whether a tree item with the given name is visible. + */ + static async isTreeItemVisible(page: Page, name: string): Promise { + return page.getByRole(VSCode.TREE_ITEM_ROLE, { name }).isVisible(); + } + + /** + * Clicks a tree item by name. + */ + static async clickTreeItem(page: Page, name: string): Promise { + await page.getByRole(VSCode.TREE_ITEM_ROLE, { name }).locator(VSCode.LINK).first().click(); + await page.waitForTimeout(Timeout.TREE_EXPAND); + } + + /** + * Waits for a tree item to appear in the DOM and become visible. + * Returns `true` if the item was found within `timeoutMs`. + */ + static async waitForTreeItem(page: Page, name: string, timeoutMs = 30_000): Promise { + try { + await page.getByRole(VSCode.TREE_ITEM_ROLE, { name }).first().waitFor({ + state: "visible", + timeout: timeoutMs, + }); + return true; + } catch { + return false; + } + } + + /** + * Waits until a tree item disappears from the view. + */ + static async waitForTreeItemGone(page: Page, name: string, timeoutMs = 15_000): Promise { + try { + await page.getByRole(VSCode.TREE_ITEM_ROLE, { name }).first().waitFor({ + state: "hidden", + timeout: timeoutMs, + }); + return true; + } catch { + return false; + } + } + + /** + * Clicks an inline action button on a tree item (the small icons that appear on hover). + * Uses aria-label matching so it works across VS Code versions. + */ + static async clickTreeItemAction(page: Page, itemName: string, actionLabel: string): Promise { + const treeItem = page.getByRole(VSCode.TREE_ITEM_ROLE, { name: itemName }); + // Hover to reveal inline action buttons + await treeItem.hover(); + await page.waitForTimeout(500); + await treeItem.locator(`a.action-label[role="button"][aria-label*="${actionLabel}"]`).click(); + await page.waitForTimeout(Timeout.CLICK); + } + + // ----------------------------------------------------------------------- + // Context menus + // ----------------------------------------------------------------------- + + /** + * Right-clicks an element and selects an item from the context menu. + * + * Scopes the search to `.monaco-menu-container .monaco-menu` to avoid + * matching menubar items. Hovers the item first to trigger VS Code's + * menu focus, then waits for the `.focused` CSS class before clicking. + */ + static async selectContextMenuItem(page: Page, target: ReturnType, menuItemName: string | RegExp): Promise { + await target.click({ button: "right" }); + const menu = page.locator(".monaco-menu-container .monaco-menu"); + await menu.waitFor({ state: "visible", timeout: 5_000 }); + const menuItem = menu.getByRole("menuitem", { name: menuItemName }); + await menuItem.first().waitFor({ state: "visible", timeout: 5_000 }); + await menuItem.first().hover(); + await page.locator(".monaco-menu-container .action-item.focused").waitFor({ + state: "visible", + timeout: 5_000, + }); + await menuItem.first().click(); + await page.waitForTimeout(Timeout.CLICK); + } + + // ----------------------------------------------------------------------- + // Dialogs + // ----------------------------------------------------------------------- + + /** + * Tries to dismiss a modal dialog (workspace-trust, update prompts, etc.) + * by clicking a button whose label matches one of the well-known accept labels. + * Silently succeeds if no dialog is present. + */ + static async dismissModalDialog(page: Page): Promise { + const acceptLabels = ["Yes, I trust the authors", "OK", "Yes", "Continue", "I Trust the Authors"]; + try { + // Handle modal dialogs + const dialog = page.locator(".monaco-dialog-box"); + if (await dialog.isVisible({ timeout: 2_000 }).catch(() => false)) { + for (const label of acceptLabels) { + const btn = dialog.getByRole(VSCode.BUTTON_ROLE, { name: label }); + if (await btn.isVisible().catch(() => false)) { + await btn.click(); + await page.waitForTimeout(Timeout.CLICK); + break; + } + } + } + } catch { + // No modal dialog — nothing to dismiss + } + + // Also dismiss notification toasts (telemetry prompts, theme suggestions, etc.) + try { + const closeButtons = page.locator(".notification-toast .codicon-close"); + let count = await closeButtons.count().catch(() => 0); + while (count > 0) { + await closeButtons.first().click(); + await page.waitForTimeout(500); + count = await closeButtons.count().catch(() => 0); + } + } catch { + // Best effort + } + } + + /** + * Waits for a modal dialog to appear and clicks a button by its label. + */ + static async clickDialogButton(page: Page, buttonLabel: string, timeoutMs = 10_000): Promise { + const dialog = page.locator(".monaco-dialog-box"); + await dialog.waitFor({ state: "visible", timeout: timeoutMs }); + await dialog.getByRole(VSCode.BUTTON_ROLE, { name: buttonLabel }).click(); + await page.waitForTimeout(Timeout.CLICK); + } + + /** + * Clicks a button inside a notification toast (e.g. refactoring confirmations + * from extensions that use `window.showInformationMessage` with action buttons). + */ + static async clickNotificationButton(page: Page, buttonLabel: string, timeoutMs = 10_000): Promise { + const notification = page.locator(".notification-toast"); + await notification.first().waitFor({ state: "visible", timeout: timeoutMs }); + const btn = notification.getByRole(VSCode.BUTTON_ROLE, { name: buttonLabel }); + await btn.first().waitFor({ state: "visible", timeout: timeoutMs }); + await btn.first().click(); + await page.waitForTimeout(Timeout.CLICK); + } + + // ----------------------------------------------------------------------- + // Editor + // ----------------------------------------------------------------------- + + /** + * Waits for an editor tab with the given title to become active. + */ + static async waitForEditorTab(page: Page, title: string, timeoutMs = 15_000): Promise { + try { + await page.getByRole(VSCode.TAB_ROLE, { name: title }).first().waitFor({ + state: "visible", + timeout: timeoutMs, + }); + return true; + } catch { + return false; + } + } + + /** + * Saves the currently active editor using the command palette. + * (More reliable than Ctrl+S because focus might not be on the editor.) + */ + static async saveActiveEditor(page: Page): Promise { + await VscodeOperator.executeCommand(page, "workbench.action.files.save"); + } +} diff --git a/test/simple/.project b/test/simple/.project index e86159b9..fa6f3f7a 100644 --- a/test/simple/.project +++ b/test/simple/.project @@ -1,17 +1,28 @@ - 1.helloworld - - - - - - org.eclipse.jdt.core.javabuilder - - - - - - org.eclipse.jdt.core.javanature - + 1.helloworld + + + + + + org.eclipse.jdt.core.javabuilder + + + + + + org.eclipse.jdt.core.javanature + + + + 1774876132513 + + 30 + + org.eclipse.core.resources.regexFilterMatcher + node_modules|\.git|__CREATED_BY_JAVA_LANGUAGE_SERVER__ + + + diff --git a/tsconfig.json b/tsconfig.json index 9e2f8fa8..98f55334 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,6 +21,7 @@ "exclude": [ "node_modules", ".vscode-test", - "test-resources" + "test-resources", + "test/e2e" ] }