diff --git a/.github/workflows/e2e_tests.yml b/.github/workflows/e2e_tests.yml new file mode 100644 index 000000000..f40dd5825 --- /dev/null +++ b/.github/workflows/e2e_tests.yml @@ -0,0 +1,74 @@ +name: Playwright Tests + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + timeout-minutes: 5 + runs-on: ubuntu-latest + + steps: + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y libwoff1 + + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 18 + + # Cache Playwright browsers + - name: Cache Playwright browsers + id: cache-playwright + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright # The default Playwright cache path + key: ${{ runner.os }}-playwright-${{ hashFiles('package-lock.json') }} # Cache key based on OS and package-lock.json + restore-keys: | + ${{ runner.os }}-playwright- + + - name: Install dependencies + run: npm ci + + - name: Install Playwright dependencies + run: npx playwright install-deps + + - name: Install Playwright and browsers unless cached + run: npx playwright install --with-deps + if: steps.cache-playwright.outputs.cache-hit != 'true' + + - name: Run Playwright tests + run: npm run test:e2e + + - name: Upload Playwright Report and Screenshots + uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report + path: | + client/playwright-report/ + client/test-results/ + client/results.json + retention-days: 2 + + - name: Publish Playwright Test Summary + uses: daun/playwright-report-summary@v3 + if: always() + with: + create-comment: ${{ github.event.pull_request.head.repo.full_name == github.repository }} + report-file: client/results.json + comment-title: "🎭 Playwright E2E Test Results" + job-summary: true + icon-style: "emojis" + custom-info: | + **Test Environment:** Ubuntu Latest, Node.js 18 + **Browsers:** Chromium, Firefox + + 📊 [View Detailed HTML Report](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}) (download artifacts) + test-command: "npm run test:e2e" diff --git a/.gitignore b/.gitignore index 60faf653e..8e184de89 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,7 @@ client/tsconfig.app.tsbuildinfo client/tsconfig.node.tsbuildinfo cli/build test-output +client/playwright-report/ +client/results.json +client/test-results/ + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 72502f961..1eeeb8640 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,7 +13,7 @@ Thanks for your interest in contributing! This guide explains how to get involve 1. Create a new branch for your changes 2. Make your changes following existing code style and conventions. You can run `npm run prettier-check` and `npm run prettier-fix` as applicable. -3. Test changes locally by running `npm test` +3. Test changes locally by running `npm test` and `npm run test:e2e` 4. Update documentation as needed 5. Use clear commit messages explaining your changes 6. Verify all changes work as expected diff --git a/client/e2e/global-teardown.js b/client/e2e/global-teardown.js new file mode 100644 index 000000000..9308d940f --- /dev/null +++ b/client/e2e/global-teardown.js @@ -0,0 +1,18 @@ +import { rimraf } from "rimraf"; + +async function globalTeardown() { + if (!process.env.CI) { + console.log("Cleaning up test-results directory..."); + // Add a small delay to ensure all Playwright files are written + await new Promise((resolve) => setTimeout(resolve, 100)); + await rimraf("./e2e/test-results"); + console.log("Test-results directory cleaned up."); + } +} + +export default globalTeardown; + +// Call the function when this script is run directly +if (import.meta.url === `file://${process.argv[1]}`) { + globalTeardown().catch(console.error); +} diff --git a/client/e2e/transport-type-dropdown.spec.ts b/client/e2e/transport-type-dropdown.spec.ts new file mode 100644 index 000000000..e4ac8fbb4 --- /dev/null +++ b/client/e2e/transport-type-dropdown.spec.ts @@ -0,0 +1,113 @@ +import { test, expect } from "@playwright/test"; + +// Adjust the URL if your dev server runs on a different port +const APP_URL = "http://localhost:6274/"; + +test.describe("Transport Type Dropdown", () => { + test("should have options for STDIO, SSE, and Streamable HTTP", async ({ + page, + }) => { + await page.goto(APP_URL); + + // Wait for the Transport Type dropdown to be visible + const selectTrigger = page.getByLabel("Transport Type"); + await expect(selectTrigger).toBeVisible(); + + // Open the dropdown + await selectTrigger.click(); + + // Check for the three options + await expect(page.getByRole("option", { name: "STDIO" })).toBeVisible(); + await expect(page.getByRole("option", { name: "SSE" })).toBeVisible(); + await expect( + page.getByRole("option", { name: "Streamable HTTP" }), + ).toBeVisible(); + }); + + test("should show Command and Arguments fields and hide URL field when Transport Type is STDIO", async ({ + page, + }) => { + await page.goto(APP_URL); + + // Wait for the Transport Type dropdown to be visible + const selectTrigger = page.getByLabel("Transport Type"); + await expect(selectTrigger).toBeVisible(); + + // Open the dropdown and select STDIO + await selectTrigger.click(); + await page.getByRole("option", { name: "STDIO" }).click(); + + // Wait for the form to update + await page.waitForTimeout(100); + + // Check that Command and Arguments fields are visible + await expect(page.locator("#command-input")).toBeVisible(); + await expect(page.locator("#arguments-input")).toBeVisible(); + + // Check that URL field is not visible + await expect(page.locator("#sse-url-input")).not.toBeVisible(); + + // Also verify the labels are present + await expect(page.getByText("Command")).toBeVisible(); + await expect(page.getByText("Arguments")).toBeVisible(); + await expect(page.getByText("URL")).not.toBeVisible(); + }); + + test("should show URL field and hide Command and Arguments fields when Transport Type is SSE", async ({ + page, + }) => { + await page.goto(APP_URL); + + // Wait for the Transport Type dropdown to be visible + const selectTrigger = page.getByLabel("Transport Type"); + await expect(selectTrigger).toBeVisible(); + + // Open the dropdown and select SSE + await selectTrigger.click(); + await page.getByRole("option", { name: "SSE" }).click(); + + // Wait for the form to update + await page.waitForTimeout(100); + + // Check that URL field is visible + await expect(page.locator("#sse-url-input")).toBeVisible(); + + // Check that Command and Arguments fields are not visible + await expect(page.locator("#command-input")).not.toBeVisible(); + await expect(page.locator("#arguments-input")).not.toBeVisible(); + + // Also verify the labels are present/absent + await expect(page.getByText("URL")).toBeVisible(); + await expect(page.getByText("Command")).not.toBeVisible(); + await expect(page.getByText("Arguments")).not.toBeVisible(); + }); + + test("should show URL field and hide Command and Arguments fields when Transport Type is Streamable HTTP", async ({ + page, + }) => { + await page.goto(APP_URL); + + // Wait for the Transport Type dropdown to be visible + const selectTrigger = page.getByLabel("Transport Type"); + await expect(selectTrigger).toBeVisible(); + + // Open the dropdown and select Streamable HTTP + await selectTrigger.click(); + await page.getByRole("option", { name: "Streamable HTTP" }).click(); + + // Wait for the form to update + await page.waitForTimeout(100); + + // Check that URL field is visible + await expect(page.locator("#sse-url-input")).toBeVisible(); + + // Check that Command and Arguments fields are not visible + await expect(page.locator("#command-input")).not.toBeVisible(); + await expect(page.locator("#arguments-input")).not.toBeVisible(); + + // Also verify the labels are present/absent + await expect(page.getByText("URL")).toBeVisible(); + await expect(page.getByText("Command")).not.toBeVisible(); + await expect(page.getByText("Arguments")).not.toBeVisible(); + }); +}); diff --git a/client/jest.config.cjs b/client/jest.config.cjs index b4d81cfc6..8652eda52 100644 --- a/client/jest.config.cjs +++ b/client/jest.config.cjs @@ -21,6 +21,7 @@ module.exports = { "/node_modules/", "/dist/", "/bin/", + "/e2e/", "\\.config\\.(js|ts|cjs|mjs)$", ], // Exclude the same patterns from coverage reports @@ -28,6 +29,7 @@ module.exports = { "/node_modules/", "/dist/", "/bin/", + "/e2e/", "\\.config\\.(js|ts|cjs|mjs)$", ], }; diff --git a/client/package.json b/client/package.json index 449e0637f..12438aeb6 100644 --- a/client/package.json +++ b/client/package.json @@ -20,7 +20,9 @@ "lint": "eslint .", "preview": "vite preview --port 6274", "test": "jest --config jest.config.cjs", - "test:watch": "jest --config jest.config.cjs --watch" + "test:watch": "jest --config jest.config.cjs --watch", + "test:e2e": "playwright test e2e && npm run cleanup:e2e", + "cleanup:e2e": "node e2e/global-teardown.js" }, "dependencies": { "@modelcontextprotocol/sdk": "^1.13.0", diff --git a/client/playwright.config.ts b/client/playwright.config.ts new file mode 100644 index 000000000..570dd054e --- /dev/null +++ b/client/playwright.config.ts @@ -0,0 +1,70 @@ +import { defineConfig, devices } from "@playwright/test"; + +/** + * @see https://playwright.dev/docs/test-configuration + */ +export default defineConfig({ + /* Run your local dev server before starting the tests */ + webServer: { + cwd: "..", + command: "npm run dev", + url: "http://localhost:6274", + reuseExistingServer: !process.env.CI, + }, + + testDir: "./e2e", + outputDir: "./e2e/test-results", + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: process.env.CI + ? [ + ["html", { outputFolder: "playwright-report" }], + ["json", { outputFile: "results.json" }], + ["line"], + ] + : [["line"]], + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: "http://localhost:6274", + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: "on-first-retry", + + /* Take screenshots on failure */ + screenshot: "only-on-failure", + + /* Record video on failure */ + video: "retain-on-failure", + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + + { + name: "firefox", + use: { ...devices["Desktop Firefox"] }, + }, + + // Skip WebKit on macOS due to compatibility issues + ...(process.platform !== "darwin" + ? [ + { + name: "webkit", + use: { ...devices["Desktop Safari"] }, + }, + ] + : []), + ], +}); diff --git a/package-lock.json b/package-lock.json index 695f6a024..0e74be500 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "mcp-inspector": "cli/build/cli.js" }, "devDependencies": { + "@playwright/test": "^1.52.0", "@types/jest": "^29.5.14", "@types/node": "^22.7.5", "@types/shell-quote": "^1.7.5", @@ -2070,6 +2071,22 @@ "node": ">=14" } }, + "node_modules/@playwright/test": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.52.0.tgz", + "integrity": "sha512-uh6W7sb55hl7D6vsAeA+V2p5JnlAqzhqFyF0VcJkKZXkgnFcVG9PziERRHQfPLfNGx1C292a4JqbWzhR8L4R1g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.52.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@radix-ui/number": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", @@ -8519,6 +8536,53 @@ "node": ">=8" } }, + "node_modules/playwright": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.52.0.tgz", + "integrity": "sha512-JAwMNMBlxJ2oD1kce4KPtMkDeKGHQstdpFPcPH3maElAXon/QZeTvtsfXmTMRyO9TslfoYOXkSsvao2nE1ilTw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.52.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.52.0.tgz", + "integrity": "sha512-l2osTgLXSMeuLZOML9qYODUQoPPnUsKsb5/P6LJ2e6uPKXUdPK5WYhN4z03G+YNbWmGDY4YENauNu4ZKczreHg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.3", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", diff --git a/package.json b/package.json index 7b89edb87..8f48f85ab 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "start-client": "cd client && npm run preview", "test": "npm run prettier-check && cd client && npm test", "test-cli": "cd cli && npm run test", + "test:e2e": "MCP_AUTO_OPEN_ENABLED=false npm run test:e2e --workspace=client", "prettier-fix": "prettier --write .", "prettier-check": "prettier --check .", "lint": "prettier --check . && cd client && npm run lint", @@ -55,6 +56,7 @@ "zod": "^3.23.8" }, "devDependencies": { + "@playwright/test": "^1.52.0", "@types/jest": "^29.5.14", "@types/node": "^22.7.5", "@types/shell-quote": "^1.7.5",