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

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

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

114 lines
3.7 KiB
TypeScript

/**
* 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)
})
})