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).
This commit is contained in:
Yaojia Wang
2026-07-02 06:13:17 +02:00
parent 2af57e6686
commit cf8cfccab4
39 changed files with 7781 additions and 0 deletions

View File

@@ -0,0 +1,96 @@
/**
* test/desktop/deep-link.test.ts — B2: parseDeepLink / deepLinkToPath.
*/
import { describe, test, expect } from 'vitest'
import {
DEEP_LINK_PROTOCOL,
parseDeepLink,
deepLinkToPath,
} from '../../desktop/src/deep-link.js'
describe('DEEP_LINK_PROTOCOL', () => {
test('is the terminalapp scheme', () => {
expect(DEEP_LINK_PROTOCOL).toBe('terminalapp')
})
})
describe('parseDeepLink', () => {
test('parses a valid terminalapp://join/<id> link', () => {
// Arrange
const url = 'terminalapp://join/abc123'
// Act
const result = parseDeepLink(url)
// Assert
expect(result).toEqual({ join: 'abc123' })
})
test('decodes a percent-encoded id', () => {
// Arrange
const url = 'terminalapp://join/a%20b'
// Act
const result = parseDeepLink(url)
// Assert
expect(result).toEqual({ join: 'a b' })
})
test('returns null for a wrong scheme', () => {
expect(parseDeepLink('https://join/abc')).toBeNull()
})
test('returns null for a wrong host', () => {
expect(parseDeepLink('terminalapp://open/abc')).toBeNull()
})
test('returns null when the id is missing', () => {
expect(parseDeepLink('terminalapp://join')).toBeNull()
expect(parseDeepLink('terminalapp://join/')).toBeNull()
})
test('returns null when the id contains a nested slash', () => {
expect(parseDeepLink('terminalapp://join/a/b')).toBeNull()
})
test('returns null for a whitespace-only id', () => {
expect(parseDeepLink('terminalapp://join/%20')).toBeNull()
})
test('returns null for an id with a control character', () => {
expect(parseDeepLink('terminalapp://join/a%01b')).toBeNull()
})
test('returns null for malformed percent-encoding', () => {
expect(parseDeepLink('terminalapp://join/%E0%A4%A')).toBeNull()
})
test('returns null for a malformed URL', () => {
expect(parseDeepLink('not a url')).toBeNull()
expect(parseDeepLink('')).toBeNull()
})
})
describe('deepLinkToPath', () => {
test('renders the /?join= route for a simple id', () => {
expect(deepLinkToPath({ join: 'abc123' })).toBe('/?join=abc123')
})
test('encodes an id needing escaping', () => {
expect(deepLinkToPath({ join: 'a b/c' })).toBe('/?join=a%20b%2Fc')
})
test('round-trips a percent-decoded id back through encoding', () => {
// Arrange
const parsed = parseDeepLink('terminalapp://join/a%20b')
expect(parsed).not.toBeNull()
// Act
const path = deepLinkToPath(parsed!)
// Assert
expect(path).toBe('/?join=a%20b')
})
})

View File

@@ -0,0 +1,134 @@
/**
* test/desktop/live-poll.test.ts — B2: mapLiveSessions boundary validation.
*/
import { describe, test, expect } from 'vitest'
import { mapLiveSessions } from '../../desktop/src/live-poll.js'
describe('mapLiveSessions', () => {
test('returns [] when the body is not an array', () => {
expect(mapLiveSessions(null)).toEqual([])
expect(mapLiveSessions(undefined)).toEqual([])
expect(mapLiveSessions({})).toEqual([])
expect(mapLiveSessions('nope')).toEqual([])
expect(mapLiveSessions(42)).toEqual([])
})
test('maps a well-formed element to a snapshot', () => {
// Arrange
const raw = [
{ id: 'sess-1', status: 'waiting', cwd: '/Users/me/projects/web-terminal', exited: false },
]
// Act
const result = mapLiveSessions(raw)
// Assert
expect(result).toEqual([
{
sessionId: 'sess-1',
claudeStatus: 'waiting',
pendingApproval: false,
gate: null,
title: 'web-terminal',
},
])
})
test('accepts every valid status literal', () => {
const raw = [
{ id: 'a', status: 'working', cwd: null },
{ id: 'b', status: 'waiting', cwd: null },
{ id: 'c', status: 'idle', cwd: null },
{ id: 'd', status: 'unknown', cwd: null },
{ id: 'e', status: 'stuck', cwd: null },
]
const result = mapLiveSessions(raw)
expect(result.map((s) => s.claudeStatus)).toEqual([
'working',
'waiting',
'idle',
'unknown',
'stuck',
])
})
test('filters out elements missing a string id', () => {
const raw = [
{ status: 'idle', cwd: null },
{ id: 42, status: 'idle', cwd: null },
{ id: '', status: 'idle', cwd: null },
{ id: 'ok', status: 'idle', cwd: null },
]
const result = mapLiveSessions(raw)
expect(result).toHaveLength(1)
expect(result[0].sessionId).toBe('ok')
})
test('filters out elements with an invalid or missing status', () => {
const raw = [
{ id: 'a', status: 'busy', cwd: null },
{ id: 'b', status: 42, cwd: null },
{ id: 'c', cwd: null },
{ id: 'd', status: 'idle', cwd: null },
]
const result = mapLiveSessions(raw)
expect(result.map((s) => s.sessionId)).toEqual(['d'])
})
test('derives the title from the last cwd path segment', () => {
const raw = [{ id: 'a', status: 'idle', cwd: '/Users/me/projects/foo/' }]
const result = mapLiveSessions(raw)
expect(result[0].title).toBe('foo')
})
test('derives the title from a Windows backslash cwd', () => {
const raw = [{ id: 'a', status: 'idle', cwd: 'C:\\Users\\me\\projects\\foo' }]
const result = mapLiveSessions(raw)
expect(result[0].title).toBe('foo')
})
test('filters out already-exited sessions', () => {
const raw = [
{ id: 'live', status: 'waiting', cwd: null, exited: false },
{ id: 'dead', status: 'waiting', cwd: null, exited: true },
]
const result = mapLiveSessions(raw)
expect(result.map((s) => s.sessionId)).toEqual(['live'])
})
test('leaves title undefined when cwd is null or blank', () => {
const raw = [
{ id: 'a', status: 'idle', cwd: null },
{ id: 'b', status: 'idle', cwd: '' },
{ id: 'c', status: 'idle' },
]
const result = mapLiveSessions(raw)
expect(result.map((s) => s.title)).toEqual([undefined, undefined, undefined])
})
test('never throws on garbage array elements', () => {
const raw = [null, undefined, 42, 'str', [], { id: 'ok', status: 'idle', cwd: null }]
// Act
const act = (): unknown => mapLiveSessions(raw)
// Assert
expect(act).not.toThrow()
expect(mapLiveSessions(raw)).toHaveLength(1)
})
})

View File

@@ -0,0 +1,113 @@
/**
* test/desktop/notifications.test.ts — B2: computeNotifications batch diff.
*/
import { describe, test, expect } from 'vitest'
import { computeNotifications } from '../../desktop/src/notifications.js'
import type { NotifyOptions } from '../../desktop/src/notify-policy.js'
import type { SessionStatusSnapshot } from '../../desktop/src/types.js'
function snapshot(overrides: Partial<SessionStatusSnapshot> = {}): SessionStatusSnapshot {
return {
sessionId: 'sess-a',
claudeStatus: 'idle',
pendingApproval: false,
gate: null,
title: undefined,
...overrides,
}
}
const OPTS: NotifyOptions = {
windowFocused: false,
notifyOnApproval: true,
notifyOnStatusChange: true,
}
describe('computeNotifications', () => {
test('collects only the notifying decisions', () => {
// Arrange: sess-a raises an approval (notify), sess-b is unchanged (silent).
const prev = new Map<string, SessionStatusSnapshot>([
['sess-a', snapshot({ sessionId: 'sess-a', pendingApproval: false })],
['sess-b', snapshot({ sessionId: 'sess-b', claudeStatus: 'working' })],
])
const snapshots = [
snapshot({ sessionId: 'sess-a', pendingApproval: true, gate: 'tool' }),
snapshot({ sessionId: 'sess-b', claudeStatus: 'working' }),
]
// Act
const { decisions } = computeNotifications(prev, snapshots, OPTS)
// Assert
expect(decisions).toHaveLength(1)
expect(decisions[0].title).toBe('Approval needed')
})
test('returns a new map keyed by sessionId', () => {
const snapshots = [
snapshot({ sessionId: 'sess-a' }),
snapshot({ sessionId: 'sess-b' }),
]
const { next } = computeNotifications(new Map(), snapshots, OPTS)
expect(next).toBeInstanceOf(Map)
expect([...next.keys()].sort()).toEqual(['sess-a', 'sess-b'])
expect(next.get('sess-a')).toEqual(snapshots[0])
})
test('does not mutate the previous map', () => {
// Arrange
const prev = new Map<string, SessionStatusSnapshot>([
['sess-a', snapshot({ sessionId: 'sess-a', claudeStatus: 'working' })],
])
const snapshots = [snapshot({ sessionId: 'sess-a', claudeStatus: 'waiting' })]
// Act
const { next } = computeNotifications(prev, snapshots, OPTS)
// Assert: prev is untouched; next is a distinct object with the new value.
expect(prev.size).toBe(1)
expect(prev.get('sess-a')?.claudeStatus).toBe('working')
expect(next).not.toBe(prev)
expect(next.get('sess-a')?.claudeStatus).toBe('waiting')
})
test('drops sessions that ended (absent from snapshots) from next', () => {
// Arrange: sess-b existed before but is gone this round.
const prev = new Map<string, SessionStatusSnapshot>([
['sess-a', snapshot({ sessionId: 'sess-a' })],
['sess-b', snapshot({ sessionId: 'sess-b' })],
])
const snapshots = [snapshot({ sessionId: 'sess-a' })]
// Act
const { next } = computeNotifications(prev, snapshots, OPTS)
// Assert
expect(next.has('sess-b')).toBe(false)
expect(next.has('sess-a')).toBe(true)
})
test('handles multiple notifying sessions', () => {
const snapshots = [
snapshot({ sessionId: 'sess-a', pendingApproval: true, gate: 'plan' }),
snapshot({ sessionId: 'sess-b', claudeStatus: 'stuck' }),
]
const { decisions, next } = computeNotifications(new Map(), snapshots, OPTS)
expect(decisions).toHaveLength(2)
expect(next.size).toBe(2)
})
test('returns no decisions for an empty snapshot list', () => {
const prev = new Map<string, SessionStatusSnapshot>([['sess-a', snapshot()]])
const { decisions, next } = computeNotifications(prev, [], OPTS)
expect(decisions).toEqual([])
expect(next.size).toBe(0)
})
})

View File

@@ -0,0 +1,188 @@
/**
* test/desktop/notify-policy.test.ts — B2: shouldNotify decision rules.
*/
import { describe, test, expect } from 'vitest'
import { shouldNotify, type NotifyOptions } from '../../desktop/src/notify-policy.js'
import type { SessionStatusSnapshot } from '../../desktop/src/types.js'
function snapshot(overrides: Partial<SessionStatusSnapshot> = {}): SessionStatusSnapshot {
return {
sessionId: 'sess-1234abcd-ef',
claudeStatus: 'idle',
pendingApproval: false,
gate: null,
title: undefined,
...overrides,
}
}
const OPTS: NotifyOptions = {
windowFocused: false,
notifyOnApproval: true,
notifyOnStatusChange: true,
}
describe('shouldNotify — focus suppression', () => {
test('suppresses everything when the window is focused', () => {
// Arrange
const prev = snapshot()
const next = snapshot({ pendingApproval: true, gate: 'plan', claudeStatus: 'waiting' })
// Act
const decision = shouldNotify(prev, next, { ...OPTS, windowFocused: true })
// Assert
expect(decision).toEqual({ notify: false, title: '', body: '' })
})
})
describe('shouldNotify — approval rising edge', () => {
test('notifies on the false → true approval edge', () => {
const prev = snapshot({ pendingApproval: false })
const next = snapshot({ pendingApproval: true, gate: 'tool' })
const decision = shouldNotify(prev, next, OPTS)
expect(decision.notify).toBe(true)
expect(decision.title).toBe('Approval needed')
expect(decision.body).toContain('tool approval')
expect(decision.body).not.toContain('tool tool')
})
test('reads "tool approval" (never "tool tool") for a tool or null gate', () => {
for (const gate of ['tool', null] as const) {
const next = snapshot({ pendingApproval: true, gate })
const decision = shouldNotify(undefined, next, OPTS)
expect(decision.body).toContain('tool approval')
expect(decision.body).not.toContain('tool tool')
}
})
test('notifies when there is no previous snapshot and approval is pending', () => {
const next = snapshot({ pendingApproval: true, gate: 'plan' })
const decision = shouldNotify(undefined, next, OPTS)
expect(decision.notify).toBe(true)
expect(decision.body).toContain('plan approval')
})
test('does NOT notify when approval was already pending', () => {
const prev = snapshot({ pendingApproval: true, gate: 'tool' })
const next = snapshot({ pendingApproval: true, gate: 'tool' })
const decision = shouldNotify(prev, next, OPTS)
expect(decision.notify).toBe(false)
})
test('is gated off by notifyOnApproval=false', () => {
const prev = snapshot({ pendingApproval: false })
const next = snapshot({ pendingApproval: true, gate: 'tool' })
const decision = shouldNotify(prev, next, { ...OPTS, notifyOnApproval: false })
expect(decision.notify).toBe(false)
})
test('uses the session title in the body when known', () => {
const next = snapshot({ pendingApproval: true, gate: 'plan', title: 'web-terminal' })
const decision = shouldNotify(undefined, next, OPTS)
expect(decision.body).toContain('web-terminal')
})
test('falls back to a short id label when title is undefined', () => {
const next = snapshot({ pendingApproval: true, gate: 'tool', title: undefined })
const decision = shouldNotify(undefined, next, OPTS)
expect(decision.body).toContain('session sess-123')
})
})
describe('shouldNotify — status change', () => {
test('notifies on a transition to waiting', () => {
const prev = snapshot({ claudeStatus: 'working' })
const next = snapshot({ claudeStatus: 'waiting' })
const decision = shouldNotify(prev, next, OPTS)
expect(decision.notify).toBe(true)
expect(decision.body).toContain('waiting')
})
test('notifies on a transition to stuck', () => {
const prev = snapshot({ claudeStatus: 'working' })
const next = snapshot({ claudeStatus: 'stuck' })
const decision = shouldNotify(prev, next, OPTS)
expect(decision.notify).toBe(true)
expect(decision.title).toBe('Session may be stuck')
expect(decision.body).toContain('stuck')
})
test('does NOT notify on a transition to working', () => {
const prev = snapshot({ claudeStatus: 'idle' })
const next = snapshot({ claudeStatus: 'working' })
const decision = shouldNotify(prev, next, OPTS)
expect(decision.notify).toBe(false)
})
test('does NOT notify on a transition to idle', () => {
const prev = snapshot({ claudeStatus: 'working' })
const next = snapshot({ claudeStatus: 'idle' })
const decision = shouldNotify(prev, next, OPTS)
expect(decision.notify).toBe(false)
})
test('does NOT notify when the status is unchanged', () => {
const prev = snapshot({ claudeStatus: 'waiting' })
const next = snapshot({ claudeStatus: 'waiting' })
const decision = shouldNotify(prev, next, OPTS)
expect(decision.notify).toBe(false)
})
test('is gated off by notifyOnStatusChange=false', () => {
const prev = snapshot({ claudeStatus: 'working' })
const next = snapshot({ claudeStatus: 'waiting' })
const decision = shouldNotify(prev, next, { ...OPTS, notifyOnStatusChange: false })
expect(decision.notify).toBe(false)
})
})
describe('shouldNotify — priority', () => {
test('approval edge takes priority over a concurrent status change', () => {
// Arrange: both an approval rising edge AND a status change to waiting.
const prev = snapshot({ pendingApproval: false, claudeStatus: 'working' })
const next = snapshot({ pendingApproval: true, gate: 'plan', claudeStatus: 'waiting' })
// Act
const decision = shouldNotify(prev, next, OPTS)
// Assert: it is the approval notification, not the status one.
expect(decision.title).toBe('Approval needed')
})
test('returns a silent decision when nothing is notable', () => {
const prev = snapshot({ claudeStatus: 'working' })
const next = snapshot({ claudeStatus: 'unknown' })
const decision = shouldNotify(prev, next, OPTS)
expect(decision).toEqual({ notify: false, title: '', body: '' })
})
})

97
test/desktop/port.test.ts Normal file
View File

@@ -0,0 +1,97 @@
/**
* test/desktop/port.test.ts — unit tests for pickFreePort (B1).
*
* Real loopback binds (not mocks) exercise both branches: the preferred port is
* free (return it) and the preferred port is occupied (fall back to an OS port).
*/
import { describe, test, expect } from 'vitest'
import net from 'node:net'
import { pickFreePort } from '../../desktop/src/port.js'
const LOOPBACK = '127.0.0.1'
const MIN_PORT = 1
const MAX_PORT = 65535
/** Bind a server on 127.0.0.1:0 and resolve with the live server + its OS-assigned port. */
function occupyEphemeralPort(): Promise<{ server: net.Server; port: number }> {
return new Promise((resolve, reject) => {
const server = net.createServer()
server.on('error', reject)
server.listen(0, LOOPBACK, () => {
const address = server.address()
if (address !== null && typeof address === 'object') resolve({ server, port: address.port })
else reject(new Error('no AddressInfo'))
})
})
}
function closeServer(server: net.Server): Promise<void> {
return new Promise((resolve) => server.close(() => resolve()))
}
/** Acquire a currently-free port number (bind ephemeral, then release it). */
async function getFreePort(): Promise<number> {
const { server, port } = await occupyEphemeralPort()
await closeServer(server)
return port
}
/** Confirm a port can be bound right now, then release it. */
async function isBindable(port: number): Promise<boolean> {
const server = net.createServer()
try {
await new Promise<void>((resolve, reject) => {
server.on('error', reject)
server.listen(port, LOOPBACK, () => resolve())
})
await closeServer(server)
return true
} catch {
return false
}
}
describe('pickFreePort', () => {
test('returns the preferred port when it is free', async () => {
// Arrange
const preferred = await getFreePort()
// Act
const result = await pickFreePort(preferred)
// Assert
expect(result).toBe(preferred)
})
test('returns a different, bindable port when the preferred port is occupied', async () => {
// Arrange
const { server, port: occupied } = await occupyEphemeralPort()
try {
// Act
const result = await pickFreePort(occupied)
// Assert
expect(result).not.toBe(occupied)
expect(Number.isInteger(result)).toBe(true)
expect(result).toBeGreaterThanOrEqual(MIN_PORT)
expect(result).toBeLessThanOrEqual(MAX_PORT)
expect(await isBindable(result)).toBe(true)
} finally {
await closeServer(server)
}
})
test('returns an integer within the valid TCP port range', async () => {
// Arrange
const preferred = await getFreePort()
// Act
const result = await pickFreePort(preferred)
// Assert
expect(Number.isInteger(result)).toBe(true)
expect(result).toBeGreaterThanOrEqual(MIN_PORT)
expect(result).toBeLessThanOrEqual(MAX_PORT)
})
})

218
test/desktop/prefs.test.ts Normal file
View File

@@ -0,0 +1,218 @@
/**
* test/desktop/prefs.test.ts — B3: unit tests for prefs defaults/validate/merge.
*
* Covers: defaultPrefs shape (and platform-independence); validatePrefs adopting
* valid fields and falling back per-field on wrong types / out-of-range port /
* empty shellPath / non-object raw / null / array; that it never throws; and
* mergePrefs immutability + ignoring undefined patch fields.
*/
import { describe, test, expect } from 'vitest'
import type { DesktopPrefs } from '../../desktop/src/types.js'
import { defaultPrefs, validatePrefs, mergePrefs } from '../../desktop/src/prefs.js'
const EXPECTED_DEFAULTS: DesktopPrefs = {
port: null,
lanSharing: false,
shellPath: null,
openAtLogin: false,
notifyOnApproval: true,
notifyOnStatusChange: true,
}
describe('defaultPrefs', () => {
test('returns the documented baseline shape (LAN sharing off by default)', () => {
// Arrange / Act
const prefs = defaultPrefs('darwin')
// Assert
expect(prefs).toEqual(EXPECTED_DEFAULTS)
})
test('is platform-independent (same defaults across platforms)', () => {
// Arrange / Act
const darwin = defaultPrefs('darwin')
const win32 = defaultPrefs('win32')
const linux = defaultPrefs('linux')
// Assert
expect(win32).toEqual(darwin)
expect(linux).toEqual(darwin)
})
test('returns a new object each call (no shared mutable state)', () => {
// Arrange / Act
const a = defaultPrefs('darwin')
const b = defaultPrefs('darwin')
// Assert
expect(a).not.toBe(b)
})
})
describe('validatePrefs', () => {
test('adopts every field from a fully-valid object', () => {
// Arrange
const raw = {
port: 8080,
lanSharing: true,
shellPath: '/bin/bash',
openAtLogin: true,
notifyOnApproval: false,
notifyOnStatusChange: false,
}
// Act
const prefs = validatePrefs(raw, 'darwin')
// Assert
expect(prefs).toEqual(raw)
})
test('falls back to defaults when raw is not an object', () => {
// Assert — strings, numbers, booleans are not prefs objects
expect(validatePrefs('nope', 'darwin')).toEqual(EXPECTED_DEFAULTS)
expect(validatePrefs(42, 'darwin')).toEqual(EXPECTED_DEFAULTS)
expect(validatePrefs(true, 'darwin')).toEqual(EXPECTED_DEFAULTS)
})
test('falls back to defaults when raw is null', () => {
expect(validatePrefs(null, 'darwin')).toEqual(EXPECTED_DEFAULTS)
})
test('falls back to defaults when raw is undefined', () => {
expect(validatePrefs(undefined, 'darwin')).toEqual(EXPECTED_DEFAULTS)
})
test('falls back to defaults when raw is an array (arrays are not records)', () => {
expect(validatePrefs([1, 2, 3], 'darwin')).toEqual(EXPECTED_DEFAULTS)
})
test('falls back per-field on wrong types, keeping valid siblings', () => {
// Arrange — lanSharing is a number (invalid); port/shellPath are valid
const raw = {
port: 3001,
lanSharing: 1,
shellPath: '/bin/zsh',
openAtLogin: 'yes',
notifyOnApproval: null,
notifyOnStatusChange: 0,
}
// Act
const prefs = validatePrefs(raw, 'darwin')
// Assert — valid fields adopted, invalid fields fall back to defaults
expect(prefs.port).toBe(3001)
expect(prefs.shellPath).toBe('/bin/zsh')
expect(prefs.lanSharing).toBe(false)
expect(prefs.openAtLogin).toBe(false)
expect(prefs.notifyOnApproval).toBe(true)
expect(prefs.notifyOnStatusChange).toBe(true)
})
test('rejects out-of-range and non-integer ports, keeping default null', () => {
expect(validatePrefs({ port: 0 }, 'darwin').port).toBeNull()
expect(validatePrefs({ port: 65536 }, 'darwin').port).toBeNull()
expect(validatePrefs({ port: -1 }, 'darwin').port).toBeNull()
expect(validatePrefs({ port: 3.5 }, 'darwin').port).toBeNull()
expect(validatePrefs({ port: NaN }, 'darwin').port).toBeNull()
expect(validatePrefs({ port: '3000' }, 'darwin').port).toBeNull()
})
test('accepts boundary ports (1 and 65535)', () => {
expect(validatePrefs({ port: 1 }, 'darwin').port).toBe(1)
expect(validatePrefs({ port: 65535 }, 'darwin').port).toBe(65535)
})
test('accepts an explicit null port', () => {
expect(validatePrefs({ port: null }, 'darwin').port).toBeNull()
})
test('rejects empty and whitespace-only shellPath, keeping default null', () => {
expect(validatePrefs({ shellPath: '' }, 'darwin').shellPath).toBeNull()
expect(validatePrefs({ shellPath: ' ' }, 'darwin').shellPath).toBeNull()
})
test('accepts an explicit null shellPath', () => {
expect(validatePrefs({ shellPath: null }, 'darwin').shellPath).toBeNull()
})
test('rejects a non-string shellPath, keeping default null', () => {
expect(validatePrefs({ shellPath: 123 }, 'darwin').shellPath).toBeNull()
})
test('never throws on hostile input shapes', () => {
// Assert — a grab-bag of malformed inputs must all return defaults, no throw
const hostile: unknown[] = [Symbol('x'), () => 0, new Map(), 0n]
for (const raw of hostile) {
expect(() => validatePrefs(raw, 'darwin')).not.toThrow()
expect(validatePrefs(raw, 'darwin')).toEqual(EXPECTED_DEFAULTS)
}
})
})
describe('mergePrefs', () => {
test('applies defined patch fields over the base', () => {
// Arrange
const base = defaultPrefs('darwin')
// Act
const merged = mergePrefs(base, { port: 4000, lanSharing: true })
// Assert
expect(merged.port).toBe(4000)
expect(merged.lanSharing).toBe(true)
expect(merged.shellPath).toBeNull()
})
test('ignores undefined patch fields (does not clobber base)', () => {
// Arrange
const base = mergePrefs(defaultPrefs('darwin'), { port: 5000, shellPath: '/bin/bash' })
// Act — an explicit-undefined patch field must be ignored
const merged = mergePrefs(base, { port: undefined, lanSharing: true })
// Assert
expect(merged.port).toBe(5000)
expect(merged.shellPath).toBe('/bin/bash')
expect(merged.lanSharing).toBe(true)
})
test('applies falsy-but-defined patch fields (false / null)', () => {
// Arrange
const base = mergePrefs(defaultPrefs('darwin'), { port: 6000, notifyOnApproval: true })
// Act
const merged = mergePrefs(base, { port: null, notifyOnApproval: false })
// Assert — null/false are defined values and must overwrite
expect(merged.port).toBeNull()
expect(merged.notifyOnApproval).toBe(false)
})
test('does not mutate the base object (immutability)', () => {
// Arrange
const base = defaultPrefs('darwin')
const snapshot = { ...base }
// Act
const merged = mergePrefs(base, { port: 7000 })
// Assert — base untouched, a new object returned
expect(base).toEqual(snapshot)
expect(merged).not.toBe(base)
})
test('does not mutate the patch object', () => {
// Arrange
const base = defaultPrefs('darwin')
const patch = { port: 8000 }
// Act
mergePrefs(base, patch)
// Assert
expect(patch).toEqual({ port: 8000 })
})
})

View File

@@ -0,0 +1,101 @@
/**
* test/desktop/server-config.test.ts — unit tests for buildServerEnv (B1).
*
* Covers the PORT / BIND_HOST / SHELL_PATH / USE_TMUX mapping, the immutability
* guarantee (input.env untouched), and pass-through of unrelated env vars.
*/
import { describe, test, expect } from 'vitest'
import { buildServerEnv } from '../../desktop/src/server-config.js'
import type { DesktopPrefs } from '../../desktop/src/types.js'
const BASE_PREFS: DesktopPrefs = {
port: null,
lanSharing: false,
shellPath: null,
openAtLogin: false,
notifyOnApproval: true,
notifyOnStatusChange: true,
}
describe('buildServerEnv', () => {
test('sets PORT to the stringified port', () => {
const result = buildServerEnv({ prefs: BASE_PREFS, port: 4321, platform: 'darwin', env: {} })
expect(result.PORT).toBe('4321')
})
test('binds 127.0.0.1 when lanSharing is off', () => {
const result = buildServerEnv({ prefs: BASE_PREFS, port: 3000, platform: 'darwin', env: {} })
expect(result.BIND_HOST).toBe('127.0.0.1')
})
test('binds 0.0.0.0 when lanSharing is on', () => {
// Arrange
const prefs = { ...BASE_PREFS, lanSharing: true }
// Act
const result = buildServerEnv({ prefs, port: 3000, platform: 'darwin', env: {} })
// Assert
expect(result.BIND_HOST).toBe('0.0.0.0')
})
test('uses prefs.shellPath as SHELL_PATH when provided (overriding the platform default)', () => {
// Arrange
const prefs = { ...BASE_PREFS, shellPath: '/usr/bin/fish' }
// Act
const result = buildServerEnv({ prefs, port: 3000, platform: 'darwin', env: { SHELL: '/bin/zsh' } })
// Assert
expect(result.SHELL_PATH).toBe('/usr/bin/fish')
})
test('falls back to the platform default SHELL_PATH when prefs.shellPath is null', () => {
const win = buildServerEnv({ prefs: BASE_PREFS, port: 3000, platform: 'win32', env: {} })
expect(win.SHELL_PATH).toBe('powershell.exe')
const mac = buildServerEnv({ prefs: BASE_PREFS, port: 3000, platform: 'darwin', env: { SHELL: '/bin/zsh' } })
expect(mac.SHELL_PATH).toBe('/bin/zsh')
})
test('forces USE_TMUX off on win32 even when the ambient env enables it', () => {
const result = buildServerEnv({ prefs: BASE_PREFS, port: 3000, platform: 'win32', env: { USE_TMUX: '1' } })
expect(result.USE_TMUX).toBe('0')
})
test('passes ambient USE_TMUX through on non-Windows platforms', () => {
const result = buildServerEnv({ prefs: BASE_PREFS, port: 3000, platform: 'linux', env: { USE_TMUX: '1' } })
expect(result.USE_TMUX).toBe('1')
})
test('defaults USE_TMUX to off on non-Windows when the env is unset', () => {
const result = buildServerEnv({ prefs: BASE_PREFS, port: 3000, platform: 'linux', env: {} })
expect(result.USE_TMUX).toBe('0')
})
test('does not mutate the input env', () => {
// Arrange
const env = { PATH: '/usr/bin', USE_TMUX: '1' }
const snapshot = { ...env }
// Act
buildServerEnv({ prefs: BASE_PREFS, port: 3000, platform: 'win32', env })
// Assert
expect(env).toEqual(snapshot)
})
test('preserves unrelated ambient env vars', () => {
// Arrange
const env = { PATH: '/usr/bin', HOME: '/Users/me', CUSTOM: 'x' }
// Act
const result = buildServerEnv({ prefs: BASE_PREFS, port: 3000, platform: 'darwin', env })
// Assert
expect(result.PATH).toBe('/usr/bin')
expect(result.HOME).toBe('/Users/me')
expect(result.CUSTOM).toBe('x')
})
})

View File

@@ -0,0 +1,188 @@
/**
* 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())
})
})

View File

@@ -0,0 +1,39 @@
/**
* test/desktop/shell.test.ts — unit tests for defaultShellForPlatform (B1).
*
* Covers every platform branch, with and without env.SHELL set.
*/
import { describe, test, expect } from 'vitest'
import { defaultShellForPlatform } from '../../desktop/src/shell.js'
describe('defaultShellForPlatform', () => {
test('returns powershell.exe on win32 regardless of env.SHELL', () => {
// Arrange / Act / Assert
expect(defaultShellForPlatform('win32', {})).toBe('powershell.exe')
expect(defaultShellForPlatform('win32', { SHELL: '/bin/zsh' })).toBe('powershell.exe')
})
test('returns env.SHELL on darwin when it is set', () => {
// Arrange
const env = { SHELL: '/opt/homebrew/bin/fish' }
// Act
const result = defaultShellForPlatform('darwin', env)
// Assert
expect(result).toBe('/opt/homebrew/bin/fish')
})
test('falls back to /bin/zsh on darwin when env.SHELL is unset', () => {
expect(defaultShellForPlatform('darwin', {})).toBe('/bin/zsh')
})
test('returns env.SHELL on linux when it is set', () => {
expect(defaultShellForPlatform('linux', { SHELL: '/usr/bin/bash' })).toBe('/usr/bin/bash')
})
test('falls back to /bin/bash on linux when env.SHELL is unset', () => {
expect(defaultShellForPlatform('linux', {})).toBe('/bin/bash')
})
})