Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions commands/__tests__/code-prefs.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "fs";
import { tmpdir } from "os";
import { dirname, join } from "path";
import { machineSettingsPath } from "../../lib/rt-paths.ts";
import { getSetting } from "../../lib/settings/resolve.ts";
import { setSetting } from "../../lib/settings/write.ts";
import { __test__ } from "../code.ts";

describe("workspace prefs through the settings resolver", () => {
const origHome = process.env.HOME;
let home: string;

beforeEach(() => {
home = realpathSync(mkdtempSync(join(tmpdir(), "rt-code-prefs-")));
process.env.HOME = home;
});

afterEach(() => {
process.env.HOME = origHome;
rmSync(home, { recursive: true, force: true });
});

test("empty store: defaults to empty editors/workspaces", () => {
expect(__test__.loadPrefs()).toEqual({ editors: {}, workspaces: {} });
});

test("a store-seeded value resolves through the loader", () => {
setSetting("rt.workspacePrefs", { editors: { myrepo: "code" } }, "machine");

expect(__test__.loadPrefs().editors.myrepo).toBe("code");
});

test("legacy 'entries' alias still resolves as workspaces", () => {
setSetting("rt.workspacePrefs", { entries: { "/some/dir": "x.code-workspace" } }, "machine");

expect(__test__.loadPrefs().workspaces["/some/dir"]).toBe("x.code-workspace");
});

test("savePrefs lands in the machine store", () => {
__test__.savePrefs({ editors: { myrepo: "cursor" }, workspaces: {} });

const stored = getSetting<{ editors: Record<string, string> }>("rt.workspacePrefs").value;
expect(stored.editors.myrepo).toBe("cursor");
const raw = JSON.parse(readFileSync(machineSettingsPath(), "utf8").replace(/^\/\/.*\n/, ""));
expect(raw["rt.workspacePrefs"].editors.myrepo).toBe("cursor");
});

test("an unexpandable ${repoRoot} in a stored value degrades to empty prefs instead of throwing", () => {
setSetting("rt.workspacePrefs", { editors: { myrepo: "${repoRoot}" } }, "machine");

expect(() => __test__.loadPrefs()).not.toThrow();
expect(__test__.loadPrefs()).toEqual({ editors: {}, workspaces: {} });
});

test("savePrefs warns and does not throw when the machine store is malformed (duplicate key anywhere in the document)", () => {
const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
const path = machineSettingsPath();
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, `{\n "rt.other": { "x": 1 },\n "rt.other": { "x": 2 }\n}\n`);

expect(() => __test__.savePrefs({ editors: { myrepo: "cursor" }, workspaces: {} })).not.toThrow();
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(warnSpy.mock.calls[0]?.[0]).toContain("rt: could not save workspace prefs");

warnSpy.mockRestore();
});
});
116 changes: 116 additions & 0 deletions commands/__tests__/daemon-tracking.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
/**
* manageTracking's off-branch — CLI wiring (the rider, RT-50).
*
* `lib/daemon-config.ts`'s RT_DIR is a MODULE-LOAD-TIME constant (frozen to
* whatever HOME was active the first time that module was imported in this
* process), so `readRepoIndex()` in commands/daemon.ts does NOT follow a
* per-test HOME repoint the way the settings stores do. Rather than fight
* that, this test drives manageTracking through its real seams as they
* actually exist: repos.json under the (ambient, process-wide) RT_DIR, and
* the settings stores under the (same, dynamically-resolved) HOME. Nothing
* is mocked — console.log is captured only to keep the run quiet. Every
* fixture is written with a name unique to this file and precisely restored
* in afterEach, since the ambient HOME is shared with every other test file
* in this process that doesn't repoint it.
*/
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { execSync } from "node:child_process";
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { RT_DIR } from "../../lib/daemon-config.ts";
import { machineSettingsPath, teamSettingsPath } from "../../lib/rt-paths.ts";
import { getSetting } from "../../lib/settings/resolve.ts";
import { manageTracking } from "../daemon.ts";

const REPO_NAME = "rt-rider-cli-wiring-repo";
const TEAM_NAME = "rt-rider-cli-wiring-team";
const IDENTITY = `rttest/${REPO_NAME}`;
const REPOS_JSON_PATH = join(RT_DIR, "repos.json");

function readOrNull(path: string): string | null {
try {
return readFileSync(path, "utf8");
} catch {
return null;
}
}

/** Restores `path` to its prior content, or removes it if it didn't exist before. */
function restore(path: string, prior: string | null): void {
if (prior === null) {
try { rmSync(path, { force: true }); } catch { /* already gone */ }
} else {
writeFileSync(path, prior);
}
}

describe("manageTracking off-branch (CLI wiring)", () => {
const origLog = console.log;
let priorReposJson: string | null;
let priorTeamStore: string | null;
let priorMachineStore: string | null;
let repoPath: string;

beforeEach(() => {
console.log = () => {};

priorReposJson = readOrNull(REPOS_JSON_PATH);
priorTeamStore = readOrNull(teamSettingsPath(TEAM_NAME));
priorMachineStore = readOrNull(machineSettingsPath());

// A real git repo with a fake-but-normalizable remote — identity derives
// directly (`rttest/${REPO_NAME}`), no override plumbing needed.
repoPath = realpathSync(mkdtempSync(join(tmpdir(), "rt-rider-cli-repo-")));
execSync("git init -q", { cwd: repoPath });
execSync(`git remote add origin git@rttest:${REPO_NAME}.git`, { cwd: repoPath });

mkdirSync(RT_DIR, { recursive: true });
const repos = priorReposJson ? JSON.parse(priorReposJson) : {};
repos[REPO_NAME] = repoPath;
writeFileSync(REPOS_JSON_PATH, JSON.stringify(repos));

// Team intent still declares this repo — mattstack.tracking's VALUE has
// its own "repos" field (identity → intent); it is not the store file's
// top-level repo-section sharding (that's for repo-scoped setting keys).
const teamStore = teamSettingsPath(TEAM_NAME);
mkdirSync(dirname(teamStore), { recursive: true });
writeFileSync(teamStore, JSON.stringify({
"mattstack.tracking": { repos: { [IDENTITY]: { caches: ["branches"] } } },
}));

// An existing machine grant for it, as if it had been tracked already.
const machineStore = machineSettingsPath();
mkdirSync(dirname(machineStore), { recursive: true });
const machine = priorMachineStore ? JSON.parse(priorMachineStore) : {};
machine["rt.repoTracking"] = {
...(machine["rt.repoTracking"] ?? {}),
[REPO_NAME]: { mode: "live", caches: ["branches"] },
};
writeFileSync(machineStore, JSON.stringify(machine));
});

afterEach(() => {
console.log = origLog;
rmSync(repoPath, { recursive: true, force: true });
restore(REPOS_JSON_PATH, priorReposJson);
restore(teamSettingsPath(TEAM_NAME), priorTeamStore);
restore(machineSettingsPath(), priorMachineStore);
});

test("off on a team-tracked repo plants an explicit {mode:\"off\"} marker, not a delete", async () => {
await manageTracking([REPO_NAME, "off"]);

const saved = getSetting<Record<string, unknown>>("rt.repoTracking").value;
expect(saved[REPO_NAME]).toEqual({ mode: "off" });
});

test("off on a repo the team no longer names deletes outright", async () => {
writeFileSync(teamSettingsPath(TEAM_NAME), JSON.stringify({ "mattstack.tracking": { repos: {} } }));

await manageTracking([REPO_NAME, "off"]);

const saved = getSetting<Record<string, unknown>>("rt.repoTracking").value;
expect(saved[REPO_NAME]).toBeUndefined();
});
});
64 changes: 64 additions & 0 deletions commands/__tests__/run-report-save.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
/**
* `reportSave` (commands/run.ts) — the only place that tells the user
* whether a preset/variation save actually landed. `savePreset`/
* `saveVariation` no-op or refuse rather than throw (best-effort I/O), so a
* formatting/branch bug here is the one way a failed save could still print
* a checkmark.
*/

import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
import { __test__ } from "../run.ts";

const { reportSave } = __test__;

/** Strips ANSI so assertions read as plain text. */
// eslint-disable-next-line no-control-regex
const plain = (s: string) => s.replace(/\[[0-9;]*m/g, "");

describe("reportSave", () => {
let writes: string[];
let spy: ReturnType<typeof spyOn>;

beforeEach(() => {
writes = [];
spy = spyOn(process.stderr, "write").mockImplementation((chunk: unknown) => {
writes.push(typeof chunk === "string" ? chunk : String(chunk));
return true;
});
});

afterEach(() => {
spy.mockRestore();
});

test("ok:true prints a checkmark with the name, never a failure line", () => {
reportSave("preset", "backend-lite", { ok: true }, "acme");

const out = plain(writes.join(""));
expect(out).toContain("✓");
expect(out).toContain("backend-lite");
expect(out).not.toContain("not saved");
});

test("no-identity failure names the repo and the override hint", () => {
reportSave("preset", "backend-lite", { ok: false, reason: "no-identity" }, "acme");

const out = plain(writes.join(""));
expect(out).toContain("not saved");
expect(out).toContain("no repo identity for acme");
expect(out).toContain("pin one with");
expect(out).toContain("rt settings set rt.repoIdentityOverrides");
expect(out).not.toContain("✓");
});

test("write-failed surfaces the refusal's own message verbatim", () => {
const message =
"rt: no local team store found — clone a team under ~/.mattstack/teams/<name> or pass opts.team";
reportSave("variation", "debug", { ok: false, reason: "write-failed", message }, "acme");

const out = plain(writes.join(""));
expect(out).toContain("not saved");
expect(out).toContain(message);
expect(out).not.toContain("✓");
});
});
4 changes: 2 additions & 2 deletions commands/__tests__/settings-keys-render.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -29,8 +29,8 @@ describe("renderListRow", () => {
});

test("a registered migrated:false key still carries its legacy note", () => {
// rt.llm is a real wave-1 registered migrated:false key.
const out = plain(renderListRow(row({ key: "rt.llm", migrated: false })));
// rt.hooks is the deliberately-deferred registered migrated:false key.
const out = plain(renderListRow(row({ key: "rt.hooks", migrated: false })));

expect(out).toMatch(/legacy|not writable/);
expect(out).not.toContain("unregistered");
Expand Down
50 changes: 50 additions & 0 deletions commands/__tests__/settings-runaway.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdtempSync, realpathSync, rmSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
import { getSetting } from "../../lib/settings/resolve.ts";
import { setSetting } from "../../lib/settings/write.ts";
import { configureRunaway } from "../settings.ts";

describe("configureRunaway through the settings resolver", () => {
const origHome = process.env.HOME;
let home: string;
let logs: string[];
const origLog = console.log;

beforeEach(() => {
home = realpathSync(mkdtempSync(join(tmpdir(), "rt-runaway-cmd-")));
process.env.HOME = home;
logs = [];
console.log = (...args: unknown[]) => { logs.push(args.map(String).join(" ")); };
});

afterEach(() => {
process.env.HOME = origHome;
console.log = origLog;
rmSync(home, { recursive: true, force: true });
});

test("empty store: the summary view prints today's defaults", async () => {
await configureRunaway([]);
expect(logs.some((l) => l.includes("80%"))).toBe(true);
});

test("setting a field lands in the machine store", async () => {
await configureRunaway(["cpu-threshold", "90"]);

const stored = getSetting<Record<string, number>>("rt.runaway").value;
expect(stored.cpuThreshold).toBe(90);
expect(logs.some((l) => l.includes("restart daemon"))).toBe(true);
});

test("an existing stored value is preserved across an unrelated field write", async () => {
setSetting("rt.runaway", { cpuThreshold: 70 }, "machine");

await configureRunaway(["sustain-min", "10"]);

const stored = getSetting<Record<string, number>>("rt.runaway").value;
expect(stored.cpuThreshold).toBe(70);
expect(stored.sustainMs).toBe(600_000);
});
});
32 changes: 19 additions & 13 deletions commands/code.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,47 +4,53 @@
* Editor-preference and launch machinery shared with `rt nav`.
*
* Tracks a per-repo editor choice and per-directory workspace-file choice
* (~/.mattstack/rt/workspace-prefs.json), detects installed editors, and
* (rt.workspacePrefs, machine-scoped), detects installed editors, and
* launches one via its CLI command (code, cursor, zed, etc.), falling back
* to the app bundle when the CLI shim is missing or broken.
*/

import { execSync } from "child_process";
import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync } from "fs";
import { existsSync, readdirSync } from "fs";
import { join } from "path";
import { homedir } from "os";
import { rtDir } from "../lib/rt-paths.ts";
import { getSetting } from "../lib/settings/resolve.ts";
import { setSetting } from "../lib/settings/write.ts";
import { dim, green, red, reset } from "../lib/tui.ts";

// ─── Preference storage (~/.mattstack/rt/workspace-prefs.json) ─────────────────────────

const PREFS_PATH = join(rtDir(), "workspace-prefs.json");
// ─── Preference storage (rt.workspacePrefs, machine-scoped) ────────────────

interface Prefs {
editors: Record<string, string>;
workspaces: Record<string, string>;
}

/** A resolver throw (unexpandable ${...} variable) degrades to the same
empty prefs a missing/corrupt file gave today. */
function loadPrefs(): Prefs {
try {
const raw = JSON.parse(readFileSync(PREFS_PATH, "utf8"));
const raw = getSetting<Record<string, unknown> | undefined>("rt.workspacePrefs").value;
return {
editors: raw.editors || {},
workspaces: raw.workspaces || raw.entries || {},
editors: (raw?.editors as Record<string, string>) || {},
workspaces: (raw?.workspaces as Record<string, string>) || (raw?.entries as Record<string, string>) || {},
};
} catch {
return { editors: {}, workspaces: {} };
}
}

/** A store typo (e.g. a duplicate key anywhere in the machine document) must
never brick editor launch — degrade to a warning, same as loadPrefs. */
function savePrefs(prefs: Prefs): void {
try {
const dir = rtDir();
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
writeFileSync(PREFS_PATH, JSON.stringify(prefs, null, 2));
} catch { /* best-effort */ }
setSetting("rt.workspacePrefs", prefs, "machine");
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.warn("rt: could not save workspace prefs — " + message);
}
}

export const __test__ = { loadPrefs, savePrefs };

// ─── Editor detection ────────────────────────────────────────────────────────

interface EditorOption {
Expand Down
Loading
Loading