cc-haha/desktop/electron/services/petWindow.test.ts
程序员阿江(Relakkes) 8f3a2f092c fix(desktop): let the dragged pet reach the macOS menu bar
The mascot sits at the bottom of a mostly transparent 384x400 window, so
clamping a drag against the mascot rather than against the whole window
deliberately asks for a negative y that pushes the padding above it off-screen.
macOS runs a visible window's frame through
-[NSWindow constrainFrameRect:toScreen:], which rewrites any y above the work
area back down to its top edge -- silently. The request was refused, the
controller never noticed, and the mascot stranded a padding-height below the
menu bar: 208px of dead band on this display, with
~/.claude/cc-haha/pet-window.json recording y=-175 for a window that never
left y=33.

Measured on Electron 42.7.0 / Darwin 25.4: only the top edge is constrained.
Off-screen x and off-screen bottom y are kept verbatim, which is why the other
three edges always worked. Nothing else escapes it -- not window level (all
eight, up to screen-saver), movable, frame, transparent, panel type, and not
positioning the window while hidden, since showInactive re-runs the constraint.
enableLargerThanScreen is the one switch that skips the method, and it leaves
size, getContentBounds and ordinary moves untouched. clampPetWindowPosition is
already a complete four-edge bound, so opting out makes it the only clamp
rather than removing one.

Restoring then exposed a second problem the constraint had been hiding. A saved
position deliberately leaves the padding off-screen, but the window is created
before the renderer reports any region, so getPetWindowBounds clamped it against
the whole window and opened a padding-height lower -- a jump of zero while the
window was pinned, 208px once it is not. Persist the mascot box next to the
position so the first frame lands where the drag left it. State written without
one still restores the way it always did, and an empty box is dropped rather
than trusted.

The fake window accepted every y, so no top-edge assertion could fail and the
suite stayed green through all of this -- the same blind spot that let the
Windows DIP bugs hide. It now applies the constraint the way AppKit does, only
while visible and only when the constructor options did not opt out, so the
assertions prove the fix rather than the platform. Reverting either half turns
the new cases red.
2026-07-26 02:38:03 +08:00

1101 lines
41 KiB
TypeScript

import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import {
PET_WINDOW_HEIGHT,
PET_WINDOW_WIDTH,
PetWindowController,
type PetWindowPosition,
clampPetWindowPosition,
getPetWindowBounds,
petWindowStatePath,
petWindowOptions,
readPetWindowPosition,
writePetWindowPosition,
} from './petWindow'
const desktopRoot = existsSync(path.resolve(process.cwd(), 'electron', 'main.ts'))
? process.cwd()
: path.resolve(process.cwd(), 'desktop')
const mainSource = readFileSync(path.join(desktopRoot, 'electron', 'main.ts'), 'utf8')
.replace(/\r\n/g, '\n')
type FakeWindowOptions = {
/**
* Display scale factor. Chromium converts window rects between physical
* pixels and DIP with ceil in *both* directions (ScreenWin::ScreenToDIPRect
* and DIPToScreenRect), so on a fractional scale every getBounds ->
* setBounds round trip grows the window by a pixel. Modelling that is what
* makes the size-neutrality assertions mean anything; the default of 1 keeps
* the arithmetic exact for every test that does not care.
*/
scaleFactor?: number
/**
* Client area, when it differs from the window box. Renderer regions are
* measured against this, not against getBounds().
*/
contentSize?: { width: number; height: number }
/** Drop getContentBounds entirely, to exercise the fallback. */
withoutContentBounds?: boolean
/**
* Work area top edge, when the platform constrains the window to it.
*
* macOS runs a *visible* window's frame through
* -[NSWindow constrainFrameRect:toScreen:], which rewrites any y above the
* work area back down to that edge — so the negative y the mascot clamp asks
* for is silently refused and the mascot strands a padding-height below the
* menu bar. A fake window that accepts every y proves nothing about the top
* edge, which is why the bug survived a green suite. Left undefined the fake
* behaves like Windows and Linux, which impose no such limit.
*/
constrainTopTo?: number
}
function createFakeWindow(
initialBounds = {
x: 100,
y: 100,
width: PET_WINDOW_WIDTH,
height: PET_WINDOW_HEIGHT,
},
{
scaleFactor = 1,
contentSize,
withoutContentBounds = false,
constrainTopTo,
}: FakeWindowOptions = {},
) {
const handlers = new Map<string, () => void>()
let visible = false
let destroyed = false
let escapesConstraint = false
const toPhysical = (value: number) => Math.ceil(value * scaleFactor)
const toDip = (value: number) => Math.ceil(value / scaleFactor)
let physical = {
x: initialBounds.x,
y: initialBounds.y,
width: toPhysical(initialBounds.width),
height: toPhysical(initialBounds.height),
}
const readBounds = () => ({
x: physical.x,
y: physical.y,
width: toDip(physical.width),
height: toDip(physical.height),
})
// enableLargerThanScreen is the one flag that skips constrainFrameRect, so
// the constraint has to read it off the constructor options the controller
// actually passed — otherwise the fake proves the platform's behaviour
// rather than the fix for it.
const constrainTop = (y: number) =>
constrainTopTo !== undefined && visible && !escapesConstraint
? Math.max(y, constrainTopTo)
: y
const setBounds = vi.fn((next: Partial<typeof physical>) => {
const merged = { ...readBounds(), ...next }
physical = {
x: merged.x,
y: constrainTop(merged.y),
width: toPhysical(merged.width),
height: toPhysical(merged.height),
}
})
return {
handlers,
applyConstructorOptions(options: { enableLargerThanScreen?: boolean }) {
escapesConstraint = options.enableLargerThanScreen === true
},
isDestroyed: vi.fn(() => destroyed),
isVisible: vi.fn(() => visible),
// Showing re-runs the constraint, so parking a hidden window above the
// work area does not survive the reveal.
showInactive: vi.fn(() => {
visible = true
physical = { ...physical, y: constrainTop(physical.y) }
}),
hide: vi.fn(() => {
visible = false
}),
destroy: vi.fn(() => {
destroyed = true
}),
setAlwaysOnTop: vi.fn(),
setVisibleOnAllWorkspaces: vi.fn(),
setIgnoreMouseEvents: vi.fn(),
setShape: vi.fn(),
getBounds: vi.fn(readBounds),
...(withoutContentBounds ? {} : {
getContentBounds: vi.fn(() => ({ ...readBounds(), ...contentSize })),
}),
setBounds,
// Electron implements NativeWindow::SetPosition as
// SetBounds(gfx::Rect(position, GetSize())) — the size makes a lossy round
// trip through DIP on every call.
setPosition: vi.fn((x: number, y: number) => {
const { width, height } = readBounds()
setBounds({ x, y, width, height })
}),
on: vi.fn((event: string, handler: () => void) => {
handlers.set(event, handler)
}),
}
}
// Dragging moves the window with setBounds so the size is restated every tick.
function lastDragBounds(window: ReturnType<typeof createFakeWindow>) {
return window.setBounds.mock.calls.at(-1)?.[0]
}
function draggedTo(position: PetWindowPosition) {
return { ...position, width: PET_WINDOW_WIDTH, height: PET_WINDOW_HEIGHT }
}
describe('Electron pet window service', () => {
it('places fixed companion bounds inside the current display work area', () => {
expect(getPetWindowBounds({ x: 1440, y: 25, width: 1920, height: 1055 })).toEqual({
x: 1440 + 1920 - PET_WINDOW_WIDTH - 24,
y: 25 + 1055 - PET_WINDOW_HEIGHT - 24,
width: PET_WINDOW_WIDTH,
height: PET_WINDOW_HEIGHT,
})
})
it('restores and clamps a saved position into its visible display work area', () => {
const workArea = { x: -900, y: 25, width: 900, height: 700 }
expect(clampPetWindowPosition({ x: -1_200, y: 900 }, workArea)).toEqual({
x: -900,
y: 325,
})
expect(getPetWindowBounds(workArea, { x: -1_200, y: 900 })).toEqual({
x: -900,
y: 325,
width: PET_WINDOW_WIDTH,
height: PET_WINDOW_HEIGHT,
})
})
it('persists position only in the app-owned cc-haha config root', () => {
const root = mkdtempSync(path.join(tmpdir(), 'cc-haha-pet-position-'))
const configDir = path.join(root, 'portable')
const env = {
CLAUDE_CONFIG_DIR: configDir,
CODEX_HOME: path.join(root, 'codex-must-not-be-used'),
}
try {
expect(petWindowStatePath(env, root)).toBe(path.join(
configDir,
'cc-haha',
'pet-window.json',
))
writePetWindowPosition({ x: -420.4, y: 85.7 }, env, root)
expect(readPetWindowPosition(env, root)).toEqual({ x: -420, y: 86 })
expect(existsSync(path.join(root, 'codex-must-not-be-used'))).toBe(false)
} finally {
rmSync(root, { recursive: true, force: true })
}
})
it('persists the mascot box with the position and tolerates state without one', () => {
const root = mkdtempSync(path.join(tmpdir(), 'cc-haha-pet-region-'))
const env = { CLAUDE_CONFIG_DIR: path.join(root, 'portable') }
try {
writePetWindowPosition(
{ x: 100, y: -215, region: { x: 136.4, y: 240.2, width: 112, height: 128 } },
env,
root,
)
expect(readPetWindowPosition(env, root)).toEqual({
x: 100,
y: -215,
region: { x: 136, y: 240, width: 112, height: 128 },
})
// State written before the region was persisted still restores.
writePetWindowPosition({ x: 12, y: 34 }, env, root)
expect(readPetWindowPosition(env, root)).toEqual({ x: 12, y: 34 })
// An empty box would clamp as if the mascot filled nothing, so it is
// dropped rather than trusted.
writePetWindowPosition(
{ x: 12, y: 34, region: { x: 0, y: 0, width: 0, height: 10 } },
env,
root,
)
expect(readPetWindowPosition(env, root)).toEqual({ x: 12, y: 34 })
} finally {
rmSync(root, { recursive: true, force: true })
}
})
it('reopens a saved edge position where the drag actually left it', () => {
const workArea = { x: 0, y: 25, width: 800, height: 575 }
const region = { x: 136, y: 240, width: 112, height: 128 }
const saved = { x: 100, y: workArea.y - region.y, region }
expect(getPetWindowBounds(workArea, saved).y).toBe(saved.y)
// The same position without the box clamps against the whole window and
// opens a padding-height lower — the jump the renderer used to correct
// once it reported the live region.
expect(getPetWindowBounds(workArea, { x: saved.x, y: saved.y }).y).toBe(workArea.y)
})
it('creates a transparent frameless sandboxed always-on-top window', () => {
const macOptions = petWindowOptions(
{ x: 20, y: 30, width: PET_WINDOW_WIDTH, height: PET_WINDOW_HEIGHT },
'/app/electron-dist/preload.cjs',
'darwin',
)
expect(macOptions).toMatchObject({
x: 20,
y: 30,
width: PET_WINDOW_WIDTH,
height: PET_WINDOW_HEIGHT,
alwaysOnTop: true,
backgroundColor: '#00000000',
// Without this macOS refuses every y above the work area, so the mascot
// can never be dragged up to the menu bar: the transparent padding above
// it has to be allowed off-screen.
enableLargerThanScreen: true,
frame: false,
fullscreenable: false,
hasShadow: false,
resizable: false,
show: false,
transparent: true,
type: 'panel',
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
partition: 'cc-haha-pet',
preload: '/app/electron-dist/preload.cjs',
sandbox: true,
},
})
expect(macOptions).not.toHaveProperty('skipTaskbar')
const windowsOptions = petWindowOptions(
{ x: 20, y: 30, width: PET_WINDOW_WIDTH, height: PET_WINDOW_HEIGHT },
'/app/electron-dist/preload.cjs',
'win32',
)
expect(windowsOptions.type).toBeUndefined()
expect(windowsOptions.skipTaskbar).toBe(true)
// Documented macOS-only; Windows and Linux never constrain the frame.
expect(windowsOptions).not.toHaveProperty('enableLargerThanScreen')
})
it('loads the dedicated renderer mode with pet-scoped server auth configured', () => {
expect(mainSource).toContain("loadRendererEntry(window, { petWindow: '1' })")
expect(mainSource).toContain('window.webContents.session.webRequest')
expect(mainSource).toContain('resolvePetServerAccess')
expect(mainSource).toContain('token: runtime.getPetAccessToken()')
})
it('validates the exact bounded spritesheet bytes without reopening a path', () => {
expect(mainSource).toContain('nativeImage.createFromBuffer(data).getSize()')
expect(mainSource).not.toContain('nativeImage.createFromPath')
})
it('focuses the main app before emitting the named session navigation event', () => {
expect(mainSource).toContain('showMainWindow(mainWindow, app)')
expect(mainSource).toContain(
'mainWindow?.webContents.send(ELECTRON_EVENT_CHANNELS.petNavigateSession, sessionId)',
)
})
it('lets only the owned pet window focus the main desktop window', () => {
expect(mainSource).toContain(
'registerHandler(ELECTRON_IPC_CHANNELS.petsFocusMainWindow, (event)',
)
expect(mainSource).toContain(
'if (!getPetWindowController().owns(currentWindow(event)))',
)
expect(mainSource).toContain('showMainWindow(mainWindow, app)')
})
it('routes the native context menu through the sender-owned pet controller', () => {
expect(mainSource).toContain(
'registerHandler(ELECTRON_IPC_CHANNELS.petsShowContextMenu, (event, payload)',
)
expect(mainSource).toContain(
'getPetWindowController().showContextMenu(\n currentWindow(event),',
)
expect(mainSource).toContain('closeLabel.trim(),\n Menu,')
})
it('routes drag coordinates through the sender-owned pet controller', () => {
expect(mainSource).toContain(
'getCursorScreenPoint: () => screen.getCursorScreenPoint()',
)
expect(mainSource).toContain(
'registerHandler(ELECTRON_IPC_CHANNELS.petsDragWindow, (event, payload)',
)
expect(mainSource).toContain(
'getPetWindowController().dragWindow(\n currentWindow(event),',
)
})
it('notifies the main renderer whenever native pet visibility changes', () => {
expect(mainSource).toContain(
'mainWindow?.webContents.send(ELECTRON_EVENT_CHANNELS.petVisibilityChanged, true)',
)
expect(mainSource).toContain(
'mainWindow?.webContents.send(ELECTRON_EVENT_CHANNELS.petVisibilityChanged, false)',
)
})
it('creates and loads only one window across concurrent show calls', async () => {
const window = createFakeWindow()
const createWindow = vi.fn(() => window)
let finishLoad: (() => void) | undefined
const load = vi.fn(() => new Promise<void>((resolve) => {
finishLoad = resolve
}))
const controller = new PetWindowController({
createWindow: createWindow as never,
getCurrentWorkArea: () => ({ x: 0, y: 0, width: 1440, height: 900 }),
load,
platform: 'darwin',
preloadPath: '/app/electron-dist/preload.cjs',
})
const firstShow = controller.show()
const secondShow = controller.show()
finishLoad?.()
await Promise.all([firstShow, secondShow])
expect(createWindow).toHaveBeenCalledTimes(1)
expect(load).toHaveBeenCalledTimes(1)
expect(window.showInactive).toHaveBeenCalledTimes(1)
expect(window.setAlwaysOnTop).toHaveBeenCalledWith(true, 'floating')
expect(window.setVisibleOnAllWorkspaces).toHaveBeenCalledWith(true, {
skipTransformProcessType: true,
visibleOnFullScreen: true,
})
expect(window.setIgnoreMouseEvents).toHaveBeenCalledWith(true, { forward: true })
})
it('restores the saved companion position before creating the window', async () => {
const window = createFakeWindow()
const createWindow = vi.fn(() => window)
const getWorkAreaForPoint = vi.fn(() => ({ x: -900, y: 25, width: 900, height: 700 }))
const controller = new PetWindowController({
createWindow: createWindow as never,
getCurrentWorkArea: () => ({ x: 0, y: 25, width: 1440, height: 875 }),
getWorkAreaForPoint,
load: vi.fn().mockResolvedValue(undefined),
platform: 'darwin',
preloadPath: '/app/electron-dist/preload.cjs',
readPosition: () => ({ x: -600, y: 80 }),
})
await controller.show()
expect(getWorkAreaForPoint).toHaveBeenCalledWith({
x: -600 + Math.floor(PET_WINDOW_WIDTH / 2),
y: 80 + Math.floor(PET_WINDOW_HEIGHT / 2),
})
expect(createWindow).toHaveBeenCalledWith(expect.objectContaining({
x: -600,
y: 80,
width: PET_WINDOW_WIDTH,
height: PET_WINDOW_HEIGHT,
}))
})
it('keeps the pointer anchor, clamps dragging to the pointer display, and saves on release', async () => {
const petWindow = createFakeWindow({
x: 100,
y: 120,
width: PET_WINDOW_WIDTH,
height: PET_WINDOW_HEIGHT,
})
const writePosition = vi.fn()
const getWorkAreaForPoint = vi.fn(() => ({ x: 0, y: 25, width: 800, height: 575 }))
const controller = new PetWindowController({
createWindow: vi.fn(() => petWindow) as never,
getCurrentWorkArea: () => ({ x: 0, y: 25, width: 800, height: 575 }),
getWorkAreaForPoint,
load: vi.fn().mockResolvedValue(undefined),
platform: 'darwin',
preloadPath: '/app/electron-dist/preload.cjs',
writePosition,
})
await controller.show()
controller.dragWindow(petWindow as never, { phase: 'start', x: 150, y: 180 })
controller.dragWindow(petWindow as never, { phase: 'move', x: 190, y: 220 })
expect(lastDragBounds(petWindow)).toEqual(draggedTo({ x: 140, y: 160 }))
controller.dragWindow(petWindow as never, { phase: 'end', x: 1_000, y: 900 })
expect(getWorkAreaForPoint).toHaveBeenLastCalledWith({ x: 1_000, y: 900 })
expect(lastDragBounds(petWindow)).toEqual(draggedTo({
x: 800 - PET_WINDOW_WIDTH,
y: 25 + 575 - PET_WINDOW_HEIGHT,
}))
expect(writePosition).toHaveBeenCalledOnce()
expect(writePosition).toHaveBeenCalledWith({
x: 800 - PET_WINDOW_WIDTH,
y: 25 + 575 - PET_WINDOW_HEIGHT,
})
})
it.each([
['darwin', 'left', { x: -100, y: 220 }, { x: -136, y: 160 }],
['darwin', 'right', { x: 1_000, y: 220 }, { x: 552, y: 160 }],
['darwin', 'top', { x: 150, y: -200 }, { x: 100, y: -215 }],
['darwin', 'bottom', { x: 150, y: 900 }, { x: 100, y: 232 }],
['win32', 'left', { x: -100, y: 220 }, { x: -136, y: 160 }],
['win32', 'right', { x: 1_000, y: 220 }, { x: 552, y: 160 }],
['win32', 'top', { x: 150, y: -200 }, { x: 100, y: -215 }],
['win32', 'bottom', { x: 150, y: 900 }, { x: 100, y: 232 }],
] as const)(
'lets the %s mascot reach the %s display edge through transparent window padding',
async (platform, _edge, pointerEnd, expectedPosition) => {
const petWindow = createFakeWindow({
x: 100,
y: 120,
width: PET_WINDOW_WIDTH,
height: PET_WINDOW_HEIGHT,
})
const controller = new PetWindowController({
createWindow: vi.fn(() => petWindow) as never,
getCurrentWorkArea: () => ({ x: 0, y: 25, width: 800, height: 575 }),
getWorkAreaForPoint: () => ({ x: 0, y: 25, width: 800, height: 575 }),
load: vi.fn().mockResolvedValue(undefined),
platform,
preloadPath: '/app/electron-dist/preload.cjs',
})
await controller.show()
controller.setInteractiveRegions(petWindow as never, [
{ x: 136, y: 240, width: 112, height: 128 },
])
controller.dragWindow(petWindow as never, { phase: 'start', x: 150, y: 180 })
controller.dragWindow(petWindow as never, { phase: 'end', ...pointerEnd })
expect(lastDragBounds(petWindow)).toEqual(draggedTo(expectedPosition))
},
)
// The mascot sits at the bottom of a mostly transparent window, so reaching
// the menu bar means the window's own top edge has to go *above* the work
// area. macOS refuses that for a visible window unless it opted out, and the
// refusal is silent — the controller reads back a position it never got.
const menuBarDrag = {
workArea: { x: 0, y: 25, width: 800, height: 575 },
region: { x: 136, y: 240, width: 112, height: 128 },
}
async function dragToTopEdge(
createWindow: (
window: ReturnType<typeof createFakeWindow>,
options: { enableLargerThanScreen?: boolean },
) => unknown,
) {
const petWindow = createFakeWindow(
{ x: 100, y: 120, width: PET_WINDOW_WIDTH, height: PET_WINDOW_HEIGHT },
{ constrainTopTo: menuBarDrag.workArea.y },
)
const writePosition = vi.fn()
const controller = new PetWindowController({
createWindow: vi.fn((options: { enableLargerThanScreen?: boolean }) =>
createWindow(petWindow, options)) as never,
getCurrentWorkArea: () => menuBarDrag.workArea,
getWorkAreaForPoint: () => menuBarDrag.workArea,
load: vi.fn().mockResolvedValue(undefined),
platform: 'darwin',
preloadPath: '/app/electron-dist/preload.cjs',
writePosition,
})
await controller.show()
controller.setInteractiveRegions(petWindow as never, [menuBarDrag.region])
controller.dragWindow(petWindow as never, { phase: 'start', x: 150, y: 180 })
controller.dragWindow(petWindow as never, { phase: 'end', x: 150, y: -400 })
return { petWindow, writePosition }
}
it('drags the darwin mascot up to the menu bar through the frame constraint', async () => {
// The opt-out has to come from the options the controller really built.
const { petWindow, writePosition } = await dragToTopEdge((window, options) => {
window.applyConstructorOptions(options)
return window
})
const { region, workArea } = menuBarDrag
// The window really sits above the work area, so the mascot's own top edge
// lands on it rather than a padding-height below.
expect(petWindow.getBounds().y).toBe(workArea.y - region.y)
expect(petWindow.getBounds().y + region.y).toBe(workArea.y)
// And what lands on disk is a position the window actually reached, next
// to the box that makes it mean anything.
expect(writePosition).toHaveBeenCalledWith({
x: 100,
y: workArea.y - region.y,
region,
})
})
it('strands the mascot below the menu bar when the window keeps the constraint', async () => {
// Guards the guard: a fake that accepted every y would pass the test above
// whether or not the window opts out, which is exactly how the real bug
// survived a green suite.
const { petWindow } = await dragToTopEdge((window) => {
window.applyConstructorOptions({ enableLargerThanScreen: false })
return window
})
const { region, workArea } = menuBarDrag
expect(petWindow.getBounds().y).toBe(workArea.y)
expect(petWindow.getBounds().y + region.y).toBe(workArea.y + region.y)
})
it('clamps dragging against the mascot measured in the real content box', async () => {
// The renderer measures the mascot against the live viewport, so a content
// area taller than the nominal height puts the mascot below the constant.
// Clamping the drag region to the constant would trim the mascot's bottom
// and stop it short of the work area floor.
const contentHeight = PET_WINDOW_HEIGHT + 40
const petWindow = createFakeWindow(
{ x: 100, y: 120, width: PET_WINDOW_WIDTH, height: PET_WINDOW_HEIGHT },
{ contentSize: { width: PET_WINDOW_WIDTH, height: contentHeight } },
)
const controller = new PetWindowController({
createWindow: vi.fn(() => petWindow) as never,
getCurrentWorkArea: () => ({ x: 0, y: 25, width: 800, height: 575 }),
getWorkAreaForPoint: () => ({ x: 0, y: 25, width: 800, height: 575 }),
load: vi.fn().mockResolvedValue(undefined),
platform: 'win32',
preloadPath: '/app/electron-dist/preload.cjs',
})
await controller.show()
controller.setInteractiveRegions(petWindow as never, [
{ x: 136, y: contentHeight - 160, width: 112, height: 128 },
])
controller.dragWindow(petWindow as never, { phase: 'start', x: 150, y: 180 })
controller.dragWindow(petWindow as never, { phase: 'end', x: 150, y: 2_000 })
expect(lastDragBounds(petWindow)).toEqual({
x: 100,
// Mascot bottom (y + 128) lands exactly on the work area floor. Clamping
// the region to the window box instead of the content box would place it
// 40px short of the floor.
y: 25 + 575 - (contentHeight - 160) - 128,
width: PET_WINDOW_WIDTH,
height: PET_WINDOW_HEIGHT,
})
})
it.each(['darwin', 'win32', 'linux'] as const)(
'restores a %s edge position once the renderer reports the mascot region',
async (platform) => {
// Dragging clamps against the mascot, so a saved edge position puts the
// window's transparent padding off-screen. Recreating the window (every
// hide/show, and every restart) re-clamps that position against the whole
// window, which walks the mascot inwards by the padding width unless the
// reported region moves it back.
let petWindow: ReturnType<typeof createFakeWindow> | undefined
const createWindow = vi.fn((bounds) => {
petWindow = createFakeWindow(bounds as {
x: number
y: number
width: number
height: number
})
return petWindow
})
const controller = new PetWindowController({
createWindow: createWindow as never,
getCurrentWorkArea: () => ({ x: 0, y: 25, width: 800, height: 575 }),
getWorkAreaForPoint: () => ({ x: 0, y: 25, width: 800, height: 575 }),
load: vi.fn().mockResolvedValue(undefined),
platform,
preloadPath: '/app/electron-dist/preload.cjs',
readPosition: () => ({ x: -136, y: 160 }),
})
await controller.show()
expect(createWindow).toHaveBeenCalledWith(expect.objectContaining({ x: 0, y: 160 }))
controller.setInteractiveRegions(petWindow as never, [
{ x: 136, y: 240, width: 112, height: 128 },
])
expect(petWindow?.getBounds()).toEqual({
x: -136,
y: 160,
width: PET_WINDOW_WIDTH,
height: PET_WINDOW_HEIGHT,
})
},
)
it('tracks the native cursor at 60 Hz without renderer move payloads', async () => {
vi.useFakeTimers()
try {
const petWindow = createFakeWindow({
x: 100,
y: 120,
width: PET_WINDOW_WIDTH,
height: PET_WINDOW_HEIGHT,
})
let cursor = { x: 150, y: 180 }
const writePosition = vi.fn()
const controller = new PetWindowController({
createWindow: vi.fn(() => petWindow) as never,
getCursorScreenPoint: () => cursor,
getCurrentWorkArea: () => ({ x: 0, y: 25, width: 1_200, height: 775 }),
getWorkAreaForPoint: () => ({ x: 0, y: 25, width: 1_200, height: 775 }),
load: vi.fn().mockResolvedValue(undefined),
platform: 'darwin',
preloadPath: '/app/electron-dist/preload.cjs',
writePosition,
})
await controller.show()
controller.dragWindow(petWindow as never, { phase: 'start', x: 150, y: 180 })
cursor = { x: 203, y: 227 }
vi.advanceTimersByTime(16)
expect(lastDragBounds(petWindow)).toEqual(draggedTo({ x: 153, y: 167 }))
expect(writePosition).not.toHaveBeenCalled()
controller.dragWindow(petWindow as never, { phase: 'end', x: 203, y: 227 })
expect(writePosition).toHaveBeenCalledOnce()
expect(writePosition).toHaveBeenCalledWith({ x: 153, y: 167 })
expect(vi.getTimerCount()).toBe(0)
const setBoundsCalls = petWindow.setBounds.mock.calls.length
cursor = { x: 260, y: 280 }
vi.advanceTimersByTime(32)
expect(petWindow.setBounds).toHaveBeenCalledTimes(setBoundsCalls)
} finally {
vi.useRealTimers()
}
})
it.each(['hide', 'closed', 'dispose'] as const)(
'stops native cursor tracking and persists the final position on %s',
async (action) => {
vi.useFakeTimers()
try {
const petWindow = createFakeWindow({
x: 100,
y: 120,
width: PET_WINDOW_WIDTH,
height: PET_WINDOW_HEIGHT,
})
let cursor = { x: 150, y: 180 }
const writePosition = vi.fn()
const controller = new PetWindowController({
createWindow: vi.fn(() => petWindow) as never,
getCursorScreenPoint: () => cursor,
getCurrentWorkArea: () => ({ x: 0, y: 25, width: 1_200, height: 775 }),
load: vi.fn().mockResolvedValue(undefined),
platform: 'darwin',
preloadPath: '/app/electron-dist/preload.cjs',
writePosition,
})
await controller.show()
controller.dragWindow(petWindow as never, { phase: 'start', x: 150, y: 180 })
expect(vi.getTimerCount()).toBe(1)
cursor = { x: 180, y: 200 }
vi.advanceTimersByTime(16)
expect(lastDragBounds(petWindow)).toEqual(draggedTo({ x: 130, y: 140 }))
if (action === 'hide') controller.hide()
if (action === 'closed') petWindow.handlers.get('closed')?.()
if (action === 'dispose') controller.dispose()
expect(vi.getTimerCount()).toBe(0)
expect(writePosition).toHaveBeenCalledOnce()
expect(writePosition).toHaveBeenCalledWith({ x: 130, y: 140 })
const setBoundsCalls = petWindow.setBounds.mock.calls.length
cursor = { x: 260, y: 280 }
vi.advanceTimersByTime(32)
expect(petWindow.setBounds).toHaveBeenCalledTimes(setBoundsCalls)
} finally {
vi.useRealTimers()
}
},
)
it('rejects drag coordinates and drag senders outside the owned pet window', async () => {
const petWindow = createFakeWindow()
const otherWindow = createFakeWindow()
const controller = new PetWindowController({
createWindow: vi.fn(() => petWindow) as never,
getCurrentWorkArea: () => ({ x: 0, y: 0, width: 1440, height: 900 }),
load: vi.fn().mockResolvedValue(undefined),
platform: 'darwin',
preloadPath: '/app/electron-dist/preload.cjs',
})
await controller.show()
expect(() => controller.dragWindow(otherWindow as never, {
phase: 'start',
x: 10,
y: 10,
})).toThrow('does not own')
expect(() => controller.dragWindow(petWindow as never, {
phase: 'start',
x: Number.POSITIVE_INFINITY,
y: 10,
})).toThrow('finite screen coordinates')
expect(() => controller.dragWindow(petWindow as never, {
phase: 'move',
x: 10,
y: 10,
})).toThrow('has not started')
})
it('destroys a hidden companion so its renderer releases observers', async () => {
const firstWindow = createFakeWindow()
const secondWindow = createFakeWindow()
const createWindow = vi.fn()
.mockReturnValueOnce(firstWindow)
.mockReturnValueOnce(secondWindow)
const controller = new PetWindowController({
createWindow: createWindow as never,
getCurrentWorkArea: () => ({ x: 0, y: 0, width: 1440, height: 900 }),
load: vi.fn().mockResolvedValue(undefined),
platform: 'linux',
preloadPath: '/app/electron-dist/preload.cjs',
})
await controller.show()
controller.hide()
await controller.show()
expect(createWindow).toHaveBeenCalledTimes(2)
expect(firstWindow.destroy).toHaveBeenCalledTimes(1)
expect(secondWindow.showInactive).toHaveBeenCalledTimes(1)
})
it('keeps the shaped Windows pet topmost and rejects IPC from another window', async () => {
const petWindow = createFakeWindow()
const otherWindow = createFakeWindow()
const controller = new PetWindowController({
createWindow: vi.fn(() => petWindow) as never,
getCurrentWorkArea: () => ({ x: 0, y: 0, width: 1440, height: 900 }),
load: vi.fn().mockResolvedValue(undefined),
platform: 'win32',
preloadPath: '/app/electron-dist/preload.cjs',
})
await controller.show()
controller.setInteractiveRegions(petWindow as never, [
{ x: 100, y: 220, width: 144, height: 170 },
])
controller.setIgnoreMouseEvents(petWindow as never, true)
expect(petWindow.setShape).toHaveBeenLastCalledWith([
{ x: 88, y: 208, width: 168, height: 192 },
])
expect(petWindow.setAlwaysOnTop).toHaveBeenCalledWith(true)
expect(petWindow.setAlwaysOnTop).toHaveBeenLastCalledWith(true)
expect(petWindow.setIgnoreMouseEvents).toHaveBeenCalledTimes(1)
expect(() => controller.setInteractiveRegions(otherWindow as never, [
{ x: 0, y: 0, width: 10, height: 10 },
])).toThrow('does not own')
})
it('shapes the Windows pet against the real content size, not the nominal height', async () => {
// A Windows content area can end up taller than the nominal height (DPI
// rounding, invisible frame). The mascot sits flush with the viewport
// bottom, so clamping the shape to the constant would slice its legs off.
const contentHeight = PET_WINDOW_HEIGHT + 40
const petWindow = createFakeWindow(
{ x: 100, y: 120, width: PET_WINDOW_WIDTH, height: PET_WINDOW_HEIGHT },
{ contentSize: { width: PET_WINDOW_WIDTH, height: contentHeight } },
)
const controller = new PetWindowController({
createWindow: vi.fn(() => petWindow) as never,
getCurrentWorkArea: () => ({ x: 0, y: 0, width: 1440, height: 900 }),
load: vi.fn().mockResolvedValue(undefined),
platform: 'win32',
preloadPath: '/app/electron-dist/preload.cjs',
})
await controller.show()
controller.setInteractiveRegions(petWindow as never, [
{ x: 144, y: contentHeight - 114, width: 96, height: 104 },
])
expect(petWindow.setShape).toHaveBeenLastCalledWith([
{ x: 132, y: contentHeight - 126, width: 120, height: 126 },
])
})
it('shapes every reported region, not just the mascot', async () => {
// The task badge sits above the mascot and carries its own rect; dropping
// the tail of the list would make it unclickable.
const petWindow = createFakeWindow()
const controller = new PetWindowController({
createWindow: vi.fn(() => petWindow) as never,
getCurrentWorkArea: () => ({ x: 0, y: 0, width: 1440, height: 900 }),
load: vi.fn().mockResolvedValue(undefined),
platform: 'win32',
preloadPath: '/app/electron-dist/preload.cjs',
})
await controller.show()
controller.setInteractiveRegions(petWindow as never, [
{ x: 144, y: 280, width: 96, height: 104 },
{ x: 250, y: 40, width: 24, height: 24 },
])
expect(petWindow.setShape).toHaveBeenLastCalledWith([
{ x: 132, y: 268, width: 120, height: 128 },
{ x: 238, y: 28, width: 48, height: 48 },
])
})
it.each([
[
'clamps a region reported past the content box',
{ x: PET_WINDOW_WIDTH + 50, y: PET_WINDOW_HEIGHT + 50, width: 40, height: 40 },
{
x: PET_WINDOW_WIDTH - 1,
y: PET_WINDOW_HEIGHT - 1,
width: 1,
height: 1,
},
],
[
'keeps a degenerate region at least one pixel wide',
{ x: 100, y: 100, width: 0, height: 0 },
{ x: 88, y: 88, width: 24, height: 24 },
],
[
'clamps a negative region back into the content box',
{ x: -100, y: -100, width: 40, height: 40 },
{ x: 0, y: 0, width: 1, height: 1 },
],
] as const)('%s', async (_name, region, expected) => {
// setShape rejects rectangles outside the window or without positive
// extent, so normalization has to hold that invariant for any input the
// renderer can produce.
const petWindow = createFakeWindow()
const controller = new PetWindowController({
createWindow: vi.fn(() => petWindow) as never,
getCurrentWorkArea: () => ({ x: 0, y: 0, width: 1440, height: 900 }),
load: vi.fn().mockResolvedValue(undefined),
platform: 'win32',
preloadPath: '/app/electron-dist/preload.cjs',
})
await controller.show()
controller.setInteractiveRegions(petWindow as never, [region])
expect(petWindow.setShape).toHaveBeenLastCalledWith([expected])
})
it.each([
['getContentBounds is unavailable', { withoutContentBounds: true }],
['the content view has no extent yet', {
contentSize: { width: 0, height: 0 },
}],
] as const)('falls back to the window box when %s', async (_name, options) => {
// Falling back to the nominal constants would silently reinstate the very
// clamp this code exists to avoid, so the live window box is the fallback.
const windowHeight = PET_WINDOW_HEIGHT + 40
const petWindow = createFakeWindow(
{ x: 100, y: 120, width: PET_WINDOW_WIDTH, height: windowHeight },
options,
)
const controller = new PetWindowController({
createWindow: vi.fn(() => petWindow) as never,
getCurrentWorkArea: () => ({ x: 0, y: 0, width: 1440, height: 900 }),
load: vi.fn().mockResolvedValue(undefined),
platform: 'win32',
preloadPath: '/app/electron-dist/preload.cjs',
})
await controller.show()
controller.setInteractiveRegions(petWindow as never, [
{ x: 144, y: windowHeight - 114, width: 96, height: 104 },
])
expect(petWindow.setShape).toHaveBeenLastCalledWith([
{ x: 132, y: windowHeight - 126, width: 120, height: 126 },
])
})
it('keeps the window size fixed across drag ticks so DIP rounding cannot grow it', async () => {
vi.useFakeTimers()
try {
const petWindow = createFakeWindow(
{ x: 100, y: 120, width: PET_WINDOW_WIDTH, height: PET_WINDOW_HEIGHT },
{ scaleFactor: 1.15 },
)
let cursor = { x: 150, y: 180 }
const controller = new PetWindowController({
createWindow: vi.fn(() => petWindow) as never,
getCursorScreenPoint: () => cursor,
getCurrentWorkArea: () => ({ x: 0, y: 0, width: 1_600, height: 1_000 }),
getWorkAreaForPoint: () => ({ x: 0, y: 0, width: 1_600, height: 1_000 }),
load: vi.fn().mockResolvedValue(undefined),
platform: 'win32',
preloadPath: '/app/electron-dist/preload.cjs',
})
await controller.show()
// Chromium reports a fractionally scaled window back a pixel larger than
// it was created; that rounding is the engine's, not ours. What has to
// hold is that dragging never adds to it.
const { width, height } = petWindow.getBounds()
controller.dragWindow(petWindow as never, { phase: 'start', x: 150, y: 180 })
for (let tick = 0; tick < 60; tick += 1) {
cursor = { x: cursor.x + 2, y: cursor.y + 2 }
vi.advanceTimersByTime(16)
}
controller.dragWindow(petWindow as never, { phase: 'end', ...cursor })
// Without a position assertion this test would also pass for a drag tick
// that moves nothing at all.
expect(petWindow.getBounds()).toEqual({ x: 220, y: 240, width, height })
} finally {
vi.useRealTimers()
}
})
it('stays size-neutral across repeated drags on a fractionally scaled display', async () => {
// Restating a size that was itself read back through the DIP round trip
// re-applies the ceil, so the window grows a pixel per drag even though any
// single drag looks stable. The recorded size has to come from the nominal
// constants the window was created with.
vi.useFakeTimers()
try {
const petWindow = createFakeWindow(
{ x: 100, y: 120, width: PET_WINDOW_WIDTH, height: PET_WINDOW_HEIGHT },
{ scaleFactor: 1.15 },
)
let cursor = { x: 150, y: 180 }
const controller = new PetWindowController({
createWindow: vi.fn(() => petWindow) as never,
getCursorScreenPoint: () => cursor,
getCurrentWorkArea: () => ({ x: 0, y: 0, width: 1_600, height: 1_000 }),
getWorkAreaForPoint: () => ({ x: 0, y: 0, width: 1_600, height: 1_000 }),
load: vi.fn().mockResolvedValue(undefined),
platform: 'win32',
preloadPath: '/app/electron-dist/preload.cjs',
})
await controller.show()
const { width, height } = petWindow.getBounds()
for (let drag = 0; drag < 40; drag += 1) {
controller.dragWindow(petWindow as never, { phase: 'start', ...cursor })
cursor = { x: cursor.x + 4, y: cursor.y + 4 }
vi.advanceTimersByTime(16)
controller.dragWindow(petWindow as never, { phase: 'end', ...cursor })
}
expect(petWindow.getBounds()).toMatchObject({ width, height })
} finally {
vi.useRealTimers()
}
})
it('returns whether the native pet context menu close item was selected', async () => {
const petWindow = createFakeWindow()
const controller = new PetWindowController({
createWindow: vi.fn(() => petWindow) as never,
getCurrentWorkArea: () => ({ x: 0, y: 0, width: 1440, height: 900 }),
load: vi.fn().mockResolvedValue(undefined),
platform: 'darwin',
preloadPath: '/app/electron-dist/preload.cjs',
})
await controller.show()
let clickClose: (() => void) | undefined
let dismissMenu: (() => void) | undefined
const popup = vi.fn((options: { callback: () => void }) => {
dismissMenu = options.callback
})
const menuFactory = {
buildFromTemplate: vi.fn((template: Array<{ click?: () => void }>) => {
clickClose = template[0]?.click
return { popup }
}),
}
const selection = controller.showContextMenu(
petWindow as never,
'关闭宠物',
menuFactory as never,
)
clickClose?.()
dismissMenu?.()
await expect(selection).resolves.toBe(true)
expect(menuFactory.buildFromTemplate).toHaveBeenCalledWith([{
label: '关闭宠物',
click: expect.any(Function),
}])
expect(popup).toHaveBeenCalledWith({
window: petWindow,
callback: expect.any(Function),
})
})
it('returns false when the native pet context menu is dismissed', async () => {
const petWindow = createFakeWindow()
const controller = new PetWindowController({
createWindow: vi.fn(() => petWindow) as never,
getCurrentWorkArea: () => ({ x: 0, y: 0, width: 1440, height: 900 }),
load: vi.fn().mockResolvedValue(undefined),
platform: 'darwin',
preloadPath: '/app/electron-dist/preload.cjs',
})
await controller.show()
const menuFactory = {
buildFromTemplate: vi.fn(() => ({
popup: ({ callback }: { callback: () => void }) => callback(),
})),
}
await expect(controller.showContextMenu(
petWindow as never,
'Close pet',
menuFactory as never,
)).resolves.toBe(false)
})
it('rejects native context menu requests from any non-pet window', async () => {
const petWindow = createFakeWindow()
const otherWindow = createFakeWindow()
const controller = new PetWindowController({
createWindow: vi.fn(() => petWindow) as never,
getCurrentWorkArea: () => ({ x: 0, y: 0, width: 1440, height: 900 }),
load: vi.fn().mockResolvedValue(undefined),
platform: 'darwin',
preloadPath: '/app/electron-dist/preload.cjs',
})
await controller.show()
await expect(controller.showContextMenu(
otherWindow as never,
'Close pet',
{ buildFromTemplate: vi.fn() } as never,
)).rejects.toThrow('does not own')
})
it('destroys a partially created window when renderer loading fails', async () => {
const window = createFakeWindow()
const controller = new PetWindowController({
createWindow: vi.fn(() => window) as never,
getCurrentWorkArea: () => ({ x: 0, y: 0, width: 1440, height: 900 }),
load: vi.fn().mockRejectedValue(new Error('load failed')),
platform: 'win32',
preloadPath: '/app/electron-dist/preload.cjs',
})
await expect(controller.show()).rejects.toThrow('load failed')
expect(window.destroy).toHaveBeenCalledTimes(1)
})
})