@moonwall/cli
Version:
Testing framework for the Moon family of projects
307 lines (302 loc) • 10.2 kB
JavaScript
// 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
});
});
});