Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,6 @@ dist
*.tsbuildinfo
demo/dist
demo/node_modules
test-results/
playwright-report/
.playwright/
7 changes: 6 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -49,14 +52,16 @@ 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)
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.

Expand Down
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
95 changes: 95 additions & 0 deletions e2e/mcp.spec.ts
Original file line number Diff line number Diff line change
@@ -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()
})
Loading
Loading