310 lines
11 KiB
TypeScript
310 lines
11 KiB
TypeScript
/**
|
|
* Tests for backward compatibility layer
|
|
*
|
|
* Ensures single-project workflows continue working without --project flags.
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { BackwardCompat, ProjectRequiredError } from "./migration.js";
|
|
import { CentralCore } from "./central-core.js";
|
|
|
|
// Helper to create temp directories
|
|
function tempDir(prefix: string): string {
|
|
return mkdtempSync(join(tmpdir(), prefix));
|
|
}
|
|
|
|
// Helper to create a fake kb project
|
|
function createFakeKbProject(dir: string): void {
|
|
const kbDir = join(dir, ".fusion");
|
|
mkdirSync(kbDir, { recursive: true });
|
|
writeFileSync(join(kbDir, "fusion.db"), "SQLite format 3\x00");
|
|
}
|
|
|
|
describe("Backward Compatibility Layer", () => {
|
|
let tempGlobalDir: string;
|
|
let central: CentralCore;
|
|
|
|
beforeEach(async () => {
|
|
tempGlobalDir = tempDir("kb-backward-compat-test-");
|
|
central = new CentralCore(tempGlobalDir);
|
|
await central.init();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await central.close();
|
|
try {
|
|
rmSync(tempGlobalDir, { recursive: true, force: true });
|
|
} catch {
|
|
// Ignore cleanup errors
|
|
}
|
|
});
|
|
|
|
describe("single project auto-resolution", () => {
|
|
it("should auto-resolve single project without --project flag", async () => {
|
|
const tempProjectDir = tempDir("kb-single-compat-");
|
|
const project = await central.registerProject({
|
|
name: "Single Project",
|
|
path: tempProjectDir,
|
|
});
|
|
|
|
const compat = new BackwardCompat(central);
|
|
// No projectId provided - should auto-resolve to single project
|
|
const context = await compat.resolveProjectContext("/any/dir");
|
|
|
|
expect(context.projectId).toBe(project.id);
|
|
expect(context.workingDirectory).toBe(tempProjectDir);
|
|
expect(context.isLegacy).toBe(false);
|
|
|
|
rmSync(tempProjectDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it("should auto-resolve the single registered project even when cwd is unrelated", async () => {
|
|
const tempProjectDir = tempDir("kb-single-path-match-");
|
|
const unrelatedDir = tempDir("kb-single-unrelated-");
|
|
const project = await central.registerProject({
|
|
name: "Path Match Project",
|
|
path: tempProjectDir,
|
|
});
|
|
|
|
const compat = new BackwardCompat(central);
|
|
const context = await compat.resolveProjectContext(unrelatedDir);
|
|
|
|
expect(context.projectId).toBe(project.id);
|
|
expect(context.workingDirectory).toBe(tempProjectDir);
|
|
|
|
rmSync(tempProjectDir, { recursive: true, force: true });
|
|
rmSync(unrelatedDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it("should use explicit project ID when provided", async () => {
|
|
const tempProjectDir = tempDir("kb-explicit-compat-");
|
|
const project = await central.registerProject({
|
|
name: "Explicit Project",
|
|
path: tempProjectDir,
|
|
});
|
|
|
|
const compat = new BackwardCompat(central);
|
|
const context = await compat.resolveProjectContext("/some/dir", project.id);
|
|
|
|
expect(context.projectId).toBe(project.id);
|
|
expect(context.workingDirectory).toBe(tempProjectDir);
|
|
|
|
rmSync(tempProjectDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it("should find project by name (case-insensitive)", async () => {
|
|
const tempProjectDir = tempDir("kb-name-compat-");
|
|
const project = await central.registerProject({
|
|
name: "My Awesome Project",
|
|
path: tempProjectDir,
|
|
});
|
|
|
|
const compat = new BackwardCompat(central);
|
|
// Use lowercase name - should still find it
|
|
const context = await compat.resolveProjectContext("/some/dir", "my awesome project");
|
|
|
|
expect(context.projectId).toBe(project.id);
|
|
|
|
rmSync(tempProjectDir, { recursive: true, force: true });
|
|
});
|
|
});
|
|
|
|
describe("multiple projects requires explicit selection", () => {
|
|
it("should throw ProjectRequiredError when multiple projects and no selection", async () => {
|
|
const tempProjectDir1 = tempDir("kb-multi-compat1-");
|
|
const tempProjectDir2 = tempDir("kb-multi-compat2-");
|
|
const project1 = await central.registerProject({
|
|
name: "Project One",
|
|
path: tempProjectDir1,
|
|
});
|
|
const project2 = await central.registerProject({
|
|
name: "Project Two",
|
|
path: tempProjectDir2,
|
|
});
|
|
|
|
const compat = new BackwardCompat(central);
|
|
|
|
await expect(compat.resolveProjectContext("/some/dir")).rejects.toThrow(
|
|
ProjectRequiredError
|
|
);
|
|
|
|
try {
|
|
await compat.resolveProjectContext("/some/dir");
|
|
} catch (err) {
|
|
expect(err).toBeInstanceOf(ProjectRequiredError);
|
|
// Should provide list of available projects
|
|
expect((err as ProjectRequiredError).availableProjects).toHaveLength(2);
|
|
const ids = (err as ProjectRequiredError).availableProjects.map((p) => p.id);
|
|
expect(ids).toContain(project1.id);
|
|
expect(ids).toContain(project2.id);
|
|
}
|
|
|
|
rmSync(tempProjectDir1, { recursive: true, force: true });
|
|
rmSync(tempProjectDir2, { recursive: true, force: true });
|
|
});
|
|
|
|
it("should resolve correctly when explicit project provided with multiple projects", async () => {
|
|
const tempProjectDir1 = tempDir("kb-multi-explicit1-");
|
|
const tempProjectDir2 = tempDir("kb-multi-explicit2-");
|
|
const project1 = await central.registerProject({
|
|
name: "Project One",
|
|
path: tempProjectDir1,
|
|
});
|
|
await central.registerProject({
|
|
name: "Project Two",
|
|
path: tempProjectDir2,
|
|
});
|
|
|
|
const compat = new BackwardCompat(central);
|
|
// Explicitly select project1
|
|
const context = await compat.resolveProjectContext("/some/dir", project1.id);
|
|
|
|
expect(context.projectId).toBe(project1.id);
|
|
expect(context.workingDirectory).toBe(tempProjectDir1);
|
|
|
|
rmSync(tempProjectDir1, { recursive: true, force: true });
|
|
rmSync(tempProjectDir2, { recursive: true, force: true });
|
|
});
|
|
|
|
it("should require explicit selection when cwd is inside one of multiple projects", async () => {
|
|
const tempProjectDir1 = tempDir("kb-multi-cwd1-");
|
|
const tempProjectDir2 = tempDir("kb-multi-cwd2-");
|
|
const nestedDir = join(tempProjectDir1, "src", "nested");
|
|
mkdirSync(nestedDir, { recursive: true });
|
|
await central.registerProject({
|
|
name: "Project One",
|
|
path: tempProjectDir1,
|
|
});
|
|
await central.registerProject({
|
|
name: "Project Two",
|
|
path: tempProjectDir2,
|
|
});
|
|
|
|
const compat = new BackwardCompat(central);
|
|
await expect(compat.resolveProjectContext(nestedDir)).rejects.toThrow(ProjectRequiredError);
|
|
|
|
rmSync(tempProjectDir1, { recursive: true, force: true });
|
|
rmSync(tempProjectDir2, { recursive: true, force: true });
|
|
});
|
|
|
|
it("should require explicit selection when cwd is outside all registered projects", async () => {
|
|
const tempProjectDir1 = tempDir("kb-multi-outside1-");
|
|
const tempProjectDir2 = tempDir("kb-multi-outside2-");
|
|
const unrelatedDir = tempDir("kb-multi-outside-unrelated-");
|
|
await central.registerProject({
|
|
name: "Project One",
|
|
path: tempProjectDir1,
|
|
});
|
|
await central.registerProject({
|
|
name: "Project Two",
|
|
path: tempProjectDir2,
|
|
});
|
|
|
|
const compat = new BackwardCompat(central);
|
|
await expect(compat.resolveProjectContext(unrelatedDir)).rejects.toThrow(ProjectRequiredError);
|
|
|
|
rmSync(tempProjectDir1, { recursive: true, force: true });
|
|
rmSync(tempProjectDir2, { recursive: true, force: true });
|
|
rmSync(unrelatedDir, { recursive: true, force: true });
|
|
});
|
|
});
|
|
|
|
describe("legacy mode without central database", () => {
|
|
it("should return legacy mode when no central DB", async () => {
|
|
// Close and remove central DB
|
|
await central.close();
|
|
rmSync(join(tempGlobalDir, "fusion-central.db"), { force: true });
|
|
|
|
// Re-create central but don't init
|
|
central = new CentralCore(tempGlobalDir);
|
|
|
|
const compat = new BackwardCompat(central);
|
|
const context = await compat.resolveProjectContext("/some/legacy/dir");
|
|
|
|
expect(context.isLegacy).toBe(true);
|
|
expect(context.projectId).toBe("legacy");
|
|
expect(context.workingDirectory).toBe("/some/legacy/dir");
|
|
});
|
|
|
|
it("should report legacy mode correctly", async () => {
|
|
// Close and remove central DB
|
|
await central.close();
|
|
rmSync(join(tempGlobalDir, "fusion-central.db"), { force: true });
|
|
|
|
central = new CentralCore(tempGlobalDir);
|
|
|
|
const compat = new BackwardCompat(central);
|
|
expect(await compat.isLegacyMode()).toBe(true);
|
|
});
|
|
|
|
it("should report non-legacy mode when central DB exists", async () => {
|
|
const compat = new BackwardCompat(central);
|
|
expect(await compat.isLegacyMode()).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("no implicit mutation during resolve", () => {
|
|
it("should not auto-register a project found in cwd when no projects are registered", async () => {
|
|
const projectDir = tempDir("kb-no-auto-migrate-compat-");
|
|
createFakeKbProject(projectDir);
|
|
|
|
const compat = new BackwardCompat(central);
|
|
|
|
await expect(compat.resolveProjectContext(projectDir)).rejects.toThrow(ProjectRequiredError);
|
|
|
|
const isRegistered = await central.isProjectRegistered(projectDir);
|
|
expect(isRegistered).toBe(false);
|
|
|
|
rmSync(projectDir, { recursive: true, force: true });
|
|
});
|
|
});
|
|
|
|
describe("error messages", () => {
|
|
it("should provide helpful error when project not found", async () => {
|
|
const compat = new BackwardCompat(central);
|
|
|
|
await expect(
|
|
compat.resolveProjectContext("/some/dir", "nonexistent-project")
|
|
).rejects.toThrow(/not found/i);
|
|
});
|
|
|
|
it("should provide helpful error when no projects registered", async () => {
|
|
const compat = new BackwardCompat(central);
|
|
|
|
await expect(compat.resolveProjectContext("/some/dir")).rejects.toThrow(
|
|
/no projects registered/i
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("ProjectRequiredError backward compat", () => {
|
|
it("should include both id and name for each available project", () => {
|
|
const available = [
|
|
{ id: "proj_abc123", name: "Frontend" },
|
|
{ id: "proj_def456", name: "Backend" },
|
|
{ id: "proj_ghi789", name: "Docs" },
|
|
];
|
|
|
|
const error = new ProjectRequiredError(
|
|
"Multiple projects available",
|
|
available
|
|
);
|
|
|
|
expect(error.availableProjects).toHaveLength(3);
|
|
expect(error.availableProjects[0]).toHaveProperty("id");
|
|
expect(error.availableProjects[0]).toHaveProperty("name");
|
|
});
|
|
|
|
it("should be catchable as ProjectRequiredError", async () => {
|
|
const error = new ProjectRequiredError("test", []);
|
|
|
|
expect(error).toBeInstanceOf(Error);
|
|
expect(error).toBeInstanceOf(ProjectRequiredError);
|
|
expect(error.name).toBe("ProjectRequiredError");
|
|
});
|
|
});
|