mirror of
https://github.com/stablyai/orca.git
synced 2026-11-01 17:47:40 +00:00
* fix(mobile): build the Source Control action sheet from desktop's menu rows Adds Force Push and desktop's order, enable rules and hints. * fix(mobile): rebase from desktop's effective base and keep the Force Push warning in the action sheet * fix(mobile): keep the rebase row's base when the branch compare fails * fix(mobile): keep upstream compares working when the default base lookup fails * fix(mobile): keep the rebase target apart from the branch compare so failed or unsupported compares keep it * fix(mobile): leave force pushes to desktop when the linked review has its own push target * refactor(mobile): rebase onto the shown base and share the force-push block with the primary action
395 lines
12 KiB
TypeScript
395 lines
12 KiB
TypeScript
import { createElement, useState } from 'react'
|
|
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import { MobileSourceControlContent } from './MobileSourceControlContent'
|
|
import type { MobileSourceControlPrimaryAction } from './mobile-source-control-primary-action'
|
|
import type { MobileSourceControlState } from './use-mobile-source-control-state'
|
|
|
|
type MobileSourceControlContentState = Omit<MobileSourceControlState, 'primaryActionArgs'> & {
|
|
primaryAction: MobileSourceControlPrimaryAction
|
|
}
|
|
import * as branchFormat from './mobile-branch-entry-format'
|
|
import type { SectionListProps } from 'react-native'
|
|
import type {
|
|
MobileBranchEntryView,
|
|
MobileGitStatusEntryView
|
|
} from './mobile-source-control-screen-state'
|
|
|
|
vi.mock('react-native', () => ({
|
|
ActivityIndicator: 'ActivityIndicator',
|
|
Pressable: 'Pressable',
|
|
ScrollView: 'ScrollView',
|
|
SectionList: (props: SectionListProps<MobileBranchEntryView | MobileGitStatusEntryView>) =>
|
|
createElement(
|
|
'SectionList',
|
|
props,
|
|
typeof props.ListFooterComponent === 'function'
|
|
? createElement(props.ListFooterComponent)
|
|
: props.ListFooterComponent,
|
|
props.sections.map((section) =>
|
|
createElement(
|
|
'Section',
|
|
{ key: section.key },
|
|
props.renderSectionHeader?.({ section }),
|
|
section.data.slice(0, 20).map((item, index) =>
|
|
createElement(
|
|
'Row',
|
|
{ key: item.path },
|
|
props.renderItem?.({
|
|
item,
|
|
index,
|
|
section,
|
|
separators: { highlight: noop, unhighlight: noop, updateProps: noop }
|
|
})
|
|
)
|
|
)
|
|
)
|
|
)
|
|
),
|
|
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 },
|
|
Text: 'Text',
|
|
TextInput: 'TextInput',
|
|
View: 'View'
|
|
}))
|
|
vi.mock('expo-clipboard', () => ({ setStringAsync: async () => {} }))
|
|
vi.mock('lucide-react-native', () => ({
|
|
ArrowDown: 'ArrowDown',
|
|
ArrowDownUp: 'ArrowDownUp',
|
|
ArrowUp: 'ArrowUp',
|
|
Check: 'Check',
|
|
ChevronRight: 'ChevronRight',
|
|
CloudUpload: 'CloudUpload',
|
|
FileText: 'FileText',
|
|
GitBranch: 'GitBranch',
|
|
GitPullRequestArrow: 'GitPullRequestArrow',
|
|
History: 'History',
|
|
Minus: 'Minus',
|
|
MoreHorizontal: 'MoreHorizontal',
|
|
Plus: 'Plus',
|
|
RefreshCw: 'RefreshCw',
|
|
Sparkles: 'Sparkles',
|
|
Trash2: 'Trash2'
|
|
}))
|
|
|
|
const noop = () => {}
|
|
const unusedAsync = async (): Promise<never> => {
|
|
throw new Error('unused action')
|
|
}
|
|
|
|
function initialState(): MobileSourceControlContentState {
|
|
const branchEntries = Array.from({ length: 20 }, (_, index) => ({
|
|
path: `src/file-${index}.ts`,
|
|
status: 'modified' as const,
|
|
added: 3,
|
|
removed: 1,
|
|
canOpen: true
|
|
}))
|
|
const branchCompareResult = {
|
|
entries: branchEntries,
|
|
summary: {
|
|
status: 'ready' as const,
|
|
baseRef: 'main',
|
|
changedFiles: 20,
|
|
commitsAhead: undefined,
|
|
errorMessage: undefined
|
|
}
|
|
}
|
|
return {
|
|
client: null,
|
|
connState: 'connected',
|
|
forceReconnect: null,
|
|
rebaseBaseRef: 'main',
|
|
insets: { top: 0, bottom: 0, left: 0, right: 0 },
|
|
router: {
|
|
back: noop,
|
|
push: noop,
|
|
replace: noop,
|
|
navigate: noop,
|
|
dismiss: noop,
|
|
dismissAll: noop,
|
|
dismissTo: noop,
|
|
prefetch: noop,
|
|
canGoBack: () => false,
|
|
canDismiss: () => false,
|
|
setParams: noop,
|
|
reload: noop
|
|
},
|
|
setRootRef: noop,
|
|
worktreeLabel: 'fixture',
|
|
screenState: { kind: 'loading' },
|
|
branchCompareState: { kind: 'ready', result: branchCompareResult },
|
|
branchDiffPreview: null,
|
|
setBranchDiffPreview: noop,
|
|
busyAction: null,
|
|
commitMessage: '',
|
|
setCommitMessage: noop,
|
|
generatingMessage: false,
|
|
showBranchPicker: false,
|
|
setShowBranchPicker: noop,
|
|
localBranches: null,
|
|
createdPrUrl: null,
|
|
setCreatedPrUrl: noop,
|
|
createdPrWarning: null,
|
|
setCreatedPrWarning: noop,
|
|
discardTarget: null,
|
|
setDiscardTarget: noop,
|
|
showActionSheet: false,
|
|
setShowActionSheet: noop,
|
|
actionError: null,
|
|
commitFailureRecovery: null,
|
|
commitFailureRecoveryAction: {
|
|
summary: null,
|
|
hasDetails: false,
|
|
launching: false,
|
|
availability: 'available',
|
|
launchError: null,
|
|
launchWarning: null,
|
|
launchSuccess: null,
|
|
undeliveredPrompt: null,
|
|
launch: unusedAsync
|
|
},
|
|
keyboardLift: 0,
|
|
openingPath: null,
|
|
openingBranchPath: null,
|
|
status: null,
|
|
sections: [],
|
|
branchCompareResult,
|
|
branchCompareSummaryText: '20 files vs main',
|
|
branchEntries,
|
|
shouldShowBranchCompareSection: true,
|
|
hasVisibleChanges: true,
|
|
stageablePaths: [],
|
|
unstageablePaths: [],
|
|
stagedCount: 1,
|
|
unstagedCount: 0,
|
|
branchLabel: 'fixture',
|
|
upstreamKnown: false,
|
|
syncLabel: null,
|
|
primaryAction: {
|
|
kind: 'commit',
|
|
label: 'Commit',
|
|
accessibilityLabel: 'Commit',
|
|
accessibilityHint: '',
|
|
disabled: false,
|
|
loading: false,
|
|
onPress: noop
|
|
},
|
|
createPrAction: {
|
|
visible: false,
|
|
label: 'Create Pull Request',
|
|
disabled: true,
|
|
loading: false,
|
|
pushFirst: false,
|
|
onPress: noop
|
|
},
|
|
loadStatus: unusedAsync,
|
|
openFile: unusedAsync,
|
|
openBranchDiff: unusedAsync,
|
|
runGitAction: unusedAsync,
|
|
stageAll: unusedAsync,
|
|
unstageAll: unusedAsync,
|
|
commit: unusedAsync,
|
|
generateCommitMessage: unusedAsync,
|
|
cancelGenerateCommitMessage: noop,
|
|
createPr: unusedAsync,
|
|
openBranchPicker: noop,
|
|
openHistory: noop,
|
|
checkoutBranch: unusedAsync,
|
|
abortConflictOperation: unusedAsync,
|
|
runActionSheetCommit: unusedAsync,
|
|
runActionSheetCommitSequence: unusedAsync,
|
|
runActionSheetCommitSync: unusedAsync,
|
|
runActionSheetGitSequence: unusedAsync,
|
|
runActionSheetGitSync: unusedAsync,
|
|
runActionSheetRebase: unusedAsync
|
|
}
|
|
}
|
|
|
|
let renderer: ReactTestRenderer | null = null
|
|
const controls: { patch: ((patch: Partial<MobileSourceControlContentState>) => void) | null } = {
|
|
patch: null
|
|
}
|
|
|
|
function Fixture({ initial }: { initial: MobileSourceControlContentState }) {
|
|
const [state, setState] = useState(initial)
|
|
controls.patch = (patch) => setState((previous) => ({ ...previous, ...patch }))
|
|
return createElement(MobileSourceControlContent, {
|
|
primaryAction: state.primaryAction,
|
|
state: {
|
|
...state,
|
|
setCommitMessage: (next) =>
|
|
setState((previous) => ({
|
|
...previous,
|
|
commitMessage: typeof next === 'function' ? next(previous.commitMessage) : next
|
|
}))
|
|
}
|
|
})
|
|
}
|
|
|
|
function render(initial = initialState()) {
|
|
act(() => {
|
|
renderer = create(createElement(Fixture, { initial }))
|
|
})
|
|
}
|
|
function patch(values: Partial<MobileSourceControlContentState>) {
|
|
act(() => {
|
|
controls.patch?.(values)
|
|
})
|
|
}
|
|
function rows() {
|
|
return (
|
|
renderer?.root.findAll(
|
|
(node) =>
|
|
String(node.type) === 'Pressable' &&
|
|
typeof node.props.accessibilityLabel === 'string' &&
|
|
node.props.accessibilityLabel.startsWith('Open committed change')
|
|
) ?? []
|
|
)
|
|
}
|
|
function text() {
|
|
return JSON.stringify(renderer?.toJSON())
|
|
}
|
|
|
|
afterEach(() => {
|
|
act(() => renderer?.unmount())
|
|
renderer = null
|
|
controls.patch = null
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
describe('committed branch files during commit-message typing', () => {
|
|
it.each(['clean', 'dirty'])('keeps the list without revisiting rows in %s', (layout) => {
|
|
const initial = initialState()
|
|
if (layout === 'dirty') {
|
|
initial.sections = [{ id: 'staged', area: 'staged', title: 'Staged Changes', data: [] }]
|
|
}
|
|
const format = vi.spyOn(branchFormat, 'formatMobileBranchEntryMeta')
|
|
render(initial)
|
|
expect(format).toHaveBeenCalledTimes(20)
|
|
format.mockClear()
|
|
for (let index = 0; index < 50; index++) {
|
|
act(() => {
|
|
const input = renderer?.root.find((node) => String(node.type) === 'TextInput')
|
|
if (typeof input?.props.onChangeText !== 'function') {
|
|
throw new Error('missing input handler')
|
|
}
|
|
input.props.onChangeText(`message ${index}`)
|
|
})
|
|
}
|
|
expect(renderer?.root.find((node) => String(node.type) === 'TextInput').props.value).toBe(
|
|
'message 49'
|
|
)
|
|
expect(rows()).toHaveLength(20)
|
|
expect(format).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it.each([false, true])(
|
|
'passes all committed files through the list window (dirty: %s)',
|
|
(dirty) => {
|
|
const initial = initialState()
|
|
const first = initial.branchEntries[0]
|
|
if (!first) {
|
|
throw new Error('missing branch fixture')
|
|
}
|
|
initial.branchEntries = Array.from({ length: 20_000 }, (_, index) => ({
|
|
...first,
|
|
path: `file-${index}.ts`
|
|
}))
|
|
if (dirty) {
|
|
initial.sections = [{ id: 'staged', area: 'staged', title: 'Staged Changes', data: [] }]
|
|
}
|
|
render(initial)
|
|
expect(rows().length).toBeGreaterThan(0)
|
|
expect(rows().length).toBeLessThanOrEqual(20)
|
|
const list = renderer?.root.find((node) => String(node.type) === 'SectionList')
|
|
expect(list?.props.sections.at(-1).data).toBe(initial.branchEntries)
|
|
}
|
|
)
|
|
|
|
it('updates rows and section messages when their inputs change', () => {
|
|
const initial = initialState()
|
|
const format = vi.spyOn(branchFormat, 'formatMobileBranchEntryMeta')
|
|
render(initial)
|
|
patch({ branchCompareSummaryText: 'changed summary' })
|
|
expect(text()).toContain('changed summary')
|
|
const first = initial.branchEntries[0]
|
|
if (!first) {
|
|
throw new Error('missing fixture entry')
|
|
}
|
|
patch({ branchEntries: [{ ...first, path: 'changed.ts' }] })
|
|
expect(rows()).toHaveLength(1)
|
|
expect(text()).toContain('changed.ts')
|
|
patch({ busyAction: 'stage-all' })
|
|
expect(rows()[0]?.props.disabled).toBe(true)
|
|
patch({ busyAction: null })
|
|
expect(rows()[0]?.props.disabled).toBe(false)
|
|
patch({ openingPath: 'local.ts' })
|
|
expect(rows()[0]?.props.disabled).toBe(true)
|
|
patch({ openingPath: null })
|
|
patch({ openingBranchPath: 'changed.ts' })
|
|
expect(rows()[0]?.props.disabled).toBe(true)
|
|
expect(rows()[0]?.findAll((node) => String(node.type) === 'ActivityIndicator')).toHaveLength(1)
|
|
patch({ openingBranchPath: null })
|
|
const open = vi.fn(async () => {})
|
|
patch({ openBranchDiff: open })
|
|
act(() => {
|
|
const onPress = rows()[0]?.props.onPress
|
|
if (typeof onPress !== 'function') {
|
|
throw new Error('missing branch opener')
|
|
}
|
|
onPress()
|
|
})
|
|
expect(open).toHaveBeenCalledWith(expect.objectContaining({ path: 'changed.ts' }))
|
|
patch({ branchCompareState: { kind: 'error', message: 'compare failed' } })
|
|
expect(text()).toContain('compare failed')
|
|
expect(rows()).toHaveLength(0)
|
|
patch({ branchCompareState: initial.branchCompareState })
|
|
patch({
|
|
branchCompareResult: {
|
|
...initial.branchCompareResult,
|
|
summary: {
|
|
status: 'error',
|
|
baseRef: 'main',
|
|
changedFiles: 0,
|
|
commitsAhead: undefined,
|
|
errorMessage: 'unavailable'
|
|
}
|
|
}
|
|
})
|
|
expect(text()).toContain('unavailable')
|
|
expect(rows()).toHaveLength(0)
|
|
patch({ branchCompareResult: initial.branchCompareResult })
|
|
expect(rows()).toHaveLength(1)
|
|
patch({ shouldShowBranchCompareSection: false })
|
|
expect(text()).not.toContain('Committed on Branch')
|
|
patch({ shouldShowBranchCompareSection: true })
|
|
expect(rows()).toHaveLength(1)
|
|
format.mockClear()
|
|
patch({ connState: 'disconnected', actionError: 'offline', keyboardLift: 100 })
|
|
expect(text()).toContain('Reconnecting to desktop')
|
|
expect(text()).toContain('offline')
|
|
expect(format).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
describe('AI commit message button', () => {
|
|
function generateButton() {
|
|
return renderer?.root.find(
|
|
(node) =>
|
|
String(node.type) === 'Pressable' &&
|
|
(node.props.accessibilityLabel === 'Generate commit message with AI' ||
|
|
node.props.accessibilityLabel === 'Cancel commit message generation')
|
|
)
|
|
}
|
|
|
|
it('matches desktop: a typed message blocks regenerating, and cancel stays available', () => {
|
|
render()
|
|
expect(generateButton()?.props.disabled).toBe(false)
|
|
patch({ commitMessage: 'my own message' })
|
|
expect(generateButton()?.props.disabled).toBe(true)
|
|
expect(generateButton()?.props.accessibilityHint).toBe('Clear the message to regenerate.')
|
|
patch({ generatingMessage: true })
|
|
expect(generateButton()?.props.disabled).toBe(false)
|
|
})
|
|
})
|