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:
96
test/desktop/deep-link.test.ts
Normal file
96
test/desktop/deep-link.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
134
test/desktop/live-poll.test.ts
Normal file
134
test/desktop/live-poll.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
113
test/desktop/notifications.test.ts
Normal file
113
test/desktop/notifications.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
188
test/desktop/notify-policy.test.ts
Normal file
188
test/desktop/notify-policy.test.ts
Normal 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
97
test/desktop/port.test.ts
Normal 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
218
test/desktop/prefs.test.ts
Normal 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 })
|
||||
})
|
||||
})
|
||||
101
test/desktop/server-config.test.ts
Normal file
101
test/desktop/server-config.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
188
test/desktop/settings-store.test.ts
Normal file
188
test/desktop/settings-store.test.ts
Normal 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())
|
||||
})
|
||||
})
|
||||
39
test/desktop/shell.test.ts
Normal file
39
test/desktop/shell.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user