diff --git a/tools/shadow/launchd/install-launchagent.test.ts b/tools/shadow/launchd/install-launchagent.test.ts new file mode 100644 index 0000000000..421dabb5cf --- /dev/null +++ b/tools/shadow/launchd/install-launchagent.test.ts @@ -0,0 +1,264 @@ +#!/usr/bin/env bun +/** + * Unit tests for tools/shadow/launchd/install-launchagent.ts. + * Implements B-0528 acceptance criteria (6 test categories). + * + * Strategy: test the pure helpers (`xmlEscape`, `substitutePlaceholders`, + * `requireAbsolute`, `tryDetect`) directly. The shell-out paths + * (`plutilLint`, `main`) are exercised via `bun` subprocess invocation + * with --dry-run so we don't touch `~/Library/LaunchAgents/`. + */ +import { describe, it, expect } from "bun:test"; +import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; + +import { + xmlEscape, + substitutePlaceholders, + requireAbsolute, + tryDetect, + plutilLint, +} from "./install-launchagent"; + +const SCRIPT_PATH = join(import.meta.dir, "install-launchagent.ts"); + +// ───────────────────────────────────────────────────────────────────── +// Category 1: Placeholder substitution +// ───────────────────────────────────────────────────────────────────── + +describe("substitutePlaceholders", () => { + it("replaces {{BUN_PATH}} and {{REPO_ROOT}}", () => { + const tpl = "bun=<{{BUN_PATH}}> root=<{{REPO_ROOT}}>"; + const out = substitutePlaceholders(tpl, "/repo", "/bun"); + expect(out).toBe("bun= root="); + }); + + it("replaces multiple occurrences of the same placeholder", () => { + const tpl = "{{REPO_ROOT}}/a {{REPO_ROOT}}/b {{REPO_ROOT}}/c"; + const out = substitutePlaceholders(tpl, "/r", "/b"); + expect(out).toBe("/r/a /r/b /r/c"); + }); + + it("exits with code 1 on unrecognized {{NAME}} placeholder", () => { + const tpl = "bun=<{{BUN_PATH}}> mystery=<{{UNKNOWN_THING}}>"; + const proc = spawnSync( + "bun", + ["-e", ` + import { substitutePlaceholders } from "${SCRIPT_PATH}"; + substitutePlaceholders(${JSON.stringify(tpl)}, "/r", "/b"); + `], + { encoding: "utf-8" }, + ); + expect(proc.status).toBe(1); + expect(proc.stderr).toContain("Unsubstituted placeholder"); + expect(proc.stderr).toContain("{{UNKNOWN_THING}}"); + }); +}); + +// ───────────────────────────────────────────────────────────────────── +// Category 2: XML escaping +// ───────────────────────────────────────────────────────────────────── + +describe("xmlEscape", () => { + it("escapes the five XML predefined entities", () => { + expect(xmlEscape("a & b < c > d \" e ' f")).toBe( + "a & b < c > d " e ' f", + ); + }); + + it("leaves safe characters unchanged", () => { + const safe = "/Users/foo/bar/Zeta-Test_baz.1234"; + expect(xmlEscape(safe)).toBe(safe); + }); + + it("escapes `&` first to avoid double-escaping (e.g., < should not become &lt;)", () => { + expect(xmlEscape("<&>")).toBe("<&>"); + }); +}); + +describe("substitutePlaceholders + xmlEscape (integration)", () => { + it("substituted values containing & < > produce plutil-valid plist", () => { + // Use a minimal valid plist template + const tpl = ` + +X{{REPO_ROOT}}`; + const dangerousPath = "/Users/foo & bar/Zeta"; + const out = substitutePlaceholders(tpl, dangerousPath, "/bun"); + expect(out).toContain("&"); + expect(out).toContain("<"); + expect(out).toContain(">"); + // Verify plutil accepts the rendered output + const dir = mkdtempSync(join(tmpdir(), "test-xml-escape-")); + const tmp = join(dir, "test.plist"); + try { + writeFileSync(tmp, out, "utf-8"); + const proc = spawnSync("plutil", ["-lint", tmp], { encoding: "utf-8" }); + expect(proc.status).toBe(0); + } finally { + try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } + } + }); +}); + +// ───────────────────────────────────────────────────────────────────── +// Category 3: Argument validation +// ───────────────────────────────────────────────────────────────────── + +describe("argument validation (via subprocess)", () => { + function runScript(args: string[]): { status: number | null; stderr: string; stdout: string } { + const proc = spawnSync("bun", [SCRIPT_PATH, ...args], { encoding: "utf-8" }); + return { + status: proc.status, + stderr: typeof proc.stderr === "string" ? proc.stderr : "", + stdout: typeof proc.stdout === "string" ? proc.stdout : "", + }; + } + + it("rejects unknown flag --dryrun (typo) before any FS action", () => { + const r = runScript(["--dryrun"]); + expect(r.status).toBe(1); + expect(r.stderr).toContain("Unknown argument"); + expect(r.stderr).toContain("--dryrun"); + }); + + it("rejects relative --repo-root .", () => { + const r = runScript(["--repo-root", ".", "--dry-run"]); + expect(r.status).toBe(1); + expect(r.stderr).toContain("--repo-root must be an absolute path"); + }); + + it("rejects relative --bun-path ./bin", () => { + const r = runScript(["--bun-path", "./bin", "--dry-run"]); + expect(r.status).toBe(1); + expect(r.stderr).toContain("--bun-path must be an absolute path"); + }); + + it("exits with descriptive error when --repo-root has no value", () => { + const r = runScript(["--repo-root"]); + expect(r.status).toBe(1); + expect(r.stderr).toContain("Missing value for --repo-root"); + }); + + it("treats a following flag as missing-value for --repo-root", () => { + const r = runScript(["--repo-root", "--dry-run"]); + expect(r.status).toBe(1); + expect(r.stderr).toContain("Missing value for --repo-root"); + expect(r.stderr).toContain("--dry-run"); + }); +}); + +describe("requireAbsolute", () => { + it("returns the value unchanged for absolute paths", () => { + expect(requireAbsolute("--repo-root", "/Users/foo/bar")).toBe("/Users/foo/bar"); + }); +}); + +// ───────────────────────────────────────────────────────────────────── +// Category 4: Dry-run output +// ───────────────────────────────────────────────────────────────────── + +describe("--dry-run", () => { + it("writes rendered plist to stdout, not to ~/Library/LaunchAgents/", () => { + // Use the actual repo template via --repo-root , --bun-path /opt/homebrew/bin/bun (or any abs). + const repoRoot = process.cwd(); + const bunPath = process.execPath; // bun itself is an absolute path + const proc = spawnSync( + "bun", + [SCRIPT_PATH, "--dry-run", "--repo-root", repoRoot, "--bun-path", bunPath], + { encoding: "utf-8" }, + ); + expect(proc.status).toBe(0); + expect(proc.stdout).toContain(""); + expect(proc.stdout).toContain(repoRoot); + expect(proc.stdout).toContain(bunPath); + // Verify it's a valid plist by piping to plutil + const dir = mkdtempSync(join(tmpdir(), "test-dry-run-")); + const tmp = join(dir, "rendered.plist"); + try { + writeFileSync(tmp, proc.stdout, "utf-8"); + const lint = spawnSync("plutil", ["-lint", tmp], { encoding: "utf-8" }); + expect(lint.status).toBe(0); + } finally { + try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } + } + }); +}); + +// ───────────────────────────────────────────────────────────────────── +// Category 5: Default detection +// ───────────────────────────────────────────────────────────────────── + +describe("tryDetect", () => { + it("returns the trimmed stdout when the command exists", () => { + // `which bun` should work in test environment + const r = tryDetect("which", ["bun"]); + expect(r).toBeTruthy(); + expect(r).toMatch(/\/bun$/); + }); + + it("returns undefined cleanly when the command does not exist (instead of throwing)", () => { + const r = tryDetect("nonexistent-binary-xyz-123", ["whatever"]); + expect(r).toBeUndefined(); + }); + + it("returns undefined for `git rev-parse --show-toplevel` outside a checkout", () => { + // Run via subprocess with cwd=/tmp (outside any git repo) + const proc = spawnSync( + "bun", + ["-e", ` + import { tryDetect } from "${SCRIPT_PATH}"; + const r = tryDetect("git", ["rev-parse", "--show-toplevel"]); + console.log(JSON.stringify({ r })); + `], + { cwd: "/tmp", encoding: "utf-8" }, + ); + expect(proc.status).toBe(0); + const parsed = JSON.parse(proc.stdout); + // outside a git checkout, git exits non-zero → tryDetect returns undefined + expect(parsed.r).toBeUndefined(); + }); +}); + +// ───────────────────────────────────────────────────────────────────── +// Category 6: Availability-preserving install pattern +// ───────────────────────────────────────────────────────────────────── + +describe("plutilLint", () => { + it("returns without throwing for a valid plist", () => { + const valid = ` + +`; + expect(() => plutilLint(valid)).not.toThrow(); + }); + + it("exits with code 1 (via subprocess) for an invalid plist", () => { + // plutilLint calls process.exit(1) on failure; can't test in-process. + const proc = spawnSync( + "bun", + ["-e", ` + import { plutilLint } from "${SCRIPT_PATH}"; + plutilLint(""); + `], + { encoding: "utf-8" }, + ); + expect(proc.status).toBe(1); + expect(proc.stderr).toContain("plutil -lint rejected"); + }); +}); + +// Notes on availability-preserving install pattern coverage: +// +// The full main() install path reads /tools/shadow/launchd/*.plist and writes +// to ~/Library/LaunchAgents/. Exercising the read-old-into-memory → write-tmp +// → atomic-rename → side-car-backup sequence end-to-end requires either +// (a) mocking the destPath to a tmpdir, OR +// (b) running the actual install and cleaning up +// Neither is straightforward in the current main() shape (destPath is +// hard-coded inside main). The pure helpers + dry-run + plutilLint above +// cover the substrate-honest behavior of the SAFE branches; the unsafe +// branches (atomic-rename failure, backup-write failure) are not currently +// reachable without refactoring main() to accept a destPath override. Filing +// a follow-up if reviewers flag this gap.