singularity-forge/src/tests/integration/web-switch-project.test.ts
2026-05-05 14:46:18 +02:00

296 lines
8.5 KiB
TypeScript

import assert from "node:assert/strict";
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
statSync,
writeFileSync,
} from "node:fs";
import { homedir, tmpdir } from "node:os";
import { isAbsolute, join, resolve } from "node:path";
import { afterAll, describe, test } from "vitest";
// ---------------------------------------------------------------------------
// Test the core validation + persistence logic used by /api/switch-root
// without pulling in the heavy bridge-service import chain.
//
// The server-side handler does:
// 1. Validate path exists and is a directory
// 2. Resolve tilde + resolve() to absolute path
// 3. Persist devRoot to web-preferences.json (clearing lastActiveProject)
// 4. Discover projects under the new root
//
// We test each concern in isolation using the same logic.
// ---------------------------------------------------------------------------
// ── Helpers (mirrors /api/switch-root handler logic) ──────────────────────
function expandTilde(p: string): string {
if (p === "~") return homedir();
if (p.startsWith("~/")) return homedir() + p.slice(1);
return p;
}
interface SwitchRootResult {
ok: boolean;
error?: string;
devRoot?: string;
}
function validateSwitchRoot(rawDevRoot: string): SwitchRootResult {
const trimmed = rawDevRoot.trim();
if (!trimmed) {
return { ok: false, error: "Missing devRoot in request body" };
}
const expanded = expandTilde(trimmed);
const resolved = resolve(expanded);
if (!existsSync(resolved)) {
return { ok: false, error: `Path does not exist: ${resolved}` };
}
try {
const stat = statSync(resolved);
if (!stat.isDirectory()) {
return { ok: false, error: `Not a directory: ${resolved}` };
}
} catch {
return { ok: false, error: `Cannot access path: ${resolved}` };
}
return { ok: true, devRoot: resolved };
}
interface WebPreferences {
devRoot?: string;
lastActiveProject?: string;
}
function persistSwitchRoot(
prefsPath: string,
newDevRoot: string,
): WebPreferences {
let existing: WebPreferences = {};
try {
if (existsSync(prefsPath)) {
existing = JSON.parse(readFileSync(prefsPath, "utf-8"));
}
} catch {
// Corrupt file — start fresh
}
const prefs: WebPreferences = {
...existing,
devRoot: newDevRoot,
lastActiveProject: undefined,
};
writeFileSync(prefsPath, JSON.stringify(prefs, null, 2), "utf-8");
return prefs;
}
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
const tempRoot = mkdtempSync(join(tmpdir(), "sf-switch-root-"));
const rootA = join(tempRoot, "root-a");
mkdirSync(rootA);
mkdirSync(join(rootA, "project-x"));
mkdirSync(join(rootA, "project-x", ".git"));
writeFileSync(join(rootA, "project-x", "package.json"), "{}");
mkdirSync(join(rootA, "project-y"));
const rootB = join(tempRoot, "root-b");
mkdirSync(rootB);
mkdirSync(join(rootB, "project-z"));
writeFileSync(join(rootB, "project-z", "Cargo.toml"), "");
const filePath = join(tempRoot, "not-a-dir.txt");
writeFileSync(filePath, "hello");
const prefsDir = join(tempRoot, "prefs");
mkdirSync(prefsDir);
const prefsPath = join(prefsDir, "web-preferences.json");
afterAll(() => {
rmSync(tempRoot, { recursive: true, force: true });
});
// ---------------------------------------------------------------------------
// Tests — Path validation
// ---------------------------------------------------------------------------
describe("switch-root: path validation", () => {
test("valid directory returns ok with resolved path", () => {
const result = validateSwitchRoot(rootA);
assert.ok(result.ok);
assert.equal(result.devRoot, rootA);
});
test("empty string returns error", () => {
const result = validateSwitchRoot("");
assert.ok(!result.ok);
assert.match(result.error!, /Missing devRoot/);
});
test("whitespace-only string returns error", () => {
const result = validateSwitchRoot(" ");
assert.ok(!result.ok);
assert.match(result.error!, /Missing devRoot/);
});
test("non-existent path returns error", () => {
const result = validateSwitchRoot(join(tempRoot, "nonexistent-dir"));
assert.ok(!result.ok);
assert.match(result.error!, /does not exist/);
});
test("file path (not a directory) returns error", () => {
const result = validateSwitchRoot(filePath);
assert.ok(!result.ok);
assert.match(result.error!, /Not a directory/);
});
test("tilde path expands to home directory", () => {
const result = validateSwitchRoot("~");
// ~ always exists as a directory (user's home)
assert.ok(result.ok, `Expected ok for ~, got error: ${result.error}`);
assert.equal(result.devRoot, homedir());
});
test("resolves relative paths to absolute", () => {
// Create a relative path that's valid from cwd
const result = validateSwitchRoot(rootA);
assert.ok(result.ok);
assert.ok(isAbsolute(result.devRoot!), "Should be absolute path");
});
});
// ---------------------------------------------------------------------------
// Tests — Preference persistence
// ---------------------------------------------------------------------------
describe("switch-root: preference persistence", () => {
test("writes devRoot and clears lastActiveProject", () => {
writeFileSync(
prefsPath,
JSON.stringify(
{
devRoot: rootA,
lastActiveProject: "/old/project",
},
null,
2,
),
);
const result = persistSwitchRoot(prefsPath, rootB);
assert.equal(result.devRoot, rootB);
assert.equal(result.lastActiveProject, undefined);
// Verify on-disk
const onDisk = JSON.parse(readFileSync(prefsPath, "utf-8"));
assert.equal(onDisk.devRoot, rootB);
// undefined is not serialized to JSON
assert.ok(
!("lastActiveProject" in onDisk) || onDisk.lastActiveProject == null,
"lastActiveProject should be cleared",
);
});
test("creates prefs file from scratch", () => {
const freshPath = join(prefsDir, "fresh.json");
assert.ok(!existsSync(freshPath));
persistSwitchRoot(freshPath, rootA);
assert.ok(existsSync(freshPath));
const onDisk = JSON.parse(readFileSync(freshPath, "utf-8"));
assert.equal(onDisk.devRoot, rootA);
});
test("handles corrupt prefs file gracefully", () => {
writeFileSync(prefsPath, "NOT VALID JSON!!!");
const result = persistSwitchRoot(prefsPath, rootB);
assert.equal(result.devRoot, rootB);
const onDisk = JSON.parse(readFileSync(prefsPath, "utf-8"));
assert.equal(onDisk.devRoot, rootB);
});
test("overwrites existing devRoot", () => {
writeFileSync(prefsPath, JSON.stringify({ devRoot: rootA }, null, 2));
persistSwitchRoot(prefsPath, rootB);
const onDisk = JSON.parse(readFileSync(prefsPath, "utf-8"));
assert.equal(onDisk.devRoot, rootB);
assert.notEqual(onDisk.devRoot, rootA);
});
});
// ---------------------------------------------------------------------------
// Tests — Tilde expansion
// ---------------------------------------------------------------------------
describe("switch-root: tilde expansion", () => {
test("~ expands to home directory", () => {
assert.equal(expandTilde("~"), homedir());
});
test("~/Projects expands correctly", () => {
assert.equal(expandTilde("~/Projects"), `${homedir()}/Projects`);
});
test("absolute path is unchanged", () => {
assert.equal(expandTilde("/usr/local/bin"), "/usr/local/bin");
});
test("relative path is unchanged", () => {
assert.equal(expandTilde("relative/path"), "relative/path");
});
test("~user is not expanded (only bare ~ or ~/)", () => {
assert.equal(expandTilde("~other"), "~other");
});
});
// ---------------------------------------------------------------------------
// Tests — End-to-end switch scenario
// ---------------------------------------------------------------------------
describe("switch-root: end-to-end scenario", () => {
test("full switch: validate + persist + verify projects change", () => {
// Start with root-a
writeFileSync(
prefsPath,
JSON.stringify(
{
devRoot: rootA,
lastActiveProject: join(rootA, "project-x"),
},
null,
2,
),
);
// User requests switch to root-b
const validation = validateSwitchRoot(rootB);
assert.ok(validation.ok, `Validation should pass: ${validation.error}`);
const prefs = persistSwitchRoot(prefsPath, validation.devRoot!);
assert.equal(prefs.devRoot, rootB);
assert.equal(prefs.lastActiveProject, undefined);
// Verify on-disk state
const finalPrefs = JSON.parse(readFileSync(prefsPath, "utf-8"));
assert.equal(finalPrefs.devRoot, rootB);
});
});