diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8040a35..312af69 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,3 +72,45 @@ jobs: - name: Build demo working-directory: demo run: npm run build + + e2e: + name: Browser (demo) + runs-on: ubuntu-latest + # Reuses the same build path as the demo job; runs the flows that only a + # real browser can cover — the OAuth redirect round-trip, IndexedDB + # persistence, and CORS on the servers the page talks to. + needs: library + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + + - name: Install & build library + run: | + npm ci + npm run build + + - name: Install demo + working-directory: demo + run: npm ci + + - name: Install Chromium + run: npx playwright install --with-deps chromium + + - name: Run browser tests + run: npm run test:e2e + + - name: Upload traces on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-traces + path: test-results/ + retention-days: 7 diff --git a/.gitignore b/.gitignore index 3153848..446d5f6 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,6 @@ dist *.tsbuildinfo demo/dist demo/node_modules +test-results/ +playwright-report/ +.playwright/ diff --git a/CLAUDE.md b/CLAUDE.md index 25f332a..b011752 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,6 +40,9 @@ The repo also contains a **Vite demo** (`demo/`) that is auto-deployed to - `src/styles.css` — optional theme (light + dark). - `src/index.ts` — public surface (+ curated re-exports from the core). - `tests/state.test.ts` — `node --test` over the reducer (imports from `dist`). +- `e2e/` — Playwright over the built demo: the OAuth redirect round-trip, + IndexedDB reuse, CORS. `servers.ts` starts a real MCP server + authorization + server on loopback. - `demo/` — the Vite + React demo (aliases the library to `../dist`). ## Commands @@ -49,6 +52,7 @@ npm run typecheck # tsc --noEmit npm run format:check # prettier npm run build # tsup → dist (ESM + CJS + d.ts) + copy styles.css npm test # node --test tests/*.test.ts (pretest builds) +npm run test:e2e # playwright over the built demo (browser flows) # demo cd demo && npm install && npm run dev # local dev (rebuild the lib first) @@ -56,7 +60,8 @@ cd demo && npm run typecheck && npm run build ``` CI (`.github/workflows/ci.yml`) runs the library job (typecheck → format:check → -build → test) then a demo job (build lib → build demo). Release (`release.yml`) +build → test), then a demo job (build lib → build demo) and a browser job +(Playwright over the built demo). Release (`release.yml`) publishes to npm via Trusted Publishing after CI passes on `main`. Pages (`deploy-demo.yml`) builds and deploys the demo. Keep all green before pushing. diff --git a/README.md b/README.md index 4043af6..1069798 100644 --- a/README.md +++ b/README.md @@ -342,6 +342,19 @@ from one place. > Ship only the **user's own** key to the browser — shared/app keys belong > behind a proxy or the gateway. +## Browser tests + +`npm run test:e2e` builds the demo and drives it in Chromium: connecting to a +remote MCP server, the full OAuth redirect round-trip (authorize → come back +with a code → connected), reuse of the stored tokens after a reload, and the +error path for an unreachable server. The MCP and authorization servers it +talks to are started on loopback by [`e2e/servers.ts`](e2e/servers.ts), CORS +headers included — so the test also pins the deployment requirement that the +challenge header be exposed. + +These cover what unit tests structurally cannot: effect ordering across a real +render, a real navigation away and back, and IndexedDB. + ## Demo & deployment The [`demo/`](demo/) app (Vite + React) is deployed to GitHub Pages by diff --git a/e2e/mcp.spec.ts b/e2e/mcp.spec.ts new file mode 100644 index 0000000..5e63fa5 --- /dev/null +++ b/e2e/mcp.spec.ts @@ -0,0 +1,95 @@ +import { expect, test } from '@playwright/test' +import { startServers, type ILocalServers } from './servers.ts' + +/** + * What only a browser can check. + * + * The MCP connector and the OAuth flow are unit-tested in the core, and the + * hook's pure parts are unit-tested here — but the failure that actually + * shipped was none of those: an unrelated `useEffect` rewrote `location` + * while the hook was awaiting a dynamic import, so the authorization code was + * gone by the time it looked. That needs a real page, real effects, real + * navigation and real IndexedDB. + */ + +let servers: ILocalServers + +test.beforeEach(async () => { + servers = await startServers({ requireAuth: test.info().title.includes('OAuth') }) +}) + +test.afterEach(async () => { + await servers.close() +}) + +const openMcpPanel = async (page: import('@playwright/test').Page) => { + await page.goto('/') + await page.getByRole('tab', { name: 'Your MCP server' }).click() + await expect(page.getByPlaceholder('https://your-server.example/mcp')).toBeVisible() +} + +test('connects to a server that needs no auth and lists its tools', async ({ page }) => { + await openMcpPanel(page) + await page.getByPlaceholder('https://your-server.example/mcp').fill(servers.mcpUrl) + await page.getByRole('button', { name: 'Connect' }).click() + + await expect(page.locator('.mcp__status')).toContainText('Connected') + await expect(page.locator('.mcp__tool code')).toHaveText(['mcp__echo']) + // The chat panel only appears once tools are in hand. + await expect(page.getByText('Connect a server on the right')).toBeHidden() +}) + +test('OAuth: authorize, come back, and end up connected', async ({ page }) => { + await openMcpPanel(page) + await page.getByPlaceholder('https://your-server.example/mcp').fill(servers.mcpUrl) + await page.getByRole('radio', { name: 'OAuth + DCR' }).check() + await page.getByRole('button', { name: 'Connect' }).click() + + // The server challenges; the app registers itself and asks the user to go. + await expect(page.locator('.mcp__status')).toContainText('Authorization required') + expect(servers.registrations()).toBe(1) + + // Clicking through leaves the app entirely — the authorization server + // bounces back with ?code=, and the page reloads from scratch. + await page.getByRole('button', { name: /Authorize with the server/ }).click() + + await expect(page.locator('.mcp__status')).toContainText('Connected', { timeout: 15_000 }) + await expect(page.locator('.mcp__tool code')).toHaveText(['mcp__echo']) + + // The single-use code must not survive in the address bar, or a reload + // replays a spent authorization. + expect(new URL(page.url()).searchParams.get('code')).toBeNull() + // And the visitor lands back on the panel they were using, not the default. + await expect(page.getByRole('tab', { name: 'Your MCP server' })).toHaveAttribute( + 'aria-selected', + 'true', + ) +}) + +test('OAuth: a reload reuses the stored tokens without re-authorizing', async ({ page }) => { + await openMcpPanel(page) + await page.getByPlaceholder('https://your-server.example/mcp').fill(servers.mcpUrl) + await page.getByRole('radio', { name: 'OAuth + DCR' }).check() + await page.getByRole('button', { name: 'Connect' }).click() + await expect(page.locator('.mcp__status')).toContainText('Authorization required') + await page.getByRole('button', { name: /Authorize with the server/ }).click() + await expect(page.locator('.mcp__status')).toContainText('Connected', { timeout: 15_000 }) + + await page.reload() + await page.getByRole('tab', { name: 'Your MCP server' }).click() + await page.getByRole('button', { name: 'Connect' }).click() + + await expect(page.locator('.mcp__status')).toContainText('Connected', { timeout: 15_000 }) + // Tokens and the dynamic registration came out of the encrypted vault: no + // second round-trip to the authorization server. + expect(servers.registrations()).toBe(1) +}) + +test('reports a server that cannot be reached instead of hanging', async ({ page }) => { + await openMcpPanel(page) + await page.getByPlaceholder('https://your-server.example/mcp').fill('http://127.0.0.1:1/mcp') + await page.getByRole('button', { name: 'Connect' }).click() + + await expect(page.locator('.mcp__status')).toContainText('Failed', { timeout: 15_000 }) + await expect(page.locator('.settings__warn')).toBeVisible() +}) diff --git a/e2e/servers.ts b/e2e/servers.ts new file mode 100644 index 0000000..9a874d5 --- /dev/null +++ b/e2e/servers.ts @@ -0,0 +1,209 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js' +import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http' +import type { AddressInfo } from 'node:net' +import { z } from 'zod' + +/** + * A real MCP server and a real OAuth authorization server for the browser + * tests — both on loopback, both CORS-enabled. + * + * The CORS part is not incidental. A browser client reads the 401 challenge + * out of `WWW-Authenticate` to find the resource metadata, and that header is + * invisible to script unless the server exposes it. Serving these headers here + * is what makes the test a check of the documented deployment requirement + * rather than a check of a convenient fiction. + */ + +export interface ILocalServers { + /** MCP endpoint to paste into the demo. */ + mcpUrl: string + /** Tool calls the server received. */ + calls: { name: string; args: unknown }[] + registrations: () => number + close: () => Promise +} + +export interface ILocalServersOptions { + requireAuth?: boolean +} + +const CORS = { + 'access-control-allow-origin': '*', + 'access-control-allow-methods': 'GET, POST, DELETE, OPTIONS', + 'access-control-allow-headers': + 'content-type, authorization, mcp-session-id, mcp-protocol-version, last-event-id', + // Without these two the browser can neither read the auth challenge nor keep + // the MCP session id — the exact failure mode the README warns about. + 'access-control-expose-headers': 'www-authenticate, mcp-session-id', + 'access-control-max-age': '86400', +} + +const json = (res: ServerResponse, status: number, body: unknown): void => { + res.writeHead(status, { 'content-type': 'application/json', ...CORS }) + res.end(JSON.stringify(body)) +} + +const readBody = async (req: IncomingMessage): Promise => { + const chunks: Buffer[] = [] + for await (const chunk of req) chunks.push(chunk as Buffer) + return Buffer.concat(chunks).toString() +} + +const listen = async (server: Server): Promise => { + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + return (server.address() as AddressInfo).port +} + +const shutdown = (server: Server): Promise => + new Promise((resolve) => { + server.closeAllConnections() + server.close(() => resolve()) + }) + +/** True for a CORS preflight, which must be answered before anything else. */ +const handledPreflight = (req: IncomingMessage, res: ServerResponse): boolean => { + if (req.method !== 'OPTIONS') return false + res.writeHead(204, CORS) + res.end() + return true +} + +export const startServers = async (opts: ILocalServersOptions = {}): Promise => { + const calls: { name: string; args: unknown }[] = [] + const validTokens = new Set() + let registrations = 0 + let issued = 0 + + // ── authorization server ──────────────────────────────────────────────── + const as = createServer((req, res) => { + void (async () => { + if (handledPreflight(req, res)) return + const url = new URL(req.url ?? '/', `http://${req.headers.host}`) + + if (url.pathname.includes('/.well-known/oauth-authorization-server')) { + json(res, 200, { + issuer: asUrl, + authorization_endpoint: `${asUrl}/authorize`, + token_endpoint: `${asUrl}/token`, + registration_endpoint: `${asUrl}/register`, + response_types_supported: ['code'], + grant_types_supported: ['authorization_code', 'refresh_token'], + code_challenge_methods_supported: ['S256'], + token_endpoint_auth_methods_supported: ['none'], + }) + return + } + + if (url.pathname === '/register') { + registrations += 1 + const metadata = JSON.parse((await readBody(req)) || '{}') as Record + json(res, 201, { ...metadata, client_id: `dcr-${registrations}`, client_id_issued_at: 1 }) + return + } + + // The consent screen, minus the consent: bounce straight back to the app + // with a code, which is what the user sees after approving. + if (url.pathname === '/authorize') { + const redirect = new URL(url.searchParams.get('redirect_uri') ?? '') + redirect.searchParams.set('code', 'auth-code-1') + const state = url.searchParams.get('state') + if (state) redirect.searchParams.set('state', state) + res.writeHead(302, { location: redirect.toString() }) + res.end() + return + } + + if (url.pathname === '/token') { + const params = new URLSearchParams(await readBody(req)) + if (params.get('grant_type') === 'authorization_code' && !params.get('code_verifier')) { + json(res, 400, { error: 'invalid_request' }) + return + } + issued += 1 + const access = `access-${issued}` + validTokens.clear() + validTokens.add(access) + json(res, 200, { + access_token: access, + token_type: 'Bearer', + expires_in: 3600, + refresh_token: `refresh-${issued}`, + }) + return + } + + json(res, 404, { error: 'not_found' }) + })() + }) + const asUrl = `http://127.0.0.1:${await listen(as)}` + + // ── MCP server ────────────────────────────────────────────────────────── + const buildServer = (): McpServer => { + const server = new McpServer({ name: 'e2e-mcp', version: '1.0.0' }) + server.registerTool( + 'echo', + { description: 'Echo the input back', inputSchema: { text: z.string() } }, + ({ text }) => { + calls.push({ name: 'echo', args: { text } }) + return { content: [{ type: 'text' as const, text: `echo:${text}` }] } + }, + ) + return server + } + + const mcpServer = createServer((req, res) => { + void (async () => { + if (handledPreflight(req, res)) return + const path = new URL(req.url ?? '/', `http://${req.headers.host}`).pathname + + if (path.includes('/.well-known/oauth-protected-resource')) { + json(res, 200, { + resource: mcpUrl, + authorization_servers: [asUrl], + scopes_supported: ['mcp:tools'], + }) + return + } + + if (opts.requireAuth) { + const token = (req.headers.authorization ?? '').replace(/^Bearer\s+/i, '') + if (!validTokens.has(token)) { + res.writeHead(401, { + 'content-type': 'application/json', + 'www-authenticate': `Bearer resource_metadata="${mcpOrigin}/.well-known/oauth-protected-resource/mcp"`, + ...CORS, + }) + res.end(JSON.stringify({ error: 'unauthorized' })) + return + } + } + + const server = buildServer() + const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }) + res.on('close', () => { + void transport.close() + void server.close() + }) + // The SDK writes its own headers, so CORS has to be set before it does. + for (const [key, value] of Object.entries(CORS)) res.setHeader(key, value) + await server.connect(transport) + const raw = await readBody(req) + await transport.handleRequest(req, res, raw ? JSON.parse(raw) : undefined) + })().catch(() => { + if (!res.headersSent) json(res, 500, { error: 'server_error' }) + }) + }) + const mcpOrigin = `http://127.0.0.1:${await listen(mcpServer)}` + const mcpUrl = `${mcpOrigin}/mcp` + + return { + mcpUrl, + calls, + registrations: () => registrations, + close: async () => { + await shutdown(mcpServer) + await shutdown(as) + }, + } +} diff --git a/package-lock.json b/package-lock.json index 8117fdc..bf989eb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "devDependencies": { "@dudko.dev/agent-web": "^0.0.12", "@modelcontextprotocol/sdk": "^1.30.0", + "@playwright/test": "^1.62.1", "@types/node": "^22.9.0", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.4", @@ -710,6 +711,22 @@ } } }, + "node_modules/@playwright/test": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", + "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.62.2", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", @@ -2298,6 +2315,53 @@ "pathe": "^2.0.1" } }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss-load-config": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", diff --git a/package.json b/package.json index 85a3f9d..8a0499e 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,8 @@ "format:check": "prettier --check \"src/**/*.{ts,tsx,css}\" \"tests/**/*.ts\"", "test": "node --test --experimental-strip-types --disable-warning=ExperimentalWarning tests/*.test.ts", "prepublishOnly": "npm run typecheck && npm test && npm run build", - "pretest": "npm run build" + "pretest": "npm run build", + "test:e2e": "playwright test" }, "keywords": [ "agent", @@ -111,6 +112,7 @@ "devDependencies": { "@dudko.dev/agent-web": "^0.0.12", "@modelcontextprotocol/sdk": "^1.30.0", + "@playwright/test": "^1.62.1", "@types/node": "^22.9.0", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.4", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..e08e8b9 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,52 @@ +import { defineConfig } from '@playwright/test' +import { existsSync } from 'node:fs' + +/** + * Browser tests for the demo. They exist for the things unit tests structurally + * cannot reach: effect ordering across a real render, a real navigation away + * and back, IndexedDB persistence, and CORS on the servers the page talks to. + * + * The demo is served from its production build, so what is tested is what gets + * deployed to Pages — including the code-split MCP chunk. + */ + +// Some sandboxes ship a Chromium that predates this Playwright's pinned build. +// Use it when it is there rather than failing on a version mismatch; CI runs +// `playwright install chromium` and takes the normal path. +const PREINSTALLED_CHROMIUM = '/opt/pw-browsers/chromium' +const executablePath = existsSync(PREINSTALLED_CHROMIUM) ? PREINSTALLED_CHROMIUM : undefined + +export default defineConfig({ + testDir: './e2e', + fullyParallel: false, + workers: 1, + timeout: 60_000, + expect: { timeout: 10_000 }, + reporter: process.env.CI ? [['list'], ['github']] : [['list']], + use: { + // The subpath is deliberate: Pages serves the demo from /agent-web-react/, + // so testing the bare origin would exercise a layout that never ships. + baseURL: 'http://127.0.0.1:4173/agent-web-react/', + trace: 'retain-on-failure', + launchOptions: executablePath ? { executablePath } : {}, + }, + projects: [{ name: 'chromium', use: { browserName: 'chromium' } }], + webServer: { + // `--host 127.0.0.1` is load-bearing, not decoration. Vite's default host + // is `localhost`, which Node resolves through DNS — on a GitHub runner that + // answers `::1` first, so the server listens on IPv6 while Playwright polls + // the IPv4 address below and waits out the full timeout with a healthy + // server running. Binding the same literal address both places removes the + // resolver from the equation. + command: + 'npm --prefix demo run build && npm --prefix demo run preview -- --host 127.0.0.1 --port 4173 --strictPort', + url: 'http://127.0.0.1:4173/agent-web-react/', + reuseExistingServer: !process.env.CI, + // Playwright ignores webServer stdout by default, which is why the first CI + // failure showed a build log and then silence. Piping it means the next one + // shows the server's own banner or its error. + stdout: 'pipe', + stderr: 'pipe', + timeout: 180_000, + }, +})