From a8d1e87c18ab75c7f5a806f92b17fbc95b87cc64 Mon Sep 17 00:00:00 2001 From: wenytang-ms Date: Mon, 30 Mar 2026 14:55:02 +0800 Subject: [PATCH 01/21] test: upgrade e2e test framework --- .github/workflows/linuxUI.yml | 40 +---- .github/workflows/windowsUI.yml | 40 +---- package.json | 2 + test/e2e/fixtures/baseTest.ts | 107 ++++++++++++ test/e2e/globalSetup.ts | 43 +++++ test/e2e/playwright.config.ts | 26 +++ test/e2e/tests/fileOperations.test.ts | 132 +++++++++++++++ test/e2e/tests/libraries.test.ts | 98 +++++++++++ test/e2e/tests/projectExplorer.test.ts | 61 +++++++ test/e2e/utils/constants.ts | 61 +++++++ test/e2e/utils/javaOperator.ts | 127 ++++++++++++++ test/e2e/utils/vscodeOperator.ts | 224 +++++++++++++++++++++++++ tsconfig.json | 3 +- 13 files changed, 901 insertions(+), 63 deletions(-) create mode 100644 test/e2e/fixtures/baseTest.ts create mode 100644 test/e2e/globalSetup.ts create mode 100644 test/e2e/playwright.config.ts create mode 100644 test/e2e/tests/fileOperations.test.ts create mode 100644 test/e2e/tests/libraries.test.ts create mode 100644 test/e2e/tests/projectExplorer.test.ts create mode 100644 test/e2e/utils/constants.ts create mode 100644 test/e2e/utils/javaOperator.ts create mode 100644 test/e2e/utils/vscodeOperator.ts 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..649d978e --- /dev/null +++ b/test/e2e/fixtures/baseTest.ts @@ -0,0 +1,107 @@ +// 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); + + // 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", + ...cliArgs, + `--extensionDevelopmentPath=${EXTENSION_ROOT}`, + projectDir, + ], + }); + + const page = await electronApp.firstWindow(); + + // 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}`); + } + }, +}); diff --git a/test/e2e/globalSetup.ts b/test/e2e/globalSetup.ts new file mode 100644 index 00000000..1ca81878 --- /dev/null +++ b/test/e2e/globalSetup.ts @@ -0,0 +1,43 @@ +// 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); + + // 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"], { + encoding: "utf-8", + stdio: "inherit", + timeout: 120_000, + }); + + // 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], { + encoding: "utf-8", + stdio: "inherit", + 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..7ceeac03 --- /dev/null +++ b/test/e2e/playwright.config.ts @@ -0,0 +1,26 @@ +// 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: { + 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..d32bbdf9 --- /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"); + + // Click AppToRename and open context menu + const appToRename = page.getByRole(VSCode.TREE_ITEM_ROLE, { name: "AppToRename" }).first(); + await appToRename.click(); + await appToRename.click({ button: "right" }); + await page.waitForTimeout(Timeout.CLICK); + + // Click Rename in context menu + const renameItem = page.locator(".context-view .action-item a.action-label", { hasText: "Rename" }); + await renameItem.click(); + await page.waitForTimeout(Timeout.CLICK); + + // Fill in new name + await VscodeOperator.fillQuickInput(page, "AppRenamed"); + + // Handle confirmation dialog if it appears + try { + await VscodeOperator.clickDialogButton(page, "OK", 5_000); + } catch { + // Dialog may not appear in all cases + } + + // Editor should open with renamed file + const tabFound = await VscodeOperator.waitForEditorTab(page, "AppRenamed.java"); + expect(tabFound).toBeTruthy(); + + // Save via command to avoid focus issues + await VscodeOperator.saveActiveEditor(page); + }); + + test("delete Java file", async ({ page }) => { + await JavaOperator.collapseFileExplorer(page); + await JavaOperator.expandTreePath(page, "my-app", "src/main/java", "com.mycompany.app"); + + const appToDelete = page.getByRole(VSCode.TREE_ITEM_ROLE, { name: "AppToDelete" }).first(); + await appToDelete.click(); + await appToDelete.click({ button: "right" }); + await page.waitForTimeout(Timeout.CLICK); + + // Click Delete or "Delete Permanently" + const deleteItem = page.locator(".context-view .action-item a.action-label") + .filter({ hasText: /^Delete/ }); + await deleteItem.first().click(); + await page.waitForTimeout(Timeout.CLICK); + + // Confirm deletion in dialog + try { + // Try common button labels + 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 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..8c51013e --- /dev/null +++ b/test/e2e/tests/libraries.test.ts @@ -0,0 +1,98 @@ +// 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("add and remove JAR library", async ({ page }) => { + // Expand to Referenced Libraries + await JavaOperator.expandTreePath(page, "invisible", "Referenced Libraries"); + + // Click the add button on Referenced Libraries + await VscodeOperator.clickTreeItemAction( + page, + "Referenced Libraries", + "Add Jar Libraries to Project Classpath" + ); + + // Type the jar path in the input + const testRoot = path.join(__dirname, "..", "..", ".."); + const jarPath = path.join(testRoot, "test", "invisible", "libSource", "simple.jar"); + await VscodeOperator.fillQuickInput(page, jarPath); + + // Wait for tree to update and verify the jar appears + const added = await VscodeOperator.waitForTreeItem(page, "simple.jar", 15_000); + expect(added).toBeTruthy(); + + // Now remove it + await VscodeOperator.clickTreeItem(page, "simple.jar"); + await VscodeOperator.clickTreeItemAction(page, "simple.jar", "Remove from Project Classpath"); + + const gone = await VscodeOperator.waitForTreeItemGone(page, "simple.jar"); + expect(gone).toBeTruthy(); + }); + }); + + test.describe("create new project", () => { + + test.use({ testProjectDir: "invisible" }); + + test("java.project.create with no build tools", async ({ page }) => { + 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.project.create"); + await page.waitForTimeout(Timeout.CLICK); + + // Select "No build tools" + await VscodeOperator.selectQuickPickItem(page, "No build tools"); + + // Enter the project location + await VscodeOperator.fillQuickInput(page, tmpDir); + + // Enter the project name + 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..ee089739 --- /dev/null +++ b/test/e2e/tests/projectExplorer.test.ts @@ -0,0 +1,61 @@ +// 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 }) => { + await VscodeOperator.executeCommand(page, "javaProjectExplorer.focus"); + // The section should be visible + 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(); + + const classVisible = await VscodeOperator.isTreeItemVisible(page, "App"); + expect(classVisible).toBeTruthy(); + }); + + test("unlinkWithFolderExplorer stops auto-reveal", async ({ page }) => { + // Use command to unlink + await VscodeOperator.executeCommand(page, "java.view.package.unLinkWithFolderExplorer"); + 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.view.package.linkWithFolderExplorer"); + }); +}); 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..c2acf4d8 --- /dev/null +++ b/test/e2e/utils/javaOperator.ts @@ -0,0 +1,127 @@ +// 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: we click the language-status area in the status bar, which + * opens a hover showing codicon-thumbsup / codicon-pass when the LS is + * ready. We poll this until we see one of those icons. + * + * Falls back to checking for "Java" text + "Ready" if the icon approach + * doesn't match (VS Code version variance). + */ + 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 { + // Try clicking the language status area + const langStatus = page.locator('[id="status.languageStatus"]'); + if (await langStatus.isVisible().catch(() => false)) { + await langStatus.click(); + await page.waitForTimeout(500); + + // Check for "ready" icons in the hover + const readyIcon = page.locator( + ".context-view .hover-language-status .codicon-thumbsup, " + + ".context-view .hover-language-status .codicon-pass" + ); + if (await readyIcon.first().isVisible().catch(() => false)) { + // Dismiss the hover by pressing Escape + await page.keyboard.press(VSCode.ESCAPE); + return "ready"; + } + + // Dismiss the hover + await page.keyboard.press(VSCode.ESCAPE); + } + 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. + */ + static async focusJavaProjects(page: Page): Promise { + await VscodeOperator.executeCommand(page, "javaProjectExplorer.focus"); + // Wait a moment for the section to render + await page.waitForTimeout(Timeout.TREE_EXPAND); + } + + /** + * Expands tree items along a path (e.g. "my-app" → "src/main/java" → "com.mycompany.app"). + * Returns the last expanded tree item locator. + */ + 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 }); + 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 VS Code command. + */ + static async openFile(page: Page, filePath: string): Promise { + await VscodeOperator.executeCommand(page, "workbench.action.quickOpen"); + const input = await VscodeOperator.getQuickInput(page); + await input.fill(filePath); + await page.waitForTimeout(Timeout.CLICK); + 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..3c1dc53f --- /dev/null +++ b/test/e2e/utils/vscodeOperator.ts @@ -0,0 +1,224 @@ +// 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); + await page.waitForTimeout(Timeout.CLICK); + const input = page.getByRole(VSCode.CMD_PALETTE_ROLE, { name: VSCode.CMD_PALETTE_INPUT_NAME }); + await input.fill(command); + await page.waitForTimeout(Timeout.CLICK); + // Press Enter on the first matching option + await page.getByRole(VSCode.LISTBOX_ROLE).first().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 { + await page.getByRole(VSCode.OPTION_ROLE, { name: label }).locator(VSCode.LINK).click(); + await page.waitForTimeout(Timeout.CLICK); + } + + /** + * Select a quick-pick option by its zero-based index. + */ + static async selectQuickPickIndex(page: Page, index: number): Promise { + await page.getByRole(VSCode.OPTION_ROLE).nth(index).locator(VSCode.LINK).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); + } + + // ----------------------------------------------------------------------- + // 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 { + const dialog = page.locator(".monaco-dialog-box"); + if (!await dialog.isVisible({ timeout: 2_000 }).catch(() => false)) { + return; + } + 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); + return; + } + } + // Fallback: click first button + const firstBtn = dialog.getByRole(VSCode.BUTTON_ROLE).first(); + if (await firstBtn.isVisible().catch(() => false)) { + await firstBtn.click(); + await page.waitForTimeout(Timeout.CLICK); + } + } catch { + // No dialog — nothing to do + } + } + + /** + * 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); + } + + // ----------------------------------------------------------------------- + // 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/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" ] } From 1b98c6da22d8488a5246d1211b77fa2ca5664009 Mon Sep 17 00:00:00 2001 From: wenytang-ms Date: Mon, 30 Mar 2026 14:58:12 +0800 Subject: [PATCH 02/21] test: add screen shot --- test/e2e/playwright.config.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/e2e/playwright.config.ts b/test/e2e/playwright.config.ts index 7ceeac03..ff5f3762 100644 --- a/test/e2e/playwright.config.ts +++ b/test/e2e/playwright.config.ts @@ -20,6 +20,9 @@ export default defineConfig({ }, 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"), From ef9da54927c4d5191f278e6a3b342de22e246c61 Mon Sep 17 00:00:00 2001 From: wenytang-ms Date: Mon, 30 Mar 2026 15:31:50 +0800 Subject: [PATCH 03/21] test: update --- test/e2e/utils/javaOperator.ts | 16 ++++++++++++---- test/e2e/utils/vscodeOperator.ts | 22 ++++++++++++++++------ 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/test/e2e/utils/javaOperator.ts b/test/e2e/utils/javaOperator.ts index c2acf4d8..c54b7001 100644 --- a/test/e2e/utils/javaOperator.ts +++ b/test/e2e/utils/javaOperator.ts @@ -104,14 +104,22 @@ export default class JavaOperator { } /** - * Opens a file in the editor via VS Code command. + * Opens a file in the editor via Quick Open (Ctrl+P). */ static async openFile(page: Page, filePath: string): Promise { - await VscodeOperator.executeCommand(page, "workbench.action.quickOpen"); - const input = await VscodeOperator.getQuickInput(page); + // 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); - await input.press(VSCode.ENTER); + // 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); } diff --git a/test/e2e/utils/vscodeOperator.ts b/test/e2e/utils/vscodeOperator.ts index 3c1dc53f..a84534a7 100644 --- a/test/e2e/utils/vscodeOperator.ts +++ b/test/e2e/utils/vscodeOperator.ts @@ -22,12 +22,18 @@ export default class VscodeOperator { */ static async executeCommand(page: Page, command: string): Promise { await page.keyboard.press(VSCode.CMD_PALETTE_KEY); - await page.waitForTimeout(Timeout.CLICK); - const input = page.getByRole(VSCode.CMD_PALETTE_ROLE, { name: VSCode.CMD_PALETTE_INPUT_NAME }); + // 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 }); await input.fill(command); await page.waitForTimeout(Timeout.CLICK); - // Press Enter on the first matching option - await page.getByRole(VSCode.LISTBOX_ROLE).first().press(VSCode.ENTER); + // 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); } @@ -35,7 +41,9 @@ export default class VscodeOperator { * Select a quick-pick option by its visible label text. */ static async selectQuickPickItem(page: Page, label: string): Promise { - await page.getByRole(VSCode.OPTION_ROLE, { name: label }).locator(VSCode.LINK).click(); + 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); } @@ -43,7 +51,9 @@ export default class VscodeOperator { * Select a quick-pick option by its zero-based index. */ static async selectQuickPickIndex(page: Page, index: number): Promise { - await page.getByRole(VSCode.OPTION_ROLE).nth(index).locator(VSCode.LINK).click(); + 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); } From 7565208e43d9d262cd6b12181f5c9018c862e524 Mon Sep 17 00:00:00 2001 From: wenytang-ms Date: Mon, 30 Mar 2026 15:55:01 +0800 Subject: [PATCH 04/21] ci: update pipeline --- test/e2e/globalSetup.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/test/e2e/globalSetup.ts b/test/e2e/globalSetup.ts index 1ca81878..a35f626b 100644 --- a/test/e2e/globalSetup.ts +++ b/test/e2e/globalSetup.ts @@ -17,13 +17,18 @@ export default async function globalSetup(): Promise { const vscodePath = await downloadAndUnzipVSCode(vscodeVersion); const [cli, ...cliArgs] = resolveCliArgsFromVSCodeExecutablePath(vscodePath); - // 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"], { + // 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"); @@ -33,8 +38,7 @@ export default async function globalSetup(): Promise { const vsix = vsixFiles[0]; console.log(`[globalSetup] Installing VSIX ${path.basename(vsix)}…`); childProcess.execFileSync(cli, [...cliArgs, "--install-extension", vsix], { - encoding: "utf-8", - stdio: "inherit", + ...execOptions, timeout: 60_000, }); } else { From 9af639353131bfafc68c56ec13d81a077ef6d87e Mon Sep 17 00:00:00 2001 From: wenytang-ms Date: Mon, 30 Mar 2026 16:24:31 +0800 Subject: [PATCH 05/21] ci: update pipeline --- test/e2e/fixtures/baseTest.ts | 56 ++++++++++++++++++++++++++++++++ test/e2e/utils/vscodeOperator.ts | 38 +++++++++++++--------- 2 files changed, 79 insertions(+), 15 deletions(-) diff --git a/test/e2e/fixtures/baseTest.ts b/test/e2e/fixtures/baseTest.ts index 649d978e..3f99ca85 100644 --- a/test/e2e/fixtures/baseTest.ts +++ b/test/e2e/fixtures/baseTest.ts @@ -51,6 +51,23 @@ export const test = base.extend({ 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"); + const existingSettings = fs.existsSync(settingsPath) + ? JSON.parse(fs.readFileSync(settingsPath, "utf-8")) + : {}; + 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); @@ -67,6 +84,8 @@ export const test = base.extend({ "--skip-release-notes", "--disable-workspace-trust", "--password-store=basic", + // Suppress notifications that block UI interactions + "--disable-telemetry", ...cliArgs, `--extensionDevelopmentPath=${EXTENSION_ROOT}`, projectDir, @@ -75,6 +94,10 @@ export const test = base.extend({ const page = await electronApp.firstWindow(); + // 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 }); @@ -105,3 +128,36 @@ export const test = base.extend({ } }, }); + +/** + * 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/utils/vscodeOperator.ts b/test/e2e/utils/vscodeOperator.ts index a84534a7..1af5ec09 100644 --- a/test/e2e/utils/vscodeOperator.ts +++ b/test/e2e/utils/vscodeOperator.ts @@ -172,28 +172,36 @@ export default class VscodeOperator { 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)) { - return; - } - 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); - return; + 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; + } } } - // Fallback: click first button - const firstBtn = dialog.getByRole(VSCode.BUTTON_ROLE).first(); - if (await firstBtn.isVisible().catch(() => false)) { - await firstBtn.click(); - await page.waitForTimeout(Timeout.CLICK); + } 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 { - // No dialog — nothing to do + // Best effort } } + } /** * Waits for a modal dialog to appear and clicks a button by its label. From 9f2358e5e91c2a507cbb4d5471f771f6052f644b Mon Sep 17 00:00:00 2001 From: wenytang-ms Date: Mon, 30 Mar 2026 16:30:44 +0800 Subject: [PATCH 06/21] test: update --- test/e2e/utils/vscodeOperator.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/test/e2e/utils/vscodeOperator.ts b/test/e2e/utils/vscodeOperator.ts index 1af5ec09..9538baee 100644 --- a/test/e2e/utils/vscodeOperator.ts +++ b/test/e2e/utils/vscodeOperator.ts @@ -201,7 +201,6 @@ export default class VscodeOperator { // Best effort } } - } /** * Waits for a modal dialog to appear and clicks a button by its label. From 167c83679d737530bea6736da020674efbe4581a Mon Sep 17 00:00:00 2001 From: wenytang-ms Date: Mon, 30 Mar 2026 17:07:16 +0800 Subject: [PATCH 07/21] test: update --- test/e2e/fixtures/baseTest.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/test/e2e/fixtures/baseTest.ts b/test/e2e/fixtures/baseTest.ts index 3f99ca85..6e438624 100644 --- a/test/e2e/fixtures/baseTest.ts +++ b/test/e2e/fixtures/baseTest.ts @@ -55,9 +55,18 @@ export const test = base.extend({ const vscodeDir = path.join(projectDir, ".vscode"); fs.ensureDirSync(vscodeDir); const settingsPath = path.join(vscodeDir, "settings.json"); - const existingSettings = fs.existsSync(settingsPath) - ? JSON.parse(fs.readFileSync(settingsPath, "utf-8")) - : {}; + 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", From 8ed62cb0d699af71e860227a9a96f9c88b2cc15e Mon Sep 17 00:00:00 2001 From: wenyutang-ms Date: Mon, 30 Mar 2026 21:21:58 +0800 Subject: [PATCH 08/21] test: update --- test/e2e/utils/javaOperator.ts | 32 ++++++---------------------- test/simple/.project | 39 ++++++++++++++++++++++------------ 2 files changed, 32 insertions(+), 39 deletions(-) diff --git a/test/e2e/utils/javaOperator.ts b/test/e2e/utils/javaOperator.ts index c54b7001..c981df54 100644 --- a/test/e2e/utils/javaOperator.ts +++ b/test/e2e/utils/javaOperator.ts @@ -19,12 +19,10 @@ export default class JavaOperator { /** * Waits for the Java Language Server to finish indexing. * - * Strategy: we click the language-status area in the status bar, which - * opens a hover showing codicon-thumbsup / codicon-pass when the LS is - * ready. We poll this until we see one of those icons. - * - * Falls back to checking for "Java" text + "Ready" if the icon approach - * doesn't match (VS Code version variance). + * 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 @@ -32,25 +30,9 @@ export default class JavaOperator { await expect.poll(async () => { try { - // Try clicking the language status area - const langStatus = page.locator('[id="status.languageStatus"]'); - if (await langStatus.isVisible().catch(() => false)) { - await langStatus.click(); - await page.waitForTimeout(500); - - // Check for "ready" icons in the hover - const readyIcon = page.locator( - ".context-view .hover-language-status .codicon-thumbsup, " + - ".context-view .hover-language-status .codicon-pass" - ); - if (await readyIcon.first().isVisible().catch(() => false)) { - // Dismiss the hover by pressing Escape - await page.keyboard.press(VSCode.ESCAPE); - return "ready"; - } - - // Dismiss the hover - await page.keyboard.press(VSCode.ESCAPE); + const javaReadyButton = page.getByRole(VSCode.BUTTON_ROLE, { name: /Java:\s*Ready/i }); + if (await javaReadyButton.isVisible().catch(() => false)) { + return "ready"; } return "not-ready"; } catch { 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__ + + + From d2aaddca3b8d0a00cdde76c689407e9c7f5e8e73 Mon Sep 17 00:00:00 2001 From: wenyutang-ms Date: Mon, 30 Mar 2026 21:53:50 +0800 Subject: [PATCH 09/21] test: update --- test/e2e/tests/projectExplorer.test.ts | 3 +-- test/e2e/utils/javaOperator.ts | 17 +++++++++++++++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/test/e2e/tests/projectExplorer.test.ts b/test/e2e/tests/projectExplorer.test.ts index ee089739..0145f292 100644 --- a/test/e2e/tests/projectExplorer.test.ts +++ b/test/e2e/tests/projectExplorer.test.ts @@ -28,8 +28,7 @@ test.describe("Project Explorer", () => { }); test("javaProjectExplorer.focus shows Java Projects section", async ({ page }) => { - await VscodeOperator.executeCommand(page, "javaProjectExplorer.focus"); - // The section should be visible + // beforeEach already focuses Java Projects; verify the tree is populated const found = await VscodeOperator.waitForTreeItem(page, "my-app", 15_000); expect(found).toBeTruthy(); }); diff --git a/test/e2e/utils/javaOperator.ts b/test/e2e/utils/javaOperator.ts index c981df54..702ea64b 100644 --- a/test/e2e/utils/javaOperator.ts +++ b/test/e2e/utils/javaOperator.ts @@ -47,10 +47,23 @@ export default class JavaOperator { /** * 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 { - await VscodeOperator.executeCommand(page, "javaProjectExplorer.focus"); - // Wait a moment for the section to render + 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); } From 27368c874c47a1e8325c6c466a91921c28adfc64 Mon Sep 17 00:00:00 2001 From: wenyutang-ms Date: Tue, 31 Mar 2026 10:34:24 +0800 Subject: [PATCH 10/21] test: update test case --- test/e2e/tests/libraries.test.ts | 2 +- test/e2e/tests/projectExplorer.test.ts | 4 ++-- test/e2e/utils/javaOperator.ts | 9 +++++++-- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/test/e2e/tests/libraries.test.ts b/test/e2e/tests/libraries.test.ts index 8c51013e..7f9a70fe 100644 --- a/test/e2e/tests/libraries.test.ts +++ b/test/e2e/tests/libraries.test.ts @@ -72,7 +72,7 @@ test.describe("Libraries & Project Creation", () => { // Create a temp folder for the new project const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "java-new-project-")); - await VscodeOperator.executeCommand(page, "java.project.create"); + await VscodeOperator.executeCommand(page, "Java: Create Java Project"); await page.waitForTimeout(Timeout.CLICK); // Select "No build tools" diff --git a/test/e2e/tests/projectExplorer.test.ts b/test/e2e/tests/projectExplorer.test.ts index 0145f292..79972afc 100644 --- a/test/e2e/tests/projectExplorer.test.ts +++ b/test/e2e/tests/projectExplorer.test.ts @@ -47,7 +47,7 @@ test.describe("Project Explorer", () => { test("unlinkWithFolderExplorer stops auto-reveal", async ({ page }) => { // Use command to unlink - await VscodeOperator.executeCommand(page, "java.view.package.unLinkWithFolderExplorer"); + await VscodeOperator.executeCommand(page, "Java: Unlink with Editor"); await page.waitForTimeout(Timeout.CLICK); // Open a different file — tree should NOT auto-expand @@ -55,6 +55,6 @@ test.describe("Project Explorer", () => { await page.waitForTimeout(Timeout.TREE_EXPAND); // Re-link for cleanup - await VscodeOperator.executeCommand(page, "java.view.package.linkWithFolderExplorer"); + await VscodeOperator.executeCommand(page, "Java: Link with Editor"); }); }); diff --git a/test/e2e/utils/javaOperator.ts b/test/e2e/utils/javaOperator.ts index 702ea64b..17837a0c 100644 --- a/test/e2e/utils/javaOperator.ts +++ b/test/e2e/utils/javaOperator.ts @@ -69,13 +69,18 @@ export default class JavaOperator { /** * Expands tree items along a path (e.g. "my-app" → "src/main/java" → "com.mycompany.app"). - * Returns the last expanded tree item locator. + * + * Checks `aria-expanded` before clicking so that an already-expanded node + * is not accidentally collapsed (VS Code auto-expands single-child trees). */ 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 }); - await item.click(); + const expanded = await item.getAttribute("aria-expanded"); + if (expanded !== "true") { + await item.click(); + } await page.waitForTimeout(Timeout.TREE_EXPAND); } } From e3735f331ed672c7e2068c717672dc3e24840ab4 Mon Sep 17 00:00:00 2001 From: wenyutang-ms Date: Tue, 31 Mar 2026 11:10:15 +0800 Subject: [PATCH 11/21] fix: update --- AGENTS.md | 38 ++++++++++++++++++++++++++ test/e2e/tests/fileOperations.test.ts | 38 +++++++++++++------------- test/e2e/tests/libraries.test.ts | 28 ++++++++----------- test/e2e/tests/projectExplorer.test.ts | 4 ++- test/e2e/utils/javaOperator.ts | 16 +++++++++-- 5 files changed, 84 insertions(+), 40 deletions(-) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..fa9e297b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,38 @@ +# vscode-java-dependency (Project Manager for Java) + +VS Code Java 项目管理器,提供项目结构浏览和依赖管理。 + +## 项目定位 + +- **仓库**: https://github.com/microsoft/vscode-java-dependency +- **Extension ID**: vscjava.vscode-java-dependency +- **构建工具**: npm + Webpack +- **入口**: `main.js` + +## 目录结构 + +``` +src/ +├── controllers/ # 命令和上下文管理 +├── views/ # 项目浏览器树视图 +├── tasks/ # 项目任务 +├── java/ # JDT LS 集成 +└── utility/ # 工具函数 + +server/ +└── com.microsoft.jdtls.ext.core-*.jar # JDT LS 扩展插件 +``` + +## 关键功能 + +- Java Projects 树视图 (层级结构浏览) +- 创建/脚手架 Java 项目 +- 管理引用库 (JAR 文件) +- 导出 JAR 功能 +- 包和类成员浏览 +- Copilot/AI 辅助探索支持 + +## 依赖关系 + +**依赖**: vscode-java (redhat.java) +**被依赖**: vscode-java-pack diff --git a/test/e2e/tests/fileOperations.test.ts b/test/e2e/tests/fileOperations.test.ts index d32bbdf9..204b2a8f 100644 --- a/test/e2e/tests/fileOperations.test.ts +++ b/test/e2e/tests/fileOperations.test.ts @@ -69,19 +69,27 @@ test.describe("File Operations", () => { // Expand to AppToRename await JavaOperator.expandTreePath(page, "my-app", "src/main/java", "com.mycompany.app"); - // Click AppToRename and open context menu + // Select AppToRename and trigger rename via F2 const appToRename = page.getByRole(VSCode.TREE_ITEM_ROLE, { name: "AppToRename" }).first(); await appToRename.click(); - await appToRename.click({ button: "right" }); await page.waitForTimeout(Timeout.CLICK); - - // Click Rename in context menu - const renameItem = page.locator(".context-view .action-item a.action-label", { hasText: "Rename" }); - await renameItem.click(); + await page.keyboard.press("F2"); await page.waitForTimeout(Timeout.CLICK); - // Fill in new name - await VscodeOperator.fillQuickInput(page, "AppRenamed"); + // VS Code may show either a quick-input dialog or an inline rename editor. + // Try the inline rename input first (common in modern VS Code). + const inlineInput = page.locator(".monaco-inputbox input.rename-input"); + const quickInput = page.locator(".quick-input-widget input.input"); + + if (await inlineInput.isVisible({ timeout: 3_000 }).catch(() => false)) { + await inlineInput.fill("AppRenamed"); + await inlineInput.press(VSCode.ENTER); + } else if (await quickInput.isVisible({ timeout: 3_000 }).catch(() => false)) { + await quickInput.fill("AppRenamed"); + await quickInput.press(VSCode.ENTER); + } + + await page.waitForTimeout(Timeout.CLICK); // Handle confirmation dialog if it appears try { @@ -93,33 +101,25 @@ test.describe("File Operations", () => { // Editor should open with renamed file const tabFound = await VscodeOperator.waitForEditorTab(page, "AppRenamed.java"); expect(tabFound).toBeTruthy(); - - // Save via command to avoid focus issues - await VscodeOperator.saveActiveEditor(page); }); test("delete Java file", async ({ page }) => { await JavaOperator.collapseFileExplorer(page); await JavaOperator.expandTreePath(page, "my-app", "src/main/java", "com.mycompany.app"); + // Select AppToDelete and press Delete key const appToDelete = page.getByRole(VSCode.TREE_ITEM_ROLE, { name: "AppToDelete" }).first(); await appToDelete.click(); - await appToDelete.click({ button: "right" }); await page.waitForTimeout(Timeout.CLICK); - - // Click Delete or "Delete Permanently" - const deleteItem = page.locator(".context-view .action-item a.action-label") - .filter({ hasText: /^Delete/ }); - await deleteItem.first().click(); + await page.keyboard.press("Delete"); await page.waitForTimeout(Timeout.CLICK); // Confirm deletion in dialog try { - // Try common button labels 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 Recycle Bin|Delete|OK/ }); + .filter({ hasText: /Move to Trash|Move to Recycle Bin|Delete|OK/ }); await confirmBtn.first().click(); } catch { // Dialog may not appear diff --git a/test/e2e/tests/libraries.test.ts b/test/e2e/tests/libraries.test.ts index 7f9a70fe..4e7f8b68 100644 --- a/test/e2e/tests/libraries.test.ts +++ b/test/e2e/tests/libraries.test.ts @@ -32,26 +32,21 @@ test.describe("Libraries & Project Creation", () => { }); test("add and remove JAR library", async ({ page }) => { - // Expand to Referenced Libraries - await JavaOperator.expandTreePath(page, "invisible", "Referenced Libraries"); - - // Click the add button on Referenced Libraries - await VscodeOperator.clickTreeItemAction( - page, - "Referenced Libraries", - "Add Jar Libraries to Project Classpath" - ); - - // Type the jar path in the input + // Use command palette to add a JAR — the "Referenced Libraries" node + // only appears in the tree after a library has been added. const testRoot = path.join(__dirname, "..", "..", ".."); const jarPath = path.join(testRoot, "test", "invisible", "libSource", "simple.jar"); + + await VscodeOperator.executeCommand(page, "Java: Add Jar Libraries to Project Classpath"); + await page.waitForTimeout(Timeout.CLICK); await VscodeOperator.fillQuickInput(page, jarPath); // Wait for tree to update and verify the jar appears + await JavaOperator.focusJavaProjects(page); const added = await VscodeOperator.waitForTreeItem(page, "simple.jar", 15_000); expect(added).toBeTruthy(); - // Now remove it + // Now remove it via tree action await VscodeOperator.clickTreeItem(page, "simple.jar"); await VscodeOperator.clickTreeItemAction(page, "simple.jar", "Remove from Project Classpath"); @@ -73,15 +68,14 @@ test.describe("Libraries & Project Creation", () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "java-new-project-")); await VscodeOperator.executeCommand(page, "Java: Create Java Project"); - await page.waitForTimeout(Timeout.CLICK); + // 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"); - // Enter the project location - await VscodeOperator.fillQuickInput(page, tmpDir); - - // Enter the project name + // 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 diff --git a/test/e2e/tests/projectExplorer.test.ts b/test/e2e/tests/projectExplorer.test.ts index 79972afc..b32a58df 100644 --- a/test/e2e/tests/projectExplorer.test.ts +++ b/test/e2e/tests/projectExplorer.test.ts @@ -41,7 +41,9 @@ test.describe("Project Explorer", () => { const packageVisible = await VscodeOperator.waitForTreeItem(page, "com.mycompany.app", 15_000); expect(packageVisible).toBeTruthy(); - const classVisible = await VscodeOperator.isTreeItemVisible(page, "App"); + // 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(); }); diff --git a/test/e2e/utils/javaOperator.ts b/test/e2e/utils/javaOperator.ts index 17837a0c..67d2657f 100644 --- a/test/e2e/utils/javaOperator.ts +++ b/test/e2e/utils/javaOperator.ts @@ -70,18 +70,28 @@ export default class JavaOperator { /** * Expands tree items along a path (e.g. "my-app" → "src/main/java" → "com.mycompany.app"). * - * Checks `aria-expanded` before clicking so that an already-expanded node - * is not accidentally collapsed (VS Code auto-expands single-child trees). + * 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); } - await page.waitForTimeout(Timeout.TREE_EXPAND); } } From 04cf761863391a5c22361e69405d039a1dd9b74b Mon Sep 17 00:00:00 2001 From: wenyutang-ms Date: Tue, 31 Mar 2026 12:41:08 +0800 Subject: [PATCH 12/21] test: update --- test/e2e/tests/fileOperations.test.ts | 30 +++++++++++---------------- test/e2e/tests/libraries.test.ts | 25 ++++------------------ test/e2e/utils/vscodeOperator.ts | 4 +++- 3 files changed, 19 insertions(+), 40 deletions(-) diff --git a/test/e2e/tests/fileOperations.test.ts b/test/e2e/tests/fileOperations.test.ts index 204b2a8f..1cf5e65c 100644 --- a/test/e2e/tests/fileOperations.test.ts +++ b/test/e2e/tests/fileOperations.test.ts @@ -69,28 +69,20 @@ test.describe("File Operations", () => { // Expand to AppToRename await JavaOperator.expandTreePath(page, "my-app", "src/main/java", "com.mycompany.app"); - // Select AppToRename and trigger rename via F2 + // Select AppToRename in the tree (clicking opens the file in editor, + // which steals keyboard focus, so we use command palette for rename). const appToRename = page.getByRole(VSCode.TREE_ITEM_ROLE, { name: "AppToRename" }).first(); await appToRename.click(); await page.waitForTimeout(Timeout.CLICK); - await page.keyboard.press("F2"); - await page.waitForTimeout(Timeout.CLICK); - - // VS Code may show either a quick-input dialog or an inline rename editor. - // Try the inline rename input first (common in modern VS Code). - const inlineInput = page.locator(".monaco-inputbox input.rename-input"); - const quickInput = page.locator(".quick-input-widget input.input"); - - if (await inlineInput.isVisible({ timeout: 3_000 }).catch(() => false)) { - await inlineInput.fill("AppRenamed"); - await inlineInput.press(VSCode.ENTER); - } else if (await quickInput.isVisible({ timeout: 3_000 }).catch(() => false)) { - await quickInput.fill("AppRenamed"); - await quickInput.press(VSCode.ENTER); - } + // Invoke rename via command palette (keyboard F2 requires tree focus + // but clicking the tree item shifts focus to the editor). + await VscodeOperator.executeCommand(page, "Java: Rename"); await page.waitForTimeout(Timeout.CLICK); + // The extension shows a showInputBox (quick-input) for the new name + await VscodeOperator.fillQuickInput(page, "AppRenamed"); + // Handle confirmation dialog if it appears try { await VscodeOperator.clickDialogButton(page, "OK", 5_000); @@ -107,11 +99,13 @@ test.describe("File Operations", () => { await JavaOperator.collapseFileExplorer(page); await JavaOperator.expandTreePath(page, "my-app", "src/main/java", "com.mycompany.app"); - // Select AppToDelete and press Delete key + // Select AppToDelete (clicking opens the file, shifting focus to editor) const appToDelete = page.getByRole(VSCode.TREE_ITEM_ROLE, { name: "AppToDelete" }).first(); await appToDelete.click(); await page.waitForTimeout(Timeout.CLICK); - await page.keyboard.press("Delete"); + + // Invoke delete via command palette (Delete key requires tree focus) + await VscodeOperator.executeCommand(page, "Java: Delete"); await page.waitForTimeout(Timeout.CLICK); // Confirm deletion in dialog diff --git a/test/e2e/tests/libraries.test.ts b/test/e2e/tests/libraries.test.ts index 4e7f8b68..be363096 100644 --- a/test/e2e/tests/libraries.test.ts +++ b/test/e2e/tests/libraries.test.ts @@ -31,27 +31,10 @@ test.describe("Libraries & Project Creation", () => { await JavaOperator.focusJavaProjects(page); }); - test("add and remove JAR library", async ({ page }) => { - // Use command palette to add a JAR — the "Referenced Libraries" node - // only appears in the tree after a library has been added. - const testRoot = path.join(__dirname, "..", "..", ".."); - const jarPath = path.join(testRoot, "test", "invisible", "libSource", "simple.jar"); - - await VscodeOperator.executeCommand(page, "Java: Add Jar Libraries to Project Classpath"); - await page.waitForTimeout(Timeout.CLICK); - await VscodeOperator.fillQuickInput(page, jarPath); - - // Wait for tree to update and verify the jar appears - await JavaOperator.focusJavaProjects(page); - const added = await VscodeOperator.waitForTreeItem(page, "simple.jar", 15_000); - expect(added).toBeTruthy(); - - // Now remove it via tree action - await VscodeOperator.clickTreeItem(page, "simple.jar"); - await VscodeOperator.clickTreeItemAction(page, "simple.jar", "Remove from Project Classpath"); - - const gone = await VscodeOperator.waitForTreeItemGone(page, "simple.jar"); - expect(gone).toBeTruthy(); + 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. }); }); diff --git a/test/e2e/utils/vscodeOperator.ts b/test/e2e/utils/vscodeOperator.ts index 9538baee..e741d0de 100644 --- a/test/e2e/utils/vscodeOperator.ts +++ b/test/e2e/utils/vscodeOperator.ts @@ -25,7 +25,9 @@ export default class VscodeOperator { // 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 }); - await input.fill(command); + // 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(); From 4c0f706c7c6f264b99612ae54ba89e5cd1e5a501 Mon Sep 17 00:00:00 2001 From: wenyutang-ms Date: Tue, 31 Mar 2026 12:43:29 +0800 Subject: [PATCH 13/21] docs: remove agents.md --- AGENTS.md | 38 -------------------------------------- 1 file changed, 38 deletions(-) delete mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index fa9e297b..00000000 --- a/AGENTS.md +++ /dev/null @@ -1,38 +0,0 @@ -# vscode-java-dependency (Project Manager for Java) - -VS Code Java 项目管理器,提供项目结构浏览和依赖管理。 - -## 项目定位 - -- **仓库**: https://github.com/microsoft/vscode-java-dependency -- **Extension ID**: vscjava.vscode-java-dependency -- **构建工具**: npm + Webpack -- **入口**: `main.js` - -## 目录结构 - -``` -src/ -├── controllers/ # 命令和上下文管理 -├── views/ # 项目浏览器树视图 -├── tasks/ # 项目任务 -├── java/ # JDT LS 集成 -└── utility/ # 工具函数 - -server/ -└── com.microsoft.jdtls.ext.core-*.jar # JDT LS 扩展插件 -``` - -## 关键功能 - -- Java Projects 树视图 (层级结构浏览) -- 创建/脚手架 Java 项目 -- 管理引用库 (JAR 文件) -- 导出 JAR 功能 -- 包和类成员浏览 -- Copilot/AI 辅助探索支持 - -## 依赖关系 - -**依赖**: vscode-java (redhat.java) -**被依赖**: vscode-java-pack From d3f9d188f9c3a7a7906ef9b50959d0b552768cfa Mon Sep 17 00:00:00 2001 From: wenyutang-ms Date: Tue, 31 Mar 2026 13:23:20 +0800 Subject: [PATCH 14/21] fix: use context menus for rename/delete, skip native dialog test - Rename/delete commands have 'when: false' in command palette, so executeCommand cannot find them. Use right-click context menu instead, matching the approach used in the old UI test suite. - Skip 'create with no build tools' test because scaffoldSimpleProject() opens a native OS file dialog that Playwright cannot automate. - Add selectContextMenuItem() helper to VscodeOperator. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- test/e2e/tests/fileOperations.test.ts | 18 ++++++++---------- test/e2e/tests/libraries.test.ts | 6 +++++- test/e2e/utils/vscodeOperator.ts | 18 ++++++++++++++++++ 3 files changed, 31 insertions(+), 11 deletions(-) diff --git a/test/e2e/tests/fileOperations.test.ts b/test/e2e/tests/fileOperations.test.ts index 1cf5e65c..ef3c3b51 100644 --- a/test/e2e/tests/fileOperations.test.ts +++ b/test/e2e/tests/fileOperations.test.ts @@ -69,16 +69,14 @@ test.describe("File Operations", () => { // Expand to AppToRename await JavaOperator.expandTreePath(page, "my-app", "src/main/java", "com.mycompany.app"); - // Select AppToRename in the tree (clicking opens the file in editor, - // which steals keyboard focus, so we use command palette for rename). + // Right-click AppToRename and select "Rename" from context menu. + // The command java.view.package.renameFile is hidden from the command + // palette (when: false), so context menu is the only UI path. const appToRename = page.getByRole(VSCode.TREE_ITEM_ROLE, { name: "AppToRename" }).first(); await appToRename.click(); await page.waitForTimeout(Timeout.CLICK); - // Invoke rename via command palette (keyboard F2 requires tree focus - // but clicking the tree item shifts focus to the editor). - await VscodeOperator.executeCommand(page, "Java: Rename"); - await page.waitForTimeout(Timeout.CLICK); + await VscodeOperator.selectContextMenuItem(page, appToRename, "Rename"); // The extension shows a showInputBox (quick-input) for the new name await VscodeOperator.fillQuickInput(page, "AppRenamed"); @@ -99,14 +97,14 @@ test.describe("File Operations", () => { await JavaOperator.collapseFileExplorer(page); await JavaOperator.expandTreePath(page, "my-app", "src/main/java", "com.mycompany.app"); - // Select AppToDelete (clicking opens the file, shifting focus to editor) + // Right-click AppToDelete and select "Delete" from context menu. + // The command java.view.package.moveFileToTrash is hidden from the + // command palette (when: false), so context menu is the only UI path. const appToDelete = page.getByRole(VSCode.TREE_ITEM_ROLE, { name: "AppToDelete" }).first(); await appToDelete.click(); await page.waitForTimeout(Timeout.CLICK); - // Invoke delete via command palette (Delete key requires tree focus) - await VscodeOperator.executeCommand(page, "Java: Delete"); - await page.waitForTimeout(Timeout.CLICK); + await VscodeOperator.selectContextMenuItem(page, appToDelete, /^Delete/); // Confirm deletion in dialog try { diff --git a/test/e2e/tests/libraries.test.ts b/test/e2e/tests/libraries.test.ts index be363096..16203892 100644 --- a/test/e2e/tests/libraries.test.ts +++ b/test/e2e/tests/libraries.test.ts @@ -42,7 +42,11 @@ test.describe("Libraries & Project Creation", () => { test.use({ testProjectDir: "invisible" }); - test("java.project.create with no build tools", async ({ page }) => { + 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); diff --git a/test/e2e/utils/vscodeOperator.ts b/test/e2e/utils/vscodeOperator.ts index e741d0de..5342702c 100644 --- a/test/e2e/utils/vscodeOperator.ts +++ b/test/e2e/utils/vscodeOperator.ts @@ -162,6 +162,24 @@ export default class VscodeOperator { await page.waitForTimeout(Timeout.CLICK); } + // ----------------------------------------------------------------------- + // Context menus + // ----------------------------------------------------------------------- + + /** + * Right-clicks an element and selects an item from the context menu. + * Uses text matching to find the menu item. + */ + static async selectContextMenuItem(page: Page, target: ReturnType, menuItemLabel: string | RegExp): Promise { + await target.click({ button: "right" }); + const menu = page.locator(".monaco-menu-container"); + await menu.waitFor({ state: "visible", timeout: 5_000 }); + const item = menu.locator(".action-item").filter({ hasText: menuItemLabel }); + await item.first().waitFor({ state: "visible", timeout: 5_000 }); + await item.first().click(); + await page.waitForTimeout(Timeout.CLICK); + } + // ----------------------------------------------------------------------- // Dialogs // ----------------------------------------------------------------------- From 5705a39c16b34be4ae30f0a82be2e7bfd624b4bd Mon Sep 17 00:00:00 2001 From: wenyutang-ms Date: Tue, 31 Mar 2026 14:07:38 +0800 Subject: [PATCH 15/21] fix: click menuitem role instead of action-item container The .action-item div is a presentation container; VS Code only handles click events on the inner anchor. Use getByRole to find and click the correct element, and wait for menu dismissal to confirm the click registered. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- test/e2e/utils/vscodeOperator.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/test/e2e/utils/vscodeOperator.ts b/test/e2e/utils/vscodeOperator.ts index 5342702c..a6fc73be 100644 --- a/test/e2e/utils/vscodeOperator.ts +++ b/test/e2e/utils/vscodeOperator.ts @@ -168,15 +168,19 @@ export default class VscodeOperator { /** * Right-clicks an element and selects an item from the context menu. - * Uses text matching to find the menu item. + * Clicks the `` anchor rather than the outer + * `.action-item` container, because VS Code only handles click events + * on the anchor element. */ static async selectContextMenuItem(page: Page, target: ReturnType, menuItemLabel: string | RegExp): Promise { await target.click({ button: "right" }); const menu = page.locator(".monaco-menu-container"); await menu.waitFor({ state: "visible", timeout: 5_000 }); - const item = menu.locator(".action-item").filter({ hasText: menuItemLabel }); - await item.first().waitFor({ state: "visible", timeout: 5_000 }); - await item.first().click(); + const menuItem = menu.getByRole("menuitem", { name: menuItemLabel }); + await menuItem.first().waitFor({ state: "visible", timeout: 5_000 }); + await menuItem.first().click(); + // Wait for context menu to close, confirming the click was effective + await menu.waitFor({ state: "hidden", timeout: 5_000 }); await page.waitForTimeout(Timeout.CLICK); } From bae57aacc0e93c7ba0c11598a2a2d84d31b37da7 Mon Sep 17 00:00:00 2001 From: wenyutang-ms Date: Tue, 31 Mar 2026 14:54:07 +0800 Subject: [PATCH 16/21] fix: use keyboard shortcuts instead of context menu clicks Playwright's click() does not reliably trigger actions on VS Code Electron context menu items (the menu stays open). Switch to: 1. Right-click to select tree item and set focusedView 2. Escape to close context menu (returns focus to tree) 3. F2/Delete keyboard shortcut to trigger rename/delete commands Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- test/e2e/tests/fileOperations.test.ts | 30 ++++++++++++++++----------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/test/e2e/tests/fileOperations.test.ts b/test/e2e/tests/fileOperations.test.ts index ef3c3b51..609556d4 100644 --- a/test/e2e/tests/fileOperations.test.ts +++ b/test/e2e/tests/fileOperations.test.ts @@ -69,14 +69,18 @@ test.describe("File Operations", () => { // Expand to AppToRename await JavaOperator.expandTreePath(page, "my-app", "src/main/java", "com.mycompany.app"); - // Right-click AppToRename and select "Rename" from context menu. - // The command java.view.package.renameFile is hidden from the command - // palette (when: false), so context menu is the only UI path. + // Right-click to select item AND set focusedView = javaProjectExplorer. + // Playwright's click() on VS Code Electron context menu items does not + // reliably trigger actions, so we use the keyboard shortcut instead. const appToRename = page.getByRole(VSCode.TREE_ITEM_ROLE, { name: "AppToRename" }).first(); - await appToRename.click(); + await appToRename.click({ button: "right" }); + await page.waitForTimeout(Timeout.CLICK); + // Close context menu – focus returns to the tree with item selected + await page.keyboard.press(VSCode.ESCAPE); + await page.waitForTimeout(Timeout.CLICK); + // F2 triggers java.view.package.renameFile (keybinding) + await page.keyboard.press("F2"); await page.waitForTimeout(Timeout.CLICK); - - await VscodeOperator.selectContextMenuItem(page, appToRename, "Rename"); // The extension shows a showInputBox (quick-input) for the new name await VscodeOperator.fillQuickInput(page, "AppRenamed"); @@ -97,14 +101,16 @@ test.describe("File Operations", () => { await JavaOperator.collapseFileExplorer(page); await JavaOperator.expandTreePath(page, "my-app", "src/main/java", "com.mycompany.app"); - // Right-click AppToDelete and select "Delete" from context menu. - // The command java.view.package.moveFileToTrash is hidden from the - // command palette (when: false), so context menu is the only UI path. + // Right-click to select item AND set focusedView = javaProjectExplorer. const appToDelete = page.getByRole(VSCode.TREE_ITEM_ROLE, { name: "AppToDelete" }).first(); - await appToDelete.click(); + await appToDelete.click({ button: "right" }); + await page.waitForTimeout(Timeout.CLICK); + // Close context menu – focus returns to the tree with item selected + await page.keyboard.press(VSCode.ESCAPE); + await page.waitForTimeout(Timeout.CLICK); + // Delete key triggers java.view.package.moveFileToTrash (keybinding) + await page.keyboard.press("Delete"); await page.waitForTimeout(Timeout.CLICK); - - await VscodeOperator.selectContextMenuItem(page, appToDelete, /^Delete/); // Confirm deletion in dialog try { From 0e09fee0c1b0a7b5029dea467170323c9591849a Mon Sep 17 00:00:00 2001 From: wenyutang-ms Date: Tue, 31 Mar 2026 15:33:20 +0800 Subject: [PATCH 17/21] fix: use dispatchEvent mouseup for context menu items VS Code menus internally handle mouseup events, not click events. Playwright's simulated .click() in Electron does not reliably trigger the mouseup handler. Use dispatchEvent('mouseup') to directly dispatch the event that VS Code's menu system listens for. Previous approaches that failed: - .click() on .action-item container: menu stays open - getByRole('menuitem').click(): menu stays open - keyboard shortcut (F2/Delete): focusedView not set after Escape Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- test/e2e/tests/fileOperations.test.ts | 29 ++++++++++----------------- test/e2e/utils/vscodeOperator.ts | 11 +++++----- 2 files changed, 16 insertions(+), 24 deletions(-) diff --git a/test/e2e/tests/fileOperations.test.ts b/test/e2e/tests/fileOperations.test.ts index 609556d4..a2ae4558 100644 --- a/test/e2e/tests/fileOperations.test.ts +++ b/test/e2e/tests/fileOperations.test.ts @@ -69,19 +69,16 @@ test.describe("File Operations", () => { // Expand to AppToRename await JavaOperator.expandTreePath(page, "my-app", "src/main/java", "com.mycompany.app"); - // Right-click to select item AND set focusedView = javaProjectExplorer. - // Playwright's click() on VS Code Electron context menu items does not - // reliably trigger actions, so we use the keyboard shortcut instead. + // Select AppToRename in the tree and invoke rename via context menu. + // The command is hidden from the command palette (when: false) + // and keyboard shortcut requires focusedView which is unreliable, + // so context menu is the only reliable UI path. const appToRename = page.getByRole(VSCode.TREE_ITEM_ROLE, { name: "AppToRename" }).first(); - await appToRename.click({ button: "right" }); - await page.waitForTimeout(Timeout.CLICK); - // Close context menu – focus returns to the tree with item selected - await page.keyboard.press(VSCode.ESCAPE); - await page.waitForTimeout(Timeout.CLICK); - // F2 triggers java.view.package.renameFile (keybinding) - await page.keyboard.press("F2"); + await appToRename.click(); await page.waitForTimeout(Timeout.CLICK); + await VscodeOperator.selectContextMenuItem(page, appToRename, "Rename"); + // The extension shows a showInputBox (quick-input) for the new name await VscodeOperator.fillQuickInput(page, "AppRenamed"); @@ -101,17 +98,13 @@ test.describe("File Operations", () => { await JavaOperator.collapseFileExplorer(page); await JavaOperator.expandTreePath(page, "my-app", "src/main/java", "com.mycompany.app"); - // Right-click to select item AND set focusedView = javaProjectExplorer. + // Select AppToDelete and invoke delete via context menu. const appToDelete = page.getByRole(VSCode.TREE_ITEM_ROLE, { name: "AppToDelete" }).first(); - await appToDelete.click({ button: "right" }); - await page.waitForTimeout(Timeout.CLICK); - // Close context menu – focus returns to the tree with item selected - await page.keyboard.press(VSCode.ESCAPE); - await page.waitForTimeout(Timeout.CLICK); - // Delete key triggers java.view.package.moveFileToTrash (keybinding) - await page.keyboard.press("Delete"); + await appToDelete.click(); await page.waitForTimeout(Timeout.CLICK); + await VscodeOperator.selectContextMenuItem(page, appToDelete, /^Delete/); + // Confirm deletion in dialog try { const dialog = page.locator(".monaco-dialog-box"); diff --git a/test/e2e/utils/vscodeOperator.ts b/test/e2e/utils/vscodeOperator.ts index a6fc73be..e8e6b6a1 100644 --- a/test/e2e/utils/vscodeOperator.ts +++ b/test/e2e/utils/vscodeOperator.ts @@ -168,9 +168,10 @@ export default class VscodeOperator { /** * Right-clicks an element and selects an item from the context menu. - * Clicks the `` anchor rather than the outer - * `.action-item` container, because VS Code only handles click events - * on the anchor element. + * + * VS Code menus internally listen for `mouseup` events on menu items. + * Playwright's simulated `.click()` does not reliably trigger these + * handlers in Electron, so we use `dispatchEvent("mouseup")` instead. */ static async selectContextMenuItem(page: Page, target: ReturnType, menuItemLabel: string | RegExp): Promise { await target.click({ button: "right" }); @@ -178,9 +179,7 @@ export default class VscodeOperator { await menu.waitFor({ state: "visible", timeout: 5_000 }); const menuItem = menu.getByRole("menuitem", { name: menuItemLabel }); await menuItem.first().waitFor({ state: "visible", timeout: 5_000 }); - await menuItem.first().click(); - // Wait for context menu to close, confirming the click was effective - await menu.waitFor({ state: "hidden", timeout: 5_000 }); + await menuItem.first().dispatchEvent("mouseup"); await page.waitForTimeout(Timeout.CLICK); } From c088e21d896abab4239d837333d77af67650ce77 Mon Sep 17 00:00:00 2001 From: wenyutang-ms Date: Tue, 31 Mar 2026 16:02:42 +0800 Subject: [PATCH 18/21] fix: use page.mouse.click with bounding box for context menu Neither .click(), dispatchEvent('mouseup'), nor keyboard shortcuts reliably trigger VS Code Electron context menu items via Playwright. Switch to raw page.mouse.click() at the element's bounding box center coordinates, which sends CDP-level InputDispatchMouseEvent that Electron's native event handling processes correctly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- test/e2e/utils/vscodeOperator.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/test/e2e/utils/vscodeOperator.ts b/test/e2e/utils/vscodeOperator.ts index e8e6b6a1..e6aaca66 100644 --- a/test/e2e/utils/vscodeOperator.ts +++ b/test/e2e/utils/vscodeOperator.ts @@ -169,9 +169,11 @@ export default class VscodeOperator { /** * Right-clicks an element and selects an item from the context menu. * - * VS Code menus internally listen for `mouseup` events on menu items. - * Playwright's simulated `.click()` does not reliably trigger these - * handlers in Electron, so we use `dispatchEvent("mouseup")` instead. + * VS Code Electron context menus do not respond to Playwright's + * `.click()` or `.dispatchEvent()` — the menu stays open. Instead, + * we locate the menu item by role and use raw `page.mouse.click()` + * with the element's bounding-box coordinates, which sends CDP-level + * mouse events that Electron handles correctly. */ static async selectContextMenuItem(page: Page, target: ReturnType, menuItemLabel: string | RegExp): Promise { await target.click({ button: "right" }); @@ -179,7 +181,12 @@ export default class VscodeOperator { await menu.waitFor({ state: "visible", timeout: 5_000 }); const menuItem = menu.getByRole("menuitem", { name: menuItemLabel }); await menuItem.first().waitFor({ state: "visible", timeout: 5_000 }); - await menuItem.first().dispatchEvent("mouseup"); + // Use raw mouse click at the centre of the element's bounding box. + const box = await menuItem.first().boundingBox(); + if (!box) { + throw new Error(`Could not get bounding box for menu item "${menuItemLabel}"`); + } + await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2); await page.waitForTimeout(Timeout.CLICK); } From bc0741b986cbaef9a2056dd159b630666d623357 Mon Sep 17 00:00:00 2001 From: wenyutang-ms Date: Tue, 31 Mar 2026 20:05:27 +0800 Subject: [PATCH 19/21] fix: context menu click with hover+focused wait, auto-dismiss native dialogs Root causes found and fixed: 1. Context menu click requires hover first to trigger VS Code's menu focus state (.action-item.focused), then click works reliably. 2. redhat.java shows an Electron native dialog (dialog.showMessageBox) for refactoring confirmation on file rename. Playwright Page API cannot interact with native dialogs. Monkey-patch showMessageBox in the Electron main process to auto-return OK. Verified locally: both rename and delete tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- test/e2e/fixtures/baseTest.ts | 9 +++++++ test/e2e/tests/fileOperations.test.ts | 22 +++++++-------- test/e2e/utils/vscodeOperator.ts | 39 +++++++++++++++++---------- 3 files changed, 43 insertions(+), 27 deletions(-) diff --git a/test/e2e/fixtures/baseTest.ts b/test/e2e/fixtures/baseTest.ts index 6e438624..f810e381 100644 --- a/test/e2e/fixtures/baseTest.ts +++ b/test/e2e/fixtures/baseTest.ts @@ -103,6 +103,15 @@ export const test = base.extend({ const page = await electronApp.firstWindow(); + // Auto-dismiss Electron native dialogs (e.g. redhat.java refactoring + // confirmation "wants to make refactoring changes"). These dialogs are + // outside the renderer DOM and cannot be handled via Playwright Page API. + // Monkey-patch dialog.showMessageBox in the main process to auto-click OK. + await electronApp.evaluate(({ dialog }) => { + dialog.showMessageBox = async () => ({ response: 0, checkboxChecked: true }); + dialog.showMessageBoxSync = () => 0; + }); + // Dismiss any startup notifications/dialogs before handing off to tests await page.waitForTimeout(3_000); await dismissAllNotifications(page); diff --git a/test/e2e/tests/fileOperations.test.ts b/test/e2e/tests/fileOperations.test.ts index a2ae4558..680dbc12 100644 --- a/test/e2e/tests/fileOperations.test.ts +++ b/test/e2e/tests/fileOperations.test.ts @@ -69,20 +69,18 @@ test.describe("File Operations", () => { // Expand to AppToRename await JavaOperator.expandTreePath(page, "my-app", "src/main/java", "com.mycompany.app"); - // Select AppToRename in the tree and invoke rename via context menu. - // The command is hidden from the command palette (when: false) - // and keyboard shortcut requires focusedView which is unreliable, - // so context menu is the only reliable UI path. + // 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 appToRename.click(); - await page.waitForTimeout(Timeout.CLICK); - - await VscodeOperator.selectContextMenuItem(page, appToRename, "Rename"); + await VscodeOperator.selectContextMenuItem(page, appToRename, /^Rename/); // The extension shows a showInputBox (quick-input) for the new name await VscodeOperator.fillQuickInput(page, "AppRenamed"); - // Handle confirmation dialog if it appears + // 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 { @@ -98,11 +96,9 @@ test.describe("File Operations", () => { await JavaOperator.collapseFileExplorer(page); await JavaOperator.expandTreePath(page, "my-app", "src/main/java", "com.mycompany.app"); - // Select AppToDelete and invoke delete via context menu. + // 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 appToDelete.click(); - await page.waitForTimeout(Timeout.CLICK); - await VscodeOperator.selectContextMenuItem(page, appToDelete, /^Delete/); // Confirm deletion in dialog diff --git a/test/e2e/utils/vscodeOperator.ts b/test/e2e/utils/vscodeOperator.ts index e6aaca66..0e0c7457 100644 --- a/test/e2e/utils/vscodeOperator.ts +++ b/test/e2e/utils/vscodeOperator.ts @@ -169,24 +169,22 @@ export default class VscodeOperator { /** * Right-clicks an element and selects an item from the context menu. * - * VS Code Electron context menus do not respond to Playwright's - * `.click()` or `.dispatchEvent()` — the menu stays open. Instead, - * we locate the menu item by role and use raw `page.mouse.click()` - * with the element's bounding-box coordinates, which sends CDP-level - * mouse events that Electron handles correctly. + * 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, menuItemLabel: string | RegExp): Promise { + static async selectContextMenuItem(page: Page, target: ReturnType, menuItemName: string | RegExp): Promise { await target.click({ button: "right" }); - const menu = page.locator(".monaco-menu-container"); + const menu = page.locator(".monaco-menu-container .monaco-menu"); await menu.waitFor({ state: "visible", timeout: 5_000 }); - const menuItem = menu.getByRole("menuitem", { name: menuItemLabel }); + const menuItem = menu.getByRole("menuitem", { name: menuItemName }); await menuItem.first().waitFor({ state: "visible", timeout: 5_000 }); - // Use raw mouse click at the centre of the element's bounding box. - const box = await menuItem.first().boundingBox(); - if (!box) { - throw new Error(`Could not get bounding box for menu item "${menuItemLabel}"`); - } - await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2); + 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); } @@ -242,6 +240,19 @@ export default class VscodeOperator { 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 // ----------------------------------------------------------------------- From 73a5342a0af6def8d4f0a9bb9f57036f41f2b046 Mon Sep 17 00:00:00 2001 From: wenyutang-ms Date: Tue, 31 Mar 2026 20:31:21 +0800 Subject: [PATCH 20/21] fix: find OK button by label in showMessageBox, handle Refactor Preview On Linux the button order in dialog.showMessageBox differs from Windows, so response:0 selects 'Show Preview' instead of 'OK'. Fix by scanning the buttons array for the 'OK' label. Also handle the Refactor Preview panel (Apply/Discard) as a fallback in case the dialog still enters preview mode. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- test/e2e/fixtures/baseTest.ts | 22 +++++++++++++++++++--- test/e2e/tests/fileOperations.test.ts | 13 +++++++++++++ 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/test/e2e/fixtures/baseTest.ts b/test/e2e/fixtures/baseTest.ts index f810e381..59803bc0 100644 --- a/test/e2e/fixtures/baseTest.ts +++ b/test/e2e/fixtures/baseTest.ts @@ -106,10 +106,26 @@ export const test = base.extend({ // Auto-dismiss Electron native dialogs (e.g. redhat.java refactoring // confirmation "wants to make refactoring changes"). These dialogs are // outside the renderer DOM and cannot be handled via Playwright Page API. - // Monkey-patch dialog.showMessageBox in the main process to auto-click OK. + // Monkey-patch dialog.showMessageBox in the main process to find and + // click the "OK" button by label, falling back to the first button. await electronApp.evaluate(({ dialog }) => { - dialog.showMessageBox = async () => ({ response: 0, checkboxChecked: true }); - dialog.showMessageBoxSync = () => 0; + const origShowMessageBox = dialog.showMessageBox; + dialog.showMessageBox = async (_win: any, opts: any) => { + // opts may be the first arg if called without a window + const options = opts || _win; + const buttons: string[] = options?.buttons || []; + // Find "OK" button index; fall back to first button + let idx = buttons.findIndex((b: string) => /^OK$/i.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) => /^OK$/i.test(b)); + if (idx < 0) idx = 0; + return idx; + }; }); // Dismiss any startup notifications/dialogs before handing off to tests diff --git a/test/e2e/tests/fileOperations.test.ts b/test/e2e/tests/fileOperations.test.ts index 680dbc12..1a103936 100644 --- a/test/e2e/tests/fileOperations.test.ts +++ b/test/e2e/tests/fileOperations.test.ts @@ -87,6 +87,19 @@ test.describe("File Operations", () => { // 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(); From ab71b15a4f860dc591e3e822e9ff156d380d0b86 Mon Sep 17 00:00:00 2001 From: wenyutang-ms Date: Tue, 31 Mar 2026 20:48:29 +0800 Subject: [PATCH 21/21] fix: match Delete/Move to Trash in showMessageBox monkey-patch On Linux CI (headless, no recycle bin), the delete confirmation uses 'Delete' or 'Move to Trash' as button labels, not 'OK'. Expand the regex to match all known confirm labels. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- test/e2e/fixtures/baseTest.ts | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/test/e2e/fixtures/baseTest.ts b/test/e2e/fixtures/baseTest.ts index 59803bc0..faf06313 100644 --- a/test/e2e/fixtures/baseTest.ts +++ b/test/e2e/fixtures/baseTest.ts @@ -104,25 +104,23 @@ export const test = base.extend({ const page = await electronApp.firstWindow(); // Auto-dismiss Electron native dialogs (e.g. redhat.java refactoring - // confirmation "wants to make refactoring changes"). These dialogs are - // outside the renderer DOM and cannot be handled via Playwright Page API. - // Monkey-patch dialog.showMessageBox in the main process to find and - // click the "OK" button by label, falling back to the first button. + // 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 origShowMessageBox = dialog.showMessageBox; + const confirmLabels = /^(OK|Delete|Move to Recycle Bin|Move to Trash)$/i; dialog.showMessageBox = async (_win: any, opts: any) => { - // opts may be the first arg if called without a window const options = opts || _win; const buttons: string[] = options?.buttons || []; - // Find "OK" button index; fall back to first button - let idx = buttons.findIndex((b: string) => /^OK$/i.test(b)); + 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) => /^OK$/i.test(b)); + let idx = buttons.findIndex((b: string) => confirmLabels.test(b)); if (idx < 0) idx = 0; return idx; };