Files
fusion/packages/dashboard/src/__tests__/chat.test.ts
Fusion 9974cc7019 feat(FN-1947): add file mention search and chat autocomplete
- Add file search API wiring in dashboard client/server with route coverage for /api/files/search
- Introduce useFileMention hook and FileMentionPopup component, integrated into ChatView and QuickChatFAB
- Resolve @file references in chat messages so referenced file content is included in AI context
- Add styling, mobile responsiveness, and focused tests for popup UI, hook behavior, chat resolution, and file services
2026-04-18 07:36:57 -07:00

185 lines
6.3 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { resolveFileReferences } from "../chat.js";
// Use vi.hoisted for proper hoisting with ES modules
const { mockReadFile, mockStat } = vi.hoisted(() => ({
mockReadFile: vi.fn(),
mockStat: vi.fn(),
}));
// Mock node:fs/promises - must provide both default and named exports
vi.mock("node:fs/promises", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs/promises")>();
return {
default: {
...actual,
readdir: vi.fn(),
readFile: mockReadFile,
stat: mockStat,
},
readdir: vi.fn(),
readFile: mockReadFile,
stat: mockStat,
};
});
// Mock @fusion/core to prevent cascade loading of real fs modules
vi.mock("@fusion/core", () => ({
summarizeTitle: vi.fn(),
AgentStore: vi.fn(),
ChatStore: vi.fn(),
}));
describe("resolveFileReferences", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("returns original content when no file mentions are present", async () => {
const content = "Hello, how are you?";
const result = await resolveFileReferences(content, "/project");
expect(result).toBe(content);
});
it("returns original content for # without file extension", async () => {
const content = "I want to mention #something but no extension";
const result = await resolveFileReferences(content, "/project");
expect(result).toBe(content);
});
it("resolves a single file mention and appends content", async () => {
const content = "Can you look at #src/index.ts?";
mockStat.mockResolvedValueOnce({ isFile: () => true, size: 500 });
mockReadFile.mockResolvedValueOnce("export function test() {}");
const result = await resolveFileReferences(content, "/project");
expect(result).toContain(content);
expect(result).toContain("[Referenced File: src/index.ts]");
expect(result).toContain("export function test() {}");
expect(result).toContain("[/Referenced File: src/index.ts]");
});
it("resolves multiple file mentions", async () => {
const content = "Check #src/a.ts and #src/b.ts";
mockStat
.mockResolvedValueOnce({ isFile: () => true, size: 100 })
.mockResolvedValueOnce({ isFile: () => true, size: 200 });
mockReadFile
.mockResolvedValueOnce("// file a")
.mockResolvedValueOnce("// file b");
const result = await resolveFileReferences(content, "/project");
expect(result).toContain("[Referenced File: src/a.ts]");
expect(result).toContain("[Referenced File: src/b.ts]");
expect(result).toContain("// file a");
expect(result).toContain("// file b");
});
it("skips files that do not exist", async () => {
const content = "Missing #nonexistent.ts";
mockStat.mockRejectedValueOnce({ code: "ENOENT" });
const result = await resolveFileReferences(content, "/project");
expect(result).toBe(content); // No file context appended
});
it("skips files larger than 50KB", async () => {
const content = "Large #big.ts";
mockStat.mockResolvedValueOnce({ isFile: () => true, size: 60 * 1024 });
const result = await resolveFileReferences(content, "/project");
expect(result).toBe(content); // No file context appended
});
it("skips directories (non-files)", async () => {
const content = "Dir #src";
mockStat.mockResolvedValueOnce({ isFile: () => false, isDirectory: () => true });
const result = await resolveFileReferences(content, "/project");
expect(result).toBe(content);
});
it("blocks path traversal attacks (e.g., ../../../etc/passwd)", async () => {
const content = "Traversal #../../../etc/passwd";
mockStat.mockRejectedValueOnce(new Error("Access denied: Path traversal detected"));
const result = await resolveFileReferences(content, "/project");
expect(result).toBe(content); // Blocked, no file context
});
it("blocks absolute path references", async () => {
const content = "Absolute #/etc/passwd";
mockStat.mockRejectedValueOnce(new Error("Access denied: Absolute paths not allowed"));
const result = await resolveFileReferences(content, "/project");
expect(result).toBe(content); // Blocked
});
it("blocks null byte injection", async () => {
const content = "Null byte #file\u0000.txt";
mockStat.mockRejectedValueOnce(new Error("Access denied: Invalid characters in path"));
const result = await resolveFileReferences(content, "/project");
expect(result).toBe(content); // Blocked
});
it("deduplicates same file mentioned multiple times", async () => {
const content = "#src/a.ts and again #src/a.ts";
mockStat.mockResolvedValueOnce({ isFile: () => true, size: 100 });
mockReadFile.mockResolvedValueOnce("// content");
const result = await resolveFileReferences(content, "/project");
// Should only appear once in output
const occurrences = (result.match(/\[Referenced File: src\/a\.ts\]/g) || []).length;
expect(occurrences).toBe(1);
});
it("handles mixed valid and invalid file references", async () => {
const content = "Valid #src/valid.ts and invalid #nonexistent.ts";
mockStat
.mockResolvedValueOnce({ isFile: () => true, size: 100 })
.mockRejectedValueOnce({ code: "ENOENT" });
mockReadFile.mockResolvedValueOnce("// valid");
const result = await resolveFileReferences(content, "/project");
expect(result).toContain("[Referenced File: src/valid.ts]");
expect(result).toContain("// valid");
// No referenced file block for nonexistent.ts
expect(result).not.toContain("[/Referenced File: nonexistent.ts]");
});
it("handles file with path containing hyphens and underscores", async () => {
const content = "Check #my-app/my-file_2.ts";
mockStat.mockResolvedValueOnce({ isFile: () => true, size: 50 });
mockReadFile.mockResolvedValueOnce("// code");
const result = await resolveFileReferences(content, "/project");
expect(result).toContain("[Referenced File: my-app/my-file_2.ts]");
});
it("returns original content when all files are invalid", async () => {
const content = "#a.ts and #b.ts";
mockStat
.mockRejectedValueOnce({ code: "ENOENT" })
.mockRejectedValueOnce({ code: "ENOENT" });
const result = await resolveFileReferences(content, "/project");
expect(result).toBe(content);
});
});