cc-haha/desktop/electron/services/preview.test.ts
程序员阿江(Relakkes) 15b3acc85b fix: stabilize browser preview zoom on high DPI
Constraint: Keep preview zoom native through WebContentsView.setZoomFactor so screenshot and selection coordinates stay aligned.
Tested: cd desktop && bun run test -- src/components/browser/BrowserSurface.test.tsx src/components/browser/computeWebviewBounds.test.ts
Tested: cd desktop && bun test electron/services/preview.test.ts
Tested: cd desktop && bun run lint
Tested: cd desktop && bun run check:electron
Tested: cd desktop && bun run build
Not-tested: Windows 225% display-scaling smoke; no Windows host is available in this worktree.
Confidence: medium
Scope-risk: moderate
2026-07-06 18:48:42 +08:00

439 lines
15 KiB
TypeScript

import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ELECTRON_EVENT_CHANNELS } from '../ipc/channels'
import {
ElectronPreviewService,
normalizePreviewBounds,
normalizePreviewUrl,
parsePreviewAgentMessage,
resolvePreviewScriptPath,
snapPreviewBoundsToScaleFactor,
shouldForwardPreviewMessage,
type PreviewViewLike,
type PreviewWebContentsLike,
} from './preview'
class FakeWebContents implements PreviewWebContentsLike {
loadedUrls: string[] = []
scripts: string[] = []
zoomFactors: number[] = []
sent: Array<{ channel: string, payload: unknown }> = []
close = vi.fn()
capturePage = vi.fn(async () => ({ toDataURL: () => 'data:image/png;base64,NATIVE' }))
private loadHandler: (() => void) | null = null
async loadURL(url: string) {
this.loadedUrls.push(url)
this.loadHandler?.()
}
async executeJavaScript(script: string) {
this.scripts.push(script)
return 'ok'
}
on(_event: 'did-finish-load', handler: () => void) {
this.loadHandler = handler
}
isDestroyed() {
return false
}
setZoomFactor(factor: number) {
this.zoomFactors.push(factor)
}
send(channel: string, payload: unknown) {
this.sent.push({ channel, payload })
}
}
class FakeView implements PreviewViewLike {
webContents = new FakeWebContents()
bounds: unknown[] = []
visible: boolean[] = []
setBounds(bounds: unknown) {
this.bounds.push(bounds)
}
setVisible(visible: boolean) {
this.visible.push(visible)
}
}
const tempDirs: string[] = []
function previewScript() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-haha-preview-'))
tempDirs.push(dir)
const file = path.join(dir, 'preview-agent.js')
fs.writeFileSync(file, 'window.__previewInjected = true')
return file
}
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true })
}
})
describe('Electron preview service', () => {
it('allows only http and https URLs', () => {
expect(normalizePreviewUrl(' https://example.com ')).toBe('https://example.com')
expect(normalizePreviewUrl('http://127.0.0.1:3000')).toBe('http://127.0.0.1:3000')
expect(() => normalizePreviewUrl('file:///tmp/index.html')).toThrow('unsupported url scheme')
expect(() => normalizePreviewUrl('javascript:alert(1)')).toThrow('unsupported url scheme')
})
it('normalizes finite bounds for WebContentsView without dropping high-DPI fractions', () => {
expect(normalizePreviewBounds({ x: 1.2, y: 2.7, width: 20.4, height: -1 })).toEqual({
x: 1.2,
y: 2.7,
width: 20.4,
height: 0,
})
expect(() => normalizePreviewBounds({ x: Number.NaN, y: 0, width: 1, height: 1 })).toThrow('invalid preview bounds x')
})
it('snaps preview bounds to physical pixels at fractional Windows scale factors', () => {
const snapped = snapPreviewBoundsToScaleFactor({
x: 1.1,
y: 2.2,
width: 10.3,
height: 4.4,
}, 2.25)
expect(snapped).toEqual({
x: 0.888889,
y: 2.222222,
width: 10.666667,
height: 4.444444,
})
expect(snapped.x * 2.25).toBeCloseTo(Math.round(snapped.x * 2.25), 5)
expect((snapped.x + snapped.width) * 2.25).toBeCloseTo(
Math.round((snapped.x + snapped.width) * 2.25),
5,
)
})
it('falls back from app.asar to app.asar.unpacked for the preview agent script', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-haha-preview-asar-'))
tempDirs.push(dir)
const unpackedFile = path.join(dir, 'app.asar.unpacked', 'src-tauri', 'resources', 'preview-agent.js')
fs.mkdirSync(path.dirname(unpackedFile), { recursive: true })
fs.writeFileSync(unpackedFile, 'window.__previewInjected = true')
const packagedPath = path.join(dir, 'app.asar', 'src-tauri', 'resources', 'preview-agent.js')
expect(resolvePreviewScriptPath(packagedPath)).toBe(unpackedFile)
})
it('creates one child WebContentsView, loads URLs, and injects the preview agent after load', async () => {
const view = new FakeView()
const parent = {
contentView: {
addChildView: vi.fn(),
removeChildView: vi.fn(),
},
}
const service = new ElectronPreviewService({
createView: () => view,
previewScriptPath: previewScript(),
})
await service.open(parent, 'http://localhost:5173', { x: 1, y: 2, width: 300, height: 200 })
await service.open(parent, 'https://example.com', { x: 3, y: 4, width: 500, height: 240 })
service.setVisible(false)
service.setBounds({ x: 5, y: 6, width: 100, height: 80 })
expect(parent.contentView.addChildView).toHaveBeenCalledTimes(1)
expect(view.webContents.loadedUrls).toEqual(['http://localhost:5173', 'https://example.com'])
expect(view.bounds).toEqual([
{ x: 1, y: 2, width: 300, height: 200 },
{ x: 3, y: 4, width: 500, height: 240 },
{ x: 5, y: 6, width: 100, height: 80 },
])
expect(view.visible).toEqual([false])
expect(view.webContents.scripts).toEqual(['window.__previewInjected = true', 'window.__previewInjected = true'])
})
it('applies scale-aware snapped bounds and can refresh them after display metrics change', async () => {
const view = new FakeView()
const parent = {
contentView: {
addChildView: vi.fn(),
removeChildView: vi.fn(),
},
getBounds: () => ({ x: 0, y: 0, width: 1200, height: 800 }),
}
let scaleFactor = 1
const service = new ElectronPreviewService({
createView: () => view,
previewScriptPath: previewScript(),
resolveScaleFactor: () => scaleFactor,
})
await service.open(parent, 'http://localhost:5173', { x: 1.1, y: 2.2, width: 10.3, height: 4.4 })
scaleFactor = 2.25
service.refreshBounds()
expect(view.bounds).toEqual([
{ x: 1, y: 2, width: 10, height: 5 },
{ x: 0.888889, y: 2.222222, width: 10.666667, height: 4.444444 },
])
})
it('forwards only validated preview messages from the child view to the renderer', async () => {
const view = new FakeView()
const renderer = new FakeWebContents()
const service = new ElectronPreviewService({
createView: () => view,
previewScriptPath: previewScript(),
})
await service.open({ contentView: { addChildView: vi.fn(), removeChildView: vi.fn() } }, 'https://example.com', {
x: 0,
y: 0,
width: 100,
height: 100,
})
service.sendMessageToRenderer(view.webContents, '{"v":1,"type":"ready"}', renderer)
service.sendMessageToRenderer(new FakeWebContents(), '{"v":1,"type":"ready"}', renderer)
expect(() => service.sendMessageToRenderer(view.webContents, 'not-json', renderer)).not.toThrow()
service.sendMessageToRenderer(view.webContents, '{"v":1,"type":"unknown"}', renderer)
service.sendMessageToRenderer(view.webContents, JSON.stringify({ v: 1, type: 'screenshot', dataUrl: 'data:text/html;base64,AAAA', kind: 'full' }), renderer)
expect(renderer.sent).toEqual([
{
channel: ELECTRON_EVENT_CHANNELS.previewEvent,
payload: { v: 1, type: 'ready' },
},
])
})
it('stops forwarding preview messages after the view is closed', async () => {
const view = new FakeView()
const renderer = new FakeWebContents()
const parent = { contentView: { addChildView: vi.fn(), removeChildView: vi.fn() } }
const service = new ElectronPreviewService({
createView: () => view,
previewScriptPath: previewScript(),
})
await service.open(parent, 'https://example.com', { x: 0, y: 0, width: 100, height: 100 })
service.close()
service.sendMessageToRenderer(view.webContents, '{"v":1,"type":"ready"}', renderer)
expect(renderer.sent).toEqual([])
})
it('captures screenshots from the native WebContentsView instead of DOM repainting in the page', async () => {
const view = new FakeView()
const renderer = new FakeWebContents()
const service = new ElectronPreviewService({
createView: () => view,
previewScriptPath: previewScript(),
})
await service.open({ contentView: { addChildView: vi.fn(), removeChildView: vi.fn() } }, 'https://example.com', {
x: 0,
y: 0,
width: 100,
height: 100,
})
await service.message({ v: 1, type: 'capture', kind: 'full' }, renderer)
expect(view.webContents.capturePage).toHaveBeenCalledTimes(1)
expect(view.webContents.scripts.at(-1)).toBe('window.__previewInjected = true')
expect(renderer.sent).toEqual([
{
channel: ELECTRON_EVENT_CHANNELS.previewEvent,
payload: { v: 1, type: 'screenshot', dataUrl: 'data:image/png;base64,NATIVE', kind: 'full' },
},
])
})
it('applies preview zoom to the native WebContentsView before screenshot capture', async () => {
const view = new FakeView()
const renderer = new FakeWebContents()
const service = new ElectronPreviewService({
createView: () => view,
previewScriptPath: previewScript(),
})
await service.open({ contentView: { addChildView: vi.fn(), removeChildView: vi.fn() } }, 'https://example.com', {
x: 0,
y: 0,
width: 100,
height: 100,
})
service.setZoomFactor(0.8)
await service.message({ v: 1, type: 'capture', kind: 'full' }, renderer)
expect(view.webContents.zoomFactors.at(-1)).toBe(0.8)
expect(view.bounds).toHaveLength(1)
expect(view.webContents.capturePage).toHaveBeenCalledTimes(1)
expect(renderer.sent.at(-1)).toEqual({
channel: ELECTRON_EVENT_CHANNELS.previewEvent,
payload: { v: 1, type: 'screenshot', dataUrl: 'data:image/png;base64,NATIVE', kind: 'full' },
})
})
it('forwards picker host messages into the injected preview bridge', async () => {
const view = new FakeView()
const service = new ElectronPreviewService({
createView: () => view,
previewScriptPath: previewScript(),
})
await service.open({ contentView: { addChildView: vi.fn(), removeChildView: vi.fn() } }, 'https://example.com', {
x: 0,
y: 0,
width: 100,
height: 100,
})
await service.message({ v: 1, type: 'enter-picker' })
expect(view.webContents.scripts.at(-1)).toBe(
'globalThis.__PREVIEW_BRIDGE__?.handleHostRaw("{\\"v\\":1,\\"type\\":\\"enter-picker\\"}")',
)
})
it('adds a native screenshot to selection events before forwarding them to the renderer', async () => {
const view = new FakeView()
const renderer = new FakeWebContents()
const service = new ElectronPreviewService({
createView: () => view,
previewScriptPath: previewScript(),
})
await service.open({ contentView: { addChildView: vi.fn(), removeChildView: vi.fn() } }, 'https://example.com', {
x: 0,
y: 0,
width: 100,
height: 100,
})
await service.sendMessageToRenderer(view.webContents, JSON.stringify({
v: 1,
type: 'selection',
payload: {
pageUrl: 'https://example.com',
element: { selector: '#todo', tag: 'input', classes: [] },
screenshot: { kind: 'region' },
},
}), renderer)
expect(view.webContents.capturePage).toHaveBeenCalledTimes(1)
expect(view.webContents.scripts.at(-1)).toBe('globalThis.__PREVIEW_AGENT_CLEAR_SELECTION_OVERLAY__?.()')
expect(renderer.sent).toEqual([
{
channel: ELECTRON_EVENT_CHANNELS.previewEvent,
payload: {
v: 1,
type: 'selection',
payload: {
pageUrl: 'https://example.com',
element: { selector: '#todo', tag: 'input', classes: [] },
screenshot: { kind: 'region', dataUrl: 'data:image/png;base64,NATIVE' },
},
},
},
])
})
it('rejects host messages before a preview view exists', async () => {
const service = new ElectronPreviewService({
createView: () => new FakeView(),
previewScriptPath: previewScript(),
})
await expect(service.message({ v: 1, type: 'capture', kind: 'viewport' })).rejects.toThrow('preview not open')
})
it('allows preload forwarding only for top-level http/https preview pages', () => {
expect(shouldForwardPreviewMessage({
raw: '{"v":1,"type":"ready"}',
href: 'https://example.com/workbench',
isTopFrame: true,
})).toBe(true)
expect(shouldForwardPreviewMessage({
raw: '{"v":1,"type":"ready"}',
href: 'http://127.0.0.1:3000',
isTopFrame: true,
})).toBe(true)
expect(shouldForwardPreviewMessage({
raw: '{"v":1,"type":"ready"}',
href: 'https://example.com/frame',
isTopFrame: false,
})).toBe(false)
expect(shouldForwardPreviewMessage({
raw: '{"v":1,"type":"ready"}',
href: 'file:///tmp/index.html',
isTopFrame: true,
})).toBe(false)
expect(shouldForwardPreviewMessage({
raw: { type: 'ready' },
href: 'https://example.com',
isTopFrame: true,
})).toBe(false)
expect(shouldForwardPreviewMessage({
raw: '{"v":1,"type":"ready"}',
href: 'not-a-url',
isTopFrame: true,
})).toBe(false)
})
it('parses only bounded preview agent message shapes', () => {
expect(parsePreviewAgentMessage('{"v":1,"type":"ready"}')).toEqual({ v: 1, type: 'ready' })
expect(parsePreviewAgentMessage(JSON.stringify({
v: 1,
type: 'navigated',
url: 'https://example.com',
title: 'Example',
}))).toEqual({
v: 1,
type: 'navigated',
url: 'https://example.com',
title: 'Example',
})
expect(parsePreviewAgentMessage(JSON.stringify({
v: 1,
type: 'screenshot',
dataUrl: 'data:image/png;base64,AAAA',
kind: 'full',
}))).toEqual({
v: 1,
type: 'screenshot',
dataUrl: 'data:image/png;base64,AAAA',
kind: 'full',
})
expect(parsePreviewAgentMessage('{"v":1,"type":"screenshot","dataUrl":"data:text/html;base64,AAAA","kind":"full"}')).toBeNull()
expect(parsePreviewAgentMessage(JSON.stringify({ v: 1, type: 'navigated', url: 'file:///tmp/a', title: 'A' }))).toBeNull()
expect(parsePreviewAgentMessage(JSON.stringify({ v: 1, type: 'selection', payload: null }))).toBeNull()
})
it('removes and closes the preview view', async () => {
const view = new FakeView()
const parent = {
contentView: {
addChildView: vi.fn(),
removeChildView: vi.fn(),
},
}
const service = new ElectronPreviewService({
createView: () => view,
previewScriptPath: previewScript(),
})
await service.open(parent, 'https://example.com', { x: 0, y: 0, width: 100, height: 100 })
service.close()
expect(parent.contentView.removeChildView).toHaveBeenCalledWith(view)
expect(view.webContents.close).toHaveBeenCalled()
await expect(service.message({ v: 1, type: 'capture', kind: 'full' })).rejects.toThrow('preview not open')
})
})