diff --git a/packages/cli/src/commands/mail-watch.ts b/packages/cli/src/commands/mail-watch.ts index 3e10ae0..ca6e621 100644 --- a/packages/cli/src/commands/mail-watch.ts +++ b/packages/cli/src/commands/mail-watch.ts @@ -269,7 +269,11 @@ export function xmlEscape(s: string): string { .replace(/'/g, "'"); } -function buildPlist(agent: string, tpsBin: string, extraHookArgs: string[]): string { +/** + * Build the launchd plist for an agent's mail-watch daemon. + * Exported for unit testing (asserts ProcessType=Background + valid XML). + */ +export function buildPlist(agent: string, tpsBin: string, extraHookArgs: string[]): string { const label = plistLabel(agent); const stdout = join(logDir(), `mail-watch-${agent}.log`); const stderr = join(logDir(), `mail-watch-${agent}.error.log`); @@ -309,8 +313,18 @@ ${progArgs} + + ProcessType + Background + ThrottleInterval - 5 + 10 StandardOutPath ${xmlEscape(stdout)} diff --git a/packages/cli/test/mail-watch.test.ts b/packages/cli/test/mail-watch.test.ts index ffd7038..8bbdaa5 100644 --- a/packages/cli/test/mail-watch.test.ts +++ b/packages/cli/test/mail-watch.test.ts @@ -13,7 +13,9 @@ import { describe, it, expect, beforeEach, afterEach } from "bun:test"; import { mkdirSync, rmSync, writeFileSync, renameSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { validateAgentId, watchMail, xmlEscape } from "../src/commands/mail-watch.js"; +import { buildPlist, validateAgentId, watchMail, xmlEscape } from "../src/commands/mail-watch.js"; +import { execFileSync } from "node:child_process"; +import { platform } from "node:os"; // --------------------------------------------------------------------------- // Helpers @@ -288,6 +290,43 @@ describe("xmlEscape", () => { }); }); +// --------------------------------------------------------------------------- +// buildPlist — generated launchd plist shape (ops-bayh: idle-reap immunity) +// --------------------------------------------------------------------------- + +describe("buildPlist", () => { + it("sets ProcessType=Background so macOS does not idle-reap the watcher", () => { + const xml = buildPlist("test-agent", "/usr/local/bin/tps.js", []); + expect(xml).toContain("ProcessType"); + // The follows the on the next line — assert the pairing. + expect(xml).toMatch(/ProcessType<\/key>\s*Background<\/string>/); + }); + + it("sets a non-zero ThrottleInterval", () => { + const xml = buildPlist("test-agent", "/usr/local/bin/tps.js", []); + expect(xml).toMatch(/ThrottleInterval<\/key>\s*10<\/integer>/); + }); + + it("keeps KeepAlive(Crashed:true) so a real crash still restarts", () => { + const xml = buildPlist("test-agent", "/usr/local/bin/tps.js", []); + expect(xml).toMatch(/KeepAlive<\/key>\s*\s*Crashed<\/key>\s*/); + }); + + it("generates plist that passes plutil -lint (valid XML, macOS only)", () => { + if (platform() !== "darwin") return; // plutil is macOS-only + const xml = buildPlist("test-agent", "/usr/local/bin/tps.js", ["arg with spaces & "]); + const tmpFile = join(tmpdir(), `buildplist-lint-${Date.now()}.plist`); + writeFileSync(tmpFile, xml); + try { + // Throws (non-zero exit) if the plist is malformed. + const out = execFileSync("plutil", ["-lint", tmpFile], { encoding: "utf-8" }); + expect(out).toContain("OK"); + } finally { + rmSync(tmpFile, { force: true }); + } + }); +}); + // --------------------------------------------------------------------------- // Daemon install (macOS only — validates args without actually calling launchctl) // ---------------------------------------------------------------------------