diff --git a/src/renderer/src/store/slices/repos-cross-host-refresh-identity.test.ts b/src/renderer/src/store/slices/repos-cross-host-refresh-identity.test.ts new file mode 100644 index 00000000000..6218d251607 --- /dev/null +++ b/src/renderer/src/store/slices/repos-cross-host-refresh-identity.test.ts @@ -0,0 +1,181 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createTestStore } from './store-test-helpers' +import type { Project, ProjectHostSetup, Repo } from '../../../../shared/types' +import { + createCompatibleRuntimeStatusResponseIfNeeded, + type RuntimeEnvironmentCallRequest +} from '../../runtime/runtime-compatibility-test-fixture' +import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' + +// One project cloned on the local Mac and on a remote Orca server under distinct repo ids — +// the shape the compat merge exists to serve, and the only shape whose sourceRepoIds are +// assembled from two hosts. +const SHARED_PROJECT_ID = 'github:stablyai/orca' + +const localRepo: Repo = { + id: 'local-repo', + path: '/local/orca', + displayName: 'orca', + badgeColor: '#22c55e', + upstream: { owner: 'stablyai', repo: 'orca' }, + addedAt: 1_700_000_000_000 +} + +const remoteRepo: Repo = { + id: 'remote-repo', + path: '/srv/orca', + displayName: 'orca', + badgeColor: '#737373', + upstream: { owner: 'stablyai', repo: 'orca' }, + addedAt: 1_700_000_001_000 +} + +const localProject: Project = { + id: SHARED_PROJECT_ID, + displayName: 'orca', + badgeColor: '#22c55e', + sourceRepoIds: ['local-repo'], + createdAt: 1, + updatedAt: 1 +} + +const remoteProject: Project = { + ...localProject, + badgeColor: '#737373', + sourceRepoIds: ['remote-repo'], + createdAt: 2, + updatedAt: 2 +} + +function setup(repoId: string, path: string): ProjectHostSetup { + return { + id: `${repoId}-setup`, + projectId: SHARED_PROJECT_ID, + hostId: 'local', + repoId, + path, + displayName: 'orca', + setupState: 'ready', + setupMethod: 'imported-existing-folder', + createdAt: 1, + updatedAt: 1 + } +} + +const reposList = vi.fn() +const projectsList = vi.fn() +const listHostSetups = vi.fn() +const runtimeEnvironmentsList = vi.fn() +const runtimeEnvironmentTransportCall = vi.fn() + +// Why: catalogs arrive over IPC, so every fetch must hand back freshly allocated rows — +// otherwise identity would match by accident and prove nothing. +function clone(value: T): T { + return structuredClone(value) +} + +function runtimeResult(method: string): unknown { + if (method === 'repo.list') { + return { repos: [clone(remoteRepo)] } + } + if (method === 'project.list') { + return { projects: [clone(remoteProject)] } + } + if (method === 'projectHostSetup.list') { + return { setups: [clone(setup('remote-repo', '/srv/orca'))] } + } + return {} +} + +beforeEach(() => { + clearRuntimeCompatibilityCacheForTests() + reposList.mockReset() + projectsList.mockReset() + listHostSetups.mockReset() + runtimeEnvironmentsList.mockReset() + runtimeEnvironmentTransportCall.mockReset() + + reposList.mockImplementation(async () => [clone(localRepo)]) + projectsList.mockImplementation(async () => [clone(localProject)]) + listHostSetups.mockImplementation(async () => [clone(setup('local-repo', '/local/orca'))]) + runtimeEnvironmentsList.mockResolvedValue([{ id: 'env-1', name: 'awin' }]) + runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + const compatible = createCompatibleRuntimeStatusResponseIfNeeded(args) + if (compatible) { + return compatible + } + return { + id: `rpc-${args.method}`, + ok: true, + result: runtimeResult(args.method), + _meta: { runtimeId: 'runtime-remote' } + } + }) + + vi.stubGlobal('window', { + api: { + repos: { list: reposList }, + projects: { list: projectsList, listHostSetups }, + projectGroups: { list: vi.fn().mockResolvedValue([]) }, + folderWorkspaces: { list: vi.fn().mockResolvedValue([]) }, + runtimeEnvironments: { + call: runtimeEnvironmentTransportCall, + list: runtimeEnvironmentsList + } + }, + dispatchEvent: vi.fn() + }) +}) + +function sharedProject(store: ReturnType): Project | undefined { + return store.getState().projects.find((project) => project.id === SHARED_PROJECT_ID) +} + +describe('cross-host project refresh identity', () => { + it('keeps sourceRepoIds ordered the same whichever host refreshed', async () => { + const store = createTestStore() + // Why: with no active env, `fetchRepos` targets local, so the two calls below really do + // alternate hosts. + store.setState({ settings: { activeRuntimeEnvironmentId: null } as never }) + await store.getState().fetchReposForAllHosts() + const afterAllHosts = sharedProject(store)?.sourceRepoIds + expect(afterAllHosts).toHaveLength(2) + + await store.getState().fetchRepos() + const afterLocal = sharedProject(store)?.sourceRepoIds + + await store.getState().fetchRuntimeEnvironmentRepos('env-1') + const afterRuntime = sharedProject(store)?.sourceRepoIds + + expect(afterLocal).toEqual(afterAllHosts) + expect(afterRuntime).toEqual(afterAllHosts) + }) + + it('reuses the cross-host project row across alternating single-host refreshes', async () => { + const store = createTestStore() + store.setState({ settings: { activeRuntimeEnvironmentId: null } as never }) + await store.getState().fetchReposForAllHosts() + // Why: the first local pass lands before the runtime catalog exists, so settle onto the + // two-host fixed point before measuring identity. + await store.getState().fetchRepos() + const settled = sharedProject(store) + + await store.getState().fetchRuntimeEnvironmentRepos('env-1') + expect(sharedProject(store)).toBe(settled) + + await store.getState().fetchRepos() + expect(sharedProject(store)).toBe(settled) + }) + + it('reuses the cross-host project row across a no-op all-hosts refresh', async () => { + const store = createTestStore() + store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) + await store.getState().fetchReposForAllHosts() + await store.getState().fetchReposForAllHosts() + const settled = sharedProject(store) + + await store.getState().fetchReposForAllHosts() + + expect(sharedProject(store)).toBe(settled) + }) +}) diff --git a/src/renderer/src/store/slices/repos-refresh-identity.test.ts b/src/renderer/src/store/slices/repos-refresh-identity.test.ts index 193bd045ede..98750177831 100644 --- a/src/renderer/src/store/slices/repos-refresh-identity.test.ts +++ b/src/renderer/src/store/slices/repos-refresh-identity.test.ts @@ -229,6 +229,57 @@ describe('repo catalog refresh identity', () => { 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() + 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() + + 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() + 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() + + 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() diff --git a/src/renderer/src/store/slices/repos.ts b/src/renderer/src/store/slices/repos.ts index 0b8f0dfcb68..3cfa784209d 100644 --- a/src/renderer/src/store/slices/repos.ts +++ b/src/renderer/src/store/slices/repos.ts @@ -672,10 +672,13 @@ function getMergedSourceRepoIdsForHostRefresh( reposById: ReadonlyMap, hostId: string ): string[] { + // Why: current-first keeps the order independent of which host refreshed. Prefixing the + // cross-host remainder made it a function of hostId, so a cross-host project's ids oscillated + // between refreshes and the projects reconcile could never reuse the row. return [ ...new Set([ - ...getSourceRepoIdsOutsideHost(previous, reposById, hostId), - ...getCurrentSourceRepoIds(current, new Set(reposById.keys())) + ...getCurrentSourceRepoIds(current, new Set(reposById.keys())), + ...getSourceRepoIdsOutsideHost(previous, reposById, hostId) ]) ] }