// acceptance grid for launchd-plist.ts.
//
// Locks the plist render contract that the macOS supervisor branch in
// installService() depends on: standard render, XML escaping of `<` `&` `"`
// embedded in paths, array `programArguments`, and absence of optional fields.
// Pure logic — no I/O, no launchctl invocation. The installer wraps this with
// writeFileSync + spawnSync("launchctl", ...) calls.
//
// Compiles to dist/__tests__/launchd-plist.test.js alongside the rest of the
// package so `node --test dist/__tests__/*.test.js` picks it up after build.
import test from "node:test";
import assert from "node:assert/strict";
import { renderPlist } from "../launchd-plist.js";
// ---------------------------------------------------------------------------
// Standard render — every required field present, no escaping needed.
// ---------------------------------------------------------------------------
test("renderPlist standard render — required fields and structure", () => {
    const xml = renderPlist({
        label: "com.rubytech.maxy",
        programArguments: ["/usr/local/bin/node", "/Users/alice/maxy/server/server.js"],
        stdoutPath: "/Users/alice/.maxy/logs/server.log",
        stderrPath: "/Users/alice/.maxy/logs/server.err.log",
        keepAlive: true,
        runAtLoad: true,
        workingDirectory: "/Users/alice/maxy/server",
    });
    // XML preamble + plist DOCTYPE — launchctl rejects plists missing either.
    assert.match(xml, /^<\?xml version="1\.0" encoding="UTF-8"\?>\n/, "XML preamble required");
    assert.match(xml, /<!DOCTYPE plist PUBLIC "-\/\/Apple\/\/DTD PLIST 1\.0\/\/EN"/, "PLIST DOCTYPE required");
    assert.match(xml, /<plist version="1\.0">[\s\S]*<\/plist>\s*$/, "plist root element wraps the dict");
    // Label, paths, working directory.
    assert.match(xml, /<key>Label<\/key>\s*<string>com\.rubytech\.maxy<\/string>/);
    assert.match(xml, /<key>StandardOutPath<\/key>\s*<string>\/Users\/alice\/\.maxy\/logs\/server\.log<\/string>/);
    assert.match(xml, /<key>StandardErrorPath<\/key>\s*<string>\/Users\/alice\/\.maxy\/logs\/server\.err\.log<\/string>/);
    assert.match(xml, /<key>WorkingDirectory<\/key>\s*<string>\/Users\/alice\/maxy\/server<\/string>/);
    // Booleans render as <true/> / <false/> (launchd's documented form).
    assert.match(xml, /<key>KeepAlive<\/key>\s*<true\/>/);
    assert.match(xml, /<key>RunAtLoad<\/key>\s*<true\/>/);
});
test("renderPlist programArguments renders as <array> of <string> in order", () => {
    // Order matters: launchd execs argv[0] with argv[1..] as positional args.
    // The renderer must preserve input order verbatim.
    const xml = renderPlist({
        label: "com.rubytech.maxy",
        programArguments: ["/usr/local/bin/node", "--enable-source-maps", "/Users/alice/maxy/server/server.js", "--port=19200"],
        stdoutPath: "/dev/null",
        stderrPath: "/dev/null",
        keepAlive: true,
        runAtLoad: true,
    });
    // Match the full ProgramArguments block — order must be exact.
    const block = xml.match(/<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/);
    assert.ok(block, "ProgramArguments array must be present");
    const inner = block[1];
    // Each <string> in the order supplied.
    const strings = [...inner.matchAll(/<string>(.*?)<\/string>/g)].map((m) => m[1]);
    assert.deepEqual(strings, [
        "/usr/local/bin/node",
        "--enable-source-maps",
        "/Users/alice/maxy/server/server.js",
        "--port=19200",
    ]);
});
// ---------------------------------------------------------------------------
// KeepAlive + RunAtLoad falsy paths — render as <false/>.
// ---------------------------------------------------------------------------
test("renderPlist keepAlive=false / runAtLoad=false render <false/>", () => {
    const xml = renderPlist({
        label: "com.rubytech.maxy",
        programArguments: ["/usr/local/bin/node", "/srv/server.js"],
        stdoutPath: "/dev/null",
        stderrPath: "/dev/null",
        keepAlive: false,
        runAtLoad: false,
    });
    assert.match(xml, /<key>KeepAlive<\/key>\s*<false\/>/);
    assert.match(xml, /<key>RunAtLoad<\/key>\s*<false\/>/);
});
// ---------------------------------------------------------------------------
// XML escaping — `<`, `&`, `>`, `"`, `'` in any string field must be escaped
// to the five XML predefined entities. Without this, a path with `&` (rare
// but legal on macOS) produces a parse error: `launchctl bootstrap` exits
// with "Bootstrap failed: 5: Input/output error" and the install completes
// in a non-functional state.
// ---------------------------------------------------------------------------
test("renderPlist escapes <, &, >, \", ' in string fields", () => {
    const xml = renderPlist({
        label: 'com.rubytech.maxy & "test" <bracket>',
        programArguments: ["/usr/local/bin/node", "/path/with & ampersand/server.js"],
        stdoutPath: "/path/with <angle>/log.out",
        stderrPath: "/path/with 'apostrophe'/log.err",
        keepAlive: true,
        runAtLoad: true,
        workingDirectory: '/path/with "quote"/cwd',
    });
    // Label: every metacharacter escaped, none of the raw forms leak through.
    assert.match(xml, /<key>Label<\/key>\s*<string>com\.rubytech\.maxy &amp; &quot;test&quot; &lt;bracket&gt;<\/string>/);
    // None of the literal metacharacters survive inside the Label string slot.
    assert.ok(!/<string>com\.rubytech\.maxy &(?!(amp|quot|lt|gt|apos);)/.test(xml), "raw & must not appear inside Label");
    // ProgramArguments entry with `&`.
    assert.match(xml, /<string>\/path\/with &amp; ampersand\/server\.js<\/string>/);
    // StandardOutPath with `<` `>`.
    assert.match(xml, /<key>StandardOutPath<\/key>\s*<string>\/path\/with &lt;angle&gt;\/log\.out<\/string>/);
    // StandardErrorPath with `'` — escaped to &apos;.
    assert.match(xml, /<key>StandardErrorPath<\/key>\s*<string>\/path\/with &apos;apostrophe&apos;\/log\.err<\/string>/);
    // WorkingDirectory with `"` — escaped to &quot;.
    assert.match(xml, /<key>WorkingDirectory<\/key>\s*<string>\/path\/with &quot;quote&quot;\/cwd<\/string>/);
});
test("renderPlist escapes `&` once, never double-escapes", () => {
    // Regression guard: a naive escape that runs the replacements in the wrong
    // order produces &amp;lt; for an input of `<`. Keep the order &-first so a
    // raw `&` becomes `&amp;` and a raw `<` stays a single `&lt;`.
    const xml = renderPlist({
        label: "a&b<c",
        programArguments: ["/x"],
        stdoutPath: "/dev/null",
        stderrPath: "/dev/null",
        keepAlive: true,
        runAtLoad: true,
    });
    assert.match(xml, /<string>a&amp;b&lt;c<\/string>/);
    assert.ok(!/&amp;amp;|&amp;lt;/.test(xml), "no double-escaping");
});
// ---------------------------------------------------------------------------
// Optional-field absence — workingDirectory is the only optional field. When
// omitted, the corresponding <key>/<value> pair must not appear in the output.
// Emitting `<key>WorkingDirectory</key><string></string>` would chdir launchd's
// child to "" (current dir = launchd's cwd, /), which silently changes
// behaviour from "stay where invoked" to "/" — a different bug than absence.
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Brand-stamped labels — both shipping brands must render distinct labels so a
// co-resident install does not collide on a single LaunchAgent file.
// `launchdLabel()` in index.ts returns `com.rubytech.${BRAND.hostname}`; this
// test pins the brand-input → label contract end-to-end against the renderer.
// ---------------------------------------------------------------------------
test("renderPlist maxy-code brand fields land in the plist verbatim", () => {
    // BRAND.hostname=maxy-code, configDir=.maxy-code per brands/maxy-code/brand.json.
    // installServiceDarwin builds ProgramArguments=["/bin/bash", wrapperPath],
    // WorkingDirectory=`${INSTALL_DIR}/server`, std{out,err}Path=`<logsDir>/server.log`.
    const xml = renderPlist({
        label: "com.rubytech.maxy-code",
        programArguments: ["/bin/bash", "/Users/alice/.maxy-code/launchd-wrapper.sh"],
        stdoutPath: "/Users/alice/.maxy-code/logs/server.log",
        stderrPath: "/Users/alice/.maxy-code/logs/server.log",
        keepAlive: true,
        runAtLoad: true,
        workingDirectory: "/Users/alice/maxy-code/server",
    });
    assert.match(xml, /<key>Label<\/key>\s*<string>com\.rubytech\.maxy-code<\/string>/);
    assert.match(xml, /<string>\/bin\/bash<\/string>/);
    assert.match(xml, /<string>\/Users\/alice\/\.maxy-code\/launchd-wrapper\.sh<\/string>/);
    assert.match(xml, /<key>WorkingDirectory<\/key>\s*<string>\/Users\/alice\/maxy-code\/server<\/string>/);
    assert.match(xml, /<key>StandardOutPath<\/key>\s*<string>\/Users\/alice\/\.maxy-code\/logs\/server\.log<\/string>/);
    assert.match(xml, /<key>KeepAlive<\/key>\s*<true\/>/);
    assert.match(xml, /<key>RunAtLoad<\/key>\s*<true\/>/);
});
test("renderPlist realagent-code brand fields land in the plist verbatim", () => {
    // BRAND.hostname=realagent-code, configDir=.realagent-code per brands/realagent-code/brand.json.
    // The label must differ from the maxy-code label so a co-resident install
    // produces two distinct ~/Library/LaunchAgents/<label>.plist files.
    const xml = renderPlist({
        label: "com.rubytech.realagent-code",
        programArguments: ["/bin/bash", "/Users/alice/.realagent-code/launchd-wrapper.sh"],
        stdoutPath: "/Users/alice/.realagent-code/logs/server.log",
        stderrPath: "/Users/alice/.realagent-code/logs/server.log",
        keepAlive: true,
        runAtLoad: true,
        workingDirectory: "/Users/alice/realagent-code/server",
    });
    assert.match(xml, /<key>Label<\/key>\s*<string>com\.rubytech\.realagent-code<\/string>/);
    assert.match(xml, /<string>\/Users\/alice\/\.realagent-code\/launchd-wrapper\.sh<\/string>/);
    assert.match(xml, /<key>WorkingDirectory<\/key>\s*<string>\/Users\/alice\/realagent-code\/server<\/string>/);
    // Label collision guard — the maxy-code label must not appear in a realagent plist.
    assert.ok(!/com\.rubytech\.maxy-code/.test(xml), "realagent plist must not carry maxy-code label");
});
test("renderPlist omits WorkingDirectory key when workingDirectory is absent", () => {
    const xml = renderPlist({
        label: "com.rubytech.maxy",
        programArguments: ["/usr/local/bin/node", "/srv/server.js"],
        stdoutPath: "/dev/null",
        stderrPath: "/dev/null",
        keepAlive: true,
        runAtLoad: true,
        // workingDirectory omitted on purpose
    });
    assert.ok(!/WorkingDirectory/.test(xml), "WorkingDirectory key must not appear when omitted");
    // Required fields still present.
    assert.match(xml, /<key>Label<\/key>/);
    assert.match(xml, /<key>ProgramArguments<\/key>/);
    assert.match(xml, /<key>StandardOutPath<\/key>/);
    assert.match(xml, /<key>StandardErrorPath<\/key>/);
    assert.match(xml, /<key>KeepAlive<\/key>/);
    assert.match(xml, /<key>RunAtLoad<\/key>/);
});
