mirror of
https://github.com/stablyai/orca.git
synced 2026-09-27 16:02:35 +00:00
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
757 lines
25 KiB
TypeScript
757 lines
25 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
import { writeFileSync, rmSync, mkdtempSync } from 'node:fs'
|
|
import { join } from 'node:path'
|
|
import { tmpdir } from 'node:os'
|
|
import type { GlobalSettings } from '../shared/global-settings-types'
|
|
import type { PersistedState } from '../shared/persisted-state-types'
|
|
import { getDefaultWorkspaceSession } from '../shared/constants'
|
|
import {
|
|
testState,
|
|
createStore,
|
|
writeDataFile,
|
|
readDataFile,
|
|
collectPropertyPaths,
|
|
makeRepo,
|
|
makeTerminalTab
|
|
} from './persistence-test-harness'
|
|
import {
|
|
getLocalWorktreeScanGeneration,
|
|
isLocalWorktreeScanGenerationCurrent
|
|
} from './local-worktree-scan-generation'
|
|
|
|
// 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', () => {
|
|
beforeEach(() => {
|
|
testState.dir = mkdtempSync(join(tmpdir(), 'orca-test-'))
|
|
trackMock.mockReset()
|
|
getCohortAtEmitMock.mockReset()
|
|
getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 })
|
|
})
|
|
|
|
afterEach(() => {
|
|
rmSync(testState.dir, { recursive: true, force: true })
|
|
})
|
|
// ── 9. Settings: get/update ────────────────────────────────────────
|
|
|
|
it('updateSettings merges partial updates', async () => {
|
|
const store = await createStore()
|
|
const initial = store.getSettings()
|
|
expect(initial.theme).toBe('system')
|
|
|
|
const updated = store.updateSettings({
|
|
theme: 'dark',
|
|
editorAutoSave: true,
|
|
editorAutoSaveDelayMs: 1500,
|
|
appFontFamily: 'Inter',
|
|
terminalFontSize: 16,
|
|
terminalFontWeight: 600,
|
|
terminalFontWeightBold: 800
|
|
})
|
|
expect(updated.theme).toBe('dark')
|
|
expect(updated.editorAutoSave).toBe(true)
|
|
expect(updated.editorAutoSaveDelayMs).toBe(1500)
|
|
expect(updated.appFontFamily).toBe('Inter')
|
|
expect(updated.terminalFontSize).toBe(16)
|
|
expect(updated.terminalFontWeight).toBe(600)
|
|
expect(updated.terminalFontWeightBold).toBe(800)
|
|
// Other fields preserved
|
|
expect(updated.branchPrefix).toBe('git-username')
|
|
})
|
|
|
|
it('persists the agent skill sharing capability as an exact boolean', async () => {
|
|
const store = await createStore()
|
|
|
|
expect(store.updateSettings({ agentSkillSharingEnabled: true }).agentSkillSharingEnabled).toBe(
|
|
true
|
|
)
|
|
expect(
|
|
store.updateSettings({ agentSkillSharingEnabled: 'yes' as never }).agentSkillSharingEnabled
|
|
).toBe(false)
|
|
expect(
|
|
store.updateSettings({ agentSkillSharingEnabled: 1 as never }).agentSkillSharingEnabled
|
|
).toBe(false)
|
|
})
|
|
|
|
it('normalizes bot-author overrides on load and every settings write', async () => {
|
|
writeDataFile({
|
|
settings: {
|
|
prBotAuthorOverrides: [' GretelFlux ', 'gretelflux', 42, '', 'another-bot']
|
|
}
|
|
})
|
|
const store = await createStore()
|
|
|
|
expect(store.getSettings().prBotAuthorOverrides).toEqual(['another-bot', 'gretelflux'])
|
|
|
|
const oversized = Array.from(
|
|
{ length: 600 },
|
|
(_, index) => ` bot-${String(index).padStart(4, '0')} `
|
|
)
|
|
const updated = store.updateSettings({ prBotAuthorOverrides: oversized })
|
|
|
|
expect(updated.prBotAuthorOverrides).toHaveLength(500)
|
|
expect(updated.prBotAuthorOverrides[0]).toBe('bot-0000')
|
|
expect(updated.prBotAuthorOverrides[499]).toBe('bot-0499')
|
|
})
|
|
|
|
it('normalizes custom mobile pairing addresses on load and every settings write', async () => {
|
|
writeDataFile({
|
|
settings: {
|
|
mobilePairingCustomAddress: 'host:99999',
|
|
mobilePairingCustomAddresses: [' first.example:6768 ', 'host:99999', 'first.example:6768']
|
|
}
|
|
})
|
|
const store = await createStore()
|
|
|
|
expect(store.getSettings().mobilePairingCustomAddress).toBeNull()
|
|
expect(store.getSettings().mobilePairingCustomAddresses).toEqual(['first.example:6768'])
|
|
store.flush()
|
|
expect(
|
|
(readDataFile() as { settings?: GlobalSettings }).settings?.mobilePairingCustomAddress
|
|
).toBeNull()
|
|
|
|
const updated = store.updateSettings({
|
|
mobilePairingCustomAddress: ' 100.126.117.25:6768 '
|
|
})
|
|
expect(updated.mobilePairingCustomAddress).toBe('100.126.117.25:6768')
|
|
expect(updated.mobilePairingCustomAddresses).toEqual([
|
|
'first.example:6768',
|
|
'100.126.117.25:6768'
|
|
])
|
|
store.flush()
|
|
expect(
|
|
(readDataFile() as { settings?: GlobalSettings }).settings?.mobilePairingCustomAddress
|
|
).toBe('100.126.117.25:6768')
|
|
|
|
expect(
|
|
store.updateSettings({ mobilePairingCustomAddress: 42 as never }).mobilePairingCustomAddress
|
|
).toBeNull()
|
|
|
|
expect(
|
|
store.updateSettings({
|
|
mobilePairingCustomAddresses: [' second.example ', 'host:99999', 'second.example']
|
|
}).mobilePairingCustomAddresses
|
|
).toEqual(['second.example'])
|
|
|
|
expect(
|
|
store.updateSettings({
|
|
mobilePairingCustomAddress: 'active.example:6768',
|
|
mobilePairingCustomAddresses: ['second.example']
|
|
}).mobilePairingCustomAddresses
|
|
).toEqual(['second.example', 'active.example:6768'])
|
|
|
|
expect(
|
|
store.updateSettings({
|
|
mobilePairingCustomAddresses: ['third.example']
|
|
}).mobilePairingCustomAddresses
|
|
).toEqual(['third.example', 'active.example:6768'])
|
|
})
|
|
|
|
it('notifies settings listeners with changed keys only', async () => {
|
|
const store = await createStore()
|
|
const listener = vi.fn()
|
|
store.onSettingsChanged(listener)
|
|
|
|
store.updateSettings(
|
|
{
|
|
theme: 'dark',
|
|
disabledTuiAgents: ['codex', 'not-real', 'codex'] as never
|
|
},
|
|
{ notifyListeners: true, originWebContentsId: 42 }
|
|
)
|
|
|
|
expect(listener).toHaveBeenCalledWith(
|
|
{
|
|
theme: 'dark',
|
|
disabledTuiAgents: ['codex']
|
|
},
|
|
expect.objectContaining({
|
|
theme: 'dark',
|
|
disabledTuiAgents: ['codex']
|
|
}),
|
|
42
|
|
)
|
|
})
|
|
|
|
it('does not notify settings listeners for unchanged scalar updates', async () => {
|
|
const store = await createStore()
|
|
const listener = vi.fn()
|
|
store.onSettingsChanged(listener)
|
|
|
|
store.updateSettings({ theme: store.getSettings().theme }, { notifyListeners: true })
|
|
|
|
expect(listener).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('does not notify settings listeners unless requested by the producer', async () => {
|
|
const store = await createStore()
|
|
const listener = vi.fn()
|
|
store.onSettingsChanged(listener)
|
|
|
|
store.updateSettings({ theme: 'dark' })
|
|
|
|
expect(listener).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('invalidates local worktree scans when the global Windows runtime changes', async () => {
|
|
writeDataFile({ repos: [makeRepo()] })
|
|
const store = await createStore()
|
|
const generation = getLocalWorktreeScanGeneration('r1')
|
|
|
|
store.updateSettings({ theme: 'dark' })
|
|
expect(isLocalWorktreeScanGenerationCurrent('r1', generation)).toBe(true)
|
|
|
|
store.updateSettings({
|
|
localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' }
|
|
})
|
|
|
|
expect(isLocalWorktreeScanGenerationCurrent('r1', generation)).toBe(false)
|
|
})
|
|
|
|
it('migrates missing terminal scrollback rows to the row default and writes back rows only', async () => {
|
|
writeDataFile({ settings: {} })
|
|
|
|
const store = await createStore()
|
|
|
|
expect(store.getSettings().terminalScrollbackRows).toBe(5_000)
|
|
|
|
store.flush()
|
|
const persisted = readDataFile() as { settings?: Record<string, unknown> }
|
|
expect(persisted.settings?.terminalScrollbackRows).toBe(5_000)
|
|
expect(persisted.settings).not.toHaveProperty('terminalScrollbackBytes')
|
|
})
|
|
|
|
it('migrates legacy terminal scrollback byte presets by intent', async () => {
|
|
writeDataFile({
|
|
settings: {
|
|
terminalScrollbackBytes: 25_000_000
|
|
}
|
|
})
|
|
|
|
const store = await createStore()
|
|
|
|
expect(store.getSettings().terminalScrollbackRows).toBe(10_000)
|
|
|
|
store.flush()
|
|
const persisted = readDataFile() as { settings?: Record<string, unknown> }
|
|
expect(persisted.settings?.terminalScrollbackRows).toBe(10_000)
|
|
expect(persisted.settings).not.toHaveProperty('terminalScrollbackBytes')
|
|
})
|
|
|
|
it('lets persisted terminal scrollback rows win over legacy bytes', async () => {
|
|
writeDataFile({
|
|
settings: {
|
|
terminalScrollbackRows: 25_000,
|
|
terminalScrollbackBytes: 100_000_000
|
|
}
|
|
})
|
|
|
|
const store = await createStore()
|
|
|
|
expect(store.getSettings().terminalScrollbackRows).toBe(25_000)
|
|
|
|
store.flush()
|
|
const persisted = readDataFile() as { settings?: Record<string, unknown> }
|
|
expect(persisted.settings?.terminalScrollbackRows).toBe(25_000)
|
|
expect(persisted.settings).not.toHaveProperty('terminalScrollbackBytes')
|
|
})
|
|
|
|
it('normalizes invalid and clamped terminal scrollback rows on load', async () => {
|
|
writeDataFile({
|
|
settings: {
|
|
terminalScrollbackRows: '50000'
|
|
}
|
|
})
|
|
|
|
const invalidStore = await createStore()
|
|
expect(invalidStore.getSettings().terminalScrollbackRows).toBe(5_000)
|
|
invalidStore.flush()
|
|
|
|
writeDataFile({
|
|
settings: {
|
|
terminalScrollbackRows: 75_000
|
|
}
|
|
})
|
|
|
|
const clampedStore = await createStore()
|
|
expect(clampedStore.getSettings().terminalScrollbackRows).toBe(50_000)
|
|
})
|
|
|
|
it('normalizes terminal scrollback row updates and ignores stale byte updates', async () => {
|
|
const store = await createStore()
|
|
const listener = vi.fn()
|
|
store.onSettingsChanged(listener)
|
|
|
|
const updated = store.updateSettings(
|
|
{
|
|
terminalScrollbackRows: 75_000,
|
|
terminalScrollbackBytes: 250_000_000
|
|
} as never,
|
|
{ notifyListeners: true }
|
|
)
|
|
|
|
expect(updated.terminalScrollbackRows).toBe(50_000)
|
|
expect(listener).toHaveBeenCalledWith(
|
|
{ terminalScrollbackRows: 50_000 },
|
|
expect.objectContaining({ terminalScrollbackRows: 50_000 }),
|
|
undefined
|
|
)
|
|
|
|
store.updateSettings({ terminalScrollbackBytes: 10_000_000 } as never)
|
|
store.flush()
|
|
const persisted = readDataFile() as { settings?: Record<string, unknown> }
|
|
expect(persisted.settings?.terminalScrollbackRows).toBe(50_000)
|
|
expect(persisted.settings).not.toHaveProperty('terminalScrollbackBytes')
|
|
})
|
|
|
|
it('retires the persisted GitHub attribution setting without dropping unknown settings', async () => {
|
|
const settledStore = await createStore()
|
|
settledStore.flush()
|
|
const settled = readDataFile() as { settings: Record<string, unknown> }
|
|
settled.settings.enableGitHubAttribution = true
|
|
settled.settings.futureSetting = { enabled: true }
|
|
writeDataFile(settled)
|
|
|
|
vi.useFakeTimers()
|
|
try {
|
|
const store = await createStore()
|
|
|
|
expect(store.getSettings()).not.toHaveProperty('enableGitHubAttribution')
|
|
expect(store.getSettings()).toHaveProperty('futureSetting', { enabled: true })
|
|
vi.advanceTimersByTime(5_000)
|
|
await store.waitForPendingWrite()
|
|
} finally {
|
|
vi.useRealTimers()
|
|
}
|
|
const persisted = readDataFile() as { settings?: Record<string, unknown> }
|
|
expect(persisted.settings).not.toHaveProperty('enableGitHubAttribution')
|
|
expect(persisted.settings).toHaveProperty('futureSetting', { enabled: true })
|
|
})
|
|
|
|
it('ignores retired GitHub attribution updates and strips stale in-memory values on save', async () => {
|
|
const store = await createStore()
|
|
const listener = vi.fn()
|
|
store.onSettingsChanged(listener)
|
|
|
|
const updated = store.updateSettings({ enableGitHubAttribution: true } as never, {
|
|
notifyListeners: true
|
|
})
|
|
|
|
expect(updated).not.toHaveProperty('enableGitHubAttribution')
|
|
expect(listener).not.toHaveBeenCalled()
|
|
const settings = store.getSettings() as GlobalSettings & Record<string, unknown>
|
|
settings.enableGitHubAttribution = false
|
|
settings.futureSetting = 'kept'
|
|
store.flush()
|
|
const persisted = readDataFile() as { settings?: Record<string, unknown> }
|
|
expect(persisted.settings).not.toHaveProperty('enableGitHubAttribution')
|
|
expect(persisted.settings?.futureSetting).toBe('kept')
|
|
})
|
|
|
|
it('normalizes terminal cursor style before persistence and listener broadcasts', async () => {
|
|
const store = await createStore()
|
|
store.updateSettings({ terminalCursorStyle: 'underline' })
|
|
const listener = vi.fn()
|
|
store.onSettingsChanged(listener)
|
|
|
|
const invalid = store.updateSettings(
|
|
{ terminalCursorStyle: 'beam' as never },
|
|
{ notifyListeners: true }
|
|
)
|
|
|
|
expect(invalid.terminalCursorStyle).toBe('block')
|
|
expect(invalid.terminalCursorStyleDefaultedToBlock).toBe(true)
|
|
expect(listener).toHaveBeenCalledWith(
|
|
{
|
|
terminalCursorStyle: 'block'
|
|
},
|
|
expect.objectContaining({ terminalCursorStyle: 'block' }),
|
|
undefined
|
|
)
|
|
|
|
const valid = store.updateSettings(
|
|
{ terminalCursorStyle: 'underline' },
|
|
{ notifyListeners: true }
|
|
)
|
|
expect(valid.terminalCursorStyle).toBe('underline')
|
|
expect(listener).toHaveBeenLastCalledWith(
|
|
{ terminalCursorStyle: 'underline' },
|
|
expect.objectContaining({ terminalCursorStyle: 'underline' }),
|
|
undefined
|
|
)
|
|
|
|
store.flush()
|
|
expect((readDataFile() as PersistedState).settings.terminalCursorStyle).toBe('underline')
|
|
})
|
|
|
|
it('normalizes disabled TUI agents on load and update', async () => {
|
|
writeFileSync(
|
|
join(testState.dir, 'orca-data.json'),
|
|
JSON.stringify({
|
|
settings: {
|
|
disabledTuiAgents: ['codex', 'not-real', 'codex', 'claude']
|
|
}
|
|
})
|
|
)
|
|
const store = await createStore()
|
|
|
|
expect(store.getSettings().disabledTuiAgents).toEqual(['codex', 'claude', 'claude-agent-teams'])
|
|
|
|
const updated = store.updateSettings({
|
|
disabledTuiAgents: ['gemini', 'not-real', 'gemini', 'opencode'] as never
|
|
})
|
|
expect(updated.disabledTuiAgents).toEqual(['gemini', 'opencode'])
|
|
})
|
|
|
|
it('enables Claude Agent Teams by default for fresh installs', async () => {
|
|
const store = await createStore()
|
|
|
|
expect(store.getSettings().disabledTuiAgents).toEqual([])
|
|
expect(store.getSettings().claudeAgentTeamsDefaultDisabledMigrated).toBe(true)
|
|
})
|
|
|
|
it('migrates yolo default args onto untouched agent launch settings', async () => {
|
|
writeFileSync(
|
|
join(testState.dir, 'orca-data.json'),
|
|
JSON.stringify({
|
|
settings: {
|
|
agentCmdOverrides: {}
|
|
}
|
|
})
|
|
)
|
|
const store = await createStore()
|
|
|
|
expect(store.getSettings().agentDefaultArgs).toMatchObject({
|
|
claude: '--dangerously-skip-permissions',
|
|
codex: '--dangerously-bypass-approvals-and-sandbox',
|
|
cursor: '--yolo'
|
|
})
|
|
expect(store.getSettings().agentDefaultEnv).toMatchObject({
|
|
goose: { GOOSE_MODE: 'auto' }
|
|
})
|
|
expect(store.getSettings().agentYoloDefaultsMigrated).toBe(true)
|
|
})
|
|
|
|
it('does not add yolo defaults for legacy agents with command overrides', async () => {
|
|
writeFileSync(
|
|
join(testState.dir, 'orca-data.json'),
|
|
JSON.stringify({
|
|
settings: {
|
|
agentCmdOverrides: {
|
|
codex: 'codex --profile work',
|
|
goose: 'goose'
|
|
}
|
|
}
|
|
})
|
|
)
|
|
const store = await createStore()
|
|
|
|
expect(store.getSettings().agentDefaultArgs?.codex).toBe('')
|
|
expect(store.getSettings().agentDefaultEnv?.goose).toEqual({})
|
|
expect(store.getSettings().agentDefaultArgs?.claude).toBe('--dangerously-skip-permissions')
|
|
})
|
|
|
|
it('removes unsupported TUI skip-permissions args from migrated profiles', async () => {
|
|
writeFileSync(
|
|
join(testState.dir, 'orca-data.json'),
|
|
JSON.stringify({
|
|
settings: {
|
|
agentYoloDefaultsMigrated: true,
|
|
agentDefaultArgs: {
|
|
opencode: '--dangerously-skip-permissions --model opencode/gpt-5',
|
|
kilo: '--dangerously-skip-permissions',
|
|
codex: '--dangerously-bypass-approvals-and-sandbox'
|
|
}
|
|
}
|
|
})
|
|
)
|
|
const store = await createStore()
|
|
store.flush()
|
|
|
|
expect(store.getSettings().agentDefaultArgs?.opencode).toBe('--model opencode/gpt-5')
|
|
expect(store.getSettings().agentDefaultArgs?.kilo).toBe('')
|
|
expect(store.getSettings().agentDefaultArgs?.codex).toBe(
|
|
'--dangerously-bypass-approvals-and-sandbox'
|
|
)
|
|
expect((readDataFile() as PersistedState).settings.agentDefaultArgs?.opencode).toBe(
|
|
'--model opencode/gpt-5'
|
|
)
|
|
expect((readDataFile() as PersistedState).settings.agentDefaultArgs?.kilo).toBe('')
|
|
})
|
|
|
|
it('normalizes app icon on load and update', async () => {
|
|
writeFileSync(
|
|
join(testState.dir, 'orca-data.json'),
|
|
JSON.stringify({
|
|
settings: {
|
|
appIcon: 'not-real'
|
|
}
|
|
})
|
|
)
|
|
const store = await createStore()
|
|
|
|
expect(store.getSettings().appIcon).toBe('classic')
|
|
|
|
expect(store.updateSettings({ appIcon: 'watercolor' }).appIcon).toBe('watercolor')
|
|
expect(store.updateSettings({ appIcon: 'blue' }).appIcon).toBe('blue')
|
|
expect(store.updateSettings({ appIcon: 'not-real' as never }).appIcon).toBe('classic')
|
|
})
|
|
|
|
it('updateSettings keeps the legacy commit-message AI projection in sync', async () => {
|
|
const store = await createStore()
|
|
const current = store.getSettings().sourceControlAi!
|
|
|
|
const updated = store.updateSettings({
|
|
sourceControlAi: {
|
|
...current,
|
|
enabled: true,
|
|
agentId: 'codex',
|
|
selectedModelByAgent: { codex: 'gpt-5.4' },
|
|
selectedThinkingByModel: { 'gpt-5.4': 'high' },
|
|
instructionsByOperation: {
|
|
commitMessage: 'Write concise commit messages.',
|
|
pullRequest: 'Write release-note-ready PR details.'
|
|
},
|
|
customAgentCommand: ''
|
|
}
|
|
})
|
|
|
|
expect(updated.commitMessageAi).toMatchObject({
|
|
enabled: true,
|
|
agentId: 'codex',
|
|
selectedModelByAgent: { codex: 'gpt-5.4' },
|
|
selectedThinkingByModel: { 'gpt-5.4': 'high' },
|
|
customPrompt: 'Write concise commit messages.',
|
|
customAgentCommand: ''
|
|
})
|
|
})
|
|
|
|
it('updateSettings keeps source-control AI in sync for legacy commit-message updates', async () => {
|
|
const store = await createStore()
|
|
const current = store.getSettings().commitMessageAi!
|
|
|
|
const updated = store.updateSettings({
|
|
commitMessageAi: {
|
|
...current,
|
|
enabled: false,
|
|
agentId: 'claude',
|
|
selectedModelByAgent: { claude: 'sonnet' },
|
|
selectedThinkingByModel: { sonnet: 'medium' },
|
|
customPrompt: 'Legacy settings update',
|
|
customAgentCommand: 'claude'
|
|
}
|
|
})
|
|
|
|
expect(updated.sourceControlAi).toMatchObject({
|
|
enabled: false,
|
|
agentId: 'claude',
|
|
selectedModelByAgent: {},
|
|
selectedThinkingByModel: {},
|
|
customAgentCommand: 'claude',
|
|
instructionsByOperation: {
|
|
commitMessage: 'Legacy settings update',
|
|
branchName: 'Legacy settings update'
|
|
}
|
|
})
|
|
expect(updated.sourceControlAi?.modelOverridesByOperation?.commitMessage).toEqual({
|
|
selectedModelByAgent: { claude: 'sonnet' },
|
|
selectedThinkingByModel: { sonnet: 'medium' }
|
|
})
|
|
})
|
|
|
|
it('updateSettings normalizes open-in applications', async () => {
|
|
const store = await createStore()
|
|
const updated = store.updateSettings({
|
|
openInApplications: [
|
|
{ id: 'cursor', label: ' Cursor ', command: ' cursor ' },
|
|
{ id: 'cursor', label: 'Dup', command: 'dup' },
|
|
{ id: 'bad', label: '', command: 'bad' }
|
|
]
|
|
})
|
|
expect(updated.openInApplications).toEqual([
|
|
{ id: 'cursor', label: 'Cursor', command: 'cursor' }
|
|
])
|
|
})
|
|
|
|
it('updateSettings deep-merges and clamps notification custom sound volume', async () => {
|
|
const store = await createStore()
|
|
const updated = store.updateSettings({
|
|
notifications: {
|
|
...store.getSettings().notifications,
|
|
customSoundVolume: -20
|
|
}
|
|
})
|
|
|
|
expect(updated.notifications.customSoundVolume).toBe(0)
|
|
expect(updated.notifications.enabled).toBe(true)
|
|
expect(updated.notifications.customSoundPath).toBeNull()
|
|
})
|
|
|
|
it('updateSettings toggles editorAutoSave', async () => {
|
|
const store = await createStore()
|
|
expect(store.getSettings().editorAutoSave).toBe(false)
|
|
|
|
store.updateSettings({ editorAutoSave: true })
|
|
expect(store.getSettings().editorAutoSave).toBe(true)
|
|
|
|
store.updateSettings({ editorAutoSave: false })
|
|
expect(store.getSettings().editorAutoSave).toBe(false)
|
|
})
|
|
|
|
it('keeps legacy rightSidebarOpenByDefault writable for backward compatibility', async () => {
|
|
const store = await createStore()
|
|
expect(store.getSettings().rightSidebarOpenByDefault).toBe(true)
|
|
|
|
store.updateSettings({ rightSidebarOpenByDefault: false })
|
|
expect(store.getSettings().rightSidebarOpenByDefault).toBe(false)
|
|
|
|
store.updateSettings({ rightSidebarOpenByDefault: true })
|
|
expect(store.getSettings().rightSidebarOpenByDefault).toBe(true)
|
|
})
|
|
|
|
it('updateSettings persists sourceControlViewMode as a user setting', async () => {
|
|
const store = await createStore()
|
|
expect(store.getSettings().sourceControlViewMode).toBe('list')
|
|
|
|
store.updateSettings({ sourceControlViewMode: 'tree' })
|
|
expect(store.getSettings().sourceControlViewMode).toBe('tree')
|
|
})
|
|
|
|
it('updateSettings persists sourceControlGroupOrder as a user setting', async () => {
|
|
const store = await createStore()
|
|
expect(store.getSettings().sourceControlGroupOrder).toBe('changes-first')
|
|
|
|
store.updateSettings({ sourceControlGroupOrder: 'staged-first' })
|
|
expect(store.getSettings().sourceControlGroupOrder).toBe('staged-first')
|
|
|
|
store.updateSettings({ sourceControlGroupOrder: 'tracked-first' as never })
|
|
expect(store.getSettings().sourceControlGroupOrder).toBe('changes-first')
|
|
})
|
|
|
|
it('updateSettings normalizes terminal shortcut policy', async () => {
|
|
const store = await createStore()
|
|
|
|
store.updateSettings({ terminalShortcutPolicy: 'terminal-first' })
|
|
expect(store.getSettings().terminalShortcutPolicy).toBe('terminal-first')
|
|
|
|
store.updateSettings({ terminalShortcutPolicy: 'terminal-maybe' as never })
|
|
expect(store.getSettings().terminalShortcutPolicy).toBe('orca-first')
|
|
})
|
|
|
|
it('reloads sourceControlViewMode from global settings without touching workspace state', async () => {
|
|
const workspaceSession = {
|
|
activeRepoId: 'r1',
|
|
activeWorktreeId: 'repo1::/worktree-a',
|
|
activeTabId: 'tab1',
|
|
tabsByWorktree: {
|
|
'repo1::/worktree-a': [
|
|
makeTerminalTab({
|
|
id: 'tab1',
|
|
worktreeId: 'repo1::/worktree-a'
|
|
})
|
|
],
|
|
'repo1::/worktree-b': [
|
|
makeTerminalTab({
|
|
id: 'tab2',
|
|
worktreeId: 'repo1::/worktree-b'
|
|
})
|
|
]
|
|
},
|
|
terminalLayoutsByTabId: {},
|
|
openFilesByWorktree: {},
|
|
markdownFrontmatterVisible: {},
|
|
browserTabsByWorktree: {},
|
|
browserPagesByWorkspace: {},
|
|
activeBrowserTabIdByWorktree: {},
|
|
activeFileIdByWorktree: {},
|
|
activeTabTypeByWorktree: {},
|
|
browserUrlHistory: [],
|
|
defaultTerminalTabsAppliedByWorktreeId: {}
|
|
}
|
|
writeDataFile({
|
|
schemaVersion: 1,
|
|
repos: [makeRepo({ id: 'repo1', path: '/repo1' })],
|
|
worktreeMeta: {
|
|
'repo1::/worktree-a': { status: 'active' },
|
|
'repo1::/worktree-b': { status: 'active' }
|
|
},
|
|
settings: { theme: 'dark' },
|
|
ui: {},
|
|
githubCache: { pr: {}, issue: {} },
|
|
workspaceSession
|
|
})
|
|
|
|
const store = await createStore()
|
|
expect(store.getSettings().sourceControlViewMode).toBe('list')
|
|
expect(store.getSettings().sourceControlGroupOrder).toBe('changes-first')
|
|
|
|
store.updateSettings({ sourceControlViewMode: 'tree', sourceControlGroupOrder: 'staged-first' })
|
|
store.flush()
|
|
|
|
const persisted = readDataFile() as {
|
|
settings?: { sourceControlGroupOrder?: string; sourceControlViewMode?: string }
|
|
workspaceSession?: typeof workspaceSession
|
|
worktreeMeta?: Record<string, unknown>
|
|
}
|
|
expect(persisted.settings?.sourceControlViewMode).toBe('tree')
|
|
expect(persisted.settings?.sourceControlGroupOrder).toBe('staged-first')
|
|
expect(persisted.workspaceSession).toEqual({
|
|
...getDefaultWorkspaceSession(),
|
|
...workspaceSession
|
|
})
|
|
expect(persisted.worktreeMeta).toEqual({
|
|
'repo1::/worktree-a': { status: 'active' },
|
|
'repo1::/worktree-b': { status: 'active' }
|
|
})
|
|
expect(collectPropertyPaths(persisted, 'sourceControlViewMode')).toEqual([
|
|
'settings.sourceControlViewMode'
|
|
])
|
|
expect(collectPropertyPaths(persisted, 'sourceControlGroupOrder')).toEqual([
|
|
'settings.sourceControlGroupOrder'
|
|
])
|
|
|
|
const reloaded = await createStore()
|
|
expect(reloaded.getSettings().sourceControlViewMode).toBe('tree')
|
|
expect(reloaded.getSettings().sourceControlGroupOrder).toBe('staged-first')
|
|
expect(reloaded.getWorkspaceSession().activeWorktreeId).toBe('repo1::/worktree-a')
|
|
})
|
|
})
|