From 2e152ada6108ffc73d193b01388003225d9f0613 Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Wed, 4 Feb 2026 15:47:10 +0000 Subject: [PATCH 1/3] add e2e tests --- .github/workflows/ui-e2e.yml | 46 ++++++ .gitignore | 4 + ui/README.md | 68 ++++++++ ui/e2e/fixtures/mcp-mocks.ts | 172 ++++++++++++++++++++ ui/e2e/fixtures/test.ts | 259 +++++++++++++++++++++++++++++++ ui/e2e/tests/get-me.spec.ts | 79 ++++++++++ ui/e2e/tests/issue-write.spec.ts | 132 ++++++++++++++++ ui/e2e/tests/pr-write.spec.ts | 121 +++++++++++++++ ui/package-lock.json | 64 ++++++++ ui/package.json | 6 +- ui/playwright.config.ts | 38 +++++ ui/src/hooks/useMcpApp.ts | 3 +- ui/vite.config.ts | 24 +-- 13 files changed, 1003 insertions(+), 13 deletions(-) create mode 100644 .github/workflows/ui-e2e.yml create mode 100644 ui/README.md create mode 100644 ui/e2e/fixtures/mcp-mocks.ts create mode 100644 ui/e2e/fixtures/test.ts create mode 100644 ui/e2e/tests/get-me.spec.ts create mode 100644 ui/e2e/tests/issue-write.spec.ts create mode 100644 ui/e2e/tests/pr-write.spec.ts create mode 100644 ui/playwright.config.ts diff --git a/.github/workflows/ui-e2e.yml b/.github/workflows/ui-e2e.yml new file mode 100644 index 000000000..4162b0c4a --- /dev/null +++ b/.github/workflows/ui-e2e.yml @@ -0,0 +1,46 @@ +name: UI E2E Tests + +on: + push: + branches: [main] + paths: + - 'ui/**' + - '.github/workflows/ui-e2e.yml' + pull_request: + branches: [main] + paths: + - 'ui/**' + - '.github/workflows/ui-e2e.yml' + +jobs: + test: + name: Playwright E2E Tests + runs-on: ubuntu-latest + defaults: + run: + working-directory: ui + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: ui/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Install Playwright Browsers + run: npx playwright install --with-deps chromium + + - name: Run Playwright tests + run: npm run test:e2e + + - uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: playwright-report + path: ui/playwright-report/ + retention-days: 7 diff --git a/.gitignore b/.gitignore index 8d5d8b7ea..df77d11ea 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,10 @@ conformance-report/ ui/dist/ ui/node_modules/ +# Playwright test artifacts +ui/playwright-report/ +ui/test-results/ + # Embedded UI assets (built from ui/) pkg/github/ui_dist/* !pkg/github/ui_dist/.gitkeep diff --git a/ui/README.md b/ui/README.md new file mode 100644 index 000000000..08d6d3695 --- /dev/null +++ b/ui/README.md @@ -0,0 +1,68 @@ +# MCP Server UI + +React-based UI apps for the GitHub MCP Server, built with [Vite](https://vitejs.dev/) and [Primer React](https://primer.style/react). + +## Apps + +- **get-me** - Displays current GitHub user profile card +- **issue-write** - Create/edit GitHub issues with rich markdown editor +- **pr-write** - Create pull requests with full form controls + +## Development + +```bash +# Install dependencies +npm install + +# Build all apps +npm run build + +# Type check +npm run typecheck +``` + +## Testing + +The UI apps have E2E tests using [Playwright](https://playwright.dev/) with mocked MCP communication. + +```bash +# Run all E2E tests +npm run test:e2e + +# Run tests with UI mode (interactive) +npm run test:e2e:ui + +# View test report +npm run test:e2e:report +``` + +### Test Structure + +``` +e2e/ +├── fixtures/ +│ ├── test.ts # Extended test fixture with MCP mocking +│ └── mcp-mocks.ts # Mock data for MCP tool responses +└── tests/ + ├── get-me.spec.ts + ├── issue-write.spec.ts + └── pr-write.spec.ts +``` + +### How Mocking Works + +The tests mock the MCP ext-apps communication layer by intercepting `window.parent.postMessage` calls. This allows testing the UI components without a real MCP host: + +1. Tests use `gotoApp()` fixture to navigate to an app with mocks injected +2. Mock responses are defined in `mcp-mocks.ts` +3. The fixture intercepts JSON-RPC messages and responds with appropriate mock data + +## Building for Production + +Apps are built as single-file HTML bundles that embed all CSS and JS: + +```bash +npm run build +``` + +Output is placed in `dist/` directory with each app as a standalone HTML file. diff --git a/ui/e2e/fixtures/mcp-mocks.ts b/ui/e2e/fixtures/mcp-mocks.ts new file mode 100644 index 000000000..7471284ae --- /dev/null +++ b/ui/e2e/fixtures/mcp-mocks.ts @@ -0,0 +1,172 @@ +/** + * Mock MCP responses for E2E testing. + * These mocks simulate responses from the MCP server. + */ + +export interface MockUser { + login: string; + avatar_url?: string; + details?: { + name?: string; + company?: string; + location?: string; + blog?: string; + email?: string; + public_repos?: number; + followers?: number; + following?: number; + }; +} + +export interface MockRepository { + id: number; + owner: { login: string }; + name: string; + full_name: string; + private: boolean; +} + +export interface MockBranch { + name: string; + protected: boolean; +} + +export interface MockLabel { + id: string; + name: string; + color: string; +} + +export interface MockPullRequest { + id: number; + number: number; + title: string; + html_url: string; +} + +export interface MockIssue { + id: number; + number: number; + title: string; + html_url: string; +} + +// Sample mock data + +export const mockUser: MockUser = { + login: "octocat", + avatar_url: "https://avatars.githubusercontent.com/u/583231?v=4", + details: { + name: "The Octocat", + company: "@github", + location: "San Francisco", + blog: "https://github.blog", + email: "octocat@github.com", + public_repos: 8, + followers: 1000, + following: 9, + }, +}; + +export const mockRepositories: MockRepository[] = [ + { + id: 1, + owner: { login: "octocat" }, + name: "hello-world", + full_name: "octocat/hello-world", + private: false, + }, + { + id: 2, + owner: { login: "octocat" }, + name: "private-repo", + full_name: "octocat/private-repo", + private: true, + }, +]; + +export const mockBranches: MockBranch[] = [ + { name: "main", protected: true }, + { name: "develop", protected: false }, + { name: "feature/test", protected: false }, +]; + +export const mockLabels: MockLabel[] = [ + { id: "1", name: "bug", color: "d73a4a" }, + { id: "2", name: "enhancement", color: "a2eeef" }, + { id: "3", name: "documentation", color: "0075ca" }, +]; + +export const mockAssignees = [ + { login: "octocat" }, + { login: "hubot" }, +]; + +export const mockMilestones = [ + { number: 1, title: "v1.0", description: "First release" }, + { number: 2, title: "v2.0", description: "Second release" }, +]; + +export const mockCreatedPR: MockPullRequest = { + id: 1, + number: 42, + title: "Test PR", + html_url: "https://github.com/octocat/hello-world/pull/42", +}; + +export const mockCreatedIssue: MockIssue = { + id: 1, + number: 123, + title: "Test Issue", + html_url: "https://github.com/octocat/hello-world/issues/123", +}; + +/** + * Create a mock MCP tool result response + */ +export function createToolResult(data: unknown, isError = false) { + return { + content: [{ type: "text", text: JSON.stringify(data) }], + isError, + }; +} + +/** + * Map of tool names to their mock responses + */ +export function getMockResponse(toolName: string, args?: Record): unknown { + switch (toolName) { + case "get_me": + return createToolResult(mockUser); + + case "search_repositories": + return createToolResult({ repositories: mockRepositories }); + + case "list_branches": + return createToolResult({ branches: mockBranches }); + + case "list_label": + return createToolResult({ labels: mockLabels }); + + case "list_assignees": + return createToolResult({ assignees: mockAssignees }); + + case "list_milestones": + return createToolResult({ milestones: mockMilestones }); + + case "create_pull_request": + return createToolResult({ + ...mockCreatedPR, + title: args?.title || mockCreatedPR.title, + }); + + case "create_issue": + return createToolResult({ + ...mockCreatedIssue, + title: args?.title || mockCreatedIssue.title, + }); + + default: + return createToolResult({ error: `Unknown tool: ${toolName}` }, true); + } +} diff --git a/ui/e2e/fixtures/test.ts b/ui/e2e/fixtures/test.ts new file mode 100644 index 000000000..3c7333c99 --- /dev/null +++ b/ui/e2e/fixtures/test.ts @@ -0,0 +1,259 @@ +import { test as base } from "@playwright/test"; +import { + mockUser, + mockBranches, + mockLabels, + mockAssignees, + mockMilestones, + mockRepositories, + mockCreatedPR, + mockCreatedIssue, + MockUser, +} from "./mcp-mocks"; + +/** + * Extended test fixture that provides MCP mocking capabilities. + * + * The MCP ext-apps library uses PostMessageTransport which communicates via + * window.postMessage. We intercept outgoing messages and respond with mock data. + */ + +interface McpFixtures { + /** + * Navigate to an app with mocked MCP communication + */ + gotoApp: (appName: "get-me" | "issue-write" | "pr-write", options?: GotoAppOptions) => Promise; +} + +interface MockOverrides { + user?: MockUser; + simulateError?: boolean; + errorMessage?: string; + toolInput?: Record; +} + +interface GotoAppOptions { + mocks?: MockOverrides; +} + +export const test = base.extend({ + gotoApp: async ({ page }, use) => { + const gotoApp = async ( + appName: "get-me" | "issue-write" | "pr-write", + options: GotoAppOptions = {} + ) => { + const mocks = options.mocks || {}; + + // Prepare mock data to inject + const mockData = { + user: mocks.user || mockUser, + branches: mockBranches, + labels: mockLabels, + assignees: mockAssignees, + milestones: mockMilestones, + repositories: mockRepositories, + createdPR: mockCreatedPR, + createdIssue: mockCreatedIssue, + simulateError: mocks.simulateError || false, + errorMessage: mocks.errorMessage || "Simulated error", + toolInput: mocks.toolInput || {}, + appName, + }; + + // Inject script that intercepts postMessage before the app loads + await page.addInitScript((data) => { + // Store mock data globally for debugging + (window as unknown as Record).__mcpMockData = data; + + // Create mock tool result helper + const createToolResult = (content: unknown, isError = false) => ({ + content: [{ type: "text", text: JSON.stringify(content) }], + isError, + }); + + // Mock tool responses + const getMockToolResponse = (toolName: string, args?: Record) => { + if (data.simulateError) { + return createToolResult({ error: data.errorMessage }, true); + } + + switch (toolName) { + case "get_me": + return createToolResult(data.user); + case "search_repositories": + return createToolResult({ repositories: data.repositories }); + case "list_branches": + return createToolResult({ branches: data.branches }); + case "list_label": + return createToolResult({ labels: data.labels }); + case "list_assignees": + return createToolResult({ assignees: data.assignees }); + case "list_milestones": + return createToolResult({ milestones: data.milestones }); + case "list_issue_types": + return createToolResult({ issueTypes: [] }); + case "create_pull_request": + return createToolResult({ ...data.createdPR, title: args?.title || data.createdPR.title }); + case "create_issue": + case "update_issue": + return createToolResult({ ...data.createdIssue, title: args?.title || data.createdIssue.title }); + default: + return createToolResult({ message: `Mock response for ${toolName}` }); + } + }; + + // Handle JSON-RPC requests + const handleRequest = (message: { id?: number | string; method?: string; params?: unknown }) => { + const { id, method, params } = message; + + // Handle ui/initialize request + if (method === "ui/initialize") { + return { + jsonrpc: "2.0", + id, + result: { + protocolVersion: "2026-01-26", + hostInfo: { name: "mock-host", version: "1.0.0" }, + hostCapabilities: { + serverTools: { listChanged: false }, + logging: {}, + }, + hostContext: { + theme: "light", + }, + }, + }; + } + + // Handle ping + if (method === "ping") { + return { jsonrpc: "2.0", id, result: {} }; + } + + // Handle ui/notifications/initialized (this is a notification, no response needed) + if (method === "ui/notifications/initialized") { + return null; + } + + // Handle tools/call + if (method === "tools/call") { + const toolParams = params as { name?: string; arguments?: Record }; + const response = getMockToolResponse(toolParams.name || "", toolParams.arguments); + return { + jsonrpc: "2.0", + id, + result: response, + }; + } + + // Default response for unknown methods + console.warn("[MCP Mock] Unknown method:", method); + return { + jsonrpc: "2.0", + id, + error: { code: -32601, message: `Method not found: ${method}` }, + }; + }; + + // Store the real window.parent + const realParent = window.parent; + + // Create a proxy for window.parent.postMessage + const originalPostMessage = realParent.postMessage.bind(realParent); + + // We need to mock the parent's postMessage + // The transport sends to window.parent and listens on window for responses + // We intercept the send and dispatch a response back to window + + // Create our mock postMessage function + const mockPostMessage = function(message: unknown, targetOrigin: string) { + const msg = message as { jsonrpc?: string; id?: number | string; method?: string; params?: unknown }; + + // Only intercept JSON-RPC messages + if (msg.jsonrpc === "2.0" && msg.method) { + console.debug("[MCP Mock] Received request:", msg.method, msg); + + // Handle the request + const response = handleRequest(msg); + + if (response) { + console.debug("[MCP Mock] Sending response:", response); + + // Dispatch response back to the app's window + // The transport listens on `window` for message events + setTimeout(() => { + // Create a MessageEvent that the transport will accept + // The source check is against window.parent, so we need to trick it + const event = new MessageEvent("message", { + data: response, + origin: window.location.origin, + }); + // Override the source getter to return realParent + Object.defineProperty(event, "source", { + get: () => realParent, + }); + window.dispatchEvent(event); + + // After initialization, send tool input if provided + if (msg.method === "ui/initialize" && Object.keys(data.toolInput).length > 0) { + setTimeout(() => { + const inputEvent = new MessageEvent("message", { + data: { + jsonrpc: "2.0", + method: "ui/notifications/tool-input", + params: { arguments: data.toolInput }, + }, + origin: window.location.origin, + }); + Object.defineProperty(inputEvent, "source", { + get: () => realParent, + }); + window.dispatchEvent(inputEvent); + }, 50); + } + + // For get-me app, send tool result after initialization + if (msg.method === "ui/initialize" && data.appName === "get-me") { + setTimeout(() => { + const resultEvent = new MessageEvent("message", { + data: { + jsonrpc: "2.0", + method: "ui/notifications/tool-result", + params: getMockToolResponse("get_me"), + }, + origin: window.location.origin, + }); + Object.defineProperty(resultEvent, "source", { + get: () => realParent, + }); + window.dispatchEvent(resultEvent); + }, 100); + } + }, 10); + return; + } + } + + // Forward non-MCP messages (shouldn't happen in practice) + originalPostMessage(message, targetOrigin); + }; + + // Replace postMessage on the real parent + realParent.postMessage = mockPostMessage as typeof realParent.postMessage; + }, mockData); + + // Navigate to the app + await page.goto(`/${appName}/index.html`); + + // Wait for React to render + await page.waitForLoadState("domcontentloaded"); + + // Give React time to render with mock data + await page.waitForTimeout(500); + }; + + await use(gotoApp); + }, +}); + +export { expect } from "@playwright/test"; diff --git a/ui/e2e/tests/get-me.spec.ts b/ui/e2e/tests/get-me.spec.ts new file mode 100644 index 000000000..d6280a49b --- /dev/null +++ b/ui/e2e/tests/get-me.spec.ts @@ -0,0 +1,79 @@ +import { test, expect } from "../fixtures/test"; +import { mockUser } from "../fixtures/mcp-mocks"; + +test.describe("get-me app", () => { + test("displays loading state or connects successfully", async ({ page }) => { + // Navigate without mocks - app will try to connect to real host + await page.goto("/get-me/index.html"); + + // Should either show loading state or an error (when no host is available) + // This verifies the app loads and attempts to connect + await expect( + page.getByText("Loading user data...").or(page.getByText(/error/i)) + ).toBeVisible({ timeout: 5000 }); + }); + + test("renders user card with profile data", async ({ gotoApp, page }) => { + await gotoApp("get-me"); + + // Should display user's name + await expect(page.getByRole("heading", { name: mockUser.details?.name })).toBeVisible(); + + // Should display username + await expect(page.getByText(`@${mockUser.login}`)).toBeVisible(); + + // Should display company (use exact match to avoid matching email) + await expect(page.getByText(mockUser.details?.company!, { exact: true })).toBeVisible(); + + // Should display location + await expect(page.getByText(mockUser.details?.location!)).toBeVisible(); + }); + + test("displays user stats correctly", async ({ gotoApp, page }) => { + await gotoApp("get-me"); + + // Should show repos count + await expect(page.getByText("Repos")).toBeVisible(); + await expect(page.getByText(String(mockUser.details?.public_repos))).toBeVisible(); + + // Should show followers count + await expect(page.getByText("Followers")).toBeVisible(); + await expect(page.getByText(String(mockUser.details?.followers))).toBeVisible(); + + // Should show following count + await expect(page.getByText("Following")).toBeVisible(); + await expect(page.getByText(String(mockUser.details?.following))).toBeVisible(); + }); + + test("shows error state when MCP fails", async ({ page }) => { + // Navigate without mocks - the connection will fail to a non-existent host + await page.goto("/get-me/index.html"); + + // Wait a bit for the connection timeout + await page.waitForTimeout(2000); + + // Should display error message (connection error) + await expect(page.getByText(/error/i)).toBeVisible({ timeout: 10000 }); + }); + + test("displays avatar with fallback on error", async ({ gotoApp, page }) => { + // Test with user that has no avatar + await gotoApp("get-me", { + mocks: { + user: { ...mockUser, avatar_url: undefined }, + }, + }); + + // Should show fallback icon (PersonIcon renders in a Box) + // The fallback renders when avatar_url is undefined + await expect(page.getByRole("heading", { name: mockUser.details?.name })).toBeVisible(); + }); + + test("links open in new tab", async ({ gotoApp, page }) => { + await gotoApp("get-me"); + + // Blog link should have target="_blank" + const blogLink = page.getByRole("link", { name: mockUser.details?.blog }); + await expect(blogLink).toHaveAttribute("target", "_blank"); + }); +}); diff --git a/ui/e2e/tests/issue-write.spec.ts b/ui/e2e/tests/issue-write.spec.ts new file mode 100644 index 000000000..9611b2279 --- /dev/null +++ b/ui/e2e/tests/issue-write.spec.ts @@ -0,0 +1,132 @@ +import { test, expect } from "../fixtures/test"; + +test.describe("issue-write app", () => { + test("renders form with all required fields", async ({ gotoApp, page }) => { + await gotoApp("issue-write", { + mocks: { + toolInput: { owner: "octocat", repo: "hello-world" }, + }, + }); + + // Title input should be visible + await expect(page.getByPlaceholder("Title")).toBeVisible(); + + // Description label should be visible + await expect(page.getByText("Description")).toBeVisible(); + + // Submit button should be visible + await expect(page.getByRole("button", { name: /create issue/i })).toBeVisible(); + }); + + test("shows loading spinner when initializing", async ({ page }) => { + // Don't use gotoApp - navigate directly so mocks aren't applied + // This will cause the app to try connecting and show loading state + await page.goto("/issue-write/index.html"); + + // Should show spinner while connecting (might be brief) + // Just verify the page loads without immediate error + await page.waitForLoadState("domcontentloaded"); + }); + + test("pre-fills form from tool input", async ({ gotoApp, page }) => { + await gotoApp("issue-write", { + mocks: { + toolInput: { + owner: "octocat", + repo: "hello-world", + title: "Bug: Something is broken", + body: "## Description\n\nThis is a test issue.", + }, + }, + }); + + // Title should be pre-filled + await expect(page.getByPlaceholder("Title")).toHaveValue("Bug: Something is broken"); + }); + + test("displays markdown editor with write/preview buttons", async ({ gotoApp, page }) => { + await gotoApp("issue-write", { + mocks: { + toolInput: { owner: "octocat", repo: "hello-world" }, + }, + }); + + // Should have Write button + await expect(page.getByRole("button", { name: "Write" })).toBeVisible(); + + // Should have Preview button + await expect(page.getByRole("button", { name: "Preview" })).toBeVisible(); + }); + + test("markdown preview renders content", async ({ gotoApp, page }) => { + await gotoApp("issue-write", { + mocks: { + toolInput: { + owner: "octocat", + repo: "hello-world", + body: "## Test Header\n\nSome **bold** text.", + }, + }, + }); + + // Click Preview button + await page.getByRole("button", { name: "Preview" }).click(); + + // Should render markdown as HTML (heading) + await expect(page.getByRole("heading", { name: "Test Header" })).toBeVisible(); + }); + + test("submit button is disabled when title is empty", async ({ gotoApp, page }) => { + await gotoApp("issue-write", { + mocks: { + toolInput: { owner: "octocat", repo: "hello-world" }, + }, + }); + + // Submit button should be disabled when title is empty + const submitButton = page.getByRole("button", { name: /create issue/i }); + await expect(submitButton).toBeDisabled(); + + // Fill in title + await page.getByPlaceholder("Title").fill("Test Issue"); + + // Button should now be enabled + await expect(submitButton).toBeEnabled(); + }); + + test("displays metadata buttons", async ({ gotoApp, page }) => { + await gotoApp("issue-write", { + mocks: { + toolInput: { owner: "octocat", repo: "hello-world" }, + }, + }); + + // Should have Assignees button + await expect(page.getByRole("button", { name: /assignees/i })).toBeVisible(); + + // Should have Labels button + await expect(page.getByRole("button", { name: /labels/i })).toBeVisible(); + + // Should have Milestone button + await expect(page.getByRole("button", { name: /milestone/i })).toBeVisible(); + }); + + test("displays repository picker", async ({ gotoApp, page }) => { + await gotoApp("issue-write"); + + // Repository button should be visible + const repoButton = page.getByRole("button", { name: /select repository/i }); + await expect(repoButton).toBeVisible(); + }); + + test("shows selected repo when provided via toolInput", async ({ gotoApp, page }) => { + await gotoApp("issue-write", { + mocks: { + toolInput: { owner: "octocat", repo: "hello-world" }, + }, + }); + + // Should show the repo name + await expect(page.getByText("octocat/hello-world")).toBeVisible(); + }); +}); diff --git a/ui/e2e/tests/pr-write.spec.ts b/ui/e2e/tests/pr-write.spec.ts new file mode 100644 index 000000000..a32e12429 --- /dev/null +++ b/ui/e2e/tests/pr-write.spec.ts @@ -0,0 +1,121 @@ +import { test, expect } from "../fixtures/test"; + +test.describe("pr-write app", () => { + test("renders form with all required fields", async ({ gotoApp, page }) => { + await gotoApp("pr-write", { + mocks: { + toolInput: { owner: "octocat", repo: "hello-world" }, + }, + }); + + // Title input should be visible + await expect(page.getByPlaceholder("Title")).toBeVisible(); + + // Description section should be visible + await expect(page.getByText("Description")).toBeVisible(); + + // Branch selectors should be visible + await expect(page.getByText("base")).toBeVisible(); + await expect(page.getByText("compare")).toBeVisible(); + + // Create button should be visible + await expect(page.getByRole("button", { name: /create.*pull request/i })).toBeVisible(); + }); + + test("shows loading spinner when initializing", async ({ page }) => { + // Don't use gotoApp - navigate directly so mocks aren't applied + await page.goto("/pr-write/index.html"); + + // Should show spinner while connecting (might be brief) + // Just verify the page loads without immediate error + await page.waitForLoadState("domcontentloaded"); + }); + + test("displays repository picker", async ({ gotoApp, page }) => { + await gotoApp("pr-write"); + + // Repository button should be visible + const repoButton = page.getByRole("button", { name: /select repository/i }); + await expect(repoButton).toBeVisible(); + }); + + test("pre-fills form from tool input", async ({ gotoApp, page }) => { + await gotoApp("pr-write", { + mocks: { + toolInput: { + owner: "octocat", + repo: "hello-world", + title: "Test PR Title", + body: "Test PR description", + head: "feature/test", + base: "main", + draft: true, + }, + }, + }); + + // Title should be pre-filled + await expect(page.getByPlaceholder("Title")).toHaveValue("Test PR Title"); + + // Draft checkbox should be checked + const draftCheckbox = page.getByRole("checkbox", { name: /draft/i }); + await expect(draftCheckbox).toBeChecked(); + }); + + test("submit button is disabled when required fields are empty", async ({ gotoApp, page }) => { + await gotoApp("pr-write", { + mocks: { + toolInput: { owner: "octocat", repo: "hello-world" }, + }, + }); + + // Submit button should be disabled when required fields are missing + const submitButton = page.getByRole("button", { name: /create.*pull request/i }); + await expect(submitButton).toBeDisabled(); + }); + + test("draft toggle changes button text", async ({ gotoApp, page }) => { + await gotoApp("pr-write", { + mocks: { + toolInput: { owner: "octocat", repo: "hello-world" }, + }, + }); + + // Initially should say "Create pull request" + await expect(page.getByRole("button", { name: "Create pull request" })).toBeVisible(); + + // Check draft checkbox + await page.getByRole("checkbox", { name: /draft/i }).check(); + + // Button should now say "Create draft pull request" + await expect(page.getByRole("button", { name: "Create draft pull request" })).toBeVisible(); + }); + + test("maintainer edit checkbox is checked by default", async ({ gotoApp, page }) => { + await gotoApp("pr-write", { + mocks: { + toolInput: { owner: "octocat", repo: "hello-world" }, + }, + }); + + const maintainerCheckbox = page.getByRole("checkbox", { name: /maintainer/i }); + await expect(maintainerCheckbox).toBeChecked(); + }); + + test("displays metadata buttons", async ({ gotoApp, page }) => { + await gotoApp("pr-write", { + mocks: { + toolInput: { owner: "octocat", repo: "hello-world" }, + }, + }); + + // Should have Reviewers button + await expect(page.getByRole("button", { name: /reviewers/i })).toBeVisible(); + + // Should have Labels button + await expect(page.getByRole("button", { name: /labels/i })).toBeVisible(); + + // Should have Milestone button + await expect(page.getByRole("button", { name: /milestone/i })).toBeVisible(); + }); +}); diff --git a/ui/package-lock.json b/ui/package-lock.json index 52963cb9c..960568696 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -18,6 +18,7 @@ "remark-gfm": "^4.0.1" }, "devDependencies": { + "@playwright/test": "^1.58.1", "@types/node": "^25.2.0", "@types/react": "^18.0.0", "@types/react-dom": "^18.0.0", @@ -1136,6 +1137,22 @@ "win32" ] }, + "node_modules/@playwright/test": { + "version": "1.58.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.1.tgz", + "integrity": "sha512-6LdVIUERWxQMmUSSQi0I53GgCBYgM2RpGngCPY7hSeju+VrKjq3lvs7HpJoPbDiY5QM5EYRtRX5fvrinnMAz3w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.58.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@primer/behaviors": { "version": "1.10.1", "resolved": "https://registry.npmjs.org/@primer/behaviors/-/behaviors-1.10.1.tgz", @@ -5155,6 +5172,53 @@ "node": ">=16.20.0" } }, + "node_modules/playwright": { + "version": "1.58.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.1.tgz", + "integrity": "sha512-+2uTZHxSCcxjvGc5C891LrS1/NlxglGxzrC4seZiVjcYVQfUa87wBL6rTDqzGjuoWNjnBzRqKmF6zRYGMvQUaQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.1.tgz", + "integrity": "sha512-bcWzOaTxcW+VOOGBCQgnaKToLJ65d6AqfLVKEWvexyS3AS6rbXl+xdpYRMGSRBClPvyj44njOWoxjNdL/H9UNg==", + "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.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", diff --git a/ui/package.json b/ui/package.json index 6b26ca316..bb5cd98b8 100644 --- a/ui/package.json +++ b/ui/package.json @@ -11,7 +11,10 @@ "build:pr-write": "cross-env APP=pr-write vite build", "dev": "npm run build", "typecheck": "tsc --noEmit", - "clean": "rm -rf dist" + "clean": "rm -rf dist", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "test:e2e:report": "playwright show-report" }, "dependencies": { "@github/markdown-toolbar-element": "^2.2.3", @@ -24,6 +27,7 @@ "remark-gfm": "^4.0.1" }, "devDependencies": { + "@playwright/test": "^1.58.1", "@types/node": "^25.2.0", "@types/react": "^18.0.0", "@types/react-dom": "^18.0.0", diff --git a/ui/playwright.config.ts b/ui/playwright.config.ts new file mode 100644 index 000000000..7a199301c --- /dev/null +++ b/ui/playwright.config.ts @@ -0,0 +1,38 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./e2e", + testMatch: "**/*.spec.ts", + + timeout: 20_000, + expect: { timeout: 5_000 }, + + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + + reporter: [ + ["list"], + ["html", { open: "never" }], + ], + + use: { + baseURL: "http://localhost:5173", + trace: "on-first-retry", + screenshot: "only-on-failure", + }, + + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], + + webServer: { + command: "npx vite --port 5173", + port: 5173, + reuseExistingServer: !process.env.CI, + }, +}); diff --git a/ui/src/hooks/useMcpApp.ts b/ui/src/hooks/useMcpApp.ts index 62ee27f6d..05798f508 100644 --- a/ui/src/hooks/useMcpApp.ts +++ b/ui/src/hooks/useMcpApp.ts @@ -1,5 +1,6 @@ import { useApp as useExtApp } from "@modelcontextprotocol/ext-apps/react"; -import type { App, CallToolResult } from "@modelcontextprotocol/ext-apps"; +import type { App } from "@modelcontextprotocol/ext-apps"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { useState, useCallback } from "react"; interface UseMcpAppOptions { diff --git a/ui/vite.config.ts b/ui/vite.config.ts index 12969db04..b575f290a 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -6,17 +6,19 @@ import { resolve } from "path"; // Get the app to build from environment variable const app = process.env.APP; -if (!app) { - throw new Error("APP environment variable must be set"); -} +// In dev mode (no APP specified), serve all apps +const isDev = !app; export default defineConfig({ - plugins: [react(), viteSingleFile()], - build: { - outDir: "dist", - emptyOutDir: false, - rollupOptions: { - input: resolve(__dirname, `src/apps/${app}/index.html`), - }, - }, + plugins: isDev ? [react()] : [react(), viteSingleFile()], + root: isDev ? resolve(__dirname, "src/apps") : undefined, + build: isDev + ? {} + : { + outDir: "dist", + emptyOutDir: false, + rollupOptions: { + input: resolve(__dirname, `src/apps/${app}/index.html`), + }, + }, }); From f69fc4b01c9e92d5181f5e11594c036661af6dc2 Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Wed, 4 Feb 2026 15:48:29 +0000 Subject: [PATCH 2/3] Potential fix for code scanning alert no. 13: Workflow does not contain permissions Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- .github/workflows/ui-e2e.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ui-e2e.yml b/.github/workflows/ui-e2e.yml index 4162b0c4a..655701382 100644 --- a/.github/workflows/ui-e2e.yml +++ b/.github/workflows/ui-e2e.yml @@ -12,6 +12,9 @@ on: - 'ui/**' - '.github/workflows/ui-e2e.yml' +permissions: + contents: read + jobs: test: name: Playwright E2E Tests From 6d227cc478b4bffc70fc2202921a67bcdc7e3d2e Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Wed, 4 Feb 2026 15:50:14 +0000 Subject: [PATCH 3/3] Update UI E2E workflow to remove branch restrictions Removed branch restrictions for push and pull_request events. --- .github/workflows/ui-e2e.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/ui-e2e.yml b/.github/workflows/ui-e2e.yml index 655701382..f2a3f4179 100644 --- a/.github/workflows/ui-e2e.yml +++ b/.github/workflows/ui-e2e.yml @@ -2,12 +2,10 @@ name: UI E2E Tests on: push: - branches: [main] paths: - 'ui/**' - '.github/workflows/ui-e2e.yml' pull_request: - branches: [main] paths: - 'ui/**' - '.github/workflows/ui-e2e.yml'