From 374b2e5108b88ff7952601030a66577475837968 Mon Sep 17 00:00:00 2001 From: Martin Szuc Date: Fri, 6 Jun 2025 18:02:12 +0200 Subject: [PATCH 1/3] Fix: add Chrome flags to suppress clipboard permission dialogs Prevents "devspaces.com wants to see text and images copied to the clipboard" popup when opening context menus on project files in VS Code editor during e2e test execution. Signed-off-by: Martin Szuc --- tests/e2e/driver/ChromeDriver.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/e2e/driver/ChromeDriver.ts b/tests/e2e/driver/ChromeDriver.ts index bdbd8725307..e3b8f6a50b6 100644 --- a/tests/e2e/driver/ChromeDriver.ts +++ b/tests/e2e/driver/ChromeDriver.ts @@ -36,7 +36,11 @@ export class ChromeDriver implements IDriver { .addArguments('--no-sandbox') .addArguments('--disable-web-security') .addArguments('--allow-running-insecure-content') - .addArguments('--ignore-certificate-errors'); + .addArguments('--ignore-certificate-errors') + .addArguments('--enable-clipboard-read') + .addArguments('--enable-clipboard-write') + .addArguments('--deny-permission-prompts') + .addArguments('--disable-popup-blocking'); // if 'true' run in 'headless' mode if (CHROME_DRIVER_CONSTANTS.TS_SELENIUM_HEADLESS) { From 44d80ab22e866d7a22b6bc635ff645193aba8566 Mon Sep 17 00:00:00 2001 From: Martin Szuc Date: Fri, 13 Jun 2025 12:26:44 +0200 Subject: [PATCH 2/3] [Test] Add yaml resources for enabling and disabling VSIX extension installation for vscode Signed-off-by: Martin Szuc --- .../configmap-disable-vsix-installation.yaml | 22 +++++++++++++++++++ .../configmap-enable-vsix-installation.yaml | 22 +++++++++++++++++++ .../default-extensions-configmap.yaml | 12 ++++++++++ 3 files changed, 56 insertions(+) create mode 100644 tests/e2e/resources/configmap-disable-vsix-installation.yaml create mode 100644 tests/e2e/resources/configmap-enable-vsix-installation.yaml create mode 100644 tests/e2e/resources/default-extensions-configmap.yaml diff --git a/tests/e2e/resources/configmap-disable-vsix-installation.yaml b/tests/e2e/resources/configmap-disable-vsix-installation.yaml new file mode 100644 index 00000000000..68d6ca1b831 --- /dev/null +++ b/tests/e2e/resources/configmap-disable-vsix-installation.yaml @@ -0,0 +1,22 @@ +kind: ConfigMap +apiVersion: v1 +metadata: + name: vscode-editor-configurations + namespace: openshift-devspaces + labels: + app.kubernetes.io/part-of: che.eclipse.org + app.kubernetes.io/component: workspaces-config +data: + configurations.json: | + { + "extensions.install-from-vsix-enabled": false + } + settings.json: | + { + "window.header": "VSIX INSTALL = DISABLED", + "window.commandCenter": false, + "workbench.colorCustomizations": { + "titleBar.activeBackground": "#CCA700", + "titleBar.activeForeground": "#ffffff" + } + } diff --git a/tests/e2e/resources/configmap-enable-vsix-installation.yaml b/tests/e2e/resources/configmap-enable-vsix-installation.yaml new file mode 100644 index 00000000000..865388af4b2 --- /dev/null +++ b/tests/e2e/resources/configmap-enable-vsix-installation.yaml @@ -0,0 +1,22 @@ +kind: ConfigMap +apiVersion: v1 +metadata: + name: vscode-editor-configurations + namespace: openshift-devspaces + labels: + app.kubernetes.io/part-of: che.eclipse.org + app.kubernetes.io/component: workspaces-config +data: + configurations.json: | + { + "extensions.install-from-vsix-enabled": true + } + settings.json: | + { + "window.header": "VSIX INSTALL = ENABLED", + "window.commandCenter": false, + "workbench.colorCustomizations": { + "titleBar.activeBackground": "#CCA700", + "titleBar.activeForeground": "#ffffff" + } + } \ No newline at end of file diff --git a/tests/e2e/resources/default-extensions-configmap.yaml b/tests/e2e/resources/default-extensions-configmap.yaml new file mode 100644 index 00000000000..61006bc6a43 --- /dev/null +++ b/tests/e2e/resources/default-extensions-configmap.yaml @@ -0,0 +1,12 @@ +kind: ConfigMap +apiVersion: v1 +metadata: + name: default-extensions + namespace: admin-devspaces + labels: + controller.devfile.io/mount-to-devworkspace: 'true' + controller.devfile.io/watch-configmap: 'true' + annotations: + controller.devfile.io/mount-as: env +data: + DEFAULT_EXTENSIONS: '/projects/web-nodejs-sample/redhat.vscode-yaml-1.17.0.vsix' \ No newline at end of file From 4605f7da3748edf2a99008ae6fd64af9e31a06db Mon Sep 17 00:00:00 2001 From: Martin Szuc Date: Fri, 13 Jun 2025 12:28:17 +0200 Subject: [PATCH 3/3] [Test] Add VsixInstallationDisable test Assisted-by: Gemini Signed-off-by: Martin Szuc --- tests/e2e/configs/inversify.config.ts | 8 + tests/e2e/configs/inversify.types.ts | 6 +- tests/e2e/index.ts | 4 + tests/e2e/pageobjects/ide/CommandPalette.ts | 109 ++++++++ tests/e2e/pageobjects/ide/ExplorerView.ts | 95 +++++++ tests/e2e/pageobjects/ide/ExtensionsView.ts | 155 +++++++++++ .../pageobjects/ide/NotificationHandler.ts | 90 +++++++ .../pageobjects/ide/ViewsMoreActionsButton.ts | 11 + .../VsixInstallationDisable.spec.ts | 244 ++++++++++++++++++ 9 files changed, 721 insertions(+), 1 deletion(-) create mode 100644 tests/e2e/pageobjects/ide/CommandPalette.ts create mode 100644 tests/e2e/pageobjects/ide/ExplorerView.ts create mode 100644 tests/e2e/pageobjects/ide/ExtensionsView.ts create mode 100644 tests/e2e/pageobjects/ide/NotificationHandler.ts create mode 100644 tests/e2e/specs/miscellaneous/VsixInstallationDisable.spec.ts diff --git a/tests/e2e/configs/inversify.config.ts b/tests/e2e/configs/inversify.config.ts index a3d7802ce10..82e42aa28fa 100644 --- a/tests/e2e/configs/inversify.config.ts +++ b/tests/e2e/configs/inversify.config.ts @@ -56,6 +56,10 @@ import { WebTerminalPage } from '../pageobjects/webterminal/WebTerminalPage'; import { TrustAuthorPopup } from '../pageobjects/dashboard/TrustAuthorPopup'; import { ViewsMoreActionsButton } from '../pageobjects/ide/ViewsMoreActionsButton'; import { RestrictedModeButton } from '../pageobjects/ide/RestrictedModeButton'; +import { CommandPalette } from '../pageobjects/ide/CommandPalette'; +import { ExtensionsView } from '../pageobjects/ide/ExtensionsView'; +import { ExplorerView } from '../pageobjects/ide/ExplorerView'; +import { NotificationHandler } from '../pageobjects/ide/NotificationHandler'; const e2eContainer: Container = new Container({ defaultScope: 'Transient', skipBaseClassChecks: true }); @@ -97,6 +101,10 @@ e2eContainer.bind(EXTERNAL_CLASSES.LocatorLoader).to(LocatorLoade e2eContainer.bind(CLASSES.TrustAuthorPopup).to(TrustAuthorPopup); e2eContainer.bind(CLASSES.ViewsMoreActionsButton).to(ViewsMoreActionsButton); e2eContainer.bind(CLASSES.RestrictedModeButton).to(RestrictedModeButton); +e2eContainer.bind(CLASSES.CommandPalette).to(CommandPalette); +e2eContainer.bind(CLASSES.ExtensionsView).to(ExtensionsView); +e2eContainer.bind(CLASSES.ExplorerView).to(ExplorerView); +e2eContainer.bind(CLASSES.NotificationHandler).to(NotificationHandler); if (BASE_TEST_CONSTANTS.TS_PLATFORM === Platform.OPENSHIFT) { if (OAUTH_CONSTANTS.TS_SELENIUM_VALUE_OPENSHIFT_OAUTH) { diff --git a/tests/e2e/configs/inversify.types.ts b/tests/e2e/configs/inversify.types.ts index c9134fb6386..5c8cfee429a 100644 --- a/tests/e2e/configs/inversify.types.ts +++ b/tests/e2e/configs/inversify.types.ts @@ -53,7 +53,11 @@ const CLASSES: any = { RevokeOauthPage: 'RevokeOauthPage', TrustAuthorPopup: 'TrustAuthorPopup', ViewsMoreActionsButton: 'ViewsMoreActionsButton', - RestrictedModeButton: 'RestrictedModeButton' + RestrictedModeButton: 'RestrictedModeButton', + CommandPalette: 'CommandPalette', + ExtensionsView: 'ExtensionsView', + ExplorerView: 'ExplorerView', + NotificationHandler: 'NotificationHandler' }; const EXTERNAL_CLASSES: any = { diff --git a/tests/e2e/index.ts b/tests/e2e/index.ts index bdbef7c233e..939498f25ee 100644 --- a/tests/e2e/index.ts +++ b/tests/e2e/index.ts @@ -30,6 +30,10 @@ export * from './pageobjects/dashboard/workspace-details/WorkspaceDetails'; export * from './pageobjects/dashboard/Workspaces'; export * from './pageobjects/git-providers/OauthPage'; export * from './pageobjects/ide/CheCodeLocatorLoader'; +export * from './pageobjects/ide/CommandPalette'; +export * from './pageobjects/ide/ExplorerView'; +export * from './pageobjects/ide/ExtensionsView'; +export * from './pageobjects/ide/NotificationHandler'; export * from './pageobjects/ide/RestrictedModeButton'; export * from './pageobjects/ide/ViewsMoreActionsButton'; export * from './pageobjects/login/interfaces/ICheLoginPage'; diff --git a/tests/e2e/pageobjects/ide/CommandPalette.ts b/tests/e2e/pageobjects/ide/CommandPalette.ts new file mode 100644 index 00000000000..acb62a5336f --- /dev/null +++ b/tests/e2e/pageobjects/ide/CommandPalette.ts @@ -0,0 +1,109 @@ +/** ******************************************************************* + * copyright (c) 2025 Red Hat, Inc. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + **********************************************************************/ +import { inject, injectable } from 'inversify'; +import { CLASSES } from '../../configs/inversify.types'; +import { By, Key, WebElement } from 'selenium-webdriver'; +import { DriverHelper } from '../../utils/DriverHelper'; +import { Logger } from '../../utils/Logger'; +import { TIMEOUT_CONSTANTS } from '../../constants/TIMEOUT_CONSTANTS'; + +@injectable() +export class CommandPalette { + private static readonly COMMAND_PALETTE_CONTAINER: By = By.css('.quick-input-widget'); + private static readonly COMMAND_PALETTE_LIST: By = By.css('#quickInput_list'); + private static readonly COMMAND_PALETTE_ITEMS: By = By.css('#quickInput_list [role="option"]'); + + constructor( + @inject(CLASSES.DriverHelper) + private readonly driverHelper: DriverHelper + ) {} + + async openCommandPalette(): Promise { + Logger.debug(); + + await this.driverHelper.getDriver().actions().keyDown(Key.F1).keyUp(Key.F1).perform(); + + const paletteVisible: boolean = await this.driverHelper.waitVisibilityBoolean( + CommandPalette.COMMAND_PALETTE_CONTAINER, + 5, + TIMEOUT_CONSTANTS.TS_SELENIUM_DEFAULT_POLLING + ); + + if (!paletteVisible) { + await this.driverHelper + .getDriver() + .actions() + .keyDown(Key.CONTROL) + .keyDown(Key.SHIFT) + .sendKeys('p') + .keyUp(Key.SHIFT) + .keyUp(Key.CONTROL) + .perform(); + } + + await this.driverHelper.waitVisibility(CommandPalette.COMMAND_PALETTE_LIST); + } + + async searchCommand(commandText: string): Promise { + Logger.debug(`"${commandText}"`); + + // add pause before typing to prevent issues with fast typing + await this.driverHelper.wait(2000); + await this.driverHelper.getDriver().actions().sendKeys(commandText).perform(); + await this.driverHelper.wait(1000); + } + + async getVisibleCommands(): Promise { + Logger.debug(); + + const listVisible: boolean = await this.driverHelper.waitVisibilityBoolean( + CommandPalette.COMMAND_PALETTE_LIST, + 10, + TIMEOUT_CONSTANTS.TS_SELENIUM_DEFAULT_POLLING + ); + + if (!listVisible) { + return []; + } + + await this.driverHelper.wait(1000); + const items: WebElement[] = await this.driverHelper.getDriver().findElements(CommandPalette.COMMAND_PALETTE_ITEMS); + const itemTexts: string[] = []; + + for (const item of items) { + try { + const ariaLabel: string = await item.getAttribute('aria-label'); + if (ariaLabel) { + itemTexts.push(ariaLabel); + } + } catch (err) { + // skip items that cannot be read + } + } + + return itemTexts; + } + + async isCommandVisible(commandText: string): Promise { + Logger.debug(`"${commandText}"`); + await this.driverHelper.wait(3000); + + const availableCommands: string[] = await this.getVisibleCommands(); + Logger.debug(`Available commands: ${availableCommands.join(', ')}`); + return availableCommands.some((command: string): boolean => command.toLowerCase().includes(commandText.toLowerCase())); + } + + async closeCommandPalette(): Promise { + Logger.debug(); + + await this.driverHelper.getDriver().actions().sendKeys(Key.ESCAPE).perform(); + await this.driverHelper.waitDisappearance(CommandPalette.COMMAND_PALETTE_CONTAINER); + } +} diff --git a/tests/e2e/pageobjects/ide/ExplorerView.ts b/tests/e2e/pageobjects/ide/ExplorerView.ts new file mode 100644 index 00000000000..930dc54a908 --- /dev/null +++ b/tests/e2e/pageobjects/ide/ExplorerView.ts @@ -0,0 +1,95 @@ +/** ******************************************************************* + * copyright (c) 2025 Red Hat, Inc. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + **********************************************************************/ +import { inject, injectable } from 'inversify'; +import { CLASSES } from '../../configs/inversify.types'; +import { By, Key } from 'selenium-webdriver'; +import { ActivityBar, ViewItem, ViewSection } from 'monaco-page-objects'; +import { DriverHelper } from '../../utils/DriverHelper'; +import { Logger } from '../../utils/Logger'; +import { CheCodeLocatorLoader } from './CheCodeLocatorLoader'; +import { ProjectAndFileTests } from '../../tests-library/ProjectAndFileTests'; + +@injectable() +export class ExplorerView { + constructor( + @inject(CLASSES.DriverHelper) + private readonly driverHelper: DriverHelper, + @inject(CLASSES.CheCodeLocatorLoader) + private readonly cheCodeLocatorLoader: CheCodeLocatorLoader, + @inject(CLASSES.ProjectAndFileTests) + private readonly projectAndFileTests: ProjectAndFileTests + ) {} + + async openExplorerView(): Promise { + Logger.debug(); + + const explorerCtrl: any = await new ActivityBar().getViewControl('Explorer'); + await explorerCtrl?.openView(); + } + + async openFileContextMenu(fileName: string): Promise { + Logger.debug(`"${fileName}"`); + + await this.openExplorerView(); + + const projectSection: ViewSection = await this.projectAndFileTests.getProjectViewSession(); + const fileItem: ViewItem | undefined = await this.projectAndFileTests.getProjectTreeItem(projectSection, fileName); + + if (!fileItem) { + throw new Error(`Could not find ${fileName} file in explorer`); + } + + await fileItem.openContextMenu(); + await this.waitContextMenuVisible(); + } + + async isContextMenuItemVisible(menuItemAriaLabel: string): Promise { + Logger.debug(`"${menuItemAriaLabel}"`); + + // add a small delay to ensure menu is stable + await this.driverHelper.wait(500); + + const contextMenuItemLocator: By = By.css(`.monaco-menu-container [aria-label="${menuItemAriaLabel}"]`); + return await this.driverHelper.waitVisibilityBoolean(contextMenuItemLocator, 5, 1000); + } + + async closeContextMenu(): Promise { + Logger.debug(); + + await this.driverHelper.getDriver().actions().sendKeys(Key.ESCAPE).perform(); + await this.driverHelper.wait(500); + } + + private async waitContextMenuVisible(): Promise { + Logger.debug(); + + // wait for container with increased timeout + const containerVisible: boolean = await this.driverHelper.waitVisibilityBoolean( + this.cheCodeLocatorLoader.webCheCodeLocators.ContextMenu.contextView, + 10, + 1000 + ); + + if (!containerVisible) { + throw new Error('Context menu container did not appear'); + } + + // brief pause to allow menu items to render + await this.driverHelper.wait(800); + + // verify menu items are present + const menuItemsLocator: By = By.css('.monaco-menu-container .action-item'); + const menuItemsVisible: boolean = await this.driverHelper.waitVisibilityBoolean(menuItemsLocator, 5, 1000); + + if (!menuItemsVisible) { + throw new Error('Context menu items did not load properly'); + } + } +} diff --git a/tests/e2e/pageobjects/ide/ExtensionsView.ts b/tests/e2e/pageobjects/ide/ExtensionsView.ts new file mode 100644 index 00000000000..770d0b72946 --- /dev/null +++ b/tests/e2e/pageobjects/ide/ExtensionsView.ts @@ -0,0 +1,155 @@ +/** ******************************************************************* + * copyright (c) 2025 Red Hat, Inc. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + **********************************************************************/ +import { inject, injectable } from 'inversify'; +import { CLASSES } from '../../configs/inversify.types'; +import { By, Key, WebElement } from 'selenium-webdriver'; +import { ActivityBar, ExtensionsViewItem, ExtensionsViewSection, SideBarView } from 'monaco-page-objects'; +import { DriverHelper } from '../../utils/DriverHelper'; +import { Logger } from '../../utils/Logger'; +import { ViewsMoreActionsButton } from './ViewsMoreActionsButton'; + +@injectable() +export class ExtensionsView { + private static readonly EXTENSIONS_VIEW_SELECTOR: By = By.css('.extensions-viewlet'); + private static readonly MENU_ITEM: By = By.css('.context-view .monaco-menu .action-item'); + + constructor( + @inject(CLASSES.DriverHelper) + private readonly driverHelper: DriverHelper, + @inject(CLASSES.ViewsMoreActionsButton) + private readonly viewsMoreActionsButton: ViewsMoreActionsButton + ) {} + + async openExtensionsView(): Promise { + Logger.debug(); + + const viewCtrl: any = await new ActivityBar().getViewControl('Extensions'); + await viewCtrl?.openView(); + + const extensionsViewVisible: boolean = await this.driverHelper.waitVisibilityBoolean( + ExtensionsView.EXTENSIONS_VIEW_SELECTOR, + 5, + 1000 + ); + + if (!extensionsViewVisible) { + throw new Error('Extensions view could not be opened'); + } + } + + async openMoreActionsMenu(): Promise { + Logger.debug(); + + await this.viewsMoreActionsButton.clickViewsMoreActionsButton(); + await this.viewsMoreActionsButton.waitForContextMenu(); + } + + async getMoreActionsMenuItems(): Promise { + Logger.debug(); + + const menuItems: WebElement[] = await this.driverHelper.getDriver().findElements(ExtensionsView.MENU_ITEM); + const menuTexts: string[] = []; + + for (const item of menuItems) { + try { + const text: string = await item.getText(); + menuTexts.push(text); + } catch (err) { + // skip items that cannot be read + } + } + + return menuTexts; + } + + async isMoreActionsMenuItemVisible(menuItemText: string): Promise { + Logger.debug(`"${menuItemText}"`); + + const menuItems: string[] = await this.getMoreActionsMenuItems(); + return menuItems.some((item: string): boolean => item.includes(menuItemText)); + } + + async closeMoreActionsMenu(): Promise { + Logger.debug(); + + await this.driverHelper.getDriver().actions().sendKeys(Key.ESCAPE).perform(); + } + + /** + * get installed extension names using @installed filter + * Reuses pattern from RecommendedExtensions.spec.ts + */ + async getInstalledExtensionNames(): Promise { + Logger.debug(); + + await this.openExtensionsView(); + + const extensionsView: SideBarView | undefined = await (await new ActivityBar().getViewControl('Extensions'))?.openView(); + if (!extensionsView) { + throw new Error('Could not open Extensions view'); + } + + const [extensionSection]: ExtensionsViewSection[] = (await extensionsView.getContent().getSections()) as ExtensionsViewSection[]; + if (!extensionSection) { + throw new Error('Could not find Extensions section'); + } + + // search for installed extensions + await this.searchInExtensions(extensionSection, '@installed'); + + // wait for results to load + await this.driverHelper.wait(2000); + + const installedItems: ExtensionsViewItem[] = await extensionSection.getVisibleItems(); + const extensionNames: string[] = []; + + for (const item of installedItems) { + try { + const title: string = await item.getTitle(); + extensionNames.push(title); + } catch (err) { + // skip items that cannot be read + } + } + + Logger.debug(`Found ${extensionNames.length} installed extensions: ${extensionNames.join(', ')}`); + return extensionNames; + } + + /** + * search in extensions - reused from RecommendedExtensions.spec.ts pattern + */ + private async searchInExtensions(extensionSection: ExtensionsViewSection, searchText: string): Promise { + Logger.debug(`Searching for: "${searchText}"`); + + const enclosingItem: WebElement = extensionSection.getEnclosingElement(); + + try { + const searchField: WebElement = await enclosingItem.findElement(By.css('input.monaco-inputbox-input')); + + // clear existing search + await this.driverHelper.getDriver().actions().click(searchField).perform(); + await this.driverHelper + .getDriver() + .actions() + .keyDown(Key.CONTROL) + .sendKeys('a') + .keyUp(Key.CONTROL) + .sendKeys(Key.DELETE) + .perform(); + + // enter new search text + await this.driverHelper.getDriver().actions().sendKeys(searchText).perform(); + await this.driverHelper.wait(1000); + } catch (err) { + Logger.debug(`Could not interact with search field: ${err}`); + } + } +} diff --git a/tests/e2e/pageobjects/ide/NotificationHandler.ts b/tests/e2e/pageobjects/ide/NotificationHandler.ts new file mode 100644 index 00000000000..8891e3f2f3f --- /dev/null +++ b/tests/e2e/pageobjects/ide/NotificationHandler.ts @@ -0,0 +1,90 @@ +/** ******************************************************************* + * copyright (c) 2025 Red Hat, Inc. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + **********************************************************************/ +import { inject, injectable } from 'inversify'; +import { CLASSES } from '../../configs/inversify.types'; +import { By, WebElement } from 'selenium-webdriver'; +import { DriverHelper } from '../../utils/DriverHelper'; +import { Logger } from '../../utils/Logger'; + +@injectable() +export class NotificationHandler { + private static readonly NOTIFICATION_SELECTORS: string[] = [ + '.notification-list-item-message', + '[class*="notification"]', + '.monaco-list-row' + ]; + + constructor( + @inject(CLASSES.DriverHelper) + private readonly driverHelper: DriverHelper + ) {} + + /** + * check for notifications containing specific text + */ + async checkForNotification(expectedText: string, timeoutSeconds: number = 20): Promise { + Logger.info(`Checking for notification containing: "${expectedText}"`); + + // check existing notifications first + if (await this.findInExistingNotifications(expectedText)) { + return true; + } + + // wait for new notifications to appear + const maxAttempts: number = timeoutSeconds / 2; + for (let attempt: number = 0; attempt < maxAttempts; attempt++) { + if (await this.findInExistingNotifications(expectedText)) { + return true; + } + await this.driverHelper.wait(2000); + } + + Logger.debug(`Notification containing "${expectedText}" not found`); + return false; + } + + /** + * check for any of multiple notification texts + */ + async checkForAnyNotification(texts: string[], timeoutSeconds: number = 20): Promise { + for (const text of texts) { + if (await this.checkForNotification(text, timeoutSeconds)) { + return true; + } + } + return false; + } + + /** + * find notification text in existing notifications + */ + private async findInExistingNotifications(expectedText: string): Promise { + for (const selector of NotificationHandler.NOTIFICATION_SELECTORS) { + try { + const elements: WebElement[] = await this.driverHelper.getDriver().findElements(By.css(selector)); + + for (const element of elements) { + try { + const text: string = await element.getText(); + if (text.trim() && text.toLowerCase().includes(expectedText.toLowerCase())) { + Logger.debug(`Found notification: "${text}"`); + return true; + } + } catch (err) { + // continue to next element + } + } + } catch (err) { + // continue to next selector + } + } + return false; + } +} diff --git a/tests/e2e/pageobjects/ide/ViewsMoreActionsButton.ts b/tests/e2e/pageobjects/ide/ViewsMoreActionsButton.ts index 45bdbd18bf7..4e79ca62e77 100644 --- a/tests/e2e/pageobjects/ide/ViewsMoreActionsButton.ts +++ b/tests/e2e/pageobjects/ide/ViewsMoreActionsButton.ts @@ -53,4 +53,15 @@ export class ViewsMoreActionsButton { return viewsActionsButton; } + + async clickViewsMoreActionsButton(): Promise { + Logger.debug(); + await this.driverHelper.waitAndClick(ViewsMoreActionsButton.VIEWS_AND_MORE_ACTIONS_BUTTON); + } + + async waitForContextMenu(): Promise { + const cheCodeLocatorLoader: CheCodeLocatorLoader = e2eContainer.get(CLASSES.CheCodeLocatorLoader); + const webCheCodeLocators: Locators = cheCodeLocatorLoader.webCheCodeLocators; + await this.driverHelper.waitVisibility(webCheCodeLocators.ContextMenu.contextView); + } } diff --git a/tests/e2e/specs/miscellaneous/VsixInstallationDisable.spec.ts b/tests/e2e/specs/miscellaneous/VsixInstallationDisable.spec.ts new file mode 100644 index 00000000000..2c7db4f68f7 --- /dev/null +++ b/tests/e2e/specs/miscellaneous/VsixInstallationDisable.spec.ts @@ -0,0 +1,244 @@ +/** ******************************************************************* + * copyright (c) 2025 Red Hat, Inc. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + **********************************************************************/ + +import 'reflect-metadata'; +import { e2eContainer } from '../../configs/inversify.config'; +import { CLASSES, TYPES } from '../../configs/inversify.types'; +import { expect } from 'chai'; +import * as fs from 'fs'; +import * as path from 'path'; + +import { WorkspaceHandlingTests } from '../../tests-library/WorkspaceHandlingTests'; +import { ProjectAndFileTests } from '../../tests-library/ProjectAndFileTests'; +import { LoginTests } from '../../tests-library/LoginTests'; +import { registerRunningWorkspace } from '../MochaHooks'; + +import { KubernetesCommandLineToolsExecutor } from '../../utils/KubernetesCommandLineToolsExecutor'; +import { ShellExecutor } from '../../utils/ShellExecutor'; +import { ITestWorkspaceUtil } from '../../utils/workspace/ITestWorkspaceUtil'; + +import { Dashboard } from '../../pageobjects/dashboard/Dashboard'; +import { NotificationHandler } from '../../pageobjects/ide/NotificationHandler'; +import { CommandPalette } from '../../pageobjects/ide/CommandPalette'; +import { ExtensionsView } from '../../pageobjects/ide/ExtensionsView'; +import { ExplorerView } from '../../pageobjects/ide/ExplorerView'; + +import { DriverHelper } from '../../utils/DriverHelper'; +import { BrowserTabsUtil } from '../../utils/BrowserTabsUtil'; +import { BASE_TEST_CONSTANTS } from '../../constants/BASE_TEST_CONSTANTS'; +import { Logger } from '../../utils/Logger'; + +suite(`Verify VSIX installation can be disabled via configuration ${BASE_TEST_CONSTANTS.TEST_ENVIRONMENT}`, function (): void { + const workspaceHandlingTests: WorkspaceHandlingTests = e2eContainer.get(CLASSES.WorkspaceHandlingTests); + const projectAndFileTests: ProjectAndFileTests = e2eContainer.get(CLASSES.ProjectAndFileTests); + const loginTests: LoginTests = e2eContainer.get(CLASSES.LoginTests); + const kubernetesCommandLineToolsExecutor: KubernetesCommandLineToolsExecutor = e2eContainer.get( + CLASSES.KubernetesCommandLineToolsExecutor + ); + const shellExecutor: ShellExecutor = e2eContainer.get(CLASSES.ShellExecutor); + const testWorkspaceUtil: ITestWorkspaceUtil = e2eContainer.get(TYPES.WorkspaceUtil); + const dashboard: Dashboard = e2eContainer.get(CLASSES.Dashboard); + const driverHelper: DriverHelper = e2eContainer.get(CLASSES.DriverHelper); + const browserTabsUtil: BrowserTabsUtil = e2eContainer.get(CLASSES.BrowserTabsUtil); + const notificationHandler: NotificationHandler = e2eContainer.get(CLASSES.NotificationHandler); + const commandPalette: CommandPalette = e2eContainer.get(CLASSES.CommandPalette); + const extensionsView: ExtensionsView = e2eContainer.get(CLASSES.ExtensionsView); + const explorerView: ExplorerView = e2eContainer.get(CLASSES.ExplorerView); + + // test configuration + const testRepoUrl: string = 'https://github.com/RomanNikitenko/web-nodejs-sample/tree/install-from-vsix-disabled-7-100'; + const resourcesPath: string = path.join(__dirname, '../../../resources'); + const defaultExtensions: string[] = ['YAML']; + + /** + * verify VSIX installation capability in UI locations + */ + async function verifyVsixInstallationCapability(expectedEnabled: boolean): Promise { + Logger.info(`Verifying VSIX installation capability - expected enabled: ${expectedEnabled}`); + + // check Command Palette + await commandPalette.openCommandPalette(); + await commandPalette.searchCommand('Install from VSIX'); + const commandAvailable: boolean = await commandPalette.isCommandVisible('Extensions: Install from VSIX...'); + await commandPalette.closeCommandPalette(); + + expect(commandAvailable).to.equal( + expectedEnabled, + `Command Palette should ${expectedEnabled ? 'contain' : 'not contain'} Install from VSIX command` + ); + + // check Extensions View More Actions Menu + await extensionsView.openExtensionsView(); + await extensionsView.openMoreActionsMenu(); + const extensionMenuAvailable: boolean = await extensionsView.isMoreActionsMenuItemVisible('Install from VSIX'); + await extensionsView.closeMoreActionsMenu(); + + expect(extensionMenuAvailable).to.equal( + expectedEnabled, + `Extensions view should ${expectedEnabled ? 'contain' : 'not contain'} Install from VSIX action` + ); + + // check Explorer Context Menu + const vsixFileName: string = 'redhat.vscode-yaml-1.17.0.vsix'; + await explorerView.openFileContextMenu(vsixFileName); + const contextMenuAvailable: boolean = await explorerView.isContextMenuItemVisible('Install Extension VSIX'); + await explorerView.closeContextMenu(); + + expect(contextMenuAvailable).to.equal( + expectedEnabled, + `Explorer context menu should ${expectedEnabled ? 'contain' : 'not contain'} Install Extension VSIX action` + ); + } + + let workspaceName: string = ''; + + /** + * verify default extensions installation status + */ + async function verifyDefaultExtensionsInstallation(shouldBeInstalled: boolean): Promise { + Logger.info(`Verifying default VSIX extensions auto-installation - expected installed: ${shouldBeInstalled}`); + + await extensionsView.openExtensionsView(); + const installedExtensions: string[] = await extensionsView.getInstalledExtensionNames(); + + Logger.debug(`Found installed extensions: ${installedExtensions.join(', ')}`); + + for (const extensionName of defaultExtensions) { + const isInstalled: boolean = installedExtensions.some((installed: string): boolean => + installed.toLowerCase().includes(extensionName.toLowerCase()) + ); + + expect(isInstalled).to.equal( + shouldBeInstalled, + `Default VSIX extension "${extensionName}" should ${shouldBeInstalled ? 'be auto-installed' : 'not be auto-installed'}` + ); + } + } + + /** + * apply a ConfigMap from resources folder + */ + function applyConfigMap(configFileName: string): void { + const configPath: string = path.join(resourcesPath, configFileName); + const configContent: string = fs.readFileSync(configPath, 'utf8'); + shellExecutor.executeCommand(`oc apply -f - < { + await loginTests.loginIntoChe(); + }); + + test('Apply ConfigMaps that disable VSIX and set default extensions', function (): void { + applyConfigMap('configmap-disable-vsix-installation.yaml'); + applyConfigMap('default-extensions-configmap.yaml'); + }); + + test('Create and open workspace from Git repository', async function (): Promise { + await workspaceHandlingTests.createAndOpenWorkspaceFromGitRepository(testRepoUrl); + await workspaceHandlingTests.obtainWorkspaceNameFromStartingPage(); + workspaceName = WorkspaceHandlingTests.getWorkspaceName(); + registerRunningWorkspace(workspaceName); + }); + + test('Wait workspace readiness', async function (): Promise { + await projectAndFileTests.waitWorkspaceReadinessForCheCodeEditor(); + }); + + test('Verify for VSIX disabled notifications', async function (): Promise { + const hasDisableNotification: boolean = await notificationHandler.checkForNotification('install from vsix command is disabled'); + expect(hasDisableNotification).to.be.true; + }); + + test('Perform trust author dialog', async function (): Promise { + await projectAndFileTests.performTrustAuthorDialog(); + }); + + test('Verify VSIX installation is disabled', async function (): Promise { + await verifyVsixInstallationCapability(false); + }); + + test('Verify default extensions are not auto-installed when VSIX disabled', async function (): Promise { + await verifyDefaultExtensionsInstallation(false); + }); + + test('Enable VSIX installation and create new workspace', async function (): Promise { + // clean up current workspace + await dashboard.openDashboard(); + await testWorkspaceUtil.stopAndDeleteWorkspaceByName(workspaceName); + registerRunningWorkspace(''); + + // apply new ConfigMaps + applyConfigMap('configmap-enable-vsix-installation.yaml'); + applyConfigMap('default-extensions-configmap.yaml'); + + Logger.info('Waiting for new ConfigMap settings to take effect...'); + await driverHelper.wait(30000); + + // create new workspace + await workspaceHandlingTests.createAndOpenWorkspaceFromGitRepository(testRepoUrl); + await workspaceHandlingTests.obtainWorkspaceNameFromStartingPage(); + workspaceName = WorkspaceHandlingTests.getWorkspaceName(); + registerRunningWorkspace(workspaceName); + }); + + test('Wait workspace readiness', async function (): Promise { + await projectAndFileTests.waitWorkspaceReadinessForCheCodeEditor(); + }); + + test('Verify default extension installation success notifications before trust dialog', async function (): Promise { + const successTexts: string[] = ['Completed installing extension', 'installed', 'extension']; + const hasSuccessNotification: boolean = await notificationHandler.checkForAnyNotification(successTexts); + expect(hasSuccessNotification).to.be.true; + }); + + test('Perform trust author dialog', async function (): Promise { + await projectAndFileTests.performTrustAuthorDialog(); + }); + + test('Verify VSIX installation is enabled', async function (): Promise { + await verifyVsixInstallationCapability(true); + }); + + test('Verify default extensions are auto-installed when VSIX enabled', async function (): Promise { + await verifyDefaultExtensionsInstallation(true); + }); + + suiteTeardown('Clean up ConfigMaps and workspace', async function (): Promise { + cleanupConfigMaps(); + await dashboard.openDashboard(); + await browserTabsUtil.closeAllTabsExceptCurrent(); + await testWorkspaceUtil.stopAndDeleteWorkspaceByName(workspaceName); + registerRunningWorkspace(''); + Logger.info('Cleanup completed'); + }); +});