mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 17:37:40 +00:00
test: remove low-value sidebar tests (#38511)
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
-379
@@ -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, '"')
|
||||
}
|
||||
|
||||
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'
|
||||
})
|
||||
})
|
||||
})
|
||||
-266
@@ -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()
|
||||
})
|
||||
|
||||
+5
-299
@@ -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())
|
||||
|
||||
-198
@@ -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()
|
||||
}
|
||||
)
|
||||
-815
@@ -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()
|
||||
})
|
||||
-326
@@ -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 & 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.'
|
||||
)
|
||||
})
|
||||
-463
@@ -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)
|
||||
})
|
||||
})
|
||||
-420
@@ -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)
|
||||
})
|
||||
})
|
||||
+1
-408
@@ -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',
|
||||
|
||||
-565
@@ -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<')
|
||||
})
|
||||
})
|
||||
-795
@@ -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
-374
@@ -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({
|
||||
|
||||
-556
@@ -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 }
|
||||
)
|
||||
})
|
||||
})
|
||||
+2
-167
@@ -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
-266
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
-635
@@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
-586
@@ -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
-169
@@ -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: {
|
||||
|
||||
-335
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user