summaryrefslogtreecommitdiffhomepage
path: root/packages/exec-backend/src/local.test.ts
blob: 5357d6f83d18c55e8b5bec97bacf0e98cd9f981a (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
import { writeFile as fsWriteFile, mkdir, mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { ExecBackend } from "./backend.js";
import { createLocalExecBackend, localExecBackend } from "./local.js";

/**
 * LocalExecBackend — integration tests against the OUTERMOST real edge
 * (real fs/spawn). Zero internal mocks; no mocking of @dispatch/*.
 */
describe("LocalExecBackend", () => {
	const backend: ExecBackend = createLocalExecBackend();
	let tmpDir: string;

	beforeEach(async () => {
		tmpDir = await mkdtemp(join(tmpdir(), "exec-backend-test-"));
	});

	afterEach(async () => {
		await rm(tmpDir, { recursive: true, force: true });
	});

	describe("spawn", () => {
		it("runs a real `sh -c 'echo hi'` and returns exitCode 0 + captured stdout", async () => {
			let output = "";
			const result = await backend.spawn({
				command: "echo hi",
				cwd: tmpDir,
				signal: AbortSignal.timeout(5000),
				timeout: 5000,
				onOutput: (data) => {
					output += data;
				},
			});
			expect(result.exitCode).toBe(0);
			expect(result.timedOut).toBe(false);
			expect(result.aborted).toBe(false);
			expect(output).toContain("hi");
		});

		it("returns a non-zero exit code for a failing command", async () => {
			const result = await backend.spawn({
				command: "false",
				cwd: tmpDir,
				signal: AbortSignal.timeout(5000),
				timeout: 5000,
				onOutput: () => {},
			});
			expect(result.exitCode).toBe(1);
			expect(result.aborted).toBe(false);
			expect(result.timedOut).toBe(false);
		});

		it("streams stderr separately from stdout", async () => {
			const streams: Array<{ data: string; stream: "stdout" | "stderr" }> = [];
			const result = await backend.spawn({
				command: "echo out; echo err 1>&2",
				cwd: tmpDir,
				signal: AbortSignal.timeout(5000),
				timeout: 5000,
				onOutput: (data, stream) => streams.push({ data, stream }),
			});
			expect(result.exitCode).toBe(0);
			expect(streams.some((s) => s.stream === "stdout" && s.data.includes("out"))).toBe(true);
			expect(streams.some((s) => s.stream === "stderr" && s.data.includes("err"))).toBe(true);
		});

		it("resolves with aborted: true when the signal fires", async () => {
			const controller = new AbortController();
			const promise = backend.spawn({
				command: "sleep 30",
				cwd: tmpDir,
				signal: controller.signal,
				timeout: 60_000,
				onOutput: () => {},
			});
			// Let the sleep actually start.
			await new Promise((r) => setTimeout(r, 300));
			controller.abort();
			const result = await promise;
			expect(result.aborted).toBe(true);
			expect(result.timedOut).toBe(false);
		});

		it("resolves with timedOut: true when the timeout elapses", async () => {
			const start = Date.now();
			const result = await backend.spawn({
				command: "sleep 30",
				cwd: tmpDir,
				signal: AbortSignal.timeout(60_000),
				timeout: 300,
				onOutput: () => {},
			});
			const elapsed = Date.now() - start;
			expect(result.timedOut).toBe(true);
			expect(result.aborted).toBe(false);
			// Should resolve shortly after the 300ms timeout, well under 30s.
			expect(elapsed).toBeLessThan(10_000);
		});
	});

	describe("stat", () => {
		it("distinguishes file vs directory", async () => {
			await fsWriteFile(join(tmpDir, "file.txt"), "hello");
			await mkdir(join(tmpDir, "subdir"));

			const fileStat = await backend.stat(join(tmpDir, "file.txt"));
			expect(fileStat.isFile).toBe(true);
			expect(fileStat.isDirectory).toBe(false);

			const dirStat = await backend.stat(join(tmpDir, "subdir"));
			expect(dirStat.isFile).toBe(false);
			expect(dirStat.isDirectory).toBe(true);
		});

		it("throws ENOENT with .code for a missing path", async () => {
			try {
				await backend.stat(join(tmpDir, "nope"));
				expect.fail("stat should have thrown for a missing path");
			} catch (err: unknown) {
				expect((err as NodeJS.ErrnoException).code).toBe("ENOENT");
			}
		});
	});

	describe("readFile / writeFile / readdir / exists round-trip", () => {
		it("writes then reads a file (utf8 round-trip)", async () => {
			const filePath = join(tmpDir, "round.txt");
			await backend.writeFile(filePath, "round-trip content");
			const content = await backend.readFile(filePath);
			expect(content).toBe("round-trip content");
		});

		it("readdir lists entries with correct isDirectory flags", async () => {
			await fsWriteFile(join(tmpDir, "a.txt"), "a");
			await mkdir(join(tmpDir, "sub"));

			const entries = await backend.readdir(tmpDir);
			const names = entries.map((e) => e.name).sort();
			expect(names).toEqual(["a.txt", "sub"]);

			const sub = entries.find((e) => e.name === "sub");
			expect(sub?.isDirectory).toBe(true);

			const file = entries.find((e) => e.name === "a.txt");
			expect(file?.isDirectory).toBe(false);
		});

		it("exists returns true for an existing file, false for a missing one", async () => {
			const filePath = join(tmpDir, "exists.txt");
			await fsWriteFile(filePath, "x");
			expect(await backend.exists(filePath)).toBe(true);
			expect(await backend.exists(join(tmpDir, "missing"))).toBe(false);
		});

		it("exists returns true for an existing directory", async () => {
			await mkdir(join(tmpDir, "adir"));
			expect(await backend.exists(join(tmpDir, "adir"))).toBe(true);
		});

		it("readFile throws ENOENT with .code for a missing file", async () => {
			try {
				await backend.readFile(join(tmpDir, "missing.txt"));
				expect.fail("readFile should have thrown for a missing file");
			} catch (err: unknown) {
				expect((err as NodeJS.ErrnoException).code).toBe("ENOENT");
			}
		});

		it("readdir throws ENOENT with .code for a missing directory", async () => {
			try {
				await backend.readdir(join(tmpDir, "missingdir"));
				expect.fail("readdir should have thrown for a missing directory");
			} catch (err: unknown) {
				expect((err as NodeJS.ErrnoException).code).toBe("ENOENT");
			}
		});

		it("writeFile throws an error with .code when the parent dir is missing", async () => {
			try {
				await backend.writeFile(join(tmpDir, "missing-parent", "child.txt"), "x");
				expect.fail("writeFile should have thrown for a missing parent dir");
			} catch (err: unknown) {
				expect((err as NodeJS.ErrnoException).code).toBe("ENOENT");
			}
		});
	});

	describe("singleton", () => {
		it("localExecBackend singleton satisfies ExecBackend and behaves identically", async () => {
			expect(typeof localExecBackend.spawn).toBe("function");
			expect(typeof localExecBackend.readFile).toBe("function");
			const filePath = join(tmpDir, "singleton.txt");
			await localExecBackend.writeFile(filePath, "singleton");
			expect(await localExecBackend.readFile(filePath)).toBe("singleton");
		});
	});
});