Files
kandev/apps/web/components/task/file-browser-apply-changes.test.ts
João Salavisa 2c06cf5b6f fix: keep file tree paths workspace-relative (#3855)
Co-authored-by: Kandev Agent <agent@kandev.dev>
2026-09-22 08:15:41 +01:00

279 lines
9.2 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
import type { FileTreeNode } from "@/lib/types/backend";
const requestFileTreeMock = vi.fn();
const getWebSocketClientMock = vi.fn(() => ({}) as unknown);
vi.mock("@/lib/ws/connection", () => ({
getWebSocketClient: () => getWebSocketClientMock(),
}));
vi.mock("@/lib/ws/workspace-files", () => ({
requestFileTree: (...args: unknown[]) => requestFileTreeMock(...args),
requestFileContent: vi.fn(),
searchWorkspaceFiles: vi.fn(),
}));
type ApplyFileChanges = (typeof import("./file-browser-hooks"))["applyFileChanges"];
let applyFileChanges: ApplyFileChanges;
const SESSION_ID = "sess";
const REFRESH_OP = "refresh";
const THM_OLD = "thm/old.txt";
const THM_NEW = "thm/new.txt";
const SRC_PATH = "src";
const SRC_COMPONENTS_PATH = `${SRC_PATH}/components`;
beforeEach(async () => {
vi.resetModules();
requestFileTreeMock.mockReset();
getWebSocketClientMock.mockReset().mockReturnValue({});
({ applyFileChanges } = await import("./file-browser-hooks"));
});
function makeTree(): FileTreeNode {
return {
name: "",
path: "",
is_dir: true,
size: 0,
children: [
{
name: "thm",
path: "thm",
is_dir: true,
size: 0,
children: [{ name: "old.txt", path: THM_OLD, is_dir: false, size: 0 }],
},
{ name: "kandev", path: "kandev", is_dir: true, size: 0, children: [] },
],
};
}
function mockEmptyTree() {
// depth=1 responses omit `children` for placeholder nodes (omitempty).
requestFileTreeMock.mockImplementation((_client: unknown, _sid: string, folder: string) =>
Promise.resolve({ root: { name: folder, path: folder, is_dir: true } }),
);
}
function client() {
return {} as ReturnType<typeof import("@/lib/ws/connection").getWebSocketClient>;
}
// Regression for #982: refresh events must expand all affected-repo folders, not just root.
describe("applyFileChanges — refresh operation expands to all expanded folders", () => {
it("refreshes every expanded folder under the affected repo on a refresh event", async () => {
mockEmptyTree();
applyFileChanges({
client: client(),
sessionId: SESSION_ID,
expandedPaths: new Set(["thm", "thm/rooms", "kandev"]),
changes: [{ path: "", operation: REFRESH_OP, repository_name: "thm" }],
setTree: vi.fn(),
setLoadState: vi.fn(),
});
await new Promise<void>((r) => setTimeout(r, 0));
// Root + every expanded path under "thm" (not "kandev" — different repo).
expect(requestFileTreeMock.mock.calls.map((c) => c[2]).sort()).toEqual([
"",
"thm",
"thm/rooms",
]);
});
it("refreshes every expanded folder when repository_name is missing", async () => {
mockEmptyTree();
applyFileChanges({
client: client(),
sessionId: SESSION_ID,
expandedPaths: new Set([SRC_PATH, SRC_COMPONENTS_PATH]),
changes: [{ path: "", operation: REFRESH_OP }],
setTree: vi.fn(),
setLoadState: vi.fn(),
});
await new Promise<void>((r) => setTimeout(r, 0));
expect(requestFileTreeMock.mock.calls.map((c) => c[2]).sort()).toEqual([
"",
SRC_PATH,
SRC_COMPONENTS_PATH,
]);
});
it("preserves the targeted-path behavior for specific operations", async () => {
mockEmptyTree();
applyFileChanges({
client: client(),
sessionId: SESSION_ID,
expandedPaths: new Set(["thm", "kandev"]),
changes: [{ path: THM_NEW, operation: "create" }],
setTree: vi.fn(),
setLoadState: vi.fn(),
});
await new Promise<void>((r) => setTimeout(r, 0));
// create event: parent "thm" is expanded, path itself is not — only "thm" refreshed.
expect(requestFileTreeMock.mock.calls.map((c) => c[2]).sort()).toEqual(["thm"]);
});
it("refreshes the nearest loaded ancestor for a new nested path", async () => {
mockEmptyTree();
applyFileChanges({
client: client(),
sessionId: SESSION_ID,
expandedPaths: new Set<string>(),
changes: [{ path: "upload-bundle/nested/leaf.txt", operation: "create" }],
setTree: vi.fn(),
setLoadState: vi.fn(),
});
await new Promise<void>((r) => setTimeout(r, 0));
expect(requestFileTreeMock.mock.calls.map((c) => c[2])).toEqual([""]);
});
it("merges fresh children into the expanded subtree", async () => {
requestFileTreeMock.mockImplementation((_c: unknown, _s: string, folder: string) =>
folder === "thm"
? Promise.resolve({ root: thmChildrenAfter() })
: Promise.resolve({ root: rootChildrenAfter() }),
);
const setTree = vi.fn();
applyFileChanges({
client: client(),
sessionId: SESSION_ID,
expandedPaths: new Set(["thm"]),
changes: [{ path: "", operation: REFRESH_OP, repository_name: "thm" }],
setTree,
setLoadState: vi.fn(),
});
await new Promise<void>((r) => setTimeout(r, 0));
expect(setTree).toHaveBeenCalled();
// Apply setTree's reducer to the prev tree to inspect the merged shape.
const reducer = setTree.mock.calls[0][0] as (prev: FileTreeNode) => FileTreeNode;
const next = reducer(makeTree());
const thmNode = next.children?.find((c) => c.path === "thm");
expect(thmNode?.children?.map((c) => c.path).sort()).toEqual([THM_NEW, THM_OLD]);
});
});
it("skips invalid expanded paths during a generic refresh", async () => {
mockEmptyTree();
applyFileChanges({
client: client(),
sessionId: SESSION_ID,
expandedPaths: new Set([SRC_PATH, SRC_COMPONENTS_PATH, "/home/jcfs/project/src"]),
changes: [{ path: "", operation: REFRESH_OP }],
setTree: vi.fn(),
setLoadState: vi.fn(),
});
await new Promise<void>((r) => setTimeout(r, 0));
expect(requestFileTreeMock.mock.calls.map((c) => c[2]).sort()).toEqual([
"",
SRC_PATH,
SRC_COMPONENTS_PATH,
]);
});
it("refreshes an expanded path containing a literal colon", async () => {
mockEmptyTree();
applyFileChanges({
client: client(),
sessionId: SESSION_ID,
expandedPaths: new Set(["config:dev"]),
changes: [{ path: "", operation: REFRESH_OP }],
setTree: vi.fn(),
setLoadState: vi.fn(),
});
await new Promise<void>((r) => setTimeout(r, 0));
expect(requestFileTreeMock.mock.calls.map((c) => c[2]).sort()).toEqual(["", "config:dev"]);
});
it("ignores a specific change with an invalid path", async () => {
mockEmptyTree();
applyFileChanges({
client: client(),
sessionId: SESSION_ID,
expandedPaths: new Set([SRC_PATH]),
changes: [{ path: "/home/jcfs/project/src/app.ts", operation: "create" }],
setTree: vi.fn(),
setLoadState: vi.fn(),
});
await new Promise<void>((r) => setTimeout(r, 0));
expect(requestFileTreeMock).not.toHaveBeenCalled();
});
// Regression (#982): refresh scoped to one repo must not wipe sibling repos' loaded subtrees.
describe("applyFileChanges — cross-repo subtree preservation", () => {
it("preserves kandev's loaded children when refresh is scoped to thm", async () => {
requestFileTreeMock.mockImplementation((_c: unknown, _s: string, folder: string) => {
if (folder === "thm") {
return Promise.resolve({ root: thmChildrenAfter() });
}
return Promise.resolve({ root: rootChildrenAfter() });
});
const prevTree: FileTreeNode = {
name: "",
path: "",
is_dir: true,
size: 0,
children: [
{
name: "kandev",
path: "kandev",
is_dir: true,
size: 0,
children: [{ name: "README.md", path: "kandev/README.md", is_dir: false, size: 0 }],
},
{
name: "thm",
path: "thm",
is_dir: true,
size: 0,
children: [{ name: "old.txt", path: THM_OLD, is_dir: false, size: 0 }],
},
],
};
const setTree = vi.fn();
applyFileChanges({
client: client(),
sessionId: SESSION_ID,
expandedPaths: new Set(["thm"]),
changes: [{ path: "", operation: REFRESH_OP, repository_name: "thm" }],
setTree,
setLoadState: vi.fn(),
});
await new Promise<void>((r) => setTimeout(r, 0));
const reducer = setTree.mock.calls[0][0] as (prev: FileTreeNode) => FileTreeNode;
const next = reducer(prevTree);
const kandevNode = next.children?.find((c) => c.path === "kandev");
// kandev's existing child must survive a refresh scoped to a different repo.
expect(kandevNode?.children?.map((c) => c.path)).toEqual(["kandev/README.md"]);
const thmNode = next.children?.find((c) => c.path === "thm");
expect(thmNode?.children?.map((c) => c.path).sort()).toEqual([THM_NEW, THM_OLD]);
});
});
function thmChildrenAfter(): FileTreeNode {
return {
name: "thm",
path: "thm",
is_dir: true,
size: 0,
children: [
{ name: "old.txt", path: THM_OLD, is_dir: false, size: 0 },
{ name: "new.txt", path: THM_NEW, is_dir: false, size: 0 },
],
};
}
function rootChildrenAfter(): FileTreeNode {
// depth=1 backend responses omit `children` (omitempty) — mergeTreeNodes uses absence to preserve loaded subtrees.
return {
name: "",
path: "",
is_dir: true,
size: 0,
children: [
{ name: "thm", path: "thm", is_dir: true, size: 0 },
{ name: "kandev", path: "kandev", is_dir: true, size: 0 },
],
};
}