UNPKG

@moonwall/cli

Version:

Testing framework for the Moon family of projects

307 lines (302 loc) 10.2 kB
// src/internal/effect/__tests__/ProcessManagerService.test.ts import { describe, it, expect, vi, beforeEach } from "vitest"; import { Effect as Effect2, Exit } from "effect"; // src/internal/effect/ProcessManagerService.ts import { Context, Effect, Layer, pipe } from "effect"; import { Path } from "@effect/platform"; import { NodePath } from "@effect/platform-node"; import { spawn } from "child_process"; import * as fs from "fs"; // src/internal/effect/errors.ts import { Data } from "effect"; var PortDiscoveryError = class extends Data.TaggedError("PortDiscoveryError") { }; var NodeLaunchError = class extends Data.TaggedError("NodeLaunchError") { }; var NodeReadinessError = class extends Data.TaggedError("NodeReadinessError") { }; var ProcessError = class extends Data.TaggedError("ProcessError") { }; var StartupCacheError = class extends Data.TaggedError("StartupCacheError") { }; var FileLockError = class extends Data.TaggedError("FileLockError") { }; // src/internal/effect/ProcessManagerService.ts import { createLogger } from "@moonwall/util"; var logger = createLogger({ name: "ProcessManagerService" }); var ProcessManagerService = class extends Context.Tag("ProcessManagerService")() { }; var getLogPath = (config, pid) => pipe( Effect.all([Path.Path, Effect.sync(() => process.cwd())]), Effect.map(([pathService, cwd]) => { const dirPath = config.logDirectory || pathService.join(cwd, "tmp", "node_logs"); const portArg = config.args.find((a) => a.includes("port")); const port = portArg?.split("=")[1] || "unknown"; const baseName = pathService.basename(config.command); return pathService.join(dirPath, `${baseName}_node_${port}_${pid}.log`).replace(/node_node_undefined/g, "chopsticks"); }), Effect.provide(NodePath.layer), Effect.mapError( (cause) => new ProcessError({ cause, operation: "spawn" }) ) ); var ensureLogDirectory = (dirPath) => Effect.tryPromise({ try: async () => { try { await fs.promises.access(dirPath); } catch { await fs.promises.mkdir(dirPath, { recursive: true }); } }, catch: (cause) => new ProcessError({ cause, operation: "spawn" }) }); var spawnProcess = (config) => Effect.try({ try: () => { const child = spawn(config.command, [...config.args]); child.on("error", (error) => { logger.error(`Process spawn error: ${error}`); }); return child; }, catch: (cause) => new NodeLaunchError({ cause, command: config.command, args: [...config.args] }) }); var createLogStream = (logPath) => Effect.try({ try: () => fs.createWriteStream(logPath, { flags: "a" }), catch: (cause) => new ProcessError({ cause, operation: "spawn" }) }); var setupLogHandlers = (process2, logStream) => Effect.sync(() => { const logHandler = (chunk) => { logStream.write(chunk); }; process2.stdout?.on("data", logHandler); process2.stderr?.on("data", logHandler); }); var constructExitMessage = (process2, code, signal) => { const timestamp = (/* @__PURE__ */ new Date()).toISOString(); if (process2.isMoonwallTerminating) { return `${timestamp} [moonwall] process killed. reason: ${process2.moonwallTerminationReason || "unknown"} `; } if (code !== null) { return `${timestamp} [moonwall] process closed with status code ${code} `; } if (signal !== null) { return `${timestamp} [moonwall] process terminated by signal ${signal} `; } return `${timestamp} [moonwall] process closed unexpectedly `; }; var setupExitHandler = (process2, logStream) => Effect.sync(() => { process2.once("close", (code, signal) => { const message = constructExitMessage(process2, code, signal); logStream.end(message); }); }); var killProcess = (process2, logStream, reason) => Effect.sync(() => { process2.isMoonwallTerminating = true; process2.moonwallTerminationReason = reason; }).pipe( Effect.flatMap( () => process2.pid ? Effect.try({ try: () => { process2.kill("SIGTERM"); }, catch: (cause) => new ProcessError({ cause, pid: process2.pid, operation: "kill" }) }) : Effect.sync(() => logStream.end()) ) ); var launchProcess = (config) => pipe( Effect.all([Path.Path, Effect.sync(() => config.logDirectory || void 0)]), Effect.flatMap(([pathService, customLogDir]) => { const dirPath = customLogDir || pathService.join(process.cwd(), "tmp", "node_logs"); return pipe( ensureLogDirectory(dirPath), Effect.flatMap(() => spawnProcess(config)), Effect.flatMap((childProcess) => { if (childProcess.pid === void 0) { return Effect.fail( new ProcessError({ cause: new Error("Process PID is undefined after spawn"), operation: "spawn" }) ); } return pipe( getLogPath(config, childProcess.pid), Effect.flatMap( (logPath) => pipe( createLogStream(logPath), Effect.flatMap( (logStream) => pipe( setupLogHandlers(childProcess, logStream), Effect.flatMap(() => setupExitHandler(childProcess, logStream)), Effect.map(() => { const processInfo = { process: childProcess, logPath }; const cleanup = pipe( killProcess(childProcess, logStream, "Manual cleanup requested"), Effect.catchAll( (error) => Effect.sync(() => { logger.error(`Failed to cleanly kill process: ${error}`); }) ) ); return { result: processInfo, cleanup }; }) ) ) ) ) ); }) ); }), Effect.provide(NodePath.layer) ); var ProcessManagerServiceLive = Layer.succeed(ProcessManagerService, { launch: launchProcess }); // src/internal/effect/__tests__/ProcessManagerService.test.ts import { spawn as spawn2 } from "child_process"; import * as fs2 from "fs"; vi.mock("node:child_process", async (importOriginal) => { const actual = await importOriginal(); const { EventEmitter } = await import("events"); return { ...actual, spawn: vi.fn(() => { const mockProcess = new EventEmitter(); mockProcess.pid = 12345; mockProcess.stdout = new EventEmitter(); mockProcess.stderr = new EventEmitter(); mockProcess.kill = vi.fn(() => { setTimeout(() => { mockProcess.emit("close", 0, null); }, 10); }); return mockProcess; }) }; }); vi.mock("node:fs", async (importOriginal) => { const actual = await importOriginal(); const { EventEmitter } = await import("events"); return { ...actual, createWriteStream: vi.fn(() => { const mockStream = new EventEmitter(); mockStream.write = vi.fn(); mockStream.end = vi.fn(); return mockStream; }), promises: { ...actual.promises, access: vi.fn(() => Promise.resolve()), mkdir: vi.fn(() => Promise.resolve()) } }; }); describe("ProcessManagerService", () => { beforeEach(() => { vi.clearAllMocks(); fs2.promises.access.mockImplementation(() => Promise.resolve()); }); it("should launch a process and return cleanup function", async () => { const config = { command: "node", args: ["-e", "console.log('hello')"], name: "test-process", logDirectory: "/tmp/test_logs" }; const program = ProcessManagerService.pipe( Effect2.flatMap( (service) => service.launch(config).pipe( Effect2.flatMap( ({ result, cleanup }) => Effect2.sync(() => { expect(result.process.pid).toBeDefined(); expect(result.logPath).toContain(config.logDirectory); expect(spawn2).toHaveBeenCalledWith(config.command, config.args); expect(fs2.createWriteStream).toHaveBeenCalledWith( expect.stringContaining(config.logDirectory), { flags: "a" } ); }).pipe( Effect2.flatMap(() => cleanup), Effect2.tap( () => Effect2.sync(() => { expect(result.process.kill).toHaveBeenCalledWith("SIGTERM"); }) ) ) ) ) ), Effect2.provide(ProcessManagerServiceLive) ); const exit = await Effect2.runPromiseExit(program); expect(Exit.isSuccess(exit)).toBe(true); }); it("should handle process launch failure", async () => { spawn2.mockImplementationOnce(() => { throw new Error("Mock spawn error"); }); const config = { command: "invalid-command", args: [], name: "fail-process" }; const program = ProcessManagerService.pipe( Effect2.flatMap((service) => service.launch(config)), Effect2.provide(ProcessManagerServiceLive) ); const exit = await Effect2.runPromiseExit(program); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit) && exit.cause._tag === "Fail") { const error = exit.cause.error; expect(error).toBeInstanceOf(NodeLaunchError); if (error instanceof NodeLaunchError) { expect(error.command).toBe(config.command); } } }); it("should ensure log directory exists if not present", async () => { const config = { command: "node", args: ["-e", "console.log('hello')"], name: "test-process", logDirectory: "/tmp/new_test_logs" }; fs2.promises.access.mockImplementationOnce( () => Promise.reject(new Error("ENOENT")) ); const program = ProcessManagerService.pipe( Effect2.flatMap((service) => service.launch(config)), Effect2.provide(ProcessManagerServiceLive) ); await Effect2.runPromise(program); expect(fs2.promises.mkdir).toHaveBeenCalledWith(config.logDirectory, { recursive: true }); }); });