also looking at this
fix(app): a settings.json this build cannot read no longer loops the app
#46
4 files
+130
-22
| @@ -6,7 +6,7 @@ import { tmpdir } from 'node:os' | ||
| 6 | 6 | import { join } from 'node:path' |
| 7 | 7 | import { loadConfig } from '../../../app/Uplink/config' |
| 8 | 8 | import { MessagesDb } from '../../../app/Uplink/messages-db' |
| 9 | import { cleanToken, readSettings, settingsEnv, tokenLooksValid, writeSettings } from '../../../app/Uplink/settings' | |
| 9 | import { cleanToken, coerceSettings, DEFAULT_SETTINGS, readSettings, settingsEnv, tokenLooksValid, writeSettings } from '../../../app/Uplink/settings' | |
| 10 | 10 | import { migrate, RUN_COLUMNS, schemaVersion, SqliteStore, TABLES } from '../../../app/Uplink/sqlite-store' |
| 11 | 11 | import { Uplink } from '../../../app/Uplink/uplink' |
| 12 | 12 | import { FakeChatDb, FakeEngine, FakeSender, ME, settle } from './fixtures' |
| @@ -309,6 +309,67 @@ describe('settings', () => { | ||
| 309 | 309 | expect(readSettings(path).engine).toBe('claude') |
| 310 | 310 | }) |
| 311 | 311 | |
| 312 | /** | |
| 313 | * `readSettings` runs at the desktop agent's first statements, before the | |
| 314 | * menubar exists, and launchd restarts a non-zero exit. So a file it cannot | |
| 315 | * read is not an error it can report: it is an invisible restart loop. Only | |
| 316 | * `engine` used to be checked, and an `allowed` that was not an array reached | |
| 317 | * `settingsEnv` and threw on every launch. | |
| 318 | */ | |
| 319 | it('falls back per field rather than throwing, whatever the file holds', () => { | |
| 320 | const path = join(tempDir(), 'settings.json') | |
| 321 | const broken: Array<[string, unknown]> = [ | |
| 322 | ['allowed', null], | |
| 323 | ['allowed', 'me@example.com'], | |
| 324 | ['engine', 'gemini'], | |
| 325 | ['model', 42], | |
| 326 | ['workdir', null], | |
| 327 | ['workdir', ''], | |
| 328 | ['openAtLogin', 'yes'], | |
| 329 | ['paused', 1], | |
| 330 | ] | |
| 331 | for (const [key, value] of broken) { | |
| 332 | Bun.write(path, JSON.stringify({ ...DEFAULT_SETTINGS, [key]: value })) | |
| 333 | const settings = readSettings(path) | |
| 334 | expect(settings[key as keyof typeof settings]).toEqual(DEFAULT_SETTINGS[key as keyof typeof DEFAULT_SETTINGS]) | |
| 335 | // The throw that used to take the app down, on the field that used to do it. | |
| 336 | expect(() => settingsEnv(settings, {})).not.toThrow() | |
| 337 | } | |
| 338 | }) | |
| 339 | ||
| 340 | it('survives a file that is not an object at all', () => { | |
| 341 | const path = join(tempDir(), 'settings.json') | |
| 342 | for (const body of ['null', '[]', '"nope"', '5', '{ not json']) { | |
| 343 | Bun.write(path, body) | |
| 344 | expect(readSettings(path)).toEqual(DEFAULT_SETTINGS) | |
| 345 | } | |
| 346 | }) | |
| 347 | ||
| 348 | it('reads back exactly what it wrote', () => { | |
| 349 | const path = join(tempDir(), 'settings.json') | |
| 350 | const settings = { allowed: ['+15550001111'], engine: 'codex' as const, model: 'gpt-5-codex', workdir: '/tmp', openAtLogin: false, paused: true } | |
| 351 | writeSettings(settings, path) | |
| 352 | expect(readSettings(path)).toEqual(settings) | |
| 353 | // And the default file too, whose `model` is null. | |
| 354 | writeSettings(DEFAULT_SETTINGS, path) | |
| 355 | expect(readSettings(path)).toEqual(DEFAULT_SETTINGS) | |
| 356 | }) | |
| 357 | ||
| 358 | /** | |
| 359 | * The popover sends the fields it changed. The same checks apply, but an | |
| 360 | * absent or unusable one keeps what is set rather than reverting to a | |
| 361 | * default, which is what makes it a partial update. | |
| 362 | */ | |
| 363 | it('keeps the current value for a field the popover did not send', () => { | |
| 364 | const current = { ...DEFAULT_SETTINGS, allowed: ['+15550001111'], paused: true } | |
| 365 | expect(coerceSettings({ paused: false }, current)).toEqual({ ...current, paused: false }) | |
| 366 | expect(coerceSettings({ paused: 'no' }, current)).toEqual(current) | |
| 367 | expect(coerceSettings({}, current)).toEqual(current) | |
| 368 | expect(coerceSettings(undefined, current)).toEqual(current) | |
| 369 | // Handles are normalized the way the popover's own path normalized them. | |
| 370 | expect(coerceSettings({ allowed: [' me@example.com ', '', 5] }, current).allowed).toEqual(['me@example.com', '5']) | |
| 371 | }) | |
| 372 | ||
| 312 | 373 | it('cleans a token pasted across a line wrap', () => { |
| 313 | 374 | const token = `sk-ant-oat01-${'a'.repeat(60)}` |
| 314 | 375 | expect(cleanToken(`${token.slice(0, 40)}\n ${token.slice(40)}\n`)).toBe(token) |