|
|
@@ -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,16 +171,50 @@ 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/)
|
|
|
await instance.dispose()
|
|
|
})
|
|
|
|
|
|
+ it('terminates when abort interrupts a backpressured didOpen write', async () => {
|
|
|
+ // The fixture consumes initialized, then stops reading. A document larger than the stdio pipe
|
|
|
+ // keeps didOpen's write callback pending until cancellation forces bounded process teardown.
|
|
|
+ await writeFile(join(ws, 'a.ts'), 'x'.repeat(2_000_000))
|
|
|
+ const marker = join(root, 'initialized.log')
|
|
|
+ const instance = makeInstance({
|
|
|
+ LSP_FAKE_INITIALIZED_MARKER: marker,
|
|
|
+ LSP_FAKE_PAUSE_STDIN_AFTER_INITIALIZED: '1',
|
|
|
+ }, {
|
|
|
+ shutdownTimeoutMs: 100,
|
|
|
+ killGraceMs: 100,
|
|
|
+ })
|
|
|
+ const controller = new AbortController()
|
|
|
+ const pending = run(instance, 'goToDefinition', controller.signal)
|
|
|
+ await waitForFile(marker)
|
|
|
+ // Let the client enter the large didOpen write after the fixture has paused stdin.
|
|
|
+ await new Promise<void>(resolve => setTimeout(resolve, 100))
|
|
|
+ controller.abort(new Error('didOpen-abort'))
|
|
|
+ await expect(pending).rejects.toThrow(/didOpen-abort/)
|
|
|
+ expect(instance.dead).toBe(true)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('terminates when stdin fails during the didOpen write', async () => {
|
|
|
+ // Closing stdin after initialized makes a large didOpen fail before `opened` can arm didClose;
|
|
|
+ // the instance must still become dead so its provider can replace it.
|
|
|
+ await writeFile(join(ws, 'a.ts'), 'x'.repeat(2_000_000))
|
|
|
+ const instance = makeInstance({ LSP_FAKE_CLOSE_STDIN_AFTER_INITIALIZED: '1' }, {
|
|
|
+ shutdownTimeoutMs: 100,
|
|
|
+ killGraceMs: 100,
|
|
|
+ })
|
|
|
+ await expect(run(instance, 'goToDefinition')).rejects.toThrow()
|
|
|
+ expect(instance.dead).toBe(true)
|
|
|
+ })
|
|
|
+
|
|
|
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 +222,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 +247,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 +277,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 +289,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 +304,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/)
|
|
|
@@ -276,3 +321,18 @@ function processAlive(pid: number): boolean {
|
|
|
throw error
|
|
|
}
|
|
|
}
|
|
|
+
|
|
|
+/** Wait until a fixture marker exists, bounded so a broken handshake cannot hang the test. */
|
|
|
+async function waitForFile(path: string, timeoutMs = 3000): Promise<void> {
|
|
|
+ const started = Date.now()
|
|
|
+ for (;;) {
|
|
|
+ try {
|
|
|
+ await readFile(path)
|
|
|
+ return
|
|
|
+ } catch (error) {
|
|
|
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
|
|
+ }
|
|
|
+ if (Date.now() - started > timeoutMs) throw new Error('waitForFile timed out')
|
|
|
+ await new Promise<void>(resolve => setTimeout(resolve, 10))
|
|
|
+ }
|
|
|
+}
|