diff --git a/.gitignore b/.gitignore index 596af538b2e..0a9e46bc888 100644 --- a/.gitignore +++ b/.gitignore @@ -56,3 +56,6 @@ packages/*/resources/css/icons.css # Created by `npm run webRun` when testing extension in web mode .vscode-test-web + +# Generated by E2E UI Tests +packages/amazonq/test/e2e/amazonq/resources \ No newline at end of file diff --git a/packages/amazonq/test/e2e_new/amazonq/helpers/mcpHelper.ts b/packages/amazonq/test/e2e_new/amazonq/helpers/mcpHelper.ts new file mode 100644 index 00000000000..ac4ef165888 --- /dev/null +++ b/packages/amazonq/test/e2e_new/amazonq/helpers/mcpHelper.ts @@ -0,0 +1,286 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +import { WebviewView, By, WebElement } from 'vscode-extension-tester' +import { waitForElement } from '../utils/generalUtils' + +/** + * Clicks the tools to get to the MCP server overlay + * @param webviewView The WebviewView instance + * @returns Promise True if tools button was found and clicked, false otherwise + */ +export async function clickToolsButton(webviewView: WebviewView): Promise { + try { + const navWrapper = await waitForElement(webviewView, By.css('.mynah-nav-tabs-wrapper.mynah-ui-clickable-item')) + const buttonsWrapper = await navWrapper.findElement(By.css('.mynah-nav-tabs-bar-buttons-wrapper')) + const buttons = await buttonsWrapper.findElements( + By.css('.mynah-button.mynah-button-secondary.fill-state-always.mynah-ui-clickable-item') + ) + for (const button of buttons) { + const icon = await button.findElement(By.css('i.mynah-ui-icon.mynah-ui-icon-tools')) + if (icon) { + await button.click() + await webviewView.getDriver().actions().move({ x: 0, y: 0 }).perform() + } + } + console.log('Tools button not found') + } catch (e) { + console.error('Error clicking tools button:', e) + } +} + +/** + * Clicks the add button in the MCP server configuration panel + * @param webviewView The WebviewView instance + * @returns Promise True if add button was found and clicked, false otherwise + */ +export async function clickMCPAddButton(webviewView: WebviewView): Promise { + try { + const sheetWrapper = await waitForElement(webviewView, By.id('mynah-sheet-wrapper')) + const header = await sheetWrapper.findElement(By.css('.mynah-sheet-header')) + const actionsContainer = await header.findElement(By.css('.mynah-sheet-header-actions-container')) + const addButton = await actionsContainer.findElement(By.css('button:has(i.mynah-ui-icon-plus)')) + await addButton.click() + } catch (e) { + console.error('Error clicking the MCP add button:', e) + } +} + +/** + * Configures an MCP server with the provided settings + * @param webviewView The WebviewView instance + * @param config Configuration object with optional parameters + * @returns Promise True if configuration was successful, false otherwise + * Note: I have the default settings in the defaultConfig + */ +interface MCPServerConfig { + scope?: 'global' | 'workspace' + name?: string + transport?: number + command?: string + args?: string[] + environmentVariable?: { name: string; value: string } + timeout?: number +} + +const defaultConfig: MCPServerConfig = { + scope: 'global', + name: 'aws-documentation', + transport: 0, + command: 'uvx', + args: ['awslabs.aws-documentation-mcp-server@latest'], + timeout: 0, +} + +// Each name maps to an index in the '.mynah-form-input-wrapper' array +const formItemsMap = { + SCOPE: 0, + NAME: 1, + TRANSPORT: 2, + COMMAND: 3, + ARGS: 4, + ENV_VARS: 6, + TIMEOUT: 9, +} as const + +type McpFormItem = keyof typeof formItemsMap + +async function selectScope(container: WebElement, scope: string) { + try { + const radioLabels = await container.findElements( + By.css('.mynah-form-input-radio-label.mynah-ui-clickable-item') + ) + if (scope === 'global') { + const globalOption = radioLabels[0] + await globalOption.click() + } else { + const workspaceOption = radioLabels[1] + await workspaceOption.click() + } + } catch (e) { + console.error('Error selecting the scope:', e) + throw e + } +} + +async function inputName(container: WebElement, name: string) { + try { + const input = await container.findElement(By.css('.mynah-form-input')) + await input.sendKeys(name) + } catch (e) { + console.error('Error inputing the name:', e) + throw e + } +} + +async function selectTransport(container: WebElement, transport: number) { + try { + const selectElement = await container.findElement(By.css('select')) + const options = await selectElement.findElements(By.css('option')) + const optionIndex = transport + await options[optionIndex].click() + } catch (e) { + console.error('Error selecting the transport:', e) + throw e + } +} + +async function inputCommand(container: WebElement, command: string) { + try { + const input = await container.findElement(By.css('.mynah-form-input')) + await input.sendKeys(command) + } catch (e) { + console.error('Error inputing the command:', e) + throw e + } +} + +async function inputArgs(container: WebElement, args: string[]) { + try { + const input = await container.findElement(By.css('.mynah-form-input')) + const addButton = await container.findElement(By.css('.mynah-form-item-list-add-button')) + for (let i = 0; i < args.length; i++) { + await input.sendKeys(args[i]) + await addButton.click() + } + } catch (e) { + console.error('Error inputing the arguments:', e) + throw e + } +} + +async function inputEnvironmentVariables(container: WebElement, environmentVariable?: { name: string; value: string }) { + try { + if (environmentVariable) { + const envInputs = await container.findElements(By.css('.mynah-form-input')) + await envInputs[0].sendKeys(environmentVariable.name) + await envInputs[1].sendKeys(environmentVariable.value) + const addButton = await container.findElement(By.css('.mynah-form-item-list-add-button')) + await addButton.click() + } else { + console.log('No environmental variables for this configuration') + } + } catch (e) { + console.error('Error inputing the environment variables:', e) + throw e + } +} + +async function inputTimeout(container: WebElement, timeout: number) { + try { + const input = await container.findElement(By.css('.mynah-form-input')) + await input.clear() + await input.sendKeys(timeout.toString()) + } catch (e) { + console.error('Error inputing the timeout:', e) + throw e + } +} + +async function processFormItem(mcpFormItem: McpFormItem, container: WebElement, config: MCPServerConfig) { + switch (mcpFormItem) { + case 'SCOPE': + await selectScope(container, config.scope!) + break + case 'NAME': + await inputName(container, config.name!) + break + case 'TRANSPORT': + await selectTransport(container, config.transport!) + break + case 'COMMAND': + await inputCommand(container, config.command!) + break + case 'ARGS': + await inputArgs(container, config.args!) + break + case 'ENV_VARS': + await inputEnvironmentVariables(container, config.environmentVariable) + break + case 'TIMEOUT': + await inputTimeout(container, config.timeout!) + break + } +} + +export async function configureMCPServer(webviewView: WebviewView, config: MCPServerConfig = {}): Promise { + const mergedConfig = { ...defaultConfig, ...config } + try { + const sheetWrapper = await waitForElement(webviewView, By.id('mynah-sheet-wrapper')) + const sheetBody = await sheetWrapper.findElement(By.css('.mynah-sheet-body')) + const filtersWrapper = await sheetBody.findElement(By.css('.mynah-detailed-list-filters-wrapper')) + const formContainer = await filtersWrapper.findElement(By.css('.mynah-chat-item-form-items-container')) + const items = await formContainer.findElements(By.css('.mynah-form-input-wrapper')) + + for (const formItem of Object.keys(formItemsMap) as McpFormItem[]) { + const index = formItemsMap[formItem] + if (index < items.length) { + await processFormItem(formItem, items[index], mergedConfig) + } + } + } catch (e) { + console.log('Error configuring the MCP Server') + } +} + +export async function saveMCPServerConfiguration(webviewView: WebviewView): Promise { + try { + const sheetWrapper = await waitForElement(webviewView, By.id('mynah-sheet-wrapper')) + const body = await sheetWrapper.findElement(By.css('.mynah-sheet-body')) + const filterActions = await body.findElement(By.css('.mynah-detailed-list-filter-actions-wrapper')) + const saveButton = await filterActions.findElement( + By.css('.mynah-button.fill-state-always.status-primary.mynah-ui-clickable-item') + ) + await saveButton.click() + } catch (e) { + console.error('Error saving the MCP server configuration:', e) + } +} + +export async function cancelMCPServerConfiguration(webviewView: WebviewView): Promise { + try { + const sheetWrapper = await waitForElement(webviewView, By.id('mynah-sheet-wrapper')) + const body = await sheetWrapper.findElement(By.css('.mynah-sheet-body')) + const filterActions = await body.findElement(By.css('.mynah-detailed-list-filter-actions-wrapper')) + const saveButton = await filterActions.findElement( + By.css('.mynah-button.mynah-button-secondary.mynah-button-border.fill-state-always.mynah-ui-clickable-item') + ) + await saveButton.click() + } catch (e) { + console.error('Error saving the MCP server configuration:', e) + } +} + +/** + * Clicks the refresh button in the MCP server configuration panel + * @param webviewView The WebviewView instance + * @returns Promise True if refresh button was found and clicked, false otherwise + */ +export async function clickMCPRefreshButton(webviewView: WebviewView): Promise { + try { + const sheetWrapper = await waitForElement(webviewView, By.id('mynah-sheet-wrapper')) + const header = await sheetWrapper.findElement(By.css('.mynah-sheet-header')) + const actionsContainer = await header.findElement(By.css('.mynah-sheet-header-actions-container')) + const refreshButton = await actionsContainer.findElement(By.css('button:has(i.mynah-ui-icon-refresh)')) + await refreshButton.click() + } catch (e) { + console.error('Error clicking the MCP refresh button:', e) + } +} + +/** + * Clicks the close/cancel button in the MCP server configuration panel + * @param webviewView The WebviewView instance + * @returns Promise True if close button was found and clicked, false otherwise + */ +export async function clickMCPCloseButton(webviewView: WebviewView): Promise { + try { + const sheetWrapper = await waitForElement(webviewView, By.id('mynah-sheet-wrapper')) + const header = await sheetWrapper.findElement(By.css('.mynah-sheet-header')) + const cancelButton = await header.findElement(By.css('button:has(i.mynah-ui-icon-cancel)')) + await webviewView.getDriver().executeScript('arguments[0].click()', cancelButton) + } catch (e) { + console.error('Error closing the MCP overlay:', e) + } +} diff --git a/packages/amazonq/test/e2e_new/amazonq/tests/mcp.test.ts b/packages/amazonq/test/e2e_new/amazonq/tests/mcp.test.ts new file mode 100644 index 00000000000..e6396fd1685 --- /dev/null +++ b/packages/amazonq/test/e2e_new/amazonq/tests/mcp.test.ts @@ -0,0 +1,49 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +import '../utils/setup' +import { WebviewView } from 'vscode-extension-tester' +import { testContext } from '../utils/testContext' +import { + clickMCPAddButton, + clickMCPCloseButton, + clickMCPRefreshButton, + clickToolsButton, + configureMCPServer, + saveMCPServerConfiguration, +} from '../helpers/mcpHelper' +import { closeAllTabs } from '../utils/cleanupUtils' + +describe('Amazon Q MCP Functionality', function () { + // this timeout is the general timeout for the entire test suite + this.timeout(150000) + let webviewView: WebviewView + + before(async function () { + webviewView = testContext.webviewView + }) + + after(async function () { + await closeAllTabs(webviewView) + }) + + it('Test Amazon Q MCP Servers and Built-in Tools Access', async () => { + await clickToolsButton(webviewView) + await clickMCPCloseButton(webviewView) + }) + + it('Add new MCP Server', async () => { + await clickToolsButton(webviewView) + await clickMCPAddButton(webviewView) + await configureMCPServer(webviewView) + await saveMCPServerConfiguration(webviewView) + await clickMCPCloseButton(webviewView) + }) + + it('Refresh MCP Server', async () => { + await clickToolsButton(webviewView) + await clickMCPRefreshButton(webviewView) + await clickMCPCloseButton(webviewView) + }) +})