Files
orca/src/main/runtime/runtime-managed-worktree-queries.test.ts
T

412 lines
15 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Repo } from '../../shared/repo-types'
import type { WorktreeMeta } from '../../shared/worktree/meta-types'
import type { GitWorktreeInfo } from '../../shared/worktree/types'
const { pruneLineageMock, pruneMetadataMock } = vi.hoisted(() => ({
pruneLineageMock: vi.fn(),
pruneMetadataMock: vi
.fn()
.mockResolvedValue({ scanGenerationCurrent: true, preservedMetadataCandidateIds: new Set() })
}))
vi.mock('../worktree-lineage-pruning', () => ({
pruneLineageForMissingRepoWorktrees: pruneLineageMock
}))
vi.mock(
'../ipc/worktrees/listing/authoritative-local-worktree-metadata-pruning',
async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
pruneMetadataMissingFromAuthoritativeLocalScan: pruneMetadataMock
})
)
const { hiddenHostIds } = vi.hoisted(() => ({ hiddenHostIds: new Set<string>() }))
vi.mock('./runtime-visible-catalog', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
runtimeHiddenHostIds: () => hiddenHostIds
}))
vi.mock('../project-runtime-git-options', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
getLocalProjectWorktreeGitOptions: () => ({})
}))
import { RuntimeManagedWorktreeQueries } from './runtime-managed-worktree-queries'
import type { RuntimeStore } from './runtime-store-contract'
import {
bumpLocalWorktreeScanGeneration,
getLocalWorktreeScanGeneration,
resetLocalWorktreeScanGenerationsForTests
} from '../local-worktree-scan-generation'
import type { HostWorktreeTarget } from '../../shared/worktree/host-qualified-identity'
const settings = {
workspaceDir: '/worktrees',
nestWorkspaces: true,
refreshLocalBaseRefOnWorktreeCreate: false,
branchPrefix: 'none',
branchPrefixCustom: ''
}
function folderRepo(overrides: Partial<Repo> = {}): Repo {
return {
id: 'repo-1',
path: '/workspace/app',
displayName: 'Local app',
badgeColor: '#000000',
addedAt: 1,
kind: 'folder',
...overrides
}
}
function metadata(overrides: Partial<WorktreeMeta> = {}): WorktreeMeta {
return {
displayName: '',
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 0,
...overrides
}
}
function queries(
store: RuntimeStore,
overrides: Partial<ConstructorParameters<typeof RuntimeManagedWorktreeQueries>[0]> = {}
): RuntimeManagedWorktreeQueries {
return new RuntimeManagedWorktreeQueries({
getStore: () => store,
listResolved: async () => [],
resolveRepo: async () => store.getRepos()[0]!,
selectRepos: () => store.getRepos(),
scanRepo: async () => ({ ok: true, worktrees: [] }),
listKnownHostIds: () => [],
...overrides
})
}
describe('RuntimeManagedWorktreeQueries.listDetected', () => {
it('stamps a scanned listing with the generation its scan began at', async () => {
resetLocalWorktreeScanGenerationsForTests()
const repo = folderRepo({ kind: 'git', path: '/source/git-app' })
const store = {
getRepos: () => [repo],
getRepo: () => repo,
getAllWorktreeMeta: () => ({}),
getWorktreeMeta: () => undefined,
setWorktreeMeta: vi.fn(),
getAllWorktreeLineage: () => ({}),
getSettings: () => settings
} as unknown as RuntimeStore
const atScanStart = getLocalWorktreeScanGeneration(repo.id)
const scanRepo = vi.fn(async () => {
// A mutation lands after the scan settles; the listing still names the catalog it scanned.
return { ok: true as const, worktrees: [] }
})
const listing = await queries(store, { scanRepo }).listDetected(repo)
bumpLocalWorktreeScanGeneration(repo.id)
expect(listing.authoritative).toBe(true)
expect(listing.catalogVersion?.sequence).toBe(atScanStart)
expect(listing.catalogVersion?.epoch).toEqual(expect.any(String))
expect(getLocalWorktreeScanGeneration(repo.id)).toBeGreaterThan(atScanStart)
})
it("does not project another host's folder metadata", async () => {
const local = folderRepo()
const remote = folderRepo({ connectionId: 'build-box', displayName: 'Remote app' })
const rootId = `${local.id}::${local.path}`
const foreignMeta = metadata({ displayName: 'Wrong host', hostId: 'ssh:build-box' })
const store = {
getRepos: () => [local, remote],
getRepo: () => local,
getAllWorktreeMeta: () => ({ [rootId]: foreignMeta }),
getWorktreeMeta: () => foreignMeta,
setWorktreeMeta: vi.fn(),
getAllWorktreeLineage: () => ({}),
getSettings: () => settings
} as unknown as RuntimeStore
const result = await queries(store).listDetected(local)
expect(result.worktrees).toHaveLength(1)
expect(result.worktrees[0]).toMatchObject({
id: rootId,
hostId: 'local',
displayName: 'Local app'
})
})
it('omits host-owned source defaults for clients that do not support them', async () => {
const repo = folderRepo({ path: '/source/app' })
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a partial store mock; the code under test reads only the members it defines.
const store = {
getRepos: () => [repo],
getRepo: () => repo,
getAllWorktreeMeta: () => ({}),
getWorktreeMeta: () => undefined,
setWorktreeMeta: vi.fn((_target: HostWorktreeTarget, updates) => metadata(updates)),
getAllWorktreeLineage: () => ({}),
getSettings: () => ({
...settings,
worktreeVisibilityDefaults: {
external: 'show' as const,
customSources: [{ id: 'host-source', rootPath: '/source' }],
sourcePreferences: { custom: { 'host-source': 'show' as const } }
}
})
} as unknown as RuntimeStore
const current = await queries(store).listDetected(repo, true)
const legacy = await queries(store).listDetected(repo, false)
expect(current.worktrees[0]).toMatchObject({
visibilitySource: { kind: 'custom', id: 'host-source' }
})
expect(legacy.worktrees[0]).not.toHaveProperty('visibilitySource')
})
})
describe('RuntimeManagedWorktreeQueries.list host scope', () => {
// Measured on hardware before this fix, same runtime and same refusing SSH host in the same
// second: the UNSCOPED listing reported `omittedHostIds: ["local","ssh:ssh-scope-refused"]` with
// `--host` selectors, while the SCOPED listing reported `{"hostIds":[],"omittedHostIds":[]}`.
// A listing that covered nothing, reporting no gaps, is indistinguishable from a repo that
// genuinely has no worktrees -- the thing docs/reference/ssh-execution-boundary.md forbids.
function sshStore(): RuntimeStore {
const repo = folderRepo({
id: 'repo-ssh',
kind: 'git',
connectionId: 'conn-1',
path: '/home/dev/app'
})
return {
getRepos: () => [repo],
getRepo: () => repo,
getAllWorktreeMeta: () => ({}),
getWorktreeMeta: () => undefined,
setWorktreeMeta: vi.fn(),
getAllWorktreeLineage: () => ({}),
getSettings: () => settings
} as unknown as RuntimeStore
}
it('names the scoped repo host as omitted when the listing covered nothing', async () => {
const result = await queries(sshStore()).list('repo-ssh', 50)
expect(result.totalCount).toBe(0)
expect(result.hostScope).toEqual({
hostIds: [],
omittedHostIds: ['ssh:conn-1']
})
})
// A selector still resolves a converted host's retained repo; its host must not leak back out.
it('never names a hidden host, even for a selector that resolves to its repo', async () => {
hiddenHostIds.add('ssh:conn-1')
try {
const result = await queries(sshStore()).list('repo-ssh', 50)
expect(result.hostScope?.omittedHostIds).toEqual([])
} finally {
hiddenHostIds.clear()
}
})
it('does not report the scoped host as omitted once it contributes rows', async () => {
const store = sshStore()
const result = await queries(store, {
listResolved: async () =>
[
{
id: 'repo-ssh::/home/dev/app',
repoId: 'repo-ssh',
path: '/home/dev/app',
hostId: 'ssh:conn-1'
}
] as never
}).list('repo-ssh', 50)
expect(result.hostScope?.hostIds).toEqual(['ssh:conn-1'])
expect(result.hostScope?.omittedHostIds).toEqual([])
})
// The caller scoped the listing, so the hosts they excluded must not come back as gaps.
it('never names a host the caller scoped out', async () => {
const scoped = await queries(sshStore(), {
listKnownHostIds: () => ['local', 'ssh:other', 'runtime:elsewhere'] as never
}).list('repo-ssh', 50)
expect(scoped.hostScope?.omittedHostIds).toEqual(['ssh:conn-1'])
})
// `getRepoExecutionHostId` derives the host from two spellings, and a scoped listing that named
// the wrong one would be worse than naming none. These pin both.
it('names the local host for a scoped local repo', async () => {
const repo = folderRepo({ id: 'repo-local', kind: 'git', path: '/workspace/local' })
const store = {
getRepos: () => [repo],
getRepo: () => repo,
getAllWorktreeMeta: () => ({}),
getWorktreeMeta: () => undefined,
setWorktreeMeta: vi.fn(),
getAllWorktreeLineage: () => ({}),
getSettings: () => settings
} as unknown as RuntimeStore
const result = await queries(store).list('repo-local', 50)
expect(result.hostScope?.omittedHostIds).toEqual(['local'])
})
it('prefers executionHostId over connectionId for the scoped host', async () => {
const repo = folderRepo({
id: 'repo-runtime',
kind: 'git',
connectionId: 'conn-legacy',
executionHostId: 'runtime:env-1',
path: '/workspace/runtime'
})
const store = {
getRepos: () => [repo],
getRepo: () => repo,
getAllWorktreeMeta: () => ({}),
getWorktreeMeta: () => undefined,
setWorktreeMeta: vi.fn(),
getAllWorktreeLineage: () => ({}),
getSettings: () => settings
} as unknown as RuntimeStore
const result = await queries(store).list('repo-runtime', 50)
expect(result.hostScope?.omittedHostIds).toEqual(['runtime:env-1'])
})
it('still reports every configured host when the listing is unscoped', async () => {
const unscoped = await queries(sshStore(), {
listKnownHostIds: () => ['local', 'ssh:conn-1'] as never
}).list(undefined, 50)
expect(unscoped.hostScope?.omittedHostIds).toEqual(['local', 'ssh:conn-1'])
})
})
describe('RuntimeManagedWorktreeQueries.listDetected overtaken scan', () => {
const repo = folderRepo({ kind: 'git' })
const created = '/worktrees/created'
function rows(paths: readonly string[]): GitWorktreeInfo[] {
return paths.map((path) => ({
path,
head: 'abc',
branch: 'main',
isBare: false,
isMainWorktree: path === repo.path
}))
}
function gitStore(overrides: Record<string, unknown> = {}): RuntimeStore {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the git listing reads only these store members; the prunes that would need more are mocked above.
return {
getRepos: () => [repo],
getRepo: () => repo,
getAllWorktreeMeta: () => ({}),
getWorktreeMeta: () => undefined,
setWorktreeMeta: vi.fn(),
getAllWorktreeLineage: () => ({}),
getSettings: () => settings,
...overrides
} as unknown as RuntimeStore
}
/** A scan a worktree change lands under: the generation moves before the rows come back. */
function overtakenScan(paths: readonly string[]) {
return async () => {
bumpLocalWorktreeScanGeneration(repo.id)
return { ok: true as const, worktrees: rows(paths) }
}
}
beforeEach(() => {
resetLocalWorktreeScanGenerationsForTests()
pruneLineageMock.mockClear()
pruneMetadataMock.mockClear()
})
it('re-runs a scan a worktree change overtook and publishes the second scan as authoritative', async () => {
const scanRepo = vi
.fn()
.mockImplementationOnce(overtakenScan([repo.path]))
.mockResolvedValueOnce({ ok: true, worktrees: rows([repo.path, created]) })
const result = await queries(gitStore(), { scanRepo }).listDetected(repo)
expect(scanRepo).toHaveBeenCalledTimes(2)
expect(result).toMatchObject({ authoritative: true, source: 'git' })
expect(result.worktrees.map((worktree) => worktree.path)).toEqual([repo.path, created])
// The overtaken rows omit the created worktree; only the settled rows may prune.
expect(pruneLineageMock).toHaveBeenCalledTimes(1)
expect(pruneLineageMock.mock.calls[0]?.[2]).toEqual(rows([repo.path, created]))
})
it('prunes metadata against the expectation and generation of the scan it publishes', async () => {
const expectations: unknown[] = []
const generations: number[] = []
const store = gitStore({
pruneSessionlessMissingLocalWorktreeMetadataForRepo: vi.fn(() => []),
getProfileStorageDirectory: () => '/profile',
captureNativeLocalWorktreeMetadataScanExpectation: vi.fn(() => {
const expectation = { repo, metadata: [] }
expectations.push(expectation)
generations.push(getLocalWorktreeScanGeneration(repo.id))
return expectation
})
})
const scanRepo = vi
.fn()
.mockImplementationOnce(overtakenScan([repo.path]))
.mockResolvedValueOnce({ ok: true, worktrees: rows([repo.path, created]) })
await queries(store, { scanRepo }).listDetected(repo)
expect(expectations).toHaveLength(2)
expect(pruneMetadataMock).toHaveBeenCalledTimes(1)
expect(pruneMetadataMock.mock.calls[0]?.[0]).toMatchObject({
scan: expectations[1],
scanGeneration: generations[1],
gitWorktrees: rows([repo.path, created])
})
})
it('publishes the last scan non-authoritatively once the re-scan bound is spent', async () => {
const scanRepo = vi.fn(overtakenScan([repo.path]))
const result = await queries(gitStore(), { scanRepo }).listDetected(repo)
// One scan plus the two re-runs the bound allows.
expect(scanRepo).toHaveBeenCalledTimes(3)
expect(result).toMatchObject({ authoritative: false, source: 'git' })
expect(result.unavailableReason).toBeUndefined()
expect(result.failureKind).toBeUndefined()
expect(result.worktrees.map((worktree) => worktree.path)).toEqual([repo.path])
expect(pruneLineageMock).not.toHaveBeenCalled()
expect(pruneMetadataMock).not.toHaveBeenCalled()
})
it('does not re-run a failed scan the change overtook', async () => {
const scanRepo = vi.fn(async () => {
bumpLocalWorktreeScanGeneration(repo.id)
return { ok: false as const, worktrees: [] }
})
const result = await queries(gitStore(), { scanRepo }).listDetected(repo)
expect(scanRepo).toHaveBeenCalledTimes(1)
expect(result).toMatchObject({ authoritative: false, source: 'metadata-fallback' })
})
})