Files
bun.sh/test/cli/process-manager.test.ts
Claude Bot 6c28459fa9 Add process manager for bun start/stop/list/logs commands
Implements a workspace-local process manager that allows running and
managing background processes via CLI commands:

- bun start SCRIPT - Start a process in the background
- bun stop NAME - Stop a running process
- bun list - List all running processes
- bun logs NAME - View logs for a process

Implementation:
- Processes are forked and exec'd as `bun run SCRIPT`
- State persists via JSON files in /tmp/bun-pm-{hash}/
- Logs are captured to /tmp/bun-logs/{hash}/
- Workspace isolation via directory hash
- Automatic cleanup of dead processes on each command

The implementation provides basic process management functionality
without complex features like auto-restart or resource limits,
making it simple for users and future developers to understand
and use.

Tests: Added comprehensive integration tests in test/cli/process-manager.test.ts
covering start, stop, list, logs, workspace isolation, and error cases.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-30 11:32:17 +00:00

532 lines
13 KiB
TypeScript

import { spawnSync } from "bun";
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { bunEnv, bunExe, tempDir } from "harness";
import * as path from "node:path";
describe("bun process manager", () => {
let testDir: ReturnType<typeof tempDir>;
beforeEach(() => {
testDir = tempDir("process-manager", {});
});
afterEach(() => {
// Clean up any running processes
const { exitCode } = spawnSync({
cmd: [bunExe(), "list"],
cwd: String(testDir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
// If there are processes, stop them
if (exitCode === 0) {
const listResult = spawnSync({
cmd: [bunExe(), "list"],
cwd: String(testDir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const output = listResult.stdout.toString();
const lines = output.split("\n");
// Find process names from the output
for (const line of lines) {
const match = line.match(/^(\S+)\s+\d+/);
if (match && match[1] && match[1] !== "NAME") {
spawnSync({
cmd: [bunExe(), "stop", match[1]],
cwd: String(testDir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
}
}
}
});
test("start a simple script", async () => {
await Bun.write(
path.join(String(testDir), "server.js"),
`
console.log("Server started");
setInterval(() => {
console.log("Server running...");
}, 1000);
`,
);
const { exitCode, stdout, stderr } = spawnSync({
cmd: [bunExe(), "start", "./server.js"],
cwd: String(testDir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
expect(exitCode).toBe(0);
expect(stdout.toString()).toContain("Started");
expect(stdout.toString()).toContain("server.js");
// Give it a moment to start
await Bun.sleep(100);
// Verify the process is running
const listResult = spawnSync({
cmd: [bunExe(), "list"],
cwd: String(testDir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
expect(listResult.exitCode).toBe(0);
expect(listResult.stdout.toString()).toContain("server.js");
});
test("list shows running processes", async () => {
await Bun.write(path.join(String(testDir), "worker1.js"), `setInterval(() => {}, 1000);`);
await Bun.write(path.join(String(testDir), "worker2.js"), `setInterval(() => {}, 1000);`);
// Start two processes
spawnSync({
cmd: [bunExe(), "start", "./worker1.js"],
cwd: String(testDir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
spawnSync({
cmd: [bunExe(), "start", "./worker2.js"],
cwd: String(testDir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
await Bun.sleep(100);
const { exitCode, stdout } = spawnSync({
cmd: [bunExe(), "list"],
cwd: String(testDir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
expect(exitCode).toBe(0);
const output = stdout.toString();
expect(output).toContain("worker1.js");
expect(output).toContain("worker2.js");
expect(output).toContain("NAME");
expect(output).toContain("PID");
expect(output).toContain("UPTIME");
});
test("stop a running process", async () => {
await Bun.write(path.join(String(testDir), "stoppable.js"), `setInterval(() => {}, 1000);`);
// Start process
spawnSync({
cmd: [bunExe(), "start", "./stoppable.js"],
cwd: String(testDir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
await Bun.sleep(100);
// Stop process
const stopResult = spawnSync({
cmd: [bunExe(), "stop", "./stoppable.js"],
cwd: String(testDir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
expect(stopResult.exitCode).toBe(0);
expect(stopResult.stdout.toString()).toContain("Stopped");
// Verify it's not in the list anymore
const listResult = spawnSync({
cmd: [bunExe(), "list"],
cwd: String(testDir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
expect(listResult.stdout.toString()).not.toContain("stoppable.js");
});
test("logs command shows log paths", async () => {
await Bun.write(
path.join(String(testDir), "logger.js"),
`
console.log("stdout message");
console.error("stderr message");
setInterval(() => {}, 1000);
`,
);
// Start process
spawnSync({
cmd: [bunExe(), "start", "./logger.js"],
cwd: String(testDir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
await Bun.sleep(200);
// Get logs
const logsResult = spawnSync({
cmd: [bunExe(), "logs", "./logger.js"],
cwd: String(testDir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
expect(logsResult.exitCode).toBe(0);
const output = logsResult.stdout.toString();
expect(output).toContain("stdout");
expect(output).toContain("stderr");
expect(output).toContain("/tmp/bun-logs/");
});
test("cannot start the same process twice", async () => {
await Bun.write(path.join(String(testDir), "unique.js"), `setInterval(() => {}, 1000);`);
// Start process
const first = spawnSync({
cmd: [bunExe(), "start", "./unique.js"],
cwd: String(testDir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
expect(first.exitCode).toBe(0);
// Try to start again
const second = spawnSync({
cmd: [bunExe(), "start", "./unique.js"],
cwd: String(testDir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
expect(second.exitCode).not.toBe(0);
expect(second.stderr.toString()).toContain("already running");
});
test("stop non-existent process fails", () => {
const { exitCode, stderr } = spawnSync({
cmd: [bunExe(), "stop", "nonexistent"],
cwd: String(testDir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
expect(exitCode).not.toBe(0);
expect(stderr.toString()).toContain("not found");
});
test("logs for non-existent process fails", () => {
const { exitCode, stderr } = spawnSync({
cmd: [bunExe(), "logs", "nonexistent"],
cwd: String(testDir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
expect(exitCode).not.toBe(0);
expect(stderr.toString()).toContain("not found");
});
test("list with no processes", () => {
const { exitCode, stdout } = spawnSync({
cmd: [bunExe(), "list"],
cwd: String(testDir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
expect(exitCode).toBe(0);
expect(stdout.toString()).toContain("No processes running");
});
test("process output is captured to logs", async () => {
await Bun.write(
path.join(String(testDir), "output.js"),
`
console.log("test output line 1");
console.log("test output line 2");
console.error("test error line 1");
setInterval(() => {}, 1000);
`,
);
// Start process
spawnSync({
cmd: [bunExe(), "start", "./output.js"],
cwd: String(testDir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
// Wait for output
await Bun.sleep(300);
// Get logs
const logsResult = spawnSync({
cmd: [bunExe(), "logs", "./output.js"],
cwd: String(testDir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
expect(logsResult.exitCode).toBe(0);
const output = logsResult.stdout.toString();
expect(output).toContain("test output line 1");
expect(output).toContain("test output line 2");
expect(output).toContain("test error line 1");
});
test("start script from package.json", async () => {
await Bun.write(
path.join(String(testDir), "package.json"),
JSON.stringify({
name: "test",
scripts: {
dev: "bun run ./script.js",
},
}),
);
await Bun.write(
path.join(String(testDir), "script.js"),
`
console.log("Running from package.json script");
setInterval(() => {}, 1000);
`,
);
const { exitCode, stdout } = spawnSync({
cmd: [bunExe(), "start", "dev"],
cwd: String(testDir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
expect(exitCode).toBe(0);
expect(stdout.toString()).toContain("Started");
await Bun.sleep(100);
const listResult = spawnSync({
cmd: [bunExe(), "list"],
cwd: String(testDir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
expect(listResult.exitCode).toBe(0);
expect(listResult.stdout.toString()).toContain("dev");
});
test("workspace isolation - processes in different dirs don't interfere", async () => {
const dir1 = tempDir("process-manager-1", {});
const dir2 = tempDir("process-manager-2", {});
try {
await Bun.write(path.join(String(dir1), "app.js"), `setInterval(() => {}, 1000);`);
await Bun.write(path.join(String(dir2), "app.js"), `setInterval(() => {}, 1000);`);
// Start process in dir1
spawnSync({
cmd: [bunExe(), "start", "./app.js"],
cwd: String(dir1),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
// Start process in dir2
spawnSync({
cmd: [bunExe(), "start", "./app.js"],
cwd: String(dir2),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
await Bun.sleep(100);
// List in dir1 should only show its process
const list1 = spawnSync({
cmd: [bunExe(), "list"],
cwd: String(dir1),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
// List in dir2 should only show its process
const list2 = spawnSync({
cmd: [bunExe(), "list"],
cwd: String(dir2),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
// Both should show exactly one process
const lines1 = list1.stdout
.toString()
.split("\n")
.filter(l => l.includes("app.js"));
const lines2 = list2.stdout
.toString()
.split("\n")
.filter(l => l.includes("app.js"));
expect(lines1.length).toBe(1);
expect(lines2.length).toBe(1);
// Cleanup
spawnSync({
cmd: [bunExe(), "stop", "./app.js"],
cwd: String(dir1),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
spawnSync({
cmd: [bunExe(), "stop", "./app.js"],
cwd: String(dir2),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
} finally {
// Cleanup dirs
}
});
test("help text is shown when no subcommand", () => {
const { exitCode, stdout } = spawnSync({
cmd: [bunExe(), "start"],
cwd: String(testDir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
expect(exitCode).not.toBe(0);
// The help should be printed by the main command handler
});
test("uptime is tracked correctly", async () => {
await Bun.write(path.join(String(testDir), "timed.js"), `setInterval(() => {}, 1000);`);
// Start process
spawnSync({
cmd: [bunExe(), "start", "./timed.js"],
cwd: String(testDir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
// Wait a bit
await Bun.sleep(2000);
// Check list
const listResult = spawnSync({
cmd: [bunExe(), "list"],
cwd: String(testDir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
expect(listResult.exitCode).toBe(0);
const output = listResult.stdout.toString();
// Should show at least 1 second of uptime
expect(output).toMatch(/\d+s/);
});
test("process manager persists state across commands", async () => {
await Bun.write(path.join(String(testDir), "persistent.js"), `setInterval(() => {}, 1000);`);
// Start process
spawnSync({
cmd: [bunExe(), "start", "./persistent.js"],
cwd: String(testDir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
await Bun.sleep(100);
// Multiple list calls should all see the same process
for (let i = 0; i < 3; i++) {
const listResult = spawnSync({
cmd: [bunExe(), "list"],
cwd: String(testDir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
expect(listResult.exitCode).toBe(0);
expect(listResult.stdout.toString()).toContain("persistent.js");
await Bun.sleep(50);
}
// Stop should work
const stopResult = spawnSync({
cmd: [bunExe(), "stop", "./persistent.js"],
cwd: String(testDir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
expect(stopResult.exitCode).toBe(0);
// And it should be gone
const finalList = spawnSync({
cmd: [bunExe(), "list"],
cwd: String(testDir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
expect(finalList.stdout.toString()).not.toContain("persistent.js");
});
});