* 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>
182 lines
6.1 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|