mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
cccda92d15
## Summary **Stacked on #534.** Adds the first *additive* configuration entry on the new schema — `ios.additionalDeviceSets`, a list of extra CoreSimulator device-set directories — delivering the config side of the #401 discussion on the #534 machinery. ### The schema entry One registry entry in `config-schema.ts`: - key `ios.additionalDeviceSets`, scopes `project` + `global`, `parse: asStringArray`, **`merge: "union"`** Unlike `lens.agent` (project *shadows* global), this is additive: the effective value is the **union of both scopes** — the global baseline first, the project's extras appended, deduplicated. A repo can commit the device sets its tooling needs to `.argent/config.json` and they *extend* (never replace) the user's global list. `argent config list/get/set/unset` handle the array key with zero command changes — arrays already round-trip through the JSON value coercion. ### The runtime reader `getAdditionalIosDeviceSets(options): string[]` in `config-access.ts` — the surface iOS tooling should call. It reads each scope separately so paths normalize against the base the author meant: - `~` / `~/…` expands to the home directory (honoring the sandbox `homeDir` override) - relative **project** entries resolve against the **project root**, relative **global** entries against **home** - output order matches the `union` preset (global first), with **post-normalization** duplicates dropped — `~/DeviceSets/ci` in global and its absolute spelling in the project file collapse to one entry - empty array when unset Support refactor: `resolveHomeDir()` extracted in `paths.ts` (the HOME/USERPROFILE + override logic previously inlined in `argentHomeDir`/`configDir`). ### Out of scope (follow-up) Actually pointing simctl at the extra sets. There is no central simctl wrapper today (~25 call sites build `xcrun simctl …` argv inline, and `ax-prefs.ts` hardcodes the default set's `CoreSimulator/Devices` path), so consuming this reader in `list-devices`/boot/etc. is its own change — the runtime-read-off-disk design here means that consumer needs no CLI/env threading, matching how #534's description framed the split. ## Testing - `configuration-core`: 5 new tests (82 total, green) — additive union across scopes + per-scope file isolation, non-array rejection + entry normalization, reader `~`/relative resolution per scope, post-normalization dedup, empty-when-unset - `argent-cli`: new real-filesystem CLI round-trip (274 total, green) — `config set` an array at each scope, effective `get` shows the union while `get --scope` shows only that scope's entries - `tsc` build + `typecheck:tests` + eslint clean on touched packages
243 lines
9.3 KiB
TypeScript
243 lines
9.3 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
import * as fs from "node:fs";
|
|
import * as path from "node:path";
|
|
import * as os from "node:os";
|
|
import { config } from "../src/config.js";
|
|
|
|
// Real-filesystem integration test: drives the actual `config()` entry point
|
|
// against a sandboxed global home (HOME) and project cwd (a tmp dir with a
|
|
// `.git` marker so the project root resolves there). No fs mocks — this
|
|
// exercises the command → configuration-core → disk path end to end.
|
|
|
|
let homeDir: string;
|
|
let projectDir: string;
|
|
let originalHome: string | undefined;
|
|
let originalUserProfile: string | undefined;
|
|
let originalCwd: string;
|
|
let logSpy: ReturnType<typeof vi.spyOn>;
|
|
let errSpy: ReturnType<typeof vi.spyOn>;
|
|
|
|
class ExitError extends Error {
|
|
constructor(public code: number) {
|
|
super(`process.exit(${code})`);
|
|
}
|
|
}
|
|
|
|
beforeEach(() => {
|
|
homeDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "argent-cli-home-")));
|
|
projectDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "argent-cli-proj-")));
|
|
fs.mkdirSync(path.join(projectDir, ".git"), { recursive: true });
|
|
originalHome = process.env.HOME;
|
|
originalUserProfile = process.env.USERPROFILE;
|
|
originalCwd = process.cwd();
|
|
process.env.HOME = homeDir;
|
|
process.env.USERPROFILE = homeDir;
|
|
process.chdir(projectDir);
|
|
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
|
|
throw new ExitError(code ?? 0);
|
|
}) as never);
|
|
});
|
|
|
|
afterEach(() => {
|
|
process.chdir(originalCwd);
|
|
if (originalHome === undefined) delete process.env.HOME;
|
|
else process.env.HOME = originalHome;
|
|
if (originalUserProfile === undefined) delete process.env.USERPROFILE;
|
|
else process.env.USERPROFILE = originalUserProfile;
|
|
vi.restoreAllMocks();
|
|
fs.rmSync(homeDir, { recursive: true, force: true });
|
|
fs.rmSync(projectDir, { recursive: true, force: true });
|
|
});
|
|
|
|
function output(): string {
|
|
return logSpy.mock.calls.map((c: unknown[]) => c.join(" ")).join("\n");
|
|
}
|
|
function errors(): string {
|
|
return errSpy.mock.calls.map((c: unknown[]) => c.join(" ")).join("\n");
|
|
}
|
|
|
|
describe("argent config — set/get across scopes", () => {
|
|
it("sets global and reads it back as the effective value", () => {
|
|
config(["set", "lens.agent", "claude"]);
|
|
logSpy.mockClear();
|
|
config(["get", "lens.agent"]);
|
|
expect(output()).toBe("claude");
|
|
// Landed in the global config file.
|
|
const globalCfg = JSON.parse(
|
|
fs.readFileSync(path.join(homeDir, ".argent", "config.json"), "utf8")
|
|
);
|
|
expect(globalCfg).toEqual({ lens: { agent: "claude" } });
|
|
});
|
|
|
|
it("project scope overrides global under prioritize-local", () => {
|
|
config(["set", "lens.agent", "claude", "--scope", "global"]);
|
|
config(["set", "lens.agent", "codex", "--scope", "project"]);
|
|
logSpy.mockClear();
|
|
config(["get", "lens.agent"]);
|
|
expect(output()).toBe("codex");
|
|
// The project file lives under the project root, not HOME.
|
|
const projCfg = JSON.parse(
|
|
fs.readFileSync(path.join(projectDir, ".argent", "config.json"), "utf8")
|
|
);
|
|
expect(projCfg).toEqual({ lens: { agent: "codex" } });
|
|
});
|
|
|
|
it("an additive array key (ios.additionalDeviceSets) unions the scopes instead of shadowing", () => {
|
|
config([
|
|
"set",
|
|
"ios.additionalDeviceSets",
|
|
'["/tmp/sets/a","/tmp/sets/b"]',
|
|
"--scope",
|
|
"global",
|
|
]);
|
|
config([
|
|
"set",
|
|
"ios.additionalDeviceSets",
|
|
'["/tmp/sets/b","/tmp/sets/c"]',
|
|
"--scope",
|
|
"project",
|
|
]);
|
|
logSpy.mockClear();
|
|
// Effective value = global baseline + project extras, deduplicated.
|
|
config(["get", "ios.additionalDeviceSets"]);
|
|
expect(output()).toBe('["/tmp/sets/a","/tmp/sets/b","/tmp/sets/c"]');
|
|
logSpy.mockClear();
|
|
// Each scope still stores (and reports) only its own entries.
|
|
config(["get", "ios.additionalDeviceSets", "--scope", "project"]);
|
|
expect(output()).toBe('["/tmp/sets/b","/tmp/sets/c"]');
|
|
});
|
|
|
|
it("get --scope shows only that scope's stored value", () => {
|
|
config(["set", "lens.agent", "claude", "--scope", "global"]);
|
|
config(["set", "lens.agent", "codex", "--scope", "project"]);
|
|
logSpy.mockClear();
|
|
config(["get", "lens.agent", "--scope", "global"]);
|
|
expect(output()).toBe("claude");
|
|
logSpy.mockClear();
|
|
config(["get", "lens.agent", "--scope", "project"]);
|
|
expect(output()).toBe("codex");
|
|
});
|
|
|
|
it("get prints (unset) for an absent value", () => {
|
|
config(["get", "lens.agent"]);
|
|
expect(output()).toBe("(unset)");
|
|
});
|
|
|
|
it("unset removes the value at a scope", () => {
|
|
config(["set", "lens.agent", "claude"]);
|
|
config(["unset", "lens.agent"]);
|
|
logSpy.mockClear();
|
|
config(["get", "lens.agent"]);
|
|
expect(output()).toBe("(unset)");
|
|
});
|
|
|
|
it("set reports the normalized value that was stored", () => {
|
|
config(["set", "lens.agent", " codex "]);
|
|
// The message echoes the trimmed value that actually landed on disk, not the
|
|
// raw CLI input. (Assert on the un-colorized value segment — the key is
|
|
// wrapped in bold ANSI codes when color is enabled, e.g. in CI.)
|
|
expect(output()).toContain("= codex (global).");
|
|
expect(output()).not.toContain(" codex ");
|
|
logSpy.mockClear();
|
|
config(["get", "lens.agent"]);
|
|
expect(output()).toBe("codex");
|
|
});
|
|
|
|
it("a no-op unset does not create the project config file", () => {
|
|
const projectCfg = path.join(projectDir, ".argent", "config.json");
|
|
config(["unset", "lens.agent", "--scope", "project"]);
|
|
expect(output()).toContain("was not set at project scope");
|
|
expect(fs.existsSync(projectCfg)).toBe(false);
|
|
});
|
|
|
|
it("a project-scope write echoes the resolved project root", () => {
|
|
config(["set", "lens.agent", "codex", "--scope", "project"]);
|
|
// (Assert on the un-colorized segment after the bold key.)
|
|
expect(output()).toContain(`(project: ${projectDir}).`);
|
|
// A real project (a `.git` marker at projectDir) is not degenerate — no warning.
|
|
expect(errors()).toBe("");
|
|
logSpy.mockClear();
|
|
config(["unset", "lens.agent", "--scope", "project"]);
|
|
expect(output()).toContain(`(project: ${projectDir}).`);
|
|
});
|
|
|
|
it("warns when project scope resolves to the home directory", () => {
|
|
// Materialize ~/.argent via a global write, then run from a non-project dir
|
|
// under HOME: the marker walk stops at ~/.argent, so "project" IS global.
|
|
config(["set", "lens.agent", "claude"]);
|
|
const nested = path.join(homeDir, "not-a-project");
|
|
fs.mkdirSync(nested);
|
|
process.chdir(nested);
|
|
logSpy.mockClear();
|
|
config(["set", "lens.agent", "codex", "--scope", "project"]);
|
|
expect(errors()).toContain("resolved to the home directory");
|
|
expect(output()).toContain(`(project: ${homeDir}).`);
|
|
// The write landed in the global config file.
|
|
const globalCfg = JSON.parse(
|
|
fs.readFileSync(path.join(homeDir, ".argent", "config.json"), "utf8")
|
|
);
|
|
expect(globalCfg).toEqual({ lens: { agent: "codex" } });
|
|
});
|
|
});
|
|
|
|
describe("argent config — validation & errors", () => {
|
|
it("rejects an unknown key with exit 2 and a hint", () => {
|
|
expect(() => config(["set", "does.not.exist", "1"])).toThrow(ExitError);
|
|
expect(errors()).toMatch(/Unknown configuration key/);
|
|
expect(errors()).toMatch(/argent config list/);
|
|
});
|
|
|
|
it("refuses to set a telemetry key, pointing at the dedicated command", () => {
|
|
expect(() => config(["set", "telemetry.enabled", "false"])).toThrow(ExitError);
|
|
expect(errors()).toMatch(/argent telemetry/);
|
|
});
|
|
|
|
it("rejects an invalid --scope", () => {
|
|
expect(() => config(["get", "lens.agent", "--scope", "bogus"])).toThrow(ExitError);
|
|
expect(errors()).toMatch(/--scope must be/);
|
|
});
|
|
|
|
it("rejects a value that fails the schema validator", () => {
|
|
// lens.agent must be a non-blank string; a JSON number is invalid.
|
|
expect(() => config(["set", "lens.agent", "42"])).toThrow(ExitError);
|
|
expect(errors()).toMatch(/Invalid value/);
|
|
});
|
|
});
|
|
|
|
describe("argent config — list & json", () => {
|
|
it("list --json reports every schema entry with per-scope values", () => {
|
|
config(["set", "lens.agent", "codex", "--scope", "project"]);
|
|
logSpy.mockClear();
|
|
config(["list", "--json"]);
|
|
const parsed = JSON.parse(output());
|
|
const lens = parsed.config.find((e: { key: string }) => e.key === "lens.agent");
|
|
expect(lens.project).toBe("codex");
|
|
expect(lens.effective).toBe("codex");
|
|
const telemetry = parsed.config.find((e: { key: string }) => e.key === "telemetry.enabled");
|
|
expect(telemetry.manageCommand).toBe("argent telemetry");
|
|
});
|
|
|
|
it("telemetry.enabled reads as true (opt-out default) on a fresh install", () => {
|
|
config(["get", "telemetry.enabled"]);
|
|
expect(output()).toBe("true");
|
|
logSpy.mockClear();
|
|
config(["list", "--json"]);
|
|
const parsed = JSON.parse(output());
|
|
const telemetry = parsed.config.find((e: { key: string }) => e.key === "telemetry.enabled");
|
|
expect(telemetry.effective).toBe(true);
|
|
});
|
|
|
|
it("get --json emits a structured record", () => {
|
|
config(["set", "lens.agent", "claude"]);
|
|
logSpy.mockClear();
|
|
config(["get", "lens.agent", "--json"]);
|
|
expect(JSON.parse(output())).toEqual({
|
|
key: "lens.agent",
|
|
scope: "effective",
|
|
value: "claude",
|
|
});
|
|
});
|
|
});
|