/** * Tests for the project scanner module. */ import assert from "node:assert/strict"; import { randomUUID } from "node:crypto"; import { chmodSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync, } from "node:fs"; import { platform, tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, it } from "vitest"; import { scanForProjects } from "./project-scanner.js"; // ---------- helpers ---------- function tmpDir(): string { return mkdtempSync( join(tmpdir(), `scanner-test-${randomUUID().slice(0, 8)}-`), ); } const cleanupDirs: string[] = []; afterEach(() => { while (cleanupDirs.length) { const d = cleanupDirs.pop()!; if (existsSync(d)) rmSync(d, { recursive: true, force: true }); } }); /** Create a project directory with specified marker files/dirs */ function createProject(root: string, name: string, markers: string[]): string { const projDir = join(root, name); mkdirSync(projDir, { recursive: true }); for (const marker of markers) { const markerPath = join(projDir, marker); if (marker.startsWith(".") && !marker.includes(".")) { // Likely a directory marker (.git, .sf) mkdirSync(markerPath, { recursive: true }); } else { // File marker (package.json, Cargo.toml, etc.) writeFileSync(markerPath, "{}"); } } return projDir; } // ---------- tests ---------- describe("scanForProjects", () => { it("finds projects with marker files", async () => { const root = tmpDir(); cleanupDirs.push(root); createProject(root, "my-app", [".git", "package.json"]); const results = await scanForProjects([root]); assert.equal(results.length, 1); assert.equal(results[0]!.name, "my-app"); assert.equal(results[0]!.path, join(root, "my-app")); assert.ok(results[0]!.markers.includes("git")); assert.ok(results[0]!.markers.includes("node")); assert.ok(results[0]!.lastModified > 0); }); it("handles missing scan_root gracefully", async () => { const results = await scanForProjects([ "/nonexistent/path/that/does/not/exist", ]); assert.deepEqual(results, []); }); it("handles permission errors on entries", { skip: platform() === "win32", }, async () => { const root = tmpDir(); cleanupDirs.push(root); // Create an accessible project createProject(root, "accessible", [".git"]); // Create an inaccessible directory const noAccess = join(root, "locked"); mkdirSync(noAccess); chmodSync(noAccess, 0o000); const results = await scanForProjects([root]); // Restore permissions for cleanup chmodSync(noAccess, 0o755); // Should find the accessible project but skip the locked one assert.equal(results.length, 1); assert.equal(results[0]!.name, "accessible"); }); it("detects multiple marker types", async () => { const root = tmpDir(); cleanupDirs.push(root); createProject(root, "full-stack", [".git", "package.json", ".sf"]); const results = await scanForProjects([root]); assert.equal(results.length, 1); assert.equal(results[0]!.markers.length, 3); assert.ok(results[0]!.markers.includes("git")); assert.ok(results[0]!.markers.includes("node")); assert.ok(results[0]!.markers.includes("sf")); }); it("returns results sorted alphabetically by name", async () => { const root = tmpDir(); cleanupDirs.push(root); createProject(root, "zebra-project", [".git"]); createProject(root, "alpha-project", [".git"]); createProject(root, "middle-project", [".git"]); const results = await scanForProjects([root]); assert.equal(results.length, 3); assert.equal(results[0]!.name, "alpha-project"); assert.equal(results[1]!.name, "middle-project"); assert.equal(results[2]!.name, "zebra-project"); }); it("ignores hidden directories", async () => { const root = tmpDir(); cleanupDirs.push(root); createProject(root, "visible", [".git"]); createProject(root, ".hidden", [".git"]); const results = await scanForProjects([root]); assert.equal(results.length, 1); assert.equal(results[0]!.name, "visible"); }); it("ignores node_modules", async () => { const root = tmpDir(); cleanupDirs.push(root); createProject(root, "real-project", ["package.json"]); createProject(root, "node_modules", ["package.json"]); const results = await scanForProjects([root]); assert.equal(results.length, 1); assert.equal(results[0]!.name, "real-project"); }); it("skips directories with no markers", async () => { const root = tmpDir(); cleanupDirs.push(root); createProject(root, "has-markers", [".git"]); // Create a plain directory with no markers mkdirSync(join(root, "no-markers")); const results = await scanForProjects([root]); assert.equal(results.length, 1); assert.equal(results[0]!.name, "has-markers"); }); it("scans multiple roots", async () => { const root1 = tmpDir(); const root2 = tmpDir(); cleanupDirs.push(root1, root2); createProject(root1, "proj-a", [".git"]); createProject(root2, "proj-b", ["Cargo.toml"]); const results = await scanForProjects([root1, root2]); assert.equal(results.length, 2); assert.equal(results[0]!.name, "proj-a"); assert.ok(results[0]!.markers.includes("git")); assert.equal(results[1]!.name, "proj-b"); assert.ok(results[1]!.markers.includes("rust")); }); it("detects all supported marker types", async () => { const root = tmpDir(); cleanupDirs.push(root); createProject(root, "git-proj", [".git"]); createProject(root, "node-proj", ["package.json"]); createProject(root, "sf-proj", [".sf"]); createProject(root, "rust-proj", ["Cargo.toml"]); createProject(root, "python-proj", ["pyproject.toml"]); createProject(root, "go-proj", ["go.mod"]); const results = await scanForProjects([root]); assert.equal(results.length, 6); const byName = new Map(results.map((r) => [r.name, r])); assert.deepEqual(byName.get("git-proj")!.markers, ["git"]); assert.deepEqual(byName.get("node-proj")!.markers, ["node"]); assert.deepEqual(byName.get("sf-proj")!.markers, ["sf"]); assert.deepEqual(byName.get("rust-proj")!.markers, ["rust"]); assert.deepEqual(byName.get("python-proj")!.markers, ["python"]); assert.deepEqual(byName.get("go-proj")!.markers, ["go"]); }); it("skips non-directory entries", async () => { const root = tmpDir(); cleanupDirs.push(root); createProject(root, "real-project", [".git"]); // Create a regular file at the root level — should be ignored writeFileSync(join(root, "some-file.txt"), "not a directory"); const results = await scanForProjects([root]); assert.equal(results.length, 1); assert.equal(results[0]!.name, "real-project"); }); it("returns empty array for empty scan_roots", async () => { const results = await scanForProjects([]); assert.deepEqual(results, []); }); it("deduplicates when same root appears twice", async () => { const root = tmpDir(); cleanupDirs.push(root); createProject(root, "only-once", [".git"]); const results = await scanForProjects([root, root]); // Same directory scanned twice — results will have duplicates // (this is acceptable; the caller can deduplicate by path if needed) assert.equal(results.length, 2); assert.equal(results[0]!.name, "only-once"); assert.equal(results[1]!.name, "only-once"); }); });