| import type { ChildProcess } from "node:child_process"; |
| import { EventEmitter } from "node:events"; |
| import { PassThrough } from "node:stream"; |
| import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; |
|
|
| const { spawnWithFallbackMock, killProcessTreeMock } = vi.hoisted(() => ({ |
| spawnWithFallbackMock: vi.fn(), |
| killProcessTreeMock: vi.fn(), |
| })); |
|
|
| vi.mock("../../spawn-utils.js", () => ({ |
| spawnWithFallback: spawnWithFallbackMock, |
| })); |
|
|
| vi.mock("../../kill-tree.js", () => ({ |
| killProcessTree: killProcessTreeMock, |
| })); |
|
|
| let createChildAdapter: typeof import("./child.js").createChildAdapter; |
|
|
| function createStubChild(pid = 1234) { |
| const child = new EventEmitter() as ChildProcess; |
| child.stdin = new PassThrough() as ChildProcess["stdin"]; |
| child.stdout = new PassThrough() as ChildProcess["stdout"]; |
| child.stderr = new PassThrough() as ChildProcess["stderr"]; |
| Object.defineProperty(child, "pid", { value: pid, configurable: true }); |
| Object.defineProperty(child, "killed", { value: false, configurable: true, writable: true }); |
| const killMock = vi.fn(() => true); |
| child.kill = killMock as ChildProcess["kill"]; |
| return { child, killMock }; |
| } |
|
|
| async function createAdapterHarness(params?: { |
| pid?: number; |
| argv?: string[]; |
| env?: NodeJS.ProcessEnv; |
| }) { |
| const { child, killMock } = createStubChild(params?.pid); |
| spawnWithFallbackMock.mockResolvedValue({ |
| child, |
| usedFallback: false, |
| }); |
| const adapter = await createChildAdapter({ |
| argv: params?.argv ?? ["node", "-e", "setTimeout(() => {}, 1000)"], |
| env: params?.env, |
| stdinMode: "pipe-open", |
| }); |
| return { adapter, killMock }; |
| } |
|
|
| describe("createChildAdapter", () => { |
| const originalServiceMarker = process.env.OPENCLAW_SERVICE_MARKER; |
|
|
| beforeAll(async () => { |
| ({ createChildAdapter } = await import("./child.js")); |
| }); |
|
|
| beforeEach(() => { |
| spawnWithFallbackMock.mockClear(); |
| killProcessTreeMock.mockClear(); |
| delete process.env.OPENCLAW_SERVICE_MARKER; |
| }); |
|
|
| afterAll(() => { |
| if (originalServiceMarker === undefined) { |
| delete process.env.OPENCLAW_SERVICE_MARKER; |
| } else { |
| process.env.OPENCLAW_SERVICE_MARKER = originalServiceMarker; |
| } |
| }); |
|
|
| it("uses process-tree kill for default SIGKILL", async () => { |
| const { adapter, killMock } = await createAdapterHarness({ pid: 4321 }); |
|
|
| const spawnArgs = spawnWithFallbackMock.mock.calls[0]?.[0] as { |
| options?: { detached?: boolean }; |
| fallbacks?: Array<{ options?: { detached?: boolean } }>; |
| }; |
| |
| |
| if (process.platform === "win32") { |
| expect(spawnArgs.options?.detached).toBe(false); |
| expect(spawnArgs.fallbacks).toEqual([]); |
| } else { |
| expect(spawnArgs.options?.detached).toBe(true); |
| expect(spawnArgs.fallbacks?.[0]?.options?.detached).toBe(false); |
| } |
|
|
| adapter.kill(); |
|
|
| expect(killProcessTreeMock).toHaveBeenCalledWith(4321); |
| expect(killMock).not.toHaveBeenCalled(); |
| }); |
|
|
| it("uses direct child.kill for non-SIGKILL signals", async () => { |
| const { adapter, killMock } = await createAdapterHarness({ pid: 7654 }); |
|
|
| adapter.kill("SIGTERM"); |
|
|
| expect(killProcessTreeMock).not.toHaveBeenCalled(); |
| expect(killMock).toHaveBeenCalledWith("SIGTERM"); |
| }); |
|
|
| it("disables detached mode in service-managed runtime", async () => { |
| process.env.OPENCLAW_SERVICE_MARKER = "openclaw"; |
|
|
| await createAdapterHarness({ pid: 7777 }); |
|
|
| const spawnArgs = spawnWithFallbackMock.mock.calls[0]?.[0] as { |
| options?: { detached?: boolean }; |
| fallbacks?: Array<{ options?: { detached?: boolean } }>; |
| }; |
| expect(spawnArgs.options?.detached).toBe(false); |
| expect(spawnArgs.fallbacks ?? []).toEqual([]); |
| }); |
|
|
| it("keeps inherited env when no override env is provided", async () => { |
| await createAdapterHarness({ |
| pid: 3333, |
| argv: ["node", "-e", "process.exit(0)"], |
| }); |
|
|
| const spawnArgs = spawnWithFallbackMock.mock.calls[0]?.[0] as { |
| options?: { env?: NodeJS.ProcessEnv }; |
| }; |
| expect(spawnArgs.options?.env).toBeUndefined(); |
| }); |
|
|
| it("passes explicit env overrides as strings", async () => { |
| await createAdapterHarness({ |
| pid: 4444, |
| argv: ["node", "-e", "process.exit(0)"], |
| env: { FOO: "bar", COUNT: "12", DROP_ME: undefined }, |
| }); |
|
|
| const spawnArgs = spawnWithFallbackMock.mock.calls[0]?.[0] as { |
| options?: { env?: Record<string, string> }; |
| }; |
| expect(spawnArgs.options?.env).toEqual({ FOO: "bar", COUNT: "12" }); |
| }); |
| }); |
|
|