188 lines
6.7 KiB
JavaScript
188 lines
6.7 KiB
JavaScript
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"), "<AssetDeclaration/>");
|
|
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 });
|
|
}
|
|
});
|