import { describe, expect, it, beforeEach } from "bun:test"; import { indexedDB } from "fake-indexeddb"; import type { GitInterface } from "../sync/gitTypes"; import { LocalGitFileEditor } from "../sync/LocalGitFileEditor"; import type { ServerToClientMessage } from "../multiplayer"; // Polyfill indexedDB for tests globalThis.indexedDB = indexedDB; /** * Create a mock GitInterface for testing */ function createMockGitInterface(): GitInterface { const repos = new Map< string, { files: Map; branches: Array<{ name: string; oid: string }>; currentOid: string; } >(); let repoCounter = 0; let oidCounter = 0; const generateOid = () => `oid-${oidCounter++}`; return { async createRepo() { const id = `repo-${repoCounter++}`; repos.set(id, { files: new Map(), branches: [{ name: "main", oid: generateOid() }], currentOid: generateOid(), }); return { id }; }, async listFiles(repoId, _ref) { const repo = repos.get(repoId); if (!repo) { throw new Error(`Repository ${repoId} not found`); } return { files: Array.from(repo.files.keys()), ref: "main", oid: repo.currentOid, }; }, async listBranches(repoId) { const repo = repos.get(repoId); if (!repo) { throw new Error(`Repository ${repoId} not found`); } return { branches: repo.branches }; }, async readFile(repoId, filePath, _ref) { const repo = repos.get(repoId); if (!repo) { throw new Error(`Repository ${repoId} not found`); } const content = repo.files.get(filePath); if (content === undefined) { throw new Error(`File ${filePath} not found`); } return { content, oid: repo.currentOid }; }, async patchFiles(repoId, patch, _ref) { const repo = repos.get(repoId); if (!repo) { throw new Error(`Repository ${repoId} not found`); } // Apply creates for (const op of patch.create ?? []) { repo.files.set(op.path, op.content); } // Apply deletes for (const op of patch.delete ?? []) { repo.files.delete(op.path); } // Apply renames for (const op of patch.rename ?? []) { const content = repo.files.get(op.oldPath); if (content !== undefined) { repo.files.delete(op.oldPath); repo.files.set(op.newPath, content); } } const newOid = generateOid(); repo.currentOid = newOid; repo.branches[0].oid = newOid; return { oid: newOid, ref: "main", files: Array.from(repo.files.keys()), }; }, }; } describe("LocalGitFileEditor", () => { let mockGit: GitInterface; let sentMessages: ServerToClientMessage[]; let persistCalls: Array<{ repoId: string; ref: string; filePath: string }>; let editor: LocalGitFileEditor; beforeEach(async () => { mockGit = createMockGitInterface(); sentMessages = []; persistCalls = []; // Create a repo with a file const { id: repoId } = await mockGit.createRepo(); await mockGit.patchFiles(repoId, { create: [{ path: "test.txt", content: "Hello World" }], }); editor = new LocalGitFileEditor({ getGit: async () => mockGit, sendMessage: (message) => { sentMessages.push(message); }, onPersistNeeded: (repoId, ref, filePath) => { persistCalls.push({ repoId, ref, filePath }); }, }); }); describe("handleMessage", () => { it("should handle gitFileEdit.open and send object message", async () => { const handled = await editor.handleMessage({ type: "gitFileEdit.open", repoId: "repo-0", filePath: "test.txt", ref: "main", }); expect(handled).toBe(true); expect(sentMessages).toHaveLength(1); expect(sentMessages[0].type).toBe("gitFileEdit.object"); const objectMsg = sentMessages[0] as any; expect(objectMsg.repoId).toBe("repo-0"); expect(objectMsg.filePath).toBe("test.txt"); expect(objectMsg.ref).toBe("main"); expect(objectMsg.content).toBe("Hello World"); expect(objectMsg.hash).toBeDefined(); }); it("should handle gitFileEdit.open for non-existent file with empty content", async () => { const handled = await editor.handleMessage({ type: "gitFileEdit.open", repoId: "repo-0", filePath: "new-file.txt", ref: "main", }); expect(handled).toBe(true); expect(sentMessages).toHaveLength(1); expect(sentMessages[0].type).toBe("gitFileEdit.object"); const objectMsg = sentMessages[0] as any; expect(objectMsg.content).toBe(""); }); it("should handle gitFileEdit.patch and apply changes", async () => { // First open the file await editor.handleMessage({ type: "gitFileEdit.open", repoId: "repo-0", filePath: "test.txt", ref: "main", }); sentMessages = []; // Then apply a patch const handled = await editor.handleMessage({ type: "gitFileEdit.patch", repoId: "repo-0", filePath: "test.txt", ref: "main", id: "patch-1", patches: [ { op: "add", path: [11], value: "!", }, ], baseHash: "abc", }); expect(handled).toBe(true); expect(sentMessages).toHaveLength(1); expect(sentMessages[0].type).toBe("gitFileEdit.acceptPatch"); const acceptMsg = sentMessages[0] as any; expect(acceptMsg.id).toBe("patch-1"); expect(acceptMsg.patches).toEqual([ { op: "add", path: [11], value: "!" }, ]); // Verify content was updated const content = editor.getContent("repo-0", "test.txt", "main"); expect(content).toBe("Hello World!"); // Verify persist was called expect(persistCalls).toHaveLength(1); expect(persistCalls[0]).toEqual({ repoId: "repo-0", ref: "main", filePath: "test.txt", }); }); it("should reject patch for unopened file", async () => { const handled = await editor.handleMessage({ type: "gitFileEdit.patch", repoId: "repo-0", filePath: "test.txt", ref: "main", id: "patch-1", patches: [{ op: "add", path: [0], value: "X" }], baseHash: "abc", }); expect(handled).toBe(true); expect(sentMessages).toHaveLength(1); expect(sentMessages[0].type).toBe("gitFileEdit.rejectPatch"); const rejectMsg = sentMessages[0] as any; expect(rejectMsg.id).toBe("patch-1"); expect(rejectMsg.reason).toBe("serverStateNotInitialized"); }); it("should handle gitFileEdit.close", async () => { // Open the file first await editor.handleMessage({ type: "gitFileEdit.open", repoId: "repo-0", filePath: "test.txt", ref: "main", }); // Close the file const handled = await editor.handleMessage({ type: "gitFileEdit.close", repoId: "repo-0", filePath: "test.txt", ref: "main", }); expect(handled).toBe(true); // Verify file is no longer tracked const content = editor.getContent("repo-0", "test.txt", "main"); expect(content).toBeUndefined(); }); it("should persist dirty file on close", async () => { // Open and modify file await editor.handleMessage({ type: "gitFileEdit.open", repoId: "repo-0", filePath: "test.txt", ref: "main", }); await editor.handleMessage({ type: "gitFileEdit.patch", repoId: "repo-0", filePath: "test.txt", ref: "main", id: "patch-1", patches: [{ op: "add", path: [11], value: "!" }], baseHash: "abc", }); const messagesBeforeClose = sentMessages.length; // Close the file - should persist first await editor.handleMessage({ type: "gitFileEdit.close", repoId: "repo-0", filePath: "test.txt", ref: "main", }); // Verify gitFileEdit.committed was sent during close const newMessages = sentMessages.slice(messagesBeforeClose); expect(newMessages).toHaveLength(1); expect(newMessages[0].type).toBe("gitFileEdit.committed"); // Verify file is persisted in git const readResult = await mockGit.readFile("repo-0", "test.txt"); expect(readResult.content).toBe("Hello World!"); // Verify file is no longer tracked const content = editor.getContent("repo-0", "test.txt", "main"); expect(content).toBeUndefined(); }); it("should return false for unknown message types", async () => { const handled = await editor.handleMessage({ type: "init", object: {}, } as any); expect(handled).toBe(false); expect(sentMessages).toHaveLength(0); }); }); describe("persist", () => { it("should persist dirty file to git", async () => { // Open and modify file await editor.handleMessage({ type: "gitFileEdit.open", repoId: "repo-0", filePath: "test.txt", ref: "main", }); await editor.handleMessage({ type: "gitFileEdit.patch", repoId: "repo-0", filePath: "test.txt", ref: "main", id: "patch-1", patches: [{ op: "add", path: [11], value: "!" }], baseHash: "abc", }); sentMessages = []; // Persist const result = await editor.persist("repo-0", "test.txt", "main"); expect(result).toBeDefined(); expect(result!.oid).toBeDefined(); // Verify gitFileEdit.committed was sent expect(sentMessages).toHaveLength(1); expect(sentMessages[0].type).toBe("gitFileEdit.committed"); const commitMsg = sentMessages[0] as any; expect(commitMsg.repoId).toBe("repo-0"); expect(commitMsg.ref).toBe("main"); expect(commitMsg.oid).toBe(result!.oid); // Verify file is in git const files = await mockGit.listFiles("repo-0"); expect(files.files).toContain("test.txt"); const readResult = await mockGit.readFile("repo-0", "test.txt"); expect(readResult.content).toBe("Hello World!"); }); it("should not persist clean file", async () => { // Open file without modifying await editor.handleMessage({ type: "gitFileEdit.open", repoId: "repo-0", filePath: "test.txt", ref: "main", }); sentMessages = []; // Try to persist const result = await editor.persist("repo-0", "test.txt", "main"); expect(result).toBeUndefined(); expect(sentMessages).toHaveLength(0); }); }); describe("isDirty", () => { it("should return false for clean file", async () => { await editor.handleMessage({ type: "gitFileEdit.open", repoId: "repo-0", filePath: "test.txt", ref: "main", }); expect(editor.isDirty("repo-0", "test.txt", "main")).toBe(false); }); it("should return true after patch", async () => { await editor.handleMessage({ type: "gitFileEdit.open", repoId: "repo-0", filePath: "test.txt", ref: "main", }); await editor.handleMessage({ type: "gitFileEdit.patch", repoId: "repo-0", filePath: "test.txt", ref: "main", id: "patch-1", patches: [{ op: "add", path: [0], value: "X" }], baseHash: "abc", }); expect(editor.isDirty("repo-0", "test.txt", "main")).toBe(true); }); it("should return false after persist", async () => { await editor.handleMessage({ type: "gitFileEdit.open", repoId: "repo-0", filePath: "test.txt", ref: "main", }); await editor.handleMessage({ type: "gitFileEdit.patch", repoId: "repo-0", filePath: "test.txt", ref: "main", id: "patch-1", patches: [{ op: "add", path: [0], value: "X" }], baseHash: "abc", }); await editor.persist("repo-0", "test.txt", "main"); expect(editor.isDirty("repo-0", "test.txt", "main")).toBe(false); }); }); describe("getDirtyFiles", () => { it("should return empty array when no dirty files", async () => { await editor.handleMessage({ type: "gitFileEdit.open", repoId: "repo-0", filePath: "test.txt", ref: "main", }); expect(editor.getDirtyFiles()).toEqual([]); }); it("should return dirty files", async () => { await editor.handleMessage({ type: "gitFileEdit.open", repoId: "repo-0", filePath: "test.txt", ref: "main", }); await editor.handleMessage({ type: "gitFileEdit.patch", repoId: "repo-0", filePath: "test.txt", ref: "main", id: "patch-1", patches: [{ op: "add", path: [0], value: "X" }], baseHash: "abc", }); const dirty = editor.getDirtyFiles(); expect(dirty).toHaveLength(1); expect(dirty[0]).toEqual({ repoId: "repo-0", ref: "main", filePath: "test.txt", }); }); }); });