267 lines
8.6 KiB
TypeScript
267 lines
8.6 KiB
TypeScript
import { mkdtempSync, mkdirSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
import { searchHomeDirectories, searchWorkspaceEntries } from "./directory-suggestions.js";
|
|
|
|
describe("searchHomeDirectories", () => {
|
|
let tempRoot: string;
|
|
let homeDir: string;
|
|
let outsideDir: string;
|
|
|
|
beforeEach(() => {
|
|
tempRoot = realpathSync(mkdtempSync(path.join(tmpdir(), "directory-suggestions-")));
|
|
homeDir = path.join(tempRoot, "home");
|
|
outsideDir = path.join(tempRoot, "outside");
|
|
|
|
mkdirSync(homeDir, { recursive: true });
|
|
mkdirSync(outsideDir, { recursive: true });
|
|
|
|
mkdirSync(path.join(homeDir, "projects", "paseo"), { recursive: true });
|
|
mkdirSync(path.join(homeDir, "projects", "playground"), { recursive: true });
|
|
mkdirSync(path.join(homeDir, "documents", "plans"), { recursive: true });
|
|
mkdirSync(path.join(homeDir, ".hidden", "cache"), { recursive: true });
|
|
writeFileSync(path.join(homeDir, "projects", "README.md"), "not a directory\n");
|
|
|
|
mkdirSync(path.join(outsideDir, "outside-match"), { recursive: true });
|
|
symlinkSync(path.join(outsideDir, "outside-match"), path.join(homeDir, "outside-link"));
|
|
});
|
|
|
|
afterEach(() => {
|
|
rmSync(tempRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
it("returns an empty list for blank queries", async () => {
|
|
await expect(
|
|
searchHomeDirectories({
|
|
homeDir,
|
|
query: " ",
|
|
limit: 10,
|
|
}),
|
|
).resolves.toEqual([]);
|
|
});
|
|
|
|
it("returns only existing directories", async () => {
|
|
const results = await searchHomeDirectories({
|
|
homeDir,
|
|
query: "proj",
|
|
limit: 10,
|
|
});
|
|
|
|
expect(results).toContain(path.join(homeDir, "projects"));
|
|
expect(results).toContain(path.join(homeDir, "projects", "paseo"));
|
|
expect(results).not.toContain(path.join(homeDir, "projects", "README.md"));
|
|
});
|
|
|
|
it("supports home-relative path query syntax", async () => {
|
|
const results = await searchHomeDirectories({
|
|
homeDir,
|
|
query: "~/projects/pa",
|
|
limit: 10,
|
|
});
|
|
|
|
expect(results).toEqual([path.join(homeDir, "projects", "paseo")]);
|
|
});
|
|
|
|
it("prioritizes exact segment matches before segment-prefix matches", async () => {
|
|
const exactSegmentPath = path.join(homeDir, "something", "faro", "something-else");
|
|
const prefixSegmentPath = path.join(homeDir, "something", "somethingelse", "faro-bla");
|
|
mkdirSync(exactSegmentPath, { recursive: true });
|
|
mkdirSync(prefixSegmentPath, { recursive: true });
|
|
|
|
const results = await searchHomeDirectories({
|
|
homeDir,
|
|
query: "faro",
|
|
limit: 30,
|
|
});
|
|
|
|
const exactIndex = results.indexOf(exactSegmentPath);
|
|
const prefixIndex = results.indexOf(prefixSegmentPath);
|
|
expect(exactIndex).toBeGreaterThanOrEqual(0);
|
|
expect(prefixIndex).toBeGreaterThanOrEqual(0);
|
|
expect(exactIndex).toBeLessThan(prefixIndex);
|
|
});
|
|
|
|
it("prioritizes partial matches that appear earlier in the path", async () => {
|
|
const earlierPath = path.join(homeDir, "farofoo");
|
|
const laterPath = path.join(homeDir, "x", "y", "farofoo");
|
|
mkdirSync(earlierPath, { recursive: true });
|
|
mkdirSync(laterPath, { recursive: true });
|
|
|
|
const results = await searchHomeDirectories({
|
|
homeDir,
|
|
query: "arofo",
|
|
limit: 30,
|
|
});
|
|
|
|
const earlierIndex = results.indexOf(earlierPath);
|
|
const laterIndex = results.indexOf(laterPath);
|
|
expect(earlierIndex).toBeGreaterThanOrEqual(0);
|
|
expect(laterIndex).toBeGreaterThanOrEqual(0);
|
|
expect(earlierIndex).toBeLessThan(laterIndex);
|
|
});
|
|
|
|
it("returns home-root suggestions when query is '~'", async () => {
|
|
const results = await searchHomeDirectories({
|
|
homeDir,
|
|
query: "~",
|
|
limit: 20,
|
|
});
|
|
|
|
expect(results).toContain(path.join(homeDir, "projects"));
|
|
expect(results).toContain(path.join(homeDir, "documents"));
|
|
expect(results).not.toContain(path.join(homeDir, ".hidden"));
|
|
});
|
|
|
|
it("does not return hidden directories during tree search", async () => {
|
|
const results = await searchHomeDirectories({
|
|
homeDir,
|
|
query: "cache",
|
|
limit: 20,
|
|
});
|
|
|
|
expect(results).not.toContain(path.join(homeDir, ".hidden", "cache"));
|
|
});
|
|
|
|
it("does not return paths that escape home through symlinks", async () => {
|
|
const results = await searchHomeDirectories({
|
|
homeDir,
|
|
query: "outside",
|
|
limit: 20,
|
|
});
|
|
|
|
expect(results).not.toContain(path.join(homeDir, "outside-link"));
|
|
expect(results).not.toContain(path.join(outsideDir, "outside-match"));
|
|
});
|
|
|
|
it("respects the result limit", async () => {
|
|
const results = await searchHomeDirectories({
|
|
homeDir,
|
|
query: "p",
|
|
limit: 1,
|
|
});
|
|
|
|
expect(results).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe("searchWorkspaceEntries", () => {
|
|
let tempRoot: string;
|
|
let workspaceDir: string;
|
|
let outsideDir: string;
|
|
|
|
beforeEach(() => {
|
|
tempRoot = realpathSync(mkdtempSync(path.join(tmpdir(), "workspace-suggestions-")));
|
|
workspaceDir = path.join(tempRoot, "workspace");
|
|
outsideDir = path.join(tempRoot, "outside");
|
|
|
|
mkdirSync(path.join(workspaceDir, "src", "components"), {
|
|
recursive: true,
|
|
});
|
|
mkdirSync(path.join(workspaceDir, "docs"), { recursive: true });
|
|
mkdirSync(path.join(outsideDir, "escaped"), { recursive: true });
|
|
|
|
writeFileSync(path.join(workspaceDir, "README.md"), "# paseo\n");
|
|
writeFileSync(
|
|
path.join(workspaceDir, "src", "components", "chat-input.tsx"),
|
|
"export const ChatInput = null;\n",
|
|
);
|
|
writeFileSync(path.join(workspaceDir, "docs", "notes.md"), "notes\n");
|
|
|
|
symlinkSync(path.join(outsideDir, "escaped"), path.join(workspaceDir, "escaped-link"));
|
|
});
|
|
|
|
afterEach(() => {
|
|
rmSync(tempRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
it("returns relative file and directory suggestions for workspace queries", async () => {
|
|
const results = await searchWorkspaceEntries({
|
|
cwd: workspaceDir,
|
|
query: "chat",
|
|
limit: 20,
|
|
includeFiles: true,
|
|
includeDirectories: true,
|
|
});
|
|
|
|
expect(results).toContainEqual({
|
|
path: "src/components/chat-input.tsx",
|
|
kind: "file",
|
|
});
|
|
expect(results.some((entry) => entry.path === path.join(workspaceDir, "src"))).toBe(false);
|
|
});
|
|
|
|
it("filters entries by kind", async () => {
|
|
const dirsOnly = await searchWorkspaceEntries({
|
|
cwd: workspaceDir,
|
|
query: "src",
|
|
limit: 20,
|
|
includeFiles: false,
|
|
includeDirectories: true,
|
|
});
|
|
expect(dirsOnly.some((entry) => entry.kind === "file")).toBe(false);
|
|
expect(dirsOnly.some((entry) => entry.path === "src")).toBe(true);
|
|
|
|
const filesOnly = await searchWorkspaceEntries({
|
|
cwd: workspaceDir,
|
|
query: "readme",
|
|
limit: 20,
|
|
includeFiles: true,
|
|
includeDirectories: false,
|
|
});
|
|
expect(filesOnly).toEqual([{ path: "README.md", kind: "file" }]);
|
|
});
|
|
|
|
it("supports path-style queries and does not escape cwd through symlinks", async () => {
|
|
const pathResults = await searchWorkspaceEntries({
|
|
cwd: workspaceDir,
|
|
query: "src/co",
|
|
limit: 20,
|
|
includeFiles: true,
|
|
includeDirectories: true,
|
|
});
|
|
expect(pathResults).toContainEqual({
|
|
path: "src/components",
|
|
kind: "directory",
|
|
});
|
|
|
|
const escapedResults = await searchWorkspaceEntries({
|
|
cwd: workspaceDir,
|
|
query: "escaped",
|
|
limit: 20,
|
|
includeFiles: true,
|
|
includeDirectories: true,
|
|
});
|
|
expect(escapedResults.some((entry) => entry.path.includes("escaped-link"))).toBe(false);
|
|
});
|
|
|
|
it("ignores node_modules entries so deep workspace files still resolve under scan limits", async () => {
|
|
mkdirSync(path.join(workspaceDir, "packages", "app", "src", "app"), { recursive: true });
|
|
writeFileSync(path.join(workspaceDir, "packages", "app", "src", "app", "_layout.tsx"), "");
|
|
|
|
for (let index = 0; index < 120; index += 1) {
|
|
mkdirSync(path.join(workspaceDir, "node_modules", `pkg-${index}`), { recursive: true });
|
|
writeFileSync(
|
|
path.join(workspaceDir, "node_modules", `pkg-${index}`, "index.js"),
|
|
"module.exports = {};\n",
|
|
);
|
|
}
|
|
writeFileSync(path.join(workspaceDir, "node_modules", "_layout.tsx"), "");
|
|
|
|
const results = await searchWorkspaceEntries({
|
|
cwd: workspaceDir,
|
|
query: "_layout.tsx",
|
|
limit: 20,
|
|
includeFiles: true,
|
|
includeDirectories: true,
|
|
maxEntriesScanned: 60,
|
|
});
|
|
|
|
expect(results).toContainEqual({
|
|
path: "packages/app/src/app/_layout.tsx",
|
|
kind: "file",
|
|
});
|
|
expect(results.some((entry) => entry.path.startsWith("node_modules/"))).toBe(false);
|
|
});
|
|
});
|