test: share repository store setup and advance mocked mail clocks (#29698)

This commit is contained in:
Neil
2026-10-16 07:16:30 -07:00
committed by GitHub
parent adf26f9ec5
commit 54dc2d609d
35 changed files with 4020 additions and 3923 deletions
+12 -9
View File
@@ -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",
@@ -16,6 +16,7 @@ import {
db,
finishWorker,
host,
observationClock,
openChat,
ptyPointer,
queuedCardTexts,
@@ -50,7 +51,7 @@ async function runningUserTurn(chat: FakeConnection): Promise<() => Promise<void
async function idleEdgesSettled(): Promise<void> {
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<string> {
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()
@@ -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<void> {
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)
@@ -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()
}
})
})
}
@@ -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()
}
})
})
@@ -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<string, FolderWorkspacePathStatusCacheEntry> = {
'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<T>(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 }
}
}
@@ -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<string, FolderWorkspacePathStatusCacheEntry> = {
'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<T>(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 }
}
@@ -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<ProjectGroup[]>>()
const foldersList = vi.fn<() => Promise<FolderWorkspace[]>>()
const folderUpdate = vi.fn<Window['api']['folderWorkspaces']['update']>()
type TestStore = ReturnType<typeof createTestStore>
type CatalogCase = {
label: string
catalog: 'projectGroups' | 'folderWorkspaces'
refresh: (store: TestStore) => Promise<void>
}
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<ProjectGroup[]>()
const olderFolders = Promise.withResolvers<FolderWorkspace[]>()
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<FolderWorkspace>()
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<FolderWorkspace>()
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 }
}
}
@@ -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<ProjectGroup[]>>()
const foldersList = vi.fn<() => Promise<FolderWorkspace[]>>()
const folderUpdate = vi.fn<Window['api']['folderWorkspaces']['update']>()
type TestStore = ReturnType<typeof createTestStore>
type CatalogCase = {
label: string
catalog: 'projectGroups' | 'folderWorkspaces'
refresh: (store: TestStore) => Promise<void>
}
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<ProjectGroup[]>()
const olderFolders = Promise.withResolvers<FolderWorkspace[]>()
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<FolderWorkspace>()
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<FolderWorkspace>()
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 }
}
@@ -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)
@@ -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<ProjectGroup>()
const createStarted = Promise.withResolvers<void>()
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 }
}
}
@@ -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<ProjectGroup>()
const createStarted = Promise.withResolvers<void>()
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 }
}
@@ -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()
})
})
}
@@ -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()
})
})
@@ -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'])
})
})
}
@@ -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'])
})
})
@@ -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 }
}
}
@@ -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 }
}
@@ -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 }
}
}
@@ -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 }
}
@@ -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)
@@ -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 }
}
}
@@ -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 }
}
@@ -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<T>(value: T): T {
return structuredClone(value)
}
function mockRepos(...rows: readonly (Repo | Omit<Repo, 'addedAt'>)[]): void {
reposList.mockImplementation(async () => rows.map(clone))
}
function omitAddedAt(row: Repo): Omit<Repo, 'addedAt'> {
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 }
}
}
@@ -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<T>(value: T): T {
return structuredClone(value)
}
function mockRepos(...rows: readonly (Repo | Omit<Repo, 'addedAt'>)[]): void {
reposList.mockImplementation(async () => rows.map(clone))
}
function omitAddedAt(row: Repo): Omit<Repo, 'addedAt'> {
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 }
}
@@ -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<typeof createTestStore>): 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'])
})
})
}
@@ -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<typeof createTestStore>): 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'])
})
})
@@ -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'
)
})
}
@@ -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'
)
})
@@ -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<Repo> }) => ({
...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<typeof createTestStore>): 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)
})
})
}
@@ -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<Repo> }) => ({
...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<typeof createTestStore>): 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)
})
})
@@ -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<Repo[]>((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<Repo[]>((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<Repo[]>()
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<Repo[]>((resolve) => {
resolveStartup = resolve
})
const refreshedRepos = new Promise<Repo[]>((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<Repo[]>((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<Repo[]>((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<void> => {
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 }
}
}
@@ -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<Repo[]>((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<Repo[]>((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<Repo[]>()
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<Repo[]>((resolve) => {
resolveStartup = resolve
})
const refreshedRepos = new Promise<Repo[]>((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<Repo[]>((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<Repo[]>((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<void> => {
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 }
}
@@ -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<T>() {
let resolve!: (value: T | PromiseLike<T>) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((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<void>()
const secondUpdate = deferred<void>()
const firstHookSettings: NonNullable<Repo['hookSettings']> = {
mode: 'override',
setupRunPolicy: 'ask',
commandSourcePolicy: 'local-only',
scripts: { setup: 'first setup', archive: '' }
}
const secondHookSettings: NonNullable<Repo['hookSettings']> = {
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<void>()
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<void>()
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()
})
})
}
@@ -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<T>() {
let resolve!: (value: T | PromiseLike<T>) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((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<void>()
const secondUpdate = deferred<void>()
const firstHookSettings: NonNullable<Repo['hookSettings']> = {
mode: 'override',
setupRunPolicy: 'ask',
commandSourcePolicy: 'local-only',
scripts: { setup: 'first setup', archive: '' }
}
const secondHookSettings: NonNullable<Repo['hookSettings']> = {
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<void>()
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<void>()
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()
})
})