import { test } from "node:test"; import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { LIVE_ONLY, liveArgsFor, parseArgs, toolNameFor, } from "../out/agent/cli.js"; // ── Argument parsing and command mapping (pure) ─────────────────────── test("parseArgs reads the project, snapshot and command", () => { const options = parseArgs(["--project", "D:/Mods/Alpha", "find", "AthenaCannon", "GameObject"]); assert.equal(options.projectDir, "D:/Mods/Alpha"); assert.equal(options.command, "find"); assert.deepEqual(options.args, ["AthenaCannon", "GameObject"]); }); test("parseArgs ignores unknown flags without failing the call", () => { // An agent probing with a flag we do not know yet must not crash the CLI. const withCommand = parseArgs(["--json", "status"]); assert.equal(withCommand.command, "status"); const trailing = parseArgs(["find", "X", "--future-flag"]); assert.equal(trailing.command, "find"); assert.deepEqual(trailing.args, ["X"]); // A stray positional is still treated as the command name. assert.equal(parseArgs([]).command, "status"); }); test("parseArgs reads outgoing-specific options", () => { const options = parseArgs([ "outgoing", "AthenaCannon", "GameObject", "--depth", "2", "--target-types", "WeaponTemplate, GameObject", "--max-edges", "25", "--include-unresolved", ]); assert.equal(options.command, "outgoing"); assert.equal(options.depth, 2); assert.deepEqual(options.targetTypes, ["WeaponTemplate", "GameObject"]); assert.equal(options.maxEdges, 25); assert.equal(options.includeUnresolved, true); }); test("toolNameFor maps every CLI command to a live tool", () => { assert.equal(toolNameFor("find"), "find_asset"); assert.equal(toolNameFor("refs"), "find_references"); assert.equal(toolNameFor("outgoing"), "get_asset_references"); assert.equal(toolNameFor("list"), "list_assets_by_type"); assert.equal(toolNameFor("active"), "is_file_active"); assert.equal(toolNameFor("define"), "find_define"); assert.equal(toolNameFor("resolve"), "resolve_include"); assert.equal(toolNameFor("projects"), "list_projects"); assert.equal(toolNameFor("status"), "get_status"); }); test("LIVE_ONLY covers exactly the commands needing DOM/project context", () => { assert.equal(LIVE_ONLY.has("outgoing"), true); assert.equal(LIVE_ONLY.has("projects"), true); assert.equal(LIVE_ONLY.has("find"), false); assert.equal(LIVE_ONLY.has("status"), false); }); test("liveArgsFor builds the right payload per command", () => { assert.deepEqual( liveArgsFor({ command: "find", args: ["X", "GameObject"] }), { id: "X", type: "GameObject" }, ); assert.deepEqual( liveArgsFor({ command: "active", args: ["D:/f.xml"] }), { path: "D:/f.xml" }, ); // Outgoing omits unset options so the server applies its own defaults. const outgoing = liveArgsFor({ command: "outgoing", args: ["X"] }); assert.equal(outgoing.id, "X"); assert.equal(outgoing.depth, undefined); assert.equal(outgoing.targetTypes, undefined); assert.equal(outgoing.maxEdges, undefined); // An explicit depth of 0 is falsy but must still be forwarded. const zero = liveArgsFor({ command: "outgoing", args: ["X"], depth: 0 }); assert.equal(zero.depth, 0); }); // ── Project inference from the current directory ────────────────────── function makeModProject() { const root = mkdtempSync(join(tmpdir(), "ra3-cli-proj-")); mkdirSync(join(root, "Data"), { recursive: true }); writeFileSync(join(root, "Data", "Mod.xml"), ""); return root; } test("the CLI finds the project root by walking up from the cwd", async () => { const root = makeModProject(); const nested = join(root, "Data", "Allied", "Units"); mkdirSync(nested, { recursive: true }); const previous = process.cwd(); try { process.chdir(nested); const options = parseArgs([]); const resolved = (await import("../out/agent/cli.js")).resolveProjectDir(options); assert.equal(resolved?.toLowerCase(), root.toLowerCase()); } finally { process.chdir(previous); rmSync(root, { recursive: true, force: true }); } }); test("resolveProjectDir prefers an explicit --project over the cwd", async () => { const { resolveProjectDir } = await import("../out/agent/cli.js"); const options = parseArgs(["--project", "D:/Somewhere/Else", "status"]); assert.equal(resolveProjectDir(options), join("D:\\Somewhere\\Else").replace(/\\/g, "\\")); }); // ── End-to-end execution (skipped when the sandbox forbids spawning) ── function canSpawnShell() { try { if (process.platform === "win32") { execFileSync("cmd.exe", ["/d", "/c", "exit 0"], { stdio: "ignore" }); } else { execFileSync("/bin/sh", ["-c", "exit 0"], { stdio: "ignore" }); } return true; } catch { return false; } } const spawnable = canSpawnShell(); const cliPath = join(process.cwd(), "dist", "agent", "cli.js"); test("CLI exits 3 with a clear message outside a project", { skip: !spawnable || !existsSync(cliPath) }, (t) => { const empty = mkdtempSync(join(tmpdir(), "ra3-cli-empty-")); try { let code = 0; let stderr = ""; try { execFileSync(process.execPath, [cliPath, "status"], { cwd: empty, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], }); } catch (err) { if (err?.code === "EPERM") { t.skip("sandbox forbids spawning"); return; } code = err.status; stderr = err.stderr ?? ""; } assert.equal(code, 3); assert.match(stderr, /No project found/); } finally { rmSync(empty, { recursive: true, force: true }); } }); test("CLI reports a live-only command as unavailable instead of empty", { skip: !spawnable || !existsSync(cliPath) }, (t) => { const empty = mkdtempSync(join(tmpdir(), "ra3-cli-liveonly-")); try { let stdout = ""; try { stdout = execFileSync(process.execPath, [cliPath, "--project", "D:/Mods/Nope", "outgoing", "X"], { cwd: empty, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], }); } catch (err) { if (err?.code === "EPERM") { t.skip("sandbox forbids spawning"); return; } stdout = err.stdout ?? ""; } const payload = JSON.parse(stdout); assert.equal(payload.source, "unavailable"); // Must explain why, never look like "this asset has no references". assert.match(payload.error, /requires a live index/); } finally { rmSync(empty, { recursive: true, force: true }); } });