/** * @file Tests the data-directory resolution in claude-home.js, and the one-time * migration that carries an existing `~/.claude/agent-dashboard` (the name the * product used before the AgentHub rebrand) over to `~/.claude/agenthub`. * * The stakes are why this is tested at all: that directory holds the live * SQLite database. Resolving to the new path when the data is still under the * old one would not lose the data, but it WOULD silently start an empty * dashboard, which looks exactly like data loss to whoever opens it. * * The split matters as much as the migration. `getDataDir()` is a getter that * gets asked for the path by tests, doctors and `--help`; if it also moved * directories, every one of those would relocate a multi-gigabyte database as a * side effect. So it is asserted pure here, and the move lives in its own * function that only server startup calls. * @author Nguyễn Ngọc Trí Vĩ */ const os = require("node:os"); const path = require("node:path"); const fs = require("node:fs"); const { describe, it, beforeEach, after } = require("node:test"); const assert = require("node:assert/strict"); const SUITE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-datadir-")); after(() => fs.rmSync(SUITE_ROOT, { recursive: true, force: true })); // claude-home reads CLAUDE_HOME on every call, so each test can point it // somewhere fresh without reloading the module. const { getDataDir, migrateLegacyDataDir, getTranscriptSnapshotDir, } = require("../lib/claude-home"); let home; let seq = 0; beforeEach(() => { seq += 1; home = path.join(SUITE_ROOT, `home${seq}`, ".claude"); fs.mkdirSync(home, { recursive: true }); process.env.CLAUDE_HOME = home; delete process.env.DASHBOARD_DATA_DIR; }); const legacy = () => path.join(home, "agent-dashboard"); const current = () => path.join(home, "agenthub"); /** Put a recognisable database in `dir` so a migration can be proven. */ function seedDataDir(dir, marker) { fs.mkdirSync(path.join(dir, "transcripts"), { recursive: true }); fs.writeFileSync(path.join(dir, "dashboard.db"), marker); } describe("getDataDir", () => { it("uses the new name on a fresh install", () => { assert.equal(getDataDir(), current()); }); it("resolves to the legacy directory until it is migrated, and moves nothing", () => { seedDataDir(legacy(), "the-real-database"); assert.equal(getDataDir(), legacy(), "the data is still there, so that is the answer"); assert.deepEqual(fs.readdirSync(home), ["agent-dashboard"], "resolving must not move anything"); }); it("prefers the new directory once both exist", () => { seedDataDir(legacy(), "old"); seedDataDir(current(), "new"); assert.equal(getDataDir(), current()); }); it("lets DASHBOARD_DATA_DIR win", () => { seedDataDir(legacy(), "db"); const pinned = path.join(SUITE_ROOT, `pinned${seq}`); process.env.DASHBOARD_DATA_DIR = pinned; assert.equal(getDataDir(), pinned); }); it("derives the transcript snapshot dir from the migrated location", () => { seedDataDir(legacy(), "db"); migrateLegacyDataDir(); assert.equal(getTranscriptSnapshotDir(), path.join(current(), "transcripts")); }); }); describe("migrateLegacyDataDir", () => { it("moves an existing legacy directory to the new name, keeping its contents", () => { seedDataDir(legacy(), "the-real-database"); assert.equal(migrateLegacyDataDir(), current()); assert.equal(fs.existsSync(legacy()), false, "the legacy directory should be gone"); assert.equal( fs.readFileSync(path.join(current(), "dashboard.db"), "utf8"), "the-real-database" ); assert.ok(fs.existsSync(path.join(current(), "transcripts")), "nested content must survive"); assert.equal(getDataDir(), current()); }); it("does nothing on a fresh install", () => { assert.equal(migrateLegacyDataDir(), null); assert.deepEqual(fs.readdirSync(home), [], "no directory should be created just by migrating"); }); it("migrates once and stays put on later calls", () => { seedDataDir(legacy(), "db"); migrateLegacyDataDir(); fs.writeFileSync(path.join(current(), "dashboard.db"), "written-after-migration"); assert.equal(migrateLegacyDataDir(), null); assert.equal( fs.readFileSync(path.join(current(), "dashboard.db"), "utf8"), "written-after-migration", "a second call must not re-migrate over newer data" ); }); it("leaves both alone when the new directory already exists", () => { // Nothing sensible to merge here, and picking wrong would hide a database: // the new one wins and the legacy one is left untouched for the user. seedDataDir(legacy(), "old"); seedDataDir(current(), "new"); assert.equal(migrateLegacyDataDir(), null); assert.equal(fs.readFileSync(path.join(current(), "dashboard.db"), "utf8"), "new"); assert.equal(fs.readFileSync(path.join(legacy(), "dashboard.db"), "utf8"), "old"); }); it("keeps using the legacy directory when it cannot be moved", () => { // A read-only Claude home makes the rename fail. Starting a blank database // beside 2 GB of unreachable history is the one outcome worth avoiding. seedDataDir(legacy(), "db"); fs.chmodSync(home, 0o500); try { assert.equal(migrateLegacyDataDir(), null, "a failed move must not be reported as done"); assert.equal(getDataDir(), legacy()); } finally { fs.chmodSync(home, 0o700); } }); it("never migrates behind an explicit DASHBOARD_DATA_DIR", () => { seedDataDir(legacy(), "db"); process.env.DASHBOARD_DATA_DIR = path.join(SUITE_ROOT, `pinned${seq}`); assert.equal(migrateLegacyDataDir(), null); assert.ok(fs.existsSync(legacy()), "an explicit data dir must not trigger a migration"); }); });