mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-24 17:15:50 +00:00
feat: add VS Code IDE bridge extension
This commit is contained in:
135
packages/vscode-ide-bridge/test/bridgeServer.test.ts
Normal file
135
packages/vscode-ide-bridge/test/bridgeServer.test.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { z } from 'zod/v4'
|
||||
import { createLinkedTransportPair } from '../../../src/services/mcp/InProcessTransport.js'
|
||||
import {
|
||||
createIdeBridgeServer,
|
||||
type DiffController,
|
||||
} from '../src/server/bridgeServer.js'
|
||||
|
||||
const SelectionChangedSchema = z.object({
|
||||
method: z.literal('selection_changed'),
|
||||
params: z.object({
|
||||
selection: z
|
||||
.object({
|
||||
start: z.object({ line: z.number(), character: z.number() }),
|
||||
end: z.object({ line: z.number(), character: z.number() }),
|
||||
})
|
||||
.nullable(),
|
||||
text: z.string().optional(),
|
||||
filePath: z.string().optional(),
|
||||
}),
|
||||
})
|
||||
|
||||
function createTestClient() {
|
||||
return new Client({
|
||||
name: 'vscode-ide-bridge-test-client',
|
||||
version: '0.0.1',
|
||||
})
|
||||
}
|
||||
|
||||
describe('ide bridge MCP server', () => {
|
||||
test('lists the bridge tools and delegates openDiff calls', async () => {
|
||||
const openDiffCalls: Array<Record<string, unknown>> = []
|
||||
const diffController: DiffController = {
|
||||
async openDiff(args) {
|
||||
openDiffCalls.push(args)
|
||||
return {
|
||||
content: [{ type: 'text', text: 'TAB_CLOSED' }],
|
||||
}
|
||||
},
|
||||
async closeTab() {
|
||||
return {
|
||||
content: [{ type: 'text', text: 'TAB_CLOSED' }],
|
||||
}
|
||||
},
|
||||
async closeAllDiffTabs() {
|
||||
return {
|
||||
content: [{ type: 'text', text: 'OK' }],
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
const bridge = createIdeBridgeServer({ diffController })
|
||||
const client = createTestClient()
|
||||
const [clientTransport, serverTransport] = createLinkedTransportPair()
|
||||
|
||||
await bridge.server.connect(serverTransport)
|
||||
await client.connect(clientTransport)
|
||||
|
||||
const toolResult = await client.listTools()
|
||||
expect(toolResult.tools.map(tool => tool.name)).toEqual([
|
||||
'openDiff',
|
||||
'close_tab',
|
||||
'closeAllDiffTabs',
|
||||
])
|
||||
|
||||
const openDiffResult = await client.callTool({
|
||||
name: 'openDiff',
|
||||
arguments: {
|
||||
old_file_path: 'D:/vibe/claude-code/src/cli/print.ts',
|
||||
new_file_path: 'D:/vibe/claude-code/src/cli/print.ts',
|
||||
new_file_contents: 'new content',
|
||||
tab_name: 'tab-1',
|
||||
},
|
||||
})
|
||||
|
||||
expect(openDiffResult.content[0]).toEqual({
|
||||
type: 'text',
|
||||
text: 'TAB_CLOSED',
|
||||
})
|
||||
expect(openDiffCalls).toHaveLength(1)
|
||||
expect(openDiffCalls[0]?.tab_name).toBe('tab-1')
|
||||
})
|
||||
|
||||
test('forwards selection_changed notifications to the connected client', async () => {
|
||||
const diffController: DiffController = {
|
||||
async openDiff() {
|
||||
return {
|
||||
content: [{ type: 'text', text: 'TAB_CLOSED' }],
|
||||
}
|
||||
},
|
||||
async closeTab() {
|
||||
return {
|
||||
content: [{ type: 'text', text: 'TAB_CLOSED' }],
|
||||
}
|
||||
},
|
||||
async closeAllDiffTabs() {
|
||||
return {
|
||||
content: [{ type: 'text', text: 'OK' }],
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
const bridge = createIdeBridgeServer({ diffController })
|
||||
const client = createTestClient()
|
||||
const [clientTransport, serverTransport] = createLinkedTransportPair()
|
||||
|
||||
await bridge.server.connect(serverTransport)
|
||||
await client.connect(clientTransport)
|
||||
|
||||
const notificationPromise = new Promise<z.infer<typeof SelectionChangedSchema>>(
|
||||
resolve => {
|
||||
client.setNotificationHandler(SelectionChangedSchema, notification => {
|
||||
resolve(notification)
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
await bridge.notifySelectionChanged({
|
||||
selection: {
|
||||
start: { line: 4, character: 2 },
|
||||
end: { line: 6, character: 0 },
|
||||
},
|
||||
text: 'selected text',
|
||||
filePath: 'D:/vibe/claude-code/src/cli/print.ts',
|
||||
})
|
||||
|
||||
const notification = await notificationPromise
|
||||
expect(notification.params.filePath).toBe(
|
||||
'D:/vibe/claude-code/src/cli/print.ts',
|
||||
)
|
||||
expect(notification.params.text).toBe('selected text')
|
||||
expect(notification.params.selection?.start.line).toBe(4)
|
||||
})
|
||||
})
|
||||
247
packages/vscode-ide-bridge/test/diffController.test.ts
Normal file
247
packages/vscode-ide-bridge/test/diffController.test.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import { mkdtempSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, mock, test } from 'bun:test'
|
||||
|
||||
type FakeUri = {
|
||||
scheme: string
|
||||
fsPath: string
|
||||
path: string
|
||||
query: string
|
||||
toString(): string
|
||||
}
|
||||
|
||||
type FakeDocument = {
|
||||
uri: FakeUri
|
||||
isDirty: boolean
|
||||
lineCount: number
|
||||
lineAt(index: number): { text: string }
|
||||
getText(): string
|
||||
setText(next: string): void
|
||||
}
|
||||
|
||||
function createFakeUri(
|
||||
scheme: string,
|
||||
fsPath: string,
|
||||
query = '',
|
||||
): FakeUri {
|
||||
const normalizedFsPath = fsPath.replaceAll('\\', '/')
|
||||
return {
|
||||
scheme,
|
||||
fsPath,
|
||||
path: fsPath,
|
||||
query,
|
||||
toString() {
|
||||
if (scheme === 'file') {
|
||||
return `file://${normalizedFsPath}`
|
||||
}
|
||||
return `${scheme}:/${normalizedFsPath}${query ? `?${query}` : ''}`
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function createFakeVscode() {
|
||||
const documents = new Map<string, FakeDocument>()
|
||||
const saveListeners = new Set<(document: FakeDocument) => void>()
|
||||
const visibleEditorListeners = new Set<(editors: any[]) => void>()
|
||||
const visibleTextEditors: any[] = []
|
||||
|
||||
function createDocument(uri: FakeUri, initialText = ''): FakeDocument {
|
||||
let text = initialText
|
||||
return {
|
||||
uri,
|
||||
isDirty: false,
|
||||
get lineCount() {
|
||||
return Math.max(text.split('\n').length, 1)
|
||||
},
|
||||
lineAt(index: number) {
|
||||
return {
|
||||
text: text.split('\n')[index] ?? '',
|
||||
}
|
||||
},
|
||||
getText() {
|
||||
return text
|
||||
},
|
||||
setText(next: string) {
|
||||
text = next
|
||||
this.isDirty = true
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const vscode = {
|
||||
Uri: {
|
||||
parse(value: string) {
|
||||
const match = value.match(/^([a-z-]+):\/(.+?)(?:\?(.*))?$/i)
|
||||
if (!match) {
|
||||
throw new Error(`Unsupported URI: ${value}`)
|
||||
}
|
||||
const [, scheme, path, query = ''] = match
|
||||
return createFakeUri(
|
||||
scheme,
|
||||
decodeURIComponent(path),
|
||||
query,
|
||||
)
|
||||
},
|
||||
file(filePath: string) {
|
||||
return createFakeUri('file', filePath)
|
||||
},
|
||||
},
|
||||
Range: class {
|
||||
constructor(
|
||||
public startLine: number,
|
||||
public startCharacter: number,
|
||||
public endLine: number,
|
||||
public endCharacter: number,
|
||||
) {}
|
||||
},
|
||||
workspace: {
|
||||
registerTextDocumentContentProvider() {
|
||||
return { dispose() {} }
|
||||
},
|
||||
onDidSaveTextDocument(handler: (document: FakeDocument) => void) {
|
||||
saveListeners.add(handler)
|
||||
return {
|
||||
dispose() {
|
||||
saveListeners.delete(handler)
|
||||
},
|
||||
}
|
||||
},
|
||||
async openTextDocument(uri: FakeUri) {
|
||||
const key = uri.toString()
|
||||
const existing = documents.get(key)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
const doc = createDocument(uri)
|
||||
documents.set(key, doc)
|
||||
return doc
|
||||
},
|
||||
},
|
||||
window: {
|
||||
visibleTextEditors,
|
||||
tabGroups: {
|
||||
all: [],
|
||||
async close() {},
|
||||
},
|
||||
onDidChangeVisibleTextEditors(handler: (editors: any[]) => void) {
|
||||
visibleEditorListeners.add(handler)
|
||||
return {
|
||||
dispose() {
|
||||
visibleEditorListeners.delete(handler)
|
||||
},
|
||||
}
|
||||
},
|
||||
async showTextDocument(document: FakeDocument) {
|
||||
const editor = {
|
||||
document,
|
||||
viewColumn: 1,
|
||||
async edit(
|
||||
callback: (editBuilder: { replace(range: unknown, text: string): void }) => void,
|
||||
) {
|
||||
callback({
|
||||
replace(_range, text) {
|
||||
document.setText(text)
|
||||
},
|
||||
})
|
||||
return true
|
||||
},
|
||||
}
|
||||
if (!visibleTextEditors.includes(editor)) {
|
||||
visibleTextEditors.splice(0, visibleTextEditors.length, editor)
|
||||
for (const listener of visibleEditorListeners) {
|
||||
listener([...visibleTextEditors])
|
||||
}
|
||||
}
|
||||
return editor
|
||||
},
|
||||
async showInformationMessage() {
|
||||
return undefined
|
||||
},
|
||||
},
|
||||
commands: {
|
||||
async executeCommand() {},
|
||||
},
|
||||
__documents: documents,
|
||||
async __emitSave(document: FakeDocument) {
|
||||
document.isDirty = false
|
||||
for (const listener of saveListeners) {
|
||||
listener(document)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
return vscode
|
||||
}
|
||||
|
||||
async function waitForDocument(
|
||||
filePath: string,
|
||||
attempts = 20,
|
||||
): Promise<FakeDocument | undefined> {
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
const document = fakeVscode.__documents.get(
|
||||
fakeVscode.Uri.file(filePath).toString(),
|
||||
)
|
||||
if (document) {
|
||||
return document
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
const fakeVscode = createFakeVscode()
|
||||
mock.module('vscode', () => fakeVscode)
|
||||
|
||||
afterEach(() => {
|
||||
fakeVscode.__documents.clear()
|
||||
fakeVscode.window.visibleTextEditors.splice(
|
||||
0,
|
||||
fakeVscode.window.visibleTextEditors.length,
|
||||
)
|
||||
})
|
||||
|
||||
describe('diff controller', () => {
|
||||
test('returns FILE_SAVED with the saved file contents', async () => {
|
||||
const { createDiffController } = await import(
|
||||
'../src/server/diffController.js'
|
||||
)
|
||||
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'claude-code-bridge-'))
|
||||
const filePath = join(tempDir, 'sample.ts')
|
||||
writeFileSync(filePath, 'const before = true\n')
|
||||
|
||||
const controller = createDiffController({
|
||||
appendLine() {},
|
||||
})
|
||||
|
||||
const resultPromise = controller.openDiff({
|
||||
old_file_path: filePath,
|
||||
new_file_path: filePath,
|
||||
new_file_contents: 'const proposed = true\n',
|
||||
tab_name: 'sample.ts',
|
||||
})
|
||||
|
||||
const savedDocument = await waitForDocument(filePath)
|
||||
expect(savedDocument).toBeDefined()
|
||||
|
||||
savedDocument?.setText('const saved = true\n')
|
||||
await fakeVscode.__emitSave(savedDocument as FakeDocument)
|
||||
|
||||
const result = await Promise.race([
|
||||
resultPromise,
|
||||
new Promise(resolve =>
|
||||
setTimeout(() => resolve('timed-out'), 200),
|
||||
),
|
||||
])
|
||||
|
||||
expect(result).toEqual({
|
||||
content: [
|
||||
{ type: 'text', text: 'FILE_SAVED' },
|
||||
{ type: 'text', text: 'const saved = true\n' },
|
||||
],
|
||||
})
|
||||
|
||||
await controller.dispose()
|
||||
})
|
||||
})
|
||||
39
packages/vscode-ide-bridge/test/lockfile.test.ts
Normal file
39
packages/vscode-ide-bridge/test/lockfile.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
buildLockfilePayload,
|
||||
getLockfilePath,
|
||||
} from '../src/server/lockfile.js'
|
||||
|
||||
describe('lockfile helpers', () => {
|
||||
test('builds a ws-ide lockfile payload with auth token and workspace folders', () => {
|
||||
const payload = buildLockfilePayload({
|
||||
pid: 123,
|
||||
ideName: 'VS Code',
|
||||
workspaceFolders: ['D:/vibe/claude-code'],
|
||||
authToken: 'token-123',
|
||||
runningInWindows: true,
|
||||
})
|
||||
|
||||
expect(payload.transport).toBe('ws')
|
||||
expect(payload.authToken).toBe('token-123')
|
||||
expect(payload.workspaceFolders).toEqual(['D:/vibe/claude-code'])
|
||||
expect(payload.pid).toBe(123)
|
||||
})
|
||||
|
||||
test('derives the lockfile path from CLAUDE_CONFIG_DIR when provided', () => {
|
||||
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR
|
||||
process.env.CLAUDE_CONFIG_DIR = 'D:/tmp/claude-config'
|
||||
|
||||
try {
|
||||
expect(getLockfilePath(4567)).toBe(
|
||||
'D:\\tmp\\claude-config\\ide\\4567.lock',
|
||||
)
|
||||
} finally {
|
||||
if (originalConfigDir === undefined) {
|
||||
delete process.env.CLAUDE_CONFIG_DIR
|
||||
} else {
|
||||
process.env.CLAUDE_CONFIG_DIR = originalConfigDir
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
32
packages/vscode-ide-bridge/test/package.test.ts
Normal file
32
packages/vscode-ide-bridge/test/package.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
const packageRoot = join(import.meta.dir, '..')
|
||||
const packageJsonPath = join(packageRoot, 'package.json')
|
||||
|
||||
describe('vscode-ide-bridge package', () => {
|
||||
test('declares a VSCode extension entry', () => {
|
||||
expect(existsSync(packageJsonPath)).toBe(true)
|
||||
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as {
|
||||
main?: string
|
||||
engines?: { vscode?: string }
|
||||
activationEvents?: string[]
|
||||
dependencies?: Record<string, string>
|
||||
devDependencies?: Record<string, string>
|
||||
}
|
||||
|
||||
expect(packageJson.main).toBe('./dist/extension.js')
|
||||
expect(packageJson.engines?.vscode).toBeDefined()
|
||||
expect(packageJson.activationEvents).toContain('onStartupFinished')
|
||||
expect(packageJson.dependencies).toMatchObject({
|
||||
'@modelcontextprotocol/sdk': expect.any(String),
|
||||
ws: expect.any(String),
|
||||
})
|
||||
expect(packageJson.devDependencies).toMatchObject({
|
||||
'@types/bun': expect.any(String),
|
||||
typescript: expect.any(String),
|
||||
})
|
||||
})
|
||||
})
|
||||
71
packages/vscode-ide-bridge/test/packagePackaging.test.ts
Normal file
71
packages/vscode-ide-bridge/test/packagePackaging.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
type PackageJson = {
|
||||
displayName?: string
|
||||
publisher?: string
|
||||
license?: string
|
||||
scripts?: Record<string, string>
|
||||
}
|
||||
|
||||
type TaskConfig = {
|
||||
label?: string
|
||||
command?: string
|
||||
args?: string[]
|
||||
}
|
||||
|
||||
const packageRoot = join(import.meta.dir, '..')
|
||||
const packageJsonPath = join(packageRoot, 'package.json')
|
||||
const tasksJsonPath = join(packageRoot, '.vscode', 'tasks.json')
|
||||
const vscodeIgnorePath = join(packageRoot, '.vscodeignore')
|
||||
const readmePath = join(packageRoot, 'README.md')
|
||||
|
||||
describe('vscode-ide-bridge packaging workflow', () => {
|
||||
test('declares the metadata and script needed to package a .vsix', () => {
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as PackageJson
|
||||
|
||||
expect(packageJson.displayName).toBe('Claude Code IDE Bridge')
|
||||
expect(packageJson.publisher).toBe('claude-code-best')
|
||||
expect(packageJson.license).toBeDefined()
|
||||
expect(packageJson.scripts?.bundle).toBe(
|
||||
'bun build ./src/extension.ts --outdir dist --target node --format esm --external vscode',
|
||||
)
|
||||
expect(packageJson.scripts?.package).toBe(
|
||||
'bun run bundle && bunx @vscode/vsce package --no-dependencies --out dist/vscode-ide-bridge.vsix',
|
||||
)
|
||||
})
|
||||
|
||||
test('declares a package-local task for building a .vsix', () => {
|
||||
expect(existsSync(tasksJsonPath)).toBe(true)
|
||||
|
||||
const tasksJson = JSON.parse(readFileSync(tasksJsonPath, 'utf8')) as {
|
||||
tasks?: TaskConfig[]
|
||||
}
|
||||
|
||||
const packageTask = tasksJson.tasks?.find(
|
||||
item => item.label === 'Package VSCode IDE Bridge',
|
||||
)
|
||||
|
||||
expect(packageTask).toBeDefined()
|
||||
expect(packageTask?.command).toBe('bun')
|
||||
expect(packageTask?.args).toEqual(['run', 'package'])
|
||||
})
|
||||
|
||||
test('excludes development-only files from the packaged extension', () => {
|
||||
expect(existsSync(vscodeIgnorePath)).toBe(true)
|
||||
|
||||
const contents = readFileSync(vscodeIgnorePath, 'utf8')
|
||||
|
||||
expect(contents).toContain('src/**')
|
||||
expect(contents).toContain('test/**')
|
||||
expect(contents).toContain('tsconfig.json')
|
||||
})
|
||||
|
||||
test('keeps the packaged README free of local absolute file links', () => {
|
||||
const contents = readFileSync(readmePath, 'utf8')
|
||||
|
||||
expect(contents).not.toContain('](/')
|
||||
expect(contents).not.toContain(':/')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,89 @@
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
type LaunchConfig = {
|
||||
name?: string
|
||||
type?: string
|
||||
request?: string
|
||||
preLaunchTask?: string
|
||||
args?: string[]
|
||||
}
|
||||
|
||||
type TaskConfig = {
|
||||
label?: string
|
||||
command?: string
|
||||
args?: string[]
|
||||
}
|
||||
|
||||
const packageRoot = join(import.meta.dir, '..')
|
||||
const launchJsonPath = join(packageRoot, '.vscode', 'launch.json')
|
||||
const tasksJsonPath = join(packageRoot, '.vscode', 'tasks.json')
|
||||
|
||||
describe('standalone package workspace workflow', () => {
|
||||
test('declares a package-local extension host launch config', () => {
|
||||
expect(existsSync(launchJsonPath)).toBe(true)
|
||||
|
||||
const launchJson = JSON.parse(readFileSync(launchJsonPath, 'utf8')) as {
|
||||
configurations?: LaunchConfig[]
|
||||
}
|
||||
|
||||
const config = launchJson.configurations?.find(
|
||||
item => item.name === 'Run VSCode IDE Bridge',
|
||||
)
|
||||
|
||||
expect(config).toBeDefined()
|
||||
expect(config?.type).toBe('extensionHost')
|
||||
expect(config?.request).toBe('launch')
|
||||
expect(config?.preLaunchTask).toBe('Build VSCode IDE Bridge')
|
||||
expect(config?.args).toContain('--new-window')
|
||||
expect(config?.args).toContain('--disable-extensions')
|
||||
expect(config?.args).toContain(
|
||||
'--extensionDevelopmentPath=${workspaceFolder}',
|
||||
)
|
||||
})
|
||||
|
||||
test('declares a launch config that opens the claude-code workspace root', () => {
|
||||
const launchJson = JSON.parse(readFileSync(launchJsonPath, 'utf8')) as {
|
||||
configurations?: LaunchConfig[]
|
||||
}
|
||||
|
||||
const config = launchJson.configurations?.find(
|
||||
item => item.name === 'Run VSCode IDE Bridge (Open Claude Code Root)',
|
||||
)
|
||||
|
||||
expect(config).toBeDefined()
|
||||
expect(config?.type).toBe('extensionHost')
|
||||
expect(config?.request).toBe('launch')
|
||||
expect(config?.preLaunchTask).toBe('Build VSCode IDE Bridge')
|
||||
expect(config?.args).toContain('--new-window')
|
||||
expect(config?.args).toContain('--disable-extensions')
|
||||
expect(config?.args).toContain(
|
||||
'--extensionDevelopmentPath=${workspaceFolder}',
|
||||
)
|
||||
expect(config?.args).toContain('${workspaceFolder}/../..')
|
||||
})
|
||||
|
||||
test('declares package-local build and test tasks', () => {
|
||||
expect(existsSync(tasksJsonPath)).toBe(true)
|
||||
|
||||
const tasksJson = JSON.parse(readFileSync(tasksJsonPath, 'utf8')) as {
|
||||
tasks?: TaskConfig[]
|
||||
}
|
||||
|
||||
const buildTask = tasksJson.tasks?.find(
|
||||
item => item.label === 'Build VSCode IDE Bridge',
|
||||
)
|
||||
const testTask = tasksJson.tasks?.find(
|
||||
item => item.label === 'Test VSCode IDE Bridge',
|
||||
)
|
||||
|
||||
expect(buildTask).toBeDefined()
|
||||
expect(buildTask?.command).toBe('bunx')
|
||||
expect(buildTask?.args).toEqual(['tsc', '-p', 'tsconfig.json'])
|
||||
|
||||
expect(testTask).toBeDefined()
|
||||
expect(testTask?.command).toBe('bun')
|
||||
expect(testTask?.args).toEqual(['test', 'test'])
|
||||
})
|
||||
})
|
||||
27
packages/vscode-ide-bridge/test/selectionPublisher.test.ts
Normal file
27
packages/vscode-ide-bridge/test/selectionPublisher.test.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { buildSelectionChangedParams } from '../src/server/selectionPublisher.js'
|
||||
|
||||
describe('selection publisher helpers', () => {
|
||||
test('serializes a selected range with text and file path', () => {
|
||||
const params = buildSelectionChangedParams({
|
||||
filePath: 'D:/vibe/claude-code/src/cli/print.ts',
|
||||
text: 'const value = 1',
|
||||
start: { line: 10, character: 2 },
|
||||
end: { line: 10, character: 17 },
|
||||
})
|
||||
|
||||
expect(params.filePath).toBe('D:/vibe/claude-code/src/cli/print.ts')
|
||||
expect(params.text).toBe('const value = 1')
|
||||
expect(params.selection?.start.line).toBe(10)
|
||||
expect(params.selection?.end.character).toBe(17)
|
||||
})
|
||||
|
||||
test('keeps file context when there is no active selection', () => {
|
||||
const params = buildSelectionChangedParams({
|
||||
filePath: 'D:/vibe/claude-code/src/cli/print.ts',
|
||||
})
|
||||
|
||||
expect(params.filePath).toBe('D:/vibe/claude-code/src/cli/print.ts')
|
||||
expect(params.selection).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { ServerWebSocketTransport } from '../src/server/serverWebSocketTransport.js'
|
||||
|
||||
class FakeWebSocket extends EventEmitter {
|
||||
readyState = 1
|
||||
sent: string[] = []
|
||||
closed = false
|
||||
|
||||
send(data: string, callback?: (error?: Error) => void) {
|
||||
this.sent.push(data)
|
||||
callback?.()
|
||||
}
|
||||
|
||||
close() {
|
||||
this.closed = true
|
||||
this.emit('close')
|
||||
}
|
||||
}
|
||||
|
||||
describe('server web socket transport', () => {
|
||||
test('forwards incoming JSON-RPC messages to the MCP server', async () => {
|
||||
const socket = new FakeWebSocket()
|
||||
const transport = new ServerWebSocketTransport(socket)
|
||||
const messages: unknown[] = []
|
||||
|
||||
transport.onmessage = message => {
|
||||
messages.push(message)
|
||||
}
|
||||
|
||||
await transport.start()
|
||||
socket.emit(
|
||||
'message',
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'ping',
|
||||
params: {},
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect(messages).toHaveLength(1)
|
||||
expect(messages[0]).toEqual({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'ping',
|
||||
params: {},
|
||||
})
|
||||
})
|
||||
|
||||
test('serializes outgoing JSON-RPC messages back to the websocket', async () => {
|
||||
const socket = new FakeWebSocket()
|
||||
const transport = new ServerWebSocketTransport(socket)
|
||||
|
||||
await transport.start()
|
||||
await transport.send({
|
||||
jsonrpc: '2.0',
|
||||
id: 2,
|
||||
result: {},
|
||||
})
|
||||
|
||||
expect(socket.sent).toHaveLength(1)
|
||||
expect(JSON.parse(socket.sent[0] ?? 'null')).toEqual({
|
||||
jsonrpc: '2.0',
|
||||
id: 2,
|
||||
result: {},
|
||||
})
|
||||
})
|
||||
})
|
||||
48
packages/vscode-ide-bridge/test/terminalEnvironment.test.ts
Normal file
48
packages/vscode-ide-bridge/test/terminalEnvironment.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
clearClaudeCodeIdePort,
|
||||
setClaudeCodeIdePort,
|
||||
} from '../src/server/terminalEnvironment.js'
|
||||
|
||||
type FakeEnvironmentVariableCollection = {
|
||||
replaceCalls: Array<{ name: string; value: string }>
|
||||
deleteCalls: string[]
|
||||
replace(name: string, value: string): void
|
||||
delete(name: string): void
|
||||
}
|
||||
|
||||
function createFakeCollection(): FakeEnvironmentVariableCollection {
|
||||
return {
|
||||
replaceCalls: [],
|
||||
deleteCalls: [],
|
||||
replace(name, value) {
|
||||
this.replaceCalls.push({ name, value })
|
||||
},
|
||||
delete(name) {
|
||||
this.deleteCalls.push(name)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('terminal environment sync', () => {
|
||||
test('sets CLAUDE_CODE_SSE_PORT to the active bridge port', () => {
|
||||
const collection = createFakeCollection()
|
||||
|
||||
setClaudeCodeIdePort(collection, 52075)
|
||||
|
||||
expect(collection.replaceCalls).toEqual([
|
||||
{
|
||||
name: 'CLAUDE_CODE_SSE_PORT',
|
||||
value: '52075',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('clears CLAUDE_CODE_SSE_PORT when the bridge stops', () => {
|
||||
const collection = createFakeCollection()
|
||||
|
||||
clearClaudeCodeIdePort(collection)
|
||||
|
||||
expect(collection.deleteCalls).toEqual(['CLAUDE_CODE_SSE_PORT'])
|
||||
})
|
||||
})
|
||||
61
packages/vscode-ide-bridge/test/vscodeWorkflow.test.ts
Normal file
61
packages/vscode-ide-bridge/test/vscodeWorkflow.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
type LaunchConfig = {
|
||||
name?: string
|
||||
type?: string
|
||||
request?: string
|
||||
preLaunchTask?: string
|
||||
args?: string[]
|
||||
}
|
||||
|
||||
type TaskConfig = {
|
||||
label?: string
|
||||
command?: string
|
||||
args?: string[]
|
||||
}
|
||||
|
||||
const workspaceRoot = join(import.meta.dir, '..', '..', '..')
|
||||
const launchJsonPath = join(workspaceRoot, '.vscode', 'launch.json')
|
||||
const tasksJsonPath = join(workspaceRoot, '.vscode', 'tasks.json')
|
||||
|
||||
describe('VSCode IDE bridge developer workflow', () => {
|
||||
test('declares a one-click extension host launch config', () => {
|
||||
const launchJson = JSON.parse(readFileSync(launchJsonPath, 'utf8')) as {
|
||||
configurations?: LaunchConfig[]
|
||||
}
|
||||
|
||||
const config = launchJson.configurations?.find(
|
||||
item => item.name === 'Run VSCode IDE Bridge',
|
||||
)
|
||||
|
||||
expect(config).toBeDefined()
|
||||
expect(config?.type).toBe('extensionHost')
|
||||
expect(config?.request).toBe('launch')
|
||||
expect(config?.preLaunchTask).toBe('Build VSCode IDE Bridge')
|
||||
expect(config?.args).toContain('--new-window')
|
||||
expect(config?.args).toContain('--disable-extensions')
|
||||
expect(config?.args).toContain(
|
||||
'--extensionDevelopmentPath=${workspaceFolder}/packages/vscode-ide-bridge',
|
||||
)
|
||||
})
|
||||
|
||||
test('declares a build task for the bridge package', () => {
|
||||
const tasksJson = JSON.parse(readFileSync(tasksJsonPath, 'utf8')) as {
|
||||
tasks?: TaskConfig[]
|
||||
}
|
||||
|
||||
const task = tasksJson.tasks?.find(
|
||||
item => item.label === 'Build VSCode IDE Bridge',
|
||||
)
|
||||
|
||||
expect(task).toBeDefined()
|
||||
expect(task?.command).toBe('bunx')
|
||||
expect(task?.args).toEqual([
|
||||
'tsc',
|
||||
'-p',
|
||||
'packages/vscode-ide-bridge/tsconfig.json',
|
||||
])
|
||||
})
|
||||
})
|
||||
41
packages/vscode-ide-bridge/test/workspaceInfo.test.ts
Normal file
41
packages/vscode-ide-bridge/test/workspaceInfo.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
getActiveSelectionSnapshot,
|
||||
getWorkspaceFolderPaths,
|
||||
} from '../src/server/workspaceInfo.js'
|
||||
|
||||
describe('workspace info helpers', () => {
|
||||
test('collects workspace folder fs paths', () => {
|
||||
expect(
|
||||
getWorkspaceFolderPaths([
|
||||
{ uri: { fsPath: 'D:/vibe/claude-code' } },
|
||||
{ uri: { fsPath: 'D:/vibe/another-project' } },
|
||||
]),
|
||||
).toEqual(['D:/vibe/claude-code', 'D:/vibe/another-project'])
|
||||
})
|
||||
|
||||
test('extracts the active editor selection text and file path', () => {
|
||||
const snapshot = getActiveSelectionSnapshot({
|
||||
document: {
|
||||
uri: { fsPath: 'D:/vibe/claude-code/src/cli/print.ts' },
|
||||
getText(selection: unknown) {
|
||||
expect(selection).toEqual({
|
||||
start: { line: 3, character: 1 },
|
||||
end: { line: 5, character: 0 },
|
||||
isEmpty: false,
|
||||
})
|
||||
return 'selected lines'
|
||||
},
|
||||
},
|
||||
selection: {
|
||||
start: { line: 3, character: 1 },
|
||||
end: { line: 5, character: 0 },
|
||||
isEmpty: false,
|
||||
},
|
||||
})
|
||||
|
||||
expect(snapshot.filePath).toBe('D:/vibe/claude-code/src/cli/print.ts')
|
||||
expect(snapshot.text).toBe('selected lines')
|
||||
expect(snapshot.selection?.start.line).toBe(3)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user