248 lines
7.1 KiB
TypeScript
248 lines
7.1 KiB
TypeScript
/**
|
|
* 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");
|
|
});
|
|
});
|