fix(lsp): align operations and harden lifecycle
This commit is contained in:
@@ -18,9 +18,7 @@ const pkgDir = fileURLToPath(new URL('..', import.meta.url))
|
||||
const seamLib = join(pkgDir, '../lsp/lib/index.js')
|
||||
const built = existsSync(join(pkgDir, 'lib/index.js')) && existsSync(seamLib)
|
||||
|
||||
const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
|
||||
const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', import.meta.url))
|
||||
const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
|
||||
|
||||
let root: string
|
||||
let ws: string
|
||||
@@ -49,13 +47,13 @@ describe.skipIf(!built)('built lib real load path (plain node)', () => {
|
||||
servers: {
|
||||
fake: {
|
||||
command: ${JSON.stringify(process.execPath)},
|
||||
args: ['--import', ${JSON.stringify(tsxLoader)}, ${JSON.stringify(fixtureServer)}],
|
||||
env: { TSX_TSCONFIG_PATH: ${JSON.stringify(repoTsconfig)}, LSP_FAKE_DEF: ${JSON.stringify(location)} },
|
||||
args: [${JSON.stringify(fixtureServer)}],
|
||||
env: { LSP_FAKE_DEF: ${JSON.stringify(location)} },
|
||||
extensionToLanguage: { '.ts': 'typescript' },
|
||||
},
|
||||
},
|
||||
})
|
||||
const result = await ctx.lsp.query({ operation: 'definition', filePath: 'a.ts', position: { line: 0, character: 6 }, workspaceRoot: ${JSON.stringify(ws)} })
|
||||
const result = await ctx.lsp.query({ operation: 'goToDefinition', filePath: 'a.ts', position: { line: 0, character: 6 }, workspaceRoot: ${JSON.stringify(ws)} })
|
||||
console.log(JSON.stringify(result))
|
||||
await ctx.fiber.dispose()
|
||||
`
|
||||
|
||||
@@ -2,9 +2,7 @@ import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { LspConnection } from '@deepseek-ai/dsh-lsp-local'
|
||||
|
||||
const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
|
||||
const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', import.meta.url))
|
||||
const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
|
||||
|
||||
/** A recorded server→client request the test's handler saw. */
|
||||
interface SeenRequest { method: string; params: unknown }
|
||||
@@ -27,9 +25,9 @@ function connect(
|
||||
): LspConnection {
|
||||
const conn = new LspConnection({
|
||||
command: process.execPath,
|
||||
args: ['--import', tsxLoader, fixtureServer],
|
||||
args: [fixtureServer],
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env as Record<string, string>, TSX_TSCONFIG_PATH: repoTsconfig, ...env },
|
||||
env: { ...process.env as Record<string, string>, ...env },
|
||||
maxMessageBytes: 16_000_000,
|
||||
maxStderrBytes: 100_000,
|
||||
configuration: { setting: 42 },
|
||||
@@ -69,7 +67,7 @@ describe('LspConnection', () => {
|
||||
seen,
|
||||
)
|
||||
await conn.request('initialize', { capabilities: {} })
|
||||
conn.notify('textDocument/didOpen', { textDocument: { uri: 'file:///x', languageId: 'ts', version: 1, text: '' } })
|
||||
await conn.notify('textDocument/didOpen', { textDocument: { uri: 'file:///x', languageId: 'ts', version: 1, text: '' } })
|
||||
await waitFor(() => seen.some(s => s.method === 'workspace/configuration'))
|
||||
expect(seen[0]?.method).toBe('workspace/configuration')
|
||||
})
|
||||
@@ -77,7 +75,7 @@ describe('LspConnection', () => {
|
||||
it('drops a server→client notification without replying', async () => {
|
||||
const conn = connect({ LSP_FAKE_ON_OPEN: 'notification' })
|
||||
await conn.request('initialize', { capabilities: {} })
|
||||
conn.notify('textDocument/didOpen', { textDocument: { uri: 'file:///x', languageId: 'ts', version: 1, text: '' } })
|
||||
await conn.notify('textDocument/didOpen', { textDocument: { uri: 'file:///x', languageId: 'ts', version: 1, text: '' } })
|
||||
// No throw and the connection stays usable.
|
||||
await expect(conn.request('textDocument/hover', {})).resolves.toBeDefined()
|
||||
})
|
||||
@@ -90,7 +88,7 @@ describe('LspConnection', () => {
|
||||
seen,
|
||||
)
|
||||
await conn.request('initialize', { capabilities: {} })
|
||||
conn.notify('textDocument/didOpen', { textDocument: { uri: 'file:///x', languageId: 'ts', version: 1, text: '' } })
|
||||
await conn.notify('textDocument/didOpen', { textDocument: { uri: 'file:///x', languageId: 'ts', version: 1, text: '' } })
|
||||
await waitFor(() => seen.some(s => s.method === 'workspace/applyEdit'))
|
||||
// The connection remains healthy after emitting the error response.
|
||||
await expect(conn.request('textDocument/hover', {})).resolves.toBeDefined()
|
||||
@@ -211,6 +209,15 @@ describe('LspConnection edge behavior', () => {
|
||||
await expect(conn.request('initialize', {})).rejects.toThrow(/exited|closed/)
|
||||
})
|
||||
|
||||
it('rejects a pending request when child stdin closes but the process stays alive', async () => {
|
||||
const conn = connectScript('require("node:fs").closeSync(0); setInterval(()=>{}, 1000)')
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 100))
|
||||
const timeout = new Promise<never>((_resolve, reject) => {
|
||||
setTimeout(() => { reject(new Error('request timed out')) }, 1000)
|
||||
})
|
||||
await expect(Promise.race([conn.request('initialize', {}), timeout])).rejects.not.toThrow(/timed out/)
|
||||
})
|
||||
|
||||
it('ignores a frame that is neither a valid request nor a numeric-id response', async () => {
|
||||
// A frame with a string id and no method: not dispatchable; the client must ignore it and still
|
||||
// answer our real request.
|
||||
|
||||
@@ -12,6 +12,9 @@
|
||||
* - LSP_FAKE_CRASH_ON_OPEN: "1" exits the process when a didOpen arrives (crash test).
|
||||
* - LSP_FAKE_EXIT_AFTER_REPLY: "1" exits the process right after answering a textDocument/* request,
|
||||
* simulating a server that dies while idle so the pool holds a dead instance (eviction test).
|
||||
* - LSP_FAKE_REPLY_DELAY_MS: delays each textDocument/* response by this many milliseconds.
|
||||
* - LSP_FAKE_OPEN_MARKER: appends each didOpen document text as one JSON line to this path.
|
||||
* - LSP_FAKE_CLOSE_STDIN_AFTER_REPLY: "1" closes fd 0 before sending the first query response.
|
||||
* - LSP_FAKE_EXIT_DELAY_MS / LSP_FAKE_EXIT_MARKER: delay protocol exit and record exit/termination.
|
||||
* - LSP_FAKE_NO_SHUTDOWN: "1" ignores the shutdown request (forces kill escalation).
|
||||
* - LSP_FAKE_ON_OPEN: server→client request to emit when a didOpen arrives, one of
|
||||
@@ -19,10 +22,10 @@
|
||||
* - LSP_FAKE_ERROR: "1" answers textDocument/* requests with a JSON-RPC error response.
|
||||
* - LSP_FAKE_GARBAGE: "1" emits an unframed garbage byte before the initialize reply.
|
||||
*
|
||||
* Run: node --import tsx fixture-server.ts
|
||||
* Run: node fixture-server.ts (Node's erasable TypeScript syntax support).
|
||||
*/
|
||||
|
||||
import { appendFileSync } from 'node:fs'
|
||||
import { appendFileSync, closeSync } from 'node:fs'
|
||||
|
||||
const enc = process.env.LSP_FAKE_ENCODING ?? 'utf-16'
|
||||
const sync: unknown = process.env.LSP_FAKE_SYNC !== undefined ? JSON.parse(process.env.LSP_FAKE_SYNC) : 1
|
||||
@@ -30,6 +33,9 @@ const extraCaps: unknown = process.env.LSP_FAKE_CAPS !== undefined ? JSON.parse(
|
||||
const hang = process.env.LSP_FAKE_HANG === '1'
|
||||
const crashOnOpen = process.env.LSP_FAKE_CRASH_ON_OPEN === '1'
|
||||
const exitAfterReply = process.env.LSP_FAKE_EXIT_AFTER_REPLY === '1'
|
||||
const replyDelayMs = Number(process.env.LSP_FAKE_REPLY_DELAY_MS ?? 0)
|
||||
const openMarker = process.env.LSP_FAKE_OPEN_MARKER
|
||||
const closeStdinAfterReply = process.env.LSP_FAKE_CLOSE_STDIN_AFTER_REPLY === '1'
|
||||
const exitDelayMs = Number(process.env.LSP_FAKE_EXIT_DELAY_MS ?? 0)
|
||||
const exitMarker = process.env.LSP_FAKE_EXIT_MARKER
|
||||
const noShutdown = process.env.LSP_FAKE_NO_SHUTDOWN === '1'
|
||||
@@ -124,17 +130,29 @@ function handle(message: { id?: number; method?: string; params?: unknown; resul
|
||||
}
|
||||
if (method === 'textDocument/didOpen') {
|
||||
if (crashOnOpen) process.exit(1)
|
||||
if (openMarker !== undefined) {
|
||||
const params = message.params as { textDocument?: { text?: unknown } } | undefined
|
||||
appendFileSync(openMarker, `${JSON.stringify(params?.textDocument?.text)}\n`)
|
||||
}
|
||||
if (onOpen !== undefined) emitServerRequest(onOpen)
|
||||
return
|
||||
}
|
||||
if (method === 'textDocument/didClose' || method === 'initialized') return
|
||||
if (method?.startsWith('textDocument/')) {
|
||||
if (hang) return
|
||||
if (errorReply) { send({ id, error: { code: -32000, message: 'server refused the request' } }); return }
|
||||
send({ id, result: resultFor(method) })
|
||||
// Simulate an idle death: answer this request, then exit before the next one arrives so the pool
|
||||
// is left holding a dead instance.
|
||||
if (exitAfterReply) setTimeout(() => process.exit(0), 20)
|
||||
const reply = (): void => {
|
||||
if (closeStdinAfterReply) closeSync(0)
|
||||
if (errorReply) {
|
||||
send({ id, error: { code: -32000, message: 'server refused the request' } })
|
||||
} else {
|
||||
send({ id, result: resultFor(method) })
|
||||
}
|
||||
// Simulate an idle death: answer this request, then exit before the next one arrives so the
|
||||
// pool is left holding a dead instance.
|
||||
if (exitAfterReply) setTimeout(() => process.exit(0), 20)
|
||||
}
|
||||
if (replyDelayMs > 0) setTimeout(reply, replyDelayMs)
|
||||
else reply()
|
||||
return
|
||||
}
|
||||
// Unknown request with an id: answer null so the client never stalls.
|
||||
@@ -172,3 +190,4 @@ function send(message: Record<string, unknown>): void {
|
||||
|
||||
// Keep the event loop alive.
|
||||
process.stdin.resume()
|
||||
if (closeStdinAfterReply) setInterval(() => {}, 1000)
|
||||
|
||||
@@ -3,8 +3,13 @@ import { mkdtemp, mkdir, rm, symlink, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { realpath } from 'node:fs/promises'
|
||||
import { execFile } from 'node:child_process'
|
||||
import { promisify } from 'node:util'
|
||||
import { deadline } from '@deepseek-ai/dsh-timeout'
|
||||
import { canonicalizeWorkspace, readHostSource } from '@deepseek-ai/dsh-lsp-local'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
let root: string
|
||||
let ws: string
|
||||
|
||||
@@ -87,6 +92,19 @@ describe('readHostSource', () => {
|
||||
await expect(readHostSource('dir', ws, BIG)).rejects.toThrow(/not a regular file/)
|
||||
})
|
||||
|
||||
it('rejects a FIFO with no writer without blocking in open', async () => {
|
||||
const fifo = join(ws, 'pipe.ts')
|
||||
await execFileAsync('mkfifo', [fifo])
|
||||
using d = deadline(undefined, 1000, 'FIFO_READ_TIMEOUT')
|
||||
await expect(readHostSource('pipe.ts', ws, BIG, d.signal)).rejects.toThrow(/not a regular file/)
|
||||
})
|
||||
|
||||
it('honors a pre-aborted source read before filesystem work', async () => {
|
||||
const controller = new AbortController()
|
||||
controller.abort(new Error('source read cancelled'))
|
||||
await expect(readHostSource('missing.ts', ws, BIG, controller.signal)).rejects.toThrow(/source read cancelled/)
|
||||
})
|
||||
|
||||
it('treats the workspace root itself as inside, then rejects it as non-regular', async () => {
|
||||
// filePath '.' canonicalizes to the workspace dir: isInside's identity branch is taken, and the
|
||||
// directory then fails the regular-file check.
|
||||
|
||||
@@ -7,9 +7,7 @@ import { LspInstance, readHostSource } from '@deepseek-ai/dsh-lsp-local'
|
||||
import type { InstanceSpec } from '@deepseek-ai/dsh-lsp-local/src/instance.ts'
|
||||
import type { LspProviderQuery, LspQueryResult } from '@deepseek-ai/dsh-lsp'
|
||||
|
||||
const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
|
||||
const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', import.meta.url))
|
||||
const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
|
||||
|
||||
let root: string
|
||||
let ws: string
|
||||
@@ -31,9 +29,9 @@ afterEach(async () => {
|
||||
function makeInstance(env: Record<string, string> = {}, overrides: Partial<InstanceSpec> = {}): LspInstance {
|
||||
const instance = new LspInstance({
|
||||
command: process.execPath,
|
||||
args: ['--import', tsxLoader, fixtureServer],
|
||||
args: [fixtureServer],
|
||||
cwd: ws,
|
||||
env: { ...process.env as Record<string, string>, TSX_TSCONFIG_PATH: repoTsconfig, ...env },
|
||||
env: { ...process.env as Record<string, string>, ...env },
|
||||
configuration: { setting: 42 },
|
||||
initializationOptions: { init: true },
|
||||
maxMessageBytes: 16_000_000,
|
||||
@@ -46,12 +44,12 @@ function makeInstance(env: Record<string, string> = {}, overrides: Partial<Insta
|
||||
return instance
|
||||
}
|
||||
|
||||
function query(operation: LspProviderQuery['operation'] = 'definition'): LspProviderQuery {
|
||||
function query(operation: LspProviderQuery['operation'] = 'goToDefinition'): LspProviderQuery {
|
||||
return { operation, filePath: 'a.ts', position: { line: 0, character: 6 }, workspaceRoot: ws, languageId: 'typescript' }
|
||||
}
|
||||
|
||||
/** Run a query against an instance, reading the source first the way the provider does. */
|
||||
async function run(instance: LspInstance, operation: LspProviderQuery['operation'] = 'definition', signal?: AbortSignal): Promise<LspQueryResult> {
|
||||
async function run(instance: LspInstance, operation: LspProviderQuery['operation'] = 'goToDefinition', signal?: AbortSignal): Promise<LspQueryResult> {
|
||||
const source = await readHostSource('a.ts', ws, 4_000_000)
|
||||
return instance.query(query(operation), source, signal)
|
||||
}
|
||||
@@ -91,43 +89,43 @@ describe('LspInstance server-request handling', () => {
|
||||
const instance = makeInstance({ LSP_FAKE_ON_OPEN: 'configuration', LSP_FAKE_DEF: locJson() })
|
||||
// The query drives didOpen, which makes the fake emit workspace/configuration; a healthy answer
|
||||
// keeps the query working.
|
||||
await expect(run(instance, 'definition')).resolves.toMatchObject({ kind: 'locations' })
|
||||
await expect(run(instance, 'goToDefinition')).resolves.toMatchObject({ kind: 'locations' })
|
||||
})
|
||||
|
||||
it('accepts a lifecycle client/registerCapability request', async () => {
|
||||
const instance = makeInstance({ LSP_FAKE_ON_OPEN: 'lifecycle', LSP_FAKE_DEF: 'null' })
|
||||
await expect(run(instance, 'definition')).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws })
|
||||
await expect(run(instance, 'goToDefinition')).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws })
|
||||
})
|
||||
|
||||
it('rejects a workspace/applyEdit request but keeps serving', async () => {
|
||||
const instance = makeInstance({ LSP_FAKE_ON_OPEN: 'applyEdit', LSP_FAKE_DEF: 'null' })
|
||||
await expect(run(instance, 'definition')).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws })
|
||||
await expect(run(instance, 'goToDefinition')).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws })
|
||||
})
|
||||
|
||||
it('rejects an unknown server request but keeps serving', async () => {
|
||||
const instance = makeInstance({ LSP_FAKE_ON_OPEN: 'unknown', LSP_FAKE_DEF: 'null' })
|
||||
await expect(run(instance, 'definition')).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws })
|
||||
await expect(run(instance, 'goToDefinition')).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws })
|
||||
})
|
||||
})
|
||||
|
||||
describe('LspInstance query and abort', () => {
|
||||
it('sends includeDeclaration for references', async () => {
|
||||
const instance = makeInstance({ LSP_FAKE_REFS: JSON.stringify([JSON.parse(locJson())]) })
|
||||
await expect(run(instance, 'references')).resolves.toMatchObject({ kind: 'locations' })
|
||||
await expect(run(instance, 'findReferences')).resolves.toMatchObject({ kind: 'locations' })
|
||||
})
|
||||
|
||||
it('rejects a query aborted before it starts', async () => {
|
||||
const instance = makeInstance({ LSP_FAKE_DEF: 'null' })
|
||||
const controller = new AbortController()
|
||||
controller.abort(new Error('pre-abort'))
|
||||
await expect(run(instance, 'definition', controller.signal)).rejects.toThrow(/pre-abort/)
|
||||
await expect(run(instance, 'goToDefinition', controller.signal)).rejects.toThrow(/pre-abort/)
|
||||
})
|
||||
|
||||
it('cancels an in-flight request on abort and rejects', async () => {
|
||||
const instance = makeInstance({ LSP_FAKE_HANG: '1' })
|
||||
const controller = new AbortController()
|
||||
// Warm the instance first so the abort lands during the hanging request, not during startup.
|
||||
const pending = run(instance, 'definition', controller.signal)
|
||||
const pending = run(instance, 'goToDefinition', controller.signal)
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 300))
|
||||
controller.abort(new Error('mid-flight'))
|
||||
await expect(pending).rejects.toThrow(/mid-flight/)
|
||||
@@ -138,7 +136,7 @@ describe('LspInstance query and abort', () => {
|
||||
// down (its process closed) rather than left with an active request.
|
||||
const instance = makeInstance({ LSP_FAKE_HANG: '1' }, { killGraceMs: 100 })
|
||||
const controller = new AbortController()
|
||||
const pending = run(instance, 'definition', controller.signal)
|
||||
const pending = run(instance, 'goToDefinition', controller.signal)
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 300))
|
||||
controller.abort(new Error('mid-flight'))
|
||||
await expect(pending).rejects.toThrow(/mid-flight/)
|
||||
@@ -159,7 +157,7 @@ describe('LspInstance query and abort', () => {
|
||||
+ '}});'
|
||||
const instance = scriptInstance(script, { killGraceMs: 2_000 })
|
||||
const controller = new AbortController()
|
||||
const pending = run(instance, 'definition', controller.signal)
|
||||
const pending = run(instance, 'goToDefinition', controller.signal)
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 300))
|
||||
controller.abort(new Error('mid-flight'))
|
||||
await expect(pending).rejects.toThrow(/mid-flight/)
|
||||
@@ -173,7 +171,7 @@ describe('LspInstance query and abort', () => {
|
||||
// observed during that wait instead of hanging the tool-timeout signal.
|
||||
const instance = scriptInstance('setInterval(()=>{},1000)', { killGraceMs: 100 })
|
||||
const controller = new AbortController()
|
||||
const pending = run(instance, 'definition', controller.signal)
|
||||
const pending = run(instance, 'goToDefinition', controller.signal)
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 150))
|
||||
controller.abort(new Error('handshake-abort'))
|
||||
await expect(pending).rejects.toThrow(/handshake-abort/)
|
||||
@@ -182,7 +180,7 @@ describe('LspInstance query and abort', () => {
|
||||
|
||||
it('rejects when the server lacks the operation capability', async () => {
|
||||
const instance = makeInstance({ LSP_FAKE_CAPS: JSON.stringify({ definitionProvider: false }), LSP_FAKE_DEF: 'null' })
|
||||
await expect(run(instance, 'definition')).rejects.toThrow(/does not support definition/)
|
||||
await expect(run(instance, 'goToDefinition')).rejects.toThrow(/does not support goToDefinition/)
|
||||
})
|
||||
|
||||
it('propagates a server error response even when a signal is supplied (not an abort)', async () => {
|
||||
@@ -190,7 +188,20 @@ describe('LspInstance query and abort', () => {
|
||||
// without treating it as an abort.
|
||||
const instance = makeInstance({ LSP_FAKE_ERROR: '1' })
|
||||
const controller = new AbortController()
|
||||
await expect(run(instance, 'definition', controller.signal)).rejects.toThrow(/server refused/)
|
||||
await expect(run(instance, 'goToDefinition', controller.signal)).rejects.toThrow(/server refused/)
|
||||
})
|
||||
|
||||
it('keeps a settled result but awaits teardown when didClose cannot be written', async () => {
|
||||
const instance = makeInstance({
|
||||
LSP_FAKE_DEF: 'null',
|
||||
LSP_FAKE_CLOSE_STDIN_AFTER_REPLY: '1',
|
||||
}, { shutdownTimeoutMs: 100, killGraceMs: 100 })
|
||||
await expect(run(instance, 'goToDefinition')).resolves.toEqual({
|
||||
kind: 'locations',
|
||||
locations: [],
|
||||
resolvedWorkspaceRoot: ws,
|
||||
})
|
||||
expect(instance.dead).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -202,28 +213,28 @@ describe('LspInstance disposal', () => {
|
||||
LSP_FAKE_EXIT_DELAY_MS: '75',
|
||||
LSP_FAKE_EXIT_MARKER: marker,
|
||||
}, { shutdownTimeoutMs: 500 })
|
||||
await run(instance, 'definition')
|
||||
await run(instance, 'goToDefinition')
|
||||
await instance.dispose()
|
||||
expect(await readFile(marker, 'utf8')).toBe('EXIT\nCLEAN\n')
|
||||
})
|
||||
|
||||
it('is idempotent — a second dispose awaits close without error', async () => {
|
||||
const instance = makeInstance({ LSP_FAKE_DEF: 'null' })
|
||||
await run(instance, 'definition')
|
||||
await run(instance, 'goToDefinition')
|
||||
await instance.dispose()
|
||||
await expect(instance.dispose()).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects a query after disposal', async () => {
|
||||
const instance = makeInstance({ LSP_FAKE_DEF: 'null' })
|
||||
await run(instance, 'definition')
|
||||
await run(instance, 'goToDefinition')
|
||||
await instance.dispose()
|
||||
await expect(run(instance, 'definition')).rejects.toThrow(/disposed/)
|
||||
await expect(run(instance, 'goToDefinition')).rejects.toThrow(expect.objectContaining({ code: 'LSP_DISPOSED' }))
|
||||
})
|
||||
|
||||
it('reports dead after the process closes', async () => {
|
||||
const instance = makeInstance({ LSP_FAKE_DEF: 'null' })
|
||||
await run(instance, 'definition')
|
||||
await run(instance, 'goToDefinition')
|
||||
await instance.dispose()
|
||||
expect(instance.dead).toBe(true)
|
||||
})
|
||||
@@ -232,7 +243,7 @@ describe('LspInstance disposal', () => {
|
||||
// Server answers initialize, ignores shutdown, and traps SIGTERM so only SIGKILL stops it.
|
||||
const script = RESPONDING_SERVER + 'process.on("SIGTERM",()=>{});'
|
||||
const instance = scriptInstance(script, { shutdownTimeoutMs: 100, killGraceMs: 100 })
|
||||
await run(instance, 'definition')
|
||||
await run(instance, 'goToDefinition')
|
||||
await expect(instance.dispose()).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -244,7 +255,7 @@ describe('LspInstance disposal', () => {
|
||||
+ `writeFileSync(${JSON.stringify(marker)},String(helper.pid));`
|
||||
+ RESPONDING_SERVER
|
||||
const instance = scriptInstance(script, { shutdownTimeoutMs: 100, killGraceMs: 100 })
|
||||
await run(instance, 'definition')
|
||||
await run(instance, 'goToDefinition')
|
||||
const helperPid = Number(await readFile(marker, 'utf8'))
|
||||
try {
|
||||
const first = instance.dispose()
|
||||
@@ -259,7 +270,7 @@ describe('LspInstance disposal', () => {
|
||||
it('carries a non-Error abort reason as a generic aborted error', async () => {
|
||||
const instance = makeInstance({ LSP_FAKE_HANG: '1' })
|
||||
const controller = new AbortController()
|
||||
const pending = run(instance, 'definition', controller.signal)
|
||||
const pending = run(instance, 'goToDefinition', controller.signal)
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 200))
|
||||
controller.abort('a string reason, not an Error')
|
||||
await expect(pending).rejects.toThrow(/aborted/)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { realpath } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
@@ -10,9 +10,7 @@ import { deadline } from '@deepseek-ai/dsh-timeout'
|
||||
import * as LspLocal from '@deepseek-ai/dsh-lsp-local'
|
||||
import type { LspLocalServerConfig } from '@deepseek-ai/dsh-lsp-local'
|
||||
|
||||
const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
|
||||
const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', import.meta.url))
|
||||
const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
|
||||
|
||||
let root: string
|
||||
let ws: string
|
||||
@@ -32,8 +30,8 @@ afterEach(async () => {
|
||||
function fakeServer(fakeEnv: Record<string, string> = {}, overrides: Partial<LspLocalServerConfig> = {}): LspLocalServerConfig {
|
||||
return {
|
||||
command: process.execPath,
|
||||
args: ['--import', tsxLoader, fixtureServer],
|
||||
env: { TSX_TSCONFIG_PATH: repoTsconfig, ...fakeEnv },
|
||||
args: [fixtureServer],
|
||||
env: { ...fakeEnv },
|
||||
extensionToLanguage: { '.ts': 'typescript' },
|
||||
...overrides,
|
||||
}
|
||||
@@ -79,7 +77,7 @@ describe('lsp-local end to end over a fake server', () => {
|
||||
|
||||
it('resolves definition to normalized locations', async () => {
|
||||
const ctx = await mount({ LSP_FAKE_DEF: JSON.stringify(locationJson(0)) })
|
||||
const result = await ctx.lsp.query(query('definition'))
|
||||
const result = await ctx.lsp.query(query('goToDefinition'))
|
||||
expect(result).toEqual<LspQueryResult>({
|
||||
kind: 'locations',
|
||||
locations: [{ uri: pathToFileURL(join(ws, 'a.ts')).href, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 3 } } }],
|
||||
@@ -91,14 +89,14 @@ describe('lsp-local end to end over a fake server', () => {
|
||||
it('maps a LocationLink for implementation', async () => {
|
||||
const link = { targetUri: pathToFileURL(join(ws, 'a.ts')).href, targetSelectionRange: { start: { line: 1, character: 0 }, end: { line: 1, character: 2 } } }
|
||||
const ctx = await mount({ LSP_FAKE_IMPL: JSON.stringify([link]) })
|
||||
const result = await ctx.lsp.query(query('implementation'))
|
||||
const result = await ctx.lsp.query(query('goToImplementation'))
|
||||
expect(result).toMatchObject({ kind: 'locations', locations: [{ range: { start: { line: 1, character: 0 } } }] })
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('returns references (server includes the declaration)', async () => {
|
||||
const ctx = await mount({ LSP_FAKE_REFS: JSON.stringify([locationJson(0), locationJson(1)]) })
|
||||
const result = await ctx.lsp.query(query('references'))
|
||||
const result = await ctx.lsp.query(query('findReferences'))
|
||||
expect(result).toMatchObject({ kind: 'locations' })
|
||||
if (result.kind !== 'locations') throw new Error('expected locations')
|
||||
expect(result.locations).toHaveLength(2)
|
||||
@@ -114,7 +112,7 @@ describe('lsp-local end to end over a fake server', () => {
|
||||
|
||||
it('returns an empty locations result for a null definition', async () => {
|
||||
const ctx = await mount({ LSP_FAKE_DEF: 'null' })
|
||||
expect(await ctx.lsp.query(query('definition'))).toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws })
|
||||
expect(await ctx.lsp.query(query('goToDefinition'))).toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws })
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
@@ -126,7 +124,7 @@ describe('lsp-local end to end over a fake server', () => {
|
||||
|
||||
it('rejects a non-utf-16 position encoding at initialize', async () => {
|
||||
const ctx = await mount({ LSP_FAKE_ENCODING: 'utf-8', LSP_FAKE_DEF: 'null' })
|
||||
await expect(ctx.lsp.query(query('definition'))).rejects.toThrow(/unsupported position encoding/)
|
||||
await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toThrow(/unsupported position encoding/)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
@@ -134,21 +132,21 @@ describe('lsp-local end to end over a fake server', () => {
|
||||
// A utf-8 server makes `initialize` reject; the instance must be torn down (not left with a
|
||||
// permanently-rejecting `ready`) so a later query starts a fresh process rather than reusing it.
|
||||
const ctx = await mount({ LSP_FAKE_ENCODING: 'utf-8', LSP_FAKE_DEF: 'null' })
|
||||
await expect(ctx.lsp.query(query('definition'))).rejects.toThrow(/unsupported position encoding/)
|
||||
await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toThrow(/unsupported position encoding/)
|
||||
// A second query must also fail the same way (fresh instance), and must NOT hang on a poisoned one.
|
||||
await expect(ctx.lsp.query(query('definition'))).rejects.toThrow(/unsupported position encoding/)
|
||||
await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toThrow(/unsupported position encoding/)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rejects a server without transient-open sync (None)', async () => {
|
||||
const ctx = await mount({ LSP_FAKE_SYNC: '0', LSP_FAKE_DEF: 'null' })
|
||||
await expect(ctx.lsp.query(query('definition'))).rejects.toThrow(/transient textDocument\/didOpen/)
|
||||
await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toThrow(/transient textDocument\/didOpen/)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('accepts openClose options sync', async () => {
|
||||
const ctx = await mount({ LSP_FAKE_SYNC: JSON.stringify({ openClose: true, change: 2 }), LSP_FAKE_DEF: 'null' })
|
||||
expect(await ctx.lsp.query(query('definition'))).toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws })
|
||||
expect(await ctx.lsp.query(query('goToDefinition'))).toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws })
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
@@ -162,25 +160,44 @@ describe('lsp-local end to end over a fake server', () => {
|
||||
const outside = join(root, 'out.ts')
|
||||
await writeFile(outside, 'x')
|
||||
const ctx = await mount({ LSP_FAKE_DEF: 'null' })
|
||||
await expect(ctx.lsp.query({ ...query('definition'), filePath: outside })).rejects.toThrow(/outside the workspace/)
|
||||
await expect(ctx.lsp.query({ ...query('goToDefinition'), filePath: outside })).rejects.toThrow(/outside the workspace/)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('serializes queries through one instance and runs them in order', async () => {
|
||||
const ctx = await mount({ LSP_FAKE_DEF: JSON.stringify(locationJson(0)) })
|
||||
const results = await Promise.all([
|
||||
ctx.lsp.query(query('definition')),
|
||||
ctx.lsp.query(query('definition')),
|
||||
ctx.lsp.query(query('definition')),
|
||||
ctx.lsp.query(query('goToDefinition')),
|
||||
ctx.lsp.query(query('goToDefinition')),
|
||||
ctx.lsp.query(query('goToDefinition')),
|
||||
])
|
||||
for (const result of results) expect(result).toMatchObject({ kind: 'locations' })
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('reads a queued query source only when its lifecycle starts', async () => {
|
||||
const marker = join(root, 'opened.jsonl')
|
||||
const ctx = await mount({
|
||||
LSP_FAKE_DEF: 'null',
|
||||
LSP_FAKE_REPLY_DELAY_MS: '300',
|
||||
LSP_FAKE_OPEN_MARKER: marker,
|
||||
})
|
||||
const first = ctx.lsp.query(query('goToDefinition'))
|
||||
await waitFor(async () => (await markerLines(marker)).length === 1)
|
||||
const second = ctx.lsp.query(query('goToDefinition'))
|
||||
await writeFile(join(ws, 'a.ts'), 'const changed = 2\n')
|
||||
await Promise.all([first, second])
|
||||
expect(await markerLines(marker)).toEqual([
|
||||
'const x = 1\nconst y = x\n',
|
||||
'const changed = 2\n',
|
||||
])
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('aborts an in-flight query when the signal fires', async () => {
|
||||
const ctx = await mount({ LSP_FAKE_HANG: '1' })
|
||||
const controller = new AbortController()
|
||||
const pending = ctx.lsp.query(query('definition'), controller.signal)
|
||||
const pending = ctx.lsp.query(query('goToDefinition'), controller.signal)
|
||||
controller.abort(new Error('caller cancelled'))
|
||||
await expect(pending).rejects.toThrow(/cancelled/)
|
||||
await ctx.fiber.dispose()
|
||||
@@ -190,7 +207,7 @@ describe('lsp-local end to end over a fake server', () => {
|
||||
const ctx = await mount({ LSP_FAKE_DEF: 'null' })
|
||||
const controller = new AbortController()
|
||||
controller.abort(new Error('pre-aborted'))
|
||||
await expect(ctx.lsp.query(query('definition'), controller.signal)).rejects.toThrow(/pre-aborted/)
|
||||
await expect(ctx.lsp.query(query('goToDefinition'), controller.signal)).rejects.toThrow(/pre-aborted/)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
@@ -201,22 +218,22 @@ describe('lsp-local end to end over a fake server', () => {
|
||||
command: process.execPath,
|
||||
args: ['-e', 'process.stderr.write("FATAL: boom\\n"); setTimeout(()=>process.exit(1), 50)'],
|
||||
})
|
||||
await expect(ctx.lsp.query(query('definition'))).rejects.toThrow(/FATAL: boom/)
|
||||
await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toThrow(/FATAL: boom/)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('classifies a timeout deadline as the abort reason', async () => {
|
||||
const ctx = await mount({ LSP_FAKE_HANG: '1' })
|
||||
using d = deadline(undefined, 50, 'TEST_TIMEOUT')
|
||||
await expect(ctx.lsp.query(query('definition'), d.signal)).rejects.toThrow(/TEST_TIMEOUT/)
|
||||
await expect(ctx.lsp.query(query('goToDefinition'), d.signal)).rejects.toThrow(/TEST_TIMEOUT/)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('fails the active query when the server crashes on open, and replaces it next query', async () => {
|
||||
const ctx = await mount({ LSP_FAKE_CRASH_ON_OPEN: '1', LSP_FAKE_DEF: 'null' }, { shutdownTimeoutMs: 100, killGraceMs: 100 })
|
||||
await expect(ctx.lsp.query(query('definition'))).rejects.toThrow()
|
||||
await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toThrow()
|
||||
// A later query starts a fresh process; still crashes, but proves the slot was replaced (no hang).
|
||||
await expect(ctx.lsp.query(query('definition'))).rejects.toThrow()
|
||||
await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toThrow()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
@@ -225,10 +242,10 @@ describe('lsp-local end to end over a fake server', () => {
|
||||
// instance in the pool. The next query must evict-and-replace it and still succeed, rather than
|
||||
// failing once on the closed connection first.
|
||||
const ctx = await mount({ LSP_FAKE_EXIT_AFTER_REPLY: '1', LSP_FAKE_DEF: JSON.stringify(locationJson(0)) })
|
||||
expect(await ctx.lsp.query(query('definition'))).toMatchObject({ kind: 'locations' })
|
||||
expect(await ctx.lsp.query(query('goToDefinition'))).toMatchObject({ kind: 'locations' })
|
||||
// Wait past the fixture's post-reply exit so the pooled instance is observably dead.
|
||||
await new Promise(resolve => setTimeout(resolve, 60))
|
||||
expect(await ctx.lsp.query(query('definition'))).toMatchObject({ kind: 'locations' })
|
||||
expect(await ctx.lsp.query(query('goToDefinition'))).toMatchObject({ kind: 'locations' })
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
@@ -237,11 +254,11 @@ describe('lsp-local end to end over a fake server', () => {
|
||||
// are awaited, so the pre-spawn recheck must reject without ever creating a pooled instance.
|
||||
const ctx = await mount({ LSP_FAKE_DEF: 'null' })
|
||||
const controller = new AbortController()
|
||||
const pending = ctx.lsp.query(query('definition'), controller.signal)
|
||||
const pending = ctx.lsp.query(query('goToDefinition'), controller.signal)
|
||||
controller.abort(new Error('mid-read cancel'))
|
||||
await expect(pending).rejects.toThrow(/mid-read cancel/)
|
||||
// A subsequent live query still works, proving no half-created instance poisoned the pool.
|
||||
expect(await ctx.lsp.query(query('definition'))).toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws })
|
||||
expect(await ctx.lsp.query(query('goToDefinition'))).toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws })
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
@@ -251,8 +268,8 @@ describe('lsp-local end to end over a fake server', () => {
|
||||
await writeFile(join(ws2, 'a.ts'), 'const z = 2\n')
|
||||
const ctx = await mount({ LSP_FAKE_DEF: JSON.stringify(locationJson(0)) })
|
||||
const [r1, r2] = await Promise.all([
|
||||
ctx.lsp.query({ ...query('definition'), workspaceRoot: ws }),
|
||||
ctx.lsp.query({ ...query('definition'), workspaceRoot: ws2 }),
|
||||
ctx.lsp.query({ ...query('goToDefinition'), workspaceRoot: ws }),
|
||||
ctx.lsp.query({ ...query('goToDefinition'), workspaceRoot: ws2 }),
|
||||
])
|
||||
expect(r1).toMatchObject({ kind: 'locations' })
|
||||
expect(r2).toMatchObject({ kind: 'locations' })
|
||||
@@ -261,7 +278,7 @@ describe('lsp-local end to end over a fake server', () => {
|
||||
|
||||
it('disposes cleanly, terminating a server that ignores shutdown', async () => {
|
||||
const ctx = await mount({ LSP_FAKE_NO_SHUTDOWN: '1', LSP_FAKE_DEF: 'null' }, { killGraceMs: 100, shutdownTimeoutMs: 100 })
|
||||
await ctx.lsp.query(query('definition'))
|
||||
await ctx.lsp.query(query('goToDefinition'))
|
||||
await expect(ctx.fiber.dispose()).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -280,3 +297,23 @@ describe('lsp-local end to end over a fake server', () => {
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
/** Read the fixture's JSON-lines didOpen marker, returning no entries before it exists. */
|
||||
async function markerLines(path: string): Promise<string[]> {
|
||||
try {
|
||||
const text = await readFile(path, 'utf8')
|
||||
return text.trim().split('\n').filter(Boolean).map(line => JSON.parse(line) as string)
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/** Poll an asynchronous condition until it succeeds or the test-local deadline expires. */
|
||||
async function waitFor(condition: () => Promise<boolean>, timeoutMs = 3000): Promise<void> {
|
||||
const started = Date.now()
|
||||
while (!await condition()) {
|
||||
if (Date.now() - started > timeoutMs) throw new Error('waitFor timed out')
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 10))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Context } from 'cordis'
|
||||
import Lsp, { type LspQueryRequest } from '@deepseek-ai/dsh-lsp'
|
||||
import * as LspLocal from '@deepseek-ai/dsh-lsp-local'
|
||||
import type { Config, LspLocalServerConfig } from '@deepseek-ai/dsh-lsp-local'
|
||||
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
|
||||
|
||||
let root: string
|
||||
let ws: string
|
||||
@@ -22,7 +23,7 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
function query(): LspQueryRequest {
|
||||
return { operation: 'definition', filePath: 'a.ts', position: { line: 0, character: 0 }, workspaceRoot: ws }
|
||||
return { operation: 'goToDefinition', filePath: 'a.ts', position: { line: 0, character: 0 }, workspaceRoot: ws }
|
||||
}
|
||||
|
||||
/** Wrap one server entry in the plugin's named server table. */
|
||||
@@ -91,6 +92,30 @@ describe('lsp-local provider resolution', () => {
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rejects a nonpositive byte cap at load', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Lsp)
|
||||
await expect(ctx.plugin(LspLocal, config('bad-cap', {
|
||||
command: process.execPath,
|
||||
args: ['-e', ''],
|
||||
extensionToLanguage: { '.ts': 'typescript' },
|
||||
maxDocumentBytes: 0,
|
||||
}))).rejects.toThrow(/servers\.bad-cap\.maxDocumentBytes must be a positive integer/)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it.each(['shutdownTimeoutMs', 'killGraceMs'] as const)('rejects %s above Node timer range at load', async (name) => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Lsp)
|
||||
await expect(ctx.plugin(LspLocal, config('bad-timer', {
|
||||
command: process.execPath,
|
||||
args: ['-e', ''],
|
||||
extensionToLanguage: { '.ts': 'typescript' },
|
||||
[name]: MAX_TIMER_DELAY_MS + 1,
|
||||
}))).rejects.toThrow(new RegExp(`servers\\.bad-timer\\.${name}`))
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rejects an absolute command that is not executable at load', async () => {
|
||||
const notExe = join(root, 'not-exe.txt')
|
||||
await writeFile(notExe, 'plain text, not executable')
|
||||
|
||||
@@ -13,9 +13,9 @@ const RANGE = { start: { line: 1, character: 2 }, end: { line: 1, character: 5 }
|
||||
|
||||
describe('requestMethod', () => {
|
||||
it('maps each operation to its textDocument request', () => {
|
||||
expect(requestMethod('definition')).toBe('textDocument/definition')
|
||||
expect(requestMethod('references')).toBe('textDocument/references')
|
||||
expect(requestMethod('implementation')).toBe('textDocument/implementation')
|
||||
expect(requestMethod('goToDefinition')).toBe('textDocument/definition')
|
||||
expect(requestMethod('findReferences')).toBe('textDocument/references')
|
||||
expect(requestMethod('goToImplementation')).toBe('textDocument/implementation')
|
||||
expect(requestMethod('hover')).toBe('textDocument/hover')
|
||||
})
|
||||
})
|
||||
@@ -27,9 +27,9 @@ describe('supportsOperation', () => {
|
||||
referencesProvider: { workDoneProgress: true },
|
||||
implementationProvider: false,
|
||||
}
|
||||
expect(supportsOperation(caps, 'definition')).toBe(true)
|
||||
expect(supportsOperation(caps, 'references')).toBe(true)
|
||||
expect(supportsOperation(caps, 'implementation')).toBe(false)
|
||||
expect(supportsOperation(caps, 'goToDefinition')).toBe(true)
|
||||
expect(supportsOperation(caps, 'findReferences')).toBe(true)
|
||||
expect(supportsOperation(caps, 'goToImplementation')).toBe(false)
|
||||
expect(supportsOperation(caps, 'hover')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -66,9 +66,9 @@ describe('negotiatePositionEncoding', () => {
|
||||
})
|
||||
|
||||
describe('normalizeLocations', () => {
|
||||
it('returns empty for null and undefined', () => {
|
||||
it('returns empty only for the protocol no-result value null', () => {
|
||||
expect(normalizeLocations(null)).toEqual([])
|
||||
expect(normalizeLocations(undefined)).toEqual([])
|
||||
expect(() => normalizeLocations(undefined)).toThrow(expect.objectContaining({ code: 'LSP_MALFORMED_RESPONSE' }))
|
||||
})
|
||||
|
||||
it('maps a single Location', () => {
|
||||
@@ -100,6 +100,13 @@ describe('normalizeLocations', () => {
|
||||
it('rejects a Location whose range positions are malformed', () => {
|
||||
expect(() => normalizeLocations([{ uri: 'file:///a', range: { start: null, end: null } }])).toThrow(/neither a Location/)
|
||||
})
|
||||
|
||||
it('rejects negative and fractional position coordinates', () => {
|
||||
expect(() => normalizeLocations([{ uri: 'file:///a', range: { start: { line: -1, character: 0 }, end: RANGE.end } }]))
|
||||
.toThrow(expect.objectContaining({ code: 'LSP_MALFORMED_RESPONSE' }))
|
||||
expect(() => normalizeLocations([{ uri: 'file:///a', range: { start: RANGE.start, end: { line: 1.5, character: 5 } } }]))
|
||||
.toThrow(expect.objectContaining({ code: 'LSP_MALFORMED_RESPONSE' }))
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeHover', () => {
|
||||
@@ -107,6 +114,10 @@ describe('normalizeHover', () => {
|
||||
expect(normalizeHover(null)).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects a missing hover result', () => {
|
||||
expect(() => normalizeHover(undefined)).toThrow(expect.objectContaining({ code: 'LSP_MALFORMED_RESPONSE' }))
|
||||
})
|
||||
|
||||
it('reads MarkupContent value and keeps a range', () => {
|
||||
expect(normalizeHover({ contents: { kind: 'markdown', value: '# H' }, range: RANGE }))
|
||||
.toEqual({ contents: '# H', range: RANGE })
|
||||
@@ -130,8 +141,9 @@ describe('normalizeHover', () => {
|
||||
expect(normalizeHover({ contents: { kind: 'plaintext', value: '' } })).toBeNull()
|
||||
})
|
||||
|
||||
it('treats a MarkupContent with a non-string value as empty (null)', () => {
|
||||
expect(normalizeHover({ contents: { kind: 'markdown', value: 42 } })).toBeNull()
|
||||
it('rejects a MarkupContent with a non-string value', () => {
|
||||
expect(() => normalizeHover({ contents: { kind: 'markdown', value: 42 } }))
|
||||
.toThrow(expect.objectContaining({ code: 'LSP_MALFORMED_RESPONSE' }))
|
||||
})
|
||||
|
||||
it('rejects a non-object payload', () => {
|
||||
@@ -143,11 +155,19 @@ describe('normalizeHover', () => {
|
||||
expect(() => normalizeHover({ contents: 42 })).toThrow(/were not MarkupContent/)
|
||||
})
|
||||
|
||||
it('rejects a malformed MarkedString array member', () => {
|
||||
expect(() => normalizeHover({ contents: ['ok', { language: 'ts', value: 42 }] }))
|
||||
.toThrow(expect.objectContaining({ code: 'LSP_MALFORMED_RESPONSE' }))
|
||||
expect(() => normalizeHover({ contents: [null] }))
|
||||
.toThrow(expect.objectContaining({ code: 'LSP_MALFORMED_RESPONSE' }))
|
||||
})
|
||||
|
||||
it('rejects a hover with no contents field', () => {
|
||||
expect(() => normalizeHover({ range: RANGE })).toThrow(/no contents/)
|
||||
})
|
||||
|
||||
it('ignores a malformed range and keeps the contents', () => {
|
||||
expect(normalizeHover({ contents: 'x', range: { start: { line: 1 } } })).toEqual({ contents: 'x' })
|
||||
it('rejects a malformed range instead of silently dropping it', () => {
|
||||
expect(() => normalizeHover({ contents: 'x', range: { start: { line: 1 } } }))
|
||||
.toThrow(expect.objectContaining({ code: 'LSP_MALFORMED_RESPONSE' }))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -81,7 +81,7 @@ function locations(result: LspQueryResult): readonly { uri: string }[] {
|
||||
describe('real typescript-language-server', () => {
|
||||
it('resolves the definition of a call site to its declaration', async () => {
|
||||
// `export const text = describe(c)` (line 15): `describe` begins at column 21.
|
||||
const result = await ctx.lsp.query(at('definition', 15, 22))
|
||||
const result = await ctx.lsp.query(at('goToDefinition', 15, 22))
|
||||
const locs = locations(result)
|
||||
expect(locs.length).toBeGreaterThanOrEqual(1)
|
||||
expect(locs.some(l => l.uri.endsWith('shapes.ts'))).toBe(true)
|
||||
@@ -89,7 +89,7 @@ describe('real typescript-language-server', () => {
|
||||
|
||||
it('finds references to a symbol including its declaration', async () => {
|
||||
// References to `describe` from its declaration (line 10, col 17).
|
||||
const result = await ctx.lsp.query(at('references', 10, 17))
|
||||
const result = await ctx.lsp.query(at('findReferences', 10, 17))
|
||||
const locs = locations(result)
|
||||
// At least the declaration plus the call site.
|
||||
expect(locs.length).toBeGreaterThanOrEqual(2)
|
||||
@@ -97,7 +97,7 @@ describe('real typescript-language-server', () => {
|
||||
|
||||
it('resolves implementations of an interface', async () => {
|
||||
// Implementations of `Shape` (line 1, col 18) → Circle.
|
||||
const result = await ctx.lsp.query(at('implementation', 1, 18))
|
||||
const result = await ctx.lsp.query(at('goToImplementation', 1, 18))
|
||||
const locs = locations(result)
|
||||
expect(locs.length).toBeGreaterThanOrEqual(1)
|
||||
}, 60_000)
|
||||
|
||||
Reference in New Issue
Block a user