test(windows): make coverage fixtures portable
This commit is contained in:
@@ -210,7 +210,7 @@ describe('LspConnection edge behavior', () => {
|
||||
})
|
||||
|
||||
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)')
|
||||
const conn = connectScript('const stdin=process.stdin; require("node:fs").closeSync(0); stdin._handle?.close(); 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)
|
||||
|
||||
@@ -16,8 +16,8 @@
|
||||
* - LSP_FAKE_OPEN_MARKER: appends each didOpen document text as one JSON line to this path.
|
||||
* - LSP_FAKE_INITIALIZED_MARKER: records when the initialized notification is received.
|
||||
* - LSP_FAKE_PAUSE_STDIN_AFTER_INITIALIZED: "1" stops consuming stdin after initialized.
|
||||
* - LSP_FAKE_CLOSE_STDIN_AFTER_INITIALIZED: "1" closes fd 0 after the initialized notification.
|
||||
* - LSP_FAKE_CLOSE_STDIN_AFTER_REPLY: "1" closes fd 0 before sending the first query response.
|
||||
* - LSP_FAKE_CLOSE_STDIN_AFTER_INITIALIZED: "1" closes the stdin pipe after initialization.
|
||||
* - LSP_FAKE_CLOSE_STDIN_AFTER_REPLY: "1" closes the stdin pipe before 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
|
||||
@@ -146,14 +146,14 @@ function handle(message: { id?: number; method?: string; params?: unknown; resul
|
||||
if (method === 'initialized') {
|
||||
if (initializedMarker !== undefined) appendFileSync(initializedMarker, 'INITIALIZED\n')
|
||||
if (pauseStdinAfterInitialized) process.stdin.pause()
|
||||
if (closeStdinAfterInitialized) closeSync(0)
|
||||
if (closeStdinAfterInitialized) closeStdinPipe()
|
||||
return
|
||||
}
|
||||
if (method === 'textDocument/didClose') return
|
||||
if (method?.startsWith('textDocument/')) {
|
||||
if (hang) return
|
||||
const reply = (): void => {
|
||||
if (closeStdinAfterReply) closeSync(0)
|
||||
if (closeStdinAfterReply) closeStdinPipe()
|
||||
if (errorReply) {
|
||||
send({ id, error: { code: -32000, message: 'server refused the request' } })
|
||||
} else {
|
||||
@@ -171,6 +171,13 @@ function handle(message: { id?: number; method?: string; params?: unknown; resul
|
||||
if (id !== undefined) send({ id, result: null })
|
||||
}
|
||||
|
||||
/** Close both the CRT descriptor and libuv handle that can own a platform's child-stdin pipe. */
|
||||
function closeStdinPipe(): void {
|
||||
const stdin = process.stdin as NodeJS.ReadStream & { _handle?: { close(): void } }
|
||||
closeSync(0)
|
||||
stdin._handle?.close()
|
||||
}
|
||||
|
||||
/** Append one teardown event when the fixture is configured to expose process ordering. */
|
||||
function markExit(event: string): void {
|
||||
if (exitMarker !== undefined) appendFileSync(exitMarker, `${event}\n`)
|
||||
|
||||
@@ -92,7 +92,8 @@ 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 () => {
|
||||
// Windows has no filesystem FIFO; the directory case above pins non-regular rejection there.
|
||||
it.skipIf(process.platform === 'win32')('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')
|
||||
|
||||
@@ -116,7 +116,8 @@ describe('lsp-local provider resolution', () => {
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rejects an absolute command that is not executable at load', async () => {
|
||||
// Node's X_OK probe is an existence check on Windows, which has no executable mode bit.
|
||||
it.skipIf(process.platform === 'win32')('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')
|
||||
const ctx = new Context()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
import { join, resolve } from 'node:path'
|
||||
import {
|
||||
DEFAULT_MAX_LOCATIONS,
|
||||
DEFAULT_MAX_RESULT_CHARS,
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from '@deepseek-ai/dsh-tool-lsp'
|
||||
import type { LspLocation } from '@deepseek-ai/dsh-lsp'
|
||||
|
||||
const WS = '/home/u/proj'
|
||||
const WS = resolve('/home/u/proj')
|
||||
|
||||
function loc(uri: string, line: number, character = 0): LspLocation {
|
||||
return { uri, range: { start: { line, character }, end: { line, character: character + 1 } } }
|
||||
@@ -52,8 +52,9 @@ describe('renderUri', () => {
|
||||
})
|
||||
|
||||
it('returns an absolute path for a file: URI outside the workspace', () => {
|
||||
const uri = pathToFileURL('/other/lib/b.ts').href
|
||||
expect(renderUri(uri, WS)).toBe('/other/lib/b.ts')
|
||||
const outside = resolve(WS, '..', 'other', 'lib', 'b.ts')
|
||||
const uri = pathToFileURL(outside).href
|
||||
expect(renderUri(uri, WS)).toBe(outside)
|
||||
})
|
||||
|
||||
it('renders the workspace root itself as "."', () => {
|
||||
@@ -72,8 +73,8 @@ describe('renderUri', () => {
|
||||
})
|
||||
|
||||
it('keeps a malformed file: URI verbatim when it cannot be parsed to a path', () => {
|
||||
// A file: URI with a host that fileURLToPath rejects falls through to the verbatim path.
|
||||
expect(renderUri('file://host/notlocal', WS)).toBe('file://host/notlocal')
|
||||
// An encoded path separator is invalid on every platform and must remain verbatim.
|
||||
expect(renderUri('file:///bad%2Fpath', WS)).toBe('file:///bad%2Fpath')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { Context } from 'cordis'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
@@ -40,8 +42,11 @@ async function mount(
|
||||
|
||||
let seq = 0
|
||||
const testToolSignal = new AbortController().signal
|
||||
const workspaceRoot = resolve('/virtual/workspace')
|
||||
const resolvedWorkspaceRoot = resolve('/virtual/real-workspace')
|
||||
const workspaceAlias = resolve('/virtual/workspace-alias')
|
||||
/** `cwd: null` means "no agent" (tests LSP_WORKSPACE_REQUIRED); a string is the session cwd. */
|
||||
function call(ctx: Context, args: unknown, cwd: string | null = '/ws') {
|
||||
function call(ctx: Context, args: unknown, cwd: string | null = workspaceRoot) {
|
||||
return ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: `c-${++seq}` as never,
|
||||
@@ -53,8 +58,8 @@ function call(ctx: Context, args: unknown, cwd: string | null = '/ws') {
|
||||
|
||||
const okLocations: LspQueryResult = {
|
||||
kind: 'locations',
|
||||
locations: [{ uri: 'file:///ws/a.ts', range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } } }],
|
||||
resolvedWorkspaceRoot: '/ws',
|
||||
locations: [{ uri: pathToFileURL(join(workspaceRoot, 'a.ts')).href, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } } }],
|
||||
resolvedWorkspaceRoot: workspaceRoot,
|
||||
}
|
||||
|
||||
describe('tool-lsp registration', () => {
|
||||
@@ -107,40 +112,39 @@ describe('tool-lsp execution', () => {
|
||||
it('converts one-based coordinates and passes the session cwd as workspaceRoot', async () => {
|
||||
const provider = stubProvider(() => okLocations)
|
||||
const { ctx } = await mount(provider)
|
||||
const result = await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 3, character: 5 }, '/ws')
|
||||
const result = await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 3, character: 5 }, workspaceRoot)
|
||||
expect(result.isError).toBe(false)
|
||||
expect(provider.seen[0]).toMatchObject({
|
||||
operation: 'goToDefinition',
|
||||
filePath: 'a.ts',
|
||||
position: { line: 2, character: 4 },
|
||||
workspaceRoot: '/ws',
|
||||
workspaceRoot,
|
||||
})
|
||||
})
|
||||
|
||||
it('renders locations relative to the workspace', async () => {
|
||||
const { ctx } = await mount(stubProvider(() => okLocations))
|
||||
const result = await call(ctx, { operation: 'findReferences', file_path: 'a.ts', line: 1, character: 1 }, '/ws')
|
||||
const result = await call(ctx, { operation: 'findReferences', file_path: 'a.ts', line: 1, character: 1 }, workspaceRoot)
|
||||
expect(result.content[0]).toEqual({ type: 'text', text: 'a.ts:1:1' })
|
||||
})
|
||||
|
||||
it('relativizes against the provider resolvedWorkspaceRoot, not the session cwd', async () => {
|
||||
// A symlinked session cwd (`/alias`) resolves to a real path (`/real/ws`) that the provider's
|
||||
// location URIs are under. Relativizing against the alias would misclassify the location as
|
||||
// external and print an absolute path; the tool must use resolvedWorkspaceRoot.
|
||||
// A symlinked session cwd resolves to the real path that contains the provider's location URIs.
|
||||
// Relativizing against the alias would misclassify the location as external.
|
||||
const provider = stubProvider(() => ({
|
||||
kind: 'locations',
|
||||
locations: [{ uri: 'file:///real/ws/a.ts', range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } } }],
|
||||
resolvedWorkspaceRoot: '/real/ws',
|
||||
locations: [{ uri: pathToFileURL(join(resolvedWorkspaceRoot, 'a.ts')).href, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } } }],
|
||||
resolvedWorkspaceRoot,
|
||||
}))
|
||||
const { ctx } = await mount(provider)
|
||||
const result = await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 1, character: 1 }, '/alias')
|
||||
expect(provider.seen[0]).toMatchObject({ workspaceRoot: '/alias' })
|
||||
const result = await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 1, character: 1 }, workspaceAlias)
|
||||
expect(provider.seen[0]).toMatchObject({ workspaceRoot: workspaceAlias })
|
||||
expect(result.content[0]).toEqual({ type: 'text', text: 'a.ts:1:1' })
|
||||
})
|
||||
|
||||
it('renders hover content', async () => {
|
||||
const { ctx } = await mount(stubProvider(() => ({ kind: 'hover', hover: { contents: 'number' } })))
|
||||
const result = await call(ctx, { operation: 'hover', file_path: 'a.ts', line: 1, character: 1 }, '/ws')
|
||||
const result = await call(ctx, { operation: 'hover', file_path: 'a.ts', line: 1, character: 1 }, workspaceRoot)
|
||||
expect(result.content[0]).toEqual({ type: 'text', text: 'number' })
|
||||
})
|
||||
|
||||
@@ -153,14 +157,14 @@ describe('tool-lsp execution', () => {
|
||||
|
||||
it('surfaces a structured LSP_UNAVAILABLE when no provider handles the file', async () => {
|
||||
const { ctx } = await mount(stubProvider(() => okLocations, { '.py': 'python' }))
|
||||
const result = await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 1, character: 1 }, '/ws')
|
||||
const result = await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 1, character: 1 }, workspaceRoot)
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error?.code).toBe('LSP_UNAVAILABLE')
|
||||
})
|
||||
|
||||
it('returns a structured INVALID_ARGS on a bad operation', async () => {
|
||||
const { ctx } = await mount(stubProvider(() => okLocations))
|
||||
const result = await call(ctx, { operation: 'rename', file_path: 'a.ts', line: 1, character: 1 }, '/ws')
|
||||
const result = await call(ctx, { operation: 'rename', file_path: 'a.ts', line: 1, character: 1 }, workspaceRoot)
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error?.code).toBe('INVALID_ARGS')
|
||||
})
|
||||
@@ -176,7 +180,7 @@ describe('tool-lsp execution', () => {
|
||||
},
|
||||
}
|
||||
const { ctx } = await mount(provider)
|
||||
await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 1, character: 1 }, '/ws')
|
||||
await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 1, character: 1 }, workspaceRoot)
|
||||
// The timeout policy is not mounted here, so the signal is whatever the registry passes (may be
|
||||
// undefined); the point is the tool threads it through without throwing.
|
||||
expect(seen).toHaveLength(1)
|
||||
|
||||
Reference in New Issue
Block a user