Files
crumbs-original/tests/e2e/mcp.spec.ts
T
Frederic CabassutandClaude Fable 5.1 7bf06edd97 perf(e2e): log in once and share the admin session via storage state
Every test in the `app` project submitted the login form through the
authenticatedPage fixture: a page load, an Argon2 verification on the
server, a redirect and a networkidle wait. Over 137 tests that was
roughly half of the median test's 1.8s.

A new `login-state` setup project (tests/e2e/auth.setup.ts) authenticates
once after auth.spec has created the account and saves the session cookie
to data/test-storage-state.json. The `app` project loads it into every
context, so setupAndLogin takes its "session still valid" path and the
fixture is reduced to a page load.

pwa.spec and mcp.spec describe anonymous visitors and opt out with an
empty storageState so their baseline is unchanged.

Local run, 4 workers: summed test time 276s -> 256s, median 1.8s -> 1.6s.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 15:29:46 +02:00

195 lines
5.4 KiB
TypeScript

import { test as base, expect } from '@playwright/test';
import type { Page } from '@playwright/test';
import { setupAndLogin } from './helpers/fixtures.js';
/**
* MCP E2E tests exercise the full MCP protocol flow:
* 1. Create an API key via the settings API (authenticated via session)
* 2. Use the key to authenticate MCP requests
* 3. Test tool invocations via JSON-RPC over Streamable HTTP
*/
// Start unauthenticated: the "rejects requests without API key" scenario must
// not carry the shared admin session cookie, and the apiKey fixture logs in itself.
base.use({ storageState: { cookies: [], origins: [] } });
const test = base.extend<{ apiKey: string }>({
apiKey: async ({ page }, use) => {
await setupAndLogin(page);
// Create API key via settings endpoint (session-authenticated)
const res = await page.request.post('/api/settings/api-keys', {
data: { name: 'E2E Test Key' }
});
expect(res.ok()).toBeTruthy();
const { key } = await res.json();
await use(key);
}
});
async function mcpInitialize(page: Page, apiKey: string) {
const res = await page.request.post('/api/mcp', {
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream'
},
data: {
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: '2025-03-26',
capabilities: {},
clientInfo: { name: 'e2e-test', version: '1.0.0' }
}
}
});
const status = res.status();
const body = await res.text();
if (!res.ok()) {
throw new Error(`MCP initialize failed with status ${status}: ${body.slice(0, 500)}`);
}
const sessionId = res.headers()['mcp-session-id'];
expect(sessionId).toBeTruthy();
// Send initialized notification
await page.request.post('/api/mcp', {
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
'mcp-session-id': sessionId
},
data: {
jsonrpc: '2.0',
method: 'notifications/initialized'
}
});
return sessionId;
}
async function mcpCallTool(
page: Page,
apiKey: string,
sessionId: string,
name: string,
args: Record<string, unknown> = {},
id = 2
) {
const res = await page.request.post('/api/mcp', {
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
'mcp-session-id': sessionId
},
data: {
jsonrpc: '2.0',
id,
method: 'tools/call',
params: { name, arguments: args }
}
});
expect(res.ok()).toBeTruthy();
const text = await res.text();
// Response may be SSE format or JSON
if (text.startsWith('event:') || text.startsWith('data:')) {
// Parse SSE - find the last data line with actual content
const lines = text.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = JSON.parse(line.slice(6));
if (data.result) return data.result;
}
}
throw new Error('No result found in SSE response');
}
return JSON.parse(text).result;
}
test.describe('MCP Server', () => {
test('Scenario: MCP client can list notes via the protocol', async ({ page, apiKey }) => {
const sessionId = await mcpInitialize(page, apiKey);
// When calling list_notes tool
const result = await mcpCallTool(page, apiKey, sessionId, 'list_notes');
// Then the response contains notes content
expect(result.content).toBeDefined();
expect(result.content[0].type).toBe('text');
const notes = JSON.parse(result.content[0].text);
expect(Array.isArray(notes)).toBe(true);
});
test('Scenario: MCP client can create and retrieve a note', async ({ page, apiKey }) => {
const sessionId = await mcpInitialize(page, apiKey);
// When creating a note via MCP
const createResult = await mcpCallTool(page, apiKey, sessionId, 'create_note', {
title: 'MCP Test Note',
content: 'Created via MCP protocol'
});
const created = JSON.parse(createResult.content[0].text);
expect(created.title).toBe('MCP Test Note');
// Then the note can be retrieved
const getResult = await mcpCallTool(
page,
apiKey,
sessionId,
'get_note',
{ id: created.id },
3
);
const fetched = JSON.parse(getResult.content[0].text);
expect(fetched.title).toBe('MCP Test Note');
expect(fetched.content).toBe('Created via MCP protocol');
});
test('Scenario: MCP client can search notes', async ({ page, apiKey }) => {
const sessionId = await mcpInitialize(page, apiKey);
// Given a note with specific content exists
await mcpCallTool(page, apiKey, sessionId, 'create_note', {
title: 'Searchable Note',
content: 'unique-search-term-xyz'
});
// When searching for it
const searchResult = await mcpCallTool(
page,
apiKey,
sessionId,
'search_notes',
{ query: 'unique-search-term-xyz' },
3
);
const results = JSON.parse(searchResult.content[0].text);
// Then it appears in results
expect(results.length).toBeGreaterThan(0);
expect(results[0].title).toBe('Searchable Note');
});
test('Scenario: MCP endpoint rejects requests without API key', async ({ page }) => {
const res = await page.request.post('/api/mcp', {
headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' },
data: {
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: '2025-03-26',
capabilities: {},
clientInfo: { name: 'test', version: '1.0.0' }
}
}
});
expect(res.status()).toBe(401);
});
});