Files
orca/src/main/persistence-native-chat-tab-view-mode.test.ts
T
Neil a2aea5d0b0 fix(persistence): sweep rows owned by deregistered repo ids at load
Deregistering a project stranded every row it owned. Each pruning path is
gated on the repo still being in `state.repos`, so once an id leaves the
catalogue its metadata, identity aliases, lineage and session rows became
unreachable forever -- and on a paired client they rendered as phantom
worktrees under an "Unknown" project.

Reconcile against the repo catalogue on load instead: any repo id that owns
rows but is absent from `state.repos` has its rows removed through the same
path `removeProject` uses. Host-independent and session-independent, because
an orphan has no owner that could object -- which is also why this reaches a
client's mirror of a remote host's session partition, something no local
removal can do.

Only a full `<repoId>::<path>` locator seeds the orphan set; bare keys can be
folder workspace ids or repo-keyed revisions, and guessing wrong there would
delete live state. `retiredWorktreeNamesByRepo` is deliberately untouched so a
re-added repo cannot reissue a name onto a cwd that still holds a prior
occupant's agent state.

Test fixtures that wrote worktree rows without registering their repo were
relying on orphans surviving a reload; they now register the repo they name.

Refs #17776
2026-09-01 17:20:17 -07:00

126 lines
3.9 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { rmSync, mkdtempSync } from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { testState, createStore, writeDataFile, makeRepo } from './persistence-test-harness'
// Stub the ~/.ssh/config parser so the SSH-import test drives the real Store with deterministic hosts, not the operator's actual ~/.ssh/config.
const { loadUserSshConfigMock, sshConfigHostsToTargetsMock } = vi.hoisted(() => ({
loadUserSshConfigMock: vi.fn(),
sshConfigHostsToTargetsMock: vi.fn()
}))
vi.mock('./ssh/ssh-config-parser', () => ({
loadUserSshConfig: loadUserSshConfigMock,
sshConfigHostsToTargets: sshConfigHostsToTargetsMock
}))
const { trackMock, getCohortAtEmitMock } = vi.hoisted(() => ({
trackMock: vi.fn(),
getCohortAtEmitMock: vi.fn()
}))
vi.mock('electron', () => ({
app: {
getPath: () => testState.dir
},
safeStorage: {
isEncryptionAvailable: () => true,
encryptString: (plaintext: string) => Buffer.from(`encrypted:${plaintext}`, 'utf-8'),
decryptString: (ciphertext: Buffer) => {
const decoded = ciphertext.toString('utf-8')
if (!decoded.startsWith('encrypted:')) {
throw new Error('invalid ciphertext')
}
return decoded.slice('encrypted:'.length)
}
}
}))
vi.mock('./telemetry/client', () => ({
track: trackMock
}))
vi.mock('./telemetry/cohort-classifier', () => ({
getCohortAtEmit: getCohortAtEmitMock
}))
describe('Store native-chat tab viewMode persistence', () => {
beforeEach(() => {
testState.dir = mkdtempSync(join(tmpdir(), 'orca-test-'))
})
afterEach(() => {
rmSync(testState.dir, { recursive: true, force: true })
})
// Why: tabs persisted before viewMode existed default to 'terminal' so older sessions stay backward-compatible.
it('round-trips viewMode for unified tabs and defaults legacy tabs to terminal', async () => {
const WORKTREE = 'repo1::/worktree'
writeDataFile({
schemaVersion: 1,
repos: [makeRepo({ id: 'repo1', path: '/repo1' })],
worktreeMeta: {},
settings: {},
ui: {},
githubCache: { pr: {}, issue: {} },
workspaceSession: {
activeRepoId: 'r1',
activeWorktreeId: WORKTREE,
activeTabId: 'chat-tab',
tabsByWorktree: {},
terminalLayoutsByTabId: {},
sleepingAgentSessionsByPaneKey: {},
unifiedTabs: {
[WORKTREE]: [
{
id: 'chat-tab',
entityId: 'chat-tab',
groupId: 'g1',
worktreeId: WORKTREE,
contentType: 'terminal',
label: 'Agent',
customLabel: null,
color: null,
sortOrder: 0,
createdAt: 1,
viewMode: 'chat'
},
{
// Legacy tab persisted before viewMode existed — no field at all.
id: 'legacy-tab',
entityId: 'legacy-tab',
groupId: 'g1',
worktreeId: WORKTREE,
contentType: 'terminal',
label: 'Legacy',
customLabel: null,
color: null,
sortOrder: 1,
createdAt: 2
}
]
},
tabGroups: {
[WORKTREE]: [
{
id: 'g1',
worktreeId: WORKTREE,
activeTabId: 'chat-tab',
tabOrder: ['chat-tab', 'legacy-tab']
}
]
}
}
})
const store = await createStore()
const restored = store.getWorkspaceSession().unifiedTabs?.[WORKTREE] ?? []
const chatTab = restored.find((tab) => tab.id === 'chat-tab')
const legacyTab = restored.find((tab) => tab.id === 'legacy-tab')
expect(chatTab?.viewMode).toBe('chat')
// Missing on a legacy tab; renderer hydration treats absent as 'terminal'.
expect(legacyTab?.viewMode).toBeUndefined()
})
})