test: improve type safety and mock patterns in source-control tests

- Add proper type definitions for toast options and test data instead of using `as never`
- Replace `mock.calls.at(-1)` with safer `mock.lastCall` pattern
- Create `entry()` helper to construct typed test entries
- Add explicit type annotations to mocked functions for better IDE support
This commit is contained in:
Jinjing
2026-09-14 12:15:17 -07:00
parent bfe99e1d00
commit 7f069c8d4d
3 changed files with 47 additions and 28 deletions
@@ -2,10 +2,18 @@
import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { DiscardAllDeps, DiscardAllResult, DiscardAllArea } from './discard-all-sequence'
type ToastOptions = { description?: string }
type DiscardAllRunner = (
area: DiscardAllArea,
paths: readonly string[],
deps: DiscardAllDeps
) => Promise<DiscardAllResult>
const mocks = vi.hoisted(() => ({
toastError: vi.fn(),
runDiscardAllForArea: vi.fn()
toastError: vi.fn<(title: string, options?: ToastOptions) => void>(),
runDiscardAllForArea: vi.fn<DiscardAllRunner>()
}))
vi.mock('sonner', () => ({ toast: { error: mocks.toastError, dismiss: vi.fn() } }))
@@ -13,7 +21,8 @@ vi.mock('@/lib/connection-context', () => ({ getConnectionId: () => undefined })
vi.mock('@/runtime/runtime-git-client', () => ({ bulkUnstageRuntimeGitPaths: vi.fn() }))
vi.mock('./discard-all-sequence', () => ({
getDiscardAllPaths: () => [],
runDiscardAllForArea: (...args: unknown[]) => mocks.runDiscardAllForArea(...args)
runDiscardAllForArea: (area: DiscardAllArea, paths: readonly string[], deps: DiscardAllDeps) =>
mocks.runDiscardAllForArea(area, paths, deps)
}))
import { useSourceControlDiscardConfirmation } from './use-discard-confirmation'
@@ -22,8 +31,7 @@ import type { SourceControlEntryGroups } from '../listing/section-order'
const EMPTY_GROUPS: SourceControlEntryGroups = { unstaged: [], staged: [], untracked: [] }
function lastDescription(): string | undefined {
const call = mocks.toastError.mock.calls.at(-1)
return (call?.[1] as { description?: string })?.description
return mocks.toastError.mock.lastCall?.[1]?.description
}
function renderDiscard() {
@@ -65,8 +73,8 @@ describe('discard-all failure descriptions', () => {
it('unwraps the IPC transport noise on a partial failure, like the per-row toast does', async () => {
mocks.runDiscardAllForArea.mockImplementation(async (_area, _paths, handlers) => {
;(handlers as { onError: (e: unknown) => void }).onError(new Error(WRAPPED))
return { aborted: false, failed: ['a.ts'] }
handlers.onError?.(new Error(WRAPPED))
return { aborted: false, discarded: [], failed: ['a.ts'] }
})
await confirmDiscardOf(['a.ts'])
@@ -78,8 +86,8 @@ describe('discard-all failure descriptions', () => {
// Why 'staged': `aborted` is set only by the bulkUnstage pre-step, which runs for staged entries.
it('unwraps it on the aborted-before-discard path too', async () => {
mocks.runDiscardAllForArea.mockImplementation(async (_area, _paths, handlers) => {
;(handlers as { onError: (e: unknown) => void }).onError(new Error(WRAPPED))
return { aborted: true, failed: [] }
handlers.onError?.(new Error(WRAPPED))
return { aborted: true, discarded: [], failed: [] }
})
await confirmDiscardOf(['a.ts'], 'staged')
@@ -2,9 +2,16 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
type ToastOptions = {
id?: string
description?: string
duration?: number
action?: { label: string; onClick: () => void }
}
const { toastError, toastDismiss } = vi.hoisted(() => ({
toastError: vi.fn(),
toastDismiss: vi.fn()
toastError: vi.fn<(title: string, options?: ToastOptions) => void>(),
toastDismiss: vi.fn<(id: string) => void>()
}))
vi.mock('sonner', () => ({ toast: { error: toastError, dismiss: toastDismiss } }))
@@ -15,19 +22,14 @@ vi.mock('@/store', () => ({
import { showSourceControlEntryFailureToast } from './source-control-entry-failure-toast'
type ToastOptions = {
id?: string
description?: string
duration?: number
action?: { label: string; onClick: () => void }
}
type FailureToastInput = Parameters<typeof showSourceControlEntryFailureToast>[0]
function lastToast(): { title: string; options: ToastOptions } {
const call = toastError.mock.calls.at(-1)
return { title: String(call?.[0]), options: (call?.[1] ?? {}) as ToastOptions }
const [title = '', options = {}] = toastError.mock.lastCall ?? []
return { title, options }
}
function show(overrides: Record<string, unknown> = {}): void {
function show(overrides: Partial<FailureToastInput> = {}): void {
showSourceControlEntryFailureToast({
operation: 'stage',
filePath: 'src/app.ts',
@@ -35,7 +37,7 @@ function show(overrides: Record<string, unknown> = {}): void {
worktreeId: 'wt-1',
worktreeName: 'feature-a',
...overrides
} as Parameters<typeof showSourceControlEntryFailureToast>[0])
})
}
describe('showSourceControlEntryFailureToast', () => {
@@ -2,9 +2,12 @@
import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { GitStatusEntry } from '../../../../../../shared/git-status-types'
type ToastOptions = { description?: string; action?: { label: string; onClick: () => void } }
const mocks = vi.hoisted(() => ({
toastError: vi.fn(),
toastError: vi.fn<(title: string, options?: ToastOptions) => void>(),
stagePath: vi.fn(),
unstagePath: vi.fn(),
discardPath: vi.fn()
@@ -37,11 +40,17 @@ import type { SourceControlEntryGroups } from '../listing/section-order'
const EMPTY_GROUPS: SourceControlEntryGroups = { unstaged: [], staged: [], untracked: [] }
type ToastOptions = { description?: string; action?: { label: string; onClick: () => void } }
function entry(
path: string,
status: GitStatusEntry['status'] = 'modified',
area: GitStatusEntry['area'] = 'unstaged'
): GitStatusEntry {
return { path, status, area }
}
function lastToast(): { title: string; options: ToastOptions } {
const call = mocks.toastError.mock.calls.at(-1)
return { title: String(call?.[0]), options: (call?.[1] ?? {}) as ToastOptions }
const [title = '', options = {}] = mocks.toastError.mock.lastCall ?? []
return { title, options }
}
function renderMutations() {
@@ -138,7 +147,7 @@ describe('source-control entry mutation failures', () => {
const { result } = renderDiscard(discardSingle)
await act(async () => {
result.current.requestDiscardEntry({ path: 'src/app.ts' } as never)
result.current.requestDiscardEntry(entry('src/app.ts'))
})
await act(async () => {
result.current.confirmPendingDiscard()
@@ -156,7 +165,7 @@ describe('source-control entry mutation failures', () => {
const { result } = renderDiscard(discardSingle)
await act(async () => {
result.current.requestDiscardEntry({ path: 'src/app.ts' } as never)
result.current.requestDiscardEntry(entry('src/app.ts'))
})
await act(async () => {
result.current.confirmPendingDiscard()
@@ -172,7 +181,7 @@ describe('source-control entry mutation failures', () => {
const { result } = renderDiscard(discardSingle)
await act(async () => {
result.current.requestDiscardEntry({ path: 'new.ts', status: 'untracked' } as never)
result.current.requestDiscardEntry(entry('new.ts', 'untracked', 'untracked'))
})
await act(async () => {
result.current.confirmPendingDiscard()