Files
goodbuddy/src/main/application-settings-store.test.ts
T
mesalogo 05838d2c50 feat(hosts): move host management into the Hosts application
Hosts becomes a built-in application opened from the Apps menu, with Hosts
and Agent packages tabs. The remote capability switch keeps using
remoteProjectsEnabled and moves to the application settings, so Main-side
gating and saved values are unchanged. Saved navigation gains the new entry
at the end, unpinned.

Settings drops the SSH hosts category and the Remote projects tab. The host
list collapses to one-line summaries with status badges, versions show as a
table, and application settings switches align to the right edge.
2026-10-13 01:15:53 +08:00

1111 lines
49 KiB
TypeScript

import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
ApplicationSettingsStore,
applicationSettingsSchema,
applicationSettingsUpdateSchema,
defaultApplicationSettings
} from './application-settings-store'
const temporaryDirectories: string[] = []
const managedMigrationResolver = async () => ({})
function createApplicationSettingsStore(
filePath: string
): ApplicationSettingsStore {
return new ApplicationSettingsStore(filePath, managedMigrationResolver)
}
async function createStore(): Promise<{
directory: string
filePath: string
store: ApplicationSettingsStore
}> {
const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-application-settings-')
)
temporaryDirectories.push(directory)
const filePath = join(directory, 'application-settings.json')
return {
directory,
filePath,
store: createApplicationSettingsStore(filePath)
}
}
afterEach(async () => {
await Promise.all(
temporaryDirectories
.splice(0)
.map((directory) => rm(directory, { recursive: true, force: true }))
)
})
describe('ApplicationSettingsStore', () => {
it.each([11, 12])('defaults missing device sharing to off and preserves explicit choices in version %s', async (version) => {
for (const deviceSharingEnabled of [undefined, false, true]) {
const { filePath, store } = await createStore()
await writeFile(filePath, JSON.stringify({ ...defaultApplicationSettings, version,
lastSeenReleaseNotesVersion: null, deviceSharingEnabled, checkUpdatesOnStartup: false }))
expect(await store.get()).toMatchObject({ deviceSharingEnabled: deviceSharingEnabled ?? false, checkUpdatesOnStartup: false })
await store.update({ magicNotesEnabled: false })
expect((await createApplicationSettingsStore(filePath).get()).deviceSharingEnabled).toBe(deviceSharingEnabled ?? false)
}
})
it('persists and publishes device sharing toggles without resetting them on unrelated updates', async () => {
const { filePath, store } = await createStore()
expect((await store.get()).deviceSharingEnabled).toBe(false)
expect(applicationSettingsSchema.parse({ ...defaultApplicationSettings, deviceSharingEnabled: undefined }).deviceSharingEnabled).toBe(false)
const changed = vi.fn()
store.onChanged(changed)
for (const deviceSharingEnabled of [true, false]) {
const saved = await store.update({ deviceSharingEnabled })
expect(saved.deviceSharingEnabled).toBe(deviceSharingEnabled)
expect(changed).toHaveBeenLastCalledWith(saved)
await store.update({ checkUpdatesOnStartup: false })
expect((await createApplicationSettingsStore(filePath).get()).deviceSharingEnabled).toBe(deviceSharingEnabled)
expect(JSON.parse(await readFile(filePath, 'utf8')).deviceSharingEnabled).toBe(deviceSharingEnabled)
}
expect(applicationSettingsUpdateSchema.parse({ checkUpdatesOnStartup: false })).toEqual({ checkUpdatesOnStartup: false })
for (const deviceSharingEnabled of ['false', 0, null]) {
await expect(store.update({ deviceSharingEnabled })).rejects.toThrow()
}
})
it.each([11, 12])('defaults missing frosted glass to on in version %s', async (version) => {
const { filePath, store } = await createStore()
const legacy: Record<string, unknown> = { ...defaultApplicationSettings, version, lastSeenReleaseNotesVersion: null, checkUpdatesOnStartup: false }
delete legacy.transparentFrostedEffectEnabled
await writeFile(filePath, JSON.stringify(legacy))
expect(await store.get()).toEqual({ ...defaultApplicationSettings, checkUpdatesOnStartup: false })
expect(applicationSettingsSchema.parse({ ...defaultApplicationSettings, transparentFrostedEffectEnabled: undefined }).transparentFrostedEffectEnabled).toBe(true)
})
it('defaults a missing scrollbar minimap setting to on and persists both choices', async () => {
const { filePath, store } = await createStore()
const legacy: Record<string, unknown> = { ...defaultApplicationSettings, version: 12, lastSeenReleaseNotesVersion: null }
delete legacy.chatMinimapEnabled
await writeFile(filePath, JSON.stringify(legacy))
expect((await store.get()).chatMinimapEnabled).toBe(true)
for (const chatMinimapEnabled of [false, true]) {
expect((await store.update({ chatMinimapEnabled })).chatMinimapEnabled).toBe(chatMinimapEnabled)
await store.update({ checkUpdatesOnStartup: false })
expect((await createApplicationSettingsStore(filePath).get()).chatMinimapEnabled).toBe(chatMinimapEnabled)
}
await expect(store.update({ chatMinimapEnabled: 'false' })).rejects.toThrow()
})
it('persists and publishes both frosted glass choices across unrelated updates and reloads', async () => {
const { filePath, store } = await createStore()
expect((await store.get()).transparentFrostedEffectEnabled).toBe(true)
const changed = vi.fn()
store.onChanged(changed)
for (const transparentFrostedEffectEnabled of [true, false]) {
const saved = await store.update({ transparentFrostedEffectEnabled })
expect(saved.transparentFrostedEffectEnabled).toBe(transparentFrostedEffectEnabled)
expect(changed).toHaveBeenLastCalledWith(saved)
await store.update({ checkUpdatesOnStartup: false })
expect((await createApplicationSettingsStore(filePath).get()).transparentFrostedEffectEnabled).toBe(transparentFrostedEffectEnabled)
expect(JSON.parse(await readFile(filePath, 'utf8')).transparentFrostedEffectEnabled).toBe(transparentFrostedEffectEnabled)
}
expect(applicationSettingsUpdateSchema.parse({ checkUpdatesOnStartup: false })).toEqual({ checkUpdatesOnStartup: false })
for (const transparentFrostedEffectEnabled of ['false', 0, null]) {
await expect(store.update({ transparentFrostedEffectEnabled })).rejects.toThrow()
}
})
it.each([11, 12])('defaults missing desktop notifications to on in version %s', async (version) => {
const { filePath, store } = await createStore()
const legacy: Record<string, unknown> = { ...defaultApplicationSettings, version, lastSeenReleaseNotesVersion: null, checkUpdatesOnStartup: false }
delete legacy.desktopNotificationsEnabled
await writeFile(filePath, JSON.stringify(legacy))
expect(await store.get()).toEqual({ ...defaultApplicationSettings, checkUpdatesOnStartup: false })
expect(applicationSettingsSchema.parse({ ...defaultApplicationSettings, desktopNotificationsEnabled: undefined }).desktopNotificationsEnabled).toBe(true)
})
it('persists and publishes desktop notification choices without resetting them on unrelated updates', async () => {
const { filePath, store } = await createStore()
expect((await store.get()).desktopNotificationsEnabled).toBe(true)
const changed = vi.fn()
store.onChanged(changed)
for (const desktopNotificationsEnabled of [false, true]) {
const saved = await store.update({ desktopNotificationsEnabled })
expect(saved.desktopNotificationsEnabled).toBe(desktopNotificationsEnabled)
expect(changed).toHaveBeenLastCalledWith(saved)
await store.update({ checkUpdatesOnStartup: false })
expect((await createApplicationSettingsStore(filePath).get()).desktopNotificationsEnabled).toBe(desktopNotificationsEnabled)
expect(JSON.parse(await readFile(filePath, 'utf8')).desktopNotificationsEnabled).toBe(desktopNotificationsEnabled)
}
expect(applicationSettingsUpdateSchema.parse({ checkUpdatesOnStartup: false })).toEqual({ checkUpdatesOnStartup: false })
for (const desktopNotificationsEnabled of ['false', 0, null]) {
await expect(store.update({ desktopNotificationsEnabled })).rejects.toThrow()
}
})
it('persists review pagination and batch controls without enabling supervision', async () => {
const { filePath, store } = await createStore()
const supervisionReview = { pageSize: 1, batchCharacters: 16000, batchMessages: 50, executionSeconds: 3600 }
await store.update({ supervisionReview })
await store.update({ checkUpdatesOnStartup: false })
expect(await createApplicationSettingsStore(filePath).get()).toMatchObject({ supervisionReview, heartbeatEnabled: false })
for (const patch of [{ pageSize: 0 }, { batchCharacters: 16001 }, { batchMessages: 0 }, { executionSeconds: 29 }, { pageSize: 1.5 }]) {
await expect(store.update({ supervisionReview: { ...supervisionReview, ...patch } })).rejects.toThrow()
}
})
it.each([11, 12])('persists the supervisor model reference and defaults old settings to app default in version %s', async (version) => {
const { filePath, store } = await createStore()
const legacy = { ...defaultApplicationSettings, version, lastSeenReleaseNotesVersion: null }
delete legacy.supervisorModelProfileId
await writeFile(filePath, JSON.stringify(legacy))
expect((await store.get()).supervisorModelProfileId).toBeNull()
const profileId = '00000000-0000-4000-8000-000000000201'
await store.update({ supervisorModelProfileId: profileId })
await store.update({ checkUpdatesOnStartup: false })
expect((await createApplicationSettingsStore(filePath).get()).supervisorModelProfileId).toBe(profileId)
for (const value of ['', 'missing', 12, { apiKey: 'not-a-profile-reference' }]) {
await expect(store.update({ supervisorModelProfileId: value })).rejects.toThrow()
}
await store.update({ supervisorModelProfileId: null })
expect((await createApplicationSettingsStore(filePath).get()).supervisorModelProfileId).toBeNull()
})
it.each([11, 12])('persists supervision concurrency and defaults missing values in version %s', async (version) => {
const { filePath, store } = await createStore()
const legacy = { ...defaultApplicationSettings, version, lastSeenReleaseNotesVersion: null }
delete legacy.supervisorModelConcurrency
await writeFile(filePath, JSON.stringify(legacy))
expect((await store.get()).supervisorModelConcurrency).toBe(1)
await store.update({ supervisorModelConcurrency: 4 })
await store.update({ heartbeatReportTimeoutSeconds: 30 })
expect(await createApplicationSettingsStore(filePath).get()).toMatchObject({ supervisorModelConcurrency: 4, heartbeatReportTimeoutSeconds: 30 })
for (const value of [0, 5, 1.5, '2', null]) {
await expect(store.update({ supervisorModelConcurrency: value })).rejects.toThrow()
}
expect((await store.update({ supervisorModelConcurrency: 1 })).supervisorModelConcurrency).toBe(1)
})
it.each([11, 12])('defaults and independently persists supervision timeouts from version %s', async (version) => {
const { filePath, store } = await createStore()
const legacy = { ...defaultApplicationSettings, version, lastSeenReleaseNotesVersion: null }
delete legacy.heartbeatReportTimeoutSeconds
delete legacy.supervisorOrganizeTimeoutSeconds
await writeFile(filePath, JSON.stringify(legacy))
expect(await store.get()).toMatchObject({ heartbeatReportTimeoutSeconds: 240, supervisorOrganizeTimeoutSeconds: 240 })
await store.update({ heartbeatReportTimeoutSeconds: 600 })
expect(await store.update({ supervisorOrganizeTimeoutSeconds: 30 })).toMatchObject({ heartbeatReportTimeoutSeconds: 600, supervisorOrganizeTimeoutSeconds: 30 })
await store.update({ checkUpdatesOnStartup: false })
expect(await createApplicationSettingsStore(filePath).get()).toMatchObject({ heartbeatReportTimeoutSeconds: 600, supervisorOrganizeTimeoutSeconds: 30 })
for (const value of [29, 601, 30.5, '240', null]) {
for (const field of ['heartbeatReportTimeoutSeconds', 'supervisorOrganizeTimeoutSeconds']) {
await expect(store.update({ [field]: value })).rejects.toThrow()
}
}
})
it.each([11, 12])('defaults missing supervisor preference to off in version %s through unrelated updates and reload', async (version) => {
const { filePath, store } = await createStore()
const legacy = { ...defaultApplicationSettings, version, lastSeenReleaseNotesVersion: null }
delete legacy.heartbeatEnabled
await writeFile(filePath, JSON.stringify(legacy), 'utf8')
expect(await store.get()).toEqual({ ...defaultApplicationSettings, heartbeatEnabled: false })
const changed = vi.fn()
store.onChanged(changed)
const updated = await store.update({ checkUpdatesOnStartup: false })
expect(updated).toMatchObject({ heartbeatEnabled: false, checkUpdatesOnStartup: false })
expect(changed).toHaveBeenCalledExactlyOnceWith(updated)
expect(await createApplicationSettingsStore(filePath).get()).toEqual(updated)
})
it.each([
{ version: 11, heartbeatEnabled: true },
{ version: 11, heartbeatEnabled: false },
{ version: 12, heartbeatEnabled: true },
{ version: 12, heartbeatEnabled: false },
])('preserves supervisor preference $heartbeatEnabled in version $version through unrelated updates and reload', async ({ version, heartbeatEnabled }) => {
const { filePath, store } = await createStore()
await writeFile(filePath, JSON.stringify({
...defaultApplicationSettings, version, lastSeenReleaseNotesVersion: null, heartbeatEnabled,
}), 'utf8')
expect((await store.get()).heartbeatEnabled).toBe(heartbeatEnabled)
expect((await store.update({ checkUpdatesOnStartup: false })).heartbeatEnabled).toBe(heartbeatEnabled)
expect(JSON.parse(await readFile(filePath, 'utf8')).heartbeatEnabled).toBe(heartbeatEnabled)
expect((await createApplicationSettingsStore(filePath).get()).heartbeatEnabled).toBe(heartbeatEnabled)
})
it('persists explicit supervisor enable and disable choices', async () => {
const { filePath, store } = await createStore()
for (const heartbeatEnabled of [true, false]) {
expect((await store.update({ heartbeatEnabled })).heartbeatEnabled).toBe(heartbeatEnabled)
await store.update({ checkUpdatesOnStartup: false })
expect((await createApplicationSettingsStore(filePath).get()).heartbeatEnabled).toBe(heartbeatEnabled)
}
})
it('defaults missing historical canvas page counts and persists updates without resetting other preferences', async () => {
const { filePath, store } = await createStore()
const legacy: Record<string, unknown> = { ...defaultApplicationSettings, version: 12,
lastSeenReleaseNotesVersion: null, magicNoteCommentMode: 'after-save-manual' }
delete legacy.magicNoteCanvasPageCount
await writeFile(filePath, JSON.stringify(legacy))
expect(await store.get()).toMatchObject({ magicNoteCanvasPageCount: 1, magicNoteCommentMode: 'after-save-manual' })
const changed = vi.fn()
store.onChanged(changed)
expect(await store.update({ magicNoteCanvasPageCount: 8 })).toMatchObject({ magicNoteCanvasPageCount: 8 })
expect(changed).toHaveBeenCalledWith(expect.objectContaining({ magicNoteCanvasPageCount: 8 }))
await store.update({ magicNotesEnabled: false })
expect(await createApplicationSettingsStore(filePath).get()).toMatchObject({ magicNoteCanvasPageCount: 8, magicNotesEnabled: false, magicNoteCommentMode: 'after-save-manual' })
for (const count of [0, 9, -1, 1.5, '2', null]) {
await expect(store.update({ magicNoteCanvasPageCount: count })).rejects.toThrow()
}
expect((await createApplicationSettingsStore(filePath).get()).magicNoteCanvasPageCount).toBe(8)
expect((await store.update({ magicNoteCanvasPageCount: 1 })).magicNoteCanvasPageCount).toBe(1)
})
it.each([
{ order: ['local-inference', 'magic-notes'], expected: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes'] },
{ order: ['local-inference'], expected: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes'] },
{ order: ['magic-notes', 'knowledge'], expected: ['heartbeat', 'magic-notes', 'knowledge', 'local-inference'] },
{ order: [], expected: ['knowledge', 'heartbeat', 'magic-notes', 'local-inference'] },
{ order: undefined, expected: ['knowledge', 'heartbeat', 'magic-notes', 'local-inference'] },
{ order: ['local-inference', 'heartbeat', 'magic-notes', 'knowledge'], expected: ['local-inference', 'heartbeat', 'magic-notes', 'knowledge'] },
])('normalizes stored order $order without changing existing relative order', async ({ order, expected }) => {
const { filePath, store } = await createStore()
await writeFile(filePath, JSON.stringify({
...defaultApplicationSettings, version: 12, lastSeenReleaseNotesVersion: null,
magicNotesEnabled: false, applicationNavigation: { order, pinned: { 'magic-notes': false } },
}))
const settings = await store.get()
expect(settings.warnings).toBeUndefined()
expect(settings.magicNotesEnabled).toBe(false)
expect(settings.applicationNavigation).toEqual({ order: [...expected, 'device-sharing', 'remote-hosts'], pinned: { 'magic-notes': false, heartbeat: true, 'local-inference': false, 'remote-hosts': false } })
expect(applicationSettingsSchema.parse(settings)).toEqual(settings)
await store.update({ checkUpdatesOnStartup: false })
expect(JSON.parse(await readFile(filePath, 'utf8')).applicationNavigation).toEqual(settings.applicationNavigation)
expect((await createApplicationSettingsStore(filePath).get()).applicationNavigation).toEqual(settings.applicationNavigation)
})
it('validates full-order writes, persists and publishes them, and rejects partial writes without mutation', async () => {
const { store, filePath } = await createStore()
const applicationNavigation = {
order: ['local-inference', 'heartbeat', 'magic-notes', 'knowledge', 'remote-hosts', 'device-sharing'],
pinned: { 'magic-notes': false, heartbeat: true, 'local-inference': false, 'remote-hosts': true },
}
const changed = vi.fn()
store.onChanged(changed)
const saved = await store.update({ applicationNavigation })
expect(applicationSettingsSchema.parse(saved).applicationNavigation).toEqual(applicationNavigation)
expect(changed).toHaveBeenCalledExactlyOnceWith(saved)
const contents = await readFile(filePath, 'utf8')
for (const order of [[], ['local-inference', 'magic-notes'], ['knowledge', 'knowledge', 'magic-notes', 'local-inference'], ['knowledge', 'heartbeat', 'magic-notes', 'invalid']]) {
expect(applicationSettingsSchema.safeParse({ ...saved, applicationNavigation: { ...applicationNavigation, order } }).success).toBe(false)
await expect(store.update({ applicationNavigation: { ...applicationNavigation, order } })).rejects.toThrow()
}
expect(await readFile(filePath, 'utf8')).toBe(contents)
expect(changed).toHaveBeenCalledTimes(1)
expect((await createApplicationSettingsStore(filePath).get()).applicationNavigation).toEqual(applicationNavigation)
})
it('keeps the confirmed snapshot when an atomic save fails', async () => {
const { filePath, store } = await createStore()
const confirmed = await store.get()
const changed = vi.fn()
store.onChanged(changed)
await mkdir(filePath)
await expect(store.update({ localInferenceEnabled: false })).rejects.toThrow()
expect(await store.get()).toEqual(confirmed)
expect(changed).not.toHaveBeenCalled()
})
it.each([11, 12])('defaults missing navigation and Notes fields in version %s without resetting unrelated values', async (version) => {
const { filePath, store } = await createStore()
const legacy: Record<string, unknown> = { ...defaultApplicationSettings, version, lastSeenReleaseNotesVersion: null, checkUpdatesOnStartup: false }
for (const key of ['applicationNavigation', 'localInferenceEnabled', 'magicNotesEnabled', 'magicNotesShowIncompleteTodoCount']) delete legacy[key]
await writeFile(filePath, JSON.stringify(legacy), 'utf8')
expect(await store.get()).toEqual({ ...defaultApplicationSettings, checkUpdatesOnStartup: false })
expect(await store.get()).toMatchObject({ localInferenceEnabled: true, applicationNavigation: defaultApplicationSettings.applicationNavigation })
expect(await createApplicationSettingsStore(filePath).get()).toEqual(await store.get())
})
it.each([true, false])('preserves a saved local inference pin of %s through migration, updates and reload', async (pinned) => {
const { filePath, store } = await createStore()
const applicationNavigation = {
order: ['local-inference', 'magic-notes'],
pinned: { 'magic-notes': false, 'local-inference': pinned }
}
await writeFile(filePath, JSON.stringify({ ...defaultApplicationSettings, applicationNavigation, version: 11, lastSeenReleaseNotesVersion: null }), 'utf8')
const normalized = { ...applicationNavigation, pinned: { ...applicationNavigation.pinned, heartbeat: true, 'remote-hosts': false }, order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes', 'device-sharing', 'remote-hosts'] }
expect((await store.get()).applicationNavigation).toEqual(normalized)
await store.update({ checkUpdatesOnStartup: false })
expect((await createApplicationSettingsStore(filePath).get()).applicationNavigation).toEqual(normalized)
})
it.each([true, false])('adds Hosts after a saved full order and keeps remote projects %s', async (remoteProjectsEnabled) => {
const { filePath, store } = await createStore()
const applicationNavigation = {
order: ['device-sharing', 'local-inference', 'heartbeat', 'magic-notes', 'knowledge'],
pinned: { 'magic-notes': false, heartbeat: false, 'local-inference': true }
}
await writeFile(filePath, JSON.stringify({ ...defaultApplicationSettings, remoteProjectsEnabled, applicationNavigation, version: 12, lastSeenReleaseNotesVersion: null }), 'utf8')
const expected = { order: [...applicationNavigation.order, 'remote-hosts'], pinned: { ...applicationNavigation.pinned, 'remote-hosts': false } }
const migrated = await store.get()
expect(migrated.warnings).toBeUndefined()
expect(migrated.remoteProjectsEnabled).toBe(remoteProjectsEnabled)
expect(migrated.applicationNavigation).toEqual(expected)
expect(applicationSettingsSchema.parse(migrated)).toEqual(migrated)
await store.update({ checkUpdatesOnStartup: false })
expect(await createApplicationSettingsStore(filePath).get()).toMatchObject({ remoteProjectsEnabled, applicationNavigation: expected })
})
it('migrates released settings without replacing explicit false values or comment choices', async () => {
const { filePath, store } = await createStore()
const legacy: Record<string, unknown> = { ...defaultApplicationSettings, version: 11, lastSeenReleaseNotesVersion: null, magicNotesEnabled: false, magicNotesShowIncompleteTodoCount: false, magicNoteCommentMode: 'after-save-manual', magicNoteCommentFormat: 'structured' }
for (const key of ['applicationNavigation', 'localInferenceEnabled']) delete legacy[key]
await writeFile(filePath, JSON.stringify(legacy), 'utf8')
const migrated = await store.get()
expect(migrated).toMatchObject({ magicNotesEnabled: false, magicNotesShowIncompleteTodoCount: false, localInferenceEnabled: true, magicNoteCommentMode: 'after-save-manual', magicNoteCommentFormat: 'structured' })
await store.update({ localInferenceEnabled: false })
await store.update({ applicationNavigation: { ...migrated.applicationNavigation, order: [...migrated.applicationNavigation.order].reverse(), pinned: { ...migrated.applicationNavigation.pinned, 'local-inference': false } } })
const reloaded = await createApplicationSettingsStore(filePath).get()
expect(reloaded).toMatchObject({ magicNotesEnabled: false, magicNotesShowIncompleteTodoCount: false, localInferenceEnabled: false, magicNoteCommentMode: 'after-save-manual', magicNoteCommentFormat: 'structured' })
expect(reloaded.applicationNavigation.order).toEqual([...migrated.applicationNavigation.order].reverse())
expect(reloaded.applicationNavigation.pinned['local-inference']).toBe(false)
})
it('rejects incomplete, duplicate, unknown order and pin values without applying defaults to patches', () => {
const navigation = defaultApplicationSettings.applicationNavigation
for (const invalid of [
{ ...navigation, order: navigation.order.slice(1) },
{ ...navigation, order: ['knowledge', 'heartbeat', 'magic-notes', 'magic-notes'] },
{ ...navigation, order: ['knowledge', 'heartbeat', 'unknown', 'local-inference'] },
{ ...navigation, order: ['magic-notes', 'local-inference'] },
{ ...navigation, order: ['knowledge', 'local-inference'] },
{ ...navigation, order: ['heartbeat', 'local-inference'] },
{ ...navigation, pinned: { ...navigation.pinned, extra: true } },
{ ...navigation, pinned: { knowledge: true } }
]) expect(applicationSettingsUpdateSchema.safeParse({ applicationNavigation: invalid }).success).toBe(false)
expect(applicationSettingsUpdateSchema.parse({ checkUpdatesOnStartup: false })).toEqual({ checkUpdatesOnStartup: false })
})
it('returns defaults without creating a settings file', async () => {
const { directory, store } = await createStore()
await expect(store.get()).resolves.toEqual(defaultApplicationSettings)
expect(defaultApplicationSettings.heartbeatEnabled).toBe(false)
expect(await store.get()).toMatchObject({ localInferenceEnabled: true, applicationNavigation: defaultApplicationSettings.applicationNavigation })
await expect(readdir(directory)).resolves.toEqual([])
})
it('persists versioned application preferences', async () => {
const { directory, filePath, store } = await createStore()
await expect(
store.update({
checkUpdatesOnStartup: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: false
})
).resolves.toEqual({
...defaultApplicationSettings,
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: false,
magicNoteCommentMode: 'immediate',
magicNoteCommentFormat: 'combined'
})
await expect(store.get()).resolves.toEqual({
...defaultApplicationSettings,
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: false,
magicNoteCommentMode: 'immediate',
magicNoteCommentFormat: 'combined'
})
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
...defaultApplicationSettings,
version: 12,
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: false,
magicNoteCommentMode: 'immediate',
magicNoteCommentFormat: 'combined',
lastSeenReleaseNotesVersion: null
})
expect(
(await readdir(directory)).filter((name) => name.endsWith('.tmp'))
).toEqual([])
})
it('persists the Remote Projects preference', async () => {
const { filePath, store } = await createStore()
await expect(
store.update({ remoteProjectsEnabled: true })
).resolves.toEqual({
...defaultApplicationSettings,
remoteProjectsEnabled: true
})
await expect(
createApplicationSettingsStore(filePath).get()
).resolves.toEqual({
...defaultApplicationSettings,
remoteProjectsEnabled: true
})
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
version: 12,
lastSeenReleaseNotesVersion: null,
...defaultApplicationSettings,
remoteProjectsEnabled: true
})
})
it('creates the parent directory and can reload persisted settings', async () => {
const { directory } = await createStore()
const filePath = join(directory, 'nested', 'application-settings.json')
const store = createApplicationSettingsStore(filePath)
await store.update({
checkUpdatesOnStartup: false,
magicNotesEnabled: false
})
await expect(
createApplicationSettingsStore(filePath).get()
).resolves.toEqual({
...defaultApplicationSettings,
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'immediate',
magicNoteCommentFormat: 'combined'
})
})
it.each([1, 2])(
'loads version %s settings missing the field with Magic Notes enabled',
async (version) => {
const { filePath, store } = await createStore()
await writeFile(
filePath,
JSON.stringify({
version,
checkUpdatesOnStartup: false
}),
'utf8'
)
await expect(store.get()).resolves.toEqual({
...defaultApplicationSettings,
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'immediate',
magicNoteCommentFormat: 'combined'
})
}
)
it('migrates version 2 Magic Notes settings with the immediate comment mode', async () => {
const { filePath, store } = await createStore()
await writeFile(
filePath,
JSON.stringify({
version: 2,
checkUpdatesOnStartup: false,
magicNotesEnabled: true
}),
'utf8'
)
await expect(store.get()).resolves.toEqual({
...defaultApplicationSettings,
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'immediate',
magicNoteCommentFormat: 'combined'
})
})
it('migrates version 3 settings with the combined comment format', async () => {
const { filePath, store } = await createStore()
await writeFile(
filePath,
JSON.stringify({
version: 3,
checkUpdatesOnStartup: false,
magicNotesEnabled: true,
magicNoteCommentMode: 'after-save-manual'
}),
'utf8'
)
await expect(store.get()).resolves.toEqual({
...defaultApplicationSettings,
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'after-save-manual',
magicNoteCommentFormat: 'combined'
})
})
it('migrates version 4 settings with no release notes acknowledged', async () => {
const { filePath, store } = await createStore()
await writeFile(
filePath,
JSON.stringify({
version: 4,
checkUpdatesOnStartup: false,
magicNotesEnabled: true,
magicNoteCommentMode: 'after-save-manual',
magicNoteCommentFormat: 'narrative'
}),
'utf8'
)
await expect(store.getLastSeenReleaseNotesVersion()).resolves.toBeNull()
await store.setLastSeenReleaseNotesVersion('0.8.18')
await expect(
createApplicationSettingsStore(filePath).getLastSeenReleaseNotesVersion()
).resolves.toBe('0.8.18')
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
...defaultApplicationSettings,
version: 12,
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'after-save-manual',
magicNoteCommentFormat: 'narrative',
lastSeenReleaseNotesVersion: '0.8.18'
})
})
it('migrates version 5 settings to the default GitHub update source', async () => {
const { filePath, store } = await createStore()
await writeFile(
filePath,
JSON.stringify({
version: 5,
checkUpdatesOnStartup: false,
magicNotesEnabled: true,
magicNoteCommentMode: 'after-save-auto',
magicNoteCommentFormat: 'structured',
lastSeenReleaseNotesVersion: '0.8.18'
}),
'utf8'
)
await expect(store.get()).resolves.toEqual({
...defaultApplicationSettings,
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'after-save-auto',
magicNoteCommentFormat: 'structured'
})
await expect(store.getLastSeenReleaseNotesVersion()).resolves.toBe('0.8.18')
})
it('migrates version 6 to the default ModelScope source', async () => {
const { filePath, store } = await createStore()
const versionSix = {
version: 6,
checkUpdatesOnStartup: false,
updateSource: 'mirror',
magicNotesEnabled: true,
magicNoteCommentMode: 'after-save-auto',
magicNoteCommentFormat: 'structured',
lastSeenReleaseNotesVersion: '0.8.18'
}
await writeFile(filePath, JSON.stringify(versionSix), 'utf8')
await expect(store.get()).resolves.toEqual({
...defaultApplicationSettings,
checkUpdatesOnStartup: false,
updateSource: 'mirror',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'after-save-auto',
magicNoteCommentFormat: 'structured'
})
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
...defaultApplicationSettings,
...versionSix,
version: 12,
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesShowIncompleteTodoCount: true
})
await store.update({ modelDownloadSource: 'hugging-face' })
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
...defaultApplicationSettings,
...versionSix,
version: 12,
modelDownloadSource: 'hugging-face',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesShowIncompleteTodoCount: true
})
})
it('migrates version 7 to show incomplete todo counts', async () => {
const { filePath, store } = await createStore()
const versionSeven = {
version: 7,
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
magicNotesEnabled: true,
magicNoteCommentMode: 'immediate',
magicNoteCommentFormat: 'combined',
lastSeenReleaseNotesVersion: null
}
await writeFile(filePath, JSON.stringify(versionSeven), 'utf8')
await expect(store.get()).resolves.toEqual({
...defaultApplicationSettings,
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'immediate',
magicNoteCommentFormat: 'combined'
})
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
...defaultApplicationSettings,
...versionSeven,
version: 12,
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesShowIncompleteTodoCount: true
})
})
it('migrates version 8 with Remote Projects disabled', async () => {
const { filePath, store } = await createStore()
const versionEight = {
version: 8,
checkUpdatesOnStartup: false,
updateSource: 'mirror',
modelDownloadSource: 'hugging-face',
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: false,
magicNoteCommentMode: 'after-save-manual',
magicNoteCommentFormat: 'narrative',
warnings: [{ code: 'application-settings-recovered' }],
lastSeenReleaseNotesVersion: '0.8.18'
}
await writeFile(filePath, JSON.stringify(versionEight), 'utf8')
const migratedSettings = await store.get()
expect(migratedSettings).toEqual({
...defaultApplicationSettings,
checkUpdatesOnStartup: false,
updateSource: 'mirror',
modelDownloadSource: 'hugging-face',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: false,
magicNoteCommentMode: 'after-save-manual',
magicNoteCommentFormat: 'narrative'
})
await expect(store.getLastSeenReleaseNotesVersion()).resolves.toBe('0.8.18')
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
...defaultApplicationSettings,
...versionEight,
version: 12,
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false
})
await store.update({ checkUpdatesOnStartup: true })
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
...defaultApplicationSettings,
...versionEight,
version: 12,
checkUpdatesOnStartup: true,
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false
})
})
it('persists independently resolved version 9 runtime selections exactly once', async () => {
const { filePath } = await createStore()
const versionNine = {
version: 9,
checkUpdatesOnStartup: false,
updateSource: 'mirror',
modelDownloadSource: 'hugging-face',
remoteProjectsEnabled: true,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: false,
magicNoteCommentMode: 'after-save-manual',
magicNoteCommentFormat: 'narrative',
lastSeenReleaseNotesVersion: '0.8.18'
}
await writeFile(filePath, JSON.stringify(versionNine), 'utf8')
let resolverCalls = 0
const store = new ApplicationSettingsStore(filePath, async () => {
resolverCalls += 1
return {
nodeExecutablePath: 'C:\\Tools\\node.exe',
pythonExecutablePath: 'relative/python'
}
})
await expect(store.get()).resolves.toEqual({
...defaultApplicationSettings,
checkUpdatesOnStartup: false,
updateSource: 'mirror',
modelDownloadSource: 'hugging-face',
remoteProjectsEnabled: true,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: false,
magicNoteCommentMode: 'after-save-manual',
magicNoteCommentFormat: 'narrative',
conversationHtmlRenderingEnabled: true,
localToolEnvironment: {
node: {
source: 'custom',
executablePath: 'C:\\Tools\\node.exe'
},
python: { source: 'managed' },
artifactDownloadSource: 'native'
}
})
expect(resolverCalls).toBe(1)
const persisted = JSON.parse(await readFile(filePath, 'utf8'))
expect(persisted).toEqual({
...defaultApplicationSettings,
...versionNine,
version: 12,
conversationHtmlRenderingEnabled: true,
localToolEnvironment: {
node: {
source: 'custom',
executablePath: 'C:\\Tools\\node.exe'
},
python: { source: 'managed' },
artifactDownloadSource: 'native'
}
})
const reloaded = new ApplicationSettingsStore(filePath, async () => {
throw new Error('A persisted migration must not resolve PATH again')
})
await expect(reloaded.get()).resolves.toEqual(await store.get())
})
it('strictly rejects incomplete full settings', () => {
expect(
applicationSettingsSchema.safeParse(defaultApplicationSettings).success
).toBe(true)
for (const input of [
{},
{ checkUpdatesOnStartup: 'true' },
{
checkUpdatesOnStartup: true,
magicNotesEnabled: true,
anotherSetting: true
},
{ checkUpdatesOnStartup: true },
null
]) {
expect(applicationSettingsSchema.safeParse(input).success).toBe(false)
}
})
it('strictly rejects empty, unknown, and mistyped updates', async () => {
const { directory, store } = await createStore()
for (const input of [
{},
{ checkUpdatesOnStartup: 'true' },
{ modelDownloadSource: 'automatic' },
{ remoteProjectsEnabled: 'false' },
{ anotherSetting: true },
null
]) {
expect(applicationSettingsUpdateSchema.safeParse(input).success).toBe(
false
)
await expect(store.update(input)).rejects.toThrow()
}
await expect(readdir(directory)).resolves.toEqual([])
})
it('merges partial updates without overwriting other settings', async () => {
const { store } = await createStore()
await store.update({ magicNotesEnabled: true })
await expect(
store.update({ checkUpdatesOnStartup: false })
).resolves.toEqual({
...defaultApplicationSettings,
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'immediate',
magicNoteCommentFormat: 'combined'
})
})
it('persists the selected mirror update source', async () => {
const { filePath, store } = await createStore()
await expect(store.update({ updateSource: 'mirror' })).resolves.toEqual({
...defaultApplicationSettings,
updateSource: 'mirror'
})
await expect(
createApplicationSettingsStore(filePath).get()
).resolves.toEqual({
...defaultApplicationSettings,
updateSource: 'mirror'
})
})
it('persists disabling conversation HTML rendering', async () => {
const { filePath, store } = await createStore()
await expect(
store.update({ conversationHtmlRenderingEnabled: false })
).resolves.toEqual({
...defaultApplicationSettings,
conversationHtmlRenderingEnabled: false
})
await expect(
createApplicationSettingsStore(filePath).get()
).resolves.toEqual({
...defaultApplicationSettings,
conversationHtmlRenderingEnabled: false
})
})
it('enables conversation HTML rendering when migrating version 10 settings', async () => {
const { filePath, store } = await createStore()
const versionTenSettings: Record<string, unknown> = {
...defaultApplicationSettings,
version: 10,
lastSeenReleaseNotesVersion: '0.8.18'
}
delete versionTenSettings.conversationHtmlRenderingEnabled
await writeFile(filePath, JSON.stringify(versionTenSettings), 'utf8')
await expect(store.get()).resolves.toEqual(defaultApplicationSettings)
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
...versionTenSettings,
version: 12,
conversationHtmlRenderingEnabled: true
})
})
it.each([
'{not-json',
JSON.stringify({
version: 3,
checkUpdatesOnStartup: false,
magicNotesEnabled: 'false'
}),
JSON.stringify({
version: 2,
checkUpdatesOnStartup: false,
magicNotesEnabled: true,
injected: true
}),
JSON.stringify({
version: 2,
checkUpdatesOnStartup: 'false',
magicNotesEnabled: true
})
])('isolates corrupt persisted data and restores defaults', async (data) => {
const { directory, filePath, store } = await createStore()
await writeFile(filePath, data, 'utf8')
await expect(store.get()).resolves.toEqual({
...defaultApplicationSettings,
warnings: [{ code: 'application-settings-recovered' }]
})
const entries = await readdir(directory)
expect(entries).toHaveLength(1)
expect(entries[0]).toMatch(
/^application-settings\.json\.corrupt-\d+-[a-f0-9]{12}$/u
)
expect(await readFile(join(directory, entries[0] ?? ''), 'utf8')).toBe(data)
})
it('preserves settings created by a newer unsupported version', async () => {
const { directory, filePath, store } = await createStore()
const futureSettings = JSON.stringify({
version: 99,
futureField: 'keep-me'
})
await writeFile(filePath, futureSettings, 'utf8')
await expect(store.get()).rejects.toThrow('不支持应用设置版本 99')
expect(await readFile(filePath, 'utf8')).toBe(futureSettings)
expect(
(await readdir(directory)).some((name) =>
name.startsWith('application-settings.json.corrupt-')
)
).toBe(false)
})
it('does not classify an I/O failure as corrupt settings', async () => {
const { directory } = await createStore()
const filePath = join(directory, 'settings-directory')
const store = createApplicationSettingsStore(filePath)
await writeFile(join(directory, 'sentinel'), 'unchanged', 'utf8')
const directoryStore = createApplicationSettingsStore(directory)
await expect(directoryStore.get()).rejects.toThrow(
'Application settings could not be read'
)
expect(await readdir(directory)).toEqual(['sentinel'])
await expect(store.get()).resolves.toEqual(defaultApplicationSettings)
})
it('serializes concurrent updates and leaves complete JSON', async () => {
const { filePath, store } = await createStore()
await Promise.all([
store.update({
checkUpdatesOnStartup: false,
magicNotesEnabled: true
}),
store.update({
checkUpdatesOnStartup: true,
magicNotesEnabled: false
}),
store.update({
checkUpdatesOnStartup: false,
magicNotesEnabled: false
})
])
await expect(store.get()).resolves.toEqual({
...defaultApplicationSettings,
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'immediate',
magicNoteCommentFormat: 'combined'
})
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
...defaultApplicationSettings,
version: 12,
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'immediate',
magicNoteCommentFormat: 'combined',
lastSeenReleaseNotesVersion: null
})
})
it('continues accepting updates after a validation failure', async () => {
const { store } = await createStore()
await expect(
store.update({
checkUpdatesOnStartup: 'invalid',
magicNotesEnabled: true
})
).rejects.toThrow()
await expect(
store.update({
checkUpdatesOnStartup: false,
magicNotesEnabled: true
})
).resolves.toEqual({
...defaultApplicationSettings,
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'immediate',
magicNoteCommentFormat: 'combined'
})
})
})