From d52b3e44f011211ef8693a440261b5801b621422 Mon Sep 17 00:00:00 2001 From: JF Date: Thu, 14 May 2026 14:49:49 -0400 Subject: [PATCH] test: lift coverage on jvm-orphan-reaper and minimal-dap jvm-orphan-reaper.ts: 36% -> 97% lines. Expose parseArgs, listLinux, listDarwin, listWindows, defaultKill as @internal exports so platform-specific code can be unit-tested on any host. Add 31 tests covering pure parsing, process.kill paths, and all three platform listers via vi.mock of node:fs/promises and node:child_process. minimal-dap.ts: 85% -> 92% lines. Add 10 tests for normalizeAdapterId branches, the non-stackTrace child-wait loop, child fallback (graceful, fall-through, rethrow), configurationDone deferral edge cases, and trace-file error swallowing. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/utils/jvm-orphan-reaper.ts | 15 +- tests/unit/proxy/minimal-dap.test.ts | 358 ++++++++++++++++ .../utils/jvm-orphan-reaper-internals.test.ts | 402 ++++++++++++++++++ 3 files changed, 770 insertions(+), 5 deletions(-) create mode 100644 tests/unit/utils/jvm-orphan-reaper-internals.test.ts diff --git a/src/utils/jvm-orphan-reaper.ts b/src/utils/jvm-orphan-reaper.ts index c7354176..a39af461 100644 --- a/src/utils/jvm-orphan-reaper.ts +++ b/src/utils/jvm-orphan-reaper.ts @@ -118,7 +118,8 @@ export async function listTaggedJvms(): Promise { } } -async function listLinux(): Promise { +/** @internal Exposed for unit tests; not part of the public module API. */ +export async function listLinux(): Promise { let entries: string[]; try { entries = await fs.readdir('/proc'); @@ -144,7 +145,8 @@ async function listLinux(): Promise { return result; } -async function listDarwin(): Promise { +/** @internal Exposed for unit tests; not part of the public module API. */ +export async function listDarwin(): Promise { // -ww disables column truncation; otherwise long java cmdlines lose the // -D markers we depend on. -A lists all users' processes (we filter by // owner_pid liveness anyway). @@ -166,7 +168,8 @@ async function listDarwin(): Promise { return result; } -async function listWindows(): Promise { +/** @internal Exposed for unit tests; not part of the public module API. */ +export async function listWindows(): Promise { // Get-CimInstance is the modern path; wmic is deprecated and missing on // fresh Windows 11 installs. ConvertTo-Json -Compress keeps stdout small. // -NoProfile skips loading user profile scripts (faster, more deterministic). @@ -206,7 +209,8 @@ async function listWindows(): Promise { return result; } -function parseArgs(pid: number, args: string[]): TaggedJvm | null { +/** @internal Exposed for unit tests; not part of the public module API. */ +export function parseArgs(pid: number, args: string[]): TaggedJvm | null { let hasMarker = false; let ownerPid = -1; let sessionTag = ''; @@ -238,7 +242,8 @@ export function isPidAlive(pid: number): boolean { } } -function defaultKill(pid: number): boolean { +/** @internal Exposed for unit tests; not part of the public module API. */ +export function defaultKill(pid: number): boolean { try { process.kill(pid, 'SIGKILL'); return true; diff --git a/tests/unit/proxy/minimal-dap.test.ts b/tests/unit/proxy/minimal-dap.test.ts index 2fdc6298..25a08d18 100644 --- a/tests/unit/proxy/minimal-dap.test.ts +++ b/tests/unit/proxy/minimal-dap.test.ts @@ -1378,4 +1378,362 @@ describe('MinimalDapClient', () => { }); }); + // Helper: a socket that auto-responds with a success response for every + // outgoing request, so `sendRequest` resolves without needing the test to + // synthesize responses by hand. + const echoSocket = (capturedRequests?: DebugProtocol.Request[]) => { + const sock = { + destroyed: false, + end: vi.fn(), + destroy: vi.fn(), + write: vi.fn((raw: string, cb?: (err?: Error | null) => void) => { + cb?.(null); + const [, body] = raw.split('\r\n\r\n'); + const request = JSON.parse(body) as DebugProtocol.Request; + capturedRequests?.push(request); + setImmediate(() => { + void (client as any).handleProtocolMessage({ + seq: request.seq, + type: 'response', + request_seq: request.seq, + command: request.command, + success: true + } satisfies DebugProtocol.Response); + }); + return true; + }) + } as unknown as net.Socket; + return sock; + }; + + describe('Adapter ID normalization on initialize', () => { + it('mutates adapterID when policy provides a normalizer that changes it', async () => { + const captured: DebugProtocol.Request[] = []; + (client as any).socket = echoSocket(captured); + (client as any).dapBehavior = { + normalizeAdapterId: vi.fn((id: string) => `${id}-normalized`) + }; + + await client.sendRequest('initialize', { adapterID: 'python' }); + + expect((client as any).dapBehavior.normalizeAdapterId).toHaveBeenCalledWith('python'); + expect(captured).toHaveLength(1); + expect((captured[0].arguments as { adapterID: string }).adapterID).toBe('python-normalized'); + }); + + it('passes original args through when normalizer throws', async () => { + const captured: DebugProtocol.Request[] = []; + (client as any).socket = echoSocket(captured); + (client as any).dapBehavior = { + normalizeAdapterId: vi.fn(() => { + throw new Error('boom'); + }) + }; + + await expect( + client.sendRequest('initialize', { adapterID: 'python' }) + ).resolves.toBeDefined(); + + expect(captured).toHaveLength(1); + expect((captured[0].arguments as { adapterID: string }).adapterID).toBe('python'); + }); + + it('does not invoke normalizer when adapterID is missing from args', async () => { + const captured: DebugProtocol.Request[] = []; + (client as any).socket = echoSocket(captured); + const normalizer = vi.fn((id: string) => id); + (client as any).dapBehavior = { normalizeAdapterId: normalizer }; + + await client.sendRequest('initialize', { clientID: 'test' }); + + expect(normalizer).not.toHaveBeenCalled(); + expect(captured).toHaveLength(1); + }); + + it('leaves args untouched when normalizer returns the same value', async () => { + const captured: DebugProtocol.Request[] = []; + (client as any).socket = echoSocket(captured); + const normalizer = vi.fn((id: string) => id); + (client as any).dapBehavior = { normalizeAdapterId: normalizer }; + + await client.sendRequest('initialize', { adapterID: 'python' }); + + expect(normalizer).toHaveBeenCalledWith('python'); + expect((captured[0].arguments as { adapterID: string }).adapterID).toBe('python'); + }); + }); + + describe('Non-stackTrace child wait loop', () => { + it('polls for an active child before dispatching a child-scoped non-stackTrace command', async () => { + const stubManager = createChildSessionManagerStub(); + stubManager.shouldRouteToChild.mockReturnValue(true); + stubManager.hasActiveChildren.mockReturnValue(true); + + const childResponse: DebugProtocol.Response = { + seq: 99, + type: 'response', + request_seq: 1, + command: 'next', + success: true + }; + const childClient = { + sendRequest: vi.fn().mockResolvedValue(childResponse) + } as unknown as MinimalDapClient; + + let polls = 0; + stubManager.getActiveChild.mockImplementation(() => { + polls += 1; + return polls >= 4 ? childClient : null; + }); + + const routedClient = new MinimalDapClient( + 'localhost', + 5678, + JsDebugAdapterPolicy, + { + childSessionManagerFactory: () => stubManager as unknown as ChildSessionManager + } + ); + (routedClient as any).socket = { + destroyed: false, + end: vi.fn(), + destroy: vi.fn(), + write: vi.fn() + } as unknown as net.Socket; + (routedClient as any).sleep = vi.fn().mockResolvedValue(undefined); + + const result = await routedClient.sendRequest('next', { threadId: 1 }); + + expect((routedClient as any).sleep).toHaveBeenCalled(); + expect(childClient.sendRequest).toHaveBeenCalledWith('next', { threadId: 1 }, 30000); + expect(result).toEqual(childResponse); + // Parent socket must not have been written to: the routed call took over. + expect((routedClient as any).socket.write).not.toHaveBeenCalled(); + + routedClient.shutdown(); + }); + }); + + describe('Child fallback behavior', () => { + it('returns a synthetic success response when child disconnects during a graceful-completion command', async () => { + const stubManager = createChildSessionManagerStub(); + stubManager.shouldRouteToChild.mockReturnValue(true); + + const childClient = { + sendRequest: vi.fn().mockRejectedValue(new Error('DAP client disconnected')) + } as unknown as MinimalDapClient; + + const routedClient = new MinimalDapClient( + 'localhost', + 5678, + JsDebugAdapterPolicy, + { + childSessionManagerFactory: () => stubManager as unknown as ChildSessionManager + } + ); + (routedClient as any).socket = { + destroyed: false, + end: vi.fn(), + destroy: vi.fn(), + write: vi.fn() + } as unknown as net.Socket; + (routedClient as any).activeChild = childClient; + + const result = await routedClient.sendRequest('continue', { threadId: 1 }); + + expect(result.success).toBe(true); + expect(result.command).toBe('continue'); + // The synthetic response must not have round-tripped through the parent socket. + expect((routedClient as any).socket.write).not.toHaveBeenCalled(); + + routedClient.shutdown(); + }); + + it('falls through to parent socket when child disconnects on a non-graceful command', async () => { + const stubManager = createChildSessionManagerStub(); + stubManager.shouldRouteToChild.mockReturnValue(true); + + const childClient = { + sendRequest: vi.fn().mockRejectedValue(new Error('Socket not connected')) + } as unknown as MinimalDapClient; + + const captured: DebugProtocol.Request[] = []; + const routedClient = new MinimalDapClient( + 'localhost', + 5678, + JsDebugAdapterPolicy, + { + childSessionManagerFactory: () => stubManager as unknown as ChildSessionManager + } + ); + (routedClient as any).socket = { + destroyed: false, + end: vi.fn(), + destroy: vi.fn(), + write: vi.fn((raw: string, cb?: (err?: Error | null) => void) => { + cb?.(null); + const [, body] = raw.split('\r\n\r\n'); + const request = JSON.parse(body) as DebugProtocol.Request; + captured.push(request); + setImmediate(() => { + void (routedClient as any).handleProtocolMessage({ + seq: request.seq, + type: 'response', + request_seq: request.seq, + command: request.command, + success: true + } satisfies DebugProtocol.Response); + }); + return true; + }) + } as unknown as net.Socket; + (routedClient as any).activeChild = childClient; + + const result = await routedClient.sendRequest('next', { threadId: 1 }); + + expect(childClient.sendRequest).toHaveBeenCalled(); + // After child fallback, the request fell through to the parent socket. + expect(captured).toHaveLength(1); + expect(captured[0].command).toBe('next'); + expect(result.success).toBe(true); + + routedClient.shutdown(); + }); + + it('rethrows when the child rejects with an unrelated error', async () => { + const stubManager = createChildSessionManagerStub(); + stubManager.shouldRouteToChild.mockReturnValue(true); + + const childClient = { + sendRequest: vi.fn().mockRejectedValue(new Error('adapter blew up')) + } as unknown as MinimalDapClient; + + const routedClient = new MinimalDapClient( + 'localhost', + 5678, + JsDebugAdapterPolicy, + { + childSessionManagerFactory: () => stubManager as unknown as ChildSessionManager + } + ); + (routedClient as any).socket = { + destroyed: false, + end: vi.fn(), + destroy: vi.fn(), + write: vi.fn() + } as unknown as net.Socket; + (routedClient as any).activeChild = childClient; + + await expect( + routedClient.sendRequest('next', { threadId: 1 }) + ).rejects.toThrow('adapter blew up'); + + routedClient.shutdown(); + }); + }); + + describe('Configuration deferral edge cases', () => { + it('replaces an in-flight deferred configurationDone with a fresh deferral', () => { + client.shutdown(); + + let timerCounter = 0; + const setTimeoutSpy = vi.fn( + () => ({ id: ++timerCounter }) as unknown as NodeJS.Timeout + ); + const clearTimeoutSpy = vi.fn(); + + client = new MinimalDapClient('localhost', 5678, undefined, { + timers: { + setTimeout: setTimeoutSpy as unknown as typeof setTimeout, + clearTimeout: clearTimeoutSpy as unknown as typeof clearTimeout + } + }); + (client as any).socket = { + destroyed: false, + end: vi.fn(), + destroy: vi.fn(), + write: vi.fn() + } as unknown as net.Socket; + (client as any).deferParentConfigDoneActive = true; + + const promise1 = client.sendRequest('configurationDone', { first: true }); + promise1.catch(() => undefined); + const firstDeferred = (client as any).parentConfigDoneDeferred; + expect(firstDeferred).not.toBeNull(); + expect(firstDeferred.timer).toEqual({ id: 1 }); + + const promise2 = client.sendRequest('configurationDone', { second: true }); + promise2.catch(() => undefined); + + expect(clearTimeoutSpy).toHaveBeenCalledWith({ id: 1 }); + expect((client as any).parentConfigDoneDeferred).not.toBe(firstDeferred); + expect((client as any).parentConfigDoneDeferred.timer).toEqual({ id: 2 }); + + // Clean up dangling deferred to avoid lingering state. + (client as any).parentConfigDoneDeferred?.reject(new Error('test cleanup')); + }); + + it('passes configurationDone through immediately when suppressNextConfigDoneDeferral is set', async () => { + const captured: DebugProtocol.Request[] = []; + (client as any).socket = echoSocket(captured); + (client as any).deferParentConfigDoneActive = true; + (client as any).suppressNextConfigDoneDeferral = true; + + await client.sendRequest('configurationDone', { go: true }); + + expect(captured).toHaveLength(1); + expect(captured[0].command).toBe('configurationDone'); + expect((client as any).suppressNextConfigDoneDeferral).toBe(false); + expect((client as any).parentConfigDoneDeferred).toBeNull(); + }); + }); + + describe('Trace file error handling', () => { + it('swallows fs.appendFileSync errors so requests still complete', async () => { + const originalTrace = process.env.DAP_TRACE_FILE; + process.env.DAP_TRACE_FILE = 'trace.ndjson'; + const appendSpy = vi.spyOn(fs, 'appendFileSync').mockImplementation(() => { + throw new Error('disk full'); + }); + + const traceClient = new MinimalDapClient('localhost', 5678); + const captured: DebugProtocol.Request[] = []; + const sock = { + destroyed: false, + end: vi.fn(), + destroy: vi.fn(), + write: vi.fn((raw: string, cb?: (err?: Error | null) => void) => { + cb?.(null); + const [, body] = raw.split('\r\n\r\n'); + const request = JSON.parse(body) as DebugProtocol.Request; + captured.push(request); + setImmediate(() => { + void (traceClient as any).handleProtocolMessage({ + seq: request.seq, + type: 'response', + request_seq: request.seq, + command: request.command, + success: true + } satisfies DebugProtocol.Response); + }); + return true; + }) + } as unknown as net.Socket; + (traceClient as any).socket = sock; + + await expect(traceClient.sendRequest('threads')).resolves.toBeDefined(); + + expect(appendSpy).toHaveBeenCalled(); + expect(captured).toHaveLength(1); + + appendSpy.mockRestore(); + traceClient.shutdown(); + if (originalTrace === undefined) { + delete process.env.DAP_TRACE_FILE; + } else { + process.env.DAP_TRACE_FILE = originalTrace; + } + }); + }); + }); diff --git a/tests/unit/utils/jvm-orphan-reaper-internals.test.ts b/tests/unit/utils/jvm-orphan-reaper-internals.test.ts new file mode 100644 index 00000000..3d5bc209 --- /dev/null +++ b/tests/unit/utils/jvm-orphan-reaper-internals.test.ts @@ -0,0 +1,402 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +// Module mocks are hoisted above all imports. Each mock preserves the rest of +// the module via importOriginal so unrelated code paths aren't disturbed. +vi.mock('node:fs/promises', async (importOriginal: () => Promise) => { + const actual = (await importOriginal()) as Record; + return { + ...actual, + readdir: vi.fn(), + readFile: vi.fn(), + }; +}); + +vi.mock('node:child_process', async (importOriginal: () => Promise) => { + const actual = (await importOriginal()) as Record; + return { + ...actual, + execFile: vi.fn(), + }; +}); + +import { execFile } from 'node:child_process'; +import * as fsp from 'node:fs/promises'; +import { + parseArgs, + isPidAlive, + defaultKill, + listLinux, + listDarwin, + listWindows, +} from '../../../src/utils/jvm-orphan-reaper.js'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const mockExecFile = execFile as unknown as ReturnType; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const mockReaddir = fsp.readdir as unknown as ReturnType; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const mockReadFile = fsp.readFile as unknown as ReturnType; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('parseArgs', () => { + it('returns null when JVM marker is missing', () => { + expect( + parseArgs(123, [ + 'java', + '-Dmcp.debugger.owner_pid=42', + '-Dmcp.debugger.session_tag=t', + ]), + ).toBeNull(); + }); + + it('returns null when owner_pid is missing', () => { + expect( + parseArgs(123, ['java', '-Dmcp.debugger.jvm=true']), + ).toBeNull(); + }); + + it('returns null when owner_pid is non-numeric', () => { + expect( + parseArgs(123, [ + 'java', + '-Dmcp.debugger.jvm=true', + '-Dmcp.debugger.owner_pid=not-a-pid', + ]), + ).toBeNull(); + }); + + it('returns null when owner_pid is zero or negative', () => { + expect( + parseArgs(123, [ + 'java', + '-Dmcp.debugger.jvm=true', + '-Dmcp.debugger.owner_pid=0', + ]), + ).toBeNull(); + expect( + parseArgs(123, [ + 'java', + '-Dmcp.debugger.jvm=true', + '-Dmcp.debugger.owner_pid=-5', + ]), + ).toBeNull(); + }); + + it('returns a full TaggedJvm when marker + owner_pid + session_tag are all present', () => { + const result = parseArgs(9001, [ + 'java', + '-Dmcp.debugger.jvm=true', + '-Dmcp.debugger.owner_pid=42', + '-Dmcp.debugger.session_tag=abc-123', + 'MyMain', + ]); + expect(result).toEqual({ pid: 9001, ownerPid: 42, sessionTag: 'abc-123' }); + }); + + it('tolerates missing session_tag by leaving it empty', () => { + const result = parseArgs(9001, [ + 'java', + '-Dmcp.debugger.jvm=true', + '-Dmcp.debugger.owner_pid=42', + ]); + expect(result).toEqual({ pid: 9001, ownerPid: 42, sessionTag: '' }); + }); + + it('ignores unrelated -D args', () => { + const result = parseArgs(9001, [ + 'java', + '-Dfoo=bar', + '-Dmcp.debugger.jvm=true', + '-Djava.awt.headless=true', + '-Dmcp.debugger.owner_pid=42', + '-Dmcp.debugger.session_tag=tag', + ]); + expect(result).toEqual({ pid: 9001, ownerPid: 42, sessionTag: 'tag' }); + }); +}); + +describe('isPidAlive', () => { + it('returns false for pid <= 0 without calling process.kill', () => { + const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true); + expect(isPidAlive(0)).toBe(false); + expect(isPidAlive(-1)).toBe(false); + expect(killSpy).not.toHaveBeenCalled(); + }); + + it('returns true when process.kill(pid, 0) succeeds', () => { + vi.spyOn(process, 'kill').mockImplementation(() => true); + expect(isPidAlive(12345)).toBe(true); + }); + + it('returns true when process.kill throws EPERM (process exists, no permission)', () => { + vi.spyOn(process, 'kill').mockImplementation(() => { + const err = new Error('permission denied') as NodeJS.ErrnoException; + err.code = 'EPERM'; + throw err; + }); + expect(isPidAlive(12345)).toBe(true); + }); + + it('returns false when process.kill throws ESRCH (no such process)', () => { + vi.spyOn(process, 'kill').mockImplementation(() => { + const err = new Error('no such process') as NodeJS.ErrnoException; + err.code = 'ESRCH'; + throw err; + }); + expect(isPidAlive(12345)).toBe(false); + }); + + it('returns false on unexpected error codes', () => { + vi.spyOn(process, 'kill').mockImplementation(() => { + const err = new Error('invalid arg') as NodeJS.ErrnoException; + err.code = 'EINVAL'; + throw err; + }); + expect(isPidAlive(12345)).toBe(false); + }); +}); + +describe('defaultKill', () => { + it('returns true when SIGKILL succeeds', () => { + const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true); + expect(defaultKill(9001)).toBe(true); + expect(killSpy).toHaveBeenCalledWith(9001, 'SIGKILL'); + }); + + it('returns false on ESRCH (process already gone)', () => { + vi.spyOn(process, 'kill').mockImplementation(() => { + const err = new Error('no such process') as NodeJS.ErrnoException; + err.code = 'ESRCH'; + throw err; + }); + expect(defaultKill(9001)).toBe(false); + }); + + it('returns false on EPERM (foreign-owned process)', () => { + vi.spyOn(process, 'kill').mockImplementation(() => { + const err = new Error('permission denied') as NodeJS.ErrnoException; + err.code = 'EPERM'; + throw err; + }); + expect(defaultKill(9001)).toBe(false); + }); + + it('rethrows on unexpected error codes', () => { + vi.spyOn(process, 'kill').mockImplementation(() => { + const err = new Error('invalid arg') as NodeJS.ErrnoException; + err.code = 'EINVAL'; + throw err; + }); + expect(() => defaultKill(9001)).toThrow('invalid arg'); + }); +}); + +describe('listLinux', () => { + it('returns empty array when /proc readdir fails', async () => { + mockReaddir.mockRejectedValueOnce(new Error('EACCES')); + expect(await listLinux()).toEqual([]); + }); + + it('skips non-numeric entries in /proc', async () => { + mockReaddir.mockResolvedValueOnce(['cpuinfo', 'self', 'cmdline', 'meminfo'] as never); + expect(await listLinux()).toEqual([]); + // None of the entries were numeric so no readFile calls should have happened + expect(mockReadFile).not.toHaveBeenCalled(); + }); + + it('skips PIDs whose cmdline read fails (process disappeared)', async () => { + mockReaddir.mockResolvedValueOnce(['100', '200'] as never); + mockReadFile.mockImplementation(async (path: unknown) => { + if (String(path).includes('/100/')) { + throw Object.assign(new Error('disappeared'), { code: 'ENOENT' }); + } + // 200 is a non-matching cmdline + return 'sh\0-c\0echo hi\0'; + }); + expect(await listLinux()).toEqual([]); + }); + + it('parses NUL-delimited cmdline and returns tagged JVMs', async () => { + mockReaddir.mockResolvedValueOnce(['100', '200', 'self'] as never); + mockReadFile.mockImplementation(async (path: unknown) => { + const p = String(path); + if (p.includes('/100/')) { + return [ + 'java', + '-Dmcp.debugger.jvm=true', + '-Dmcp.debugger.owner_pid=42', + '-Dmcp.debugger.session_tag=tag-a', + 'MyMain', + ].join('\0') + '\0'; + } + if (p.includes('/200/')) { + // Not a tagged JVM + return 'bash\0-l\0'; + } + return ''; + }); + const result = await listLinux(); + expect(result).toEqual([ + { pid: 100, ownerPid: 42, sessionTag: 'tag-a' }, + ]); + }); + + it('returns empty array when no /proc entries match the marker', async () => { + mockReaddir.mockResolvedValueOnce(['100', '200'] as never); + mockReadFile.mockResolvedValue('java\0-jar\0app.jar\0' as never); + expect(await listLinux()).toEqual([]); + }); +}); + +describe('listDarwin', () => { + // The reaper calls promisify(execFile) at module load. Tests drive the mock + // by invoking the supplied callback. Promisify's default wrapper resolves + // with the value passed as the second callback arg, so we pass an object + // shaped like {stdout, stderr} for the destructuring on the receiving side. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const respondWith = (result: { stdout: string; stderr?: string }) => + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockExecFile.mockImplementation((..._args: any[]) => { + // Last argument is the node-style callback + const cb = _args[_args.length - 1] as ( + err: Error | null, + result: { stdout: string; stderr: string }, + ) => void; + cb(null, { stdout: result.stdout, stderr: result.stderr ?? '' }); + }); + + it('parses tagged JVMs from ps output', async () => { + respondWith({ + stdout: [ + ' 100 java -Dmcp.debugger.jvm=true -Dmcp.debugger.owner_pid=42 -Dmcp.debugger.session_tag=tag-a MyMain', + ' 200 bash -l', + ' 300 java -Dmcp.debugger.jvm=true -Dmcp.debugger.owner_pid=99 -Dmcp.debugger.session_tag=tag-b OtherMain', + '', + ].join('\n'), + }); + const result = await listDarwin(); + expect(result).toEqual([ + { pid: 100, ownerPid: 42, sessionTag: 'tag-a' }, + { pid: 300, ownerPid: 99, sessionTag: 'tag-b' }, + ]); + }); + + it('returns empty array when ps stdout is empty', async () => { + respondWith({ stdout: '' }); + expect(await listDarwin()).toEqual([]); + }); + + it('skips lines that do not match the pid+command pattern', async () => { + respondWith({ + stdout: ['garbage-line', ' ', ' not-a-pid bash', '\t\t'].join('\n'), + }); + expect(await listDarwin()).toEqual([]); + }); + + it('tolerates trailing whitespace on each line', async () => { + respondWith({ + stdout: ' 100 java -Dmcp.debugger.jvm=true -Dmcp.debugger.owner_pid=42 \n', + }); + expect(await listDarwin()).toEqual([ + { pid: 100, ownerPid: 42, sessionTag: '' }, + ]); + }); +}); + +describe('listWindows', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const respondWith = (result: { stdout?: string; err?: Error }) => + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockExecFile.mockImplementation((..._args: any[]) => { + const cb = _args[_args.length - 1] as ( + err: Error | null, + result?: { stdout: string; stderr: string }, + ) => void; + if (result.err) { + cb(result.err); + } else { + cb(null, { stdout: result.stdout ?? '', stderr: '' }); + } + }); + + it('parses a JSON array of multiple tagged JVMs', async () => { + respondWith({ + stdout: JSON.stringify([ + { + ProcessId: 100, + CommandLine: + 'java -Dmcp.debugger.jvm=true -Dmcp.debugger.owner_pid=42 -Dmcp.debugger.session_tag=tag-a MyMain', + }, + { + ProcessId: 200, + CommandLine: 'java -Xmx2g MyOtherMain', + }, + { + ProcessId: 300, + CommandLine: + 'java -Dmcp.debugger.jvm=true -Dmcp.debugger.owner_pid=99 -Dmcp.debugger.session_tag=tag-b', + }, + ]), + }); + const result = await listWindows(); + expect(result).toEqual([ + { pid: 100, ownerPid: 42, sessionTag: 'tag-a' }, + { pid: 300, ownerPid: 99, sessionTag: 'tag-b' }, + ]); + }); + + it('handles PowerShell single-object output (no array wrapper)', async () => { + respondWith({ + stdout: JSON.stringify({ + ProcessId: 100, + CommandLine: + 'java -Dmcp.debugger.jvm=true -Dmcp.debugger.owner_pid=42 -Dmcp.debugger.session_tag=only', + }), + }); + expect(await listWindows()).toEqual([ + { pid: 100, ownerPid: 42, sessionTag: 'only' }, + ]); + }); + + it('returns empty array when PowerShell errors out', async () => { + respondWith({ err: new Error('powershell.exe not found') }); + expect(await listWindows()).toEqual([]); + }); + + it('returns empty array when stdout is empty/whitespace', async () => { + respondWith({ stdout: ' \n ' }); + expect(await listWindows()).toEqual([]); + }); + + it('returns empty array when stdout is not valid JSON', async () => { + respondWith({ stdout: 'not json {{{' }); + expect(await listWindows()).toEqual([]); + }); + + it('skips entries with missing or wrong-typed ProcessId / CommandLine', async () => { + respondWith({ + stdout: JSON.stringify([ + { ProcessId: 'not-a-number', CommandLine: 'java -Dmcp.debugger.jvm=true' }, + { ProcessId: 100 }, // CommandLine missing + { CommandLine: 'java -Dmcp.debugger.jvm=true' }, // ProcessId missing + null, + 'not-an-object', + { + ProcessId: 999, + CommandLine: + 'java -Dmcp.debugger.jvm=true -Dmcp.debugger.owner_pid=7 -Dmcp.debugger.session_tag=only', + }, + ]), + }); + expect(await listWindows()).toEqual([ + { pid: 999, ownerPid: 7, sessionTag: 'only' }, + ]); + }); +});