mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 16:02:50 +00:00
617 lines
22 KiB
TypeScript
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()
|
|
}
|
|
})
|
|
})
|