Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
347 changes: 347 additions & 0 deletions src/__tests__/main/ipc/handlers/symphony.test.ts

Large diffs are not rendered by default.

194 changes: 194 additions & 0 deletions src/__tests__/main/services/symphony-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ vi.mock('../../../main/utils/logger', () => ({
},
}));

// Mock symphony-fork
vi.mock('../../../main/utils/symphony-fork', () => ({
ensureForkSetup: vi.fn(),
}));

// Mock global fetch
const mockFetch = vi.fn();
global.fetch = mockFetch;
Expand All @@ -41,6 +46,7 @@ global.fetch = mockFetch;
import fs from 'fs/promises';
import { execFileNoThrow } from '../../../main/utils/execFile';
import { logger } from '../../../main/utils/logger';
import { ensureForkSetup } from '../../../main/utils/symphony-fork';
import {
startContribution,
finalizeContribution,
Expand Down Expand Up @@ -81,6 +87,9 @@ describe('Symphony Runner Service', () => {
vi.mocked(fs.rm).mockResolvedValue(undefined);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
vi.mocked(fs.copyFile).mockResolvedValue(undefined);

// Default: no fork needed
vi.mocked(ensureForkSetup).mockResolvedValue({ isFork: false });
});

afterEach(() => {
Expand Down Expand Up @@ -1334,5 +1343,190 @@ describe('Symphony Runner Service', () => {

expect(result.success).toBe(true);
});

it('adds --repo flag without --delete-branch for fork contributions', async () => {
vi.mocked(execFileNoThrow).mockResolvedValueOnce({
stdout: '',
stderr: '',
exitCode: 0,
});

await cancelContribution('/tmp/test-repo', 42, true, 'upstream-owner/repo');

expect(execFileNoThrow).toHaveBeenCalledWith(
'gh',
['pr', 'close', '42', '--repo', 'upstream-owner/repo'],
'/tmp/test-repo'
);
});
});

// ============================================================================
// Fork Support Tests
// ============================================================================

describe('fork support', () => {
const defaultOptions = {
contributionId: 'test-id',
repoSlug: 'upstream-owner/repo',
repoUrl: 'https://github.com/upstream-owner/repo.git',
issueNumber: 42,
issueTitle: 'Test Fork Issue',
documentPaths: [],
localPath: '/tmp/test-repo',
branchName: 'symphony/test-branch',
};

describe('startContribution with fork', () => {
it('calls ensureForkSetup after clone and branch creation', async () => {
mockSuccessfulWorkflow();

await startContribution(defaultOptions);

expect(ensureForkSetup).toHaveBeenCalledWith('/tmp/test-repo', 'upstream-owner/repo');
});

it('returns fork info when ensureForkSetup detects a fork', async () => {
vi.mocked(ensureForkSetup).mockResolvedValue({
isFork: true,
forkSlug: 'myuser/repo',
});
vi.mocked(execFileNoThrow)
.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 }) // clone
.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 }) // checkout -b
.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 }) // config user.name
.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 }) // config user.email
.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 }) // commit --allow-empty
.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 }) // push
.mockResolvedValueOnce({ stdout: 'symphony/test-branch', stderr: '', exitCode: 0 }) // git rev-parse --abbrev-ref HEAD
.mockResolvedValueOnce({
stdout: 'https://github.com/upstream-owner/repo/pull/5',
stderr: '',
exitCode: 0,
}); // pr create

const result = await startContribution(defaultOptions);

expect(result.success).toBe(true);
expect(result.isFork).toBe(true);
expect(result.forkSlug).toBe('myuser/repo');
});

it('passes --repo and --head to gh pr create for fork contributions', async () => {
vi.mocked(ensureForkSetup).mockResolvedValue({
isFork: true,
forkSlug: 'myuser/repo',
});
vi.mocked(execFileNoThrow)
.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 }) // clone
.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 }) // checkout -b
.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 }) // config user.name
.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 }) // config user.email
.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 }) // commit --allow-empty
.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 }) // push
.mockResolvedValueOnce({ stdout: 'symphony/test-branch', stderr: '', exitCode: 0 }) // git rev-parse --abbrev-ref HEAD
.mockResolvedValueOnce({
stdout: 'https://github.com/upstream-owner/repo/pull/5',
stderr: '',
exitCode: 0,
}); // pr create

await startContribution(defaultOptions);

const prCreateCall = vi
.mocked(execFileNoThrow)
.mock.calls.find((call) => call[0] === 'gh' && call[1]?.includes('create'));
expect(prCreateCall).toBeDefined();
expect(prCreateCall![1]).toContain('--repo');
expect(prCreateCall![1]).toContain('upstream-owner/repo');
expect(prCreateCall![1]).toContain('--head');
expect(prCreateCall![1]).toContain('myuser:symphony/test-branch');
});

it('cleans up and returns error when ensureForkSetup fails', async () => {
vi.mocked(ensureForkSetup).mockResolvedValue({
isFork: false,
error: 'GitHub CLI not authenticated',
});
vi.mocked(execFileNoThrow)
.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 }) // clone
.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 }); // checkout -b

const result = await startContribution(defaultOptions);

expect(result.success).toBe(false);
expect(result.error).toContain('Fork setup failed');
expect(fs.rm).toHaveBeenCalledWith('/tmp/test-repo', { recursive: true, force: true });
});

it('does not pass --repo/--head for non-fork contributions', async () => {
vi.mocked(ensureForkSetup).mockResolvedValue({ isFork: false });
mockSuccessfulWorkflow();

await startContribution(defaultOptions);

const prCreateCall = vi
.mocked(execFileNoThrow)
.mock.calls.find((call) => call[0] === 'gh' && call[1]?.includes('create'));
expect(prCreateCall).toBeDefined();
expect(prCreateCall![1]).not.toContain('--repo');
expect(prCreateCall![1]).not.toContain('--head');
});
});

describe('finalizeContribution with fork', () => {
it('adds --repo flag to gh pr ready, edit, and view for fork contributions', async () => {
mockFinalizeWorkflow('https://github.com/upstream-owner/repo/pull/5');

await finalizeContribution('/tmp/test-repo', 5, 42, 'Test Issue', 'upstream-owner/repo');

const readyCall = vi
.mocked(execFileNoThrow)
.mock.calls.find((call) => call[0] === 'gh' && call[1]?.includes('ready'));
expect(readyCall![1]).toContain('--repo');
expect(readyCall![1]).toContain('upstream-owner/repo');

const editCall = vi
.mocked(execFileNoThrow)
.mock.calls.find((call) => call[0] === 'gh' && call[1]?.includes('edit'));
expect(editCall![1]).toContain('--repo');
expect(editCall![1]).toContain('upstream-owner/repo');

const viewCall = vi
.mocked(execFileNoThrow)
.mock.calls.find((call) => call[0] === 'gh' && call[1]?.includes('view'));
expect(viewCall![1]).toContain('--repo');
expect(viewCall![1]).toContain('upstream-owner/repo');
});

it('does not add --repo flag when upstreamSlug is not provided', async () => {
mockFinalizeWorkflow();

await finalizeContribution('/tmp/test-repo', 1, 123, 'Test Issue');

const readyCall = vi
.mocked(execFileNoThrow)
.mock.calls.find((call) => call[0] === 'gh' && call[1]?.includes('ready'));
expect(readyCall![1]).not.toContain('--repo');
});
});

describe('cancelContribution with fork', () => {
it('does not add --repo flag when upstreamSlug is not provided', async () => {
vi.mocked(execFileNoThrow).mockResolvedValueOnce({
stdout: '',
stderr: '',
exitCode: 0,
});

await cancelContribution('/tmp/test-repo', 42, true);

expect(execFileNoThrow).toHaveBeenCalledWith(
'gh',
['pr', 'close', '42', '--delete-branch'],
'/tmp/test-repo'
);
});
});
});
});
Loading