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).
189 lines
6.3 KiB
TypeScript
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())
|
|
})
|
|
})
|