Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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.

204 changes: 204 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,200 @@ describe('Symphony Runner Service', () => {

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

it('adds --repo flag 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', '--delete-branch', '--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 HEAD
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mock comment inaccuracy

The comment says git rev-parse HEAD but the actual call is git rev-parse --abbrev-ref HEAD (which gets the branch name, not the commit SHA). Since the mocks are positional, an inaccurate comment makes it harder to maintain this test.

Suggested change
.mockResolvedValueOnce({ stdout: 'symphony/test-branch', stderr: '', exitCode: 0 }) // git rev-parse HEAD
.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 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'
);
});
});
});
});
166 changes: 166 additions & 0 deletions src/__tests__/main/utils/symphony-fork.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { ExecResult } from '../../../main/utils/execFile';

const mockExecFileNoThrow = vi.fn<(...args: unknown[]) => Promise<ExecResult>>();

vi.mock('../../../main/utils/execFile', () => ({
execFileNoThrow: (...args: unknown[]) => mockExecFileNoThrow(...args),
}));

vi.mock('../../../main/utils/logger', () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
}));

vi.mock('../../../main/agents/path-prober', () => ({
getExpandedEnv: () => ({ PATH: '/usr/bin' }),
}));

import { ensureForkSetup } from '../../../main/utils/symphony-fork';

function ok(stdout: string): ExecResult {
return { stdout, stderr: '', exitCode: 0 };
}

function fail(stderr: string, exitCode: number | string = 1): ExecResult {
return { stdout: '', stderr, exitCode };
}

describe('ensureForkSetup', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('returns error when gh is not authenticated', async () => {
mockExecFileNoThrow.mockResolvedValueOnce(fail('not logged in'));

const result = await ensureForkSetup('/tmp/repo', 'owner/repo');

expect(result).toEqual({ isFork: false, error: 'GitHub CLI not authenticated' });
});

it('returns isFork: false when user owns the repo', async () => {
mockExecFileNoThrow.mockResolvedValueOnce(ok('chris\n'));

const result = await ensureForkSetup('/tmp/repo', 'chris/repo');

expect(result).toEqual({ isFork: false });
expect(mockExecFileNoThrow).toHaveBeenCalledTimes(1);
});

it('returns isFork: false when user has push access', async () => {
mockExecFileNoThrow
.mockResolvedValueOnce(ok('chris\n')) // gh api user
.mockResolvedValueOnce(ok('true\n')); // permissions.push

const result = await ensureForkSetup('/tmp/repo', 'owner/repo');

expect(result).toEqual({ isFork: false });
});

it('forks and reconfigures remotes when no push access', async () => {
mockExecFileNoThrow
.mockResolvedValueOnce(ok('chris\n')) // gh api user
.mockResolvedValueOnce(ok('false\n')) // permissions.push
.mockResolvedValueOnce(ok('')) // gh repo fork
.mockResolvedValueOnce(ok('https://github.com/chris/repo.git\n')) // clone url
.mockResolvedValueOnce(ok('')) // git remote rename
.mockResolvedValueOnce(ok('')); // git remote add

const result = await ensureForkSetup('/tmp/repo', 'owner/repo');

expect(result).toEqual({ isFork: true, forkSlug: 'chris/repo' });

// Verify fork command
expect(mockExecFileNoThrow).toHaveBeenCalledWith(
'gh', ['repo', 'fork', 'owner/repo', '--clone=false'], undefined, expect.any(Object)
);

// Verify remote rename
expect(mockExecFileNoThrow).toHaveBeenCalledWith(
'git', ['remote', 'rename', 'origin', 'upstream'], '/tmp/repo'
);

// Verify remote add
expect(mockExecFileNoThrow).toHaveBeenCalledWith(
'git', ['remote', 'add', 'origin', 'https://github.com/chris/repo.git'], '/tmp/repo'
);
});

it('handles fork already existing', async () => {
mockExecFileNoThrow
.mockResolvedValueOnce(ok('chris\n')) // gh api user
.mockResolvedValueOnce(ok('false\n')) // permissions.push
.mockResolvedValueOnce(fail('repo already exists', 1)) // gh repo fork - already exists
.mockResolvedValueOnce(ok('https://github.com/chris/repo.git\n')) // clone url
.mockResolvedValueOnce(ok('')) // git remote rename
.mockResolvedValueOnce(ok('')); // git remote add

const result = await ensureForkSetup('/tmp/repo', 'owner/repo');

expect(result).toEqual({ isFork: true, forkSlug: 'chris/repo' });
});

it('returns error when fork fails for non-existing reason', async () => {
mockExecFileNoThrow
.mockResolvedValueOnce(ok('chris\n')) // gh api user
.mockResolvedValueOnce(ok('false\n')) // permissions.push
.mockResolvedValueOnce(fail('permission denied', 1)); // gh repo fork

const result = await ensureForkSetup('/tmp/repo', 'owner/repo');

expect(result.isFork).toBe(false);
expect(result.error).toContain('permission denied');
});

it('falls back to set-url when remote rename fails', async () => {
mockExecFileNoThrow
.mockResolvedValueOnce(ok('chris\n')) // gh api user
.mockResolvedValueOnce(ok('false\n')) // permissions.push
.mockResolvedValueOnce(ok('')) // gh repo fork
.mockResolvedValueOnce(ok('https://github.com/chris/repo.git\n')) // clone url
.mockResolvedValueOnce(fail('upstream already exists')) // remote rename fails
.mockResolvedValueOnce(ok('')); // git remote set-url (fallback)

const result = await ensureForkSetup('/tmp/repo', 'owner/repo');

expect(result).toEqual({ isFork: true, forkSlug: 'chris/repo' });

// Verify fallback to set-url
expect(mockExecFileNoThrow).toHaveBeenCalledWith(
'git', ['remote', 'set-url', 'origin', 'https://github.com/chris/repo.git'], '/tmp/repo'
);
});

it('returns error when both remote rename and set-url fail', async () => {
mockExecFileNoThrow
.mockResolvedValueOnce(ok('chris\n')) // gh api user
.mockResolvedValueOnce(ok('false\n')) // permissions.push
.mockResolvedValueOnce(ok('')) // gh repo fork
.mockResolvedValueOnce(ok('https://github.com/chris/repo.git\n')) // clone url
.mockResolvedValueOnce(fail('rename error')) // remote rename fails
.mockResolvedValueOnce(fail('set-url error')); // set-url also fails

const result = await ensureForkSetup('/tmp/repo', 'owner/repo');

expect(result.isFork).toBe(false);
expect(result.error).toContain('set-url error');
});

it('returns error when getting fork clone URL fails', async () => {
mockExecFileNoThrow
.mockResolvedValueOnce(ok('chris\n')) // gh api user
.mockResolvedValueOnce(ok('false\n')) // permissions.push
.mockResolvedValueOnce(ok('')) // gh repo fork
.mockResolvedValueOnce(fail('not found')); // clone url fails

const result = await ensureForkSetup('/tmp/repo', 'owner/repo');

expect(result.isFork).toBe(false);
expect(result.error).toContain('Failed to get fork clone URL');
});
});
Loading
Loading