296 lines
8.5 KiB
TypeScript
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);
|
|
});
|
|
});
|