test: remove low-value sidebar tests (#38511)

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
OrcaWin
2026-11-05 02:55:51 -08:00
committed by GitHub
co-authored by OrcaWin
parent 1772b455f6
commit c1b71588b6
22 changed files with 11 additions and 8889 deletions
-1
View File
@@ -292,7 +292,6 @@
1 test-no-real-time-wait:src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-stale-analysis-work-budget.test.tsx
1 test-no-real-time-wait:src/renderer/src/components/native-chat/NativeChatModelCombobox.test.tsx
1 test-no-real-time-wait:src/renderer/src/components/native-chat/native-chat-composer-draft-lifecycle.test.tsx
1 test-no-real-time-wait:src/renderer/src/components/right-sidebar/source-control/review/use-pull-request-generation.test.tsx
1 test-no-real-time-wait:src/renderer/src/components/sidebar/sidebar-feedback/SidebarFeedbackDialog.test.tsx
1 test-no-real-time-wait:src/renderer/src/components/status-bar/NativeChatResumeStatusSegment.test.tsx
1 test-no-real-time-wait:src/renderer/src/components/terminal-pane/issue-12112-agent-pane-startup-color-reply-leak.repro.test.ts
@@ -7613,21 +7613,14 @@
"src/renderer/src/components/right-sidebar/ai-vault/session-list/AiVaultSessionRow.test.tsx": 3168,
"src/renderer/src/components/right-sidebar/ai-vault/session-list/AiVaultSessionSubagents.test.tsx": 2538,
"src/renderer/src/components/right-sidebar/ai-vault/session-list/AiVaultSessionVirtualList.measurement.test.tsx": 3117,
"src/renderer/src/components/right-sidebar/source-control/commit/CommitArea.generate.test.tsx": 2616,
"src/renderer/src/components/right-sidebar/source-control/commit/CommitArea.test.tsx": 17311,
"src/renderer/src/components/right-sidebar/file-explorer/panel/FileExplorerQueryStrip.focus.test.tsx": 690,
"src/renderer/src/components/right-sidebar/FolderWorkspaceWorktreesPanel.test.tsx": 461,
"src/renderer/src/components/right-sidebar/plugin-panels/PluginPanel.test.tsx": 2710,
"src/renderer/src/components/right-sidebar/PortsPanel.test.tsx": 1618,
"src/renderer/src/components/right-sidebar/ai-vault/session-list/SessionRowTrailingActions.menus.test.tsx": 698,
"src/renderer/src/components/right-sidebar/source-control/sync/SourceControl.branch-line-total.test.tsx": 3916,
"src/renderer/src/components/right-sidebar/source-control/sync/SourceControl.compare-summary.test.ts": 279,
"src/renderer/src/components/right-sidebar/source-control/listing/SourceControl.open-file-highlight.test.tsx": 4749,
"src/renderer/src/components/right-sidebar/source-control/review/SourceControl.pr-generation-records.test.ts": 102,
"src/renderer/src/components/right-sidebar/source-control/review/SourceControl.preview-open.test.tsx": 4703,
"src/renderer/src/components/right-sidebar/source-control/listing/SourceControl.virtual-file-list.test.tsx": 6427,
"src/renderer/src/components/right-sidebar/source-control/ai/SourceControlAgentActionDialog.test.tsx": 2772,
"src/renderer/src/components/right-sidebar/source-control/ai/SourceControlTextGenerationDialog.test.ts": 609,
"src/renderer/src/components/right-sidebar/checks-panel/active-checks-status.test.ts": 1717,
"src/renderer/src/components/right-sidebar/ai-vault/accounts/ai-vault-account-native.test.tsx": 2785,
"src/renderer/src/components/right-sidebar/ai-vault/accounts/ai-vault-account-peer.test.tsx": 2516,
@@ -7679,12 +7672,8 @@
"src/renderer/src/components/right-sidebar/checks-panel/comments/pr-comments-list-selection.test.tsx": 3290,
"src/renderer/src/components/right-sidebar/right-sidebar-titlebar-drag-regions.render.test.tsx": 1044,
"src/renderer/src/components/right-sidebar/source-control/ai/runSourceControlAgentActionStart.test.ts": 1917,
"src/renderer/src/components/right-sidebar/source-control/panel/source-control-branch-context-row.test.tsx": 790,
"src/renderer/src/components/right-sidebar/source-control/review/source-control-create-pr-intent-flow.test.ts": 71,
"src/renderer/src/components/right-sidebar/source-control/panel/source-control-dropdown-items.test.ts": 409,
"src/renderer/src/components/right-sidebar/source-control/review/source-control-manual-review-url.test.ts": 77,
"src/renderer/src/components/right-sidebar/source-control/review/source-control-primary-action.create-pr-intent.test.ts": 340,
"src/renderer/src/components/right-sidebar/source-control/review/source-control-primary-action.test.ts": 287,
"src/renderer/src/components/right-sidebar/source-control/listing/source-control-submodule-expansion.test.ts": 80,
"src/renderer/src/components/right-sidebar/source-control/ai/use-ai.operation-recovery.test.tsx": 13031,
"src/renderer/src/components/right-sidebar/source-control/commit/rename-mutation-paths.test.ts": 49,
@@ -7724,7 +7713,6 @@
"src/renderer/src/components/right-sidebar/source-control/sync/useGitStatusPolling.rerender.test.ts": 1928,
"src/renderer/src/components/right-sidebar/source-control/sync/useGitStatusPolling.test.ts": 2071,
"src/renderer/src/components/right-sidebar/hosted-review/provider-actions/useHostedReviewStackParent.test.tsx": 288,
"src/renderer/src/components/right-sidebar/source-control/ai/useSourceControlAgentActionDialog.test.tsx": 6336,
"src/renderer/src/components/right-sidebar/source-control/listing/useSourceControlSubmoduleStatus.test.tsx": 2076,
"src/renderer/src/components/settings/accounts/AccountsPane.owner-scope.test.tsx": 4057,
"src/renderer/src/components/skills/setup/AgentSkillSetupPanel.freshness.test.tsx": 15418,
@@ -1,379 +0,0 @@
import React, { type ReactNode } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
import './text-generation-dialog'
import { buildCommitMessageGenerationParams } from './text-generation-params'
import {
getDefaultSourceControlTextGenerationSaveTargetKey,
SourceControlTextGenerationDialogForm
} from './text-generation-dialog-form'
import {
applyCommitMessageGenerationDefaults,
applySourceControlTextGenerationDefaults
} from './SourceControlTextGenerationDefaults'
vi.mock('../../../source-control/SourceControlActionVariableChips', () => ({
SourceControlActionVariableChips: ({
variablePreviews
}: {
variablePreviews?: Partial<Record<string, string>>
}) =>
React.createElement('div', {
'data-variable-previews': JSON.stringify(variablePreviews ?? {})
})
}))
vi.mock('@/components/ui/dialog', () => ({
DialogFooter: ({ children }: { children?: ReactNode }) =>
React.createElement('div', null, children)
}))
vi.mock('@/components/ui/select', () => ({
Select: ({ children }: { children?: ReactNode }) => React.createElement('div', null, children),
SelectContent: ({ children }: { children?: ReactNode }) =>
React.createElement('div', null, children),
SelectItem: ({ children }: { children?: ReactNode }) =>
React.createElement('div', null, children),
SelectTrigger: ({ children }: { children?: ReactNode }) =>
React.createElement('button', null, children),
SelectValue: () => React.createElement('span')
}))
/** The mocked chips serialize their previews into an attribute, so quotes arrive escaped. */
function escapeHtml(value: string): string {
return value.replace(/"/g, '&quot;')
}
function renderTextGenerationForm(input: {
commandInputTemplate: string
linkedIssue: number | null
}): string {
return renderToStaticMarkup(
React.createElement(SourceControlTextGenerationDialogForm, {
actionId: 'commitMessage',
generateLabel: 'Generate commit message',
settings: null,
repo: null,
baseParams: {
agentId: 'codex',
model: 'gpt-5.4-mini',
commandInputTemplate: input.commandInputTemplate
},
linkedIssue: input.linkedIssue,
saveTargets: [
{ target: { type: 'global' }, label: 'Save as global default', successMessage: '' }
],
onGenerate: () => {},
onOpenChange: () => {},
onSaveDefaults: () => {}
})
)
}
describe('buildCommitMessageGenerationParams', () => {
it('defaults saved text-generation recipes to the global target when repo and global are available', () => {
expect(
getDefaultSourceControlTextGenerationSaveTargetKey([
{
target: { type: 'repo', repoId: 'repo-1' },
label: 'Save for this repository only',
successMessage: ''
},
{
target: { type: 'global' },
label: 'Save as default for all repositories',
successMessage: ''
}
])
).toBe('global')
})
it('passes the base prompt preview to variable chips in text generation dialogs', () => {
const markup = renderToStaticMarkup(
React.createElement(SourceControlTextGenerationDialogForm, {
actionId: 'commitMessage',
generateLabel: 'Generate',
settings: null,
repo: null,
baseParams: {
agentId: 'codex',
model: 'gpt-5.4-mini',
commandInputTemplate: '{basePrompt}'
},
basePromptPreview: 'You are generating a single git commit message.',
saveTargets: [],
onGenerate: () => {},
onOpenChange: () => {},
onSaveDefaults: () => {}
})
)
expect(markup).toContain('You are generating a single git commit message.')
})
// Missing chip previews fall back to synthetic 123, falsely promising an unlinked workspace Fixes #123.
it.each([
{ label: 'the linked issue', linkedIssue: 42, expected: '42' },
{ label: 'an empty value when unlinked', linkedIssue: null, expected: '' }
])('previews $label for a workspace-scoped dialog', ({ linkedIssue, expected }) => {
const markup = renderToStaticMarkup(
React.createElement(SourceControlTextGenerationDialogForm, {
actionId: 'commitMessage',
generateLabel: 'Generate',
settings: null,
repo: null,
baseParams: {
agentId: 'codex',
model: 'gpt-5.4-mini',
commandInputTemplate: '{basePrompt}'
},
linkedIssue,
saveTargets: [],
onGenerate: () => {},
onOpenChange: () => {},
onSaveDefaults: () => {}
})
)
expect(markup).toContain(escapeHtml(JSON.stringify({ linkedIssue: expected })))
})
it('omits the issue preview when no workspace is in scope', () => {
const markup = renderToStaticMarkup(
React.createElement(SourceControlTextGenerationDialogForm, {
actionId: 'commitMessage',
generateLabel: 'Generate',
settings: null,
repo: null,
baseParams: {
agentId: 'codex',
model: 'gpt-5.4-mini',
commandInputTemplate: '{basePrompt}'
},
saveTargets: [],
onGenerate: () => {},
onOpenChange: () => {},
onSaveDefaults: () => {}
})
)
expect(markup).not.toContain('linkedIssue')
})
// Repo/global recipe saves must not depend on the active workspace having a linked issue.
it.each([
{ label: 'a linked workspace', linkedIssue: 42 },
{ label: 'an unlinked workspace', linkedIssue: null }
])('keeps a bare {linkedIssue} recipe runnable from $label', ({ linkedIssue }) => {
const markup = renderTextGenerationForm({
commandInputTemplate: '{linkedIssue}',
linkedIssue
})
// Empty-input copy appears after clicks; static markup can observe only the disabled gate.
expect(markup).toContain('Save defaults')
expect(markup).toContain('Generate commit message')
expect(markup).not.toContain('disabled=""')
})
it('still disables both actions for a template that renders empty for everyone', () => {
const markup = renderTextGenerationForm({ commandInputTemplate: ' ', linkedIssue: 42 })
expect(markup).toContain('disabled=""')
})
it('preserves the resolved model and thinking level for the selected agent', () => {
expect(
buildCommitMessageGenerationParams({
agentId: 'codex',
commandTemplate: '{basePrompt}\n\nUse Conventional Commits.',
agentArgs: '--model gpt-5.5',
baseParams: {
agentId: 'codex',
model: 'gpt-5.4-mini',
thinkingLevel: 'xhigh',
commandInputTemplate: '{basePrompt}',
agentArgs: '--model gpt-5.5',
agentCommandOverride: 'codex'
},
settings: { agentCmdOverrides: { codex: 'codex --profile work' } }
})
).toEqual({
agentId: 'codex',
model: 'gpt-5.4-mini',
thinkingLevel: 'xhigh',
commandInputTemplate: '{basePrompt}\n\nUse Conventional Commits.',
agentArgs: '--model gpt-5.5',
agentCommandOverride: 'codex --profile work'
})
})
it('keeps existing custom-command generation usable from the dialog', () => {
expect(
buildCommitMessageGenerationParams({
agentId: 'custom',
commandTemplate: '{basePrompt}\n\nPrefer ticket IDs.',
baseParams: {
agentId: 'custom',
model: '',
customPrompt: 'Prefer ticket IDs.',
commandInputTemplate: '{basePrompt}\n\nPrefer ticket IDs.',
customAgentCommand: 'my-commit-writer --prompt {prompt}'
},
settings: null
})
).toEqual({
agentId: 'custom',
model: '',
customPrompt: 'Prefer ticket IDs.',
commandInputTemplate: '{basePrompt}\n\nPrefer ticket IDs.',
customAgentCommand: 'my-commit-writer --prompt {prompt}'
})
})
it('uses the configured custom command when switching agents in the dialog', () => {
expect(
buildCommitMessageGenerationParams({
agentId: 'custom',
commandTemplate: '{basePrompt}',
baseParams: {
agentId: 'codex',
model: 'gpt-5.4-mini',
commandInputTemplate: '{basePrompt}'
},
settings: null,
customAgentCommand: 'my-commit-writer --prompt {prompt}'
})
).toEqual({
agentId: 'custom',
model: '',
customPrompt: undefined,
commandInputTemplate: '{basePrompt}',
customAgentCommand: 'my-commit-writer --prompt {prompt}'
})
})
it('saves custom-command templates as an explicit action recipe', () => {
const saved = applyCommitMessageGenerationDefaults(
{
enabled: true,
agentId: 'custom',
selectedModelByAgent: {},
selectedThinkingByModel: {},
customAgentCommand: 'my-commit-writer',
instructionsByOperation: {},
actions: {
commitMessage: {
agentId: 'codex',
commandInputTemplate: '{basePrompt}'
}
}
},
'local',
{
agentId: 'custom',
model: '',
commandInputTemplate: '{basePrompt}\n\nPrefer ticket IDs.',
customAgentCommand: 'my-commit-writer'
}
)
expect(saved.agentId).toBe('custom')
expect(saved.actions?.commitMessage).toEqual({
agentId: 'custom',
commandInputTemplate: '{basePrompt}\n\nPrefer ticket IDs.'
})
})
it('saves a custom default without changing unrelated text actions', () => {
const saved = applySourceControlTextGenerationDefaults(
{
enabled: true,
agentId: 'codex',
selectedModelByAgent: {},
selectedThinkingByModel: {},
customAgentCommand: 'my-commit-writer',
instructionsByOperation: {},
actions: {
pullRequest: {
agentId: 'codex',
commandInputTemplate: '{basePrompt}'
}
}
},
'pullRequest',
{
agentId: 'custom',
model: '',
commandInputTemplate: '{basePrompt}\n\nPrefer ticket IDs.',
customAgentCommand: 'my-commit-writer'
}
)
expect(saved.agentId).toBe('codex')
expect(saved.actions?.pullRequest).toEqual({
agentId: 'custom',
commandInputTemplate: '{basePrompt}\n\nPrefer ticket IDs.'
})
})
it('saves the selected agent and command template as the commit-message recipe', () => {
expect(
applyCommitMessageGenerationDefaults(
{
enabled: true,
agentId: null,
selectedModelByAgent: {},
selectedThinkingByModel: {},
customAgentCommand: '',
instructionsByOperation: {},
actions: {
commitMessage: {
commandInputTemplate: '{basePrompt}'
}
}
},
'local',
{
agentId: 'codex',
model: 'gpt-5.4-mini',
commandInputTemplate: 'just use "{branch}"'
}
).actions?.commitMessage
).toEqual({
agentId: 'codex',
commandInputTemplate: 'just use "{branch}"'
})
})
it('saves pull-request text generation defaults through the shared dialog helper', () => {
expect(
applySourceControlTextGenerationDefaults(
{
enabled: true,
agentId: null,
selectedModelByAgent: {},
selectedThinkingByModel: {},
customAgentCommand: '',
instructionsByOperation: {},
actions: {
pullRequest: {
commandInputTemplate: '{basePrompt}'
}
}
},
'pullRequest',
{
agentId: 'codex',
model: 'gpt-5.4-mini',
commandInputTemplate: '{basePrompt}\n\nKeep it short.',
agentArgs: '--model gpt-5.5'
}
).actions?.pullRequest
).toEqual({
agentId: 'codex',
commandInputTemplate: '{basePrompt}\n\nKeep it short.',
agentArgs: '--model gpt-5.5'
})
})
})
@@ -32,15 +32,6 @@ vi.mock('sonner', () => ({
import { runSourceControlAgentActionStart } from './runSourceControlAgentActionStart'
import { getDefaultSettings } from '../../../../../../shared/constants'
import { saveSourceControlActionRecipe } from '../../../../../../shared/source-control/ai/settings/source-control-ai-recipe-save'
import { resolveSourceControlActionRecipe } from '../../../../../../shared/source-control/ai/source-control-ai-operation-policy'
import { saveSourceControlAiActionRecipeForTarget } from './recipe-persistence'
import { sourceControlActionLaunchOptions } from '../../../../../../shared/source-control/ai/recipes/source-control-action-launch-preferences'
import {
resolveAgentLaunchCommand,
terminalSessionOptionInputs
} from '../../../../../../shared/agent-launch/startup/tui-agent-launch-command'
import type { Repo } from '../../../../../../shared/repo-types'
function buildArgs(
overrides: Partial<Omit<Parameters<typeof runSourceControlAgentActionStart>[0], 'recipe'>> & {
@@ -81,26 +72,6 @@ describe('runSourceControlAgentActionStart', () => {
mocks.mutationInFlight.mockResolvedValue({ inFlight: false })
})
it('rejects conflict recovery before creating a tab when Git is still running', async () => {
const onLaunchAccepted = vi.fn()
mocks.mutationInFlight.mockResolvedValue({ inFlight: true })
await expect(
runSourceControlAgentActionStart(
buildArgs({
actionId: 'resolveConflicts',
onLaunchAccepted,
recoveryContext: { target: { kind: 'local' }, worktreeId: 'wt-1', worktreePath: '/repo' }
})
)
).resolves.toBe(false)
expect(mocks.launchAgentInNewTab).not.toHaveBeenCalled()
expect(mocks.focusTerminalTabSurface).not.toHaveBeenCalled()
expect(onLaunchAccepted).not.toHaveBeenCalled()
expect(mocks.onLaunched).not.toHaveBeenCalled()
expect(mocks.onSaveAgentDefault).not.toHaveBeenCalled()
expect(mocks.onClose).not.toHaveBeenCalled()
})
it('notifies onLaunchAccepted as soon as the agent tab is created, before prompt delivery', async () => {
let resolveDelivery: (value: {
delivered: boolean
@@ -180,98 +151,6 @@ describe('runSourceControlAgentActionStart', () => {
expect(mocks.onLaunched).not.toHaveBeenCalled()
expect(mocks.onClose).not.toHaveBeenCalled()
})
it('keeps injected onStart successes immediate', async () => {
const onStart = vi.fn().mockResolvedValue(true)
await expect(
runSourceControlAgentActionStart(
buildArgs({
onStart,
worktreeId: undefined,
groupId: undefined
})
)
).resolves.toBe(true)
expect(onStart).toHaveBeenCalledWith({
signal: undefined,
onCreateAccepted: undefined,
onFailureNotified: expect.any(Function),
agent: 'codex',
commandInput: 'Fix the bug',
agentArgs: '--model gpt-5',
agentLaunchPreferences: undefined,
chatSessionOptions: { model: 'gpt-5' },
preferredSurface: undefined
})
expect(mocks.launchAgentInNewTab).not.toHaveBeenCalled()
expect(mocks.onLaunched).toHaveBeenCalledTimes(1)
expect(mocks.onClose).toHaveBeenCalledTimes(1)
})
// onStart awaits no delivery result, so acceptance is terminal for that route.
// Recipe persistence can fail while prompt delivery succeeds; report each outcome independently.
it('completes the launch and reports a failed repository recipe write', async () => {
const onLaunchAborted = vi.fn()
const originalConsole = console
const consoleError = vi.fn()
vi.stubGlobal('console', { ...originalConsole, error: consoleError })
const settings = getDefaultSettings('/tmp')
const updateRepo = vi.fn().mockResolvedValue(false)
const onSaveAgentDefault = vi.fn(
(
...[target, actionId, recipe]: Parameters<
NonNullable<Parameters<typeof runSourceControlAgentActionStart>[0]['onSaveAgentDefault']>
>
) =>
saveSourceControlAiActionRecipeForTarget({
getStoreState: () => ({ settings, repos: [] }),
updateSettings: vi.fn(),
updateRepo,
target,
actionId,
recipe
})
)
mocks.launchAgentInNewTab.mockReturnValue({
surface: { kind: 'local-terminal', tabId: 'tab-1' },
startupPlan: {} as never,
pasteDraftAfterLaunch: true,
promptDeliveryResult: Promise.resolve({ delivered: true, failureNotified: false })
})
try {
await expect(
runSourceControlAgentActionStart(
buildArgs({
saveTargetValue: 'repo',
repoId: 'repo-1',
settings,
onSaveAgentDefault,
onLaunchAborted
})
)
).resolves.toBe(true)
expect(updateRepo).toHaveBeenCalledOnce()
expect(mocks.onLaunched).toHaveBeenCalledTimes(1)
expect(mocks.onClose).toHaveBeenCalledTimes(1)
expect(onLaunchAborted).not.toHaveBeenCalled()
await vi.waitFor(() => {
expect(consoleError).toHaveBeenCalledWith(
'onSaveAgentDefault failed',
new Error('Could not save the Source Control AI action.')
)
expect(mocks.toastError).toHaveBeenCalledWith(
'Could not save this action. Try saving it again in Settings.'
)
})
} finally {
vi.stubGlobal('console', originalConsole)
}
})
})
it('confirms delivery and closes even while saving remains pending', async () => {
@@ -289,148 +168,3 @@ it('confirms delivery and closes even while saving remains pending', async () =>
expect(onLaunched).toHaveBeenCalledOnce()
expect(mocks.onClose).toHaveBeenCalled()
})
// Save keeps the repository's selected Args group on every later launch.
it.each(['--model gpt-5.6-luna', '--model gpt-5.6-sol'])(
'persists the complete target for changed or unchanged Customize arguments %s',
async (agentArgs) => {
vi.clearAllMocks()
const base = getDefaultSettings('/tmp')
const terra = { agentId: 'codex' as const, model: 'gpt-5.6-terra' }
const fixCommitFailure = {
agentId: null,
commandInputTemplate: '{basePrompt}',
launchPreferences: terra
}
const settings = {
...base,
sourceControlAi: { ...base.sourceControlAi!, actions: { fixCommitFailure } }
}
const legacy = { agentArgs: '--model gpt-5.6-sol' }
const repo: Pick<Repo, 'id' | 'sourceControlAi'> = {
id: 'repo-1',
sourceControlAi: { actionOverrides: { fixCommitFailure: legacy } }
}
const shown = resolveSourceControlActionRecipe({ settings, repo, actionId: 'fixCommitFailure' })
mocks.launchAgentInNewTab.mockReturnValue({ surface: { kind: 'local-terminal', tabId: 't' } })
const recipe = { ...shown, agentArgs }
mocks.onSaveAgentDefault.mockImplementation((target, actionId, recipe) => {
const saved = saveSourceControlActionRecipe({ target, actionId, recipe, settings, repo })
if ('update' in saved) {
repo.sourceControlAi = saved.update.sourceControlAi ?? undefined
}
})
const args = { recipe, agentArgs, settings, repo, saveTargetValue: 'repo', repoId: 'repo-1' }
await runSourceControlAgentActionStart(buildArgs({ ...args, actionId: 'fixCommitFailure' }))
expect(shown.launchPreferencesYieldToAgentArgs).toBeUndefined()
expect(mocks.launchAgentInNewTab.mock.lastCall?.[0]).toMatchObject({
agentArgs: `--dangerously-bypass-approvals-and-sandbox ${agentArgs}`
})
expect(mocks.launchAgentInNewTab.mock.lastCall?.[0]).not.toHaveProperty(
'sessionOptionsYieldToAgentArgs'
)
expect(repo.sourceControlAi?.actionOverrides?.fixCommitFailure).toEqual({
agentId: null,
commandInputTemplate: '{basePrompt}',
agentArgs
})
const next = resolveSourceControlActionRecipe({ settings, repo, actionId: 'fixCommitFailure' })
const nextOptions = sourceControlActionLaunchOptions(next, 'codex')
expect(nextOptions).toEqual({ chatSessionOptions: { model: agentArgs.split(' ')[1] } })
const command = resolveAgentLaunchCommand({
agent: 'codex',
cmdOverrides: {},
platform: 'linux',
shell: 'posix',
agentArgs,
...terminalSessionOptionInputs({
agent: 'codex',
agentArgs,
shell: 'posix',
sessionOptions: nextOptions.sessionOptions,
yieldToAgentArgs: nextOptions.sessionOptionsYieldToAgentArgs
})
})
expect(command).toMatchObject({
ok: true,
appliedSessionOptions: {},
command: `codex '-c' 'disable_paste_burst=true' '--model' '${agentArgs.split(' ')[1]}'`
})
expect(mocks.onSaveAgentDefault).toHaveBeenCalledTimes(1)
await runSourceControlAgentActionStart(
buildArgs({ ...args, recipe: next, actionId: 'fixCommitFailure' })
)
expect(mocks.onSaveAgentDefault).toHaveBeenCalledTimes(1)
}
)
it('saves a foreign resolved literal as current raw and starts from the canonical saved result', async () => {
vi.clearAllMocks()
const base = getDefaultSettings('/tmp')
const settings = {
...base,
agentPermissionDefault: 'own' as const,
sourceControlAi: {
...base.sourceControlAi!,
actions: {
fixChecks: {
agentId: 'codex' as const,
agentArgs: '',
argumentChoices: {
syntaxAgent: 'codex' as const,
nodes: [{ at: 0, text: '-c model=o3' }]
}
}
}
}
}
const repo = {
id: 'repo-1',
sourceControlAi: { actionOverrides: { fixChecks: { agentId: 'claude' as const } } }
}
const recipe = resolveSourceControlActionRecipe({ settings, repo, actionId: 'fixChecks' })
const onStart = vi.fn().mockResolvedValue(true)
await expect(
runSourceControlAgentActionStart(
buildArgs({
actionId: 'fixChecks',
recipe,
settings,
repo,
repoId: 'repo-1',
selectedAgent: 'claude',
saveTargetValue: 'repo',
onStart
})
)
).resolves.toBe(true)
expect(mocks.onSaveAgentDefault).toHaveBeenCalledExactlyOnceWith(
{ type: 'repo', repoId: 'repo-1' },
'fixChecks',
{
agentId: 'claude',
commandInputTemplate: '{basePrompt}',
agentArgs: '-c model=o3'
}
)
expect(onStart).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({ agent: 'claude', agentArgs: '-c model=o3' })
)
})
it('passes attempt cancellation to onStart and leaves cancellation quiet', async () => {
const attempt = new AbortController()
const onStart = vi.fn().mockResolvedValue('cancelled')
const onLaunched = vi.fn()
const onClose = vi.fn()
await expect(
runSourceControlAgentActionStart(
buildArgs({ onStart, onLaunched, onClose, signal: attempt.signal })
)
).resolves.toBe(false)
expect(onStart).toHaveBeenCalledWith(expect.objectContaining({ signal: attempt.signal }))
expect(mocks.toastError).not.toHaveBeenCalled()
expect(onLaunched).not.toHaveBeenCalled()
expect(onClose).not.toHaveBeenCalled()
})
@@ -1,8 +1,8 @@
import { sourceControlActionRecipeEntry } from '../../../../../../shared/source-control/ai/recipes/source-control-action-selection'
import { runtimeStatusEntry } from '@/runtime/server-layout-frames.test-fixture'
import { setLocalRuntimeCapabilitiesForTests } from '@/runtime/local-runtime-capabilities'
import { getRuntimeAgentInventoryKey } from '@/store/slices/runtime-agent-inventory-key'
import { projectAgentLaunchSettings } from '../../../../../../shared/agent-launch/choices/agent-launch-settings'
import { makeWorktree, TEST_REPO } from '@/store/slices/worktrees-slice-test-fixtures'
import { clearHostAgentMenuSettingsForTests } from '@/runtime/agent-launch-settings/host-agent-launch-preferences-resource'
import {
@@ -14,15 +14,13 @@ import { toast } from 'sonner'
import type * as RuntimeGitStatusClientModule from '@/runtime/runtime-git-status-client'
import path from 'node:path'
import { act, cleanup, renderHook, waitFor } from '@testing-library/react'
import { act, cleanup, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAppStore } from '@/store'
import { getDefaultSettings } from '../../../../../../shared/constants'
import type { GitConflictOperation } from '../../../../../../shared/git/status/git-status-types'
import type { TuiAgent } from '../../../../../../shared/agent-launch/catalog/tui-agent'
import { useSourceControlAi } from './use-ai'
import type { SourceControlAiControllerParams } from './controller-types'
import { deferred } from '../panel/source-control-hook-test-harness'
const { mutationInFlight, detectAgents, launchAgent } = vi.hoisted(() => ({
mutationInFlight: vi.fn(),
detectAgents: vi.fn(),
@@ -37,33 +35,6 @@ vi.mock('@/runtime/runtime-git-status-client', async (importOriginal) => ({
const initialState = useAppStore.getState()
function controllerParams(
overrides: Partial<SourceControlAiControllerParams> = {}
): SourceControlAiControllerParams {
return {
settings: getDefaultSettings(path.resolve('tmp')),
target: { kind: 'local' },
activeRepo: null,
activeWorktreeId: 'wt-1',
activeConnectionId: null,
activeGroupId: 'group-1',
activeSourceControlLaunchPlatform: 'linux',
conflictOperation: 'rebase',
unresolvedConflicts: [],
stagedEntries: [],
worktreePath: path.resolve('tmp', 'wt-1'),
commitMessage: '',
commitError: null,
pushRecoveryPrompt: null,
updateSettings: vi.fn(),
updateSettingsOrThrow: vi.fn().mockResolvedValue(undefined),
updateRepo: vi.fn(),
openSettingsTarget: vi.fn(),
openSettingsPage: vi.fn(),
...overrides
}
}
describe('AI operation recovery composer', () => {
beforeEach(() => {
vi.mocked(toast.error).mockClear()
@@ -148,271 +119,6 @@ describe('AI operation recovery composer', () => {
expect(toast.success).not.toHaveBeenCalled()
expect(toast.error).not.toHaveBeenCalled()
})
it.each<GitConflictOperation>(['merge', 'rebase', 'cherry-pick'])(
'opens for a paused %s with no unmerged paths',
async (conflictOperation) => {
const { result } = renderHook(() =>
useSourceControlAi(controllerParams({ conflictOperation }))
)
await act(() => result.current.handleResolveConflictsWithAI())
expect(result.current.resolveConflictsComposerOpen).toBe(true)
expect(result.current.resolveConflictsPrompt).toContain(`- Operation: ${conflictOperation}`)
}
)
it('keeps conflict resolution available without a known operation', async () => {
const { result } = renderHook(() =>
useSourceControlAi(
controllerParams({ conflictOperation: 'unknown', unresolvedConflicts: [{ path: 'a.ts' }] })
)
)
await act(() => result.current.handleResolveConflictsWithAI())
expect(result.current.resolveConflictsComposerOpen).toBe(true)
})
it('does not open when there is no operation or unresolved conflict', async () => {
const { result } = renderHook(() =>
useSourceControlAi(controllerParams({ conflictOperation: 'unknown' }))
)
await act(() => result.current.handleResolveConflictsWithAI())
expect(result.current.resolveConflictsComposerOpen).toBe(false)
})
it.each([0, 1])(
'reads execution-host busy state through a retained callback with %i unmerged paths',
async (unresolvedCount) => {
const unresolvedConflicts = unresolvedCount ? [{ path: 'a.ts' }] : []
const { result } = renderHook(() =>
useSourceControlAi(controllerParams({ unresolvedConflicts }))
)
const openComposer = result.current.handleResolveConflictsWithAI
mutationInFlight.mockResolvedValue({ inFlight: true })
await act(() => openComposer())
expect(result.current.resolveConflictsComposerOpen).toBe(false)
mutationInFlight.mockResolvedValue({ inFlight: false })
await act(() => openComposer())
expect(result.current.resolveConflictsComposerOpen).toBe(true)
}
)
it('opens while an unrelated workspace fetch is pending', async () => {
const { result } = renderHook(() => useSourceControlAi(controllerParams()))
useAppStore.getState().beginRemoteOperation('fetch', 'wt-2')
await act(() => result.current.handleResolveConflictsWithAI())
expect(result.current.resolveConflictsComposerOpen).toBe(true)
expect(mutationInFlight).toHaveBeenCalledWith({
target: { kind: 'local' },
worktreeId: 'wt-1',
worktreePath: path.resolve('tmp', 'wt-1'),
connectionId: undefined
})
})
it('closes the composer on navigation and keeps it closed when returning', async () => {
const first = controllerParams()
const other = controllerParams({
activeWorktreeId: 'wt-2',
worktreePath: path.resolve('tmp', 'wt-2')
})
const view = renderHook(useSourceControlAi, { initialProps: first })
await act(() => view.result.current.handleResolveConflictsWithAI())
expect(view.result.current.resolveConflictsComposerOpen).toBe(true)
view.rerender(other)
expect(view.result.current.resolveConflictsComposerOpen).toBe(false)
view.rerender(first)
expect(view.result.current.resolveConflictsComposerOpen).toBe(false)
})
it.each(['worktree', 'host'] as const)(
'ignores an old check after leaving and returning to a %s',
async (change) => {
const oldCheck = deferred<{ inFlight: boolean }>()
const newCheck = deferred<{ inFlight: boolean }>()
mutationInFlight.mockReturnValueOnce(oldCheck.promise).mockReturnValueOnce(newCheck.promise)
const first = controllerParams()
const other = controllerParams(
change === 'worktree'
? { activeWorktreeId: 'wt-2', worktreePath: path.resolve('tmp', 'wt-2') }
: { target: { kind: 'environment', environmentId: 'host-2' } }
)
const view = renderHook(useSourceControlAi, { initialProps: first })
let oldOpening: Promise<void> = Promise.resolve()
act(() => {
oldOpening = view.result.current.handleResolveConflictsWithAI()
})
view.rerender(other)
view.rerender(first)
expect(view.result.current.isCheckingRecoveryAdmission).toBe(false)
let newOpening: Promise<void> = Promise.resolve()
act(() => {
newOpening = view.result.current.handleResolveConflictsWithAI()
})
await act(async () => {
oldCheck.resolve({ inFlight: false })
await oldOpening
})
expect(view.result.current.resolveConflictsComposerOpen).toBe(false)
expect(view.result.current.isCheckingRecoveryAdmission).toBe(true)
await act(async () => {
newCheck.resolve({ inFlight: false })
await newOpening
})
expect(view.result.current.resolveConflictsComposerOpen).toBe(true)
expect(view.result.current.isCheckingRecoveryAdmission).toBe(false)
}
)
it('cancels a pending check when the composer is closed', async () => {
const check = deferred<{ inFlight: boolean }>()
mutationInFlight.mockReturnValue(check.promise)
const view = renderHook(() => useSourceControlAi(controllerParams()))
let opening: Promise<void> = Promise.resolve()
act(() => {
opening = view.result.current.handleResolveConflictsWithAI()
})
act(() => view.result.current.setResolveConflictsComposerOpen(false))
await act(async () => {
check.resolve({ inFlight: false })
await opening
})
expect(view.result.current.resolveConflictsComposerOpen).toBe(false)
expect(view.result.current.isCheckingRecoveryAdmission).toBe(false)
})
it.each([false, true])(
'cold saved recovery respects the host and admission cancellation (%s)',
async (cancel) => {
const settings = getDefaultSettings(path.resolve('tmp'))
settings.disabledTuiAgents = ['codex']
settings.agentPermissionDefault = 'ask'
settings.sourceControlAi!.actions = { resolveConflicts: { agentId: 'codex' } }
const pending = Promise.withResolvers<{
settings: ReturnType<typeof projectAgentLaunchSettings>
}>()
hostRpc.mockReturnValueOnce(pending.promise)
useAppStore.setState({
settings,
repos: [TEST_REPO],
runtimeStatusByEnvironmentId: new Map([['alpha', runtimeStatusEntry('r1', false)]]),
worktreesByRepo: {
[TEST_REPO.id]: [
makeWorktree({ id: 'wt-1', repoId: TEST_REPO.id, hostId: 'runtime:alpha' })
]
}
})
const first = controllerParams({ settings })
const view = renderHook(useSourceControlAi, { initialProps: first })
let starting: Promise<void> = Promise.resolve()
act(() => {
starting = view.result.current.handleResolveConflictsWithAI()
})
await waitFor(() =>
expect(
hostRpc.mock.calls.filter((call) => call[1] === 'settings.getAgentLaunch')
).toHaveLength(1)
)
if (cancel) {
view.rerender(controllerParams({ activeWorktreeId: 'wt-2', worktreePath: '/wt-2' }))
await act(async () => {})
expect(
hostRpc.mock.calls.find((call) => call[1] === 'settings.getAgentLaunch')?.[3].signal
.aborted
).toBe(true)
}
await act(async () => {
pending.resolve({ settings: projectAgentLaunchSettings({ defaultTuiAgent: 'codex' }) })
await starting
})
expect(launchAgent).toHaveBeenCalledTimes(cancel ? 0 : 1)
expect(view.result.current.resolveConflictsComposerOpen).toBe(false)
expect(first.updateSettingsOrThrow).not.toHaveBeenCalled()
expect(toast.error).not.toHaveBeenCalled()
if (!cancel) {
await launchSourceControlRecoveryAgentWithDefault({
activeWorktreeId: 'wt-1',
activeGroupId: null,
activeSourceControlLaunchPlatform: 'linux',
sourceRepoConnectionId: null,
actionId: 'fixCommitFailure',
basePrompt: 'fix',
getLaunchActionRecipe: () =>
sourceControlActionRecipeEntry({
agentId: null,
agentArgs: '--model chosen',
commandInputTemplate: 'Recovery: {basePrompt}'
}),
getStoreState: useAppStore.getState,
copy: getDefaultSourceControlRecoveryLaunchCopy('commit')
})
expect(launchAgent).toHaveBeenLastCalledWith(
expect.objectContaining({
agent: 'codex',
prompt: 'Recovery: fix',
agentArgs: '-a on-request -s read-only -c approvals_reviewer=user --model chosen'
})
)
}
}
)
it.each(['unavailable', 'error', 'runtime-replaced'] as const)(
'does not reopen an obsolete fallback when saved discovery ends as %s',
async (resultKind) => {
const detection = deferred<TuiAgent[]>()
detectAgents.mockReturnValueOnce(detection.promise)
const settings = getDefaultSettings(path.resolve('tmp'))
settings.sourceControlAi!.actions = { resolveConflicts: { agentId: 'codex' } }
if (resultKind === 'runtime-replaced') {
useAppStore.setState({
runtimeStatusByEnvironmentId: new Map([['alpha', runtimeStatusEntry('r1', false)]]),
worktreesByRepo: {
[TEST_REPO.id]: [
makeWorktree({ id: 'wt-1', repoId: TEST_REPO.id, hostId: 'runtime:alpha' })
]
}
})
hostRpc.mockResolvedValue({
settings: projectAgentLaunchSettings({ defaultTuiAgent: 'codex' })
})
}
const first = controllerParams({ settings })
const view = renderHook(useSourceControlAi, { initialProps: first })
let starting: Promise<void> = Promise.resolve()
act(() => {
starting = view.result.current.handleResolveConflictsWithAI()
})
await waitFor(() => expect(detectAgents).toHaveBeenCalledOnce())
if (resultKind === 'runtime-replaced') {
await act(async () => {
useAppStore.setState({
runtimeStatusByEnvironmentId: new Map([['alpha', runtimeStatusEntry('r2', false)]])
})
await starting
detection.resolve(['codex'])
})
expect(toast.error).not.toHaveBeenCalled()
expect(first.updateSettingsOrThrow).not.toHaveBeenCalled()
} else {
view.rerender(
controllerParams({ settings, activeWorktreeId: 'wt-2', worktreePath: '/wt-2' })
)
view.rerender(first)
}
await act(async () => {
if (resultKind === 'error') {
detection.reject(new Error('Detection failed'))
} else if (resultKind === 'unavailable') {
detection.resolve([])
}
await starting
})
expect(launchAgent).not.toHaveBeenCalled()
expect(view.result.current.resolveConflictsComposerOpen).toBe(false)
expect(view.result.current.isCheckingRecoveryAdmission).toBe(false)
expect(view.result.current.isResolvingConflictsWithAI).toBe(false)
}
)
})
const hostRpc = vi.hoisted(() => vi.fn())
@@ -1,198 +0,0 @@
// @vitest-environment happy-dom
import type * as StructuredAgentClient from '@/runtime/structured-agent-session-client'
import { act, cleanup, renderHook, waitFor } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import { useAppStore } from '@/store/index'
import { makeWorktree } from '@/store/slices/worktrees-slice-test-fixtures'
import { setLocalRuntimeCapabilitiesForTests } from '@/runtime/local-runtime-capabilities'
import { clearHostAgentMenuSettingsForTests } from '../../../../runtime/agent-launch-settings/host-agent-launch-preferences-resource'
import { canonicalSourceControlActionSelection } from '../../../../../../shared/source-control/ai/recipes/source-control-action-selection'
import { planAgentSessionLaunch } from '@/lib/agent-session-launch-plan'
import { useSavedSourceControlActionStart } from './use-saved-source-control-action-start'
import { mountSourceDialog, renderDialog } from '../panel/source-control-action-dialog.test-fixture'
vi.mock('@/runtime/structured-agent-session-client', async (importOriginal) => ({
...(await importOriginal<typeof StructuredAgentClient>()),
callStructuredAgentSession: vi.fn(async () => undefined)
}))
const initial = useAppStore.getState()
afterEach(() => {
cleanup()
vi.restoreAllMocks()
clearHostAgentMenuSettingsForTests()
useAppStore.setState(initial, true)
setLocalRuntimeCapabilitiesForTests(null)
})
it('saved primary Chat skips Terminal-only validation under CLI settings and dispatches the captured two views', async () => {
const recipe = {
agentId: 'codex' as const,
surface: 'chat' as const,
agentArgs: '--model "unterminated'
}
const f = mountSourceDialog(recipe, recipe)
act(() =>
useAppStore.setState(({ settings }) => ({
settings: { ...settings!, nativeChatProviders: { codex: false } },
worktreesByRepo: { [f.repo.id]: [makeWorktree({ id: 'wt-saved', repoId: f.repo.id })] },
ensureDetectedAgents: async () => ['codex']
}))
)
f.onStart.mockImplementation(async (launch) => {
const plan = planAgentSessionLaunch(useAppStore.getState(), {
requestId: 'saved-primary',
agent: launch.agent,
workspace: { kind: 'git-worktree', worktreeId: 'wt-saved' },
preferredSurface: launch.preferredSurface,
requestedSurface: launch.launchChoice?.requestedSurface,
launchOptions: launch.sessionOptions,
chatLaunchOptions: launch.chatSessionOptions
})
expect(plan.route).toBe('structured-native-chat')
expect(plan).not.toHaveProperty('requestedSurface')
expect(launch.agentArgs).toBe('--model "unterminated')
expect(launch.preferredSurface).toBe('chat')
return true
})
const fallback = vi.fn()
const primary = renderHook(useSavedSourceControlActionStart)
await act(async () =>
primary.result.current.start({
selection: canonicalSourceControlActionSelection(recipe),
basePrompt: 'fix',
actionId: 'resolveConflicts',
repoId: f.repo.id,
repo: f.repo,
settings: useAppStore.getState().settings,
worktreeId: 'wt-saved',
connectionId: null,
promptDelivery: 'submit-after-ready',
launchPlatform: 'darwin',
launchSource: 'source_control_recovery',
onStart: f.onStart,
onClose: vi.fn(),
onFallback: fallback
})
)
expect(f.onStart).toHaveBeenCalledOnce()
expect(fallback).not.toHaveBeenCalled()
expect(f.persist).not.toHaveBeenCalled()
primary.unmount()
})
it('preserves a stored equal surface on single-mode acceptance while keeping the live explicit operation', async () => {
const recipe = { agentId: 'codex' as const, surface: 'terminal' as const, agentArgs: '--search' }
const f = renderDialog(false, true, recipe)
await waitFor(() => expect(f.result.current.canStart).toBe(true))
act(() => f.result.current.onAgentPick('codex', 'terminal'))
expect(f.result.current.recipe).toMatchObject(recipe)
expect(f.result.current.surfaceRows.selection.choice).toMatchObject({
route: 'terminal-tui',
requestedSurface: 'terminal'
})
expect(f.result.current.agentArgsApply).toBe(true)
})
it.each(
(['claude', 'codex'] as const).flatMap((agent) =>
(['chat', 'terminal'] as const).map((surface) => ({ agent, surface }))
)
)(
'dispatches the exact newly authored $agent $surface recipe through saved start against opposite Settings',
async ({ agent, surface }) => {
const recipe = {
agentId: agent,
surface,
agentArgs: '--add-dir "two words"',
commandInputTemplate: 'current {basePrompt}',
launchPreferences: {
agentId: agent,
modelSelection: {
base: 'default' as const,
model: { agentId: agent, value: agent === 'claude' ? 'sonnet' : 'gpt-5.5' },
options: { effort: { agentId: agent, value: 'high' } }
},
permissionMode: 'own' as const
}
}
const original = structuredClone(recipe)
const f = mountSourceDialog(recipe, recipe)
act(() =>
useAppStore.setState(({ settings }) => ({
settings: { ...settings!, nativeChatProviders: { [agent]: surface !== 'chat' } },
worktreesByRepo: { [f.repo.id]: [makeWorktree({ id: 'wt-writer', repoId: f.repo.id })] },
ensureDetectedAgents: async () => [agent]
}))
)
f.onStart.mockImplementation(async (launch) => {
expect(launch.agent).toBe(agent)
expect(launch.commandInput).toBe('current fix')
expect(launch.agentArgs).toBe('--add-dir "two words"')
expect(launch.sessionOptions).toEqual({
model: agent === 'claude' ? 'sonnet' : 'gpt-5.5',
effort: 'high',
permissionMode: 'own'
})
expect(launch.chatSessionOptions).toBeUndefined()
expect(launch.preferredSurface).toBe(surface === 'chat' ? 'chat' : undefined)
expect(launch.launchChoice?.requestedSurface).toBe(
surface === 'terminal' ? 'terminal' : undefined
)
expect(launch.launchChoice?.route).toBe(
surface === 'chat' ? 'structured-native-chat' : 'terminal-tui'
)
const plan = planAgentSessionLaunch(useAppStore.getState(), {
requestId: 'saved-writer',
agent: launch.agent,
workspace: { kind: 'git-worktree', worktreeId: 'wt-writer' },
prompt: launch.commandInput,
preferredSurface: launch.preferredSurface,
requestedSurface: launch.launchChoice?.requestedSurface,
launchOptions: launch.sessionOptions,
chatLaunchOptions: launch.chatSessionOptions
})
expect(plan.route).toBe(surface === 'chat' ? 'structured-native-chat' : 'terminal-tui')
expect(plan.prompt).toBe('current fix')
expect(plan.launchOptions).toEqual({
model: agent === 'claude' ? 'sonnet' : 'gpt-5.5',
effort: 'high',
permissionMode: 'own'
})
if (surface === 'chat') {
expect(plan).not.toHaveProperty('requestedSurface')
} else {
expect(plan.requestedSurface).toBe('terminal')
}
return true
})
const fallback = vi.fn()
const close = vi.fn()
const primary = renderHook(useSavedSourceControlActionStart)
await act(async () =>
primary.result.current.start({
selection: canonicalSourceControlActionSelection(recipe),
basePrompt: 'fix',
actionId: 'fixChecks',
repoId: f.repo.id,
repo: f.repo,
settings: useAppStore.getState().settings,
worktreeId: 'wt-writer',
connectionId: null,
promptDelivery: 'submit-after-ready',
launchPlatform: 'darwin',
launchSource: 'source_control_recovery',
onStart: f.onStart,
onClose: close,
onFallback: fallback
})
)
expect(f.onStart).toHaveBeenCalledOnce()
expect(close).toHaveBeenCalledOnce()
expect(fallback).not.toHaveBeenCalled()
expect(f.persist).not.toHaveBeenCalled()
expect(primary.result.current.isStarting).toBe(false)
expect(recipe).toEqual(original)
primary.unmount()
}
)
@@ -1,815 +0,0 @@
// @vitest-environment happy-dom
import type * as StructuredAgentClient from '@/runtime/structured-agent-session-client'
import {
commitSourceControlActionArguments,
editSourceControlActionArguments
} from '../../../../../../shared/source-control/ai/recipes/source-control-action-selection'
import {
captureSourceControlActionSelection,
resolveSourceControlActionRecipe
} from '../../../../../../shared/source-control/ai/source-control-ai-operation-policy'
import { projectSourceControlActionLaunch } from '../../../../../../shared/source-control/ai/recipes/source-control-action-launch-preferences'
import { structuredLaunchRequestedOptions } from '../../../../../../shared/native-chat/input/native-chat-session-option-defaults'
import { liftActionPermissionArgs } from '../../../../../../shared/source-control/ai/recipes/source-control-action-permission-lift'
import type { Repo } from '../../../../../../shared/repo-types'
import { projectAgentLaunchSettings } from '../../../../../../shared/agent-launch/choices/agent-launch-settings'
import { makeWorktree, TEST_REPO } from '@/store/slices/worktrees-slice-test-fixtures'
import { getRuntimeAgentInventoryKey } from '@/store/slices/runtime-agent-inventory-key'
import { clearHostAgentMenuSettingsForTests } from '../../../../runtime/agent-launch-settings/host-agent-launch-preferences-resource'
import type { SourceControlActionRecipe } from '../../../../../../shared/source-control/ai/recipes/source-control-ai-actions'
import { act, cleanup, fireEvent, renderHook, screen, waitFor } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import { getDefaultSettings } from '../../../../../../shared/constants'
import { useAppStore } from '@/store/index'
import { setLocalRuntimeCapabilitiesForTests } from '@/runtime/local-runtime-capabilities'
import { useSourceControlAgentActionDialog } from './useSourceControlAgentActionDialog'
import {
mountSourceDialog,
freshDialogRecipe,
pickDialogSaveTarget,
submittedActionStartupPlan,
renderDialog,
pickDialogAgent,
type SaveAction
} from '../panel/source-control-action-dialog.test-fixture'
vi.mock('@/runtime/structured-agent-session-client', async (importOriginal) => ({
...(await importOriginal<typeof StructuredAgentClient>()),
callStructuredAgentSession: vi.fn(async () => undefined)
}))
const initialState = useAppStore.getState()
afterEach(() => {
cleanup()
vi.restoreAllMocks()
clearHostAgentMenuSettingsForTests()
useAppStore.setState(initialState, true)
setLocalRuntimeCapabilitiesForTests(null)
})
it('starts and saves a live pick from the unified picker, keeping the agent-bound model', async () => {
const { result, onStart, onSaveAgentDefault, launchPreferences } = renderDialog(true)
await waitFor(() => expect(result.current.canStart).toBe(true))
// An untouched default recipe stays the default and saves no surface of its own.
expect(result.current.selectedAgent).toBe('claude')
expect(result.current.recipe).toMatchObject({ agentId: null, launchPreferences })
expect(result.current.agentArgsApply).toBe(false)
act(() => result.current.surfaceRows.onPick('codex', 'terminal'))
expect(result.current.recipe).toMatchObject({ agentId: 'codex', surface: 'terminal' })
expect(result.current.recipe.launchPreferences).toEqual(launchPreferences)
expect(result.current.surfaceRows.selection.cli).toBe(true)
expect(result.current.agentArgsApply).toBe(true)
await act(() => result.current.handleStart())
expect(onStart).toHaveBeenCalledWith(
expect.objectContaining({
agent: 'codex',
launchChoice: expect.objectContaining({
route: 'terminal-tui',
requestedSurface: 'terminal'
})
})
)
expect(onSaveAgentDefault).toHaveBeenCalledWith(
{ type: 'global' },
'resolveConflicts',
expect.objectContaining({ agentId: 'codex', surface: 'terminal' })
)
act(() => result.current.surfaceRows.onPick('claude', 'chat'))
expect(result.current.recipe).toMatchObject({ agentId: 'claude', surface: 'chat' })
expect(result.current.recipe.launchPreferences).toBeUndefined()
expect(result.current.agentArgsApply).toBe(false)
})
it('leaves an untouched saved surface in the draft but omits it when saving to an older action host', async () => {
const { result, onSaveAgentDefault } = renderDialog(true, false, {
agentId: 'codex',
surface: 'terminal'
})
await waitFor(() => expect(result.current.canStart).toBe(true))
expect(result.current.recipe.surface).toBe('terminal')
expect(result.current.agentArgsApply).toBe(true)
act(() => result.current.onCommandTemplateChange('edited {basePrompt}'))
await act(() => result.current.handleStart())
const saved = onSaveAgentDefault.mock.calls[0]?.[2]
expect(saved).not.toHaveProperty('surface')
expect(saved).not.toHaveProperty('agentArgs')
expect(result.current.recipe.surface).toBe('terminal')
})
it('allows replacing an unavailable saved agent without making it launchable', async () => {
const f = renderDialog(false, true, { agentId: 'codex', agentArgs: '' })
await act(async () =>
useAppStore.setState({
detectedAgentIds: ['claude'],
localDetectedAgentIdsByContext: { host: ['claude'] }
})
)
expect(f.result.current.surfaceRows.selection.ready).toBe(true)
expect(f.result.current.canStart).toBe(false)
expect(f.result.current.recipe.agentId).toBe('codex')
expect(f.onStart).not.toHaveBeenCalled()
act(() => f.result.current.surfaceRows.onPick('claude', 'terminal'))
await waitFor(() => expect(f.result.current.canStart).toBe(true))
})
const hostRpc = vi.hoisted(() => vi.fn())
vi.mock('@/runtime/runtime-rpc-client', () => ({
callRuntimeRpc: hostRpc,
runtimeEnvironmentSupportsCapability: async () => true
}))
it.each(['codex', null] as const)(
'uses the offered host identity for delivery and scope, preserving authored %s',
async (agentId) => {
const reply = Promise.withResolvers<{
settings: ReturnType<typeof projectAgentLaunchSettings>
}>()
hostRpc.mockReturnValue(reply.promise)
const settings = {
...getDefaultSettings('/tmp/host-action'),
defaultTuiAgent: 'opencode' as const,
disabledTuiAgents: ['codex' as const],
nativeChatProviders: { claude: false, codex: false }
}
const recipe = {
agentId,
surface: 'terminal' as const,
agentArgs: '--model chosen',
launchPreferences: { agentId: 'codex' as const, model: 'chosen' }
}
const repo: Repo = {
...TEST_REPO,
executionHostId: 'runtime:alpha',
sourceControlAi: {
actionOverrides: {
resolveConflicts: {
agentId: 'codex' as const,
launchPreferences: recipe.launchPreferences
}
}
}
}
useAppStore.setState({
settings,
repos: [repo],
worktreesByRepo: {
[repo.id]: [makeWorktree({ id: 'wt', repoId: repo.id, hostId: 'runtime:alpha' })]
},
runtimeDetectedAgentIds: {
[getRuntimeAgentInventoryKey('alpha', 'wt')]: ['claude', 'codex', 'opencode']
},
ensureRuntimeDetectedAgents: vi.fn().mockResolvedValue(['claude', 'codex', 'opencode']),
refreshRuntimeDetectedAgents: vi.fn().mockResolvedValue(['claude', 'codex', 'opencode'])
})
const onStart = vi.fn().mockResolvedValue(true)
const onSaveAgentDefault = vi.fn()
const view = renderHook(() =>
useSourceControlAgentActionDialog({
open: true,
initialRecipe: recipe,
onOpenChange: vi.fn(),
actionId: 'resolveConflicts',
title: '',
description: '',
baseCommandInput: 'fix',
launchSource: 'source_control_recovery',
worktreeId: 'wt',
repoId: repo.id,
connectionId: null,
onStart,
onSaveAgentDefault
})
)
expect(view.result.current.canStart).toBe(false)
expect(view.result.current.saveTargetValue).toBe('repo')
act(() => view.result.current.onSaveAgentDefaultChange('global'))
await act(async () =>
reply.resolve({
settings: projectAgentLaunchSettings({
defaultTuiAgent: 'codex',
disabledTuiAgents: ['opencode', 'claude']
})
})
)
expect(view.result.current.surfaceRows.model).toMatchObject({
state: 'settled',
rows: [{ agent: 'codex', kind: 'cli' }]
})
expect(view.result.current.selectedAgent).toBe('codex')
expect(view.result.current.canStart).toBe(true)
expect(view.result.current.saveTargetValue).toBe('global')
expect(view.result.current.agentScopeNote).toBeNull()
await act(() => view.result.current.handleStart())
expect(onStart).toHaveBeenCalledWith(
expect.objectContaining({
agent: 'codex',
agentArgs: '--dangerously-bypass-approvals-and-sandbox --model chosen',
sessionOptions: { model: 'chosen' },
agentLaunchPreferences: {
agentLaunchChoices: {},
defaultTuiAgent: 'codex',
disabledTuiAgents: ['opencode', 'claude']
}
})
)
expect(onSaveAgentDefault).toHaveBeenCalledWith(
{ type: 'global' },
'resolveConflicts',
expect.objectContaining({ agentId, agentArgs: '--model chosen' })
)
expect(useAppStore.getState().settings).toBe(settings)
}
)
it.each([
{ saved: 'claude' as const, model: 'opus', terminal: true },
{ saved: null, model: undefined, terminal: false }
])(
'keeps captured choices through actual same-agent picker dispatch, complete Save, Start and reopen (%s)',
async ({ saved, model, terminal }) => {
const global: SourceControlActionRecipe = {
agentId: 'claude',
agentArgs: '--verbose',
launchPreferences: {
agentId: 'claude',
...(model ? { model } : {}),
options: { effort: 'high' }
}
}
const own: SourceControlActionRecipe = {
agentId: saved,
launchPreferences: { agentId: 'claude', permissionMode: 'ask' }
}
const f = mountSourceDialog(global, own)
await waitFor(() =>
expect(
screen.getByRole('button', { name: 'Save & start agent' }).hasAttribute('disabled')
).toBe(false)
)
await pickDialogAgent('Claude', terminal)
if (terminal) {
expect(f.controller.agentArgs).toBe('--verbose')
} else {
expect(screen.queryByRole('textbox', { name: 'Extra CLI arguments' })).toBeNull()
}
expect(
projectSourceControlActionLaunch(f.controller.recipe, 'claude', { basePrompt: 'fix' })
.sessionOptions
).toMatchObject({ effort: 'high' })
if (model) {
expect(
projectSourceControlActionLaunch(f.controller.recipe, 'claude', { basePrompt: 'fix' })
.sessionOptions?.model
).toBe('opus')
}
fireEvent.click(screen.getByRole('button', { name: 'Save & start agent' }))
await waitFor(() => expect(f.onStart).toHaveBeenCalledOnce())
const options = { ...(model ? { model } : {}), effort: 'high', permissionMode: 'ask' }
expect(f.onStart).toHaveBeenCalledWith(
expect.objectContaining({ agent: 'claude', agentArgs: '--verbose', sessionOptions: options })
)
expect(f.persist).toHaveBeenCalledOnce()
const savedRecipe = f.persist.mock.calls[0][2]
expect(savedRecipe.launchPreferences).toEqual({
agentId: 'claude',
permissionMode: 'ask',
modelSelection: {
base: 'default',
...(model ? { model: { agentId: 'claude', value: model } } : {}),
options: { effort: { agentId: 'claude', value: 'high' } }
}
})
expect(
projectSourceControlActionLaunch(freshDialogRecipe(f.repo.id), 'claude', {
basePrompt: 'fix'
})
).toMatchObject({ agentArgs: '--verbose', sessionOptions: options })
f.view.unmount()
const reopened = f.mount()
expect(
projectSourceControlActionLaunch(f.controller.recipe, 'claude', { basePrompt: 'fix' })
.sessionOptions
).toMatchObject({ effort: 'high' })
reopened.unmount()
}
)
it('distinguishes masked eligibility from owning reset and uses the captured parent through Save/Start', async () => {
const global: SourceControlActionRecipe = {
agentId: 'codex',
agentArgs: '',
launchPreferences: {
agentId: 'codex',
permissionMode: 'own',
modelSelection: {
base: 'default',
model: { agentId: 'codex', value: 'o3' }
}
}
}
const own: SourceControlActionRecipe = {
agentId: 'claude',
launchPreferences: {
agentId: 'claude',
permissionMode: 'ask',
modelSelection: {
base: 'inherit',
model: { agentId: 'claude', value: 'opus' }
}
}
}
const f = mountSourceDialog(global, own)
const captured = resolveSourceControlActionRecipe({
settings: useAppStore.getState().settings,
repo: f.repo,
actionId: 'resolveConflicts'
})
expect(
projectSourceControlActionLaunch(captured, 'codex', {
basePrompt: 'fix'
}).sessionOptions
).toBeUndefined()
await waitFor(() =>
expect(
screen.getByRole('button', { name: 'Save & start agent' }).hasAttribute('disabled')
).toBe(false)
)
act(() =>
useAppStore.setState((state) => ({
settings: {
...state.settings!,
sourceControlAi: {
...state.settings!.sourceControlAi!,
actions: {
resolveConflicts: { ...global, launchPreferences: { agentId: 'codex', model: 'later' } }
}
}
}
}))
)
await pickDialogAgent('Codex', true)
expect(
projectSourceControlActionLaunch(f.controller.recipe, 'codex', { basePrompt: 'fix' })
.sessionOptions?.model
).toBe('o3')
fireEvent.click(screen.getByRole('button', { name: 'Save & start agent' }))
await waitFor(() => expect(f.onStart).toHaveBeenCalledOnce())
expect(f.onStart).toHaveBeenCalledWith(
expect.objectContaining({ agent: 'codex', agentArgs: '', sessionOptions: { model: 'o3' } })
)
expect(f.persist.mock.calls[0][2]).toMatchObject({
agentId: 'codex',
agentArgs: '',
launchPreferences: {
agentId: 'codex',
modelSelection: {
base: 'default',
model: { agentId: 'codex', value: 'o3' }
}
}
})
expect(f.persist.mock.calls[0][2].launchPreferences).toEqual({
agentId: 'codex',
modelSelection: { base: 'default', model: { agentId: 'codex', value: 'o3' } }
})
expect(f.persist.mock.calls[0][2]).not.toHaveProperty('resolvedChoiceSources')
expect(
projectSourceControlActionLaunch(freshDialogRecipe(f.repo.id), 'codex', { basePrompt: 'fix' })
.sessionOptions
).toEqual({ model: 'o3' })
await pickDialogAgent('Claude', false)
await pickDialogAgent('Codex', true)
expect(screen.queryByText(/Opus/)).toBeNull()
f.view.unmount()
const reopened = f.mount()
expect(
projectSourceControlActionLaunch(f.controller.recipe, 'codex', { basePrompt: 'fix' })
.sessionOptions?.model
).toBe('o3')
reopened.unmount()
useAppStore.setState((state) => ({
repos: state.repos.map((row) =>
row.id === f.repo.id
? {
...row,
sourceControlAi: { actionOverrides: { resolveConflicts: { agentId: 'codex' } } }
}
: row
)
}))
const fresh = f.mount()
expect(
projectSourceControlActionLaunch(f.controller.recipe, 'codex', { basePrompt: 'fix' })
.sessionOptions?.model
).toBe('later')
fresh.unmount()
})
it('retains newly eligible literal syntax, demotes obsolete syntax once and does not restore it after switching back', async () => {
const global = liftActionPermissionArgs<SourceControlActionRecipe>({
agentId: 'claude' as const,
agentArgs: '--model opus --effort high --verbose'
})
const f = mountSourceDialog(global, {
agentId: 'codex',
launchPreferences: { agentId: 'codex', permissionMode: 'ask' }
})
await waitFor(() =>
expect(
screen.getByRole('button', { name: 'Save & start agent' }).hasAttribute('disabled')
).toBe(false)
)
await pickDialogAgent('Claude', true)
expect(f.controller.agentArgs).toBe('--verbose')
expect(
projectSourceControlActionLaunch(f.controller.recipe, 'claude', { basePrompt: 'fix' })
.sessionOptions
).toMatchObject({ model: 'opus', effort: 'high' })
fireEvent.click(screen.getByRole('button', { name: 'Save & start agent' }))
await waitFor(() => expect(f.onStart).toHaveBeenCalledOnce())
expect(f.onStart).toHaveBeenCalledWith(
expect.objectContaining({
agent: 'claude',
agentArgs: '--verbose',
sessionOptions: { model: 'opus', effort: 'high' }
})
)
const saved = f.persist.mock.calls[0][2]
expect(saved.argumentChoices).toEqual(global.argumentChoices)
expect(saved.launchPreferences).toBeUndefined()
await pickDialogAgent('Codex', true)
expect(f.controller.agentArgs).toBe('--model opus --effort high --verbose')
await pickDialogAgent('Claude', true)
expect(screen.queryByText(/Model from Settings/)).toBeNull()
expect(f.controller.agentArgs).toBe('--model opus --effort high --verbose')
})
it('keeps an owned empty-Args default barrier and submitted choices across failed Save, newer edits and Discard', async () => {
const done = Promise.withResolvers<void>()
const save = vi.fn<SaveAction>(() => done.promise)
const f = mountSourceDialog(
{ agentId: 'codex', launchPreferences: { agentId: 'codex', model: 'o3' } },
{
agentId: 'claude',
agentArgs: '',
launchPreferences: {
agentId: 'claude',
modelSelection: { base: 'inherit', model: { agentId: 'claude', value: null } }
}
},
save
)
await waitFor(() =>
expect(
screen.getByRole('button', { name: 'Save & start agent' }).hasAttribute('disabled')
).toBe(false)
)
await pickDialogAgent('Codex', true)
expect(screen.queryByText(/Model from Settings/)).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Save & start agent' }))
await waitFor(() => expect(f.onStart).toHaveBeenCalledOnce())
expect(f.onStart.mock.calls[0][0]).toMatchObject({
agent: 'codex',
agentArgs: '',
commandInput: 'fix'
})
expect(f.onStart.mock.calls[0][0].sessionOptions).toBeUndefined()
act(() => f.controller.onAgentArgsChange('--newer'))
await act(async () => done.reject(new Error('write failed')))
expect(f.controller.agentArgs).toBe('--newer')
expect(save.mock.calls[0][2].agentArgs).toBe('')
expect(f.onStart).toHaveBeenCalledOnce()
expect(useAppStore.getState().repos[0].sourceControlAi).toEqual(f.repo.sourceControlAi)
expect(freshDialogRecipe(f.repo.id).agentId).toBe('claude')
f.view.unmount()
const fresh = f.mount()
await pickDialogAgent('Claude', true)
expect(f.controller.agentArgs).toBe('')
fresh.unmount()
})
it.each([
{ agent: 'claude', pair: false, save: false, native: false, scope: 'global' },
{ agent: 'claude', pair: true, save: true, native: false, scope: 'repo' },
{ agent: 'claude', pair: false, save: true, native: false, scope: 'global' },
{ agent: 'claude', pair: true, save: true, native: true, scope: 'repo' },
{ agent: 'codex', pair: true, save: false, native: true, scope: 'repo' },
{ agent: 'codex', pair: true, save: false, native: true, scope: 'repo', foreignDocument: true },
{ agent: 'claude', pair: true, save: false, native: true, scope: 'repo', defaultGroup: true },
{ agent: 'claude', pair: false, save: false, native: true, scope: 'repo', defaultGroup: true },
{ agent: 'claude', pair: false, save: true, native: false, scope: 'repo', typedDefault: true },
{ agent: 'claude', pair: false, save: true, native: true, scope: 'repo', typedDefault: true }
] as const)(
'commits edited $agent literals once through Start/save=$save/native=$native/$scope and fresh reopen',
async (row) => {
const { agent, pair, save, native, scope } = row
const original =
agent === 'claude'
? `--model opus ${pair || save || 'defaultGroup' in row ? '--effort high ' : ''}--verbose`
: '-m gpt-5.4 -c model_reasoning_effort=high --verbose'
const replacement =
agent === 'claude'
? `--model sonnet ${pair ? '--effort low ' : ''}--verbose`
: '-m gpt-5.5 -c model_reasoning_effort=low --verbose'
const global = liftActionPermissionArgs<SourceControlActionRecipe>({
agentId: 'foreignDocument' in row ? 'claude' : agent,
surface: 'terminal',
agentArgs: 'foreignDocument' in row ? '--model opus --effort high --verbose' : original
})
const f = mountSourceDialog(
'typedDefault' in row
? {
...global,
argumentChoices: undefined,
launchPreferences: {
agentId: agent,
modelSelection: {
base: 'default',
model: { agentId: agent, value: 'opus' },
options: { effort: { agentId: agent, value: 'high' } }
}
}
}
: global,
{
agentId: agent,
surface: 'terminal',
...('defaultGroup' in row || 'typedDefault' in row
? {
launchPreferences: { agentId: agent, modelSelection: { base: 'default' as const } }
}
: {})
},
undefined,
undefined,
scope
)
await waitFor(() =>
expect(
screen
.getByRole('button', {
name: /^(Save & start agent|Start agent)$/
})
.hasAttribute('disabled')
).toBe(false)
)
if ('typedDefault' in row) {
const state = useAppStore.getState()
const captured = captureSourceControlActionSelection({
settings: state.settings,
repo: state.repos[0],
actionId: 'resolveConflicts'
})
const committed = commitSourceControlActionArguments(
editSourceControlActionArguments(captured, replacement),
agent
)
expect(committed.selection.source).toEqual(captured.source)
expect(committed.selection.source).toMatchObject({
scope: 'repo',
ownsArgs: false,
authoredPreferences: { agentId: agent, modelSelection: { base: 'default' } },
parentPreferences: {
modelSelection: { model: { value: 'opus' }, options: { effort: { value: 'high' } } }
}
})
expect(committed.recipe.resolvedChoiceSources).toMatchObject({
model: { base: 'default' },
literalKeys: ['model'],
permission: captured.source.authoredPreferences
})
}
act(() => f.controller.onAgentArgsChange(replacement))
if (native) {
await pickDialogAgent(agent === 'claude' ? 'Claude' : 'Codex', false)
expect(screen.queryByRole('textbox', { name: 'Extra CLI arguments' })).toBeNull()
}
if (save && scope === 'repo') {
await pickDialogSaveTarget('This repository')
}
if (!save) {
fireEvent.click(screen.getByRole('checkbox'))
}
// No blur: submission itself must settle the pending edit.
fireEvent.click(
screen.getByRole('button', { name: save ? 'Save & start agent' : 'Start agent' })
)
await waitFor(() => expect(f.onStart).toHaveBeenCalledOnce())
const options = {
model: agent === 'claude' ? 'sonnet' : 'gpt-5.5',
...(pair ? { effort: 'low' } : {})
}
const submitted = f.onStart.mock.calls[0][0]
const residual =
'foreignDocument' in row
? '--model opus --effort high --verbose'
: 'defaultGroup' in row && !pair
? '--effort high --verbose'
: '--verbose'
expect(submitted).toMatchObject({ agent, agentArgs: residual, sessionOptions: options })
expect(submitted.sessionOptions).toEqual(options)
const plan = submittedActionStartupPlan(submitted)
expect(plan?.launchCommand).toBe(
agent === 'claude'
? `claude ${'defaultGroup' in row && !pair ? "'--effort' 'high' " : ''}'--verbose' '--model' 'sonnet'${pair ? " '--effort' 'low'" : ''} 'fix'`
: `codex '-c' 'disable_paste_burst=true' ${'foreignDocument' in row ? "'--effort' 'high' " : ''}'--verbose' '-m' 'gpt-5.5' '-c' 'model_reasoning_effort=low' 'fix'`
)
if (native) {
expect(submitted.launchChoice?.requestedSurface).toBe('chat')
expect(
structuredLaunchRequestedOptions({
explicit: submitted.sessionOptions,
seed: { model: 'remembered', effort: 'high' },
held: {}
})
).toEqual(options)
expect(
structuredLaunchRequestedOptions({
explicit: submitted.sessionOptions,
seed: { effort: 'high' },
held: { effort: 'medium' }
})
).toEqual({ ...options, effort: 'medium' })
}
expect(f.persist).toHaveBeenCalledTimes(save ? 1 : 0)
if (save) {
const saved = f.persist.mock.calls[0][2]
expect(saved.agentArgs).toBe('--verbose')
expect(saved.argumentChoices?.nodes.map((node) => node.text)).toEqual([
'--model sonnet',
...(pair ? ['--effort low'] : [])
])
expect(saved).not.toHaveProperty('resolvedChoiceSources')
const state = useAppStore.getState()
const fresh = resolveSourceControlActionRecipe({
settings: state.settings,
repo: scope === 'repo' ? state.repos[0] : undefined,
actionId: 'resolveConflicts'
})
expect(projectSourceControlActionLaunch(fresh, agent, { basePrompt: 'fix' })).toMatchObject({
agentArgs: '--verbose',
sessionOptions: options
})
f.view.unmount()
const reopened = f.mount()
if (native) {
await waitFor(() =>
expect(screen.queryByRole('textbox', { name: 'Extra CLI arguments' })).toBeNull()
)
} else {
expect(f.controller.agentArgs).toBe('--verbose')
}
reopened.unmount()
}
}
)
it.each([
{ edit: undefined, raw: '--model sonnet --verbose' },
{ edit: '--unrelated', raw: '--unrelated' },
{ edit: '', raw: '' }
])('preserves untouched conflict or untouched nodes for residual $edit', async ({ edit, raw }) => {
const global = liftActionPermissionArgs<SourceControlActionRecipe>({
agentId: 'claude',
surface: 'terminal',
agentArgs: '--model opus --effort high --verbose'
})
const f = mountSourceDialog(
{ ...global, agentArgs: edit === undefined ? raw : global.agentArgs },
{}
)
await waitFor(() => expect(screen.getByRole('checkbox')).toBeDefined())
if (edit !== undefined) {
act(() => f.controller.onAgentArgsChange(edit))
}
fireEvent.click(screen.getByRole('checkbox'))
fireEvent.click(screen.getByRole('button', { name: 'Start agent' }))
await waitFor(() => expect(f.onStart).toHaveBeenCalledOnce())
expect(f.onStart.mock.calls[0][0]).toMatchObject({
agentArgs: raw,
sessionOptions: { model: 'opus', effort: 'high' }
})
expect(f.persist).not.toHaveBeenCalled()
})
it('replaces the selected captured parent key without flattening sparse ownership or permission', async () => {
const f = mountSourceDialog(
{
agentId: 'claude',
surface: 'terminal',
agentArgs: '--verbose',
launchPreferences: { agentId: 'claude', model: 'opus', options: { effort: 'high' } }
},
{
launchPreferences: {
agentId: 'claude',
permissionMode: 'ask',
modelSelection: {
base: 'inherit',
options: { fastMode: { agentId: 'codex', value: 'true' } }
}
}
}
)
await waitFor(() => expect(screen.getByRole('checkbox')).toBeDefined())
act(() =>
useAppStore.setState((state) => ({
settings: {
...state.settings!,
sourceControlAi: {
...state.settings!.sourceControlAi!,
actions: {
resolveConflicts: {
agentId: 'claude',
launchPreferences: { agentId: 'claude', model: 'later' }
}
}
}
}
}))
)
act(() => f.controller.onAgentArgsChange('--model sonnet --verbose'))
fireEvent.click(screen.getByRole('checkbox'))
fireEvent.click(screen.getByRole('button', { name: 'Start agent' }))
await waitFor(() => expect(f.onStart).toHaveBeenCalledOnce())
expect(f.onStart.mock.calls[0][0]).toMatchObject({
agentArgs: '--verbose',
sessionOptions: { model: 'sonnet', permissionMode: 'ask' }
})
expect(f.persist).not.toHaveBeenCalled()
})
it('settles pending replacement in its old grammar before a genuine agent demotion', async () => {
const f = mountSourceDialog(
liftActionPermissionArgs<SourceControlActionRecipe>({
agentId: 'claude',
surface: 'terminal',
agentArgs: '--model opus --effort high --verbose'
}),
{}
)
await waitFor(() => expect(f.controller.selectedAgent).toBeTruthy())
act(() => f.controller.onAgentArgsChange('--model sonnet --verbose'))
await pickDialogAgent('Codex', true)
expect(f.controller.agentArgs).toBe('--model sonnet --verbose')
await pickDialogAgent('Claude', true)
fireEvent.click(screen.getByRole('checkbox'))
fireEvent.click(screen.getByRole('button', { name: 'Start agent' }))
await waitFor(() => expect(f.onStart).toHaveBeenCalledOnce())
expect(f.onStart.mock.calls[0][0].sessionOptions).toBeUndefined()
expect(f.onStart.mock.calls[0][0].agentArgs).toBe('--model sonnet --verbose')
})
it.each([
{ raw: 'acceptEdits', permission: 'accept-edits', residual: '--verbose' },
{ raw: 'plan', permission: 'ask', residual: '--permission-mode plan --verbose' }
] as const)(
'keeps independent effort and separately selected permission for raw $raw',
async ({ raw, permission, residual }) => {
const f = mountSourceDialog(
{
agentId: 'claude',
surface: 'terminal',
agentArgs: '--verbose',
launchPreferences: { agentId: 'claude', options: { effort: 'high' }, permissionMode: 'ask' }
},
{}
)
await waitFor(() => expect(f.controller.selectedAgent).toBeTruthy())
act(() => f.controller.onAgentArgsChange(`--model sonnet --permission-mode ${raw} --verbose`))
fireEvent.click(screen.getByRole('checkbox'))
fireEvent.click(screen.getByRole('button', { name: 'Start agent' }))
await waitFor(() => expect(f.onStart).toHaveBeenCalledOnce())
expect(f.onStart.mock.calls[0][0]).toMatchObject({
agentArgs: residual,
sessionOptions: { model: 'sonnet', effort: 'high', permissionMode: permission }
})
expect(f.persist).not.toHaveBeenCalled()
}
)
it('owns one pending Start, carries its signal, and permits a fresh Start after setup cancellation', async () => {
const f = renderDialog(false, false)
const choice = Promise.withResolvers<'cancelled'>()
f.onStart.mockImplementationOnce(() => choice.promise)
await waitFor(() => expect(f.result.current.canStart).toBe(true))
let first: Promise<void> | undefined
act(() => {
first = f.result.current.handleStart()
})
await waitFor(() => expect(f.onStart).toHaveBeenCalledOnce())
expect(f.onStart.mock.calls[0][0].signal).toBeInstanceOf(AbortSignal)
await act(async () => f.result.current.handleStart())
expect(f.onStart).toHaveBeenCalledOnce()
await act(async () => {
choice.resolve('cancelled')
await first
})
await act(async () => f.result.current.handleStart())
expect(f.onStart).toHaveBeenCalledTimes(2)
expect(f.onStart.mock.calls[1][0].signal).not.toBe(f.onStart.mock.calls[0][0].signal)
f.unmount()
})
@@ -1,326 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import { renderToStaticMarkup } from 'react-dom/server'
import { CommitArea } from './commit-area'
import {
hasConfiguredCommitMessageGenerationDefaults,
hasConfiguredSourceControlTextGenerationDefaults
} from '../ai/text-generation-defaults'
import { TooltipProvider } from '@/components/ui/tooltip'
import { resolvePrimaryAction } from '../review/source-control-primary-action'
import type { PrimaryActionInputs } from '../review/source-control-primary-action-types'
import { resolveDropdownItems } from '../panel/source-control-dropdown-items'
import type { DropdownActionKind } from '../../../../../../shared/source-control/dropdown/source-control-dropdown-item-types'
import { getDefaultSettings } from '../../../../../../shared/constants'
import { CUSTOM_AGENT_ID } from '../../../../../../shared/source-control/ai/generation/commit-message-agent-spec'
import { resolveSourceControlAiForOperation } from '../../../../../../shared/source-control/ai/source-control-ai-operation-resolution'
function buildInputs(overrides: Partial<PrimaryActionInputs> = {}): PrimaryActionInputs {
return {
stagedCount: 1,
hasUnstagedChanges: false,
hasStageableChanges: false,
hasPartiallyStagedChanges: false,
hasMessage: true,
hasUnresolvedConflicts: false,
isCommitting: false,
isRemoteOperationActive: false,
upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 },
...overrides
}
}
function baseProps(overrides: Partial<PrimaryActionInputs> = {}) {
const inputs = buildInputs(overrides)
return {
worktreeId: 'wt-1',
groupId: 'group-1',
commitMessage: 'feat: add commit area',
commitError: null as string | null,
commitFailureRecoveryPrompt: null as string | null,
pushRecovery: null,
remoteActionError: null as string | null,
isCommitting: inputs.isCommitting,
isFixingCommitFailureWithAI: false,
isFixingPushFailureWithAI: false,
showComposer: true,
sourceControlAiActionsVisible: true,
aiAgentConfigured: false,
isGenerating: false,
generateError: null as string | null,
stagedCount: inputs.stagedCount,
hasPartiallyStagedChanges: inputs.hasPartiallyStagedChanges,
hasUnresolvedConflicts: inputs.hasUnresolvedConflicts,
isRemoteOperationActive: inputs.isRemoteOperationActive,
inFlightRemoteOpKind: inputs.inFlightRemoteOpKind ?? null,
primaryAction: resolvePrimaryAction(inputs),
dropdownItems: resolveDropdownItems(inputs),
onCommitMessageChange: vi.fn(),
onGenerate: vi.fn(),
onCancelGenerate: vi.fn(),
onFixCommitFailureWithAI: vi.fn(),
onFixPushFailureWithAI: vi.fn(),
onPrimaryAction: vi.fn(),
onDropdownAction: vi.fn() as (kind: DropdownActionKind) => void
}
}
function renderCommitArea(props: ReturnType<typeof baseProps>): string {
return renderToStaticMarkup(
<TooltipProvider>
<CommitArea {...props} />
</TooltipProvider>
)
}
function buttonByLabel(markup: string, label: string): string {
const button = [...markup.matchAll(/<button\b[\s\S]*?<\/button>/g)]
.map((match) => match[0])
.find((entry) => entry.includes(`aria-label="${label}"`))
if (!button) {
throw new Error(`button not found: ${label}`)
}
return button
}
function hasDisabledAttribute(markup: string): boolean {
return markup.includes(' disabled=""') || markup.includes('aria-disabled="true"')
}
describe('CommitArea AI generation', () => {
it('does not render the AI generate affordance when the feature is disabled', () => {
expect(
renderCommitArea({
...baseProps(),
sourceControlAiActionsVisible: false
})
).not.toContain('aria-label="Generate commit message with AI"')
})
it('enables AI generation only when an agent is configured, changes are staged, and the message is empty', () => {
const props = baseProps({ hasMessage: false })
const markup = renderCommitArea({
...props,
commitMessage: '',
aiAgentConfigured: true
})
const button = buttonByLabel(markup, 'Generate commit message with AI')
expect(hasDisabledAttribute(button)).toBe(false)
expect(button).not.toContain('title=')
})
it('disables AI generation when the textarea already has user text', () => {
const markup = renderCommitArea({
...baseProps(),
aiAgentConfigured: true
})
const button = buttonByLabel(markup, 'Generate commit message with AI')
expect(button).toContain('aria-disabled="true"')
expect(button).not.toContain('title=')
})
it('keeps AI generation discoverable when the configured agent needs attention', () => {
const settings = getDefaultSettings('/tmp')
const resolved = resolveSourceControlAiForOperation({
settings: {
...settings,
sourceControlAi: {
...settings.sourceControlAi!,
customAgentCommand: '',
actions: {
...settings.sourceControlAi!.actions,
commitMessage: {
agentId: CUSTOM_AGENT_ID
}
}
}
},
repo: null,
operation: 'commitMessage'
})
expect(resolved).toMatchObject({
ok: false,
error: 'Custom command is empty. Add one in Settings -> Git -> Source Control AI.'
})
const props = baseProps({ hasMessage: false })
const markup = renderCommitArea({
...props,
commitMessage: '',
aiAgentConfigured: resolved.ok
})
const button = buttonByLabel(markup, 'Generate commit message with AI')
expect(hasDisabledAttribute(button)).toBe(false)
expect(button).not.toContain('title=')
})
it('turns the generating icon into a stop affordance', () => {
const props = baseProps({ hasMessage: false })
const markup = renderCommitArea({
...props,
commitMessage: '',
aiAgentConfigured: true,
isGenerating: true
})
const button = buttonByLabel(markup, 'Stop generating commit message')
expect(button).not.toContain('title=')
expect(button).toContain('lucide-refresh-cw')
expect(button).toContain('lucide-square')
})
it('shows generation errors separately from commit errors and links them to the textarea', () => {
const markup = renderCommitArea({
...baseProps(),
commitError: null,
generateError: 'No staged changes to summarize.'
})
expect(markup).toContain('No staged changes to summarize.')
expect(markup).toContain('aria-describedby="commit-area-generate-error"')
})
it('renders a single commit-message AI entry point in the composer', () => {
const props = baseProps({ hasMessage: false })
const markup = renderCommitArea({
...props,
commitMessage: '',
aiAgentConfigured: true
})
const matches = markup.match(/aria-label="Generate commit message with AI"/g) ?? []
expect(matches).toHaveLength(1)
expect(markup).not.toContain('aria-label="Customize commit-message generation"')
expect(markup).not.toContain('aria-label="Add commit message instructions"')
})
it('can hide only the composer while keeping the split action surface visible', () => {
const markup = renderCommitArea({
...baseProps({ hasMessage: false, stagedCount: 0 }),
commitMessage: '',
aiAgentConfigured: true,
showComposer: false
})
expect(markup).not.toContain('aria-label="Commit message"')
expect(markup).not.toContain('aria-label="Generate commit message with AI"')
expect(markup).toContain('aria-label="More commit and remote actions"')
})
})
describe('commit-message generation defaults', () => {
it('treats factory Source Control AI settings as needing the first-run dialog', () => {
expect(
hasConfiguredCommitMessageGenerationDefaults({
settings: getDefaultSettings('/tmp'),
repo: null
})
).toBe(false)
})
it('treats a saved action agent or custom template as configured defaults', () => {
const settings = getDefaultSettings('/tmp')
expect(
hasConfiguredCommitMessageGenerationDefaults({
settings: {
...settings,
sourceControlAi: {
...settings.sourceControlAi!,
actions: {
...settings.sourceControlAi!.actions,
commitMessage: {
commandInputTemplate: '{basePrompt}',
agentId: 'codex'
}
}
}
},
repo: null
})
).toBe(true)
expect(
hasConfiguredCommitMessageGenerationDefaults({
settings: {
...settings,
sourceControlAi: {
...settings.sourceControlAi!,
actions: {
...settings.sourceControlAi!.actions,
commitMessage: {
commandInputTemplate: 'just use "{branch}"'
}
}
}
},
repo: null
})
).toBe(true)
})
it('uses the same configured-defaults check for pull-request generation', () => {
const settings = getDefaultSettings('/tmp')
expect(
hasConfiguredSourceControlTextGenerationDefaults({
actionId: 'pullRequest',
settings,
repo: null
})
).toBe(false)
expect(
hasConfiguredSourceControlTextGenerationDefaults({
actionId: 'pullRequest',
settings: {
...settings,
commitMessageAi: {
...settings.commitMessageAi!,
agentId: 'codex'
}
},
repo: null
})
).toBe(false)
expect(
hasConfiguredSourceControlTextGenerationDefaults({
actionId: 'pullRequest',
settings: {
...settings,
sourceControlAi: {
...settings.sourceControlAi!,
actions: {
...settings.sourceControlAi!.actions,
pullRequest: {
commandInputTemplate: '{basePrompt}\n\nKeep it short.'
}
}
}
},
repo: null
})
).toBe(true)
expect(
hasConfiguredSourceControlTextGenerationDefaults({
actionId: 'pullRequest',
settings: {
...settings,
sourceControlAi: {
...settings.sourceControlAi!,
actions: {
...settings.sourceControlAi!.actions,
pullRequest: {
commandInputTemplate: '{basePrompt}',
agentArgs: '--model gpt-5.5'
}
}
}
},
repo: null
})
).toBe(true)
})
})
@@ -1,854 +0,0 @@
// @vitest-environment happy-dom
import React from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { renderToStaticMarkup } from 'react-dom/server'
import { fireEvent, render } from '@testing-library/react'
import { CommitArea } from './commit-area'
import { ConflictSummaryCard, OperationBanner } from '../listing/conflict-status-cards'
import { handleSourceControlCommitShortcut } from './commit-shortcut'
import { resolveCommitAreaPrimaryAction } from '../review/source-control-primary-action'
import type { PrimaryActionInputs } from '../review/source-control-primary-action-types'
import { resolveDropdownItems } from '../panel/source-control-dropdown-items'
import type { DropdownActionKind } from '../../../../../../shared/source-control/dropdown/source-control-dropdown-item-types'
import { TooltipProvider } from '@/components/ui/tooltip'
import { deriveSourceControlPushRecovery } from '../sync/push-recovery'
vi.mock('@/components/ui/tooltip', () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipContent: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div className={className}>{children}</div>
),
TooltipProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>
}))
afterEach(() => {
vi.unstubAllGlobals()
})
function setUserAgent(userAgent: string): void {
vi.stubGlobal('navigator', { userAgent })
}
function buildInputs(overrides: Partial<PrimaryActionInputs> = {}): PrimaryActionInputs {
return {
stagedCount: 1,
hasUnstagedChanges: false,
hasStageableChanges: false,
hasPartiallyStagedChanges: false,
hasMessage: true,
hasUnresolvedConflicts: false,
isCommitting: false,
isRemoteOperationActive: false,
upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 },
...overrides
}
}
function baseProps(overrides: Partial<PrimaryActionInputs> = {}) {
const inputs = buildInputs(overrides)
return {
worktreeId: 'wt-1',
groupId: 'group-1',
commitMessage: 'feat: add commit area',
commitError: null as string | null,
commitFailureRecoveryPrompt: null as string | null,
pushRecovery: null as ReturnType<typeof deriveSourceControlPushRecovery>,
remoteActionError: null as string | null,
isCommitting: inputs.isCommitting,
isFixingCommitFailureWithAI: false,
isFixingPushFailureWithAI: false,
sourceControlAiActionsVisible: true,
aiAgentConfigured: false,
isGenerating: false,
generateError: null as string | null,
stagedCount: inputs.stagedCount,
hasPartiallyStagedChanges: inputs.hasPartiallyStagedChanges,
hasUnresolvedConflicts: inputs.hasUnresolvedConflicts,
isRemoteOperationActive: inputs.isRemoteOperationActive,
inFlightRemoteOpKind: inputs.inFlightRemoteOpKind ?? null,
primaryAction: resolveCommitAreaPrimaryAction(inputs),
dropdownItems: resolveDropdownItems(inputs),
onCommitMessageChange: vi.fn(),
onGenerate: vi.fn(),
onCancelGenerate: vi.fn(),
onFixCommitFailureWithAI: vi.fn(),
onFixPushFailureWithAI: vi.fn(),
onPrimaryAction: vi.fn(),
onDropdownAction: vi.fn() as (kind: DropdownActionKind) => void
}
}
function buildPushRecovery(
rawError: string
): NonNullable<ReturnType<typeof deriveSourceControlPushRecovery>> {
const recovery = deriveSourceControlPushRecovery({
actionError: {
kind: 'push',
message: 'Push blocked',
rawError,
branchName: 'main',
worktreePath: '/repo',
entriesSnapshot: [],
entriesSnapshotTotalCount: 0,
sequence: 1
},
currentBranchName: 'main',
currentSequence: 1
})
if (!recovery) {
throw new Error('push recovery was not derived')
}
return recovery
}
function renderCommitArea(props: Parameters<typeof CommitArea>[0]): string {
return renderToStaticMarkup(
<TooltipProvider>
<CommitArea {...props} />
</TooltipProvider>
)
}
function firstButton(markup: string): string {
const button = [...markup.matchAll(/<button\b[\s\S]*?<\/button>/g)]
.map((match) => match[0])
.find((entry) => entry.includes('data-slot="button"'))
if (!button) {
throw new Error('button not found')
}
return button
}
function buttonContaining(markup: string, label: string): string {
const buttons = markup.match(/<button\b[\s\S]*?<\/button>/g) ?? []
const button = buttons.find((candidate) => candidate.includes(label))
if (!button) {
throw new Error(`button not found: ${label}`)
}
return button
}
function textarea(markup: string): string {
const match = markup.match(/<textarea\b[\s\S]*?<\/textarea>/)
if (!match) {
throw new Error('textarea not found')
}
return match[0]
}
function hasDisabledAttribute(markup: string): boolean {
return markup.includes(' disabled=""')
}
describe('CommitArea', () => {
it('disables the primary button when no staged files', () => {
expect(hasDisabledAttribute(firstButton(renderCommitArea(baseProps({ stagedCount: 0 }))))).toBe(
true
)
})
it('disables the primary button when the commit message is empty', () => {
const props = baseProps({ hasMessage: false })
expect(
hasDisabledAttribute(firstButton(renderCommitArea({ ...props, commitMessage: ' ' })))
).toBe(true)
})
it('disables the primary button when unresolved conflicts exist', () => {
expect(
hasDisabledAttribute(
firstButton(renderCommitArea(baseProps({ hasUnresolvedConflicts: true })))
)
).toBe(true)
})
it('enables the primary button when staged + message + no conflicts', () => {
expect(hasDisabledAttribute(firstButton(renderCommitArea(baseProps())))).toBe(false)
})
it('renders the Commit shortcut key indicator (⌘Enter) in primary button tooltip on macOS', () => {
const props = baseProps()
setUserAgent('Macintosh')
const markupMac = renderCommitArea({
...props,
primaryAction: { kind: 'commit', disabled: false, label: 'Commit', title: 'Commit changes' }
})
expect(markupMac).toContain('Commit changes')
expect(markupMac).toContain('⌘')
expect(markupMac).toContain('Enter')
})
it('renders the Commit shortcut key indicator (Ctrl+Enter) in primary button tooltip on Windows/Linux', () => {
const props = baseProps()
setUserAgent('Windows NT')
const markupWin = renderCommitArea({
...props,
primaryAction: { kind: 'commit', disabled: false, label: 'Commit', title: 'Commit changes' }
})
expect(markupWin).toContain('Commit changes')
expect(markupWin).toContain('Ctrl')
expect(markupWin).toContain('+')
expect(markupWin).toContain('Enter')
})
it('renders no tooltip on enabled Stage All — the label already states the action', () => {
const props = baseProps()
const markup = renderCommitArea({
...props,
primaryAction: {
kind: 'stage',
disabled: false,
label: 'Stage All',
title: 'Stage all changes'
}
})
expect(firstButton(markup)).not.toContain('title=')
expect(markup).not.toContain('Stage all changes')
})
it('renders the disabled reason in the primary button tooltip', () => {
const props = baseProps()
const markup = renderCommitArea({
...props,
primaryAction: {
kind: 'commit',
disabled: true,
label: 'Commit',
title: 'Enter a commit message to commit'
}
})
expect(markup).toContain('Enter a commit message to commit')
})
it('only handles Cmd+Enter when focus is within the Source Control sidebar', () => {
setUserAgent('Macintosh')
const onPrimaryAction = vi.fn()
const primaryAction = {
kind: 'commit' as const,
disabled: false
}
const { getByTestId } = render(
<>
<div
data-testid="source-control-sidebar"
onKeyDown={(event) =>
handleSourceControlCommitShortcut(event, primaryAction, onPrimaryAction)
}
>
<button type="button" data-testid="inside-sidebar">
Inside
</button>
</div>
<button type="button" data-testid="outside-sidebar">
Outside
</button>
</>
)
const outside = getByTestId('outside-sidebar')
outside.focus()
fireEvent.keyDown(outside, { key: 'Enter', metaKey: true })
expect(onPrimaryAction).not.toHaveBeenCalled()
const inside = getByTestId('inside-sidebar')
inside.focus()
fireEvent.keyDown(inside, { key: 'Enter', metaKey: true })
expect(onPrimaryAction).toHaveBeenCalledTimes(1)
})
it('handles Ctrl+Enter, but not Cmd+Enter, inside the sidebar on Windows/Linux', () => {
setUserAgent('Linux')
const onPrimaryAction = vi.fn()
const primaryAction = {
kind: 'commit' as const,
disabled: false
}
const { getByRole } = render(
<button
type="button"
onKeyDown={(event) =>
handleSourceControlCommitShortcut(event, primaryAction, onPrimaryAction)
}
>
Commit scope
</button>
)
const target = getByRole('button', { name: 'Commit scope' })
fireEvent.keyDown(target, { key: 'Enter', metaKey: true })
expect(onPrimaryAction).not.toHaveBeenCalled()
fireEvent.keyDown(target, { key: 'Enter', ctrlKey: true })
expect(onPrimaryAction).toHaveBeenCalledTimes(1)
})
it('does not render the Commit shortcut keys inside the primary button tooltip when the action is not commit', () => {
const props = baseProps()
const markup = renderCommitArea({
...props,
primaryAction: { kind: 'push', disabled: false, label: 'Push', title: 'Push changes' }
})
expect(markup).not.toContain('Enter')
})
it('disables the textarea while the commit is in flight', () => {
const markup = renderCommitArea({
...baseProps({ isCommitting: true }),
isCommitting: true
})
expect(hasDisabledAttribute(textarea(markup))).toBe(true)
})
it('disables the textarea when no files are staged', () => {
expect(hasDisabledAttribute(textarea(renderCommitArea(baseProps({ stagedCount: 0 }))))).toBe(
true
)
})
it('disables the textarea when unresolved conflicts exist', () => {
expect(
hasDisabledAttribute(textarea(renderCommitArea(baseProps({ hasUnresolvedConflicts: true }))))
).toBe(true)
})
it('keeps the textarea enabled when staged files need a commit message', () => {
const props = baseProps({ hasMessage: false })
expect(hasDisabledAttribute(textarea(renderCommitArea({ ...props, commitMessage: '' })))).toBe(
false
)
})
it('clears the message and keeps error hidden after a successful commit lifecycle', () => {
const markup = renderCommitArea({ ...baseProps(), commitMessage: '' })
expect(textarea(markup)).toContain('></textarea>')
expect(markup).not.toContain('commit-area-error')
})
it('preserves the message and shows the summary after a failed commit lifecycle', () => {
const markup = renderCommitArea({
...baseProps(),
commitError: 'pre-commit hook failed'
})
expect(textarea(markup)).toContain('feat: add commit area')
expect(markup).toContain('Pre-commit hook failed.')
})
it('locks the primary button while the commit is in flight', () => {
const props = baseProps({ isCommitting: true })
expect(
hasDisabledAttribute(firstButton(renderCommitArea({ ...props, isCommitting: true })))
).toBe(true)
})
it('shows a compact summary and not raw multiline text when the commit fails', () => {
const raw = 'husky - pre-commit hook\neslint found 2 errors\nfull lint output line'
const markup = renderCommitArea({ ...baseProps(), commitError: raw })
expect(markup).toContain('id="commit-area-error"')
expect(markup).toContain('role="alert"')
expect(markup).toContain('aria-live="polite"')
expect(markup).toContain('Lint failed during commit.')
expect(markup).not.toContain('full lint output line')
expect(markup).toContain('Fix')
expect(markup).toContain('aria-label="Choose agent to fix commit failure"')
expect(markup).toContain('Details')
})
it('disables the commit failure fix action while an AI launch is in progress', () => {
const markup = renderCommitArea({
...baseProps(),
commitError: 'husky - pre-commit hook failed',
commitFailureRecoveryPrompt: 'Fix this commit failure.',
isFixingCommitFailureWithAI: true
})
const button = [...markup.matchAll(/<button\b[\s\S]*?<\/button>/g)]
.map((match) => match[0])
.find((entry) => entry.includes('aria-label="Fix commit failure with AI"'))
expect(button).toBeDefined()
expect(button).toContain('disabled=""')
expect(button).toContain('animate-spin')
})
it('hides commit failure AI actions when Source Control AI actions are hidden', () => {
const markup = renderCommitArea({
...baseProps(),
commitError: 'husky - pre-commit hook failed',
commitFailureRecoveryPrompt: 'Fix this commit failure.',
sourceControlAiActionsVisible: false
})
expect(markup).not.toContain('AI Fix')
expect(markup).not.toContain('Fix commit failure with AI')
expect(markup).toContain('Commit blocked')
})
it('enables the agent picker when commit failure context is available', () => {
const markup = renderCommitArea({
...baseProps(),
commitError: 'husky - pre-commit hook failed',
commitFailureRecoveryPrompt: 'Fix this commit failure.'
})
const picker = [...markup.matchAll(/<button\b[\s\S]*?<\/button>/g)]
.map((match) => match[0])
.find((entry) => entry.includes('aria-label="Choose agent to fix commit failure"'))
expect(picker).toBeDefined()
expect(picker).not.toContain('disabled=""')
expect(picker).toContain('lucide-chevron-down')
})
it('omits the details trigger when the raw error matches the summary', () => {
const markup = renderCommitArea({ ...baseProps(), commitError: 'nothing to commit' })
expect(markup).toContain('nothing to commit')
expect(markup).not.toContain('Details')
})
it('shows an inline error message when a remote action fails', () => {
const markup = renderCommitArea({
...baseProps(),
remoteActionError: 'Fetch failed. network timeout'
})
expect(markup).toContain('Fetch failed. network timeout')
expect(markup).toContain('commit-area-remote-error')
})
it('shows a compact push hook summary and AI fix action when push fails on a hook', () => {
const raw =
"error: failed to push some refs to 'origin'\nhusky - pre-push hook exited with code 1\neslint found 2 errors"
const markup = renderCommitArea({
...baseProps(),
pushRecovery: buildPushRecovery(raw)
})
expect(markup).toContain('id="commit-area-push-error"')
expect(markup).toContain('Push blocked')
expect(markup).toContain('Lint failed during push.')
expect(markup).not.toContain('eslint found 2 errors')
expect(markup).toContain('aria-label="Fix push failure with AI"')
expect(markup).toContain('Details')
})
it('hides push failure AI actions when Source Control AI actions are hidden', () => {
const markup = renderCommitArea({
...baseProps(),
pushRecovery: buildPushRecovery('husky - pre-push hook failed'),
sourceControlAiActionsVisible: false
})
expect(markup).not.toContain('Fix push failure with AI')
expect(markup).toContain('Push blocked')
})
it('formats pull policy errors with command options', () => {
const markup = renderCommitArea({
...baseProps(),
remoteActionError:
'Pull needs a Git pull policy for divergent branches. Configure one for this repository or host, then try again: git config pull.rebase false (merge), git config pull.rebase true (rebase), or git config pull.ff only (fast-forward only).'
})
expect(markup).toContain('Pull needs a policy')
expect(markup).toContain('Diverged')
expect(markup).toContain('git config pull.rebase false')
expect(markup).toContain('git config pull.rebase true')
expect(markup).toContain('git config pull.ff only')
expect(markup).toContain('aria-label="Copy merge pull policy command"')
expect(markup).toContain('commit-area-remote-error')
})
it('keeps generation errors separate from commit and remote errors', () => {
const markup = renderCommitArea({
...baseProps(),
generateError: 'No staged changes to summarize.'
})
expect(markup).toContain('No staged changes to summarize.')
expect(markup).toContain('aria-describedby="commit-area-generate-error"')
})
it('keeps all visible errors linked to the textarea', () => {
const markup = renderCommitArea({
...baseProps(),
commitError: 'pre-commit hook failed',
remoteActionError: 'Fetch failed.',
generateError: 'No staged changes.'
})
expect(markup).toContain(
'aria-describedby="commit-area-error commit-area-remote-error commit-area-generate-error"'
)
})
it('keeps the primary button labelled Commit when the tree is staged, even with commits to push', () => {
const markup = renderCommitArea(
baseProps({
stagedCount: 1,
hasMessage: true,
upstreamStatus: { hasUpstream: true, ahead: 1, behind: 0 }
})
)
expect(firstButton(markup)).toContain('Commit')
expect(firstButton(markup)).not.toContain('Commit &amp; Push')
})
it('does not show a spinner on a plain Commit primary when a dropdown remote op is running', () => {
const markup = renderCommitArea(
baseProps({
stagedCount: 1,
hasMessage: false,
upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 },
isCommitting: false,
isRemoteOperationActive: true
})
)
expect(firstButton(markup)).not.toContain('animate-spin')
})
it('shows a spinner on a remote primary while the matching remote op is active', () => {
const markup = renderCommitArea(
baseProps({
stagedCount: 0,
hasMessage: false,
upstreamStatus: { hasUpstream: true, ahead: 1, behind: 0 },
isRemoteOperationActive: true,
inFlightRemoteOpKind: 'push'
})
)
expect(firstButton(markup)).toContain('animate-spin')
})
it('mirrors a dropdown-triggered Sync on the primary button while it runs', () => {
const markup = renderCommitArea(
baseProps({
stagedCount: 0,
hasMessage: false,
upstreamStatus: { hasUpstream: true, ahead: 3, behind: 0 },
isRemoteOperationActive: true,
inFlightRemoteOpKind: 'sync'
})
)
expect(firstButton(markup)).toContain('Sync')
expect(firstButton(markup)).not.toContain('Push')
expect(firstButton(markup)).toContain('animate-spin')
})
it('does not spin or relabel the primary when a dropdown Fetch is in flight', () => {
const markup = renderCommitArea(
baseProps({
stagedCount: 0,
hasMessage: false,
upstreamStatus: { hasUpstream: true, ahead: 1, behind: 0 },
isRemoteOperationActive: true,
inFlightRemoteOpKind: 'fetch'
})
)
expect(firstButton(markup)).toContain('Push')
expect(hasDisabledAttribute(firstButton(markup))).toBe(true)
expect(firstButton(markup)).not.toContain('animate-spin')
})
it('renders a leading checkmark on a Commit primary', () => {
expect(firstButton(renderCommitArea(baseProps()))).toContain('lucide-check')
})
it('omits the checkmark when the primary is a remote action', () => {
const markup = renderCommitArea(
baseProps({
stagedCount: 0,
hasMessage: false,
upstreamStatus: { hasUpstream: true, ahead: 1, behind: 0 }
})
)
expect(firstButton(markup)).not.toContain('lucide-check')
})
it('replaces the checkmark with a spinner while the commit is in flight', () => {
const props = baseProps({ isCommitting: true })
const button = firstButton(renderCommitArea({ ...props, isCommitting: true }))
expect(button).toContain('animate-spin')
expect(button).not.toContain('lucide-check')
})
it('keeps Stage All as the commit-area primary when review prep can stage changes', () => {
const input = buildInputs({
stagedCount: 0,
hasUnstagedChanges: true,
hasStageableChanges: true,
hasPartiallyStagedChanges: false,
hasMessage: false,
upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 },
hostedReviewCreation: {
provider: 'github',
review: null,
canCreate: false,
blockedReason: 'dirty',
nextAction: 'commit',
reviewLookupOutcome: 'not_found'
}
})
const markup = renderCommitArea(baseProps(input))
const stageAllButton = firstButton(markup)
expect(stageAllButton).toContain('Stage All')
expect(stageAllButton).toContain('data-variant="outline"')
expect(stageAllButton).not.toContain('disabled=""')
expect(stageAllButton).toContain('lucide-plus')
expect(stageAllButton).toContain('rounded-r-none')
expect(stageAllButton).not.toContain('title=')
expect(markup).toContain('aria-label="More commit and remote actions"')
expect(
(markup.match(/<button\b[\s\S]*?<\/button>/g) ?? []).some((button) =>
button.includes('Commit</button>')
)
).toBe(false)
})
it('keeps Push as the commit-area primary when review prep can create after pushing', () => {
const input = buildInputs({
stagedCount: 0,
hasUnstagedChanges: false,
hasStageableChanges: false,
hasPartiallyStagedChanges: false,
hasMessage: false,
upstreamStatus: { hasUpstream: true, ahead: 2, behind: 0 },
hostedReviewCreation: {
provider: 'github',
review: null,
canCreate: false,
blockedReason: 'needs_push',
nextAction: 'push',
reviewLookupOutcome: 'not_found'
}
})
const markup = renderCommitArea(baseProps(input))
const pushButton = firstButton(markup)
expect(pushButton).toContain('Push')
expect(pushButton).toContain('data-variant="outline"')
expect(pushButton).not.toContain('disabled=""')
expect(pushButton).toContain('lucide-arrow-up')
expect(pushButton).toContain('rounded-r-none')
expect(markup).toContain('aria-label="More commit and remote actions"')
})
it('hides the composer generate affordance while Create PR intent is in flight', () => {
const markup = renderCommitArea({
...baseProps(),
aiAgentConfigured: true,
isGenerating: true,
isCreatePrIntentInFlight: true,
createPrIntentNotice: {
tone: 'muted',
message: 'Generating commit message…'
}
})
expect(markup).not.toContain('lucide-sparkles')
expect(markup).not.toContain('animate-spin')
expect(markup).toContain('Generating commit message…')
})
it('renders Create PR failures in the visible inline notice', () => {
const markup = renderCommitArea({
...baseProps(),
createPrIntentNotice: {
tone: 'destructive',
message: 'Create PR failed: push this branch first.'
}
})
expect(markup).toContain('id="commit-area-create-pr-intent"')
expect(markup).toContain('role="alert"')
expect(markup).toContain('Create PR failed: push this branch first.')
const notice = markup.match(/id="commit-area-create-pr-intent"[\s\S]*?<\/div>/)?.[0] ?? ''
expect(notice).toContain('break-words')
expect(notice).not.toContain('truncate')
})
})
describe('ConflictSummaryCard', () => {
it('shows Resolve with AI above Review conflicts', () => {
const markup = renderToStaticMarkup(
<ConflictSummaryCard
conflictOperation="rebase"
unresolvedCount={1}
sourceControlAiActionsVisible={true}
onResolveWithAI={vi.fn()}
onReview={vi.fn()}
/>
)
expect(markup.indexOf('Resolve with AI')).toBeLessThan(markup.indexOf('Review conflicts'))
})
it('shows the matching abort action for merge and rebase conflicts only', () => {
const mergeMarkup = renderToStaticMarkup(
<ConflictSummaryCard
conflictOperation="merge"
unresolvedCount={1}
sourceControlAiActionsVisible={true}
onAbortOperation={vi.fn()}
onResolveWithAI={vi.fn()}
onReview={vi.fn()}
/>
)
const rebaseMarkup = renderToStaticMarkup(
<ConflictSummaryCard
conflictOperation="rebase"
unresolvedCount={1}
sourceControlAiActionsVisible={true}
onAbortOperation={vi.fn()}
onResolveWithAI={vi.fn()}
onReview={vi.fn()}
/>
)
const cherryPickMarkup = renderToStaticMarkup(
<ConflictSummaryCard
conflictOperation="cherry-pick"
unresolvedCount={1}
sourceControlAiActionsVisible={true}
onAbortOperation={vi.fn()}
onResolveWithAI={vi.fn()}
onReview={vi.fn()}
/>
)
expect(mergeMarkup).toContain('Abort merge')
expect(mergeMarkup).not.toContain('Abort rebase')
expect(rebaseMarkup).toContain('Abort rebase')
expect(rebaseMarkup).not.toContain('Abort merge')
expect(cherryPickMarkup).not.toContain('Abort merge')
expect(cherryPickMarkup).not.toContain('Abort rebase')
})
it('renders abort actions with the quiet outline review-conflicts button treatment', () => {
const mergeMarkup = renderToStaticMarkup(
<ConflictSummaryCard
conflictOperation="merge"
unresolvedCount={1}
sourceControlAiActionsVisible={true}
onAbortOperation={vi.fn()}
onResolveWithAI={vi.fn()}
onReview={vi.fn()}
/>
)
const rebaseMarkup = renderToStaticMarkup(
<ConflictSummaryCard
conflictOperation="rebase"
unresolvedCount={1}
sourceControlAiActionsVisible={true}
onAbortOperation={vi.fn()}
onResolveWithAI={vi.fn()}
onReview={vi.fn()}
/>
)
expect(buttonContaining(mergeMarkup, 'Review conflicts')).toContain('data-variant="outline"')
expect(buttonContaining(mergeMarkup, 'Abort merge')).toContain('data-variant="outline"')
expect(buttonContaining(rebaseMarkup, 'Review conflicts')).toContain('data-variant="outline"')
expect(buttonContaining(rebaseMarkup, 'Abort rebase')).toContain('data-variant="outline"')
})
it('renders the Sparkles icon on the idle Resolve with AI button', () => {
const markup = renderToStaticMarkup(
<ConflictSummaryCard
conflictOperation="merge"
unresolvedCount={2}
sourceControlAiActionsVisible={true}
onResolveWithAI={vi.fn()}
onReview={vi.fn()}
/>
)
expect(markup).toContain('Resolve with AI')
expect(markup).toContain('lucide-sparkles')
expect(markup).not.toMatch(/\blucide-sparkle(?!s)\b/)
})
it('hides Resolve with AI when Source Control AI actions are hidden', () => {
const markup = renderToStaticMarkup(
<ConflictSummaryCard
conflictOperation="merge"
unresolvedCount={2}
sourceControlAiActionsVisible={false}
onResolveWithAI={vi.fn()}
onReview={vi.fn()}
/>
)
expect(markup).not.toContain('Resolve with AI')
expect(markup).toContain('Review conflicts')
})
})
describe('OperationBanner', () => {
it('shows abort actions for merge and rebase but not cherry-pick', () => {
const mergeMarkup = renderToStaticMarkup(
<OperationBanner conflictOperation="merge" onAbortOperation={vi.fn()} />
)
const rebaseMarkup = renderToStaticMarkup(
<OperationBanner conflictOperation="rebase" onAbortOperation={vi.fn()} />
)
const cherryPickMarkup = renderToStaticMarkup(
<OperationBanner conflictOperation="cherry-pick" onAbortOperation={vi.fn()} />
)
expect(mergeMarkup).toContain('Abort merge')
expect(rebaseMarkup).toContain('Abort rebase')
expect(cherryPickMarkup).not.toContain('Abort merge')
expect(cherryPickMarkup).not.toContain('Abort rebase')
})
it('renders abort actions with the quiet outline button treatment', () => {
const mergeMarkup = renderToStaticMarkup(
<OperationBanner conflictOperation="merge" onAbortOperation={vi.fn()} />
)
const rebaseMarkup = renderToStaticMarkup(
<OperationBanner conflictOperation="rebase" onAbortOperation={vi.fn()} />
)
expect(buttonContaining(mergeMarkup, 'Abort merge')).toContain('data-variant="outline"')
expect(buttonContaining(rebaseMarkup, 'Abort rebase')).toContain('data-variant="outline"')
})
it('offers Resolve with AI for every in-progress operation only when AI actions are visible', () => {
for (const operation of ['merge', 'rebase', 'cherry-pick'] as const) {
const visibleMarkup = renderToStaticMarkup(
<OperationBanner
conflictOperation={operation}
sourceControlAiActionsVisible
onResolveWithAI={vi.fn()}
/>
)
const hiddenMarkup = renderToStaticMarkup(
<OperationBanner
conflictOperation={operation}
sourceControlAiActionsVisible={false}
onResolveWithAI={vi.fn()}
/>
)
expect(visibleMarkup).toContain('Resolve with AI')
expect(hiddenMarkup).not.toContain('Resolve with AI')
}
})
})
it('shows waiting feedback without a publish spinner when another workspace publishes', () => {
const props = baseProps({
stagedCount: 0,
hasMessage: false,
upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 },
isRemoteOperationActive: true,
inFlightRemoteOpKind: null,
isRemoteOperationInAnotherWorktree: true
})
const { container, getByRole } = render(<CommitArea {...props} />)
expect(
getByRole('button', { name: 'Git operation running elsewhere' }).hasAttribute('disabled')
).toBe(true)
expect(container.querySelector('.animate-spin')).toBeNull()
expect(container.textContent).toContain(
'Another workspace is running a Git operation, such as push, pull, or force push.'
)
})
@@ -1,463 +0,0 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { TooltipProvider } from '@/components/ui/tooltip'
import type { GitStatusEntry } from '../../../../../../shared/git/status/git-status-types'
import type { GitBranchCompareSummary } from '../../../../../../shared/git/diff/git-diff-compare-types'
import { toBranchCompareSnapshot } from '@/store/slices/editor/git/git-status-reconciliation'
import SourceControl from '../panel/SourceControl'
const mocks = vi.hoisted(() => {
const activeRepo = {
id: 'repo-1',
path: '/repo',
displayName: 'Repo',
badgeColor: '#000',
addedAt: 0
}
const activeWorktree = {
id: 'wt-1',
repoId: 'repo-1',
path: '/repo/wt',
head: 'abcdef123',
branch: 'refs/heads/feature/open-file-highlight',
isBare: false,
isMainWorktree: false,
displayName: 'feature/open-file-highlight',
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
linkedGitLabMR: null,
linkedGitLabIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 0
}
return {
activeRepo,
activeWorktree,
isFolder: false,
state: {} as Record<string, unknown>
}
})
vi.mock('@/store/index', () => {
const useAppStore = Object.assign(
(selector?: (state: Record<string, unknown>) => unknown) =>
selector ? selector(mocks.state) : mocks.state,
{
getState: () => mocks.state
}
)
return { useAppStore }
})
vi.mock('@/store/selectors', () => ({
useActiveWorktree: () => mocks.activeWorktree,
useRepoById: (repoId: string | null) =>
repoId === mocks.activeRepo.id
? { ...mocks.activeRepo, kind: mocks.isFolder ? 'folder' : 'git' }
: null,
useWorktreeMap: () => new Map([[mocks.activeWorktree.id, mocks.activeWorktree]])
}))
vi.mock('@/components/confirmation-dialog-context', () => ({
useConfirmationDialog: () => vi.fn().mockResolvedValue(true)
}))
vi.mock('../sync/git-status-refresh', () => ({
refreshGitStatusForWorktree: vi.fn().mockResolvedValue(undefined)
}))
function gitEntry(overrides: Partial<GitStatusEntry>): GitStatusEntry {
return {
path: 'src/file.ts',
area: 'unstaged',
status: 'modified',
added: 1,
removed: 0,
...overrides
}
}
type OpenFileStub = {
id: string
worktreeId: string
relativePath: string
diffSource?: string
}
function noopAsync(value: unknown = undefined): () => Promise<unknown> {
return vi.fn().mockResolvedValue(value)
}
function resetState(overrides: Partial<Record<string, unknown>> = {}): void {
vi.clearAllMocks()
mocks.state = {
activeWorktreeId: mocks.activeWorktree.id,
activeGroupIdByWorktree: { [mocks.activeWorktree.id]: 'group-1' },
groupsByWorktree: { [mocks.activeWorktree.id]: [{ id: 'group-1', activeTabId: null }] },
repos: [mocks.activeRepo],
worktreesByRepo: { [mocks.activeRepo.id]: [mocks.activeWorktree] },
rightSidebarOpen: false,
rightSidebarTab: 'source-control',
gitStatusByWorktree: { [mocks.activeWorktree.id]: [] },
gitBranchChangesByWorktree: { [mocks.activeWorktree.id]: [] },
gitBranchCompareSummaryByWorktree: { [mocks.activeWorktree.id]: null },
gitBranchLineTotalByWorktree: {},
gitConflictOperationByWorktree: {},
remoteStatusesByWorktree: {},
isRemoteOperationActive: false,
remoteOperations: [],
settings: null,
hostedReviewCache: {},
prCache: {},
commitMessageGenerationRecords: {},
pullRequestGenerationRecords: {},
openFiles: [] as OpenFileStub[],
activeFileIdByWorktree: {},
activeTabTypeByWorktree: {},
getDiffComments: vi.fn(() => []),
updateSettings: noopAsync(),
openSettingsTarget: vi.fn(),
openSettingsPage: vi.fn(),
fetchHostedReviewForBranch: noopAsync(),
getHostedReviewCreationEligibility: noopAsync(null),
createHostedReview: noopAsync({ ok: false, error: 'not available' }),
updateWorktreeMeta: noopAsync(),
fetchPRForBranch: noopAsync(),
enqueueGitHubPRRefresh: vi.fn(),
updateRepo: noopAsync(),
setGitStatus: vi.fn(),
updateWorktreeGitIdentity: vi.fn(),
beginGitBranchCompareRequest: vi.fn(() => 'request-key'),
setGitBranchCompareResult: vi.fn(),
clearGitBranchCompare: vi.fn(),
fetchUpstreamStatus: noopAsync(),
setUpstreamStatus: vi.fn(),
pushBranch: noopAsync(),
pullBranch: noopAsync(),
fastForwardBranch: noopAsync(),
syncBranch: noopAsync(),
rebaseFromBase: noopAsync(),
fetchBranch: noopAsync(),
revealInExplorer: vi.fn(),
trackConflictPath: vi.fn(),
openDiff: vi.fn(),
openFile: vi.fn(),
setEditorViewMode: vi.fn(),
setMarkdownViewMode: vi.fn(),
setPendingEditorReveal: vi.fn(),
openConflictFile: vi.fn(),
openConflictReview: vi.fn(),
openBranchDiff: vi.fn(),
createEmptySplitGroup: vi.fn(() => 'group-2'),
openAllDiffs: vi.fn(),
openBranchAllDiffs: vi.fn(),
openCommitAllDiffs: vi.fn(),
deleteDiffComment: noopAsync(true),
clearDiffComments: noopAsync(true),
clearDiffCommentsForFile: noopAsync(true),
setScrollToDiffCommentId: vi.fn(),
setRightSidebarOpen: vi.fn(),
setRightSidebarTab: vi.fn(),
allocateCommitMessageGenerationRequestId: vi.fn(() => 'commit-generation-1'),
setCommitMessageGenerationRecord: vi.fn(),
updateCommitMessageGenerationRecord: vi.fn(),
pruneCommitMessageGenerationRecords: vi.fn(),
allocatePullRequestGenerationRequestId: vi.fn(() => 'pr-generation-1'),
setPullRequestGenerationRecord: vi.fn(),
updatePullRequestGenerationRecord: vi.fn(),
prunePullRequestGenerationRecords: vi.fn(),
...overrides
}
}
let container: HTMLDivElement
let root: Root
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
resetState()
mocks.isFolder = false
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})
afterEach(() => {
act(() => root.unmount())
container.remove()
})
function renderSourceControl(renderKey = 0): void {
act(() => {
root.render(
<TooltipProvider>
<SourceControl key={renderKey} />
</TooltipProvider>
)
})
}
function row(path: string, area: GitStatusEntry['area'] | 'branch'): HTMLDivElement | null {
return container.querySelector<HTMLDivElement>(
`[data-source-control-path="${path}"][data-source-control-area="${area}"]`
)
}
function isHighlighted(element: HTMLElement | null): boolean {
if (!element) {
return false
}
return (
element.getAttribute('data-current') === 'true' &&
element.classList.contains('bg-accent') &&
!element.classList.contains('bg-accent/60')
)
}
describe('SourceControl open-file highlight', () => {
it.each(['list', 'tree'] as const)('marks the active committed comparison in %s mode', (mode) => {
const summary: GitBranchCompareSummary = {
baseRef: 'main',
baseOid: 'base-1',
compareRef: 'feature',
headOid: 'head-1',
mergeBase: 'merge-1',
changedFiles: 2,
status: 'ready'
}
const entry = { path: 'src/file.ts', status: 'modified' as const, added: 1, removed: 0 }
resetState({
settings: { sourceControlViewMode: mode },
gitStatusByWorktree: { 'wt-1': [gitEntry({})] },
gitBranchChangesByWorktree: { 'wt-1': [entry, { ...entry, path: 'src/other.ts' }] },
gitBranchCompareSummaryByWorktree: { 'wt-1': summary }
})
renderSourceControl()
const branchRow = row(entry.path, 'branch')
expect(branchRow).not.toBeNull()
act(() => branchRow?.dispatchEvent(new MouseEvent('click', { bubbles: true })))
expect(mocks.state.openBranchDiff).toHaveBeenCalledWith(
'wt-1',
'/repo/wt',
entry,
summary,
'typescript',
{ targetGroupId: undefined, preview: true }
)
const tab = {
id: 'branch-tab',
worktreeId: 'wt-1',
relativePath: entry.path,
diffSource: 'branch',
branchCompare: toBranchCompareSnapshot(summary)
}
mocks.state.openFiles = [tab]
mocks.state.activeFileIdByWorktree = { 'wt-1': tab.id }
mocks.state.activeTabTypeByWorktree = { 'wt-1': 'editor' }
renderSourceControl(1)
expect(isHighlighted(row(entry.path, 'branch'))).toBe(true)
expect(isHighlighted(row('src/other.ts', 'branch'))).toBe(false)
expect(isHighlighted(row(entry.path, 'unstaged'))).toBe(false)
mocks.state.gitBranchCompareSummaryByWorktree = { 'wt-1': { ...summary, headOid: 'head-2' } }
renderSourceControl(2)
expect(isHighlighted(row(entry.path, 'branch'))).toBe(false)
mocks.state.gitBranchCompareSummaryByWorktree = { 'wt-1': summary }
mocks.state.activeTabTypeByWorktree = { 'wt-1': 'terminal' }
renderSourceControl(3)
expect(isHighlighted(row(entry.path, 'branch'))).toBe(false)
mocks.state.activeTabTypeByWorktree = { 'wt-1': 'editor' }
mocks.state.openFiles = [{ ...tab, worktreeId: 'another-workspace' }]
renderSourceControl(4)
expect(isHighlighted(row(entry.path, 'branch'))).toBe(false)
mocks.state.openFiles = [{ ...tab, diffSource: 'combined-branch' }]
renderSourceControl(5)
expect(isHighlighted(row(entry.path, 'branch'))).toBe(false)
})
it('renders no committed row for a folder workspace without a branch summary', () => {
mocks.isFolder = true
resetState({
gitBranchChangesByWorktree: { 'wt-1': [{ path: 'src/file.ts', status: 'modified' }] }
})
renderSourceControl()
expect(row('src/file.ts', 'branch')).toBeNull()
expect(mocks.state.beginGitBranchCompareRequest).not.toHaveBeenCalled()
})
it('highlights the row whose unstaged diff is the active editor tab', () => {
resetState({
gitStatusByWorktree: {
[mocks.activeWorktree.id]: [
gitEntry({ path: 'src/file.ts', area: 'unstaged' }),
gitEntry({ path: 'src/other.ts', area: 'unstaged' })
]
},
openFiles: [
{
id: 'tab-1',
worktreeId: mocks.activeWorktree.id,
relativePath: 'src/file.ts',
diffSource: 'unstaged'
}
],
activeFileIdByWorktree: { [mocks.activeWorktree.id]: 'tab-1' },
activeTabTypeByWorktree: { [mocks.activeWorktree.id]: 'editor' }
})
renderSourceControl()
expect(isHighlighted(row('src/file.ts', 'unstaged'))).toBe(true)
expect(isHighlighted(row('src/other.ts', 'unstaged'))).toBe(false)
})
it('matches by area so only the staged row of a partially staged file lights up', () => {
resetState({
gitStatusByWorktree: {
[mocks.activeWorktree.id]: [
gitEntry({ path: 'src/file.ts', area: 'staged' }),
gitEntry({ path: 'src/file.ts', area: 'unstaged' })
]
},
openFiles: [
{
id: 'tab-1',
worktreeId: mocks.activeWorktree.id,
relativePath: 'src/file.ts',
diffSource: 'staged'
}
],
activeFileIdByWorktree: { [mocks.activeWorktree.id]: 'tab-1' },
activeTabTypeByWorktree: { [mocks.activeWorktree.id]: 'editor' }
})
renderSourceControl()
expect(isHighlighted(row('src/file.ts', 'staged'))).toBe(true)
expect(isHighlighted(row('src/file.ts', 'unstaged'))).toBe(false)
})
it('highlights the untracked row when an untracked file is open as an unstaged diff', () => {
resetState({
gitStatusByWorktree: {
[mocks.activeWorktree.id]: [
gitEntry({ path: 'src/new.ts', area: 'untracked', status: 'untracked' })
]
},
openFiles: [
{
id: 'tab-1',
worktreeId: mocks.activeWorktree.id,
relativePath: 'src/new.ts',
diffSource: 'unstaged'
}
],
activeFileIdByWorktree: { [mocks.activeWorktree.id]: 'tab-1' },
activeTabTypeByWorktree: { [mocks.activeWorktree.id]: 'editor' }
})
renderSourceControl()
expect(isHighlighted(row('src/new.ts', 'untracked'))).toBe(true)
})
it('does not highlight any row when the visible tab is not an editor', () => {
resetState({
gitStatusByWorktree: {
[mocks.activeWorktree.id]: [gitEntry({ path: 'src/file.ts', area: 'unstaged' })]
},
openFiles: [
{
id: 'tab-1',
worktreeId: mocks.activeWorktree.id,
relativePath: 'src/file.ts',
diffSource: 'unstaged'
}
],
activeFileIdByWorktree: { [mocks.activeWorktree.id]: 'tab-1' },
activeTabTypeByWorktree: { [mocks.activeWorktree.id]: 'terminal' }
})
renderSourceControl()
expect(isHighlighted(row('src/file.ts', 'unstaged'))).toBe(false)
})
it('does not highlight pending rows for branch compare tabs with the same path', () => {
resetState({
gitStatusByWorktree: {
[mocks.activeWorktree.id]: [gitEntry({ path: 'src/file.ts', area: 'unstaged' })]
},
openFiles: [
{
id: 'tab-1',
worktreeId: mocks.activeWorktree.id,
relativePath: 'src/file.ts',
diffSource: 'branch'
}
],
activeFileIdByWorktree: { [mocks.activeWorktree.id]: 'tab-1' },
activeTabTypeByWorktree: { [mocks.activeWorktree.id]: 'editor' }
})
renderSourceControl()
expect(isHighlighted(row('src/file.ts', 'unstaged'))).toBe(false)
})
it('falls back to a staged-only row for an ordinary editor tab', () => {
resetState({
gitStatusByWorktree: {
[mocks.activeWorktree.id]: [gitEntry({ path: 'src/file.ts', area: 'staged' })]
},
openFiles: [
{
id: 'tab-1',
worktreeId: mocks.activeWorktree.id,
relativePath: 'src/file.ts'
}
],
activeFileIdByWorktree: { [mocks.activeWorktree.id]: 'tab-1' },
activeTabTypeByWorktree: { [mocks.activeWorktree.id]: 'editor' }
})
renderSourceControl()
expect(isHighlighted(row('src/file.ts', 'staged'))).toBe(true)
})
it('falls back to the visible staged row when the working-tree section is collapsed', () => {
resetState({
gitStatusByWorktree: {
[mocks.activeWorktree.id]: [
gitEntry({ path: 'src/file.ts', area: 'staged' }),
gitEntry({ path: 'src/file.ts', area: 'unstaged' })
]
},
openFiles: [
{
id: 'tab-1',
worktreeId: mocks.activeWorktree.id,
relativePath: 'src/file.ts'
}
],
activeFileIdByWorktree: { [mocks.activeWorktree.id]: 'tab-1' },
activeTabTypeByWorktree: { [mocks.activeWorktree.id]: 'editor' }
})
renderSourceControl()
const changesHeader = Array.from(container.querySelectorAll('button')).find((button) =>
button.textContent?.includes('Changes')
)
expect(changesHeader).toBeTruthy()
act(() => {
changesHeader?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
expect(row('src/file.ts', 'unstaged')).toBeNull()
expect(isHighlighted(row('src/file.ts', 'staged'))).toBe(true)
})
})
@@ -1,420 +0,0 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { TooltipProvider } from '@/components/ui/tooltip'
import type { GitStatusEntry } from '../../../../../../shared/git/status/git-status-types'
import SourceControl from '../panel/SourceControl'
import {
VIRTUALIZED_LIST_MIN_ROWS,
VIRTUALIZED_LIST_OVERSCAN,
VIRTUALIZED_LIST_ROW_HEIGHT_PX
} from '@/components/virtualized-list'
const mocks = vi.hoisted(() => {
const activeRepo = {
id: 'repo-1',
path: '/repo',
displayName: 'Repo',
badgeColor: '#000',
addedAt: 0
}
const activeWorktree = {
id: 'wt-1',
repoId: 'repo-1',
path: '/repo/wt',
head: 'abcdef123',
branch: 'refs/heads/feature/virtual-list',
isBare: false,
isMainWorktree: false,
displayName: 'feature/virtual-list',
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
linkedGitLabMR: null,
linkedGitLabIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 0
}
return {
activeRepo,
activeWorktree,
state: {} as Record<string, unknown>
}
})
vi.mock('@/store/index', () => {
const useAppStore = Object.assign(
(selector?: (state: Record<string, unknown>) => unknown) =>
selector ? selector(mocks.state) : mocks.state,
{
getState: () => mocks.state
}
)
return { useAppStore }
})
vi.mock('@/store/selectors', () => ({
useActiveWorktree: () => mocks.activeWorktree,
useRepoById: (repoId: string | null) =>
repoId === mocks.activeRepo.id ? mocks.activeRepo : null,
useWorktreeMap: () => new Map([[mocks.activeWorktree.id, mocks.activeWorktree]])
}))
vi.mock('@/components/confirmation-dialog-context', () => ({
useConfirmationDialog: () => vi.fn().mockResolvedValue(true)
}))
vi.mock('../sync/git-status-refresh', () => ({
refreshGitStatusForWorktree: vi.fn().mockResolvedValue(undefined)
}))
const VIEWPORT_HEIGHT_PX = 600
const MAX_MOUNTED_ROWS =
Math.ceil(VIEWPORT_HEIGHT_PX / VIRTUALIZED_LIST_ROW_HEIGHT_PX) + 2 * VIRTUALIZED_LIST_OVERSCAN + 2
function gitEntry(overrides: Partial<GitStatusEntry>): GitStatusEntry {
return {
path: 'src/file.ts',
area: 'unstaged',
status: 'modified',
added: 1,
removed: 0,
...overrides
}
}
function manyEntries(count: number): GitStatusEntry[] {
return Array.from({ length: count }, (_, index) =>
gitEntry({ path: `src/file-${String(index).padStart(3, '0')}.ts` })
)
}
function noopAsync(value: unknown = undefined): () => Promise<unknown> {
return vi.fn().mockResolvedValue(value)
}
function resetState(overrides: Partial<Record<string, unknown>> = {}): void {
vi.clearAllMocks()
mocks.state = {
activeWorktreeId: mocks.activeWorktree.id,
activeGroupIdByWorktree: { [mocks.activeWorktree.id]: 'group-1' },
groupsByWorktree: { [mocks.activeWorktree.id]: [{ id: 'group-1', activeTabId: null }] },
repos: [mocks.activeRepo],
worktreesByRepo: { [mocks.activeRepo.id]: [mocks.activeWorktree] },
rightSidebarOpen: false,
rightSidebarTab: 'source-control',
gitStatusByWorktree: { [mocks.activeWorktree.id]: [] },
gitBranchChangesByWorktree: { [mocks.activeWorktree.id]: [] },
gitBranchCompareSummaryByWorktree: { [mocks.activeWorktree.id]: null },
gitBranchLineTotalByWorktree: {},
gitConflictOperationByWorktree: {},
remoteStatusesByWorktree: {},
isRemoteOperationActive: false,
remoteOperations: [],
settings: null,
hostedReviewCache: {},
prCache: {},
commitMessageGenerationRecords: {},
pullRequestGenerationRecords: {},
openFiles: [],
activeFileIdByWorktree: {},
activeTabTypeByWorktree: {},
getDiffComments: vi.fn(() => []),
updateSettings: noopAsync(),
openSettingsTarget: vi.fn(),
openSettingsPage: vi.fn(),
fetchHostedReviewForBranch: noopAsync(),
getHostedReviewCreationEligibility: noopAsync(null),
createHostedReview: noopAsync({ ok: false, error: 'not available' }),
updateWorktreeMeta: noopAsync(),
fetchPRForBranch: noopAsync(),
enqueueGitHubPRRefresh: vi.fn(),
updateRepo: noopAsync(),
setGitStatus: vi.fn(),
updateWorktreeGitIdentity: vi.fn(),
beginGitBranchCompareRequest: vi.fn(() => 'request-key'),
setGitBranchCompareResult: vi.fn(),
clearGitBranchCompare: vi.fn(),
fetchUpstreamStatus: noopAsync(),
setUpstreamStatus: vi.fn(),
pushBranch: noopAsync(),
pullBranch: noopAsync(),
fastForwardBranch: noopAsync(),
syncBranch: noopAsync(),
rebaseFromBase: noopAsync(),
fetchBranch: noopAsync(),
revealInExplorer: vi.fn(),
trackConflictPath: vi.fn(),
openDiff: vi.fn(),
openFile: vi.fn(),
setEditorViewMode: vi.fn(),
setMarkdownViewMode: vi.fn(),
setPendingEditorReveal: vi.fn(),
openConflictFile: vi.fn(),
openConflictReview: vi.fn(),
openBranchDiff: vi.fn(),
createEmptySplitGroup: vi.fn(() => 'group-2'),
openAllDiffs: vi.fn(),
openBranchAllDiffs: vi.fn(),
openCommitAllDiffs: vi.fn(),
deleteDiffComment: noopAsync(true),
clearDiffComments: noopAsync(true),
clearDiffCommentsForFile: noopAsync(true),
setScrollToDiffCommentId: vi.fn(),
setRightSidebarOpen: vi.fn(),
setRightSidebarTab: vi.fn(),
allocateCommitMessageGenerationRequestId: vi.fn(() => 'commit-generation-1'),
setCommitMessageGenerationRecord: vi.fn(),
updateCommitMessageGenerationRecord: vi.fn(),
pruneCommitMessageGenerationRecords: vi.fn(),
allocatePullRequestGenerationRequestId: vi.fn(() => 'pr-generation-1'),
setPullRequestGenerationRecord: vi.fn(),
updatePullRequestGenerationRecord: vi.fn(),
prunePullRequestGenerationRecords: vi.fn(),
...overrides
}
}
let container: HTMLDivElement
let root: Root
class NoopResizeObserver {
observe(): void {}
unobserve(): void {}
disconnect(): void {}
}
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
resetState()
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
// happy-dom has no layout; supply fixed viewport/row sizes and disable observer measurements.
vi.stubGlobal('ResizeObserver', NoopResizeObserver)
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation(function (
this: HTMLElement
) {
return this.classList.contains('overflow-auto')
? VIEWPORT_HEIGHT_PX
: VIRTUALIZED_LIST_ROW_HEIGHT_PX
})
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (this: Element) {
// Only the viewport stays at zero; child tops follow scrollTop for scroll-margin calculations.
const isScroller = this.classList.contains('overflow-auto')
const scroller = isScroller ? null : this.closest('.overflow-auto')
const top = isScroller ? 0 : -(scroller?.scrollTop ?? 0)
return new DOMRect(0, top, 240, VIRTUALIZED_LIST_ROW_HEIGHT_PX)
})
})
afterEach(() => {
act(() => root.unmount())
container.remove()
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
function renderSourceControl(): void {
act(() => {
root.render(
<TooltipProvider>
<SourceControl />
</TooltipProvider>
)
})
}
function scroller(): HTMLDivElement {
const element = container.querySelector<HTMLDivElement>('.overflow-auto')
if (!element) {
throw new Error('source control scroller not found')
}
return element
}
function scrollTo(offset: number): void {
const element = scroller()
// Bypass happy-dom clamping scrollTop against its zero-height layout.
Object.defineProperty(element, 'scrollTop', {
configurable: true,
writable: true,
value: offset
})
act(() => {
element.dispatchEvent(new Event('scroll'))
})
}
function mountedRows(): HTMLDivElement[] {
return Array.from(
container.querySelectorAll<HTMLDivElement>('[data-testid="source-control-entry"]')
)
}
function row(path: string): HTMLDivElement | null {
return container.querySelector<HTMLDivElement>(
`[data-source-control-path="${path}"][data-source-control-area="unstaged"]`
)
}
function virtualList(): HTMLDivElement | null {
return container.querySelector<HTMLDivElement>('[data-testid="virtualized-list"]')
}
describe('SourceControl virtualized changed-files list', () => {
it('bounds mounted rows by viewport + overscan with 500 entries', () => {
resetState({
gitStatusByWorktree: { [mocks.activeWorktree.id]: manyEntries(500) }
})
renderSourceControl()
expect(virtualList()).toBeTruthy()
const mounted = mountedRows().length
expect(mounted).toBeGreaterThan(0)
expect(mounted).toBeLessThanOrEqual(MAX_MOUNTED_ROWS)
})
it('windows rows while scrolling instead of mounting the tail', () => {
resetState({
gitStatusByWorktree: { [mocks.activeWorktree.id]: manyEntries(500) }
})
renderSourceControl()
expect(row('src/file-000.ts')).toBeTruthy()
expect(row('src/file-250.ts')).toBeNull()
scrollTo(250 * VIRTUALIZED_LIST_ROW_HEIGHT_PX)
expect(row('src/file-250.ts')).toBeTruthy()
expect(row('src/file-000.ts')).toBeNull()
expect(mountedRows().length).toBeLessThanOrEqual(MAX_MOUNTED_ROWS)
})
it('keeps row selection across the virtualized boundary', () => {
resetState({
gitStatusByWorktree: { [mocks.activeWorktree.id]: manyEntries(500) }
})
renderSourceControl()
const target = row('src/file-000.ts')
expect(target).toBeTruthy()
act(() => {
target?.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true }))
})
expect(row('src/file-000.ts')?.className).toContain('bg-accent/60')
expect(container.textContent).toContain('1 selected')
scrollTo(240 * VIRTUALIZED_LIST_ROW_HEIGHT_PX)
expect(row('src/file-000.ts')).toBeNull()
expect(container.textContent).toContain('1 selected')
scrollTo(0)
expect(row('src/file-000.ts')?.className).toContain('bg-accent/60')
})
it('retains a collapsed virtualized section across panel remount', () => {
resetState({
gitStatusByWorktree: { [mocks.activeWorktree.id]: manyEntries(500) }
})
renderSourceControl()
expect(mountedRows().length).toBeGreaterThan(0)
const changesHeader = Array.from(container.querySelectorAll('button')).find((button) =>
button.textContent?.includes('Changes')
)
expect(changesHeader).toBeTruthy()
act(() => {
changesHeader?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
expect(mountedRows().length).toBe(0)
expect(virtualList()).toBeNull()
act(() => root.unmount())
root = createRoot(container)
renderSourceControl()
expect(mountedRows().length).toBe(0)
expect(virtualList()).toBeNull()
const remountedHeader = Array.from(container.querySelectorAll('button')).find((button) =>
button.textContent?.includes('Changes')
)
act(() => {
remountedHeader?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
expect(mountedRows().length).toBeGreaterThan(0)
expect(mountedRows().length).toBeLessThanOrEqual(MAX_MOUNTED_ROWS)
})
it('keeps the scroll offset and window when a refresh delivers identical entries', () => {
resetState({
gitStatusByWorktree: { [mocks.activeWorktree.id]: manyEntries(500) }
})
renderSourceControl()
scrollTo(100 * VIRTUALIZED_LIST_ROW_HEIGHT_PX)
expect(row('src/file-100.ts')).toBeTruthy()
const heightBefore = virtualList()?.style.height
// Rebuild every entry with identical content to simulate a status refresh.
mocks.state = {
...mocks.state,
gitStatusByWorktree: {
[mocks.activeWorktree.id]: manyEntries(500).map((entry) => ({ ...entry }))
}
}
renderSourceControl()
expect(scroller().scrollTop).toBe(100 * VIRTUALIZED_LIST_ROW_HEIGHT_PX)
expect(row('src/file-100.ts')).toBeTruthy()
expect(virtualList()?.style.height).toBe(heightBefore)
})
it('renders small changesets in full without the virtualization container', () => {
const paths = ['src/a.ts', 'src/b.ts', 'src/c.ts']
resetState({
gitStatusByWorktree: {
[mocks.activeWorktree.id]: paths.map((path) => gitEntry({ path }))
}
})
renderSourceControl()
expect(paths.length).toBeLessThan(VIRTUALIZED_LIST_MIN_ROWS)
expect(virtualList()).toBeNull()
expect(mountedRows().length).toBe(paths.length)
for (const path of paths) {
expect(row(path)).toBeTruthy()
}
})
it('bounds mounted tree-view rows for large changesets', () => {
resetState({
settings: { sourceControlViewMode: 'tree' },
gitStatusByWorktree: {
[mocks.activeWorktree.id]: Array.from({ length: 500 }, (_, index) =>
gitEntry({
path: `src/dir-${String(Math.floor(index / 20)).padStart(2, '0')}/file-${String(
index
).padStart(3, '0')}.ts`
})
)
}
})
renderSourceControl()
expect(virtualList()).toBeTruthy()
const mounted = container.querySelectorAll(
'[data-testid="virtualized-list"] [data-index]'
).length
expect(mounted).toBeGreaterThan(0)
expect(mounted).toBeLessThanOrEqual(MAX_MOUNTED_ROWS)
})
})
@@ -2,18 +2,12 @@ import { describe, expect, it } from 'vitest'
import type { GitStatusEntry } from '../../../../../../shared/git/status/git-status-types'
import {
buildSubmoduleChildNodes,
collectListSelectionEntries,
getSubmoduleExpansionKey,
injectExpandedSubmoduleEntries,
injectExpandedSubmoduleRows,
isExpandableSubmoduleEntry,
type SubmoduleSectionTreeNode,
type SubmoduleStatusState
type SubmoduleSectionTreeNode
} from './submodule-expansion'
const LOADING = 'Loading submodule changes…'
const EMPTY = 'No changes in submodule'
const FLUTTER_KEY = 'unstaged::flutter_mine'
function submoduleEntry(partial: Partial<GitStatusEntry> & { path: string }): GitStatusEntry {
return {
@@ -36,60 +30,6 @@ function fileNode(entry: GitStatusEntry, depth = 0): SubmoduleSectionTreeNode &
}
}
describe('isExpandableSubmoduleEntry', () => {
it('is expandable when the submodule has tracked or untracked changes', () => {
expect(
isExpandableSubmoduleEntry(
submoduleEntry({
path: 'flutter_mine',
submodule: { commitChanged: false, trackedChanges: true, untrackedChanges: false }
})
)
).toBe(true)
expect(
isExpandableSubmoduleEntry(
submoduleEntry({
path: 'flutter_mine',
submodule: { commitChanged: true, trackedChanges: false, untrackedChanges: true }
})
)
).toBe(true)
})
it('is expandable for a pointer-only (commit) change so its files can be inspected', () => {
expect(
isExpandableSubmoduleEntry(
submoduleEntry({
path: 'flutter_mine',
submodule: { commitChanged: true, trackedChanges: false, untrackedChanges: false }
})
)
).toBe(true)
})
it('is not expandable when the submodule has no changes at all', () => {
expect(
isExpandableSubmoduleEntry(
submoduleEntry({
path: 'flutter_mine',
submodule: { commitChanged: false, trackedChanges: false, untrackedChanges: false }
})
)
).toBe(false)
})
it('is not expandable for non-submodule entries or already-inner entries', () => {
expect(
isExpandableSubmoduleEntry({ path: 'src/a.ts', status: 'modified', area: 'unstaged' })
).toBe(false)
expect(
isExpandableSubmoduleEntry(
submoduleEntry({ path: 'flutter_mine/lib/main.dart', submoduleRoot: 'flutter_mine' })
)
).toBe(false)
})
})
describe('buildSubmoduleChildNodes', () => {
it('prefixes inner paths, stamps submoduleRoot, and nests one level deeper', () => {
const parent = fileNode(submoduleEntry({ path: 'flutter_mine' }), 2)
@@ -114,354 +54,7 @@ describe('buildSubmoduleChildNodes', () => {
})
})
describe('injectExpandedSubmoduleRows', () => {
it('passes through unexpanded nodes untouched', () => {
const node = fileNode(submoduleEntry({ path: 'flutter_mine' }))
const result = injectExpandedSubmoduleRows([node], new Set(), {}, LOADING, EMPTY)
expect(result).toEqual([node])
})
it('emits a loading placeholder when status is missing or loading', () => {
const node = fileNode(submoduleEntry({ path: 'flutter_mine' }))
const result = injectExpandedSubmoduleRows([node], new Set([FLUTTER_KEY]), {}, LOADING, EMPTY)
expect(result).toHaveLength(2)
expect(result[1]).toMatchObject({
type: 'submodule-placeholder',
state: 'loading',
message: LOADING,
submodulePath: 'flutter_mine'
})
})
it('emits an error placeholder carrying the error message', () => {
const node = fileNode(submoduleEntry({ path: 'flutter_mine' }))
const statuses: Record<string, SubmoduleStatusState> = {
[FLUTTER_KEY]: { status: 'error', error: 'boom' }
}
const result = injectExpandedSubmoduleRows(
[node],
new Set([FLUTTER_KEY]),
statuses,
LOADING,
EMPTY
)
expect(result[1]).toMatchObject({
type: 'submodule-placeholder',
state: 'error',
message: 'boom'
})
})
it('emits an empty placeholder when the submodule has no inner entries', () => {
const node = fileNode(submoduleEntry({ path: 'flutter_mine' }))
const statuses: Record<string, SubmoduleStatusState> = {
[FLUTTER_KEY]: { status: 'loaded', entries: [] }
}
const result = injectExpandedSubmoduleRows(
[node],
new Set([FLUTTER_KEY]),
statuses,
LOADING,
EMPTY
)
expect(result[1]).toMatchObject({
type: 'submodule-placeholder',
state: 'empty',
message: EMPTY
})
})
it('injects child file rows when inner status is loaded', () => {
const node = fileNode(submoduleEntry({ path: 'flutter_mine' }))
const statuses: Record<string, SubmoduleStatusState> = {
[FLUTTER_KEY]: {
status: 'loaded',
entries: [{ path: 'lib/main.dart', status: 'modified', area: 'unstaged' }]
}
}
const result = injectExpandedSubmoduleRows(
[node],
new Set([FLUTTER_KEY]),
statuses,
LOADING,
EMPTY
)
expect(result).toHaveLength(2)
expect(result[1]).toMatchObject({
type: 'file',
path: 'flutter_mine/lib/main.dart'
})
const child = result[1] as SubmoduleSectionTreeNode & { type: 'file' }
expect(child.entry.submoduleRoot).toBe('flutter_mine')
})
it('shows that capped tree results omit additional submodule changes', () => {
const node = fileNode(submoduleEntry({ path: 'flutter_mine' }))
const statuses: Record<string, SubmoduleStatusState> = {
[FLUTTER_KEY]: {
status: 'loaded',
entries: [{ path: 'lib/main.dart', status: 'modified', area: 'unstaged' }],
didHitLimit: true
}
}
const result = injectExpandedSubmoduleRows(
[node],
new Set([FLUTTER_KEY]),
statuses,
LOADING,
EMPTY
)
expect(result.at(-1)).toMatchObject({
type: 'submodule-placeholder',
state: 'truncated',
submodulePath: 'flutter_mine'
})
})
it('keeps inner staged rows staged for tree-view diff routing', () => {
const node = fileNode(submoduleEntry({ path: 'flutter_mine' }))
const statuses: Record<string, SubmoduleStatusState> = {
[FLUTTER_KEY]: {
status: 'loaded',
entries: [{ path: 'lib/main.dart', status: 'modified', area: 'staged' }]
}
}
const result = injectExpandedSubmoduleRows(
[node],
new Set([FLUTTER_KEY]),
statuses,
LOADING,
EMPTY
)
expect(result[1]).toMatchObject({
type: 'file',
key: 'staged::flutter_mine/lib/main.dart',
area: 'staged',
entry: {
path: 'flutter_mine/lib/main.dart',
area: 'staged',
submoduleRoot: 'flutter_mine'
}
})
})
it('expands a pointer-only (commit) submodule into its commit-range files', () => {
const node = fileNode(
submoduleEntry({
path: 'flutter_mine',
submodule: { commitChanged: true, trackedChanges: false, untrackedChanges: false }
})
)
const statuses: Record<string, SubmoduleStatusState> = {
[FLUTTER_KEY]: {
status: 'loaded',
entries: [{ path: 'lib/main.dart', status: 'modified', area: 'unstaged' }]
}
}
const result = injectExpandedSubmoduleRows(
[node],
new Set([FLUTTER_KEY]),
statuses,
LOADING,
EMPTY
)
expect(result).toHaveLength(2)
expect(result[1]).toMatchObject({ type: 'file', path: 'flutter_mine/lib/main.dart' })
})
it('never expands a non-submodule entry that is in the expanded set', () => {
const node = fileNode({ path: 'src/a.ts', status: 'modified', area: 'unstaged' })
const result = injectExpandedSubmoduleRows(
[node],
new Set(['unstaged::src/a.ts']),
{},
LOADING,
EMPTY
)
expect(result).toEqual([node])
})
})
describe('injectExpandedSubmoduleEntries (list view)', () => {
it('passes through unexpanded entries untouched', () => {
const entry = submoduleEntry({ path: 'flutter_mine' })
const result = injectExpandedSubmoduleEntries([entry], new Set(), {}, LOADING, EMPTY)
expect(result).toEqual([{ type: 'entry', entry }])
})
it('emits a loading placeholder when status is missing', () => {
const entry = submoduleEntry({ path: 'flutter_mine' })
const result = injectExpandedSubmoduleEntries(
[entry],
new Set([FLUTTER_KEY]),
{},
LOADING,
EMPTY
)
expect(result).toHaveLength(2)
expect(result[1]).toMatchObject({
type: 'submodule-placeholder',
state: 'loading',
submodulePath: 'flutter_mine',
depth: 1
})
})
it('injects child entries (with submoduleRoot) for a pointer-only commit change', () => {
const entry = submoduleEntry({
path: 'flutter_mine',
submodule: { commitChanged: true, trackedChanges: false, untrackedChanges: false }
})
const statuses: Record<string, SubmoduleStatusState> = {
[FLUTTER_KEY]: {
status: 'loaded',
entries: [{ path: 'lib/main.dart', status: 'modified', area: 'unstaged' }]
}
}
const result = injectExpandedSubmoduleEntries(
[entry],
new Set([FLUTTER_KEY]),
statuses,
LOADING,
EMPTY
)
expect(result).toHaveLength(2)
expect(result[0]).toEqual({ type: 'entry', entry })
expect(result[1]).toMatchObject({
type: 'entry',
entry: { path: 'flutter_mine/lib/main.dart', submoduleRoot: 'flutter_mine' }
})
})
it('keeps staged-only inner entries staged in list view', () => {
const entry = submoduleEntry({ path: 'flutter_mine' })
const statuses: Record<string, SubmoduleStatusState> = {
[FLUTTER_KEY]: {
status: 'loaded',
entries: [{ path: 'lib/main.dart', status: 'modified', area: 'staged' }]
}
}
const result = injectExpandedSubmoduleEntries(
[entry],
new Set([FLUTTER_KEY]),
statuses,
LOADING,
EMPTY
)
expect(result[1]).toMatchObject({
type: 'entry',
entry: {
path: 'flutter_mine/lib/main.dart',
area: 'staged',
submoduleRoot: 'flutter_mine'
}
})
})
it('emits an empty placeholder when loaded with no inner entries', () => {
const entry = submoduleEntry({ path: 'flutter_mine' })
const statuses: Record<string, SubmoduleStatusState> = {
[FLUTTER_KEY]: { status: 'loaded', entries: [] }
}
const result = injectExpandedSubmoduleEntries(
[entry],
new Set([FLUTTER_KEY]),
statuses,
LOADING,
EMPTY
)
expect(result[1]).toMatchObject({
type: 'submodule-placeholder',
state: 'empty',
message: EMPTY
})
})
it('shows that capped list results omit additional submodule changes', () => {
const entry = submoduleEntry({ path: 'flutter_mine' })
const statuses: Record<string, SubmoduleStatusState> = {
[FLUTTER_KEY]: {
status: 'loaded',
entries: [{ path: 'lib/main.dart', status: 'modified', area: 'unstaged' }],
didHitLimit: true
}
}
const result = injectExpandedSubmoduleEntries(
[entry],
new Set([FLUTTER_KEY]),
statuses,
LOADING,
EMPTY
)
expect(result.at(-1)).toMatchObject({
type: 'submodule-placeholder',
state: 'truncated',
submodulePath: 'flutter_mine'
})
})
})
describe('collectListSelectionEntries', () => {
it('includes injected submodule child entries and skips placeholders', () => {
const entry = submoduleEntry({
path: 'flutter_mine',
submodule: { commitChanged: true, trackedChanges: false, untrackedChanges: false }
})
const statuses: Record<string, SubmoduleStatusState> = {
[FLUTTER_KEY]: {
status: 'loaded',
entries: [{ path: 'lib/main.dart', status: 'modified', area: 'unstaged' }]
}
}
const rows = injectExpandedSubmoduleEntries(
[entry],
new Set([FLUTTER_KEY]),
statuses,
LOADING,
EMPTY
)
expect(collectListSelectionEntries(rows)).toEqual([
{ key: 'unstaged::flutter_mine', entry, area: 'unstaged' },
{
key: 'unstaged::flutter_mine/lib/main.dart',
entry: {
path: 'flutter_mine/lib/main.dart',
status: 'modified',
area: 'unstaged',
submoduleRoot: 'flutter_mine'
},
area: 'unstaged'
}
])
})
it('drops loading placeholders from selection entries', () => {
const entry = submoduleEntry({ path: 'flutter_mine' })
const rows = injectExpandedSubmoduleEntries([entry], new Set([FLUTTER_KEY]), {}, LOADING, EMPTY)
expect(collectListSelectionEntries(rows)).toEqual([
{ key: 'unstaged::flutter_mine', entry, area: 'unstaged' }
])
})
})
describe('getSubmoduleExpansionKey', () => {
it('separates staged and unstaged rows for the same submodule path', () => {
expect(getSubmoduleExpansionKey(submoduleEntry({ path: 'flutter_mine', area: 'staged' }))).toBe(
'staged::flutter_mine'
)
expect(
getSubmoduleExpansionKey(submoduleEntry({ path: 'flutter_mine', area: 'unstaged' }))
).toBe(FLUTTER_KEY)
})
it('keeps staged children in the staged area for diff routing', () => {
const entry = submoduleEntry({
path: 'flutter_mine',
@@ -1,565 +0,0 @@
import { renderToStaticMarkup } from 'react-dom/server'
import type { ReactNode } from 'react'
import { describe, expect, it, vi } from 'vitest'
import { SourceControlBranchContextRow } from './branch-context-row'
import type { GitBranchCompareSummary } from '../../../../../../shared/git/diff/git-diff-compare-types'
import type { GitBranchLineTotal } from '../../../../../../shared/git/status/git-status-types'
vi.mock('@/components/ui/tooltip', () => ({
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipContent: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>
}))
vi.mock('@/components/ui/hover-card', () => ({
HoverCard: ({ children }: { children: ReactNode }) => <>{children}</>,
HoverCardContent: ({ children }: { children: ReactNode }) => <>{children}</>,
HoverCardTrigger: ({ children }: { children: ReactNode }) => <>{children}</>
}))
const readySummary: GitBranchCompareSummary = {
baseRef: 'refs/remotes/origin/FRONT-192-ZisVoucherStrip',
baseOid: 'base',
compareRef: 'feature',
headOid: 'head',
mergeBase: 'base',
changedFiles: 0,
commitsAhead: 0,
status: 'ready'
}
describe('SourceControlBranchContextRow', () => {
it('lets the base ref use the full available header width', () => {
const markup = renderToStaticMarkup(
<SourceControlBranchContextRow
summary={readySummary}
compareBaseRef={null}
onChangeBaseRef={vi.fn()}
onRetry={vi.fn()}
/>
)
expect(markup).toContain('origin/FRONT-192-ZisVoucherStrip')
expect(markup).toContain('refs/remotes/origin/FRONT-192-ZisVoucherStrip')
expect(markup).toContain('max-w-full')
expect(markup).toContain('min-w-0 flex-1')
})
it('stacks head above → base so both keep full row width', () => {
const markup = renderToStaticMarkup(
<SourceControlBranchContextRow
summary={readySummary}
compareBaseRef={null}
headDisplay={{ kind: 'branch', branchName: 'fix-fork-pr-fetch-head-race' }}
onChangeBaseRef={vi.fn()}
onRetry={vi.fn()}
/>
)
const headIndex = markup.indexOf('fix-fork-pr-fetch-head-race')
const arrowIndex = markup.indexOf('→')
const baseIndex = markup.indexOf('origin/FRONT-192-ZisVoucherStrip')
expect(headIndex).toBeGreaterThan(-1)
expect(arrowIndex).toBeGreaterThan(headIndex)
expect(baseIndex).toBeGreaterThan(arrowIndex)
expect(markup).toContain('flex-col')
expect(markup).not.toContain('>vs<')
expect(markup).toContain(
'aria-label="fix-fork-pr-fetch-head-race → origin/FRONT-192-ZisVoucherStrip"'
)
})
it('falls back to "vs base" when head identity is missing', () => {
const markup = renderToStaticMarkup(
<SourceControlBranchContextRow
summary={readySummary}
compareBaseRef={null}
onChangeBaseRef={vi.fn()}
onRetry={vi.fn()}
/>
)
expect(markup).toContain('>vs<')
expect(markup).toContain('origin/FRONT-192-ZisVoucherStrip')
expect(markup).not.toContain('→')
})
it('shows head-only identity when there is no compare base', () => {
const markup = renderToStaticMarkup(
<SourceControlBranchContextRow
summary={null}
compareBaseRef={null}
headDisplay={{ kind: 'branch', branchName: 'local-only-branch' }}
onChangeBaseRef={vi.fn()}
onRetry={vi.fn()}
/>
)
expect(markup).toContain('data-testid="source-control-head-identity"')
expect(markup).toContain('local-only-branch')
expect(markup).toContain('aria-label="Current branch: local-only-branch"')
expect(markup).toContain('tabindex="0"')
expect(markup).not.toContain('→')
expect(markup).not.toContain('>vs<')
})
it('marks the loading path busy and announces comparing', () => {
const markup = renderToStaticMarkup(
<SourceControlBranchContextRow
summary={{ ...readySummary, status: 'loading' }}
compareBaseRef={null}
headDisplay={{ kind: 'branch', branchName: 'loading-branch' }}
onChangeBaseRef={vi.fn()}
onRetry={vi.fn()}
/>
)
expect(markup).toContain('aria-busy="true"')
expect(markup).toContain('Comparing against')
expect(markup).toContain('aria-label="loading-branch → origin/FRONT-192-ZisVoucherStrip"')
})
it('shows detached head-only identity when there is no compare base', () => {
const markup = renderToStaticMarkup(
<SourceControlBranchContextRow
summary={null}
compareBaseRef={null}
headDisplay={{
kind: 'detached',
shortHead: '8cec248',
sidebarLabel: 'Detached HEAD @ 8cec248',
sourceControlLabel: 'Detached HEAD · 8cec248',
tooltip: 'Detached HEAD at 8cec248. You are viewing a commit, not a branch.'
}}
onChangeBaseRef={vi.fn()}
onRetry={vi.fn()}
/>
)
expect(markup).toContain('Detached HEAD · 8cec248')
expect(markup).toContain('tabindex="0"')
expect(markup).not.toContain('→')
expect(markup).not.toContain('>vs<')
})
it('renders nothing when neither base nor head identity is available', () => {
const markup = renderToStaticMarkup(
<SourceControlBranchContextRow
summary={null}
compareBaseRef={null}
onChangeBaseRef={vi.fn()}
onRetry={vi.fn()}
/>
)
expect(markup).toBe('')
})
it('renders detached HEAD identity above the base with keyboard-reachable badge', () => {
const markup = renderToStaticMarkup(
<SourceControlBranchContextRow
summary={readySummary}
compareBaseRef={null}
headDisplay={{
kind: 'detached',
shortHead: '8cec248',
sidebarLabel: 'Detached HEAD @ 8cec248',
sourceControlLabel: 'Detached HEAD · 8cec248',
tooltip: 'Detached HEAD at 8cec248. You are viewing a commit, not a branch.'
}}
onChangeBaseRef={vi.fn()}
onRetry={vi.fn()}
/>
)
const headIndex = markup.indexOf('Detached HEAD · 8cec248')
const baseIndex = markup.indexOf('origin/FRONT-192-ZisVoucherStrip')
expect(headIndex).toBeGreaterThan(-1)
expect(baseIndex).toBeGreaterThan(headIndex)
expect(markup).toContain(
'aria-label="Detached HEAD at 8cec248. You are viewing a commit, not a branch."'
)
expect(markup).toContain('tabindex="0"')
expect(markup).toContain(
'aria-label="Detached HEAD · 8cec248 → origin/FRONT-192-ZisVoucherStrip"'
)
})
it('keeps the head→base label on the error path', () => {
const markup = renderToStaticMarkup(
<SourceControlBranchContextRow
summary={{
...readySummary,
status: 'error',
errorMessage: 'Could not compare against base'
}}
compareBaseRef={null}
headDisplay={{ kind: 'branch', branchName: 'feature/retry-me' }}
onChangeBaseRef={vi.fn()}
onRetry={vi.fn()}
/>
)
expect(markup).toContain('role="group"')
expect(markup).toContain('aria-label="feature/retry-me → origin/FRONT-192-ZisVoucherStrip"')
expect(markup).toContain('Could not compare against base')
expect(markup).toContain('Retry')
})
// Post-rebase upstream may still name the old branch; this count must measure and label the compare base.
it('counts commits against the compare base, on the base line', () => {
const markup = renderToStaticMarkup(
<SourceControlBranchContextRow
summary={{ ...readySummary, baseRef: 'refs/remotes/origin/main', commitsAhead: 5 }}
compareBaseRef={null}
headDisplay={{ kind: 'branch', branchName: 'feature/rebased' }}
onChangeBaseRef={vi.fn()}
onRetry={vi.fn()}
/>
)
// Anchor on the base button because the earlier group aria-label repeats the ref.
const baseIndex = markup.indexOf('aria-label="Change base ref:')
expect(markup.indexOf('↑5')).toBeGreaterThan(baseIndex)
expect(markup).toContain('aria-label="5 commits ahead of origin/main"')
expect(markup).not.toContain('↓')
})
it('shows the count on the base line when there is no head identity', () => {
const markup = renderToStaticMarkup(
<SourceControlBranchContextRow
summary={{ ...readySummary, commitsAhead: 3 }}
compareBaseRef={null}
headDisplay={null}
onChangeBaseRef={vi.fn()}
onRetry={vi.fn()}
/>
)
expect(markup).toContain('↑3')
expect(markup).toContain('aria-label="3 commits ahead of origin/FRONT-192-ZisVoucherStrip"')
})
it('renders a compact external review link when a manual URL is available', () => {
const markup = renderToStaticMarkup(
<SourceControlBranchContextRow
summary={readySummary}
compareBaseRef={null}
manualReviewUrl="https://github.com/stablyai/orca/compare/main...feature?expand=1"
onChangeBaseRef={vi.fn()}
onRetry={vi.fn()}
/>
)
expect(markup).toContain('aria-label="Open review page in browser"')
})
})
function renderWithLineTotal(
branchLineTotal: GitBranchLineTotal | null | undefined,
summary: GitBranchCompareSummary | null = readySummary
): string {
return renderToStaticMarkup(
<SourceControlBranchContextRow
summary={summary}
compareBaseRef={null}
headDisplay={{ kind: 'branch', branchName: 'feature/line-total' }}
manualReviewUrl="https://example.test/review"
branchLineTotal={branchLineTotal}
onChangeBaseRef={vi.fn()}
onRetry={vi.fn()}
/>
)
}
describe('SourceControlBranchContextRow branch line total', () => {
it('renders both halves with grouped digits and a spoken label', () => {
const markup = renderWithLineTotal({ added: 8259, removed: 670, mergeBase: 'base' })
expect(markup).toContain('+8,259')
expect(markup).toContain('-670')
expect(markup).toContain('aria-label="8259 lines added, 670 lines deleted"')
expect(markup).toContain('tabular-nums')
expect(markup).toContain('text-[color:var(--git-decoration-added)]')
expect(markup).toContain('text-[color:var(--git-decoration-deleted)]')
// The chip's scope differs from openBranchAllDiffs, so it has no click action.
expect(markup).not.toContain('<button type="button" data-testid="source-control-branch')
})
// Truncate needs a block box; inline branch names would overflow beneath the chip.
it('gives the head identity a block box so long names ellipsize instead of overlapping', () => {
const markup = renderToStaticMarkup(
<SourceControlBranchContextRow
summary={readySummary}
compareBaseRef={null}
headDisplay={{
kind: 'branch',
branchName: 'refactor-remove-legacy-gemini-cli-current-model-plumbing'
}}
branchLineTotal={{ added: 16, removed: 1541, mergeBase: 'base' }}
onChangeBaseRef={vi.fn()}
onRetry={vi.fn()}
/>
)
const identityClasses =
/class="([^"]*)"[^>]*data-testid="source-control-head-identity"/.exec(markup)?.[1] ?? ''
expect(identityClasses).toContain('block')
expect(identityClasses).toContain('truncate')
expect(identityClasses).toContain('min-w-0')
})
it('keeps full precision instead of a compact 8.3k form', () => {
const markup = renderWithLineTotal({ added: 123456, removed: 0, mergeBase: 'base' })
expect(markup).toContain(`+${(123456).toLocaleString()}`)
expect(markup).not.toContain('123k')
expect(markup).not.toContain('123.5')
})
it('omits the zero half rather than printing +42 -0', () => {
const addedOnly = renderWithLineTotal({ added: 42, removed: 0, mergeBase: 'base' })
expect(addedOnly).toContain('>+42<')
expect(addedOnly).not.toContain('>-0<')
expect(addedOnly).toContain('aria-label="42 lines added"')
const removedOnly = renderWithLineTotal({ added: 0, removed: 7, mergeBase: 'base' })
expect(removedOnly).toContain('>-7<')
expect(removedOnly).not.toContain('>+0<')
expect(removedOnly).toContain('aria-label="7 lines deleted"')
})
it('hides the chip when both counts are zero', () => {
const markup = renderWithLineTotal({ added: 0, removed: 0, mergeBase: 'base' })
expect(markup).not.toContain('data-testid="source-control-branch-line-total"')
expect(markup).not.toContain('>+0<')
expect(markup).not.toContain('>-0<')
})
it('renders nothing at all when the total is absent', () => {
// Absent totals may never arrive from older hosts; leave no indefinite pending placeholder.
for (const total of [null, undefined]) {
const markup = renderWithLineTotal(total)
expect(markup).not.toContain('data-testid="source-control-branch-line-total"')
expect(markup).not.toContain('animate-pulse')
expect(markup).not.toContain('NaN')
}
})
// Without upstream divergence, this is the only remaining count for a pushed rebased branch.
it('counts commits against the compare base on the base line', () => {
const markup = renderWithLineTotal(
{ added: 8259, removed: 670, mergeBase: 'base' },
{ ...readySummary, commitsAhead: 2 }
)
expect(markup).toContain('↑2')
expect(markup).toContain('2 commits ahead of origin/FRONT-192-ZisVoucherStrip')
expect(markup.indexOf('↑2')).toBeGreaterThan(markup.indexOf('aria-label="Change base ref:'))
expect(markup).not.toContain('↓')
})
it('keeps the commit count out of the line-total colors', () => {
const markup = renderToStaticMarkup(
<SourceControlBranchContextRow
summary={{ ...readySummary, commitsAhead: 2 }}
compareBaseRef={null}
headDisplay={{ kind: 'branch', branchName: 'feature/line-total' }}
branchLineTotal={{ added: 8259, removed: 670, mergeBase: 'base' }}
onChangeBaseRef={vi.fn()}
onRetry={vi.fn()}
/>
)
// Two adjacent green numbers counting different units is the bug this guards.
const aheadSpan = markup.slice(
markup.lastIndexOf('<span', markup.indexOf('↑2')),
markup.indexOf('↑2')
)
expect(aheadSpan).toContain('text-muted-foreground/70')
expect(aheadSpan).not.toContain('--git-decoration-added')
})
it('folds the chip onto the base line when there is no head identity', () => {
const markup = renderToStaticMarkup(
<SourceControlBranchContextRow
summary={readySummary}
compareBaseRef={null}
headDisplay={null}
branchLineTotal={{ added: 8259, removed: 670, mergeBase: 'base' }}
onChangeBaseRef={vi.fn()}
onRetry={vi.fn()}
/>
)
expect(markup).toContain('data-testid="source-control-branch-line-total"')
expect(markup).toContain('+8,259')
})
it('stays hidden while compare is loading or failed', () => {
const total: GitBranchLineTotal = { added: 8259, removed: 670, mergeBase: 'base' }
for (const summary of [
null,
{ ...readySummary, status: 'loading' } as GitBranchCompareSummary,
{
...readySummary,
status: 'error',
errorMessage: 'Could not compare against base'
} as GitBranchCompareSummary
]) {
const markup = renderWithLineTotal(total, summary)
expect(markup).not.toContain('data-testid="source-control-branch-line-total"')
expect(markup).not.toContain('+8,259')
}
})
})
// The hover-card mock renders inline, allowing copy assertions without hover events.
describe('SourceControlBranchContextRow line total test split', () => {
it('breaks the panel down into test and non-test halves', () => {
const markup = renderWithLineTotal({
added: 243,
removed: 149,
mergeBase: 'base',
test: { added: 120, removed: 40 }
})
expect(markup).toContain('Code breakdown')
expect(markup).toContain('Lines of code')
expect(markup).toContain('data-testid="source-control-branch-line-total-breakdown"')
expect(markup).toContain('Non-test')
expect(markup).toContain('>+123<')
expect(markup).toContain('>-109<')
expect(markup).toContain('Tests')
expect(markup).toContain('>+120<')
expect(markup).toContain('>-40<')
expect(markup.indexOf('Non-test')).toBeLessThan(markup.indexOf('Tests'))
})
it('spells the split into the label so the summary is announced closed', () => {
const markup = renderWithLineTotal({
added: 243,
removed: 149,
mergeBase: 'base',
test: { added: 120, removed: 40 }
})
expect(markup).toContain(
'aria-label="243 lines added, 149 lines deleted — test code: 120 lines added, 40 lines deleted"'
)
})
it('keeps a zero test share visible rather than hiding the row', () => {
const markup = renderWithLineTotal({
added: 243,
removed: 149,
mergeBase: 'base',
test: { added: 0, removed: 0 }
})
expect(markup).toContain('>+0<')
expect(markup).toContain('>-0<')
expect(markup).toContain('>+243<')
expect(markup).toContain('>-149<')
// Panel keeps the zero row; the spoken label stays the main totals only.
expect(markup).toContain('aria-label="243 lines added, 149 lines deleted"')
expect(markup).not.toContain('test code:')
})
// Older hosts omit the split; absence must not imply a measured zero-percent test share.
it('renders exactly as before when the host reported no split', () => {
const withoutSplit = renderWithLineTotal({ added: 243, removed: 149, mergeBase: 'base' })
expect(withoutSplit).not.toContain('Non-test')
expect(withoutSplit).not.toContain('Code breakdown')
expect(withoutSplit).toContain('aria-label="243 lines added, 149 lines deleted"')
})
it('labels the remainder Source once generated is known, and shows that row', () => {
const markup = renderWithLineTotal({
added: 243,
removed: 149,
mergeBase: 'base',
test: { added: 20, removed: 10 },
generated: { added: 100, removed: 50 }
})
expect(markup).toContain('Source')
expect(markup).not.toContain('Non-test')
expect(markup).toContain('Generated')
expect(markup).toContain('>+123<')
expect(markup).toContain('>-89<')
expect(markup).toContain('>+100<')
expect(markup).toContain('>-50<')
expect(markup).toContain(
'aria-label="243 lines added, 149 lines deleted — test code: 20 lines added, 10 lines deleted — generated: 100 lines added, 50 lines deleted"'
)
})
// Without a test split, the remainder may include tests and cannot be called Source.
it('labels the remainder Non-generated when the host omitted the test field', () => {
const markup = renderWithLineTotal({
added: 243,
removed: 149,
mergeBase: 'base',
generated: { added: 100, removed: 50 }
})
expect(markup).toContain('Non-generated')
expect(markup).toContain('>Generated<')
expect(markup).not.toContain('>Source<')
expect(markup).not.toContain('Tests')
expect(markup).toContain('>+143<')
expect(markup).toContain('>-99<')
})
// Keep measured zero-test rows; omit zero-generated rows, which are the normal case.
it('drops the generated row and its announcement when nothing was generated', () => {
const markup = renderWithLineTotal({
added: 243,
removed: 149,
mergeBase: 'base',
test: { added: 120, removed: 40 },
generated: { added: 0, removed: 0 }
})
expect(markup).not.toContain('Generated')
expect(markup).not.toContain('generated:')
expect(markup).toContain('Source')
expect(markup).toContain('Tests')
expect(markup).toContain('>+123<')
})
it('orders the rows source, tests, generated', () => {
const markup = renderWithLineTotal({
added: 1243,
removed: 149,
mergeBase: 'base',
test: { added: 120, removed: 40 },
generated: { added: 1000, removed: 0 }
})
expect(markup.indexOf('Source')).toBeLessThan(markup.indexOf('Tests'))
expect(markup.indexOf('Tests')).toBeLessThan(markup.indexOf('Generated'))
expect(markup).toContain('>+1,000<')
})
it('keeps the three rows summing back to the chip total', () => {
const markup = renderWithLineTotal({
added: 500,
removed: 200,
mergeBase: 'base',
test: { added: 150, removed: 60 },
generated: { added: 300, removed: 100 }
})
expect(markup).toContain('>+50<')
expect(markup).toContain('>-40<')
expect(markup).toContain('>+150<')
expect(markup).toContain('>+300<')
})
})
@@ -1,795 +0,0 @@
import { describe, expect, it } from 'vitest'
import { i18n } from '@/i18n/i18n'
import { PSEUDO_LOCALIZATION_LOCALE } from '@/i18n/pseudo-localization'
import { resolveDropdownItems } from './source-control-dropdown-items'
import type {
DropdownActionInputs,
DropdownItem
} from '../../../../../../shared/source-control/dropdown/source-control-dropdown-item-types'
import {
hasUsableHostedReviewPushTarget,
resolveHostedReviewActionUpstreamStatus
} from '../../../../../../shared/source-control/hosted-review/hosted-review-push-target'
function inputs(overrides: Partial<DropdownActionInputs> = {}): DropdownActionInputs {
return {
stagedCount: 0,
hasUnstagedChanges: false,
hasStageableChanges: false,
hasPartiallyStagedChanges: false,
hasMessage: false,
hasUnresolvedConflicts: false,
isCommitting: false,
isRemoteOperationActive: false,
upstreamStatus: undefined,
...overrides
}
}
describe('resolveDropdownItems', () => {
it('renders every row — Commit through Publish — for a staged, tracked, ahead+behind branch', () => {
const items = resolveDropdownItems(
inputs({
stagedCount: 1,
hasMessage: true,
upstreamStatus: { hasUpstream: true, ahead: 2, behind: 3 }
})
)
const kinds = items.map((item) => item.kind)
expect(kinds).toEqual([
'commit',
'commit_push',
'commit_sync',
'separator',
'push',
'force_push',
'create_pr',
'push_create_pr',
'pull',
'fast_forward',
'sync',
'rebase_base',
'fetch',
'publish'
])
})
it('disables compound commit actions when no staged files', () => {
const items = resolveDropdownItems(
inputs({ upstreamStatus: { hasUpstream: true, ahead: 1, behind: 0 } })
)
const byKind = Object.fromEntries(
items.filter((e) => e.kind !== 'separator').map((e) => [e.kind, e])
)
expect(byKind.commit.disabled).toBe(true)
expect(byKind.commit_push.disabled).toBe(true)
expect(byKind.commit_sync.disabled).toBe(true)
})
it('enables commit actions when staged files also have unstaged changes', () => {
const items = resolveDropdownItems(
inputs({
stagedCount: 1,
hasUnstagedChanges: true,
hasPartiallyStagedChanges: true,
hasMessage: true,
upstreamStatus: { hasUpstream: true, ahead: 1, behind: 0 }
})
)
const byKind = Object.fromEntries(
items.filter((e) => e.kind !== 'separator').map((e) => [e.kind, e])
)
expect(byKind.commit.disabled).toBe(false)
expect(byKind.commit_push.disabled).toBe(false)
expect(byKind.commit_sync.disabled).toBe(false)
expect(byKind.commit.title).toBe('Commit staged changes')
})
it('enables explicit push actions and keeps Fetch enabled when branch has no upstream', () => {
const items = resolveDropdownItems(
inputs({
stagedCount: 1,
hasMessage: true,
upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 }
})
)
const byKind = Object.fromEntries(
items.filter((e) => e.kind !== 'separator').map((e) => [e.kind, e])
)
expect(byKind.push.disabled).toBe(false)
expect(byKind.force_push.disabled).toBe(false)
expect(byKind.commit_push.disabled).toBe(true)
expect(byKind.publish.disabled).toBe(false)
expect(byKind.fetch.disabled).toBe(false)
})
it('does not offer Publish Branch when HEAD is detached', () => {
const items = resolveDropdownItems(
inputs({
upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 },
branchCommitsAhead: 4,
hasCurrentBranch: false
})
)
const byKind = Object.fromEntries(
items.filter((e) => e.kind !== 'separator').map((e) => [e.kind, e])
)
expect(byKind.push.title).toBe('Check out a branch before pushing commits')
expect(byKind.publish.label).toBe('No Branch')
expect(byKind.publish.title).toBe('Check out a branch before publishing commits')
expect(byKind.publish.disabled).toBe(true)
})
it('disables Publish Branch when branch already has an upstream', () => {
const items = resolveDropdownItems(
inputs({
upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 }
})
)
const byKind = Object.fromEntries(
items.filter((e) => e.kind !== 'separator').map((e) => [e.kind, e])
)
expect(byKind.publish.disabled).toBe(true)
})
it('renders counts on action labels when > 0', () => {
const items = resolveDropdownItems(
inputs({ upstreamStatus: { hasUpstream: true, ahead: 3, behind: 2 } })
)
const byKind = Object.fromEntries(
items.filter((e) => e.kind !== 'separator').map((e) => [e.kind, e])
)
expect(byKind.push.label).toBe('Push (3)')
expect(byKind.force_push.label).toBe('Force Push (3)')
expect(byKind.pull.label).toBe('Pull (2)')
expect(byKind.sync.label).toBe('Sync (↓2 ↑3)')
})
it('keeps push-only actions clickable on diverged branches', () => {
const items = resolveDropdownItems(
inputs({
stagedCount: 1,
hasMessage: true,
upstreamStatus: { hasUpstream: true, ahead: 2, behind: 3 }
})
)
const byKind = Object.fromEntries(
items.filter((e) => e.kind !== 'separator').map((e) => [e.kind, e])
)
expect(byKind.push.disabled).toBe(false)
expect(byKind.push.title).toBe('Push local commits; git may require syncing first')
expect(byKind.commit_push.disabled).toBe(false)
expect(byKind.commit_push.title).toBe('Commit staged changes and try to push')
expect(byKind.sync.disabled).toBe(false)
expect(byKind.commit_sync.disabled).toBe(false)
})
it('offers force-push-with-lease when remote-only commits are patch-equivalent', () => {
const items = resolveDropdownItems(
inputs({
stagedCount: 1,
hasMessage: true,
branchCommitsAhead: 4,
upstreamStatus: {
hasUpstream: true,
upstreamName: 'origin/feature',
ahead: 14,
behind: 3,
behindCommitsArePatchEquivalent: true
},
hostedReviewCreation: {
provider: 'github',
review: null,
canCreate: false,
blockedReason: 'needs_sync',
nextAction: 'sync',
reviewLookupOutcome: 'not_found'
}
})
)
const byKind = Object.fromEntries(
items.filter((e) => e.kind !== 'separator').map((e) => [e.kind, e])
)
expect(byKind.push.label).toBe('Push (14)')
expect(byKind.push.disabled).toBe(false)
expect(byKind.push.title).toBe('Try a regular push; git may require force push')
expect(byKind.force_push.label).toBe('Force Push (4)')
expect(byKind.force_push.disabled).toBe(false)
expect(byKind.force_push.title).toBe(
'Remote only has older copies of local commits. Force push 4 branch commits with lease to update origin/feature.'
)
expect(byKind.commit_push.label).toBe('Commit & Force Push')
expect(byKind.commit_push.disabled).toBe(false)
expect(byKind.commit_push.title).toBe('Commit staged changes and force push with lease')
expect(byKind.pull.disabled).toBe(false)
expect(byKind.pull.title).toBe(
'Nothing new to pull — remote only has older copies of local commits'
)
expect(byKind.fast_forward.disabled).toBe(false)
expect(byKind.fast_forward.title).toBe(
'Nothing new to fast-forward — remote only has older copies of local commits'
)
expect(byKind.commit_sync.label).toBe('Commit & Sync')
expect(byKind.commit_sync.disabled).toBe(true)
expect(byKind.commit_sync.title).toBe(
'Use Commit & Force Push — remote only has older copies of local commits'
)
expect(byKind.sync.disabled).toBe(true)
expect(byKind.sync.title).toBe('Use Force Push — remote only has older copies of local commits')
expect(byKind.create_pr.hint).toBe('Force Push first')
expect(byKind.push_create_pr.label).toBe('Force Push before PR')
expect(byKind.push_create_pr.disabled).toBe(false)
})
it('offers explicit force-push-with-lease for an ordinary ahead branch', () => {
const items = resolveDropdownItems(
inputs({
upstreamStatus: {
hasUpstream: true,
upstreamName: 'origin/feature',
ahead: 1,
behind: 0
}
})
)
const byKind = Object.fromEntries(
items.filter((e) => e.kind !== 'separator').map((e) => [e.kind, e])
)
expect(byKind.push.label).toBe('Push (1)')
expect(byKind.push.disabled).toBe(false)
expect(byKind.force_push.label).toBe('Force Push (1)')
expect(byKind.force_push.disabled).toBe(false)
expect(byKind.force_push.title).toBe(
'Force push 1 local commit with lease to update origin/feature.'
)
})
it('omits counts from labels when ahead/behind are 0', () => {
const items = resolveDropdownItems(
inputs({ upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 } })
)
const byKind = Object.fromEntries(
items.filter((e) => e.kind !== 'separator').map((e) => [e.kind, e])
)
expect(byKind.push.label).toBe('Push')
expect(byKind.force_push.label).toBe('Force Push')
expect(byKind.pull.label).toBe('Pull')
expect(byKind.sync.label).toBe('Sync')
expect(byKind.push.disabled).toBe(false)
expect(byKind.force_push.disabled).toBe(false)
expect(byKind.pull.disabled).toBe(false)
expect(byKind.fast_forward.disabled).toBe(false)
expect(byKind.sync.disabled).toBe(false)
})
it('locks every item while a remote op is running', () => {
const items = resolveDropdownItems(
inputs({
isRemoteOperationActive: true,
stagedCount: 1,
hasMessage: true,
upstreamStatus: { hasUpstream: true, ahead: 2, behind: 3 }
})
)
for (const entry of items) {
if (entry.kind !== 'separator') {
expect(entry.disabled).toBe(true)
}
}
})
it('shows a destructive abort item only while merge or rebase is in progress', () => {
const mergeItems = resolveDropdownItems(
inputs({
conflictOperation: 'merge',
upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 }
})
)
const rebaseItems = resolveDropdownItems(
inputs({
conflictOperation: 'rebase',
upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 }
})
)
const mergeByKind = Object.fromEntries(
mergeItems.filter((e) => e.kind !== 'separator').map((e) => [e.kind, e])
)
const rebaseByKind = Object.fromEntries(
rebaseItems.filter((e) => e.kind !== 'separator').map((e) => [e.kind, e])
)
expect(mergeByKind.abort_merge).toMatchObject({
label: 'Abort merge',
title: 'Abort the merge in progress',
disabled: false,
variant: 'destructive'
})
expect(rebaseByKind.abort_rebase).toMatchObject({
label: 'Abort rebase',
title: 'Abort the rebase in progress',
disabled: false,
variant: 'destructive'
})
expect(mergeByKind.abort_rebase).toBeUndefined()
expect(rebaseByKind.abort_merge).toBeUndefined()
for (const conflictOperation of ['unknown', 'cherry-pick'] as const) {
const items = resolveDropdownItems(inputs({ conflictOperation }))
expect(items.some((entry) => entry.kind === 'abort_merge')).toBe(false)
expect(items.some((entry) => entry.kind === 'abort_rebase')).toBe(false)
}
})
it('disables conflict abort actions while another action is busy', () => {
const items = resolveDropdownItems(
inputs({
conflictOperation: 'merge',
isRemoteOperationActive: true,
upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 }
})
)
const rebaseItems = resolveDropdownItems(
inputs({
conflictOperation: 'rebase',
isRemoteOperationActive: true,
upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 }
})
)
const abortMerge = items.find((entry) => entry.kind === 'abort_merge')
const abortRebase = rebaseItems.find((entry) => entry.kind === 'abort_rebase')
expect(abortMerge).toMatchObject({
disabled: true,
title: 'Operation in progress…'
})
expect(abortRebase).toMatchObject({
disabled: true,
title: 'Operation in progress…'
})
})
it('locks every item while a hosted review operation is running', () => {
const items = resolveDropdownItems(
inputs({
isPullRequestOperationActive: true,
upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 },
hostedReviewCreation: {
provider: 'github',
review: null,
canCreate: true,
blockedReason: null,
nextAction: null,
reviewLookupOutcome: 'not_found'
}
})
)
for (const entry of items) {
if (entry.kind !== 'separator') {
expect(entry.disabled).toBe(true)
expect(entry.title).toBe('Hosted review operation in progress…')
}
}
})
it('keeps explicit push rows available while upstreamStatus is undefined', () => {
// Block Publish while upstream loads; explicit Push/Force Push safely resolve their target at click time.
const items = resolveDropdownItems(
inputs({ stagedCount: 1, hasMessage: true, upstreamStatus: undefined })
)
const byKind = Object.fromEntries(
items.filter((e) => e.kind !== 'separator').map((e) => [e.kind, e])
)
const loadingBlocked = [
'commit_push',
'commit_sync',
'pull',
'fast_forward',
'sync',
'publish'
] as const
for (const kind of loadingBlocked) {
expect(byKind[kind].disabled).toBe(true)
expect(byKind[kind].title).toBe('Checking branch status…')
}
expect(byKind.commit.disabled).toBe(false)
expect(byKind.push.disabled).toBe(false)
expect(byKind.push.title).toBe('Push this branch and set an upstream if needed')
expect(byKind.force_push.disabled).toBe(false)
expect(byKind.force_push.title).toBe(
'Force push this branch with lease and set an upstream if needed.'
)
expect(byKind.fetch.disabled).toBe(false)
expect(byKind.fetch.title).toBe('Fetch from remote without merging')
})
it('keeps explicit push rows enabled and surfaces publish-first tooltips elsewhere', () => {
// Explicit pushes can set upstream; Pull/Sync need publish-first guidance after absence is confirmed.
const items = resolveDropdownItems(
inputs({
upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 },
branchCommitsAhead: 2
})
)
const byKind = Object.fromEntries(
items.filter((e) => e.kind !== 'separator').map((e) => [e.kind, e])
)
expect(byKind.push.title).toBe('Push this branch and set an upstream if needed')
expect(byKind.push.disabled).toBe(false)
expect(byKind.force_push.label).toBe('Force Push (2)')
expect(byKind.force_push.title).toBe(
'Force push 2 branch commits with lease and set an upstream if needed.'
)
expect(byKind.force_push.disabled).toBe(false)
expect(byKind.pull.title).toBe('Publish the branch first to pull commits')
expect(byKind.fast_forward.title).toBe('Publish the branch first to fast-forward')
expect(byKind.sync.title).toBe('Publish the branch first to sync commits')
expect(byKind.fetch.title).toBe('Fetch from remote without merging')
expect(byKind.fetch.disabled).toBe(false)
expect(byKind.publish.title).toBe('Publish this branch to origin')
expect(byKind.publish.disabled).toBe(false)
})
it('enables rebase from base only on a clean tree with a remote base ref', () => {
const items = resolveDropdownItems(
inputs({
upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 },
rebaseBaseRef: 'origin/main'
})
)
const byKind = Object.fromEntries(
items.filter((e) => e.kind !== 'separator').map((e) => [e.kind, e])
)
expect(byKind.rebase_base.label).toBe('Rebase from origin/main')
expect(byKind.rebase_base.title).toBe(
'Rebase current branch with latest commits from origin/main'
)
expect(byKind.rebase_base.disabled).toBe(false)
})
it('keeps rebase from base clickable while local changes are present', () => {
const items = resolveDropdownItems(
inputs({
hasUnstagedChanges: true,
upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 },
rebaseBaseRef: 'origin/main'
})
)
const byKind = Object.fromEntries(
items.filter((e) => e.kind !== 'separator').map((e) => [e.kind, e])
)
expect(byKind.rebase_base.disabled).toBe(false)
expect(byKind.rebase_base.title).toBe(
'Try rebasing; git may require committing or stashing local changes first'
)
})
it('shows Publish Branch when an unpublished branch has no commits ahead', () => {
const items = resolveDropdownItems(
inputs({
upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 },
branchCommitsAhead: 0
})
)
const byKind = Object.fromEntries(
items.filter((e) => e.kind !== 'separator').map((e) => [e.kind, e])
)
expect(byKind.publish.label).toBe('Publish Branch')
expect(byKind.publish.title).toBe('Publish this branch to origin')
expect(byKind.publish.disabled).toBe(false)
})
it('keeps Publish Branch available for an unpublished dirty branch with no commits', () => {
const items = resolveDropdownItems(
inputs({
stagedCount: 1,
upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 },
branchCommitsAhead: 0
})
)
const byKind = Object.fromEntries(
items.filter((e) => e.kind !== 'separator').map((e) => [e.kind, e])
)
expect(byKind.publish.label).toBe('Publish Branch')
expect(byKind.publish.title).toBe('Publish this branch to origin')
expect(byKind.publish.disabled).toBe(false)
})
it('keeps explicit push rows available when the linked PR is already merged', () => {
const items = resolveDropdownItems(
inputs({
upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 },
prState: 'merged'
})
)
const byKind = Object.fromEntries(
items.filter((e) => e.kind !== 'separator').map((e) => [e.kind, e])
)
expect(byKind.push.title).toBe('Push this branch and set an upstream if needed')
expect(byKind.push.disabled).toBe(false)
expect(byKind.force_push.title).toBe(
'Force push this branch with lease and set an upstream if needed.'
)
expect(byKind.force_push.disabled).toBe(false)
expect(byKind.pull.title).toBe('PR is already merged')
expect(byKind.fast_forward.title).toBe('PR is already merged')
expect(byKind.sync.title).toBe('PR is already merged')
expect(byKind.publish.label).toBe('PR Status')
expect(byKind.publish.title).toBe('PR is already merged')
expect(byKind.publish.disabled).toBe(true)
})
it('offers Push instead of Publish Branch when an open linked review has no upstream', () => {
const items = resolveDropdownItems(
inputs({
stagedCount: 1,
hasMessage: true,
upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 },
branchCommitsAhead: 1,
prState: 'open',
canPushLinkedReviewWithoutUpstream: true
})
)
const byKind = Object.fromEntries(
items.filter((e) => e.kind !== 'separator').map((e) => [e.kind, e])
)
expect(byKind.commit_push.disabled).toBe(false)
expect(byKind.push.title).toBe('Push updates to the linked review branch')
expect(byKind.push.disabled).toBe(false)
expect(byKind.force_push.label).toBe('Force Push (1)')
expect(byKind.force_push.disabled).toBe(false)
expect(byKind.publish.label).toBe('Linked Review')
expect(byKind.publish.title).toBe('Linked review branch already exists')
expect(byKind.publish.disabled).toBe(true)
})
it('keeps explicit Push available for a linked review with a usable target and no commits ahead', () => {
// Zero commits ahead still has a valid review head; match the primary Push gate.
const items = resolveDropdownItems(
inputs({
stagedCount: 1,
hasMessage: true,
upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 },
branchCommitsAhead: 0,
prState: 'open',
canPushLinkedReviewWithoutUpstream: true
})
)
const byKind = Object.fromEntries(
items.filter((e) => e.kind !== 'separator').map((e) => [e.kind, e])
)
expect(byKind.push.disabled).toBe(false)
expect(byKind.push.title).toBe('Push this branch and set an upstream if needed')
expect(byKind.force_push.disabled).toBe(false)
expect(byKind.commit_push.disabled).toBe(false)
expect(byKind.publish.disabled).toBe(true)
})
it('blocks Push when an open linked review has no branch target', () => {
// Missing review heads must not fall through to an unrelated configured upstream; match the primary target gate.
const items = resolveDropdownItems(
inputs({
stagedCount: 1,
hasMessage: true,
upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 },
branchCommitsAhead: 1,
prState: 'open'
})
)
const byKind = Object.fromEntries(
items.filter((e) => e.kind !== 'separator').map((e) => [e.kind, e])
)
expect(byKind.commit_push.disabled).toBe(true)
expect(byKind.commit_push.title).toBe('Linked review branch target is unavailable')
expect(byKind.push.title).toBe('Linked review branch target is unavailable')
expect(byKind.push.disabled).toBe(true)
expect(byKind.force_push.title).toBe('Linked review branch target is unavailable')
expect(byKind.force_push.disabled).toBe(true)
expect(byKind.publish.label).toBe('Linked Review')
expect(byKind.publish.title).toBe('Linked review branch target is unavailable')
expect(byKind.publish.disabled).toBe(true)
})
it('waits for linked PR state before showing a publish prompt', () => {
const items = resolveDropdownItems(
inputs({
upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 },
isPRStateLoading: true
})
)
const byKind = Object.fromEntries(
items.filter((e) => e.kind !== 'separator').map((e) => [e.kind, e])
)
expect(byKind.publish.label).toBe('PR Status')
expect(byKind.publish.title).toBe('Checking PR status…')
expect(byKind.publish.disabled).toBe(true)
})
it('omits counts from compound commit labels even when ahead/behind are nonzero', () => {
// Commit changes ahead/behind, invalidating compound-label counts; plain remote actions keep their counts.
const items = resolveDropdownItems(
inputs({
stagedCount: 1,
hasMessage: true,
upstreamStatus: { hasUpstream: true, ahead: 2, behind: 3 }
})
)
const byKind = Object.fromEntries(
items.filter((e) => e.kind !== 'separator').map((e) => [e.kind, e])
)
expect(byKind.commit_push.label).toBe('Commit & Push')
expect(byKind.commit_sync.label).toBe('Commit & Sync')
expect(byKind.push.label).toBe('Push (2)')
expect(byKind.sync.label).toBe('Sync (↓3 ↑2)')
})
it('keeps fast-forward clickable even when git may reject the branch shape', () => {
const behindOnly = resolveDropdownItems(
inputs({ upstreamStatus: { hasUpstream: true, ahead: 0, behind: 2 } })
)
const diverged = resolveDropdownItems(
inputs({ upstreamStatus: { hasUpstream: true, ahead: 1, behind: 2 } })
)
const behindOnlyByKind = Object.fromEntries(
behindOnly.filter((e) => e.kind !== 'separator').map((e) => [e.kind, e])
)
const divergedByKind = Object.fromEntries(
diverged.filter((e) => e.kind !== 'separator').map((e) => [e.kind, e])
)
expect(behindOnlyByKind.fast_forward.label).toBe('Fast-forward (2)')
expect(behindOnlyByKind.fast_forward.title).toBe('Fast-forward 2 commits')
expect(behindOnlyByKind.fast_forward.disabled).toBe(false)
expect(divergedByKind.fast_forward.disabled).toBe(false)
expect(divergedByKind.fast_forward.title).toBe(
'Try a fast-forward pull; git may reject local commits'
)
})
it('routes Commit & Sync, Rebase, and Create PR blocked copy through the active locale', async () => {
await i18n.changeLanguage(PSEUDO_LOCALIZATION_LOCALE)
try {
const loading = resolveDropdownItems(inputs({ rebaseBaseRef: 'origin/main' }))
const blocked = resolveDropdownItems(
inputs({
upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 },
hostedReviewCreation: {
provider: 'github',
review: null,
canCreate: false,
blockedReason: 'dirty',
nextAction: 'commit',
reviewLookupOutcome: 'not_found'
}
})
)
const loadingByKind = Object.fromEntries(
loading.filter((e) => e.kind !== 'separator').map((e) => [e.kind, e])
)
const blockedByKind = Object.fromEntries(
blocked.filter((e) => e.kind !== 'separator').map((e) => [e.kind, e])
)
expect(loadingByKind.commit_sync.title).toBe('[Checking branch status…]')
expect(loadingByKind.rebase_base.label).toBe('[Rebase from origin/main]')
expect(blockedByKind.create_pr.hint).toBe('[Commit changes first]')
} finally {
await i18n.changeLanguage('en')
}
})
it('routes every row label and tooltip through the active locale', async () => {
await i18n.changeLanguage(PSEUDO_LOCALIZATION_LOCALE)
try {
const idle = resolveDropdownItems(
inputs({
conflictOperation: 'merge',
upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0, upstreamName: 'origin/main' },
hostedReviewCreation: {
provider: 'github',
review: null,
canCreate: true,
blockedReason: null,
nextAction: null,
reviewLookupOutcome: 'not_found'
}
})
)
for (const entry of idle) {
if (entry.kind !== 'separator') {
expect(entry.label).toMatch(/^\[/)
expect(entry.title).toMatch(/^\[/)
}
}
const idleByKind = Object.fromEntries(
idle.filter((e) => e.kind !== 'separator').map((e) => [e.kind, e])
)
expect(idleByKind.commit.title).toBe('[Stage at least one file to commit]')
expect(idleByKind.push.title).toBe('[Nothing to push to origin/main]')
expect(idleByKind.abort_merge.title).toBe('[Abort the merge in progress]')
const loading = resolveDropdownItems(inputs())
const loadingByKind = Object.fromEntries(
loading.filter((e) => e.kind !== 'separator').map((e) => [e.kind, e])
)
for (const kind of ['commit_push', 'pull', 'fast_forward', 'sync', 'publish']) {
expect(loadingByKind[kind].title).toBe('[Checking branch status…]')
}
} finally {
await i18n.changeLanguage('en')
}
})
})
// Exercise the real push-target resolution chain used by the component (#8196).
describe('resolveDropdownItems with an unhydrated linked-review push target', () => {
function pipeline(args: {
branchName: string
upstreamStatus: DropdownActionInputs['upstreamStatus']
}): Record<string, DropdownItem> {
const canUseHostedReviewPushTarget = hasUsableHostedReviewPushTarget({
// pushTarget intentionally omitted: the resolver has not hydrated it yet.
hasResolvableHostedReviewPushTargetLink: true,
branchName: args.branchName,
upstreamStatus: args.upstreamStatus
})
const upstreamStatus = resolveHostedReviewActionUpstreamStatus({
hasHostedReviewLink: true,
hasResolvableHostedReviewPushTargetLink: true,
hostedReviewState: 'open',
isHostedReviewStateLoading: false,
canUseHostedReviewPushTarget,
upstreamStatus: args.upstreamStatus
})
const items = resolveDropdownItems(
inputs({
stagedCount: 1,
hasMessage: true,
branchCommitsAhead: 7,
prState: 'open',
upstreamStatus,
canPushLinkedReviewWithoutUpstream: canUseHostedReviewPushTarget
})
)
return Object.fromEntries(
items.filter((e): e is DropdownItem => e.kind !== 'separator').map((e) => [e.kind, e])
)
}
it('enables Push and Force Push when the real upstream is the same-repo review head', () => {
const byKind = pipeline({
branchName: 'mobile-resume-suspected-fixes',
upstreamStatus: {
hasUpstream: true,
upstreamName: 'origin/mobile-resume-suspected-fixes',
ahead: 7,
behind: 2
}
})
expect(byKind.push.disabled).toBe(false)
expect(byKind.push.title).not.toBe('Linked review branch target is unavailable')
expect(byKind.force_push.disabled).toBe(false)
expect(byKind.pull.disabled).toBe(false)
expect(byKind.sync.disabled).toBe(false)
expect(byKind.publish.disabled).toBe(true)
})
it('still blocks Push when the real upstream is an unrelated fork/helper head', () => {
const byKind = pipeline({
branchName: 'mobile-resume-suspected-fixes',
upstreamStatus: {
hasUpstream: true,
upstreamName: 'origin/helper-branch',
ahead: 1,
behind: 0
}
})
expect(byKind.push.disabled).toBe(true)
expect(byKind.push.title).toBe('Linked review branch target is unavailable')
expect(byKind.force_push.disabled).toBe(true)
})
})
@@ -1,21 +1,12 @@
import { describe, expect, it } from 'vitest'
import { create } from 'zustand'
import {
arePullRequestGenerationFieldsEqual,
clearPullRequestGenerationRequiresPushBeforeCreate,
createPullRequestGenerationSlice,
createRunningPullRequestGenerationRecord,
getPullRequestGenerationRecordKey,
getPullRequestGenerationSeedRestoreKey,
getPullRequestGenerationWorktreeKey,
markPullRequestGenerationRequiresPushBeforeCreate,
markPullRequestGenerationTerminalSeedRestored,
resolvePullRequestGenerationCancel,
resolvePullRequestGenerationSuccess,
shouldApplyPullRequestGenerationResult,
shouldHydratePullRequestGenerationResult,
type PullRequestGenerationSlice,
type PullRequestGenerationRecord
type PullRequestGenerationSlice
} from '@/store/slices/pull-request-generation'
const seed = {
@@ -32,28 +23,6 @@ const fieldRevisions = {
draft: 0
}
function runningRecord(overrides: Partial<PullRequestGenerationRecord> = {}) {
return {
context: {
worktreeId: 'wt-a',
worktreePath: '/repo/a',
connectionId: 'conn-a',
requestId: 3,
runtimeTarget: { kind: 'local' as const },
repoId: 'repo-1',
branch: 'feature-a'
},
seed,
seedFieldRevisions: fieldRevisions,
requiresPushBeforeCreate: false,
status: 'running' as const,
result: null,
error: null,
hydrated: false,
...overrides
}
}
function createPullRequestGenerationTestStore() {
return create<PullRequestGenerationSlice>()((...args) =>
createPullRequestGenerationSlice(
@@ -63,348 +32,6 @@ function createPullRequestGenerationTestStore() {
}
describe('SourceControl pull request generation records', () => {
it('keys PR generation by worktree id and falls back to path', () => {
expect(getPullRequestGenerationWorktreeKey('wt-a', '/repo/a')).toBe('wt-a')
expect(getPullRequestGenerationWorktreeKey(null, '/repo/a')).toBe('/repo/a')
expect(getPullRequestGenerationWorktreeKey(null, '')).toBeNull()
expect(
getPullRequestGenerationRecordKey({
worktreeId: 'wt-a',
worktreePath: '/repo/a',
repoId: 'repo-1',
branch: 'feature-a'
})
).not.toBe(
getPullRequestGenerationRecordKey({
worktreeId: 'wt-a',
worktreePath: '/repo/a',
repoId: 'repo-1',
branch: 'feature-b'
})
)
})
it('applies generated PR fields only to the original running request', () => {
expect(
shouldApplyPullRequestGenerationResult({
record: runningRecord(),
requestId: 3
})
).toBe(true)
expect(
shouldApplyPullRequestGenerationResult({
record: runningRecord(),
requestId: 4
})
).toBe(false)
expect(
shouldApplyPullRequestGenerationResult({
record: runningRecord({ status: 'succeeded' }),
requestId: 3
})
).toBe(false)
})
it('treats draft changes as stale PR generation input', () => {
expect(arePullRequestGenerationFieldsEqual(seed, { ...seed, draft: true })).toBe(false)
})
it('rehydrates a completed result until it is marked hydrated', () => {
const record = runningRecord({
status: 'succeeded',
result: { ...seed, title: 'Generated title' }
})
expect(
shouldHydratePullRequestGenerationResult({
record
})
).toBe(true)
expect(
shouldHydratePullRequestGenerationResult({
record: { ...record, hydrated: true }
})
).toBe(false)
})
it('restores terminal failed and canceled seeds once after remount', () => {
const failed = runningRecord({
status: 'failed',
error: 'Model failed'
})
const canceled = runningRecord({
status: 'canceled',
error: null
})
expect(
getPullRequestGenerationSeedRestoreKey({
recordKey: 'pr-record',
record: failed
})
).toBe('pr-record:3:failed')
expect(
getPullRequestGenerationSeedRestoreKey({
recordKey: 'pr-record',
record: canceled
})
).toBe('pr-record:3:canceled')
const restored = markPullRequestGenerationTerminalSeedRestored({
record: failed,
requestId: 3
})
expect(restored).toMatchObject({
status: 'failed',
hydrated: true
})
expect(
getPullRequestGenerationSeedRestoreKey({
recordKey: 'pr-record',
record: restored
})
).toBeNull()
})
it('keeps a switched-away PR generation owned by the original worktree', () => {
const worktreeA = createRunningPullRequestGenerationRecord(
{
worktreeId: 'wt-a',
worktreePath: '/repo/a',
connectionId: 'conn-a',
requestId: 1,
runtimeTarget: { kind: 'local' as const },
repoId: 'repo-1',
branch: 'feature-a'
},
seed,
fieldRevisions
)
const records: Record<string, PullRequestGenerationRecord> = {
'wt-a': worktreeA
}
// Stopping in B must neither create a B record nor cancel A.
const canceledB = resolvePullRequestGenerationCancel(records['wt-b'])
expect(canceledB).toBeNull()
expect(records['wt-a'].status).toBe('running')
const generated = {
base: 'main',
title: 'Generated PR title',
body: 'Generated body',
draft: false
}
const completedA = resolvePullRequestGenerationSuccess({
record: records['wt-a'],
requestId: 1,
result: generated
})
expect(completedA).toMatchObject({
status: 'succeeded',
result: generated,
hydrated: false
})
expect(
shouldHydratePullRequestGenerationResult({
record: completedA
})
).toBe(true)
})
it.each([
{
name: 'an untouched unchecked Draft takes the agent draft',
seedDraft: false,
revision: 0,
agentDraft: true,
draft: true
},
{
name: 'a checked Draft stays checked',
seedDraft: true,
revision: 0,
agentDraft: false,
draft: true
},
{
name: 'a Draft the user unchecked stays unchecked',
seedDraft: false,
revision: 1,
agentDraft: true,
draft: false
}
])(
'a Create PR run never takes the generated base, and $name',
({ seedDraft, revision, agentDraft, draft }) => {
const record = runningRecord({
autoSubmit: true,
seed: { ...seed, draft: seedDraft },
seedFieldRevisions: { ...fieldRevisions, draft: revision }
})
const completed = resolvePullRequestGenerationSuccess({
record,
requestId: 3,
result: {
base: 'develop',
title: 'Generated title',
body: 'Generated body',
draft: agentDraft
}
})
expect(completed?.result).toEqual({
base: 'main',
title: 'Generated title',
body: 'Generated body',
draft
})
}
)
it('a reviewed run keeps the agent base and title for the form', () => {
const generated = {
base: 'develop',
title: 'Generated title',
body: 'Generated body',
draft: true
}
const completed = resolvePullRequestGenerationSuccess({
record: runningRecord(),
requestId: 3,
result: generated
})
expect(completed?.result).toEqual(generated)
})
it.each([
{
name: 'a Draft checked before the run stays checked',
seedDraft: true,
revision: 0,
draft: true
},
{
name: 'a Draft the user unchecked before the run stays unchecked',
seedDraft: false,
revision: 1,
draft: false
}
])(
'a reviewed run never reverts the user Draft choice: $name',
({ seedDraft, revision, draft }) => {
const completed = resolvePullRequestGenerationSuccess({
record: runningRecord({
seed: { ...seed, draft: seedDraft },
seedFieldRevisions: { ...fieldRevisions, draft: revision }
}),
requestId: 3,
result: {
base: 'develop',
title: 'Generated title',
body: 'Generated body',
draft: !seedDraft
}
})
expect(completed?.result?.draft).toBe(draft)
}
)
it('keeps PR generation results in the store after the composer unmounts', () => {
const store = createPullRequestGenerationTestStore()
const key = getPullRequestGenerationRecordKey({
worktreeId: 'wt-a',
worktreePath: '/repo/a',
repoId: 'repo-1',
branch: 'feature-a'
})
expect(key).not.toBeNull()
const record = createRunningPullRequestGenerationRecord(
{
worktreeId: 'wt-a',
worktreePath: '/repo/a',
connectionId: 'conn-a',
requestId: 1,
repoId: 'repo-1',
branch: 'feature-a',
runtimeTarget: { kind: 'environment' as const, environmentId: 'runtime-a' }
},
seed,
fieldRevisions
)
store.getState().setPullRequestGenerationRecord(key!, record)
const generated = {
base: 'main',
title: 'Generated after tab switch',
body: 'Generated body',
draft: false
}
store.getState().updatePullRequestGenerationRecord(key!, (current) =>
resolvePullRequestGenerationSuccess({
record: current,
requestId: 1,
result: generated
})
)
expect(store.getState().pullRequestGenerationRecords[key!]).toMatchObject({
context: { runtimeTarget: { kind: 'environment' as const, environmentId: 'runtime-a' } },
status: 'succeeded',
result: generated,
hydrated: false
})
})
it('persists branch-preparation push requirement until create succeeds', () => {
const record = createRunningPullRequestGenerationRecord(
{
worktreeId: 'wt-a',
worktreePath: '/repo/a',
connectionId: 'conn-a',
requestId: 1,
runtimeTarget: { kind: 'local' as const },
repoId: 'repo-1',
branch: 'feature-a'
},
seed,
fieldRevisions
)
const marked = markPullRequestGenerationRequiresPushBeforeCreate({
record,
requestId: 1
})
expect(marked?.requiresPushBeforeCreate).toBe(true)
const completed = resolvePullRequestGenerationSuccess({
record: marked,
requestId: 1,
result: {
base: 'main',
title: 'Generated after branch preparation',
body: 'Generated body',
draft: false
}
})
expect(completed).toMatchObject({
status: 'succeeded',
requiresPushBeforeCreate: true
})
expect(clearPullRequestGenerationRequiresPushBeforeCreate(completed)).toMatchObject({
requiresPushBeforeCreate: false
})
})
it('prunes PR generation records for removed worktrees', () => {
const store = createPullRequestGenerationTestStore()
const keyA = getPullRequestGenerationRecordKey({
@@ -1,556 +0,0 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest'
import { TooltipProvider } from '@/components/ui/tooltip'
import type {
GitBranchChangeEntry,
GitBranchCompareSummary
} from '../../../../../../shared/git/diff/git-diff-compare-types'
import type { GitStatusEntry } from '../../../../../../shared/git/status/git-status-types'
import SourceControl from '../panel/SourceControl'
const mocks = vi.hoisted(() => {
const activeRepo = {
id: 'repo-1',
path: '/repo',
displayName: 'Repo',
badgeColor: '#000',
addedAt: 0
}
const activeWorktree = {
id: 'wt-1',
repoId: 'repo-1',
path: '/repo/wt',
head: 'abcdef123',
branch: 'refs/heads/feature/source-control-preview',
isBare: false,
isMainWorktree: false,
displayName: 'feature/source-control-preview',
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
linkedGitLabMR: null,
linkedGitLabIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 0
}
const calls = {
openDiff: vi.fn(),
openFile: vi.fn(),
openConflictFile: vi.fn(),
openBranchDiff: vi.fn(),
createEmptySplitGroup: vi.fn(),
discardRuntimeGitPath: vi.fn(),
bulkStageRuntimeGitPaths: vi.fn(),
refreshGitStatusForWorktree: vi.fn(),
requestEditorSaveQuiesce: vi.fn(),
notifyEditorExternalFileChange: vi.fn()
}
return {
activeRepo,
activeWorktree,
calls,
state: {} as Record<string, unknown>
}
})
vi.mock('@/store/index', () => {
const useAppStore = Object.assign(
(selector?: (state: Record<string, unknown>) => unknown) =>
selector ? selector(mocks.state) : mocks.state,
{
getState: () => mocks.state
}
)
return { useAppStore }
})
vi.mock('@/store/selectors', () => ({
useActiveWorktree: () => mocks.activeWorktree,
useRepoById: (repoId: string | null) =>
repoId === mocks.activeRepo.id ? mocks.activeRepo : null,
useWorktreeMap: () => new Map([[mocks.activeWorktree.id, mocks.activeWorktree]])
}))
vi.mock('@/components/confirmation-dialog-context', () => ({
useConfirmationDialog: () => vi.fn().mockResolvedValue(true)
}))
vi.mock('@/runtime/runtime-git-working-tree-client', async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>()
return {
...actual,
discardRuntimeGitPath: mocks.calls.discardRuntimeGitPath,
bulkStageRuntimeGitPaths: mocks.calls.bulkStageRuntimeGitPaths
}
})
vi.mock('@/components/editor/autosave/editor-autosave', () => ({
requestEditorSaveQuiesce: mocks.calls.requestEditorSaveQuiesce,
notifyEditorExternalFileChange: mocks.calls.notifyEditorExternalFileChange
}))
vi.mock('../sync/git-status-refresh', () => ({
refreshGitStatusForWorktree: mocks.calls.refreshGitStatusForWorktree
}))
function gitEntry(overrides: Partial<GitStatusEntry>): GitStatusEntry {
return {
path: 'src/file.ts',
area: 'unstaged',
status: 'modified',
added: 1,
removed: 0,
...overrides
}
}
function branchEntry(overrides: Partial<GitBranchChangeEntry> = {}): GitBranchChangeEntry {
return {
path: 'src/branch.ts',
status: 'modified',
added: 2,
removed: 1,
...overrides
}
}
function branchSummary(): GitBranchCompareSummary {
return {
baseRef: 'origin/main',
baseOid: 'base',
compareRef: 'feature/source-control-preview',
headOid: 'head',
mergeBase: 'base',
changedFiles: 1,
commitsAhead: 1,
status: 'ready'
}
}
function noopAsync(value: unknown = undefined): () => Promise<unknown> {
return vi.fn().mockResolvedValue(value)
}
function resetState(overrides: Partial<Record<string, unknown>> = {}): void {
vi.clearAllMocks()
mocks.calls.createEmptySplitGroup.mockReturnValue('group-2')
mocks.calls.discardRuntimeGitPath.mockResolvedValue(undefined)
mocks.calls.bulkStageRuntimeGitPaths.mockResolvedValue(undefined)
mocks.calls.refreshGitStatusForWorktree.mockResolvedValue(undefined)
mocks.calls.requestEditorSaveQuiesce.mockResolvedValue(undefined)
mocks.state = {
activeWorktreeId: mocks.activeWorktree.id,
activeGroupIdByWorktree: { [mocks.activeWorktree.id]: 'group-1' },
groupsByWorktree: { [mocks.activeWorktree.id]: [{ id: 'group-1', activeTabId: null }] },
repos: [mocks.activeRepo],
worktreesByRepo: { [mocks.activeRepo.id]: [mocks.activeWorktree] },
sshConnectionStates: new Map(),
sshStateByEnvironment: new Map(),
runtimeStatusByEnvironmentId: new Map(),
rightSidebarOpen: false,
rightSidebarTab: 'source-control',
gitStatusByWorktree: { [mocks.activeWorktree.id]: [] },
gitBranchChangesByWorktree: { [mocks.activeWorktree.id]: [] },
gitBranchCompareSummaryByWorktree: { [mocks.activeWorktree.id]: null },
gitBranchLineTotalByWorktree: {},
gitConflictOperationByWorktree: {},
remoteStatusesByWorktree: {},
isRemoteOperationActive: false,
remoteOperations: [],
settings: null,
hostedReviewCache: {},
prCache: {},
commitMessageGenerationRecords: {},
pullRequestGenerationRecords: {},
getDiffComments: vi.fn(() => []),
updateSettings: noopAsync(),
openSettingsTarget: vi.fn(),
openSettingsPage: vi.fn(),
fetchHostedReviewForBranch: noopAsync(),
getHostedReviewCreationEligibility: noopAsync(null),
createHostedReview: noopAsync({ ok: false, error: 'not available' }),
updateWorktreeMeta: noopAsync(),
fetchPRForBranch: noopAsync(),
enqueueGitHubPRRefresh: vi.fn(),
updateRepo: noopAsync(),
setGitStatus: vi.fn(),
updateWorktreeGitIdentity: vi.fn(),
beginGitBranchCompareRequest: vi.fn(() => 'request-key'),
setGitBranchCompareResult: vi.fn(),
clearGitBranchCompare: vi.fn(),
fetchUpstreamStatus: noopAsync(),
setUpstreamStatus: vi.fn(),
pushBranch: noopAsync(),
pullBranch: noopAsync(),
fastForwardBranch: noopAsync(),
syncBranch: noopAsync(),
rebaseFromBase: noopAsync(),
fetchBranch: noopAsync(),
revealInExplorer: vi.fn(),
trackConflictPath: vi.fn(),
openDiff: mocks.calls.openDiff,
openFile: mocks.calls.openFile,
setEditorViewMode: vi.fn(),
setMarkdownViewMode: vi.fn(),
setPendingEditorReveal: vi.fn(),
openConflictFile: mocks.calls.openConflictFile,
openConflictReview: vi.fn(),
openBranchDiff: mocks.calls.openBranchDiff,
createEmptySplitGroup: mocks.calls.createEmptySplitGroup,
openAllDiffs: vi.fn(),
openBranchAllDiffs: vi.fn(),
openCommitAllDiffs: vi.fn(),
deleteDiffComment: noopAsync(true),
clearDiffComments: noopAsync(true),
clearDiffCommentsForFile: noopAsync(true),
setScrollToDiffCommentId: vi.fn(),
setRightSidebarOpen: vi.fn(),
setRightSidebarTab: vi.fn(),
allocateCommitMessageGenerationRequestId: vi.fn(() => 'commit-generation-1'),
setCommitMessageGenerationRecord: vi.fn(),
updateCommitMessageGenerationRecord: vi.fn(),
pruneCommitMessageGenerationRecords: vi.fn(),
allocatePullRequestGenerationRequestId: vi.fn(() => 'pr-generation-1'),
setPullRequestGenerationRecord: vi.fn(),
updatePullRequestGenerationRecord: vi.fn(),
prunePullRequestGenerationRecords: vi.fn(),
...overrides
}
}
let container: HTMLDivElement
let root: Root
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
resetState()
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})
afterEach(() => {
act(() => root.unmount())
container.remove()
})
function renderSourceControl(): void {
act(() => {
root.render(
<TooltipProvider>
<SourceControl />
</TooltipProvider>
)
})
}
function clickUncommitted(path: string, init: MouseEventInit = {}): void {
const row = container.querySelector<HTMLDivElement>(`[data-source-control-path="${path}"]`)
expect(row).not.toBeNull()
act(() => {
row?.dispatchEvent(new MouseEvent('click', { bubbles: true, ...init }))
})
}
function doubleClickUncommitted(path: string): void {
const row = container.querySelector<HTMLDivElement>(`[data-source-control-path="${path}"]`)
expect(row).not.toBeNull()
act(() => {
row?.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }))
})
}
function clickBranchRow(init: MouseEventInit = {}): void {
const label = [...container.querySelectorAll('span')].find(
(candidate) => candidate.textContent === 'branch.ts'
)
const row = label?.closest('div')
expect(row).not.toBeNull()
act(() => {
row?.dispatchEvent(new MouseEvent('click', { bubbles: true, ...init }))
})
}
describe('SourceControl preview row opens', () => {
it.each([
['src/file.ts', 'src/staged.ts', 'typescript'],
['report.html', 'staged.html', 'html'],
['REPORT.HTM', 'STAGED.HTM', 'html']
])('opens %s and %s as preview diff tabs on plain clicks', (path, stagedPath, language) => {
resetState({
gitStatusByWorktree: {
[mocks.activeWorktree.id]: [
gitEntry({ path }),
gitEntry({ path: stagedPath, area: 'staged' })
]
}
})
renderSourceControl()
clickUncommitted(path)
clickUncommitted(stagedPath)
expect(mocks.calls.openDiff).toHaveBeenCalledWith(
mocks.activeWorktree.id,
`/repo/wt/${path}`,
path,
language,
false,
{ targetGroupId: undefined, preview: true }
)
expect(mocks.calls.openDiff).toHaveBeenCalledWith(
mocks.activeWorktree.id,
`/repo/wt/${stagedPath}`,
stagedPath,
language,
true,
{ targetGroupId: undefined, preview: true }
)
})
it('keeps modifier split row opens permanent and targeted at the split group', () => {
resetState({
gitStatusByWorktree: { [mocks.activeWorktree.id]: [gitEntry({ path: 'src/file.ts' })] }
})
renderSourceControl()
clickUncommitted('src/file.ts', { ctrlKey: true })
expect(mocks.calls.createEmptySplitGroup).toHaveBeenCalledWith(
mocks.activeWorktree.id,
'group-1',
'right'
)
expect(mocks.calls.openDiff).toHaveBeenCalledWith(
mocks.activeWorktree.id,
'/repo/wt/src/file.ts',
'src/file.ts',
'typescript',
false,
{ targetGroupId: 'group-2', preview: false }
)
})
it('keeps explicit permanent uncommitted opens permanent', () => {
resetState({
gitStatusByWorktree: { [mocks.activeWorktree.id]: [gitEntry({ path: 'src/file.ts' })] }
})
renderSourceControl()
doubleClickUncommitted('src/file.ts')
expect(mocks.calls.openDiff).toHaveBeenCalledWith(
mocks.activeWorktree.id,
'/repo/wt/src/file.ts',
'src/file.ts',
'typescript',
false,
{ targetGroupId: undefined, preview: false }
)
})
it('passes preview through new markdown rich editor and conflict file opens', () => {
resetState({
gitStatusByWorktree: {
[mocks.activeWorktree.id]: [
gitEntry({ path: 'docs/readme.md', status: 'untracked', area: 'untracked' }),
gitEntry({ path: 'docs/existing.md' }),
gitEntry({
path: 'src/conflict.ts',
conflictKind: 'both_modified',
conflictStatus: 'unresolved'
})
]
}
})
renderSourceControl()
clickUncommitted('docs/readme.md')
clickUncommitted('docs/existing.md')
clickUncommitted('src/conflict.ts')
expect(mocks.calls.openFile).toHaveBeenCalledWith(
{
filePath: '/repo/wt/docs/readme.md',
relativePath: 'docs/readme.md',
worktreeId: mocks.activeWorktree.id,
language: 'markdown',
mode: 'edit'
},
{ targetGroupId: undefined, preview: true }
)
expect(mocks.calls.openDiff).toHaveBeenCalledWith(
mocks.activeWorktree.id,
'/repo/wt/docs/existing.md',
'docs/existing.md',
'markdown',
false,
{ targetGroupId: undefined, preview: true }
)
expect(mocks.calls.openConflictFile).toHaveBeenCalledWith(
mocks.activeWorktree.id,
'/repo/wt',
expect.objectContaining({ path: 'src/conflict.ts' }),
'typescript',
{ targetGroupId: undefined, preview: true }
)
})
it('scopes discard autosave quiesce and reload notifications to the repo owner runtime', async () => {
resetState({
gitStatusByWorktree: { [mocks.activeWorktree.id]: [gitEntry({ path: 'src/file.ts' })] }
})
// Route discard by owner stamp even when another server is focused.
Object.assign(mocks.activeRepo, { executionHostId: 'runtime:runtime-remote' })
onTestFinished(() => {
Reflect.deleteProperty(mocks.activeRepo, 'executionHostId')
})
renderSourceControl()
mocks.state.settings = { activeRuntimeEnvironmentId: 'runtime-other' }
const row = container.querySelector<HTMLDivElement>('[data-source-control-path="src/file.ts"]')
const discardButton = row?.querySelector<HTMLButtonElement>(
'button[aria-label="Discard changes"]'
)
expect(discardButton).not.toBeNull()
act(() => {
discardButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
const confirmButton = [...document.body.querySelectorAll<HTMLButtonElement>('button')].find(
(button) => button.textContent?.trim() === 'Discard'
)
expect(confirmButton).not.toBeNull()
await act(async () => {
confirmButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await Promise.resolve()
})
expect(mocks.calls.requestEditorSaveQuiesce).toHaveBeenCalledWith({
worktreeId: mocks.activeWorktree.id,
worktreePath: '/repo/wt',
relativePath: 'src/file.ts',
runtimeEnvironmentId: 'runtime-remote'
})
expect(mocks.calls.notifyEditorExternalFileChange).toHaveBeenCalledWith({
worktreeId: mocks.activeWorktree.id,
worktreePath: '/repo/wt',
relativePath: 'src/file.ts',
runtimeEnvironmentId: 'runtime-remote'
})
})
it('keeps nested-only submodule rows non-stageable from the parent repo', () => {
resetState({
gitStatusByWorktree: {
[mocks.activeWorktree.id]: [
gitEntry({
path: 'packages/nested',
submodule: { commitChanged: false, trackedChanges: true, untrackedChanges: false }
})
]
}
})
renderSourceControl()
const row = container.querySelector<HTMLDivElement>(
'[data-source-control-path="packages/nested"]'
)
expect(row?.textContent).toContain('Stage inside submodule')
expect(
row?.querySelector('[title*="cannot stage file changes inside a submodule"]')
).not.toBeNull()
expect(row?.querySelector<HTMLButtonElement>('button[aria-label="Stage"]')).toBeNull()
})
it('does not render a commit-area Stage All primary for submodule worktree-only rows', () => {
resetState({
gitStatusByWorktree: {
[mocks.activeWorktree.id]: [
gitEntry({
path: 'june-11th-launch',
submodule: { commitChanged: false, trackedChanges: true, untrackedChanges: false }
})
]
}
})
renderSourceControl()
const row = container.querySelector<HTMLDivElement>(
'[data-source-control-path="june-11th-launch"]'
)
expect(row?.textContent).toContain('Stage inside submodule')
const stageAllButton = [...container.querySelectorAll<HTMLButtonElement>('button')].find(
(button) => button.textContent?.trim() === 'Stage All'
)
expect(stageAllButton).toBeUndefined()
const commitButton = [...container.querySelectorAll<HTMLButtonElement>('button')].find(
(button) => button.textContent?.trim() === 'Commit'
)
expect(commitButton).toBeDefined()
expect(commitButton?.disabled).toBe(true)
act(() => {
commitButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
expect(mocks.calls.bulkStageRuntimeGitPaths).not.toHaveBeenCalled()
})
it('keeps the commit-area Stage All primary for changed submodule gitlinks', async () => {
resetState({
gitStatusByWorktree: {
[mocks.activeWorktree.id]: [
gitEntry({
path: 'packages/nested',
submodule: { commitChanged: true, trackedChanges: false, untrackedChanges: false }
})
]
}
})
renderSourceControl()
const stageAllButton = [...container.querySelectorAll<HTMLButtonElement>('button')].find(
(button) => button.textContent?.trim() === 'Stage All'
)
expect(stageAllButton).toBeDefined()
expect(stageAllButton?.disabled).toBe(false)
await act(async () => {
stageAllButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await Promise.resolve()
})
expect(mocks.calls.bulkStageRuntimeGitPaths).toHaveBeenCalledWith(
expect.objectContaining({ worktreeId: mocks.activeWorktree.id, worktreePath: '/repo/wt' }),
['packages/nested']
)
})
it('passes preview=true when a plain branch row click opens a branch diff tab', () => {
resetState({
gitBranchChangesByWorktree: { [mocks.activeWorktree.id]: [branchEntry()] },
gitBranchCompareSummaryByWorktree: { [mocks.activeWorktree.id]: branchSummary() }
})
renderSourceControl()
clickBranchRow()
expect(mocks.calls.openBranchDiff).toHaveBeenCalledWith(
mocks.activeWorktree.id,
'/repo/wt',
expect.objectContaining({ path: 'src/branch.ts' }),
expect.objectContaining({ status: 'ready' }),
'typescript',
{ targetGroupId: undefined, preview: true }
)
})
})
@@ -1,66 +1,13 @@
import { join, sep } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { describe, expect, it } from 'vitest'
import {
createCreatePrIntentRunToken,
createPrIntentCurrentTargetConflictsWithToken,
createPrIntentGitStatusMatchesToken,
createPrIntentRunTokenMatches,
getCreatePrIntentCommitFailureNoticeMessage,
getCreatePrIntentStagePaths,
resolveCreatePrIntentReviewBase,
resolveCreatePrIntentGeneratedReviewFields,
shouldAttemptCreateHostedReviewForIntent,
shouldAttemptCreateHostedReviewForIntent as shouldGenerateHostedReviewDetailsForIntent
resolveCreatePrIntentGeneratedReviewFields
} from './create-pr-intent-flow'
import type { GitStatusEntry } from '../../../../../../shared/git/status/git-status-types'
describe('source-control Create PR intent flow helpers', () => {
it('matches async completions only to the original repo, worktree, path, branch, and base', () => {
const now = vi.spyOn(Date, 'now').mockReturnValue(123)
try {
const token = createCreatePrIntentRunToken({
repoId: 'repo-1',
worktreeId: 'wt-1',
worktreePath: '/repo',
branch: 'feature',
provider: 'github',
baseRef: 'origin/main',
pinnedCopy: null
})
expect(token.startedAt).toBe(123)
expect(createPrIntentRunTokenMatches(token, token)).toBe(true)
expect(
createPrIntentRunTokenMatches(token, { ...token, baseRef: 'refs/remotes/origin/main' })
).toBe(true)
expect(createPrIntentRunTokenMatches(token, { ...token, branch: 'other' })).toBe(false)
expect(createPrIntentRunTokenMatches(token, { ...token, worktreeId: 'wt-2' })).toBe(false)
expect(createPrIntentRunTokenMatches(token, { ...token, baseRef: 'upstream/main' })).toBe(
false
)
} finally {
now.mockRestore()
}
})
it('matches strict git status snapshots to the original branch', () => {
const token = createCreatePrIntentRunToken({
repoId: 'repo-1',
worktreeId: 'wt-1',
worktreePath: '/repo',
branch: 'feature/pr',
provider: 'github',
pinnedCopy: null
})
expect(createPrIntentGitStatusMatchesToken(token, { branch: 'refs/heads/feature/pr' })).toBe(
true
)
expect(createPrIntentGitStatusMatchesToken(token, { branch: 'feature/pr' })).toBe(true)
expect(createPrIntentGitStatusMatchesToken(token, { branch: 'refs/heads/other' })).toBe(false)
expect(createPrIntentGitStatusMatchesToken(token, { branch: null })).toBe(false)
})
it('does not treat navigating to another worktree as an intent conflict', () => {
const wt1Path = join(sep, 'repo', 'wt-1')
const wt2Path = join(sep, 'repo', 'wt-2')
@@ -136,23 +83,6 @@ describe('source-control Create PR intent flow helpers', () => {
).toBe(true)
})
it('stages only safe unstaged and untracked paths', () => {
const unresolved = {
path: 'conflicted.ts',
status: 'modified',
area: 'unstaged',
conflictKind: 'both_modified',
conflictStatus: 'unresolved'
} satisfies GitStatusEntry
expect(
getCreatePrIntentStagePaths({
unstaged: [{ path: 'safe.ts', status: 'modified', area: 'unstaged' }, unresolved],
untracked: [{ path: 'new.ts', status: 'untracked', area: 'untracked' }]
})
).toEqual(['safe.ts', 'new.ts'])
})
it('prefers the remote-validated eligibility default so it cannot diverge from the composer', () => {
// Match the composer's remote-validated base default so one-click creation cannot target an unpushable local stack parent.
expect(
@@ -172,55 +102,6 @@ describe('source-control Create PR intent flow helpers', () => {
).toBe('develop')
})
it('falls back to the compare base when eligibility supplies no default', () => {
// If eligibility has no remote default, preserve the selected compare base instead of an empty target.
expect(
resolveCreatePrIntentReviewBase({
currentBaseRef: 'refs/remotes/origin/release',
eligibilityDefaultBaseRef: null,
composerBaseRef: 'main'
})
).toBe('release')
})
it('generates details while main preflight remains the final lookup authority', () => {
const unavailable = {
provider: 'github' as const,
review: null,
canCreate: false,
blockedReason: null,
nextAction: null,
reviewLookupOutcome: 'unavailable' as const,
head: 'feature-branch'
}
expect(shouldAttemptCreateHostedReviewForIntent(unavailable)).toBe(true)
// Loading placeholders share the unavailable/null-reason shape but carry no branch.
expect(shouldAttemptCreateHostedReviewForIntent({ ...unavailable, head: undefined })).toBe(
false
)
expect(shouldGenerateHostedReviewDetailsForIntent(unavailable)).toBe(true)
expect(shouldGenerateHostedReviewDetailsForIntent({ ...unavailable, head: undefined })).toBe(
false
)
expect(
shouldGenerateHostedReviewDetailsForIntent({
...unavailable,
canCreate: true,
reviewLookupOutcome: 'not_found'
})
).toBe(true)
expect(
shouldAttemptCreateHostedReviewForIntent({
provider: 'github',
review: null,
canCreate: false,
blockedReason: 'needs_push',
nextAction: 'push',
reviewLookupOutcome: 'unavailable'
})
).toBe(false)
})
it('keeps generated review fields fail-closed', () => {
const current = {
base: 'main',
@@ -256,33 +137,6 @@ describe('source-control Create PR intent flow helpers', () => {
})
})
it('accepts generated review fields with an empty description', () => {
expect(
resolveCreatePrIntentGeneratedReviewFields(
{ base: 'main', title: 'Feature branch', body: '', draft: false },
{
success: true,
fields: { base: 'main', title: 'chore: add new app config', body: '', draft: false }
}
)
).toEqual({
ok: true,
fields: { base: 'main', title: 'chore: add new app config', body: '', draft: false }
})
})
it('falls back to the current title when the generated title is blank and the body is empty', () => {
expect(
resolveCreatePrIntentGeneratedReviewFields(
{ base: 'main', title: 'Feature branch', body: '', draft: false },
{ success: true, fields: { base: 'main', title: ' ', body: ' ', draft: true } }
)
).toEqual({
ok: true,
fields: { base: 'main', title: 'Feature branch', body: ' ', draft: true }
})
})
it('never downgrades a "Create as draft" choice to a ready review', () => {
expect(
resolveCreatePrIntentGeneratedReviewFields(
@@ -297,23 +151,4 @@ describe('source-control Create PR intent flow helpers', () => {
fields: { base: 'main', title: 'Generated title', body: 'Details', draft: true }
})
})
it('surfaces the commit failure summary in the Create PR intent notice', () => {
expect(
getCreatePrIntentCommitFailureNoticeMessage(
'husky - pre-commit hook\neslint found 2 errors\nfull output'
)
).toBe('Commit blocked: Lint failed during commit. Fix the issue, then retry Create PR.')
expect(getCreatePrIntentCommitFailureNoticeMessage(null)).toBe(
'Could not commit changes. Fix the issue, then retry Create PR.'
)
expect(
getCreatePrIntentCommitFailureNoticeMessage('pre-commit hook failed', {
fallback: 'fallback',
withSummary: (summary) => `localized ${summary}`
})
).toBe('localized Pre-commit hook failed.')
})
})
@@ -1,174 +1,7 @@
import { describe, expect, it } from 'vitest'
import {
buildSourceControlManualReviewUrl,
buildSourceControlManualReviewUrlFromContext,
resolveSourceControlManualReviewProvider
} from './manual-review-url'
import { parseRemoteRepo } from './remote-repo'
describe('resolveSourceControlManualReviewProvider', () => {
it('prefers hosted review and creation providers over linked review hints', () => {
expect(
resolveSourceControlManualReviewProvider({
hostedReviewProvider: 'gitlab',
hostedReviewCreationProvider: 'github',
linkedGitLabMR: 12
})
).toBe('gitlab')
expect(
resolveSourceControlManualReviewProvider({
hostedReviewCreationProvider: 'bitbucket',
linkedGitLabMR: 12
})
).toBe('bitbucket')
})
it('falls back through linked review metadata when no hosted provider is known', () => {
expect(
resolveSourceControlManualReviewProvider({
linkedGitLabMR: 12
})
).toBe('gitlab')
expect(
resolveSourceControlManualReviewProvider({
linkedBitbucketPR: 3
})
).toBe('bitbucket')
expect(
resolveSourceControlManualReviewProvider({
linkedAzureDevOpsPR: 7
})
).toBe('azure-devops')
expect(
resolveSourceControlManualReviewProvider({
linkedGiteaPR: 2
})
).toBe('gitea')
expect(
resolveSourceControlManualReviewProvider({
linkedGitHubPR: 42
})
).toBe('github')
expect(
resolveSourceControlManualReviewProvider({
fallbackGitHubPRNumber: 42
})
).toBe('github')
})
it('returns null when there is no provider hint', () => {
expect(resolveSourceControlManualReviewProvider({})).toBeNull()
})
})
describe('buildSourceControlManualReviewUrlFromContext', () => {
it('resolves the provider from linked review metadata before building the URL', () => {
expect(
buildSourceControlManualReviewUrlFromContext({
linkedBitbucketPR: 3,
baseRef: 'origin/main',
branchName: 'feature/bitbucket',
repoRemoteName: 'origin',
repoRemoteUrl: 'git@bitbucket.org:team/project.git',
upstreamName: 'origin/feature/bitbucket'
})
).toBe(
'https://bitbucket.org/team/project/pull-requests/new?source=feature%2Fbitbucket&dest=main'
)
})
})
import { buildSourceControlManualReviewUrl } from './manual-review-url'
describe('buildSourceControlManualReviewUrl', () => {
it.each([
'ssh://git@altssh.gitlab.com:443/team/project.git',
'git@altssh.gitlab.com:team/project.git',
'ssh://git@ALTSSH.GITLAB.COM:443/team/project.git',
'git@ALTSSH.GITLAB.COM:team/project.git'
])('canonicalizes GitLab SSH-over-443 web identity: %s', (remote) => {
expect(parseRemoteRepo(remote)).toEqual({
provider: 'gitlab',
path: 'team/project',
webBaseUrl: 'https://gitlab.com/team/project'
})
for (const provider of [undefined, 'gitlab'] as const) {
expect(
buildSourceControlManualReviewUrl({
baseRef: 'main',
branchName: 'feature/x',
repoRemoteName: 'origin',
repoRemoteUrl: remote,
provider,
upstreamName: 'origin/feature/x'
})
).toBe(
'https://gitlab.com/team/project/-/merge_requests/new?merge_request%5Bsource_branch%5D=feature%2Fx&merge_request%5Btarget_branch%5D=main'
)
}
})
it.each([
'ssh://git@ssh.bitbucket.org/team/project.git',
'git@ssh.bitbucket.org:team/project.git',
'git@SSH.BITBUCKET.ORG:team/project.git'
])('canonicalizes the Bitbucket SSH host web identity: %s', (remote) => {
expect(parseRemoteRepo(remote)).toEqual({
provider: 'bitbucket',
path: 'team/project',
webBaseUrl: 'https://bitbucket.org/team/project'
})
for (const provider of [undefined, 'bitbucket'] as const) {
expect(
buildSourceControlManualReviewUrl({
baseRef: 'main',
branchName: 'feature/x',
repoRemoteName: 'origin',
repoRemoteUrl: remote,
provider,
upstreamName: 'origin/feature/x'
})
).toBe('https://bitbucket.org/team/project/pull-requests/new?source=feature%2Fx&dest=main')
}
})
it.each(['ssh://git@ssh.github.com:443/team/project.git', 'git@ssh.github.com:team/project.git'])(
'canonicalizes the GitHub SSH-over-443 web identity: %s',
(remote) => {
expect(parseRemoteRepo(remote)).toEqual({
provider: 'github',
path: 'team/project',
webBaseUrl: 'https://github.com/team/project'
})
}
)
it('does not infer GitLab or rewrite the web origin of an alias lookalike', () => {
const remote = 'ssh://git@altssh.gitlab.com.example.com:443/team/project.git'
expect(parseRemoteRepo(remote)).toEqual({
provider: null,
path: 'team/project',
webBaseUrl: 'https://altssh.gitlab.com.example.com/team/project'
})
expect(parseRemoteRepo(remote, 'gitlab')).toEqual({
provider: 'gitlab',
path: 'team/project',
webBaseUrl: 'https://altssh.gitlab.com.example.com/team/project'
})
})
it('builds a GitHub compare URL for the current remote branch', () => {
expect(
buildSourceControlManualReviewUrl({
baseRef: 'refs/remotes/origin/main',
branchName: 'native-chat-does-not-auto-open',
repoRemoteName: 'origin',
repoRemoteUrl: 'git@github.com:stablyai/orca.git',
upstreamName: 'origin/native-chat-does-not-auto-open'
})
).toBe(
'https://github.com/stablyai/orca/compare/main...native-chat-does-not-auto-open?expand=1'
)
})
it('qualifies GitHub fork heads when the push target remote differs from the base repo', () => {
expect(
buildSourceControlManualReviewUrl({
@@ -199,21 +32,6 @@ describe('buildSourceControlManualReviewUrl', () => {
)
})
it('builds a GitLab merge request URL for a self-hosted GitLab remote', () => {
expect(
buildSourceControlManualReviewUrl({
baseRef: 'refs/remotes/origin/release/next',
branchName: 'feature/gitlab',
repoRemoteName: 'origin',
repoRemoteUrl: 'git@gitlab.company.test:group/sub/orca.git',
provider: 'gitlab',
upstreamName: 'origin/feature/gitlab'
})
).toBe(
'https://gitlab.company.test/group/sub/orca/-/merge_requests/new?merge_request%5Bsource_branch%5D=feature%2Fgitlab&merge_request%5Btarget_branch%5D=release%2Fnext'
)
})
it('opens the GitLab New-MR page on the fork project when the branch was pushed to a fork', () => {
expect(
buildSourceControlManualReviewUrl({
@@ -234,47 +52,6 @@ describe('buildSourceControlManualReviewUrl', () => {
)
})
it('builds a Bitbucket manual pull request URL', () => {
expect(
buildSourceControlManualReviewUrl({
baseRef: 'origin/main',
branchName: 'feature/bitbucket',
repoRemoteName: 'origin',
repoRemoteUrl: 'git@bitbucket.org:team/project.git',
upstreamName: 'origin/feature/bitbucket'
})
).toBe(
'https://bitbucket.org/team/project/pull-requests/new?source=feature%2Fbitbucket&dest=main'
)
})
it('builds an Azure DevOps pull request creation URL', () => {
expect(
buildSourceControlManualReviewUrl({
baseRef: 'refs/remotes/origin/main',
branchName: 'feature/azure',
repoRemoteName: 'origin',
repoRemoteUrl: 'git@ssh.dev.azure.com:v3/acme/widgets/widget-app.git',
upstreamName: 'origin/feature/azure'
})
).toBe(
'https://dev.azure.com/acme/widgets/_git/widget-app/pullrequestcreate?sourceRef=refs%2Fheads%2Ffeature%2Fazure&targetRef=refs%2Fheads%2Fmain'
)
})
it('builds a Gitea compare URL when the provider is known', () => {
expect(
buildSourceControlManualReviewUrl({
baseRef: 'refs/remotes/origin/main',
branchName: 'feature/gitea',
repoRemoteName: 'origin',
repoRemoteUrl: 'ssh://git@gitea.company.test/team/orca.git',
provider: 'gitea',
upstreamName: 'origin/feature/gitea'
})
).toBe('https://gitea.company.test/team/orca/compare/main...feature/gitea')
})
it('suppresses the link when the branch tracks a fork remote with no resolvable push URL', () => {
// A fork-only tracked branch without pushTarget would 404 through the base-repo compare URL.
expect(
@@ -299,46 +76,4 @@ describe('buildSourceControlManualReviewUrl', () => {
})
).toBe('https://github.com/stablyai/orca/compare/main...feature/pushed-name?expand=1')
})
it('still qualifies the fork head when Orca resolved the fork push URL', () => {
expect(
buildSourceControlManualReviewUrl({
baseRef: 'refs/remotes/origin/main',
branchName: 'quick-commands',
repoRemoteName: 'origin',
repoRemoteUrl: 'https://github.com/stablyai/orca.git',
upstreamName: 'pr-devajmeireles-orca/quick-commands',
pushTarget: {
remoteName: 'pr-devajmeireles-orca',
branchName: 'quick-commands',
remoteUrl: 'git@github.com:devajmeireles/orca.git'
}
})
).toBe('https://github.com/stablyai/orca/compare/main...devajmeireles:quick-commands?expand=1')
})
it('does not guess a provider for unknown hosts without a provider hint', () => {
expect(
buildSourceControlManualReviewUrl({
baseRef: 'refs/remotes/origin/main',
branchName: 'feature/unknown',
repoRemoteName: 'origin',
repoRemoteUrl: 'git@git.company.test:team/orca.git',
upstreamName: 'origin/feature/unknown'
})
).toBeNull()
})
it('suppresses the link when the branch was never pushed (no upstream, no push target)', () => {
// Unpushed local branches would lead to GitHub's empty compare page.
expect(
buildSourceControlManualReviewUrl({
baseRef: 'refs/remotes/origin/main',
branchName: 'codex-runtime-home-refactor-design',
repoRemoteName: 'origin',
repoRemoteUrl: 'git@github.com:stablyai/orca.git',
upstreamName: null
})
).toBeNull()
})
})
@@ -1,635 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
resolveCommitAreaPrimaryAction,
resolvePrimaryAction
} from './source-control-primary-action'
import type { PrimaryActionInputs } from './source-control-primary-action-types'
import { resolveCreatePrHeaderAction } from './primary-create-pr-intent-action'
function inputs(overrides: Partial<PrimaryActionInputs> = {}): PrimaryActionInputs {
return {
stagedCount: 0,
hasUnstagedChanges: false,
hasStageableChanges: false,
hasPartiallyStagedChanges: false,
hasMessage: false,
hasUnresolvedConflicts: false,
isCommitting: false,
isRemoteOperationActive: false,
upstreamStatus: undefined,
...overrides
}
}
const upstreamInSync = {
hasUpstream: true,
upstreamName: 'origin/main',
ahead: 0,
behind: 0
}
describe('resolvePrimaryAction Create PR intent', () => {
it('returns Create PR intent for an unpublished clean branch with commits to publish', () => {
const result = resolvePrimaryAction(
inputs({
upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 },
branchCommitsAhead: 2,
hostedReviewCreation: {
provider: 'github',
review: null,
canCreate: false,
blockedReason: 'no_upstream',
nextAction: 'publish',
reviewLookupOutcome: 'not_found'
}
})
)
expect(result.kind).toBe('create_pr_intent')
expect(result.disabled).toBe(false)
})
it('returns Create PR intent for patch-equivalent force-push before review', () => {
const result = resolvePrimaryAction(
inputs({
branchCommitsAhead: 4,
upstreamStatus: {
hasUpstream: true,
upstreamName: 'origin/feature',
ahead: 14,
behind: 3,
behindCommitsArePatchEquivalent: true
},
hostedReviewCreation: {
provider: 'github',
review: null,
canCreate: false,
blockedReason: 'needs_sync',
nextAction: 'sync',
reviewLookupOutcome: 'not_found'
}
})
)
expect(result.kind).toBe('create_pr_intent')
expect(result.disabled).toBe(false)
})
it('returns Create PR intent for a behind-only branch (fast-forward prepare)', () => {
const input = inputs({
upstreamStatus: {
hasUpstream: true,
upstreamName: 'origin/feature',
ahead: 0,
behind: 3
},
hostedReviewCreation: {
provider: 'github',
review: null,
canCreate: false,
blockedReason: 'needs_sync',
nextAction: 'sync',
reviewLookupOutcome: 'not_found'
}
})
const result = resolvePrimaryAction(input)
expect(result.kind).toBe('create_pr_intent')
expect(result.disabled).toBe(false)
expect(resolveCreatePrHeaderAction(input)).toEqual({
kind: 'create_pr_intent',
label: 'Create PR',
title: 'Prepare this branch and create a pull request',
disabled: false
})
})
it('returns Create PR intent for a branch that needs a safe push before review', () => {
const input = inputs({
upstreamStatus: { hasUpstream: true, ahead: 2, behind: 0 },
hostedReviewCreation: {
provider: 'github',
review: null,
canCreate: false,
blockedReason: 'needs_push',
nextAction: 'push',
reviewLookupOutcome: 'not_found'
}
})
const result = resolvePrimaryAction(input)
expect(result.kind).toBe('create_pr_intent')
expect(result.disabled).toBe(false)
expect(resolveCreatePrHeaderAction(input)).toEqual({
kind: 'create_pr_intent',
label: 'Create PR',
title: 'Prepare this branch and create a pull request',
disabled: false
})
expect(resolveCommitAreaPrimaryAction(input)).toEqual({
kind: 'push',
label: 'Push',
title: 'Push 2 commits',
disabled: false
})
})
it('returns Create PR intent for a dirty tree when hosted review prep can commit changes', () => {
const result = resolvePrimaryAction(
inputs({
hasUnstagedChanges: true,
hasStageableChanges: true,
upstreamStatus: upstreamInSync,
hostedReviewCreation: {
provider: 'github',
review: null,
canCreate: false,
blockedReason: 'dirty',
nextAction: 'commit',
reviewLookupOutcome: 'not_found'
}
})
)
expect(result).toEqual({
kind: 'create_pr_intent',
label: 'Create PR',
title: 'Prepare this branch and create a pull request',
disabled: false
})
expect(
resolveCreatePrHeaderAction(
inputs({
stagedCount: 0,
hasUnstagedChanges: true,
hasStageableChanges: true,
upstreamStatus: upstreamInSync,
hostedReviewCreation: {
provider: 'github',
review: null,
canCreate: false,
blockedReason: 'dirty',
nextAction: 'commit',
reviewLookupOutcome: 'not_found'
}
})
)
).toEqual({
kind: 'create_pr_intent',
label: 'Create PR',
title: 'Prepare this branch and create a pull request',
disabled: false
})
})
it('returns Create PR intent for a dirty tree when review lookup is unavailable', () => {
const input = inputs({
hasUnstagedChanges: true,
hasStageableChanges: true,
upstreamStatus: upstreamInSync,
hostedReviewCreation: {
provider: 'github',
review: null,
canCreate: false,
blockedReason: 'dirty',
nextAction: 'commit',
defaultBaseRef: 'main',
reviewLookupOutcome: 'unavailable'
}
})
expect(resolvePrimaryAction(input)).toMatchObject({
kind: 'create_pr_intent',
disabled: false
})
expect(resolveCreatePrHeaderAction(input)).toMatchObject({
kind: 'create_pr_intent',
disabled: false
})
})
it('returns Create PR intent for staged changes without a message so the flow can request one', () => {
const input = inputs({
stagedCount: 1,
hasMessage: false,
upstreamStatus: upstreamInSync,
hostedReviewCreation: {
provider: 'github',
review: null,
canCreate: false,
blockedReason: 'dirty',
nextAction: 'commit',
reviewLookupOutcome: 'not_found'
}
})
const result = resolvePrimaryAction(input)
expect(result.kind).toBe('create_pr_intent')
expect(result.disabled).toBe(false)
expect(resolveCreatePrHeaderAction(input)).toEqual({
kind: 'create_pr_intent',
label: 'Create PR',
title: 'Prepare this branch and create a pull request',
disabled: false
})
})
it('keeps default branch blockers on the direct notice path', () => {
expect(
resolveCreatePrHeaderAction(
inputs({
stagedCount: 1,
upstreamStatus: upstreamInSync,
hostedReviewCreation: {
provider: 'github',
review: null,
canCreate: false,
blockedReason: 'default_branch',
nextAction: null,
reviewLookupOutcome: 'not_found'
}
})
)
).toEqual({
kind: 'create_pr',
label: 'Create PR',
title: 'Cannot create a pull request from the default branch.',
disabled: false
})
})
it('returns Create MR intent with provider copy for a GitLab dirty branch', () => {
const result = resolvePrimaryAction(
inputs({
stagedCount: 1,
hasMessage: true,
upstreamStatus: upstreamInSync,
hostedReviewCreation: {
provider: 'gitlab',
review: null,
canCreate: false,
blockedReason: 'dirty',
nextAction: 'commit',
reviewLookupOutcome: 'not_found'
}
})
)
expect(result.kind).toBe('create_pr_intent')
expect(result.label).toBe('Create MR')
expect(result.title).toBe('Prepare this branch and create a merge request')
})
it('routes a GitLab dirty branch header through Create MR intent', () => {
expect(
resolveCreatePrHeaderAction(
inputs({
stagedCount: 1,
hasMessage: true,
upstreamStatus: upstreamInSync,
hostedReviewCreation: {
provider: 'gitlab',
review: null,
canCreate: false,
blockedReason: 'dirty',
nextAction: 'commit',
reviewLookupOutcome: 'not_found'
}
})
)
).toEqual({
kind: 'create_pr_intent',
label: 'Create MR',
title: 'Prepare this branch and create a merge request',
disabled: false
})
})
it('keeps in-flight Create MR intent copy provider-aware', () => {
const input = inputs({
isPrIntentInFlight: true,
hostedReviewCreation: {
provider: 'gitlab',
review: null,
canCreate: false,
blockedReason: 'dirty',
nextAction: 'commit',
reviewLookupOutcome: 'not_found'
}
})
expect(resolvePrimaryAction(input)).toEqual({
kind: 'create_pr_intent',
label: 'Create MR',
title: 'Preparing branch for review…',
disabled: true
})
expect(resolveCreatePrHeaderAction(input)).toEqual({
kind: 'create_pr_intent',
label: 'Create MR',
title: 'Preparing branch for review…',
disabled: true
})
})
it('shows waiting feedback across PR preparation actions when another workspace is busy', () => {
const input = inputs({
isPrIntentInFlight: true,
isRemoteOperationActive: true,
isRemoteOperationInAnotherWorktree: true,
upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 }
})
for (const resolveAction of [resolvePrimaryAction, resolveCreatePrHeaderAction]) {
expect(resolveAction(input)).toEqual({
kind: 'create_pr_intent',
label: 'Git operation running elsewhere',
title:
"Another workspace is running a Git operation, such as push, pull, or force push. Wait for it to finish before using this action, so the two operations don't overlap.",
disabled: true,
isWaitingForRemoteOperation: true
})
}
expect(resolveCommitAreaPrimaryAction(input)).toEqual({
kind: 'publish',
label: 'Git operation running elsewhere',
title:
"Another workspace is running a Git operation, such as push, pull, or force push. Wait for it to finish before using this action, so the two operations don't overlap.",
disabled: true,
isWaitingForRemoteOperation: true
})
})
it.each([
{ isRemoteOperationActive: false, isRemoteOperationInAnotherWorktree: true },
{ isRemoteOperationActive: true, isRemoteOperationInAnotherWorktree: false },
{
isRemoteOperationActive: true,
isRemoteOperationInAnotherWorktree: true,
isCommitting: true
}
])('keeps preparation feedback when the intent is not waiting elsewhere: %j', (busyState) => {
const input = inputs({ isPrIntentInFlight: true, ...busyState })
const headerAction = resolveCreatePrHeaderAction(input)
expect(headerAction).toMatchObject({
kind: 'create_pr_intent',
label: 'Create PR',
title: 'Preparing branch for review…',
disabled: true
})
expect(headerAction?.isWaitingForRemoteOperation).toBeUndefined()
const primaryAction = resolvePrimaryAction(input)
expect(primaryAction.isWaitingForRemoteOperation).toBeUndefined()
expect(primaryAction.title).toBe('Preparing branch for review…')
})
it.each(['azure-devops', 'gitea'] as const)(
'returns Create PR intent for a %s branch that needs a safe push before review',
(provider) => {
const result = resolvePrimaryAction(
inputs({
upstreamStatus: { hasUpstream: true, ahead: 2, behind: 0 },
hostedReviewCreation: {
provider,
review: null,
canCreate: false,
blockedReason: 'needs_push',
nextAction: 'push',
reviewLookupOutcome: 'not_found'
}
})
)
expect(result).toEqual({
kind: 'create_pr_intent',
label: 'Create PR',
title: 'Prepare this branch and create a pull request',
disabled: false
})
}
)
it('routes unpublished commits through Create PR intent while keeping Publish Branch in the commit area', () => {
const input = inputs({
upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 },
branchCommitsAhead: 2,
hostedReviewCreation: {
provider: 'github',
review: null,
canCreate: false,
blockedReason: 'no_upstream',
nextAction: 'publish',
reviewLookupOutcome: 'not_found'
}
})
expect(resolveCreatePrHeaderAction(input)).toEqual({
kind: 'create_pr_intent',
label: 'Create PR',
title: 'Prepare this branch and create a pull request',
disabled: false
})
expect(resolveCommitAreaPrimaryAction(input)).toEqual({
kind: 'publish',
label: 'Publish Branch',
title: 'Publish this branch to origin',
disabled: false
})
})
it('routes patch-equivalent divergence through Create PR intent while keeping Force Push in the commit area', () => {
const input = inputs({
branchCommitsAhead: 4,
upstreamStatus: {
hasUpstream: true,
upstreamName: 'origin/feature',
ahead: 14,
behind: 3,
behindCommitsArePatchEquivalent: true
},
hostedReviewCreation: {
provider: 'github',
review: null,
canCreate: false,
blockedReason: 'needs_sync',
nextAction: 'sync',
reviewLookupOutcome: 'not_found'
}
})
expect(resolveCreatePrHeaderAction(input)).toEqual({
kind: 'create_pr_intent',
label: 'Create PR',
title: 'Prepare this branch and create a pull request',
disabled: false
})
expect(resolveCommitAreaPrimaryAction(input)).toEqual({
kind: 'push',
label: 'Force Push',
title:
'Remote only has older copies of local commits. Force push 4 branch commits with lease to update origin/feature.',
disabled: false
})
})
it('keeps unsafe sync blockers on the direct notice path', () => {
const input = inputs({
branchCommitsAhead: 4,
upstreamStatus: {
hasUpstream: true,
upstreamName: 'origin/feature',
ahead: 2,
behind: 3
},
hostedReviewCreation: {
provider: 'github',
review: null,
canCreate: false,
blockedReason: 'needs_sync',
nextAction: 'sync',
reviewLookupOutcome: 'not_found'
}
})
expect(resolveCreatePrHeaderAction(input)).toEqual({
kind: 'create_pr',
label: 'Create PR',
title: 'Sync this branch before creating a pull request.',
disabled: false
})
})
it('returns direct Create PR as a header action when the branch is ready', () => {
expect(
resolveCreatePrHeaderAction(
inputs({
upstreamStatus: upstreamInSync,
hostedReviewCreation: {
provider: 'github',
review: null,
canCreate: true,
blockedReason: null,
nextAction: null,
reviewLookupOutcome: 'not_found'
}
})
)
).toEqual({
kind: 'create_pr',
label: 'Create PR',
title: 'Create a pull request for this branch',
disabled: false
})
})
it('returns a disabled loading Create PR header while eligibility is still fetching', () => {
expect(
resolveCreatePrHeaderAction(
inputs({
hostedReviewCreation: {
provider: 'github',
review: null,
canCreate: false,
blockedReason: null,
nextAction: null,
reviewLookupOutcome: 'not_found'
},
isHostedReviewCreationLoading: true
})
)
).toEqual({
kind: 'create_pr',
label: 'Create PR',
title: 'Checking whether this branch can create a pull request…',
disabled: true
})
})
it('keeps stale Create PR eligibility disabled while a newer preflight is loading', () => {
expect(
resolveCreatePrHeaderAction(
inputs({
hostedReviewCreation: {
provider: 'github',
review: null,
canCreate: true,
blockedReason: null,
nextAction: null,
reviewLookupOutcome: 'not_found'
},
isHostedReviewCreationLoading: true
})
)
).toEqual({
kind: 'create_pr',
label: 'Create PR',
title: 'Checking whether this branch can create a pull request…',
disabled: true
})
})
it('keeps stale Create MR eligibility disabled with provider-aware loading copy', () => {
expect(
resolveCreatePrHeaderAction(
inputs({
hostedReviewCreation: {
provider: 'gitlab',
review: null,
canCreate: true,
blockedReason: null,
nextAction: null,
reviewLookupOutcome: 'not_found'
},
isHostedReviewCreationLoading: true
})
)
).toEqual({
kind: 'create_pr',
label: 'Create MR',
title: 'Checking whether this branch can create a merge request…',
disabled: true
})
})
it('names a failed review lookup instead of calling a ready branch not ready', () => {
expect(
resolveCreatePrHeaderAction(
inputs({
upstreamStatus: upstreamInSync,
hostedReviewCreation: {
provider: 'gitlab',
review: null,
reviewLookupOutcome: 'unavailable',
defaultBaseRef: 'origin/main',
head: 'feature',
canCreate: false,
blockedReason: null,
nextAction: null
}
})
)
).toEqual({
kind: 'create_pr',
label: 'Create MR',
title:
'Create MR failed: Orca could not confirm whether this branch already has a merge request. Retry once the GitLab lookup succeeds.',
disabled: true
})
})
it('returns a clickable Create PR header notice path when the branch has nothing to publish yet', () => {
expect(
resolveCreatePrHeaderAction(
inputs({
upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 },
branchCommitsAhead: 0,
hostedReviewCreation: {
provider: 'github',
review: null,
canCreate: false,
blockedReason: 'no_upstream',
nextAction: 'publish',
reviewLookupOutcome: 'not_found'
}
})
)
).toEqual({
kind: 'create_pr',
label: 'Create PR',
title: 'Publish commits before creating a pull request.',
disabled: false
})
})
})
@@ -1,586 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
resolveCommitAreaPrimaryAction,
resolvePrimaryAction
} from './source-control-primary-action'
import type { PrimaryActionInputs } from './source-control-primary-action-types'
function inputs(overrides: Partial<PrimaryActionInputs> = {}): PrimaryActionInputs {
return {
stagedCount: 0,
hasUnstagedChanges: false,
hasStageableChanges: false,
hasPartiallyStagedChanges: false,
hasMessage: false,
hasUnresolvedConflicts: false,
isCommitting: false,
isRemoteOperationActive: false,
upstreamStatus: undefined,
...overrides
}
}
const upstreamInSync = {
hasUpstream: true,
upstreamName: 'origin/main',
ahead: 0,
behind: 0
}
describe('resolvePrimaryAction', () => {
it('returns a disabled Commit while a commit is in flight', () => {
const result = resolvePrimaryAction(
inputs({ isCommitting: true, stagedCount: 1, hasMessage: true })
)
expect(result).toEqual({
kind: 'commit',
label: 'Commit',
title: 'Commit in progress…',
disabled: true
})
})
it('keeps the contextual label but disables it while a remote op is in flight', () => {
const result = resolvePrimaryAction(
inputs({
isRemoteOperationActive: true,
upstreamStatus: { hasUpstream: true, ahead: 0, behind: 3 }
})
)
expect(result).toEqual({
kind: 'pull',
label: 'Pull',
title: 'Remote operation in progress…',
disabled: true
})
})
// Mirror dropdown-started operations so selecting Sync cannot leave a misleading Push spinner.
it('mirrors the in-flight remote op kind on the primary while a remote op runs', () => {
const result = resolvePrimaryAction(
inputs({
isRemoteOperationActive: true,
upstreamStatus: { hasUpstream: true, ahead: 3, behind: 0 },
inFlightRemoteOpKind: 'sync'
})
)
expect(result).toEqual({
kind: 'sync',
label: 'Sync',
title: 'Sync in progress…',
disabled: true
})
})
it('mirrors an in-flight Pull on the primary even when natural label is Push', () => {
const result = resolvePrimaryAction(
inputs({
isRemoteOperationActive: true,
upstreamStatus: { hasUpstream: true, ahead: 3, behind: 0 },
inFlightRemoteOpKind: 'pull'
})
)
expect(result.kind).toBe('pull')
expect(result.label).toBe('Pull')
expect(result.title).toBe('Pull in progress…')
expect(result.disabled).toBe(true)
})
it('keeps the natural Publish label and tooltip when an in-flight Publish matches', () => {
// When the operation matches, preserve the natural state-machine label and full tooltip.
const result = resolvePrimaryAction(
inputs({
isRemoteOperationActive: true,
upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 },
inFlightRemoteOpKind: 'publish'
})
)
expect(result.kind).toBe('publish')
expect(result.label).toBe('Publish Branch')
expect(result.title).toBe('Remote operation in progress…')
expect(result.disabled).toBe(true)
})
// Fetch is dropdown-only: preserve the primary label/kind so CommitArea shows its spinner on the chevron.
it('keeps the natural primary label when an in-flight Fetch is dropdown-only', () => {
const result = resolvePrimaryAction(
inputs({
isRemoteOperationActive: true,
upstreamStatus: { hasUpstream: true, ahead: 3, behind: 0 },
inFlightRemoteOpKind: 'fetch'
})
)
expect(result).toEqual({
kind: 'push',
label: 'Push',
title: 'Remote operation in progress…',
disabled: true
})
})
it('mirrors an in-flight Force Push on the push primary slot', () => {
const result = resolvePrimaryAction(
inputs({
isRemoteOperationActive: true,
upstreamStatus: { hasUpstream: true, ahead: 3, behind: 0 },
inFlightRemoteOpKind: 'force_push'
})
)
expect(result).toEqual({
kind: 'push',
label: 'Force Push',
title: 'Force Push in progress…',
disabled: true
})
})
it('blocks commits while unresolved conflicts exist', () => {
const result = resolvePrimaryAction(
inputs({ hasUnresolvedConflicts: true, stagedCount: 2, hasMessage: true })
)
expect(result).toEqual({
kind: 'commit',
label: 'Commit',
title: 'Resolve conflicts before committing',
disabled: true
})
})
// Keep primary Commit independent of remote state; compound flows stay in the dropdown until the next natural action.
it('returns plain Commit for staged+message regardless of upstream state', () => {
const upstreams = [
undefined,
{ hasUpstream: false as const, ahead: 0, behind: 0 },
{ hasUpstream: true as const, ahead: 0, behind: 0 },
{ hasUpstream: true as const, ahead: 3, behind: 0 },
{ hasUpstream: true as const, ahead: 2, behind: 1 },
{ hasUpstream: true as const, ahead: 0, behind: 4 }
]
for (const upstreamStatus of upstreams) {
const result = resolvePrimaryAction(
inputs({ stagedCount: 1, hasMessage: true, upstreamStatus })
)
expect(result.kind).toBe('commit')
expect(result.label).toBe('Commit')
expect(result.disabled).toBe(false)
}
})
it('disables Commit with a message-needed hint when staged but no message', () => {
const result = resolvePrimaryAction(inputs({ stagedCount: 1, hasMessage: false }))
expect(result).toEqual({
kind: 'commit',
label: 'Commit',
title: 'Enter a commit message to commit',
disabled: true
})
})
it('returns Publish Branch on a clean tree when no upstream exists', () => {
const result = resolvePrimaryAction(
inputs({ upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 }, branchCommitsAhead: 1 })
)
expect(result).toEqual({
kind: 'publish',
label: 'Publish Branch',
title: 'Publish this branch to origin',
disabled: false
})
})
it('returns Push when no upstream exists but an open linked review already owns the branch', () => {
const result = resolvePrimaryAction(
inputs({
upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 },
branchCommitsAhead: 1,
prState: 'open',
canPushLinkedReviewWithoutUpstream: true
})
)
expect(result).toEqual({
kind: 'push',
label: 'Push',
title: 'Push updates to the linked review branch',
disabled: false
})
})
it('does not push an open linked review when its branch target is unavailable', () => {
const result = resolvePrimaryAction(
inputs({
upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 },
branchCommitsAhead: 1,
prState: 'open'
})
)
expect(result).toEqual({
kind: 'commit',
label: 'Commit',
title: 'Linked review branch target is unavailable.',
disabled: true
})
})
it('does not offer Publish Branch when HEAD is detached', () => {
const result = resolvePrimaryAction(
inputs({
upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 },
branchCommitsAhead: 4,
hasCurrentBranch: false
})
)
expect(result).toEqual({
kind: 'commit',
label: 'Commit',
title: 'Check out a branch before publishing commits.',
disabled: true
})
})
it('offers Publish Branch when an unpublished branch has no commits ahead', () => {
const result = resolvePrimaryAction(
inputs({ upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 }, branchCommitsAhead: 0 })
)
expect(result).toEqual({
kind: 'publish',
label: 'Publish Branch',
title: 'Publish this branch to origin',
disabled: false
})
})
it.each([
[{ prState: 'merged' as const }, 'Nothing to commit. PR is already merged.'],
[{ isPRStateLoading: true }, 'Checking PR status…']
])('does not offer Publish Branch when linked PR state blocks it', (overrides, title) => {
const result = resolvePrimaryAction(
inputs({ upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 }, ...overrides })
)
expect(result).toEqual({ kind: 'commit', label: 'Commit', title, disabled: true })
})
it('returns Sync when clean + tracked + diverged both ways', () => {
const result = resolvePrimaryAction(
inputs({ upstreamStatus: { hasUpstream: true, ahead: 2, behind: 3 } })
)
expect(result).toEqual({
kind: 'sync',
label: 'Sync',
title: 'Pull 3, push 2',
disabled: false
})
})
it('returns Force Push when remote-only commits are patch-equivalent after a rebase', () => {
const result = resolvePrimaryAction(
inputs({
branchCommitsAhead: 4,
upstreamStatus: {
hasUpstream: true,
upstreamName: 'origin/feature',
ahead: 14,
behind: 3,
behindCommitsArePatchEquivalent: true
}
})
)
expect(result).toEqual({
kind: 'push',
label: 'Force Push',
title:
'Remote only has older copies of local commits. Force push 4 branch commits with lease to update origin/feature.',
disabled: false
})
})
it('returns Pull when clean + behind-only', () => {
const result = resolvePrimaryAction(
inputs({ upstreamStatus: { hasUpstream: true, ahead: 0, behind: 4 } })
)
expect(result.kind).toBe('pull')
expect(result.label).toBe('Pull')
expect(result.title).toBe('Pull 4 commits')
})
it('uses singular copy for a single-commit pull', () => {
const result = resolvePrimaryAction(
inputs({ upstreamStatus: { hasUpstream: true, ahead: 0, behind: 1 } })
)
expect(result.title).toBe('Pull 1 commit')
})
it('returns Push when clean + ahead-only', () => {
const result = resolvePrimaryAction(
inputs({ upstreamStatus: { hasUpstream: true, ahead: 3, behind: 0 } })
)
expect(result).toEqual({
kind: 'push',
label: 'Push',
title: 'Push 3 commits',
disabled: false
})
})
it('returns a disabled up-to-date Commit when tracked branch is clean and in sync', () => {
const result = resolvePrimaryAction(inputs({ upstreamStatus: upstreamInSync }))
expect(result).toEqual({
kind: 'commit',
label: 'Commit',
title: 'Nothing to commit. Branch is up to date.',
disabled: true
})
})
// Stage dirty files before remote actions: Pull/Sync would fail and Push ignores the immediate commit prerequisite.
it('returns Stage All on a dirty tree that is behind upstream', () => {
const result = resolvePrimaryAction(
inputs({
hasUnstagedChanges: true,
hasStageableChanges: true,
upstreamStatus: { hasUpstream: true, ahead: 0, behind: 3 }
})
)
expect(result).toEqual({
kind: 'stage',
label: 'Stage All',
title: 'Stage all changes',
disabled: false
})
})
it('returns Stage All on a dirty tree that is ahead of upstream', () => {
const result = resolvePrimaryAction(
inputs({
hasUnstagedChanges: true,
hasStageableChanges: true,
upstreamStatus: { hasUpstream: true, ahead: 2, behind: 0 }
})
)
expect(result.kind).toBe('stage')
expect(result.label).toBe('Stage All')
expect(result.disabled).toBe(false)
})
it('returns Stage All on a dirty tree with no upstream branch', () => {
const result = resolvePrimaryAction(
inputs({
hasUnstagedChanges: true,
hasStageableChanges: true,
upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 }
})
)
expect(result.kind).toBe('stage')
})
it('returns Stage All on a dirty tree while upstream status is still loading', () => {
const result = resolvePrimaryAction(
inputs({ hasUnstagedChanges: true, hasStageableChanges: true, upstreamStatus: undefined })
)
expect(result.kind).toBe('stage')
expect(result.disabled).toBe(false)
})
it('returns Commit when a staged file also has unstaged changes', () => {
const result = resolvePrimaryAction(
inputs({
stagedCount: 1,
hasUnstagedChanges: true,
hasStageableChanges: true,
hasPartiallyStagedChanges: true,
hasMessage: true,
upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 }
})
)
expect(result.kind).toBe('commit')
expect(result.label).toBe('Commit')
expect(result.disabled).toBe(false)
})
it('keeps Stage All available in the commit area when Create PR intent is additive', () => {
const input = inputs({
stagedCount: 0,
hasUnstagedChanges: true,
hasStageableChanges: true,
hasPartiallyStagedChanges: false,
hasMessage: false,
upstreamStatus: upstreamInSync,
hostedReviewCreation: {
provider: 'github',
review: null,
canCreate: false,
blockedReason: 'dirty',
nextAction: 'commit',
reviewLookupOutcome: 'not_found'
}
})
expect(resolvePrimaryAction(input).kind).toBe('create_pr_intent')
expect(resolveCommitAreaPrimaryAction(input)).toEqual({
kind: 'stage',
label: 'Stage All',
title: 'Stage all changes',
disabled: false
})
})
it('keeps the additive commit-area action on Commit for partially staged files', () => {
const input = inputs({
stagedCount: 1,
hasUnstagedChanges: true,
hasStageableChanges: true,
hasPartiallyStagedChanges: true,
hasMessage: true,
upstreamStatus: upstreamInSync,
hostedReviewCreation: {
provider: 'github',
review: null,
canCreate: false,
blockedReason: 'dirty',
nextAction: 'commit',
reviewLookupOutcome: 'not_found'
}
})
expect(resolvePrimaryAction(input).kind).toBe('create_pr_intent')
expect(resolveCommitAreaPrimaryAction(input)).toEqual({
kind: 'commit',
label: 'Commit',
title: 'Commit staged changes',
disabled: false
})
})
it('still resolves to Commit when staged and unrelated unstaged files exist', () => {
const result = resolvePrimaryAction(
inputs({
stagedCount: 1,
hasUnstagedChanges: true,
hasPartiallyStagedChanges: false,
hasMessage: true,
upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 }
})
)
expect(result.kind).toBe('commit')
expect(result.disabled).toBe(false)
})
it('does not return Stage All when dirty rows cannot be staged from the parent repo', () => {
const result = resolvePrimaryAction(
inputs({
hasUnstagedChanges: true,
hasStageableChanges: false,
upstreamStatus: upstreamInSync
})
)
expect(result).toEqual({
kind: 'commit',
label: 'Commit',
title: 'Stage at least one file to commit',
disabled: true
})
})
it('still disables Commit (needs message) when staged+dirty without a message', () => {
const result = resolvePrimaryAction(
inputs({ stagedCount: 1, hasUnstagedChanges: true, hasMessage: false })
)
expect(result.kind).toBe('commit')
expect(result.disabled).toBe(true)
expect(result.title).toBe('Enter a commit message to commit')
})
it('returns Stage All when unstaged changes exist on an in-sync branch', () => {
const result = resolvePrimaryAction(
inputs({
hasUnstagedChanges: true,
hasStageableChanges: true,
upstreamStatus: upstreamInSync
})
)
expect(result).toEqual({
kind: 'stage',
label: 'Stage All',
title: 'Stage all changes',
disabled: false
})
})
it('returns a disabled Commit when clean and upstream status not yet resolved', () => {
const result = resolvePrimaryAction(inputs())
expect(result).toEqual({
kind: 'commit',
label: 'Commit',
title: 'Stage at least one file to commit',
disabled: true
})
})
it('returns Create PR when a clean tracked branch is eligible for review creation', () => {
const result = resolvePrimaryAction(
inputs({
upstreamStatus: upstreamInSync,
hostedReviewCreation: {
provider: 'github',
review: null,
canCreate: true,
blockedReason: null,
nextAction: null,
reviewLookupOutcome: 'not_found'
}
})
)
expect(result).toEqual({
kind: 'create_pr',
label: 'Create PR',
title: 'Create a pull request for this branch',
disabled: false
})
})
it('returns Create MR when a clean tracked GitLab branch is eligible for review creation', () => {
const result = resolvePrimaryAction(
inputs({
upstreamStatus: upstreamInSync,
hostedReviewCreation: {
provider: 'gitlab',
review: null,
canCreate: true,
blockedReason: null,
nextAction: null,
reviewLookupOutcome: 'not_found'
}
})
)
expect(result).toEqual({
kind: 'create_pr',
label: 'Create MR',
title: 'Create a merge request for this branch',
disabled: false
})
})
it.each(['azure-devops', 'gitea'] as const)(
'returns Create PR when a clean tracked %s branch is eligible for review creation',
(provider) => {
const result = resolvePrimaryAction(
inputs({
upstreamStatus: upstreamInSync,
hostedReviewCreation: {
provider,
review: null,
canCreate: true,
blockedReason: null,
nextAction: null,
reviewLookupOutcome: 'not_found'
}
})
)
expect(result).toEqual({
kind: 'create_pr',
label: 'Create PR',
title: 'Create a pull request for this branch',
disabled: false
})
}
)
})
@@ -1,6 +1,6 @@
// @vitest-environment happy-dom
import { act, cleanup, renderHook } from '@testing-library/react'
import { cleanup, renderHook } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type * as RuntimeGitGenerationClient from '@/runtime/runtime-git-generation-client'
import type { RuntimeGeneratePullRequestFieldsResult } from '@/runtime/runtime-git-client-context'
@@ -33,174 +33,6 @@ afterEach(() => {
})
describe('useSourceControlPullRequestGeneration outcome', () => {
it.each([
{ name: 'keeps the base for a Create PR run', autoSubmit: true, base: 'main' },
{ name: 'keeps the agent base for a reviewed run', autoSubmit: false, base: 'develop' }
])('$name', async ({ autoSubmit, base }) => {
const generated = { base: 'develop', title: 'Add feature flag', body: 'Details.', draft: false }
runtime.generate.mockResolvedValue({ success: true, fields: generated })
const { setPullRequestGenerationRecord, updatePullRequestGenerationRecord } =
useAppStore.getState()
const { result } = renderHook(() =>
useSourceControlPullRequestGeneration({
activeRepo: {
id: 'repo-1',
path: '/repo',
displayName: 'repo',
badgeColor: '#000',
addedAt: 0
},
activeRepoTarget: { kind: 'local' },
activeWorktreeId: 'wt-1',
allocatePullRequestGenerationRequestId: vi.fn(() => 5),
branchName: 'feature',
hostedReviewCreateProvider: 'github',
prGenerationRecords: {},
refreshGitStatusAfterPullRequestGeneration: vi.fn(),
resolvedPrCreationDefaults: DEFAULT_SOURCE_CONTROL_AI_PR_CREATION_DEFAULTS,
setPullRequestGenerationRecord,
updatePullRequestGenerationRecord,
worktreePath: '/repo'
})
)
const outcome = await result.current.handleGeneratePullRequestFieldsForActive(
{ base: 'main', title: 'Feature', body: '', draft: false },
{ base: 0, title: 0, body: 0, draft: 0 },
undefined,
{ autoSubmit }
)
expect(outcome).toEqual({ result: { ...generated, base } })
})
it("returns no result when a later run replaced this one, not the later run's details", async () => {
const generationKey = JSON.stringify(['repo-1', 'wt-1', 'feature'])
let answer: (result: RuntimeGeneratePullRequestFieldsResult) => void = () => {}
runtime.generate.mockImplementation(
() =>
new Promise<RuntimeGeneratePullRequestFieldsResult>((resolve) => {
answer = resolve
})
)
const { setPullRequestGenerationRecord, updatePullRequestGenerationRecord } =
useAppStore.getState()
const { result } = renderHook(() =>
useSourceControlPullRequestGeneration({
activeRepo: {
id: 'repo-1',
path: '/repo',
displayName: 'repo',
badgeColor: '#000',
addedAt: 0
},
activeRepoTarget: { kind: 'local' },
activeWorktreeId: 'wt-1',
allocatePullRequestGenerationRequestId: vi.fn(() => 5),
branchName: 'feature',
hostedReviewCreateProvider: 'github',
prGenerationRecords: {},
refreshGitStatusAfterPullRequestGeneration: vi.fn(),
resolvedPrCreationDefaults: DEFAULT_SOURCE_CONTROL_AI_PR_CREATION_DEFAULTS,
setPullRequestGenerationRecord,
updatePullRequestGenerationRecord,
worktreePath: '/repo'
})
)
const fields = { base: 'main', title: 'Feature', body: '', draft: false }
const outcome = result.current.handleGeneratePullRequestFieldsForActive(
fields,
{ base: 0, title: 0, body: 0, draft: 0 },
undefined,
{ autoSubmit: true }
)
// Finish a new Generate run before the stopped run winds down.
const stopped = useAppStore.getState().pullRequestGenerationRecords[generationKey]
expect(stopped?.status).toBe('running')
setPullRequestGenerationRecord(generationKey, {
...stopped!,
context: { ...stopped!.context, requestId: 6 },
status: 'succeeded',
result: { ...fields, title: 'Later run' }
})
answer({ success: false, error: 'canceled', canceled: true })
await expect(outcome).resolves.toEqual({ result: null })
})
it('settles with no result as soon as Stop lands, without waiting for the stopped request, and drops its late result', async () => {
const generationKey = JSON.stringify(['repo-1', 'wt-1', 'feature'])
let answer: (result: RuntimeGeneratePullRequestFieldsResult) => void = () => {}
runtime.generate.mockImplementation(
() =>
new Promise<RuntimeGeneratePullRequestFieldsResult>((resolve) => {
answer = resolve
})
)
// Model cancellation never reaching the host so the stopped request remains pending.
runtime.cancel.mockReturnValue(new Promise(() => {}))
const subscribe = useAppStore.subscribe
const released = vi.fn()
const subscribed = vi.spyOn(useAppStore, 'subscribe').mockImplementation((listener) => {
const unsubscribe = subscribe(listener)
return () => {
released()
unsubscribe()
}
})
const { setPullRequestGenerationRecord, updatePullRequestGenerationRecord } =
useAppStore.getState()
const { result } = renderHook(() =>
useSourceControlPullRequestGeneration({
activeRepo: {
id: 'repo-1',
path: '/repo',
displayName: 'repo',
badgeColor: '#000',
addedAt: 0
},
activeRepoTarget: { kind: 'local' },
activeWorktreeId: 'wt-1',
allocatePullRequestGenerationRequestId: vi.fn(() => 5),
branchName: 'feature',
hostedReviewCreateProvider: 'github',
prGenerationRecords: useAppStore((s) => s.pullRequestGenerationRecords),
refreshGitStatusAfterPullRequestGeneration: vi.fn(),
resolvedPrCreationDefaults: DEFAULT_SOURCE_CONTROL_AI_PR_CREATION_DEFAULTS,
setPullRequestGenerationRecord,
updatePullRequestGenerationRecord,
worktreePath: '/repo'
})
)
let outcome: Promise<unknown> = Promise.resolve()
act(() => {
outcome = result.current.handleGeneratePullRequestFieldsForActive(
{ base: 'main', title: 'Feature', body: '', draft: false },
{ base: 0, title: 0, body: 0, draft: 0 },
undefined,
{ autoSubmit: true }
)
})
act(() => result.current.handleCancelGeneratePullRequestFieldsForActive())
expect(runtime.cancel).toHaveBeenCalledTimes(1)
const stillPending = new Promise((resolve) => setTimeout(() => resolve('still pending'), 50))
await expect(Promise.race([outcome, stillPending])).resolves.toEqual({ result: null })
expect(subscribed).toHaveBeenCalledTimes(1)
expect(released).toHaveBeenCalledTimes(1)
subscribed.mockRestore()
answer({
success: true,
fields: { base: 'develop', title: 'Late run', body: 'Late.', draft: false }
})
await act(async () => {})
expect(useAppStore.getState().pullRequestGenerationRecords[generationKey]).toMatchObject({
status: 'canceled',
result: null
})
})
it('captures the owning host’s Jira key before a background generation outlives a workspace switch', async () => {
const workspace = makeWorktree('wt-1', 'Jira task', {
linkedWorkItem: {
@@ -1,335 +0,0 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { TooltipProvider } from '@/components/ui/tooltip'
import type { GitBranchCompareSummary } from '../../../../../../shared/git/diff/git-diff-compare-types'
import {
clearBranchLineTotalRequestGateForTests,
getBranchLineTotalMergeBase
} from './branch-line-total-request-gate'
import SourceControl from '../panel/SourceControl'
const MERGE_BASE = '1f3c0d9a5b6e7f8091a2b3c4d5e6f708192a3b4c'
const mocks = vi.hoisted(() => {
const activeRepo = {
id: 'repo-1',
path: '/repo',
displayName: 'Repo',
badgeColor: '#000',
addedAt: 0,
kind: 'git' as string
}
const activeWorktree = {
id: 'wt-1',
repoId: 'repo-1',
path: '/repo/wt',
head: 'head-1',
branch: 'refs/heads/feature/line-total',
isBare: false,
isMainWorktree: false,
displayName: 'feature/line-total',
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
linkedGitLabMR: null,
linkedGitLabIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 0
}
return {
activeRepo,
activeWorktree,
state: {} as Record<string, unknown>
}
})
vi.mock('@/store/index', () => {
const useAppStore = Object.assign(
(selector?: (state: Record<string, unknown>) => unknown) =>
selector ? selector(mocks.state) : mocks.state,
{
getState: () => mocks.state
}
)
return { useAppStore }
})
vi.mock('@/store/selectors', () => ({
useActiveWorktree: () => mocks.activeWorktree,
useRepoById: (repoId: string | null) =>
repoId === mocks.activeRepo.id ? mocks.activeRepo : null,
useWorktreeMap: () => new Map([[mocks.activeWorktree.id, mocks.activeWorktree]])
}))
vi.mock('@/components/confirmation-dialog-context', () => ({
useConfirmationDialog: () => vi.fn().mockResolvedValue(true)
}))
vi.mock('./git-status-refresh', () => ({
refreshGitStatusForWorktree: vi.fn().mockResolvedValue(undefined),
refreshGitStatusForWorktreeStrict: vi.fn().mockResolvedValue(undefined)
}))
function noopAsync(value: unknown = undefined): () => Promise<unknown> {
return vi.fn().mockResolvedValue(value)
}
const readySummary: GitBranchCompareSummary = {
baseRef: 'refs/remotes/origin/main',
baseOid: 'base-oid',
compareRef: 'feature/line-total',
headOid: 'head-1',
mergeBase: MERGE_BASE,
changedFiles: 2,
commitsAhead: 1,
status: 'ready'
}
function resetState(overrides: Partial<Record<string, unknown>> = {}): void {
vi.clearAllMocks()
mocks.activeRepo.kind = 'git'
mocks.state = {
activeWorktreeId: mocks.activeWorktree.id,
activeGroupIdByWorktree: { [mocks.activeWorktree.id]: 'group-1' },
groupsByWorktree: { [mocks.activeWorktree.id]: [{ id: 'group-1', activeTabId: null }] },
repos: [mocks.activeRepo],
worktreesByRepo: { [mocks.activeRepo.id]: [mocks.activeWorktree] },
rightSidebarOpen: true,
rightSidebarTab: 'source-control',
gitStatusByWorktree: { [mocks.activeWorktree.id]: [] },
gitBranchChangesByWorktree: { [mocks.activeWorktree.id]: [] },
gitBranchCompareSummaryByWorktree: { [mocks.activeWorktree.id]: readySummary },
gitBranchLineTotalByWorktree: {},
gitConflictOperationByWorktree: {},
remoteStatusesByWorktree: {},
isRemoteOperationActive: false,
remoteOperations: [],
settings: null,
hostedReviewCache: {},
prCache: {},
commitMessageGenerationRecords: {},
pullRequestGenerationRecords: {},
openFiles: [],
activeFileIdByWorktree: {},
activeTabTypeByWorktree: {},
getDiffComments: vi.fn(() => []),
updateSettings: noopAsync(),
openSettingsTarget: vi.fn(),
openSettingsPage: vi.fn(),
fetchHostedReviewForBranch: noopAsync(),
getHostedReviewCreationEligibility: noopAsync(null),
createHostedReview: noopAsync({ ok: false, error: 'not available' }),
updateWorktreeMeta: noopAsync(),
fetchPRForBranch: noopAsync(),
enqueueGitHubPRRefresh: vi.fn(),
updateRepo: noopAsync(),
setGitStatus: vi.fn(),
updateWorktreeGitIdentity: vi.fn(),
beginGitBranchCompareRequest: vi.fn(() => 'request-key'),
setGitBranchCompareResult: vi.fn(),
clearGitBranchCompare: vi.fn(),
fetchUpstreamStatus: noopAsync(),
setUpstreamStatus: vi.fn(),
pushBranch: noopAsync(),
pullBranch: noopAsync(),
fastForwardBranch: noopAsync(),
syncBranch: noopAsync(),
rebaseFromBase: noopAsync(),
fetchBranch: noopAsync(),
revealInExplorer: vi.fn(),
trackConflictPath: vi.fn(),
openDiff: vi.fn(),
openFile: vi.fn(),
setEditorViewMode: vi.fn(),
setMarkdownViewMode: vi.fn(),
setPendingEditorReveal: vi.fn(),
openConflictFile: vi.fn(),
openConflictReview: vi.fn(),
openBranchDiff: vi.fn(),
createEmptySplitGroup: vi.fn(() => 'group-2'),
openAllDiffs: vi.fn(),
openBranchAllDiffs: vi.fn(),
openCommitAllDiffs: vi.fn(),
deleteDiffComment: noopAsync(true),
clearDiffComments: noopAsync(true),
clearDiffCommentsForFile: noopAsync(true),
setScrollToDiffCommentId: vi.fn(),
setRightSidebarOpen: vi.fn(),
setRightSidebarTab: vi.fn(),
allocateCommitMessageGenerationRequestId: vi.fn(() => 'commit-generation-1'),
setCommitMessageGenerationRecord: vi.fn(),
updateCommitMessageGenerationRecord: vi.fn(),
pruneCommitMessageGenerationRecords: vi.fn(),
allocatePullRequestGenerationRequestId: vi.fn(() => 'pr-generation-1'),
setPullRequestGenerationRecord: vi.fn(),
updatePullRequestGenerationRecord: vi.fn(),
prunePullRequestGenerationRecords: vi.fn(),
...overrides
}
}
let container: HTMLDivElement
let root: Root
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
clearBranchLineTotalRequestGateForTests()
resetState()
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})
afterEach(() => {
act(() => root.unmount())
container.remove()
clearBranchLineTotalRequestGateForTests()
})
function renderSourceControl(): void {
act(() => {
root.render(
<TooltipProvider>
<SourceControl />
</TooltipProvider>
)
})
}
function chip(): HTMLElement | null {
return container.querySelector<HTMLElement>('[data-testid="source-control-branch-line-total"]')
}
function loadingChip(): HTMLElement | null {
return container.querySelector<HTMLElement>(
'[data-testid="source-control-branch-line-total-loading"]'
)
}
describe('SourceControl branch line total request gate', () => {
it('asks for a total when the panel is visible and compare is ready', () => {
renderSourceControl()
expect(getBranchLineTotalMergeBase(mocks.activeWorktree.id)).toBe(MERGE_BASE)
})
it('asks for nothing while the sidebar is closed', () => {
resetState({ rightSidebarOpen: false })
renderSourceControl()
expect(getBranchLineTotalMergeBase(mocks.activeWorktree.id)).toBeUndefined()
})
it('asks for nothing while another right-sidebar tab is showing', () => {
resetState({ rightSidebarTab: 'checks' })
renderSourceControl()
expect(getBranchLineTotalMergeBase(mocks.activeWorktree.id)).toBeUndefined()
})
it('asks for nothing until branch compare is ready', () => {
resetState({
gitBranchCompareSummaryByWorktree: {
[mocks.activeWorktree.id]: { ...readySummary, status: 'loading' }
}
})
renderSourceControl()
expect(getBranchLineTotalMergeBase(mocks.activeWorktree.id)).toBeUndefined()
})
it('asks for nothing when compare has no merge base', () => {
resetState({
gitBranchCompareSummaryByWorktree: {
[mocks.activeWorktree.id]: { ...readySummary, status: 'invalid-base' }
}
})
renderSourceControl()
expect(getBranchLineTotalMergeBase(mocks.activeWorktree.id)).toBeUndefined()
})
it('asks for nothing in a folder workspace', () => {
resetState()
mocks.activeRepo.kind = 'folder'
renderSourceControl()
expect(getBranchLineTotalMergeBase(mocks.activeWorktree.id)).toBeUndefined()
})
it('releases the gate when the panel unmounts', () => {
renderSourceControl()
expect(getBranchLineTotalMergeBase(mocks.activeWorktree.id)).toBe(MERGE_BASE)
act(() => root.render(<TooltipProvider>{null}</TooltipProvider>))
expect(getBranchLineTotalMergeBase(mocks.activeWorktree.id)).toBeUndefined()
})
})
describe('SourceControl branch line total chip', () => {
it('renders a total measured against the current fork point', () => {
resetState({
gitBranchLineTotalByWorktree: {
[mocks.activeWorktree.id]: { added: 8259, removed: 670, mergeBase: MERGE_BASE }
}
})
renderSourceControl()
expect(chip()?.getAttribute('aria-label')).toBe('8259 lines added, 670 lines deleted')
// Grouping is pinned to the app locale (`en`), not the runner's host locale.
expect(chip()?.textContent).toBe('+8,259-670')
})
it('drops a total whose fork point has since moved', () => {
resetState({
gitBranchLineTotalByWorktree: {
[mocks.activeWorktree.id]: { added: 8259, removed: 670, mergeBase: 'stale-merge-base' }
}
})
renderSourceControl()
expect(chip()).toBeNull()
expect(loadingChip()).toBeNull()
})
it('drops a published total while branch compare has no ready summary', () => {
resetState({
gitBranchCompareSummaryByWorktree: {
[mocks.activeWorktree.id]: { ...readySummary, status: 'loading', mergeBase: '' }
},
gitBranchLineTotalByWorktree: {
[mocks.activeWorktree.id]: { added: 8259, removed: 670, mergeBase: MERGE_BASE }
}
})
renderSourceControl()
expect(chip()).toBeNull()
expect(loadingChip()).toBeNull()
})
// Old hosts, hard failures, and cooldowns may never send a total; a pending placeholder could pulse forever.
it('shows no placeholder while the total is still pending', () => {
vi.useFakeTimers()
try {
renderSourceControl()
expect(chip()).toBeNull()
act(() => {
vi.advanceTimersByTime(20_000)
})
expect(chip()).toBeNull()
expect(loadingChip()).toBeNull()
expect(container.innerHTML).not.toContain('animate-pulse')
} finally {
vi.useRealTimers()
}
})
})