Files
zerobyte/app/server/utils/__tests__/spawn.test.ts
Nico 332e5bffda refactor: extract restic in core package (#651)
* refactor: extract restic in core package

* chore: add turbo task runner

* refactor: split server utils

* chore: simplify withDeps signature and fix non-null assertion
2026-03-11 21:56:07 +01:00

296 lines
7.4 KiB
TypeScript

import { describe, expect, test } from "bun:test";
import { safeExec, safeSpawn } from "@zerobyte/core/node";
describe("safeExec", () => {
describe("successful commands", () => {
test("returns exitCode 0 and output for successful command", async () => {
const result = await safeExec({ command: "echo", args: ["hello"] });
expect(result.exitCode).toBe(0);
expect(result.stdout.trim()).toBe("hello");
expect(result.stderr).toBe("");
expect(result.timedOut).toBe(false);
});
});
describe("failed commands", () => {
test("returns non-zero exitCode for failed command", async () => {
const result = await safeExec({
command: "sh",
args: ["-c", "exit 1"],
});
expect(result.exitCode).toBe(1);
expect(result.timedOut).toBe(false);
});
test("captures stderr from failed command", async () => {
const result = await safeExec({
command: "sh",
args: ["-c", "echo 'error message' >&2 && exit 1"],
});
expect(result.exitCode).toBe(1);
expect(result.stderr).toContain("error message");
expect(result.timedOut).toBe(false);
});
});
describe("timeout handling", () => {
test("detects timeout and sets timedOut flag", async () => {
const result = await safeExec({
command: "sleep",
args: ["10"],
timeout: 100,
});
expect(result.timedOut).toBe(true);
expect(result.exitCode).toBe(1);
expect(result.stderr).toBe("Command timed out before completing");
});
test("returns timedOut false when command completes within timeout", async () => {
const result = await safeExec({
command: "echo",
args: ["quick"],
timeout: 5000,
});
expect(result.timedOut).toBe(false);
expect(result.exitCode).toBe(0);
});
});
describe("env", () => {
test("passes custom env variables to the command", async () => {
const result = await safeExec({
command: "sh",
args: ["-c", "echo $TEST_EXEC_VAR"],
env: { TEST_EXEC_VAR: "exec_value" },
});
expect(result.exitCode).toBe(0);
expect(result.stdout.trim()).toBe("exec_value");
});
});
describe("shell injection protection", () => {
test.each([
{
name: "treats semicolon-separated commands as a single literal argument",
argument: "safe; echo injected",
expected: "safe; echo injected",
},
{
name: "does not evaluate command substitution syntax",
argument: "$(echo injected)",
expected: "$(echo injected)",
},
{
name: "does not expand glob patterns",
argument: "*.ts",
expected: "*.ts",
},
])("$name", async ({ argument, expected }) => {
const result = await safeExec({
command: "echo",
args: [argument],
});
expect(result.exitCode).toBe(0);
expect(result.stdout.trim()).toBe(expected);
});
});
describe("stdout on failure", () => {
test("captures stdout written before a non-zero exit", async () => {
const result = await safeExec({
command: "sh",
args: ["-c", "echo output_before_failure && exit 1"],
});
expect(result.exitCode).toBe(1);
expect(result.stdout).toContain("output_before_failure");
});
});
});
describe("safeSpawn", () => {
describe("successful commands", () => {
test("returns exitCode 0 and correct summary", async () => {
const result = await safeSpawn({ command: "echo", args: ["hello"] });
expect(result.exitCode).toBe(0);
expect(result.summary).toBe("hello");
expect(result.error).toBe("");
});
test("summary is the last non-empty stdout line", async () => {
const result = await safeSpawn({
command: "sh",
args: ["-c", "echo first && echo second && echo third"],
});
expect(result.exitCode).toBe(0);
expect(result.summary).toBe("third");
});
test("skips blank and whitespace-only lines when tracking summary", async () => {
const result = await safeSpawn({
command: "sh",
args: ["-c", "printf 'first\\n\\n \\n'"],
});
expect(result.exitCode).toBe(0);
expect(result.summary).toBe("first");
});
});
describe("callbacks", () => {
test("calls onStdout once per stdout line", async () => {
const lines: string[] = [];
await safeSpawn({
command: "sh",
args: ["-c", "echo line1 && echo line2 && echo line3"],
onStdout: (line) => lines.push(line),
});
expect(lines).toEqual(["line1", "line2", "line3"]);
});
test("calls onStderr once per stderr line", async () => {
const errors: string[] = [];
await safeSpawn({
command: "sh",
args: ["-c", "echo err1 >&2 && echo err2 >&2"],
onStderr: (line) => errors.push(line),
});
expect(errors).toEqual(["err1", "err2"]);
});
test("calls onSpawn immediately with the child process", async () => {
let receivedChild: ReturnType<typeof import("node:child_process").spawn> | null = null;
await safeSpawn({
command: "echo",
args: ["test"],
onSpawn: (child) => {
receivedChild = child;
},
});
expect(receivedChild).not.toBeNull();
});
});
describe("failed commands", () => {
test("returns the exact non-zero exit code", async () => {
const result = await safeSpawn({
command: "sh",
args: ["-c", "exit 42"],
});
expect(result.exitCode).toBe(42);
});
test("error contains the last stderr line", async () => {
const result = await safeSpawn({
command: "sh",
args: ["-c", "echo err_first >&2 && echo err_last >&2 && exit 1"],
});
expect(result.exitCode).toBe(1);
expect(result.error).toBe("err_last");
});
test("returns exitCode -1 when the command is not found", async () => {
const result = await safeSpawn({
command: "this-command-does-not-exist-zerobyte",
args: [],
});
expect(result.exitCode).toBe(-1);
expect(result.error.length).toBeGreaterThan(0);
});
});
describe("stdoutMode", () => {
test("raw mode skips readline and leaves summary empty", async () => {
const result = await safeSpawn({
command: "echo",
args: ["hello"],
stdoutMode: "raw",
onSpawn: (child) => {
child.stdout?.resume();
},
});
expect(result.summary).toBe("");
});
test("raw mode exposes the raw stdout stream via onSpawn", async () => {
const chunks: Buffer[] = [];
await safeSpawn({
command: "echo",
args: ["raw_output"],
stdoutMode: "raw",
onSpawn: (child) => {
child.stdout?.on("data", (chunk: Buffer) => chunks.push(chunk));
},
});
const output = Buffer.concat(chunks).toString("utf8").trim();
expect(output).toBe("raw_output");
});
});
describe("env", () => {
test("passes custom env variables to the spawned process", async () => {
const lines: string[] = [];
await safeSpawn({
command: "sh",
args: ["-c", "echo $TEST_SPAWN_VAR"],
env: { TEST_SPAWN_VAR: "spawn_value" },
onStdout: (line) => lines.push(line),
});
expect(lines).toContain("spawn_value");
});
});
describe("shell injection protection", () => {
test.each([
{
name: "treats semicolon-separated commands as a single literal argument",
argument: "safe; echo injected",
expected: "safe; echo injected",
},
{
name: "does not evaluate command substitution syntax",
argument: "$(echo injected)",
expected: "$(echo injected)",
},
{
name: "does not expand glob patterns",
argument: "*.ts",
expected: "*.ts",
},
])("$name", async ({ argument, expected }) => {
const lines: string[] = [];
await safeSpawn({
command: "echo",
args: [argument],
onStdout: (line) => lines.push(line),
});
expect(lines).toEqual([expected]);
});
});
});