Files
orca/src/main/persistence-workspace-session-scrollback.test.ts

617 lines
22 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { writeFileSync, readFileSync, rmSync, mkdtempSync, mkdirSync, existsSync } from 'node:fs'
import type * as NodeFsPromises from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { isTerminalLeafId, makePaneKey } from '../shared/stable-pane-id'
import { TERMINAL_SCROLLBACK_REPLAY_BYTE_LIMIT } from '../shared/terminal-scrollback-limits'
import { MAX_BROWSER_HISTORY_ENTRIES } from '../shared/workspace-session-browser-history'
import {
testState,
createStore,
dataFile,
writeDataFile,
makeRepo,
makeTerminalTab
} from './persistence-test-harness'
import {
TEST_LEAF_1,
TEST_LEAF_2,
makeSessionWithTerminalBuffers,
makeSessionWithBrowserHistory
} from './persistence-session-fixtures'
import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup'
import { Store, initDataPath } from './persistence'
// 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()
}))
const asyncPrimaryWriteFailure = vi.hoisted(() => ({
error: null as Error | null,
targetPrefix: ''
}))
vi.mock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof NodeFsPromises>()
return {
...actual,
open: async (...args: Parameters<typeof actual.open>) => {
const target = args[0]
if (
asyncPrimaryWriteFailure.error &&
typeof target === 'string' &&
target.startsWith(asyncPrimaryWriteFailure.targetPrefix)
) {
throw asyncPrimaryWriteFailure.error
}
return actual.open(...args)
}
}
})
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', () => {
beforeEach(() => {
testState.dir = mkdtempSync(join(tmpdir(), 'orca-test-'))
trackMock.mockReset()
getCohortAtEmitMock.mockReset()
getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 })
asyncPrimaryWriteFailure.error = null
asyncPrimaryWriteFailure.targetPrefix = ''
})
afterEach(() => {
rmSync(testState.dir, { recursive: true, force: true })
asyncPrimaryWriteFailure.error = null
asyncPrimaryWriteFailure.targetPrefix = ''
})
// ── GitHub Cache ───────────────────────────────────────────────────
it('get/set GitHub cache round-trips', async () => {
const store = await createStore()
const cache = {
pr: { 'owner/repo#1': { data: null, fetchedAt: 1000 } },
issue: {}
}
store.setGitHubCache(cache)
expect(store.getGitHubCache()).toEqual(cache)
})
// ── Workspace Session ──────────────────────────────────────────────
it('get/set workspace session round-trips', async () => {
const store = await createStore()
const session = {
activeRepoId: 'r1',
activeWorktreeId: 'wt1',
activeTabId: 'tab1',
tabsByWorktree: {},
terminalLayoutsByTabId: {}
}
store.setWorkspaceSession(session)
expect(store.getWorkspaceSession()).toEqual(session)
})
it('patches workspace session without replacing unchanged slices', async () => {
const store = await createStore()
const tabsByWorktree = {
wt1: [makeTerminalTab({ id: 'tab1', ptyId: null, worktreeId: 'wt1' })]
}
const terminalLayoutsByTabId = {
tab1: { root: null, activeLeafId: null, expandedLeafId: null }
}
store.setWorkspaceSession({
activeRepoId: 'r1',
activeWorktreeId: 'wt1',
activeTabId: 'tab1',
tabsByWorktree,
terminalLayoutsByTabId,
activeConnectionIdsAtShutdown: ['ssh-1']
})
store.patchWorkspaceSession({
activeTabId: 'tab2',
activeConnectionIdsAtShutdown: undefined
})
const session = store.getWorkspaceSession()
expect(session.activeTabId).toBe('tab2')
expect(session.tabsByWorktree).toEqual(tabsByWorktree)
expect(session.terminalLayoutsByTabId).toEqual(terminalLayoutsByTabId)
expect(session.activeConnectionIdsAtShutdown).toBeUndefined()
})
it('uses full normalization for structural workspace session patches', async () => {
const store = await createStore()
store.addRepo(makeRepo({ id: 'local-repo', connectionId: null }))
store.setWorkspaceSession({
activeRepoId: 'local-repo',
activeWorktreeId: 'local-repo::/worktree',
activeTabId: 'tab-local',
tabsByWorktree: {
'local-repo::/worktree': [
makeTerminalTab({
id: 'tab-local',
ptyId: 'pty-local',
worktreeId: 'local-repo::/worktree'
})
]
},
terminalLayoutsByTabId: {}
})
store.patchWorkspaceSession({
terminalLayoutsByTabId: {
'tab-local': {
root: { type: 'leaf', leafId: TEST_LEAF_1 },
activeLeafId: TEST_LEAF_1,
expandedLeafId: null,
buffersByLeafId: { [TEST_LEAF_1]: 'local-scrollback' },
ptyIdsByLeafId: { [TEST_LEAF_1]: 'pty-local' }
}
}
})
expect(
store.getWorkspaceSession().terminalLayoutsByTabId['tab-local'].buffersByLeafId
).toBeUndefined()
})
it('stores remote terminal scrollback out of workspace session JSON', async () => {
const store = await createStore()
store.addRepo(makeRepo({ id: 'local-repo', connectionId: null }))
store.addRepo(makeRepo({ id: 'remote-repo', connectionId: 'ssh-target-1' }))
store.setWorkspaceSession(makeSessionWithTerminalBuffers())
const session = store.getWorkspaceSession()
expect(session.terminalLayoutsByTabId['local-tab'].buffersByLeafId).toBeUndefined()
expect(session.terminalLayoutsByTabId['local-tab'].ptyIdsByLeafId).toEqual({
[TEST_LEAF_1]: 'local-pty'
})
expect(session.terminalLayoutsByTabId['remote-tab'].buffersByLeafId).toBeUndefined()
expect(session.terminalLayoutsByTabId['remote-tab'].scrollbackRefsByLeafId).toEqual({
[TEST_LEAF_2]: expect.stringMatching(/^v1-[0-9a-f]{32}$/)
})
const ref = session.terminalLayoutsByTabId['remote-tab'].scrollbackRefsByLeafId?.[TEST_LEAF_2]
expect(ref ? store.readTerminalScrollbackSnapshot(ref) : null).toBe('remote-scrollback')
})
it('stores terminal scrollback snapshots beside explicit profile data files', async () => {
const profileDataDirectory = join(testState.dir, 'profiles', 'local-default')
const profileDataFile = join(profileDataDirectory, 'orca-data.json')
mkdirSync(profileDataDirectory, { recursive: true })
// Why: the legacy snapshot dir hangs off userData, which resolves through
// AppEnvironment — without this it points at the global fake's shared dir.
installFakeAppEnvironment({ getPath: () => testState.dir })
initDataPath()
const store = new Store({ dataFile: profileDataFile })
store.addRepo(makeRepo({ id: 'remote-repo', connectionId: 'ssh-target-1' }))
const session = makeSessionWithTerminalBuffers()
store.setWorkspaceSession({
...session,
tabsByWorktree: { 'remote-repo::/remote': session.tabsByWorktree['remote-repo::/remote'] },
terminalLayoutsByTabId: { 'remote-tab': session.terminalLayoutsByTabId['remote-tab'] }
})
const ref =
store.getWorkspaceSession().terminalLayoutsByTabId['remote-tab'].scrollbackRefsByLeafId?.[
TEST_LEAF_2
]
expect(ref).toEqual(expect.stringMatching(/^v1-[0-9a-f]{32}$/))
expect(existsSync(join(profileDataDirectory, 'terminal-scrollback', `${ref}.bin`))).toBe(true)
expect(existsSync(join(testState.dir, 'terminal-scrollback', `${ref}.bin`))).toBe(false)
})
it('reads legacy terminal scrollback snapshots for explicit profile data files', async () => {
const profileDataDirectory = join(testState.dir, 'profiles', 'local-default')
const profileDataFile = join(profileDataDirectory, 'orca-data.json')
const ref = 'v1-11111111111111111111111111111111'
const legacySnapshotDir = join(testState.dir, 'terminal-scrollback')
mkdirSync(profileDataDirectory, { recursive: true })
mkdirSync(legacySnapshotDir, { recursive: true })
writeFileSync(join(legacySnapshotDir, `${ref}.bin`), 'legacy-scrollback', 'utf-8')
// Why: the legacy snapshot dir hangs off userData, which resolves through
// AppEnvironment — without this it points at the global fake's shared dir.
installFakeAppEnvironment({ getPath: () => testState.dir })
initDataPath()
const store = new Store({ dataFile: profileDataFile })
expect(store.readTerminalScrollbackSnapshot(ref)).toBe('legacy-scrollback')
})
it('caps oversized browser history when setting workspace session', async () => {
const store = await createStore()
const oversizedSession = makeSessionWithBrowserHistory(500)
const oversizedBytes = Buffer.byteLength(JSON.stringify(oversizedSession))
store.setWorkspaceSession(oversizedSession)
const session = store.getWorkspaceSession()
const prunedBytes = Buffer.byteLength(JSON.stringify(session))
expect(session.browserUrlHistory).toHaveLength(MAX_BROWSER_HISTORY_ENTRIES)
expect(session.browserUrlHistory?.at(-1)?.url).toBe('https://example.com/199')
expect(prunedBytes).toBeLessThan(oversizedBytes / 2)
})
it('stores maybe-remote terminal scrollback out of workspace session JSON', async () => {
const store = await createStore()
store.setWorkspaceSession({
activeRepoId: 'remote-repo',
activeWorktreeId: 'remote-repo::/remote',
activeTabId: 'remote-tab',
tabsByWorktree: {
'remote-repo::/remote': [
makeTerminalTab({
id: 'remote-tab',
ptyId: 'remote-pty',
worktreeId: 'remote-repo::/remote'
})
]
},
terminalLayoutsByTabId: {
'remote-tab': {
root: { type: 'leaf', leafId: TEST_LEAF_2 },
activeLeafId: TEST_LEAF_2,
expandedLeafId: null,
buffersByLeafId: { [TEST_LEAF_2]: 'maybe-remote-scrollback' }
}
}
})
expect(
store.getWorkspaceSession().terminalLayoutsByTabId['remote-tab'].buffersByLeafId
).toBeUndefined()
expect(
store.getWorkspaceSession().terminalLayoutsByTabId['remote-tab'].scrollbackRefsByLeafId
).toEqual({
[TEST_LEAF_2]: expect.stringMatching(/^v1-[0-9a-f]{32}$/)
})
const ref =
store.getWorkspaceSession().terminalLayoutsByTabId['remote-tab'].scrollbackRefsByLeafId?.[
TEST_LEAF_2
]
expect(ref ? store.readTerminalScrollbackSnapshot(ref) : null).toBe('maybe-remote-scrollback')
})
it('deletes terminal scrollback snapshot files when refs leave the workspace session', async () => {
const store = await createStore()
store.addRepo(makeRepo({ id: 'remote-repo', connectionId: 'ssh-target-1' }))
const session = makeSessionWithTerminalBuffers()
store.setWorkspaceSession({
...session,
tabsByWorktree: { 'remote-repo::/remote': session.tabsByWorktree['remote-repo::/remote'] },
terminalLayoutsByTabId: { 'remote-tab': session.terminalLayoutsByTabId['remote-tab'] }
})
const ref =
store.getWorkspaceSession().terminalLayoutsByTabId['remote-tab'].scrollbackRefsByLeafId?.[
TEST_LEAF_2
]
expect(ref).toEqual(expect.stringMatching(/^v1-[0-9a-f]{32}$/))
if (!ref) {
throw new Error('expected scrollback snapshot ref')
}
expect(existsSync(join(testState.dir, 'terminal-scrollback', `${ref}.bin`))).toBe(true)
store.flushOrThrow()
store.setWorkspaceSession({
activeRepoId: null,
activeWorktreeId: null,
activeTabId: null,
tabsByWorktree: {},
terminalLayoutsByTabId: {}
})
expect(existsSync(join(testState.dir, 'terminal-scrollback', `${ref}.bin`))).toBe(false)
const stillPublished = JSON.parse(readFileSync(dataFile(), 'utf-8')) as {
workspaceSession: {
terminalLayoutsByTabId: Record<string, { scrollbackRefsByLeafId?: Record<string, string> }>
}
}
expect(
stillPublished.workspaceSession.terminalLayoutsByTabId['remote-tab']
?.scrollbackRefsByLeafId?.[TEST_LEAF_2]
).toBe(ref)
})
it('leaves durable JSON pointing at deleted scrollback when the replacement write fails', async () => {
const store = await createStore()
store.addRepo(makeRepo({ id: 'remote-repo', connectionId: 'ssh-target-1' }))
const session = makeSessionWithTerminalBuffers()
store.setWorkspaceSession({
...session,
tabsByWorktree: {
'remote-repo::/remote': session.tabsByWorktree['remote-repo::/remote']
},
terminalLayoutsByTabId: {
'remote-tab': session.terminalLayoutsByTabId['remote-tab']
}
})
const ref =
store.getWorkspaceSession().terminalLayoutsByTabId['remote-tab'].scrollbackRefsByLeafId?.[
TEST_LEAF_2
]
if (!ref) {
throw new Error('expected scrollback snapshot ref')
}
const snapshotPath = join(testState.dir, 'terminal-scrollback', `${ref}.bin`)
store.flushOrThrow()
const durableBeforeRemoval = readFileSync(dataFile(), 'utf-8')
expect(existsSync(snapshotPath)).toBe(true)
const writeError = Object.assign(new Error('profile mount rejected replacement write'), {
code: 'EIO'
})
asyncPrimaryWriteFailure.error = writeError
asyncPrimaryWriteFailure.targetPrefix = `${dataFile()}.`
const errors = vi.spyOn(console, 'error').mockImplementation(() => {})
try {
store.setWorkspaceSession({
activeRepoId: null,
activeWorktreeId: null,
activeTabId: null,
tabsByWorktree: {},
terminalLayoutsByTabId: {}
})
expect(existsSync(snapshotPath)).toBe(false)
expect(store.getWorkspaceSession().terminalLayoutsByTabId).toEqual({})
await expect(store.flushPendingOrThrowAsync()).rejects.toBe(writeError)
} finally {
errors.mockRestore()
}
expect(readFileSync(dataFile(), 'utf-8')).toBe(durableBeforeRemoval)
const stillPublished = JSON.parse(durableBeforeRemoval) as {
workspaceSession: {
terminalLayoutsByTabId: Record<string, { scrollbackRefsByLeafId?: Record<string, string> }>
}
}
expect(
stillPublished.workspaceSession.terminalLayoutsByTabId['remote-tab']
?.scrollbackRefsByLeafId?.[TEST_LEAF_2]
).toBe(ref)
})
it('reads only the replay tail from oversized terminal scrollback snapshots', async () => {
const store = await createStore()
const ref = 'v1-00000000000000000000000000000000'
const snapshotDir = join(testState.dir, 'terminal-scrollback')
mkdirSync(snapshotDir, { recursive: true })
writeFileSync(
join(snapshotDir, `${ref}.bin`),
`stale-prefix-${'x'.repeat(TERMINAL_SCROLLBACK_REPLAY_BYTE_LIMIT)}tail`,
'utf-8'
)
const buffer = store.readTerminalScrollbackSnapshot(ref)
expect(buffer).toHaveLength(TERMINAL_SCROLLBACK_REPLAY_BYTE_LIMIT)
expect(buffer?.startsWith('stale-prefix')).toBe(false)
expect(buffer?.endsWith('tail')).toBe(true)
})
it('strips legacy local terminal scrollback buffers when loading workspace session', async () => {
writeDataFile({
schemaVersion: 1,
repos: [
makeRepo({ id: 'local-repo', connectionId: null }),
makeRepo({ id: 'remote-repo', connectionId: 'ssh-target-1' })
],
worktreeMeta: {},
settings: {},
ui: {},
githubCache: { pr: {}, issue: {} },
workspaceSession: makeSessionWithTerminalBuffers()
})
const store = await createStore()
const session = store.getWorkspaceSession()
expect(session.terminalLayoutsByTabId['local-tab'].buffersByLeafId).toBeUndefined()
expect(session.terminalLayoutsByTabId['remote-tab'].buffersByLeafId).toBeUndefined()
expect(session.terminalLayoutsByTabId['remote-tab'].scrollbackRefsByLeafId).toEqual({
[TEST_LEAF_2]: expect.stringMatching(/^v1-[0-9a-f]{32}$/)
})
const ref = session.terminalLayoutsByTabId['remote-tab'].scrollbackRefsByLeafId?.[TEST_LEAF_2]
expect(ref ? store.readTerminalScrollbackSnapshot(ref) : null).toBe('remote-scrollback')
})
it('caps oversized legacy browser history when loading workspace session', async () => {
writeDataFile({
schemaVersion: 1,
repos: [],
worktreeMeta: {},
settings: {},
ui: {},
githubCache: { pr: {}, issue: {} },
workspaceSession: makeSessionWithBrowserHistory(500)
})
const store = await createStore()
const session = store.getWorkspaceSession()
expect(session.browserUrlHistory).toHaveLength(MAX_BROWSER_HISTORY_ENTRIES)
expect(session.browserUrlHistory?.at(-1)?.url).toBe('https://example.com/199')
})
it('remaps legacy SSH lease leaf ids when loading legacy workspace layouts', async () => {
writeDataFile({
schemaVersion: 1,
repos: [],
worktreeMeta: {},
settings: {},
ui: {},
githubCache: { pr: {}, issue: {} },
workspaceSession: {
activeRepoId: 'r1',
activeWorktreeId: 'wt1',
activeTabId: 'tab1',
tabsByWorktree: {
wt1: [
{
id: 'tab1',
worktreeId: 'wt1',
title: 'Terminal',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1,
ptyId: 'remote-pty'
}
]
},
terminalLayoutsByTabId: {
tab1: {
root: { type: 'leaf', leafId: 'pane:1' },
activeLeafId: 'pane:1',
expandedLeafId: null,
ptyIdsByLeafId: { 'pane:1': 'remote-pty' }
}
}
},
sshRemotePtyLeases: [
{
targetId: 'ssh-1',
ptyId: 'remote-pty',
worktreeId: 'wt1',
tabId: 'tab1',
leafId: 'pane:1',
state: 'detached',
createdAt: 1,
updatedAt: 1
}
]
})
const store = await createStore()
const layout = store.getWorkspaceSession().terminalLayoutsByTabId.tab1
const leafId = layout.root?.type === 'leaf' ? layout.root.leafId : null
if (leafId === null) {
throw new Error('Expected remapped leaf id')
}
expect(isTerminalLeafId(leafId)).toBe(true)
expect(layout.ptyIdsByLeafId).toEqual({ [leafId]: 'remote-pty' })
expect(store.getSshRemotePtyLeases('ssh-1')[0].leafId).toBe(leafId)
})
it('hydrates legacy numeric agent status cache through the pane identity migration', async () => {
const agentHooksDir = join(testState.dir, 'agent-hooks')
mkdirSync(agentHooksDir, { recursive: true })
writeDataFile({
schemaVersion: 1,
repos: [],
worktreeMeta: {},
settings: {},
ui: {},
githubCache: { pr: {}, issue: {} },
workspaceSession: {
activeRepoId: 'r1',
activeWorktreeId: 'wt1',
activeTabId: 'tab1',
tabsByWorktree: {
wt1: [
{
id: 'tab1',
worktreeId: 'wt1',
title: 'Terminal',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1,
ptyId: 'local-pty'
}
]
},
terminalLayoutsByTabId: {
tab1: {
root: { type: 'leaf', leafId: 'pane:1' },
activeLeafId: 'pane:1',
expandedLeafId: null,
ptyIdsByLeafId: { 'pane:1': 'local-pty' }
}
}
}
})
writeFileSync(
join(agentHooksDir, 'last-status.json'),
JSON.stringify({
version: 2,
entries: {
'tab1:1': {
paneKey: 'tab1:1',
tabId: 'tab1',
worktreeId: 'wt1',
connectionId: null,
receivedAt: Date.now(),
stateStartedAt: Date.now() - 1000,
payload: { state: 'working', prompt: 'legacy numeric prompt', agentType: 'claude' }
}
}
}),
'utf-8'
)
const store = await createStore()
const { agentHookServer } = await import('./agent-hooks/server')
await agentHookServer.start({ env: 'production', userDataPath: testState.dir })
try {
const layout = store.getWorkspaceSession().terminalLayoutsByTabId.tab1
const leafId = layout.root?.type === 'leaf' ? layout.root.leafId : null
if (leafId === null) {
throw new Error('Expected remapped leaf id')
}
const stablePaneKey = makePaneKey('tab1', leafId)
expect(agentHookServer.getStatusSnapshot()).toEqual([
expect.objectContaining({
paneKey: stablePaneKey,
tabId: 'tab1',
worktreeId: 'wt1',
state: 'working',
prompt: 'legacy numeric prompt',
agentType: 'claude'
})
])
} finally {
agentHookServer.stop()
}
})
})