Skip to content

Commit 70c1215

Browse files
ochafikclaude
andcommitted
Add Playwright E2E tests with screenshot golden testing
- Add E2E tests for all 8 MCP server examples - Screenshot golden images for visual regression testing - CI workflow for running E2E tests - npm scripts: test:e2e, test:e2e:update, test:e2e:ui 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent 03cf620 commit 70c1215

15 files changed

+235
-2
lines changed

.github/workflows/ci.yml

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,32 @@ jobs:
3030
- run: npm test
3131

3232
- run: npm run prettier
33+
34+
e2e:
35+
runs-on: ubuntu-latest
36+
steps:
37+
- uses: actions/checkout@v4
38+
39+
- uses: oven-sh/setup-bun@v2
40+
with:
41+
bun-version: latest
42+
43+
- uses: actions/setup-node@v4
44+
with:
45+
node-version: "20"
46+
47+
- run: npm install
48+
49+
- name: Install Playwright browsers
50+
run: npx playwright install --with-deps chromium
51+
52+
- name: Run E2E tests
53+
run: npm run test:e2e
54+
55+
- name: Upload Playwright report
56+
uses: actions/upload-artifact@v4
57+
if: ${{ !cancelled() }}
58+
with:
59+
name: playwright-report
60+
path: playwright-report/
61+
retention-days: 7

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,8 @@ bun.lockb
66
.vscode/
77
docs/api/
88
tmp/
9+
intermediate-findings/
10+
11+
# Playwright
12+
playwright-report/
13+
test-results/

package-lock.json

Lines changed: 64 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@
3535
"prepack": "npm run build",
3636
"build:all": "npm run build && npm run examples:build",
3737
"test": "bun test",
38+
"test:e2e": "playwright test",
39+
"test:e2e:update": "playwright test --update-snapshots",
40+
"test:e2e:ui": "playwright test --ui",
3841
"examples:build": "bun examples/run-all.ts build",
3942
"examples:start": "NODE_ENV=development npm run build && bun examples/run-all.ts start",
4043
"examples:dev": "NODE_ENV=development bun examples/run-all.ts dev",
@@ -48,10 +51,11 @@
4851
},
4952
"author": "Olivier Chafik",
5053
"devDependencies": {
54+
"@playwright/test": "^1.52.0",
5155
"@types/bun": "^1.3.2",
52-
"bun": "^1.3.2",
5356
"@types/react": "^19.2.2",
5457
"@types/react-dom": "^19.2.2",
58+
"bun": "^1.3.2",
5559
"concurrently": "^9.2.1",
5660
"cors": "^2.8.5",
5761
"esbuild": "^0.25.12",
@@ -71,8 +75,8 @@
7175
"optionalDependencies": {
7276
"@rollup/rollup-darwin-arm64": "^4.53.3",
7377
"@rollup/rollup-darwin-x64": "^4.53.3",
74-
"@rollup/rollup-linux-x64-gnu": "^4.53.3",
7578
"@rollup/rollup-linux-arm64-gnu": "^4.53.3",
79+
"@rollup/rollup-linux-x64-gnu": "^4.53.3",
7680
"@rollup/rollup-win32-x64-msvc": "^4.53.3"
7781
}
7882
}

playwright.config.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { defineConfig, devices } from "@playwright/test";
2+
3+
export default defineConfig({
4+
testDir: "./tests/e2e",
5+
fullyParallel: false, // Run tests sequentially to share server
6+
forbidOnly: !!process.env.CI,
7+
retries: process.env.CI ? 2 : 0,
8+
workers: 1, // Single worker since we share the server
9+
reporter: "html",
10+
use: {
11+
baseURL: "http://localhost:8080",
12+
trace: "on-first-retry",
13+
screenshot: "only-on-failure",
14+
},
15+
projects: [
16+
{
17+
name: "chromium",
18+
use: {
19+
...devices["Desktop Chrome"],
20+
launchOptions: {
21+
// Use system Chrome on macOS for stability, default chromium in CI
22+
...(process.platform === "darwin" ? { channel: "chrome" } : {}),
23+
},
24+
},
25+
},
26+
],
27+
// Run examples server before tests
28+
webServer: {
29+
command: "npm run examples:start",
30+
url: "http://localhost:8080",
31+
reuseExistingServer: !process.env.CI,
32+
timeout: 120000,
33+
},
34+
// Snapshot configuration
35+
expect: {
36+
toHaveScreenshot: {
37+
// Allow 2% pixel difference for dynamic content (timestamps, etc.)
38+
maxDiffPixelRatio: 0.02,
39+
// Animation stabilization
40+
animations: "disabled",
41+
},
42+
},
43+
});

tests/e2e/servers.spec.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { test, expect } from "@playwright/test";
2+
3+
// Server configurations
4+
const SERVERS = [
5+
{ key: "basic-react", index: 0, name: "Basic MCP App Server (React-based)" },
6+
{
7+
key: "basic-vanillajs",
8+
index: 1,
9+
name: "Basic MCP App Server (Vanilla JS)",
10+
},
11+
{ key: "budget-allocator", index: 2, name: "Budget Allocator Server" },
12+
{ key: "cohort-heatmap", index: 3, name: "Cohort Heatmap Server" },
13+
{
14+
key: "customer-segmentation",
15+
index: 4,
16+
name: "Customer Segmentation Server",
17+
},
18+
{ key: "scenario-modeler", index: 5, name: "SaaS Scenario Modeler" },
19+
{ key: "system-monitor", index: 6, name: "System Monitor Server" },
20+
{ key: "threejs", index: 7, name: "Three.js Server" },
21+
];
22+
23+
// Increase timeout for iframe-heavy tests
24+
test.setTimeout(90000);
25+
26+
test.describe("Host UI", () => {
27+
test("initial state shows controls", async ({ page }) => {
28+
await page.goto("/");
29+
await expect(page.locator("label:has-text('Server')")).toBeVisible();
30+
await expect(page.locator("label:has-text('Tool')")).toBeVisible();
31+
await expect(page.locator('button:has-text("Call Tool")')).toBeVisible();
32+
});
33+
34+
test("screenshot of initial state", async ({ page }) => {
35+
await page.goto("/");
36+
await page.waitForTimeout(1000);
37+
await expect(page).toHaveScreenshot("host-initial.png");
38+
});
39+
});
40+
41+
// Generate tests for each server
42+
for (const server of SERVERS) {
43+
test.describe(`${server.name}`, () => {
44+
test(`loads app UI`, async ({ page }) => {
45+
await page.goto("/");
46+
47+
// Select server
48+
const serverSelect = page.locator("select").first();
49+
await serverSelect.selectOption({ index: server.index });
50+
51+
// Click Call Tool
52+
await page.click('button:has-text("Call Tool")');
53+
54+
// Wait for outer iframe
55+
await page.waitForSelector("iframe", { timeout: 10000 });
56+
57+
// Wait for content to load (generous timeout for nested iframes)
58+
await page.waitForTimeout(5000);
59+
60+
// Verify iframe structure exists
61+
const outerFrame = page.frameLocator("iframe").first();
62+
await expect(outerFrame.locator("iframe")).toBeVisible({
63+
timeout: 10000,
64+
});
65+
});
66+
67+
test(`screenshot matches golden`, async ({ page }) => {
68+
await page.goto("/");
69+
70+
// Select server
71+
const serverSelect = page.locator("select").first();
72+
await serverSelect.selectOption({ index: server.index });
73+
74+
// Click Call Tool
75+
await page.click('button:has-text("Call Tool")');
76+
77+
// Wait for app to fully load
78+
await page.waitForSelector("iframe", { timeout: 10000 });
79+
await page.waitForTimeout(6000); // Extra time for nested iframe content
80+
81+
// Take screenshot
82+
await expect(page).toHaveScreenshot(`${server.key}.png`, {
83+
maxDiffPixelRatio: 0.1, // 10% tolerance for dynamic content
84+
timeout: 10000,
85+
});
86+
});
87+
});
88+
}
52.3 KB
Loading
51.3 KB
Loading
66.6 KB
Loading
105 KB
Loading

0 commit comments

Comments
 (0)