Files
software-mansion__argent/packages/argent-cli/test/config-command.test.ts
filip131311 cccda92d15 feat(config): ios.additionalDeviceSets — additive array of extra CoreSimulator device sets (#542)
## 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
2026-07-31 10:27:25 +02:00

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",
});
});
});