This commit is contained in:
Oleg Maslov
2026-09-02 10:14:22 +02:00
parent 0c3e2ead3b
commit b20b138fe4
771 changed files with 161561 additions and 9027 deletions

View File

@@ -1,5 +1,12 @@
import { execFileSync } from 'node:child_process';
import { join } from 'node:path';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createLspTools, _resetLspState } from '../src/lsp-tools.js';
import {
createLspTools,
_resetLspState,
spawnLspServerProcess,
stopLspServerProcess,
} from '../src/lsp-tools.js';
import type { ToolDefinition } from '../src/tools.js';
// Mock child_process.spawn to prevent actually spawning LSP servers
@@ -7,7 +14,15 @@ vi.mock('node:child_process', async (importOriginal) => {
const original = await importOriginal<typeof import('node:child_process')>();
return {
...original,
spawn: vi.fn(() => {
spawn: vi.fn((command, args, options) => {
if (
command === process.execPath
&& Array.isArray(args)
&& args[0] === '-e'
&& args[2] === process.execPath
) {
return original.spawn(command, args, options);
}
throw new Error('spawn ENOENT');
}),
};
@@ -30,6 +45,100 @@ describe('LSP Tools', () => {
// ── Tool registration ─────────────────────────────────────────────────
it('spawns LSP through the sidecar-owned boundary with a sanitized environment', async () => {
const previous = process.env.WAGGLE_LSP_AMBIENT_SECRET;
process.env.WAGGLE_LSP_AMBIENT_SECRET = 'must-not-reach-language-server';
const fakeProcess = { pid: 4242 };
const resolveCommand = vi.fn(async () => ({
binary: process.execPath,
args: ['/trusted/typescript-language-server.js', '--stdio'],
}));
const spawnOwned = vi.fn(() => fakeProcess);
try {
const result = await spawnLspServerProcess(workspace, { resolveCommand, spawnOwned });
expect(result).toBe(fakeProcess);
expect(resolveCommand).toHaveBeenCalledWith(
'typescript-language-server',
['--stdio'],
process.platform,
expect.objectContaining({ env: expect.any(Object) }),
);
const resolvedEnv = resolveCommand.mock.calls[0][3].env as NodeJS.ProcessEnv;
expect(resolvedEnv.WAGGLE_LSP_AMBIENT_SECRET).toBeUndefined();
expect(spawnOwned).toHaveBeenCalledWith(
process.execPath,
['/trusted/typescript-language-server.js', '--stdio'],
expect.objectContaining({
cwd: workspace,
env: resolvedEnv,
stdio: ['pipe', 'pipe', 'pipe'],
windowsHide: true,
}),
);
expect(spawnOwned.mock.calls[0][2]).not.toHaveProperty('shell');
} finally {
if (previous === undefined) delete process.env.WAGGLE_LSP_AMBIENT_SECRET;
else process.env.WAGGLE_LSP_AMBIENT_SECRET = previous;
}
});
it.runIf(process.platform === 'win32')(
'explicit LSP stop removes the supervised target and its descendant',
async () => {
const targetSource = [
"const { spawn } = require('node:child_process');",
"const descendant = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { detached: true, stdio: 'ignore' });",
'descendant.unref();',
'console.log(JSON.stringify({ pid: process.pid, descendantPid: descendant.pid }));',
'setInterval(() => {}, 1000);',
].join('\n');
const child = await spawnLspServerProcess(process.cwd(), {
resolveCommand: async () => ({
binary: process.execPath,
args: ['-e', targetSource],
}),
});
const receipt = await new Promise<{ pid: number; descendantPid: number }>((resolveReceipt, reject) => {
const timer = setTimeout(() => reject(new Error('LSP target produced no ownership receipt')), 5_000);
let output = '';
child.stdout?.on('data', (chunk: Buffer) => {
output += chunk.toString('utf8');
const line = output.split(/\r?\n/, 1)[0];
try {
const parsed = JSON.parse(line) as { pid: number; descendantPid: number };
clearTimeout(timer);
resolveReceipt(parsed);
} catch { /* wait for a complete JSON line */ }
});
});
const isAlive = (pid: number): boolean => {
try { process.kill(pid, 0); return true; } catch { return false; }
};
try {
await stopLspServerProcess(child, 8_000);
await vi.waitUntil(
() => !isAlive(receipt.pid) && !isAlive(receipt.descendantPid),
{ timeout: 5_000, interval: 50 },
);
expect(isAlive(receipt.pid)).toBe(false);
expect(isAlive(receipt.descendantPid)).toBe(false);
} finally {
const windowsRoot = process.env.SystemRoot ?? process.env.WINDIR ?? 'C:\\Windows';
for (const pid of [receipt.pid, receipt.descendantPid, child.pid]) {
if (!pid || !isAlive(pid)) continue;
try {
execFileSync(join(windowsRoot, 'System32', 'taskkill.exe'), [
'/PID', String(pid), '/T', '/F',
], { stdio: 'ignore', windowsHide: true });
} catch { /* best-effort fixture cleanup */ }
}
}
},
20_000,
);
it('creates 4 LSP tools', () => {
expect(tools).toHaveLength(4);
const names = tools.map(t => t.name);