ext-apps/tests/e2e/servers.spec.ts
Olivier Chafik c9e37204de
tests: add Playwright E2E tests with screenshot golden testing (+ fix examples session handling) (#115)
* 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 <noreply@anthropic.com>

* Add default demo code for Three.js example

When no code is provided, show a rotating green cube demo.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Add E2E testing documentation to CONTRIBUTING.md

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Update Three.js golden screenshot with 3D cube

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Add explicit permissions to CI workflow

Set minimal read-only permissions for security best practices.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Fix test.setTimeout() to be inside describe blocks

Playwright requires setTimeout to be called within a test or describe block.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Simplify E2E tests - remove excessive timeouts

- Remove explicit setTimeout calls (default 30s is sufficient)
- Replace waitForTimeout(5000/6000) with proper waitForAppLoad()
- Wait for inner iframe visibility instead of fixed delays
- Keep only 500ms stabilization for screenshot animations

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Auto-generate missing snapshots in CI

Use updateSnapshots: 'missing' in CI to handle cross-platform
screenshot differences (macOS vs Linux).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Use platform-agnostic golden screenshots

- Remove -chromium-darwin suffix from snapshot filenames
- Configure snapshotPathTemplate for cross-platform compatibility
- Increase tolerance to 5% for rendering differences between platforms

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Refactor tests to use forEach instead of for-of loop

May help with Playwright test discovery issues in CI.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Use npm ci and list reporter in CI

- Use npm ci for exact package versions
- Use --reporter=list instead of html to avoid potential re-evaluation issues
- Only upload test-results on failure

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Exclude e2e tests from bun test

Run bun test only on src/ directory to avoid running Playwright tests
with Bun's test runner.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Fix prettier formatting

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Add interaction tests for basic server apps

Test that clicking buttons in the MCP App triggers the corresponding
host callbacks:
- Send Message → host logs "Message from MCP App"
- Send Log → host logs "Log message from MCP App"
- Open Link → host logs "Open link request"

Tests both React and Vanilla JS implementations.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Mask dynamic content in E2E screenshot tests

Add Playwright mask option to handle servers with dynamic/random content:
- basic-react/vanillajs: mask server time display
- system-monitor: mask CPU chart, memory stats, uptime
- cohort-heatmap: mask heatmap grid (random data)
- customer-segmentation: mask scatter chart (random data)

This addresses PR feedback about handling examples with non-deterministic
output. Masking replaces the previous 10% tolerance with proper exclusion
of dynamic elements, allowing tighter 1% tolerance for the rest of the UI.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Add wiki-explorer to E2E tests with default URL param

- Add default URL "https://en.wikipedia.org/wiki/Model_Context_Protocol" to
  wiki-explorer-server's get-first-degree-links tool inputSchema
- Update basic-host to automatically populate input field with tool defaults
  extracted from inputSchema.properties
- Add wiki-explorer-server to E2E test suite with dynamic masking for the
  force-directed graph

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Use .default() for threejs tool schema instead of .optional()

This exposes the default code and height values in the JSON Schema,
allowing basic-host to auto-populate the input field with defaults.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Enable parallel E2E tests with timeouts and canvas masking

- Enable fullyParallel with 4 workers locally (2 in CI)
- Add 30s timeout per test
- Mask threejs canvas for stable screenshots
- Update snapshots to reflect default values in input fields

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Add pre-commit check for private registry URLs in package-lock.json

Same check as in CI to catch issues before push.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Increase screenshot diff tolerance to 6% for cross-platform rendering

Font rendering differs between macOS and Linux, causing ~5% pixel differences.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Revert pre-commit artifactory check (moved to separate PR #133)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Format threejs server.ts

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Update CONTRIBUTING.md to reflect platform-agnostic screenshots

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(e2e): use factory pattern for MCP servers to support parallel connections

McpServer only supports one transport at a time. When multiple browser
contexts connected in parallel, calling server.connect(transport) for
each session overwrote the previous transport's callbacks, causing
connection corruption and test timeouts.

Changes:
- server-utils.ts: accept factory function instead of single instance
- All 9 example servers: wrap server creation in createServer() function
- playwright.config.ts: re-enable parallel execution (4 workers)
- servers.spec.ts: add toBeEnabled wait for server connection
- Update screenshot baselines for basic-vanillajs and cohort-heatmap

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-12-13 20:57:27 +00:00

182 lines
6.1 KiB
TypeScript

import { test, expect, type Page, type ConsoleMessage } from "@playwright/test";
// Dynamic element selectors to mask for screenshot comparison
// Note: CSS modules generate unique class names, so we use attribute selectors
// with partial matches (e.g., [class*="heatmapWrapper"]) for those components
const DYNAMIC_MASKS: Record<string, string[]> = {
"basic-react": ["code"], // Server time display
"basic-vanillajs": ["#server-time"], // Server time display
"cohort-heatmap": ['[class*="heatmapWrapper"]'], // Heatmap grid (random data)
"customer-segmentation": [".chart-container"], // Scatter plot (random data)
"system-monitor": [
".chart-container", // CPU chart (highly dynamic)
"#status-text", // Current timestamp
"#memory-percent", // Memory percentage
"#memory-detail", // Memory usage details
"#memory-bar-fill", // Memory bar fill level
"#info-uptime", // System uptime
],
"wiki-explorer": ["#graph"], // Force-directed graph (dynamic layout)
threejs: ["canvas"], // 3D render canvas (dynamic animation)
};
// Server configurations
const SERVERS = [
{ key: "basic-react", index: 0, name: "Basic MCP App Server (React-based)" },
{
key: "basic-vanillajs",
index: 1,
name: "Basic MCP App Server (Vanilla JS)",
},
{ key: "budget-allocator", index: 2, name: "Budget Allocator Server" },
{ key: "cohort-heatmap", index: 3, name: "Cohort Heatmap Server" },
{
key: "customer-segmentation",
index: 4,
name: "Customer Segmentation Server",
},
{ key: "scenario-modeler", index: 5, name: "SaaS Scenario Modeler" },
{ key: "system-monitor", index: 6, name: "System Monitor Server" },
{ key: "threejs", index: 7, name: "Three.js Server" },
{ key: "wiki-explorer", index: 8, name: "Wiki Explorer" },
];
/**
* Helper to get the app frame locator (nested: sandbox > app)
*/
function getAppFrame(page: Page) {
return page.frameLocator("iframe").first().frameLocator("iframe").first();
}
/**
* Collect console messages with [HOST] prefix
*/
function captureHostLogs(page: Page): string[] {
const logs: string[] = [];
page.on("console", (msg: ConsoleMessage) => {
const text = msg.text();
if (text.includes("[HOST]")) {
logs.push(text);
}
});
return logs;
}
/**
* Wait for the MCP App to load inside nested iframes.
* Structure: page > iframe (sandbox) > iframe (app)
*/
async function waitForAppLoad(page: Page) {
const outerFrame = page.frameLocator("iframe").first();
await expect(outerFrame.locator("iframe")).toBeVisible();
}
/**
* Load a server by selecting it and clicking Call Tool
*/
async function loadServer(page: Page, serverIndex: number) {
await page.goto("/");
// Wait for servers to connect (select becomes enabled when servers are ready)
await expect(page.locator("select").first()).toBeEnabled({ timeout: 30000 });
await page.locator("select").first().selectOption({ index: serverIndex });
await page.click('button:has-text("Call Tool")');
await waitForAppLoad(page);
}
/**
* Get mask locators for dynamic elements inside the nested app iframe.
*/
function getMaskLocators(page: Page, serverKey: string) {
const selectors = DYNAMIC_MASKS[serverKey];
if (!selectors) return [];
const appFrame = getAppFrame(page);
return selectors.map((selector) => appFrame.locator(selector));
}
test.describe("Host UI", () => {
test("initial state shows controls", async ({ page }) => {
await page.goto("/");
await expect(page.locator("label:has-text('Server')")).toBeVisible();
await expect(page.locator("label:has-text('Tool')")).toBeVisible();
await expect(page.locator('button:has-text("Call Tool")')).toBeVisible();
});
test("screenshot of initial state", async ({ page }) => {
await page.goto("/");
await expect(page.locator('button:has-text("Call Tool")')).toBeVisible();
await expect(page).toHaveScreenshot("host-initial.png");
});
});
// Define tests for each server using forEach to avoid for-loop issues
SERVERS.forEach((server) => {
test.describe(server.name, () => {
test("loads app UI", async ({ page }) => {
await loadServer(page, server.index);
});
test("screenshot matches golden", async ({ page }) => {
await loadServer(page, server.index);
await page.waitForTimeout(500); // Brief stabilization
// Get mask locators for dynamic content (timestamps, charts, etc.)
const mask = getMaskLocators(page, server.key);
await expect(page).toHaveScreenshot(`${server.key}.png`, {
mask,
maxDiffPixelRatio: 0.06,
});
});
});
});
// Interaction tests for basic servers (React and Vanilla JS have identical UIs)
const BASIC_SERVERS = SERVERS.filter(
(s) => s.key === "basic-react" || s.key === "basic-vanillajs",
);
BASIC_SERVERS.forEach((server) => {
test.describe(`${server.name} - Interactions`, () => {
test("Send Message button triggers host callback", async ({ page }) => {
const logs = captureHostLogs(page);
await loadServer(page, server.index);
const appFrame = getAppFrame(page);
await appFrame.locator('button:has-text("Send Message")').click();
// Wait for the async message to be processed
await page.waitForTimeout(500);
expect(logs.some((log) => log.includes("Message from MCP App"))).toBe(
true,
);
});
test("Send Log button triggers host callback", async ({ page }) => {
const logs = captureHostLogs(page);
await loadServer(page, server.index);
const appFrame = getAppFrame(page);
await appFrame.locator('button:has-text("Send Log")').click();
await page.waitForTimeout(500);
expect(logs.some((log) => log.includes("Log message from MCP App"))).toBe(
true,
);
});
test("Open Link button triggers host callback", async ({ page }) => {
const logs = captureHostLogs(page);
await loadServer(page, server.index);
const appFrame = getAppFrame(page);
await appFrame.locator('button:has-text("Open Link")').click();
await page.waitForTimeout(500);
expect(logs.some((log) => log.includes("Open link request"))).toBe(true);
});
});
});