openpencil/tests/engine/mcp/server/transport.test.ts
Danila Poyarkov aa86873dd7
test: typecheck the test suites and fix what that found (#896)
* build: typecheck the test suites

Tests were in no TypeScript program: no tsconfig included tests/** or
packages/*/tests/**, and bun strips types without checking them, so a
fixture could drop a required field and keep passing until something
read it.

@types/bun moves to the root because it was installed per package only,
and #cli-tests/* joins the paths the root config already carries.

* test: fix the type errors the test suites were hiding

Typechecking the tests turned up 1123 errors. Most were ordinary
strictness, but some were real: `NodeChange` bound to Figma's plugin
typings rather than the Kiwi codec in thirteen .fig tests,
materializeInstance was called with six arguments against five so the
blobs and source children were dropped, CanvasKit pixels were written
to a plain object that never reached WASM, and assertions were made
through accessors that do not exist, so they asserted nothing.

Fixtures that had quietly lost a required field now carry it, nullable
results are narrowed through the existing expectDefined helper rather
than assumed, and stand-ins for CanvasKit and the editor go through one
named helper instead of an unexplained cast at each site.

No test was deleted, skipped, or weakened, and no `any`, non-null
assertion, or ts-expect-error was introduced.

* docs: record what typechecking the tests established

Pins the app program's global types with an assertion rather than a
note, since an unpinned types list lets any root @types package decide
which platform src/** is judged against.

The two environment faults that look like code regressions — Vite's
dependency pre-bundle outliving a package rebuild, and heavy .fig
suites failing under load — go to the development docs, where an
explanation belongs.

* fix: align @types/bun and keep node types resolvable when extended

The root manifest declared a newer @types/bun than every package, which
check:monorepo rejects, and pinning the app program's types left them
unresolvable from a config that extends this one out of tree.

* fix: fail the test typecheck when the compiler itself fails

The gate matched diagnostics by substring, so a compiler or config
failure that named no test file printed a pass while having checked
nothing. Diagnostics are now split by whether they name a file: an
unscoped one is the run failing and stops the gate, a test file's is a
finding, and a source file's stays out by design.

Also drops the parameter planComponentConstruction never read, and
makes the inner-shadow verification script exit non-zero when it
renders no image instead of logging and succeeding.

* chore: merge master into tests-typecheck
2026-10-05 12:42:38 +00:00

473 lines
15 KiB
TypeScript

import { describe, expect, it, test, beforeAll, afterAll } from 'bun:test'
import { stat, mkdir, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { WebSocket } from 'ws'
import { SceneGraph } from '@open-pencil/scene-graph'
import { startServer, type ServerHandle } from '#mcp/server'
import { parseDiscoveryInfo } from '#mcp/transport/discovery'
import { expectDefined } from '#tests/helpers/assert'
import {
connectMockBrowser,
openWs,
readHealth,
readNextResponse,
readWsJSON,
RegisterMessage,
ResponseMessage,
socketRequest,
tcpRequest,
waitForBrowserRegistration,
type HealthResponse,
type MockBrowser
} from '#tests/helpers/mcp/server'
const isUnix = process.platform !== 'win32'
const SOCKET_DIR = join(tmpdir(), `openpencil-test-server-${process.pid}`)
const SOCKET_PATH = isUnix ? join(SOCKET_DIR, 'mcp.sock') : null
const TEST_AUTH_TOKEN = 'test-auth-token'
let testCounter = 0
let sharedPort = 0
function testSocketPath(): string | null {
if (!isUnix) return null
return join(SOCKET_DIR, `mcp-test-${process.pid}-${++testCounter}.sock`)
}
// ---------------------------------------------------------------------------
// Unified transport tests (socket + TCP)
// ---------------------------------------------------------------------------
describe('MCP server unified transport', () => {
let handle: ServerHandle | null = null
beforeAll(async () => {
if (isUnix) await mkdir(SOCKET_DIR, { recursive: true })
try {
handle = await startServer({
httpPort: 0,
withTcp: true,
socketPath: SOCKET_PATH,
authToken: 'test-token-123',
enableEval: false,
mcpRoot: null
})
sharedPort = handle.httpPort
} catch (e) {
await handle?.close().catch(() => undefined)
throw e
}
})
afterAll(async () => {
await handle?.close()
if (isUnix) await rm(SOCKET_DIR, { recursive: true, force: true })
})
describe('TCP HTTP endpoint', () => {
it('responds to /health', async () => {
const result = await tcpRequest(sharedPort, 'GET', '/health')
expect(result.status).toBe(200)
const health = result.data as HealthResponse
expect(health.version).toBeTruthy()
expect(health.status).toBe('no_app')
expect(health.authRequired).toBe(true)
})
it('rejects /rpc without auth', async () => {
const result = await tcpRequest(sharedPort, 'POST', '/rpc', { command: 'tool' })
expect(result.status).toBe(401)
})
it('rejects /mcp without auth', async () => {
const result = await tcpRequest(sharedPort, 'POST', '/mcp')
expect(result.status).toBe(401)
})
it('accepts /mcp with auth token', async () => {
const result = await tcpRequest(
sharedPort,
'POST',
'/mcp',
{
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: '2025-06-18',
capabilities: {},
clientInfo: { name: 'smoke-test', version: '0.0.0' }
}
},
{
accept: 'application/json, text/event-stream',
Authorization: 'Bearer test-token-123'
}
)
expect(result.status).toBe(200)
})
})
if (isUnix) {
describe('Unix domain socket endpoint', () => {
it('responds to /health via socket', async () => {
if (!handle) throw new Error('Server handle not initialized')
const socketPath = handle.socketPath
expect(socketPath).toBeTruthy()
if (!socketPath) throw new Error('socketPath is null')
const result = await socketRequest(socketPath, 'GET', '/health')
expect(result.status).toBe(200)
const health = result.data as HealthResponse
expect(health.version).toBeTruthy()
})
it('socket file has restrictive permissions', async () => {
if (!handle) throw new Error('Server handle not initialized')
const socketPath = handle.socketPath
expect(socketPath).toBeTruthy()
if (!socketPath) throw new Error('socketPath is null')
const info = await stat(socketPath)
const mode = info.mode & 0o777
expect(mode).toBe(0o600)
})
})
}
describe('Discovery', () => {
it('writes a discovery file matching the running server', async () => {
const { getDiscoveryPath } = await import('#mcp/transport/paths')
const discoveryPath = await getDiscoveryPath()
const file = Bun.file(discoveryPath)
expect(await file.exists()).toBe(true)
const info = expectDefined(parseDiscoveryInfo(await file.text()), 'discovery file')
expect(info.pid).toBe(process.pid)
expect(info.httpPort).toBe(handle?.httpPort ?? 0)
expect(info.socketPath).toBe(handle?.socketPath ?? null)
expect(info.version).toBeTruthy()
expect(info.authToken).toBe('test-token-123')
})
})
})
// ---------------------------------------------------------------------------
// Streamable HTTP transport
// ---------------------------------------------------------------------------
describe('MCP Streamable HTTP transport', () => {
test('returns JSON responses for request/response calls', async () => {
if (isUnix) await mkdir(SOCKET_DIR, { recursive: true })
const handle = await startServer({
httpPort: 0,
withTcp: true,
socketPath: testSocketPath(),
authToken: TEST_AUTH_TOKEN,
enableEval: false,
mcpRoot: null
})
const httpPort = handle.httpPort
if (!httpPort) {
await handle.close()
throw new Error('withTcp: true did not produce an HTTP port')
}
try {
const response = await fetch(`http://127.0.0.1:${httpPort}/mcp`, {
method: 'POST',
headers: {
accept: 'application/json, text/event-stream',
'content-type': 'application/json',
Authorization: `Bearer ${TEST_AUTH_TOKEN}`
},
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: '2025-06-18',
capabilities: {},
clientInfo: { name: 'raw-test', version: '0.0.0' }
}
})
})
expect(response.status).toBe(200)
expect(response.headers.get('content-type')).toContain('application/json')
expect(response.headers.get('content-type')).not.toContain('text/event-stream')
} finally {
await handle.close()
}
})
})
// ---------------------------------------------------------------------------
// WebSocket stdio bridge routing
// ---------------------------------------------------------------------------
describe('MCP WebSocket stdio bridge routing', () => {
test('forwards stdio client requests to the registered desktop app', async () => {
if (isUnix) await mkdir(SOCKET_DIR, { recursive: true })
const authToken = 'bridge-test-token'
const handle = await startServer({
httpPort: 0,
withTcp: true,
socketPath: testSocketPath(),
authToken,
enableEval: false,
mcpRoot: null
})
let browser: MockBrowser | undefined
let clientWs: WebSocket | undefined
try {
const httpPort = handle.httpPort
if (!httpPort) throw new Error('withTcp: true did not produce an HTTP port')
const graph = new SceneGraph()
browser = await connectMockBrowser(httpPort, graph, authToken)
await waitForBrowserRegistration(httpPort)
clientWs = await openWs(`ws://127.0.0.1:${httpPort}`, authToken)
const register = await readWsJSON(clientWs, RegisterMessage)
expect(register.type).toBe('register')
expect(register.token).toBeNull()
clientWs.send(
JSON.stringify({
type: 'request',
id: 'stdio-1',
command: 'tool',
args: { name: 'get_current_page', args: {} }
})
)
const response = await readNextResponse(clientWs, ResponseMessage)
expect(response.type).toBe('response')
expect(response.id).toBe('stdio-1')
expect(response.ok).toBe(true)
expect(response.result?.name).toBe('Page 1')
expect(browser.requests.at(-1)?.command).toBe('tool')
} finally {
clientWs?.close()
browser?.close()
await handle.close()
}
})
test('returns a disconnected response after waiting when no app is registered', async () => {
if (isUnix) await mkdir(SOCKET_DIR, { recursive: true })
const handle = await startServer({
httpPort: 0,
withTcp: true,
socketPath: testSocketPath(),
authToken: 'bridge-test-token',
enableEval: false,
mcpRoot: null,
appWaitTimeoutMs: 50
})
let clientWs: WebSocket | undefined
try {
const httpPort = handle.httpPort
if (!httpPort) throw new Error('withTcp: true did not produce an HTTP port')
clientWs = await openWs(`ws://127.0.0.1:${httpPort}`, 'bridge-test-token')
const register = await readWsJSON(clientWs, RegisterMessage)
expect(register.type).toBe('register')
clientWs.send(
JSON.stringify({
type: 'request',
id: 'stdio-no-app',
command: 'tool',
args: { name: 'get_current_page', args: {} }
})
)
const response = await readNextResponse(clientWs, ResponseMessage, 15_000)
expect(response.type).toBe('response')
expect(response.id).toBe('stdio-no-app')
expect(response.ok).toBe(false)
expect(response.error).toContain('OpenPencil app is not connected')
} finally {
clientWs?.close()
await handle.close()
}
}, 20_000)
test('notifies already-connected stdio clients when the desktop app registers later', async () => {
if (isUnix) await mkdir(SOCKET_DIR, { recursive: true })
const authToken = 'bridge-test-token'
const handle = await startServer({
httpPort: 0,
withTcp: true,
socketPath: testSocketPath(),
authToken,
enableEval: false,
mcpRoot: null
})
let clientWs: WebSocket | undefined
let browser: MockBrowser | undefined
try {
const httpPort = handle.httpPort
if (!httpPort) throw new Error('withTcp: true did not produce an HTTP port')
clientWs = await openWs(`ws://127.0.0.1:${httpPort}`, authToken)
const graph = new SceneGraph()
const initialRegister = await readWsJSON(clientWs, RegisterMessage)
expect(initialRegister.type).toBe('register')
expect(initialRegister.token).toBeNull()
const broadcastPromise = readWsJSON(clientWs, RegisterMessage, 3_000)
browser = await connectMockBrowser(httpPort, graph, authToken)
const broadcastRegister = await broadcastPromise
expect(broadcastRegister.type).toBe('register')
expect(broadcastRegister.token).toBeNull()
} finally {
clientWs?.close()
browser?.close()
await handle.close()
}
})
test('pending request succeeds when browser connects during the wait', async () => {
if (isUnix) await mkdir(SOCKET_DIR, { recursive: true })
const authToken = 'bridge-test-token'
const handle = await startServer({
httpPort: 0,
withTcp: true,
socketPath: testSocketPath(),
authToken,
enableEval: false,
mcpRoot: null
})
let clientWs: WebSocket | undefined
let browser: MockBrowser | undefined
try {
const httpPort = handle.httpPort
if (!httpPort) throw new Error('withTcp: true did not produce an HTTP port')
clientWs = await openWs(`ws://127.0.0.1:${httpPort}`, authToken)
const ws = clientWs
const initReg = await readWsJSON(ws, RegisterMessage)
expect(initReg.type).toBe('register')
const requestPromise = (async () => {
ws.send(
JSON.stringify({
type: 'request',
id: 'wait-test-1',
command: 'tool',
args: { name: 'get_current_page', args: {} }
})
)
return readNextResponse(ws, ResponseMessage, 15_000)
})()
await new Promise<void>((r) => {
setTimeout(r, 500)
})
const graph = new SceneGraph()
browser = await connectMockBrowser(httpPort, graph, authToken)
const response = await requestPromise
expect(response.type).toBe('response')
expect(response.id).toBe('wait-test-1')
expect(response.ok).toBe(true)
expect(response.result?.name).toBe('Page 1')
} finally {
clientWs?.close()
browser?.close()
await handle.close()
}
}, 20_000)
test('routes new requests to the latest registered desktop app after reconnect', async () => {
if (isUnix) await mkdir(SOCKET_DIR, { recursive: true })
const authToken = 'bridge-test-token'
const handle = await startServer({
httpPort: 0,
withTcp: true,
socketPath: testSocketPath(),
authToken,
enableEval: false,
mcpRoot: null
})
let clientWs: WebSocket | undefined
let secondBrowser: MockBrowser | undefined
try {
const httpPort = handle.httpPort
if (!httpPort) throw new Error('withTcp: true did not produce an HTTP port')
const waitForHealth = async (
predicate: (status: string) => boolean,
timeoutMs = 2000
): Promise<{ status: string }> => {
const sleep = (ms: number) =>
new Promise<void>((resolve) => {
setTimeout(resolve, ms)
})
const start = Date.now()
let last: { status: string } = { status: 'unknown' }
while (Date.now() - start < timeoutMs) {
const r = await fetch(`http://127.0.0.1:${httpPort}/health`)
last = await readHealth(r)
if (predicate(last.status)) return last
await sleep(25)
}
throw new Error(
`Health predicate never matched within ${timeoutMs}ms (last status: ${last.status})`
)
}
const firstGraph = new SceneGraph()
const firstBrowser = await connectMockBrowser(httpPort, firstGraph, authToken)
await waitForHealth((s) => s === 'ok')
firstBrowser.close()
await waitForHealth((s) => s === 'no_app')
const secondGraph = new SceneGraph()
secondBrowser = await connectMockBrowser(httpPort, secondGraph, authToken)
const browser2 = secondBrowser
await waitForHealth((s) => s === 'ok')
clientWs = await openWs(`ws://127.0.0.1:${httpPort}`, authToken)
const ws = clientWs
const initReg = await readWsJSON(ws, RegisterMessage)
expect(initReg.type).toBe('register')
expect(initReg.token).toBeNull()
ws.send(
JSON.stringify({
type: 'request',
id: 'stdio-after-reconnect',
command: 'tool',
args: { name: 'get_current_page', args: {} }
})
)
const response = await readNextResponse(ws, ResponseMessage)
expect(response.type).toBe('response')
expect(response.id).toBe('stdio-after-reconnect')
expect(response.ok).toBe(true)
expect(browser2.requests.at(-1)?.command).toBe('tool')
} finally {
clientWs?.close()
secondBrowser?.close()
await handle.close()
}
}, 10000)
})