199 lines
7.8 KiB
TypeScript
199 lines
7.8 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import { renderHook, act } from '@testing-library/react'
|
|
import { useCommitFlow } from './useCommitFlow'
|
|
|
|
const mockInvokeFn = vi.fn()
|
|
const mockTrackEvent = vi.fn()
|
|
|
|
vi.mock('@tauri-apps/api/core', () => ({
|
|
invoke: (command: string, args: Record<string, unknown>) => mockInvokeFn(command, args),
|
|
}))
|
|
|
|
vi.mock('../mock-tauri', () => ({
|
|
isTauri: () => false,
|
|
mockInvoke: (command: string, args: Record<string, unknown>) => mockInvokeFn(command, args),
|
|
}))
|
|
|
|
vi.mock('../lib/telemetry', () => ({
|
|
trackEvent: (event: string, properties?: Record<string, unknown>) => mockTrackEvent(event, properties),
|
|
}))
|
|
|
|
describe('useCommitFlow', () => {
|
|
let savePending: vi.Mock
|
|
let loadModifiedFiles: vi.Mock
|
|
let resolveRemoteStatus: vi.Mock
|
|
let setToastMessage: vi.Mock
|
|
let onPushRejected: vi.Mock
|
|
|
|
beforeEach(() => {
|
|
savePending = vi.fn().mockResolvedValue(undefined)
|
|
loadModifiedFiles = vi.fn().mockResolvedValue(undefined)
|
|
resolveRemoteStatus = vi.fn().mockResolvedValue({ branch: 'main', ahead: 0, behind: 0, hasRemote: true })
|
|
setToastMessage = vi.fn()
|
|
onPushRejected = vi.fn()
|
|
mockTrackEvent.mockReset()
|
|
mockInvokeFn.mockReset()
|
|
mockInvokeFn.mockImplementation((command: string) => {
|
|
if (command === 'git_commit') return Promise.resolve('[main abc1234] test commit')
|
|
if (command === 'git_push') return Promise.resolve({ status: 'ok', message: 'Pushed to remote' })
|
|
if (command === 'get_modified_files') return Promise.resolve([{ path: '/vault/a.md', relativePath: 'a.md', status: 'modified' }])
|
|
throw new Error(`Unexpected command: ${command}`)
|
|
})
|
|
})
|
|
|
|
function renderCommitFlow() {
|
|
return renderHook(() => useCommitFlow({
|
|
savePending,
|
|
loadModifiedFiles,
|
|
resolveRemoteStatus,
|
|
setToastMessage,
|
|
onPushRejected,
|
|
vaultPath: '/vault',
|
|
}))
|
|
}
|
|
|
|
it('openCommitDialog saves pending, refreshes files, and sets local mode when no remote exists', async () => {
|
|
resolveRemoteStatus.mockResolvedValueOnce({ branch: 'main', ahead: 0, behind: 0, hasRemote: false })
|
|
const { result } = renderCommitFlow()
|
|
|
|
await act(async () => {
|
|
await result.current.openCommitDialog()
|
|
})
|
|
|
|
expect(savePending).toHaveBeenCalledTimes(1)
|
|
expect(loadModifiedFiles).toHaveBeenCalledTimes(1)
|
|
expect(resolveRemoteStatus).toHaveBeenCalledTimes(1)
|
|
expect(result.current.showCommitDialog).toBe(true)
|
|
expect(result.current.commitMode).toBe('local')
|
|
})
|
|
|
|
it('handleCommitPush commits and pushes when a remote is configured', async () => {
|
|
const { result } = renderCommitFlow()
|
|
|
|
await act(async () => {
|
|
await result.current.handleCommitPush('test message')
|
|
})
|
|
|
|
expect(savePending).toHaveBeenCalled()
|
|
expect(mockInvokeFn).toHaveBeenNthCalledWith(1, 'git_commit', { vaultPath: '/vault', message: 'test message' })
|
|
expect(mockInvokeFn).toHaveBeenNthCalledWith(2, 'git_push', { vaultPath: '/vault' })
|
|
expect(setToastMessage).toHaveBeenCalledWith('Committed and pushed')
|
|
expect(loadModifiedFiles).toHaveBeenCalled()
|
|
expect(resolveRemoteStatus).toHaveBeenCalledTimes(2)
|
|
expect(mockTrackEvent).toHaveBeenCalledWith('commit_made', undefined)
|
|
expect(result.current.showCommitDialog).toBe(false)
|
|
})
|
|
|
|
it('runAutomaticCheckpoint saves pending first and uses the deterministic automatic message', async () => {
|
|
const { result } = renderCommitFlow()
|
|
|
|
await act(async () => {
|
|
await result.current.runAutomaticCheckpoint({ savePendingBeforeCommit: true })
|
|
})
|
|
|
|
expect(savePending).toHaveBeenCalledTimes(1)
|
|
expect(mockInvokeFn).toHaveBeenNthCalledWith(1, 'get_modified_files', { vaultPath: '/vault' })
|
|
expect(mockInvokeFn).toHaveBeenNthCalledWith(2, 'git_commit', { vaultPath: '/vault', message: 'Updated 1 note' })
|
|
expect(mockInvokeFn).toHaveBeenNthCalledWith(3, 'git_push', { vaultPath: '/vault' })
|
|
expect(setToastMessage).toHaveBeenCalledWith('Committed and pushed')
|
|
})
|
|
|
|
it('runAutomaticCheckpoint retries push-only when local commits are already ahead', async () => {
|
|
resolveRemoteStatus.mockResolvedValue({ branch: 'main', ahead: 2, behind: 0, hasRemote: true })
|
|
mockInvokeFn.mockImplementation((command: string) => {
|
|
if (command === 'get_modified_files') return Promise.resolve([])
|
|
if (command === 'git_push') return Promise.resolve({ status: 'ok', message: 'Pushed to remote' })
|
|
throw new Error(`Unexpected command: ${command}`)
|
|
})
|
|
|
|
const { result } = renderCommitFlow()
|
|
|
|
await act(async () => {
|
|
await result.current.runAutomaticCheckpoint()
|
|
})
|
|
|
|
expect(mockInvokeFn).toHaveBeenCalledTimes(2)
|
|
expect(mockInvokeFn).toHaveBeenNthCalledWith(1, 'get_modified_files', { vaultPath: '/vault' })
|
|
expect(mockInvokeFn).toHaveBeenNthCalledWith(2, 'git_push', { vaultPath: '/vault' })
|
|
expect(setToastMessage).toHaveBeenCalledWith('Pushed committed changes')
|
|
expect(mockTrackEvent).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('runAutomaticCheckpoint reports when there is nothing to commit or push', async () => {
|
|
mockInvokeFn.mockImplementation((command: string) => {
|
|
if (command === 'get_modified_files') return Promise.resolve([])
|
|
throw new Error(`Unexpected command: ${command}`)
|
|
})
|
|
|
|
const { result } = renderCommitFlow()
|
|
|
|
await act(async () => {
|
|
await result.current.runAutomaticCheckpoint()
|
|
})
|
|
|
|
expect(setToastMessage).toHaveBeenCalledWith('Nothing to commit or push')
|
|
expect(mockTrackEvent).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('handleCommitPush commits locally and skips push when no remote is configured', async () => {
|
|
resolveRemoteStatus.mockResolvedValue({ branch: 'main', ahead: 0, behind: 0, hasRemote: false })
|
|
mockInvokeFn.mockImplementation((command: string) => {
|
|
if (command === 'git_commit') return Promise.resolve('[main abc1234] test message')
|
|
throw new Error(`Unexpected command: ${command}`)
|
|
})
|
|
const { result } = renderCommitFlow()
|
|
|
|
await act(async () => {
|
|
await result.current.handleCommitPush('test message')
|
|
})
|
|
|
|
expect(mockInvokeFn).toHaveBeenCalledTimes(1)
|
|
expect(mockInvokeFn).toHaveBeenCalledWith('git_commit', { vaultPath: '/vault', message: 'test message' })
|
|
expect(setToastMessage).toHaveBeenCalledWith('Committed locally (no remote configured)')
|
|
expect(onPushRejected).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('handleCommitPush calls onPushRejected when push is rejected', async () => {
|
|
mockInvokeFn.mockImplementation((command: string) => {
|
|
if (command === 'git_commit') return Promise.resolve('[main abc1234] test message')
|
|
if (command === 'git_push') return Promise.resolve({ status: 'rejected', message: 'Push rejected' })
|
|
throw new Error(`Unexpected command: ${command}`)
|
|
})
|
|
const { result } = renderCommitFlow()
|
|
|
|
await act(async () => {
|
|
await result.current.handleCommitPush('test message')
|
|
})
|
|
|
|
expect(onPushRejected).toHaveBeenCalledTimes(1)
|
|
expect(setToastMessage).toHaveBeenCalledWith(expect.stringContaining('push rejected'))
|
|
})
|
|
|
|
it('handleCommitPush shows error toast on failure', async () => {
|
|
mockInvokeFn.mockImplementation(() => Promise.reject(new Error('push failed')))
|
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
const { result } = renderCommitFlow()
|
|
|
|
await act(async () => {
|
|
await result.current.handleCommitPush('test')
|
|
})
|
|
|
|
expect(setToastMessage).toHaveBeenCalledWith('Commit failed: push failed')
|
|
consoleSpy.mockRestore()
|
|
})
|
|
|
|
it('closeCommitDialog closes the dialog', async () => {
|
|
const { result } = renderCommitFlow()
|
|
|
|
await act(async () => {
|
|
await result.current.openCommitDialog()
|
|
})
|
|
expect(result.current.showCommitDialog).toBe(true)
|
|
|
|
act(() => {
|
|
result.current.closeCommitDialog()
|
|
})
|
|
expect(result.current.showCommitDialog).toBe(false)
|
|
})
|
|
})
|