diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index eba48399400..e0d152f7b4f 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -9724,7 +9724,7 @@ "invariant": "Every agent launch surface must advertise the provider set detected on its explicit execution authority and launch on that same authority; a local Floating Workspace must never inherit an unrelated active project's WSL, SSH, or paired-runtime context.", "oracle": "Model an active WSL project and a native-Windows Floating Workspace with WSL-only Claude and host-only Codex. Keep both detection consumers mounted, require their advertised sets to stay disjoint through refresh, require the legacy active-project inventory to remain unchanged, then independently assert the Floating launch call and PTY transport select local IPC with no WSL project runtime.", "commands": [ - "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/useDetectedAgents.test.tsx src/renderer/src/hooks/useAgentDetectionTarget.test.ts src/renderer/src/store/slices/detected-agents.test.ts src/renderer/src/store/slices/local-detected-agent-state.test.ts src/renderer/src/store/slices/repos-remove-project-purge-leak.test.ts src/renderer/src/lib/local-preflight-context.test.ts src/renderer/src/lib/launch-agent-in-new-tab.test.ts --reporter=verbose", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/useDetectedAgents.test.tsx src/renderer/src/hooks/useAgentDetectionTarget.test.ts src/renderer/src/store/slices/detected-agents.test.ts src/renderer/src/store/slices/local-detected-agent-state.test.ts src/renderer/src/store/slices/repos-project-mutations.test.ts src/renderer/src/lib/local-preflight-context.test.ts src/renderer/src/lib/launch-agent-in-new-tab.test.ts --reporter=verbose", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection-spawn-and-startup.test.ts --reporter=dot", "Headed Electron/CDP STA-4027 journey on Orca environment windows 2 with a selected Ubuntu-24.04 WSL project and local Floating Workspace" ], @@ -9733,7 +9733,7 @@ "src/renderer/src/hooks/useAgentDetectionTarget.test.ts", "src/renderer/src/store/slices/detected-agents.test.ts", "src/renderer/src/store/slices/local-detected-agent-state.test.ts", - "src/renderer/src/store/slices/repos-remove-project-purge-leak.test.ts", + "src/renderer/src/store/slices/repos-project-mutations.test.ts", "src/renderer/src/lib/local-preflight-context.test.ts", "src/renderer/src/lib/launch-agent-in-new-tab.test.ts", "src/renderer/src/components/terminal-pane/pty-connection-spawn-and-startup.test.ts" @@ -9755,7 +9755,7 @@ ] }, { - "file": "src/renderer/src/store/slices/repos-remove-project-purge-leak.test.ts", + "file": "src/renderer/src/store/slices/repos-project-mutations.test.ts", "assertions": [ "removing one project evicts its authority-scoped agent inventory without disturbing another project" ] @@ -9781,7 +9781,8 @@ "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/useDetectedAgents.test.tsx src/renderer/src/hooks/useAgentDetectionTarget.test.ts src/renderer/src/store/slices/detected-agents.test.ts src/renderer/src/store/slices/local-detected-agent-state.test.ts src/renderer/src/store/slices/repos-remove-project-purge-leak.test.ts src/renderer/src/lib/local-preflight-context.test.ts src/renderer/src/lib/launch-agent-in-new-tab.test.ts --reporter=verbose", "result": "passed", "durationSeconds": 2, - "summary": "Seven focused files passed independent WSL/local inventory, refresh isolation, cache lifecycle, project eviction, and Floating launch-call assertions." + "summary": "Seven focused files passed independent WSL/local inventory, refresh isolation, cache lifecycle, project eviction, and Floating launch-call assertions.", + "currentCommand": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/useDetectedAgents.test.tsx src/renderer/src/hooks/useAgentDetectionTarget.test.ts src/renderer/src/store/slices/detected-agents.test.ts src/renderer/src/store/slices/local-detected-agent-state.test.ts src/renderer/src/store/slices/repos-project-mutations.test.ts src/renderer/src/lib/local-preflight-context.test.ts src/renderer/src/lib/launch-agent-in-new-tab.test.ts --reporter=verbose" }, { "date": "2026-08-12", @@ -10458,9 +10459,9 @@ "oracle": "Start with Active Server=Local desktop, connect and navigate Windows 2, then reveal a local terminal and assert it succeeds while the persisted preference remains local before and after restart. Repeat transient routing and selected-runtime Add Project operations with exact-ID host decoys and completion reordering. Separately pair the real web client to an isolated headed host, then make its sole still-listed runtime compatibility-blocked and unavailable. Require a null authoritative selection, a disabled host option, and disabled Browse, Clone, and Create actions with no local fallback. Repeat against headless serve. Run the byte-identical selector/action contract on latest main, candidate, and candidate with the eligibility seam disabled.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/settings.test.ts src/main/ipc/runtime-environments-pairing.test.ts src/renderer/src/store/slices/settings.test.ts src/renderer/src/store/slices/browser.test.ts src/renderer/src/components/settings/browser-session-host-selection.test.ts src/renderer/src/components/settings/RuntimeEnvironmentsPane.test.ts src/renderer/src/components/status-bar/SshStatusSegment.test.ts src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts src/renderer/src/hooks/useIpcEvents-terminal-create-surfacing.test.ts src/renderer/src/web/web-preload-api-runtime-environment.test.ts --maxWorkers=1", - "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/sidebar/AddProjectFromFolderDialog.test.tsx src/renderer/src/components/sidebar/NonGitFolderDialog.test.tsx src/renderer/src/components/sidebar/AddRepoDialog.default-checkout.test.ts src/renderer/src/components/sidebar/AddRepoSteps.default-checkout.test.ts src/renderer/src/components/sidebar/project-added-default-checkout.test.ts src/renderer/src/components/sidebar/useAddRepoLocalFolderFlow.test.ts src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.test.ts src/renderer/src/components/sidebar/useAddRepoServerPathFlow.test.ts src/renderer/src/components/sidebar/useAddRepoCloneFlow.test.ts src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts src/renderer/src/components/sidebar/worktree-list/listing/host-filtering.test.ts src/renderer/src/lib/resolved-worktree-execution-host.test.ts src/renderer/src/lib/worktree-runtime-owner.test.ts src/renderer/src/store/selectors.test.ts src/renderer/src/store/slices/repos-all-hosts-folder-workspaces.test.ts src/renderer/src/store/slices/repos-project-groups.test.ts src/renderer/src/store/slices/repos-selected-owner-routing.test.ts src/renderer/src/store/slices/selected-host-active-workspace-identity.test.ts src/renderer/src/store/slices/worktrees-fetch-owner-routing.test.ts --maxWorkers=1", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/sidebar/AddProjectFromFolderDialog.test.tsx src/renderer/src/components/sidebar/NonGitFolderDialog.test.tsx src/renderer/src/components/sidebar/AddRepoDialog.default-checkout.test.ts src/renderer/src/components/sidebar/AddRepoSteps.default-checkout.test.ts src/renderer/src/components/sidebar/project-added-default-checkout.test.ts src/renderer/src/components/sidebar/useAddRepoLocalFolderFlow.test.ts src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.test.ts src/renderer/src/components/sidebar/useAddRepoServerPathFlow.test.ts src/renderer/src/components/sidebar/useAddRepoCloneFlow.test.ts src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts src/renderer/src/components/sidebar/worktree-list/listing/host-filtering.test.ts src/renderer/src/lib/resolved-worktree-execution-host.test.ts src/renderer/src/lib/worktree-runtime-owner.test.ts src/renderer/src/store/selectors.test.ts src/renderer/src/store/slices/repos-catalog-reconciliation.test.ts src/renderer/src/store/slices/repos-project-groups.test.ts src/renderer/src/store/slices/repos-selected-owner-routing.test.ts src/renderer/src/store/slices/selected-host-active-workspace-identity.test.ts src/renderer/src/store/slices/worktrees-fetch-owner-routing.test.ts --maxWorkers=1", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts src/renderer/src/components/sidebar/add-repo-browse-authority.test.ts src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx src/renderer/src/components/sidebar/AddRepoStartSteps.test.tsx src/renderer/src/components/sidebar/useAddRepoServerPathFlow.test.ts src/renderer/src/runtime/runtime-server-directory-browser.test.ts src/renderer/src/store/slices/repos-paired-runtime-add.test.ts --maxWorkers=1", - "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/sidebar/AddProjectFromFolderDialog.test.tsx src/renderer/src/components/sidebar/NonGitFolderDialog.test.tsx src/renderer/src/components/sidebar/AddRepoDialog.default-checkout.test.ts src/renderer/src/components/sidebar/AddRepoSteps.default-checkout.test.ts src/renderer/src/components/sidebar/project-added-default-checkout.test.ts src/renderer/src/components/sidebar/useAddRepoLocalFolderFlow.test.ts src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.test.ts src/renderer/src/components/sidebar/useAddRepoServerPathFlow.test.ts src/renderer/src/components/sidebar/useAddRepoCloneFlow.test.ts src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts src/renderer/src/components/sidebar/worktree-list/listing/host-filtering.test.ts src/renderer/src/lib/resolved-worktree-execution-host.test.ts src/renderer/src/lib/worktree-runtime-owner.test.ts src/renderer/src/store/selectors.test.ts src/renderer/src/store/slices/repos-all-hosts-folder-workspaces.test.ts src/renderer/src/store/slices/repos-project-groups.test.ts src/renderer/src/store/slices/repos-selected-owner-routing.test.ts src/renderer/src/store/slices/selected-host-active-workspace-identity.test.ts src/renderer/src/store/slices/worktrees-fetch-owner-routing.test.ts src/renderer/src/runtime/web-runtime-session.test.ts --maxWorkers=1", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/sidebar/AddProjectFromFolderDialog.test.tsx src/renderer/src/components/sidebar/NonGitFolderDialog.test.tsx src/renderer/src/components/sidebar/AddRepoDialog.default-checkout.test.ts src/renderer/src/components/sidebar/AddRepoSteps.default-checkout.test.ts src/renderer/src/components/sidebar/project-added-default-checkout.test.ts src/renderer/src/components/sidebar/useAddRepoLocalFolderFlow.test.ts src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.test.ts src/renderer/src/components/sidebar/useAddRepoServerPathFlow.test.ts src/renderer/src/components/sidebar/useAddRepoCloneFlow.test.ts src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts src/renderer/src/components/sidebar/worktree-list/listing/host-filtering.test.ts src/renderer/src/lib/resolved-worktree-execution-host.test.ts src/renderer/src/lib/worktree-runtime-owner.test.ts src/renderer/src/store/selectors.test.ts src/renderer/src/store/slices/repos-catalog-reconciliation.test.ts src/renderer/src/store/slices/repos-project-groups.test.ts src/renderer/src/store/slices/repos-selected-owner-routing.test.ts src/renderer/src/store/slices/selected-host-active-workspace-identity.test.ts src/renderer/src/store/slices/worktrees-fetch-owner-routing.test.ts src/renderer/src/runtime/web-runtime-session.test.ts --maxWorkers=1", "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/pr11346-selected-runtime-add.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1 --reporter=line", "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/pr11346-selected-runtime-add.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line", "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-web-add-project-unavailable-host.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1 --reporter=line", @@ -10495,7 +10496,7 @@ "src/renderer/src/lib/worktree-runtime-owner.test.ts", "src/renderer/src/store/selectors.test.ts", "src/renderer/src/store/slices/repos-paired-runtime-add.test.ts", - "src/renderer/src/store/slices/repos-all-hosts-folder-workspaces.test.ts", + "src/renderer/src/store/slices/repos-catalog-reconciliation.test.ts", "src/renderer/src/store/slices/repos-project-groups.test.ts", "src/renderer/src/store/slices/repos-selected-owner-routing.test.ts", "src/renderer/src/store/slices/selected-host-active-workspace-identity.test.ts", @@ -10686,7 +10687,8 @@ "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/sidebar/AddProjectFromFolderDialog.test.tsx src/renderer/src/components/sidebar/NonGitFolderDialog.test.tsx src/renderer/src/components/sidebar/AddRepoDialog.default-checkout.test.ts src/renderer/src/components/sidebar/AddRepoSteps.default-checkout.test.ts src/renderer/src/components/sidebar/project-added-default-checkout.test.ts src/renderer/src/components/sidebar/useAddRepoLocalFolderFlow.test.ts src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.test.ts src/renderer/src/components/sidebar/useAddRepoServerPathFlow.test.ts src/renderer/src/components/sidebar/useAddRepoCloneFlow.test.ts src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts src/renderer/src/components/sidebar/worktree-list/listing/host-filtering.test.ts src/renderer/src/lib/resolved-worktree-execution-host.test.ts src/renderer/src/lib/worktree-runtime-owner.test.ts src/renderer/src/store/selectors.test.ts src/renderer/src/store/slices/repos-all-hosts-folder-workspaces.test.ts src/renderer/src/store/slices/repos-project-groups.test.ts src/renderer/src/store/slices/repos-selected-owner-routing.test.ts src/renderer/src/store/slices/selected-host-active-workspace-identity.test.ts src/renderer/src/store/slices/worktrees-fetch-owner-routing.test.ts --maxWorkers=1", "result": "passed", "durationSeconds": 8.77, - "summary": "Nineteen files and 386 tests passed after rebasing onto 94cf2f1422f30fc309cb47c5e864a831d516fa8b, including exact-ID repo/worktree/group/folder host identity in both catalog orders, fail-closed active-repo selection when a runtime row is unavailable, selected runtime and SSH owner capture through final Add/Clone/Create/folder activation, direct-SSH authoritative pruning, reversed catalog responses, reconnect generation fencing, local-after-runtime isolation, and missing-catalog worktree routing." + "summary": "Nineteen files and 386 tests passed after rebasing onto 94cf2f1422f30fc309cb47c5e864a831d516fa8b, including exact-ID repo/worktree/group/folder host identity in both catalog orders, fail-closed active-repo selection when a runtime row is unavailable, selected runtime and SSH owner capture through final Add/Clone/Create/folder activation, direct-SSH authoritative pruning, reversed catalog responses, reconnect generation fencing, local-after-runtime isolation, and missing-catalog worktree routing.", + "currentCommand": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/sidebar/AddProjectFromFolderDialog.test.tsx src/renderer/src/components/sidebar/NonGitFolderDialog.test.tsx src/renderer/src/components/sidebar/AddRepoDialog.default-checkout.test.ts src/renderer/src/components/sidebar/AddRepoSteps.default-checkout.test.ts src/renderer/src/components/sidebar/project-added-default-checkout.test.ts src/renderer/src/components/sidebar/useAddRepoLocalFolderFlow.test.ts src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.test.ts src/renderer/src/components/sidebar/useAddRepoServerPathFlow.test.ts src/renderer/src/components/sidebar/useAddRepoCloneFlow.test.ts src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts src/renderer/src/components/sidebar/worktree-list/listing/host-filtering.test.ts src/renderer/src/lib/resolved-worktree-execution-host.test.ts src/renderer/src/lib/worktree-runtime-owner.test.ts src/renderer/src/store/selectors.test.ts src/renderer/src/store/slices/repos-catalog-reconciliation.test.ts src/renderer/src/store/slices/repos-project-groups.test.ts src/renderer/src/store/slices/repos-selected-owner-routing.test.ts src/renderer/src/store/slices/selected-host-active-workspace-identity.test.ts src/renderer/src/store/slices/worktrees-fetch-owner-routing.test.ts --maxWorkers=1" }, { "date": "2026-07-30", @@ -10713,7 +10715,8 @@ "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/sidebar/AddProjectFromFolderDialog.test.tsx src/renderer/src/components/sidebar/NonGitFolderDialog.test.tsx src/renderer/src/components/sidebar/AddRepoDialog.default-checkout.test.ts src/renderer/src/components/sidebar/AddRepoSteps.default-checkout.test.ts src/renderer/src/components/sidebar/project-added-default-checkout.test.ts src/renderer/src/components/sidebar/useAddRepoLocalFolderFlow.test.ts src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.test.ts src/renderer/src/components/sidebar/useAddRepoServerPathFlow.test.ts src/renderer/src/components/sidebar/useAddRepoCloneFlow.test.ts src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts src/renderer/src/components/sidebar/worktree-list/listing/host-filtering.test.ts src/renderer/src/lib/resolved-worktree-execution-host.test.ts src/renderer/src/lib/worktree-runtime-owner.test.ts src/renderer/src/store/selectors.test.ts src/renderer/src/store/slices/repos-all-hosts-folder-workspaces.test.ts src/renderer/src/store/slices/repos-project-groups.test.ts src/renderer/src/store/slices/repos-selected-owner-routing.test.ts src/renderer/src/store/slices/selected-host-active-workspace-identity.test.ts src/renderer/src/store/slices/worktrees-fetch-owner-routing.test.ts src/renderer/src/runtime/web-runtime-session.test.ts --maxWorkers=1", "result": "passed", "durationSeconds": 7.1, - "summary": "Twenty files and 430 tests passed on current main integration, including explicit runtime-owner preservation for terminal and browser session activation." + "summary": "Twenty files and 430 tests passed on current main integration, including explicit runtime-owner preservation for terminal and browser session activation.", + "currentCommand": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/sidebar/AddProjectFromFolderDialog.test.tsx src/renderer/src/components/sidebar/NonGitFolderDialog.test.tsx src/renderer/src/components/sidebar/AddRepoDialog.default-checkout.test.ts src/renderer/src/components/sidebar/AddRepoSteps.default-checkout.test.ts src/renderer/src/components/sidebar/project-added-default-checkout.test.ts src/renderer/src/components/sidebar/useAddRepoLocalFolderFlow.test.ts src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.test.ts src/renderer/src/components/sidebar/useAddRepoServerPathFlow.test.ts src/renderer/src/components/sidebar/useAddRepoCloneFlow.test.ts src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts src/renderer/src/components/sidebar/worktree-list/listing/host-filtering.test.ts src/renderer/src/lib/resolved-worktree-execution-host.test.ts src/renderer/src/lib/worktree-runtime-owner.test.ts src/renderer/src/store/selectors.test.ts src/renderer/src/store/slices/repos-catalog-reconciliation.test.ts src/renderer/src/store/slices/repos-project-groups.test.ts src/renderer/src/store/slices/repos-selected-owner-routing.test.ts src/renderer/src/store/slices/selected-host-active-workspace-identity.test.ts src/renderer/src/store/slices/worktrees-fetch-owner-routing.test.ts src/renderer/src/runtime/web-runtime-session.test.ts --maxWorkers=1" }, { "date": "2026-07-31", diff --git a/src/main/runtime/structured-chat-coordinator-mail-queue.test.ts b/src/main/runtime/structured-chat-coordinator-mail-queue.test.ts index 420fa3cd418..789fe3a5c90 100644 --- a/src/main/runtime/structured-chat-coordinator-mail-queue.test.ts +++ b/src/main/runtime/structured-chat-coordinator-mail-queue.test.ts @@ -16,6 +16,7 @@ import { db, finishWorker, host, + observationClock, openChat, ptyPointer, queuedCardTexts, @@ -50,7 +51,7 @@ async function runningUserTurn(chat: FakeConnection): Promise<() => Promise { for (let edge = 0; edge < 3; edge += 1) { runtime.onStructuredSessionStatusForMail({ sessionId: COORDINATOR, status: 'idle' }) - await new Promise((resolve) => setTimeout(resolve, 100)) + await observationClock.observe(100) } } @@ -68,6 +69,7 @@ async function secondTask(): Promise { describe("a busy chat's orchestration pointer waits in its queue", () => { it('queues the pointer as a card, with who it is from, and sends it once when the turn ends', async () => { + observationClock.start() const chat = await openChat(COORDINATOR) const { runId, taskId } = await coordinatorRunAndTask() const endTurn = await runningUserTurn(chat) @@ -115,6 +117,7 @@ describe("a busy chat's orchestration pointer waits in its queue", () => { }) it('queues a second card for mail that arrives while the first waits, each counting its own mail', async () => { + observationClock.start() const chat = await openChat(COORDINATOR) const { runId, taskId } = await coordinatorRunAndTask() const second = await secondTask() diff --git a/src/main/runtime/structured-chat-dispatch-worker-mail.test.ts b/src/main/runtime/structured-chat-dispatch-worker-mail.test.ts index a4200de211e..a45aadc0131 100644 --- a/src/main/runtime/structured-chat-dispatch-worker-mail.test.ts +++ b/src/main/runtime/structured-chat-dispatch-worker-mail.test.ts @@ -17,6 +17,8 @@ import { connectionFor, coordinatorRunAndTask, db, + host, + observationClock, openChat, restartRuntime, runtime, @@ -31,7 +33,8 @@ const POINTER = /orchestration message/ async function idleEdges(sessionId: string): Promise { runtime.onStructuredSessionStatusForMail({ sessionId, status: null }) runtime.onStructuredSessionStatusForMail({ sessionId, status: 'idle' }) - await new Promise((resolve) => setTimeout(resolve, 400)) + await observationClock.observe(400) + await host.flushStreamedEvents(sessionId) } /** Coordinator mail to the peer chat's Dispatch, its pointer lost with the provider. */ @@ -70,6 +73,7 @@ async function nextTurn(sessionId: string): Promise<{ turns: { text: string }[] describe("a chat worker's held Dispatch mail is pointed again", () => { // Also covered by the restored-mail repoint a restart schedules; this pins the end result. it('after a restart', async () => { + observationClock.start() await heldDispatchMail() restartRuntime() @@ -80,6 +84,7 @@ describe("a chat worker's held Dispatch mail is pointed again", () => { }) it('in the same chat after /clear', async () => { + observationClock.start() await heldDispatchMail() const cleared = await clearChat(PEER_CHAT) expect(cleared).toBe(PEER_CHAT) diff --git a/src/renderer/src/store/slices/repos-all-hosts-folder-workspaces.spec.ts b/src/renderer/src/store/slices/repos-all-hosts-folder-workspaces.spec.ts new file mode 100644 index 00000000000..d16b3671e42 --- /dev/null +++ b/src/renderer/src/store/slices/repos-all-hosts-folder-workspaces.spec.ts @@ -0,0 +1,327 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createTestStore } from './store-test-helpers' +import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' +import type { ProjectGroup } from '../../../../shared/project-group-types' +import type { Repo } from '../../../../shared/repo-types' +import { + createCompatibleRuntimeStatusResponseIfNeeded, + type RuntimeEnvironmentCallRequest +} from '../../runtime/runtime-compatibility-test-fixture' +import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' +import { folderWorkspaceKey } from '../../../../shared/workspace-scope' + +export function registerReposAllHostsFolderWorkspacesTests(): void { + const localRepo: Repo = { + id: 'local-repo', + path: '/local', + displayName: 'Local', + badgeColor: '#000', + addedAt: 1 + } + + const remoteRepo: Repo = { + id: 'remote-repo', + path: '/srv/repo', + displayName: 'Remote', + badgeColor: '#000', + addedAt: 1 + } + + const localProjectGroup: ProjectGroup = { + id: 'local-group', + name: 'Local group', + parentPath: '/local', + parentGroupId: null, + createdFrom: 'manual', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 + } + + const remoteProjectGroup: ProjectGroup = { + id: 'remote-group', + name: 'Remote group', + parentPath: '/srv', + parentGroupId: null, + createdFrom: 'manual', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 + } + + const localFolderWorkspace: FolderWorkspace = { + id: 'local-folder', + projectGroupId: 'local-group', + name: 'Local folder', + folderPath: '/local', + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 1, + createdAt: 1, + updatedAt: 1 + } + + const remoteFolderWorkspace: FolderWorkspace = { + id: 'remote-folder', + projectGroupId: 'remote-group', + name: 'Remote folder', + folderPath: '/srv', + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 1, + createdAt: 1, + updatedAt: 1 + } + + const reposList = vi.fn() + const projectsList = vi.fn() + const listHostSetups = vi.fn() + const projectGroupsList = vi.fn() + const folderWorkspacesList = vi.fn() + const runtimeEnvironmentsList = vi.fn() + const runtimeEnvironmentCall = vi.fn() + const runtimeEnvironmentTransportCall = vi.fn() + const dispatchEventMock = vi.fn() + + beforeEach(() => { + clearRuntimeCompatibilityCacheForTests() + reposList.mockReset() + projectsList.mockReset() + listHostSetups.mockReset() + projectGroupsList.mockReset() + folderWorkspacesList.mockReset() + runtimeEnvironmentsList.mockReset() + runtimeEnvironmentCall.mockReset() + runtimeEnvironmentTransportCall.mockReset() + dispatchEventMock.mockReset() + + reposList.mockResolvedValue([localRepo]) + projectsList.mockResolvedValue([]) + listHostSetups.mockResolvedValue([]) + projectGroupsList.mockResolvedValue([localProjectGroup]) + folderWorkspacesList.mockResolvedValue([localFolderWorkspace]) + runtimeEnvironmentsList.mockResolvedValue([{ id: 'env-1', name: 'lobster' }]) + runtimeEnvironmentCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + if (args.method === 'repo.list') { + return { + id: 'rpc-repo-list', + ok: true, + result: { repos: [remoteRepo] }, + _meta: { runtimeId: 'runtime-remote' } + } + } + if (args.method === 'projectGroup.list') { + return { + id: 'rpc-project-group-list', + ok: true, + result: { groups: [remoteProjectGroup] }, + _meta: { runtimeId: 'runtime-remote' } + } + } + if (args.method === 'folderWorkspace.list') { + return { + id: 'rpc-folder-workspace-list', + ok: true, + result: { folderWorkspaces: [remoteFolderWorkspace] }, + _meta: { runtimeId: 'runtime-remote' } + } + } + return { + id: 'rpc-other', + ok: true, + result: { projects: [], setups: [] }, + _meta: { runtimeId: 'runtime-remote' } + } + }) + runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args) + }) + + vi.stubGlobal('window', { + api: { + repos: { list: reposList }, + projects: { list: projectsList, listHostSetups }, + projectGroups: { list: projectGroupsList }, + folderWorkspaces: { list: folderWorkspacesList }, + runtimeEnvironments: { + call: runtimeEnvironmentTransportCall, + list: runtimeEnvironmentsList + } + }, + dispatchEvent: dispatchEventMock + }) + }) + + describe('all-host folder workspace startup catalogs', () => { + it('loads project groups and folder workspaces for every host', async () => { + const store = createTestStore() + store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) + const restoredFolderKey = folderWorkspaceKey('remote-folder') + store.setState({ + restoredRuntimeHostIdByWorkspaceSessionKey: { + [restoredFolderKey]: 'runtime:env-1', + 'remote-repo::/srv/repo': 'runtime:env-1' + } + }) + + await store.getState().fetchProjectGroupsForAllHosts() + await store.getState().fetchFolderWorkspacesForAllHosts() + + expect(store.getState().projectGroups).toEqual([ + { ...localProjectGroup, executionHostId: 'local' }, + { ...remoteProjectGroup, executionHostId: 'runtime:env-1' } + ]) + expect(store.getState().folderWorkspaces.map((workspace) => workspace.id)).toEqual([ + 'local-folder', + 'remote-folder' + ]) + expect(store.getState().restoredRuntimeHostIdByWorkspaceSessionKey).toEqual({ + 'remote-repo::/srv/repo': 'runtime:env-1' + }) + + const missingGroupStore = createTestStore() + missingGroupStore.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as never, + projectGroups: [localProjectGroup], + restoredRuntimeHostIdByWorkspaceSessionKey: { [restoredFolderKey]: 'runtime:env-1' } + }) + await missingGroupStore.getState().fetchFolderWorkspacesForAllHosts() + + expect(missingGroupStore.getState().restoredRuntimeHostIdByWorkspaceSessionKey).toEqual({ + [restoredFolderKey]: 'runtime:env-1' + }) + }) + + it('accumulates folder catalogs from concurrent runtime hosts', async () => { + const secondRemoteGroup: ProjectGroup = { + ...remoteProjectGroup, + id: 'remote-group-2', + executionHostId: 'runtime:env-2' + } + const secondRemoteFolder: FolderWorkspace = { + ...remoteFolderWorkspace, + id: 'remote-folder-2', + projectGroupId: secondRemoteGroup.id + } + runtimeEnvironmentsList.mockResolvedValue([ + { id: 'env-1', name: 'lobster' }, + { id: 'env-2', name: 'shrimp' } + ]) + runtimeEnvironmentCall.mockImplementation( + (args: RuntimeEnvironmentCallRequest & { selector: string }) => { + if (args.method === 'folderWorkspace.list') { + const folderWorkspaces = + args.selector === 'env-2' ? [secondRemoteFolder] : [remoteFolderWorkspace] + return { + id: `rpc-folder-workspace-list-${args.selector}`, + ok: true, + result: { folderWorkspaces }, + _meta: { runtimeId: `runtime-${args.selector}` } + } + } + return { + id: `rpc-other-${args.selector}`, + ok: true, + result: { projects: [], setups: [] }, + _meta: { runtimeId: `runtime-${args.selector}` } + } + } + ) + const store = createTestStore() + store.setState({ + projectGroups: [ + { ...localProjectGroup, executionHostId: 'local' }, + { ...remoteProjectGroup, executionHostId: 'runtime:env-1' }, + secondRemoteGroup + ] + }) + + await store.getState().fetchFolderWorkspacesForAllHosts() + + expect( + store + .getState() + .folderWorkspaces.map((workspace) => workspace.id) + .sort() + ).toEqual(['local-folder', 'remote-folder', 'remote-folder-2']) + }) + + it('keeps local project groups and folder workspaces when a runtime is unreachable', async () => { + runtimeEnvironmentCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + if (args.method === 'projectGroup.list' || args.method === 'folderWorkspace.list') { + throw new Error('runtime_unreachable') + } + return { + id: 'rpc-other', + ok: true, + result: { repos: [], projects: [], setups: [] }, + _meta: { runtimeId: 'runtime-remote' } + } + }) + const store = createTestStore() + store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) + + await store.getState().fetchProjectGroupsForAllHosts() + await store.getState().fetchFolderWorkspacesForAllHosts() + + expect(store.getState().projectGroups).toEqual([ + { ...localProjectGroup, executionHostId: 'local' } + ]) + expect(store.getState().folderWorkspaces).toEqual([ + { ...localFolderWorkspace, executionHostId: 'local' } + ]) + }) + + it('does not repeat offline runtime compatibility probes across startup catalog loads', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + runtimeEnvironmentTransportCall.mockResolvedValue({ + id: 'status', + ok: false, + error: { code: 'runtime_unavailable', message: 'offline' }, + _meta: { runtimeId: 'runtime-remote' } + }) + const store = createTestStore() + store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) + const restoredFolderKey = folderWorkspaceKey('remote-folder') + store.setState({ + restoredRuntimeHostIdByWorkspaceSessionKey: { + [restoredFolderKey]: 'runtime:env-1' + } + }) + + try { + await store.getState().fetchReposForAllHosts() + await store.getState().fetchProjectGroupsForAllHosts() + await store.getState().fetchFolderWorkspacesForAllHosts() + + expect(store.getState().repos).toEqual([{ ...localRepo, executionHostId: 'local' }]) + expect(store.getState().projectGroups).toEqual([ + { ...localProjectGroup, executionHostId: 'local' } + ]) + expect(store.getState().folderWorkspaces).toEqual([ + { ...localFolderWorkspace, executionHostId: 'local' } + ]) + expect(runtimeEnvironmentTransportCall.mock.calls.map((call) => call[0].method)).toEqual([ + 'status.get' + ]) + expect(store.getState().restoredRuntimeHostIdByWorkspaceSessionKey).toEqual({ + [restoredFolderKey]: 'runtime:env-1' + }) + } finally { + warn.mockRestore() + } + }) + }) +} diff --git a/src/renderer/src/store/slices/repos-all-hosts-folder-workspaces.test.ts b/src/renderer/src/store/slices/repos-all-hosts-folder-workspaces.test.ts deleted file mode 100644 index 148ea2209dc..00000000000 --- a/src/renderer/src/store/slices/repos-all-hosts-folder-workspaces.test.ts +++ /dev/null @@ -1,325 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { createTestStore } from './store-test-helpers' -import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' -import type { ProjectGroup } from '../../../../shared/project-group-types' -import type { Repo } from '../../../../shared/repo-types' -import { - createCompatibleRuntimeStatusResponseIfNeeded, - type RuntimeEnvironmentCallRequest -} from '../../runtime/runtime-compatibility-test-fixture' -import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' -import { folderWorkspaceKey } from '../../../../shared/workspace-scope' - -const localRepo: Repo = { - id: 'local-repo', - path: '/local', - displayName: 'Local', - badgeColor: '#000', - addedAt: 1 -} - -const remoteRepo: Repo = { - id: 'remote-repo', - path: '/srv/repo', - displayName: 'Remote', - badgeColor: '#000', - addedAt: 1 -} - -const localProjectGroup: ProjectGroup = { - id: 'local-group', - name: 'Local group', - parentPath: '/local', - parentGroupId: null, - createdFrom: 'manual', - tabOrder: 0, - isCollapsed: false, - color: null, - createdAt: 1, - updatedAt: 1 -} - -const remoteProjectGroup: ProjectGroup = { - id: 'remote-group', - name: 'Remote group', - parentPath: '/srv', - parentGroupId: null, - createdFrom: 'manual', - tabOrder: 0, - isCollapsed: false, - color: null, - createdAt: 1, - updatedAt: 1 -} - -const localFolderWorkspace: FolderWorkspace = { - id: 'local-folder', - projectGroupId: 'local-group', - name: 'Local folder', - folderPath: '/local', - linkedTask: null, - comment: '', - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: 0, - lastActivityAt: 1, - createdAt: 1, - updatedAt: 1 -} - -const remoteFolderWorkspace: FolderWorkspace = { - id: 'remote-folder', - projectGroupId: 'remote-group', - name: 'Remote folder', - folderPath: '/srv', - linkedTask: null, - comment: '', - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: 0, - lastActivityAt: 1, - createdAt: 1, - updatedAt: 1 -} - -const reposList = vi.fn() -const projectsList = vi.fn() -const listHostSetups = vi.fn() -const projectGroupsList = vi.fn() -const folderWorkspacesList = vi.fn() -const runtimeEnvironmentsList = vi.fn() -const runtimeEnvironmentCall = vi.fn() -const runtimeEnvironmentTransportCall = vi.fn() -const dispatchEventMock = vi.fn() - -beforeEach(() => { - clearRuntimeCompatibilityCacheForTests() - reposList.mockReset() - projectsList.mockReset() - listHostSetups.mockReset() - projectGroupsList.mockReset() - folderWorkspacesList.mockReset() - runtimeEnvironmentsList.mockReset() - runtimeEnvironmentCall.mockReset() - runtimeEnvironmentTransportCall.mockReset() - dispatchEventMock.mockReset() - - reposList.mockResolvedValue([localRepo]) - projectsList.mockResolvedValue([]) - listHostSetups.mockResolvedValue([]) - projectGroupsList.mockResolvedValue([localProjectGroup]) - folderWorkspacesList.mockResolvedValue([localFolderWorkspace]) - runtimeEnvironmentsList.mockResolvedValue([{ id: 'env-1', name: 'lobster' }]) - runtimeEnvironmentCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { - if (args.method === 'repo.list') { - return { - id: 'rpc-repo-list', - ok: true, - result: { repos: [remoteRepo] }, - _meta: { runtimeId: 'runtime-remote' } - } - } - if (args.method === 'projectGroup.list') { - return { - id: 'rpc-project-group-list', - ok: true, - result: { groups: [remoteProjectGroup] }, - _meta: { runtimeId: 'runtime-remote' } - } - } - if (args.method === 'folderWorkspace.list') { - return { - id: 'rpc-folder-workspace-list', - ok: true, - result: { folderWorkspaces: [remoteFolderWorkspace] }, - _meta: { runtimeId: 'runtime-remote' } - } - } - return { - id: 'rpc-other', - ok: true, - result: { projects: [], setups: [] }, - _meta: { runtimeId: 'runtime-remote' } - } - }) - runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { - return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args) - }) - - vi.stubGlobal('window', { - api: { - repos: { list: reposList }, - projects: { list: projectsList, listHostSetups }, - projectGroups: { list: projectGroupsList }, - folderWorkspaces: { list: folderWorkspacesList }, - runtimeEnvironments: { - call: runtimeEnvironmentTransportCall, - list: runtimeEnvironmentsList - } - }, - dispatchEvent: dispatchEventMock - }) -}) - -describe('all-host folder workspace startup catalogs', () => { - it('loads project groups and folder workspaces for every host', async () => { - const store = createTestStore() - store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) - const restoredFolderKey = folderWorkspaceKey('remote-folder') - store.setState({ - restoredRuntimeHostIdByWorkspaceSessionKey: { - [restoredFolderKey]: 'runtime:env-1', - 'remote-repo::/srv/repo': 'runtime:env-1' - } - }) - - await store.getState().fetchProjectGroupsForAllHosts() - await store.getState().fetchFolderWorkspacesForAllHosts() - - expect(store.getState().projectGroups).toEqual([ - { ...localProjectGroup, executionHostId: 'local' }, - { ...remoteProjectGroup, executionHostId: 'runtime:env-1' } - ]) - expect(store.getState().folderWorkspaces.map((workspace) => workspace.id)).toEqual([ - 'local-folder', - 'remote-folder' - ]) - expect(store.getState().restoredRuntimeHostIdByWorkspaceSessionKey).toEqual({ - 'remote-repo::/srv/repo': 'runtime:env-1' - }) - - const missingGroupStore = createTestStore() - missingGroupStore.setState({ - settings: { activeRuntimeEnvironmentId: 'env-1' } as never, - projectGroups: [localProjectGroup], - restoredRuntimeHostIdByWorkspaceSessionKey: { [restoredFolderKey]: 'runtime:env-1' } - }) - await missingGroupStore.getState().fetchFolderWorkspacesForAllHosts() - - expect(missingGroupStore.getState().restoredRuntimeHostIdByWorkspaceSessionKey).toEqual({ - [restoredFolderKey]: 'runtime:env-1' - }) - }) - - it('accumulates folder catalogs from concurrent runtime hosts', async () => { - const secondRemoteGroup: ProjectGroup = { - ...remoteProjectGroup, - id: 'remote-group-2', - executionHostId: 'runtime:env-2' - } - const secondRemoteFolder: FolderWorkspace = { - ...remoteFolderWorkspace, - id: 'remote-folder-2', - projectGroupId: secondRemoteGroup.id - } - runtimeEnvironmentsList.mockResolvedValue([ - { id: 'env-1', name: 'lobster' }, - { id: 'env-2', name: 'shrimp' } - ]) - runtimeEnvironmentCall.mockImplementation( - (args: RuntimeEnvironmentCallRequest & { selector: string }) => { - if (args.method === 'folderWorkspace.list') { - const folderWorkspaces = - args.selector === 'env-2' ? [secondRemoteFolder] : [remoteFolderWorkspace] - return { - id: `rpc-folder-workspace-list-${args.selector}`, - ok: true, - result: { folderWorkspaces }, - _meta: { runtimeId: `runtime-${args.selector}` } - } - } - return { - id: `rpc-other-${args.selector}`, - ok: true, - result: { projects: [], setups: [] }, - _meta: { runtimeId: `runtime-${args.selector}` } - } - } - ) - const store = createTestStore() - store.setState({ - projectGroups: [ - { ...localProjectGroup, executionHostId: 'local' }, - { ...remoteProjectGroup, executionHostId: 'runtime:env-1' }, - secondRemoteGroup - ] - }) - - await store.getState().fetchFolderWorkspacesForAllHosts() - - expect( - store - .getState() - .folderWorkspaces.map((workspace) => workspace.id) - .sort() - ).toEqual(['local-folder', 'remote-folder', 'remote-folder-2']) - }) - - it('keeps local project groups and folder workspaces when a runtime is unreachable', async () => { - runtimeEnvironmentCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { - if (args.method === 'projectGroup.list' || args.method === 'folderWorkspace.list') { - throw new Error('runtime_unreachable') - } - return { - id: 'rpc-other', - ok: true, - result: { repos: [], projects: [], setups: [] }, - _meta: { runtimeId: 'runtime-remote' } - } - }) - const store = createTestStore() - store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) - - await store.getState().fetchProjectGroupsForAllHosts() - await store.getState().fetchFolderWorkspacesForAllHosts() - - expect(store.getState().projectGroups).toEqual([ - { ...localProjectGroup, executionHostId: 'local' } - ]) - expect(store.getState().folderWorkspaces).toEqual([ - { ...localFolderWorkspace, executionHostId: 'local' } - ]) - }) - - it('does not repeat offline runtime compatibility probes across startup catalog loads', async () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - runtimeEnvironmentTransportCall.mockResolvedValue({ - id: 'status', - ok: false, - error: { code: 'runtime_unavailable', message: 'offline' }, - _meta: { runtimeId: 'runtime-remote' } - }) - const store = createTestStore() - store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) - const restoredFolderKey = folderWorkspaceKey('remote-folder') - store.setState({ - restoredRuntimeHostIdByWorkspaceSessionKey: { - [restoredFolderKey]: 'runtime:env-1' - } - }) - - try { - await store.getState().fetchReposForAllHosts() - await store.getState().fetchProjectGroupsForAllHosts() - await store.getState().fetchFolderWorkspacesForAllHosts() - - expect(store.getState().repos).toEqual([{ ...localRepo, executionHostId: 'local' }]) - expect(store.getState().projectGroups).toEqual([ - { ...localProjectGroup, executionHostId: 'local' } - ]) - expect(store.getState().folderWorkspaces).toEqual([ - { ...localFolderWorkspace, executionHostId: 'local' } - ]) - expect(runtimeEnvironmentTransportCall.mock.calls.map((call) => call[0].method)).toEqual([ - 'status.get' - ]) - expect(store.getState().restoredRuntimeHostIdByWorkspaceSessionKey).toEqual({ - [restoredFolderKey]: 'runtime:env-1' - }) - } finally { - warn.mockRestore() - } - }) -}) diff --git a/src/renderer/src/store/slices/repos-catalog-merge-identity.spec.ts b/src/renderer/src/store/slices/repos-catalog-merge-identity.spec.ts new file mode 100644 index 00000000000..41fd71effd1 --- /dev/null +++ b/src/renderer/src/store/slices/repos-catalog-merge-identity.spec.ts @@ -0,0 +1,291 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' +import type { ProjectGroup } from '../../../../shared/project-group-types' +import type { Repo } from '../../../../shared/repo-types' +import type { FolderWorkspacePathStatusCacheEntry } from '../repos/repo-state' +import { createTestStore } from './store-test-helpers' + +export function registerReposCatalogMergeIdentityTests(): void { + const projectGroup: ProjectGroup = { + id: 'group-1', + name: 'Group 1', + parentPath: '/parent', + parentGroupId: null, + createdFrom: 'manual', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 + } + + const secondProjectGroup: ProjectGroup = { + ...projectGroup, + id: 'group-2', + name: 'Group 2', + parentPath: '/parent-2', + tabOrder: 1 + } + + const folderWorkspace: FolderWorkspace = { + id: 'folder-1', + projectGroupId: 'group-1', + name: 'Folder 1', + folderPath: '/parent/folder-1', + linkedTask: { + provider: 'github', + type: 'issue', + number: 7, + title: 'Issue 7', + url: 'https://example.test/7' + }, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 1, + createdAt: 1, + updatedAt: 1 + } + + const secondFolderWorkspace: FolderWorkspace = { + ...folderWorkspace, + id: 'folder-2', + name: 'Folder 2', + folderPath: '/parent/folder-2', + linkedTask: null, + sortOrder: 1 + } + + const repo: Repo = { + id: 'repo-1', + path: '/repo-1', + displayName: 'Repo 1', + badgeColor: '#000', + addedAt: 1, + executionHostId: 'local' + } + + const cachedPathStatuses: Record = { + 'local:folder-workspace:folder-1': { + status: { path: '/parent/folder-1', exists: false, reason: 'missing' }, + checkedAt: Date.now(), + requestSnapshot: 'snapshot' + } + } + + const projectGroupsList = vi.fn() + const folderWorkspacesList = vi.fn() + const reposList = vi.fn() + const projectsList = vi.fn() + const listHostSetups = vi.fn() + + // Why: catalogs arrive over IPC, so every fetch must hand back freshly allocated objects. + function clone(value: T): T { + return structuredClone(value) + } + + beforeEach(() => { + projectGroupsList.mockReset() + folderWorkspacesList.mockReset() + reposList.mockReset() + projectsList.mockReset() + listHostSetups.mockReset() + projectGroupsList.mockImplementation(async () => [clone(projectGroup)]) + folderWorkspacesList.mockImplementation(async () => [clone(folderWorkspace)]) + reposList.mockImplementation(async () => [clone(repo)]) + projectsList.mockImplementation(async () => []) + listHostSetups.mockImplementation(async () => []) + + vi.stubGlobal('window', { + api: { + projectGroups: { list: projectGroupsList }, + folderWorkspaces: { list: folderWorkspacesList }, + repos: { list: reposList }, + projects: { list: projectsList, listHostSetups } + }, + dispatchEvent: vi.fn() + }) + }) + + describe('catalog merge referential stability', () => { + it('keeps the same projectGroups array when a refetch changes nothing', async () => { + const store = createTestStore() + + await store.getState().fetchProjectGroups(catalogOwner(store)) + const first = store.getState().projectGroups + expect(first).toEqual([{ ...projectGroup, executionHostId: 'local' }]) + + await store.getState().fetchProjectGroups(catalogOwner(store)) + + expect(store.getState().projectGroups).toBe(first) + }) + + it('keeps the same folderWorkspaces array (and entries) when a refetch changes nothing', async () => { + const store = createTestStore() + store.setState({ projectGroups: [{ ...projectGroup, executionHostId: 'local' }] }) + + await store.getState().fetchFolderWorkspaces(catalogOwner(store)) + const first = store.getState().folderWorkspaces + expect(first).toHaveLength(1) + const firstEntry = first[0] + + await store.getState().fetchFolderWorkspaces(catalogOwner(store)) + + expect(store.getState().folderWorkspaces).toBe(first) + expect(store.getState().folderWorkspaces[0]).toBe(firstEntry) + }) + + it('appends new entries and replaces changed ones while keeping order', async () => { + const store = createTestStore() + + await store.getState().fetchProjectGroups(catalogOwner(store)) + const first = store.getState().projectGroups + + projectGroupsList.mockImplementation(async () => [ + clone({ ...projectGroup, name: 'Renamed' }), + clone(secondProjectGroup) + ]) + await store.getState().fetchProjectGroups(catalogOwner(store)) + + const merged = store.getState().projectGroups + expect(merged).not.toBe(first) + expect(merged).toEqual([ + { ...projectGroup, name: 'Renamed', executionHostId: 'local' }, + { ...secondProjectGroup, executionHostId: 'local' } + ]) + }) + + it('appends new folder workspaces without disturbing existing order', async () => { + const store = createTestStore() + store.setState({ projectGroups: [{ ...projectGroup, executionHostId: 'local' }] }) + + await store.getState().fetchFolderWorkspaces(catalogOwner(store)) + const firstEntry = store.getState().folderWorkspaces[0] + + folderWorkspacesList.mockImplementation(async () => [ + clone(secondFolderWorkspace), + clone(folderWorkspace) + ]) + await store.getState().fetchFolderWorkspaces(catalogOwner(store)) + + const merged = store.getState().folderWorkspaces + expect(merged.map((workspace) => workspace.id)).toEqual(['folder-1', 'folder-2']) + // Unchanged entries keep their reference even when the array changes. + expect(merged[0]).toBe(firstEntry) + }) + + it('drops entries that vanish from the fetched catalog', async () => { + const store = createTestStore() + store.setState({ projectGroups: [{ ...projectGroup, executionHostId: 'local' }] }) + + projectGroupsList.mockImplementation(async () => [clone(secondProjectGroup)]) + await store.getState().fetchProjectGroups(catalogOwner(store)) + + expect(store.getState().projectGroups).toEqual([ + { ...secondProjectGroup, executionHostId: 'local' } + ]) + }) + + it('treats a nested linkedTask change as a change', async () => { + const store = createTestStore() + store.setState({ projectGroups: [{ ...projectGroup, executionHostId: 'local' }] }) + + await store.getState().fetchFolderWorkspaces(catalogOwner(store)) + const first = store.getState().folderWorkspaces + + folderWorkspacesList.mockImplementation(async () => [ + clone({ + ...folderWorkspace, + linkedTask: { ...folderWorkspace.linkedTask!, title: 'Issue 7 renamed' } + }) + ]) + await store.getState().fetchFolderWorkspaces(catalogOwner(store)) + + expect(store.getState().folderWorkspaces).not.toBe(first) + expect(store.getState().folderWorkspaces[0]?.linkedTask?.title).toBe('Issue 7 renamed') + }) + }) + + // The sidebar burst that refills this cache only runs when these arrays change reference, so a + // no-op fetch must not wipe it — otherwise stale-folder rows silently fail open forever. + describe('folder path status cache retention across catalog fetches', () => { + it('keeps cached path statuses when a project-group refetch changes nothing', async () => { + const store = createTestStore() + await store.getState().fetchProjectGroups(catalogOwner(store)) + store.setState({ folderWorkspacePathStatuses: cachedPathStatuses }) + + await store.getState().fetchProjectGroups(catalogOwner(store)) + + expect(store.getState().folderWorkspacePathStatuses).toBe(cachedPathStatuses) + }) + + it('clears cached path statuses when the project-group catalog changes', async () => { + const store = createTestStore() + await store.getState().fetchProjectGroups(catalogOwner(store)) + store.setState({ folderWorkspacePathStatuses: cachedPathStatuses }) + + projectGroupsList.mockImplementation(async () => [ + clone(projectGroup), + clone(secondProjectGroup) + ]) + await store.getState().fetchProjectGroups(catalogOwner(store)) + + expect(store.getState().folderWorkspacePathStatuses).toEqual({}) + }) + + it('keeps cached path statuses when a folder-workspace refetch changes nothing', async () => { + const store = createTestStore() + store.setState({ projectGroups: [{ ...projectGroup, executionHostId: 'local' }] }) + await store.getState().fetchFolderWorkspaces(catalogOwner(store)) + store.setState({ folderWorkspacePathStatuses: cachedPathStatuses }) + + await store.getState().fetchFolderWorkspaces(catalogOwner(store)) + + expect(store.getState().folderWorkspacePathStatuses).toBe(cachedPathStatuses) + }) + + it('clears cached path statuses when a folder workspace path changes', async () => { + const store = createTestStore() + store.setState({ projectGroups: [{ ...projectGroup, executionHostId: 'local' }] }) + await store.getState().fetchFolderWorkspaces(catalogOwner(store)) + store.setState({ folderWorkspacePathStatuses: cachedPathStatuses }) + + folderWorkspacesList.mockImplementation(async () => [ + clone({ ...folderWorkspace, folderPath: '/parent/folder-1-renamed' }) + ]) + await store.getState().fetchFolderWorkspaces(catalogOwner(store)) + + expect(store.getState().folderWorkspacePathStatuses).toEqual({}) + }) + + it('keeps cached path statuses when a repo refetch changes nothing', async () => { + const store = createTestStore() + await store.getState().fetchRepos(catalogOwner(store)) + store.setState({ folderWorkspacePathStatuses: cachedPathStatuses }) + + await store.getState().fetchRepos(catalogOwner(store)) + + expect(store.getState().folderWorkspacePathStatuses).toBe(cachedPathStatuses) + }) + + it('clears cached path statuses when the repo catalog changes', async () => { + const store = createTestStore() + await store.getState().fetchRepos(catalogOwner(store)) + store.setState({ folderWorkspacePathStatuses: cachedPathStatuses }) + + reposList.mockImplementation(async () => [clone({ ...repo, path: '/repo-1-moved' })]) + await store.getState().fetchRepos(catalogOwner(store)) + + expect(store.getState().folderWorkspacePathStatuses).toEqual({}) + }) + }) + + // The catalog these tests read is the one the focused host would have shown. + function catalogOwner(store: { + getState: () => { settings: { activeRuntimeEnvironmentId?: string | null } | null } + }): { runtimeEnvironmentId: string | null } { + return { runtimeEnvironmentId: store.getState().settings?.activeRuntimeEnvironmentId ?? null } + } +} diff --git a/src/renderer/src/store/slices/repos-catalog-merge-identity.test.ts b/src/renderer/src/store/slices/repos-catalog-merge-identity.test.ts deleted file mode 100644 index 44a482de0d0..00000000000 --- a/src/renderer/src/store/slices/repos-catalog-merge-identity.test.ts +++ /dev/null @@ -1,289 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' -import type { ProjectGroup } from '../../../../shared/project-group-types' -import type { Repo } from '../../../../shared/repo-types' -import type { FolderWorkspacePathStatusCacheEntry } from '../repos/repo-state' -import { createTestStore } from './store-test-helpers' - -const projectGroup: ProjectGroup = { - id: 'group-1', - name: 'Group 1', - parentPath: '/parent', - parentGroupId: null, - createdFrom: 'manual', - tabOrder: 0, - isCollapsed: false, - color: null, - createdAt: 1, - updatedAt: 1 -} - -const secondProjectGroup: ProjectGroup = { - ...projectGroup, - id: 'group-2', - name: 'Group 2', - parentPath: '/parent-2', - tabOrder: 1 -} - -const folderWorkspace: FolderWorkspace = { - id: 'folder-1', - projectGroupId: 'group-1', - name: 'Folder 1', - folderPath: '/parent/folder-1', - linkedTask: { - provider: 'github', - type: 'issue', - number: 7, - title: 'Issue 7', - url: 'https://example.test/7' - }, - comment: '', - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: 0, - lastActivityAt: 1, - createdAt: 1, - updatedAt: 1 -} - -const secondFolderWorkspace: FolderWorkspace = { - ...folderWorkspace, - id: 'folder-2', - name: 'Folder 2', - folderPath: '/parent/folder-2', - linkedTask: null, - sortOrder: 1 -} - -const repo: Repo = { - id: 'repo-1', - path: '/repo-1', - displayName: 'Repo 1', - badgeColor: '#000', - addedAt: 1, - executionHostId: 'local' -} - -const cachedPathStatuses: Record = { - 'local:folder-workspace:folder-1': { - status: { path: '/parent/folder-1', exists: false, reason: 'missing' }, - checkedAt: Date.now(), - requestSnapshot: 'snapshot' - } -} - -const projectGroupsList = vi.fn() -const folderWorkspacesList = vi.fn() -const reposList = vi.fn() -const projectsList = vi.fn() -const listHostSetups = vi.fn() - -// Why: catalogs arrive over IPC, so every fetch must hand back freshly allocated objects. -function clone(value: T): T { - return structuredClone(value) -} - -beforeEach(() => { - projectGroupsList.mockReset() - folderWorkspacesList.mockReset() - reposList.mockReset() - projectsList.mockReset() - listHostSetups.mockReset() - projectGroupsList.mockImplementation(async () => [clone(projectGroup)]) - folderWorkspacesList.mockImplementation(async () => [clone(folderWorkspace)]) - reposList.mockImplementation(async () => [clone(repo)]) - projectsList.mockImplementation(async () => []) - listHostSetups.mockImplementation(async () => []) - - vi.stubGlobal('window', { - api: { - projectGroups: { list: projectGroupsList }, - folderWorkspaces: { list: folderWorkspacesList }, - repos: { list: reposList }, - projects: { list: projectsList, listHostSetups } - }, - dispatchEvent: vi.fn() - }) -}) - -describe('catalog merge referential stability', () => { - it('keeps the same projectGroups array when a refetch changes nothing', async () => { - const store = createTestStore() - - await store.getState().fetchProjectGroups(catalogOwner(store)) - const first = store.getState().projectGroups - expect(first).toEqual([{ ...projectGroup, executionHostId: 'local' }]) - - await store.getState().fetchProjectGroups(catalogOwner(store)) - - expect(store.getState().projectGroups).toBe(first) - }) - - it('keeps the same folderWorkspaces array (and entries) when a refetch changes nothing', async () => { - const store = createTestStore() - store.setState({ projectGroups: [{ ...projectGroup, executionHostId: 'local' }] }) - - await store.getState().fetchFolderWorkspaces(catalogOwner(store)) - const first = store.getState().folderWorkspaces - expect(first).toHaveLength(1) - const firstEntry = first[0] - - await store.getState().fetchFolderWorkspaces(catalogOwner(store)) - - expect(store.getState().folderWorkspaces).toBe(first) - expect(store.getState().folderWorkspaces[0]).toBe(firstEntry) - }) - - it('appends new entries and replaces changed ones while keeping order', async () => { - const store = createTestStore() - - await store.getState().fetchProjectGroups(catalogOwner(store)) - const first = store.getState().projectGroups - - projectGroupsList.mockImplementation(async () => [ - clone({ ...projectGroup, name: 'Renamed' }), - clone(secondProjectGroup) - ]) - await store.getState().fetchProjectGroups(catalogOwner(store)) - - const merged = store.getState().projectGroups - expect(merged).not.toBe(first) - expect(merged).toEqual([ - { ...projectGroup, name: 'Renamed', executionHostId: 'local' }, - { ...secondProjectGroup, executionHostId: 'local' } - ]) - }) - - it('appends new folder workspaces without disturbing existing order', async () => { - const store = createTestStore() - store.setState({ projectGroups: [{ ...projectGroup, executionHostId: 'local' }] }) - - await store.getState().fetchFolderWorkspaces(catalogOwner(store)) - const firstEntry = store.getState().folderWorkspaces[0] - - folderWorkspacesList.mockImplementation(async () => [ - clone(secondFolderWorkspace), - clone(folderWorkspace) - ]) - await store.getState().fetchFolderWorkspaces(catalogOwner(store)) - - const merged = store.getState().folderWorkspaces - expect(merged.map((workspace) => workspace.id)).toEqual(['folder-1', 'folder-2']) - // Unchanged entries keep their reference even when the array changes. - expect(merged[0]).toBe(firstEntry) - }) - - it('drops entries that vanish from the fetched catalog', async () => { - const store = createTestStore() - store.setState({ projectGroups: [{ ...projectGroup, executionHostId: 'local' }] }) - - projectGroupsList.mockImplementation(async () => [clone(secondProjectGroup)]) - await store.getState().fetchProjectGroups(catalogOwner(store)) - - expect(store.getState().projectGroups).toEqual([ - { ...secondProjectGroup, executionHostId: 'local' } - ]) - }) - - it('treats a nested linkedTask change as a change', async () => { - const store = createTestStore() - store.setState({ projectGroups: [{ ...projectGroup, executionHostId: 'local' }] }) - - await store.getState().fetchFolderWorkspaces(catalogOwner(store)) - const first = store.getState().folderWorkspaces - - folderWorkspacesList.mockImplementation(async () => [ - clone({ - ...folderWorkspace, - linkedTask: { ...folderWorkspace.linkedTask!, title: 'Issue 7 renamed' } - }) - ]) - await store.getState().fetchFolderWorkspaces(catalogOwner(store)) - - expect(store.getState().folderWorkspaces).not.toBe(first) - expect(store.getState().folderWorkspaces[0]?.linkedTask?.title).toBe('Issue 7 renamed') - }) -}) - -// The sidebar burst that refills this cache only runs when these arrays change reference, so a -// no-op fetch must not wipe it — otherwise stale-folder rows silently fail open forever. -describe('folder path status cache retention across catalog fetches', () => { - it('keeps cached path statuses when a project-group refetch changes nothing', async () => { - const store = createTestStore() - await store.getState().fetchProjectGroups(catalogOwner(store)) - store.setState({ folderWorkspacePathStatuses: cachedPathStatuses }) - - await store.getState().fetchProjectGroups(catalogOwner(store)) - - expect(store.getState().folderWorkspacePathStatuses).toBe(cachedPathStatuses) - }) - - it('clears cached path statuses when the project-group catalog changes', async () => { - const store = createTestStore() - await store.getState().fetchProjectGroups(catalogOwner(store)) - store.setState({ folderWorkspacePathStatuses: cachedPathStatuses }) - - projectGroupsList.mockImplementation(async () => [ - clone(projectGroup), - clone(secondProjectGroup) - ]) - await store.getState().fetchProjectGroups(catalogOwner(store)) - - expect(store.getState().folderWorkspacePathStatuses).toEqual({}) - }) - - it('keeps cached path statuses when a folder-workspace refetch changes nothing', async () => { - const store = createTestStore() - store.setState({ projectGroups: [{ ...projectGroup, executionHostId: 'local' }] }) - await store.getState().fetchFolderWorkspaces(catalogOwner(store)) - store.setState({ folderWorkspacePathStatuses: cachedPathStatuses }) - - await store.getState().fetchFolderWorkspaces(catalogOwner(store)) - - expect(store.getState().folderWorkspacePathStatuses).toBe(cachedPathStatuses) - }) - - it('clears cached path statuses when a folder workspace path changes', async () => { - const store = createTestStore() - store.setState({ projectGroups: [{ ...projectGroup, executionHostId: 'local' }] }) - await store.getState().fetchFolderWorkspaces(catalogOwner(store)) - store.setState({ folderWorkspacePathStatuses: cachedPathStatuses }) - - folderWorkspacesList.mockImplementation(async () => [ - clone({ ...folderWorkspace, folderPath: '/parent/folder-1-renamed' }) - ]) - await store.getState().fetchFolderWorkspaces(catalogOwner(store)) - - expect(store.getState().folderWorkspacePathStatuses).toEqual({}) - }) - - it('keeps cached path statuses when a repo refetch changes nothing', async () => { - const store = createTestStore() - await store.getState().fetchRepos(catalogOwner(store)) - store.setState({ folderWorkspacePathStatuses: cachedPathStatuses }) - - await store.getState().fetchRepos(catalogOwner(store)) - - expect(store.getState().folderWorkspacePathStatuses).toBe(cachedPathStatuses) - }) - - it('clears cached path statuses when the repo catalog changes', async () => { - const store = createTestStore() - await store.getState().fetchRepos(catalogOwner(store)) - store.setState({ folderWorkspacePathStatuses: cachedPathStatuses }) - - reposList.mockImplementation(async () => [clone({ ...repo, path: '/repo-1-moved' })]) - await store.getState().fetchRepos(catalogOwner(store)) - - expect(store.getState().folderWorkspacePathStatuses).toEqual({}) - }) -}) - -// The catalog these tests read is the one the focused host would have shown. -function catalogOwner(store: { - getState: () => { settings: { activeRuntimeEnvironmentId?: string | null } | null } -}): { runtimeEnvironmentId: string | null } { - return { runtimeEnvironmentId: store.getState().settings?.activeRuntimeEnvironmentId ?? null } -} diff --git a/src/renderer/src/store/slices/repos-catalog-notifications.spec.ts b/src/renderer/src/store/slices/repos-catalog-notifications.spec.ts new file mode 100644 index 00000000000..9dd227adacb --- /dev/null +++ b/src/renderer/src/store/slices/repos-catalog-notifications.spec.ts @@ -0,0 +1,238 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' +import type { ProjectGroup } from '../../../../shared/project-group-types' +import { createTestStore } from './store-test-helpers' + +export function registerReposCatalogNotificationsTests(): void { + const group: ProjectGroup = { + id: 'group-1', + name: 'Group', + parentPath: '/parent', + parentGroupId: null, + createdFrom: 'manual', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 3 + } + const folder: FolderWorkspace = { + id: 'folder-1', + projectGroupId: group.id, + name: 'Folder', + folderPath: '/parent/folder', + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 1, + createdAt: 1, + updatedAt: 3 + } + const groupsList = vi.fn<() => Promise>() + const foldersList = vi.fn<() => Promise>() + const folderUpdate = vi.fn() + type TestStore = ReturnType + type CatalogCase = { + label: string + catalog: 'projectGroups' | 'folderWorkspaces' + refresh: (store: TestStore) => Promise + } + const cases: CatalogCase[] = [ + { + label: 'selected groups', + catalog: 'projectGroups', + refresh: (s) => s.getState().fetchProjectGroups(catalogOwner(s)) + }, + { + label: 'all-host local groups', + catalog: 'projectGroups', + refresh: (s) => s.getState().fetchProjectGroupsForAllHosts({ remoteHosts: 'skip' }) + }, + { + label: 'selected folders', + catalog: 'folderWorkspaces', + refresh: (s) => s.getState().fetchFolderWorkspaces(catalogOwner(s)) + }, + { + label: 'all-host local folders', + catalog: 'folderWorkspaces', + refresh: (s) => s.getState().fetchFolderWorkspacesForAllHosts({ remoteHosts: 'skip' }) + } + ] + + beforeEach(() => { + groupsList.mockReset().mockImplementation(async () => structuredClone([group])) + foldersList.mockReset().mockImplementation(async () => structuredClone([folder])) + folderUpdate.mockReset() + vi.stubGlobal('window', { + api: { + projectGroups: { list: groupsList }, + folderWorkspaces: { list: foldersList, update: folderUpdate }, + runtimeEnvironments: { list: async () => [] } + }, + dispatchEvent: vi.fn() + }) + vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() + }) + + function seed(): TestStore { + const store = createTestStore() + store.setState({ + projectGroups: [{ ...group, executionHostId: 'local' }], + folderWorkspaces: [{ ...folder, executionHostId: 'local' }], + folderWorkspacePathStatuses: { + cached: { + status: { path: folder.folderPath, exists: true }, + checkedAt: 1, + requestSnapshot: 'snapshot' + } + } + }) + return store + } + + it.each(cases)('does not notify for ten equal $label refreshes', async ({ refresh, catalog }) => { + const store = seed() + const initial = store.getState() + const changed = vi.fn() + const unsubscribe = store.subscribe(changed) + for (let index = 0; index < 10; index += 1) { + await refresh(store) + } + unsubscribe() + expect(changed).not.toHaveBeenCalled() + expect(store.getState()).toBe(initial) + expect(store.getState()[catalog]).toBe(initial[catalog]) + expect(store.getState().folderWorkspacePathStatuses).toBe(initial.folderWorkspacePathStatuses) + }) + + it.each(cases)( + 'publishes changed $label and invalidates path statuses', + async ({ refresh, catalog }) => { + const store = seed() + groupsList.mockResolvedValue([{ ...group, name: 'Changed group' }]) + foldersList.mockResolvedValue([{ ...folder, name: 'Changed folder' }]) + const changed = vi.fn() + const unsubscribe = store.subscribe(changed) + await refresh(store) + unsubscribe() + expect(changed).toHaveBeenCalledOnce() + expect(store.getState()[catalog][0]?.name).toMatch(/^Changed /) + expect(store.getState().folderWorkspacePathStatuses).toEqual({}) + } + ) + + it.each(cases)('retains state after a failed $label read', async ({ refresh }) => { + const store = seed() + const initial = store.getState() + groupsList.mockRejectedValue(new Error('offline')) + foldersList.mockRejectedValue(new Error('offline')) + const changed = vi.fn() + const unsubscribe = store.subscribe(changed) + await refresh(store) + unsubscribe() + expect(store.getState()).toBe(initial) + expect(changed).not.toHaveBeenCalled() + }) + + it.each(cases)( + 'fences an older $label response after a newer no-op response', + async ({ refresh, catalog }) => { + const store = seed() + const initial = store.getState() + const olderGroups = Promise.withResolvers() + const olderFolders = Promise.withResolvers() + if (catalog === 'projectGroups') { + groupsList.mockReturnValueOnce(olderGroups.promise) + } else { + foldersList.mockReturnValueOnce(olderFolders.promise) + } + const older = refresh(store) + const changed = vi.fn() + const unsubscribe = store.subscribe(changed) + await refresh(store) + olderGroups.resolve([{ ...group, name: 'Obsolete group' }]) + olderFolders.resolve([{ ...folder, name: 'Obsolete folder' }]) + await older + unsubscribe() + expect(store.getState()).toBe(initial) + expect(changed).not.toHaveBeenCalled() + } + ) + + it.each(cases.filter((entry) => entry.catalog === 'folderWorkspaces'))( + 'keeps update revision fencing after an equal $label response', + async ({ refresh }) => { + const store = seed() + const pending = Promise.withResolvers() + folderUpdate.mockReturnValueOnce(pending.promise) + const update = store.getState().updateFolderWorkspace(folder.id, { isUnread: true }) + const initial = store.getState() + await refresh(store) + expect(store.getState()).toBe(initial) + pending.resolve({ ...folder, isUnread: true, updatedAt: 2 }) + await update + expect(store.getState().folderWorkspaces[0]?.isUnread).toBe(false) + expect(store.getState().folderWorkspaces[0]?.updatedAt).toBe(3) + } + ) + + it('accepts a newer update after an equal catalog response', async () => { + const store = seed() + const pending = Promise.withResolvers() + folderUpdate.mockReturnValueOnce(pending.promise) + const update = store.getState().updateFolderWorkspace(folder.id, { isUnread: true }) + await store.getState().fetchFolderWorkspaces(catalogOwner(store)) + pending.resolve({ ...folder, isUnread: true, updatedAt: 4 }) + await update + expect(store.getState().folderWorkspaces[0]?.isUnread).toBe(true) + expect(store.getState().folderWorkspaces[0]?.updatedAt).toBe(4) + }) + + it('still clears restored folder owners after a successful all-host refresh', async () => { + const store = seed() + store.setState({ + restoredRuntimeHostIdByWorkspaceSessionKey: { + 'folder:folder-1': 'runtime:retired', + unrelated: 'runtime:kept' + } + }) + const changed = vi.fn() + const unsubscribe = store.subscribe(changed) + await store.getState().fetchFolderWorkspacesForAllHosts() + unsubscribe() + expect(changed).toHaveBeenCalledOnce() + expect(store.getState().restoredRuntimeHostIdByWorkspaceSessionKey).toEqual({ + unrelated: 'runtime:kept' + }) + }) + + it('does not publish an all-host refresh whose restored owners need no cleanup', async () => { + const store = seed() + store.setState({ restoredRuntimeHostIdByWorkspaceSessionKey: { unrelated: 'runtime:kept' } }) + const initial = store.getState() + const changed = vi.fn() + const unsubscribe = store.subscribe(changed) + await store.getState().fetchFolderWorkspacesForAllHosts() + unsubscribe() + expect(changed).not.toHaveBeenCalled() + expect(store.getState()).toBe(initial) + expect(store.getState().restoredRuntimeHostIdByWorkspaceSessionKey).toBe( + initial.restoredRuntimeHostIdByWorkspaceSessionKey + ) + }) + + // The catalog these tests read is the one the focused host would have shown. + function catalogOwner(store: { + getState: () => { settings: { activeRuntimeEnvironmentId?: string | null } | null } + }): { runtimeEnvironmentId: string | null } { + return { runtimeEnvironmentId: store.getState().settings?.activeRuntimeEnvironmentId ?? null } + } +} diff --git a/src/renderer/src/store/slices/repos-catalog-notifications.test.ts b/src/renderer/src/store/slices/repos-catalog-notifications.test.ts deleted file mode 100644 index cb06b6c788c..00000000000 --- a/src/renderer/src/store/slices/repos-catalog-notifications.test.ts +++ /dev/null @@ -1,236 +0,0 @@ -import { afterEach, beforeEach, expect, it, vi } from 'vitest' -import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' -import type { ProjectGroup } from '../../../../shared/project-group-types' -import { createTestStore } from './store-test-helpers' - -const group: ProjectGroup = { - id: 'group-1', - name: 'Group', - parentPath: '/parent', - parentGroupId: null, - createdFrom: 'manual', - tabOrder: 0, - isCollapsed: false, - color: null, - createdAt: 1, - updatedAt: 3 -} -const folder: FolderWorkspace = { - id: 'folder-1', - projectGroupId: group.id, - name: 'Folder', - folderPath: '/parent/folder', - linkedTask: null, - comment: '', - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: 0, - lastActivityAt: 1, - createdAt: 1, - updatedAt: 3 -} -const groupsList = vi.fn<() => Promise>() -const foldersList = vi.fn<() => Promise>() -const folderUpdate = vi.fn() -type TestStore = ReturnType -type CatalogCase = { - label: string - catalog: 'projectGroups' | 'folderWorkspaces' - refresh: (store: TestStore) => Promise -} -const cases: CatalogCase[] = [ - { - label: 'selected groups', - catalog: 'projectGroups', - refresh: (s) => s.getState().fetchProjectGroups(catalogOwner(s)) - }, - { - label: 'all-host local groups', - catalog: 'projectGroups', - refresh: (s) => s.getState().fetchProjectGroupsForAllHosts({ remoteHosts: 'skip' }) - }, - { - label: 'selected folders', - catalog: 'folderWorkspaces', - refresh: (s) => s.getState().fetchFolderWorkspaces(catalogOwner(s)) - }, - { - label: 'all-host local folders', - catalog: 'folderWorkspaces', - refresh: (s) => s.getState().fetchFolderWorkspacesForAllHosts({ remoteHosts: 'skip' }) - } -] - -beforeEach(() => { - groupsList.mockReset().mockImplementation(async () => structuredClone([group])) - foldersList.mockReset().mockImplementation(async () => structuredClone([folder])) - folderUpdate.mockReset() - vi.stubGlobal('window', { - api: { - projectGroups: { list: groupsList }, - folderWorkspaces: { list: foldersList, update: folderUpdate }, - runtimeEnvironments: { list: async () => [] } - }, - dispatchEvent: vi.fn() - }) - vi.spyOn(console, 'error').mockImplementation(() => {}) -}) -afterEach(() => { - vi.restoreAllMocks() - vi.unstubAllGlobals() -}) - -function seed(): TestStore { - const store = createTestStore() - store.setState({ - projectGroups: [{ ...group, executionHostId: 'local' }], - folderWorkspaces: [{ ...folder, executionHostId: 'local' }], - folderWorkspacePathStatuses: { - cached: { - status: { path: folder.folderPath, exists: true }, - checkedAt: 1, - requestSnapshot: 'snapshot' - } - } - }) - return store -} - -it.each(cases)('does not notify for ten equal $label refreshes', async ({ refresh, catalog }) => { - const store = seed() - const initial = store.getState() - const changed = vi.fn() - const unsubscribe = store.subscribe(changed) - for (let index = 0; index < 10; index += 1) { - await refresh(store) - } - unsubscribe() - expect(changed).not.toHaveBeenCalled() - expect(store.getState()).toBe(initial) - expect(store.getState()[catalog]).toBe(initial[catalog]) - expect(store.getState().folderWorkspacePathStatuses).toBe(initial.folderWorkspacePathStatuses) -}) - -it.each(cases)( - 'publishes changed $label and invalidates path statuses', - async ({ refresh, catalog }) => { - const store = seed() - groupsList.mockResolvedValue([{ ...group, name: 'Changed group' }]) - foldersList.mockResolvedValue([{ ...folder, name: 'Changed folder' }]) - const changed = vi.fn() - const unsubscribe = store.subscribe(changed) - await refresh(store) - unsubscribe() - expect(changed).toHaveBeenCalledOnce() - expect(store.getState()[catalog][0]?.name).toMatch(/^Changed /) - expect(store.getState().folderWorkspacePathStatuses).toEqual({}) - } -) - -it.each(cases)('retains state after a failed $label read', async ({ refresh }) => { - const store = seed() - const initial = store.getState() - groupsList.mockRejectedValue(new Error('offline')) - foldersList.mockRejectedValue(new Error('offline')) - const changed = vi.fn() - const unsubscribe = store.subscribe(changed) - await refresh(store) - unsubscribe() - expect(store.getState()).toBe(initial) - expect(changed).not.toHaveBeenCalled() -}) - -it.each(cases)( - 'fences an older $label response after a newer no-op response', - async ({ refresh, catalog }) => { - const store = seed() - const initial = store.getState() - const olderGroups = Promise.withResolvers() - const olderFolders = Promise.withResolvers() - if (catalog === 'projectGroups') { - groupsList.mockReturnValueOnce(olderGroups.promise) - } else { - foldersList.mockReturnValueOnce(olderFolders.promise) - } - const older = refresh(store) - const changed = vi.fn() - const unsubscribe = store.subscribe(changed) - await refresh(store) - olderGroups.resolve([{ ...group, name: 'Obsolete group' }]) - olderFolders.resolve([{ ...folder, name: 'Obsolete folder' }]) - await older - unsubscribe() - expect(store.getState()).toBe(initial) - expect(changed).not.toHaveBeenCalled() - } -) - -it.each(cases.filter((entry) => entry.catalog === 'folderWorkspaces'))( - 'keeps update revision fencing after an equal $label response', - async ({ refresh }) => { - const store = seed() - const pending = Promise.withResolvers() - folderUpdate.mockReturnValueOnce(pending.promise) - const update = store.getState().updateFolderWorkspace(folder.id, { isUnread: true }) - const initial = store.getState() - await refresh(store) - expect(store.getState()).toBe(initial) - pending.resolve({ ...folder, isUnread: true, updatedAt: 2 }) - await update - expect(store.getState().folderWorkspaces[0]?.isUnread).toBe(false) - expect(store.getState().folderWorkspaces[0]?.updatedAt).toBe(3) - } -) - -it('accepts a newer update after an equal catalog response', async () => { - const store = seed() - const pending = Promise.withResolvers() - folderUpdate.mockReturnValueOnce(pending.promise) - const update = store.getState().updateFolderWorkspace(folder.id, { isUnread: true }) - await store.getState().fetchFolderWorkspaces(catalogOwner(store)) - pending.resolve({ ...folder, isUnread: true, updatedAt: 4 }) - await update - expect(store.getState().folderWorkspaces[0]?.isUnread).toBe(true) - expect(store.getState().folderWorkspaces[0]?.updatedAt).toBe(4) -}) - -it('still clears restored folder owners after a successful all-host refresh', async () => { - const store = seed() - store.setState({ - restoredRuntimeHostIdByWorkspaceSessionKey: { - 'folder:folder-1': 'runtime:retired', - unrelated: 'runtime:kept' - } - }) - const changed = vi.fn() - const unsubscribe = store.subscribe(changed) - await store.getState().fetchFolderWorkspacesForAllHosts() - unsubscribe() - expect(changed).toHaveBeenCalledOnce() - expect(store.getState().restoredRuntimeHostIdByWorkspaceSessionKey).toEqual({ - unrelated: 'runtime:kept' - }) -}) - -it('does not publish an all-host refresh whose restored owners need no cleanup', async () => { - const store = seed() - store.setState({ restoredRuntimeHostIdByWorkspaceSessionKey: { unrelated: 'runtime:kept' } }) - const initial = store.getState() - const changed = vi.fn() - const unsubscribe = store.subscribe(changed) - await store.getState().fetchFolderWorkspacesForAllHosts() - unsubscribe() - expect(changed).not.toHaveBeenCalled() - expect(store.getState()).toBe(initial) - expect(store.getState().restoredRuntimeHostIdByWorkspaceSessionKey).toBe( - initial.restoredRuntimeHostIdByWorkspaceSessionKey - ) -}) - -// The catalog these tests read is the one the focused host would have shown. -function catalogOwner(store: { - getState: () => { settings: { activeRuntimeEnvironmentId?: string | null } | null } -}): { runtimeEnvironmentId: string | null } { - return { runtimeEnvironmentId: store.getState().settings?.activeRuntimeEnvironmentId ?? null } -} diff --git a/src/renderer/src/store/slices/repos-catalog-reconciliation.test.ts b/src/renderer/src/store/slices/repos-catalog-reconciliation.test.ts new file mode 100644 index 00000000000..ad3e686ee69 --- /dev/null +++ b/src/renderer/src/store/slices/repos-catalog-reconciliation.test.ts @@ -0,0 +1,16 @@ +import { describe } from 'vitest' +import { registerReposCatalogMergeIdentityTests } from './repos-catalog-merge-identity.spec' +import { registerReposRefreshIdentityTests } from './repos-refresh-identity.spec' +import { registerReposCatalogNotificationsTests } from './repos-catalog-notifications.spec' +import { registerReposStaleFetchTests } from './repos-stale-fetch.spec' +import { registerReposSharedProjectBadgeColorTests } from './repos-shared-project-badge-color.spec' +import { registerReposAllHostsFolderWorkspacesTests } from './repos-all-hosts-folder-workspaces.spec' +import { registerReposRuntimeVisibilityDefaultsTests } from './repos-runtime-visibility-defaults.spec' + +describe('repos-catalog-merge-identity', registerReposCatalogMergeIdentityTests) +describe('repos-refresh-identity', registerReposRefreshIdentityTests) +describe('repos-catalog-notifications', registerReposCatalogNotificationsTests) +describe('repos-stale-fetch', registerReposStaleFetchTests) +describe('repos-shared-project-badge-color', registerReposSharedProjectBadgeColorTests) +describe('repos-all-hosts-folder-workspaces', registerReposAllHostsFolderWorkspacesTests) +describe('repos-runtime-visibility-defaults', registerReposRuntimeVisibilityDefaultsTests) diff --git a/src/renderer/src/store/slices/repos-project-group-create-race.spec.ts b/src/renderer/src/store/slices/repos-project-group-create-race.spec.ts new file mode 100644 index 00000000000..3e946c5c307 --- /dev/null +++ b/src/renderer/src/store/slices/repos-project-group-create-race.spec.ts @@ -0,0 +1,185 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getDefaultSettings } from '../../../../shared/constants' +import type { ProjectGroup } from '../../../../shared/project-group-types' +import type { ExecutionHostId } from '../../../../shared/execution-host' +import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' +import { + createCompatibleRuntimeStatusResponseIfNeeded, + type RuntimeEnvironmentCallRequest +} from '../../runtime/runtime-compatibility-test-fixture' +import { createTestStore } from './store-test-helpers' + +export function registerReposProjectGroupCreateRaceTests(): void { + const projectGroup: ProjectGroup = { + id: 'group-1', + name: 'Platform', + parentPath: null, + parentGroupId: null, + createdFrom: 'manual', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 + } + const refreshedGroup = { ...projectGroup, name: 'Renamed after creation', updatedAt: 2 } + const otherHostGroup = { ...projectGroup, executionHostId: 'runtime:other' } + + beforeEach(() => { + clearRuntimeCompatibilityCacheForTests() + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() + }) + + function setup(runtimeEnvironmentId: string | null) { + const created = Promise.withResolvers() + const createStarted = Promise.withResolvers() + const create = vi.fn(() => { + createStarted.resolve() + return created.promise + }) + const list = vi.fn(async () => [refreshedGroup]) + vi.stubGlobal('window', { + api: { + projectGroups: { create, list }, + runtimeEnvironments: { + call: async (request: RuntimeEnvironmentCallRequest) => { + const compatibility = createCompatibleRuntimeStatusResponseIfNeeded(request) + if (compatibility) { + return compatibility + } + expect(request).toMatchObject({ selector: runtimeEnvironmentId }) + switch (request.method) { + case 'projectGroup.create': + return { id: 'create', ok: true, result: { group: await create() } } + case 'projectGroup.list': + return { id: 'list', ok: true, result: { groups: await list() } } + default: + throw new Error(`Unexpected RPC: ${request.method}`) + } + } + } + } + }) + const store = createTestStore() + store.setState({ + settings: { + ...getDefaultSettings('/test'), + activeRuntimeEnvironmentId: runtimeEnvironmentId + }, + projectGroups: [otherHostGroup] + }) + const ownerHostId: ExecutionHostId = runtimeEnvironmentId + ? `runtime:${runtimeEnvironmentId}` + : 'local' + return { store, created, createStarted, ownerHostId } + } + + describe.each([null, 'env-1'])('project group creation on host %s', (runtimeEnvironmentId) => { + it('keeps the refreshed row without notifying subscribers when refresh finishes first', async () => { + const { store, created, createStarted, ownerHostId } = setup(runtimeEnvironmentId) + const pendingCreate = store.getState().createProjectGroup('Platform', ownerHostId) + await createStarted.promise + await store.getState().fetchProjectGroups(catalogOwner(store)) + const refreshedState = store.getState() + const listener = vi.fn() + const unsubscribe = store.subscribe(listener) + try { + created.resolve(projectGroup) + await expect(pendingCreate).resolves.toEqual({ + ...projectGroup, + executionHostId: ownerHostId + }) + expect(store.getState()).toBe(refreshedState) + expect(listener).not.toHaveBeenCalled() + expect(store.getState().projectGroups).toEqual([ + otherHostGroup, + { ...refreshedGroup, executionHostId: ownerHostId } + ]) + } finally { + unsubscribe() + } + }) + + it('inserts beside another host with the same ID, then accepts the later refresh', async () => { + const { store, created, ownerHostId } = setup(runtimeEnvironmentId) + const pendingCreate = store.getState().createProjectGroup('Platform', ownerHostId) + created.resolve(projectGroup) + await pendingCreate + expect(store.getState().projectGroups).toEqual([ + otherHostGroup, + { ...projectGroup, executionHostId: ownerHostId } + ]) + await store.getState().fetchProjectGroups(catalogOwner(store)) + expect(store.getState().projectGroups).toEqual([ + otherHostGroup, + { ...refreshedGroup, executionHostId: ownerHostId } + ]) + const groups = store.getState().projectGroups + await store.getState().fetchProjectGroups(catalogOwner(store)) + expect(store.getState().projectGroups).toBe(groups) + }) + + it('keeps the original owner when the focused host changes during creation', async () => { + const { store, created, createStarted, ownerHostId } = setup(runtimeEnvironmentId) + const pendingCreate = store.getState().createProjectGroup('Platform', ownerHostId) + await createStarted.promise + store.setState({ + settings: { ...getDefaultSettings('/test'), activeRuntimeEnvironmentId: 'other' } + }) + await store.getState().fetchProjectGroups({ runtimeEnvironmentId }) + created.resolve(projectGroup) + await pendingCreate + expect(store.getState().projectGroups).toEqual([ + otherHostGroup, + { ...refreshedGroup, executionHostId: ownerHostId } + ]) + }) + + it('does not roll back a successful refresh if the create response fails', async () => { + const { store, created, createStarted, ownerHostId } = setup(runtimeEnvironmentId) + vi.spyOn(console, 'error').mockImplementation(() => {}) + const pendingCreate = store.getState().createProjectGroup('Platform', ownerHostId) + await createStarted.promise + await store.getState().fetchProjectGroups(catalogOwner(store)) + const refreshedState = store.getState() + created.reject(new Error('Create response lost')) + await expect(pendingCreate).resolves.toBeNull() + expect(store.getState()).toBe(refreshedState) + }) + }) + + it('recognizes an unstamped local group without conflating an SSH catalog row', async () => { + const { store, created, ownerHostId } = setup(null) + const sshGroup = { ...projectGroup, connectionId: 'server' } + store.setState({ projectGroups: [sshGroup, refreshedGroup] }) + const state = store.getState() + const pendingCreate = state.createProjectGroup('Platform', ownerHostId) + created.resolve(projectGroup) + await pendingCreate + expect(store.getState()).toBe(state) + }) + + it('does not suppress a local group whose ID matches a direct SSH group', async () => { + const { store, created, ownerHostId } = setup(null) + const sshGroup = { ...projectGroup, connectionId: 'server' } + store.setState({ projectGroups: [sshGroup] }) + const pendingCreate = store.getState().createProjectGroup('Platform', ownerHostId) + created.resolve(projectGroup) + await pendingCreate + expect(store.getState().projectGroups).toEqual([ + sshGroup, + { ...projectGroup, executionHostId: 'local' } + ]) + }) + + // The catalog these tests read is the one the focused host would have shown. + function catalogOwner(store: { + getState: () => { settings: { activeRuntimeEnvironmentId?: string | null } | null } + }): { runtimeEnvironmentId: string | null } { + return { runtimeEnvironmentId: store.getState().settings?.activeRuntimeEnvironmentId ?? null } + } +} diff --git a/src/renderer/src/store/slices/repos-project-group-create-race.test.ts b/src/renderer/src/store/slices/repos-project-group-create-race.test.ts deleted file mode 100644 index 44250355679..00000000000 --- a/src/renderer/src/store/slices/repos-project-group-create-race.test.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { getDefaultSettings } from '../../../../shared/constants' -import type { ProjectGroup } from '../../../../shared/project-group-types' -import type { ExecutionHostId } from '../../../../shared/execution-host' -import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' -import { - createCompatibleRuntimeStatusResponseIfNeeded, - type RuntimeEnvironmentCallRequest -} from '../../runtime/runtime-compatibility-test-fixture' -import { createTestStore } from './store-test-helpers' - -const projectGroup: ProjectGroup = { - id: 'group-1', - name: 'Platform', - parentPath: null, - parentGroupId: null, - createdFrom: 'manual', - tabOrder: 0, - isCollapsed: false, - color: null, - createdAt: 1, - updatedAt: 1 -} -const refreshedGroup = { ...projectGroup, name: 'Renamed after creation', updatedAt: 2 } -const otherHostGroup = { ...projectGroup, executionHostId: 'runtime:other' } - -beforeEach(() => { - clearRuntimeCompatibilityCacheForTests() -}) - -afterEach(() => { - vi.unstubAllGlobals() - vi.restoreAllMocks() -}) - -function setup(runtimeEnvironmentId: string | null) { - const created = Promise.withResolvers() - const createStarted = Promise.withResolvers() - const create = vi.fn(() => { - createStarted.resolve() - return created.promise - }) - const list = vi.fn(async () => [refreshedGroup]) - vi.stubGlobal('window', { - api: { - projectGroups: { create, list }, - runtimeEnvironments: { - call: async (request: RuntimeEnvironmentCallRequest) => { - const compatibility = createCompatibleRuntimeStatusResponseIfNeeded(request) - if (compatibility) { - return compatibility - } - expect(request).toMatchObject({ selector: runtimeEnvironmentId }) - switch (request.method) { - case 'projectGroup.create': - return { id: 'create', ok: true, result: { group: await create() } } - case 'projectGroup.list': - return { id: 'list', ok: true, result: { groups: await list() } } - default: - throw new Error(`Unexpected RPC: ${request.method}`) - } - } - } - } - }) - const store = createTestStore() - store.setState({ - settings: { ...getDefaultSettings('/test'), activeRuntimeEnvironmentId: runtimeEnvironmentId }, - projectGroups: [otherHostGroup] - }) - const ownerHostId: ExecutionHostId = runtimeEnvironmentId - ? `runtime:${runtimeEnvironmentId}` - : 'local' - return { store, created, createStarted, ownerHostId } -} - -describe.each([null, 'env-1'])('project group creation on host %s', (runtimeEnvironmentId) => { - it('keeps the refreshed row without notifying subscribers when refresh finishes first', async () => { - const { store, created, createStarted, ownerHostId } = setup(runtimeEnvironmentId) - const pendingCreate = store.getState().createProjectGroup('Platform', ownerHostId) - await createStarted.promise - await store.getState().fetchProjectGroups(catalogOwner(store)) - const refreshedState = store.getState() - const listener = vi.fn() - const unsubscribe = store.subscribe(listener) - try { - created.resolve(projectGroup) - await expect(pendingCreate).resolves.toEqual({ - ...projectGroup, - executionHostId: ownerHostId - }) - expect(store.getState()).toBe(refreshedState) - expect(listener).not.toHaveBeenCalled() - expect(store.getState().projectGroups).toEqual([ - otherHostGroup, - { ...refreshedGroup, executionHostId: ownerHostId } - ]) - } finally { - unsubscribe() - } - }) - - it('inserts beside another host with the same ID, then accepts the later refresh', async () => { - const { store, created, ownerHostId } = setup(runtimeEnvironmentId) - const pendingCreate = store.getState().createProjectGroup('Platform', ownerHostId) - created.resolve(projectGroup) - await pendingCreate - expect(store.getState().projectGroups).toEqual([ - otherHostGroup, - { ...projectGroup, executionHostId: ownerHostId } - ]) - await store.getState().fetchProjectGroups(catalogOwner(store)) - expect(store.getState().projectGroups).toEqual([ - otherHostGroup, - { ...refreshedGroup, executionHostId: ownerHostId } - ]) - const groups = store.getState().projectGroups - await store.getState().fetchProjectGroups(catalogOwner(store)) - expect(store.getState().projectGroups).toBe(groups) - }) - - it('keeps the original owner when the focused host changes during creation', async () => { - const { store, created, createStarted, ownerHostId } = setup(runtimeEnvironmentId) - const pendingCreate = store.getState().createProjectGroup('Platform', ownerHostId) - await createStarted.promise - store.setState({ - settings: { ...getDefaultSettings('/test'), activeRuntimeEnvironmentId: 'other' } - }) - await store.getState().fetchProjectGroups({ runtimeEnvironmentId }) - created.resolve(projectGroup) - await pendingCreate - expect(store.getState().projectGroups).toEqual([ - otherHostGroup, - { ...refreshedGroup, executionHostId: ownerHostId } - ]) - }) - - it('does not roll back a successful refresh if the create response fails', async () => { - const { store, created, createStarted, ownerHostId } = setup(runtimeEnvironmentId) - vi.spyOn(console, 'error').mockImplementation(() => {}) - const pendingCreate = store.getState().createProjectGroup('Platform', ownerHostId) - await createStarted.promise - await store.getState().fetchProjectGroups(catalogOwner(store)) - const refreshedState = store.getState() - created.reject(new Error('Create response lost')) - await expect(pendingCreate).resolves.toBeNull() - expect(store.getState()).toBe(refreshedState) - }) -}) - -it('recognizes an unstamped local group without conflating an SSH catalog row', async () => { - const { store, created, ownerHostId } = setup(null) - const sshGroup = { ...projectGroup, connectionId: 'server' } - store.setState({ projectGroups: [sshGroup, refreshedGroup] }) - const state = store.getState() - const pendingCreate = state.createProjectGroup('Platform', ownerHostId) - created.resolve(projectGroup) - await pendingCreate - expect(store.getState()).toBe(state) -}) - -it('does not suppress a local group whose ID matches a direct SSH group', async () => { - const { store, created, ownerHostId } = setup(null) - const sshGroup = { ...projectGroup, connectionId: 'server' } - store.setState({ projectGroups: [sshGroup] }) - const pendingCreate = store.getState().createProjectGroup('Platform', ownerHostId) - created.resolve(projectGroup) - await pendingCreate - expect(store.getState().projectGroups).toEqual([ - sshGroup, - { ...projectGroup, executionHostId: 'local' } - ]) -}) - -// The catalog these tests read is the one the focused host would have shown. -function catalogOwner(store: { - getState: () => { settings: { activeRuntimeEnvironmentId?: string | null } | null } -}): { runtimeEnvironmentId: string | null } { - return { runtimeEnvironmentId: store.getState().settings?.activeRuntimeEnvironmentId ?? null } -} diff --git a/src/renderer/src/store/slices/repos-project-groups-delete.spec.ts b/src/renderer/src/store/slices/repos-project-groups-delete.spec.ts new file mode 100644 index 00000000000..afcaa027b7d --- /dev/null +++ b/src/renderer/src/store/slices/repos-project-groups-delete.spec.ts @@ -0,0 +1,316 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createTestStore } from './store-test-helpers' +import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' +import type { ProjectGroup } from '../../../../shared/project-group-types' +import type { Repo } from '../../../../shared/repo-types' +import { + createCompatibleRuntimeStatusResponseIfNeeded, + type RuntimeEnvironmentCallRequest +} from '../../runtime/runtime-compatibility-test-fixture' +import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' + +export function registerReposProjectGroupsDeleteTests(): void { + const remoteRepo: Repo = { + id: 'remote-repo', + path: '/remote', + displayName: 'Remote', + badgeColor: '#111', + addedAt: 2 + } + + const projectGroup: ProjectGroup = { + id: 'group-1', + name: 'Platform', + parentPath: null, + parentGroupId: null, + createdFrom: 'manual', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 + } + + const reposRemove = vi.fn() + const reposRemoveForHost = vi.fn() + const projectGroupsDelete = vi.fn() + const runtimeEnvironmentCall = vi.fn() + const runtimeEnvironmentTransportCall = vi.fn() + + beforeEach(() => { + clearRuntimeCompatibilityCacheForTests() + reposRemove.mockReset() + reposRemoveForHost.mockReset() + reposRemoveForHost.mockResolvedValue(undefined) + reposRemove.mockResolvedValue(undefined) + projectGroupsDelete.mockReset() + runtimeEnvironmentCall.mockReset() + runtimeEnvironmentTransportCall.mockReset() + runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args) + }) + vi.stubGlobal('window', { + api: { + repos: { remove: reposRemove, removeForHost: reposRemoveForHost }, + projectGroups: { delete: projectGroupsDelete }, + runtimeEnvironments: { call: runtimeEnvironmentTransportCall } + } + }) + }) + + describe('project group deletion store routing', () => { + it('removes local project group subtrees from renderer state after delete', async () => { + const childGroup: ProjectGroup = { + ...projectGroup, + id: 'child', + parentGroupId: projectGroup.id + } + const siblingGroup: ProjectGroup = { + ...projectGroup, + id: 'sibling', + name: 'Tools', + tabOrder: 1 + } + const childWorkspace: FolderWorkspace = { + id: 'folder-workspace-1', + projectGroupId: childGroup.id, + name: 'Shared cleanup', + folderPath: '/workspace/platform/shared', + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 1, + lastActivityAt: 0, + createdAt: 1, + updatedAt: 1 + } + projectGroupsDelete.mockResolvedValue(true) + const store = createTestStore() + store.setState({ + projectGroups: [projectGroup, childGroup, siblingGroup], + folderWorkspaces: [childWorkspace], + repos: [ + { ...remoteRepo, id: 'direct', projectGroupId: projectGroup.id }, + { ...remoteRepo, id: 'nested', projectGroupId: childGroup.id }, + { ...remoteRepo, id: 'sibling', projectGroupId: siblingGroup.id } + ] + }) + + await expect(store.getState().deleteProjectGroup(projectGroup.id)).resolves.toBe(true) + + expect(store.getState().projectGroups.map((group) => group.id)).toEqual([siblingGroup.id]) + expect(store.getState().folderWorkspaces).toEqual([]) + expect(store.getState().repos).toMatchObject([ + { id: 'direct', projectGroupId: null }, + { id: 'nested', projectGroupId: null }, + { id: 'sibling', projectGroupId: siblingGroup.id } + ]) + }) + + it('uses the remote delete response shape before mutating local state', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-delete-group', + ok: true, + result: { deleted: false }, + _meta: { runtimeId: 'runtime-remote' } + }) + const serverGroup = { ...projectGroup, executionHostId: 'runtime:env-1' as const } + const groupedRepo = { ...remoteRepo, projectGroupId: projectGroup.id } + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as never, + projectGroups: [serverGroup], + repos: [groupedRepo] + }) + + await expect(store.getState().deleteProjectGroup(projectGroup.id)).resolves.toBe(false) + + expect(store.getState().projectGroups).toEqual([serverGroup]) + expect(store.getState().repos).toEqual([groupedRepo]) + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'projectGroup.delete', + params: { groupId: projectGroup.id }, + timeoutMs: 15_000 + }) + expect(projectGroupsDelete).not.toHaveBeenCalled() + }) + + it('deletes only the group when contained project removal is not requested', async () => { + projectGroupsDelete.mockResolvedValue(true) + const groupedRepo = { ...remoteRepo, id: 'direct', projectGroupId: projectGroup.id } + const store = createTestStore() + store.setState({ + projectGroups: [projectGroup], + repos: [groupedRepo] + }) + + await expect( + store.getState().deleteProjectGroupWithContainedProjects(projectGroup.id, { + removeContainedProjects: false + }) + ).resolves.toEqual({ + status: 'deleted-group', + groupId: projectGroup.id, + requestedProjectIds: [], + removedProjectIds: [], + failedProjectRemovals: [] + }) + + expect(reposRemove).not.toHaveBeenCalled() + expect(store.getState().repos).toMatchObject([{ id: 'direct', projectGroupId: null }]) + }) + + it('removes direct and nested child projects after deleting a group', async () => { + const childGroup: ProjectGroup = { + ...projectGroup, + id: 'child', + parentGroupId: projectGroup.id + } + const siblingRepo = { ...remoteRepo, id: 'sibling', projectGroupId: null } + projectGroupsDelete.mockResolvedValue(true) + const store = createTestStore() + store.setState({ + projectGroups: [projectGroup, childGroup], + repos: [ + { ...remoteRepo, id: 'direct', projectGroupId: projectGroup.id }, + { ...remoteRepo, id: 'nested', projectGroupId: childGroup.id }, + siblingRepo + ] + }) + + await expect( + store.getState().deleteProjectGroupWithContainedProjects(projectGroup.id, { + removeContainedProjects: true + }) + ).resolves.toEqual({ + status: 'deleted-group', + groupId: projectGroup.id, + requestedProjectIds: ['direct', 'nested'], + removedProjectIds: ['direct', 'nested'], + failedProjectRemovals: [] + }) + + expect(reposRemoveForHost).toHaveBeenCalledWith({ repoId: 'direct', hostId: 'local' }) + expect(reposRemoveForHost).toHaveBeenCalledWith({ repoId: 'nested', hostId: 'local' }) + expect(reposRemove).not.toHaveBeenCalled() + expect(store.getState().repos).toEqual([siblingRepo]) + }) + + it('processes local/direct-SSH same-ID contained rows sequentially', async () => { + const localRepo = { + ...remoteRepo, + id: 'shared', + path: '/local/shared', + projectGroupId: projectGroup.id, + executionHostId: 'local' as const + } + const sshRepo = { + ...localRepo, + path: '/ssh/shared', + connectionId: 'ssh-1', + executionHostId: 'ssh:ssh-1' as const + } + projectGroupsDelete.mockResolvedValue(true) + const store = createTestStore() + store.setState({ + projectGroups: [{ ...projectGroup, executionHostId: 'local' }], + repos: [localRepo, sshRepo], + settings: { activeRuntimeEnvironmentId: null } as never + }) + + await expect( + store.getState().deleteProjectGroupWithContainedProjects(projectGroup.id, { + removeContainedProjects: true + }) + ).resolves.toEqual({ + status: 'deleted-group', + groupId: projectGroup.id, + requestedProjectIds: ['shared', 'shared'], + removedProjectIds: ['shared'], + failedProjectRemovals: [] + }) + + expect(reposRemoveForHost).toHaveBeenCalledWith({ + repoId: 'shared', + hostId: 'local' + }) + expect(reposRemoveForHost).toHaveBeenCalledWith({ + repoId: 'shared', + hostId: 'ssh:ssh-1' + }) + expect(reposRemove).not.toHaveBeenCalled() + expect(store.getState().repos).toEqual([]) + }) + + it('does not remove contained projects when group deletion fails', async () => { + projectGroupsDelete.mockResolvedValue(false) + const groupedRepo = { ...remoteRepo, id: 'direct', projectGroupId: projectGroup.id } + const store = createTestStore() + store.setState({ + projectGroups: [projectGroup], + repos: [groupedRepo] + }) + + await expect( + store.getState().deleteProjectGroupWithContainedProjects(projectGroup.id, { + removeContainedProjects: true + }) + ).resolves.toEqual({ + status: 'group-delete-failed', + groupId: projectGroup.id, + requestedProjectIds: ['direct'], + removedProjectIds: [], + failedProjectRemovals: [] + }) + + expect(reposRemove).not.toHaveBeenCalled() + expect(store.getState().repos).toEqual([groupedRepo]) + }) + + it('reports project removal failures by comparing store state after removeProject', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined) + reposRemoveForHost.mockImplementation(async ({ repoId }: { repoId: string }) => { + if (repoId === 'nested') { + throw new Error('remove failed') + } + }) + const childGroup: ProjectGroup = { + ...projectGroup, + id: 'child', + parentGroupId: projectGroup.id + } + projectGroupsDelete.mockResolvedValue(true) + const store = createTestStore() + store.setState({ + projectGroups: [projectGroup, childGroup], + repos: [ + { ...remoteRepo, id: 'direct', projectGroupId: projectGroup.id }, + { ...remoteRepo, id: 'nested', projectGroupId: childGroup.id } + ] + }) + + await expect( + store.getState().deleteProjectGroupWithContainedProjects(projectGroup.id, { + removeContainedProjects: true + }) + ).resolves.toEqual({ + status: 'deleted-group', + groupId: projectGroup.id, + requestedProjectIds: ['direct', 'nested'], + removedProjectIds: ['direct'], + failedProjectRemovals: [ + { + projectId: 'nested', + reason: 'Project remained in Orca after removeProject completed.' + } + ] + }) + + expect(store.getState().repos.map((repo) => repo.id)).toEqual(['nested']) + consoleError.mockRestore() + }) + }) +} diff --git a/src/renderer/src/store/slices/repos-project-groups-delete.test.ts b/src/renderer/src/store/slices/repos-project-groups-delete.test.ts deleted file mode 100644 index e56eabee30e..00000000000 --- a/src/renderer/src/store/slices/repos-project-groups-delete.test.ts +++ /dev/null @@ -1,314 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { createTestStore } from './store-test-helpers' -import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' -import type { ProjectGroup } from '../../../../shared/project-group-types' -import type { Repo } from '../../../../shared/repo-types' -import { - createCompatibleRuntimeStatusResponseIfNeeded, - type RuntimeEnvironmentCallRequest -} from '../../runtime/runtime-compatibility-test-fixture' -import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' - -const remoteRepo: Repo = { - id: 'remote-repo', - path: '/remote', - displayName: 'Remote', - badgeColor: '#111', - addedAt: 2 -} - -const projectGroup: ProjectGroup = { - id: 'group-1', - name: 'Platform', - parentPath: null, - parentGroupId: null, - createdFrom: 'manual', - tabOrder: 0, - isCollapsed: false, - color: null, - createdAt: 1, - updatedAt: 1 -} - -const reposRemove = vi.fn() -const reposRemoveForHost = vi.fn() -const projectGroupsDelete = vi.fn() -const runtimeEnvironmentCall = vi.fn() -const runtimeEnvironmentTransportCall = vi.fn() - -beforeEach(() => { - clearRuntimeCompatibilityCacheForTests() - reposRemove.mockReset() - reposRemoveForHost.mockReset() - reposRemoveForHost.mockResolvedValue(undefined) - reposRemove.mockResolvedValue(undefined) - projectGroupsDelete.mockReset() - runtimeEnvironmentCall.mockReset() - runtimeEnvironmentTransportCall.mockReset() - runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { - return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args) - }) - vi.stubGlobal('window', { - api: { - repos: { remove: reposRemove, removeForHost: reposRemoveForHost }, - projectGroups: { delete: projectGroupsDelete }, - runtimeEnvironments: { call: runtimeEnvironmentTransportCall } - } - }) -}) - -describe('project group deletion store routing', () => { - it('removes local project group subtrees from renderer state after delete', async () => { - const childGroup: ProjectGroup = { - ...projectGroup, - id: 'child', - parentGroupId: projectGroup.id - } - const siblingGroup: ProjectGroup = { - ...projectGroup, - id: 'sibling', - name: 'Tools', - tabOrder: 1 - } - const childWorkspace: FolderWorkspace = { - id: 'folder-workspace-1', - projectGroupId: childGroup.id, - name: 'Shared cleanup', - folderPath: '/workspace/platform/shared', - linkedTask: null, - comment: '', - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: 1, - lastActivityAt: 0, - createdAt: 1, - updatedAt: 1 - } - projectGroupsDelete.mockResolvedValue(true) - const store = createTestStore() - store.setState({ - projectGroups: [projectGroup, childGroup, siblingGroup], - folderWorkspaces: [childWorkspace], - repos: [ - { ...remoteRepo, id: 'direct', projectGroupId: projectGroup.id }, - { ...remoteRepo, id: 'nested', projectGroupId: childGroup.id }, - { ...remoteRepo, id: 'sibling', projectGroupId: siblingGroup.id } - ] - }) - - await expect(store.getState().deleteProjectGroup(projectGroup.id)).resolves.toBe(true) - - expect(store.getState().projectGroups.map((group) => group.id)).toEqual([siblingGroup.id]) - expect(store.getState().folderWorkspaces).toEqual([]) - expect(store.getState().repos).toMatchObject([ - { id: 'direct', projectGroupId: null }, - { id: 'nested', projectGroupId: null }, - { id: 'sibling', projectGroupId: siblingGroup.id } - ]) - }) - - it('uses the remote delete response shape before mutating local state', async () => { - runtimeEnvironmentCall.mockResolvedValue({ - id: 'rpc-delete-group', - ok: true, - result: { deleted: false }, - _meta: { runtimeId: 'runtime-remote' } - }) - const serverGroup = { ...projectGroup, executionHostId: 'runtime:env-1' as const } - const groupedRepo = { ...remoteRepo, projectGroupId: projectGroup.id } - const store = createTestStore() - store.setState({ - settings: { activeRuntimeEnvironmentId: 'env-1' } as never, - projectGroups: [serverGroup], - repos: [groupedRepo] - }) - - await expect(store.getState().deleteProjectGroup(projectGroup.id)).resolves.toBe(false) - - expect(store.getState().projectGroups).toEqual([serverGroup]) - expect(store.getState().repos).toEqual([groupedRepo]) - expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ - selector: 'env-1', - method: 'projectGroup.delete', - params: { groupId: projectGroup.id }, - timeoutMs: 15_000 - }) - expect(projectGroupsDelete).not.toHaveBeenCalled() - }) - - it('deletes only the group when contained project removal is not requested', async () => { - projectGroupsDelete.mockResolvedValue(true) - const groupedRepo = { ...remoteRepo, id: 'direct', projectGroupId: projectGroup.id } - const store = createTestStore() - store.setState({ - projectGroups: [projectGroup], - repos: [groupedRepo] - }) - - await expect( - store.getState().deleteProjectGroupWithContainedProjects(projectGroup.id, { - removeContainedProjects: false - }) - ).resolves.toEqual({ - status: 'deleted-group', - groupId: projectGroup.id, - requestedProjectIds: [], - removedProjectIds: [], - failedProjectRemovals: [] - }) - - expect(reposRemove).not.toHaveBeenCalled() - expect(store.getState().repos).toMatchObject([{ id: 'direct', projectGroupId: null }]) - }) - - it('removes direct and nested child projects after deleting a group', async () => { - const childGroup: ProjectGroup = { - ...projectGroup, - id: 'child', - parentGroupId: projectGroup.id - } - const siblingRepo = { ...remoteRepo, id: 'sibling', projectGroupId: null } - projectGroupsDelete.mockResolvedValue(true) - const store = createTestStore() - store.setState({ - projectGroups: [projectGroup, childGroup], - repos: [ - { ...remoteRepo, id: 'direct', projectGroupId: projectGroup.id }, - { ...remoteRepo, id: 'nested', projectGroupId: childGroup.id }, - siblingRepo - ] - }) - - await expect( - store.getState().deleteProjectGroupWithContainedProjects(projectGroup.id, { - removeContainedProjects: true - }) - ).resolves.toEqual({ - status: 'deleted-group', - groupId: projectGroup.id, - requestedProjectIds: ['direct', 'nested'], - removedProjectIds: ['direct', 'nested'], - failedProjectRemovals: [] - }) - - expect(reposRemoveForHost).toHaveBeenCalledWith({ repoId: 'direct', hostId: 'local' }) - expect(reposRemoveForHost).toHaveBeenCalledWith({ repoId: 'nested', hostId: 'local' }) - expect(reposRemove).not.toHaveBeenCalled() - expect(store.getState().repos).toEqual([siblingRepo]) - }) - - it('processes local/direct-SSH same-ID contained rows sequentially', async () => { - const localRepo = { - ...remoteRepo, - id: 'shared', - path: '/local/shared', - projectGroupId: projectGroup.id, - executionHostId: 'local' as const - } - const sshRepo = { - ...localRepo, - path: '/ssh/shared', - connectionId: 'ssh-1', - executionHostId: 'ssh:ssh-1' as const - } - projectGroupsDelete.mockResolvedValue(true) - const store = createTestStore() - store.setState({ - projectGroups: [{ ...projectGroup, executionHostId: 'local' }], - repos: [localRepo, sshRepo], - settings: { activeRuntimeEnvironmentId: null } as never - }) - - await expect( - store.getState().deleteProjectGroupWithContainedProjects(projectGroup.id, { - removeContainedProjects: true - }) - ).resolves.toEqual({ - status: 'deleted-group', - groupId: projectGroup.id, - requestedProjectIds: ['shared', 'shared'], - removedProjectIds: ['shared'], - failedProjectRemovals: [] - }) - - expect(reposRemoveForHost).toHaveBeenCalledWith({ - repoId: 'shared', - hostId: 'local' - }) - expect(reposRemoveForHost).toHaveBeenCalledWith({ - repoId: 'shared', - hostId: 'ssh:ssh-1' - }) - expect(reposRemove).not.toHaveBeenCalled() - expect(store.getState().repos).toEqual([]) - }) - - it('does not remove contained projects when group deletion fails', async () => { - projectGroupsDelete.mockResolvedValue(false) - const groupedRepo = { ...remoteRepo, id: 'direct', projectGroupId: projectGroup.id } - const store = createTestStore() - store.setState({ - projectGroups: [projectGroup], - repos: [groupedRepo] - }) - - await expect( - store.getState().deleteProjectGroupWithContainedProjects(projectGroup.id, { - removeContainedProjects: true - }) - ).resolves.toEqual({ - status: 'group-delete-failed', - groupId: projectGroup.id, - requestedProjectIds: ['direct'], - removedProjectIds: [], - failedProjectRemovals: [] - }) - - expect(reposRemove).not.toHaveBeenCalled() - expect(store.getState().repos).toEqual([groupedRepo]) - }) - - it('reports project removal failures by comparing store state after removeProject', async () => { - const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined) - reposRemoveForHost.mockImplementation(async ({ repoId }: { repoId: string }) => { - if (repoId === 'nested') { - throw new Error('remove failed') - } - }) - const childGroup: ProjectGroup = { - ...projectGroup, - id: 'child', - parentGroupId: projectGroup.id - } - projectGroupsDelete.mockResolvedValue(true) - const store = createTestStore() - store.setState({ - projectGroups: [projectGroup, childGroup], - repos: [ - { ...remoteRepo, id: 'direct', projectGroupId: projectGroup.id }, - { ...remoteRepo, id: 'nested', projectGroupId: childGroup.id } - ] - }) - - await expect( - store.getState().deleteProjectGroupWithContainedProjects(projectGroup.id, { - removeContainedProjects: true - }) - ).resolves.toEqual({ - status: 'deleted-group', - groupId: projectGroup.id, - requestedProjectIds: ['direct', 'nested'], - removedProjectIds: ['direct'], - failedProjectRemovals: [ - { - projectId: 'nested', - reason: 'Project remained in Orca after removeProject completed.' - } - ] - }) - - expect(store.getState().repos.map((repo) => repo.id)).toEqual(['nested']) - consoleError.mockRestore() - }) -}) diff --git a/src/renderer/src/store/slices/repos-project-groups-owner-routing.spec.ts b/src/renderer/src/store/slices/repos-project-groups-owner-routing.spec.ts new file mode 100644 index 00000000000..fb6318a8a5b --- /dev/null +++ b/src/renderer/src/store/slices/repos-project-groups-owner-routing.spec.ts @@ -0,0 +1,325 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' +import type { ProjectGroup } from '../../../../shared/project-group-types' +import type { Repo } from '../../../../shared/repo-types' +import { + createCompatibleRuntimeStatusResponseIfNeeded, + type RuntimeEnvironmentCallRequest +} from '../../runtime/runtime-compatibility-test-fixture' +import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' +import { createTestStore } from './store-test-helpers' + +export function registerReposProjectGroupsOwnerRoutingTests(): void { + const folderScanGroup: ProjectGroup = { + id: 'group-1', + name: 'Projects', + parentPath: '/srv/projects', + parentGroupId: null, + createdFrom: 'folder-scan', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 + } + + const baseRepo: Repo = { + id: 'repo', + path: '/repo', + displayName: 'Repo', + badgeColor: '#111', + addedAt: 1 + } + + const baseFolderWorkspace: FolderWorkspace = { + id: 'folder-workspace-1', + projectGroupId: folderScanGroup.id, + name: 'Notes', + folderPath: '/srv/projects/notes', + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 1, + lastActivityAt: 0, + createdAt: 1, + updatedAt: 1 + } + + const projectGroupsUpdate = vi.fn() + const projectGroupsDelete = vi.fn() + const runtimeEnvironmentCall = vi.fn() + const runtimeEnvironmentTransportCall = vi.fn() + + beforeEach(() => { + clearRuntimeCompatibilityCacheForTests() + vi.clearAllMocks() + projectGroupsUpdate.mockResolvedValue(null) + projectGroupsDelete.mockResolvedValue(false) + runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args) + }) + vi.stubGlobal('window', { + api: { + repos: {}, + projectGroups: { update: projectGroupsUpdate, delete: projectGroupsDelete }, + runtimeEnvironments: { call: runtimeEnvironmentTransportCall } + } + }) + }) + + function runtimeRpcResponse(result: unknown) { + return { id: 'rpc-1', ok: true, result, _meta: { runtimeId: 'runtime-remote' } } + } + + describe('project group mutations route to the owning host', () => { + it('renames a runtime-owned folder-scan group while the local host is focused', async () => { + const runtimeGroup: ProjectGroup = { ...folderScanGroup, executionHostId: 'runtime:env-1' } + runtimeEnvironmentCall.mockResolvedValue( + runtimeRpcResponse({ group: { ...runtimeGroup, name: 'Renamed' } }) + ) + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: null } as never, + projectGroups: [runtimeGroup] + }) + + await expect( + store.getState().updateProjectGroup(runtimeGroup.id, { name: 'Renamed' }) + ).resolves.toBe(true) + + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'projectGroup.update', + params: { groupId: runtimeGroup.id, updates: { name: 'Renamed' } }, + timeoutMs: 15_000 + }) + expect(projectGroupsUpdate).not.toHaveBeenCalled() + expect(store.getState().projectGroups[0]).toMatchObject({ + name: 'Renamed', + executionHostId: 'runtime:env-1' + }) + }) + + it('deletes a local folder-scan group while a runtime host is focused', async () => { + projectGroupsDelete.mockResolvedValue(true) + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as never, + projectGroups: [{ ...folderScanGroup, executionHostId: 'local' }] + }) + + await expect(store.getState().deleteProjectGroup(folderScanGroup.id)).resolves.toBe(true) + + expect(projectGroupsDelete).toHaveBeenCalledWith({ groupId: folderScanGroup.id }) + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + expect(store.getState().projectGroups).toEqual([]) + }) + + it('routes a direct-SSH group through the local catalog, not the focused runtime', async () => { + projectGroupsDelete.mockResolvedValue(true) + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as never, + projectGroups: [{ ...folderScanGroup, connectionId: 'conn-1' }] + }) + + await expect(store.getState().deleteProjectGroup(folderScanGroup.id)).resolves.toBe(true) + + expect(projectGroupsDelete).toHaveBeenCalledWith({ groupId: folderScanGroup.id }) + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) + + it('prefers an explicit hostId over both the focused host and a colliding row', async () => { + runtimeEnvironmentCall.mockResolvedValue(runtimeRpcResponse({ deleted: true })) + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: null } as never, + projectGroups: [ + { ...folderScanGroup, executionHostId: 'local' }, + { ...folderScanGroup, executionHostId: 'runtime:env-1' } + ] + }) + + await expect( + store.getState().deleteProjectGroup(folderScanGroup.id, { hostId: 'runtime:env-1' }) + ).resolves.toBe(true) + + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'projectGroup.delete', + params: { groupId: folderScanGroup.id }, + timeoutMs: 15_000 + }) + expect(projectGroupsDelete).not.toHaveBeenCalled() + expect(store.getState().projectGroups).toMatchObject([{ executionHostId: 'local' }]) + }) + + it('refuses, rather than following focus, when a colliding id has no unambiguous owner', async () => { + projectGroupsDelete.mockResolvedValue(true) + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: null } as never, + projectGroups: [ + { ...folderScanGroup, executionHostId: 'local' }, + { ...folderScanGroup, executionHostId: 'runtime:env-1' } + ] + }) + + await expect(store.getState().deleteProjectGroup(folderScanGroup.id)).resolves.toBe(false) + + expect(projectGroupsDelete).not.toHaveBeenCalled() + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) + + // Why: a lost connection is unverifiable, not a refusal - the failure toast copy must stay non-asserting. + it('reports an unreachable owner as failure without touching local state', async () => { + const runtimeGroup: ProjectGroup = { ...folderScanGroup, executionHostId: 'runtime:env-1' } + runtimeEnvironmentCall.mockRejectedValue(new Error('runtime rpc timed out')) + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: null } as never, + projectGroups: [runtimeGroup], + folderWorkspaces: [baseFolderWorkspace] + }) + + await expect( + store.getState().updateProjectGroup(runtimeGroup.id, { name: 'Renamed' }) + ).resolves.toBe(false) + await expect(store.getState().deleteProjectGroup(runtimeGroup.id)).resolves.toBe(false) + + expect(projectGroupsUpdate).not.toHaveBeenCalled() + expect(store.getState().projectGroups).toEqual([runtimeGroup]) + expect(store.getState().folderWorkspaces).toEqual([baseFolderWorkspace]) + }) + }) + + describe('project group state cascades stay scoped to the owner host', () => { + it('leaves another host rows intact when deleting a colliding local group', async () => { + projectGroupsDelete.mockResolvedValue(true) + const runtimeChild: ProjectGroup = { + ...folderScanGroup, + id: 'child', + parentGroupId: folderScanGroup.id, + executionHostId: 'runtime:env-1' + } + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: null } as never, + projectGroups: [ + { ...folderScanGroup, executionHostId: 'local' }, + { ...folderScanGroup, name: 'Remote projects', executionHostId: 'runtime:env-1' }, + runtimeChild + ], + folderWorkspaces: [ + { ...baseFolderWorkspace, executionHostId: 'local' }, + { + ...baseFolderWorkspace, + id: 'folder-workspace-remote', + executionHostId: 'runtime:env-1' + } + ], + repos: [ + { ...baseRepo, id: 'local-repo', projectGroupId: folderScanGroup.id }, + { + ...baseRepo, + id: 'remote-repo', + projectGroupId: folderScanGroup.id, + executionHostId: 'runtime:env-1' + } + ] + }) + + await expect( + store.getState().deleteProjectGroup(folderScanGroup.id, { hostId: 'local' }) + ).resolves.toBe(true) + + expect(store.getState().projectGroups).toMatchObject([ + { id: folderScanGroup.id, executionHostId: 'runtime:env-1' }, + { id: 'child', executionHostId: 'runtime:env-1' } + ]) + expect(store.getState().folderWorkspaces.map((workspace) => workspace.id)).toEqual([ + 'folder-workspace-remote' + ]) + expect(store.getState().repos).toMatchObject([ + { id: 'local-repo', projectGroupId: null }, + { id: 'remote-repo', projectGroupId: folderScanGroup.id } + ]) + }) + + it('renames only the owner host row when the id exists on two hosts', async () => { + projectGroupsUpdate.mockResolvedValue({ + ...folderScanGroup, + executionHostId: 'local', + name: 'Renamed' + }) + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: null } as never, + projectGroups: [ + { ...folderScanGroup, executionHostId: 'local' }, + { ...folderScanGroup, name: 'Remote projects', executionHostId: 'runtime:env-1' } + ] + }) + + await expect( + store.getState().updateProjectGroup( + folderScanGroup.id, + { name: 'Renamed' }, + { + hostId: 'local' + } + ) + ).resolves.toBe(true) + + expect(store.getState().projectGroups).toMatchObject([ + { name: 'Renamed', executionHostId: 'local' }, + { name: 'Remote projects', executionHostId: 'runtime:env-1' } + ]) + }) + + it('removes contained projects only from the owner host', async () => { + projectGroupsDelete.mockResolvedValue(true) + const reposRemoveForHost = vi.fn().mockResolvedValue(undefined) + vi.stubGlobal('window', { + api: { + repos: { removeForHost: reposRemoveForHost }, + projectGroups: { update: projectGroupsUpdate, delete: projectGroupsDelete }, + runtimeEnvironments: { call: runtimeEnvironmentTransportCall } + } + }) + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: null } as never, + projectGroups: [ + { ...folderScanGroup, executionHostId: 'local' }, + { ...folderScanGroup, executionHostId: 'runtime:env-1' } + ], + repos: [ + { ...baseRepo, id: 'local-repo', projectGroupId: folderScanGroup.id }, + { + ...baseRepo, + id: 'remote-repo', + projectGroupId: folderScanGroup.id, + executionHostId: 'runtime:env-1' + } + ] + }) + + const result = await store + .getState() + .deleteProjectGroupWithContainedProjects(folderScanGroup.id, { + removeContainedProjects: true, + hostId: 'local' + }) + + expect(result).toMatchObject({ + status: 'deleted-group', + requestedProjectIds: ['local-repo'], + removedProjectIds: ['local-repo'] + }) + expect(store.getState().repos.map((repo) => repo.id)).toEqual(['remote-repo']) + }) + }) +} diff --git a/src/renderer/src/store/slices/repos-project-groups-owner-routing.test.ts b/src/renderer/src/store/slices/repos-project-groups-owner-routing.test.ts deleted file mode 100644 index 94711fdc113..00000000000 --- a/src/renderer/src/store/slices/repos-project-groups-owner-routing.test.ts +++ /dev/null @@ -1,323 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' -import type { ProjectGroup } from '../../../../shared/project-group-types' -import type { Repo } from '../../../../shared/repo-types' -import { - createCompatibleRuntimeStatusResponseIfNeeded, - type RuntimeEnvironmentCallRequest -} from '../../runtime/runtime-compatibility-test-fixture' -import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' -import { createTestStore } from './store-test-helpers' - -const folderScanGroup: ProjectGroup = { - id: 'group-1', - name: 'Projects', - parentPath: '/srv/projects', - parentGroupId: null, - createdFrom: 'folder-scan', - tabOrder: 0, - isCollapsed: false, - color: null, - createdAt: 1, - updatedAt: 1 -} - -const baseRepo: Repo = { - id: 'repo', - path: '/repo', - displayName: 'Repo', - badgeColor: '#111', - addedAt: 1 -} - -const baseFolderWorkspace: FolderWorkspace = { - id: 'folder-workspace-1', - projectGroupId: folderScanGroup.id, - name: 'Notes', - folderPath: '/srv/projects/notes', - linkedTask: null, - comment: '', - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: 1, - lastActivityAt: 0, - createdAt: 1, - updatedAt: 1 -} - -const projectGroupsUpdate = vi.fn() -const projectGroupsDelete = vi.fn() -const runtimeEnvironmentCall = vi.fn() -const runtimeEnvironmentTransportCall = vi.fn() - -beforeEach(() => { - clearRuntimeCompatibilityCacheForTests() - vi.clearAllMocks() - projectGroupsUpdate.mockResolvedValue(null) - projectGroupsDelete.mockResolvedValue(false) - runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { - return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args) - }) - vi.stubGlobal('window', { - api: { - repos: {}, - projectGroups: { update: projectGroupsUpdate, delete: projectGroupsDelete }, - runtimeEnvironments: { call: runtimeEnvironmentTransportCall } - } - }) -}) - -function runtimeRpcResponse(result: unknown) { - return { id: 'rpc-1', ok: true, result, _meta: { runtimeId: 'runtime-remote' } } -} - -describe('project group mutations route to the owning host', () => { - it('renames a runtime-owned folder-scan group while the local host is focused', async () => { - const runtimeGroup: ProjectGroup = { ...folderScanGroup, executionHostId: 'runtime:env-1' } - runtimeEnvironmentCall.mockResolvedValue( - runtimeRpcResponse({ group: { ...runtimeGroup, name: 'Renamed' } }) - ) - const store = createTestStore() - store.setState({ - settings: { activeRuntimeEnvironmentId: null } as never, - projectGroups: [runtimeGroup] - }) - - await expect( - store.getState().updateProjectGroup(runtimeGroup.id, { name: 'Renamed' }) - ).resolves.toBe(true) - - expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ - selector: 'env-1', - method: 'projectGroup.update', - params: { groupId: runtimeGroup.id, updates: { name: 'Renamed' } }, - timeoutMs: 15_000 - }) - expect(projectGroupsUpdate).not.toHaveBeenCalled() - expect(store.getState().projectGroups[0]).toMatchObject({ - name: 'Renamed', - executionHostId: 'runtime:env-1' - }) - }) - - it('deletes a local folder-scan group while a runtime host is focused', async () => { - projectGroupsDelete.mockResolvedValue(true) - const store = createTestStore() - store.setState({ - settings: { activeRuntimeEnvironmentId: 'env-1' } as never, - projectGroups: [{ ...folderScanGroup, executionHostId: 'local' }] - }) - - await expect(store.getState().deleteProjectGroup(folderScanGroup.id)).resolves.toBe(true) - - expect(projectGroupsDelete).toHaveBeenCalledWith({ groupId: folderScanGroup.id }) - expect(runtimeEnvironmentCall).not.toHaveBeenCalled() - expect(store.getState().projectGroups).toEqual([]) - }) - - it('routes a direct-SSH group through the local catalog, not the focused runtime', async () => { - projectGroupsDelete.mockResolvedValue(true) - const store = createTestStore() - store.setState({ - settings: { activeRuntimeEnvironmentId: 'env-1' } as never, - projectGroups: [{ ...folderScanGroup, connectionId: 'conn-1' }] - }) - - await expect(store.getState().deleteProjectGroup(folderScanGroup.id)).resolves.toBe(true) - - expect(projectGroupsDelete).toHaveBeenCalledWith({ groupId: folderScanGroup.id }) - expect(runtimeEnvironmentCall).not.toHaveBeenCalled() - }) - - it('prefers an explicit hostId over both the focused host and a colliding row', async () => { - runtimeEnvironmentCall.mockResolvedValue(runtimeRpcResponse({ deleted: true })) - const store = createTestStore() - store.setState({ - settings: { activeRuntimeEnvironmentId: null } as never, - projectGroups: [ - { ...folderScanGroup, executionHostId: 'local' }, - { ...folderScanGroup, executionHostId: 'runtime:env-1' } - ] - }) - - await expect( - store.getState().deleteProjectGroup(folderScanGroup.id, { hostId: 'runtime:env-1' }) - ).resolves.toBe(true) - - expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ - selector: 'env-1', - method: 'projectGroup.delete', - params: { groupId: folderScanGroup.id }, - timeoutMs: 15_000 - }) - expect(projectGroupsDelete).not.toHaveBeenCalled() - expect(store.getState().projectGroups).toMatchObject([{ executionHostId: 'local' }]) - }) - - it('refuses, rather than following focus, when a colliding id has no unambiguous owner', async () => { - projectGroupsDelete.mockResolvedValue(true) - const store = createTestStore() - store.setState({ - settings: { activeRuntimeEnvironmentId: null } as never, - projectGroups: [ - { ...folderScanGroup, executionHostId: 'local' }, - { ...folderScanGroup, executionHostId: 'runtime:env-1' } - ] - }) - - await expect(store.getState().deleteProjectGroup(folderScanGroup.id)).resolves.toBe(false) - - expect(projectGroupsDelete).not.toHaveBeenCalled() - expect(runtimeEnvironmentCall).not.toHaveBeenCalled() - }) - - // Why: a lost connection is unverifiable, not a refusal - the failure toast copy must stay non-asserting. - it('reports an unreachable owner as failure without touching local state', async () => { - const runtimeGroup: ProjectGroup = { ...folderScanGroup, executionHostId: 'runtime:env-1' } - runtimeEnvironmentCall.mockRejectedValue(new Error('runtime rpc timed out')) - const store = createTestStore() - store.setState({ - settings: { activeRuntimeEnvironmentId: null } as never, - projectGroups: [runtimeGroup], - folderWorkspaces: [baseFolderWorkspace] - }) - - await expect( - store.getState().updateProjectGroup(runtimeGroup.id, { name: 'Renamed' }) - ).resolves.toBe(false) - await expect(store.getState().deleteProjectGroup(runtimeGroup.id)).resolves.toBe(false) - - expect(projectGroupsUpdate).not.toHaveBeenCalled() - expect(store.getState().projectGroups).toEqual([runtimeGroup]) - expect(store.getState().folderWorkspaces).toEqual([baseFolderWorkspace]) - }) -}) - -describe('project group state cascades stay scoped to the owner host', () => { - it('leaves another host rows intact when deleting a colliding local group', async () => { - projectGroupsDelete.mockResolvedValue(true) - const runtimeChild: ProjectGroup = { - ...folderScanGroup, - id: 'child', - parentGroupId: folderScanGroup.id, - executionHostId: 'runtime:env-1' - } - const store = createTestStore() - store.setState({ - settings: { activeRuntimeEnvironmentId: null } as never, - projectGroups: [ - { ...folderScanGroup, executionHostId: 'local' }, - { ...folderScanGroup, name: 'Remote projects', executionHostId: 'runtime:env-1' }, - runtimeChild - ], - folderWorkspaces: [ - { ...baseFolderWorkspace, executionHostId: 'local' }, - { - ...baseFolderWorkspace, - id: 'folder-workspace-remote', - executionHostId: 'runtime:env-1' - } - ], - repos: [ - { ...baseRepo, id: 'local-repo', projectGroupId: folderScanGroup.id }, - { - ...baseRepo, - id: 'remote-repo', - projectGroupId: folderScanGroup.id, - executionHostId: 'runtime:env-1' - } - ] - }) - - await expect( - store.getState().deleteProjectGroup(folderScanGroup.id, { hostId: 'local' }) - ).resolves.toBe(true) - - expect(store.getState().projectGroups).toMatchObject([ - { id: folderScanGroup.id, executionHostId: 'runtime:env-1' }, - { id: 'child', executionHostId: 'runtime:env-1' } - ]) - expect(store.getState().folderWorkspaces.map((workspace) => workspace.id)).toEqual([ - 'folder-workspace-remote' - ]) - expect(store.getState().repos).toMatchObject([ - { id: 'local-repo', projectGroupId: null }, - { id: 'remote-repo', projectGroupId: folderScanGroup.id } - ]) - }) - - it('renames only the owner host row when the id exists on two hosts', async () => { - projectGroupsUpdate.mockResolvedValue({ - ...folderScanGroup, - executionHostId: 'local', - name: 'Renamed' - }) - const store = createTestStore() - store.setState({ - settings: { activeRuntimeEnvironmentId: null } as never, - projectGroups: [ - { ...folderScanGroup, executionHostId: 'local' }, - { ...folderScanGroup, name: 'Remote projects', executionHostId: 'runtime:env-1' } - ] - }) - - await expect( - store.getState().updateProjectGroup( - folderScanGroup.id, - { name: 'Renamed' }, - { - hostId: 'local' - } - ) - ).resolves.toBe(true) - - expect(store.getState().projectGroups).toMatchObject([ - { name: 'Renamed', executionHostId: 'local' }, - { name: 'Remote projects', executionHostId: 'runtime:env-1' } - ]) - }) - - it('removes contained projects only from the owner host', async () => { - projectGroupsDelete.mockResolvedValue(true) - const reposRemoveForHost = vi.fn().mockResolvedValue(undefined) - vi.stubGlobal('window', { - api: { - repos: { removeForHost: reposRemoveForHost }, - projectGroups: { update: projectGroupsUpdate, delete: projectGroupsDelete }, - runtimeEnvironments: { call: runtimeEnvironmentTransportCall } - } - }) - const store = createTestStore() - store.setState({ - settings: { activeRuntimeEnvironmentId: null } as never, - projectGroups: [ - { ...folderScanGroup, executionHostId: 'local' }, - { ...folderScanGroup, executionHostId: 'runtime:env-1' } - ], - repos: [ - { ...baseRepo, id: 'local-repo', projectGroupId: folderScanGroup.id }, - { - ...baseRepo, - id: 'remote-repo', - projectGroupId: folderScanGroup.id, - executionHostId: 'runtime:env-1' - } - ] - }) - - const result = await store - .getState() - .deleteProjectGroupWithContainedProjects(folderScanGroup.id, { - removeContainedProjects: true, - hostId: 'local' - }) - - expect(result).toMatchObject({ - status: 'deleted-group', - requestedProjectIds: ['local-repo'], - removedProjectIds: ['local-repo'] - }) - expect(store.getState().repos.map((repo) => repo.id)).toEqual(['remote-repo']) - }) -}) diff --git a/src/renderer/src/store/slices/repos-project-host-capability.spec.ts b/src/renderer/src/store/slices/repos-project-host-capability.spec.ts new file mode 100644 index 00000000000..6ca81212ce2 --- /dev/null +++ b/src/renderer/src/store/slices/repos-project-host-capability.spec.ts @@ -0,0 +1,153 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Repo } from '../../../../shared/repo-types' +import { PROJECT_HOST_SETUP_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' +import type { RuntimeEnvironmentCallRequest } from '../../runtime/runtime-compatibility-test-fixture' +import { createTestStore } from './store-test-helpers' + +export function registerReposProjectHostCapabilityTests(): void { + const remoteRepo: Repo = { + id: 'remote-repo', + path: '/remote', + displayName: 'Remote', + badgeColor: '#111', + addedAt: 2 + } + + const reposList = vi.fn() + const reposClone = vi.fn() + const runtimeEnvironmentCall = vi.fn() + const runtimeEnvironmentTransportCall = vi.fn() + let runtimeCapabilities: string[] = [] + + function runtimeStatusWithoutProjectHostSetup() { + return { + id: 'status', + ok: true, + result: { + runtimeId: 'runtime-remote', + rendererGraphEpoch: 0, + graphStatus: 'ready', + authoritativeWindowId: null, + liveTabCount: 0, + liveLeafCount: 0, + runtimeProtocolVersion: 3, + minCompatibleRuntimeClientVersion: 2, + capabilities: runtimeCapabilities + }, + _meta: { runtimeId: 'runtime-remote' } + } + } + + beforeEach(() => { + clearRuntimeCompatibilityCacheForTests() + reposList.mockReset() + reposClone.mockReset() + runtimeEnvironmentCall.mockReset() + runtimeEnvironmentTransportCall.mockReset() + runtimeCapabilities = [] + runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + if (args.method === 'status.get') { + return runtimeStatusWithoutProjectHostSetup() + } + return runtimeEnvironmentCall(args) + }) + vi.stubGlobal('window', { + api: { + repos: { + list: reposList, + clone: reposClone + }, + runtimeEnvironments: { call: runtimeEnvironmentTransportCall } + } + }) + }) + + describe('repo slice project-host setup runtime capability', () => { + it('falls back to repo-derived project setup state when a remote runtime lacks support', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-1', + ok: true, + result: { repos: [remoteRepo] }, + _meta: { runtimeId: 'runtime-remote' } + }) + const store = createTestStore() + store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) + + await store.getState().fetchRepos(catalogOwner(store)) + + expect(store.getState().projectHostSetups).toEqual([ + expect.objectContaining({ id: 'remote-repo', hostId: 'runtime:env-1' }) + ]) + expect(runtimeEnvironmentCall).toHaveBeenCalledTimes(1) + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'repo.list', + params: undefined, + timeoutMs: 15_000 + }) + }) + + it('blocks runtime project setup when the server does not advertise support', async () => { + const store = createTestStore() + + await expect( + store.getState().setupProjectExistingFolder({ + projectId: 'project-1', + hostId: 'runtime:env-1', + path: '/srv/project', + kind: 'git' + }) + ).resolves.toBeNull() + + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) + + it('blocks runtime project clone before mutating unsupported servers', async () => { + const store = createTestStore() + + await expect( + store.getState().setupProjectClone({ + projectId: 'project-1', + hostId: 'runtime:env-1', + url: 'https://github.com/stablyai/orca.git', + destination: '/srv' + }) + ).resolves.toBeNull() + + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) + + it('blocks runtime project setup mutations when workspace run-context support is missing', async () => { + runtimeCapabilities = [PROJECT_HOST_SETUP_RUNTIME_CAPABILITY] + const store = createTestStore() + + await expect( + store.getState().setupProjectExistingFolder({ + projectId: 'project-1', + hostId: 'runtime:env-1', + path: '/srv/project', + kind: 'git' + }) + ).resolves.toBeNull() + + await expect( + store.getState().setupProjectClone({ + projectId: 'project-1', + hostId: 'runtime:env-1', + url: 'https://github.com/stablyai/orca.git', + destination: '/srv' + }) + ).resolves.toBeNull() + + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) + }) + + // The catalog these tests read is the one the focused host would have shown. + function catalogOwner(store: { + getState: () => { settings: { activeRuntimeEnvironmentId?: string | null } | null } + }): { runtimeEnvironmentId: string | null } { + return { runtimeEnvironmentId: store.getState().settings?.activeRuntimeEnvironmentId ?? null } + } +} diff --git a/src/renderer/src/store/slices/repos-project-host-capability.test.ts b/src/renderer/src/store/slices/repos-project-host-capability.test.ts deleted file mode 100644 index f65ba3b50fd..00000000000 --- a/src/renderer/src/store/slices/repos-project-host-capability.test.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { Repo } from '../../../../shared/repo-types' -import { PROJECT_HOST_SETUP_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' -import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' -import type { RuntimeEnvironmentCallRequest } from '../../runtime/runtime-compatibility-test-fixture' -import { createTestStore } from './store-test-helpers' - -const remoteRepo: Repo = { - id: 'remote-repo', - path: '/remote', - displayName: 'Remote', - badgeColor: '#111', - addedAt: 2 -} - -const reposList = vi.fn() -const reposClone = vi.fn() -const runtimeEnvironmentCall = vi.fn() -const runtimeEnvironmentTransportCall = vi.fn() -let runtimeCapabilities: string[] = [] - -function runtimeStatusWithoutProjectHostSetup() { - return { - id: 'status', - ok: true, - result: { - runtimeId: 'runtime-remote', - rendererGraphEpoch: 0, - graphStatus: 'ready', - authoritativeWindowId: null, - liveTabCount: 0, - liveLeafCount: 0, - runtimeProtocolVersion: 3, - minCompatibleRuntimeClientVersion: 2, - capabilities: runtimeCapabilities - }, - _meta: { runtimeId: 'runtime-remote' } - } -} - -beforeEach(() => { - clearRuntimeCompatibilityCacheForTests() - reposList.mockReset() - reposClone.mockReset() - runtimeEnvironmentCall.mockReset() - runtimeEnvironmentTransportCall.mockReset() - runtimeCapabilities = [] - runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { - if (args.method === 'status.get') { - return runtimeStatusWithoutProjectHostSetup() - } - return runtimeEnvironmentCall(args) - }) - vi.stubGlobal('window', { - api: { - repos: { - list: reposList, - clone: reposClone - }, - runtimeEnvironments: { call: runtimeEnvironmentTransportCall } - } - }) -}) - -describe('repo slice project-host setup runtime capability', () => { - it('falls back to repo-derived project setup state when a remote runtime lacks support', async () => { - runtimeEnvironmentCall.mockResolvedValue({ - id: 'rpc-1', - ok: true, - result: { repos: [remoteRepo] }, - _meta: { runtimeId: 'runtime-remote' } - }) - const store = createTestStore() - store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) - - await store.getState().fetchRepos(catalogOwner(store)) - - expect(store.getState().projectHostSetups).toEqual([ - expect.objectContaining({ id: 'remote-repo', hostId: 'runtime:env-1' }) - ]) - expect(runtimeEnvironmentCall).toHaveBeenCalledTimes(1) - expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ - selector: 'env-1', - method: 'repo.list', - params: undefined, - timeoutMs: 15_000 - }) - }) - - it('blocks runtime project setup when the server does not advertise support', async () => { - const store = createTestStore() - - await expect( - store.getState().setupProjectExistingFolder({ - projectId: 'project-1', - hostId: 'runtime:env-1', - path: '/srv/project', - kind: 'git' - }) - ).resolves.toBeNull() - - expect(runtimeEnvironmentCall).not.toHaveBeenCalled() - }) - - it('blocks runtime project clone before mutating unsupported servers', async () => { - const store = createTestStore() - - await expect( - store.getState().setupProjectClone({ - projectId: 'project-1', - hostId: 'runtime:env-1', - url: 'https://github.com/stablyai/orca.git', - destination: '/srv' - }) - ).resolves.toBeNull() - - expect(runtimeEnvironmentCall).not.toHaveBeenCalled() - }) - - it('blocks runtime project setup mutations when workspace run-context support is missing', async () => { - runtimeCapabilities = [PROJECT_HOST_SETUP_RUNTIME_CAPABILITY] - const store = createTestStore() - - await expect( - store.getState().setupProjectExistingFolder({ - projectId: 'project-1', - hostId: 'runtime:env-1', - path: '/srv/project', - kind: 'git' - }) - ).resolves.toBeNull() - - await expect( - store.getState().setupProjectClone({ - projectId: 'project-1', - hostId: 'runtime:env-1', - url: 'https://github.com/stablyai/orca.git', - destination: '/srv' - }) - ).resolves.toBeNull() - - expect(runtimeEnvironmentCall).not.toHaveBeenCalled() - }) -}) - -// The catalog these tests read is the one the focused host would have shown. -function catalogOwner(store: { - getState: () => { settings: { activeRuntimeEnvironmentId?: string | null } | null } -}): { runtimeEnvironmentId: string | null } { - return { runtimeEnvironmentId: store.getState().settings?.activeRuntimeEnvironmentId ?? null } -} diff --git a/src/renderer/src/store/slices/repos-project-host-lifecycle.spec.ts b/src/renderer/src/store/slices/repos-project-host-lifecycle.spec.ts new file mode 100644 index 00000000000..e2c7aee50b1 --- /dev/null +++ b/src/renderer/src/store/slices/repos-project-host-lifecycle.spec.ts @@ -0,0 +1,295 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Project, ProjectHostSetup } from '../../../../shared/project-types' +import type { Repo } from '../../../../shared/repo-types' +import { + createCompatibleRuntimeStatusResponseIfNeeded, + type RuntimeEnvironmentCallRequest +} from '../../runtime/runtime-compatibility-test-fixture' +import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' +import { createTestStore } from './store-test-helpers' + +export function registerReposProjectHostLifecycleTests(): void { + const projectsCreateHostSetup = vi.fn() + const projectsUpdateHostSetup = vi.fn() + const projectsDeleteHostSetup = vi.fn() + const runtimeEnvironmentCall = vi.fn() + const runtimeEnvironmentTransportCall = vi.fn() + + const project: Project = { + id: 'project-1', + displayName: 'Project', + badgeColor: '#000', + sourceRepoIds: ['local-repo'], + createdAt: 1, + updatedAt: 1 + } + + const runtimeRepo: Repo = { + id: 'runtime-repo', + path: '/srv/project', + displayName: 'Project', + badgeColor: '#111', + addedAt: 1, + executionHostId: 'runtime:env-1' + } + + const runtimeSetup: ProjectHostSetup = { + id: 'setup-gpu', + projectId: project.id, + hostId: 'runtime:env-1', + repoId: '', + path: '/srv/project', + displayName: 'GPU VM', + setupState: 'ready', + setupMethod: 'provisioned', + createdAt: 1, + updatedAt: 1 + } + + beforeEach(() => { + clearRuntimeCompatibilityCacheForTests() + projectsCreateHostSetup.mockReset() + projectsUpdateHostSetup.mockReset() + projectsDeleteHostSetup.mockReset() + runtimeEnvironmentCall.mockReset() + runtimeEnvironmentTransportCall.mockReset() + runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args) + }) + vi.stubGlobal('window', { + api: { + repos: { + list: vi.fn() + }, + projects: { + createHostSetup: projectsCreateHostSetup, + updateHostSetup: projectsUpdateHostSetup, + deleteHostSetup: projectsDeleteHostSetup + }, + runtimeEnvironments: { call: runtimeEnvironmentTransportCall } + } + }) + }) + + describe('repo slice project host setup lifecycle', () => { + it('creates independent project host setup metadata through local IPC', async () => { + const setup: ProjectHostSetup = { + ...runtimeSetup, + hostId: 'local', + path: '', + setupState: 'setting-up' + } + projectsCreateHostSetup.mockResolvedValue({ project, setup }) + const store = createTestStore() + + await expect( + store.getState().createProjectHostSetup({ + projectId: project.id, + hostId: 'local', + setupId: setup.id, + setupState: 'setting-up', + setupMethod: 'provisioned' + }) + ).resolves.toEqual({ project, setup }) + + expect(store.getState().projects).toEqual([project]) + expect(store.getState().projectHostSetups).toEqual([setup]) + expect(projectsCreateHostSetup).toHaveBeenCalledWith({ + projectId: project.id, + hostId: 'local', + setupId: setup.id, + setupState: 'setting-up', + setupMethod: 'provisioned' + }) + }) + + it('updates runtime-owned project host setups through their owning runtime', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-update-setup', + ok: true, + result: { + result: { + project, + setup: { ...runtimeSetup, displayName: 'GPU VM renamed' } + } + }, + _meta: { runtimeId: 'runtime-remote' } + }) + const store = createTestStore() + store.setState({ + projectHostSetups: [runtimeSetup], + settings: { activeRuntimeEnvironmentId: null } as never + }) + + await expect( + store.getState().updateProjectHostSetup({ + setupId: runtimeSetup.id, + updates: { displayName: 'GPU VM renamed' } + }) + ).resolves.toEqual({ + project, + setup: { + ...runtimeSetup, + displayName: 'GPU VM renamed', + executionHostId: 'runtime:env-1', + runtimeOwnerEnvironmentId: 'env-1', + connectionId: null + }, + repo: undefined + }) + + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'projectHostSetup.update', + params: { + setupId: runtimeSetup.id, + updates: { displayName: 'GPU VM renamed' } + }, + timeoutMs: 15_000 + }) + }) + + it('deletes runtime-owned project host setups through their owning runtime', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-delete-setup', + ok: true, + result: { result: { project, setup: runtimeSetup } }, + _meta: { runtimeId: 'runtime-remote' } + }) + const store = createTestStore() + store.setState({ + projects: [project], + projectHostSetups: [runtimeSetup], + settings: { activeRuntimeEnvironmentId: null } as never + }) + + await expect( + store.getState().deleteProjectHostSetup({ setupId: runtimeSetup.id }) + ).resolves.toEqual({ + project, + setup: runtimeSetup, + repo: undefined + }) + + expect(store.getState().projects).toEqual([project]) + expect(store.getState().projectHostSetups).toEqual([]) + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'projectHostSetup.delete', + params: { setupId: runtimeSetup.id }, + timeoutMs: 15_000 + }) + }) + + it('routes duplicate setup IDs through the first row and replaces every collision', async () => { + const localSetup: ProjectHostSetup = { + ...runtimeSetup, + hostId: 'local', + displayName: 'Local setup' + } + const updatedLocalSetup = { ...localSetup, displayName: 'Local renamed', updatedAt: 2 } + projectsUpdateHostSetup.mockResolvedValue({ + project, + setup: updatedLocalSetup + }) + const store = createTestStore() + store.setState({ + projectHostSetups: [localSetup, runtimeSetup], + settings: { activeRuntimeEnvironmentId: null } as never + }) + + await store.getState().updateProjectHostSetup({ + setupId: localSetup.id, + updates: { displayName: 'Local renamed' } + }) + + expect(projectsUpdateHostSetup).toHaveBeenCalledWith({ + setupId: localSetup.id, + updates: { displayName: 'Local renamed' } + }) + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + // Current contract: setup mutations are keyed by bare setup ID after the first row selects routing. + expect(store.getState().projectHostSetups).toEqual([updatedLocalSetup, updatedLocalSetup]) + }) + + it('routes duplicate setup-ID deletion through the first row and removes every collision', async () => { + const localSetup: ProjectHostSetup = { + ...runtimeSetup, + hostId: 'local', + displayName: 'Local setup' + } + projectsDeleteHostSetup.mockResolvedValue({ project, setup: localSetup }) + const store = createTestStore() + store.setState({ + projects: [project], + projectHostSetups: [localSetup, runtimeSetup], + settings: { activeRuntimeEnvironmentId: null } as never + }) + + await store.getState().deleteProjectHostSetup({ setupId: localSetup.id }) + + expect(projectsDeleteHostSetup).toHaveBeenCalledWith({ setupId: localSetup.id }) + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + // Current contract: delete filters the full catalog by bare setup ID. + expect(store.getState().projectHostSetups).toEqual([]) + }) + + it('preserves runtime-fetched setup-only states during repo hydration', async () => { + const pendingSetup: ProjectHostSetup = { + ...runtimeSetup, + id: 'setup-pending', + repoId: '', + path: '', + setupState: 'setting-up' + } + runtimeEnvironmentCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + if (args.method === 'repo.list') { + return { + id: 'rpc-repos', + ok: true, + result: { repos: [runtimeRepo] }, + _meta: { runtimeId: 'runtime-remote' } + } + } + if (args.method === 'project.list') { + return { + id: 'rpc-projects', + ok: true, + result: { projects: [project] }, + _meta: { runtimeId: 'runtime-remote' } + } + } + if (args.method === 'projectHostSetup.list') { + return { + id: 'rpc-setups', + ok: true, + result: { setups: [pendingSetup] }, + _meta: { runtimeId: 'runtime-remote' } + } + } + throw new Error(`Unexpected runtime method: ${args.method}`) + }) + const store = createTestStore() + store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) + + await store.getState().fetchRepos(catalogOwner(store)) + + expect(store.getState().projectHostSetups).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 'setup-pending', + hostId: 'runtime:env-1', + setupState: 'setting-up' + }) + ]) + ) + }) + }) + + // The catalog these tests read is the one the focused host would have shown. + function catalogOwner(store: { + getState: () => { settings: { activeRuntimeEnvironmentId?: string | null } | null } + }): { runtimeEnvironmentId: string | null } { + return { runtimeEnvironmentId: store.getState().settings?.activeRuntimeEnvironmentId ?? null } + } +} diff --git a/src/renderer/src/store/slices/repos-project-host-lifecycle.test.ts b/src/renderer/src/store/slices/repos-project-host-lifecycle.test.ts deleted file mode 100644 index b88c67d5fef..00000000000 --- a/src/renderer/src/store/slices/repos-project-host-lifecycle.test.ts +++ /dev/null @@ -1,293 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { Project, ProjectHostSetup } from '../../../../shared/project-types' -import type { Repo } from '../../../../shared/repo-types' -import { - createCompatibleRuntimeStatusResponseIfNeeded, - type RuntimeEnvironmentCallRequest -} from '../../runtime/runtime-compatibility-test-fixture' -import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' -import { createTestStore } from './store-test-helpers' - -const projectsCreateHostSetup = vi.fn() -const projectsUpdateHostSetup = vi.fn() -const projectsDeleteHostSetup = vi.fn() -const runtimeEnvironmentCall = vi.fn() -const runtimeEnvironmentTransportCall = vi.fn() - -const project: Project = { - id: 'project-1', - displayName: 'Project', - badgeColor: '#000', - sourceRepoIds: ['local-repo'], - createdAt: 1, - updatedAt: 1 -} - -const runtimeRepo: Repo = { - id: 'runtime-repo', - path: '/srv/project', - displayName: 'Project', - badgeColor: '#111', - addedAt: 1, - executionHostId: 'runtime:env-1' -} - -const runtimeSetup: ProjectHostSetup = { - id: 'setup-gpu', - projectId: project.id, - hostId: 'runtime:env-1', - repoId: '', - path: '/srv/project', - displayName: 'GPU VM', - setupState: 'ready', - setupMethod: 'provisioned', - createdAt: 1, - updatedAt: 1 -} - -beforeEach(() => { - clearRuntimeCompatibilityCacheForTests() - projectsCreateHostSetup.mockReset() - projectsUpdateHostSetup.mockReset() - projectsDeleteHostSetup.mockReset() - runtimeEnvironmentCall.mockReset() - runtimeEnvironmentTransportCall.mockReset() - runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { - return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args) - }) - vi.stubGlobal('window', { - api: { - repos: { - list: vi.fn() - }, - projects: { - createHostSetup: projectsCreateHostSetup, - updateHostSetup: projectsUpdateHostSetup, - deleteHostSetup: projectsDeleteHostSetup - }, - runtimeEnvironments: { call: runtimeEnvironmentTransportCall } - } - }) -}) - -describe('repo slice project host setup lifecycle', () => { - it('creates independent project host setup metadata through local IPC', async () => { - const setup: ProjectHostSetup = { - ...runtimeSetup, - hostId: 'local', - path: '', - setupState: 'setting-up' - } - projectsCreateHostSetup.mockResolvedValue({ project, setup }) - const store = createTestStore() - - await expect( - store.getState().createProjectHostSetup({ - projectId: project.id, - hostId: 'local', - setupId: setup.id, - setupState: 'setting-up', - setupMethod: 'provisioned' - }) - ).resolves.toEqual({ project, setup }) - - expect(store.getState().projects).toEqual([project]) - expect(store.getState().projectHostSetups).toEqual([setup]) - expect(projectsCreateHostSetup).toHaveBeenCalledWith({ - projectId: project.id, - hostId: 'local', - setupId: setup.id, - setupState: 'setting-up', - setupMethod: 'provisioned' - }) - }) - - it('updates runtime-owned project host setups through their owning runtime', async () => { - runtimeEnvironmentCall.mockResolvedValue({ - id: 'rpc-update-setup', - ok: true, - result: { - result: { - project, - setup: { ...runtimeSetup, displayName: 'GPU VM renamed' } - } - }, - _meta: { runtimeId: 'runtime-remote' } - }) - const store = createTestStore() - store.setState({ - projectHostSetups: [runtimeSetup], - settings: { activeRuntimeEnvironmentId: null } as never - }) - - await expect( - store.getState().updateProjectHostSetup({ - setupId: runtimeSetup.id, - updates: { displayName: 'GPU VM renamed' } - }) - ).resolves.toEqual({ - project, - setup: { - ...runtimeSetup, - displayName: 'GPU VM renamed', - executionHostId: 'runtime:env-1', - runtimeOwnerEnvironmentId: 'env-1', - connectionId: null - }, - repo: undefined - }) - - expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ - selector: 'env-1', - method: 'projectHostSetup.update', - params: { - setupId: runtimeSetup.id, - updates: { displayName: 'GPU VM renamed' } - }, - timeoutMs: 15_000 - }) - }) - - it('deletes runtime-owned project host setups through their owning runtime', async () => { - runtimeEnvironmentCall.mockResolvedValue({ - id: 'rpc-delete-setup', - ok: true, - result: { result: { project, setup: runtimeSetup } }, - _meta: { runtimeId: 'runtime-remote' } - }) - const store = createTestStore() - store.setState({ - projects: [project], - projectHostSetups: [runtimeSetup], - settings: { activeRuntimeEnvironmentId: null } as never - }) - - await expect( - store.getState().deleteProjectHostSetup({ setupId: runtimeSetup.id }) - ).resolves.toEqual({ - project, - setup: runtimeSetup, - repo: undefined - }) - - expect(store.getState().projects).toEqual([project]) - expect(store.getState().projectHostSetups).toEqual([]) - expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ - selector: 'env-1', - method: 'projectHostSetup.delete', - params: { setupId: runtimeSetup.id }, - timeoutMs: 15_000 - }) - }) - - it('routes duplicate setup IDs through the first row and replaces every collision', async () => { - const localSetup: ProjectHostSetup = { - ...runtimeSetup, - hostId: 'local', - displayName: 'Local setup' - } - const updatedLocalSetup = { ...localSetup, displayName: 'Local renamed', updatedAt: 2 } - projectsUpdateHostSetup.mockResolvedValue({ - project, - setup: updatedLocalSetup - }) - const store = createTestStore() - store.setState({ - projectHostSetups: [localSetup, runtimeSetup], - settings: { activeRuntimeEnvironmentId: null } as never - }) - - await store.getState().updateProjectHostSetup({ - setupId: localSetup.id, - updates: { displayName: 'Local renamed' } - }) - - expect(projectsUpdateHostSetup).toHaveBeenCalledWith({ - setupId: localSetup.id, - updates: { displayName: 'Local renamed' } - }) - expect(runtimeEnvironmentCall).not.toHaveBeenCalled() - // Current contract: setup mutations are keyed by bare setup ID after the first row selects routing. - expect(store.getState().projectHostSetups).toEqual([updatedLocalSetup, updatedLocalSetup]) - }) - - it('routes duplicate setup-ID deletion through the first row and removes every collision', async () => { - const localSetup: ProjectHostSetup = { - ...runtimeSetup, - hostId: 'local', - displayName: 'Local setup' - } - projectsDeleteHostSetup.mockResolvedValue({ project, setup: localSetup }) - const store = createTestStore() - store.setState({ - projects: [project], - projectHostSetups: [localSetup, runtimeSetup], - settings: { activeRuntimeEnvironmentId: null } as never - }) - - await store.getState().deleteProjectHostSetup({ setupId: localSetup.id }) - - expect(projectsDeleteHostSetup).toHaveBeenCalledWith({ setupId: localSetup.id }) - expect(runtimeEnvironmentCall).not.toHaveBeenCalled() - // Current contract: delete filters the full catalog by bare setup ID. - expect(store.getState().projectHostSetups).toEqual([]) - }) - - it('preserves runtime-fetched setup-only states during repo hydration', async () => { - const pendingSetup: ProjectHostSetup = { - ...runtimeSetup, - id: 'setup-pending', - repoId: '', - path: '', - setupState: 'setting-up' - } - runtimeEnvironmentCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { - if (args.method === 'repo.list') { - return { - id: 'rpc-repos', - ok: true, - result: { repos: [runtimeRepo] }, - _meta: { runtimeId: 'runtime-remote' } - } - } - if (args.method === 'project.list') { - return { - id: 'rpc-projects', - ok: true, - result: { projects: [project] }, - _meta: { runtimeId: 'runtime-remote' } - } - } - if (args.method === 'projectHostSetup.list') { - return { - id: 'rpc-setups', - ok: true, - result: { setups: [pendingSetup] }, - _meta: { runtimeId: 'runtime-remote' } - } - } - throw new Error(`Unexpected runtime method: ${args.method}`) - }) - const store = createTestStore() - store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) - - await store.getState().fetchRepos(catalogOwner(store)) - - expect(store.getState().projectHostSetups).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - id: 'setup-pending', - hostId: 'runtime:env-1', - setupState: 'setting-up' - }) - ]) - ) - }) -}) - -// The catalog these tests read is the one the focused host would have shown. -function catalogOwner(store: { - getState: () => { settings: { activeRuntimeEnvironmentId?: string | null } | null } -}): { runtimeEnvironmentId: string | null } { - return { runtimeEnvironmentId: store.getState().settings?.activeRuntimeEnvironmentId ?? null } -} diff --git a/src/renderer/src/store/slices/repos-project-mutations.test.ts b/src/renderer/src/store/slices/repos-project-mutations.test.ts new file mode 100644 index 00000000000..711f6c029b3 --- /dev/null +++ b/src/renderer/src/store/slices/repos-project-mutations.test.ts @@ -0,0 +1,18 @@ +import { describe } from 'vitest' +import { registerReposProjectHostLifecycleTests } from './repos-project-host-lifecycle.spec' +import { registerReposProjectHostCapabilityTests } from './repos-project-host-capability.spec' +import { registerReposProjectRuntimeTests } from './repos-project-runtime.spec' +import { registerReposProjectGroupsOwnerRoutingTests } from './repos-project-groups-owner-routing.spec' +import { registerReposProjectGroupsDeleteTests } from './repos-project-groups-delete.spec' +import { registerReposUpdateSerializationTests } from './repos-update-serialization.spec' +import { registerReposProjectGroupCreateRaceTests } from './repos-project-group-create-race.spec' +import { registerReposRemoveProjectPurgeLeakTests } from './repos-remove-project-purge-leak.spec' + +describe('repos-project-host-lifecycle', registerReposProjectHostLifecycleTests) +describe('repos-project-host-capability', registerReposProjectHostCapabilityTests) +describe('repos-project-runtime', registerReposProjectRuntimeTests) +describe('repos-project-groups-owner-routing', registerReposProjectGroupsOwnerRoutingTests) +describe('repos-project-groups-delete', registerReposProjectGroupsDeleteTests) +describe('repos-update-serialization', registerReposUpdateSerializationTests) +describe('repos-project-group-create-race', registerReposProjectGroupCreateRaceTests) +describe('repos-remove-project-purge-leak', registerReposRemoveProjectPurgeLeakTests) diff --git a/src/renderer/src/store/slices/repos-project-runtime.spec.ts b/src/renderer/src/store/slices/repos-project-runtime.spec.ts new file mode 100644 index 00000000000..d3fd6fe4e45 --- /dev/null +++ b/src/renderer/src/store/slices/repos-project-runtime.spec.ts @@ -0,0 +1,436 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createTestStore } from './store-test-helpers' +import type { Project, ProjectHostSetup } from '../../../../shared/project-types' +import type { Repo } from '../../../../shared/repo-types' +import { + createCompatibleRuntimeStatusResponseIfNeeded, + type RuntimeEnvironmentCallRequest +} from '../../runtime/runtime-compatibility-test-fixture' +import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' + +export function registerReposProjectRuntimeTests(): void { + const localRepo: Repo = { + id: 'local-repo', + path: '/local', + displayName: 'Local', + badgeColor: '#000', + addedAt: 1 + } + + const projectsList = vi.fn() + const projectsUpdate = vi.fn() + const projectsCreateHostSetup = vi.fn() + const projectsSetupExistingFolder = vi.fn() + const projectsUpdateHostSetup = vi.fn() + const projectsDeleteHostSetup = vi.fn() + const reposList = vi.fn() + const runtimeEnvironmentCall = vi.fn() + const runtimeEnvironmentTransportCall = vi.fn() + const dispatchEventMock = vi.fn() + + beforeEach(() => { + clearRuntimeCompatibilityCacheForTests() + projectsList.mockReset() + projectsUpdate.mockReset() + projectsCreateHostSetup.mockReset() + projectsSetupExistingFolder.mockReset() + projectsUpdateHostSetup.mockReset() + projectsDeleteHostSetup.mockReset() + reposList.mockReset() + runtimeEnvironmentCall.mockReset() + runtimeEnvironmentTransportCall.mockReset() + dispatchEventMock.mockReset() + runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args) + }) + vi.stubGlobal('window', { + api: { + repos: { + list: reposList + }, + projects: { + list: projectsList, + update: projectsUpdate, + listHostSetups: vi.fn(), + createHostSetup: projectsCreateHostSetup, + setupExistingFolder: projectsSetupExistingFolder, + updateHostSetup: projectsUpdateHostSetup, + deleteHostSetup: projectsDeleteHostSetup + }, + runtimeEnvironments: { call: runtimeEnvironmentTransportCall } + }, + dispatchEvent: dispatchEventMock + }) + }) + + function expectInstalledSkillRefreshEvent(): void { + expect( + dispatchEventMock.mock.calls.some(([event]) => { + return event instanceof CustomEvent && event.type === 'orca:installed-agent-skills-changed' + }) + ).toBe(true) + } + + describe('repo slice project runtime updates', () => { + it('clears local runtime-scoped detection state when project runtime changes', async () => { + const project: Project = { + id: 'project-1', + displayName: 'Project', + badgeColor: '#000', + sourceRepoIds: ['local-repo'], + createdAt: 1, + updatedAt: 1 + } + projectsUpdate.mockResolvedValue({ + ...project, + localWindowsRuntimePreference: { kind: 'windows-host' } + }) + const store = createTestStore() + store.setState({ + projects: [project], + detectedAgentIds: ['claude'], + isDetectingAgents: true, + isRefreshingAgents: true + }) + + await store.getState().updateProject( + project.id, + { + localWindowsRuntimePreference: { kind: 'windows-host' } + }, + 'local' + ) + + expect(store.getState().detectedAgentIds).toBeNull() + expect(store.getState().isDetectingAgents).toBe(false) + expect(store.getState().isRefreshingAgents).toBe(false) + expectInstalledSkillRefreshEvent() + }) + + it('hydrates projects from local IPC when the project API is available', async () => { + const project: Project = { + id: 'project-1', + displayName: 'Project', + badgeColor: '#000', + sourceRepoIds: ['local-repo'], + createdAt: 1, + updatedAt: 1 + } + const setup: ProjectHostSetup = { + id: 'setup-1', + projectId: project.id, + hostId: 'local', + repoId: 'local-repo', + path: '/local', + displayName: 'Local', + setupState: 'ready', + setupMethod: 'legacy-repo', + createdAt: 1, + updatedAt: 1 + } + projectsList.mockResolvedValue([project]) + window.api.projects.listHostSetups = vi.fn().mockResolvedValue([setup]) + reposList.mockResolvedValue([localRepo]) + const store = createTestStore() + + await store.getState().fetchRepos(catalogOwner(store)) + + expect(store.getState().projects).toEqual([project]) + expect(store.getState().projectHostSetups).toEqual([setup]) + expect(projectsList).toHaveBeenCalled() + expect(window.api.projects.listHostSetups).toHaveBeenCalled() + }) + + it('clears stale local runtime preferences when local project refresh omits them', async () => { + const staleProject: Project = { + id: 'local-project', + displayName: 'Project', + badgeColor: '#000', + sourceRepoIds: ['local-repo'], + localWindowsRuntimePreference: { kind: 'windows-host' }, + createdAt: 1, + updatedAt: 1 + } + const refreshedProject: Project = { + id: staleProject.id, + displayName: staleProject.displayName, + badgeColor: staleProject.badgeColor, + sourceRepoIds: ['local-repo'], + createdAt: 1, + updatedAt: 2 + } + const setup: ProjectHostSetup = { + id: 'setup-1', + projectId: staleProject.id, + hostId: 'local', + repoId: 'local-repo', + path: '/local', + displayName: 'Local', + setupState: 'ready', + setupMethod: 'legacy-repo', + createdAt: 1, + updatedAt: 1 + } + projectsList.mockResolvedValue([refreshedProject]) + window.api.projects.listHostSetups = vi.fn().mockResolvedValue([setup]) + reposList.mockResolvedValue([localRepo]) + const store = createTestStore() + store.setState({ projects: [staleProject], projectHostSetups: [setup] }) + + await store.getState().fetchRepos(catalogOwner(store)) + + expect(store.getState().projects[0]?.localWindowsRuntimePreference).toBeUndefined() + }) + + it('updates local project runtime preferences through the projects API', async () => { + const project: Project = { + id: 'project-1', + displayName: 'Project', + badgeColor: '#000', + sourceRepoIds: ['local-repo'], + createdAt: 1, + updatedAt: 1 + } + projectsUpdate.mockResolvedValue({ + ...project, + localWindowsRuntimePreference: { kind: 'windows-host' } + }) + const store = createTestStore() + store.setState({ projects: [project] }) + + await store.getState().updateProject( + project.id, + { + localWindowsRuntimePreference: { kind: 'windows-host' } + }, + 'local' + ) + + expect(store.getState().projects[0]?.localWindowsRuntimePreference).toEqual({ + kind: 'windows-host' + }) + expect(projectsUpdate).toHaveBeenCalledWith({ + projectId: project.id, + updates: { localWindowsRuntimePreference: { kind: 'windows-host' } } + }) + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) + + it('updates a local project on this computer even while a server with a setup is the default host', async () => { + const project: Project = { + id: 'project-1', + displayName: 'Project', + badgeColor: '#000', + sourceRepoIds: ['local-repo'], + createdAt: 1, + updatedAt: 1 + } + projectsUpdate.mockResolvedValue({ + ...project, + localWindowsRuntimePreference: { kind: 'windows-host' } + }) + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as never, + projects: [project], + projectHostSetups: [ + { + id: 'setup-1', + projectId: project.id, + hostId: 'runtime:env-1', + repoId: 'remote-repo', + path: '/srv/repo', + displayName: 'Remote', + setupState: 'ready', + setupMethod: 'imported-existing-folder', + createdAt: 1, + updatedAt: 1 + } + ] + }) + + await store + .getState() + .updateProject( + project.id, + { localWindowsRuntimePreference: { kind: 'windows-host' } }, + 'local' + ) + + expect(projectsUpdate).toHaveBeenCalledWith({ + projectId: project.id, + updates: { localWindowsRuntimePreference: { kind: 'windows-host' } } + }) + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) + + it('updates remote project runtime preferences through the owning runtime', async () => { + const project: Project = { + id: 'project-1', + displayName: 'Project', + badgeColor: '#000', + sourceRepoIds: ['remote-repo'], + createdAt: 1, + updatedAt: 1 + } + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-project-update', + ok: true, + result: { + project: { + ...project, + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } + } + }, + _meta: { runtimeId: 'runtime-remote' } + }) + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as never, + projects: [project], + projectHostSetups: [ + { + id: 'setup-1', + projectId: project.id, + hostId: 'runtime:env-1', + repoId: 'remote-repo', + path: '/srv/repo', + displayName: 'Remote', + setupState: 'ready', + setupMethod: 'imported-existing-folder', + createdAt: 1, + updatedAt: 1 + } + ] + }) + + await store.getState().updateProject( + project.id, + { + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } + }, + 'runtime:env-1' + ) + + expect(store.getState().projects[0]?.localWindowsRuntimePreference).toEqual({ + kind: 'wsl', + distro: 'Ubuntu' + }) + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'project.update', + params: { + projectId: project.id, + updates: { localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } } + }, + timeoutMs: 15_000 + }) + expect(projectsUpdate).not.toHaveBeenCalled() + }) + + // Why: a host whose repos carry no `addedAt` projects createdAt 0, which means unknown, not + // epoch. Merging it with the host that does know the timestamp must keep the real one. + it('prefers a known createdAt over an unknown 0 when merging the same project id', async () => { + const project: Project = { + id: 'github:stablyai/orca', + displayName: 'Orca', + badgeColor: '#000', + sourceRepoIds: ['remote-repo'], + createdAt: 0, + updatedAt: 0 + } + projectsUpdate.mockResolvedValue({ + ...project, + sourceRepoIds: ['local-repo'], + createdAt: 100, + updatedAt: 100, + localWindowsRuntimePreference: { kind: 'windows-host' } + }) + const store = createTestStore() + store.setState({ projects: [project] }) + + await store.getState().updateProject( + project.id, + { + localWindowsRuntimePreference: { kind: 'windows-host' } + }, + 'local' + ) + + expect(store.getState().projects[0]?.createdAt).toBe(100) + expect(store.getState().projects[0]?.updatedAt).toBe(100) + }) + + it('preserves shared project source repos when updating local runtime preferences', async () => { + const project: Project = { + id: 'github:stablyai/orca', + displayName: 'Orca', + badgeColor: '#000', + sourceRepoIds: ['local-repo', 'remote-repo'], + createdAt: 1, + updatedAt: 2 + } + projectsUpdate.mockResolvedValue({ + ...project, + sourceRepoIds: ['local-repo'], + localWindowsRuntimePreference: { kind: 'windows-host' }, + updatedAt: 3 + }) + const store = createTestStore() + store.setState({ projects: [project] }) + + await store.getState().updateProject( + project.id, + { + localWindowsRuntimePreference: { kind: 'windows-host' } + }, + 'local' + ) + + expect(store.getState().projects[0]?.sourceRepoIds).toEqual(['local-repo', 'remote-repo']) + expect(store.getState().projects[0]?.localWindowsRuntimePreference).toEqual({ + kind: 'windows-host' + }) + }) + + it('clears local runtime preferences without dropping shared project source repos', async () => { + const project: Project = { + id: 'github:stablyai/orca', + displayName: 'Orca', + badgeColor: '#000', + sourceRepoIds: ['local-repo', 'remote-repo'], + localWindowsRuntimePreference: { kind: 'windows-host' }, + createdAt: 1, + updatedAt: 2 + } + projectsUpdate.mockResolvedValue({ + id: project.id, + displayName: project.displayName, + badgeColor: project.badgeColor, + sourceRepoIds: ['local-repo'], + createdAt: project.createdAt, + updatedAt: 3 + }) + const store = createTestStore() + store.setState({ projects: [project] }) + + await store.getState().updateProject( + project.id, + { + localWindowsRuntimePreference: undefined + }, + 'local' + ) + + expect(store.getState().projects[0]?.sourceRepoIds).toEqual(['local-repo', 'remote-repo']) + expect(store.getState().projects[0]?.localWindowsRuntimePreference).toBeUndefined() + }) + }) + + // The catalog these tests read is the one the focused host would have shown. + function catalogOwner(store: { + getState: () => { settings: { activeRuntimeEnvironmentId?: string | null } | null } + }): { runtimeEnvironmentId: string | null } { + return { runtimeEnvironmentId: store.getState().settings?.activeRuntimeEnvironmentId ?? null } + } +} diff --git a/src/renderer/src/store/slices/repos-project-runtime.test.ts b/src/renderer/src/store/slices/repos-project-runtime.test.ts deleted file mode 100644 index b59fa4c47a9..00000000000 --- a/src/renderer/src/store/slices/repos-project-runtime.test.ts +++ /dev/null @@ -1,434 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { createTestStore } from './store-test-helpers' -import type { Project, ProjectHostSetup } from '../../../../shared/project-types' -import type { Repo } from '../../../../shared/repo-types' -import { - createCompatibleRuntimeStatusResponseIfNeeded, - type RuntimeEnvironmentCallRequest -} from '../../runtime/runtime-compatibility-test-fixture' -import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' - -const localRepo: Repo = { - id: 'local-repo', - path: '/local', - displayName: 'Local', - badgeColor: '#000', - addedAt: 1 -} - -const projectsList = vi.fn() -const projectsUpdate = vi.fn() -const projectsCreateHostSetup = vi.fn() -const projectsSetupExistingFolder = vi.fn() -const projectsUpdateHostSetup = vi.fn() -const projectsDeleteHostSetup = vi.fn() -const reposList = vi.fn() -const runtimeEnvironmentCall = vi.fn() -const runtimeEnvironmentTransportCall = vi.fn() -const dispatchEventMock = vi.fn() - -beforeEach(() => { - clearRuntimeCompatibilityCacheForTests() - projectsList.mockReset() - projectsUpdate.mockReset() - projectsCreateHostSetup.mockReset() - projectsSetupExistingFolder.mockReset() - projectsUpdateHostSetup.mockReset() - projectsDeleteHostSetup.mockReset() - reposList.mockReset() - runtimeEnvironmentCall.mockReset() - runtimeEnvironmentTransportCall.mockReset() - dispatchEventMock.mockReset() - runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { - return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args) - }) - vi.stubGlobal('window', { - api: { - repos: { - list: reposList - }, - projects: { - list: projectsList, - update: projectsUpdate, - listHostSetups: vi.fn(), - createHostSetup: projectsCreateHostSetup, - setupExistingFolder: projectsSetupExistingFolder, - updateHostSetup: projectsUpdateHostSetup, - deleteHostSetup: projectsDeleteHostSetup - }, - runtimeEnvironments: { call: runtimeEnvironmentTransportCall } - }, - dispatchEvent: dispatchEventMock - }) -}) - -function expectInstalledSkillRefreshEvent(): void { - expect( - dispatchEventMock.mock.calls.some(([event]) => { - return event instanceof CustomEvent && event.type === 'orca:installed-agent-skills-changed' - }) - ).toBe(true) -} - -describe('repo slice project runtime updates', () => { - it('clears local runtime-scoped detection state when project runtime changes', async () => { - const project: Project = { - id: 'project-1', - displayName: 'Project', - badgeColor: '#000', - sourceRepoIds: ['local-repo'], - createdAt: 1, - updatedAt: 1 - } - projectsUpdate.mockResolvedValue({ - ...project, - localWindowsRuntimePreference: { kind: 'windows-host' } - }) - const store = createTestStore() - store.setState({ - projects: [project], - detectedAgentIds: ['claude'], - isDetectingAgents: true, - isRefreshingAgents: true - }) - - await store.getState().updateProject( - project.id, - { - localWindowsRuntimePreference: { kind: 'windows-host' } - }, - 'local' - ) - - expect(store.getState().detectedAgentIds).toBeNull() - expect(store.getState().isDetectingAgents).toBe(false) - expect(store.getState().isRefreshingAgents).toBe(false) - expectInstalledSkillRefreshEvent() - }) - - it('hydrates projects from local IPC when the project API is available', async () => { - const project: Project = { - id: 'project-1', - displayName: 'Project', - badgeColor: '#000', - sourceRepoIds: ['local-repo'], - createdAt: 1, - updatedAt: 1 - } - const setup: ProjectHostSetup = { - id: 'setup-1', - projectId: project.id, - hostId: 'local', - repoId: 'local-repo', - path: '/local', - displayName: 'Local', - setupState: 'ready', - setupMethod: 'legacy-repo', - createdAt: 1, - updatedAt: 1 - } - projectsList.mockResolvedValue([project]) - window.api.projects.listHostSetups = vi.fn().mockResolvedValue([setup]) - reposList.mockResolvedValue([localRepo]) - const store = createTestStore() - - await store.getState().fetchRepos(catalogOwner(store)) - - expect(store.getState().projects).toEqual([project]) - expect(store.getState().projectHostSetups).toEqual([setup]) - expect(projectsList).toHaveBeenCalled() - expect(window.api.projects.listHostSetups).toHaveBeenCalled() - }) - - it('clears stale local runtime preferences when local project refresh omits them', async () => { - const staleProject: Project = { - id: 'local-project', - displayName: 'Project', - badgeColor: '#000', - sourceRepoIds: ['local-repo'], - localWindowsRuntimePreference: { kind: 'windows-host' }, - createdAt: 1, - updatedAt: 1 - } - const refreshedProject: Project = { - id: staleProject.id, - displayName: staleProject.displayName, - badgeColor: staleProject.badgeColor, - sourceRepoIds: ['local-repo'], - createdAt: 1, - updatedAt: 2 - } - const setup: ProjectHostSetup = { - id: 'setup-1', - projectId: staleProject.id, - hostId: 'local', - repoId: 'local-repo', - path: '/local', - displayName: 'Local', - setupState: 'ready', - setupMethod: 'legacy-repo', - createdAt: 1, - updatedAt: 1 - } - projectsList.mockResolvedValue([refreshedProject]) - window.api.projects.listHostSetups = vi.fn().mockResolvedValue([setup]) - reposList.mockResolvedValue([localRepo]) - const store = createTestStore() - store.setState({ projects: [staleProject], projectHostSetups: [setup] }) - - await store.getState().fetchRepos(catalogOwner(store)) - - expect(store.getState().projects[0]?.localWindowsRuntimePreference).toBeUndefined() - }) - - it('updates local project runtime preferences through the projects API', async () => { - const project: Project = { - id: 'project-1', - displayName: 'Project', - badgeColor: '#000', - sourceRepoIds: ['local-repo'], - createdAt: 1, - updatedAt: 1 - } - projectsUpdate.mockResolvedValue({ - ...project, - localWindowsRuntimePreference: { kind: 'windows-host' } - }) - const store = createTestStore() - store.setState({ projects: [project] }) - - await store.getState().updateProject( - project.id, - { - localWindowsRuntimePreference: { kind: 'windows-host' } - }, - 'local' - ) - - expect(store.getState().projects[0]?.localWindowsRuntimePreference).toEqual({ - kind: 'windows-host' - }) - expect(projectsUpdate).toHaveBeenCalledWith({ - projectId: project.id, - updates: { localWindowsRuntimePreference: { kind: 'windows-host' } } - }) - expect(runtimeEnvironmentCall).not.toHaveBeenCalled() - }) - - it('updates a local project on this computer even while a server with a setup is the default host', async () => { - const project: Project = { - id: 'project-1', - displayName: 'Project', - badgeColor: '#000', - sourceRepoIds: ['local-repo'], - createdAt: 1, - updatedAt: 1 - } - projectsUpdate.mockResolvedValue({ - ...project, - localWindowsRuntimePreference: { kind: 'windows-host' } - }) - const store = createTestStore() - store.setState({ - settings: { activeRuntimeEnvironmentId: 'env-1' } as never, - projects: [project], - projectHostSetups: [ - { - id: 'setup-1', - projectId: project.id, - hostId: 'runtime:env-1', - repoId: 'remote-repo', - path: '/srv/repo', - displayName: 'Remote', - setupState: 'ready', - setupMethod: 'imported-existing-folder', - createdAt: 1, - updatedAt: 1 - } - ] - }) - - await store - .getState() - .updateProject( - project.id, - { localWindowsRuntimePreference: { kind: 'windows-host' } }, - 'local' - ) - - expect(projectsUpdate).toHaveBeenCalledWith({ - projectId: project.id, - updates: { localWindowsRuntimePreference: { kind: 'windows-host' } } - }) - expect(runtimeEnvironmentCall).not.toHaveBeenCalled() - }) - - it('updates remote project runtime preferences through the owning runtime', async () => { - const project: Project = { - id: 'project-1', - displayName: 'Project', - badgeColor: '#000', - sourceRepoIds: ['remote-repo'], - createdAt: 1, - updatedAt: 1 - } - runtimeEnvironmentCall.mockResolvedValue({ - id: 'rpc-project-update', - ok: true, - result: { - project: { - ...project, - localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } - } - }, - _meta: { runtimeId: 'runtime-remote' } - }) - const store = createTestStore() - store.setState({ - settings: { activeRuntimeEnvironmentId: 'env-1' } as never, - projects: [project], - projectHostSetups: [ - { - id: 'setup-1', - projectId: project.id, - hostId: 'runtime:env-1', - repoId: 'remote-repo', - path: '/srv/repo', - displayName: 'Remote', - setupState: 'ready', - setupMethod: 'imported-existing-folder', - createdAt: 1, - updatedAt: 1 - } - ] - }) - - await store.getState().updateProject( - project.id, - { - localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } - }, - 'runtime:env-1' - ) - - expect(store.getState().projects[0]?.localWindowsRuntimePreference).toEqual({ - kind: 'wsl', - distro: 'Ubuntu' - }) - expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ - selector: 'env-1', - method: 'project.update', - params: { - projectId: project.id, - updates: { localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } } - }, - timeoutMs: 15_000 - }) - expect(projectsUpdate).not.toHaveBeenCalled() - }) - - // Why: a host whose repos carry no `addedAt` projects createdAt 0, which means unknown, not - // epoch. Merging it with the host that does know the timestamp must keep the real one. - it('prefers a known createdAt over an unknown 0 when merging the same project id', async () => { - const project: Project = { - id: 'github:stablyai/orca', - displayName: 'Orca', - badgeColor: '#000', - sourceRepoIds: ['remote-repo'], - createdAt: 0, - updatedAt: 0 - } - projectsUpdate.mockResolvedValue({ - ...project, - sourceRepoIds: ['local-repo'], - createdAt: 100, - updatedAt: 100, - localWindowsRuntimePreference: { kind: 'windows-host' } - }) - const store = createTestStore() - store.setState({ projects: [project] }) - - await store.getState().updateProject( - project.id, - { - localWindowsRuntimePreference: { kind: 'windows-host' } - }, - 'local' - ) - - expect(store.getState().projects[0]?.createdAt).toBe(100) - expect(store.getState().projects[0]?.updatedAt).toBe(100) - }) - - it('preserves shared project source repos when updating local runtime preferences', async () => { - const project: Project = { - id: 'github:stablyai/orca', - displayName: 'Orca', - badgeColor: '#000', - sourceRepoIds: ['local-repo', 'remote-repo'], - createdAt: 1, - updatedAt: 2 - } - projectsUpdate.mockResolvedValue({ - ...project, - sourceRepoIds: ['local-repo'], - localWindowsRuntimePreference: { kind: 'windows-host' }, - updatedAt: 3 - }) - const store = createTestStore() - store.setState({ projects: [project] }) - - await store.getState().updateProject( - project.id, - { - localWindowsRuntimePreference: { kind: 'windows-host' } - }, - 'local' - ) - - expect(store.getState().projects[0]?.sourceRepoIds).toEqual(['local-repo', 'remote-repo']) - expect(store.getState().projects[0]?.localWindowsRuntimePreference).toEqual({ - kind: 'windows-host' - }) - }) - - it('clears local runtime preferences without dropping shared project source repos', async () => { - const project: Project = { - id: 'github:stablyai/orca', - displayName: 'Orca', - badgeColor: '#000', - sourceRepoIds: ['local-repo', 'remote-repo'], - localWindowsRuntimePreference: { kind: 'windows-host' }, - createdAt: 1, - updatedAt: 2 - } - projectsUpdate.mockResolvedValue({ - id: project.id, - displayName: project.displayName, - badgeColor: project.badgeColor, - sourceRepoIds: ['local-repo'], - createdAt: project.createdAt, - updatedAt: 3 - }) - const store = createTestStore() - store.setState({ projects: [project] }) - - await store.getState().updateProject( - project.id, - { - localWindowsRuntimePreference: undefined - }, - 'local' - ) - - expect(store.getState().projects[0]?.sourceRepoIds).toEqual(['local-repo', 'remote-repo']) - expect(store.getState().projects[0]?.localWindowsRuntimePreference).toBeUndefined() - }) -}) - -// The catalog these tests read is the one the focused host would have shown. -function catalogOwner(store: { - getState: () => { settings: { activeRuntimeEnvironmentId?: string | null } | null } -}): { runtimeEnvironmentId: string | null } { - return { runtimeEnvironmentId: store.getState().settings?.activeRuntimeEnvironmentId ?? null } -} diff --git a/src/renderer/src/store/slices/repos-refresh-identity.spec.ts b/src/renderer/src/store/slices/repos-refresh-identity.spec.ts new file mode 100644 index 00000000000..7031e4a7820 --- /dev/null +++ b/src/renderer/src/store/slices/repos-refresh-identity.spec.ts @@ -0,0 +1,503 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Project } from '../../../../shared/project-types' +import type { Repo } from '../../../../shared/repo-types' +import { getSetupScriptPromptDismissalKey } from '../../lib/setup-script-prompt' +import { getRepoHostIdentityForParts } from './repo-host-identity' +import { createTestStore } from './store-test-helpers' + +export function registerReposRefreshIdentityTests(): void { + // Why: every field here is load-bearing. A scalar-only repo reconciles even when the structural + // compare is broken, which is exactly how an earlier version of this work shipped green and inert. + // addedAt is non-zero on this fixture so the default case still exercises a real timestamp; + // dedicated tests below cover addedAt 0 / omitted without restamping Date.now(). + const repo: Repo = { + id: 'repo-1', + path: '/repo-1', + displayName: 'Repo 1', + badgeColor: '#000000', + addedAt: 1_700_000_000_000, + executionHostId: 'local', + kind: 'git', + repoIcon: { type: 'lucide', name: 'Box' }, + upstream: { owner: 'upstream-owner', repo: 'repo-1', host: 'github.com' }, + gitRemoteIdentity: { + canonicalKey: 'github.com/octocat/repo-1', + remoteName: 'origin', + remoteUrl: 'git@github.com:octocat/repo-1.git' + }, + hookSettings: { mode: 'auto', scripts: { setup: 'echo hi', archive: '' } }, + symlinkPaths: ['.env', 'node_modules'], + importedExternalWorktreePaths: [] + } + + const secondRepo: Repo = { + ...repo, + id: 'repo-2', + path: '/repo-2', + displayName: 'Repo 2', + addedAt: 1_700_000_001_000, + upstream: { owner: 'upstream-owner', repo: 'repo-2', host: 'github.com' }, + gitRemoteIdentity: { + canonicalKey: 'github.com/octocat/repo-2', + remoteName: 'origin', + remoteUrl: 'git@github.com:octocat/repo-2.git' + } + } + + const reposList = vi.fn() + const projectsList = vi.fn() + const listHostSetups = vi.fn() + + // Why: catalogs arrive over IPC, so every fetch must hand back freshly allocated objects — + // otherwise identity would match by accident and prove nothing. + function clone(value: T): T { + return structuredClone(value) + } + + function mockRepos(...rows: readonly (Repo | Omit)[]): void { + reposList.mockImplementation(async () => rows.map(clone)) + } + + function omitAddedAt(row: Repo): Omit { + const { addedAt: _addedAt, ...rest } = row + return rest + } + + beforeEach(() => { + reposList.mockReset() + projectsList.mockReset() + listHostSetups.mockReset() + mockRepos(repo) + projectsList.mockImplementation(async () => []) + listHostSetups.mockImplementation(async () => []) + + vi.stubGlobal('window', { + api: { + repos: { list: reposList }, + projects: { list: projectsList, listHostSetups } + }, + dispatchEvent: vi.fn() + }) + }) + + describe('repo catalog refresh identity', () => { + it('keeps the projects and host setups arrays and entries across a no-op refetch', async () => { + const store = createTestStore() + await store.getState().fetchRepos(catalogOwner(store)) + const projects = store.getState().projects + const setups = store.getState().projectHostSetups + expect(projects).toHaveLength(1) + expect(setups).toHaveLength(1) + + await store.getState().fetchRepos(catalogOwner(store)) + + expect(store.getState().projects).toBe(projects) + expect(store.getState().projects[0]).toBe(projects[0]) + expect(store.getState().projectHostSetups).toBe(setups) + expect(store.getState().projectHostSetups[0]).toBe(setups[0]) + }) + + it('lets a nested hookSettings change through', async () => { + const store = createTestStore() + await store.getState().fetchRepos(catalogOwner(store)) + const setups = store.getState().projectHostSetups + + mockRepos({ + ...repo, + hookSettings: { mode: 'override', scripts: { setup: '', archive: '' } } + }) + await store.getState().fetchRepos(catalogOwner(store)) + + expect(store.getState().projectHostSetups).not.toBe(setups) + expect(store.getState().projectHostSetups[0]?.hookSettings?.mode).toBe('override') + }) + + it('lets a displayName change through on projects', async () => { + const store = createTestStore() + await store.getState().fetchRepos(catalogOwner(store)) + const projects = store.getState().projects + + mockRepos({ ...repo, displayName: 'Renamed' }) + await store.getState().fetchRepos(catalogOwner(store)) + + expect(store.getState().projects).not.toBe(projects) + expect(store.getState().projects[0]?.displayName).toBe('Renamed') + }) + + it('lets an array-field change through', async () => { + const store = createTestStore() + await store.getState().fetchRepos(catalogOwner(store)) + const repos = store.getState().repos + + mockRepos({ ...repo, symlinkPaths: ['.env'] }) + await store.getState().fetchRepos(catalogOwner(store)) + + expect(store.getState().repos).not.toBe(repos) + expect(store.getState().repos[0]?.symlinkPaths).toEqual(['.env']) + }) + + it('lets a nested gitRemoteIdentity change through', async () => { + const store = createTestStore() + await store.getState().fetchRepos(catalogOwner(store)) + const projects = store.getState().projects + + mockRepos({ + ...repo, + gitRemoteIdentity: { ...repo.gitRemoteIdentity!, remoteName: 'upstream' } + }) + await store.getState().fetchRepos(catalogOwner(store)) + + expect(store.getState().projects).not.toBe(projects) + expect(store.getState().projects[0]?.gitRemoteIdentity?.remoteName).toBe('upstream') + }) + + it('treats clearing localWindowsRuntimePreference as a change', async () => { + const store = createTestStore() + await store.getState().fetchRepos(catalogOwner(store)) + const projectId = store.getState().projects[0]!.id + const withPreference: Project = { + ...store.getState().projects[0]!, + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } + } + store.setState({ projects: [withPreference] }) + + await store.getState().fetchRepos(catalogOwner(store)) + + // Why: a local-host refresh is authoritative; an absent key must not read as unchanged. + expect(store.getState().projects[0]).not.toBe(withPreference) + expect(store.getState().projects[0]?.id).toBe(projectId) + expect(store.getState().projects[0]?.localWindowsRuntimePreference).toBeUndefined() + }) + + it('reuses an unchanged setup element while a sibling changes', async () => { + mockRepos(repo, secondRepo) + const store = createTestStore() + await store.getState().fetchRepos(catalogOwner(store)) + const setups = store.getState().projectHostSetups + expect(setups).toHaveLength(2) + + mockRepos(repo, { ...secondRepo, displayName: 'Repo 2 renamed' }) + await store.getState().fetchRepos(catalogOwner(store)) + + const next = store.getState().projectHostSetups + expect(next).not.toBe(setups) + expect(next[0]).toBe(setups[0]) + expect(next[1]).not.toBe(setups[1]) + }) + + it('does not reuse a setup that moved to a different execution host', async () => { + const store = createTestStore() + await store.getState().fetchRepos(catalogOwner(store)) + const setups = store.getState().projectHostSetups + expect(setups[0]?.hostId).toBe('local') + + // Why: the repo-derived fallback sets setup.id = repo.id, so the same id on a second host is + // the case that would silently splice the wrong host's routing metadata into the row. + mockRepos({ ...repo, executionHostId: 'ssh:host-a', connectionId: 'host-a' }) + await store.getState().fetchRepos(catalogOwner(store)) + + const next = store.getState().projectHostSetups + expect(next[0]).not.toBe(setups[0]) + expect(next[0]?.hostId).toBe('ssh:host-a') + }) + + it('reconciles both setups when one repo id exists on two hosts', async () => { + // Why: the repo-derived fallback sets setup.id = repo.id, so these two setups share an id and + // differ only by host. Keying the reconcile on setup.id instead of the owner key collapses them + // onto one slot and one of the two churns on every refresh. + const sshRepo: Repo = { ...repo, executionHostId: 'ssh:host-a', connectionId: 'host-a' } + mockRepos(repo, sshRepo) + const store = createTestStore() + await store.getState().fetchRepos(catalogOwner(store)) + const setups = store.getState().projectHostSetups + expect(setups).toHaveLength(2) + expect(new Set(setups.map((setup) => setup.id)).size).toBe(1) + + await store.getState().fetchRepos(catalogOwner(store)) + + expect(store.getState().projectHostSetups).toBe(setups) + expect(store.getState().projectHostSetups[0]).toBe(setups[0]) + expect(store.getState().projectHostSetups[1]).toBe(setups[1]) + }) + + it('drops a project and its setup when its repo disappears', async () => { + mockRepos(repo, secondRepo) + const store = createTestStore() + await store.getState().fetchRepos(catalogOwner(store)) + const projects = store.getState().projects + + mockRepos(repo) + await store.getState().fetchRepos(catalogOwner(store)) + + expect(store.getState().projects).not.toBe(projects) + expect(store.getState().projects).toHaveLength(1) + expect(store.getState().projectHostSetups).toHaveLength(1) + }) + + it('keeps a project owned only through a repo on another host', async () => { + // Why: no setup row names this project, so the refreshing host can only be ruled out from the + // project's own source repos. Feeding the host-id resolvers anything but this project's repo + // slice prunes it on a local refresh. + const sshRepo: Repo = { ...secondRepo, executionHostId: 'ssh:host-a', connectionId: 'host-a' } + mockRepos(repo, sshRepo) + const store = createTestStore() + await store.getState().fetchRepos(catalogOwner(store)) + const sshOwned: Project = { + id: 'ssh-owned', + displayName: 'SSH Owned', + badgeColor: '#000000', + sourceRepoIds: [sshRepo.id], + createdAt: 1, + updatedAt: 1 + } + store.setState({ + projects: [...store.getState().projects, sshOwned], + projectHostSetups: [] + }) + + await store.getState().fetchRepos(catalogOwner(store)) + + expect(store.getState().projects.map((project) => project.id)).toContain('ssh-owned') + }) + + it('keeps a project whose repo id is cloned onto a second host', async () => { + // Why: both rows share `repo.id`, so the project's repo slice must carry every duplicate — + // keeping only the first drops the remote host and the local refresh prunes the project. + const sshRepo: Repo = { ...repo, executionHostId: 'ssh:host-a', connectionId: 'host-a' } + mockRepos(repo, sshRepo) + const store = createTestStore() + await store.getState().fetchRepos(catalogOwner(store)) + const dualHostOwned: Project = { + id: 'dual-host-owned', + displayName: 'Dual Host Owned', + badgeColor: '#000000', + sourceRepoIds: [repo.id], + createdAt: 1, + updatedAt: 1 + } + store.setState({ + projects: [...store.getState().projects, dualHostOwned], + projectHostSetups: [] + }) + + await store.getState().fetchRepos(catalogOwner(store)) + + expect(store.getState().projects.map((project) => project.id)).toContain('dual-host-owned') + }) + + it('adds a project and its setup when a repo appears', async () => { + const store = createTestStore() + await store.getState().fetchRepos(catalogOwner(store)) + const projects = store.getState().projects + + mockRepos(repo, secondRepo) + await store.getState().fetchRepos(catalogOwner(store)) + + expect(store.getState().projects).not.toBe(projects) + expect(store.getState().projects).toHaveLength(2) + expect(store.getState().projectHostSetups).toHaveLength(2) + }) + + it('keeps catalog identity when repo.addedAt is 0', async () => { + mockRepos({ ...repo, addedAt: 0 }) + const store = createTestStore() + await store.getState().fetchRepos(catalogOwner(store)) + const projects = store.getState().projects + const setups = store.getState().projectHostSetups + expect(projects).toHaveLength(1) + expect(setups).toHaveLength(1) + expect(projects[0]?.createdAt).toBe(0) + expect(setups[0]?.createdAt).toBe(0) + + await store.getState().fetchRepos(catalogOwner(store)) + + expect(store.getState().projects).toBe(projects) + expect(store.getState().projects[0]).toBe(projects[0]) + expect(store.getState().projectHostSetups).toBe(setups) + expect(store.getState().projectHostSetups[0]).toBe(setups[0]) + }) + + it('keeps catalog identity when repo.addedAt is omitted', async () => { + mockRepos(omitAddedAt(repo)) + const store = createTestStore() + await store.getState().fetchRepos(catalogOwner(store)) + const projects = store.getState().projects + const setups = store.getState().projectHostSetups + expect(projects).toHaveLength(1) + expect(setups).toHaveLength(1) + expect(projects[0]?.createdAt).toBe(0) + expect(setups[0]?.createdAt).toBe(0) + + await store.getState().fetchRepos(catalogOwner(store)) + + expect(store.getState().projects).toBe(projects) + expect(store.getState().projects[0]).toBe(projects[0]) + expect(store.getState().projectHostSetups).toBe(setups) + expect(store.getState().projectHostSetups[0]).toBe(setups[0]) + }) + + it('lets a nested hookSettings change through when repo.addedAt is 0', async () => { + mockRepos({ ...repo, addedAt: 0 }) + const store = createTestStore() + await store.getState().fetchRepos(catalogOwner(store)) + const setups = store.getState().projectHostSetups + + mockRepos({ + ...repo, + addedAt: 0, + hookSettings: { mode: 'override', scripts: { setup: '', archive: '' } } + }) + await store.getState().fetchRepos(catalogOwner(store)) + + expect(store.getState().projectHostSetups).not.toBe(setups) + expect(store.getState().projectHostSetups[0]).not.toBe(setups[0]) + expect(store.getState().projectHostSetups[0]?.hookSettings?.mode).toBe('override') + }) + }) + + describe('repo filter identity across catalog refreshes', () => { + it('keeps the filterRepoIds array when a refetch prunes nothing', async () => { + const store = createTestStore() + store.setState({ filterRepoIds: [repo.id] }) + const first = store.getState().filterRepoIds + + await store.getState().fetchRepos(catalogOwner(store)) + + // Why: App.tsx at the root and five sidebar consumers select this array by identity. + expect(store.getState().filterRepoIds).toBe(first) + }) + + it('still prunes a filtered repo id that no longer exists', async () => { + const store = createTestStore() + store.setState({ filterRepoIds: [repo.id, 'gone'] }) + + await store.getState().fetchRepos(catalogOwner(store)) + + expect(store.getState().filterRepoIds).toEqual([repo.id]) + }) + + it('prunes only the vanished id and reallocates', async () => { + mockRepos(repo) + const store = createTestStore() + store.setState({ filterRepoIds: [repo.id, secondRepo.id] }) + const first = store.getState().filterRepoIds + + await store.getState().fetchRepos(catalogOwner(store)) + + expect(store.getState().filterRepoIds).toEqual([repo.id]) + expect(store.getState().filterRepoIds).not.toBe(first) + }) + + it('keeps a filtered id whose repo merely moved to another host', async () => { + const store = createTestStore() + store.setState({ filterRepoIds: [repo.id] }) + const first = store.getState().filterRepoIds + + // Why: the filter is keyed on repo id, not host identity — a rehomed repo is not pruned. + mockRepos({ ...repo, executionHostId: 'ssh:host-a', connectionId: 'host-a' }) + await store.getState().fetchRepos(catalogOwner(store)) + + expect(store.getState().filterRepoIds).toBe(first) + }) + }) + describe('setup-script dismissal identity across catalog refreshes', () => { + it('keeps the dismissal array when a refetch prunes nothing', async () => { + const store = createTestStore() + store.setState({ + setupScriptPromptDismissedRepoIds: [ + getSetupScriptPromptDismissalKey(getRepoHostIdentityForParts(repo.id, 'local')) + ] + }) + const first = store.getState().setupScriptPromptDismissedRepoIds + + await store.getState().fetchRepos(catalogOwner(store)) + + // Why: SetupScriptPromptCard Object.is-subscribes to this array. A no-op + // catalog refresh must not allocate just because the helper rebuilt next. + expect(store.getState().setupScriptPromptDismissedRepoIds).toBe(first) + }) + }) + + describe('SSH readoption catalog identity', () => { + it('keeps projects and host setups across a no-op recordSshRepoReadoptions([])', async () => { + const store = createTestStore() + await store.getState().fetchRepos(catalogOwner(store)) + const before = store.getState() + const projects = before.projects + const setups = before.projectHostSetups + expect(projects).toHaveLength(1) + expect(setups).toHaveLength(1) + + store.getState().recordSshRepoReadoptions([]) + + // Why: the empty-in/empty-pending call must hand the state object back untouched, or the + // freshly allocated pendingSshRepoReadoptions alone would wake every store subscriber. + expect(store.getState()).toBe(before) + expect(store.getState().pendingSshRepoReadoptions).toBe(before.pendingSshRepoReadoptions) + expect(store.getState().projects).toBe(projects) + expect(store.getState().projects[0]).toBe(projects[0]) + expect(store.getState().projectHostSetups).toBe(setups) + expect(store.getState().projectHostSetups[0]).toBe(setups[0]) + }) + + it('keeps catalog identity for a pending-only readoption while pending updates', async () => { + const store = createTestStore() + await store.getState().fetchRepos(catalogOwner(store)) + const projects = store.getState().projects + const setups = store.getState().projectHostSetups + const readoption = { oldTargetId: 'ssh-old', newTargetId: 'ssh-new', repoIds: [repo.id] } + + store.getState().recordSshRepoReadoptions([readoption]) + + expect(store.getState().pendingSshRepoReadoptions).toEqual([readoption]) + expect(store.getState().projects).toBe(projects) + expect(store.getState().projects[0]).toBe(projects[0]) + expect(store.getState().projectHostSetups).toBe(setups) + expect(store.getState().projectHostSetups[0]).toBe(setups[0]) + }) + + it('replaces the moved setup on a real prune/rehome', async () => { + const oldHostRepo: Repo = { + ...repo, + connectionId: 'ssh-old', + executionHostId: 'ssh:ssh-old' + } + const newHostRepo: Repo = { + ...repo, + connectionId: 'ssh-new', + executionHostId: 'ssh:ssh-new' + } + mockRepos(oldHostRepo, newHostRepo) + const store = createTestStore() + await store.getState().fetchRepos(catalogOwner(store)) + const setups = store.getState().projectHostSetups + expect(setups).toHaveLength(2) + const oldSetup = setups.find((setup) => setup.hostId === 'ssh:ssh-old') + const newSetup = setups.find((setup) => setup.hostId === 'ssh:ssh-new') + expect(oldSetup).toBeDefined() + expect(newSetup).toBeDefined() + + store + .getState() + .recordSshRepoReadoptions([ + { oldTargetId: 'ssh-old', newTargetId: 'ssh-new', repoIds: [repo.id] } + ]) + + const next = store.getState().projectHostSetups + expect(next).not.toBe(setups) + expect(next).toHaveLength(1) + expect(next[0]).not.toBe(oldSetup) + expect(next[0]?.hostId).toBe('ssh:ssh-new') + expect(store.getState().repos).toHaveLength(1) + expect(store.getState().repos[0]?.executionHostId).toBe('ssh:ssh-new') + expect(store.getState().pendingSshRepoReadoptions).toEqual([]) + }) + }) + + // The catalog these tests read is the one the focused host would have shown. + function catalogOwner(store: { + getState: () => { settings: { activeRuntimeEnvironmentId?: string | null } | null } + }): { runtimeEnvironmentId: string | null } { + return { runtimeEnvironmentId: store.getState().settings?.activeRuntimeEnvironmentId ?? null } + } +} diff --git a/src/renderer/src/store/slices/repos-refresh-identity.test.ts b/src/renderer/src/store/slices/repos-refresh-identity.test.ts deleted file mode 100644 index 0bb06dddcd5..00000000000 --- a/src/renderer/src/store/slices/repos-refresh-identity.test.ts +++ /dev/null @@ -1,498 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { Project } from '../../../../shared/project-types' -import type { Repo } from '../../../../shared/repo-types' -import { getSetupScriptPromptDismissalKey } from '../../lib/setup-script-prompt' -import { getRepoHostIdentityForParts } from './repo-host-identity' -import { createTestStore } from './store-test-helpers' - -// Why: every field here is load-bearing. A scalar-only repo reconciles even when the structural -// compare is broken, which is exactly how an earlier version of this work shipped green and inert. -// addedAt is non-zero on this fixture so the default case still exercises a real timestamp; -// dedicated tests below cover addedAt 0 / omitted without restamping Date.now(). -const repo: Repo = { - id: 'repo-1', - path: '/repo-1', - displayName: 'Repo 1', - badgeColor: '#000000', - addedAt: 1_700_000_000_000, - executionHostId: 'local', - kind: 'git', - repoIcon: { type: 'lucide', name: 'Box' }, - upstream: { owner: 'upstream-owner', repo: 'repo-1', host: 'github.com' }, - gitRemoteIdentity: { - canonicalKey: 'github.com/octocat/repo-1', - remoteName: 'origin', - remoteUrl: 'git@github.com:octocat/repo-1.git' - }, - hookSettings: { mode: 'auto', scripts: { setup: 'echo hi', archive: '' } }, - symlinkPaths: ['.env', 'node_modules'], - importedExternalWorktreePaths: [] -} - -const secondRepo: Repo = { - ...repo, - id: 'repo-2', - path: '/repo-2', - displayName: 'Repo 2', - addedAt: 1_700_000_001_000, - upstream: { owner: 'upstream-owner', repo: 'repo-2', host: 'github.com' }, - gitRemoteIdentity: { - canonicalKey: 'github.com/octocat/repo-2', - remoteName: 'origin', - remoteUrl: 'git@github.com:octocat/repo-2.git' - } -} - -const reposList = vi.fn() -const projectsList = vi.fn() -const listHostSetups = vi.fn() - -// Why: catalogs arrive over IPC, so every fetch must hand back freshly allocated objects — -// otherwise identity would match by accident and prove nothing. -function clone(value: T): T { - return structuredClone(value) -} - -function mockRepos(...rows: readonly (Repo | Omit)[]): void { - reposList.mockImplementation(async () => rows.map(clone)) -} - -function omitAddedAt(row: Repo): Omit { - const { addedAt: _addedAt, ...rest } = row - return rest -} - -beforeEach(() => { - reposList.mockReset() - projectsList.mockReset() - listHostSetups.mockReset() - mockRepos(repo) - projectsList.mockImplementation(async () => []) - listHostSetups.mockImplementation(async () => []) - - vi.stubGlobal('window', { - api: { - repos: { list: reposList }, - projects: { list: projectsList, listHostSetups } - }, - dispatchEvent: vi.fn() - }) -}) - -describe('repo catalog refresh identity', () => { - it('keeps the projects and host setups arrays and entries across a no-op refetch', async () => { - const store = createTestStore() - await store.getState().fetchRepos(catalogOwner(store)) - const projects = store.getState().projects - const setups = store.getState().projectHostSetups - expect(projects).toHaveLength(1) - expect(setups).toHaveLength(1) - - await store.getState().fetchRepos(catalogOwner(store)) - - expect(store.getState().projects).toBe(projects) - expect(store.getState().projects[0]).toBe(projects[0]) - expect(store.getState().projectHostSetups).toBe(setups) - expect(store.getState().projectHostSetups[0]).toBe(setups[0]) - }) - - it('lets a nested hookSettings change through', async () => { - const store = createTestStore() - await store.getState().fetchRepos(catalogOwner(store)) - const setups = store.getState().projectHostSetups - - mockRepos({ ...repo, hookSettings: { mode: 'override', scripts: { setup: '', archive: '' } } }) - await store.getState().fetchRepos(catalogOwner(store)) - - expect(store.getState().projectHostSetups).not.toBe(setups) - expect(store.getState().projectHostSetups[0]?.hookSettings?.mode).toBe('override') - }) - - it('lets a displayName change through on projects', async () => { - const store = createTestStore() - await store.getState().fetchRepos(catalogOwner(store)) - const projects = store.getState().projects - - mockRepos({ ...repo, displayName: 'Renamed' }) - await store.getState().fetchRepos(catalogOwner(store)) - - expect(store.getState().projects).not.toBe(projects) - expect(store.getState().projects[0]?.displayName).toBe('Renamed') - }) - - it('lets an array-field change through', async () => { - const store = createTestStore() - await store.getState().fetchRepos(catalogOwner(store)) - const repos = store.getState().repos - - mockRepos({ ...repo, symlinkPaths: ['.env'] }) - await store.getState().fetchRepos(catalogOwner(store)) - - expect(store.getState().repos).not.toBe(repos) - expect(store.getState().repos[0]?.symlinkPaths).toEqual(['.env']) - }) - - it('lets a nested gitRemoteIdentity change through', async () => { - const store = createTestStore() - await store.getState().fetchRepos(catalogOwner(store)) - const projects = store.getState().projects - - mockRepos({ - ...repo, - gitRemoteIdentity: { ...repo.gitRemoteIdentity!, remoteName: 'upstream' } - }) - await store.getState().fetchRepos(catalogOwner(store)) - - expect(store.getState().projects).not.toBe(projects) - expect(store.getState().projects[0]?.gitRemoteIdentity?.remoteName).toBe('upstream') - }) - - it('treats clearing localWindowsRuntimePreference as a change', async () => { - const store = createTestStore() - await store.getState().fetchRepos(catalogOwner(store)) - const projectId = store.getState().projects[0]!.id - const withPreference: Project = { - ...store.getState().projects[0]!, - localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } - } - store.setState({ projects: [withPreference] }) - - await store.getState().fetchRepos(catalogOwner(store)) - - // Why: a local-host refresh is authoritative; an absent key must not read as unchanged. - expect(store.getState().projects[0]).not.toBe(withPreference) - expect(store.getState().projects[0]?.id).toBe(projectId) - expect(store.getState().projects[0]?.localWindowsRuntimePreference).toBeUndefined() - }) - - it('reuses an unchanged setup element while a sibling changes', async () => { - mockRepos(repo, secondRepo) - const store = createTestStore() - await store.getState().fetchRepos(catalogOwner(store)) - const setups = store.getState().projectHostSetups - expect(setups).toHaveLength(2) - - mockRepos(repo, { ...secondRepo, displayName: 'Repo 2 renamed' }) - await store.getState().fetchRepos(catalogOwner(store)) - - const next = store.getState().projectHostSetups - expect(next).not.toBe(setups) - expect(next[0]).toBe(setups[0]) - expect(next[1]).not.toBe(setups[1]) - }) - - it('does not reuse a setup that moved to a different execution host', async () => { - const store = createTestStore() - await store.getState().fetchRepos(catalogOwner(store)) - const setups = store.getState().projectHostSetups - expect(setups[0]?.hostId).toBe('local') - - // Why: the repo-derived fallback sets setup.id = repo.id, so the same id on a second host is - // the case that would silently splice the wrong host's routing metadata into the row. - mockRepos({ ...repo, executionHostId: 'ssh:host-a', connectionId: 'host-a' }) - await store.getState().fetchRepos(catalogOwner(store)) - - const next = store.getState().projectHostSetups - expect(next[0]).not.toBe(setups[0]) - expect(next[0]?.hostId).toBe('ssh:host-a') - }) - - it('reconciles both setups when one repo id exists on two hosts', async () => { - // Why: the repo-derived fallback sets setup.id = repo.id, so these two setups share an id and - // differ only by host. Keying the reconcile on setup.id instead of the owner key collapses them - // onto one slot and one of the two churns on every refresh. - const sshRepo: Repo = { ...repo, executionHostId: 'ssh:host-a', connectionId: 'host-a' } - mockRepos(repo, sshRepo) - const store = createTestStore() - await store.getState().fetchRepos(catalogOwner(store)) - const setups = store.getState().projectHostSetups - expect(setups).toHaveLength(2) - expect(new Set(setups.map((setup) => setup.id)).size).toBe(1) - - await store.getState().fetchRepos(catalogOwner(store)) - - expect(store.getState().projectHostSetups).toBe(setups) - expect(store.getState().projectHostSetups[0]).toBe(setups[0]) - expect(store.getState().projectHostSetups[1]).toBe(setups[1]) - }) - - it('drops a project and its setup when its repo disappears', async () => { - mockRepos(repo, secondRepo) - const store = createTestStore() - await store.getState().fetchRepos(catalogOwner(store)) - const projects = store.getState().projects - - mockRepos(repo) - await store.getState().fetchRepos(catalogOwner(store)) - - expect(store.getState().projects).not.toBe(projects) - expect(store.getState().projects).toHaveLength(1) - expect(store.getState().projectHostSetups).toHaveLength(1) - }) - - it('keeps a project owned only through a repo on another host', async () => { - // Why: no setup row names this project, so the refreshing host can only be ruled out from the - // project's own source repos. Feeding the host-id resolvers anything but this project's repo - // slice prunes it on a local refresh. - const sshRepo: Repo = { ...secondRepo, executionHostId: 'ssh:host-a', connectionId: 'host-a' } - mockRepos(repo, sshRepo) - const store = createTestStore() - await store.getState().fetchRepos(catalogOwner(store)) - const sshOwned: Project = { - id: 'ssh-owned', - displayName: 'SSH Owned', - badgeColor: '#000000', - sourceRepoIds: [sshRepo.id], - createdAt: 1, - updatedAt: 1 - } - store.setState({ - projects: [...store.getState().projects, sshOwned], - projectHostSetups: [] - }) - - await store.getState().fetchRepos(catalogOwner(store)) - - expect(store.getState().projects.map((project) => project.id)).toContain('ssh-owned') - }) - - it('keeps a project whose repo id is cloned onto a second host', async () => { - // Why: both rows share `repo.id`, so the project's repo slice must carry every duplicate — - // keeping only the first drops the remote host and the local refresh prunes the project. - const sshRepo: Repo = { ...repo, executionHostId: 'ssh:host-a', connectionId: 'host-a' } - mockRepos(repo, sshRepo) - const store = createTestStore() - await store.getState().fetchRepos(catalogOwner(store)) - const dualHostOwned: Project = { - id: 'dual-host-owned', - displayName: 'Dual Host Owned', - badgeColor: '#000000', - sourceRepoIds: [repo.id], - createdAt: 1, - updatedAt: 1 - } - store.setState({ - projects: [...store.getState().projects, dualHostOwned], - projectHostSetups: [] - }) - - await store.getState().fetchRepos(catalogOwner(store)) - - expect(store.getState().projects.map((project) => project.id)).toContain('dual-host-owned') - }) - - it('adds a project and its setup when a repo appears', async () => { - const store = createTestStore() - await store.getState().fetchRepos(catalogOwner(store)) - const projects = store.getState().projects - - mockRepos(repo, secondRepo) - await store.getState().fetchRepos(catalogOwner(store)) - - expect(store.getState().projects).not.toBe(projects) - expect(store.getState().projects).toHaveLength(2) - expect(store.getState().projectHostSetups).toHaveLength(2) - }) - - it('keeps catalog identity when repo.addedAt is 0', async () => { - mockRepos({ ...repo, addedAt: 0 }) - const store = createTestStore() - await store.getState().fetchRepos(catalogOwner(store)) - const projects = store.getState().projects - const setups = store.getState().projectHostSetups - expect(projects).toHaveLength(1) - expect(setups).toHaveLength(1) - expect(projects[0]?.createdAt).toBe(0) - expect(setups[0]?.createdAt).toBe(0) - - await store.getState().fetchRepos(catalogOwner(store)) - - expect(store.getState().projects).toBe(projects) - expect(store.getState().projects[0]).toBe(projects[0]) - expect(store.getState().projectHostSetups).toBe(setups) - expect(store.getState().projectHostSetups[0]).toBe(setups[0]) - }) - - it('keeps catalog identity when repo.addedAt is omitted', async () => { - mockRepos(omitAddedAt(repo)) - const store = createTestStore() - await store.getState().fetchRepos(catalogOwner(store)) - const projects = store.getState().projects - const setups = store.getState().projectHostSetups - expect(projects).toHaveLength(1) - expect(setups).toHaveLength(1) - expect(projects[0]?.createdAt).toBe(0) - expect(setups[0]?.createdAt).toBe(0) - - await store.getState().fetchRepos(catalogOwner(store)) - - expect(store.getState().projects).toBe(projects) - expect(store.getState().projects[0]).toBe(projects[0]) - expect(store.getState().projectHostSetups).toBe(setups) - expect(store.getState().projectHostSetups[0]).toBe(setups[0]) - }) - - it('lets a nested hookSettings change through when repo.addedAt is 0', async () => { - mockRepos({ ...repo, addedAt: 0 }) - const store = createTestStore() - await store.getState().fetchRepos(catalogOwner(store)) - const setups = store.getState().projectHostSetups - - mockRepos({ - ...repo, - addedAt: 0, - hookSettings: { mode: 'override', scripts: { setup: '', archive: '' } } - }) - await store.getState().fetchRepos(catalogOwner(store)) - - expect(store.getState().projectHostSetups).not.toBe(setups) - expect(store.getState().projectHostSetups[0]).not.toBe(setups[0]) - expect(store.getState().projectHostSetups[0]?.hookSettings?.mode).toBe('override') - }) -}) - -describe('repo filter identity across catalog refreshes', () => { - it('keeps the filterRepoIds array when a refetch prunes nothing', async () => { - const store = createTestStore() - store.setState({ filterRepoIds: [repo.id] }) - const first = store.getState().filterRepoIds - - await store.getState().fetchRepos(catalogOwner(store)) - - // Why: App.tsx at the root and five sidebar consumers select this array by identity. - expect(store.getState().filterRepoIds).toBe(first) - }) - - it('still prunes a filtered repo id that no longer exists', async () => { - const store = createTestStore() - store.setState({ filterRepoIds: [repo.id, 'gone'] }) - - await store.getState().fetchRepos(catalogOwner(store)) - - expect(store.getState().filterRepoIds).toEqual([repo.id]) - }) - - it('prunes only the vanished id and reallocates', async () => { - mockRepos(repo) - const store = createTestStore() - store.setState({ filterRepoIds: [repo.id, secondRepo.id] }) - const first = store.getState().filterRepoIds - - await store.getState().fetchRepos(catalogOwner(store)) - - expect(store.getState().filterRepoIds).toEqual([repo.id]) - expect(store.getState().filterRepoIds).not.toBe(first) - }) - - it('keeps a filtered id whose repo merely moved to another host', async () => { - const store = createTestStore() - store.setState({ filterRepoIds: [repo.id] }) - const first = store.getState().filterRepoIds - - // Why: the filter is keyed on repo id, not host identity — a rehomed repo is not pruned. - mockRepos({ ...repo, executionHostId: 'ssh:host-a', connectionId: 'host-a' }) - await store.getState().fetchRepos(catalogOwner(store)) - - expect(store.getState().filterRepoIds).toBe(first) - }) -}) -describe('setup-script dismissal identity across catalog refreshes', () => { - it('keeps the dismissal array when a refetch prunes nothing', async () => { - const store = createTestStore() - store.setState({ - setupScriptPromptDismissedRepoIds: [ - getSetupScriptPromptDismissalKey(getRepoHostIdentityForParts(repo.id, 'local')) - ] - }) - const first = store.getState().setupScriptPromptDismissedRepoIds - - await store.getState().fetchRepos(catalogOwner(store)) - - // Why: SetupScriptPromptCard Object.is-subscribes to this array. A no-op - // catalog refresh must not allocate just because the helper rebuilt next. - expect(store.getState().setupScriptPromptDismissedRepoIds).toBe(first) - }) -}) - -describe('SSH readoption catalog identity', () => { - it('keeps projects and host setups across a no-op recordSshRepoReadoptions([])', async () => { - const store = createTestStore() - await store.getState().fetchRepos(catalogOwner(store)) - const before = store.getState() - const projects = before.projects - const setups = before.projectHostSetups - expect(projects).toHaveLength(1) - expect(setups).toHaveLength(1) - - store.getState().recordSshRepoReadoptions([]) - - // Why: the empty-in/empty-pending call must hand the state object back untouched, or the - // freshly allocated pendingSshRepoReadoptions alone would wake every store subscriber. - expect(store.getState()).toBe(before) - expect(store.getState().pendingSshRepoReadoptions).toBe(before.pendingSshRepoReadoptions) - expect(store.getState().projects).toBe(projects) - expect(store.getState().projects[0]).toBe(projects[0]) - expect(store.getState().projectHostSetups).toBe(setups) - expect(store.getState().projectHostSetups[0]).toBe(setups[0]) - }) - - it('keeps catalog identity for a pending-only readoption while pending updates', async () => { - const store = createTestStore() - await store.getState().fetchRepos(catalogOwner(store)) - const projects = store.getState().projects - const setups = store.getState().projectHostSetups - const readoption = { oldTargetId: 'ssh-old', newTargetId: 'ssh-new', repoIds: [repo.id] } - - store.getState().recordSshRepoReadoptions([readoption]) - - expect(store.getState().pendingSshRepoReadoptions).toEqual([readoption]) - expect(store.getState().projects).toBe(projects) - expect(store.getState().projects[0]).toBe(projects[0]) - expect(store.getState().projectHostSetups).toBe(setups) - expect(store.getState().projectHostSetups[0]).toBe(setups[0]) - }) - - it('replaces the moved setup on a real prune/rehome', async () => { - const oldHostRepo: Repo = { - ...repo, - connectionId: 'ssh-old', - executionHostId: 'ssh:ssh-old' - } - const newHostRepo: Repo = { - ...repo, - connectionId: 'ssh-new', - executionHostId: 'ssh:ssh-new' - } - mockRepos(oldHostRepo, newHostRepo) - const store = createTestStore() - await store.getState().fetchRepos(catalogOwner(store)) - const setups = store.getState().projectHostSetups - expect(setups).toHaveLength(2) - const oldSetup = setups.find((setup) => setup.hostId === 'ssh:ssh-old') - const newSetup = setups.find((setup) => setup.hostId === 'ssh:ssh-new') - expect(oldSetup).toBeDefined() - expect(newSetup).toBeDefined() - - store - .getState() - .recordSshRepoReadoptions([ - { oldTargetId: 'ssh-old', newTargetId: 'ssh-new', repoIds: [repo.id] } - ]) - - const next = store.getState().projectHostSetups - expect(next).not.toBe(setups) - expect(next).toHaveLength(1) - expect(next[0]).not.toBe(oldSetup) - expect(next[0]?.hostId).toBe('ssh:ssh-new') - expect(store.getState().repos).toHaveLength(1) - expect(store.getState().repos[0]?.executionHostId).toBe('ssh:ssh-new') - expect(store.getState().pendingSshRepoReadoptions).toEqual([]) - }) -}) - -// The catalog these tests read is the one the focused host would have shown. -function catalogOwner(store: { - getState: () => { settings: { activeRuntimeEnvironmentId?: string | null } | null } -}): { runtimeEnvironmentId: string | null } { - return { runtimeEnvironmentId: store.getState().settings?.activeRuntimeEnvironmentId ?? null } -} diff --git a/src/renderer/src/store/slices/repos-remove-project-purge-leak.spec.ts b/src/renderer/src/store/slices/repos-remove-project-purge-leak.spec.ts new file mode 100644 index 00000000000..165bd81195a --- /dev/null +++ b/src/renderer/src/store/slices/repos-remove-project-purge-leak.spec.ts @@ -0,0 +1,108 @@ +/** + * Memory-leak regression: removing a project must purge its worktrees' per-worktree + * state. + * + * `removeProject` hand-deleted only a small subset of per-worktree maps + * (tabsByWorktree, terminalLayoutsByTabId, ptyIdsByTabId, …) and never called the + * canonical `purgeWorktreeTerminalState` / `buildWorktreePurgeState`. So ~30+ + * worktree-scoped maps (unifiedTabsByWorktree, groupsByWorktree, layoutByWorktree, + * gitStatusByWorktree, gitStatusHugeByWorktree, browserTabsByWorktree, + * everActivatedWorktreeIds, …) kept an entry for every worktree of every removed + * project. No background reaper recovers them (fetchWorktrees never runs again for a + * removed repo). Repos churn forever and each can own many worktrees, so the state + * grew monotonically. The single `removeWorktree` path already routes through + * `purgeWorktreeTerminalState`; project removal did not. + */ +import { getRepoExecutionHostId } from '../../../../shared/execution-host' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { createTestStore, makeWorktree } from './store-test-helpers' +import type { Repo } from '../../../../shared/repo-types' + +export function registerReposRemoveProjectPurgeLeakTests(): void { + const repo1: Repo = { + id: 'repo-1', + path: '/r1', + displayName: 'R1', + badgeColor: '#000', + addedAt: 1 + } + const repo2: Repo = { + id: 'repo-2', + path: '/r2', + displayName: 'R2', + badgeColor: '#111', + addedAt: 2 + } + + const reposRemove = vi.fn().mockResolvedValue(undefined) + const ptyKill = vi.fn() + + beforeEach(() => { + reposRemove.mockReset().mockResolvedValue(undefined) + ptyKill.mockReset() + vi.stubGlobal('window', { + api: { + repos: { removeForHost: reposRemove }, + pty: { kill: ptyKill }, + runtimeEnvironments: { call: vi.fn() } + } + }) + }) + + const W1 = 'repo-1::/r1/wt1' + const W2 = 'repo-2::/r2/wt1' + + function seedTwoProjects(store: ReturnType): void { + store.setState({ + repos: [repo1, repo2], + worktreesByRepo: { + [repo1.id]: [makeWorktree({ id: W1, repoId: repo1.id, path: '/r1/wt1' })], + [repo2.id]: [makeWorktree({ id: W2, repoId: repo2.id, path: '/r2/wt1' })] + }, + // Per-worktree maps that removeProject previously left behind. + unifiedTabsByWorktree: { [W1]: [], [W2]: [] }, + groupsByWorktree: { [W1]: [], [W2]: [] }, + browserTabsByWorktree: { [W1]: [], [W2]: [] }, + gitStatusHugeByWorktree: { [W1]: { limit: 1000 }, [W2]: { limit: 2000 } }, + everActivatedWorktreeIds: new Set([W1, W2]), + localDetectedAgentIdsByContext: { + 'repo-1:windows-host': ['claude'], + 'repo-2:windows-host': ['codex'] + } + }) + } + + describe('removeProject purges per-worktree state (leak regression)', () => { + it('drops every per-worktree map entry for the removed project', async () => { + const store = createTestStore() + seedTwoProjects(store) + + await store.getState().removeProject(repo1.id, { hostId: getRepoExecutionHostId(repo1) }) + + const s = store.getState() + // Removed project's worktree is purged from every map. + expect(s.unifiedTabsByWorktree[W1]).toBeUndefined() + expect(s.groupsByWorktree[W1]).toBeUndefined() + expect(s.browserTabsByWorktree[W1]).toBeUndefined() + expect(s.gitStatusHugeByWorktree[W1]).toBeUndefined() + expect(s.everActivatedWorktreeIds.has(W1)).toBe(false) + expect(s.localDetectedAgentIdsByContext['repo-1:windows-host']).toBeUndefined() + }) + + it('keeps per-worktree state for projects that are NOT removed', async () => { + const store = createTestStore() + seedTwoProjects(store) + + await store.getState().removeProject(repo1.id, { hostId: getRepoExecutionHostId(repo1) }) + + const s = store.getState() + // Surviving project's worktree state is untouched (guard over-eviction). + expect(s.unifiedTabsByWorktree[W2]).toBeDefined() + expect(s.groupsByWorktree[W2]).toBeDefined() + expect(s.browserTabsByWorktree[W2]).toBeDefined() + expect(s.gitStatusHugeByWorktree[W2]).toEqual({ limit: 2000 }) + expect(s.everActivatedWorktreeIds.has(W2)).toBe(true) + expect(s.localDetectedAgentIdsByContext['repo-2:windows-host']).toEqual(['codex']) + }) + }) +} diff --git a/src/renderer/src/store/slices/repos-remove-project-purge-leak.test.ts b/src/renderer/src/store/slices/repos-remove-project-purge-leak.test.ts deleted file mode 100644 index 551e4363ee7..00000000000 --- a/src/renderer/src/store/slices/repos-remove-project-purge-leak.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -/** - * Memory-leak regression: removing a project must purge its worktrees' per-worktree - * state. - * - * `removeProject` hand-deleted only a small subset of per-worktree maps - * (tabsByWorktree, terminalLayoutsByTabId, ptyIdsByTabId, …) and never called the - * canonical `purgeWorktreeTerminalState` / `buildWorktreePurgeState`. So ~30+ - * worktree-scoped maps (unifiedTabsByWorktree, groupsByWorktree, layoutByWorktree, - * gitStatusByWorktree, gitStatusHugeByWorktree, browserTabsByWorktree, - * everActivatedWorktreeIds, …) kept an entry for every worktree of every removed - * project. No background reaper recovers them (fetchWorktrees never runs again for a - * removed repo). Repos churn forever and each can own many worktrees, so the state - * grew monotonically. The single `removeWorktree` path already routes through - * `purgeWorktreeTerminalState`; project removal did not. - */ -import { getRepoExecutionHostId } from '../../../../shared/execution-host' -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { createTestStore, makeWorktree } from './store-test-helpers' -import type { Repo } from '../../../../shared/repo-types' - -const repo1: Repo = { id: 'repo-1', path: '/r1', displayName: 'R1', badgeColor: '#000', addedAt: 1 } -const repo2: Repo = { id: 'repo-2', path: '/r2', displayName: 'R2', badgeColor: '#111', addedAt: 2 } - -const reposRemove = vi.fn().mockResolvedValue(undefined) -const ptyKill = vi.fn() - -beforeEach(() => { - reposRemove.mockReset().mockResolvedValue(undefined) - ptyKill.mockReset() - vi.stubGlobal('window', { - api: { - repos: { removeForHost: reposRemove }, - pty: { kill: ptyKill }, - runtimeEnvironments: { call: vi.fn() } - } - }) -}) - -const W1 = 'repo-1::/r1/wt1' -const W2 = 'repo-2::/r2/wt1' - -function seedTwoProjects(store: ReturnType): void { - store.setState({ - repos: [repo1, repo2], - worktreesByRepo: { - [repo1.id]: [makeWorktree({ id: W1, repoId: repo1.id, path: '/r1/wt1' })], - [repo2.id]: [makeWorktree({ id: W2, repoId: repo2.id, path: '/r2/wt1' })] - }, - // Per-worktree maps that removeProject previously left behind. - unifiedTabsByWorktree: { [W1]: [], [W2]: [] }, - groupsByWorktree: { [W1]: [], [W2]: [] }, - browserTabsByWorktree: { [W1]: [], [W2]: [] }, - gitStatusHugeByWorktree: { [W1]: { limit: 1000 }, [W2]: { limit: 2000 } }, - everActivatedWorktreeIds: new Set([W1, W2]), - localDetectedAgentIdsByContext: { - 'repo-1:windows-host': ['claude'], - 'repo-2:windows-host': ['codex'] - } - }) -} - -describe('removeProject purges per-worktree state (leak regression)', () => { - it('drops every per-worktree map entry for the removed project', async () => { - const store = createTestStore() - seedTwoProjects(store) - - await store.getState().removeProject(repo1.id, { hostId: getRepoExecutionHostId(repo1) }) - - const s = store.getState() - // Removed project's worktree is purged from every map. - expect(s.unifiedTabsByWorktree[W1]).toBeUndefined() - expect(s.groupsByWorktree[W1]).toBeUndefined() - expect(s.browserTabsByWorktree[W1]).toBeUndefined() - expect(s.gitStatusHugeByWorktree[W1]).toBeUndefined() - expect(s.everActivatedWorktreeIds.has(W1)).toBe(false) - expect(s.localDetectedAgentIdsByContext['repo-1:windows-host']).toBeUndefined() - }) - - it('keeps per-worktree state for projects that are NOT removed', async () => { - const store = createTestStore() - seedTwoProjects(store) - - await store.getState().removeProject(repo1.id, { hostId: getRepoExecutionHostId(repo1) }) - - const s = store.getState() - // Surviving project's worktree state is untouched (guard over-eviction). - expect(s.unifiedTabsByWorktree[W2]).toBeDefined() - expect(s.groupsByWorktree[W2]).toBeDefined() - expect(s.browserTabsByWorktree[W2]).toBeDefined() - expect(s.gitStatusHugeByWorktree[W2]).toEqual({ limit: 2000 }) - expect(s.everActivatedWorktreeIds.has(W2)).toBe(true) - expect(s.localDetectedAgentIdsByContext['repo-2:windows-host']).toEqual(['codex']) - }) -}) diff --git a/src/renderer/src/store/slices/repos-runtime-visibility-defaults.spec.ts b/src/renderer/src/store/slices/repos-runtime-visibility-defaults.spec.ts new file mode 100644 index 00000000000..2cf37c427e5 --- /dev/null +++ b/src/renderer/src/store/slices/repos-runtime-visibility-defaults.spec.ts @@ -0,0 +1,138 @@ +import { expect, it, vi } from 'vitest' +import { + MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, + RUNTIME_PROTOCOL_VERSION, + WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY +} from '../../../../shared/protocol-version' +import { clearRuntimeCompatibilityCacheForTests } from '@/runtime/runtime-rpc-client' +import { createTestStore } from './store-test-helpers' +import { stubRuntimeEnvironmentBridge } from '../../runtime/runtime-environment-bridge-fixture' + +export function registerReposRuntimeVisibilityDefaultsTests(): void { + it('hydrates a runtime owner default when refreshing its repositories', async () => { + clearRuntimeCompatibilityCacheForTests() + stubRuntimeEnvironmentBridge({ + call: vi.fn(({ method }: { method: string }) => { + const result = + method === 'status.get' + ? { + runtimeId: 'runtime-1', + graphStatus: 'ready', + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, + capabilities: [WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY] + } + : method === 'repo.list' + ? { repos: [] } + : method === 'settings.get' + ? { settings: { worktreeVisibilityDefaults: { external: 'show' } } } + : method === 'project.list' + ? { projects: [] } + : { setups: [] } + return Promise.resolve({ + ok: true, + result, + _meta: { runtimeId: 'runtime-1' } + }) + }) + }) + const store = createTestStore() + store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) + + await store.getState().fetchRuntimeEnvironmentRepos('env-1') + + expect(store.getState().worktreeVisibilityDefaultsByHost['runtime:env-1']).toEqual({ + external: 'show' + }) + expect(store.getState().settings?.worktreeVisibilityDefaults).toEqual({ external: 'show' }) + expect(store.getState().worktreeVisibilityDefaultsSupportedRuntimeEnvironmentId).toBe('env-1') + expect(store.getState().worktreeVisibilitySourceDefaultsSupportedRuntimeEnvironmentId).toBe( + 'env-1' + ) + }) + + it('clears focused support when a runtime omits the default', async () => { + clearRuntimeCompatibilityCacheForTests() + stubRuntimeEnvironmentBridge({ + call: vi.fn(({ method }: { method: string }) => + Promise.resolve({ + ok: true, + result: + method === 'status.get' + ? { + runtimeId: 'runtime-1', + graphStatus: 'ready', + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION + } + : method === 'repo.list' + ? { repos: [] } + : { settings: {} }, + _meta: { runtimeId: 'runtime-1' } + }) + ) + }) + const store = createTestStore() + store.setState({ + settings: { + activeRuntimeEnvironmentId: 'env-1', + worktreeVisibilityDefaults: { external: 'show' } + } as never, + worktreeVisibilityDefaultsSupportedRuntimeEnvironmentId: 'env-1', + worktreeVisibilitySourceDefaultsSupportedRuntimeEnvironmentId: 'env-1' + }) + + await store.getState().fetchRuntimeEnvironmentRepos('env-1') + + expect(store.getState().worktreeVisibilityDefaultsByHost['runtime:env-1']).toBeNull() + expect(store.getState().settings?.worktreeVisibilityDefaults).toBeUndefined() + expect(store.getState().worktreeVisibilityDefaultsSupportedRuntimeEnvironmentId).toBeNull() + expect( + store.getState().worktreeVisibilitySourceDefaultsSupportedRuntimeEnvironmentId + ).toBeNull() + }) + + it('preserves focused support when runtime default hydration fails', async () => { + clearRuntimeCompatibilityCacheForTests() + stubRuntimeEnvironmentBridge({ + call: vi.fn(({ method }: { method: string }) => + method === 'settings.get' + ? Promise.reject(new Error('offline')) + : Promise.resolve({ + ok: true, + result: + method === 'status.get' + ? { + runtimeId: 'runtime-1', + graphStatus: 'ready', + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION + } + : { repos: [] }, + _meta: { runtimeId: 'runtime-1' } + }) + ) + }) + const store = createTestStore() + store.setState({ + settings: { + activeRuntimeEnvironmentId: 'env-1', + worktreeVisibilityDefaults: { external: 'show' } + } as never, + worktreeVisibilityDefaultsByHost: { 'runtime:env-1': { external: 'show' } }, + worktreeVisibilityDefaultsSupportedRuntimeEnvironmentId: 'env-1', + worktreeVisibilitySourceDefaultsSupportedRuntimeEnvironmentId: 'env-1' + }) + + await store.getState().fetchRuntimeEnvironmentRepos('env-1') + + expect(store.getState().worktreeVisibilityDefaultsByHost['runtime:env-1']).toEqual({ + external: 'show' + }) + expect(store.getState().settings?.worktreeVisibilityDefaults).toEqual({ external: 'show' }) + expect(store.getState().worktreeVisibilityDefaultsSupportedRuntimeEnvironmentId).toBe('env-1') + expect(store.getState().worktreeVisibilitySourceDefaultsSupportedRuntimeEnvironmentId).toBe( + 'env-1' + ) + }) +} diff --git a/src/renderer/src/store/slices/repos-runtime-visibility-defaults.test.ts b/src/renderer/src/store/slices/repos-runtime-visibility-defaults.test.ts deleted file mode 100644 index 13efa6daf41..00000000000 --- a/src/renderer/src/store/slices/repos-runtime-visibility-defaults.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { expect, it, vi } from 'vitest' -import { - MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, - RUNTIME_PROTOCOL_VERSION, - WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY -} from '../../../../shared/protocol-version' -import { clearRuntimeCompatibilityCacheForTests } from '@/runtime/runtime-rpc-client' -import { createTestStore } from './store-test-helpers' -import { stubRuntimeEnvironmentBridge } from '../../runtime/runtime-environment-bridge-fixture' - -it('hydrates a runtime owner default when refreshing its repositories', async () => { - clearRuntimeCompatibilityCacheForTests() - stubRuntimeEnvironmentBridge({ - call: vi.fn(({ method }: { method: string }) => { - const result = - method === 'status.get' - ? { - runtimeId: 'runtime-1', - graphStatus: 'ready', - runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, - minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, - capabilities: [WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY] - } - : method === 'repo.list' - ? { repos: [] } - : method === 'settings.get' - ? { settings: { worktreeVisibilityDefaults: { external: 'show' } } } - : method === 'project.list' - ? { projects: [] } - : { setups: [] } - return Promise.resolve({ - ok: true, - result, - _meta: { runtimeId: 'runtime-1' } - }) - }) - }) - const store = createTestStore() - store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) - - await store.getState().fetchRuntimeEnvironmentRepos('env-1') - - expect(store.getState().worktreeVisibilityDefaultsByHost['runtime:env-1']).toEqual({ - external: 'show' - }) - expect(store.getState().settings?.worktreeVisibilityDefaults).toEqual({ external: 'show' }) - expect(store.getState().worktreeVisibilityDefaultsSupportedRuntimeEnvironmentId).toBe('env-1') - expect(store.getState().worktreeVisibilitySourceDefaultsSupportedRuntimeEnvironmentId).toBe( - 'env-1' - ) -}) - -it('clears focused support when a runtime omits the default', async () => { - clearRuntimeCompatibilityCacheForTests() - stubRuntimeEnvironmentBridge({ - call: vi.fn(({ method }: { method: string }) => - Promise.resolve({ - ok: true, - result: - method === 'status.get' - ? { - runtimeId: 'runtime-1', - graphStatus: 'ready', - runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, - minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION - } - : method === 'repo.list' - ? { repos: [] } - : { settings: {} }, - _meta: { runtimeId: 'runtime-1' } - }) - ) - }) - const store = createTestStore() - store.setState({ - settings: { - activeRuntimeEnvironmentId: 'env-1', - worktreeVisibilityDefaults: { external: 'show' } - } as never, - worktreeVisibilityDefaultsSupportedRuntimeEnvironmentId: 'env-1', - worktreeVisibilitySourceDefaultsSupportedRuntimeEnvironmentId: 'env-1' - }) - - await store.getState().fetchRuntimeEnvironmentRepos('env-1') - - expect(store.getState().worktreeVisibilityDefaultsByHost['runtime:env-1']).toBeNull() - expect(store.getState().settings?.worktreeVisibilityDefaults).toBeUndefined() - expect(store.getState().worktreeVisibilityDefaultsSupportedRuntimeEnvironmentId).toBeNull() - expect(store.getState().worktreeVisibilitySourceDefaultsSupportedRuntimeEnvironmentId).toBeNull() -}) - -it('preserves focused support when runtime default hydration fails', async () => { - clearRuntimeCompatibilityCacheForTests() - stubRuntimeEnvironmentBridge({ - call: vi.fn(({ method }: { method: string }) => - method === 'settings.get' - ? Promise.reject(new Error('offline')) - : Promise.resolve({ - ok: true, - result: - method === 'status.get' - ? { - runtimeId: 'runtime-1', - graphStatus: 'ready', - runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, - minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION - } - : { repos: [] }, - _meta: { runtimeId: 'runtime-1' } - }) - ) - }) - const store = createTestStore() - store.setState({ - settings: { - activeRuntimeEnvironmentId: 'env-1', - worktreeVisibilityDefaults: { external: 'show' } - } as never, - worktreeVisibilityDefaultsByHost: { 'runtime:env-1': { external: 'show' } }, - worktreeVisibilityDefaultsSupportedRuntimeEnvironmentId: 'env-1', - worktreeVisibilitySourceDefaultsSupportedRuntimeEnvironmentId: 'env-1' - }) - - await store.getState().fetchRuntimeEnvironmentRepos('env-1') - - expect(store.getState().worktreeVisibilityDefaultsByHost['runtime:env-1']).toEqual({ - external: 'show' - }) - expect(store.getState().settings?.worktreeVisibilityDefaults).toEqual({ external: 'show' }) - expect(store.getState().worktreeVisibilityDefaultsSupportedRuntimeEnvironmentId).toBe('env-1') - expect(store.getState().worktreeVisibilitySourceDefaultsSupportedRuntimeEnvironmentId).toBe( - 'env-1' - ) -}) diff --git a/src/renderer/src/store/slices/repos-shared-project-badge-color.spec.ts b/src/renderer/src/store/slices/repos-shared-project-badge-color.spec.ts new file mode 100644 index 00000000000..8f3222b9c77 --- /dev/null +++ b/src/renderer/src/store/slices/repos-shared-project-badge-color.spec.ts @@ -0,0 +1,186 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createTestStore } from './store-test-helpers' +import type { Project, ProjectHostSetup } from '../../../../shared/project-types' +import type { Repo } from '../../../../shared/repo-types' +import { + createCompatibleRuntimeStatusResponseIfNeeded, + type RuntimeEnvironmentCallRequest +} from '../../runtime/runtime-compatibility-test-fixture' +import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' + +export function registerReposSharedProjectBadgeColorTests(): void { + // Mirrors the real report: one project name ("orca") set up on the local Mac and on a + // remote Orca server, where only the local repo row carries the user's chosen color. + const SHARED_PROJECT_ID = 'github:stablyai/orca' + const LOCAL_GREEN = '#22c55e' + const REMOTE_NEUTRAL = '#737373' + + const localRepo: Repo = { + id: 'local-repo', + path: '/local/orca', + displayName: 'orca', + badgeColor: LOCAL_GREEN, + upstream: { owner: 'stablyai', repo: 'orca' }, + addedAt: 1 + } + + const remoteRepo: Repo = { + id: 'remote-repo', + path: '/srv/orca', + displayName: 'orca', + badgeColor: REMOTE_NEUTRAL, + upstream: { owner: 'stablyai', repo: 'orca' }, + addedAt: 1 + } + + const localProject: Project = { + id: SHARED_PROJECT_ID, + displayName: 'orca', + badgeColor: LOCAL_GREEN, + sourceRepoIds: ['local-repo'], + createdAt: 1, + updatedAt: 1 + } + + const remoteProject: Project = { + id: SHARED_PROJECT_ID, + displayName: 'orca', + badgeColor: REMOTE_NEUTRAL, + sourceRepoIds: ['remote-repo'], + createdAt: 2, + updatedAt: 2 + } + + function setup(projectId: string, repoId: string, path: string): ProjectHostSetup { + return { + id: `${repoId}-setup`, + projectId, + hostId: 'local', + repoId, + path, + displayName: 'orca', + setupState: 'ready', + setupMethod: 'imported-existing-folder', + createdAt: 1, + updatedAt: 1 + } + } + + const reposList = vi.fn() + const reposUpdate = vi.fn() + const projectsList = vi.fn() + const listHostSetups = vi.fn() + const runtimeEnvironmentsList = vi.fn() + const runtimeEnvironmentCall = vi.fn() + const runtimeEnvironmentTransportCall = vi.fn() + + function runtimeResult(method: string): unknown { + if (method === 'repo.list') { + return { repos: [remoteRepo] } + } + if (method === 'project.list') { + return { projects: [remoteProject] } + } + if (method === 'projectHostSetup.list') { + return { setups: [setup(SHARED_PROJECT_ID, 'remote-repo', '/srv/orca')] } + } + return {} + } + + beforeEach(() => { + clearRuntimeCompatibilityCacheForTests() + reposList.mockReset() + reposUpdate.mockReset() + projectsList.mockReset() + listHostSetups.mockReset() + runtimeEnvironmentsList.mockReset() + runtimeEnvironmentCall.mockReset() + runtimeEnvironmentTransportCall.mockReset() + + reposList.mockResolvedValue([localRepo]) + reposUpdate.mockImplementation(({ updates }: { updates: Partial }) => ({ + ...localRepo, + ...updates + })) + projectsList.mockResolvedValue([localProject]) + listHostSetups.mockResolvedValue([setup(SHARED_PROJECT_ID, 'local-repo', '/local/orca')]) + runtimeEnvironmentsList.mockResolvedValue([{ id: 'env-1', name: 'awin' }]) + runtimeEnvironmentCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => ({ + id: `rpc-${args.method}`, + ok: true, + result: runtimeResult(args.method), + _meta: { runtimeId: 'runtime-remote' } + })) + runtimeEnvironmentTransportCall.mockImplementation( + (args: RuntimeEnvironmentCallRequest) => + createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args) + ) + + vi.stubGlobal('window', { + api: { + repos: { list: reposList, update: reposUpdate }, + projects: { list: projectsList, listHostSetups }, + projectGroups: { list: vi.fn().mockResolvedValue([]) }, + folderWorkspaces: { list: vi.fn().mockResolvedValue([]) }, + runtimeEnvironments: { + call: runtimeEnvironmentTransportCall, + list: runtimeEnvironmentsList + } + }, + dispatchEvent: vi.fn() + }) + }) + + function badgeColor(store: ReturnType): string | undefined { + return store.getState().projects.find((project) => project.id === SHARED_PROJECT_ID)?.badgeColor + } + + describe('shared project badge color across hosts', () => { + it('keeps the local color when a remote host shares the project name', async () => { + const store = createTestStore() + store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) + + await store.getState().fetchReposForAllHosts() + + expect(badgeColor(store)).toBe(LOCAL_GREEN) + }) + + it('keeps the local color when a runtime-only refresh lands after startup', async () => { + const store = createTestStore() + store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) + + await store.getState().fetchReposForAllHosts() + await store.getState().fetchRuntimeEnvironmentRepos('env-1') + + expect(badgeColor(store)).toBe(LOCAL_GREEN) + }) + + it('reflects a recolor of the local repo even while the remote host stays connected', async () => { + const store = createTestStore() + store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) + + await store.getState().fetchReposForAllHosts() + const recolored = '#ec4899' + await store + .getState() + .updateRepo('local-repo', { badgeColor: recolored }, { hostId: 'local' }) + + expect(store.getState().repos.find((repo) => repo.id === 'local-repo')?.badgeColor).toBe( + recolored + ) + expect(badgeColor(store)).toBe(recolored) + }) + + it('leaves a remote-only project showing its own host color', async () => { + reposList.mockResolvedValue([]) + projectsList.mockResolvedValue([]) + listHostSetups.mockResolvedValue([]) + const store = createTestStore() + store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) + + await store.getState().fetchReposForAllHosts() + + expect(badgeColor(store)).toBe(REMOTE_NEUTRAL) + }) + }) +} diff --git a/src/renderer/src/store/slices/repos-shared-project-badge-color.test.ts b/src/renderer/src/store/slices/repos-shared-project-badge-color.test.ts deleted file mode 100644 index bc143733556..00000000000 --- a/src/renderer/src/store/slices/repos-shared-project-badge-color.test.ts +++ /dev/null @@ -1,182 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { createTestStore } from './store-test-helpers' -import type { Project, ProjectHostSetup } from '../../../../shared/project-types' -import type { Repo } from '../../../../shared/repo-types' -import { - createCompatibleRuntimeStatusResponseIfNeeded, - type RuntimeEnvironmentCallRequest -} from '../../runtime/runtime-compatibility-test-fixture' -import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' - -// Mirrors the real report: one project name ("orca") set up on the local Mac and on a -// remote Orca server, where only the local repo row carries the user's chosen color. -const SHARED_PROJECT_ID = 'github:stablyai/orca' -const LOCAL_GREEN = '#22c55e' -const REMOTE_NEUTRAL = '#737373' - -const localRepo: Repo = { - id: 'local-repo', - path: '/local/orca', - displayName: 'orca', - badgeColor: LOCAL_GREEN, - upstream: { owner: 'stablyai', repo: 'orca' }, - addedAt: 1 -} - -const remoteRepo: Repo = { - id: 'remote-repo', - path: '/srv/orca', - displayName: 'orca', - badgeColor: REMOTE_NEUTRAL, - upstream: { owner: 'stablyai', repo: 'orca' }, - addedAt: 1 -} - -const localProject: Project = { - id: SHARED_PROJECT_ID, - displayName: 'orca', - badgeColor: LOCAL_GREEN, - sourceRepoIds: ['local-repo'], - createdAt: 1, - updatedAt: 1 -} - -const remoteProject: Project = { - id: SHARED_PROJECT_ID, - displayName: 'orca', - badgeColor: REMOTE_NEUTRAL, - sourceRepoIds: ['remote-repo'], - createdAt: 2, - updatedAt: 2 -} - -function setup(projectId: string, repoId: string, path: string): ProjectHostSetup { - return { - id: `${repoId}-setup`, - projectId, - hostId: 'local', - repoId, - path, - displayName: 'orca', - setupState: 'ready', - setupMethod: 'imported-existing-folder', - createdAt: 1, - updatedAt: 1 - } -} - -const reposList = vi.fn() -const reposUpdate = vi.fn() -const projectsList = vi.fn() -const listHostSetups = vi.fn() -const runtimeEnvironmentsList = vi.fn() -const runtimeEnvironmentCall = vi.fn() -const runtimeEnvironmentTransportCall = vi.fn() - -function runtimeResult(method: string): unknown { - if (method === 'repo.list') { - return { repos: [remoteRepo] } - } - if (method === 'project.list') { - return { projects: [remoteProject] } - } - if (method === 'projectHostSetup.list') { - return { setups: [setup(SHARED_PROJECT_ID, 'remote-repo', '/srv/orca')] } - } - return {} -} - -beforeEach(() => { - clearRuntimeCompatibilityCacheForTests() - reposList.mockReset() - reposUpdate.mockReset() - projectsList.mockReset() - listHostSetups.mockReset() - runtimeEnvironmentsList.mockReset() - runtimeEnvironmentCall.mockReset() - runtimeEnvironmentTransportCall.mockReset() - - reposList.mockResolvedValue([localRepo]) - reposUpdate.mockImplementation(({ updates }: { updates: Partial }) => ({ - ...localRepo, - ...updates - })) - projectsList.mockResolvedValue([localProject]) - listHostSetups.mockResolvedValue([setup(SHARED_PROJECT_ID, 'local-repo', '/local/orca')]) - runtimeEnvironmentsList.mockResolvedValue([{ id: 'env-1', name: 'awin' }]) - runtimeEnvironmentCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => ({ - id: `rpc-${args.method}`, - ok: true, - result: runtimeResult(args.method), - _meta: { runtimeId: 'runtime-remote' } - })) - runtimeEnvironmentTransportCall.mockImplementation( - (args: RuntimeEnvironmentCallRequest) => - createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args) - ) - - vi.stubGlobal('window', { - api: { - repos: { list: reposList, update: reposUpdate }, - projects: { list: projectsList, listHostSetups }, - projectGroups: { list: vi.fn().mockResolvedValue([]) }, - folderWorkspaces: { list: vi.fn().mockResolvedValue([]) }, - runtimeEnvironments: { - call: runtimeEnvironmentTransportCall, - list: runtimeEnvironmentsList - } - }, - dispatchEvent: vi.fn() - }) -}) - -function badgeColor(store: ReturnType): string | undefined { - return store.getState().projects.find((project) => project.id === SHARED_PROJECT_ID)?.badgeColor -} - -describe('shared project badge color across hosts', () => { - it('keeps the local color when a remote host shares the project name', async () => { - const store = createTestStore() - store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) - - await store.getState().fetchReposForAllHosts() - - expect(badgeColor(store)).toBe(LOCAL_GREEN) - }) - - it('keeps the local color when a runtime-only refresh lands after startup', async () => { - const store = createTestStore() - store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) - - await store.getState().fetchReposForAllHosts() - await store.getState().fetchRuntimeEnvironmentRepos('env-1') - - expect(badgeColor(store)).toBe(LOCAL_GREEN) - }) - - it('reflects a recolor of the local repo even while the remote host stays connected', async () => { - const store = createTestStore() - store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) - - await store.getState().fetchReposForAllHosts() - const recolored = '#ec4899' - await store.getState().updateRepo('local-repo', { badgeColor: recolored }, { hostId: 'local' }) - - expect(store.getState().repos.find((repo) => repo.id === 'local-repo')?.badgeColor).toBe( - recolored - ) - expect(badgeColor(store)).toBe(recolored) - }) - - it('leaves a remote-only project showing its own host color', async () => { - reposList.mockResolvedValue([]) - projectsList.mockResolvedValue([]) - listHostSetups.mockResolvedValue([]) - const store = createTestStore() - store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) - - await store.getState().fetchReposForAllHosts() - - expect(badgeColor(store)).toBe(REMOTE_NEUTRAL) - }) -}) diff --git a/src/renderer/src/store/slices/repos-stale-fetch.spec.ts b/src/renderer/src/store/slices/repos-stale-fetch.spec.ts new file mode 100644 index 00000000000..ac1105f2689 --- /dev/null +++ b/src/renderer/src/store/slices/repos-stale-fetch.spec.ts @@ -0,0 +1,235 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest' +import { getRepoExecutionHostId } from '../../../../shared/execution-host' +import { createTestStore, makeLayout, makeTab } from './store-test-helpers' +import type { Repo } from '../../../../shared/repo-types' +import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types' +import { getDefaultWorkspaceSession } from '../../../../shared/constants' +import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' + +export function registerReposStaleFetchTests(): void { + const localRepo: Repo = { + id: 'local-repo', + path: '/local', + displayName: 'Local', + badgeColor: '#000', + addedAt: 1 + } + + const remoteRepo: Repo = { + id: 'remote-repo', + path: '/remote', + displayName: 'Remote', + badgeColor: '#111', + addedAt: 2 + } + + const reposList = vi.fn() + const promotionWorktreeId = 'local-repo::/local' + const promotionTabId = 'restored-tab' + const promotionPtyId = 'original-daemon-pty' + + function makePromotionSession(): WorkspaceSessionState { + return { + ...getDefaultWorkspaceSession(), + activeRepoId: localRepo.id, + activeWorktreeId: promotionWorktreeId, + activeTabId: promotionTabId, + activeWorktreeIdsOnShutdown: [promotionWorktreeId], + tabsByWorktree: { + [promotionWorktreeId]: [ + makeTab({ + id: promotionTabId, + worktreeId: promotionWorktreeId, + ptyId: promotionPtyId + }) + ] + }, + terminalLayoutsByTabId: { + [promotionTabId]: { + ...makeLayout(), + ptyIdsByLeafId: { 'pane:1': promotionPtyId } + } + } + } + } + + beforeEach(() => { + clearRuntimeCompatibilityCacheForTests() + reposList.mockReset() + // Only repos.list is exercised here — the missing projects API makes + // fetchProjectHostSetupCompatibility fall back to deriving from repos. + vi.stubGlobal('window', { + api: { + repos: { + list: reposList, + removeForHost: vi.fn().mockResolvedValue(undefined) + } + } + }) + }) + + // A repos:changed burst (deleting a project group with contained projects) starts + // overlapping fetchRepos calls; the slice must keep the latest and drop superseded + // results so removed projects don't reappear until restart (#7020). + describe('repos slice stale-fetch race (#7020)', () => { + it('drops a stale repos fetch that resolves after a newer one', async () => { + const store = createTestStore() + let resolveStale!: (repos: Repo[]) => void + const stalePromise = new Promise((resolve) => { + resolveStale = resolve + }) + // Why: mirrors the delete-project-group burst — the first fetch reads + // pre-removal state (both repos) but resolves LAST; the second reads + // post-removal state (remoteRepo gone) and resolves first. + reposList.mockReturnValueOnce(stalePromise).mockResolvedValueOnce([localRepo]) + + const stale = store.getState().fetchRepos(catalogOwner(store)) + const fresh = store.getState().fetchRepos(catalogOwner(store)) + await fresh + expect(store.getState().repos.map((repo) => repo.id)).toEqual(['local-repo']) + + resolveStale([localRepo, remoteRepo]) + await stale + // The superseded fetch must not resurrect the removed repo. + expect(store.getState().repos.map((repo) => repo.id)).toEqual(['local-repo']) + }) + + it('a superseding fetch that later rejects still blocks the older stale fetch', async () => { + const store = createTestStore() + let resolveStale!: (repos: Repo[]) => void + const stalePromise = new Promise((resolve) => { + resolveStale = resolve + }) + // The stale fetch reads pre-removal state and resolves LAST; the superseding + // fetch reads post-removal state but REJECTS. Because the generation is + // claimed synchronously before the await, the failed fetch still supersedes + // the stale one, which must be dropped rather than resurrect remoteRepo. + reposList.mockReturnValueOnce(stalePromise).mockRejectedValueOnce(new Error('boom')) + + const stale = store.getState().fetchRepos(catalogOwner(store)) + await store.getState().fetchRepos(catalogOwner(store)) + + resolveStale([localRepo, remoteRepo]) + await stale + expect(store.getState().repos).toEqual([]) + }) + + it('reapplies an in-flight catalog after the repo is removed', async () => { + const { promise: catalog, resolve: resolveCatalog } = Promise.withResolvers() + reposList.mockReturnValueOnce(catalog) + const store = createTestStore() + store.setState({ repos: [localRepo] }) + + const pending = store.getState().fetchRepos(catalogOwner(store)) + await store + .getState() + .removeProject(localRepo.id, { hostId: getRepoExecutionHostId(localRepo) }) + expect(store.getState().repos).toEqual([]) + + resolveCatalog([localRepo]) + await pending + + // Current contract: removal does not claim a catalog generation, so the latest fetch can resurrect it. + expect(store.getState().repos).toEqual([{ ...localRepo, executionHostId: 'local' }]) + }) + + it('waits for the superseding catalog fetch before startup hydration', async () => { + let resolveStartup!: (repos: Repo[]) => void + let resolveRefresh!: (repos: Repo[]) => void + const startupRepos = new Promise((resolve) => { + resolveStartup = resolve + }) + const refreshedRepos = new Promise((resolve) => { + resolveRefresh = resolve + }) + reposList.mockReturnValueOnce(startupRepos).mockReturnValueOnce(refreshedRepos) + const store = createTestStore() + + const startup = store.getState().fetchReposForAllHosts({ remoteHosts: 'skip' }) + const refresh = store.getState().fetchRepos(catalogOwner(store)) + resolveStartup([localRepo]) + await startup + + let settled = false + const settlement = store + .getState() + .awaitLocalRepoCatalogSettlement() + .then(() => { + settled = true + }) + await Promise.resolve() + expect(settled).toBe(false) + + resolveRefresh([localRepo]) + await Promise.all([refresh, settlement]) + expect(store.getState().repos.map((repo) => repo.id)).toEqual(['local-repo']) + + store.getState().hydrateWorkspaceSession(makePromotionSession()) + + expect(store.getState().activeWorktreeId).toBe(promotionWorktreeId) + expect(store.getState().pendingReconnectPtyIdByTabId).toEqual({ + [promotionTabId]: promotionPtyId + }) + expect(store.getState().terminalLayoutsByTabId[promotionTabId]?.ptyIdsByLeafId).toEqual({ + 'pane:1': promotionPtyId + }) + }) + + it('waits for a refresh started after initial settlement before startup hydration', async () => { + let resolveRefresh!: (repos: Repo[]) => void + const refreshedRepos = new Promise((resolve) => { + resolveRefresh = resolve + }) + reposList.mockResolvedValueOnce([localRepo]).mockReturnValueOnce(refreshedRepos) + const store = createTestStore() + + await store.getState().fetchReposForAllHosts({ remoteHosts: 'skip' }) + await store.getState().awaitLocalRepoCatalogSettlement() + const refresh = store.getState().fetchRepos(catalogOwner(store)) + let hydrated = false + const hydration = (async () => { + await store.getState().awaitLocalRepoCatalogSettlement() + store.getState().hydrateWorkspaceSession(makePromotionSession()) + hydrated = true + })() + + await Promise.resolve() + expect(hydrated).toBe(false) + + resolveRefresh([localRepo]) + await Promise.all([refresh, hydration]) + expect(store.getState().activeWorktreeId).toBe(promotionWorktreeId) + expect(store.getState().pendingReconnectPtyIdByTabId[promotionTabId]).toBe(promotionPtyId) + }) + + it('rejects startup settlement when the superseding local catalog fails', async () => { + let resolveStartup!: (repos: Repo[]) => void + const startupRepos = new Promise((resolve) => { + resolveStartup = resolve + }) + reposList.mockReturnValueOnce(startupRepos).mockRejectedValueOnce(new Error('catalog failed')) + const store = createTestStore() + + const startup = store.getState().fetchReposForAllHosts({ remoteHosts: 'skip' }) + const refresh = store.getState().fetchRepos(catalogOwner(store)) + resolveStartup([localRepo]) + await Promise.all([startup, refresh]) + + const hydration = async (): Promise => { + await store.getState().awaitLocalRepoCatalogSettlement() + store.getState().hydrateWorkspaceSession(makePromotionSession()) + } + await expect(hydration()).rejects.toThrow('catalog failed') + expect(store.getState().tabsByWorktree).toEqual({}) + expect(store.getState().terminalLayoutsByTabId).toEqual({}) + expect(store.getState().pendingReconnectPtyIdByTabId).toEqual({}) + }) + }) + + // The catalog these tests read is the one the focused host would have shown. + function catalogOwner(store: { + getState: () => { settings: { activeRuntimeEnvironmentId?: string | null } | null } + }): { runtimeEnvironmentId: string | null } { + return { runtimeEnvironmentId: store.getState().settings?.activeRuntimeEnvironmentId ?? null } + } +} diff --git a/src/renderer/src/store/slices/repos-stale-fetch.test.ts b/src/renderer/src/store/slices/repos-stale-fetch.test.ts deleted file mode 100644 index 38e38d78cb9..00000000000 --- a/src/renderer/src/store/slices/repos-stale-fetch.test.ts +++ /dev/null @@ -1,233 +0,0 @@ -import { describe, expect, it, vi, beforeEach } from 'vitest' -import { getRepoExecutionHostId } from '../../../../shared/execution-host' -import { createTestStore, makeLayout, makeTab } from './store-test-helpers' -import type { Repo } from '../../../../shared/repo-types' -import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types' -import { getDefaultWorkspaceSession } from '../../../../shared/constants' -import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' - -const localRepo: Repo = { - id: 'local-repo', - path: '/local', - displayName: 'Local', - badgeColor: '#000', - addedAt: 1 -} - -const remoteRepo: Repo = { - id: 'remote-repo', - path: '/remote', - displayName: 'Remote', - badgeColor: '#111', - addedAt: 2 -} - -const reposList = vi.fn() -const promotionWorktreeId = 'local-repo::/local' -const promotionTabId = 'restored-tab' -const promotionPtyId = 'original-daemon-pty' - -function makePromotionSession(): WorkspaceSessionState { - return { - ...getDefaultWorkspaceSession(), - activeRepoId: localRepo.id, - activeWorktreeId: promotionWorktreeId, - activeTabId: promotionTabId, - activeWorktreeIdsOnShutdown: [promotionWorktreeId], - tabsByWorktree: { - [promotionWorktreeId]: [ - makeTab({ - id: promotionTabId, - worktreeId: promotionWorktreeId, - ptyId: promotionPtyId - }) - ] - }, - terminalLayoutsByTabId: { - [promotionTabId]: { - ...makeLayout(), - ptyIdsByLeafId: { 'pane:1': promotionPtyId } - } - } - } -} - -beforeEach(() => { - clearRuntimeCompatibilityCacheForTests() - reposList.mockReset() - // Only repos.list is exercised here — the missing projects API makes - // fetchProjectHostSetupCompatibility fall back to deriving from repos. - vi.stubGlobal('window', { - api: { - repos: { - list: reposList, - removeForHost: vi.fn().mockResolvedValue(undefined) - } - } - }) -}) - -// A repos:changed burst (deleting a project group with contained projects) starts -// overlapping fetchRepos calls; the slice must keep the latest and drop superseded -// results so removed projects don't reappear until restart (#7020). -describe('repos slice stale-fetch race (#7020)', () => { - it('drops a stale repos fetch that resolves after a newer one', async () => { - const store = createTestStore() - let resolveStale!: (repos: Repo[]) => void - const stalePromise = new Promise((resolve) => { - resolveStale = resolve - }) - // Why: mirrors the delete-project-group burst — the first fetch reads - // pre-removal state (both repos) but resolves LAST; the second reads - // post-removal state (remoteRepo gone) and resolves first. - reposList.mockReturnValueOnce(stalePromise).mockResolvedValueOnce([localRepo]) - - const stale = store.getState().fetchRepos(catalogOwner(store)) - const fresh = store.getState().fetchRepos(catalogOwner(store)) - await fresh - expect(store.getState().repos.map((repo) => repo.id)).toEqual(['local-repo']) - - resolveStale([localRepo, remoteRepo]) - await stale - // The superseded fetch must not resurrect the removed repo. - expect(store.getState().repos.map((repo) => repo.id)).toEqual(['local-repo']) - }) - - it('a superseding fetch that later rejects still blocks the older stale fetch', async () => { - const store = createTestStore() - let resolveStale!: (repos: Repo[]) => void - const stalePromise = new Promise((resolve) => { - resolveStale = resolve - }) - // The stale fetch reads pre-removal state and resolves LAST; the superseding - // fetch reads post-removal state but REJECTS. Because the generation is - // claimed synchronously before the await, the failed fetch still supersedes - // the stale one, which must be dropped rather than resurrect remoteRepo. - reposList.mockReturnValueOnce(stalePromise).mockRejectedValueOnce(new Error('boom')) - - const stale = store.getState().fetchRepos(catalogOwner(store)) - await store.getState().fetchRepos(catalogOwner(store)) - - resolveStale([localRepo, remoteRepo]) - await stale - expect(store.getState().repos).toEqual([]) - }) - - it('reapplies an in-flight catalog after the repo is removed', async () => { - const { promise: catalog, resolve: resolveCatalog } = Promise.withResolvers() - reposList.mockReturnValueOnce(catalog) - const store = createTestStore() - store.setState({ repos: [localRepo] }) - - const pending = store.getState().fetchRepos(catalogOwner(store)) - await store - .getState() - .removeProject(localRepo.id, { hostId: getRepoExecutionHostId(localRepo) }) - expect(store.getState().repos).toEqual([]) - - resolveCatalog([localRepo]) - await pending - - // Current contract: removal does not claim a catalog generation, so the latest fetch can resurrect it. - expect(store.getState().repos).toEqual([{ ...localRepo, executionHostId: 'local' }]) - }) - - it('waits for the superseding catalog fetch before startup hydration', async () => { - let resolveStartup!: (repos: Repo[]) => void - let resolveRefresh!: (repos: Repo[]) => void - const startupRepos = new Promise((resolve) => { - resolveStartup = resolve - }) - const refreshedRepos = new Promise((resolve) => { - resolveRefresh = resolve - }) - reposList.mockReturnValueOnce(startupRepos).mockReturnValueOnce(refreshedRepos) - const store = createTestStore() - - const startup = store.getState().fetchReposForAllHosts({ remoteHosts: 'skip' }) - const refresh = store.getState().fetchRepos(catalogOwner(store)) - resolveStartup([localRepo]) - await startup - - let settled = false - const settlement = store - .getState() - .awaitLocalRepoCatalogSettlement() - .then(() => { - settled = true - }) - await Promise.resolve() - expect(settled).toBe(false) - - resolveRefresh([localRepo]) - await Promise.all([refresh, settlement]) - expect(store.getState().repos.map((repo) => repo.id)).toEqual(['local-repo']) - - store.getState().hydrateWorkspaceSession(makePromotionSession()) - - expect(store.getState().activeWorktreeId).toBe(promotionWorktreeId) - expect(store.getState().pendingReconnectPtyIdByTabId).toEqual({ - [promotionTabId]: promotionPtyId - }) - expect(store.getState().terminalLayoutsByTabId[promotionTabId]?.ptyIdsByLeafId).toEqual({ - 'pane:1': promotionPtyId - }) - }) - - it('waits for a refresh started after initial settlement before startup hydration', async () => { - let resolveRefresh!: (repos: Repo[]) => void - const refreshedRepos = new Promise((resolve) => { - resolveRefresh = resolve - }) - reposList.mockResolvedValueOnce([localRepo]).mockReturnValueOnce(refreshedRepos) - const store = createTestStore() - - await store.getState().fetchReposForAllHosts({ remoteHosts: 'skip' }) - await store.getState().awaitLocalRepoCatalogSettlement() - const refresh = store.getState().fetchRepos(catalogOwner(store)) - let hydrated = false - const hydration = (async () => { - await store.getState().awaitLocalRepoCatalogSettlement() - store.getState().hydrateWorkspaceSession(makePromotionSession()) - hydrated = true - })() - - await Promise.resolve() - expect(hydrated).toBe(false) - - resolveRefresh([localRepo]) - await Promise.all([refresh, hydration]) - expect(store.getState().activeWorktreeId).toBe(promotionWorktreeId) - expect(store.getState().pendingReconnectPtyIdByTabId[promotionTabId]).toBe(promotionPtyId) - }) - - it('rejects startup settlement when the superseding local catalog fails', async () => { - let resolveStartup!: (repos: Repo[]) => void - const startupRepos = new Promise((resolve) => { - resolveStartup = resolve - }) - reposList.mockReturnValueOnce(startupRepos).mockRejectedValueOnce(new Error('catalog failed')) - const store = createTestStore() - - const startup = store.getState().fetchReposForAllHosts({ remoteHosts: 'skip' }) - const refresh = store.getState().fetchRepos(catalogOwner(store)) - resolveStartup([localRepo]) - await Promise.all([startup, refresh]) - - const hydration = async (): Promise => { - await store.getState().awaitLocalRepoCatalogSettlement() - store.getState().hydrateWorkspaceSession(makePromotionSession()) - } - await expect(hydration()).rejects.toThrow('catalog failed') - expect(store.getState().tabsByWorktree).toEqual({}) - expect(store.getState().terminalLayoutsByTabId).toEqual({}) - expect(store.getState().pendingReconnectPtyIdByTabId).toEqual({}) - }) -}) - -// The catalog these tests read is the one the focused host would have shown. -function catalogOwner(store: { - getState: () => { settings: { activeRuntimeEnvironmentId?: string | null } | null } -}): { runtimeEnvironmentId: string | null } { - return { runtimeEnvironmentId: store.getState().settings?.activeRuntimeEnvironmentId ?? null } -} diff --git a/src/renderer/src/store/slices/repos-update-serialization.spec.ts b/src/renderer/src/store/slices/repos-update-serialization.spec.ts new file mode 100644 index 00000000000..fc675e19c78 --- /dev/null +++ b/src/renderer/src/store/slices/repos-update-serialization.spec.ts @@ -0,0 +1,228 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createTestStore } from './store-test-helpers' +import type { Repo } from '../../../../shared/repo-types' + +export function registerReposUpdateSerializationTests(): void { + const localRepo: Repo = { + id: 'local-repo', + path: '/local', + displayName: 'Local', + badgeColor: '#000', + addedAt: 1 + } + + const secondRepo: Repo = { + id: 'second-repo', + path: '/second', + displayName: 'Second', + badgeColor: '#111', + addedAt: 2 + } + + const reposUpdate = vi.fn() + + function deferred() { + let resolve!: (value: T | PromiseLike) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } + } + + beforeEach(() => { + reposUpdate.mockReset() + vi.stubGlobal('window', { + api: { + repos: { + update: reposUpdate + } + } + }) + }) + + describe('repo update serialization', () => { + it('serializes local repo updates for the same repo before applying state', async () => { + const firstUpdate = deferred() + const secondUpdate = deferred() + const firstHookSettings: NonNullable = { + mode: 'override', + setupRunPolicy: 'ask', + commandSourcePolicy: 'local-only', + scripts: { setup: 'first setup', archive: '' } + } + const secondHookSettings: NonNullable = { + mode: 'override', + setupRunPolicy: 'skip-by-default', + commandSourcePolicy: 'run-both', + scripts: { setup: 'second setup', archive: 'second archive' } + } + reposUpdate.mockImplementationOnce(() => firstUpdate.promise) + reposUpdate.mockImplementationOnce(() => secondUpdate.promise) + const store = createTestStore() + store.setState({ repos: [localRepo] }) + + const first = store.getState().updateRepo(localRepo.id, { hookSettings: firstHookSettings }) + const second = store.getState().updateRepo(localRepo.id, { hookSettings: secondHookSettings }) + + expect(reposUpdate).toHaveBeenCalledTimes(1) + expect(store.getState().repos[0]?.hookSettings).toBeUndefined() + + firstUpdate.resolve() + await first + await Promise.resolve() + + expect(reposUpdate).toHaveBeenCalledTimes(2) + expect(store.getState().repos[0]?.hookSettings).toEqual(firstHookSettings) + + secondUpdate.resolve() + await second + + expect(store.getState().repos[0]?.hookSettings).toEqual(secondHookSettings) + }) + + it('does not serialize updates for different repos', async () => { + const slowLocalUpdate = deferred() + reposUpdate.mockImplementationOnce(() => slowLocalUpdate.promise) + reposUpdate.mockResolvedValueOnce(undefined) + const store = createTestStore() + store.setState({ repos: [localRepo, secondRepo] }) + + const local = store.getState().updateRepo(localRepo.id, { displayName: 'Local slow' }) + const second = store.getState().updateRepo(secondRepo.id, { displayName: 'Second fast' }) + + expect(reposUpdate).toHaveBeenCalledTimes(2) + await second + expect(store.getState().repos.find((repo) => repo.id === secondRepo.id)?.displayName).toBe( + 'Second fast' + ) + expect(store.getState().repos.find((repo) => repo.id === localRepo.id)?.displayName).toBe( + 'Local' + ) + + slowLocalUpdate.resolve() + await local + + expect(store.getState().repos.find((repo) => repo.id === localRepo.id)?.displayName).toBe( + 'Local slow' + ) + }) + + it('runs same-ID updates on different hosts independently', async () => { + const slowLocalUpdate = deferred() + reposUpdate.mockImplementationOnce(() => slowLocalUpdate.promise) + reposUpdate.mockResolvedValueOnce(undefined) + const sshRepo: Repo = { + ...localRepo, + path: '/ssh/repo', + displayName: 'SSH', + executionHostId: 'ssh:connection-1' + } + const store = createTestStore() + store.setState({ repos: [localRepo, sshRepo] }) + + const local = store + .getState() + .updateRepo(localRepo.id, { displayName: 'Local slow' }, { hostId: 'local' }) + const ssh = store + .getState() + .updateRepo(sshRepo.id, { displayName: 'SSH fast' }, { hostId: 'ssh:connection-1' }) + + expect(reposUpdate).toHaveBeenCalledTimes(2) + await ssh + expect(store.getState().repos).toEqual([localRepo, { ...sshRepo, displayName: 'SSH fast' }]) + + slowLocalUpdate.resolve() + await local + expect(store.getState().repos).toEqual([ + { ...localRepo, displayName: 'Local slow' }, + { ...sshRepo, displayName: 'SSH fast' } + ]) + expect(reposUpdate).toHaveBeenNthCalledWith(1, { + repoId: localRepo.id, + updates: { displayName: 'Local slow' }, + hostId: 'local' + }) + expect(reposUpdate).toHaveBeenNthCalledWith(2, { + repoId: sshRepo.id, + updates: { displayName: 'SSH fast' }, + hostId: 'ssh:connection-1' + }) + }) + + it('continues a repo update chain after a failed update', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + try { + reposUpdate.mockRejectedValueOnce(new Error('update failed')) + reposUpdate.mockResolvedValueOnce(undefined) + const store = createTestStore() + store.setState({ repos: [localRepo] }) + + const failed = store.getState().updateRepo(localRepo.id, { displayName: 'Failed' }) + const recovered = store.getState().updateRepo(localRepo.id, { displayName: 'Recovered' }) + + await expect(Promise.all([failed, recovered])).resolves.toEqual([false, true]) + + expect(reposUpdate).toHaveBeenCalledTimes(2) + expect(store.getState().repos[0]?.displayName).toBe('Recovered') + } finally { + errorSpy.mockRestore() + } + }) + + it('clears nullable fallback fields when local IPC returns no authoritative repo', async () => { + reposUpdate.mockResolvedValueOnce(undefined) + const repo: Repo = { + ...localRepo, + externalWorktreeVisibility: 'show', + agentWorktreeVisibility: 'hide', + sourceControlAi: {}, + externalWorktreeDiscoverySuppressedAt: 123 + } + const store = createTestStore() + store.setState({ repos: [repo] }) + + await store.getState().updateRepo(repo.id, { + externalWorktreeVisibility: null, + agentWorktreeVisibility: null, + sourceControlAi: null, + externalWorktreeDiscoverySuppressedAt: null + }) + + expect(reposUpdate).toHaveBeenCalledWith({ + repoId: repo.id, + updates: { + externalWorktreeVisibility: null, + agentWorktreeVisibility: null, + sourceControlAi: null, + externalWorktreeDiscoverySuppressedAt: null + } + }) + expect(store.getState().repos[0]).toMatchObject({ + externalWorktreeVisibilityLegacy: false + }) + expect(store.getState().repos[0]?.externalWorktreeVisibility).toBeUndefined() + expect(store.getState().repos[0]?.agentWorktreeVisibility).toBeUndefined() + expect(store.getState().repos[0]?.sourceControlAi).toBeUndefined() + expect(store.getState().repos[0]?.externalWorktreeDiscoverySuppressedAt).toBeUndefined() + }) + + it('does not apply repo icons that fail shared sanitization', async () => { + reposUpdate.mockResolvedValueOnce(undefined) + const store = createTestStore() + store.setState({ repos: [localRepo] }) + + await store.getState().updateRepo(localRepo.id, { + repoIcon: { + type: 'image', + source: 'upload', + src: 'data:image/svg+xml;base64,PHN2Zz48L3N2Zz4=' + } as never + }) + + expect(reposUpdate).toHaveBeenCalledWith({ repoId: localRepo.id, updates: {} }) + expect(store.getState().repos[0]?.repoIcon).toBeUndefined() + }) + }) +} diff --git a/src/renderer/src/store/slices/repos-update-serialization.test.ts b/src/renderer/src/store/slices/repos-update-serialization.test.ts deleted file mode 100644 index deabe87bda3..00000000000 --- a/src/renderer/src/store/slices/repos-update-serialization.test.ts +++ /dev/null @@ -1,226 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { createTestStore } from './store-test-helpers' -import type { Repo } from '../../../../shared/repo-types' - -const localRepo: Repo = { - id: 'local-repo', - path: '/local', - displayName: 'Local', - badgeColor: '#000', - addedAt: 1 -} - -const secondRepo: Repo = { - id: 'second-repo', - path: '/second', - displayName: 'Second', - badgeColor: '#111', - addedAt: 2 -} - -const reposUpdate = vi.fn() - -function deferred() { - let resolve!: (value: T | PromiseLike) => void - let reject!: (reason?: unknown) => void - const promise = new Promise((res, rej) => { - resolve = res - reject = rej - }) - return { promise, resolve, reject } -} - -beforeEach(() => { - reposUpdate.mockReset() - vi.stubGlobal('window', { - api: { - repos: { - update: reposUpdate - } - } - }) -}) - -describe('repo update serialization', () => { - it('serializes local repo updates for the same repo before applying state', async () => { - const firstUpdate = deferred() - const secondUpdate = deferred() - const firstHookSettings: NonNullable = { - mode: 'override', - setupRunPolicy: 'ask', - commandSourcePolicy: 'local-only', - scripts: { setup: 'first setup', archive: '' } - } - const secondHookSettings: NonNullable = { - mode: 'override', - setupRunPolicy: 'skip-by-default', - commandSourcePolicy: 'run-both', - scripts: { setup: 'second setup', archive: 'second archive' } - } - reposUpdate.mockImplementationOnce(() => firstUpdate.promise) - reposUpdate.mockImplementationOnce(() => secondUpdate.promise) - const store = createTestStore() - store.setState({ repos: [localRepo] }) - - const first = store.getState().updateRepo(localRepo.id, { hookSettings: firstHookSettings }) - const second = store.getState().updateRepo(localRepo.id, { hookSettings: secondHookSettings }) - - expect(reposUpdate).toHaveBeenCalledTimes(1) - expect(store.getState().repos[0]?.hookSettings).toBeUndefined() - - firstUpdate.resolve() - await first - await Promise.resolve() - - expect(reposUpdate).toHaveBeenCalledTimes(2) - expect(store.getState().repos[0]?.hookSettings).toEqual(firstHookSettings) - - secondUpdate.resolve() - await second - - expect(store.getState().repos[0]?.hookSettings).toEqual(secondHookSettings) - }) - - it('does not serialize updates for different repos', async () => { - const slowLocalUpdate = deferred() - reposUpdate.mockImplementationOnce(() => slowLocalUpdate.promise) - reposUpdate.mockResolvedValueOnce(undefined) - const store = createTestStore() - store.setState({ repos: [localRepo, secondRepo] }) - - const local = store.getState().updateRepo(localRepo.id, { displayName: 'Local slow' }) - const second = store.getState().updateRepo(secondRepo.id, { displayName: 'Second fast' }) - - expect(reposUpdate).toHaveBeenCalledTimes(2) - await second - expect(store.getState().repos.find((repo) => repo.id === secondRepo.id)?.displayName).toBe( - 'Second fast' - ) - expect(store.getState().repos.find((repo) => repo.id === localRepo.id)?.displayName).toBe( - 'Local' - ) - - slowLocalUpdate.resolve() - await local - - expect(store.getState().repos.find((repo) => repo.id === localRepo.id)?.displayName).toBe( - 'Local slow' - ) - }) - - it('runs same-ID updates on different hosts independently', async () => { - const slowLocalUpdate = deferred() - reposUpdate.mockImplementationOnce(() => slowLocalUpdate.promise) - reposUpdate.mockResolvedValueOnce(undefined) - const sshRepo: Repo = { - ...localRepo, - path: '/ssh/repo', - displayName: 'SSH', - executionHostId: 'ssh:connection-1' - } - const store = createTestStore() - store.setState({ repos: [localRepo, sshRepo] }) - - const local = store - .getState() - .updateRepo(localRepo.id, { displayName: 'Local slow' }, { hostId: 'local' }) - const ssh = store - .getState() - .updateRepo(sshRepo.id, { displayName: 'SSH fast' }, { hostId: 'ssh:connection-1' }) - - expect(reposUpdate).toHaveBeenCalledTimes(2) - await ssh - expect(store.getState().repos).toEqual([localRepo, { ...sshRepo, displayName: 'SSH fast' }]) - - slowLocalUpdate.resolve() - await local - expect(store.getState().repos).toEqual([ - { ...localRepo, displayName: 'Local slow' }, - { ...sshRepo, displayName: 'SSH fast' } - ]) - expect(reposUpdate).toHaveBeenNthCalledWith(1, { - repoId: localRepo.id, - updates: { displayName: 'Local slow' }, - hostId: 'local' - }) - expect(reposUpdate).toHaveBeenNthCalledWith(2, { - repoId: sshRepo.id, - updates: { displayName: 'SSH fast' }, - hostId: 'ssh:connection-1' - }) - }) - - it('continues a repo update chain after a failed update', async () => { - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) - try { - reposUpdate.mockRejectedValueOnce(new Error('update failed')) - reposUpdate.mockResolvedValueOnce(undefined) - const store = createTestStore() - store.setState({ repos: [localRepo] }) - - const failed = store.getState().updateRepo(localRepo.id, { displayName: 'Failed' }) - const recovered = store.getState().updateRepo(localRepo.id, { displayName: 'Recovered' }) - - await expect(Promise.all([failed, recovered])).resolves.toEqual([false, true]) - - expect(reposUpdate).toHaveBeenCalledTimes(2) - expect(store.getState().repos[0]?.displayName).toBe('Recovered') - } finally { - errorSpy.mockRestore() - } - }) - - it('clears nullable fallback fields when local IPC returns no authoritative repo', async () => { - reposUpdate.mockResolvedValueOnce(undefined) - const repo: Repo = { - ...localRepo, - externalWorktreeVisibility: 'show', - agentWorktreeVisibility: 'hide', - sourceControlAi: {}, - externalWorktreeDiscoverySuppressedAt: 123 - } - const store = createTestStore() - store.setState({ repos: [repo] }) - - await store.getState().updateRepo(repo.id, { - externalWorktreeVisibility: null, - agentWorktreeVisibility: null, - sourceControlAi: null, - externalWorktreeDiscoverySuppressedAt: null - }) - - expect(reposUpdate).toHaveBeenCalledWith({ - repoId: repo.id, - updates: { - externalWorktreeVisibility: null, - agentWorktreeVisibility: null, - sourceControlAi: null, - externalWorktreeDiscoverySuppressedAt: null - } - }) - expect(store.getState().repos[0]).toMatchObject({ - externalWorktreeVisibilityLegacy: false - }) - expect(store.getState().repos[0]?.externalWorktreeVisibility).toBeUndefined() - expect(store.getState().repos[0]?.agentWorktreeVisibility).toBeUndefined() - expect(store.getState().repos[0]?.sourceControlAi).toBeUndefined() - expect(store.getState().repos[0]?.externalWorktreeDiscoverySuppressedAt).toBeUndefined() - }) - - it('does not apply repo icons that fail shared sanitization', async () => { - reposUpdate.mockResolvedValueOnce(undefined) - const store = createTestStore() - store.setState({ repos: [localRepo] }) - - await store.getState().updateRepo(localRepo.id, { - repoIcon: { - type: 'image', - source: 'upload', - src: 'data:image/svg+xml;base64,PHN2Zz48L3N2Zz4=' - } as never - }) - - expect(reposUpdate).toHaveBeenCalledWith({ repoId: localRepo.id, updates: {} }) - expect(store.getState().repos[0]?.repoIcon).toBeUndefined() - }) -})