summaryrefslogtreecommitdiffhomepage
path: root/packages/exec-backend/src/local.test.ts
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-25 12:22:41 +0900
committerAdam Malczewski <[email protected]>2026-06-25 12:22:41 +0900
commit54db4583e66134010375a1fa94256f36034ffdff (patch)
treeec0bcd395d365741ed18e160f9b5842233051ba2 /packages/exec-backend/src/local.test.ts
parent0b154bdad4f75a091db3ca46424abd17fbbc23ff (diff)
downloaddispatch-54db4583e66134010375a1fa94256f36034ffdff.tar.gz
dispatch-54db4583e66134010375a1fa94256f36034ffdff.zip
feat(ssh): wave 1 — ExecBackend + computer data model + runtime threading
Wave 1 of transparent SSH support (parallel owner-agents on disjoint packages, plus the orchestrator-authored kernel contract seam from wave 0): - packages/wire: + Computer/ComputerEntry (read-only view over ~/.ssh/config Host aliases) + Workspace.defaultComputerId (string|null, null=local). Types only; 3 conformance tests. - packages/exec-backend (NEW core extension): the ExecBackend abstraction (spawn + minimal fs surface) the bundled tools will program against instead of node:fs/child_process. LocalExecBackend wraps today's node calls (behavior-identical; node:fs-style .code errors). execBackendHandle + ExecBackendResolver (sync; computerId undefined -> local; set -> throws until the ssh package wires remote resolution in wave 5). 20 tests. - packages/kernel (runtime only): thread computerId through dispatch.ts + run-turn.ts exactly as cwd is threaded (opaque, forwarded to ToolExecuteContext; absent = local = byte-identical to today). +2 tests. - packages/conversation-store: computer (SSH alias) assignment + resolution mirroring cwd — WorkspaceRow.defaultComputerId + setWorkspaceDefaultComputerId + getComputerId/setComputerId/clearComputerId + getEffectiveComputer (override -> per-conv -> workspace default -> null/local). Fixes the 3 Workspace literal sites the new required wire field broke. +18 tests. - orchestrator: root tsconfig.json ref for exec-backend + bun install. Verified: tsc -b EXIT 0, biome clean, 1592 vitest pass (was 1549, +43). Refs: notes/ssh-support-plan.md (decisions §0.5/§13). No merge or push.
Diffstat (limited to 'packages/exec-backend/src/local.test.ts')
-rw-r--r--packages/exec-backend/src/local.test.ts199
1 files changed, 199 insertions, 0 deletions
diff --git a/packages/exec-backend/src/local.test.ts b/packages/exec-backend/src/local.test.ts
new file mode 100644
index 0000000..5357d6f
--- /dev/null
+++ b/packages/exec-backend/src/local.test.ts
@@ -0,0 +1,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");
+ });
+ });
+});