Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { useState } from 'react';
import { BranchWorktreeChip } from '../../../src/renderer/components/new-chat/WorktreeChipsRow';
import { mountPage, type UiPageContext } from '../../ui-page/harness';
import type { BranchSyncRequest, BranchSyncStatus } from '../../../src/shared/worktreeBranchSync';
const branches = ['main', 'feature/search', 'origin/main'];
function Fixture({ ctx }: { ctx: UiPageContext }) {
const [branch, setBranch] = useState('main');
const [checked, setChecked] = useState(ctx.case !== 'unchecked');
return (
<main className="p-6 pt-16">
<BranchWorktreeChip
baseRepo="/projects/cindy"
branchLabel={branch}
deviceId={ctx.case === 'old-host' ? 'old-pc' : undefined}
remoteDefaultBranch={ctx.case === 'old-host' ? undefined : 'refs/remotes/origin/main'}
branches={branches}
branchesLoading={false}
branchesFailed={false}
onRetryBranches={() => {}}
checked={checked}
branchSourceSelected
branchInteractive
onPick={setBranch}
onOpenRequested={() => {}}
onToggle={setChecked}
compact={ctx.case === 'worker'}
branchOnly={ctx.case === 'worker'}
/>
</main>
);
}
mountPage({
host: (ctx) => {
const read = (req: BranchSyncRequest): BranchSyncStatus => ({
branch: req.branch,
remote: 'origin',
remoteBranch: 'main',
remoteOnly: req.branch.startsWith('refs/'),
ahead: ['diverged', 'unchecked'].includes(ctx.case) ? 1 : 0,
behind: ctx.case === 'unchecked' ? 0 : ctx.case === 'default' ? 3 : 1,
dirtyFiles: ctx.case === 'diverged' ? 2 : ctx.case === 'default' ? 3 : 1,
checkoutPath: '/projects/cindy',
...(ctx.case === 'diverged' || ctx.case === 'conflict' ? { blocked: ctx.case } : {}),
});
return {
deviceLink: {
invoke: async () => {
throw new Error('[DEVICE_LINK_CHANNEL_NOT_ALLOWED] Old host');
},
},
worktreeBranchSyncStatus: async (req: BranchSyncRequest) => read(req),
worktreeSyncBranch: async (req: BranchSyncRequest) => ({
status: { ...read(req), behind: 0 },
updated: ctx.case === 'unchecked' ? 0 : ctx.case === 'worker' ? 1 : 3,
}),
};
},
render: (ctx) => <Fixture ctx={ctx} />,
});
107 changes: 107 additions & 0 deletions apps/desktop/scripts/fixtures/branch-sync/ui-page.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import assert from 'node:assert/strict';
export default {
name: 'branch-sync',
entry: './branch-sync-fixture.tsx',
viewport: { width: 560, height: 400 },
real: 'BranchWorktreeChip, sync hook, grouped combobox, tooltip, themes and locale strings',
notCovered:
'Git processes, device-link transport and native task creation; covered by unit and Git integration tests',
cases: {
default: {
about: 'Main-task capsule, selected-branch details, remote group and successful fetch',
},
worker: { about: 'Compact Worker branch-only capsule', viewport: { width: 360, height: 400 } },
unchecked: { about: 'Unchecked main-task worktree excludes remote sources' },
conflict: { about: 'Disabled sync remains focusable and explains overlapping changes' },
diverged: { about: 'Diverged branches keep local source and explain why sync is unavailable' },
'old-host': { about: 'Old controlled host has neither refresh button nor remote group' },
},
async check(page, ui) {
const trigger = page.getByRole('button', {
name: await ui.t('newChat.branchChip.label'),
exact: true,
});
await trigger.waitFor();
await ui.capture('capsule');
if (ui.case === 'default' || ui.case === 'worker') {
const action = page.getByRole('button', {
name: await ui.t('newChat.branchSync.action', {
remote: 'origin',
remoteBranch: 'main',
}),
exact: false,
});
await action.hover();
await page.getByRole('tooltip').waitFor();
await ui.capture('action-hint');
}
if (ui.case === 'conflict' || ui.case === 'diverged') {
const action = page.getByRole('button', {
name: await ui.t(`newChat.branchSync.${ui.case}`, {
branch: 'main',
remote: 'origin',
remoteBranch: 'main',
}),
exact: true,
});
assert.equal(await action.isDisabled(), true);
await action.locator('..').hover();
await page.getByRole('tooltip').waitFor();
await ui.capture('disabled-reason');
}
await trigger.click();
await page.getByRole('combobox').waitFor();
const remote = page.getByRole('group', {
name: await ui.t('newChat.branchSync.remoteGroup'),
exact: true,
});
assert.equal(await remote.count(), ['unchecked', 'old-host'].includes(ui.case) ? 0 : 1);
await ui.capture('branches');
if (ui.case === 'unchecked') {
await page.keyboard.press('Escape');
await page
.getByRole('button', {
name: await ui.t('newChat.branchSync.action', { remote: 'origin', remoteBranch: 'main' }),
exact: false,
})
.click();
const result = page.getByRole('button', {
name: await ui.t('newChat.branchSync.keptAhead', { count: 1 }),
exact: true,
});
await page.mouse.move(340, 350);
await result.hover();
await page.getByRole('tooltip').waitFor();
await ui.capture('kept-ahead');
}
if (ui.case === 'default' || ui.case === 'worker') {
await page.getByRole('combobox').fill('origin/main');
assert.equal(await page.getByRole('option').count(), 2);
await page.keyboard.press('Enter');
assert.match(await trigger.textContent(), /origin\/main/);
const action = page.getByRole('button', {
name: await ui.t('newChat.branchSync.remoteAction', {
remote: 'origin',
remoteBranch: 'main',
}),
exact: false,
});
await action.click();
await page
.getByRole('button', {
name: await ui.t('newChat.branchSync.updated', { count: ui.case === 'worker' ? 1 : 3 }),
exact: true,
})
.waitFor();
await page.mouse.move(340, 350);
await page
.getByRole('button', {
name: await ui.t('newChat.branchSync.updated', { count: ui.case === 'worker' ? 1 : 3 }),
exact: true,
})
.hover();
await page.getByRole('tooltip').waitFor();
await ui.capture('updated');
}
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,10 @@ void mountPage({
},
},
},
worktreeBranchSyncStatus: async ({ branch }: { branch: string }) => ({ branch, remote: 'origin', remoteBranch: branch, remoteOnly: false, ahead: 0, behind: 3, dirtyFiles: 2, checkoutPath: inherited.directory }),
worktreeSyncBranch: async ({ branch }: { branch: string }) => ({ updated: 3, status: { branch, remote: 'origin', remoteBranch: branch, remoteOnly: false, ahead: 0, behind: 0, dirtyFiles: 2 } }),
worktreeListBranches: async () => ({
remoteDefaultBranch: 'refs/remotes/origin/main',
branches: ['main', 'feat/worker-project-picker', 'release/v2'],
}),
}),
Expand Down
12 changes: 6 additions & 6 deletions apps/desktop/src/main/__tests__/deviceLinkHostDispatch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,20 +176,20 @@ describe('device-link host dispatch (runInvoke) — real gate + async fs guard +

// 远程 worktree:worktree:create 的 baseRepo 与 create-session 的 workingDir 同口径收敛
// (决定在被控端哪个仓库下跑 git worktree add)。
it('worktree:create 的 baseRepo 是真实存在目录 → 放行到 handler', async () => {
const handler = registerHandler('worktree:create', () => ({ ok: true }));
it.each(['worktree:create', 'worktree:sync-branch'])('%s 的 baseRepo 是真实存在目录 → 放行到 handler', async (channel) => {
const handler = registerHandler(channel, () => ({ ok: true }));
const res = (await runInvoke(SRC, {
channel: 'worktree:create',
channel,
args: [{ sessionId: 's-wt', baseRepo: TMP_DIR, name: 'auto-x1', sourceBranch: 'main' }],
})) as Extract<InvokeResultPayload, { ok: true }>;
expect(res.ok).toBe(true);
expect(handler).toHaveBeenCalledTimes(1);
});

it('worktree:create 的 baseRepo 不存在 → 结构化拒绝,不落到 handler', async () => {
const handler = registerHandler('worktree:create', () => ({ ok: true }));
it.each(['worktree:create', 'worktree:sync-branch'])('%s 的 baseRepo 不存在 → 结构化拒绝,不落到 handler', async (channel) => {
const handler = registerHandler(channel, () => ({ ok: true }));
const res = (await runInvoke(SRC, {
channel: 'worktree:create',
channel,
args: [{ sessionId: 's-wt', baseRepo: '/definitely/not/a/real/repo/xyz123', name: 'auto-x1', sourceBranch: 'main' }],
})) as Extract<InvokeResultPayload, { ok: false }>;
expect(res.ok).toBe(false);
Expand Down
28 changes: 27 additions & 1 deletion apps/desktop/src/main/__tests__/worktreeReveal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@ import { extractIpcError } from '../../renderer/utils/ipcError';
const showItemInFolderMock = vi.fn();
const suggestNameMock = vi.fn();
const detectCwdMock = vi.fn();
const listBranchesMock = vi.fn();
const readOriginDefaultBranchMock = vi.fn();
vi.mock('../worktree/branchSync', () => ({
readOriginDefaultBranch: (...args: unknown[]) => readOriginDefaultBranchMock(...args),
readBranchSyncStatus: vi.fn(),
syncBranch: vi.fn(),
}));
type Handler = (event: unknown, req: unknown) => unknown | Promise<unknown>;
const handlers = new Map<string, Handler>();
const ipcMainMock = {
Expand Down Expand Up @@ -46,7 +53,7 @@ vi.mock('../worktree/WorktreeManager', () => ({
getForSession: vi.fn(),
listAll: vi.fn(),
suggestName: (...args: unknown[]) => suggestNameMock(...args),
listBranches: vi.fn(),
listBranches: (...args: unknown[]) => listBranchesMock(...args),
}));

let registerWorktreeIpc: typeof import('../worktree/index').registerWorktreeIpc;
Expand All @@ -55,6 +62,8 @@ beforeEach(async () => {
showItemInFolderMock.mockReset();
suggestNameMock.mockReset();
detectCwdMock.mockReset();
listBranchesMock.mockReset();
readOriginDefaultBranchMock.mockReset();
getAllPathsMock.mockReset();
handlers.clear();
if (!registerWorktreeIpc) {
Expand Down Expand Up @@ -163,3 +172,20 @@ describe('worktree:reveal whitelist (I-3 fix)', () => {
expect(showItemInFolderMock).not.toHaveBeenCalled();
});
});

// F1 regression (unit): optional remote grouping must not make the existing branch
// list fail. Existing reveal/probe cases never exercise this enrichment; the
// minimal failure is an origin lookup timeout after a successful local list.
describe('worktree:list-branches optional remote group', () => {
it.each([false, true])('preserves local branches when remote lookup fails: %s', async (fails) => {
const baseRepo = path.resolve('/repo');
const local = { branches: ['main'], current: 'main' };
listBranchesMock.mockResolvedValue(local);
if (fails) readOriginDefaultBranchMock.mockRejectedValue(new Error('Git timed out'));
else readOriginDefaultBranchMock.mockResolvedValue('refs/remotes/origin/main');
const result = await handlers.get('worktree:list-branches')!({}, { baseRepo });
expect(result).toEqual({ ...local, remoteDefaultBranch: fails ? undefined : 'refs/remotes/origin/main' });
expect(listBranchesMock).toHaveBeenCalledExactlyOnceWith(baseRepo);
expect(readOriginDefaultBranchMock).toHaveBeenCalledExactlyOnceWith(baseRepo);
});
});
1 change: 1 addition & 0 deletions apps/desktop/src/main/device-link/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,7 @@ const PATH_GUARDED_CHANNELS: ReadonlyMap<string, 'workingDir' | 'baseRepo'> = ne
// 协同远端 Worker 指定本机目录时同口径收敛;不指定则由本机分配任务目录。
[ORCA_REMOTE_WORKER_OPEN_CHANNEL, 'workingDir'],
['worktree:create', 'baseRepo'],
['worktree:sync-branch', 'baseRepo'],
]);

type RemoteWorkingDirGuardValue = boolean | RemoteWorkingDirCheckResult;
Expand Down
Loading
Loading