Files
orca/src/renderer/src/components/sidebar/WorktreeContextMenu.test.ts
T

237 lines
8.9 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import {
hasSleepableWorkspaceActivity,
isContextWorktreeDeletable,
shouldUseNativeContextMenu,
shouldIgnoreNestedWorktreeContextMenuScope,
shouldRemoveProjectFromContextMenu,
shouldSuppressContextMenuFollowUpClick,
shouldContinueDeleteSiblingPositionRestore,
getWorktreeParentPickerAnchor,
getWorktreeParentPickerLabel,
hasWorktreeParentLink,
isWorktreeParentPickerDisabled,
selectMenuScopedMap
} from './WorktreeContextMenu'
import type { Worktree, WorktreeLineage } from '../../../../shared/types'
describe('selectMenuScopedMap (delete-teardown re-render guard)', () => {
// Why: the closed menu wrapper must stay inert to delete teardown's high-churn
// set()s. The guard is referential stability — when closed, the selector must
// return the SAME `empty` reference even as the live map identity changes each
// teardown set(), so Zustand's Object.is equality short-circuits the subscription
// and the (common) closed wrapper does not re-render. These assertions pin that
// contract; if they regress, every visible card re-renders on every teardown set()
// and worktree-card hover popovers stall during a delete.
it('returns the stable empty sentinel when the menu is closed', () => {
const empty = Object.freeze({})
const liveA = { 'wt-1': ['pty-1'] }
const liveB = { 'wt-2': ['pty-2'] }
// Live map identity churns across teardown set()s, yet a closed wrapper keeps
// the same reference — no subscription wakeup.
expect(selectMenuScopedMap(false, liveA, empty)).toBe(empty)
expect(selectMenuScopedMap(false, liveB, empty)).toBe(empty)
expect(selectMenuScopedMap(false, liveA, empty)).toBe(selectMenuScopedMap(false, liveB, empty))
})
it('returns the live map synchronously once the menu is open', () => {
const empty = Object.freeze({})
const live = { 'wt-1': ['pty-1'] }
// The render where menuOpen flips true must read real data so menu items
// (sleep/delete/lineage) reflect live tabs/ptys/delete state.
expect(selectMenuScopedMap(true, live, empty)).toBe(live)
})
})
describe('shouldUseNativeContextMenu', () => {
it('uses the browser context menu for marked hovercard content', () => {
const target = {
closest: (selector: string) =>
selector === '[data-worktree-native-context-menu]' ? ({} as Element) : null
} as unknown as EventTarget
expect(shouldUseNativeContextMenu(target)).toBe(true)
})
it('uses the browser context menu for text nodes inside marked content', () => {
const target = {
parentElement: {
closest: (selector: string) =>
selector === '[data-worktree-native-context-menu]' ? ({} as Element) : null
}
} as unknown as EventTarget
expect(shouldUseNativeContextMenu(target)).toBe(true)
})
it('keeps the worktree context menu for unmarked targets', () => {
const target = {
closest: () => null
} as unknown as EventTarget
expect(shouldUseNativeContextMenu(target)).toBe(false)
})
})
describe('shouldIgnoreNestedWorktreeContextMenuScope', () => {
it('allows the context menu scope that owns the event target', () => {
const currentScope = {} as EventTarget
const target = {
closest: () => currentScope
} as unknown as EventTarget
expect(shouldIgnoreNestedWorktreeContextMenuScope(currentScope, target)).toBe(false)
})
it('ignores context menu events owned by a nested scope', () => {
const currentScope = {} as EventTarget
const nestedScope = {} as Element
const target = {
closest: () => nestedScope
} as unknown as EventTarget
expect(shouldIgnoreNestedWorktreeContextMenuScope(currentScope, target)).toBe(true)
})
it('ignores context menu events from text nodes inside a nested scope', () => {
const currentScope = {} as EventTarget
const nestedScope = {} as Element
const target = {
parentElement: {
closest: () => nestedScope
}
} as unknown as EventTarget
expect(shouldIgnoreNestedWorktreeContextMenuScope(currentScope, target)).toBe(true)
})
it('allows events from unscoped targets', () => {
const currentScope = {} as EventTarget
const target = {
closest: () => null
} as unknown as EventTarget
expect(shouldIgnoreNestedWorktreeContextMenuScope(currentScope, target)).toBe(false)
})
})
describe('shouldSuppressContextMenuFollowUpClick', () => {
it('suppresses the click emitted immediately after opening a context menu', () => {
expect(shouldSuppressContextMenuFollowUpClick(1_000, 1_050)).toBe(true)
})
it('does not suppress later unrelated clicks', () => {
expect(shouldSuppressContextMenuFollowUpClick(1_000, 1_700)).toBe(false)
})
it('does not suppress clicks that predate the context menu timestamp', () => {
expect(shouldSuppressContextMenuFollowUpClick(1_000, 999)).toBe(false)
})
})
describe('shouldContinueDeleteSiblingPositionRestore', () => {
it('stops once the delete row position has settled even when the row remains mounted', () => {
expect(
shouldContinueDeleteSiblingPositionRestore({
attempts: 6,
stableFrames: 6
})
).toBe(false)
})
})
describe('parent picker context menu affordance', () => {
it('offers unlink for valid inline-only legacy lineage after stable-update hydration', () => {
const parent = { id: 'repo::parent', instanceId: 'parent-instance' }
const lineage: WorktreeLineage = {
worktreeId: 'repo::child',
worktreeInstanceId: 'child-instance',
parentWorktreeId: parent.id,
parentWorktreeInstanceId: parent.instanceId,
origin: 'cli',
capture: { source: 'explicit-cli-flag', confidence: 'explicit' },
createdAt: 1
}
const child = {
id: lineage.worktreeId,
instanceId: lineage.worktreeInstanceId,
lineage
} as Worktree & { lineage: WorktreeLineage }
expect(hasWorktreeParentLink(child, {}, {})).toBe(true)
})
it('uses set/change labels based on valid parent presence', () => {
expect(getWorktreeParentPickerLabel(null)).toBe('Set Parent Worktree...')
expect(getWorktreeParentPickerLabel('parent-1')).toBe('Change Parent Worktree...')
})
it('disables the parent picker while deleting or without candidates', () => {
expect(isWorktreeParentPickerDisabled({ isDeleting: true, eligibleParentCount: 1 })).toBe(true)
expect(isWorktreeParentPickerDisabled({ isDeleting: false, eligibleParentCount: 0 })).toBe(true)
expect(isWorktreeParentPickerDisabled({ isDeleting: false, eligibleParentCount: 1 })).toBe(
false
)
})
it('snapshots the stable row anchor before the context menu closes', () => {
const card = { dataset: { worktreeDragId: 'child' } } as unknown as HTMLElement
const scope = {
closest: (selector: string) => (selector === '[data-worktree-drag-id]' ? card : null)
} as HTMLElement
expect(getWorktreeParentPickerAnchor(scope, 'child')).toBe(card)
})
it('uses the child scope instead of climbing to a different workspace drag row', () => {
const parentCard = { dataset: { worktreeDragId: 'parent' } } as unknown as HTMLElement
const scope = {
closest: (selector: string) => (selector === '[data-worktree-drag-id]' ? parentCard : null)
} as HTMLElement
expect(getWorktreeParentPickerAnchor(scope, 'child')).toBe(scope)
})
})
describe('hasSleepableWorkspaceActivity', () => {
it('treats preserved empty PTY arrays as slept, not live', () => {
expect(
hasSleepableWorkspaceActivity('wt-1', { 'wt-1': [{ id: 'tab-1' }] }, { 'tab-1': [] }, {})
).toBe(false)
})
it('detects live terminal and browser activity', () => {
expect(
hasSleepableWorkspaceActivity(
'wt-1',
{ 'wt-1': [{ id: 'tab-1' }] },
{ 'tab-1': ['pty-1'] },
{}
)
).toBe(true)
expect(hasSleepableWorkspaceActivity('wt-1', {}, {}, { 'wt-1': [{ id: 'browser-1' }] })).toBe(
true
)
})
})
describe('project removal from workspace context menus', () => {
it('routes primary workspace rows to project removal in non-repo grouped views', () => {
const gitRepo = { id: 'repo-1' }
const folderRepo = { id: 'folder-1' }
expect(shouldRemoveProjectFromContextMenu(gitRepo, { isMainWorktree: true })).toBe(true)
expect(shouldRemoveProjectFromContextMenu(folderRepo, { isMainWorktree: true })).toBe(true)
expect(shouldRemoveProjectFromContextMenu(gitRepo, { isMainWorktree: false })).toBe(false)
expect(shouldRemoveProjectFromContextMenu(null, { isMainWorktree: true })).toBe(false)
})
it('treats additional folder workspace rows as deletable workspace rows', () => {
const folderRepo = { kind: 'folder' as const }
expect(isContextWorktreeDeletable({ isMainWorktree: false }, folderRepo)).toBe(true)
expect(isContextWorktreeDeletable({ isMainWorktree: true }, folderRepo)).toBe(false)
expect(isContextWorktreeDeletable({ isMainWorktree: false }, null)).toBe(false)
})
})