import { Patcher } from '../patcher';
import * as fs from 'fs/promises';
import * as path from 'path';
import { spawn } from 'child_process';
// Mock child_process spawn
jest.mock('child_process');
const mockSpawn = spawn as jest.MockedFunction<typeof spawn>;
// Mock fs promises
jest.mock('fs/promises');
const mockFs = fs as jest.Mocked<typeof fs>;
describe('Patcher', () => {
let patcher: Patcher;
let mockProjectRoot: string;
beforeEach(() => {
mockProjectRoot = '/test/project';
patcher = new Patcher(mockProjectRoot);
// Reset mocks
jest.clearAllMocks();
// Mock HOME environment variable
process.env.HOME = '/home/test';
});
afterEach(() => {
delete process.env.HOME;
});
describe('applyPatch', () => {
it('should apply a simple unified diff patch', async () => {
// Mock git commands to succeed
mockSpawn.mockImplementation(() => {
const mockProcess = {
on: jest.fn((event, callback) => {
if (event === 'close') {
setTimeout(() => callback(0), 0); // Exit code 0 = success
}
}),
stdout: {
on: jest.fn((event, callback) => {
if (event === 'data') {
setTimeout(() => callback(Buffer.from('success output')), 0);
}
})
},
stderr: {
on: jest.fn()
}
};
return mockProcess as any;
});
// Mock file system operations
mockFs.writeFile.mockResolvedValue(undefined);
const unifiedDiff = `
--- a/scripts/fighter.gd
+++ b/scripts/fighter.gd
@@ -42,7 +42,7 @@
func _ready():
health = 100
- damage = get_damage() # This function doesn't exist
+ damage = calculate_damage() # Fixed function name
setup_animations()
`;
const result = await patcher.applyPatch(unifiedDiff);
expect(result.success).toBe(true);
expect(result.branch_name).toMatch(/^ai\/fix-\d{8}-\d{6}$/);
expect(mockFs.writeFile).toHaveBeenCalledWith(
expect.stringContaining('patch.diff'),
expect.stringContaining('calculate_damage')
);
});
it('should extract patches from sentinel fences', async () => {
mockSpawn.mockImplementation(() => ({
on: jest.fn((event, callback) => {
if (event === 'close') callback(0);
}),
stdout: { on: jest.fn() },
stderr: { on: jest.fn() }
} as any));
mockFs.writeFile.mockResolvedValue(undefined);
const fencedPatch = `
Some explanatory text here...
*** begin patch
# Root cause: Function name was incorrect
*** update file: scripts/fighter.gd
@@ -42,7 +42,7 @@
func _ready():
health = 100
- damage = get_damage()
+ damage = calculate_damage()
setup_animations()
*** end patch
More text after...
`;
const result = await patcher.applyPatch(fencedPatch);
expect(result.success).toBe(true);
expect(mockFs.writeFile).toHaveBeenCalledWith(
expect.stringContaining('patch.diff'),
expect.stringMatching(/calculate_damage/)
);
});
it('should handle git command failures', async () => {
// Mock git command to fail
mockSpawn.mockImplementation(() => ({
on: jest.fn((event, callback) => {
if (event === 'close') callback(1); // Exit code 1 = failure
}),
stdout: { on: jest.fn() },
stderr: {
on: jest.fn((event, callback) => {
if (event === 'data') callback(Buffer.from('Git apply failed'));
})
}
} as any));
const result = await patcher.applyPatch('invalid diff');
expect(result.success).toBe(false);
expect(result.error).toContain('Git apply failed');
});
it('should handle stashing and unstashing dirty repos', async () => {
let gitCallCount = 0;
mockSpawn.mockImplementation((cmd, args) => {
gitCallCount++;
// First call: git status (returns dirty)
if (args?.[0] === 'status' && args?.[1] === '--porcelain') {
return {
on: jest.fn((event, callback) => {
if (event === 'close') callback(0);
}),
stdout: {
on: jest.fn((event, callback) => {
if (event === 'data') callback(Buffer.from('M some_file.gd'));
})
},
stderr: { on: jest.fn() }
} as any;
}
// Other git commands succeed
return {
on: jest.fn((event, callback) => {
if (event === 'close') callback(0);
}),
stdout: { on: jest.fn() },
stderr: { on: jest.fn() }
} as any;
});
mockFs.writeFile.mockResolvedValue(undefined);
const result = await patcher.applyPatch('--- a/test.gd\n+++ b/test.gd\n@@ -1 +1 @@\n-old\n+new');
expect(result.success).toBe(true);
// Should have called git stash and git stash pop
expect(mockSpawn).toHaveBeenCalledWith('git', ['stash', 'push', '-m', 'Sentinel auto-stash'], expect.any(Object));
expect(mockSpawn).toHaveBeenCalledWith('git', ['stash', 'pop'], expect.any(Object));
});
});
describe('getCurrentBranch', () => {
it('should return current git branch', async () => {
mockSpawn.mockImplementation(() => ({
on: jest.fn((event, callback) => {
if (event === 'close') callback(0);
}),
stdout: {
on: jest.fn((event, callback) => {
if (event === 'data') callback(Buffer.from('feature-branch\n'));
})
},
stderr: { on: jest.fn() }
} as any));
const branch = await patcher.getCurrentBranch();
expect(branch).toBe('feature-branch');
});
it('should return "main" as fallback on error', async () => {
mockSpawn.mockImplementation(() => ({
on: jest.fn((event, callback) => {
if (event === 'close') callback(1); // Failure
}),
stdout: { on: jest.fn() },
stderr: { on: jest.fn() }
} as any));
const branch = await patcher.getCurrentBranch();
expect(branch).toBe('main');
});
});
describe('getRecentDiff', () => {
it('should return git diff output', async () => {
const mockDiff = '--- a/file.gd\n+++ b/file.gd\n@@ -1 +1 @@\n-old\n+new';
mockSpawn.mockImplementation(() => ({
on: jest.fn((event, callback) => {
if (event === 'close') callback(0);
}),
stdout: {
on: jest.fn((event, callback) => {
if (event === 'data') callback(Buffer.from(mockDiff));
})
},
stderr: { on: jest.fn() }
} as any));
const diff = await patcher.getRecentDiff();
expect(diff).toBe(mockDiff);
});
});
});