Files
web-terminal/test/desktop/settings-store.test.ts
Yaojia Wang cf8cfccab4 feat(desktop): Electron all-in-one desktop shell (Mac/Windows) embedding the server
Add a `desktop/` Electron app that embeds the existing Node server + node-pty
(all-in-one): the window loads http://127.0.0.1:<port>/ and reuses the frontend
unchanged; other LAN devices can still connect. The server needs zero changes —
startServer(cfg)/loadConfig already support programmatic embedding.

- Pure, unit-tested modules: port, shell, server-config, deep-link, notify-policy,
  notifications, live-poll, prefs, settings-store (94 tests; desktop/src ~97% cov)
- Electron glue: main/window/tray/menu/preload/embedded-server/logger
  (hardened: contextIsolation, sandbox, deny foreign-origin navigation)
- Native value: OS notifications driven by /live-sessions status, tray, deep links
- Packaging (electron-builder -> arm64 .dmg): ships dist/ + public/ + node_modules
  on-disk under Resources so the server resolves its deps from /Applications;
  node-pty rebuilt for the Electron ABI
- Docs: docs/DESKTOP_PLAN.md; PROGRESS_LOG updated

Verified: desktop tsc clean; 1401 tests pass; coverage >=80% (desktop/src 97/95/100/97);
.dmg built and launch-tested on arm64 (server boots, UI serves 200, node-pty loads).
2026-07-02 06:13:17 +02:00

189 lines
6.3 KiB
TypeScript

/**
* test/desktop/settings-store.test.ts — B3: unit tests for the on-disk prefs store.
*
* Uses a REAL temp dir under os.tmpdir() (unique per test via process.pid + an
* incrementing counter — Math.random/Date.now are intentionally avoided). Covers:
* missing file → defaults (no warn); set→get round-trip; on-disk file contents;
* corrupt JSON → defaults + warn; and a write failure → error logged, merged
* result still returned. A spy Logger asserts the logging contract.
*/
import { describe, test, expect, afterEach, vi } from 'vitest'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import type { Logger } from '../../desktop/src/logger.js'
import type { DesktopPrefs } from '../../desktop/src/types.js'
import { createSettingsStore } from '../../desktop/src/settings-store.js'
import { defaultPrefs } from '../../desktop/src/prefs.js'
const PLATFORM: NodeJS.Platform = 'darwin'
// ── unique temp-dir helper (no Math.random / Date.now) ──────────────────────────
let dirCounter = 0
const createdDirs: string[] = []
function makeTempDir(): string {
dirCounter += 1
const dir = path.join(os.tmpdir(), `web-terminal-store-test-${process.pid}-${dirCounter}`)
fs.mkdirSync(dir, { recursive: true })
createdDirs.push(dir)
return dir
}
interface SpyLogger extends Logger {
info: ReturnType<typeof vi.fn>
warn: ReturnType<typeof vi.fn>
error: ReturnType<typeof vi.fn>
}
function makeSpyLogger(): SpyLogger {
return { info: vi.fn(), warn: vi.fn(), error: vi.fn() }
}
afterEach(() => {
for (const dir of createdDirs) {
fs.rmSync(dir, { recursive: true, force: true })
}
createdDirs.length = 0
})
describe('createSettingsStore.get', () => {
test('returns defaults when the prefs file does not exist (and logs no warn)', () => {
// Arrange
const dir = makeTempDir()
const logger = makeSpyLogger()
const store = createSettingsStore(dir, PLATFORM, logger)
// Act
const prefs = store.get()
// Assert — missing file is the normal first-run case, not a warning
expect(prefs).toEqual(defaultPrefs(PLATFORM))
expect(logger.warn).not.toHaveBeenCalled()
})
test('returns defaults and logs a warn when the file is corrupt JSON', () => {
// Arrange
const dir = makeTempDir()
const logger = makeSpyLogger()
fs.writeFileSync(path.join(dir, 'prefs.json'), '{ not valid json', 'utf8')
const store = createSettingsStore(dir, PLATFORM, logger)
// Act
const prefs = store.get()
// Assert
expect(prefs).toEqual(defaultPrefs(PLATFORM))
expect(logger.warn).toHaveBeenCalledTimes(1)
})
test('validates untrusted on-disk fields, falling back per bad field', () => {
// Arrange — a hand-edited file with an out-of-range port and wrong-typed flag
const dir = makeTempDir()
const logger = makeSpyLogger()
fs.writeFileSync(
path.join(dir, 'prefs.json'),
JSON.stringify({ port: 999999, lanSharing: 'yes', shellPath: '/bin/bash' }),
'utf8',
)
const store = createSettingsStore(dir, PLATFORM, logger)
// Act
const prefs = store.get()
// Assert
expect(prefs.port).toBeNull()
expect(prefs.lanSharing).toBe(false)
expect(prefs.shellPath).toBe('/bin/bash')
})
})
describe('createSettingsStore.set', () => {
test('persists the patch and round-trips via a fresh store', () => {
// Arrange
const dir = makeTempDir()
const store = createSettingsStore(dir, PLATFORM, makeSpyLogger())
// Act
const merged = store.set({ port: 4321, lanSharing: true })
const reread = createSettingsStore(dir, PLATFORM, makeSpyLogger()).get()
// Assert
expect(merged.port).toBe(4321)
expect(merged.lanSharing).toBe(true)
expect(reread).toEqual(merged)
})
test('merges over previously-saved prefs rather than replacing them', () => {
// Arrange
const dir = makeTempDir()
const store = createSettingsStore(dir, PLATFORM, makeSpyLogger())
store.set({ port: 4321, shellPath: '/bin/zsh' })
// Act — a second partial set keeps the earlier shellPath
const merged = store.set({ lanSharing: true })
// Assert
expect(merged.port).toBe(4321)
expect(merged.shellPath).toBe('/bin/zsh')
expect(merged.lanSharing).toBe(true)
})
test('writes pretty 2-space JSON to <dir>/prefs.json', () => {
// Arrange
const dir = makeTempDir()
const store = createSettingsStore(dir, PLATFORM, makeSpyLogger())
// Act
store.set({ port: 5000 })
const onDisk = fs.readFileSync(path.join(dir, 'prefs.json'), 'utf8')
const parsed: unknown = JSON.parse(onDisk)
// Assert — content matches and the file is indented (pretty-printed)
expect(parsed).toMatchObject({ port: 5000 })
expect(onDisk).toContain('\n "port": 5000')
})
test('creates the store directory if it does not yet exist (mkdir -p)', () => {
// Arrange — point at a not-yet-created nested subdir
const dir = path.join(makeTempDir(), 'nested', 'deeper')
const store = createSettingsStore(dir, PLATFORM, makeSpyLogger())
// Act
store.set({ port: 6000 })
// Assert
expect(fs.existsSync(path.join(dir, 'prefs.json'))).toBe(true)
})
test('logs an error but still returns the merged prefs when the write fails', () => {
// Arrange — make the store "dir" live under a regular FILE, so mkdirSync fails
const base = makeTempDir()
const blocker = path.join(base, 'blocker')
fs.writeFileSync(blocker, 'i am a file, not a directory', 'utf8')
const unwritableDir = path.join(blocker, 'sub')
const logger = makeSpyLogger()
const store = createSettingsStore(unwritableDir, PLATFORM, logger)
// Act
const merged = store.set({ port: 7000 })
// Assert — best-effort: error surfaced, caller still gets the change
expect(logger.error).toHaveBeenCalledTimes(1)
expect(merged.port).toBe(7000)
})
test('returned merged prefs are a fully-formed DesktopPrefs', () => {
// Arrange
const dir = makeTempDir()
const store = createSettingsStore(dir, PLATFORM, makeSpyLogger())
// Act
const merged: DesktopPrefs = store.set({ port: 8000 })
// Assert — every field present (defaults + patch)
expect(Object.keys(merged).sort()).toEqual(Object.keys(defaultPrefs(PLATFORM)).sort())
})
})