cc-haha/desktop/electron/services/windows.test.ts
程序员阿江(Relakkes) 90cbe9f62c fix(desktop): polish plan mode and window drag behavior
Fixes #869, #874.

Render EnterPlanMode as a compact desktop status instead of exposing model-facing plan-mode instructions, and refresh Windows frameless window drag hit testing after first show.

Tested: cd desktop && bun run test -- src/components/chat/PlanModePermissionDialog.test.tsx --run

Tested: cd desktop && bun run test -- electron/services/windows.test.ts --run

Tested: bun run check:desktop

Tested: bun run check:native

Confidence: high

Scope-risk: narrow
2026-06-23 00:09:51 +08:00

401 lines
13 KiB
TypeScript

import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
import path from 'node:path'
import { tmpdir } from 'node:os'
import { describe, expect, it, vi } from 'vitest'
import {
clampWindowStateToVisibleWorkArea,
captureWindowState,
hideWindowSafely,
hasMeaningfulIntersection,
installWindowLifecycle,
isPersistableWindowState,
isWindowStateVisibleOnAnyDisplay,
readWindowState,
refreshWindowsDragHitTest,
restoreWindowMaximized,
showMainWindow,
toggleWindowFullScreen,
windowChromeOptionsForPlatform,
windowOptionsFromState,
windowStatePath,
writeWindowState,
} from './windows'
const fakeApp = (userData: string) => ({
getPath: vi.fn(() => userData),
})
describe('Electron window service', () => {
it('persists window state in CLAUDE_CONFIG_DIR when portable config is active', () => {
const tmp = mkdtempSync(path.join(tmpdir(), 'electron-window-state-'))
try {
const app = fakeApp(path.join(tmp, 'user-data'))
const state = { x: 10, y: 20, width: 1280, height: 820, maximized: false }
writeWindowState(app as never, state, { CLAUDE_CONFIG_DIR: tmp })
const statePath = windowStatePath(app as never, { CLAUDE_CONFIG_DIR: tmp })
expect(JSON.parse(readFileSync(statePath, 'utf-8'))).toEqual(state)
expect(app.getPath).not.toHaveBeenCalled()
} finally {
rmSync(tmp, { recursive: true, force: true })
}
})
it('rejects undersized or off-screen state before restore', () => {
expect(isPersistableWindowState({ x: 0, y: 0, width: 100, height: 100, maximized: false })).toBe(false)
expect(hasMeaningfulIntersection(
{ x: 5000, y: 5000, width: 1280, height: 820 },
{ x: 0, y: 0, width: 1440, height: 900 },
)).toBe(false)
expect(isWindowStateVisibleOnAnyDisplay(
{ x: 100, y: 100, width: 1280, height: 820, maximized: false },
[{ bounds: { x: 0, y: 0, width: 1440, height: 900 }, workArea: { x: 0, y: 0, width: 1440, height: 860 } }],
)).toBe(true)
})
it('reads only valid visible window state', () => {
const tmp = mkdtempSync(path.join(tmpdir(), 'electron-window-state-read-'))
try {
const app = fakeApp(tmp)
const state = { x: 50, y: 60, width: 1280, height: 820, maximized: true }
writeWindowState(app as never, state, {})
expect(readWindowState(
app as never,
[{ bounds: { x: 0, y: 0, width: 1440, height: 900 }, workArea: { x: 0, y: 0, width: 1440, height: 860 } }],
{},
'linux',
)).toEqual(state)
expect(readWindowState(
app as never,
[{ bounds: { x: 3000, y: 3000, width: 1440, height: 900 }, workArea: { x: 3000, y: 3000, width: 1440, height: 860 } }],
{},
'linux',
)).toBeNull()
} finally {
rmSync(tmp, { recursive: true, force: true })
}
})
it('clamps restored macOS windows below the menu bar work area', () => {
const tmp = mkdtempSync(path.join(tmpdir(), 'electron-window-state-clamp-'))
try {
const app = fakeApp(tmp)
const state = { x: 620, y: 0, width: 1280, height: 820, maximized: false }
const display = {
bounds: { x: 0, y: 0, width: 2560, height: 1440 },
workArea: { x: 0, y: 40, width: 2560, height: 1320 },
}
writeWindowState(app as never, state, {})
expect(clampWindowStateToVisibleWorkArea(state, [display])).toEqual({
...state,
y: 40,
})
expect(readWindowState(app as never, [display], {}, 'darwin')).toEqual({
...state,
y: 40,
})
expect(readWindowState(app as never, [display], {}, 'linux')).toEqual(state)
} finally {
rmSync(tmp, { recursive: true, force: true })
}
})
it('does not capture minimized windows', () => {
const window = {
isMinimized: () => true,
isMaximized: () => false,
getBounds: () => ({ x: 0, y: 0, width: 1280, height: 820 }),
}
expect(captureWindowState(window as never)).toBeNull()
})
it('restores persisted bounds and maximized state when reopening the window', () => {
const state = { x: 12, y: 34, width: 1400, height: 900, maximized: true }
const maximize = vi.fn()
expect(windowOptionsFromState(state)).toEqual({
x: 12,
y: 34,
width: 1400,
height: 900,
})
restoreWindowMaximized({ maximize } as never, state)
expect(maximize).toHaveBeenCalledTimes(1)
})
it('uses frameless custom chrome only on Windows', () => {
expect(windowChromeOptionsForPlatform('win32')).toEqual({
frame: false,
autoHideMenuBar: true,
fullscreenable: true,
})
expect(windowChromeOptionsForPlatform('darwin')).toEqual({
titleBarStyle: 'hiddenInset',
fullscreenable: false,
})
expect(windowChromeOptionsForPlatform('linux')).toEqual({
titleBarStyle: 'default',
fullscreenable: true,
})
})
it('refreshes Windows drag hit testing after the first frameless show', () => {
vi.useFakeTimers()
try {
const bounds = { x: 20, y: 30, width: 1280, height: 820 }
const window = {
isDestroyed: () => false,
isMinimized: () => false,
isMaximized: () => false,
isFullScreen: () => false,
getBounds: vi.fn(() => bounds),
setBounds: vi.fn(),
}
const cancel = refreshWindowsDragHitTest(window as never, 'win32', 100)
expect(cancel).toEqual(expect.any(Function))
expect(window.setBounds).not.toHaveBeenCalled()
vi.advanceTimersByTime(100)
expect(window.getBounds).toHaveBeenCalledTimes(1)
expect(window.setBounds).toHaveBeenNthCalledWith(1, { ...bounds, height: bounds.height + 1 })
expect(window.setBounds).toHaveBeenNthCalledWith(2, bounds)
} finally {
vi.useRealTimers()
}
})
it('does not refresh drag hit testing outside Windows', () => {
vi.useFakeTimers()
try {
const window = {
setBounds: vi.fn(),
}
expect(refreshWindowsDragHitTest(window as never, 'darwin', 100)).toBeUndefined()
vi.advanceTimersByTime(100)
expect(window.setBounds).not.toHaveBeenCalled()
} finally {
vi.useRealTimers()
}
})
it('skips the Windows drag hit-test refresh after the window is destroyed', () => {
vi.useFakeTimers()
try {
const window = {
isDestroyed: () => true,
isMinimized: () => false,
isMaximized: () => false,
isFullScreen: () => false,
getBounds: vi.fn(() => ({ x: 20, y: 30, width: 1280, height: 820 })),
setBounds: vi.fn(),
}
refreshWindowsDragHitTest(window as never, 'win32', 100)
vi.advanceTimersByTime(100)
expect(window.getBounds).not.toHaveBeenCalled()
expect(window.setBounds).not.toHaveBeenCalled()
} finally {
vi.useRealTimers()
}
})
it('shows, restores, and focuses the hidden main window when a tray or notification action reopens it', () => {
const window = {
isVisible: () => false,
isMinimized: () => true,
show: vi.fn(),
restore: vi.fn(),
focus: vi.fn(),
}
const app = {
show: vi.fn(),
}
showMainWindow(window as never, app)
expect(app.show).toHaveBeenCalledTimes(1)
expect(window.show).toHaveBeenCalledTimes(1)
expect(window.restore).toHaveBeenCalledTimes(1)
expect(window.focus).toHaveBeenCalledTimes(1)
})
it('hides instead of closing until the app is explicitly quitting', () => {
const tmp = mkdtempSync(path.join(tmpdir(), 'electron-window-close-'))
try {
const handlers = new Map<string, (...args: never[]) => void>()
const preventDefault = vi.fn()
const window = {
on: vi.fn((event: string, handler: (...args: never[]) => void) => {
handlers.set(event, handler)
}),
hide: vi.fn(),
isSimpleFullScreen: () => false,
isFullScreen: () => false,
isMinimized: () => false,
isMaximized: () => false,
getBounds: () => ({ x: 0, y: 0, width: 1280, height: 820 }),
}
installWindowLifecycle({
app: fakeApp(tmp) as never,
window: window as never,
shouldQuit: () => false,
})
handlers.get('close')?.({ preventDefault } as never)
expect(preventDefault).toHaveBeenCalledTimes(1)
expect(window.hide).toHaveBeenCalledTimes(1)
} finally {
rmSync(tmp, { recursive: true, force: true })
}
})
it('exits fullscreen before hiding on close to avoid a black macOS fullscreen Space', () => {
const tmp = mkdtempSync(path.join(tmpdir(), 'electron-window-fullscreen-close-'))
try {
const handlers = new Map<string, (...args: never[]) => void>()
const onceHandlers = new Map<string, (...args: never[]) => void>()
const preventDefault = vi.fn()
const window = {
on: vi.fn((event: string, handler: (...args: never[]) => void) => {
handlers.set(event, handler)
}),
once: vi.fn((event: string, handler: (...args: never[]) => void) => {
onceHandlers.set(event, handler)
}),
hide: vi.fn(),
setFullScreen: vi.fn(),
isDestroyed: () => false,
isSimpleFullScreen: () => false,
isFullScreen: () => true,
isMinimized: () => false,
isMaximized: () => false,
getBounds: () => ({ x: 0, y: 0, width: 1280, height: 820 }),
}
installWindowLifecycle({
app: fakeApp(tmp) as never,
window: window as never,
shouldQuit: () => false,
})
handlers.get('close')?.({ preventDefault } as never)
expect(preventDefault).toHaveBeenCalledTimes(1)
expect(window.setFullScreen).toHaveBeenCalledWith(false)
expect(window.hide).not.toHaveBeenCalled()
onceHandlers.get('leave-full-screen')?.()
expect(window.hide).toHaveBeenCalledTimes(1)
} finally {
rmSync(tmp, { recursive: true, force: true })
}
})
it('runs follow-up hide actions only after fullscreen has been left', () => {
const onceHandlers = new Map<string, (...args: never[]) => void>()
const afterHide = vi.fn()
const window = {
once: vi.fn((event: string, handler: (...args: never[]) => void) => {
onceHandlers.set(event, handler)
}),
hide: vi.fn(),
setFullScreen: vi.fn(),
isDestroyed: () => false,
isSimpleFullScreen: () => false,
isFullScreen: () => true,
}
hideWindowSafely(window as never, afterHide)
expect(window.setFullScreen).toHaveBeenCalledWith(false)
expect(window.hide).not.toHaveBeenCalled()
expect(afterHide).not.toHaveBeenCalled()
onceHandlers.get('leave-full-screen')?.()
expect(window.hide).toHaveBeenCalledTimes(1)
expect(afterHide).toHaveBeenCalledTimes(1)
})
it('hides immediately after leaving simple fullscreen because it does not create a macOS Space', () => {
const afterHide = vi.fn()
const window = {
isSimpleFullScreen: () => true,
setSimpleFullScreen: vi.fn(),
hide: vi.fn(),
}
hideWindowSafely(window as never, afterHide)
expect(window.setSimpleFullScreen).toHaveBeenCalledWith(false)
expect(window.hide).toHaveBeenCalledTimes(1)
expect(afterHide).toHaveBeenCalledTimes(1)
})
it('toggles simple fullscreen on macOS instead of native fullscreen Spaces', () => {
const window = {
isSimpleFullScreen: () => false,
setSimpleFullScreen: vi.fn(),
isFullScreen: vi.fn(),
setFullScreen: vi.fn(),
}
toggleWindowFullScreen(window as never, 'darwin')
expect(window.setSimpleFullScreen).toHaveBeenCalledWith(true)
expect(window.setFullScreen).not.toHaveBeenCalled()
})
it('toggles native fullscreen on non-macOS platforms', () => {
const window = {
isSimpleFullScreen: vi.fn(),
setSimpleFullScreen: vi.fn(),
isFullScreen: () => false,
setFullScreen: vi.fn(),
}
toggleWindowFullScreen(window as never, 'linux')
expect(window.setFullScreen).toHaveBeenCalledWith(true)
expect(window.setSimpleFullScreen).not.toHaveBeenCalled()
})
it('allows the window to close normally once the app is explicitly quitting', () => {
const tmp = mkdtempSync(path.join(tmpdir(), 'electron-window-quit-'))
try {
const handlers = new Map<string, (...args: never[]) => void>()
const preventDefault = vi.fn()
const window = {
on: vi.fn((event: string, handler: (...args: never[]) => void) => {
handlers.set(event, handler)
}),
hide: vi.fn(),
isMinimized: () => false,
isMaximized: () => false,
getBounds: () => ({ x: 0, y: 0, width: 1280, height: 820 }),
}
installWindowLifecycle({
app: fakeApp(tmp) as never,
window: window as never,
shouldQuit: () => true,
})
handlers.get('close')?.({ preventDefault } as never)
expect(preventDefault).not.toHaveBeenCalled()
expect(window.hide).not.toHaveBeenCalled()
} finally {
rmSync(tmp, { recursive: true, force: true })
}
})
})