mirror of
https://github.com/stablyai/orca.git
synced 2026-11-14 01:47:24 +00:00
test: remove 164 low-value renderer store tests (#37638)
This commit is contained in:
@@ -1078,9 +1078,6 @@
|
||||
7 test-no-wall-clock:src/renderer/src/store/slices/linear-source-context-cache-scope.test.ts
|
||||
1 test-no-wall-clock:src/renderer/src/store/slices/store-sleep-agent-status-retention.test.ts
|
||||
2 test-no-wall-clock:src/renderer/src/store/slices/store-sleep-exact-runtime-stop.test.ts
|
||||
2 test-no-wall-clock:src/renderer/src/store/slices/workspace-cleanup-agent-stop-preflight.test.ts
|
||||
4 test-no-wall-clock:src/renderer/src/store/slices/workspace-cleanup-local-evidence-invariants.test.ts
|
||||
2 test-no-wall-clock:src/renderer/src/store/slices/workspace-cleanup-removal-preflight.test.ts
|
||||
1 test-no-wall-clock:src/renderer/src/store/slices/worktrees-always-force-delete.test.ts
|
||||
6 test-no-wall-clock:src/renderer/src/store/slices/worktrees-metadata-persistence.test.ts
|
||||
1 test-no-wall-clock:src/renderer/src/store/slices/worktrees-terminal-reference-origins.test.ts
|
||||
|
||||
@@ -11421,33 +11421,19 @@
|
||||
"src/renderer/src/store/slices/workspace-attention/workspace-unread-characterization.test.ts": 1974,
|
||||
"src/renderer/src/store/slices/workspace-attention/workspace-unread-flag-host-raises.test.ts": 1914,
|
||||
"src/renderer/src/store/slices/workspace-attention/workspace-unread-on-screen-characterization.test.ts": 1780,
|
||||
"src/renderer/src/store/slices/workspace-attention/workspace-unread-removal-characterization.test.ts": 2113,
|
||||
"src/renderer/src/store/slices/workspace-attention/workspace-unread-screen-moves-characterization.test.ts": 1884,
|
||||
"src/renderer/src/store/slices/workspace-chat-draft-removal.test.ts": 1896,
|
||||
"src/renderer/src/store/slices/workspace-cleanup-agent-stop-preflight.test.ts": 1752,
|
||||
"src/renderer/src/store/slices/workspace-cleanup-browse.test.ts": 69,
|
||||
"src/renderer/src/store/slices/workspace-cleanup-cache-hydration.test.ts": 815,
|
||||
"src/renderer/src/store/slices/workspace-cleanup-enrichment-performance.test.ts": 995,
|
||||
"src/renderer/src/store/slices/workspace-cleanup-host-qualified-list-state.test.ts": 1784,
|
||||
"src/renderer/src/store/slices/workspace-cleanup-local-evidence-invariants.test.ts": 681,
|
||||
"src/renderer/src/store/slices/workspace-cleanup-plain-shell-visit.test.ts": 632,
|
||||
"src/renderer/src/store/slices/workspace-cleanup-removal-preflight.test.ts": 750,
|
||||
"src/renderer/src/store/slices/workspace-cleanup-scan-progress-boundary.test.ts": 632,
|
||||
"src/renderer/src/store/slices/workspace-cleanup-scan-progress.test.ts": 1207,
|
||||
"src/renderer/src/store/slices/workspace-cleanup-unverified-removal-consent.test.ts": 639,
|
||||
"src/renderer/src/store/slices/workspace-cleanup-wrong-host-removal-guard.test.ts": 1800,
|
||||
"src/renderer/src/store/slices/workspace-cleanup-wrong-host-removal.test.ts": 1909,
|
||||
"src/renderer/src/store/slices/workspace-document-title-refresh.test.ts": 1630,
|
||||
"src/renderer/src/store/slices/workspace-space.test.ts": 49,
|
||||
"src/renderer/src/store/slices/worktree-catalog-reconciliation.test.ts": 39,
|
||||
"src/renderer/src/store/slices/worktree-helpers.test.ts": 94,
|
||||
"src/renderer/src/store/slices/worktree-listing-branch-switch.test.ts": 48,
|
||||
"src/renderer/src/store/slices/worktree-meta-update-application.test.ts": 96,
|
||||
"src/renderer/src/store/slices/worktree-nav-history-view-entries.test.ts": 107,
|
||||
"src/renderer/src/store/slices/worktree-nav-history.test.ts": 112,
|
||||
"src/renderer/src/store/slices/worktree-removal-maps-leak.test.ts": 1643,
|
||||
"src/renderer/src/store/slices/worktree-sleep-intent-lifecycle.test.ts": 1764,
|
||||
"src/renderer/src/store/slices/worktree-terminal-removal-teardown.test.ts": 1786,
|
||||
"src/renderer/src/store/slices/worktree-visibility-owner-settings.test.ts": 323,
|
||||
"src/renderer/src/store/slices/worktree-visibility-settings-write.test.ts": 307,
|
||||
"src/renderer/src/store/slices/worktrees-activity-persistence.test.ts": 1305,
|
||||
|
||||
+4
-606
@@ -1,25 +1,16 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as WorktreeMetaPersistModule from '../worktrees/metadata/worktree-meta-persist'
|
||||
import { makeFolderWorkspace, makeWorktree } from '../worktrees-slice-test-fixtures'
|
||||
import { makeFolderWorkspace } from '../worktrees-slice-test-fixtures'
|
||||
import { folderWorkspaceKey } from '../../../../../shared/workspace-scope'
|
||||
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../../shared/constants'
|
||||
import '../../../../../shared/constants'
|
||||
import {
|
||||
addLiveTerminalTab,
|
||||
createAttentionStore,
|
||||
installAttentionMockApi,
|
||||
LEAF_2,
|
||||
observe,
|
||||
WS_A,
|
||||
WS_B
|
||||
WS_A
|
||||
} from './workspace-unread-characterization-fixture'
|
||||
import {
|
||||
autoAckViewedTab,
|
||||
completeAgentTurn,
|
||||
pointerDownInTab,
|
||||
ringBell,
|
||||
toggleUnreadByUser,
|
||||
typeInPane
|
||||
} from './workspace-unread-characterization-driver'
|
||||
import { pointerDownInTab, ringBell, typeInPane } from './workspace-unread-characterization-driver'
|
||||
|
||||
// Pin marker/flag persistence, activity, Dock and floating-dot transitions using the production scan; default window is visible/focused.
|
||||
|
||||
@@ -43,599 +34,6 @@ afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('bells', () => {
|
||||
it('a hidden waiting tab keeps the dot after switching in and while typing in another tab (STA-9273)', () => {
|
||||
const { store, persists } = createAttentionStore()
|
||||
const first = addLiveTerminalTab(store, WS_A, 'tab-a1')
|
||||
const second = addLiveTerminalTab(store, WS_A, 'tab-a2', LEAF_2)
|
||||
observe(store, persists)
|
||||
|
||||
ringBell(store, first)
|
||||
expect(observe(store, persists)).toMatchInlineSnapshot(`
|
||||
[
|
||||
"rows a@local:UNREAD:1000 b@local:read:0 sortEpoch=1",
|
||||
"tabs tab-a1=terminal-bell",
|
||||
"panes -",
|
||||
"completions -",
|
||||
"dock=1 floatingDot=false",
|
||||
"persist worktree a {"isUnread":true,"lastActivityAt":1000}",
|
||||
]
|
||||
`)
|
||||
|
||||
vi.setSystemTime(2000)
|
||||
store.getState().setActiveWorktree(WS_A)
|
||||
expect(observe(store, persists)).toMatchInlineSnapshot(`
|
||||
[
|
||||
"rows a@local:UNREAD:1000 b@local:read:0 sortEpoch=1",
|
||||
"tabs tab-a1=terminal-bell",
|
||||
"panes -",
|
||||
"completions -",
|
||||
"dock=1 floatingDot=false",
|
||||
]
|
||||
`)
|
||||
|
||||
typeInPane(store, second)
|
||||
expect(observe(store, persists)).toMatchInlineSnapshot(`
|
||||
[
|
||||
"rows a@local:UNREAD:1000 b@local:read:0 sortEpoch=1",
|
||||
"tabs tab-a1=terminal-bell",
|
||||
"panes -",
|
||||
"completions -",
|
||||
"dock=1 floatingDot=false",
|
||||
]
|
||||
`)
|
||||
|
||||
vi.setSystemTime(3000)
|
||||
ringBell(store, first)
|
||||
expect(observe(store, persists)).toMatchInlineSnapshot(`
|
||||
[
|
||||
"rows a@local:UNREAD:1000 b@local:read:0 sortEpoch=1",
|
||||
"tabs tab-a1=terminal-bell",
|
||||
"panes -",
|
||||
"completions -",
|
||||
"dock=1 floatingDot=false",
|
||||
]
|
||||
`)
|
||||
})
|
||||
|
||||
it('a bell in the pane on screen in a focused window leaves no marker and no flag write', () => {
|
||||
const { store, persists } = createAttentionStore({ experimental: true })
|
||||
const pane = addLiveTerminalTab(store, WS_A, 'tab-a1')
|
||||
store.getState().setActiveWorktree(WS_A)
|
||||
observe(store, persists)
|
||||
|
||||
ringBell(store, pane)
|
||||
expect(observe(store, persists)).toMatchInlineSnapshot(`
|
||||
[
|
||||
"rows a@local:read:0 b@local:read:0 sortEpoch=0",
|
||||
"tabs -",
|
||||
"panes -",
|
||||
"completions -",
|
||||
"dock=0 floatingDot=false",
|
||||
]
|
||||
`)
|
||||
|
||||
pointerDownInTab(store, { ...pane, paneKey: null })
|
||||
expect(observe(store, persists)).toMatchInlineSnapshot(`
|
||||
[
|
||||
"rows a@local:read:0 b@local:read:0 sortEpoch=0",
|
||||
"tabs -",
|
||||
"panes -",
|
||||
"completions -",
|
||||
"dock=0 floatingDot=false",
|
||||
]
|
||||
`)
|
||||
|
||||
typeInPane(store, pane)
|
||||
expect(observe(store, persists)).toMatchInlineSnapshot(`
|
||||
[
|
||||
"rows a@local:read:0 b@local:read:0 sortEpoch=0",
|
||||
"tabs -",
|
||||
"panes -",
|
||||
"completions -",
|
||||
"dock=0 floatingDot=false",
|
||||
]
|
||||
`)
|
||||
})
|
||||
|
||||
it('bell on a tab no workspace owns lights the workspace but no tab marker', () => {
|
||||
const { store, persists } = createAttentionStore({ experimental: true })
|
||||
observe(store, persists)
|
||||
|
||||
ringBell(store, { worktreeId: WS_A, tabId: 'orphan-tab', paneKey: 'orphan-tab:leaf' })
|
||||
expect(observe(store, persists)).toMatchInlineSnapshot(`
|
||||
[
|
||||
"rows a@local:UNREAD:1000 b@local:read:0 sortEpoch=1",
|
||||
"tabs -",
|
||||
"panes orphan-tab:leaf=terminal-bell",
|
||||
"completions -",
|
||||
"dock=1 floatingDot=false",
|
||||
"persist worktree a {"isUnread":true,"lastActivityAt":1000}",
|
||||
]
|
||||
`)
|
||||
})
|
||||
|
||||
it('activating a tab in the active workspace reads every marker of it and settles the flag', () => {
|
||||
const { store, persists } = createAttentionStore({ experimental: true })
|
||||
const first = addLiveTerminalTab(store, WS_A, 'tab-a1')
|
||||
const second = addLiveTerminalTab(store, WS_A, 'tab-a2', LEAF_2)
|
||||
store.getState().setActiveWorktree(WS_A)
|
||||
ringBell(store, first)
|
||||
observe(store, persists)
|
||||
|
||||
store.getState().setActiveTab(first.tabId)
|
||||
expect(observe(store, persists)).toMatchInlineSnapshot(`
|
||||
[
|
||||
"rows a@local:read:1000 b@local:read:0 sortEpoch=1",
|
||||
"tabs -",
|
||||
"panes -",
|
||||
"completions -",
|
||||
"dock=0 floatingDot=false",
|
||||
"persist worktree a {"isUnread":false}",
|
||||
]
|
||||
`)
|
||||
|
||||
ringBell(store, second)
|
||||
store.getState().activateTab(`unified-${second.tabId}`)
|
||||
expect(observe(store, persists)).toMatchInlineSnapshot(`
|
||||
[
|
||||
"rows a@local:read:1000 b@local:read:0 sortEpoch=2",
|
||||
"tabs -",
|
||||
"panes -",
|
||||
"completions -",
|
||||
"dock=0 floatingDot=false",
|
||||
"persist worktree a {"isUnread":true,"lastActivityAt":1000}",
|
||||
"persist worktree a {"isUnread":false}",
|
||||
]
|
||||
`)
|
||||
})
|
||||
|
||||
it('tab activation in a background workspace keeps the tab marker', () => {
|
||||
const { store, persists } = createAttentionStore()
|
||||
const pane = addLiveTerminalTab(store, WS_A, 'tab-a1')
|
||||
ringBell(store, pane)
|
||||
observe(store, persists)
|
||||
|
||||
store.getState().setActiveTab(pane.tabId)
|
||||
store.getState().activateTab(`unified-${pane.tabId}`)
|
||||
expect(observe(store, persists)).toMatchInlineSnapshot(`
|
||||
[
|
||||
"rows a@local:UNREAD:1000 b@local:read:0 sortEpoch=1",
|
||||
"tabs tab-a1=terminal-bell",
|
||||
"panes -",
|
||||
"completions -",
|
||||
"dock=1 floatingDot=false",
|
||||
]
|
||||
`)
|
||||
})
|
||||
|
||||
it('floating terminal bell', () => {
|
||||
const { store, persists } = createAttentionStore()
|
||||
store.getState().setFloatingWorkspacePath('/home/test/floating')
|
||||
const pane = addLiveTerminalTab(store, FLOATING_TERMINAL_WORKTREE_ID, 'tab-float')
|
||||
observe(store, persists)
|
||||
|
||||
const before = store.getState()
|
||||
ringBell(store, pane)
|
||||
expect(store.getState() === before).toBe(false)
|
||||
expect(observe(store, persists)).toMatchInlineSnapshot(`
|
||||
[
|
||||
"rows a@local:read:0 b@local:read:0 sortEpoch=0",
|
||||
"tabs tab-float=terminal-bell",
|
||||
"panes -",
|
||||
"completions -",
|
||||
"dock=0 floatingDot=true",
|
||||
"persist worktree global-floating-terminal {"isUnread":true,"lastActivityAt":1000}",
|
||||
]
|
||||
`)
|
||||
|
||||
ringBell(store, pane)
|
||||
expect(observe(store, persists)).toMatchInlineSnapshot(`
|
||||
[
|
||||
"rows a@local:read:0 b@local:read:0 sortEpoch=0",
|
||||
"tabs tab-float=terminal-bell",
|
||||
"panes -",
|
||||
"completions -",
|
||||
"dock=0 floatingDot=true",
|
||||
"persist worktree global-floating-terminal {"isUnread":true,"lastActivityAt":1000}",
|
||||
]
|
||||
`)
|
||||
|
||||
typeInPane(store, pane)
|
||||
expect(observe(store, persists)).toMatchInlineSnapshot(`
|
||||
[
|
||||
"rows a@local:read:0 b@local:read:0 sortEpoch=0",
|
||||
"tabs tab-float=terminal-bell",
|
||||
"panes -",
|
||||
"completions -",
|
||||
"dock=0 floatingDot=true",
|
||||
]
|
||||
`)
|
||||
|
||||
store.setState({
|
||||
settings: { ...store.getState().settings!, floatingTerminalEnabled: true },
|
||||
floatingWorkspacePanelOpen: true
|
||||
})
|
||||
expect(observe(store, persists)).toMatchInlineSnapshot(`
|
||||
[
|
||||
"rows a@local:read:0 b@local:read:0 sortEpoch=0",
|
||||
"tabs -",
|
||||
"panes -",
|
||||
"completions -",
|
||||
"dock=0 floatingDot=false",
|
||||
]
|
||||
`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('agent completions', () => {
|
||||
function viewedThenHiddenCompletion(experimental: boolean) {
|
||||
const { store, persists } = createAttentionStore({ experimental })
|
||||
const viewed = addLiveTerminalTab(store, WS_A, 'tab-a1')
|
||||
const hidden = addLiveTerminalTab(store, WS_A, 'tab-a2', LEAF_2)
|
||||
store.getState().setActiveWorktree(WS_A)
|
||||
store.getState().activateTab(`unified-${viewed.tabId}`)
|
||||
observe(store, persists)
|
||||
completeAgentTurn(store, viewed)
|
||||
const afterViewed = observe(store, persists)
|
||||
completeAgentTurn(store, hidden)
|
||||
return { afterViewed, afterHidden: observe(store, persists) }
|
||||
}
|
||||
|
||||
it('hidden vs viewed completion, experimental off', () => {
|
||||
const { afterViewed, afterHidden } = viewedThenHiddenCompletion(false)
|
||||
expect(afterViewed).toMatchInlineSnapshot(`
|
||||
[
|
||||
"rows a@local:read:0 b@local:read:0 sortEpoch=0",
|
||||
"tabs -",
|
||||
"panes -",
|
||||
"completions -",
|
||||
"dock=0 floatingDot=false",
|
||||
]
|
||||
`)
|
||||
expect(afterHidden).toMatchInlineSnapshot(`
|
||||
[
|
||||
"rows a@local:UNREAD:1000 b@local:read:0 sortEpoch=1",
|
||||
"tabs -",
|
||||
"panes -",
|
||||
"completions tab-a2:L2=agent-completion",
|
||||
"dock=1 floatingDot=false",
|
||||
"persist worktree a {"isUnread":true,"lastActivityAt":1000}",
|
||||
]
|
||||
`)
|
||||
})
|
||||
|
||||
it('hidden vs viewed completion, experimental on', () => {
|
||||
const { afterViewed, afterHidden } = viewedThenHiddenCompletion(true)
|
||||
expect(afterViewed).toMatchInlineSnapshot(`
|
||||
[
|
||||
"rows a@local:read:0 b@local:read:0 sortEpoch=0",
|
||||
"tabs -",
|
||||
"panes -",
|
||||
"completions -",
|
||||
"dock=0 floatingDot=false",
|
||||
]
|
||||
`)
|
||||
expect(afterHidden).toMatchInlineSnapshot(`
|
||||
[
|
||||
"rows a@local:UNREAD:1000 b@local:read:0 sortEpoch=1",
|
||||
"tabs tab-a2=agent-completion",
|
||||
"panes tab-a2:L2=agent-completion",
|
||||
"completions tab-a2:L2=agent-completion",
|
||||
"dock=1 floatingDot=false",
|
||||
"persist worktree a {"isUnread":true,"lastActivityAt":1000}",
|
||||
]
|
||||
`)
|
||||
})
|
||||
|
||||
it('completion with no subject lights only the workspace of a background workspace', () => {
|
||||
const { store, persists } = createAttentionStore({ experimental: true })
|
||||
addLiveTerminalTab(store, WS_A, 'tab-a1')
|
||||
observe(store, persists)
|
||||
|
||||
completeAgentTurn(store, { worktreeId: WS_A, paneKey: null })
|
||||
expect(observe(store, persists)).toMatchInlineSnapshot(`
|
||||
[
|
||||
"rows a@local:UNREAD:1000 b@local:read:0 sortEpoch=1",
|
||||
"tabs -",
|
||||
"panes -",
|
||||
"completions -",
|
||||
"dock=1 floatingDot=false",
|
||||
"persist worktree a {"isUnread":true,"lastActivityAt":1000}",
|
||||
]
|
||||
`)
|
||||
|
||||
store.getState().setActiveWorktree(WS_A)
|
||||
observe(store, persists)
|
||||
completeAgentTurn(store, { worktreeId: WS_A, paneKey: null })
|
||||
expect(observe(store, persists)).toMatchInlineSnapshot(`
|
||||
[
|
||||
"rows a@local:read:1000 b@local:read:0 sortEpoch=1",
|
||||
"tabs -",
|
||||
"panes -",
|
||||
"completions -",
|
||||
"dock=0 floatingDot=false",
|
||||
]
|
||||
`)
|
||||
})
|
||||
|
||||
it('the workspace clears only once no sibling marker remains', () => {
|
||||
const { store, persists } = createAttentionStore()
|
||||
const first = addLiveTerminalTab(store, WS_A, 'tab-a1')
|
||||
const second = addLiveTerminalTab(store, WS_A, 'tab-a2', LEAF_2)
|
||||
store.getState().setActiveWorktree(WS_A)
|
||||
const unifiedOf = (tabId: string) => `unified-${tabId}`
|
||||
store.getState().activateTab(unifiedOf(second.tabId))
|
||||
completeAgentTurn(store, first)
|
||||
store.getState().activateTab(unifiedOf(first.tabId))
|
||||
completeAgentTurn(store, second)
|
||||
expect(observe(store, persists)).toMatchInlineSnapshot(`
|
||||
[
|
||||
"rows a@local:UNREAD:1000 b@local:read:0 sortEpoch=2",
|
||||
"tabs -",
|
||||
"panes -",
|
||||
"completions tab-a2:L2=agent-completion",
|
||||
"dock=1 floatingDot=false",
|
||||
"persist worktree a {"isUnread":true,"lastActivityAt":1000}",
|
||||
"persist worktree a {"isUnread":false}",
|
||||
"persist worktree a {"isUnread":true,"lastActivityAt":1000}",
|
||||
]
|
||||
`)
|
||||
|
||||
autoAckViewedTab(store, first)
|
||||
expect(observe(store, persists)).toMatchInlineSnapshot(`
|
||||
[
|
||||
"rows a@local:UNREAD:1000 b@local:read:0 sortEpoch=2",
|
||||
"tabs -",
|
||||
"panes -",
|
||||
"completions tab-a2:L2=agent-completion",
|
||||
"dock=1 floatingDot=false",
|
||||
]
|
||||
`)
|
||||
|
||||
store.getState().activateTab(unifiedOf(second.tabId))
|
||||
autoAckViewedTab(store, second)
|
||||
expect(observe(store, persists)).toMatchInlineSnapshot(`
|
||||
[
|
||||
"rows a@local:read:1000 b@local:read:0 sortEpoch=2",
|
||||
"tabs -",
|
||||
"panes -",
|
||||
"completions -",
|
||||
"dock=0 floatingDot=false",
|
||||
"persist worktree a {"isUnread":false}",
|
||||
]
|
||||
`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('manual, inbound and restart', () => {
|
||||
it('Mark as unread / read on host A of two same-id host rows', async () => {
|
||||
const { store, persists } = createAttentionStore()
|
||||
store.setState({
|
||||
repos: [
|
||||
{
|
||||
id: 'repo1',
|
||||
path: '/repo1',
|
||||
displayName: 'Local',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
executionHostId: 'local'
|
||||
},
|
||||
{
|
||||
id: 'repo1',
|
||||
path: '/repo1',
|
||||
displayName: 'SSH',
|
||||
badgeColor: '#111',
|
||||
addedAt: 1,
|
||||
connectionId: 'ssh-1'
|
||||
}
|
||||
],
|
||||
worktreesByRepo: {
|
||||
repo1: [
|
||||
makeWorktree({ id: WS_A, repoId: 'repo1', path: '/path/a', hostId: 'local' }),
|
||||
makeWorktree({ id: WS_A, repoId: 'repo1', path: '/path/a', hostId: 'ssh:ssh-1' })
|
||||
]
|
||||
}
|
||||
})
|
||||
observe(store, persists)
|
||||
|
||||
toggleUnreadByUser(store, store.getState().worktreesByRepo.repo1[0]!)
|
||||
await Promise.resolve()
|
||||
expect(observe(store, persists)).toMatchInlineSnapshot(`
|
||||
[
|
||||
"rows a@local:UNREAD:0 a@ssh:ssh-1:read:0 sortEpoch=1",
|
||||
"tabs -",
|
||||
"panes -",
|
||||
"completions -",
|
||||
"dock=1 floatingDot=false",
|
||||
"persist worktree a {"isUnread":true} host=local",
|
||||
]
|
||||
`)
|
||||
|
||||
toggleUnreadByUser(store, store.getState().worktreesByRepo.repo1[0]!)
|
||||
await Promise.resolve()
|
||||
expect(observe(store, persists)).toMatchInlineSnapshot(`
|
||||
[
|
||||
"rows a@local:read:0 a@ssh:ssh-1:read:0 sortEpoch=2",
|
||||
"tabs -",
|
||||
"panes -",
|
||||
"completions -",
|
||||
"dock=0 floatingDot=false",
|
||||
"persist worktree a {"isUnread":false} host=local",
|
||||
]
|
||||
`)
|
||||
})
|
||||
|
||||
it('inbound refetch overwrites the flag and leaves markers', () => {
|
||||
const { store, persists } = createAttentionStore({ experimental: true })
|
||||
const pane = addLiveTerminalTab(store, WS_A, 'tab-a1')
|
||||
ringBell(store, pane)
|
||||
observe(store, persists)
|
||||
const rowsWith = (isUnread: boolean) => ({
|
||||
worktreesByRepo: {
|
||||
repo1: store
|
||||
.getState()
|
||||
.worktreesByRepo.repo1.map((row) => (row.id === WS_A ? { ...row, isUnread } : row))
|
||||
}
|
||||
})
|
||||
|
||||
store.setState(rowsWith(false))
|
||||
expect(observe(store, persists)).toMatchInlineSnapshot(`
|
||||
[
|
||||
"rows a@local:read:1000 b@local:read:0 sortEpoch=1",
|
||||
"tabs tab-a1=terminal-bell",
|
||||
"panes tab-a1:L1=terminal-bell",
|
||||
"completions -",
|
||||
"dock=0 floatingDot=false",
|
||||
]
|
||||
`)
|
||||
|
||||
ringBell(store, pane)
|
||||
expect(observe(store, persists)).toMatchInlineSnapshot(`
|
||||
[
|
||||
"rows a@local:UNREAD:1000 b@local:read:0 sortEpoch=2",
|
||||
"tabs tab-a1=terminal-bell",
|
||||
"panes tab-a1:L1=terminal-bell",
|
||||
"completions -",
|
||||
"dock=1 floatingDot=false",
|
||||
"persist worktree a {"isUnread":true,"lastActivityAt":1000}",
|
||||
]
|
||||
`)
|
||||
|
||||
typeInPane(store, pane)
|
||||
store.setState(rowsWith(true))
|
||||
expect(observe(store, persists)).toMatchInlineSnapshot(`
|
||||
[
|
||||
"rows a@local:UNREAD:1000 b@local:read:0 sortEpoch=2",
|
||||
"tabs tab-a1=terminal-bell",
|
||||
"panes tab-a1:L1=terminal-bell",
|
||||
"completions -",
|
||||
"dock=1 floatingDot=false",
|
||||
]
|
||||
`)
|
||||
})
|
||||
|
||||
it('restart: persisted flags with no markers, worktree and folder Dock count', () => {
|
||||
const { store, persists } = createAttentionStore()
|
||||
store.setState({
|
||||
worktreesByRepo: {
|
||||
repo1: [makeWorktree({ id: WS_A, repoId: 'repo1', path: '/path/a', isUnread: true })]
|
||||
},
|
||||
projectGroups: [
|
||||
{
|
||||
id: 'group-1',
|
||||
name: 'platform',
|
||||
parentPath: '/work',
|
||||
parentGroupId: null,
|
||||
createdFrom: 'manual',
|
||||
tabOrder: 0,
|
||||
isCollapsed: false,
|
||||
color: null,
|
||||
createdAt: 0,
|
||||
updatedAt: 0
|
||||
}
|
||||
],
|
||||
folderWorkspaces: [makeFolderWorkspace({ isUnread: true })]
|
||||
})
|
||||
expect(observe(store, persists)).toMatchInlineSnapshot(`
|
||||
[
|
||||
"rows a@local:UNREAD:0 folder-1@local:UNREAD:0 sortEpoch=0",
|
||||
"tabs -",
|
||||
"panes -",
|
||||
"completions -",
|
||||
"dock=1 floatingDot=false",
|
||||
]
|
||||
`)
|
||||
|
||||
const folderKey = folderWorkspaceKey('folder-1')
|
||||
const folderPane = addLiveTerminalTab(store, folderKey, 'tab-f1')
|
||||
ringBell(store, folderPane)
|
||||
expect(observe(store, persists)).toMatchInlineSnapshot(`
|
||||
[
|
||||
"rows a@local:UNREAD:0 folder-1@local:UNREAD:0 sortEpoch=0",
|
||||
"tabs tab-f1=terminal-bell",
|
||||
"panes -",
|
||||
"completions -",
|
||||
"dock=2 floatingDot=false",
|
||||
]
|
||||
`)
|
||||
|
||||
vi.setSystemTime(2000)
|
||||
store.getState().setActiveWorktree(folderKey, 'local')
|
||||
expect(observe(store, persists)).toMatchInlineSnapshot(`
|
||||
[
|
||||
"rows a@local:UNREAD:0 folder-1@local:read:0 sortEpoch=0",
|
||||
"tabs -",
|
||||
"panes -",
|
||||
"completions -",
|
||||
"dock=1 floatingDot=false",
|
||||
"persist folder folder-1 {"isUnread":false} host=local",
|
||||
]
|
||||
`)
|
||||
|
||||
ringBell(store, folderPane)
|
||||
typeInPane(store, folderPane)
|
||||
expect(observe(store, persists)).toMatchInlineSnapshot(`
|
||||
[
|
||||
"rows a@local:UNREAD:0 folder-1@local:read:0 sortEpoch=0",
|
||||
"tabs -",
|
||||
"panes -",
|
||||
"completions -",
|
||||
"dock=1 floatingDot=false",
|
||||
]
|
||||
`)
|
||||
})
|
||||
|
||||
it('folder Dock counts flag AND live tab marker; a completion marker alone does not count', () => {
|
||||
const { store, persists } = createAttentionStore()
|
||||
store.setState({
|
||||
projectGroups: [
|
||||
{
|
||||
id: 'group-1',
|
||||
name: 'platform',
|
||||
parentPath: '/work',
|
||||
parentGroupId: null,
|
||||
createdFrom: 'manual',
|
||||
tabOrder: 0,
|
||||
isCollapsed: false,
|
||||
color: null,
|
||||
createdAt: 0,
|
||||
updatedAt: 0
|
||||
}
|
||||
],
|
||||
folderWorkspaces: [makeFolderWorkspace()]
|
||||
})
|
||||
const folderKey = folderWorkspaceKey('folder-1')
|
||||
const folderPane = addLiveTerminalTab(store, folderKey, 'tab-f1')
|
||||
observe(store, persists)
|
||||
|
||||
completeAgentTurn(store, folderPane)
|
||||
expect(observe(store, persists)).toMatchInlineSnapshot(`
|
||||
[
|
||||
"rows a@local:read:0 b@local:read:0 folder-1@local:UNREAD:1000 sortEpoch=1",
|
||||
"tabs -",
|
||||
"panes -",
|
||||
"completions tab-f1:L1=agent-completion",
|
||||
"dock=0 floatingDot=false",
|
||||
"persist folder folder-1 {"isUnread":true,"lastActivityAt":1000} host=local",
|
||||
]
|
||||
`)
|
||||
|
||||
store.getState().setActiveWorktree(WS_B)
|
||||
vi.setSystemTime(5000)
|
||||
ringBell(store, folderPane)
|
||||
expect(observe(store, persists)).toMatchInlineSnapshot(`
|
||||
[
|
||||
"rows a@local:read:0 b@local:read:0 folder-1@local:UNREAD:1000 sortEpoch=1",
|
||||
"tabs tab-f1=terminal-bell",
|
||||
"panes -",
|
||||
"completions tab-f1:L1=agent-completion",
|
||||
"dock=1 floatingDot=false",
|
||||
]
|
||||
`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hot-path no-ops keep the store reference', () => {
|
||||
it('a keystroke on a read workspace and a repeat bell notify nobody', () => {
|
||||
const { store, persists } = createAttentionStore({ experimental: true })
|
||||
|
||||
+1
-12
@@ -8,7 +8,7 @@ import { createTestStore, makeWorktree, TEST_REPO } from '../store-test-helpers'
|
||||
import { makeFolderWorkspace } from '../worktrees-slice-test-fixtures'
|
||||
import { markWorkspaceUnreadFlag } from './workspace-unread-flag'
|
||||
|
||||
import { runtimeEnvironmentSupportsCapability } from '@/runtime/runtime-rpc-client'
|
||||
import '@/runtime/runtime-rpc-client'
|
||||
|
||||
// With the switch on, the window raises the flag exactly where the owning host does not.
|
||||
|
||||
@@ -90,17 +90,6 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
describe('the window raises the unread flag only where its host does not', () => {
|
||||
it('answers from the cached status without probing again', async () => {
|
||||
const { mark, isUnread } = createStore()
|
||||
cachedStatus.set('slow', [WORKSPACE_UNREAD_HOST_RAISES_CAPABILITY])
|
||||
await mark(SLOW_SERVER_WT)
|
||||
expect(isUnread(SLOW_SERVER_WT)).toBe(false)
|
||||
cachedStatus.set('slow', [])
|
||||
await mark(SLOW_SERVER_WT)
|
||||
expect(isUnread(SLOW_SERVER_WT)).toBe(true)
|
||||
expect(runtimeEnvironmentSupportsCapability).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('waits for a server’s first status, and raises it when the status cannot be read', async () => {
|
||||
const { mark, isUnread } = createStore()
|
||||
let answer: (capabilities: readonly string[]) => void = () => {}
|
||||
|
||||
+5
-296
@@ -1,31 +1,15 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as WorktreeMetaPersistModule from '../worktrees/metadata/worktree-meta-persist'
|
||||
import { structuredAgentSessionPaneKey } from '../../../../../shared/agent-session/view/structured-agent-session-projection'
|
||||
import {
|
||||
registerAgentSubjectReadCapture,
|
||||
subscribeAgentSubjectReads,
|
||||
type AgentSubjectRead,
|
||||
type AgentSubjectReadIntent,
|
||||
type StructuredSubjectRead
|
||||
} from '@/attention/agent-subject-read-actions'
|
||||
import { makeWorktree } from '../worktrees-slice-test-fixtures'
|
||||
import {
|
||||
completeAgentTurn,
|
||||
pointerDownInTab,
|
||||
ringBell,
|
||||
setWindowOnScreen,
|
||||
toggleUnreadByUser,
|
||||
typeInPane
|
||||
} from './workspace-unread-characterization-driver'
|
||||
import '../../../../../shared/agent-session/view/structured-agent-session-projection'
|
||||
import '@/attention/agent-subject-read-actions'
|
||||
import '../worktrees-slice-test-fixtures'
|
||||
import { ringBell, setWindowOnScreen } from './workspace-unread-characterization-driver'
|
||||
import {
|
||||
addLiveTerminalTab,
|
||||
createAttentionStore,
|
||||
installAttentionMockApi,
|
||||
LEAF_1,
|
||||
LEAF_2,
|
||||
observe,
|
||||
WS_A,
|
||||
type AttentionStore
|
||||
WS_A
|
||||
} from './workspace-unread-characterization-fixture'
|
||||
|
||||
// On screen means read, the flag follows its markers, and reads retire OS notifications.
|
||||
@@ -52,26 +36,6 @@ afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
/** A tab split into LEAF_1 | LEAF_2. */
|
||||
function splitTab(store: AttentionStore, tabId: string): void {
|
||||
const s = store.getState()
|
||||
store.setState({
|
||||
terminalLayoutsByTabId: {
|
||||
...s.terminalLayoutsByTabId,
|
||||
[tabId]: {
|
||||
root: {
|
||||
type: 'split',
|
||||
direction: 'vertical',
|
||||
first: { type: 'leaf', leafId: LEAF_1 },
|
||||
second: { type: 'leaf', leafId: LEAF_2 }
|
||||
},
|
||||
activeLeafId: LEAF_1,
|
||||
expandedLeafId: null
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const dismissedPaneKeys = (): string[][] =>
|
||||
dismiss.mock.calls.map((call) => [...(call[1] ?? [])].sort())
|
||||
|
||||
@@ -108,259 +72,4 @@ describe('on screen means read', () => {
|
||||
`)
|
||||
expect(dismissedPaneKeys()).toEqual([[pane.paneKey]])
|
||||
})
|
||||
|
||||
it('a bell in the pane on screen of a focused window leaves no marker and writes no flag', () => {
|
||||
const { store, persists } = createAttentionStore()
|
||||
const pane = addLiveTerminalTab(store, WS_A, 'tab-a1')
|
||||
store.getState().setActiveWorktree(WS_A)
|
||||
observe(store, persists)
|
||||
|
||||
ringBell(store, pane)
|
||||
expect(observe(store, persists)).toMatchInlineSnapshot(`
|
||||
[
|
||||
"rows a@local:read:0 b@local:read:0 sortEpoch=0",
|
||||
"tabs -",
|
||||
"panes -",
|
||||
"completions -",
|
||||
"dock=0 floatingDot=false",
|
||||
]
|
||||
`)
|
||||
expect(dismiss).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('a completion in any split pane of the tab on screen leaves no marker and writes no flag', () => {
|
||||
const { store, persists } = createAttentionStore({ experimental: true })
|
||||
addLiveTerminalTab(store, WS_A, 'tab-a1')
|
||||
splitTab(store, 'tab-a1')
|
||||
store.getState().setActiveWorktree(WS_A)
|
||||
observe(store, persists)
|
||||
|
||||
completeAgentTurn(store, { worktreeId: WS_A, paneKey: `tab-a1:${LEAF_2}` })
|
||||
expect(observe(store, persists)).toMatchInlineSnapshot(`
|
||||
[
|
||||
"rows a@local:read:0 b@local:read:0 sortEpoch=0",
|
||||
"tabs -",
|
||||
"panes -",
|
||||
"completions -",
|
||||
"dock=0 floatingDot=false",
|
||||
]
|
||||
`)
|
||||
expect(dismiss).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('a tab-level bell dismisses every pane of its split tab; a pane completion only its pane', () => {
|
||||
const { store } = createAttentionStore()
|
||||
addLiveTerminalTab(store, WS_A, 'tab-a1')
|
||||
const hidden = addLiveTerminalTab(store, WS_A, 'tab-a2', LEAF_1)
|
||||
splitTab(store, 'tab-a2')
|
||||
store.getState().setActiveWorktree(WS_A)
|
||||
store.getState().activateTab('unified-tab-a1')
|
||||
|
||||
ringBell(store, hidden)
|
||||
completeAgentTurn(store, { worktreeId: WS_A, paneKey: `tab-a2:${LEAF_2}` })
|
||||
expect(dismiss).not.toHaveBeenCalled()
|
||||
|
||||
store.getState().activateTab('unified-tab-a2')
|
||||
expect(dismissedPaneKeys()).toEqual([[`tab-a2:${LEAF_1}`, `tab-a2:${LEAF_2}`]])
|
||||
})
|
||||
|
||||
it('no-op keystrokes and pointerdowns write nothing and send no dismiss', () => {
|
||||
const { store } = createAttentionStore()
|
||||
const pane = addLiveTerminalTab(store, WS_A, 'tab-a1')
|
||||
store.getState().setActiveWorktree(WS_A)
|
||||
const before = store.getState()
|
||||
|
||||
for (let i = 0; i < 20; i += 1) {
|
||||
typeInPane(store, pane)
|
||||
pointerDownInTab(store, { ...pane, paneKey: null })
|
||||
}
|
||||
expect(store.getState()).toBe(before)
|
||||
expect(dismiss).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('the flag follows its markers', () => {
|
||||
it('a restart flag with no marker is read when its workspace comes on screen', () => {
|
||||
const { store, persists } = createAttentionStore({ windowOnScreen: false })
|
||||
store.setState({
|
||||
worktreesByRepo: {
|
||||
repo1: [makeWorktree({ id: WS_A, repoId: 'repo1', path: '/path/a', isUnread: true })]
|
||||
}
|
||||
})
|
||||
addLiveTerminalTab(store, WS_A, 'tab-a1')
|
||||
store.getState().setActiveWorktree(WS_A)
|
||||
expect(observe(store, persists)).toMatchInlineSnapshot(`
|
||||
[
|
||||
"rows a@local:UNREAD:0 sortEpoch=0",
|
||||
"tabs -",
|
||||
"panes -",
|
||||
"completions -",
|
||||
"dock=1 floatingDot=false",
|
||||
]
|
||||
`)
|
||||
|
||||
setWindowOnScreen(store, true)
|
||||
expect(observe(store, persists)).toMatchInlineSnapshot(`
|
||||
[
|
||||
"rows a@local:read:0 sortEpoch=0",
|
||||
"tabs -",
|
||||
"panes -",
|
||||
"completions -",
|
||||
"dock=0 floatingDot=false",
|
||||
"persist worktree a {"isUnread":false}",
|
||||
]
|
||||
`)
|
||||
})
|
||||
|
||||
it('Mark as unread on the workspace on screen survives input until the screen moves', () => {
|
||||
const { store, persists } = createAttentionStore()
|
||||
const first = addLiveTerminalTab(store, WS_A, 'tab-a1')
|
||||
addLiveTerminalTab(store, WS_A, 'tab-a2', LEAF_2)
|
||||
store.getState().setActiveWorktree(WS_A)
|
||||
observe(store, persists)
|
||||
|
||||
toggleUnreadByUser(store, store.getState().worktreesByRepo.repo1[0]!)
|
||||
typeInPane(store, first)
|
||||
pointerDownInTab(store, { ...first, paneKey: null })
|
||||
expect(observe(store, persists)).toMatchInlineSnapshot(`
|
||||
[
|
||||
"rows a@local:UNREAD:0 b@local:read:0 sortEpoch=1",
|
||||
"tabs -",
|
||||
"panes -",
|
||||
"completions -",
|
||||
"dock=1 floatingDot=false",
|
||||
"persist worktree a {"isUnread":true} host=local",
|
||||
]
|
||||
`)
|
||||
|
||||
store.getState().activateTab('unified-tab-a1')
|
||||
expect(observe(store, persists)).toMatchInlineSnapshot(`
|
||||
[
|
||||
"rows a@local:read:0 b@local:read:0 sortEpoch=1",
|
||||
"tabs -",
|
||||
"panes -",
|
||||
"completions -",
|
||||
"dock=0 floatingDot=false",
|
||||
"persist worktree a {"isUnread":false}",
|
||||
]
|
||||
`)
|
||||
})
|
||||
|
||||
it('a superseded turn removing the last completion marker clears the flag', () => {
|
||||
const { store, persists } = createAttentionStore()
|
||||
const pane = addLiveTerminalTab(store, WS_A, 'tab-a1')
|
||||
completeAgentTurn(store, pane)
|
||||
observe(store, persists)
|
||||
|
||||
store
|
||||
.getState()
|
||||
.recordAgentProviderSession(
|
||||
pane.paneKey,
|
||||
'codex',
|
||||
{ key: 'session_id', id: 'session-a1' },
|
||||
{ updatedAt: 2000 },
|
||||
{ tabId: pane.tabId, worktreeId: WS_A }
|
||||
)
|
||||
expect(observe(store, persists)).toMatchInlineSnapshot(`
|
||||
[
|
||||
"rows a@local:read:1000 b@local:read:0 sortEpoch=1",
|
||||
"tabs -",
|
||||
"panes -",
|
||||
"completions -",
|
||||
"dock=0 floatingDot=false",
|
||||
"persist worktree a {"isUnread":false}",
|
||||
]
|
||||
`)
|
||||
expect(dismissedPaneKeys()).toEqual([[pane.paneKey]])
|
||||
})
|
||||
|
||||
it('Mark as read removes every marker in the workspace and dismisses their panes', () => {
|
||||
const { store, persists } = createAttentionStore({ experimental: true })
|
||||
const first = addLiveTerminalTab(store, WS_A, 'tab-a1')
|
||||
const second = addLiveTerminalTab(store, WS_A, 'tab-a2', LEAF_2)
|
||||
ringBell(store, first)
|
||||
completeAgentTurn(store, second)
|
||||
observe(store, persists)
|
||||
|
||||
toggleUnreadByUser(store, store.getState().worktreesByRepo.repo1[0]!)
|
||||
expect(observe(store, persists)).toMatchInlineSnapshot(`
|
||||
[
|
||||
"rows a@local:read:1000 b@local:read:0 sortEpoch=2",
|
||||
"tabs -",
|
||||
"panes -",
|
||||
"completions -",
|
||||
"dock=0 floatingDot=false",
|
||||
"persist worktree a {"isUnread":false} host=local",
|
||||
]
|
||||
`)
|
||||
expect(dismissedPaneKeys()).toEqual([[first.paneKey, second.paneKey]])
|
||||
})
|
||||
|
||||
it('Mark as read emits an explicit read for each cleared chat', () => {
|
||||
const { store } = createAttentionStore()
|
||||
const chat = store
|
||||
.getState()
|
||||
.createUnifiedTab(WS_A, 'agent-session', { id: 'chat-a1', entityId: 'session-1' })
|
||||
const paneKey = structuredAgentSessionPaneKey(chat.id, chat.entityId)
|
||||
store.getState().markAgentAttentionUnread({
|
||||
workspaceId: WS_A,
|
||||
subjectKey: paneKey,
|
||||
groupId: null,
|
||||
reason: 'agent-completion'
|
||||
})
|
||||
const structured: StructuredSubjectRead = {
|
||||
sessionId: 'session-1',
|
||||
target: { kind: 'local' },
|
||||
observedCursor: { epoch: 'epoch-1', sequence: 10 },
|
||||
observationKey: 'edge:epoch-1:10'
|
||||
}
|
||||
const intents: AgentSubjectReadIntent[] = []
|
||||
const emitted: AgentSubjectRead[] = []
|
||||
const stopCapture = registerAgentSubjectReadCapture(paneKey, (intent) => {
|
||||
intents.push(intent)
|
||||
return structured
|
||||
})
|
||||
const stopReads = subscribeAgentSubjectReads((reads) => emitted.push(...reads))
|
||||
|
||||
toggleUnreadByUser(store, store.getState().worktreesByRepo.repo1[0]!)
|
||||
stopReads()
|
||||
stopCapture()
|
||||
|
||||
expect(intents).toEqual(['explicit'])
|
||||
expect(emitted).toEqual([{ subjectKey: paneKey, structured }])
|
||||
})
|
||||
|
||||
it('closing an unmarked tab keeps a manual unread; closing a marked one dismisses its panes', () => {
|
||||
const { store, persists } = createAttentionStore()
|
||||
const marked = addLiveTerminalTab(store, WS_A, 'tab-a1')
|
||||
const unmarked = addLiveTerminalTab(store, WS_A, 'tab-a2', LEAF_2)
|
||||
toggleUnreadByUser(store, store.getState().worktreesByRepo.repo1[0]!)
|
||||
observe(store, persists)
|
||||
|
||||
store.getState().closeTab(unmarked.tabId)
|
||||
expect(observe(store, persists)).toMatchInlineSnapshot(`
|
||||
[
|
||||
"rows a@local:UNREAD:0 b@local:read:0 sortEpoch=1",
|
||||
"tabs -",
|
||||
"panes -",
|
||||
"completions -",
|
||||
"dock=1 floatingDot=false",
|
||||
]
|
||||
`)
|
||||
expect(dismiss).not.toHaveBeenCalled()
|
||||
|
||||
ringBell(store, marked)
|
||||
store.getState().closeTab(marked.tabId)
|
||||
expect(observe(store, persists)).toMatchInlineSnapshot(`
|
||||
[
|
||||
"rows a@local:read:0 b@local:read:0 sortEpoch=1",
|
||||
"tabs -",
|
||||
"panes -",
|
||||
"completions -",
|
||||
"dock=0 floatingDot=false",
|
||||
"persist worktree a {"isUnread":false}",
|
||||
]
|
||||
`)
|
||||
expect(dismissedPaneKeys()).toEqual([[marked.paneKey]])
|
||||
})
|
||||
})
|
||||
|
||||
-395
@@ -1,395 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as WorktreeMetaPersistModule from '../worktrees/metadata/worktree-meta-persist'
|
||||
import type * as AgentStatusModule from '@/lib/agent-status'
|
||||
import { structuredAgentSessionPaneKey } from '../../../../../shared/agent-session/view/structured-agent-session-projection'
|
||||
import { makeFolderWorkspace } from '../worktrees-slice-test-fixtures'
|
||||
import { folderWorkspaceKey } from '../../../../../shared/workspace-scope'
|
||||
import {
|
||||
applySleepRuntimeRpcDefault,
|
||||
createStoreCascadesMockApi
|
||||
} from '../store-cascades-test-harness'
|
||||
import {
|
||||
addLiveTerminalTab,
|
||||
createAttentionStore,
|
||||
LEAF_2,
|
||||
observe,
|
||||
WS_A,
|
||||
type AttentionStore,
|
||||
type PaneTarget
|
||||
} from './workspace-unread-characterization-fixture'
|
||||
import { completeAgentTurn, ringBell } from './workspace-unread-characterization-driver'
|
||||
import { drawTabsFromMain, type TabsDrawnFromMain } from '../tabs-drawn-from-main.test-fixture'
|
||||
|
||||
// Pins what removing a tab or pane does to unread: its markers go, and a workspace whose removed
|
||||
// marker was its last live one is read.
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn(), dismiss: vi.fn() }
|
||||
}))
|
||||
|
||||
vi.mock('@/components/terminal-pane/pty-dispatcher', () => ({
|
||||
restorePtyDataHandlersAfterFailedShutdown: vi.fn(),
|
||||
unregisterPtyDataHandlers: vi.fn(() => [])
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/agent-status', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof AgentStatusModule>()
|
||||
return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) }
|
||||
})
|
||||
|
||||
vi.mock('../worktrees/metadata/worktree-meta-persist', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof WorktreeMetaPersistModule>()
|
||||
return { ...actual, persistWorktreeMeta: vi.fn(async () => undefined) }
|
||||
})
|
||||
|
||||
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) =>
|
||||
(
|
||||
await import('@/runtime/layout-apply-test-transport.test-fixture')
|
||||
).runtimeRpcClientWithLayoutApply(await importOriginal())
|
||||
)
|
||||
vi.mock('@/runtime/runtime-layout-client', async (importOriginal) =>
|
||||
(
|
||||
await import('@/runtime/layout-apply-test-transport.test-fixture')
|
||||
).runtimeLayoutClientWithTestFrames(await importOriginal())
|
||||
)
|
||||
|
||||
let main: TabsDrawnFromMain | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers({ toFake: ['Date'] })
|
||||
vi.setSystemTime(1000)
|
||||
const api = createStoreCascadesMockApi()
|
||||
applySleepRuntimeRpcDefault(api)
|
||||
api.pty.kill.mockResolvedValue(undefined)
|
||||
Object.assign(api, { notifications: { dismiss: vi.fn().mockResolvedValue(undefined) } })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
main?.close()
|
||||
main = undefined
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
/** Two live tabs in background WS_A, each with every marker layer lit. */
|
||||
function seedLitWorkspace(): {
|
||||
store: AttentionStore
|
||||
persists: ReturnType<typeof createAttentionStore>['persists']
|
||||
first: PaneTarget
|
||||
second: PaneTarget
|
||||
} {
|
||||
const { store, persists } = createAttentionStore({ experimental: true })
|
||||
const first = addLiveTerminalTab(store, WS_A, 'tab-a1')
|
||||
const second = addLiveTerminalTab(store, WS_A, 'tab-a2', LEAF_2)
|
||||
for (const pane of [first, second]) {
|
||||
ringBell(store, pane)
|
||||
completeAgentTurn(store, pane)
|
||||
}
|
||||
observe(store, persists)
|
||||
return { store, persists, first, second }
|
||||
}
|
||||
|
||||
describe('closing tabs', () => {
|
||||
it('closing the only unread terminal tab removes its markers and the dot (STA-9043)', () => {
|
||||
const { store, persists } = createAttentionStore({ experimental: true })
|
||||
const pane = addLiveTerminalTab(store, WS_A, 'tab-a1')
|
||||
ringBell(store, pane)
|
||||
completeAgentTurn(store, pane)
|
||||
observe(store, persists)
|
||||
|
||||
store.getState().closeTab(pane.tabId)
|
||||
expect(observe(store, persists)).toMatchInlineSnapshot(`
|
||||
[
|
||||
"rows a@local:read:1000 b@local:read:0 sortEpoch=1",
|
||||
"tabs -",
|
||||
"panes -",
|
||||
"completions -",
|
||||
"dock=0 floatingDot=false",
|
||||
"persist worktree a {"isUnread":false}",
|
||||
]
|
||||
`)
|
||||
})
|
||||
|
||||
it('closing a terminal through its unified tab removes all of its markers', () => {
|
||||
const { store, persists, first } = seedLitWorkspace()
|
||||
|
||||
store.getState().closeUnifiedTab(`unified-${first.tabId}`)
|
||||
expect(observe(store, persists)).toMatchInlineSnapshot(`
|
||||
[
|
||||
"rows a@local:UNREAD:1000 b@local:read:0 sortEpoch=1",
|
||||
"tabs tab-a2=terminal-bell",
|
||||
"panes tab-a2:L2=terminal-bell",
|
||||
"completions tab-a2:L2=agent-completion",
|
||||
"dock=1 floatingDot=false",
|
||||
]
|
||||
`)
|
||||
})
|
||||
|
||||
it('a unified close whose terminal retirement is handled elsewhere removes only the tab marker', () => {
|
||||
const { store, persists, first } = seedLitWorkspace()
|
||||
|
||||
store.getState().closeUnifiedTab(`unified-${first.tabId}`, { terminalRetirementHandled: true })
|
||||
expect(observe(store, persists)).toMatchInlineSnapshot(`
|
||||
[
|
||||
"rows a@local:UNREAD:1000 b@local:read:0 sortEpoch=1",
|
||||
"tabs tab-a2=terminal-bell",
|
||||
"panes tab-a1:L1=terminal-bell tab-a2:L2=terminal-bell",
|
||||
"completions tab-a1:L1=agent-completion tab-a2:L2=agent-completion",
|
||||
"dock=1 floatingDot=false",
|
||||
]
|
||||
`)
|
||||
})
|
||||
|
||||
it('closing a structured chat tab removes its markers', async () => {
|
||||
const { store, persists } = createAttentionStore({ experimental: true })
|
||||
const chat = store.getState().createUnifiedTab(WS_A, 'agent-session', {
|
||||
id: 'chat-1',
|
||||
entityId: 'session-1'
|
||||
})
|
||||
const paneKey = structuredAgentSessionPaneKey(chat.id, chat.entityId)
|
||||
store.setState({
|
||||
unreadTerminalTabs: { [chat.id]: 'agent-completion' },
|
||||
unreadTerminalPanes: { [paneKey]: 'agent-completion' },
|
||||
unreadAgentCompletionPanes: { [paneKey]: 'agent-completion' }
|
||||
})
|
||||
main = await drawTabsFromMain(store)
|
||||
observe(store, persists)
|
||||
|
||||
store.getState().closeUnifiedTab(chat.id)
|
||||
await main.settle()
|
||||
expect(observe(store, persists)).toMatchInlineSnapshot(`
|
||||
[
|
||||
"rows a@local:read:0 b@local:read:0 sortEpoch=0",
|
||||
"tabs -",
|
||||
"panes -",
|
||||
"completions -",
|
||||
"dock=0 floatingDot=false",
|
||||
]
|
||||
`)
|
||||
})
|
||||
|
||||
it('closing the only unread folder tab clears the folder flag', () => {
|
||||
const { store, persists } = createAttentionStore()
|
||||
store.setState({
|
||||
projectGroups: [
|
||||
{
|
||||
id: 'group-1',
|
||||
name: 'platform',
|
||||
parentPath: '/work',
|
||||
parentGroupId: null,
|
||||
createdFrom: 'manual',
|
||||
tabOrder: 0,
|
||||
isCollapsed: false,
|
||||
color: null,
|
||||
createdAt: 0,
|
||||
updatedAt: 0
|
||||
}
|
||||
],
|
||||
folderWorkspaces: [makeFolderWorkspace()]
|
||||
})
|
||||
const pane = addLiveTerminalTab(store, folderWorkspaceKey('folder-1'), 'tab-f1')
|
||||
ringBell(store, pane)
|
||||
expect(observe(store, persists)).toMatchInlineSnapshot(`
|
||||
[
|
||||
"rows a@local:read:0 b@local:read:0 folder-1@local:UNREAD:1000 sortEpoch=1",
|
||||
"tabs tab-f1=terminal-bell",
|
||||
"panes -",
|
||||
"completions -",
|
||||
"dock=1 floatingDot=false",
|
||||
"persist folder folder-1 {"isUnread":true,"lastActivityAt":1000} host=local",
|
||||
]
|
||||
`)
|
||||
|
||||
store.getState().closeTab(pane.tabId)
|
||||
expect(observe(store, persists)).toMatchInlineSnapshot(`
|
||||
[
|
||||
"rows a@local:read:0 b@local:read:0 folder-1@local:read:1000 sortEpoch=1",
|
||||
"tabs -",
|
||||
"panes -",
|
||||
"completions -",
|
||||
"dock=0 floatingDot=false",
|
||||
"persist folder folder-1 {"isUnread":false} host=local",
|
||||
]
|
||||
`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sleep, hibernate, retire, rekey, reconcile, purge', () => {
|
||||
it('sleeping the workspace removes every marker of its tabs and its flag', async () => {
|
||||
const { store, persists } = seedLitWorkspace()
|
||||
|
||||
await store.getState().shutdownWorktreeTerminals(WS_A, { keepIdentifiers: true })
|
||||
expect(observe(store, persists)).toMatchInlineSnapshot(`
|
||||
[
|
||||
"rows a@local:read:1000 b@local:read:0 sortEpoch=1",
|
||||
"tabs -",
|
||||
"panes -",
|
||||
"completions -",
|
||||
"dock=0 floatingDot=false",
|
||||
"persist worktree a {"isUnread":false}",
|
||||
]
|
||||
`)
|
||||
})
|
||||
|
||||
it('hibernating a completed agent pane removes that pane’s markers only', async () => {
|
||||
const { store, persists, first } = seedLitWorkspace()
|
||||
store
|
||||
.getState()
|
||||
.setAgentStatus(
|
||||
first.paneKey,
|
||||
{ state: 'done', prompt: 'p', agentType: 'codex', lastAssistantMessage: 'done' },
|
||||
'Codex',
|
||||
{ updatedAt: 900, stateStartedAt: 800 },
|
||||
{ tabId: first.tabId, worktreeId: WS_A },
|
||||
{ providerSession: { key: 'session_id', id: 'session-a1' } }
|
||||
)
|
||||
observe(store, persists)
|
||||
|
||||
await store.getState().shutdownCompletedAgentPaneForHibernation(WS_A, {
|
||||
paneKey: first.paneKey,
|
||||
tabId: first.tabId,
|
||||
leafId: first.paneKey.split(':')[1]!,
|
||||
ptyId: `pty-${first.tabId}`
|
||||
})
|
||||
expect(observe(store, persists)).toMatchInlineSnapshot(`
|
||||
[
|
||||
"rows a@local:UNREAD:1000 b@local:read:0 sortEpoch=3",
|
||||
"tabs tab-a1=terminal-bell tab-a2=terminal-bell",
|
||||
"panes tab-a2:L2=terminal-bell",
|
||||
"completions tab-a2:L2=agent-completion",
|
||||
"dock=1 floatingDot=false",
|
||||
]
|
||||
`)
|
||||
})
|
||||
|
||||
it('retiring a pane authority removes its pane and completion markers', () => {
|
||||
const { store, persists, first } = seedLitWorkspace()
|
||||
|
||||
store.getState().retireAgentPaneAuthority(first.paneKey)
|
||||
expect(observe(store, persists)).toMatchInlineSnapshot(`
|
||||
[
|
||||
"rows a@local:UNREAD:1000 b@local:read:0 sortEpoch=1",
|
||||
"tabs tab-a1=terminal-bell tab-a2=terminal-bell",
|
||||
"panes tab-a2:L2=terminal-bell",
|
||||
"completions tab-a2:L2=agent-completion",
|
||||
"dock=1 floatingDot=false",
|
||||
]
|
||||
`)
|
||||
})
|
||||
|
||||
it('recording a provider session removes only the completion marker', () => {
|
||||
const { store, persists, first } = seedLitWorkspace()
|
||||
|
||||
store
|
||||
.getState()
|
||||
.recordAgentProviderSession(
|
||||
first.paneKey,
|
||||
'codex',
|
||||
{ key: 'session_id', id: 'session-a1' },
|
||||
{ updatedAt: 2000 },
|
||||
{ tabId: first.tabId, worktreeId: WS_A }
|
||||
)
|
||||
expect(observe(store, persists)).toMatchInlineSnapshot(`
|
||||
[
|
||||
"rows a@local:UNREAD:1000 b@local:read:0 sortEpoch=1",
|
||||
"tabs tab-a1=terminal-bell tab-a2=terminal-bell",
|
||||
"panes tab-a1:L1=terminal-bell tab-a2:L2=terminal-bell",
|
||||
"completions tab-a2:L2=agent-completion",
|
||||
"dock=1 floatingDot=false",
|
||||
]
|
||||
`)
|
||||
})
|
||||
|
||||
it('transferring a pane authority moves its pane markers', () => {
|
||||
const { store, persists, first } = seedLitWorkspace()
|
||||
|
||||
store.getState().transferAgentPaneAuthority({
|
||||
fromPaneKey: first.paneKey,
|
||||
toPaneKey: `${first.tabId}:33333333-3333-4333-8333-333333333333`
|
||||
})
|
||||
expect(observe(store, persists)).toMatchInlineSnapshot(`
|
||||
[
|
||||
"rows a@local:UNREAD:1000 b@local:read:0 sortEpoch=1",
|
||||
"tabs tab-a1=terminal-bell tab-a2=terminal-bell",
|
||||
"panes tab-a2:L2=terminal-bell tab-a1:33333333-3333-4333-8333-333333333333=terminal-bell",
|
||||
"completions tab-a2:L2=agent-completion tab-a1:33333333-3333-4333-8333-333333333333=agent-completion",
|
||||
"dock=1 floatingDot=false",
|
||||
]
|
||||
`)
|
||||
})
|
||||
|
||||
it('reconciling a dropped terminal on activation leaves no live marker, so the flag reads', () => {
|
||||
const { store, persists, first } = seedLitWorkspace()
|
||||
store.setState({
|
||||
tabsByWorktree: {
|
||||
[WS_A]: (store.getState().tabsByWorktree[WS_A] ?? []).filter(
|
||||
(tab) => tab.id !== first.tabId
|
||||
)
|
||||
}
|
||||
})
|
||||
observe(store, persists)
|
||||
|
||||
store.getState().setActiveWorktree(WS_A)
|
||||
expect(observe(store, persists)).toMatchInlineSnapshot(`
|
||||
[
|
||||
"rows a@local:read:1000 b@local:read:0 sortEpoch=1",
|
||||
"tabs -",
|
||||
"panes tab-a1:L1=terminal-bell",
|
||||
"completions tab-a1:L1=agent-completion",
|
||||
"dock=0 floatingDot=false",
|
||||
"persist worktree a {"isUnread":false}",
|
||||
]
|
||||
`)
|
||||
})
|
||||
|
||||
it('purging the workspace removes every marker of its tabs and its flag', () => {
|
||||
const { store, persists } = seedLitWorkspace()
|
||||
|
||||
store.getState().purgeWorktreeTerminalState([WS_A])
|
||||
expect(observe(store, persists)).toMatchInlineSnapshot(`
|
||||
[
|
||||
"rows a@local:read:1000 b@local:read:0 sortEpoch=1",
|
||||
"tabs -",
|
||||
"panes -",
|
||||
"completions -",
|
||||
"dock=0 floatingDot=false",
|
||||
"persist worktree a {"isUnread":false}",
|
||||
]
|
||||
`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('engagement, read state and tab focus on other surfaces', () => {
|
||||
it('focusing a group reads nothing beyond the active tab already on screen', () => {
|
||||
const { store, persists } = seedLitWorkspace()
|
||||
store.getState().setActiveWorktree(WS_A)
|
||||
observe(store, persists)
|
||||
const groupId = store.getState().groupsByWorktree[WS_A]?.[0]?.id
|
||||
|
||||
store.getState().focusGroup(WS_A, groupId!)
|
||||
expect(observe(store, persists)).toMatchInlineSnapshot(`
|
||||
[
|
||||
"rows a@local:UNREAD:1000 b@local:read:0 sortEpoch=1",
|
||||
"tabs tab-a1=terminal-bell",
|
||||
"panes tab-a1:L1=terminal-bell",
|
||||
"completions tab-a1:L1=agent-completion",
|
||||
"dock=1 floatingDot=false",
|
||||
]
|
||||
`)
|
||||
})
|
||||
|
||||
it('acknowledging agents drops their completion markers only', () => {
|
||||
const { store, persists, first } = seedLitWorkspace()
|
||||
|
||||
store.getState().acknowledgeAgents([first.paneKey], [])
|
||||
expect(observe(store, persists)).toMatchInlineSnapshot(`
|
||||
[
|
||||
"rows a@local:UNREAD:1000 b@local:read:0 sortEpoch=1",
|
||||
"tabs tab-a1=terminal-bell tab-a2=terminal-bell",
|
||||
"panes tab-a1:L1=terminal-bell tab-a2:L2=terminal-bell",
|
||||
"completions tab-a2:L2=agent-completion",
|
||||
"dock=1 floatingDot=false",
|
||||
]
|
||||
`)
|
||||
})
|
||||
})
|
||||
+4
-202
@@ -1,21 +1,14 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as WorktreeMetaPersistModule from '../worktrees/metadata/worktree-meta-persist'
|
||||
import { makeFolderWorkspace, makeWorktree } from '../worktrees-slice-test-fixtures'
|
||||
import { folderWorkspaceKey } from '../../../../../shared/workspace-scope'
|
||||
import {
|
||||
completeAgentTurn,
|
||||
pointerDownInTab,
|
||||
ringBell,
|
||||
toggleUnreadByUser
|
||||
} from './workspace-unread-characterization-driver'
|
||||
import { makeWorktree } from '../worktrees-slice-test-fixtures'
|
||||
import '../../../../../shared/workspace-scope'
|
||||
import { pointerDownInTab, ringBell } from './workspace-unread-characterization-driver'
|
||||
import {
|
||||
addLiveTerminalTab,
|
||||
createAttentionStore,
|
||||
installAttentionMockApi,
|
||||
LEAF_2,
|
||||
observe,
|
||||
WS_A,
|
||||
type AttentionStore
|
||||
WS_A
|
||||
} from './workspace-unread-characterization-fixture'
|
||||
|
||||
// What counts as the screen moving, what is on it, and who is looking at it.
|
||||
@@ -46,41 +39,6 @@ const flushPresenceCheck = async (): Promise<void> => {
|
||||
await Promise.resolve()
|
||||
}
|
||||
|
||||
function seedSameIdFolders(store: AttentionStore, unread: { local: boolean; ssh: boolean }) {
|
||||
store.setState({
|
||||
folderWorkspaces: [
|
||||
makeFolderWorkspace({ executionHostId: 'local', isUnread: unread.local }),
|
||||
makeFolderWorkspace({ executionHostId: 'ssh:remote', isUnread: unread.ssh })
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
describe('arrival without a terminal or chat on screen', () => {
|
||||
it('reads a restart flag on a workspace with no tabs, but not from Settings', () => {
|
||||
const { store, persists } = createAttentionStore()
|
||||
store.setState({
|
||||
activeView: 'settings',
|
||||
worktreesByRepo: {
|
||||
repo1: [makeWorktree({ id: WS_A, repoId: 'repo1', path: '/path/a', isUnread: true })]
|
||||
}
|
||||
})
|
||||
store.getState().setActiveWorktree(WS_A)
|
||||
expect(observe(store, persists)[0]).toContain('a@local:UNREAD')
|
||||
|
||||
store.setState({ activeView: 'terminal' })
|
||||
expect(observe(store, persists)).toMatchInlineSnapshot(`
|
||||
[
|
||||
"rows a@local:read:0 sortEpoch=0",
|
||||
"tabs -",
|
||||
"panes -",
|
||||
"completions -",
|
||||
"dock=0 floatingDot=false",
|
||||
"persist worktree a {"isUnread":false}",
|
||||
]
|
||||
`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('presence', () => {
|
||||
it('a bell on screen while the desktop is away marks, and returning input reads it', async () => {
|
||||
const { store, persists } = createAttentionStore({ desktopAway: true })
|
||||
@@ -147,160 +105,4 @@ describe('presence evidence', () => {
|
||||
]
|
||||
`)
|
||||
})
|
||||
|
||||
it('a completion with no subject on the active workspace marks while Settings is showing', () => {
|
||||
const { store, persists } = createAttentionStore()
|
||||
addLiveTerminalTab(store, WS_A, 'tab-a1')
|
||||
store.getState().setActiveWorktree(WS_A)
|
||||
store.setState({ activeView: 'settings' })
|
||||
observe(store, persists)
|
||||
|
||||
completeAgentTurn(store, { worktreeId: WS_A, paneKey: null })
|
||||
expect(observe(store, persists)[0]).toContain('a@local:UNREAD')
|
||||
})
|
||||
})
|
||||
|
||||
describe('hosts and groups', () => {
|
||||
it('switching between same-id folder rows on two hosts is an arrival on the new host only', () => {
|
||||
const { store, persists } = createAttentionStore()
|
||||
const key = folderWorkspaceKey('folder-1')
|
||||
seedSameIdFolders(store, { local: false, ssh: true })
|
||||
|
||||
store.getState().setActiveWorktree(key, 'local')
|
||||
expect(observe(store, persists)).toMatchInlineSnapshot(`
|
||||
[
|
||||
"rows a@local:read:0 b@local:read:0 folder-1@local:read:0 folder-1@ssh:remote:UNREAD:0 sortEpoch=0",
|
||||
"tabs -",
|
||||
"panes -",
|
||||
"completions -",
|
||||
"dock=0 floatingDot=false",
|
||||
]
|
||||
`)
|
||||
|
||||
store.getState().setActiveWorktree(key, 'ssh:remote')
|
||||
expect(observe(store, persists)).toMatchInlineSnapshot(`
|
||||
[
|
||||
"rows a@local:read:0 b@local:read:0 folder-1@local:read:0 folder-1@ssh:remote:read:0 sortEpoch=0",
|
||||
"tabs -",
|
||||
"panes -",
|
||||
"completions -",
|
||||
"dock=0 floatingDot=false",
|
||||
"persist folder folder-1 {"isUnread":false} host=ssh:remote",
|
||||
]
|
||||
`)
|
||||
})
|
||||
|
||||
it('closing the last marked tab lowers every same-id folder row and persists each host', () => {
|
||||
const { store, persists } = createAttentionStore()
|
||||
const key = folderWorkspaceKey('folder-1')
|
||||
const pane = addLiveTerminalTab(store, key, 'tab-f1')
|
||||
seedSameIdFolders(store, { local: true, ssh: true })
|
||||
ringBell(store, pane)
|
||||
observe(store, persists)
|
||||
|
||||
store.getState().closeTab(pane.tabId)
|
||||
expect(observe(store, persists)).toMatchInlineSnapshot(`
|
||||
[
|
||||
"rows a@local:read:0 b@local:read:0 folder-1@local:read:0 folder-1@ssh:remote:read:0 sortEpoch=0",
|
||||
"tabs -",
|
||||
"panes -",
|
||||
"completions -",
|
||||
"dock=0 floatingDot=false",
|
||||
"persist folder folder-1 {"isUnread":false} host=local",
|
||||
"persist folder folder-1 {"isUnread":false} host=ssh:remote",
|
||||
]
|
||||
`)
|
||||
})
|
||||
|
||||
it('a folder row with no host of its own marks and persists under its project group’s host', () => {
|
||||
const { store, persists } = createAttentionStore()
|
||||
store.setState({
|
||||
projectGroups: [
|
||||
{
|
||||
id: 'group-1',
|
||||
name: 'platform',
|
||||
parentPath: '/work',
|
||||
parentGroupId: null,
|
||||
createdFrom: 'manual',
|
||||
tabOrder: 0,
|
||||
isCollapsed: false,
|
||||
color: null,
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
connectionId: 'ssh-1'
|
||||
}
|
||||
],
|
||||
folderWorkspaces: [makeFolderWorkspace()]
|
||||
})
|
||||
const pane = addLiveTerminalTab(store, folderWorkspaceKey('folder-1'), 'tab-f1')
|
||||
observe(store, persists)
|
||||
|
||||
ringBell(store, pane)
|
||||
expect(observe(store, persists).at(-1)).toMatchInlineSnapshot(
|
||||
`"persist folder folder-1 {"isUnread":true,"lastActivityAt":1000} host=ssh:ssh-1"`
|
||||
)
|
||||
})
|
||||
|
||||
it('a runtime host purge keeps the surviving host row’s manual unread', () => {
|
||||
const { store, persists } = createAttentionStore()
|
||||
const key = folderWorkspaceKey('folder-1')
|
||||
store.setState({
|
||||
folderWorkspaces: [
|
||||
makeFolderWorkspace({ executionHostId: 'local', isUnread: true }),
|
||||
makeFolderWorkspace({ executionHostId: 'runtime:env-1', isUnread: true })
|
||||
]
|
||||
})
|
||||
const pane = addLiveTerminalTab(store, key, 'tab-f1')
|
||||
ringBell(store, pane)
|
||||
observe(store, persists)
|
||||
|
||||
store.getState().purgeStaleRuntimeHostState(['env-1'])
|
||||
expect(observe(store, persists)[0]).toContain('folder-1@local:UNREAD')
|
||||
})
|
||||
|
||||
it('a bell in the unfocused side-by-side group is on screen and leaves no marker', () => {
|
||||
const { store, persists } = createAttentionStore()
|
||||
addLiveTerminalTab(store, WS_A, 'tab-a1')
|
||||
const leftGroupId = store.getState().groupsByWorktree[WS_A]![0]!.id
|
||||
const rightGroupId = store.getState().createEmptySplitGroup(WS_A, leftGroupId, 'right')!
|
||||
const right = addLiveTerminalTab(store, WS_A, 'tab-a2', LEAF_2, rightGroupId)
|
||||
store.getState().setActiveWorktree(WS_A)
|
||||
store.getState().focusGroup(WS_A, leftGroupId)
|
||||
observe(store, persists)
|
||||
|
||||
ringBell(store, right)
|
||||
expect(observe(store, persists)).toMatchInlineSnapshot(`
|
||||
[
|
||||
"rows a@local:read:0 b@local:read:0 sortEpoch=0",
|
||||
"tabs -",
|
||||
"panes -",
|
||||
"completions -",
|
||||
"dock=0 floatingDot=false",
|
||||
]
|
||||
`)
|
||||
})
|
||||
|
||||
it('closing the floating panel moves the screen and reads a manual unread behind it', () => {
|
||||
const { store, persists } = createAttentionStore()
|
||||
addLiveTerminalTab(store, WS_A, 'tab-a1')
|
||||
store.setState({
|
||||
settings: { ...store.getState().settings!, floatingTerminalEnabled: true },
|
||||
floatingWorkspacePanelOpen: true
|
||||
})
|
||||
store.getState().setActiveWorktree(WS_A)
|
||||
toggleUnreadByUser(store, store.getState().worktreesByRepo.repo1[0]!)
|
||||
expect(observe(store, persists)[0]).toContain('a@local:UNREAD')
|
||||
|
||||
store.setState({ floatingWorkspacePanelOpen: false })
|
||||
expect(observe(store, persists)).toMatchInlineSnapshot(`
|
||||
[
|
||||
"rows a@local:read:0 b@local:read:0 sortEpoch=1",
|
||||
"tabs -",
|
||||
"panes -",
|
||||
"completions -",
|
||||
"dock=0 floatingDot=false",
|
||||
"persist worktree a {"isUnread":false}",
|
||||
]
|
||||
`)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import type * as AgentStatusModule from '@/lib/agent-status'
|
||||
import type { FolderWorkspace } from '../../../../shared/folder-workspace-types'
|
||||
import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments'
|
||||
import { toRuntimeExecutionHostId } from '../../../../shared/execution-host'
|
||||
|
||||
import '../../../../shared/execution-host'
|
||||
import { folderWorkspaceKey } from '../../../../shared/workspace-scope'
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
@@ -34,8 +34,7 @@ import {
|
||||
makeWorktree,
|
||||
makeTab,
|
||||
makeTabGroup,
|
||||
makeUnifiedTab,
|
||||
TEST_REPO
|
||||
makeUnifiedTab
|
||||
} from './store-test-helpers'
|
||||
import {
|
||||
clearNativeChatComposerDraftsForTests,
|
||||
@@ -220,83 +219,6 @@ describe('workspace chat drafts on removal', () => {
|
||||
expect(draftTexts()).toEqual(['', '', 'other'])
|
||||
})
|
||||
|
||||
it('a removal the host refuses keeps the drafts', async () => {
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const store = createTestStore()
|
||||
seedWorktree(store)
|
||||
mockApi.worktrees.remove.mockRejectedValue(new Error('Permission denied'))
|
||||
|
||||
const result = await store.getState().removeWorktree({ id: WT1, executionHostId: null }, true)
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
expect(draftTexts()).toEqual(['chat', 'pane', 'other'])
|
||||
})
|
||||
|
||||
it('a listing purge alone keeps the drafts', () => {
|
||||
const store = createTestStore()
|
||||
seedWorktree(store)
|
||||
|
||||
store.getState().purgeWorktreeTerminalState([WT1])
|
||||
|
||||
expect(store.getState().unifiedTabsByWorktree[WT1]).toBeUndefined()
|
||||
expect(draftTexts()).toEqual(['chat', 'pane', 'other'])
|
||||
})
|
||||
|
||||
it('re-pairing a server under the same id keeps the drafts of chats it restores', () => {
|
||||
const runtime = toRuntimeExecutionHostId('env-a')
|
||||
const environment = (pairingRevision: number): PublicKnownRuntimeEnvironment => ({
|
||||
id: 'env-a',
|
||||
name: 'Server',
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
pairingRevision,
|
||||
lastUsedAt: null,
|
||||
runtimeId: null,
|
||||
endpoints: [{ id: 'e', kind: 'websocket', label: 'Server', endpoint: 'ws://server' }],
|
||||
preferredEndpointId: 'e'
|
||||
})
|
||||
const store = createTestStore()
|
||||
seedStore(store, {
|
||||
runtimeEnvironments: [environment(1)],
|
||||
repos: [TEST_REPO, { ...TEST_REPO, id: 'repoA', path: '/repoA', executionHostId: runtime }],
|
||||
worktreesByRepo: {
|
||||
repoA: [makeWorktree({ id: WT1, repoId: 'repoA', path: '/path/wt1', hostId: runtime })]
|
||||
}
|
||||
})
|
||||
seedWorkspace(store, WT1)
|
||||
|
||||
store.getState().setRuntimeEnvironments([environment(2)])
|
||||
|
||||
expect(store.getState().unifiedTabsByWorktree[WT1]).toBeUndefined()
|
||||
expect(draftTexts()).toEqual(['chat', 'pane', 'other'])
|
||||
})
|
||||
|
||||
it('removing a project deletes its worktrees’ drafts only', async () => {
|
||||
const store = createTestStore()
|
||||
seedStore(store, { repos: [{ ...TEST_REPO, id: 'repo1' }] })
|
||||
seedWorktree(store)
|
||||
seedStore(store, {
|
||||
worktreesByRepo: {
|
||||
repo1: [makeWorktree({ id: WT1, repoId: 'repo1', path: '/path/wt1' })],
|
||||
repo2: [makeWorktree({ id: WT2, repoId: 'repo2', path: '/path/wt2' })]
|
||||
}
|
||||
})
|
||||
|
||||
await store.getState().removeProject('repo1', { hostId: 'local' })
|
||||
|
||||
expect(draftTexts()).toEqual(['', '', 'other'])
|
||||
})
|
||||
|
||||
it('deleting a folder workspace deletes its drafts', async () => {
|
||||
mockApi.folderWorkspaces.delete.mockResolvedValue(true)
|
||||
const store = createTestStore()
|
||||
seedFolderWorkspace(store)
|
||||
|
||||
await expect(store.getState().deleteFolderWorkspace(FOLDER.id)).resolves.toBe(true)
|
||||
|
||||
expect(draftTexts()).toEqual(['', '', 'other'])
|
||||
})
|
||||
|
||||
it('a paste still uploading when its folder workspace is deleted never writes a draft back', async () => {
|
||||
mockApi.folderWorkspaces.delete.mockResolvedValue(true)
|
||||
const store = createTestStore()
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
|
||||
import { getWorkspaceCleanupHostIdentity as identity } from '../../../../shared/workspace-cleanup-host-identity'
|
||||
import { makePaneKey } from '../../../../shared/stable-pane-id'
|
||||
import { makeTab, makeLayout } from './store-test-helpers'
|
||||
import {
|
||||
makeAgentEntry,
|
||||
makeUnifiedTab
|
||||
} from '@/components/worktree-jump/worktree-jump-palette-test-fixtures'
|
||||
import { enrichWorkspaceCleanupCandidates } from './workspace-cleanup-candidate-enrichment'
|
||||
import { getWorkspaceCleanupAgentEvidence } from './workspace-cleanup-agent-evidence'
|
||||
import { WORKTREE_ID, makeCandidate, makeState } from './workspace-cleanup-slice-test-harness'
|
||||
|
||||
function paneKey(tabId: string): string {
|
||||
return makePaneKey(tabId, '11111111-1111-4111-8111-111111111111')
|
||||
}
|
||||
|
||||
function agentStatus(paneKey: string, state: AgentStatusEntry['state']): AgentStatusEntry {
|
||||
return makeAgentEntry('unused', state, Date.now(), { paneKey, connectionId: null })
|
||||
}
|
||||
|
||||
// A structured chat publishes status under its own surface id; its tab is not in tabsByWorktree.
|
||||
function structuredChatStatus(state: AgentStatusEntry['state']): AgentStatusEntry {
|
||||
return { ...agentStatus(paneKey('chat-1'), state), worktreeId: WORKTREE_ID }
|
||||
}
|
||||
|
||||
describe('workspace cleanup live-agent finder', () => {
|
||||
it.each([
|
||||
['working', true],
|
||||
['done', false]
|
||||
] as const)('qualifies %s structured evidence and live children: %s', async (state, live) => {
|
||||
const entry = structuredChatStatus(state)
|
||||
const appState = makeState({ agentStatusByPaneKey: { [entry.paneKey]: entry } })
|
||||
|
||||
const candidates = (['local', 'runtime:host', 'ssh:other'] as const).map((executionHostId) =>
|
||||
makeCandidate({ executionHostId })
|
||||
)
|
||||
const evidence = () => getWorkspaceCleanupAgentEvidence(appState, candidates)
|
||||
expect([...evidence().keys()]).toEqual(live ? [identity('local', WORKTREE_ID)] : [])
|
||||
const [enriched] = await enrichWorkspaceCleanupCandidates([makeCandidate()], appState)
|
||||
expect(enriched.blockers.includes('live-agent')).toBe(live)
|
||||
appState.tabsByWorktree = {
|
||||
[WORKTREE_ID]: [makeTab({ id: 'chat-1', worktreeId: WORKTREE_ID, title: '⠋ Codex' })]
|
||||
}
|
||||
expect([...evidence().keys()]).toEqual(live ? [identity('local', WORKTREE_ID)] : [])
|
||||
entry.subagents = [{ id: 'child', state: 'working', startedAt: Date.now() }]
|
||||
appState.tabsByWorktree = {}
|
||||
if (!live) {
|
||||
const tab = makeUnifiedTab('chat-1', WORKTREE_ID, 'session-1', 'Codex Chat')
|
||||
appState.unifiedTabsByWorktree = { [WORKTREE_ID]: [{ ...tab, contentType: 'agent-session' }] }
|
||||
}
|
||||
expect([...evidence().keys()]).toEqual([identity('local', WORKTREE_ID)])
|
||||
appState.ptyIdsByTabId = { 'chat-1': ['pty-other', 'remote:host@@pty'] }
|
||||
const layout = makeLayout()
|
||||
layout.ptyIdsByLeafId = { [entry.paneKey.split(':')[1]]: 'remote:host@@pty' }
|
||||
appState.terminalLayoutsByTabId = { 'chat-1': layout }
|
||||
expect([...evidence().keys()]).toEqual([identity('runtime:host', WORKTREE_ID)])
|
||||
appState.terminalLayoutsByTabId = {}
|
||||
appState.agentStatusByPaneKey = { [entry.paneKey]: { ...entry, connectionId: undefined } }
|
||||
expect([...evidence().values()]).toEqual(['unknown', 'unknown', 'unknown'])
|
||||
})
|
||||
})
|
||||
@@ -6,10 +6,7 @@ import {
|
||||
resetWorkspaceCleanupBrowsePersistTimer,
|
||||
WORKSPACE_CLEANUP_BROWSE_PERSIST_DEBOUNCE_MS
|
||||
} from './workspace-cleanup-browse'
|
||||
import {
|
||||
createDefaultWorkspaceCleanupBrowseState,
|
||||
normalizeWorkspaceCleanupBrowseState
|
||||
} from '../../../../shared/workspace-cleanup-browse-state'
|
||||
import { createDefaultWorkspaceCleanupBrowseState } from '../../../../shared/workspace-cleanup-browse-state'
|
||||
|
||||
const DISMISSAL = {
|
||||
worktreeId: 'wt-1',
|
||||
@@ -39,32 +36,6 @@ describe('workspace cleanup browse slice', () => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('starts from the default browse state', () => {
|
||||
const store = createStore(vi.fn().mockResolvedValue(undefined))
|
||||
expect(store.getState().workspaceCleanupBrowse).toEqual(
|
||||
createDefaultWorkspaceCleanupBrowseState()
|
||||
)
|
||||
})
|
||||
|
||||
it('persists the debounced state alongside the dismissals it would otherwise erase', async () => {
|
||||
const uiSet = vi.fn().mockResolvedValue(undefined)
|
||||
const store = createStore(uiSet)
|
||||
|
||||
const next = createDefaultWorkspaceCleanupBrowseState()
|
||||
next.filters.query = 'stale'
|
||||
store.getState().updateWorkspaceCleanupBrowseState(next)
|
||||
|
||||
expect(store.getState().workspaceCleanupBrowse).toBe(next)
|
||||
expect(uiSet).not.toHaveBeenCalled()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(WORKSPACE_CLEANUP_BROWSE_PERSIST_DEBOUNCE_MS)
|
||||
|
||||
expect(uiSet).toHaveBeenCalledTimes(1)
|
||||
expect(uiSet).toHaveBeenCalledWith({
|
||||
workspaceCleanup: { dismissals: { 'wt-1': DISMISSAL }, browse: next }
|
||||
})
|
||||
})
|
||||
|
||||
it('coalesces a burst of edits into one write carrying the newest state', async () => {
|
||||
const uiSet = vi.fn().mockResolvedValue(undefined)
|
||||
const store = createStore(uiSet)
|
||||
@@ -80,18 +51,4 @@ describe('workspace cleanup browse slice', () => {
|
||||
expect(uiSet).toHaveBeenCalledTimes(1)
|
||||
expect(uiSet.mock.calls[0][0].workspaceCleanup.browse.filters.query).toBe('sta')
|
||||
})
|
||||
|
||||
it('round-trips what it persisted back through hydration', async () => {
|
||||
const uiSet = vi.fn().mockResolvedValue(undefined)
|
||||
const store = createStore(uiSet)
|
||||
|
||||
const next = createDefaultWorkspaceCleanupBrowseState()
|
||||
next.filters.safety.blockers = ['dirty-files']
|
||||
next.sort = { field: 'size', direction: 'desc' }
|
||||
store.getState().updateWorkspaceCleanupBrowseState(next)
|
||||
await vi.advanceTimersByTimeAsync(WORKSPACE_CLEANUP_BROWSE_PERSIST_DEBOUNCE_MS)
|
||||
|
||||
const persisted = JSON.parse(JSON.stringify(uiSet.mock.calls[0][0].workspaceCleanup.browse))
|
||||
expect(normalizeWorkspaceCleanupBrowseState(persisted)).toEqual(next)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
/** Same repoId::path on two hosts must remain distinct through progress merge, dismissal and removal pruning; otherwise confirmation can target a different row (STA-4343). */
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
getWorkspaceCleanupCandidateIdentity,
|
||||
resolveWorkspaceCleanupRemovalHostId
|
||||
} from '../../../../shared/workspace-cleanup-host-identity'
|
||||
import {
|
||||
shouldHideWorkspaceCleanupCandidate,
|
||||
WORKSPACE_CLEANUP_CLASSIFIER_VERSION,
|
||||
type WorkspaceCleanupCandidate,
|
||||
type WorkspaceCleanupDismissal
|
||||
} from '../../../../shared/workspace-cleanup'
|
||||
import { resolveWorkspaceCleanupRemovalTargets } from './workspace-cleanup-removal-targets'
|
||||
import type { AppState } from '../types'
|
||||
import { createTestStore, seedStore } from './store-test-helpers'
|
||||
|
||||
const dismissMock = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
// @ts-expect-error -- minimal window.api stub for dismissal persistence
|
||||
globalThis.window = { api: { workspaceCleanup: { dismiss: dismissMock } } }
|
||||
|
||||
const WORKTREE_ID = 'repo1::/shared/workspace'
|
||||
|
||||
function makeCandidate(
|
||||
executionHostId: WorkspaceCleanupCandidate['executionHostId'],
|
||||
connectionId: string | null = null
|
||||
): WorkspaceCleanupCandidate {
|
||||
return {
|
||||
worktreeId: WORKTREE_ID,
|
||||
repoId: 'repo1',
|
||||
repoName: 'Repo 1',
|
||||
connectionId,
|
||||
...(executionHostId ? { executionHostId } : {}),
|
||||
displayName: 'shared-workspace',
|
||||
branch: 'old',
|
||||
path: '/shared/workspace',
|
||||
tier: 'ready',
|
||||
selectedByDefault: true,
|
||||
reasons: ['idle-clean'],
|
||||
blockers: [],
|
||||
lastActivityAt: 0,
|
||||
localContext: {
|
||||
terminalTabCount: 0,
|
||||
cleanEditorTabCount: 0,
|
||||
browserTabCount: 0,
|
||||
diffCommentCount: 0,
|
||||
newestDiffCommentAt: null,
|
||||
retainedDoneAgentCount: 0
|
||||
},
|
||||
git: { clean: true, upstreamAhead: 0, upstreamBehind: 0, checkedAt: 1 },
|
||||
fingerprint: 'fp-1'
|
||||
}
|
||||
}
|
||||
|
||||
function makeDismissal(
|
||||
executionHostId?: WorkspaceCleanupDismissal['executionHostId']
|
||||
): WorkspaceCleanupDismissal {
|
||||
return {
|
||||
worktreeId: WORKTREE_ID,
|
||||
dismissedAt: 0,
|
||||
fingerprint: 'fp-1',
|
||||
classifierVersion: WORKSPACE_CLEANUP_CLASSIFIER_VERSION,
|
||||
...(executionHostId ? { executionHostId } : {})
|
||||
}
|
||||
}
|
||||
|
||||
describe('workspace cleanup host-qualified identity', () => {
|
||||
it('reports no removal host for a row with no host evidence', () => {
|
||||
expect(resolveWorkspaceCleanupRemovalHostId(makeCandidate(undefined))).toBeNull()
|
||||
// A pre-executionHostId remote row still names its connection.
|
||||
expect(resolveWorkspaceCleanupRemovalHostId(makeCandidate(undefined, 'ssh-1'))).toBe(
|
||||
'ssh:ssh-1'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('workspace cleanup dismissals', () => {
|
||||
it('hides only the dismissed host row', () => {
|
||||
const dismissal = makeDismissal('ssh:ssh-1')
|
||||
expect(
|
||||
shouldHideWorkspaceCleanupCandidate(makeCandidate('ssh:ssh-1', 'ssh-1'), dismissal)
|
||||
).toBe(true)
|
||||
expect(shouldHideWorkspaceCleanupCandidate(makeCandidate('local'), dismissal)).toBe(false)
|
||||
})
|
||||
|
||||
it('stores same-id dismissals independently and still reads a legacy id-only key', async () => {
|
||||
const local = makeCandidate('local')
|
||||
const remote = makeCandidate('ssh:ssh-1', 'ssh-1')
|
||||
const store = createTestStore()
|
||||
seedStore(store, {
|
||||
workspaceCleanupScan: { scannedAt: 1, candidates: [local, remote], errors: [] }
|
||||
} as Partial<AppState>)
|
||||
|
||||
await store.getState().dismissWorkspaceCleanupCandidates([local, remote])
|
||||
|
||||
expect(Object.keys(store.getState().workspaceCleanupDismissals).sort()).toEqual(
|
||||
[
|
||||
getWorkspaceCleanupCandidateIdentity(local),
|
||||
getWorkspaceCleanupCandidateIdentity(remote)
|
||||
].sort()
|
||||
)
|
||||
expect(store.getState().workspaceCleanupScan?.candidates).toEqual([
|
||||
expect.objectContaining({ blockers: expect.arrayContaining(['dismissed']) }),
|
||||
expect.objectContaining({ blockers: expect.arrayContaining(['dismissed']) })
|
||||
])
|
||||
|
||||
seedStore(store, {
|
||||
workspaceCleanupDismissals: { [WORKTREE_ID]: makeDismissal() },
|
||||
workspaceCleanupScan: { scannedAt: 1, candidates: [local], errors: [] }
|
||||
} as Partial<AppState>)
|
||||
await store.getState().dismissWorkspaceCleanupCandidates([])
|
||||
|
||||
expect(store.getState().workspaceCleanupScan?.candidates[0]?.blockers).toContain('dismissed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('workspace cleanup removal targets', () => {
|
||||
const emptyState = { worktreesByRepo: {}, detectedWorktreesByRepo: {} } as unknown as AppState
|
||||
|
||||
it('carries the confirmed host through as the removal owner', () => {
|
||||
const [target] = resolveWorkspaceCleanupRemovalTargets([WORKTREE_ID], emptyState, {
|
||||
approvedCandidates: [makeCandidate('ssh:ssh-1', 'ssh-1')]
|
||||
})
|
||||
expect(target).toMatchObject({ kind: 'target', executionHostId: 'ssh:ssh-1' })
|
||||
})
|
||||
|
||||
it('fails closed when one id is confirmed for two hosts at once', () => {
|
||||
const [target] = resolveWorkspaceCleanupRemovalTargets([WORKTREE_ID], emptyState, {
|
||||
approvedCandidates: [makeCandidate('local'), makeCandidate('ssh:ssh-1', 'ssh-1')]
|
||||
})
|
||||
expect(target?.kind).toBe('unresolved')
|
||||
})
|
||||
|
||||
it('fails closed for a confirmed row with no host evidence despite one known owner', () => {
|
||||
const state = {
|
||||
worktreesByRepo: { repo1: [{ id: WORKTREE_ID, repoId: 'repo1', hostId: 'ssh:ssh-1' }] },
|
||||
detectedWorktreesByRepo: {}
|
||||
} as unknown as AppState
|
||||
const [target] = resolveWorkspaceCleanupRemovalTargets([WORKTREE_ID], state, {
|
||||
approvedCandidates: [makeCandidate(undefined)]
|
||||
})
|
||||
expect(target?.kind).toBe('unresolved')
|
||||
})
|
||||
})
|
||||
@@ -1,63 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { composeWorktreeHostIdentity } from '../../../../shared/worktree/host-qualified-identity'
|
||||
import { shouldPreserveCleanupInspection } from './workspace-cleanup-local-evidence'
|
||||
import { WORKTREE_ID, makeCandidate, makeState } from './workspace-cleanup-slice-test-harness'
|
||||
|
||||
describe('cleanup inspection grace survives upgrades', () => {
|
||||
// Legacy persisted viewed marks lack newer fields; requiring them would silently remove existing users’ grace period.
|
||||
it('honours a viewed mark persisted without any newer fields', () => {
|
||||
const candidate = makeCandidate({ fingerprint: 'fingerprint-1' })
|
||||
const state = makeState({
|
||||
workspaceCleanupViewedCandidates: {
|
||||
[WORKTREE_ID]: { viewedAt: Date.now(), fingerprint: 'fingerprint-1' }
|
||||
}
|
||||
})
|
||||
|
||||
expect(shouldPreserveCleanupInspection(candidate, state)).toBe(true)
|
||||
})
|
||||
|
||||
it('still drops the grace once the row fingerprint changes', () => {
|
||||
const candidate = makeCandidate({ fingerprint: 'fingerprint-2' })
|
||||
const state = makeState({
|
||||
workspaceCleanupViewedCandidates: {
|
||||
[WORKTREE_ID]: { viewedAt: Date.now(), fingerprint: 'fingerprint-1' }
|
||||
}
|
||||
})
|
||||
|
||||
expect(shouldPreserveCleanupInspection(candidate, state)).toBe(false)
|
||||
})
|
||||
|
||||
it('still expires the grace after the window', () => {
|
||||
const candidate = makeCandidate({ fingerprint: 'fingerprint-1' })
|
||||
const state = makeState({
|
||||
workspaceCleanupViewedCandidates: {
|
||||
[WORKTREE_ID]: {
|
||||
viewedAt: Date.now() - 3 * 60 * 60 * 1000,
|
||||
fingerprint: 'fingerprint-1'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(shouldPreserveCleanupInspection(candidate, state)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cleanup reads visit recency host-qualified', () => {
|
||||
// Modern visits use host|worktreeId, including local; bare-ID reads miss them and report zero.
|
||||
it('finds a visit stamped under the host-qualified key', async () => {
|
||||
const { enrichWorkspaceCleanupCandidates } = await import('./workspace-cleanup')
|
||||
const qualifiedKey = composeWorktreeHostIdentity('local', WORKTREE_ID)
|
||||
expect(qualifiedKey).not.toBe(WORKTREE_ID)
|
||||
|
||||
const [candidate] = await enrichWorkspaceCleanupCandidates(
|
||||
[makeCandidate()],
|
||||
makeState({
|
||||
lastVisitedAtByWorktreeId: { [qualifiedKey]: Date.now() },
|
||||
browserTabsByWorktree: { [WORKTREE_ID]: [{ id: 'tab-1' }] } as never
|
||||
}),
|
||||
{ applyDismissals: false }
|
||||
)
|
||||
|
||||
expect(candidate.blockers).toContain('recent-visible-context')
|
||||
})
|
||||
})
|
||||
@@ -1,54 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { composeWorktreeHostIdentity } from '../../../../shared/worktree/host-qualified-identity'
|
||||
import { makeTerminalTab } from '@/components/worktree-jump/worktree-jump-palette-test-fixtures'
|
||||
import { NOW, WORKTREE_ID, makeCandidate, makeState } from './workspace-cleanup-slice-test-harness'
|
||||
|
||||
vi.mock('../../../../shared/sidebar-inputs-switch', () => ({ ORCA_SIDEBAR_INPUTS_IS_MAIN: true }))
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
// Plain-shell-only remote visits lack agent activity; host-owned start/exit stamps do not update Recent either.
|
||||
function plainShellWorkspace(visitedDaysAgo: number) {
|
||||
const candidate = makeCandidate({
|
||||
executionHostId: 'ssh:box',
|
||||
lastActivityAt: NOW - 45 * DAY_MS,
|
||||
reasons: ['idle-clean']
|
||||
})
|
||||
const state = makeState({
|
||||
tabsByWorktree: { [WORKTREE_ID]: [makeTerminalTab('tab-1', WORKTREE_ID, 'zsh')] },
|
||||
lastVisitedAtByWorktreeId: {
|
||||
[composeWorktreeHostIdentity('ssh:box', WORKTREE_ID)]: NOW - visitedDaysAgo * DAY_MS
|
||||
}
|
||||
})
|
||||
return { candidate, state }
|
||||
}
|
||||
|
||||
describe('cleanup reads a plain-shell workspace’s visits as activity (switch on)', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ now: NOW, toFake: ['Date'] })
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('a workspace visited two days ago is not idle, and shows that as its last activity', async () => {
|
||||
const { enrichWorkspaceCleanupCandidates } = await import('./workspace-cleanup')
|
||||
const { candidate, state } = plainShellWorkspace(2)
|
||||
const [enriched] = await enrichWorkspaceCleanupCandidates([candidate], state, {
|
||||
applyDismissals: false
|
||||
})
|
||||
expect(enriched.reasons).toEqual([])
|
||||
expect(enriched.lastActivityAt).toBe(NOW - 2 * DAY_MS)
|
||||
expect(enriched.selectedByDefault).toBe(false)
|
||||
})
|
||||
|
||||
it('a workspace last visited 40 days ago is still idle', async () => {
|
||||
const { enrichWorkspaceCleanupCandidates } = await import('./workspace-cleanup')
|
||||
const { candidate, state } = plainShellWorkspace(40)
|
||||
const [enriched] = await enrichWorkspaceCleanupCandidates([candidate], state, {
|
||||
applyDismissals: false
|
||||
})
|
||||
expect(enriched.reasons).toEqual(['idle-clean'])
|
||||
expect(enriched.lastActivityAt).toBe(NOW - 40 * DAY_MS)
|
||||
})
|
||||
})
|
||||
@@ -1,18 +1,14 @@
|
||||
import {
|
||||
getWorkspaceCleanupCandidateIdentity,
|
||||
getWorkspaceCleanupHostIdentity
|
||||
} from '../../../../shared/workspace-cleanup-host-identity'
|
||||
import { getWorkspaceCleanupCandidateIdentity } from '../../../../shared/workspace-cleanup-host-identity'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { AppState } from '../types'
|
||||
import type { WorkspaceCleanupScanResult } from '../../../../shared/workspace-cleanup'
|
||||
import { enrichWorkspaceCleanupCandidates } from './workspace-cleanup'
|
||||
import './workspace-cleanup'
|
||||
import {
|
||||
NOW,
|
||||
WORKTREE_ID,
|
||||
createCleanupTestStore,
|
||||
installWorkspaceCleanupApi,
|
||||
makeCandidate,
|
||||
makeState
|
||||
makeCandidate
|
||||
} from './workspace-cleanup-slice-test-harness'
|
||||
|
||||
describe('workspace cleanup removal and protection', () => {
|
||||
@@ -97,137 +93,6 @@ describe('workspace cleanup removal and protection', () => {
|
||||
expect(store.getState().workspaceCleanupScan?.candidates).toEqual([])
|
||||
})
|
||||
|
||||
it('returns preserved branches for the cleanup batch summary', async () => {
|
||||
const candidate = makeCandidate()
|
||||
installWorkspaceCleanupApi(
|
||||
vi.fn(async () => ({
|
||||
scannedAt: NOW,
|
||||
candidates: [candidate],
|
||||
errors: []
|
||||
}))
|
||||
)
|
||||
const removeWorktree = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
preservedBranch: { branchName: 'feature/cleanup', head: 'saved-head' }
|
||||
})
|
||||
const store = createCleanupTestStore(removeWorktree)
|
||||
store.setState({
|
||||
workspaceCleanupScan: {
|
||||
scannedAt: NOW,
|
||||
candidates: [candidate],
|
||||
errors: []
|
||||
}
|
||||
} as Partial<AppState>)
|
||||
|
||||
await expect(
|
||||
store.getState().removeWorkspaceCleanupCandidates([candidate.worktreeId])
|
||||
).resolves.toEqual({
|
||||
removedIds: [candidate.worktreeId],
|
||||
removedIdentities: [getWorkspaceCleanupHostIdentity('local', candidate.worktreeId)],
|
||||
failures: [],
|
||||
preservedBranches: [
|
||||
{
|
||||
worktreeId: candidate.worktreeId,
|
||||
branchName: 'feature/cleanup',
|
||||
expectedHead: 'saved-head'
|
||||
}
|
||||
]
|
||||
})
|
||||
expect(removeWorktree).toHaveBeenCalledWith(
|
||||
{ id: candidate.worktreeId, executionHostId: null },
|
||||
false,
|
||||
{
|
||||
assertBeforeDispatch: expect.any(Function),
|
||||
suppressPreservedBranchToast: true
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps a viewed active workspace visible but not removable', async () => {
|
||||
const [candidate] = await enrichWorkspaceCleanupCandidates(
|
||||
[makeCandidate()],
|
||||
makeState({
|
||||
activeWorktreeId: WORKTREE_ID,
|
||||
workspaceCleanupViewedCandidates: {
|
||||
[WORKTREE_ID]: {
|
||||
viewedAt: Date.now(),
|
||||
fingerprint: 'fingerprint-1'
|
||||
}
|
||||
}
|
||||
}),
|
||||
{ applyDismissals: false }
|
||||
)
|
||||
|
||||
expect(candidate.tier).toBe('protected')
|
||||
expect(candidate.selectedByDefault).toBe(false)
|
||||
expect(candidate.blockers).toContain('active-workspace')
|
||||
})
|
||||
|
||||
it('protects recently visible old workspaces with open context', async () => {
|
||||
const [candidate] = await enrichWorkspaceCleanupCandidates(
|
||||
[makeCandidate()],
|
||||
makeState({
|
||||
openFiles: [
|
||||
{
|
||||
id: 'file-1',
|
||||
worktreeId: WORKTREE_ID,
|
||||
filePath: '/tmp/old-workspace/src/app.ts',
|
||||
relativePath: 'src/app.ts',
|
||||
language: 'typescript',
|
||||
isDirty: false
|
||||
}
|
||||
] as AppState['openFiles'],
|
||||
lastVisitedAtByWorktreeId: {
|
||||
[WORKTREE_ID]: Date.now()
|
||||
}
|
||||
}),
|
||||
{ applyDismissals: false }
|
||||
)
|
||||
|
||||
expect(candidate.tier).toBe('protected')
|
||||
expect(candidate.selectedByDefault).toBe(false)
|
||||
expect(candidate.blockers).toContain('recent-visible-context')
|
||||
})
|
||||
|
||||
it('lets explicitly selected not-suggested workspaces reach the removal path', async () => {
|
||||
const scan = vi.fn().mockResolvedValue({
|
||||
scannedAt: NOW,
|
||||
candidates: [makeCandidate()],
|
||||
errors: []
|
||||
} satisfies WorkspaceCleanupScanResult)
|
||||
const removeWorktree = vi.fn().mockResolvedValue({ ok: true })
|
||||
;(globalThis as { window: unknown }).window = {
|
||||
api: {
|
||||
workspaceCleanup: {
|
||||
scan,
|
||||
dismiss: vi.fn().mockResolvedValue(undefined),
|
||||
clearDismissals: vi.fn().mockResolvedValue(undefined)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const store = createCleanupTestStore(removeWorktree)
|
||||
|
||||
store.setState({
|
||||
tabsByWorktree: {
|
||||
[WORKTREE_ID]: [{ id: 'tab-1', title: 'zsh' }] as AppState['tabsByWorktree'][string]
|
||||
},
|
||||
ptyIdsByTabId: { 'tab-1': ['pty-1'] }
|
||||
} as Partial<AppState>)
|
||||
|
||||
await expect(store.getState().removeWorkspaceCleanupCandidates([WORKTREE_ID])).resolves.toEqual(
|
||||
{
|
||||
removedIds: [WORKTREE_ID],
|
||||
removedIdentities: [getWorkspaceCleanupHostIdentity('local', WORKTREE_ID)],
|
||||
failures: []
|
||||
}
|
||||
)
|
||||
expect(removeWorktree).toHaveBeenCalledWith({ id: WORKTREE_ID, executionHostId: null }, false, {
|
||||
assertBeforeDispatch: expect.any(Function),
|
||||
suppressPreservedBranchToast: true
|
||||
})
|
||||
})
|
||||
|
||||
it('fails a queued removal that now needs a force the user never approved', async () => {
|
||||
const approvedCandidate = makeCandidate({ executionHostId: 'local' })
|
||||
const dirtySinceConfirmation = makeCandidate({
|
||||
@@ -269,46 +134,6 @@ describe('workspace cleanup removal and protection', () => {
|
||||
expect(removeWorktree).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still force-removes rows whose approved candidate already carried git risk', async () => {
|
||||
const approvedCandidate = makeCandidate({
|
||||
executionHostId: 'local',
|
||||
tier: 'review',
|
||||
blockers: ['dirty-files'],
|
||||
git: {
|
||||
clean: false,
|
||||
upstreamAhead: 0,
|
||||
upstreamBehind: 0,
|
||||
checkedAt: NOW
|
||||
}
|
||||
})
|
||||
const scan = vi.fn().mockResolvedValue({
|
||||
scannedAt: NOW,
|
||||
candidates: [approvedCandidate],
|
||||
errors: []
|
||||
} satisfies WorkspaceCleanupScanResult)
|
||||
installWorkspaceCleanupApi(scan)
|
||||
const removeWorktree = vi.fn().mockResolvedValue({ ok: true })
|
||||
const store = createCleanupTestStore(removeWorktree)
|
||||
|
||||
await expect(
|
||||
store.getState().removeWorkspaceCleanupCandidates([WORKTREE_ID], {
|
||||
approvedCandidates: [approvedCandidate]
|
||||
})
|
||||
).resolves.toEqual({
|
||||
removedIds: [WORKTREE_ID],
|
||||
removedIdentities: [getWorkspaceCleanupHostIdentity('local', WORKTREE_ID)],
|
||||
failures: []
|
||||
})
|
||||
expect(removeWorktree).toHaveBeenCalledWith(
|
||||
{ id: WORKTREE_ID, executionHostId: 'local' },
|
||||
true,
|
||||
{
|
||||
assertBeforeDispatch: expect.any(Function),
|
||||
suppressPreservedBranchToast: true
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('fails a removal that reveals concrete git risk after an unverified force approval', async () => {
|
||||
const approvedCandidate = makeCandidate({
|
||||
executionHostId: 'local',
|
||||
@@ -354,38 +179,4 @@ describe('workspace cleanup removal and protection', () => {
|
||||
})
|
||||
expect(removeWorktree).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not let an idle title in another tab mask a running agent process', async () => {
|
||||
;(globalThis as { window: unknown }).window = {
|
||||
api: {
|
||||
pty: {
|
||||
hasChildProcesses: vi.fn(async (ptyId: string) => ptyId === 'pty-running'),
|
||||
getForegroundProcess: vi.fn(async (ptyId: string) =>
|
||||
ptyId === 'pty-running' ? 'codex' : 'zsh'
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const [candidate] = await enrichWorkspaceCleanupCandidates(
|
||||
[makeCandidate()],
|
||||
makeState({
|
||||
tabsByWorktree: {
|
||||
[WORKTREE_ID]: [
|
||||
{ id: 'tab-running', title: 'zsh' },
|
||||
{ id: 'tab-idle', title: 'Codex done' }
|
||||
] as AppState['tabsByWorktree'][string]
|
||||
},
|
||||
ptyIdsByTabId: {
|
||||
'tab-running': ['pty-running'],
|
||||
'tab-idle': ['pty-idle']
|
||||
}
|
||||
}),
|
||||
{ applyDismissals: false }
|
||||
)
|
||||
|
||||
expect(candidate.tier).toBe('protected')
|
||||
expect(candidate.selectedByDefault).toBe(false)
|
||||
expect(candidate.blockers).toContain('running-terminal')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { registerInFlightWorkspaceCleanupScan } from './workspace-cleanup-broad-scan-registry'
|
||||
import {
|
||||
beginWorkspaceCleanupScan,
|
||||
invalidateWorkspaceCleanupScanProgress,
|
||||
isLatestWorkspaceCleanupScan
|
||||
} from './workspace-cleanup-scan-progress'
|
||||
|
||||
describe('workspace cleanup stale scan guard', () => {
|
||||
const cancelScan = vi.fn(async () => true)
|
||||
|
||||
beforeEach(() => {
|
||||
cancelScan.mockReset()
|
||||
;(globalThis as { window: unknown }).window = {
|
||||
api: { workspaceCleanup: { cancelScan } }
|
||||
}
|
||||
})
|
||||
|
||||
it('revokes the current token before removal pruning can expose late progress', () => {
|
||||
const staleToken = beginWorkspaceCleanupScan()
|
||||
registerInFlightWorkspaceCleanupScan(
|
||||
'native:repo-1',
|
||||
'scan-before-removal',
|
||||
new Promise<never>(() => undefined)
|
||||
)
|
||||
expect(isLatestWorkspaceCleanupScan(staleToken)).toBe(true)
|
||||
|
||||
invalidateWorkspaceCleanupScanProgress()
|
||||
|
||||
expect(isLatestWorkspaceCleanupScan(staleToken)).toBe(false)
|
||||
expect(cancelScan).toHaveBeenCalledExactlyOnceWith('scan-before-removal')
|
||||
})
|
||||
})
|
||||
@@ -7,7 +7,6 @@ import type {
|
||||
} from '../../../../shared/workspace-cleanup'
|
||||
import {
|
||||
NOW,
|
||||
WORKTREE_ID,
|
||||
createCleanupTestStore,
|
||||
deferred,
|
||||
installWorkspaceCleanupApi,
|
||||
@@ -16,26 +15,6 @@ import {
|
||||
import { WorkspaceCleanupScanSupersededError } from './workspace-cleanup-broad-scan-registry'
|
||||
|
||||
describe('workspace cleanup scan progress', () => {
|
||||
it('joins duplicate broad cleanup scans', async () => {
|
||||
const pending = deferred<WorkspaceCleanupScanResult>()
|
||||
const scan = vi.fn().mockReturnValue(pending.promise)
|
||||
installWorkspaceCleanupApi(scan)
|
||||
const store = createCleanupTestStore()
|
||||
|
||||
const first = store.getState().scanWorkspaceCleanup()
|
||||
const second = store.getState().scanWorkspaceCleanup()
|
||||
|
||||
expect(scan).toHaveBeenCalledTimes(1)
|
||||
expect(store.getState().workspaceCleanupLoading).toBe(true)
|
||||
|
||||
const result = { scannedAt: NOW, candidates: [makeCandidate()], errors: [] }
|
||||
pending.resolve(result)
|
||||
|
||||
await expect(Promise.all([first, second])).resolves.toEqual([result, result])
|
||||
expect(store.getState().workspaceCleanupScan?.candidates).toHaveLength(1)
|
||||
expect(store.getState().workspaceCleanupLoading).toBe(false)
|
||||
})
|
||||
|
||||
it('does not leave cleanup loading stuck when a reopen joins a just-settled scan', async () => {
|
||||
const pending = deferred<WorkspaceCleanupScanResult>()
|
||||
const result = { scannedAt: NOW, candidates: [makeCandidate()], errors: [] }
|
||||
@@ -65,55 +44,6 @@ describe('workspace cleanup scan progress', () => {
|
||||
expect(store.getState().workspaceCleanupLoading).toBe(false)
|
||||
})
|
||||
|
||||
it('shows scanned cleanup candidates before the final broad scan resolves', async () => {
|
||||
const pending = deferred<WorkspaceCleanupScanResult>()
|
||||
let onProgress: ((progress: WorkspaceCleanupScanProgress) => void) | undefined
|
||||
const partialCandidate = makeCandidate({ worktreeId: 'repo1::/tmp/partial' })
|
||||
const finalCandidate = makeCandidate({ worktreeId: 'repo1::/tmp/final' })
|
||||
const scan = vi.fn((_args, progressCallback) => {
|
||||
onProgress = progressCallback
|
||||
return pending.promise
|
||||
})
|
||||
installWorkspaceCleanupApi(scan)
|
||||
const store = createCleanupTestStore()
|
||||
|
||||
const scanPromise = store.getState().scanWorkspaceCleanup()
|
||||
onProgress?.({
|
||||
scanId: 'scan-1',
|
||||
scannedAt: NOW,
|
||||
scannedWorktreeCount: 1,
|
||||
totalWorktreeCount: 2,
|
||||
candidates: [partialCandidate],
|
||||
errors: []
|
||||
})
|
||||
|
||||
expect(store.getState().workspaceCleanupLoading).toBe(true)
|
||||
await vi.waitFor(() => {
|
||||
expect(store.getState().workspaceCleanupProgress).toMatchObject({
|
||||
scannedWorktreeCount: 1,
|
||||
totalWorktreeCount: 2
|
||||
})
|
||||
})
|
||||
expect(store.getState().workspaceCleanupScan?.candidates).toEqual([partialCandidate])
|
||||
|
||||
pending.resolve({
|
||||
scannedAt: NOW,
|
||||
candidates: [partialCandidate, finalCandidate],
|
||||
errors: []
|
||||
})
|
||||
|
||||
await expect(scanPromise).resolves.toEqual({
|
||||
scannedAt: NOW,
|
||||
candidates: [partialCandidate, finalCandidate],
|
||||
errors: []
|
||||
})
|
||||
expect(store.getState().workspaceCleanupLoading).toBe(false)
|
||||
expect(store.getState().workspaceCleanupProgress).toMatchObject({
|
||||
scannedWorktreeCount: 2,
|
||||
totalWorktreeCount: 2
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps same-id host rows distinct across streamed enrichment frames', async () => {
|
||||
const pending = deferred<WorkspaceCleanupScanResult>()
|
||||
let onProgress: ((progress: WorkspaceCleanupScanProgress) => void) | undefined
|
||||
@@ -162,133 +92,6 @@ describe('workspace cleanup scan progress', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('does not re-probe previously enriched rows during cumulative progress updates', async () => {
|
||||
const pending = deferred<WorkspaceCleanupScanResult>()
|
||||
let onProgress: ((progress: WorkspaceCleanupScanProgress) => void) | undefined
|
||||
const terminalCandidate = makeCandidate({ worktreeId: 'repo1::/tmp/terminal' })
|
||||
const laterCandidate = makeCandidate({ worktreeId: 'repo1::/tmp/later' })
|
||||
const scan = vi.fn((_args, progressCallback) => {
|
||||
onProgress = progressCallback
|
||||
return pending.promise
|
||||
})
|
||||
installWorkspaceCleanupApi(scan)
|
||||
const hasChildProcesses = vi.fn().mockResolvedValue(false)
|
||||
const getForegroundProcess = vi.fn().mockResolvedValue('zsh')
|
||||
;(
|
||||
globalThis.window as unknown as {
|
||||
api: {
|
||||
pty?: {
|
||||
hasChildProcesses: typeof hasChildProcesses
|
||||
getForegroundProcess: typeof getForegroundProcess
|
||||
}
|
||||
}
|
||||
}
|
||||
).api.pty = { hasChildProcesses, getForegroundProcess }
|
||||
const store = createCleanupTestStore()
|
||||
store.setState({
|
||||
tabsByWorktree: {
|
||||
'repo1::/tmp/terminal': [
|
||||
{ id: 'tab-1', title: 'zsh' }
|
||||
] as AppState['tabsByWorktree'][string]
|
||||
},
|
||||
ptyIdsByTabId: { 'tab-1': ['pty-1'] }
|
||||
} as Partial<AppState>)
|
||||
|
||||
const scanPromise = store.getState().scanWorkspaceCleanup()
|
||||
onProgress?.({
|
||||
scanId: 'scan-1',
|
||||
scannedAt: NOW,
|
||||
scannedWorktreeCount: 1,
|
||||
totalWorktreeCount: 2,
|
||||
candidates: [terminalCandidate],
|
||||
errors: [],
|
||||
candidateMode: 'append'
|
||||
})
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(store.getState().workspaceCleanupProgress?.scannedWorktreeCount).toBe(1)
|
||||
})
|
||||
expect(hasChildProcesses).toHaveBeenCalledTimes(1)
|
||||
expect(getForegroundProcess).toHaveBeenCalledTimes(1)
|
||||
|
||||
onProgress?.({
|
||||
scanId: 'scan-1',
|
||||
scannedAt: NOW,
|
||||
scannedWorktreeCount: 2,
|
||||
totalWorktreeCount: 2,
|
||||
candidates: [laterCandidate],
|
||||
errors: [],
|
||||
candidateMode: 'append'
|
||||
})
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(store.getState().workspaceCleanupProgress?.scannedWorktreeCount).toBe(2)
|
||||
})
|
||||
expect(store.getState().workspaceCleanupScan?.candidates).toHaveLength(2)
|
||||
expect(hasChildProcesses).toHaveBeenCalledTimes(1)
|
||||
expect(getForegroundProcess).toHaveBeenCalledTimes(1)
|
||||
|
||||
pending.resolve({
|
||||
scannedAt: NOW,
|
||||
candidates: [terminalCandidate, laterCandidate],
|
||||
errors: []
|
||||
})
|
||||
await scanPromise
|
||||
|
||||
expect(hasChildProcesses).toHaveBeenCalledTimes(1)
|
||||
expect(getForegroundProcess).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('updates count-only append progress without replacing existing candidate rows', async () => {
|
||||
const pending = deferred<WorkspaceCleanupScanResult>()
|
||||
let onProgress: ((progress: WorkspaceCleanupScanProgress) => void) | undefined
|
||||
const candidate = makeCandidate({ worktreeId: 'repo1::/tmp/alpha' })
|
||||
const scan = vi.fn((_args, progressCallback) => {
|
||||
onProgress = progressCallback
|
||||
return pending.promise
|
||||
})
|
||||
installWorkspaceCleanupApi(scan)
|
||||
const store = createCleanupTestStore()
|
||||
|
||||
const scanPromise = store.getState().scanWorkspaceCleanup()
|
||||
onProgress?.({
|
||||
scanId: 'scan-1',
|
||||
scannedAt: NOW,
|
||||
scannedWorktreeCount: 1,
|
||||
totalWorktreeCount: 2,
|
||||
candidates: [candidate],
|
||||
errors: [],
|
||||
candidateMode: 'append'
|
||||
})
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(store.getState().workspaceCleanupProgress?.scannedWorktreeCount).toBe(1)
|
||||
})
|
||||
const candidatesAfterFirstAppend = store.getState().workspaceCleanupScan?.candidates
|
||||
|
||||
onProgress?.({
|
||||
scanId: 'scan-1',
|
||||
scannedAt: NOW,
|
||||
scannedWorktreeCount: 2,
|
||||
totalWorktreeCount: 2,
|
||||
candidates: [],
|
||||
errors: [],
|
||||
candidateMode: 'append'
|
||||
})
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(store.getState().workspaceCleanupProgress?.scannedWorktreeCount).toBe(2)
|
||||
})
|
||||
expect(store.getState().workspaceCleanupScan?.candidates).toBe(candidatesAfterFirstAppend)
|
||||
|
||||
pending.resolve({
|
||||
scannedAt: NOW,
|
||||
candidates: [candidate],
|
||||
errors: []
|
||||
})
|
||||
await scanPromise
|
||||
})
|
||||
|
||||
it('keeps append progress rows when terminal enrichment resolves out of order', async () => {
|
||||
const pending = deferred<WorkspaceCleanupScanResult>()
|
||||
const terminalProbe = deferred<boolean>()
|
||||
@@ -575,127 +378,4 @@ describe('workspace cleanup scan progress', () => {
|
||||
expect(store.getState().workspaceCleanupLoading).toBe(false)
|
||||
expect(store.getState().workspaceCleanupScan?.candidates).toEqual([refreshedCandidate])
|
||||
})
|
||||
|
||||
it('does not join broad cleanup scans with different explicit args', async () => {
|
||||
const firstPending = deferred<WorkspaceCleanupScanResult>()
|
||||
const secondPending = deferred<WorkspaceCleanupScanResult>()
|
||||
const firstResult = {
|
||||
scannedAt: NOW,
|
||||
candidates: [makeCandidate({ worktreeId: 'repo1::/tmp/first' })],
|
||||
errors: []
|
||||
}
|
||||
const secondResult = {
|
||||
scannedAt: NOW + 1,
|
||||
candidates: [makeCandidate({ worktreeId: 'repo1::/tmp/second' })],
|
||||
errors: []
|
||||
}
|
||||
const scan = vi.fn((args?: { skipGitWorktreeIds?: string[] }) =>
|
||||
args?.skipGitWorktreeIds?.includes('repo1::/tmp/first')
|
||||
? firstPending.promise
|
||||
: secondPending.promise
|
||||
)
|
||||
installWorkspaceCleanupApi(scan)
|
||||
const store = createCleanupTestStore()
|
||||
|
||||
const first = store
|
||||
.getState()
|
||||
.scanWorkspaceCleanup({ skipGitWorktreeIds: ['repo1::/tmp/first'] })
|
||||
const second = store
|
||||
.getState()
|
||||
.scanWorkspaceCleanup({ skipGitWorktreeIds: ['repo1::/tmp/second'] })
|
||||
|
||||
expect(scan).toHaveBeenCalledTimes(2)
|
||||
secondPending.resolve(secondResult)
|
||||
await second
|
||||
expect(store.getState().workspaceCleanupScan).toMatchObject(secondResult)
|
||||
|
||||
// Superseded scan settlement must neither surface nor overwrite the newer result.
|
||||
firstPending.resolve(firstResult)
|
||||
await expect(first).rejects.toBeInstanceOf(WorkspaceCleanupScanSupersededError)
|
||||
expect(store.getState().workspaceCleanupScan).toMatchObject(secondResult)
|
||||
})
|
||||
|
||||
it('does not join suggestion-only and full-workspace broad scans', async () => {
|
||||
const legacyPending = deferred<WorkspaceCleanupScanResult>()
|
||||
const fullPending = deferred<WorkspaceCleanupScanResult>()
|
||||
const scan = vi.fn((args?: { includeAllWorkspaces?: boolean }) =>
|
||||
args?.includeAllWorkspaces ? fullPending.promise : legacyPending.promise
|
||||
)
|
||||
installWorkspaceCleanupApi(scan)
|
||||
const store = createCleanupTestStore()
|
||||
|
||||
const legacy = store.getState().scanWorkspaceCleanup({ includeAllWorkspaces: false })
|
||||
const full = store.getState().scanWorkspaceCleanup({ includeAllWorkspaces: true })
|
||||
|
||||
expect(scan).toHaveBeenCalledTimes(2)
|
||||
fullPending.resolve({ scannedAt: NOW, candidates: [], errors: [] })
|
||||
legacyPending.resolve({ scannedAt: NOW - 1, candidates: [], errors: [] })
|
||||
await expect(Promise.all([legacy, full])).resolves.toHaveLength(2)
|
||||
})
|
||||
|
||||
it('restarts a superseded key instead of joining the cancelled scan', async () => {
|
||||
const firstPending = deferred<WorkspaceCleanupScanResult>()
|
||||
const secondPending = deferred<WorkspaceCleanupScanResult>()
|
||||
const scan = vi.fn((args?: { skipGitWorktreeIds?: string[] }) =>
|
||||
args?.skipGitWorktreeIds?.includes('second') ? secondPending.promise : firstPending.promise
|
||||
)
|
||||
installWorkspaceCleanupApi(scan)
|
||||
const store = createCleanupTestStore()
|
||||
|
||||
const first = store.getState().scanWorkspaceCleanup({ skipGitWorktreeIds: ['first'] })
|
||||
const second = store.getState().scanWorkspaceCleanup({ skipGitWorktreeIds: ['second'] })
|
||||
const firstAgain = store.getState().scanWorkspaceCleanup({ skipGitWorktreeIds: ['first'] })
|
||||
|
||||
// Reissued keys start fresh; they cannot join a superseded/cancelled fleet scan.
|
||||
expect(scan).toHaveBeenCalledTimes(3)
|
||||
firstPending.resolve({ scannedAt: NOW, candidates: [], errors: [] })
|
||||
secondPending.resolve({ scannedAt: NOW, candidates: [], errors: [] })
|
||||
await expect(first).rejects.toBeInstanceOf(WorkspaceCleanupScanSupersededError)
|
||||
await expect(second).rejects.toBeInstanceOf(WorkspaceCleanupScanSupersededError)
|
||||
await expect(firstAgain).resolves.toMatchObject({ scannedAt: NOW })
|
||||
})
|
||||
|
||||
it('keeps stale cleanup results visible after a broad refresh failure', async () => {
|
||||
const previous = { scannedAt: NOW, candidates: [makeCandidate()], errors: [] }
|
||||
const scan = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(previous)
|
||||
.mockRejectedValueOnce(new Error('scan failed'))
|
||||
installWorkspaceCleanupApi(scan)
|
||||
const store = createCleanupTestStore()
|
||||
|
||||
await store.getState().scanWorkspaceCleanup()
|
||||
await expect(store.getState().scanWorkspaceCleanup()).rejects.toThrow('scan failed')
|
||||
|
||||
expect(store.getState().workspaceCleanupScan).toMatchObject(previous)
|
||||
expect(store.getState().workspaceCleanupError).toBe('scan failed')
|
||||
expect(store.getState().workspaceCleanupLoading).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps focused cleanup preflight scans separate from broad scans', async () => {
|
||||
const broad = deferred<WorkspaceCleanupScanResult>()
|
||||
const scan = vi.fn((args?: { worktreeId?: string }) => {
|
||||
if (args?.worktreeId) {
|
||||
return Promise.resolve({
|
||||
scannedAt: NOW + 1,
|
||||
candidates: [makeCandidate({ worktreeId: args.worktreeId })],
|
||||
errors: []
|
||||
} satisfies WorkspaceCleanupScanResult)
|
||||
}
|
||||
return broad.promise
|
||||
})
|
||||
installWorkspaceCleanupApi(scan)
|
||||
const store = createCleanupTestStore()
|
||||
|
||||
const broadScan = store.getState().scanWorkspaceCleanup()
|
||||
const focusedScan = await store.getState().scanWorkspaceCleanup({ worktreeId: WORKTREE_ID })
|
||||
|
||||
expect(scan).toHaveBeenCalledTimes(2)
|
||||
expect(focusedScan.candidates[0]?.worktreeId).toBe(WORKTREE_ID)
|
||||
expect(store.getState().workspaceCleanupScan).toBeNull()
|
||||
|
||||
broad.resolve({ scannedAt: NOW, candidates: [], errors: [] })
|
||||
await broadScan
|
||||
expect(store.getState().workspaceCleanupScan?.scannedAt).toBe(NOW)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,44 +11,6 @@ import {
|
||||
} from './workspace-cleanup-slice-test-harness'
|
||||
|
||||
describe('workspace cleanup removal verification and consent', () => {
|
||||
it.each([null, NOW])(
|
||||
'fails closed when git evidence is unavailable with checkedAt=%s',
|
||||
async (checkedAt) => {
|
||||
const candidate = makeCandidate({
|
||||
executionHostId: 'local',
|
||||
blockers: ['git-status-error'],
|
||||
git: {
|
||||
clean: null,
|
||||
upstreamAhead: null,
|
||||
upstreamBehind: null,
|
||||
checkedAt
|
||||
}
|
||||
})
|
||||
installWorkspaceCleanupApi(
|
||||
vi.fn().mockResolvedValue({
|
||||
scannedAt: NOW,
|
||||
candidates: [candidate],
|
||||
errors: []
|
||||
})
|
||||
)
|
||||
const removeWorktree = vi.fn().mockResolvedValue({ ok: true })
|
||||
const store = createCleanupTestStore(removeWorktree)
|
||||
|
||||
const result = await store.getState().removeWorkspaceCleanupCandidates([WORKTREE_ID], {
|
||||
approvedCandidates: [candidate]
|
||||
})
|
||||
|
||||
expect(result.failures).toEqual([
|
||||
expect.objectContaining({
|
||||
worktreeId: WORKTREE_ID,
|
||||
canDeleteAnyway: true,
|
||||
message: expect.stringContaining("couldn't check this workspace's git status")
|
||||
})
|
||||
])
|
||||
expect(removeWorktree).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it('scopes repo-level scan failures to their execution host', async () => {
|
||||
const local = makeCandidate({ executionHostId: 'local' })
|
||||
const remote = makeCandidate({
|
||||
@@ -88,41 +50,6 @@ describe('workspace cleanup removal verification and consent', () => {
|
||||
expect(removeWorktree).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('fails every same-repo host when an older peer omits the error host', async () => {
|
||||
const local = makeCandidate({ executionHostId: 'local' })
|
||||
const remote = makeCandidate({
|
||||
executionHostId: 'ssh:remote',
|
||||
displayName: 'remote'
|
||||
})
|
||||
installWorkspaceCleanupApi(
|
||||
vi.fn().mockResolvedValue({
|
||||
scannedAt: NOW,
|
||||
candidates: [local, remote],
|
||||
errors: [
|
||||
{
|
||||
repoId: local.repoId,
|
||||
repoName: local.repoName,
|
||||
message: 'Git could not list worktrees.'
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
const removeWorktree = vi.fn().mockResolvedValue({ ok: true })
|
||||
const store = createCleanupTestStore(removeWorktree)
|
||||
|
||||
const result = await store
|
||||
.getState()
|
||||
.removeWorkspaceCleanupCandidates([WORKTREE_ID, WORKTREE_ID], {
|
||||
approvedCandidates: [local, remote]
|
||||
})
|
||||
|
||||
expect(result.failures).toHaveLength(2)
|
||||
expect(
|
||||
result.failures.every((failure) => failure.message.includes('older connected peer'))
|
||||
).toBe(true)
|
||||
expect(removeWorktree).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('honors one matching unverified-removal consent and rejects its replay', async () => {
|
||||
const candidate = makeCandidate({
|
||||
executionHostId: 'local',
|
||||
@@ -163,84 +90,6 @@ describe('workspace cleanup removal verification and consent', () => {
|
||||
expect(store.getState().beginUnverifiedRemovalConsent(identity)).toEqual(expect.any(String))
|
||||
})
|
||||
|
||||
it("does not let one row's consent authorize a different identity", async () => {
|
||||
const first = makeCandidate({
|
||||
executionHostId: 'local',
|
||||
blockers: ['git-status-error'],
|
||||
git: {
|
||||
clean: null,
|
||||
upstreamAhead: null,
|
||||
upstreamBehind: null,
|
||||
checkedAt: null
|
||||
}
|
||||
})
|
||||
const second = makeCandidate({
|
||||
worktreeId: 'repo1::/tmp/second',
|
||||
path: '/tmp/second',
|
||||
displayName: 'second',
|
||||
executionHostId: 'local',
|
||||
blockers: ['git-status-error'],
|
||||
git: {
|
||||
clean: null,
|
||||
upstreamAhead: null,
|
||||
upstreamBehind: null,
|
||||
checkedAt: null
|
||||
}
|
||||
})
|
||||
installWorkspaceCleanupApi(
|
||||
vi.fn(async (args: { worktreeIds?: string[] }) => ({
|
||||
scannedAt: NOW,
|
||||
candidates: [first, second].filter((candidate) =>
|
||||
args.worktreeIds?.includes(candidate.worktreeId)
|
||||
),
|
||||
errors: []
|
||||
}))
|
||||
)
|
||||
const removeWorktree = vi.fn().mockResolvedValue({ ok: true })
|
||||
const store = createCleanupTestStore(removeWorktree)
|
||||
const identity = getWorkspaceCleanupCandidateIdentity(first)
|
||||
const attemptId = store.getState().beginUnverifiedRemovalConsent(identity)!
|
||||
|
||||
const result = await store.getState().removeWorkspaceCleanupCandidates([second.worktreeId], {
|
||||
approvedCandidates: [second],
|
||||
unverifiedRemovalConsent: { identity, attemptId }
|
||||
})
|
||||
|
||||
expect(result.failures).toEqual([expect.objectContaining({ worktreeId: second.worktreeId })])
|
||||
expect(removeWorktree).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fails closed when the workspace host changes before consent is consumed', async () => {
|
||||
const approved = makeCandidate({
|
||||
executionHostId: 'local',
|
||||
blockers: ['git-status-error'],
|
||||
git: {
|
||||
clean: null,
|
||||
upstreamAhead: null,
|
||||
upstreamBehind: null,
|
||||
checkedAt: null
|
||||
}
|
||||
})
|
||||
const moved = makeCandidate({ ...approved, executionHostId: 'ssh:remote' })
|
||||
installWorkspaceCleanupApi(
|
||||
vi.fn().mockResolvedValue({ scannedAt: NOW, candidates: [moved], errors: [] })
|
||||
)
|
||||
const removeWorktree = vi.fn().mockResolvedValue({ ok: true })
|
||||
const store = createCleanupTestStore(removeWorktree)
|
||||
const identity = getWorkspaceCleanupCandidateIdentity(approved)
|
||||
const attemptId = store.getState().beginUnverifiedRemovalConsent(identity)!
|
||||
|
||||
const result = await store.getState().removeWorkspaceCleanupCandidates([WORKTREE_ID], {
|
||||
approvedCandidates: [approved],
|
||||
unverifiedRemovalConsent: { identity, attemptId }
|
||||
})
|
||||
|
||||
expect(result.failures).toEqual([
|
||||
expect.objectContaining({ message: 'Workspace no longer exists.' })
|
||||
])
|
||||
expect(removeWorktree).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps a live consent bound to its call when dialog state is cleared', async () => {
|
||||
const candidate = makeCandidate({
|
||||
executionHostId: 'local',
|
||||
@@ -271,146 +120,4 @@ describe('workspace cleanup removal verification and consent', () => {
|
||||
expect.objectContaining({ removedIdentities: [identity], failures: [] })
|
||||
)
|
||||
})
|
||||
|
||||
it('releases consent when an earlier queue guard refuses the row', async () => {
|
||||
const candidate = makeCandidate({
|
||||
executionHostId: 'local',
|
||||
blockers: ['main-worktree', 'git-status-error'],
|
||||
git: {
|
||||
clean: null,
|
||||
upstreamAhead: null,
|
||||
upstreamBehind: null,
|
||||
checkedAt: null
|
||||
}
|
||||
})
|
||||
installWorkspaceCleanupApi(
|
||||
vi.fn().mockResolvedValue({
|
||||
scannedAt: NOW,
|
||||
candidates: [candidate],
|
||||
errors: []
|
||||
})
|
||||
)
|
||||
const store = createCleanupTestStore()
|
||||
const identity = getWorkspaceCleanupCandidateIdentity(candidate)
|
||||
const attemptId = store.getState().beginUnverifiedRemovalConsent(identity)!
|
||||
|
||||
const result = await store.getState().removeWorkspaceCleanupCandidates([WORKTREE_ID], {
|
||||
approvedCandidates: [candidate],
|
||||
unverifiedRemovalConsent: { identity, attemptId }
|
||||
})
|
||||
|
||||
expect(result.failures).toHaveLength(1)
|
||||
expect(store.getState().beginUnverifiedRemovalConsent(identity)).toEqual(expect.any(String))
|
||||
})
|
||||
|
||||
it('allows different identities to hold unverified-removal grants concurrently', async () => {
|
||||
const candidates = [
|
||||
makeCandidate({
|
||||
executionHostId: 'local',
|
||||
blockers: ['git-status-error'],
|
||||
git: {
|
||||
clean: null,
|
||||
upstreamAhead: null,
|
||||
upstreamBehind: null,
|
||||
checkedAt: null
|
||||
}
|
||||
}),
|
||||
makeCandidate({
|
||||
worktreeId: 'repo1::/tmp/second',
|
||||
path: '/tmp/second',
|
||||
displayName: 'second',
|
||||
executionHostId: 'local',
|
||||
blockers: ['git-status-error'],
|
||||
git: {
|
||||
clean: null,
|
||||
upstreamAhead: null,
|
||||
upstreamBehind: null,
|
||||
checkedAt: null
|
||||
}
|
||||
})
|
||||
]
|
||||
installWorkspaceCleanupApi(
|
||||
vi.fn(async (args: { worktreeIds?: string[] }) => ({
|
||||
scannedAt: NOW,
|
||||
candidates: candidates.filter((candidate) =>
|
||||
args.worktreeIds?.includes(candidate.worktreeId)
|
||||
),
|
||||
errors: []
|
||||
}))
|
||||
)
|
||||
const removeWorktree = vi.fn().mockResolvedValue({ ok: true })
|
||||
const store = createCleanupTestStore(removeWorktree)
|
||||
const consents = candidates.map((candidate) => {
|
||||
const identity = getWorkspaceCleanupCandidateIdentity(candidate)
|
||||
return {
|
||||
identity,
|
||||
attemptId: store.getState().beginUnverifiedRemovalConsent(identity)!
|
||||
}
|
||||
})
|
||||
|
||||
const results = await Promise.all(
|
||||
candidates.map((candidate, index) =>
|
||||
store.getState().removeWorkspaceCleanupCandidates([candidate.worktreeId], {
|
||||
approvedCandidates: [candidate],
|
||||
unverifiedRemovalConsent: consents[index]
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
expect(results.flatMap((result) => result.removedIdentities)).toEqual(
|
||||
expect.arrayContaining(candidates.map(getWorkspaceCleanupCandidateIdentity))
|
||||
)
|
||||
expect(removeWorktree).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('requires recorded approval before an id-only force removal', async () => {
|
||||
const candidate = makeCandidate({
|
||||
blockers: ['unknown-base'],
|
||||
git: { clean: true, upstreamAhead: 0, upstreamBehind: 0, checkedAt: NOW }
|
||||
})
|
||||
installWorkspaceCleanupApi(
|
||||
vi.fn().mockResolvedValue({
|
||||
scannedAt: NOW,
|
||||
candidates: [candidate],
|
||||
errors: []
|
||||
})
|
||||
)
|
||||
const removeWorktree = vi.fn().mockResolvedValue({ ok: true })
|
||||
const store = createCleanupTestStore(removeWorktree)
|
||||
|
||||
const result = await store.getState().removeWorkspaceCleanupCandidates([WORKTREE_ID])
|
||||
|
||||
expect(result.failures).toEqual([
|
||||
expect.objectContaining({
|
||||
message: 'Review and confirm this workspace before force deleting it.'
|
||||
})
|
||||
])
|
||||
expect(removeWorktree).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['dismissed', makeCandidate({ executionHostId: 'local', blockers: ['dismissed'] })],
|
||||
['recently active', makeCandidate({ executionHostId: 'local', reasons: [] })]
|
||||
])('removes an explicitly selected %s row', async (_label, approvedCandidate) => {
|
||||
const freshCandidate = makeCandidate({
|
||||
...approvedCandidate,
|
||||
blockers: approvedCandidate.blockers.filter((blocker) => blocker !== 'dismissed')
|
||||
})
|
||||
installWorkspaceCleanupApi(
|
||||
vi.fn().mockResolvedValue({
|
||||
scannedAt: NOW,
|
||||
candidates: [freshCandidate],
|
||||
errors: []
|
||||
})
|
||||
)
|
||||
const removeWorktree = vi.fn().mockResolvedValue({ ok: true })
|
||||
const store = createCleanupTestStore(removeWorktree)
|
||||
|
||||
const result = await store.getState().removeWorkspaceCleanupCandidates([WORKTREE_ID], {
|
||||
approvedCandidates: [approvedCandidate]
|
||||
})
|
||||
|
||||
expect(result.removedIds).toEqual([WORKTREE_ID])
|
||||
expect(removeWorktree).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,9 +8,6 @@ import type { ExecutionHostId } from '../../../../shared/execution-host'
|
||||
const NOW = 1_700_000_000_000
|
||||
const HOST_A_HOST_ID: ExecutionHostId = 'local'
|
||||
const HOST_B_HOST_ID: ExecutionHostId = 'ssh:ssh-1'
|
||||
const HOST_UNRESOLVED_MESSAGE =
|
||||
'Orca cannot tell which host owns this workspace. Refresh projects and review it again.'
|
||||
const WORKSPACE_GONE_MESSAGE = 'Workspace no longer exists.'
|
||||
|
||||
const mockApi = {
|
||||
worktrees: {
|
||||
@@ -37,7 +34,7 @@ const mockApi = {
|
||||
// @ts-expect-error -- minimal window.api stub for the store under test
|
||||
globalThis.window = { api: mockApi }
|
||||
|
||||
import { createTestStore, seedStore, makeTab, makeWorktree } from './store-test-helpers'
|
||||
import { createTestStore, seedStore, makeWorktree } from './store-test-helpers'
|
||||
import { resetAuthoritativelyRemovedWorktreeMemoryForTests } from './worktrees'
|
||||
import type { AppState } from '../types'
|
||||
import type {
|
||||
@@ -164,134 +161,6 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('STA-4343: cleanup deletes on the confirmed host, never the active one', () => {
|
||||
it('confirming host B deletes host B and leaves ACTIVE host A intact', async () => {
|
||||
const hosts = createHostDirectories()
|
||||
const routedHostIds: string[] = []
|
||||
installRemovalTransport(
|
||||
{ [HOST_A_HOST_ID]: hosts.hostARoot, [HOST_B_HOST_ID]: hosts.hostBRoot },
|
||||
routedHostIds
|
||||
)
|
||||
const hostBCandidate = makeHostCandidate(HOST_B_HOST_ID)
|
||||
seedScan([makeHostCandidate(HOST_A_HOST_ID), hostBCandidate])
|
||||
|
||||
const store = createTestStore()
|
||||
// Host A owns the ACTIVE workspace, so removal routing prefers host A.
|
||||
seedStore(store, {
|
||||
activeWorktreeId: WORKTREE_ID,
|
||||
activeWorkspaceExecutionHostId: HOST_A_HOST_ID,
|
||||
worktreesByRepo: {
|
||||
repo1: [
|
||||
makeWorktree({
|
||||
id: WORKTREE_ID,
|
||||
repoId: 'repo1',
|
||||
path: WORKTREE_PATH,
|
||||
hostId: HOST_A_HOST_ID
|
||||
}),
|
||||
makeWorktree({
|
||||
id: WORKTREE_ID,
|
||||
repoId: 'repo1',
|
||||
path: WORKTREE_PATH,
|
||||
hostId: HOST_B_HOST_ID
|
||||
})
|
||||
]
|
||||
},
|
||||
tabsByWorktree: { [WORKTREE_ID]: [makeTab({ id: 'host-a-tab', worktreeId: WORKTREE_ID })] }
|
||||
} as Partial<AppState>)
|
||||
|
||||
const removal = await store
|
||||
.getState()
|
||||
.removeWorkspaceCleanupCandidates([WORKTREE_ID], { approvedCandidates: [hostBCandidate] })
|
||||
|
||||
expect(
|
||||
fs.existsSync(hosts.hostAMarkerPath),
|
||||
`HOST A (active) worktree directory must still exist after confirming HOST B's row. worktrees.remove was routed to hostId=${routedHostIds.join(',')}`
|
||||
).toBe(true)
|
||||
expect(
|
||||
fs.existsSync(hosts.hostBMarkerPath),
|
||||
"HOST B's directory must be gone — its row is what the user confirmed"
|
||||
).toBe(false)
|
||||
expect(routedHostIds).toEqual([HOST_B_HOST_ID])
|
||||
expect(removal.removedIds).toEqual([WORKTREE_ID])
|
||||
expect(removal.failures).toEqual([])
|
||||
expect(store.getState().tabsByWorktree[WORKTREE_ID]).toEqual([
|
||||
expect.objectContaining({ id: 'host-a-tab' })
|
||||
])
|
||||
})
|
||||
|
||||
it('refuses a row whose confirmed host no longer appears in the refreshed scan', async () => {
|
||||
const hosts = createHostDirectories()
|
||||
const routedHostIds: string[] = []
|
||||
installRemovalTransport(
|
||||
{ [HOST_A_HOST_ID]: hosts.hostARoot, [HOST_B_HOST_ID]: hosts.hostBRoot },
|
||||
routedHostIds
|
||||
)
|
||||
// The refreshed scan only reaches host A, but host B's row was confirmed.
|
||||
seedScan([makeHostCandidate(HOST_A_HOST_ID)])
|
||||
|
||||
const store = createTestStore()
|
||||
seedStore(store, {
|
||||
worktreesByRepo: {
|
||||
repo1: [
|
||||
makeWorktree({
|
||||
id: WORKTREE_ID,
|
||||
repoId: 'repo1',
|
||||
path: WORKTREE_PATH,
|
||||
hostId: HOST_A_HOST_ID
|
||||
})
|
||||
]
|
||||
}
|
||||
} as Partial<AppState>)
|
||||
|
||||
const removal = await store.getState().removeWorkspaceCleanupCandidates([WORKTREE_ID], {
|
||||
approvedCandidates: [makeHostCandidate(HOST_B_HOST_ID)]
|
||||
})
|
||||
|
||||
expect(fs.existsSync(hosts.hostAMarkerPath), 'host A must survive').toBe(true)
|
||||
expect(fs.existsSync(hosts.hostBMarkerPath), 'host B must survive').toBe(true)
|
||||
expect(routedHostIds).toEqual([])
|
||||
// Missing confirmed-host row must fail; another host’s scan cannot decide force/blockers for it.
|
||||
expect(removal.failures).toEqual([
|
||||
{
|
||||
worktreeId: WORKTREE_ID,
|
||||
executionHostId: HOST_B_HOST_ID,
|
||||
displayName: 'shared-workspace',
|
||||
message: WORKSPACE_GONE_MESSAGE
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('refuses a row that carries no host evidence at all', async () => {
|
||||
const hosts = createHostDirectories()
|
||||
const routedHostIds: string[] = []
|
||||
installRemovalTransport({ [HOST_A_HOST_ID]: hosts.hostARoot }, routedHostIds)
|
||||
const unqualifiedCandidate = makeHostCandidate(undefined, null)
|
||||
seedScan([unqualifiedCandidate])
|
||||
|
||||
const store = createTestStore()
|
||||
seedStore(store, {
|
||||
worktreesByRepo: {
|
||||
repo1: [
|
||||
makeWorktree({
|
||||
id: WORKTREE_ID,
|
||||
repoId: 'repo1',
|
||||
path: WORKTREE_PATH,
|
||||
hostId: HOST_A_HOST_ID
|
||||
})
|
||||
]
|
||||
}
|
||||
} as Partial<AppState>)
|
||||
|
||||
const removal = await store.getState().removeWorkspaceCleanupCandidates([WORKTREE_ID], {
|
||||
approvedCandidates: [unqualifiedCandidate]
|
||||
})
|
||||
|
||||
expect(fs.existsSync(hosts.hostAMarkerPath), 'host A must survive').toBe(true)
|
||||
expect(routedHostIds).toEqual([])
|
||||
expect(removal.failures).toEqual([
|
||||
{ worktreeId: WORKTREE_ID, displayName: 'shared-workspace', message: HOST_UNRESOLVED_MESSAGE }
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps routing to the confirmed host after an earlier removal changes the active one', async () => {
|
||||
const hosts = createHostDirectories()
|
||||
const routedHostIds: string[] = []
|
||||
@@ -355,185 +224,6 @@ describe('STA-4343: cleanup deletes on the confirmed host, never the active one'
|
||||
})
|
||||
})
|
||||
|
||||
describe.each([
|
||||
{ label: 'normal', force: false },
|
||||
{ label: 'force', force: true }
|
||||
])('STA-4343: confirmed-host routing holds under $label removal', ({ force }) => {
|
||||
it('deletes the confirmed remote row while the ACTIVE local one survives', async () => {
|
||||
const hosts = createHostDirectories()
|
||||
const routedHostIds: string[] = []
|
||||
const routedForce: boolean[] = []
|
||||
installRemovalTransport(
|
||||
{ [HOST_A_HOST_ID]: hosts.hostARoot, [HOST_B_HOST_ID]: hosts.hostBRoot },
|
||||
routedHostIds,
|
||||
routedForce
|
||||
)
|
||||
const hostBCandidate = makeHostCandidate(HOST_B_HOST_ID, undefined, WORKTREE_ID, force)
|
||||
seedScan([makeHostCandidate(HOST_A_HOST_ID, undefined, WORKTREE_ID, force), hostBCandidate])
|
||||
|
||||
const store = createTestStore()
|
||||
seedStore(store, {
|
||||
// The ACTIVE workspace is host A's, so routing prefers host A on its own.
|
||||
activeWorktreeId: WORKTREE_ID,
|
||||
activeWorkspaceExecutionHostId: HOST_A_HOST_ID,
|
||||
worktreesByRepo: {
|
||||
repo1: [
|
||||
makeWorktree({
|
||||
id: WORKTREE_ID,
|
||||
repoId: 'repo1',
|
||||
path: WORKTREE_PATH,
|
||||
hostId: HOST_A_HOST_ID
|
||||
}),
|
||||
makeWorktree({
|
||||
id: WORKTREE_ID,
|
||||
repoId: 'repo1',
|
||||
path: WORKTREE_PATH,
|
||||
hostId: HOST_B_HOST_ID
|
||||
})
|
||||
]
|
||||
}
|
||||
} as Partial<AppState>)
|
||||
|
||||
const removal = await store
|
||||
.getState()
|
||||
.removeWorkspaceCleanupCandidates([WORKTREE_ID], { approvedCandidates: [hostBCandidate] })
|
||||
|
||||
expect(removal.failures).toEqual([])
|
||||
expect(removal.removedIds).toEqual([WORKTREE_ID])
|
||||
expect(routedHostIds).toEqual([HOST_B_HOST_ID])
|
||||
expect(routedForce).toEqual([force])
|
||||
expect(fs.existsSync(hosts.hostBMarkerPath), 'confirmed host B must be gone').toBe(false)
|
||||
expect(fs.existsSync(hosts.hostAMarkerPath), 'ACTIVE host A must survive').toBe(true)
|
||||
})
|
||||
|
||||
it('still deletes an ordinary single-host workspace', async () => {
|
||||
const hosts = createHostDirectories()
|
||||
const routedHostIds: string[] = []
|
||||
const routedForce: boolean[] = []
|
||||
installRemovalTransport({ [HOST_A_HOST_ID]: hosts.hostARoot }, routedHostIds, routedForce)
|
||||
const candidate = makeHostCandidate(HOST_A_HOST_ID, undefined, WORKTREE_ID, force)
|
||||
seedScan([candidate])
|
||||
|
||||
const store = createTestStore()
|
||||
seedStore(store, {
|
||||
worktreesByRepo: {
|
||||
repo1: [
|
||||
makeWorktree({
|
||||
id: WORKTREE_ID,
|
||||
repoId: 'repo1',
|
||||
path: WORKTREE_PATH,
|
||||
hostId: HOST_A_HOST_ID
|
||||
})
|
||||
]
|
||||
}
|
||||
} as Partial<AppState>)
|
||||
|
||||
const removal = await store
|
||||
.getState()
|
||||
.removeWorkspaceCleanupCandidates([WORKTREE_ID], { approvedCandidates: [candidate] })
|
||||
|
||||
expect(removal.failures).toEqual([])
|
||||
expect(removal.removedIds).toEqual([WORKTREE_ID])
|
||||
expect(routedForce).toEqual([force])
|
||||
expect(fs.existsSync(hosts.hostAMarkerPath)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('STA-4343: ordinary single-host cleanup still deletes', () => {
|
||||
it('deletes the confirmed local workspace for real', async () => {
|
||||
const hosts = createHostDirectories()
|
||||
const routedHostIds: string[] = []
|
||||
installRemovalTransport({ [HOST_A_HOST_ID]: hosts.hostARoot }, routedHostIds)
|
||||
const hostACandidate = makeHostCandidate(HOST_A_HOST_ID)
|
||||
seedScan([hostACandidate])
|
||||
|
||||
const store = createTestStore()
|
||||
seedStore(store, {
|
||||
worktreesByRepo: {
|
||||
repo1: [
|
||||
makeWorktree({
|
||||
id: WORKTREE_ID,
|
||||
repoId: 'repo1',
|
||||
path: WORKTREE_PATH,
|
||||
hostId: HOST_A_HOST_ID
|
||||
})
|
||||
]
|
||||
}
|
||||
} as Partial<AppState>)
|
||||
|
||||
const removal = await store
|
||||
.getState()
|
||||
.removeWorkspaceCleanupCandidates([WORKTREE_ID], { approvedCandidates: [hostACandidate] })
|
||||
|
||||
expect(removal.failures).toEqual([])
|
||||
expect(removal.removedIds).toEqual([WORKTREE_ID])
|
||||
expect(routedHostIds).toEqual([HOST_A_HOST_ID])
|
||||
expect(fs.existsSync(hosts.hostAMarkerPath)).toBe(false)
|
||||
})
|
||||
|
||||
it('deletes the confirmed SSH workspace for real', async () => {
|
||||
const hosts = createHostDirectories()
|
||||
const routedHostIds: string[] = []
|
||||
installRemovalTransport({ [HOST_B_HOST_ID]: hosts.hostBRoot }, routedHostIds)
|
||||
const hostBCandidate = makeHostCandidate(HOST_B_HOST_ID)
|
||||
seedScan([hostBCandidate])
|
||||
|
||||
const store = createTestStore()
|
||||
seedStore(store, {
|
||||
worktreesByRepo: {
|
||||
repo1: [
|
||||
makeWorktree({
|
||||
id: WORKTREE_ID,
|
||||
repoId: 'repo1',
|
||||
path: WORKTREE_PATH,
|
||||
hostId: HOST_B_HOST_ID
|
||||
})
|
||||
]
|
||||
}
|
||||
} as Partial<AppState>)
|
||||
|
||||
const removal = await store
|
||||
.getState()
|
||||
.removeWorkspaceCleanupCandidates([WORKTREE_ID], { approvedCandidates: [hostBCandidate] })
|
||||
|
||||
expect(removal.failures).toEqual([])
|
||||
expect(removal.removedIds).toEqual([WORKTREE_ID])
|
||||
expect(routedHostIds).toEqual([HOST_B_HOST_ID])
|
||||
expect(fs.existsSync(hosts.hostBMarkerPath)).toBe(false)
|
||||
})
|
||||
|
||||
it('deletes a host-qualified snapshot row after the refreshed owner matches', async () => {
|
||||
const hosts = createHostDirectories()
|
||||
const routedHostIds: string[] = []
|
||||
installRemovalTransport({ [HOST_A_HOST_ID]: hosts.hostARoot }, routedHostIds)
|
||||
const snapshotCandidate = makeHostCandidate(HOST_A_HOST_ID)
|
||||
seedScan([makeHostCandidate(HOST_A_HOST_ID)])
|
||||
|
||||
const store = createTestStore()
|
||||
seedStore(store, {
|
||||
worktreesByRepo: {
|
||||
repo1: [
|
||||
makeWorktree({
|
||||
id: WORKTREE_ID,
|
||||
repoId: 'repo1',
|
||||
path: WORKTREE_PATH,
|
||||
hostId: HOST_A_HOST_ID
|
||||
})
|
||||
]
|
||||
}
|
||||
} as Partial<AppState>)
|
||||
|
||||
const removal = await store.getState().removeWorkspaceCleanupCandidates([WORKTREE_ID], {
|
||||
approvedCandidates: [snapshotCandidate]
|
||||
})
|
||||
|
||||
expect(removal.failures).toEqual([])
|
||||
expect(removal.removedIds).toEqual([WORKTREE_ID])
|
||||
expect(routedHostIds).toEqual([HOST_A_HOST_ID])
|
||||
expect(fs.existsSync(hosts.hostAMarkerPath)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
import * as launchDrafts from '@/components/native-chat/native-chat-composer-draft-store'
|
||||
import * as pendingChips from '@/components/native-chat/native-chat-pending-attachment-cache'
|
||||
|
||||
|
||||
@@ -203,51 +203,6 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('STA-4343 wrong-host cleanup removal (git worktree identity)', () => {
|
||||
it('tears down only the confirmed host VM when another VM owns the same id', async () => {
|
||||
const worktreeId = 'repo1::/shared/workspace/path'
|
||||
const worktreePath = '/shared/workspace/path'
|
||||
const hostA: ExecutionHostId = 'ssh:runtime-ssh-a'
|
||||
const hostB: ExecutionHostId = 'ssh:runtime-ssh-b'
|
||||
const hosts = createHostDirectories(worktreeId)
|
||||
installRemovalTransports({ [hostA]: hosts.hostARoot, [hostB]: hosts.hostBRoot }, [])
|
||||
mockApi.ephemeralVm.listRuntimes.mockResolvedValue([
|
||||
{
|
||||
id: 'runtime-a',
|
||||
workspaceId: worktreeId,
|
||||
sshTargetId: 'runtime-ssh-a',
|
||||
cleanupStatus: 'not_started'
|
||||
},
|
||||
{
|
||||
id: 'runtime-b',
|
||||
workspaceId: worktreeId,
|
||||
sshTargetId: 'runtime-ssh-b',
|
||||
cleanupStatus: 'not_started'
|
||||
}
|
||||
])
|
||||
const hostBCandidate = makeHostCandidate(worktreeId, hostB, worktreePath, 'runtime-ssh-b')
|
||||
seedCollidingScan([
|
||||
makeHostCandidate(worktreeId, hostA, worktreePath, 'runtime-ssh-a'),
|
||||
hostBCandidate
|
||||
])
|
||||
const store = createTestStore()
|
||||
seedStore(store, {
|
||||
worktreesByRepo: {
|
||||
repo1: [
|
||||
makeWorktree({ id: worktreeId, repoId: 'repo1', path: worktreePath, hostId: hostA }),
|
||||
makeWorktree({ id: worktreeId, repoId: 'repo1', path: worktreePath, hostId: hostB })
|
||||
]
|
||||
}
|
||||
} as Partial<AppState>)
|
||||
|
||||
const removal = await store
|
||||
.getState()
|
||||
.removeWorkspaceCleanupCandidates([worktreeId], { approvedCandidates: [hostBCandidate] })
|
||||
|
||||
expect(removal.failures).toEqual([])
|
||||
expect(mockApi.ephemeralVm.cleanup).toHaveBeenCalledTimes(1)
|
||||
expect(mockApi.ephemeralVm.cleanup).toHaveBeenCalledWith({ runtimeId: 'runtime-b' })
|
||||
})
|
||||
|
||||
it('confirming host B row deletes host B and leaves ACTIVE host A intact', async () => {
|
||||
const worktreeId = 'repo1::/shared/workspace/path'
|
||||
const worktreePath = '/shared/workspace/path'
|
||||
@@ -387,76 +342,6 @@ describe('STA-4343 wrong-host cleanup removal (git worktree identity)', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('soundness control: with host B ACTIVE, confirming host B still deletes only host B', async () => {
|
||||
const worktreeId = 'repo1::/shared/workspace/path'
|
||||
const worktreePath = '/shared/workspace/path'
|
||||
const hosts = createHostDirectories(worktreeId)
|
||||
const routedHostIds: string[] = []
|
||||
installRemovalTransports(
|
||||
{ [HOST_A_HOST_ID]: hosts.hostARoot, [HOST_B_HOST_ID]: hosts.hostBRoot },
|
||||
routedHostIds
|
||||
)
|
||||
const hostBCandidate = makeHostCandidate(worktreeId, HOST_B_HOST_ID, worktreePath)
|
||||
seedCollidingScan([makeHostCandidate(worktreeId, HOST_A_HOST_ID, worktreePath), hostBCandidate])
|
||||
|
||||
const store = createTestStore()
|
||||
seedStore(store, {
|
||||
activeWorktreeId: worktreeId,
|
||||
activeWorkspaceExecutionHostId: HOST_B_HOST_ID
|
||||
} as Partial<AppState>)
|
||||
|
||||
const removal = await store
|
||||
.getState()
|
||||
.removeWorkspaceCleanupCandidates([worktreeId], { approvedCandidates: [hostBCandidate] })
|
||||
|
||||
expect(removal.failures).toEqual([])
|
||||
expect(fs.existsSync(hosts.hostBMarkerPath)).toBe(false)
|
||||
expect(fs.existsSync(hosts.hostAMarkerPath), 'host A must survive').toBe(true)
|
||||
expect(routedHostIds).toEqual([HOST_B_HOST_ID])
|
||||
expect(store.getState().activeWorktreeId).toBe(worktreeId)
|
||||
expect(store.getState().activeWorkspaceExecutionHostId).toBe(HOST_A_HOST_ID)
|
||||
})
|
||||
|
||||
it('soundness control: confirming ACTIVE host A deletes host A and leaves host B intact', async () => {
|
||||
const worktreeId = 'repo1::/shared/workspace/path'
|
||||
const worktreePath = '/shared/workspace/path'
|
||||
const hosts = createHostDirectories(worktreeId)
|
||||
const routedHostIds: string[] = []
|
||||
installRemovalTransports(
|
||||
{ [HOST_A_HOST_ID]: hosts.hostARoot, [HOST_B_HOST_ID]: hosts.hostBRoot },
|
||||
routedHostIds
|
||||
)
|
||||
const hostACandidate = makeHostCandidate(worktreeId, HOST_A_HOST_ID, worktreePath)
|
||||
seedCollidingScan([hostACandidate, makeHostCandidate(worktreeId, HOST_B_HOST_ID, worktreePath)])
|
||||
|
||||
const store = createTestStore()
|
||||
seedStore(store, {
|
||||
activeWorktreeId: worktreeId,
|
||||
activeWorkspaceExecutionHostId: HOST_A_HOST_ID,
|
||||
worktreesByRepo: {
|
||||
repo1: [
|
||||
makeWorktree({
|
||||
id: worktreeId,
|
||||
repoId: 'repo1',
|
||||
path: worktreePath,
|
||||
hostId: HOST_A_HOST_ID
|
||||
})
|
||||
]
|
||||
}
|
||||
} as Partial<AppState>)
|
||||
|
||||
const removal = await store
|
||||
.getState()
|
||||
.removeWorkspaceCleanupCandidates([worktreeId], { approvedCandidates: [hostACandidate] })
|
||||
|
||||
expect(removal.failures).toEqual([])
|
||||
expect(fs.existsSync(hosts.hostAMarkerPath)).toBe(false)
|
||||
expect(fs.existsSync(hosts.hostBMarkerPath), 'host B must survive').toBe(true)
|
||||
expect(routedHostIds).toEqual([HOST_A_HOST_ID])
|
||||
expect(store.getState().activeWorktreeId).toBe(worktreeId)
|
||||
expect(store.getState().activeWorkspaceExecutionHostId).toBe(HOST_B_HOST_ID)
|
||||
})
|
||||
|
||||
it('tears down shared state when cleanup confirms both same-id host rows', async () => {
|
||||
const worktreeId = 'repo1::/shared/workspace/path'
|
||||
const worktreePath = '/shared/workspace/path'
|
||||
@@ -536,166 +421,9 @@ describe('STA-4343 wrong-host cleanup removal (git worktree identity)', () => {
|
||||
expect(store.getState().tabsByWorktree[worktreeId]).toBeUndefined()
|
||||
expect(store.getState().activeWorktreeId).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the confirmed host on the preserved-branch follow-up', async () => {
|
||||
const worktreeId = 'repo1::/shared/workspace/path'
|
||||
const worktreePath = '/shared/workspace/path'
|
||||
const hosts = createHostDirectories(worktreeId)
|
||||
const routedHostIds: string[] = []
|
||||
installRemovalTransports(
|
||||
{ [HOST_A_HOST_ID]: hosts.hostARoot, [HOST_B_HOST_ID]: hosts.hostBRoot },
|
||||
routedHostIds,
|
||||
{ preservedBranch: { branchName: 'feature/kept', head: 'abc123' } }
|
||||
)
|
||||
const hostBCandidate = makeHostCandidate(worktreeId, HOST_B_HOST_ID, worktreePath)
|
||||
seedCollidingScan([makeHostCandidate(worktreeId, HOST_A_HOST_ID, worktreePath), hostBCandidate])
|
||||
const store = createTestStore()
|
||||
seedStore(store, {
|
||||
activeWorktreeId: worktreeId,
|
||||
activeWorkspaceExecutionHostId: HOST_A_HOST_ID
|
||||
} as Partial<AppState>)
|
||||
|
||||
const removal = await store
|
||||
.getState()
|
||||
.removeWorkspaceCleanupCandidates([worktreeId], { approvedCandidates: [hostBCandidate] })
|
||||
|
||||
expect(removal.preservedBranches).toEqual([
|
||||
{
|
||||
worktreeId,
|
||||
branchName: 'feature/kept',
|
||||
expectedHead: 'abc123',
|
||||
hostId: HOST_B_HOST_ID
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('routes a Windows-path colliding identity to the confirmed host', async () => {
|
||||
const worktreeId = 'repo1::C:\\Users\\me\\workspaces\\shared'
|
||||
const worktreePath = 'C:\\Users\\me\\workspaces\\shared'
|
||||
const hosts = createHostDirectories(worktreeId)
|
||||
const routedHostIds: string[] = []
|
||||
installRemovalTransports(
|
||||
{ [HOST_A_HOST_ID]: hosts.hostARoot, [HOST_B_HOST_ID]: hosts.hostBRoot },
|
||||
routedHostIds
|
||||
)
|
||||
const hostBCandidate = makeHostCandidate(worktreeId, HOST_B_HOST_ID, worktreePath)
|
||||
seedCollidingScan([makeHostCandidate(worktreeId, HOST_A_HOST_ID, worktreePath), hostBCandidate])
|
||||
|
||||
const store = createTestStore()
|
||||
seedStore(store, {
|
||||
activeWorktreeId: worktreeId,
|
||||
activeWorkspaceExecutionHostId: HOST_A_HOST_ID,
|
||||
worktreesByRepo: {
|
||||
repo1: [
|
||||
makeWorktree({
|
||||
id: worktreeId,
|
||||
repoId: 'repo1',
|
||||
path: worktreePath,
|
||||
hostId: HOST_A_HOST_ID
|
||||
})
|
||||
]
|
||||
}
|
||||
} as Partial<AppState>)
|
||||
|
||||
const removal = await store
|
||||
.getState()
|
||||
.removeWorkspaceCleanupCandidates([worktreeId], { approvedCandidates: [hostBCandidate] })
|
||||
|
||||
expect(removal.failures).toEqual([])
|
||||
expect(fs.existsSync(hosts.hostAMarkerPath), 'host A (active) must survive').toBe(true)
|
||||
expect(fs.existsSync(hosts.hostBMarkerPath)).toBe(false)
|
||||
expect(routedHostIds).toEqual([HOST_B_HOST_ID])
|
||||
})
|
||||
})
|
||||
|
||||
describe('STA-4343 wrong-host cleanup removal (folder workspace identity)', () => {
|
||||
it('folder workspace identity deletes the confirmed host, not the ACTIVE one', async () => {
|
||||
const worktreeId = 'repo1::/shared/folder-workspace'
|
||||
const worktreePath = '/shared/folder-workspace'
|
||||
const hosts = createHostDirectories(worktreeId)
|
||||
const routedHostIds: string[] = []
|
||||
installRemovalTransports(
|
||||
{ [HOST_A_HOST_ID]: hosts.hostARoot, [HOST_B_HOST_ID]: hosts.hostBRoot },
|
||||
routedHostIds
|
||||
)
|
||||
const hostBCandidate = makeHostCandidate(worktreeId, HOST_B_HOST_ID, worktreePath)
|
||||
seedCollidingScan([makeHostCandidate(worktreeId, HOST_A_HOST_ID, worktreePath), hostBCandidate])
|
||||
|
||||
const store = createTestStore()
|
||||
// Folder workspaces are not Git worktrees: no worktree owner rows exist.
|
||||
seedStore(store, {
|
||||
activeWorktreeId: worktreeId,
|
||||
activeWorkspaceExecutionHostId: HOST_A_HOST_ID,
|
||||
tabsByWorktree: {
|
||||
[worktreeId]: [makeTab({ id: 'host-a-folder-tab', worktreeId })]
|
||||
}
|
||||
} as Partial<AppState>)
|
||||
|
||||
const removal = await store
|
||||
.getState()
|
||||
.removeWorkspaceCleanupCandidates([worktreeId], { approvedCandidates: [hostBCandidate] })
|
||||
|
||||
expect(removal.removedIds).toEqual([worktreeId])
|
||||
expect(removal.failures).toEqual([])
|
||||
expect(
|
||||
fs.existsSync(hosts.hostAMarkerPath),
|
||||
`HOST A (active) folder workspace must still exist. worktrees.remove was routed to hostId=${routedHostIds.join(',')}`
|
||||
).toBe(true)
|
||||
expect(fs.existsSync(hosts.hostBMarkerPath)).toBe(false)
|
||||
expect(routedHostIds).toEqual([HOST_B_HOST_ID])
|
||||
expect(store.getState().tabsByWorktree[worktreeId]).toEqual([
|
||||
expect.objectContaining({ id: 'host-a-folder-tab' })
|
||||
])
|
||||
expect(store.getState().activeWorktreeId).toBe(worktreeId)
|
||||
})
|
||||
})
|
||||
|
||||
describe('STA-4343 wrong-host cleanup removal (paired runtime host)', () => {
|
||||
it('confirming a paired runtime row deletes over the runtime RPC, not the local host', async () => {
|
||||
const worktreeId = 'repo1::/shared/workspace/path'
|
||||
const worktreePath = '/shared/workspace/path'
|
||||
const hosts = createHostDirectories(worktreeId)
|
||||
const routedHostIds: string[] = []
|
||||
installRemovalTransports(
|
||||
{ [HOST_A_HOST_ID]: hosts.hostARoot, [HOST_B_RUNTIME_HOST_ID]: hosts.hostBRoot },
|
||||
routedHostIds
|
||||
)
|
||||
const hostBCandidate = makeHostCandidate(worktreeId, HOST_B_RUNTIME_HOST_ID, worktreePath, null)
|
||||
seedCollidingScan([makeHostCandidate(worktreeId, HOST_A_HOST_ID, worktreePath), hostBCandidate])
|
||||
|
||||
const store = createTestStore()
|
||||
seedStore(store, {
|
||||
activeWorktreeId: worktreeId,
|
||||
activeWorkspaceExecutionHostId: HOST_A_HOST_ID,
|
||||
worktreesByRepo: {
|
||||
repo1: [
|
||||
makeWorktree({
|
||||
id: worktreeId,
|
||||
repoId: 'repo1',
|
||||
path: worktreePath,
|
||||
hostId: HOST_A_HOST_ID
|
||||
})
|
||||
]
|
||||
}
|
||||
} as Partial<AppState>)
|
||||
|
||||
const removal = await store
|
||||
.getState()
|
||||
.removeWorkspaceCleanupCandidates([worktreeId], { approvedCandidates: [hostBCandidate] })
|
||||
|
||||
expect(removal.failures).toEqual([])
|
||||
expect(fs.existsSync(hosts.hostAMarkerPath), 'local host A must survive').toBe(true)
|
||||
expect(fs.existsSync(hosts.hostBMarkerPath)).toBe(false)
|
||||
expect(routedHostIds).toEqual([HOST_B_RUNTIME_HOST_ID])
|
||||
expect(mockApi.worktrees.remove).not.toHaveBeenCalled()
|
||||
expect(runtimeRpc.callRuntimeRpc).toHaveBeenCalledWith(
|
||||
{ kind: 'environment', environmentId: 'hub-1' },
|
||||
'worktree.rm',
|
||||
expect.objectContaining({ hostId: HOST_B_RUNTIME_HOST_ID }),
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
// Older paired hosts may discard hostId and delete another same-ID owner; refuse collisions until capability proves host-qualified routing.
|
||||
it('refuses a colliding removal over the paired transport rather than trusting it', async () => {
|
||||
const worktreeId = 'repo1::/shared/workspace/path'
|
||||
@@ -744,112 +472,3 @@ describe('STA-4343 wrong-host cleanup removal (paired runtime host)', () => {
|
||||
expect(fs.existsSync(hosts.hostBMarkerPath), 'runtime host B must survive').toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('STA-4343 cleanup removal fails closed on an unresolved owner', () => {
|
||||
it('deletes nothing when the confirmed host no longer lists the row', async () => {
|
||||
const worktreeId = 'repo1::/shared/workspace/path'
|
||||
const worktreePath = '/shared/workspace/path'
|
||||
const hosts = createHostDirectories(worktreeId)
|
||||
const routedHostIds: string[] = []
|
||||
installRemovalTransports(
|
||||
{ [HOST_A_HOST_ID]: hosts.hostARoot, [HOST_B_HOST_ID]: hosts.hostBRoot },
|
||||
routedHostIds
|
||||
)
|
||||
const hostBCandidate = makeHostCandidate(worktreeId, HOST_B_HOST_ID, worktreePath)
|
||||
// The confirm-time list had both hosts; the preflight rescan sees only host A.
|
||||
seedCollidingScan([makeHostCandidate(worktreeId, HOST_A_HOST_ID, worktreePath)])
|
||||
|
||||
const store = createTestStore()
|
||||
seedStore(store, {
|
||||
activeWorktreeId: worktreeId,
|
||||
activeWorkspaceExecutionHostId: HOST_A_HOST_ID
|
||||
} as Partial<AppState>)
|
||||
|
||||
const removal = await store
|
||||
.getState()
|
||||
.removeWorkspaceCleanupCandidates([worktreeId], { approvedCandidates: [hostBCandidate] })
|
||||
|
||||
expect(removal.removedIds).toEqual([])
|
||||
expect(removal.failures).toHaveLength(1)
|
||||
expect(fs.existsSync(hosts.hostAMarkerPath), 'host A must survive').toBe(true)
|
||||
expect(fs.existsSync(hosts.hostBMarkerPath), 'host B must survive').toBe(true)
|
||||
expect(routedHostIds).toEqual([])
|
||||
})
|
||||
|
||||
it('deletes nothing for an unqualified row the store knows on two hosts', async () => {
|
||||
const worktreeId = 'repo1::/shared/workspace/path'
|
||||
const worktreePath = '/shared/workspace/path'
|
||||
const hosts = createHostDirectories(worktreeId)
|
||||
const routedHostIds: string[] = []
|
||||
installRemovalTransports(
|
||||
{ [HOST_A_HOST_ID]: hosts.hostARoot, [HOST_B_HOST_ID]: hosts.hostBRoot },
|
||||
routedHostIds
|
||||
)
|
||||
// A row published before host-qualified candidates existed: no host evidence.
|
||||
const legacyCandidate = makeHostCandidate(worktreeId, undefined, worktreePath, null)
|
||||
seedCollidingScan([legacyCandidate])
|
||||
|
||||
const store = createTestStore()
|
||||
seedStore(store, {
|
||||
worktreesByRepo: {
|
||||
repo1: [
|
||||
makeWorktree({
|
||||
id: worktreeId,
|
||||
repoId: 'repo1',
|
||||
path: worktreePath,
|
||||
hostId: HOST_A_HOST_ID
|
||||
}),
|
||||
makeWorktree({
|
||||
id: worktreeId,
|
||||
repoId: 'repo1',
|
||||
path: worktreePath,
|
||||
hostId: HOST_B_HOST_ID
|
||||
})
|
||||
]
|
||||
}
|
||||
} as Partial<AppState>)
|
||||
|
||||
const removal = await store
|
||||
.getState()
|
||||
.removeWorkspaceCleanupCandidates([worktreeId], { approvedCandidates: [legacyCandidate] })
|
||||
|
||||
expect(removal.removedIds).toEqual([])
|
||||
expect(removal.failures).toHaveLength(1)
|
||||
expect(fs.existsSync(hosts.hostAMarkerPath), 'host A must survive').toBe(true)
|
||||
expect(fs.existsSync(hosts.hostBMarkerPath), 'host B must survive').toBe(true)
|
||||
expect(routedHostIds).toEqual([])
|
||||
expect(mockApi.worktrees.remove).not.toHaveBeenCalled()
|
||||
expect(runtimeRpc.callRuntimeRpc).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('deletes nothing for an old-client row with no host qualifier or catalog owner', async () => {
|
||||
const worktreeId = 'repo1::/shared/workspace/path'
|
||||
const worktreePath = '/shared/workspace/path'
|
||||
const hosts = createHostDirectories(worktreeId)
|
||||
const routedHostIds: string[] = []
|
||||
installRemovalTransports(
|
||||
{ [HOST_A_HOST_ID]: hosts.hostARoot, [HOST_B_HOST_ID]: hosts.hostBRoot },
|
||||
routedHostIds
|
||||
)
|
||||
const legacyCandidate = makeHostCandidate(worktreeId, undefined, worktreePath, null)
|
||||
seedCollidingScan([legacyCandidate])
|
||||
|
||||
const store = createTestStore()
|
||||
seedStore(store, {
|
||||
activeWorktreeId: worktreeId,
|
||||
activeWorkspaceExecutionHostId: HOST_A_HOST_ID
|
||||
} as Partial<AppState>)
|
||||
|
||||
const removal = await store
|
||||
.getState()
|
||||
.removeWorkspaceCleanupCandidates([worktreeId], { approvedCandidates: [legacyCandidate] })
|
||||
|
||||
expect(removal.removedIds).toEqual([])
|
||||
expect(removal.failures).toHaveLength(1)
|
||||
expect(fs.existsSync(hosts.hostAMarkerPath), 'host A must survive').toBe(true)
|
||||
expect(fs.existsSync(hosts.hostBMarkerPath), 'host B must survive').toBe(true)
|
||||
expect(routedHostIds).toEqual([])
|
||||
expect(mockApi.worktrees.remove).not.toHaveBeenCalled()
|
||||
expect(runtimeRpc.callRuntimeRpc).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createTestStore } from './store-test-helpers'
|
||||
import { createStoreCascadesMockApi } from './store-cascades-test-harness'
|
||||
import {
|
||||
MAX_WORKSPACE_DOC_HISTORY_ENTRIES,
|
||||
workspaceDocHistoryEntriesEqual,
|
||||
type WorkspaceDocHistoryEntry
|
||||
} from '../../../../shared/workspace-doc-history'
|
||||
import '../../../../shared/workspace-doc-history'
|
||||
|
||||
createStoreCascadesMockApi()
|
||||
const WT = 'repo1::/path/wt1'
|
||||
@@ -33,55 +29,4 @@ describe('workspace document history title refresh', () => {
|
||||
expect(store.getState().workspaceDocHistory[0].visitCount).toBe(2)
|
||||
unsubscribe()
|
||||
})
|
||||
|
||||
it('still trims an over-cap history that a persisted state carried in', () => {
|
||||
const store = createTestStore()
|
||||
const location = {
|
||||
kind: 'workspace-doc' as const,
|
||||
worktreeId: WT,
|
||||
filePath: '/path/wt1/doc-0.html'
|
||||
}
|
||||
const overCap: WorkspaceDocHistoryEntry[] = Array.from(
|
||||
{ length: MAX_WORKSPACE_DOC_HISTORY_ENTRIES + 5 },
|
||||
(_unused, index) => ({
|
||||
docLocation: {
|
||||
kind: 'workspace-doc' as const,
|
||||
worktreeId: WT,
|
||||
filePath: `/path/wt1/doc-${index}.html`
|
||||
},
|
||||
title: `Doc ${index}`,
|
||||
lastVisitedAt: 1_000 - index,
|
||||
visitCount: 1
|
||||
})
|
||||
)
|
||||
store.setState({ workspaceDocHistory: overCap })
|
||||
|
||||
// The title is already current, but skipping here would strand the list above its cap forever.
|
||||
store.getState().recordWorkspaceDocVisit(location, 'Doc 0', { bump: false })
|
||||
|
||||
expect(store.getState().workspaceDocHistory).toHaveLength(MAX_WORKSPACE_DOC_HISTORY_ENTRIES)
|
||||
expect(store.getState().workspaceDocHistory[0].title).toBe('Doc 0')
|
||||
})
|
||||
|
||||
it('compares every entry field, so an added field cannot be silently dropped', () => {
|
||||
const base: WorkspaceDocHistoryEntry = {
|
||||
docLocation: { kind: 'workspace-doc', worktreeId: WT, filePath: '/path/wt1/doc.html' },
|
||||
title: 'Document',
|
||||
lastVisitedAt: 10,
|
||||
visitCount: 1
|
||||
}
|
||||
expect(workspaceDocHistoryEntriesEqual(base, { ...base })).toBe(true)
|
||||
expect(workspaceDocHistoryEntriesEqual(base, { ...base, title: 'Other' })).toBe(false)
|
||||
expect(workspaceDocHistoryEntriesEqual(base, { ...base, visitCount: 2 })).toBe(false)
|
||||
expect(workspaceDocHistoryEntriesEqual(base, { ...base, lastVisitedAt: 11 })).toBe(false)
|
||||
expect(
|
||||
workspaceDocHistoryEntriesEqual(base, { ...base, docLocation: { ...base.docLocation } })
|
||||
).toBe(false)
|
||||
expect(
|
||||
workspaceDocHistoryEntriesEqual(base, {
|
||||
...base,
|
||||
...({ faviconUrl: 'x' } as Partial<WorkspaceDocHistoryEntry>)
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,198 +0,0 @@
|
||||
import { create } from 'zustand'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { WorkspaceSpaceAnalysis } from '../../../../shared/workspace-space-types'
|
||||
import type { AppState } from '../types'
|
||||
import { createWorkspaceSpaceSlice } from './workspace-space'
|
||||
|
||||
function createWorkspaceSpaceTestStore() {
|
||||
return create<AppState>()(
|
||||
(...a) =>
|
||||
({
|
||||
...createWorkspaceSpaceSlice(...a)
|
||||
}) as unknown as AppState
|
||||
)
|
||||
}
|
||||
|
||||
function makeAnalysis(): WorkspaceSpaceAnalysis {
|
||||
return {
|
||||
scannedAt: 1,
|
||||
totalSizeBytes: 300,
|
||||
reclaimableBytes: 300,
|
||||
worktreeCount: 2,
|
||||
scannedWorktreeCount: 2,
|
||||
unavailableWorktreeCount: 0,
|
||||
repos: [
|
||||
{
|
||||
repoId: 'repo-1',
|
||||
displayName: 'orca',
|
||||
path: '/repo/main',
|
||||
isRemote: false,
|
||||
worktreeCount: 2,
|
||||
scannedWorktreeCount: 2,
|
||||
unavailableWorktreeCount: 0,
|
||||
totalSizeBytes: 300,
|
||||
reclaimableBytes: 300,
|
||||
error: null
|
||||
}
|
||||
],
|
||||
worktrees: [
|
||||
{
|
||||
worktreeId: 'repo-1::/repo/main',
|
||||
repoId: 'repo-1',
|
||||
repoDisplayName: 'orca',
|
||||
repoPath: '/repo/main',
|
||||
displayName: 'main',
|
||||
path: '/repo/main',
|
||||
branch: 'refs/heads/main',
|
||||
isMainWorktree: true,
|
||||
isRemote: false,
|
||||
isSparse: false,
|
||||
status: 'ok',
|
||||
error: null,
|
||||
sizeBytes: 100,
|
||||
reclaimableBytes: 0,
|
||||
canDelete: false,
|
||||
lastActivityAt: 0,
|
||||
scannedAt: 1,
|
||||
skippedEntryCount: 0,
|
||||
topLevelItems: [],
|
||||
omittedTopLevelItemCount: 0,
|
||||
omittedTopLevelSizeBytes: 0
|
||||
},
|
||||
{
|
||||
worktreeId: 'repo-1::/repo/feature',
|
||||
repoId: 'repo-1',
|
||||
repoDisplayName: 'orca',
|
||||
repoPath: '/repo/main',
|
||||
displayName: 'feature',
|
||||
path: '/repo/feature',
|
||||
branch: 'refs/heads/feature',
|
||||
isMainWorktree: false,
|
||||
isRemote: false,
|
||||
isSparse: false,
|
||||
status: 'ok',
|
||||
error: null,
|
||||
sizeBytes: 200,
|
||||
reclaimableBytes: 200,
|
||||
canDelete: true,
|
||||
lastActivityAt: 0,
|
||||
scannedAt: 1,
|
||||
skippedEntryCount: 0,
|
||||
topLevelItems: [],
|
||||
omittedTopLevelItemCount: 0,
|
||||
omittedTopLevelSizeBytes: 0
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
describe('workspace space slice', () => {
|
||||
it('keeps streamed measurements across later progress updates', () => {
|
||||
const store = createWorkspaceSpaceTestStore()
|
||||
store.setState({ workspaceSpaceScanning: true })
|
||||
const progress = {
|
||||
scanId: 'scan-1',
|
||||
state: 'running' as const,
|
||||
startedAt: 1,
|
||||
updatedAt: 1,
|
||||
totalRepoCount: 1,
|
||||
scannedRepoCount: 0,
|
||||
totalWorktreeCount: 2,
|
||||
scannedWorktreeCount: 1,
|
||||
currentRepoDisplayName: 'orca',
|
||||
currentWorktreeDisplayName: 'feature'
|
||||
}
|
||||
|
||||
store.getState().applyWorkspaceSpaceProgress({
|
||||
...progress,
|
||||
completedMeasurements: [{ worktreeId: 'a', status: 'ok', sizeBytes: 10 }]
|
||||
})
|
||||
store.getState().applyWorkspaceSpaceProgress({
|
||||
...progress,
|
||||
scannedWorktreeCount: 2,
|
||||
currentWorktreeDisplayName: null
|
||||
})
|
||||
|
||||
expect(store.getState().workspaceSpaceMeasurements).toEqual([
|
||||
{ worktreeId: 'a', status: 'ok', sizeBytes: 10 }
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps state identity when removal matches no worktree', () => {
|
||||
const store = createWorkspaceSpaceTestStore()
|
||||
store.setState({ workspaceSpaceAnalysis: makeAnalysis() })
|
||||
const before = store.getState()
|
||||
|
||||
store.getState().removeWorkspaceSpaceWorktrees(['repo-2::/elsewhere'])
|
||||
|
||||
expect(store.getState()).toBe(before)
|
||||
expect(store.getState().workspaceSpaceAnalysis).toBe(before.workspaceSpaceAnalysis)
|
||||
expect(store.getState().workspaceSpaceMeasurements).toBe(before.workspaceSpaceMeasurements)
|
||||
})
|
||||
|
||||
it('removes deleted worktrees from cached analysis totals', () => {
|
||||
const store = createWorkspaceSpaceTestStore()
|
||||
store.setState({ workspaceSpaceAnalysis: makeAnalysis() })
|
||||
|
||||
store.getState().removeWorkspaceSpaceWorktrees(['repo-1::/repo/feature'])
|
||||
|
||||
const analysis = store.getState().workspaceSpaceAnalysis
|
||||
expect(analysis?.worktreeCount).toBe(1)
|
||||
expect(analysis?.totalSizeBytes).toBe(100)
|
||||
expect(analysis?.reclaimableBytes).toBe(0)
|
||||
expect(analysis?.repos[0]).toMatchObject({
|
||||
worktreeCount: 1,
|
||||
scannedWorktreeCount: 1,
|
||||
totalSizeBytes: 100,
|
||||
reclaimableBytes: 0
|
||||
})
|
||||
})
|
||||
|
||||
it('removes only the confirmed host row when another host shares its id', () => {
|
||||
const analysis = makeAnalysis()
|
||||
const feature = analysis.worktrees[1]
|
||||
feature.executionHostId = 'local'
|
||||
analysis.worktrees.push({
|
||||
...feature,
|
||||
executionHostId: 'ssh:builder',
|
||||
displayName: 'feature on builder',
|
||||
sizeBytes: 50,
|
||||
reclaimableBytes: 50
|
||||
})
|
||||
analysis.worktreeCount = 3
|
||||
analysis.scannedWorktreeCount = 3
|
||||
analysis.totalSizeBytes = 350
|
||||
analysis.reclaimableBytes = 250
|
||||
const store = createWorkspaceSpaceTestStore()
|
||||
store.setState({ workspaceSpaceAnalysis: analysis })
|
||||
|
||||
store
|
||||
.getState()
|
||||
.removeWorkspaceSpaceWorktrees([{ id: feature.worktreeId, executionHostId: 'local' }])
|
||||
|
||||
expect(store.getState().workspaceSpaceAnalysis?.worktrees).toEqual([
|
||||
expect.objectContaining({ worktreeId: 'repo-1::/repo/main' }),
|
||||
expect.objectContaining({
|
||||
worktreeId: feature.worktreeId,
|
||||
executionHostId: 'ssh:builder'
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps a qualified row when removing an unqualified same-id row', () => {
|
||||
const analysis = makeAnalysis()
|
||||
const unqualified = analysis.worktrees[1]
|
||||
analysis.worktrees.push({ ...unqualified, executionHostId: 'local' })
|
||||
const store = createWorkspaceSpaceTestStore()
|
||||
store.setState({ workspaceSpaceAnalysis: analysis })
|
||||
|
||||
store
|
||||
.getState()
|
||||
.removeWorkspaceSpaceWorktrees([{ id: unqualified.worktreeId, executionHostId: null }])
|
||||
|
||||
expect(store.getState().workspaceSpaceAnalysis?.worktrees).toEqual([
|
||||
expect.objectContaining({ worktreeId: 'repo-1::/repo/main' }),
|
||||
expect.objectContaining({ worktreeId: unqualified.worktreeId, executionHostId: 'local' })
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -1,71 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Worktree } from '../../../../shared/worktree/types'
|
||||
import { applyWorktreeUpdates } from './worktree-helpers'
|
||||
|
||||
function makeWorktree(overrides: Partial<Worktree> & { id: string; repoId: string }): Worktree {
|
||||
return {
|
||||
path: '/workspace/repo',
|
||||
head: 'abc123',
|
||||
branch: 'refs/heads/main',
|
||||
isBare: false,
|
||||
isMainWorktree: true,
|
||||
displayName: 'main',
|
||||
comment: '',
|
||||
linkedIssue: null,
|
||||
linkedPR: null,
|
||||
linkedLinearIssue: null,
|
||||
linkedGitLabMR: null,
|
||||
linkedGitLabIssue: null,
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: 0,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('applyWorktreeUpdates', () => {
|
||||
it('only updates the repo bucket encoded in the worktree id', () => {
|
||||
const target = makeWorktree({
|
||||
id: 'repo-a::/Users/alice/project',
|
||||
repoId: 'repo-a',
|
||||
displayName: 'Project A'
|
||||
})
|
||||
const samePathDifferentProject = makeWorktree({
|
||||
id: 'repo-a::/Users/alice/project',
|
||||
repoId: 'repo-b',
|
||||
displayName: 'Project B'
|
||||
})
|
||||
|
||||
const result = applyWorktreeUpdates(
|
||||
{
|
||||
'repo-a': [target],
|
||||
'repo-b': [samePathDifferentProject]
|
||||
},
|
||||
target.id,
|
||||
{ displayName: 'Renamed A' }
|
||||
)
|
||||
|
||||
expect(result['repo-a']?.[0]?.displayName).toBe('Renamed A')
|
||||
expect(result['repo-b']?.[0]).toBe(samePathDifferentProject)
|
||||
expect(result['repo-b']?.[0]?.displayName).toBe('Project B')
|
||||
})
|
||||
|
||||
it('never lets a present-but-undefined value clobber existing worktree fields', () => {
|
||||
const target = makeWorktree({
|
||||
id: 'repo-a::/Users/alice/project',
|
||||
repoId: 'repo-a',
|
||||
displayName: 'Project A',
|
||||
comment: 'notes'
|
||||
})
|
||||
|
||||
const result = applyWorktreeUpdates({ 'repo-a': [target] }, target.id, {
|
||||
displayName: undefined,
|
||||
comment: 'edited'
|
||||
})
|
||||
|
||||
expect(result['repo-a']?.[0]?.displayName).toBe('Project A')
|
||||
expect(result['repo-a']?.[0]?.comment).toBe('edited')
|
||||
})
|
||||
})
|
||||
@@ -1,172 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { Worktree } from '../../../../shared/worktree/types'
|
||||
import { routeListingBranchSwitchesThroughGitIdentity } from './worktree-listing-branch-switch'
|
||||
|
||||
function makeWorktree(overrides: Partial<Worktree> & { id: string }): Worktree {
|
||||
return {
|
||||
repoId: 'repo1',
|
||||
path: '/path/wt',
|
||||
head: 'abc123',
|
||||
branch: 'refs/heads/feature-one',
|
||||
isBare: false,
|
||||
isMainWorktree: false,
|
||||
displayName: 'feature-one',
|
||||
comment: '',
|
||||
linkedIssue: null,
|
||||
linkedPR: null,
|
||||
linkedLinearIssue: null,
|
||||
linkedGitLabMR: null,
|
||||
linkedGitLabIssue: null,
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: 0,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
const hasLinkedPR = (worktree: Worktree): boolean => worktree.linkedPR != null
|
||||
const matchesAnyHost = (): boolean => true
|
||||
|
||||
describe('routeListingBranchSwitchesThroughGitIdentity', () => {
|
||||
it('routes a listing-observed branch switch through updateWorktreeGitIdentity', () => {
|
||||
const updateWorktreeGitIdentity = vi.fn()
|
||||
const current = [makeWorktree({ id: 'repo1::/path/wt1', linkedPR: 101 })]
|
||||
|
||||
routeListingBranchSwitchesThroughGitIdentity({
|
||||
requestStarted: current,
|
||||
current,
|
||||
incoming: [
|
||||
makeWorktree({
|
||||
id: 'repo1::/path/wt1',
|
||||
branch: 'refs/heads/feature-two',
|
||||
head: 'def456'
|
||||
})
|
||||
],
|
||||
matchesRefreshHost: matchesAnyHost,
|
||||
hasBranchScopedReviewContext: hasLinkedPR,
|
||||
updateWorktreeGitIdentity
|
||||
})
|
||||
|
||||
expect(updateWorktreeGitIdentity).toHaveBeenCalledTimes(1)
|
||||
expect(updateWorktreeGitIdentity).toHaveBeenCalledWith(
|
||||
'repo1::/path/wt1',
|
||||
{
|
||||
head: 'def456',
|
||||
branch: 'refs/heads/feature-two'
|
||||
},
|
||||
null
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects a listing response when the branch changed again after the request started', () => {
|
||||
const updateWorktreeGitIdentity = vi.fn()
|
||||
const current = [
|
||||
makeWorktree({
|
||||
id: 'repo1::/path/wt1',
|
||||
branch: 'refs/heads/feature-three',
|
||||
head: 'newest-head',
|
||||
linkedPR: 303
|
||||
})
|
||||
]
|
||||
|
||||
const reconciled = routeListingBranchSwitchesThroughGitIdentity({
|
||||
requestStarted: [
|
||||
makeWorktree({
|
||||
id: 'repo1::/path/wt1',
|
||||
branch: 'refs/heads/feature-one',
|
||||
linkedPR: 101
|
||||
})
|
||||
],
|
||||
current,
|
||||
incoming: [
|
||||
makeWorktree({
|
||||
id: 'repo1::/path/wt1',
|
||||
branch: 'refs/heads/feature-two',
|
||||
head: 'stale-head'
|
||||
})
|
||||
],
|
||||
matchesRefreshHost: matchesAnyHost,
|
||||
hasBranchScopedReviewContext: hasLinkedPR,
|
||||
updateWorktreeGitIdentity
|
||||
})
|
||||
|
||||
expect(updateWorktreeGitIdentity).not.toHaveBeenCalled()
|
||||
expect(reconciled).toEqual(current)
|
||||
})
|
||||
|
||||
it('preserves a manual relink made while the listing request was in flight', () => {
|
||||
const updateWorktreeGitIdentity = vi.fn()
|
||||
const requestStarted = [makeWorktree({ id: 'repo1::/path/wt1', linkedPR: 101 })]
|
||||
const current = [makeWorktree({ id: 'repo1::/path/wt1', linkedPR: 303 })]
|
||||
|
||||
const reconciled = routeListingBranchSwitchesThroughGitIdentity({
|
||||
requestStarted,
|
||||
current,
|
||||
incoming: [
|
||||
makeWorktree({
|
||||
id: 'repo1::/path/wt1',
|
||||
branch: 'refs/heads/feature-two',
|
||||
head: 'stale-head',
|
||||
linkedPR: 101
|
||||
})
|
||||
],
|
||||
matchesRefreshHost: matchesAnyHost,
|
||||
hasBranchScopedReviewContext: hasLinkedPR,
|
||||
updateWorktreeGitIdentity
|
||||
})
|
||||
|
||||
expect(updateWorktreeGitIdentity).not.toHaveBeenCalled()
|
||||
expect(reconciled).toEqual(current)
|
||||
})
|
||||
|
||||
it('fails closed when the same worktree id belongs to multiple execution hosts', () => {
|
||||
const updateWorktreeGitIdentity = vi.fn()
|
||||
const requestStarted = [
|
||||
makeWorktree({ id: 'repo1::/same/path', hostId: 'local', linkedPR: 101 }),
|
||||
makeWorktree({ id: 'repo1::/same/path', hostId: 'ssh:ssh-1', linkedPR: 202 })
|
||||
]
|
||||
|
||||
routeListingBranchSwitchesThroughGitIdentity({
|
||||
requestStarted,
|
||||
current: requestStarted,
|
||||
incoming: [
|
||||
makeWorktree({
|
||||
id: 'repo1::/same/path',
|
||||
branch: 'refs/heads/feature-two',
|
||||
head: 'remote-head'
|
||||
})
|
||||
],
|
||||
matchesRefreshHost: matchesAnyHost,
|
||||
hasBranchScopedReviewContext: hasLinkedPR,
|
||||
updateWorktreeGitIdentity
|
||||
})
|
||||
|
||||
expect(updateWorktreeGitIdentity).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not route an old-host response through a row now owned by another host', () => {
|
||||
const updateWorktreeGitIdentity = vi.fn()
|
||||
|
||||
routeListingBranchSwitchesThroughGitIdentity({
|
||||
requestStarted: [
|
||||
makeWorktree({ id: 'repo1::/same/path', hostId: 'ssh:ssh-1', linkedPR: 101 })
|
||||
],
|
||||
current: [makeWorktree({ id: 'repo1::/same/path', hostId: 'ssh:ssh-2', linkedPR: 202 })],
|
||||
incoming: [
|
||||
makeWorktree({
|
||||
id: 'repo1::/same/path',
|
||||
hostId: 'ssh:ssh-1',
|
||||
branch: 'refs/heads/feature-two',
|
||||
head: 'old-host-head'
|
||||
})
|
||||
],
|
||||
matchesRefreshHost: (worktree) => worktree.hostId === 'ssh:ssh-1',
|
||||
hasBranchScopedReviewContext: hasLinkedPR,
|
||||
updateWorktreeGitIdentity
|
||||
})
|
||||
|
||||
expect(updateWorktreeGitIdentity).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,44 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { applyWorktreeUpdates } from './worktree-meta-update-application'
|
||||
|
||||
describe('applyWorktreeUpdates display-name provenance', () => {
|
||||
it('returns to the current branch immediately when a label is cleared', () => {
|
||||
const worktree = {
|
||||
id: 'repo-1::/workspace/feature',
|
||||
repoId: 'repo-1',
|
||||
path: '/workspace/feature',
|
||||
branch: 'refs/heads/main',
|
||||
displayName: 'Agent workspace'
|
||||
}
|
||||
|
||||
const next = applyWorktreeUpdates({ 'repo-1': [worktree as never] }, worktree.id, {
|
||||
displayName: '',
|
||||
displayNameIsPinned: false
|
||||
})
|
||||
|
||||
expect(next['repo-1']?.[0]).toMatchObject({
|
||||
displayName: 'main',
|
||||
displayNameMode: 'automatic'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps text until the host resolves a detached fallback', () => {
|
||||
const worktree = {
|
||||
id: 'repo-1::/workspace/feature',
|
||||
repoId: 'repo-1',
|
||||
path: '/workspace/feature',
|
||||
branch: '',
|
||||
displayName: 'Agent workspace'
|
||||
}
|
||||
|
||||
const next = applyWorktreeUpdates({ 'repo-1': [worktree as never] }, worktree.id, {
|
||||
displayName: '',
|
||||
displayNameIsPinned: false
|
||||
})
|
||||
|
||||
expect(next['repo-1']?.[0]).toMatchObject({
|
||||
displayName: 'Agent workspace',
|
||||
displayNameMode: 'automatic'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,316 +0,0 @@
|
||||
import { createStore, type StoreApi } from 'zustand/vanilla'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { AppState } from '../types'
|
||||
import type { GitHubWorkItem } from '../../../../shared/github/work-item-types'
|
||||
import type { JiraIssue } from '../../../../shared/jira/jira-types'
|
||||
import type { Worktree } from '../../../../shared/worktree/types'
|
||||
import type { GitLabWorkItem } from '../../../../shared/gitlab-types'
|
||||
import type { TaskSourceContext } from '../../../../shared/task-source-context'
|
||||
import {
|
||||
createWorktreeNavHistorySlice,
|
||||
findPrevLiveWorktreeHistoryIndex,
|
||||
setWorktreeNavActivator,
|
||||
setWorktreeNavViewActivator,
|
||||
type WorktreeNavHistorySimpleViewEntry
|
||||
} from './worktree-nav-history'
|
||||
|
||||
type MinimalState = Pick<
|
||||
AppState,
|
||||
| 'worktreeNavHistory'
|
||||
| 'worktreeNavHistoryIndex'
|
||||
| 'isNavigatingHistory'
|
||||
| 'recordWorktreeVisit'
|
||||
| 'recordViewVisit'
|
||||
| 'goBackWorktree'
|
||||
| 'goForwardWorktree'
|
||||
| 'worktreesByRepo'
|
||||
>
|
||||
|
||||
function makeWorktree(id: string): Worktree {
|
||||
return { id } as unknown as Worktree
|
||||
}
|
||||
|
||||
function createHistoryStore(worktreeIds: string[] = []): StoreApi<MinimalState> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return createStore<any>()((set, get, api) => ({
|
||||
worktreesByRepo: {
|
||||
'repo-1': worktreeIds.map(makeWorktree)
|
||||
},
|
||||
...createWorktreeNavHistorySlice(
|
||||
set as Parameters<typeof createWorktreeNavHistorySlice>[0],
|
||||
get as Parameters<typeof createWorktreeNavHistorySlice>[1],
|
||||
api as Parameters<typeof createWorktreeNavHistorySlice>[2]
|
||||
)
|
||||
})) as unknown as StoreApi<MinimalState>
|
||||
}
|
||||
|
||||
const viewCases: { entry: WorktreeNavHistorySimpleViewEntry; label: string }[] = [
|
||||
{ entry: 'tasks', label: 'Tasks' },
|
||||
{ entry: 'automations', label: 'Automations' },
|
||||
{ entry: 'artifacts', label: 'Artifacts' },
|
||||
{ entry: 'skills', label: 'Skills' }
|
||||
]
|
||||
|
||||
function makeGitHubWorkItem(overrides: Partial<GitHubWorkItem> = {}): GitHubWorkItem {
|
||||
return {
|
||||
id: 'pr-95',
|
||||
type: 'pr',
|
||||
number: 95,
|
||||
title: 'feat: add file upload command',
|
||||
state: 'open',
|
||||
url: 'https://github.com/acme/repo/pull/95',
|
||||
labels: [],
|
||||
updatedAt: '2026-05-20T00:00:00.000Z',
|
||||
author: 'octocat',
|
||||
repoId: 'repo-1',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function makeGitLabWorkItem(overrides: Partial<GitLabWorkItem> = {}): GitLabWorkItem {
|
||||
return {
|
||||
id: 'mr-12',
|
||||
type: 'mr',
|
||||
number: 12,
|
||||
title: 'Fix runner routing',
|
||||
state: 'opened',
|
||||
url: 'https://gitlab.com/acme/repo/-/merge_requests/12',
|
||||
labels: [],
|
||||
updatedAt: '2026-05-20T00:00:00.000Z',
|
||||
author: 'gitlab-user',
|
||||
repoId: 'repo-1',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function makeJiraIssue(overrides: Partial<JiraIssue> = {}): JiraIssue {
|
||||
return {
|
||||
id: 'ORC-1',
|
||||
key: 'ORC-1',
|
||||
title: 'Fix task source context',
|
||||
url: 'https://example.atlassian.net/browse/ORC-1',
|
||||
siteId: 'site-1',
|
||||
siteName: 'Example Jira',
|
||||
project: { id: '10000', key: 'ORC', name: 'Orca', siteId: 'site-1' },
|
||||
issueType: { id: '10001', name: 'Bug' },
|
||||
status: { id: '1', name: 'Todo', categoryKey: 'new', categoryName: 'To Do' },
|
||||
labels: [],
|
||||
createdAt: '2026-05-30T00:00:00.000Z',
|
||||
updatedAt: '2026-05-30T00:00:00.000Z',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('worktree-nav-history slice: view entries', () => {
|
||||
afterEach(() => {
|
||||
setWorktreeNavActivator(null)
|
||||
setWorktreeNavViewActivator(null)
|
||||
})
|
||||
|
||||
for (const { entry, label } of viewCases) {
|
||||
it(`A -> ${label} -> B, back lands on ${label} then A`, () => {
|
||||
const store = createHistoryStore(['a', 'b'])
|
||||
const activated: string[] = []
|
||||
const viewed: WorktreeNavHistorySimpleViewEntry[] = []
|
||||
setWorktreeNavActivator((id) => {
|
||||
activated.push(id as string)
|
||||
return { primaryTabId: null }
|
||||
})
|
||||
setWorktreeNavViewActivator((v) => {
|
||||
viewed.push(v as WorktreeNavHistorySimpleViewEntry)
|
||||
})
|
||||
|
||||
store.getState().recordWorktreeVisit('a')
|
||||
store.getState().recordViewVisit(entry)
|
||||
store.getState().recordWorktreeVisit('b')
|
||||
|
||||
expect(store.getState().worktreeNavHistory).toEqual(['a', entry, 'b'])
|
||||
expect(store.getState().worktreeNavHistoryIndex).toBe(2)
|
||||
|
||||
store.getState().goBackWorktree()
|
||||
expect(viewed).toEqual([entry])
|
||||
expect(store.getState().worktreeNavHistoryIndex).toBe(1)
|
||||
|
||||
store.getState().goBackWorktree()
|
||||
expect(activated).toEqual(['a'])
|
||||
expect(store.getState().worktreeNavHistoryIndex).toBe(0)
|
||||
})
|
||||
|
||||
it(`dedupes ${label} against the current ${label} entry`, () => {
|
||||
const store = createHistoryStore(['a'])
|
||||
store.getState().recordWorktreeVisit('a')
|
||||
store.getState().recordViewVisit(entry)
|
||||
store.getState().recordViewVisit(entry)
|
||||
store.getState().recordViewVisit(entry)
|
||||
|
||||
expect(store.getState().worktreeNavHistory).toEqual(['a', entry])
|
||||
expect(store.getState().worktreeNavHistoryIndex).toBe(1)
|
||||
})
|
||||
|
||||
it(`skips a dead worktree when backing to a prior ${label} entry`, () => {
|
||||
const store = createHistoryStore([])
|
||||
const viewed: WorktreeNavHistorySimpleViewEntry[] = []
|
||||
setWorktreeNavViewActivator((v) => {
|
||||
viewed.push(v as WorktreeNavHistorySimpleViewEntry)
|
||||
})
|
||||
|
||||
store.setState({
|
||||
worktreeNavHistory: [entry, 'b', entry],
|
||||
worktreeNavHistoryIndex: 2
|
||||
})
|
||||
|
||||
store.getState().goBackWorktree()
|
||||
expect(viewed).toEqual([entry])
|
||||
expect(store.getState().worktreeNavHistoryIndex).toBe(0)
|
||||
})
|
||||
|
||||
it(`close-page-style rewind for ${label} preserves forward replay`, () => {
|
||||
const store = createHistoryStore(['a'])
|
||||
store.getState().recordWorktreeVisit('a')
|
||||
store.getState().recordViewVisit(entry)
|
||||
expect(store.getState().worktreeNavHistoryIndex).toBe(1)
|
||||
|
||||
const prev = findPrevLiveWorktreeHistoryIndex(store.getState() as AppState)
|
||||
expect(prev).toBe(0)
|
||||
store.setState({ worktreeNavHistoryIndex: prev ?? store.getState().worktreeNavHistoryIndex })
|
||||
|
||||
const viewed: WorktreeNavHistorySimpleViewEntry[] = []
|
||||
setWorktreeNavViewActivator((v) => {
|
||||
viewed.push(v as WorktreeNavHistorySimpleViewEntry)
|
||||
})
|
||||
|
||||
store.getState().goForwardWorktree()
|
||||
expect(viewed).toEqual([entry])
|
||||
expect(store.getState().worktreeNavHistoryIndex).toBe(1)
|
||||
})
|
||||
}
|
||||
|
||||
it('replays task detail entries through the same back/forward stack', () => {
|
||||
const store = createHistoryStore(['a'])
|
||||
const detail = {
|
||||
kind: 'task-detail',
|
||||
source: 'github',
|
||||
workItem: makeGitHubWorkItem(),
|
||||
initialTab: 'checks'
|
||||
} as const
|
||||
const viewed: unknown[] = []
|
||||
setWorktreeNavViewActivator((v) => {
|
||||
viewed.push(v)
|
||||
})
|
||||
|
||||
store.getState().recordWorktreeVisit('a')
|
||||
store.getState().recordViewVisit('tasks')
|
||||
store.getState().recordViewVisit(detail)
|
||||
|
||||
expect(store.getState().worktreeNavHistory).toEqual(['a', 'tasks', detail])
|
||||
expect(store.getState().worktreeNavHistoryIndex).toBe(2)
|
||||
|
||||
store.getState().goBackWorktree()
|
||||
expect(viewed).toEqual(['tasks'])
|
||||
expect(store.getState().worktreeNavHistoryIndex).toBe(1)
|
||||
|
||||
store.getState().goForwardWorktree()
|
||||
expect(viewed).toEqual(['tasks', detail])
|
||||
expect(store.getState().worktreeNavHistoryIndex).toBe(2)
|
||||
})
|
||||
|
||||
it('keeps same GitHub item details separate when the source host differs', () => {
|
||||
const store = createHistoryStore(['a'])
|
||||
const workItem = makeGitHubWorkItem()
|
||||
const localSource: TaskSourceContext = {
|
||||
kind: 'task-source',
|
||||
provider: 'github',
|
||||
projectId: 'project-1',
|
||||
hostId: 'local',
|
||||
repoId: 'repo-1',
|
||||
providerIdentity: { provider: 'github', owner: 'acme', repo: 'repo' }
|
||||
}
|
||||
const sshSource: TaskSourceContext = {
|
||||
...localSource,
|
||||
hostId: 'ssh:devbox',
|
||||
projectHostSetupId: 'setup-ssh'
|
||||
}
|
||||
|
||||
store.getState().recordViewVisit({
|
||||
kind: 'task-detail',
|
||||
source: 'github',
|
||||
workItem,
|
||||
sourceContext: localSource
|
||||
})
|
||||
store.getState().recordViewVisit({
|
||||
kind: 'task-detail',
|
||||
source: 'github',
|
||||
workItem,
|
||||
sourceContext: sshSource
|
||||
})
|
||||
|
||||
expect(store.getState().worktreeNavHistory).toHaveLength(2)
|
||||
expect(store.getState().worktreeNavHistoryIndex).toBe(1)
|
||||
})
|
||||
|
||||
it('keeps same GitLab item details separate when the source host differs', () => {
|
||||
const store = createHistoryStore(['a'])
|
||||
const workItem = makeGitLabWorkItem()
|
||||
const localSource: TaskSourceContext = {
|
||||
kind: 'task-source',
|
||||
provider: 'gitlab',
|
||||
projectId: 'project-1',
|
||||
hostId: 'local',
|
||||
repoId: 'repo-1',
|
||||
providerIdentity: { provider: 'gitlab', projectId: '1234' }
|
||||
}
|
||||
const sshSource: TaskSourceContext = {
|
||||
...localSource,
|
||||
hostId: 'ssh:devbox',
|
||||
projectHostSetupId: 'setup-ssh'
|
||||
}
|
||||
|
||||
store.getState().recordViewVisit({
|
||||
kind: 'task-detail',
|
||||
source: 'gitlab',
|
||||
workItem,
|
||||
sourceContext: localSource
|
||||
})
|
||||
store.getState().recordViewVisit({
|
||||
kind: 'task-detail',
|
||||
source: 'gitlab',
|
||||
workItem,
|
||||
sourceContext: sshSource
|
||||
})
|
||||
|
||||
expect(store.getState().worktreeNavHistory).toHaveLength(2)
|
||||
expect(store.getState().worktreeNavHistoryIndex).toBe(1)
|
||||
})
|
||||
|
||||
it('keeps same Jira issue details separate when the source host differs', () => {
|
||||
const store = createHistoryStore(['a'])
|
||||
const issue = makeJiraIssue()
|
||||
const localSource: TaskSourceContext = {
|
||||
kind: 'task-source',
|
||||
provider: 'jira',
|
||||
projectId: 'project-1',
|
||||
hostId: 'local',
|
||||
providerIdentity: { provider: 'jira', siteId: 'site-1' }
|
||||
}
|
||||
const remoteSource: TaskSourceContext = {
|
||||
...localSource,
|
||||
hostId: 'runtime:remote-server'
|
||||
}
|
||||
|
||||
store.getState().recordViewVisit({
|
||||
kind: 'task-detail',
|
||||
source: 'jira',
|
||||
issue,
|
||||
sourceContext: localSource
|
||||
})
|
||||
store.getState().recordViewVisit({
|
||||
kind: 'task-detail',
|
||||
source: 'jira',
|
||||
issue,
|
||||
sourceContext: remoteSource
|
||||
})
|
||||
|
||||
expect(store.getState().worktreeNavHistory).toHaveLength(2)
|
||||
expect(store.getState().worktreeNavHistoryIndex).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -1,258 +0,0 @@
|
||||
import { createStore, type StoreApi } from 'zustand/vanilla'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { AppState } from '../types'
|
||||
import type { FolderWorkspace } from '../../../../shared/folder-workspace-types'
|
||||
import type { Worktree } from '../../../../shared/worktree/types'
|
||||
import { folderWorkspaceKey } from '../../../../shared/workspace-scope'
|
||||
import {
|
||||
canGoBackWorktreeHistory,
|
||||
canGoForwardWorktreeHistory,
|
||||
createWorktreeNavHistorySlice,
|
||||
setWorktreeNavActivator
|
||||
} from './worktree-nav-history'
|
||||
|
||||
type MinimalState = Pick<
|
||||
AppState,
|
||||
| 'worktreeNavHistory'
|
||||
| 'worktreeNavHistoryIndex'
|
||||
| 'isNavigatingHistory'
|
||||
| 'recordWorktreeVisit'
|
||||
| 'recordViewVisit'
|
||||
| 'goBackWorktree'
|
||||
| 'goForwardWorktree'
|
||||
| 'worktreesByRepo'
|
||||
| 'folderWorkspaces'
|
||||
>
|
||||
|
||||
function makeWorktree(id: string): Worktree {
|
||||
// Only `id` is read by findWorktreeById, which is what the slice uses for
|
||||
// live-entry checks. Cast covers fields irrelevant to these tests.
|
||||
return { id } as unknown as Worktree
|
||||
}
|
||||
|
||||
function makeFolderWorkspace(id: string): FolderWorkspace {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
folderPath: `/folders/${id}`,
|
||||
projectGroupId: 'group-1',
|
||||
linkedTask: null,
|
||||
comment: '',
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
createdAt: 1,
|
||||
lastActivityAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
}
|
||||
|
||||
function createHistoryStore(
|
||||
worktreeIds: string[] = [],
|
||||
folderWorkspaceIds: string[] = []
|
||||
): StoreApi<MinimalState> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return createStore<any>()((set, get, api) => ({
|
||||
worktreesByRepo: {
|
||||
'repo-1': worktreeIds.map(makeWorktree)
|
||||
},
|
||||
folderWorkspaces: folderWorkspaceIds.map(makeFolderWorkspace),
|
||||
...createWorktreeNavHistorySlice(
|
||||
set as Parameters<typeof createWorktreeNavHistorySlice>[0],
|
||||
get as Parameters<typeof createWorktreeNavHistorySlice>[1],
|
||||
api as Parameters<typeof createWorktreeNavHistorySlice>[2]
|
||||
)
|
||||
})) as unknown as StoreApi<MinimalState>
|
||||
}
|
||||
|
||||
describe('worktree-nav-history slice: recordWorktreeVisit', () => {
|
||||
it('appends new entries and advances the index', () => {
|
||||
const store = createHistoryStore()
|
||||
store.getState().recordWorktreeVisit('a')
|
||||
store.getState().recordWorktreeVisit('b')
|
||||
store.getState().recordWorktreeVisit('c')
|
||||
|
||||
expect(store.getState().worktreeNavHistory).toEqual(['a', 'b', 'c'])
|
||||
expect(store.getState().worktreeNavHistoryIndex).toBe(2)
|
||||
})
|
||||
|
||||
it('de-dupes only the current entry (A -> B -> A is valid)', () => {
|
||||
const store = createHistoryStore()
|
||||
store.getState().recordWorktreeVisit('a')
|
||||
store.getState().recordWorktreeVisit('b')
|
||||
store.getState().recordWorktreeVisit('a')
|
||||
store.getState().recordWorktreeVisit('a')
|
||||
|
||||
expect(store.getState().worktreeNavHistory).toEqual(['a', 'b', 'a'])
|
||||
expect(store.getState().worktreeNavHistoryIndex).toBe(2)
|
||||
})
|
||||
|
||||
it('truncates forward entries when recording from a non-head index', () => {
|
||||
const store = createHistoryStore(['a', 'b', 'c'])
|
||||
store.getState().recordWorktreeVisit('a')
|
||||
store.getState().recordWorktreeVisit('b')
|
||||
store.getState().recordWorktreeVisit('c')
|
||||
store.setState({ worktreeNavHistoryIndex: 0 })
|
||||
store.getState().recordWorktreeVisit('d')
|
||||
|
||||
expect(store.getState().worktreeNavHistory).toEqual(['a', 'd'])
|
||||
expect(store.getState().worktreeNavHistoryIndex).toBe(1)
|
||||
})
|
||||
|
||||
it('caps the history at 50 entries, evicting oldest', () => {
|
||||
const store = createHistoryStore()
|
||||
for (let i = 0; i < 60; i++) {
|
||||
store.getState().recordWorktreeVisit(`w${i}`)
|
||||
}
|
||||
const state = store.getState()
|
||||
expect(state.worktreeNavHistory).toHaveLength(50)
|
||||
expect(state.worktreeNavHistory[0]).toBe('w10')
|
||||
expect(state.worktreeNavHistory[49]).toBe('w59')
|
||||
expect(state.worktreeNavHistoryIndex).toBe(49)
|
||||
})
|
||||
})
|
||||
|
||||
describe('worktree-nav-history slice: goBack / goForward', () => {
|
||||
// Reset module activator after every case, including throws, to prevent sibling-test contamination.
|
||||
afterEach(() => {
|
||||
setWorktreeNavActivator(null)
|
||||
})
|
||||
|
||||
it('moves the index without mutating the history array on success', () => {
|
||||
const store = createHistoryStore(['a', 'b', 'c'])
|
||||
setWorktreeNavActivator(() => ({ primaryTabId: null }))
|
||||
|
||||
store.getState().recordWorktreeVisit('a')
|
||||
store.getState().recordWorktreeVisit('b')
|
||||
store.getState().recordWorktreeVisit('c')
|
||||
|
||||
store.getState().goBackWorktree()
|
||||
expect(store.getState().worktreeNavHistoryIndex).toBe(1)
|
||||
expect(store.getState().worktreeNavHistory).toEqual(['a', 'b', 'c'])
|
||||
expect(store.getState().isNavigatingHistory).toBe(false)
|
||||
|
||||
store.getState().goForwardWorktree()
|
||||
expect(store.getState().worktreeNavHistoryIndex).toBe(2)
|
||||
})
|
||||
|
||||
it('leaves the index untouched when activator returns false', () => {
|
||||
const store = createHistoryStore(['a', 'b', 'c'])
|
||||
setWorktreeNavActivator(() => false)
|
||||
|
||||
store.getState().recordWorktreeVisit('a')
|
||||
store.getState().recordWorktreeVisit('b')
|
||||
|
||||
store.getState().goBackWorktree()
|
||||
expect(store.getState().worktreeNavHistoryIndex).toBe(1)
|
||||
expect(store.getState().isNavigatingHistory).toBe(false)
|
||||
})
|
||||
|
||||
it('skips deleted worktrees when searching for the prev live entry', () => {
|
||||
const store = createHistoryStore(['a', 'c'])
|
||||
const activated: string[] = []
|
||||
setWorktreeNavActivator((id) => {
|
||||
activated.push(id as string)
|
||||
return { primaryTabId: null }
|
||||
})
|
||||
|
||||
store.setState({
|
||||
worktreeNavHistory: ['a', 'b', 'c'],
|
||||
worktreeNavHistoryIndex: 2
|
||||
})
|
||||
|
||||
store.getState().goBackWorktree()
|
||||
expect(activated).toEqual(['a'])
|
||||
expect(store.getState().worktreeNavHistoryIndex).toBe(0)
|
||||
})
|
||||
|
||||
it('treats folder workspaces as live history entries', () => {
|
||||
const folderKey = folderWorkspaceKey('folder-1')
|
||||
const store = createHistoryStore(['child'], ['folder-1'])
|
||||
const activated: string[] = []
|
||||
setWorktreeNavActivator((id) => {
|
||||
activated.push(id as string)
|
||||
return { primaryTabId: null }
|
||||
})
|
||||
|
||||
store.setState({
|
||||
worktreeNavHistory: [folderKey, 'child'],
|
||||
worktreeNavHistoryIndex: 1
|
||||
})
|
||||
|
||||
store.getState().goBackWorktree()
|
||||
expect(activated).toEqual([folderKey])
|
||||
expect(store.getState().worktreeNavHistoryIndex).toBe(0)
|
||||
})
|
||||
|
||||
it('no-ops when the entire direction is dead', () => {
|
||||
const store = createHistoryStore(['c'])
|
||||
const activated: string[] = []
|
||||
setWorktreeNavActivator((id) => {
|
||||
activated.push(id as string)
|
||||
return { primaryTabId: null }
|
||||
})
|
||||
|
||||
store.setState({
|
||||
worktreeNavHistory: ['a', 'b', 'c'],
|
||||
worktreeNavHistoryIndex: 2
|
||||
})
|
||||
|
||||
store.getState().goBackWorktree()
|
||||
expect(activated).toEqual([])
|
||||
expect(store.getState().worktreeNavHistoryIndex).toBe(2)
|
||||
})
|
||||
|
||||
it('two rapid back presses each decrement the index by one', () => {
|
||||
const store = createHistoryStore(['a', 'b', 'c'])
|
||||
setWorktreeNavActivator(() => ({ primaryTabId: null }))
|
||||
|
||||
store.getState().recordWorktreeVisit('a')
|
||||
store.getState().recordWorktreeVisit('b')
|
||||
store.getState().recordWorktreeVisit('c')
|
||||
|
||||
store.getState().goBackWorktree()
|
||||
store.getState().goBackWorktree()
|
||||
|
||||
expect(store.getState().worktreeNavHistoryIndex).toBe(0)
|
||||
expect(store.getState().isNavigatingHistory).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('worktree-nav-history selectors', () => {
|
||||
it('reports back availability only when a live prior entry exists', () => {
|
||||
const store = createHistoryStore(['c'])
|
||||
store.setState({
|
||||
worktreeNavHistory: ['a', 'b', 'c'],
|
||||
worktreeNavHistoryIndex: 2
|
||||
})
|
||||
|
||||
expect(canGoBackWorktreeHistory(store.getState() as AppState)).toBe(false)
|
||||
|
||||
store.setState({
|
||||
worktreesByRepo: {
|
||||
'repo-1': [makeWorktree('a'), makeWorktree('c')]
|
||||
}
|
||||
})
|
||||
|
||||
expect(canGoBackWorktreeHistory(store.getState() as AppState)).toBe(true)
|
||||
})
|
||||
|
||||
it('reports forward availability only when a live next entry exists', () => {
|
||||
const store = createHistoryStore(['a'])
|
||||
store.setState({
|
||||
worktreeNavHistory: ['a', 'b', 'c'],
|
||||
worktreeNavHistoryIndex: 0
|
||||
})
|
||||
|
||||
expect(canGoForwardWorktreeHistory(store.getState() as AppState)).toBe(false)
|
||||
|
||||
store.setState({
|
||||
worktreesByRepo: {
|
||||
'repo-1': [makeWorktree('a'), makeWorktree('c')]
|
||||
}
|
||||
})
|
||||
|
||||
expect(canGoForwardWorktreeHistory(store.getState() as AppState)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,300 +0,0 @@
|
||||
/** Exercise single and bulk removal eviction of worktree caches plus browser page/workspace maps. Fresh ephemeral keys cannot reclaim missed entries, so omissions leak across the renderer session. */
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import type * as AgentStatusModule from '@/lib/agent-status'
|
||||
import type { BrowserPage, BrowserWorkspace } from '../../../../shared/browser-workspace-types'
|
||||
import {
|
||||
getAgentHibernationPaneOutputEpoch,
|
||||
recordAgentHibernationPaneOutput,
|
||||
resetAgentHibernationOutputActivityForTests
|
||||
} from '@/lib/agent-hibernation-output-activity'
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn() }
|
||||
}))
|
||||
|
||||
vi.mock('@/components/terminal-pane/pty-dispatcher', () => ({
|
||||
restorePtyDataHandlersAfterFailedShutdown: vi.fn(),
|
||||
unregisterPtyDataHandlers: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/agent-status', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof AgentStatusModule>()
|
||||
return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) }
|
||||
})
|
||||
|
||||
const mockApi = {
|
||||
worktrees: {
|
||||
remove: vi.fn().mockResolvedValue(undefined),
|
||||
forceDeletePreservedBranch: vi.fn().mockResolvedValue({ deleted: true }),
|
||||
updateMeta: vi.fn().mockResolvedValue({})
|
||||
},
|
||||
pty: { kill: vi.fn().mockResolvedValue(undefined) },
|
||||
runtimeEnvironments: { call: vi.fn().mockResolvedValue({ ok: true, result: {} }) }
|
||||
}
|
||||
|
||||
// @ts-expect-error -- minimal window.api stub for the store under test
|
||||
globalThis.window = { api: mockApi }
|
||||
|
||||
import {
|
||||
createTestStore,
|
||||
seedStore,
|
||||
makeWorktree,
|
||||
makeOpenFile,
|
||||
makeTab
|
||||
} from './store-test-helpers'
|
||||
|
||||
const WT1 = 'repo1::/path/wt1'
|
||||
const WT2 = 'repo1::/path/wt2'
|
||||
|
||||
function makeWorkspace(id: string, worktreeId: string): BrowserWorkspace {
|
||||
return {
|
||||
id,
|
||||
worktreeId,
|
||||
url: 'about:blank',
|
||||
title: '',
|
||||
loading: false,
|
||||
faviconUrl: null,
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
loadError: null,
|
||||
createdAt: 0
|
||||
}
|
||||
}
|
||||
|
||||
function makePage(id: string, workspaceId: string, worktreeId: string): BrowserPage {
|
||||
return {
|
||||
id,
|
||||
workspaceId,
|
||||
worktreeId,
|
||||
url: 'about:blank',
|
||||
title: '',
|
||||
loading: false,
|
||||
faviconUrl: null,
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
loadError: null,
|
||||
createdAt: 0
|
||||
}
|
||||
}
|
||||
|
||||
describe('worktree removal evicts the per-worktree + per-page maps it previously missed', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockApi.worktrees.remove.mockResolvedValue(undefined)
|
||||
resetAgentHibernationOutputActivityForTests()
|
||||
})
|
||||
|
||||
function seedWorktreeKeyedMaps(store: ReturnType<typeof createTestStore>): void {
|
||||
seedStore(store, {
|
||||
worktreesByRepo: {
|
||||
repo1: [
|
||||
makeWorktree({ id: WT1, repoId: 'repo1', path: '/path/wt1' }),
|
||||
makeWorktree({ id: WT2, repoId: 'repo1', path: '/path/wt2' })
|
||||
]
|
||||
},
|
||||
remoteStatusesByWorktree: {
|
||||
[WT1]: { hasUpstream: true, ahead: 1, behind: 0 },
|
||||
[WT2]: { hasUpstream: true, ahead: 0, behind: 2 }
|
||||
},
|
||||
recentlyClosedEditorTabsByWorktree: {
|
||||
[WT1]: [makeOpenFile({ id: '/path/wt1/closed.ts', worktreeId: WT1 })],
|
||||
[WT2]: [makeOpenFile({ id: '/path/wt2/closed.ts', worktreeId: WT2 })]
|
||||
},
|
||||
recentlyClosedTerminalTabsByWorktree: {
|
||||
[WT1]: [{ startupCwd: '/path/wt1' }],
|
||||
[WT2]: [{ startupCwd: '/path/wt2' }]
|
||||
},
|
||||
recentlyClosedTabKindsByWorktree: { [WT1]: ['terminal'], [WT2]: ['terminal'] },
|
||||
defaultTerminalTabsAppliedByWorktreeId: { [WT1]: true, [WT2]: true }
|
||||
})
|
||||
}
|
||||
|
||||
it('bulk purgeWorktreeTerminalState drops worktree-keyed maps for the removed worktree only', () => {
|
||||
const store = createTestStore()
|
||||
seedWorktreeKeyedMaps(store)
|
||||
|
||||
store.getState().purgeWorktreeTerminalState([WT1])
|
||||
|
||||
const s = store.getState()
|
||||
expect(s.remoteStatusesByWorktree[WT1]).toBeUndefined()
|
||||
expect(s.recentlyClosedEditorTabsByWorktree[WT1]).toBeUndefined()
|
||||
expect(s.recentlyClosedTerminalTabsByWorktree[WT1]).toBeUndefined()
|
||||
expect(s.recentlyClosedTabKindsByWorktree[WT1]).toBeUndefined()
|
||||
expect(s.defaultTerminalTabsAppliedByWorktreeId[WT1]).toBeUndefined()
|
||||
expect(s.remoteStatusesByWorktree[WT2]).toBeDefined()
|
||||
expect(s.recentlyClosedEditorTabsByWorktree[WT2]).toBeDefined()
|
||||
expect(s.recentlyClosedTerminalTabsByWorktree[WT2]).toBeDefined()
|
||||
expect(s.recentlyClosedTabKindsByWorktree[WT2]).toBeDefined()
|
||||
expect(s.defaultTerminalTabsAppliedByWorktreeId[WT2]).toBe(true)
|
||||
})
|
||||
|
||||
it('single removeWorktree drops worktree-keyed maps for the removed worktree only', async () => {
|
||||
const store = createTestStore()
|
||||
seedWorktreeKeyedMaps(store)
|
||||
|
||||
const result = await store.getState().removeWorktree({ id: WT1, executionHostId: null })
|
||||
expect(result).toEqual({ ok: true })
|
||||
|
||||
const s = store.getState()
|
||||
expect(s.remoteStatusesByWorktree[WT1]).toBeUndefined()
|
||||
expect(s.recentlyClosedEditorTabsByWorktree[WT1]).toBeUndefined()
|
||||
expect(s.recentlyClosedTerminalTabsByWorktree[WT1]).toBeUndefined()
|
||||
expect(s.recentlyClosedTabKindsByWorktree[WT1]).toBeUndefined()
|
||||
expect(s.defaultTerminalTabsAppliedByWorktreeId[WT1]).toBeUndefined()
|
||||
expect(s.remoteStatusesByWorktree[WT2]).toBeDefined()
|
||||
expect(s.recentlyClosedEditorTabsByWorktree[WT2]).toBeDefined()
|
||||
expect(s.recentlyClosedTerminalTabsByWorktree[WT2]).toBeDefined()
|
||||
expect(s.recentlyClosedTabKindsByWorktree[WT2]).toBeDefined()
|
||||
expect(s.defaultTerminalTabsAppliedByWorktreeId[WT2]).toBe(true)
|
||||
})
|
||||
|
||||
it('worktree removal drops the hibernation output-epoch map for the removed worktree only', () => {
|
||||
const store = createTestStore()
|
||||
const LEAF = '11111111-1111-4111-8111-111111111111'
|
||||
const TAB1 = 'tab-wt1'
|
||||
const TAB2 = 'tab-wt2'
|
||||
seedStore(store, {
|
||||
worktreesByRepo: {
|
||||
repo1: [
|
||||
makeWorktree({ id: WT1, repoId: 'repo1', path: '/path/wt1' }),
|
||||
makeWorktree({ id: WT2, repoId: 'repo1', path: '/path/wt2' })
|
||||
]
|
||||
},
|
||||
tabsByWorktree: {
|
||||
[WT1]: [makeTab({ id: TAB1, worktreeId: WT1 })],
|
||||
[WT2]: [makeTab({ id: TAB2, worktreeId: WT2 })]
|
||||
}
|
||||
})
|
||||
const removedPaneKey = `${TAB1}:${LEAF}`
|
||||
const survivingPaneKey = `${TAB2}:${LEAF}`
|
||||
recordAgentHibernationPaneOutput(removedPaneKey)
|
||||
recordAgentHibernationPaneOutput(survivingPaneKey)
|
||||
|
||||
store.getState().purgeWorktreeTerminalState([WT1])
|
||||
|
||||
expect(getAgentHibernationPaneOutputEpoch(removedPaneKey)).toBe(0)
|
||||
expect(getAgentHibernationPaneOutputEpoch(survivingPaneKey)).toBe(1)
|
||||
})
|
||||
|
||||
it('bulk purgeWorktreeTerminalState drops native-chat launch prompts for removed tabs only', () => {
|
||||
const store = createTestStore()
|
||||
const TAB1 = 'tab-wt1'
|
||||
const TAB2 = 'tab-wt2'
|
||||
seedStore(store, {
|
||||
worktreesByRepo: {
|
||||
repo1: [
|
||||
makeWorktree({ id: WT1, repoId: 'repo1', path: '/path/wt1' }),
|
||||
makeWorktree({ id: WT2, repoId: 'repo1', path: '/path/wt2' })
|
||||
]
|
||||
},
|
||||
tabsByWorktree: {
|
||||
[WT1]: [makeTab({ id: TAB1, worktreeId: WT1 })],
|
||||
[WT2]: [makeTab({ id: TAB2, worktreeId: WT2 })]
|
||||
},
|
||||
nativeChatLaunchPromptByTabId: {
|
||||
[TAB1]: { tabId: TAB1, agent: 'codex', text: 'fix wt1', createdAt: 1 },
|
||||
[TAB2]: { tabId: TAB2, agent: 'codex', text: 'fix wt2', createdAt: 2 }
|
||||
}
|
||||
})
|
||||
|
||||
store.getState().purgeWorktreeTerminalState([WT1])
|
||||
|
||||
const s = store.getState()
|
||||
expect(s.nativeChatLaunchPromptByTabId[TAB1]).toBeUndefined()
|
||||
expect(s.nativeChatLaunchPromptByTabId[TAB2]).toEqual({
|
||||
tabId: TAB2,
|
||||
agent: 'codex',
|
||||
text: 'fix wt2',
|
||||
createdAt: 2
|
||||
})
|
||||
})
|
||||
|
||||
it('single removeWorktree drops native-chat launch prompts for removed tabs only', async () => {
|
||||
const store = createTestStore()
|
||||
const TAB1 = 'tab-wt1'
|
||||
const TAB2 = 'tab-wt2'
|
||||
seedStore(store, {
|
||||
worktreesByRepo: {
|
||||
repo1: [
|
||||
makeWorktree({ id: WT1, repoId: 'repo1', path: '/path/wt1' }),
|
||||
makeWorktree({ id: WT2, repoId: 'repo1', path: '/path/wt2' })
|
||||
]
|
||||
},
|
||||
tabsByWorktree: {
|
||||
[WT1]: [makeTab({ id: TAB1, worktreeId: WT1 })],
|
||||
[WT2]: [makeTab({ id: TAB2, worktreeId: WT2 })]
|
||||
},
|
||||
nativeChatLaunchPromptByTabId: {
|
||||
[TAB1]: { tabId: TAB1, agent: 'claude', text: 'fix wt1', createdAt: 1 },
|
||||
[TAB2]: { tabId: TAB2, agent: 'claude', text: 'fix wt2', createdAt: 2 }
|
||||
}
|
||||
})
|
||||
|
||||
const result = await store.getState().removeWorktree({ id: WT1, executionHostId: null })
|
||||
|
||||
expect(result).toEqual({ ok: true })
|
||||
const s = store.getState()
|
||||
expect(s.nativeChatLaunchPromptByTabId[TAB1]).toBeUndefined()
|
||||
expect(s.nativeChatLaunchPromptByTabId[TAB2]).toEqual({
|
||||
tabId: TAB2,
|
||||
agent: 'claude',
|
||||
text: 'fix wt2',
|
||||
createdAt: 2
|
||||
})
|
||||
})
|
||||
|
||||
it('bulk purgeWorktreeTerminalState drops page/workspace-keyed browser maps for the removed worktree only', () => {
|
||||
const store = createTestStore()
|
||||
const WS1 = 'ws-1'
|
||||
const WS2 = 'ws-2'
|
||||
const P1 = 'page-1'
|
||||
const P2 = 'page-2'
|
||||
seedStore(store, {
|
||||
worktreesByRepo: {
|
||||
repo1: [
|
||||
makeWorktree({ id: WT1, repoId: 'repo1', path: '/path/wt1' }),
|
||||
makeWorktree({ id: WT2, repoId: 'repo1', path: '/path/wt2' })
|
||||
]
|
||||
},
|
||||
browserTabsByWorktree: {
|
||||
[WT1]: [makeWorkspace(WS1, WT1)],
|
||||
[WT2]: [makeWorkspace(WS2, WT2)]
|
||||
},
|
||||
browserPagesByWorkspace: {
|
||||
[WS1]: [makePage(P1, WS1, WT1)],
|
||||
[WS2]: [makePage(P2, WS2, WT2)]
|
||||
},
|
||||
browserAnnotationsByPageId: { [P1]: [], [P2]: [] },
|
||||
browserAnnotationMarkerIdsByPageId: { [P1]: ['note-1'], [P2]: ['note-2'] },
|
||||
remoteBrowserPageHandlesByPageId: {
|
||||
[P1]: { environmentId: 'env-1', remotePageId: 'r-1' },
|
||||
[P2]: { environmentId: 'env-2', remotePageId: 'r-2' }
|
||||
},
|
||||
pendingAddressBarFocusByPageId: { [P1]: true, [P2]: true },
|
||||
// createBrowserTab writes BOTH the workspace id and the page id here.
|
||||
pendingAddressBarFocusByTabId: { [WS1]: true, [P1]: true, [WS2]: true, [P2]: true },
|
||||
recentlyClosedBrowserPagesByWorkspace: {
|
||||
[WS1]: [makePage(P1, WS1, WT1)],
|
||||
[WS2]: [makePage(P2, WS2, WT2)]
|
||||
}
|
||||
})
|
||||
|
||||
store.getState().purgeWorktreeTerminalState([WT1])
|
||||
|
||||
const s = store.getState()
|
||||
expect(s.browserAnnotationsByPageId[P1]).toBeUndefined()
|
||||
expect(s.browserAnnotationMarkerIdsByPageId[P1]).toBeUndefined()
|
||||
expect(s.remoteBrowserPageHandlesByPageId[P1]).toBeUndefined()
|
||||
expect(s.pendingAddressBarFocusByPageId[P1]).toBeUndefined()
|
||||
expect(s.pendingAddressBarFocusByTabId[WS1]).toBeUndefined()
|
||||
expect(s.pendingAddressBarFocusByTabId[P1]).toBeUndefined()
|
||||
expect(s.recentlyClosedBrowserPagesByWorkspace[WS1]).toBeUndefined()
|
||||
expect(s.browserAnnotationsByPageId[P2]).toBeDefined()
|
||||
expect(s.browserAnnotationMarkerIdsByPageId[P2]).toEqual(['note-2'])
|
||||
expect(s.remoteBrowserPageHandlesByPageId[P2]).toBeDefined()
|
||||
expect(s.pendingAddressBarFocusByPageId[P2]).toBe(true)
|
||||
expect(s.pendingAddressBarFocusByTabId[WS2]).toBe(true)
|
||||
expect(s.pendingAddressBarFocusByTabId[P2]).toBe(true)
|
||||
expect(s.recentlyClosedBrowserPagesByWorkspace[WS2]).toBeDefined()
|
||||
})
|
||||
})
|
||||
@@ -1,13 +1,9 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import * as intent from '@/lib/worktree-sleep-intent'
|
||||
import { buildWorktreePurgeState } from './worktrees/teardown/worktree-purge-state'
|
||||
import './worktrees/teardown/worktree-purge-state'
|
||||
import { createTestStore, makeWorktree, seedStore } from './store-test-helpers'
|
||||
import { createStoreCascadesMockApi } from './store-cascades-test-harness'
|
||||
import {
|
||||
getStructuredAgentSessionReadOwner,
|
||||
findStructuredAgentSessionReadOwner,
|
||||
resetStructuredAgentSessionReadOwnersForTests
|
||||
} from '@/components/native-chat/structured-agent-session-read-owner'
|
||||
import { resetStructuredAgentSessionReadOwnersForTests } from '@/components/native-chat/structured-agent-session-read-owner'
|
||||
|
||||
const { clearWorktreeSleepIntent, hasWorktreeSleepIntent, markWorktreeSleepIntent } = intent
|
||||
const WORKTREE_ID = 'repo1::/path/wt1'
|
||||
@@ -33,97 +29,6 @@ describe('worktree sleep intent lifecycle', () => {
|
||||
resetStructuredAgentSessionReadOwnersForTests()
|
||||
})
|
||||
|
||||
it('is released by activating the worktree', () => {
|
||||
const store = createTestStore()
|
||||
seedWorktree(store)
|
||||
markWorktreeSleepIntent(WORKTREE_ID)
|
||||
|
||||
store.getState().setActiveWorktree(WORKTREE_ID)
|
||||
|
||||
expect(hasWorktreeSleepIntent(WORKTREE_ID)).toBe(false)
|
||||
})
|
||||
|
||||
it('survives the sleep flow clearing the active selection', () => {
|
||||
const store = createTestStore()
|
||||
seedWorktree(store)
|
||||
markWorktreeSleepIntent(WORKTREE_ID)
|
||||
|
||||
store.getState().setActiveWorktree(null)
|
||||
|
||||
expect(hasWorktreeSleepIntent(WORKTREE_ID)).toBe(true)
|
||||
})
|
||||
|
||||
it('is released by activating a folder workspace', () => {
|
||||
const store = createTestStore()
|
||||
store.setState({
|
||||
folderWorkspaces: [
|
||||
{
|
||||
id: 'folder-1',
|
||||
projectGroupId: 'group-1',
|
||||
name: 'Folder',
|
||||
folderPath: '/folder',
|
||||
executionHostId: 'local',
|
||||
linkedTask: null,
|
||||
comment: '',
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: 0,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
]
|
||||
})
|
||||
markWorktreeSleepIntent(FOLDER_KEY)
|
||||
|
||||
store.getState().setActiveFolderWorkspace('folder-1')
|
||||
|
||||
expect(hasWorktreeSleepIntent(FOLDER_KEY)).toBe(false)
|
||||
})
|
||||
|
||||
it('is released when any PTY binds to a tab in the worktree', () => {
|
||||
const store = createTestStore()
|
||||
seedWorktree(store)
|
||||
const tab = store.getState().createTab(WORKTREE_ID, undefined, undefined, { activate: false })
|
||||
markWorktreeSleepIntent(WORKTREE_ID)
|
||||
|
||||
store.getState().updateTabPtyId(tab.id, 'pty-cli-created')
|
||||
|
||||
expect(hasWorktreeSleepIntent(WORKTREE_ID)).toBe(false)
|
||||
})
|
||||
|
||||
it('is released when a tab is created with a live PTY', () => {
|
||||
const store = createTestStore()
|
||||
seedWorktree(store)
|
||||
markWorktreeSleepIntent(WORKTREE_ID)
|
||||
|
||||
store.getState().createTab(WORKTREE_ID, undefined, undefined, {
|
||||
activate: false,
|
||||
initialPtyId: 'pty-cli-created'
|
||||
})
|
||||
|
||||
expect(hasWorktreeSleepIntent(WORKTREE_ID)).toBe(false)
|
||||
})
|
||||
|
||||
it('notifies wake listeners once and only on a real clear', () => {
|
||||
const { onWorktreeSleepIntentCleared } = intent
|
||||
const woke = vi.fn()
|
||||
markWorktreeSleepIntent(WORKTREE_ID)
|
||||
const unsubscribe = onWorktreeSleepIntentCleared(WORKTREE_ID, woke)
|
||||
|
||||
clearWorktreeSleepIntent('repo1::/path/other')
|
||||
expect(woke).not.toHaveBeenCalled()
|
||||
clearWorktreeSleepIntent(WORKTREE_ID)
|
||||
clearWorktreeSleepIntent(WORKTREE_ID)
|
||||
expect(woke).toHaveBeenCalledTimes(1)
|
||||
|
||||
markWorktreeSleepIntent(WORKTREE_ID)
|
||||
clearWorktreeSleepIntent(WORKTREE_ID)
|
||||
expect(woke).toHaveBeenCalledTimes(1)
|
||||
unsubscribe()
|
||||
})
|
||||
|
||||
it('ignores a PTY bind that lands while the sleep teardown is in flight', async () => {
|
||||
const store = createTestStore()
|
||||
seedWorktree(store)
|
||||
@@ -141,52 +46,4 @@ describe('worktree sleep intent lifecycle', () => {
|
||||
store.getState().updateTabPtyId(tab.id, 'pty-after-teardown')
|
||||
expect(hasWorktreeSleepIntent(WORKTREE_ID)).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps notifying siblings when one wake listener throws', () => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const woke = vi.fn()
|
||||
markWorktreeSleepIntent(WORKTREE_ID)
|
||||
intent.onWorktreeSleepIntentCleared(WORKTREE_ID, () => {
|
||||
throw new Error('boom')
|
||||
})
|
||||
intent.onWorktreeSleepIntentCleared(WORKTREE_ID, woke)
|
||||
|
||||
expect(() => clearWorktreeSleepIntent(WORKTREE_ID)).not.toThrow()
|
||||
expect(woke).toHaveBeenCalledTimes(1)
|
||||
errorSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('is forgotten without waking panes when the worktree is purged', () => {
|
||||
const store = createTestStore()
|
||||
seedWorktree(store)
|
||||
markWorktreeSleepIntent(WORKTREE_ID)
|
||||
const woke = vi.fn()
|
||||
intent.onWorktreeSleepIntentCleared(WORKTREE_ID, woke)
|
||||
|
||||
store.setState(buildWorktreePurgeState(store.getState(), [WORKTREE_ID]))
|
||||
|
||||
expect(hasWorktreeSleepIntent(WORKTREE_ID)).toBe(false)
|
||||
expect(woke).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([WORKTREE_ID, FOLDER_KEY])(
|
||||
'retires created chat readers when %s is purged',
|
||||
(workspaceId) => {
|
||||
const store = createTestStore()
|
||||
seedWorktree(store)
|
||||
const target = { kind: 'environment', environmentId: 'server-1' } as const
|
||||
const owner = getStructuredAgentSessionReadOwner('created-chat', target)
|
||||
const local = getStructuredAgentSessionReadOwner('created-chat', { kind: 'local' })
|
||||
store.getState().createUnifiedTab(workspaceId, 'agent-session', {
|
||||
entityId: 'created-chat',
|
||||
executionHostId: 'runtime:server-1',
|
||||
activate: false
|
||||
})
|
||||
const dispose = vi.spyOn(owner, 'dispose')
|
||||
store.setState(buildWorktreePurgeState(store.getState(), [workspaceId]))
|
||||
expect(findStructuredAgentSessionReadOwner('created-chat', target)).toBeUndefined()
|
||||
expect(findStructuredAgentSessionReadOwner('created-chat', { kind: 'local' })).toBe(local)
|
||||
expect(dispose).toHaveBeenCalledOnce()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const unregisterPtyDataHandlers = vi.hoisted(() => vi.fn(() => []))
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn() }
|
||||
}))
|
||||
|
||||
vi.mock('@/components/terminal-pane/pty-dispatcher', () => ({
|
||||
restorePtyDataHandlersAfterFailedShutdown: vi.fn(),
|
||||
unregisterPtyDataHandlers
|
||||
}))
|
||||
|
||||
const runtimeCall = vi.fn()
|
||||
const killPty = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
globalThis.window = {
|
||||
api: {
|
||||
pty: { kill: killPty },
|
||||
runtimeEnvironments: { call: runtimeCall }
|
||||
}
|
||||
} as never
|
||||
|
||||
import { getDefaultSettings } from '../../../../shared/constants'
|
||||
import { createCompatibleRuntimeStatusResponseIfNeeded } from '../../runtime/runtime-compatibility-test-fixture'
|
||||
import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client'
|
||||
import { createTestStore, makeRuntimeOwnedWorktree, makeTab, seedStore } from './store-test-helpers'
|
||||
|
||||
const worktreeId = 'repo1::/srv/worktree'
|
||||
const ptyId = 'remote:env-1@@pty-1'
|
||||
|
||||
function seedRuntimeOwnedWorktree(store: ReturnType<typeof createTestStore>): void {
|
||||
seedStore(store, {
|
||||
settings: { ...getDefaultSettings('/tmp'), activeRuntimeEnvironmentId: 'env-1' },
|
||||
worktreesByRepo: {
|
||||
repo1: [
|
||||
makeRuntimeOwnedWorktree(
|
||||
{ id: worktreeId, repoId: 'repo1', path: '/srv/worktree' },
|
||||
'env-1'
|
||||
)
|
||||
]
|
||||
},
|
||||
tabsByWorktree: {
|
||||
[worktreeId]: [makeTab({ id: 'tab-1', worktreeId, ptyId })]
|
||||
},
|
||||
ptyIdsByTabId: { 'tab-1': [ptyId] }
|
||||
})
|
||||
}
|
||||
|
||||
describe('worktree terminal removal teardown', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
clearRuntimeCompatibilityCacheForTests()
|
||||
runtimeCall.mockImplementation((args: { method: string }) =>
|
||||
Promise.resolve(
|
||||
createCompatibleRuntimeStatusResponseIfNeeded(args) ?? {
|
||||
id: 'rpc-default',
|
||||
ok: true,
|
||||
result: {},
|
||||
_meta: { runtimeId: 'remote-runtime' }
|
||||
}
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('retires renderer bindings without stopping the already-removed remote workspace again', async () => {
|
||||
const store = createTestStore()
|
||||
seedRuntimeOwnedWorktree(store)
|
||||
|
||||
await store.getState().shutdownWorktreeTerminals(worktreeId, {
|
||||
shutdownReason: 'remove-worktree',
|
||||
backendOwnsPtyTeardown: true
|
||||
})
|
||||
|
||||
expect(runtimeCall.mock.calls.filter(([args]) => args.method === 'terminal.stop')).toHaveLength(
|
||||
0
|
||||
)
|
||||
expect(killPty).not.toHaveBeenCalled()
|
||||
expect(store.getState().ptyIdsByTabId['tab-1']).toEqual([])
|
||||
expect(store.getState().pendingPtyShutdownIds[ptyId]).toBeUndefined()
|
||||
})
|
||||
|
||||
// Why: the skip is an opt-in for backend-paired removal; a bare call must still kill remote PTYs or they leak with no local trace.
|
||||
it('still stops remote terminals when the caller passes no options', async () => {
|
||||
const store = createTestStore()
|
||||
seedRuntimeOwnedWorktree(store)
|
||||
|
||||
await store.getState().shutdownWorktreeTerminals(worktreeId)
|
||||
|
||||
expect(runtimeCall).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
selector: 'env-1',
|
||||
method: 'terminal.stop',
|
||||
params: { worktree: `id:${worktreeId}` }
|
||||
})
|
||||
)
|
||||
expect(store.getState().ptyIdsByTabId['tab-1']).toEqual([])
|
||||
})
|
||||
|
||||
it('still stops remote terminals for an explicit remove reason without the opt-in', async () => {
|
||||
const store = createTestStore()
|
||||
seedRuntimeOwnedWorktree(store)
|
||||
|
||||
await store
|
||||
.getState()
|
||||
.shutdownWorktreeTerminals(worktreeId, { shutdownReason: 'remove-worktree' })
|
||||
|
||||
expect(runtimeCall.mock.calls.filter(([args]) => args.method === 'terminal.stop')).toHaveLength(
|
||||
1
|
||||
)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user