From c1b71588b671eb1837e6dbb96ef1cd15c8cb88e2 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Thu, 5 Nov 2026 02:55:51 -0800 Subject: [PATCH] test: remove low-value sidebar tests (#38511) Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- config/flaky-test-pattern-baseline.txt | 1 - .../scripts/ci/sharding/ci-shard-timings.json | 12 - .../SourceControlTextGenerationDialog.test.ts | 379 -------- .../runSourceControlAgentActionStart.test.ts | 266 ------ .../ai/use-ai.operation-recovery.test.tsx | 304 +------ ...saved-source-control-action-start.test.tsx | 198 ---- ...useSourceControlAgentActionDialog.test.tsx | 815 ----------------- .../commit/CommitArea.generate.test.tsx | 326 ------- .../source-control/commit/CommitArea.test.tsx | 854 ------------------ ...SourceControl.open-file-highlight.test.tsx | 463 ---------- .../SourceControl.virtual-file-list.test.tsx | 420 --------- ...source-control-submodule-expansion.test.ts | 409 +-------- ...source-control-branch-context-row.test.tsx | 565 ------------ .../source-control-dropdown-items.test.ts | 795 ---------------- ...ourceControl.pr-generation-records.test.ts | 375 +------- .../SourceControl.preview-open.test.tsx | 556 ------------ ...urce-control-create-pr-intent-flow.test.ts | 169 +--- .../source-control-manual-review-url.test.ts | 267 +----- ...ol-primary-action.create-pr-intent.test.ts | 635 ------------- .../source-control-primary-action.test.ts | 586 ------------ .../use-pull-request-generation.test.tsx | 170 +--- .../SourceControl.branch-line-total.test.tsx | 335 ------- 22 files changed, 11 insertions(+), 8889 deletions(-) delete mode 100644 src/renderer/src/components/right-sidebar/source-control/ai/SourceControlTextGenerationDialog.test.ts delete mode 100644 src/renderer/src/components/right-sidebar/source-control/ai/use-saved-source-control-action-start.test.tsx delete mode 100644 src/renderer/src/components/right-sidebar/source-control/ai/useSourceControlAgentActionDialog.test.tsx delete mode 100644 src/renderer/src/components/right-sidebar/source-control/commit/CommitArea.generate.test.tsx delete mode 100644 src/renderer/src/components/right-sidebar/source-control/commit/CommitArea.test.tsx delete mode 100644 src/renderer/src/components/right-sidebar/source-control/listing/SourceControl.open-file-highlight.test.tsx delete mode 100644 src/renderer/src/components/right-sidebar/source-control/listing/SourceControl.virtual-file-list.test.tsx delete mode 100644 src/renderer/src/components/right-sidebar/source-control/panel/source-control-branch-context-row.test.tsx delete mode 100644 src/renderer/src/components/right-sidebar/source-control/panel/source-control-dropdown-items.test.ts delete mode 100644 src/renderer/src/components/right-sidebar/source-control/review/SourceControl.preview-open.test.tsx delete mode 100644 src/renderer/src/components/right-sidebar/source-control/review/source-control-primary-action.create-pr-intent.test.ts delete mode 100644 src/renderer/src/components/right-sidebar/source-control/review/source-control-primary-action.test.ts delete mode 100644 src/renderer/src/components/right-sidebar/source-control/sync/SourceControl.branch-line-total.test.tsx diff --git a/config/flaky-test-pattern-baseline.txt b/config/flaky-test-pattern-baseline.txt index 9819df34611..5ac8256f4f2 100644 --- a/config/flaky-test-pattern-baseline.txt +++ b/config/flaky-test-pattern-baseline.txt @@ -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 diff --git a/config/scripts/ci/sharding/ci-shard-timings.json b/config/scripts/ci/sharding/ci-shard-timings.json index 60323f44e7d..12ef3312863 100644 --- a/config/scripts/ci/sharding/ci-shard-timings.json +++ b/config/scripts/ci/sharding/ci-shard-timings.json @@ -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, diff --git a/src/renderer/src/components/right-sidebar/source-control/ai/SourceControlTextGenerationDialog.test.ts b/src/renderer/src/components/right-sidebar/source-control/ai/SourceControlTextGenerationDialog.test.ts deleted file mode 100644 index f7378ca55a7..00000000000 --- a/src/renderer/src/components/right-sidebar/source-control/ai/SourceControlTextGenerationDialog.test.ts +++ /dev/null @@ -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> - }) => - 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' - }) - }) -}) diff --git a/src/renderer/src/components/right-sidebar/source-control/ai/runSourceControlAgentActionStart.test.ts b/src/renderer/src/components/right-sidebar/source-control/ai/runSourceControlAgentActionStart.test.ts index dbca45aa89b..c75b17d9dee 100644 --- a/src/renderer/src/components/right-sidebar/source-control/ai/runSourceControlAgentActionStart.test.ts +++ b/src/renderer/src/components/right-sidebar/source-control/ai/runSourceControlAgentActionStart.test.ts @@ -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[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[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 = { - 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() -}) diff --git a/src/renderer/src/components/right-sidebar/source-control/ai/use-ai.operation-recovery.test.tsx b/src/renderer/src/components/right-sidebar/source-control/ai/use-ai.operation-recovery.test.tsx index 4a4fea817e7..79ab706f491 100644 --- a/src/renderer/src/components/right-sidebar/source-control/ai/use-ai.operation-recovery.test.tsx +++ b/src/renderer/src/components/right-sidebar/source-control/ai/use-ai.operation-recovery.test.tsx @@ -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 { - 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(['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 = Promise.resolve() - act(() => { - oldOpening = view.result.current.handleResolveConflictsWithAI() - }) - view.rerender(other) - view.rerender(first) - expect(view.result.current.isCheckingRecoveryAdmission).toBe(false) - let newOpening: Promise = 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 = 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 - }>() - 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 = 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() - 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 = 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()) diff --git a/src/renderer/src/components/right-sidebar/source-control/ai/use-saved-source-control-action-start.test.tsx b/src/renderer/src/components/right-sidebar/source-control/ai/use-saved-source-control-action-start.test.tsx deleted file mode 100644 index a91c8ed0125..00000000000 --- a/src/renderer/src/components/right-sidebar/source-control/ai/use-saved-source-control-action-start.test.tsx +++ /dev/null @@ -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()), - 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() - } -) diff --git a/src/renderer/src/components/right-sidebar/source-control/ai/useSourceControlAgentActionDialog.test.tsx b/src/renderer/src/components/right-sidebar/source-control/ai/useSourceControlAgentActionDialog.test.tsx deleted file mode 100644 index 60c12e3c0f1..00000000000 --- a/src/renderer/src/components/right-sidebar/source-control/ai/useSourceControlAgentActionDialog.test.tsx +++ /dev/null @@ -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()), - 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 - }>() - 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({ - 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() - const save = vi.fn(() => 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({ - 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({ - 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({ - 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 | 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() -}) diff --git a/src/renderer/src/components/right-sidebar/source-control/commit/CommitArea.generate.test.tsx b/src/renderer/src/components/right-sidebar/source-control/commit/CommitArea.generate.test.tsx deleted file mode 100644 index 0bb4f2d6a26..00000000000 --- a/src/renderer/src/components/right-sidebar/source-control/commit/CommitArea.generate.test.tsx +++ /dev/null @@ -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 { - 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 = {}) { - 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): string { - return renderToStaticMarkup( - - - - ) -} - -function buttonByLabel(markup: string, label: string): string { - const button = [...markup.matchAll(//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) - }) -}) diff --git a/src/renderer/src/components/right-sidebar/source-control/commit/CommitArea.test.tsx b/src/renderer/src/components/right-sidebar/source-control/commit/CommitArea.test.tsx deleted file mode 100644 index 1c6e6c79954..00000000000 --- a/src/renderer/src/components/right-sidebar/source-control/commit/CommitArea.test.tsx +++ /dev/null @@ -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 }) => ( -
{children}
- ), - TooltipProvider: ({ children }: { children: React.ReactNode }) => <>{children} -})) - -afterEach(() => { - vi.unstubAllGlobals() -}) - -function setUserAgent(userAgent: string): void { - vi.stubGlobal('navigator', { userAgent }) -} - -function buildInputs(overrides: Partial = {}): 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 = {}) { - 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, - 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> { - 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[0]): string { - return renderToStaticMarkup( - - - - ) -} - -function firstButton(markup: string): string { - const button = [...markup.matchAll(//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(//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(//) - 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( - <> -
- handleSourceControlCommitShortcut(event, primaryAction, onPrimaryAction) - } - > - -
- - - ) - - 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( - - ) - 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('>') - 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(//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(//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(//g) ?? []).some((button) => - button.includes('Commit') - ) - ).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( - - ) - - 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( - - ) - const rebaseMarkup = renderToStaticMarkup( - - ) - const cherryPickMarkup = renderToStaticMarkup( - - ) - - 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( - - ) - const rebaseMarkup = renderToStaticMarkup( - - ) - - 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( - - ) - - 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( - - ) - - 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( - - ) - const rebaseMarkup = renderToStaticMarkup( - - ) - const cherryPickMarkup = renderToStaticMarkup( - - ) - - 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( - - ) - const rebaseMarkup = renderToStaticMarkup( - - ) - - 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( - - ) - const hiddenMarkup = renderToStaticMarkup( - - ) - 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() - 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.' - ) -}) diff --git a/src/renderer/src/components/right-sidebar/source-control/listing/SourceControl.open-file-highlight.test.tsx b/src/renderer/src/components/right-sidebar/source-control/listing/SourceControl.open-file-highlight.test.tsx deleted file mode 100644 index 09d3f6c1c6c..00000000000 --- a/src/renderer/src/components/right-sidebar/source-control/listing/SourceControl.open-file-highlight.test.tsx +++ /dev/null @@ -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 - } -}) - -vi.mock('@/store/index', () => { - const useAppStore = Object.assign( - (selector?: (state: Record) => 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 { - 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 { - return vi.fn().mockResolvedValue(value) -} - -function resetState(overrides: Partial> = {}): 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( - - - - ) - }) -} - -function row(path: string, area: GitStatusEntry['area'] | 'branch'): HTMLDivElement | null { - return container.querySelector( - `[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) - }) -}) diff --git a/src/renderer/src/components/right-sidebar/source-control/listing/SourceControl.virtual-file-list.test.tsx b/src/renderer/src/components/right-sidebar/source-control/listing/SourceControl.virtual-file-list.test.tsx deleted file mode 100644 index bc65e864736..00000000000 --- a/src/renderer/src/components/right-sidebar/source-control/listing/SourceControl.virtual-file-list.test.tsx +++ /dev/null @@ -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 - } -}) - -vi.mock('@/store/index', () => { - const useAppStore = Object.assign( - (selector?: (state: Record) => 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 { - 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 { - return vi.fn().mockResolvedValue(value) -} - -function resetState(overrides: Partial> = {}): 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( - - - - ) - }) -} - -function scroller(): HTMLDivElement { - const element = container.querySelector('.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('[data-testid="source-control-entry"]') - ) -} - -function row(path: string): HTMLDivElement | null { - return container.querySelector( - `[data-source-control-path="${path}"][data-source-control-area="unstaged"]` - ) -} - -function virtualList(): HTMLDivElement | null { - return container.querySelector('[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) - }) -}) diff --git a/src/renderer/src/components/right-sidebar/source-control/listing/source-control-submodule-expansion.test.ts b/src/renderer/src/components/right-sidebar/source-control/listing/source-control-submodule-expansion.test.ts index 6d2cb5634fb..6d4fc891080 100644 --- a/src/renderer/src/components/right-sidebar/source-control/listing/source-control-submodule-expansion.test.ts +++ b/src/renderer/src/components/right-sidebar/source-control/listing/source-control-submodule-expansion.test.ts @@ -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 & { 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 = { - [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 = { - [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 = { - [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 = { - [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 = { - [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 = { - [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 = { - [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 = { - [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 = { - [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 = { - [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 = { - [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', diff --git a/src/renderer/src/components/right-sidebar/source-control/panel/source-control-branch-context-row.test.tsx b/src/renderer/src/components/right-sidebar/source-control/panel/source-control-branch-context-row.test.tsx deleted file mode 100644 index b7399dec94c..00000000000 --- a/src/renderer/src/components/right-sidebar/source-control/panel/source-control-branch-context-row.test.tsx +++ /dev/null @@ -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( - - ) - - 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( - - ) - - 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( - - ) - - 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( - - ) - - 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( - - ) - - 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( - - ) - - 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( - - ) - - expect(markup).toBe('') - }) - - it('renders detached HEAD identity above the base with keyboard-reachable badge', () => { - const markup = renderToStaticMarkup( - - ) - - 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( - - ) - - 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( - - ) - - // 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( - - ) - - 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( - - ) - - expect(markup).toContain('aria-label="Open review page in browser"') - }) -}) - -function renderWithLineTotal( - branchLineTotal: GitBranchLineTotal | null | undefined, - summary: GitBranchCompareSummary | null = readySummary -): string { - return renderToStaticMarkup( - - ) -} - -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('