From ec7da75d4f372ea3270864d3e2b8d0ba1efcee5d Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Mon, 2 Nov 2026 19:20:23 -0800 Subject: [PATCH] test: remove 164 low-value renderer store tests (#37638) --- config/flaky-test-pattern-baseline.txt | 3 - config/scripts/ci-shard-timings.json | 14 - .../workspace-unread-characterization.test.ts | 610 +----------------- .../workspace-unread-flag-host-raises.test.ts | 13 +- ...-unread-on-screen-characterization.test.ts | 301 +-------- ...ce-unread-removal-characterization.test.ts | 395 ------------ ...read-screen-moves-characterization.test.ts | 206 +----- .../workspace-chat-draft-removal.test.ts | 84 +-- ...space-cleanup-agent-stop-preflight.test.ts | 62 -- .../slices/workspace-cleanup-browse.test.ts | 45 +- ...-cleanup-host-qualified-list-state.test.ts | 144 ----- ...-cleanup-local-evidence-invariants.test.ts | 63 -- ...orkspace-cleanup-plain-shell-visit.test.ts | 54 -- ...orkspace-cleanup-removal-preflight.test.ts | 215 +----- ...ace-cleanup-scan-progress-boundary.test.ts | 33 - .../workspace-cleanup-scan-progress.test.ts | 320 --------- ...cleanup-unverified-removal-consent.test.ts | 293 --------- ...e-cleanup-wrong-host-removal-guard.test.ts | 312 +-------- ...rkspace-cleanup-wrong-host-removal.test.ts | 381 ----------- .../workspace-document-title-refresh.test.ts | 57 +- .../src/store/slices/workspace-space.test.ts | 198 ------ .../src/store/slices/worktree-helpers.test.ts | 71 -- .../worktree-listing-branch-switch.test.ts | 172 ----- .../worktree-meta-update-application.test.ts | 44 -- .../worktree-nav-history-view-entries.test.ts | 316 --------- .../store/slices/worktree-nav-history.test.ts | 258 -------- .../slices/worktree-removal-maps-leak.test.ts | 300 --------- .../worktree-sleep-intent-lifecycle.test.ts | 147 +---- ...worktree-terminal-removal-teardown.test.ts | 112 ---- 29 files changed, 25 insertions(+), 5198 deletions(-) delete mode 100644 src/renderer/src/store/slices/workspace-attention/workspace-unread-removal-characterization.test.ts delete mode 100644 src/renderer/src/store/slices/workspace-cleanup-agent-stop-preflight.test.ts delete mode 100644 src/renderer/src/store/slices/workspace-cleanup-host-qualified-list-state.test.ts delete mode 100644 src/renderer/src/store/slices/workspace-cleanup-local-evidence-invariants.test.ts delete mode 100644 src/renderer/src/store/slices/workspace-cleanup-plain-shell-visit.test.ts delete mode 100644 src/renderer/src/store/slices/workspace-cleanup-scan-progress-boundary.test.ts delete mode 100644 src/renderer/src/store/slices/workspace-space.test.ts delete mode 100644 src/renderer/src/store/slices/worktree-helpers.test.ts delete mode 100644 src/renderer/src/store/slices/worktree-listing-branch-switch.test.ts delete mode 100644 src/renderer/src/store/slices/worktree-meta-update-application.test.ts delete mode 100644 src/renderer/src/store/slices/worktree-nav-history-view-entries.test.ts delete mode 100644 src/renderer/src/store/slices/worktree-nav-history.test.ts delete mode 100644 src/renderer/src/store/slices/worktree-removal-maps-leak.test.ts delete mode 100644 src/renderer/src/store/slices/worktree-terminal-removal-teardown.test.ts diff --git a/config/flaky-test-pattern-baseline.txt b/config/flaky-test-pattern-baseline.txt index ecf1b670858..e26665af040 100644 --- a/config/flaky-test-pattern-baseline.txt +++ b/config/flaky-test-pattern-baseline.txt @@ -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 diff --git a/config/scripts/ci-shard-timings.json b/config/scripts/ci-shard-timings.json index 8df05a85d3d..55e76d6a74b 100644 --- a/config/scripts/ci-shard-timings.json +++ b/config/scripts/ci-shard-timings.json @@ -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, diff --git a/src/renderer/src/store/slices/workspace-attention/workspace-unread-characterization.test.ts b/src/renderer/src/store/slices/workspace-attention/workspace-unread-characterization.test.ts index dabedaa17f6..83459b659bc 100644 --- a/src/renderer/src/store/slices/workspace-attention/workspace-unread-characterization.test.ts +++ b/src/renderer/src/store/slices/workspace-attention/workspace-unread-characterization.test.ts @@ -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 }) diff --git a/src/renderer/src/store/slices/workspace-attention/workspace-unread-flag-host-raises.test.ts b/src/renderer/src/store/slices/workspace-attention/workspace-unread-flag-host-raises.test.ts index e0d388637c9..91afdf17bd4 100644 --- a/src/renderer/src/store/slices/workspace-attention/workspace-unread-flag-host-raises.test.ts +++ b/src/renderer/src/store/slices/workspace-attention/workspace-unread-flag-host-raises.test.ts @@ -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 = () => {} diff --git a/src/renderer/src/store/slices/workspace-attention/workspace-unread-on-screen-characterization.test.ts b/src/renderer/src/store/slices/workspace-attention/workspace-unread-on-screen-characterization.test.ts index 202bcbf684e..a30d0047356 100644 --- a/src/renderer/src/store/slices/workspace-attention/workspace-unread-on-screen-characterization.test.ts +++ b/src/renderer/src/store/slices/workspace-attention/workspace-unread-on-screen-characterization.test.ts @@ -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]]) - }) }) diff --git a/src/renderer/src/store/slices/workspace-attention/workspace-unread-removal-characterization.test.ts b/src/renderer/src/store/slices/workspace-attention/workspace-unread-removal-characterization.test.ts deleted file mode 100644 index 1d179dd62ca..00000000000 --- a/src/renderer/src/store/slices/workspace-attention/workspace-unread-removal-characterization.test.ts +++ /dev/null @@ -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() - return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) } -}) - -vi.mock('../worktrees/metadata/worktree-meta-persist', async (importOriginal) => { - const actual = await importOriginal() - 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['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", - ] - `) - }) -}) diff --git a/src/renderer/src/store/slices/workspace-attention/workspace-unread-screen-moves-characterization.test.ts b/src/renderer/src/store/slices/workspace-attention/workspace-unread-screen-moves-characterization.test.ts index edd39798457..d96e0159709 100644 --- a/src/renderer/src/store/slices/workspace-attention/workspace-unread-screen-moves-characterization.test.ts +++ b/src/renderer/src/store/slices/workspace-attention/workspace-unread-screen-moves-characterization.test.ts @@ -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 => { 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}", - ] - `) - }) }) diff --git a/src/renderer/src/store/slices/workspace-chat-draft-removal.test.ts b/src/renderer/src/store/slices/workspace-chat-draft-removal.test.ts index 23458e6be92..4bc67e34659 100644 --- a/src/renderer/src/store/slices/workspace-chat-draft-removal.test.ts +++ b/src/renderer/src/store/slices/workspace-chat-draft-removal.test.ts @@ -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() diff --git a/src/renderer/src/store/slices/workspace-cleanup-agent-stop-preflight.test.ts b/src/renderer/src/store/slices/workspace-cleanup-agent-stop-preflight.test.ts deleted file mode 100644 index 010f1e125c1..00000000000 --- a/src/renderer/src/store/slices/workspace-cleanup-agent-stop-preflight.test.ts +++ /dev/null @@ -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']) - }) -}) diff --git a/src/renderer/src/store/slices/workspace-cleanup-browse.test.ts b/src/renderer/src/store/slices/workspace-cleanup-browse.test.ts index 760678aa26c..2380eb28158 100644 --- a/src/renderer/src/store/slices/workspace-cleanup-browse.test.ts +++ b/src/renderer/src/store/slices/workspace-cleanup-browse.test.ts @@ -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) - }) }) diff --git a/src/renderer/src/store/slices/workspace-cleanup-host-qualified-list-state.test.ts b/src/renderer/src/store/slices/workspace-cleanup-host-qualified-list-state.test.ts deleted file mode 100644 index a16d69de2e9..00000000000 --- a/src/renderer/src/store/slices/workspace-cleanup-host-qualified-list-state.test.ts +++ /dev/null @@ -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) - - 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) - 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') - }) -}) diff --git a/src/renderer/src/store/slices/workspace-cleanup-local-evidence-invariants.test.ts b/src/renderer/src/store/slices/workspace-cleanup-local-evidence-invariants.test.ts deleted file mode 100644 index 7a0b1c2a459..00000000000 --- a/src/renderer/src/store/slices/workspace-cleanup-local-evidence-invariants.test.ts +++ /dev/null @@ -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') - }) -}) diff --git a/src/renderer/src/store/slices/workspace-cleanup-plain-shell-visit.test.ts b/src/renderer/src/store/slices/workspace-cleanup-plain-shell-visit.test.ts deleted file mode 100644 index 69fc09ffa29..00000000000 --- a/src/renderer/src/store/slices/workspace-cleanup-plain-shell-visit.test.ts +++ /dev/null @@ -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) - }) -}) diff --git a/src/renderer/src/store/slices/workspace-cleanup-removal-preflight.test.ts b/src/renderer/src/store/slices/workspace-cleanup-removal-preflight.test.ts index 2b13c14c643..881eb984331 100644 --- a/src/renderer/src/store/slices/workspace-cleanup-removal-preflight.test.ts +++ b/src/renderer/src/store/slices/workspace-cleanup-removal-preflight.test.ts @@ -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) - - 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) - - 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') - }) }) diff --git a/src/renderer/src/store/slices/workspace-cleanup-scan-progress-boundary.test.ts b/src/renderer/src/store/slices/workspace-cleanup-scan-progress-boundary.test.ts deleted file mode 100644 index f970542ee7e..00000000000 --- a/src/renderer/src/store/slices/workspace-cleanup-scan-progress-boundary.test.ts +++ /dev/null @@ -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(() => undefined) - ) - expect(isLatestWorkspaceCleanupScan(staleToken)).toBe(true) - - invalidateWorkspaceCleanupScanProgress() - - expect(isLatestWorkspaceCleanupScan(staleToken)).toBe(false) - expect(cancelScan).toHaveBeenCalledExactlyOnceWith('scan-before-removal') - }) -}) diff --git a/src/renderer/src/store/slices/workspace-cleanup-scan-progress.test.ts b/src/renderer/src/store/slices/workspace-cleanup-scan-progress.test.ts index 6558f86e9fe..ecd5c8e1fae 100644 --- a/src/renderer/src/store/slices/workspace-cleanup-scan-progress.test.ts +++ b/src/renderer/src/store/slices/workspace-cleanup-scan-progress.test.ts @@ -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() - 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() 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() - 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() 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() - 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) - - 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() - 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() const terminalProbe = deferred() @@ -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() - const secondPending = deferred() - 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() - const fullPending = deferred() - 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() - const secondPending = deferred() - 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() - 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) - }) }) diff --git a/src/renderer/src/store/slices/workspace-cleanup-unverified-removal-consent.test.ts b/src/renderer/src/store/slices/workspace-cleanup-unverified-removal-consent.test.ts index 1cdf9a5388f..ce6a823e72c 100644 --- a/src/renderer/src/store/slices/workspace-cleanup-unverified-removal-consent.test.ts +++ b/src/renderer/src/store/slices/workspace-cleanup-unverified-removal-consent.test.ts @@ -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) - }) }) diff --git a/src/renderer/src/store/slices/workspace-cleanup-wrong-host-removal-guard.test.ts b/src/renderer/src/store/slices/workspace-cleanup-wrong-host-removal-guard.test.ts index d05f7b18833..0e88fc55436 100644 --- a/src/renderer/src/store/slices/workspace-cleanup-wrong-host-removal-guard.test.ts +++ b/src/renderer/src/store/slices/workspace-cleanup-wrong-host-removal-guard.test.ts @@ -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) - - 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) - - 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) - - 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) - - 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) - - 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) - - 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) - - 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) - - 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' diff --git a/src/renderer/src/store/slices/workspace-cleanup-wrong-host-removal.test.ts b/src/renderer/src/store/slices/workspace-cleanup-wrong-host-removal.test.ts index ca980a74d1b..1169a3c016a 100644 --- a/src/renderer/src/store/slices/workspace-cleanup-wrong-host-removal.test.ts +++ b/src/renderer/src/store/slices/workspace-cleanup-wrong-host-removal.test.ts @@ -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) - - 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) - - 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) - - 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) - - 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) - - 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) - - 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) - - 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) - - 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) - - 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) - - 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() - }) -}) diff --git a/src/renderer/src/store/slices/workspace-document-title-refresh.test.ts b/src/renderer/src/store/slices/workspace-document-title-refresh.test.ts index 21e2cf77751..4330c9cf75c 100644 --- a/src/renderer/src/store/slices/workspace-document-title-refresh.test.ts +++ b/src/renderer/src/store/slices/workspace-document-title-refresh.test.ts @@ -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) - }) - ).toBe(false) - }) }) diff --git a/src/renderer/src/store/slices/workspace-space.test.ts b/src/renderer/src/store/slices/workspace-space.test.ts deleted file mode 100644 index 057c70f99ae..00000000000 --- a/src/renderer/src/store/slices/workspace-space.test.ts +++ /dev/null @@ -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()( - (...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' }) - ]) - }) -}) diff --git a/src/renderer/src/store/slices/worktree-helpers.test.ts b/src/renderer/src/store/slices/worktree-helpers.test.ts deleted file mode 100644 index d483c04aeaf..00000000000 --- a/src/renderer/src/store/slices/worktree-helpers.test.ts +++ /dev/null @@ -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 & { 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') - }) -}) diff --git a/src/renderer/src/store/slices/worktree-listing-branch-switch.test.ts b/src/renderer/src/store/slices/worktree-listing-branch-switch.test.ts deleted file mode 100644 index 99cb7ff9d2a..00000000000 --- a/src/renderer/src/store/slices/worktree-listing-branch-switch.test.ts +++ /dev/null @@ -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 & { 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() - }) -}) diff --git a/src/renderer/src/store/slices/worktree-meta-update-application.test.ts b/src/renderer/src/store/slices/worktree-meta-update-application.test.ts deleted file mode 100644 index c02387f0adf..00000000000 --- a/src/renderer/src/store/slices/worktree-meta-update-application.test.ts +++ /dev/null @@ -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' - }) - }) -}) diff --git a/src/renderer/src/store/slices/worktree-nav-history-view-entries.test.ts b/src/renderer/src/store/slices/worktree-nav-history-view-entries.test.ts deleted file mode 100644 index 940ec0a77c2..00000000000 --- a/src/renderer/src/store/slices/worktree-nav-history-view-entries.test.ts +++ /dev/null @@ -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 { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return createStore()((set, get, api) => ({ - worktreesByRepo: { - 'repo-1': worktreeIds.map(makeWorktree) - }, - ...createWorktreeNavHistorySlice( - set as Parameters[0], - get as Parameters[1], - api as Parameters[2] - ) - })) as unknown as StoreApi -} - -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 { - 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 { - 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 { - 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) - }) -}) diff --git a/src/renderer/src/store/slices/worktree-nav-history.test.ts b/src/renderer/src/store/slices/worktree-nav-history.test.ts deleted file mode 100644 index eb1e601834b..00000000000 --- a/src/renderer/src/store/slices/worktree-nav-history.test.ts +++ /dev/null @@ -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 { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return createStore()((set, get, api) => ({ - worktreesByRepo: { - 'repo-1': worktreeIds.map(makeWorktree) - }, - folderWorkspaces: folderWorkspaceIds.map(makeFolderWorkspace), - ...createWorktreeNavHistorySlice( - set as Parameters[0], - get as Parameters[1], - api as Parameters[2] - ) - })) as unknown as StoreApi -} - -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) - }) -}) diff --git a/src/renderer/src/store/slices/worktree-removal-maps-leak.test.ts b/src/renderer/src/store/slices/worktree-removal-maps-leak.test.ts deleted file mode 100644 index c1550cff6c9..00000000000 --- a/src/renderer/src/store/slices/worktree-removal-maps-leak.test.ts +++ /dev/null @@ -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() - 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): 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() - }) -}) diff --git a/src/renderer/src/store/slices/worktree-sleep-intent-lifecycle.test.ts b/src/renderer/src/store/slices/worktree-sleep-intent-lifecycle.test.ts index aa1de1d021f..3f4ce2da585 100644 --- a/src/renderer/src/store/slices/worktree-sleep-intent-lifecycle.test.ts +++ b/src/renderer/src/store/slices/worktree-sleep-intent-lifecycle.test.ts @@ -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() - } - ) }) diff --git a/src/renderer/src/store/slices/worktree-terminal-removal-teardown.test.ts b/src/renderer/src/store/slices/worktree-terminal-removal-teardown.test.ts deleted file mode 100644 index 8cee0962adb..00000000000 --- a/src/renderer/src/store/slices/worktree-terminal-removal-teardown.test.ts +++ /dev/null @@ -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): 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 - ) - }) -})