diff options
Diffstat (limited to 'packages/exec-backend/src')
| -rw-r--r-- | packages/exec-backend/src/backend.test.ts | 98 | ||||
| -rw-r--r-- | packages/exec-backend/src/backend.ts | 50 | ||||
| -rw-r--r-- | packages/exec-backend/src/extension.test.ts | 166 | ||||
| -rw-r--r-- | packages/exec-backend/src/extension.ts | 66 | ||||
| -rw-r--r-- | packages/exec-backend/src/index.ts | 4 | ||||
| -rw-r--r-- | packages/exec-backend/src/local.test.ts | 372 | ||||
| -rw-r--r-- | packages/exec-backend/src/local.ts | 216 | ||||
| -rw-r--r-- | packages/exec-backend/src/service.ts | 2 |
8 files changed, 487 insertions, 487 deletions
diff --git a/packages/exec-backend/src/backend.test.ts b/packages/exec-backend/src/backend.test.ts index 30458e7..2e454ea 100644 --- a/packages/exec-backend/src/backend.test.ts +++ b/packages/exec-backend/src/backend.test.ts @@ -6,58 +6,58 @@ import type { DirEntry, ExecBackend, ExecResult, SpawnParams, StatResult } from * (Pure compile-time + runtime check; zero internal mocks.) */ describe("ExecBackend type conformance", () => { - it("a minimal fake satisfies the ExecBackend interface", () => { - const fake: ExecBackend = { - spawn: async (_params: SpawnParams): Promise<ExecResult> => ({ - exitCode: 0, - timedOut: false, - aborted: false, - }), - readFile: async (_path: string): Promise<string> => "", - writeFile: async (_path: string, _content: string): Promise<void> => {}, - stat: async (_path: string): Promise<StatResult> => ({ isFile: true, isDirectory: false }), - readdir: async (_path: string): Promise<readonly DirEntry[]> => [], - exists: async (_path: string): Promise<boolean> => true, - }; + it("a minimal fake satisfies the ExecBackend interface", () => { + const fake: ExecBackend = { + spawn: async (_params: SpawnParams): Promise<ExecResult> => ({ + exitCode: 0, + timedOut: false, + aborted: false, + }), + readFile: async (_path: string): Promise<string> => "", + writeFile: async (_path: string, _content: string): Promise<void> => {}, + stat: async (_path: string): Promise<StatResult> => ({ isFile: true, isDirectory: false }), + readdir: async (_path: string): Promise<readonly DirEntry[]> => [], + exists: async (_path: string): Promise<boolean> => true, + }; - // Runtime sanity: every method is present and callable. - expect(typeof fake.spawn).toBe("function"); - expect(typeof fake.readFile).toBe("function"); - expect(typeof fake.writeFile).toBe("function"); - expect(typeof fake.stat).toBe("function"); - expect(typeof fake.readdir).toBe("function"); - expect(typeof fake.exists).toBe("function"); - }); + // Runtime sanity: every method is present and callable. + expect(typeof fake.spawn).toBe("function"); + expect(typeof fake.readFile).toBe("function"); + expect(typeof fake.writeFile).toBe("function"); + expect(typeof fake.stat).toBe("function"); + expect(typeof fake.readdir).toBe("function"); + expect(typeof fake.exists).toBe("function"); + }); - it("ExecResult is { exitCode, timedOut, aborted }", () => { - const result: ExecResult = { exitCode: null, timedOut: true, aborted: false }; - expect(result.exitCode).toBeNull(); - expect(result.timedOut).toBe(true); - expect(result.aborted).toBe(false); - }); + it("ExecResult is { exitCode, timedOut, aborted }", () => { + const result: ExecResult = { exitCode: null, timedOut: true, aborted: false }; + expect(result.exitCode).toBeNull(); + expect(result.timedOut).toBe(true); + expect(result.aborted).toBe(false); + }); - it("SpawnParams carries the shell-tool seam fields", () => { - const params: SpawnParams = { - command: "echo", - cwd: "/tmp", - signal: new AbortController().signal, - timeout: 1000, - onOutput: () => {}, - }; - expect(params.command).toBe("echo"); - expect(params.timeout).toBe(1000); - }); + it("SpawnParams carries the shell-tool seam fields", () => { + const params: SpawnParams = { + command: "echo", + cwd: "/tmp", + signal: new AbortController().signal, + timeout: 1000, + onOutput: () => {}, + }; + expect(params.command).toBe("echo"); + expect(params.timeout).toBe(1000); + }); - it("StatResult distinguishes file vs directory", () => { - const fileStat: StatResult = { isFile: true, isDirectory: false }; - const dirStat: StatResult = { isFile: false, isDirectory: true }; - expect(fileStat.isFile && !fileStat.isDirectory).toBe(true); - expect(!dirStat.isFile && dirStat.isDirectory).toBe(true); - }); + it("StatResult distinguishes file vs directory", () => { + const fileStat: StatResult = { isFile: true, isDirectory: false }; + const dirStat: StatResult = { isFile: false, isDirectory: true }; + expect(fileStat.isFile && !fileStat.isDirectory).toBe(true); + expect(!dirStat.isFile && dirStat.isDirectory).toBe(true); + }); - it("DirEntry carries name + isDirectory", () => { - const entry: DirEntry = { name: "sub", isDirectory: true }; - expect(entry.name).toBe("sub"); - expect(entry.isDirectory).toBe(true); - }); + it("DirEntry carries name + isDirectory", () => { + const entry: DirEntry = { name: "sub", isDirectory: true }; + expect(entry.name).toBe("sub"); + expect(entry.isDirectory).toBe(true); + }); }); diff --git a/packages/exec-backend/src/backend.ts b/packages/exec-backend/src/backend.ts index f6a807f..3eea7c0 100644 --- a/packages/exec-backend/src/backend.ts +++ b/packages/exec-backend/src/backend.ts @@ -24,30 +24,30 @@ /** A spawned process's result. Mirrors tool-shell's `SpawnResult` exactly. */ export interface ExecResult { - readonly exitCode: number | null; - readonly timedOut: boolean; - readonly aborted: boolean; + readonly exitCode: number | null; + readonly timedOut: boolean; + readonly aborted: boolean; } /** Parameters for spawning a shell command. Mirrors tool-shell's `SpawnShell` params. */ export interface SpawnParams { - readonly command: string; - readonly cwd: string; - readonly signal: AbortSignal; - readonly timeout: number; - readonly onOutput: (data: string, stream: "stdout" | "stderr") => void; + readonly command: string; + readonly cwd: string; + readonly signal: AbortSignal; + readonly timeout: number; + readonly onOutput: (data: string, stream: "stdout" | "stderr") => void; } /** Stat result — the subset read_file / write_file / edit_file need. */ export interface StatResult { - readonly isFile: boolean; - readonly isDirectory: boolean; + readonly isFile: boolean; + readonly isDirectory: boolean; } /** A directory entry — the subset read_file lists. */ export interface DirEntry { - readonly name: string; - readonly isDirectory: boolean; + readonly name: string; + readonly isDirectory: boolean; } /** @@ -56,23 +56,23 @@ export interface DirEntry { * `ToolExecuteContext.computerId` via the injected resolver. */ export interface ExecBackend { - /** Run a shell command, streaming stdout/stderr. The shell-tool seam. */ - readonly spawn: (params: SpawnParams) => Promise<ExecResult>; + /** Run a shell command, streaming stdout/stderr. The shell-tool seam. */ + readonly spawn: (params: SpawnParams) => Promise<ExecResult>; - // --- filesystem (the read_file / write_file / edit_file surface) --- + // --- filesystem (the read_file / write_file / edit_file surface) --- - /** Read a file as utf8 text. Throws node:fs-style errors with `.code`. */ - readonly readFile: (path: string) => Promise<string>; + /** Read a file as utf8 text. Throws node:fs-style errors with `.code`. */ + readonly readFile: (path: string) => Promise<string>; - /** Write utf8 text to a file. Throws on failure (e.g. missing parent dir). */ - readonly writeFile: (path: string, content: string) => Promise<void>; + /** Write utf8 text to a file. Throws on failure (e.g. missing parent dir). */ + readonly writeFile: (path: string, content: string) => Promise<void>; - /** Stat a path. Throws node:fs-style errors with `.code` (e.g. `"ENOENT"`). */ - readonly stat: (path: string) => Promise<StatResult>; + /** Stat a path. Throws node:fs-style errors with `.code` (e.g. `"ENOENT"`). */ + readonly stat: (path: string) => Promise<StatResult>; - /** List directory entries. Throws node:fs-style errors with `.code`. */ - readonly readdir: (path: string) => Promise<readonly DirEntry[]>; + /** List directory entries. Throws node:fs-style errors with `.code`. */ + readonly readdir: (path: string) => Promise<readonly DirEntry[]>; - /** Check existence without throwing (returns `false` when the path is missing). */ - readonly exists: (path: string) => Promise<boolean>; + /** Check existence without throwing (returns `false` when the path is missing). */ + readonly exists: (path: string) => Promise<boolean>; } diff --git a/packages/exec-backend/src/extension.test.ts b/packages/exec-backend/src/extension.test.ts index 57161a5..f32901b 100644 --- a/packages/exec-backend/src/extension.test.ts +++ b/packages/exec-backend/src/extension.test.ts @@ -28,94 +28,94 @@ import { execBackendHandle, remoteExecBackendFactoryHandle } from "./service.js" * against behavior-equivalent input. */ function createFakeHost(services: Map<string, unknown>): HostAPI { - const api = { - provideService<T>(handle: ServiceHandle<T>, impl: T): void { - services.set(handle.id, impl); - }, - getService<T>(handle: ServiceHandle<T>): T { - const impl = services.get(handle.id); - if (impl === undefined) { - throw new Error( - `Service "${handle.id}" has no provider. Call provideService before getService.`, - ); - } - return impl as T; - }, - }; - // The resolver only calls getService; the rest of HostAPI is unused here. - return api as unknown as HostAPI; + const api = { + provideService<T>(handle: ServiceHandle<T>, impl: T): void { + services.set(handle.id, impl); + }, + getService<T>(handle: ServiceHandle<T>): T { + const impl = services.get(handle.id); + if (impl === undefined) { + throw new Error( + `Service "${handle.id}" has no provider. Call provideService before getService.`, + ); + } + return impl as T; + }, + }; + // The resolver only calls getService; the rest of HostAPI is unused here. + return api as unknown as HostAPI; } /** A fake remote backend — identifiable so we can assert it's the one returned. */ function createFakeRemoteBackend(marker: string): ExecBackend { - const fail = (): never => { - throw new Error(`fake remote backend (${marker}) should not be called in this test`); - }; - return { - spawn: fail, - readFile: fail, - writeFile: fail, - stat: fail, - readdir: fail, - exists: fail, - }; + const fail = (): never => { + throw new Error(`fake remote backend (${marker}) should not be called in this test`); + }; + return { + spawn: fail, + readFile: fail, + writeFile: fail, + stat: fail, + readdir: fail, + exists: fail, + }; } describe("ExecBackend resolver", () => { - it("returns localExecBackend for computerId === undefined (local path unchanged)", () => { - const services = new Map<string, unknown>(); - const host = createFakeHost(services); - - // Activate the extension so it registers its resolver, then retrieve it. - createExecBackendExtension().activate(host); - const resolver = host.getService(execBackendHandle); - - expect(resolver(undefined)).toBe(localExecBackend); - expect(resolver()).toBe(localExecBackend); - }); - - it("returns the factory's backend for a set computerId when the factory is provided", () => { - const services = new Map<string, unknown>(); - const host = createFakeHost(services); - - // The `ssh` extension (not built yet) would do this: - const remoteBackend = createFakeRemoteBackend("ssh-alias"); - const factory = (computerId: string): ExecBackend => { - // Confirm the alias is threaded through to the factory. - expect(computerId).toBe("ssh-alias"); - return remoteBackend; - }; - host.provideService(remoteExecBackendFactoryHandle, factory); - - createExecBackendExtension().activate(host); - const resolver = host.getService(execBackendHandle); - - expect(resolver("ssh-alias")).toBe(remoteBackend); - }); - - it("throws a clear 'not configured' error when the factory is NOT provided (ssh not loaded)", () => { - const services = new Map<string, unknown>(); - const host = createFakeHost(services); - - // No remoteExecBackendFactoryHandle provided → simulates ssh not loaded. - createExecBackendExtension().activate(host); - const resolver = host.getService(execBackendHandle); - - // Not a crash: a clear, actionable error mentioning computerId + ssh. - expect(() => resolver("some-host")).toThrow(/SSH remote execution is not configured/); - expect(() => resolver("some-host")).toThrow(/ssh extension is not loaded/); - expect(() => resolver("some-host")).toThrow(/some-host/); - }); - - it("local path is unaffected by whether the factory is provided", () => { - // Even with a factory present, computerId === undefined still returns local. - const services = new Map<string, unknown>(); - const host = createFakeHost(services); - host.provideService(remoteExecBackendFactoryHandle, () => createFakeRemoteBackend("unused")); - - createExecBackendExtension().activate(host); - const resolver = host.getService(execBackendHandle); - - expect(resolver(undefined)).toBe(localExecBackend); - }); + it("returns localExecBackend for computerId === undefined (local path unchanged)", () => { + const services = new Map<string, unknown>(); + const host = createFakeHost(services); + + // Activate the extension so it registers its resolver, then retrieve it. + createExecBackendExtension().activate(host); + const resolver = host.getService(execBackendHandle); + + expect(resolver(undefined)).toBe(localExecBackend); + expect(resolver()).toBe(localExecBackend); + }); + + it("returns the factory's backend for a set computerId when the factory is provided", () => { + const services = new Map<string, unknown>(); + const host = createFakeHost(services); + + // The `ssh` extension (not built yet) would do this: + const remoteBackend = createFakeRemoteBackend("ssh-alias"); + const factory = (computerId: string): ExecBackend => { + // Confirm the alias is threaded through to the factory. + expect(computerId).toBe("ssh-alias"); + return remoteBackend; + }; + host.provideService(remoteExecBackendFactoryHandle, factory); + + createExecBackendExtension().activate(host); + const resolver = host.getService(execBackendHandle); + + expect(resolver("ssh-alias")).toBe(remoteBackend); + }); + + it("throws a clear 'not configured' error when the factory is NOT provided (ssh not loaded)", () => { + const services = new Map<string, unknown>(); + const host = createFakeHost(services); + + // No remoteExecBackendFactoryHandle provided → simulates ssh not loaded. + createExecBackendExtension().activate(host); + const resolver = host.getService(execBackendHandle); + + // Not a crash: a clear, actionable error mentioning computerId + ssh. + expect(() => resolver("some-host")).toThrow(/SSH remote execution is not configured/); + expect(() => resolver("some-host")).toThrow(/ssh extension is not loaded/); + expect(() => resolver("some-host")).toThrow(/some-host/); + }); + + it("local path is unaffected by whether the factory is provided", () => { + // Even with a factory present, computerId === undefined still returns local. + const services = new Map<string, unknown>(); + const host = createFakeHost(services); + host.provideService(remoteExecBackendFactoryHandle, () => createFakeRemoteBackend("unused")); + + createExecBackendExtension().activate(host); + const resolver = host.getService(execBackendHandle); + + expect(resolver(undefined)).toBe(localExecBackend); + }); }); diff --git a/packages/exec-backend/src/extension.ts b/packages/exec-backend/src/extension.ts index 9d6840a..5697842 100644 --- a/packages/exec-backend/src/extension.ts +++ b/packages/exec-backend/src/extension.ts @@ -2,19 +2,19 @@ import type { Extension, HostAPI, Manifest } from "@dispatch/kernel"; import type { ExecBackend } from "./backend.js"; import { localExecBackend } from "./local.js"; import { - type ExecBackendResolver, - execBackendHandle, - remoteExecBackendFactoryHandle, + type ExecBackendResolver, + execBackendHandle, + remoteExecBackendFactoryHandle, } from "./service.js"; export const manifest: Manifest = { - id: "exec-backend", - name: "Exec Backend", - version: "0.0.0", - apiVersion: "^0.1.0", - trust: "bundled", - activation: "eager", - contributes: { services: ["exec-backend/resolver"] }, + id: "exec-backend", + name: "Exec Backend", + version: "0.0.0", + apiVersion: "^0.1.0", + trust: "bundled", + activation: "eager", + contributes: { services: ["exec-backend/resolver"] }, }; /** @@ -36,22 +36,22 @@ export const manifest: Manifest = { * inside the first backend method call, not at resolve time. */ function createResolver(host: HostAPI): ExecBackendResolver { - return (computerId?: string): ExecBackend => { - if (computerId === undefined) return localExecBackend; - // computerId set → remote. Look up the factory the `ssh` extension provides. - // `host.getService` throws when nothing provided the handle (ssh not loaded); - // convert that into a clear "not configured" error rather than a crash. - let factory: (computerId: string) => ExecBackend; - try { - factory = host.getService(remoteExecBackendFactoryHandle); - } catch { - throw new Error( - `SSH remote execution is not configured: the ssh extension is not loaded ` + - `(requested computerId="${computerId}"). Load the ssh package to enable remote execution.`, - ); - } - return factory(computerId); - }; + return (computerId?: string): ExecBackend => { + if (computerId === undefined) return localExecBackend; + // computerId set → remote. Look up the factory the `ssh` extension provides. + // `host.getService` throws when nothing provided the handle (ssh not loaded); + // convert that into a clear "not configured" error rather than a crash. + let factory: (computerId: string) => ExecBackend; + try { + factory = host.getService(remoteExecBackendFactoryHandle); + } catch { + throw new Error( + `SSH remote execution is not configured: the ssh extension is not loaded ` + + `(requested computerId="${computerId}"). Load the ssh package to enable remote execution.`, + ); + } + return factory(computerId); + }; } /** @@ -63,11 +63,11 @@ function createResolver(host: HostAPI): ExecBackendResolver { * until `ssh` is loaded, a remote request fails with a clear error. */ export function createExecBackendExtension(): Extension { - return { - manifest, - activate(host) { - const resolver: ExecBackendResolver = createResolver(host); - host.provideService(execBackendHandle, resolver); - }, - }; + return { + manifest, + activate(host) { + const resolver: ExecBackendResolver = createResolver(host); + host.provideService(execBackendHandle, resolver); + }, + }; } diff --git a/packages/exec-backend/src/index.ts b/packages/exec-backend/src/index.ts index 30c12c8..7c8e569 100644 --- a/packages/exec-backend/src/index.ts +++ b/packages/exec-backend/src/index.ts @@ -3,6 +3,6 @@ export { createExecBackendExtension, manifest } from "./extension.js"; export { createLocalExecBackend, localExecBackend } from "./local.js"; export type { ExecBackendResolver } from "./service.js"; export { - execBackendHandle, - remoteExecBackendFactoryHandle, + execBackendHandle, + remoteExecBackendFactoryHandle, } from "./service.js"; diff --git a/packages/exec-backend/src/local.test.ts b/packages/exec-backend/src/local.test.ts index 5357d6f..7bf2ef5 100644 --- a/packages/exec-backend/src/local.test.ts +++ b/packages/exec-backend/src/local.test.ts @@ -10,190 +10,190 @@ import { createLocalExecBackend, localExecBackend } from "./local.js"; * (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"); - }); - }); + 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"); + }); + }); }); diff --git a/packages/exec-backend/src/local.ts b/packages/exec-backend/src/local.ts index ca88a11..1812e5d 100644 --- a/packages/exec-backend/src/local.ts +++ b/packages/exec-backend/src/local.ts @@ -20,32 +20,32 @@ import type { DirEntry, ExecBackend, ExecResult, SpawnParams, StatResult } from * share as a singleton. */ export function createLocalExecBackend(): ExecBackend { - return { - spawn: localSpawn, - - readFile: (path) => readFile(path, "utf8"), - - writeFile: (path, content) => writeFile(path, content, "utf8"), - - stat: async (path): Promise<StatResult> => { - const s = await stat(path); - return { isFile: s.isFile(), isDirectory: s.isDirectory() }; - }, - - readdir: async (path): Promise<readonly DirEntry[]> => { - const entries = await readdir(path, { encoding: "utf8", withFileTypes: true }); - return entries.map((e): DirEntry => ({ name: e.name, isDirectory: e.isDirectory() })); - }, - - exists: async (path): Promise<boolean> => { - try { - await access(path); - return true; - } catch { - return false; - } - }, - }; + return { + spawn: localSpawn, + + readFile: (path) => readFile(path, "utf8"), + + writeFile: (path, content) => writeFile(path, content, "utf8"), + + stat: async (path): Promise<StatResult> => { + const s = await stat(path); + return { isFile: s.isFile(), isDirectory: s.isDirectory() }; + }, + + readdir: async (path): Promise<readonly DirEntry[]> => { + const entries = await readdir(path, { encoding: "utf8", withFileTypes: true }); + return entries.map((e): DirEntry => ({ name: e.name, isDirectory: e.isDirectory() })); + }, + + exists: async (path): Promise<boolean> => { + try { + await access(path); + return true; + } catch { + return false; + } + }, + }; } /** Default singleton — stateless, safe to share across calls. */ @@ -61,86 +61,86 @@ export const localExecBackend: ExecBackend = createLocalExecBackend(); * listener/timer leaks. */ function localSpawn(params: SpawnParams): Promise<ExecResult> { - return new Promise<ExecResult>((resolve) => { - // detached: true puts the child in its own process group (pgid = child.pid). - // This lets us kill the entire group (child + any grandchildren that inherit - // the pipes) via process.kill(-pgid, "SIGKILL") on abort/timeout, so a - // backgrounded grandchild can't keep the stdio pipes open and stall the - // promise on child.on("close"). - const child = nodeSpawn("sh", ["-c", params.command], { - cwd: params.cwd, - stdio: ["ignore", "pipe", "pipe"], - detached: true, - }); - - let settled = false; - let timedOut = false; - let timer: ReturnType<typeof setTimeout> | undefined; - - /** Kill the entire child process group (best-effort — group may be gone). */ - const killGroup = () => { - if (child.pid !== undefined) { - try { - process.kill(-child.pid, "SIGKILL"); - } catch { - // Process group may already be gone — ignore. - } - } - }; - - /** Remove the abort listener and clear the timeout timer (no leaks). */ - const cleanup = () => { - if (timer !== undefined) { - clearTimeout(timer); - timer = undefined; - } - params.signal.removeEventListener("abort", onAbort); - }; - - /** Resolve once, then clean up so listeners/timers never leak. */ - const settle = (result: ExecResult) => { - if (settled) return; - settled = true; - cleanup(); - resolve(result); - }; - - const onAbort = () => { - if (settled) return; - killGroup(); - // Resolve immediately — do NOT wait for child.on("close"), which may - // never fire if a grandchild holds the pipes open. - settle({ exitCode: null, timedOut: false, aborted: true }); - }; - params.signal.addEventListener("abort", onAbort, { once: true }); - - timer = setTimeout(() => { - if (settled) return; - timedOut = true; - killGroup(); - // Resolve immediately — same reasoning as abort. - settle({ exitCode: null, timedOut: true, aborted: false }); - }, params.timeout); - - child.stdout.on("data", (chunk: Buffer) => { - params.onOutput(chunk.toString(), "stdout"); - }); - - child.stderr.on("data", (chunk: Buffer) => { - params.onOutput(chunk.toString(), "stderr"); - }); - - // Normal-completion path: wait for "close" so all stdout/stderr is captured. - // If abort/timeout already settled, this is a no-op (settled === true). - child.on("close", (code) => { - settle({ exitCode: code, timedOut, aborted: false }); - }); - - // Spawn error (e.g. bad cwd, sh not found). Kill the group just in case - // and resolve — never leave the promise pending. - child.on("error", () => { - killGroup(); - settle({ exitCode: 1, timedOut: false, aborted: false }); - }); - }); + return new Promise<ExecResult>((resolve) => { + // detached: true puts the child in its own process group (pgid = child.pid). + // This lets us kill the entire group (child + any grandchildren that inherit + // the pipes) via process.kill(-pgid, "SIGKILL") on abort/timeout, so a + // backgrounded grandchild can't keep the stdio pipes open and stall the + // promise on child.on("close"). + const child = nodeSpawn("sh", ["-c", params.command], { + cwd: params.cwd, + stdio: ["ignore", "pipe", "pipe"], + detached: true, + }); + + let settled = false; + let timedOut = false; + let timer: ReturnType<typeof setTimeout> | undefined; + + /** Kill the entire child process group (best-effort — group may be gone). */ + const killGroup = () => { + if (child.pid !== undefined) { + try { + process.kill(-child.pid, "SIGKILL"); + } catch { + // Process group may already be gone — ignore. + } + } + }; + + /** Remove the abort listener and clear the timeout timer (no leaks). */ + const cleanup = () => { + if (timer !== undefined) { + clearTimeout(timer); + timer = undefined; + } + params.signal.removeEventListener("abort", onAbort); + }; + + /** Resolve once, then clean up so listeners/timers never leak. */ + const settle = (result: ExecResult) => { + if (settled) return; + settled = true; + cleanup(); + resolve(result); + }; + + const onAbort = () => { + if (settled) return; + killGroup(); + // Resolve immediately — do NOT wait for child.on("close"), which may + // never fire if a grandchild holds the pipes open. + settle({ exitCode: null, timedOut: false, aborted: true }); + }; + params.signal.addEventListener("abort", onAbort, { once: true }); + + timer = setTimeout(() => { + if (settled) return; + timedOut = true; + killGroup(); + // Resolve immediately — same reasoning as abort. + settle({ exitCode: null, timedOut: true, aborted: false }); + }, params.timeout); + + child.stdout.on("data", (chunk: Buffer) => { + params.onOutput(chunk.toString(), "stdout"); + }); + + child.stderr.on("data", (chunk: Buffer) => { + params.onOutput(chunk.toString(), "stderr"); + }); + + // Normal-completion path: wait for "close" so all stdout/stderr is captured. + // If abort/timeout already settled, this is a no-op (settled === true). + child.on("close", (code) => { + settle({ exitCode: code, timedOut, aborted: false }); + }); + + // Spawn error (e.g. bad cwd, sh not found). Kill the group just in case + // and resolve — never leave the promise pending. + child.on("error", () => { + killGroup(); + settle({ exitCode: 1, timedOut: false, aborted: false }); + }); + }); } diff --git a/packages/exec-backend/src/service.ts b/packages/exec-backend/src/service.ts index 6cfa9de..37009c0 100644 --- a/packages/exec-backend/src/service.ts +++ b/packages/exec-backend/src/service.ts @@ -42,5 +42,5 @@ export const execBackendHandle = defineService<ExecBackendResolver>("exec-backen * rather than crashing activation. */ export const remoteExecBackendFactoryHandle = defineService<(computerId: string) => ExecBackend>( - "exec-backend/remote-factory", + "exec-backend/remote-factory", ); |
