Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
Addresses all 10 quality remediation issues for the orchestrator module: TypeScript & Type Safety: - #260: Fix TypeScript compilation errors in tests - #261: Replace explicit 'any' types with proper typed mocks Error Handling & Reliability: - #262: Fix silent cleanup failures - return structured results - #263: Fix silent Valkey event parsing failures with proper error handling - #266: Improve error context in Docker operations - #267: Fix secret scanner false negatives on file read errors - #268: Fix worktree cleanup error swallowing Testing & Quality: - #264: Add queue integration tests (coverage 15% → 85%) - #265: Fix Prettier formatting violations - #269: Update outdated TODO comments All tests passing (406/406), TypeScript compiles cleanly, ESLint clean. Fixes #260, Fixes #261, Fixes #262, Fixes #263, Fixes #264 Fixes #265, Fixes #266, Fixes #267, Fixes #268, Fixes #269 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
409 lines
12 KiB
TypeScript
409 lines
12 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { ConflictDetectionService } from "./conflict-detection.service";
|
|
import { ConflictDetectionError } from "./types";
|
|
|
|
// Mock simple-git
|
|
const mockGit = {
|
|
fetch: vi.fn(),
|
|
status: vi.fn(),
|
|
raw: vi.fn(),
|
|
revparse: vi.fn(),
|
|
};
|
|
|
|
vi.mock("simple-git", () => ({
|
|
simpleGit: vi.fn(() => mockGit),
|
|
}));
|
|
|
|
describe("ConflictDetectionService", () => {
|
|
let service: ConflictDetectionService;
|
|
|
|
beforeEach(() => {
|
|
// Reset all mocks
|
|
vi.clearAllMocks();
|
|
|
|
// Create service
|
|
service = new ConflictDetectionService();
|
|
});
|
|
|
|
it("should be defined", () => {
|
|
expect(service).toBeDefined();
|
|
});
|
|
|
|
describe("checkForConflicts", () => {
|
|
it("should return no conflicts when branches can merge cleanly", async () => {
|
|
// Mock successful fetch
|
|
mockGit.fetch.mockResolvedValue(undefined);
|
|
|
|
// Mock current branch
|
|
mockGit.revparse.mockResolvedValue("feature-branch");
|
|
|
|
// Mock merge test - no conflicts
|
|
mockGit.raw.mockResolvedValue("");
|
|
|
|
// Mock status - no conflicted files
|
|
mockGit.status.mockResolvedValue({
|
|
conflicted: [],
|
|
files: [],
|
|
});
|
|
|
|
const result = await service.checkForConflicts("/test/repo", {
|
|
localPath: "/test/repo",
|
|
remote: "origin",
|
|
remoteBranch: "develop",
|
|
strategy: "merge",
|
|
});
|
|
|
|
expect(result.hasConflicts).toBe(false);
|
|
expect(result.conflicts).toHaveLength(0);
|
|
expect(result.strategy).toBe("merge");
|
|
expect(result.remoteBranch).toBe("develop");
|
|
expect(mockGit.fetch).toHaveBeenCalledWith("origin", "develop");
|
|
});
|
|
|
|
it("should detect merge conflicts", async () => {
|
|
// Mock successful fetch
|
|
mockGit.fetch.mockResolvedValue(undefined);
|
|
|
|
// Mock current branch
|
|
mockGit.revparse.mockResolvedValue("feature-branch");
|
|
|
|
// Mock merge test - conflicts detected
|
|
mockGit.raw.mockRejectedValueOnce(new Error("CONFLICT (content): Merge conflict in file.ts"));
|
|
|
|
// Mock status - show conflicted files
|
|
mockGit.status.mockResolvedValue({
|
|
conflicted: ["src/file.ts", "src/other.ts"],
|
|
files: [
|
|
{
|
|
path: "src/file.ts",
|
|
index: "U",
|
|
working_dir: "U",
|
|
},
|
|
{
|
|
path: "src/other.ts",
|
|
index: "U",
|
|
working_dir: "U",
|
|
},
|
|
],
|
|
});
|
|
|
|
// Mock merge abort (cleanup)
|
|
mockGit.raw.mockResolvedValue("");
|
|
|
|
const result = await service.checkForConflicts("/test/repo", {
|
|
localPath: "/test/repo",
|
|
remote: "origin",
|
|
remoteBranch: "develop",
|
|
strategy: "merge",
|
|
});
|
|
|
|
expect(result.hasConflicts).toBe(true);
|
|
expect(result.conflicts).toHaveLength(2);
|
|
expect(result.conflicts[0].file).toBe("src/file.ts");
|
|
expect(result.conflicts[0].type).toBe("content");
|
|
expect(result.canRetry).toBe(true);
|
|
});
|
|
|
|
it("should detect rebase conflicts", async () => {
|
|
// Mock successful fetch
|
|
mockGit.fetch.mockResolvedValue(undefined);
|
|
|
|
// Mock current branch
|
|
mockGit.revparse.mockResolvedValue("feature-branch");
|
|
|
|
// Mock rebase test - conflicts detected
|
|
mockGit.raw.mockRejectedValueOnce(
|
|
new Error("CONFLICT (content): Rebase conflict in file.ts")
|
|
);
|
|
|
|
// Mock status - show conflicted files
|
|
mockGit.status.mockResolvedValue({
|
|
conflicted: ["src/file.ts"],
|
|
files: [
|
|
{
|
|
path: "src/file.ts",
|
|
index: "U",
|
|
working_dir: "U",
|
|
},
|
|
],
|
|
});
|
|
|
|
// Mock rebase abort (cleanup)
|
|
mockGit.raw.mockResolvedValue("");
|
|
|
|
const result = await service.checkForConflicts("/test/repo", {
|
|
localPath: "/test/repo",
|
|
remote: "origin",
|
|
remoteBranch: "develop",
|
|
strategy: "rebase",
|
|
});
|
|
|
|
expect(result.hasConflicts).toBe(true);
|
|
expect(result.conflicts).toHaveLength(1);
|
|
expect(result.strategy).toBe("rebase");
|
|
});
|
|
|
|
it("should handle fetch failure", async () => {
|
|
// Mock fetch failure
|
|
mockGit.fetch.mockRejectedValue(new Error("Network error"));
|
|
|
|
await expect(
|
|
service.checkForConflicts("/test/repo", {
|
|
localPath: "/test/repo",
|
|
remote: "origin",
|
|
remoteBranch: "develop",
|
|
})
|
|
).rejects.toThrow(ConflictDetectionError);
|
|
});
|
|
|
|
it("should detect delete conflicts", async () => {
|
|
// Mock successful fetch
|
|
mockGit.fetch.mockResolvedValue(undefined);
|
|
|
|
// Mock current branch
|
|
mockGit.revparse.mockResolvedValue("feature-branch");
|
|
|
|
// Mock merge test - conflicts detected
|
|
mockGit.raw.mockRejectedValueOnce(
|
|
new Error("CONFLICT (delete/modify): file.ts deleted in HEAD")
|
|
);
|
|
|
|
// Mock status - show conflicted files with delete
|
|
mockGit.status.mockResolvedValue({
|
|
conflicted: ["src/file.ts"],
|
|
files: [
|
|
{
|
|
path: "src/file.ts",
|
|
index: "D",
|
|
working_dir: "U",
|
|
},
|
|
],
|
|
});
|
|
|
|
// Mock merge abort
|
|
mockGit.raw.mockResolvedValue("");
|
|
|
|
const result = await service.checkForConflicts("/test/repo", {
|
|
localPath: "/test/repo",
|
|
remote: "origin",
|
|
remoteBranch: "develop",
|
|
strategy: "merge",
|
|
});
|
|
|
|
expect(result.hasConflicts).toBe(true);
|
|
expect(result.conflicts[0].type).toBe("delete");
|
|
});
|
|
|
|
it("should detect add conflicts", async () => {
|
|
// Mock successful fetch
|
|
mockGit.fetch.mockResolvedValue(undefined);
|
|
|
|
// Mock current branch
|
|
mockGit.revparse.mockResolvedValue("feature-branch");
|
|
|
|
// Mock merge test - conflicts detected
|
|
mockGit.raw.mockRejectedValueOnce(new Error("CONFLICT (add/add): Merge conflict in file.ts"));
|
|
|
|
// Mock status - show conflicted files with add
|
|
mockGit.status.mockResolvedValue({
|
|
conflicted: ["src/file.ts"],
|
|
files: [
|
|
{
|
|
path: "src/file.ts",
|
|
index: "A",
|
|
working_dir: "A",
|
|
},
|
|
],
|
|
});
|
|
|
|
// Mock merge abort
|
|
mockGit.raw.mockResolvedValue("");
|
|
|
|
const result = await service.checkForConflicts("/test/repo", {
|
|
localPath: "/test/repo",
|
|
remote: "origin",
|
|
remoteBranch: "develop",
|
|
strategy: "merge",
|
|
});
|
|
|
|
expect(result.hasConflicts).toBe(true);
|
|
expect(result.conflicts[0].type).toBe("add");
|
|
});
|
|
|
|
it("should use default values for remote and branch", async () => {
|
|
// Mock successful fetch
|
|
mockGit.fetch.mockResolvedValue(undefined);
|
|
|
|
// Mock current branch
|
|
mockGit.revparse.mockResolvedValue("feature-branch");
|
|
|
|
// Mock merge test - no conflicts
|
|
mockGit.raw.mockResolvedValue("");
|
|
|
|
// Mock status - no conflicted files
|
|
mockGit.status.mockResolvedValue({
|
|
conflicted: [],
|
|
files: [],
|
|
});
|
|
|
|
const result = await service.checkForConflicts("/test/repo");
|
|
|
|
expect(result.remoteBranch).toBe("develop");
|
|
expect(mockGit.fetch).toHaveBeenCalledWith("origin", "develop");
|
|
});
|
|
|
|
it("should clean up after conflict detection", async () => {
|
|
// Mock successful fetch
|
|
mockGit.fetch.mockResolvedValue(undefined);
|
|
|
|
// Mock current branch
|
|
mockGit.revparse.mockResolvedValue("feature-branch");
|
|
|
|
// Mock merge test - conflicts
|
|
mockGit.raw.mockRejectedValueOnce(new Error("CONFLICT"));
|
|
|
|
// Mock status
|
|
mockGit.status.mockResolvedValue({
|
|
conflicted: ["src/file.ts"],
|
|
files: [],
|
|
});
|
|
|
|
// Track raw calls
|
|
const rawCalls: string[][] = [];
|
|
mockGit.raw.mockImplementation((args: string[]) => {
|
|
rawCalls.push(args);
|
|
if (args[0] === "merge") {
|
|
if (args[1] === "--abort") {
|
|
return Promise.resolve("");
|
|
}
|
|
return Promise.reject(new Error("CONFLICT"));
|
|
}
|
|
return Promise.resolve("");
|
|
});
|
|
|
|
await service.checkForConflicts("/test/repo", {
|
|
localPath: "/test/repo",
|
|
strategy: "merge",
|
|
});
|
|
|
|
// Verify abort was called
|
|
expect(rawCalls).toContainEqual(["merge", "--abort"]);
|
|
});
|
|
});
|
|
|
|
describe("fetchRemote", () => {
|
|
it("should fetch from remote successfully", async () => {
|
|
mockGit.fetch.mockResolvedValue(undefined);
|
|
|
|
await service.fetchRemote("/test/repo", "origin", "develop");
|
|
|
|
expect(mockGit.fetch).toHaveBeenCalledWith("origin", "develop");
|
|
});
|
|
|
|
it("should throw ConflictDetectionError on fetch failure", async () => {
|
|
mockGit.fetch.mockRejectedValue(new Error("Network error"));
|
|
|
|
await expect(service.fetchRemote("/test/repo", "origin", "develop")).rejects.toThrow(
|
|
ConflictDetectionError
|
|
);
|
|
});
|
|
|
|
it("should use default remote", async () => {
|
|
mockGit.fetch.mockResolvedValue(undefined);
|
|
|
|
await service.fetchRemote("/test/repo");
|
|
|
|
expect(mockGit.fetch).toHaveBeenCalledWith("origin");
|
|
});
|
|
});
|
|
|
|
describe("detectConflicts", () => {
|
|
it("should return empty array when no conflicts", async () => {
|
|
mockGit.status.mockResolvedValue({
|
|
conflicted: [],
|
|
files: [],
|
|
});
|
|
|
|
const conflicts = await service.detectConflicts("/test/repo");
|
|
|
|
expect(conflicts).toHaveLength(0);
|
|
});
|
|
|
|
it("should detect conflicted files", async () => {
|
|
mockGit.status.mockResolvedValue({
|
|
conflicted: ["src/file1.ts", "src/file2.ts"],
|
|
files: [
|
|
{
|
|
path: "src/file1.ts",
|
|
index: "U",
|
|
working_dir: "U",
|
|
},
|
|
{
|
|
path: "src/file2.ts",
|
|
index: "U",
|
|
working_dir: "U",
|
|
},
|
|
],
|
|
});
|
|
|
|
const conflicts = await service.detectConflicts("/test/repo");
|
|
|
|
expect(conflicts).toHaveLength(2);
|
|
expect(conflicts[0].file).toBe("src/file1.ts");
|
|
expect(conflicts[1].file).toBe("src/file2.ts");
|
|
});
|
|
|
|
it("should determine conflict type from git status", async () => {
|
|
mockGit.status.mockResolvedValue({
|
|
conflicted: ["deleted.ts", "added.ts", "modified.ts"],
|
|
files: [
|
|
{
|
|
path: "deleted.ts",
|
|
index: "D",
|
|
working_dir: "U",
|
|
},
|
|
{
|
|
path: "added.ts",
|
|
index: "A",
|
|
working_dir: "A",
|
|
},
|
|
{
|
|
path: "modified.ts",
|
|
index: "U",
|
|
working_dir: "U",
|
|
},
|
|
],
|
|
});
|
|
|
|
const conflicts = await service.detectConflicts("/test/repo");
|
|
|
|
expect(conflicts[0].type).toBe("delete");
|
|
expect(conflicts[1].type).toBe("add");
|
|
expect(conflicts[2].type).toBe("content");
|
|
});
|
|
|
|
it("should throw ConflictDetectionError on git status failure", async () => {
|
|
mockGit.status.mockRejectedValue(new Error("Git error"));
|
|
|
|
await expect(service.detectConflicts("/test/repo")).rejects.toThrow(ConflictDetectionError);
|
|
});
|
|
});
|
|
|
|
describe("getCurrentBranch", () => {
|
|
it("should return current branch name", async () => {
|
|
mockGit.revparse.mockResolvedValue("feature-branch");
|
|
|
|
const branch = await service.getCurrentBranch("/test/repo");
|
|
|
|
expect(branch).toBe("feature-branch");
|
|
expect(mockGit.revparse).toHaveBeenCalledWith(["--abbrev-ref", "HEAD"]);
|
|
});
|
|
|
|
it("should throw ConflictDetectionError on failure", async () => {
|
|
mockGit.revparse.mockRejectedValue(new Error("Not a git repository"));
|
|
|
|
await expect(service.getCurrentBranch("/test/repo")).rejects.toThrow(ConflictDetectionError);
|
|
});
|
|
});
|
|
});
|