Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions .github/workflows/ui-e2e.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
name: UI E2E Tests

on:
push:
paths:
- 'ui/**'
- '.github/workflows/ui-e2e.yml'
pull_request:
paths:
- 'ui/**'
- '.github/workflows/ui-e2e.yml'

permissions:
contents: read

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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
68 changes: 68 additions & 0 deletions ui/README.md
Original file line number Diff line number Diff line change
@@ -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.
172 changes: 172 additions & 0 deletions ui/e2e/fixtures/mcp-mocks.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>): 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);
}
}
Loading
Loading