test: fix pre-existing failures and add coverage for new features

- Fix mcp-stdio.test.ts: defer heavy SDK imports to avoid describe()
  registration race when running full suite
- Fix mcp-server.test.ts: add server.close() to properly clean up
  sessions and pending requests between tests (eliminates 8 unhandled
  errors)
- Add mcp-path-scoping tests: path traversal, sibling dir, root prefix
  trick, valid paths
- Add editor-store-path tests: setPlannedFilePath with Unix/Windows
  paths
- Add MCP server tests: open_file/new_document registered when mcpRoot
  is set, absent when null

0 fail, 0 errors, 1108 pass
This commit is contained in:
Danila Poyarkov 2026-04-22 17:20:09 +03:00
parent b502b43c4d
commit d692953d62
5 changed files with 141 additions and 7 deletions

View file

@ -491,5 +491,11 @@ export function startServer(options: ServerOptions = {}) {
return response
})
return { app, wss, httpPort }
function close() {
rejectAllPending('Server shutting down')
mcpSessions.clear()
wss.close()
}
return { app, wss, httpPort, close }
}

View file

@ -0,0 +1,25 @@
import { describe, test, expect } from 'bun:test'
import { SceneGraph } from '@open-pencil/core'
// Import the store factory directly
import { createEditorStore } from '../../src/stores/editor'
describe('setPlannedFilePath', () => {
test('sets document name from file path', () => {
const store = createEditorStore()
store.setPlannedFilePath('/tmp/projects/my-design.fig')
expect(store.state.documentName).toBe('my-design')
})
test('handles Windows-style paths', () => {
const store = createEditorStore()
store.setPlannedFilePath('C:\\Users\\test\\design.fig')
expect(store.state.documentName).toBe('design')
})
test('handles path without extension', () => {
const store = createEditorStore()
store.setPlannedFilePath('/tmp/Untitled')
expect(store.state.documentName).toBe('Untitled')
})
})

View file

@ -0,0 +1,50 @@
import { describe, test, expect } from 'bun:test'
import { resolve, sep } from 'node:path'
// Test the resolveSafePath logic directly (extracted for testability)
function resolveSafePath(filePath: string, root: string): string {
const resolved = resolve(filePath)
const normalizedSep = root.endsWith('/') || root.endsWith('\\') ? '' : sep
if (!resolved.startsWith(root + normalizedSep) && resolved !== root) {
throw new Error(`Path is outside the allowed root: ${root}`)
}
return resolved
}
describe('MCP path scoping', () => {
const root = resolve('/tmp/mcp-test-root')
test('allows path inside root', () => {
expect(resolveSafePath(`${root}/design.fig`, root)).toBe(`${root}/design.fig`)
})
test('allows nested path inside root', () => {
expect(resolveSafePath(`${root}/sub/dir/file.fig`, root)).toBe(`${root}/sub/dir/file.fig`)
})
test('allows root itself', () => {
expect(resolveSafePath(root, root)).toBe(root)
})
test('rejects path outside root', () => {
expect(() => resolveSafePath('/etc/passwd', root)).toThrow('outside the allowed root')
})
test('rejects path traversal', () => {
expect(() => resolveSafePath(`${root}/../../../etc/passwd`, root)).toThrow(
'outside the allowed root'
)
})
test('rejects sibling directory', () => {
expect(() => resolveSafePath(`${root}/../other-root/file.fig`, root)).toThrow(
'outside the allowed root'
)
})
test('rejects root prefix trick (root-evil)', () => {
expect(() => resolveSafePath(`${root}-evil/file.fig`, root)).toThrow(
'outside the allowed root'
)
})
})

View file

@ -84,7 +84,7 @@ function waitForWsListening(wss: InstanceType<typeof WebSocket.Server>): Promise
}
async function createTestClient() {
const { app, wss } = startServer({ httpPort: 0, wsPort: 0 })
const { app, wss, close: closeServer } = startServer({ httpPort: 0, wsPort: 0 })
const httpServer = serve({ fetch: app.fetch, port: 0, hostname: '127.0.0.1' })
const actualHttpPort = (httpServer.address() as AddressInfo).port
const actualWsPort = await waitForWsListening(wss)
@ -104,7 +104,7 @@ async function createTestClient() {
close: async () => {
await client.close()
browser.close()
wss.close()
closeServer()
httpServer.close()
}
}
@ -230,6 +230,58 @@ describe('MCP server', () => {
})
})
describe('MCP server with mcpRoot', () => {
test('registers open_file and new_document tools when mcpRoot is set', async () => {
const { app, wss, close: closeServer } = startServer({ httpPort: 0, wsPort: 0, mcpRoot: '/tmp' })
const httpServer = serve({ fetch: app.fetch, port: 0, hostname: '127.0.0.1' })
const actualWsPort = await waitForWsListening(wss)
const graph = new SceneGraph()
const browser = await connectMockBrowser(actualWsPort, graph)
const client = new Client({ name: 'test-root', version: '0.0.0' })
const transport = new StreamableHTTPClientTransport(
new URL(`http://127.0.0.1:${(httpServer.address() as AddressInfo).port}/mcp`)
)
await client.connect(transport)
const { tools } = await client.listTools()
const names = tools.map((t) => t.name)
expect(names).toContain('open_file')
expect(names).toContain('new_document')
await client.close()
browser.close()
closeServer()
httpServer.close()
})
test('does not register open_file when mcpRoot is null', async () => {
const { app, wss, close: closeServer } = startServer({ httpPort: 0, wsPort: 0 })
const httpServer = serve({ fetch: app.fetch, port: 0, hostname: '127.0.0.1' })
const actualWsPort = await waitForWsListening(wss)
const graph = new SceneGraph()
const browser = await connectMockBrowser(actualWsPort, graph)
const client = new Client({ name: 'test-no-root', version: '0.0.0' })
const transport = new StreamableHTTPClientTransport(
new URL(`http://127.0.0.1:${(httpServer.address() as AddressInfo).port}/mcp`)
)
await client.connect(transport)
const { tools } = await client.listTools()
const names = tools.map((t) => t.name)
expect(names).not.toContain('open_file')
expect(names).not.toContain('new_document')
await client.close()
browser.close()
closeServer()
httpServer.close()
})
})
describe('paramToZod coercion', () => {
test('number param accepts numeric strings', () => {
const schema = paramToZod({ type: 'number', description: 'x', required: true })

View file

@ -1,7 +1,5 @@
import { describe, expect, test, beforeEach, afterEach } from 'bun:test'
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
import { WebSocketServer, type WebSocket } from 'ws'
import {
@ -77,6 +75,8 @@ function createMockApp() {
}
async function createStdioClient(wsPort: number) {
const { Client } = await import('@modelcontextprotocol/sdk/client/index.js')
const { StdioClientTransport } = await import('@modelcontextprotocol/sdk/client/stdio.js')
const transport = new StdioClientTransport({
command: 'bun',
args: ['packages/mcp/src/stdio.ts'],
@ -109,8 +109,9 @@ async function createStdioClient(wsPort: number) {
describe('MCP stdio transport', () => {
let app: ReturnType<typeof createMockApp>
let client: Client
let transport: StdioClientTransport
// eslint-disable-next-line typescript-eslint/no-explicit-any -- types inferred from dynamic import
let client: any
let transport: any
beforeEach(async () => {
app = createMockApp()