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
12 changes: 8 additions & 4 deletions docs/contributing/adding-capabilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,8 +219,11 @@ the domain modules.
`capabilities: [..., yourCapability]`.
5. If the domain uses `index.ts`, ensure it still exports `domain` /
`codingCapabilities`-style aliases as needed for local imports.
6. Add or update tests in `packages/worker/src/mcp/*.mcp-e2e.test.ts` for
MCP-visible behavior.
6. Add or update focused `*.node.test.ts` or `*.workers.test.ts` coverage beside
the implementation for most MCP-visible behavior. Touch
`packages/worker/src/mcp/*.mcp-e2e.test.ts` only when the behavior truly
depends on the real MCP transport, OAuth handshake, or saved-app session
wiring.

Example (assuming `example` exists in `capabilityDomainNames`):

Expand Down Expand Up @@ -317,8 +320,9 @@ Public MCP behavior should be verified through the compact tool surface:
ranked results
- use `execute` to confirm the capability runs correctly

Prefer E2E tests in `packages/worker/src/mcp/*.mcp-e2e.test.ts` for the real MCP
contract.
Prefer `*.node.test.ts` and `*.workers.test.ts` for capability behavior. Reserve
`packages/worker/src/mcp/*.mcp-e2e.test.ts` for a very small number of real MCP
contract smoke tests.

Registry invariants (duplicate capability names, domain/capability mismatches,
duplicate domain registration) are covered in
Expand Down
8 changes: 8 additions & 0 deletions docs/contributing/end-to-end-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ that are unlikely to recur.
cases.
- If a bug is unlikely to show up again, do not add an E2E test just to lock in
the fix.
- For MCP specifically, treat `*.mcp-e2e.test.ts` as a tiny transport smoke
suite. Do not add capability-by-capability coverage there unless the failure
mode depends on the real MCP HTTP transport, OAuth flow, or saved-app session
wiring.

## Structure and style

Expand Down Expand Up @@ -108,6 +112,10 @@ Common commands:
- `npx playwright test`
- `npx playwright test e2e/login.spec.ts`

For MCP capability work, prefer `*.node.test.ts` or `*.workers.test.ts` beside
the implementation and keep `npm run test:mcp` limited to a couple of
high-signal smoke journeys.

If `packages/worker/.env` is missing, the E2E server startup path copies
`packages/worker/.env.example` to `packages/worker/.env` before Wrangler starts.

Expand Down
3 changes: 3 additions & 0 deletions docs/contributing/testing-principles.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ when that makes a single test longer and more assertion-heavy.
tests.
- Prefer fast unit tests for server logic; keep e2e tests focused on a very
small number of important happy-path journeys.
- Treat `packages/worker/src/mcp/*.mcp-e2e.test.ts` as a tiny MCP transport
smoke suite. Do not add capability-specific cases there unless they require
the real MCP HTTP transport, OAuth flow, and saved-app session wiring.
- Prefer asserting intermediate states inside the broader workflow that causes
them rather than adding isolated tests that only check an incidental loading
or transition state.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
import { expect, test, vi } from 'vitest'
import { createMcpCallerContext } from '#mcp/context.ts'

const mockModule = vi.hoisted(() => ({
syncSavedAppRunnerFromDb: vi.fn(),
execSavedAppRunnerServer: vi.fn(),
exportSavedAppRunnerStorage: vi.fn(),
getUiArtifactById: vi.fn(),
}))

vi.mock('#mcp/app-runner.ts', () => ({
syncSavedAppRunnerFromDb: (...args: Array<unknown>) =>
mockModule.syncSavedAppRunnerFromDb(...args),
execSavedAppRunnerServer: (...args: Array<unknown>) =>
mockModule.execSavedAppRunnerServer(...args),
exportSavedAppRunnerStorage: (...args: Array<unknown>) =>
mockModule.exportSavedAppRunnerStorage(...args),
}))

vi.mock('#mcp/ui-artifacts-repo.ts', () => ({
getUiArtifactById: (...args: Array<unknown>) =>
mockModule.getUiArtifactById(...args),
}))

const { appServerExecCapability } = await import('./app-server-exec.ts')
const { appStorageExportCapability } = await import('./app-storage-export.ts')
const { uiLoadAppSourceCapability } = await import('./ui-load-app-source.ts')

test('app_server_exec syncs the runner and normalizes the runner response', async () => {
mockModule.syncSavedAppRunnerFromDb.mockReset()
mockModule.execSavedAppRunnerServer.mockReset()
mockModule.exportSavedAppRunnerStorage.mockReset()
mockModule.getUiArtifactById.mockReset()

mockModule.syncSavedAppRunnerFromDb.mockResolvedValueOnce({
id: 'app-1',
})
mockModule.execSavedAppRunnerServer.mockResolvedValueOnce({
appId: 'app-1',
facetName: 'main',
result: { count: 3 },
})

const result = await appServerExecCapability.handler(
{
app_id: 'app-1',
code: 'return await app.call("incrementBy", params.amount ?? 1)',
params: { amount: 3 },
},
{
env: {} as Env,
callerContext: createMcpCallerContext({
baseUrl: 'https://heykody.dev',
user: { userId: 'user-1', email: 'user@example.com' },
}),
},
)

expect(mockModule.syncSavedAppRunnerFromDb).toHaveBeenCalledWith({
env: {},
appId: 'app-1',
userId: 'user-1',
baseUrl: 'https://heykody.dev',
})
expect(mockModule.execSavedAppRunnerServer).toHaveBeenCalledWith({
env: {},
appId: 'app-1',
facetName: 'main',
code: 'return await app.call("incrementBy", params.amount ?? 1)',
params: { amount: 3 },
})
expect(result).toEqual({
ok: true,
app_id: 'app-1',
facet_name: 'main',
result: { count: 3 },
})
})

test('app_storage_export forwards pagination options to the runner export helper', async () => {
mockModule.syncSavedAppRunnerFromDb.mockReset()
mockModule.execSavedAppRunnerServer.mockReset()
mockModule.exportSavedAppRunnerStorage.mockReset()
mockModule.getUiArtifactById.mockReset()

mockModule.syncSavedAppRunnerFromDb.mockResolvedValueOnce({
id: 'app-1',
})
mockModule.exportSavedAppRunnerStorage.mockResolvedValueOnce({
appId: 'app-1',
facetName: 'analytics',
export: {
entries: [{ key: 'count', value: 3 }],
estimatedBytes: 128,
truncated: true,
nextStartAfter: 'count',
pageSize: 1,
},
})

const result = await appStorageExportCapability.handler(
{
app_id: 'app-1',
facet_name: 'analytics',
page_size: 1,
start_after: 'count',
},
{
env: {} as Env,
callerContext: createMcpCallerContext({
baseUrl: 'https://heykody.dev',
user: { userId: 'user-1', email: 'user@example.com' },
}),
},
)

expect(mockModule.syncSavedAppRunnerFromDb).toHaveBeenCalledWith({
env: {},
appId: 'app-1',
userId: 'user-1',
baseUrl: 'https://heykody.dev',
})
expect(mockModule.exportSavedAppRunnerStorage).toHaveBeenCalledWith({
env: {},
appId: 'app-1',
facetName: 'analytics',
pageSize: 1,
startAfter: 'count',
})
expect(result).toEqual({
ok: true,
app_id: 'app-1',
facet_name: 'analytics',
export: {
entries: [{ key: 'count', value: 3 }],
estimatedBytes: 128,
truncated: true,
nextStartAfter: 'count',
pageSize: 1,
},
})
})

test('ui_load_app_source returns saved source for the authenticated user', async () => {
mockModule.syncSavedAppRunnerFromDb.mockReset()
mockModule.execSavedAppRunnerServer.mockReset()
mockModule.exportSavedAppRunnerStorage.mockReset()
mockModule.getUiArtifactById.mockReset()

mockModule.getUiArtifactById.mockResolvedValueOnce({
id: 'app-1',
title: 'Patchable App',
description: 'Saved app source',
clientCode: '<main><h1>Saved</h1></main>',
serverCode:
'import { DurableObject } from "cloudflare:workers"; export class App extends DurableObject {}',
serverCodeId: 'server-code-v1',
parameters: JSON.stringify([
{
name: 'team',
description: 'Team slug',
type: 'string',
required: true,
},
]),
hidden: true,
})

const result = await uiLoadAppSourceCapability.handler(
{
app_id: 'app-1',
},
{
env: { APP_DB: {} } as Env,
callerContext: createMcpCallerContext({
baseUrl: 'https://heykody.dev',
user: { userId: 'user-1', email: 'user@example.com' },
}),
},
)

expect(mockModule.getUiArtifactById).toHaveBeenCalledWith(
{},
'user-1',
'app-1',
)
expect(result).toEqual({
app_id: 'app-1',
title: 'Patchable App',
description: 'Saved app source',
client_code: '<main><h1>Saved</h1></main>',
server_code:
'import { DurableObject } from "cloudflare:workers"; export class App extends DurableObject {}',
server_code_id: 'server-code-v1',
parameters: [
{
name: 'team',
description: 'Team slug',
type: 'string',
required: true,
},
],
hidden: true,
})
})
Loading
Loading