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:
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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user