cc-haha/desktop/src/preview-agent/screenshot.test.ts
程序员阿江(Relakkes) a09d9aa009 fix: keep preview selection markers in the captured viewport
Selection screenshots were drawing the marker after html2canvas finished by
mapping DOM coordinates onto a separate canvas. That leaves room for drift when
html2canvas renders at a different output size, the viewport is resized, or the
thumbnail is compressed. The marker is now a fixed DOM overlay captured with the
page itself, so the chosen element and its marker share the same viewport,
layout, crop, and scale path.

Constraint: No issue number was provided for this task.
Constraint: Preserve the existing selection event payload and preview bridge protocol.
Rejected: Keep canvas post-processing with more scale math | it still maintains a second coordinate system beside the rendered page.
Rejected: Keep the unused annotate module | it would preserve the stale path that caused this class of drift.
Confidence: high
Scope-risk: narrow
Directive: Do not reintroduce canvas-side selection marker math unless it is proven against resized and scaled viewport captures.
Tested: cd desktop && bun run test --run src/preview-agent/screenshot.test.ts src/preview-agent/picker.test.ts src/preview-agent/metadata.test.ts src/preview-agent/protocol.test.ts src/preview-agent/bridge.test.ts src/preview-agent/editBubble.test.ts
Tested: cd desktop && bun run lint
Tested: cd desktop && bun run build:preview-agent
Tested: bun run check:desktop
Tested: git diff --check
Not-tested: Real Tauri webview visual smoke.
2026-06-01 01:57:58 +08:00

202 lines
7.2 KiB
TypeScript

import { describe, expect, it, vi, beforeEach } from 'vitest'
const html2canvasMock = vi.fn()
vi.mock('html2canvas', () => ({ default: (...args: unknown[]) => html2canvasMock(...args) }))
vi.mock('../lib/imageCompress', () => ({ compressDataUrl: vi.fn(async (d: string) => `c:${d}`) }))
import { captureToDataUrl, captureAnnotatedRegion } from './screenshot'
import { compressDataUrl } from '../lib/imageCompress'
function makeMockCtx() {
return {
lineWidth: 0,
strokeStyle: '',
fillStyle: '',
font: '',
textAlign: '' as CanvasRenderingContext2D['textAlign'],
textBaseline: '' as CanvasRenderingContext2D['textBaseline'],
save: vi.fn(),
restore: vi.fn(),
beginPath: vi.fn(),
moveTo: vi.fn(),
arcTo: vi.fn(),
arc: vi.fn(),
closePath: vi.fn(),
stroke: vi.fn(),
fill: vi.fn(),
fillText: vi.fn(),
}
}
beforeEach(() => {
html2canvasMock.mockReset()
html2canvasMock.mockResolvedValue({ toDataURL: () => 'data:image/png;base64,RAW' })
vi.mocked(compressDataUrl).mockClear()
})
describe('captureToDataUrl', () => {
it('captures document.body for full and compresses the result', async () => {
const out = await captureToDataUrl('full')
expect(html2canvasMock).toHaveBeenCalledWith(document.body, expect.any(Object))
expect(compressDataUrl).toHaveBeenCalledWith('data:image/png;base64,RAW')
expect(out).toBe('c:data:image/png;base64,RAW')
})
it('captures the given element for element kind', async () => {
const el = document.createElement('div')
await captureToDataUrl('element', el)
expect(html2canvasMock).toHaveBeenCalledWith(el, expect.any(Object))
})
it('falls back to document.body for element kind without element', async () => {
await captureToDataUrl('element')
expect(html2canvasMock).toHaveBeenCalledWith(document.body, expect.any(Object))
})
it('passes viewport height option for viewport kind', async () => {
await captureToDataUrl('viewport')
const opts = html2canvasMock.mock.calls[0]![1] as Record<string, unknown>
expect(opts.height).toBe(window.innerHeight)
expect(opts.windowWidth).toBe(window.innerWidth)
})
})
describe('captureAnnotatedRegion', () => {
it('captures the visible viewport with scale:1', async () => {
const ctx = makeMockCtx()
html2canvasMock.mockResolvedValue({
getContext: () => ctx as unknown as CanvasRenderingContext2D,
toDataURL: () => 'data:image/png;base64,RAW',
width: 1000,
height: 2000,
})
const el = document.createElement('div')
vi.spyOn(el, 'getBoundingClientRect').mockReturnValue({
left: 100, top: 50, width: 80, height: 40,
right: 180, bottom: 90, x: 100, y: 50,
toJSON: () => ({}),
} as DOMRect)
await captureAnnotatedRegion(el, 1)
expect(html2canvasMock).toHaveBeenCalledWith(document.documentElement, expect.objectContaining({
width: window.innerWidth,
height: window.innerHeight,
scale: 1,
}))
})
it('keeps the annotation inside the DOM capture instead of post-processing canvas pixels', async () => {
const ctx = makeMockCtx()
html2canvasMock.mockResolvedValue({
getContext: () => ctx as unknown as CanvasRenderingContext2D,
toDataURL: () => 'data:image/png;base64,RAW',
width: 1000,
height: 2000,
})
const el = document.createElement('div')
vi.spyOn(el, 'getBoundingClientRect').mockReturnValue({
left: 100, top: 50, width: 80, height: 40,
right: 180, bottom: 90, x: 100, y: 50,
toJSON: () => ({}),
} as DOMRect)
await captureAnnotatedRegion(el, 1)
expect(ctx.stroke).not.toHaveBeenCalled()
expect(ctx.fillText).not.toHaveBeenCalled()
})
it('places the selected element overlay at viewport coordinates in the captured viewport', async () => {
const ctx = makeMockCtx()
html2canvasMock.mockImplementation(async () => {
const overlay = document.querySelector('[data-preview-selection-annotation="true"]') as HTMLElement | null
expect(overlay?.style.left).toBe('100px')
expect(overlay?.style.top).toBe('50px')
expect(overlay?.style.width).toBe('80px')
expect(overlay?.style.height).toBe('40px')
return {
getContext: () => ctx as unknown as CanvasRenderingContext2D,
toDataURL: () => 'data:image/png;base64,RAW',
width: window.innerWidth,
height: window.innerHeight,
}
})
const el = document.createElement('input')
vi.spyOn(el, 'getBoundingClientRect').mockReturnValue({
left: 100, top: 50, width: 80, height: 40,
right: 180, bottom: 90, x: 100, y: 50,
toJSON: () => ({}),
} as DOMRect)
vi.spyOn(document.body, 'getBoundingClientRect').mockReturnValue({
left: 0, top: -200, width: 1000, height: 2000,
right: 1000, bottom: 1800, x: 0, y: -200,
toJSON: () => ({}),
} as DOMRect)
await captureAnnotatedRegion(el, 1)
const options = html2canvasMock.mock.calls[0]?.[1] as Record<string, unknown>
expect(html2canvasMock.mock.calls[0]?.[0]).toBe(document.documentElement)
expect(options).toMatchObject({
x: window.scrollX,
y: window.scrollY,
width: window.innerWidth,
height: window.innerHeight,
windowWidth: window.innerWidth,
windowHeight: window.innerHeight,
scale: 1,
})
expect(ctx.arc).not.toHaveBeenCalled()
})
it('captures the annotation as a viewport DOM overlay so output canvas scaling cannot drift', async () => {
const ctx = makeMockCtx()
html2canvasMock.mockImplementation(async () => {
const overlay = document.querySelector('[data-preview-selection-annotation="true"]') as HTMLElement | null
expect(overlay).not.toBeNull()
expect(overlay?.style.position).toBe('fixed')
expect(overlay?.style.left).toBe('900px')
expect(overlay?.style.top).toBe('320px')
expect(overlay?.style.width).toBe('86px')
expect(overlay?.style.height).toBe('48px')
expect(overlay?.textContent).toContain('1')
return {
getContext: () => ctx as unknown as CanvasRenderingContext2D,
toDataURL: () => 'data:image/png;base64,RAW',
width: 480,
height: 320,
}
})
const el = document.createElement('button')
vi.spyOn(el, 'getBoundingClientRect').mockReturnValue({
left: 900, top: 320, width: 86, height: 48,
right: 986, bottom: 368, x: 900, y: 320,
toJSON: () => ({}),
} as DOMRect)
await captureAnnotatedRegion(el, 1)
expect(ctx.arc).not.toHaveBeenCalled()
expect(document.querySelector('[data-preview-selection-annotation="true"]')).toBeNull()
})
it('returns the compressed wrapper of the canvas dataURL', async () => {
const ctx = makeMockCtx()
html2canvasMock.mockResolvedValue({
getContext: () => ctx as unknown as CanvasRenderingContext2D,
toDataURL: () => 'data:image/png;base64,RAW',
width: 1000,
height: 2000,
})
const el = document.createElement('div')
vi.spyOn(el, 'getBoundingClientRect').mockReturnValue({
left: 0, top: 0, width: 50, height: 50,
right: 50, bottom: 50, x: 0, y: 0,
toJSON: () => ({}),
} as DOMRect)
const result = await captureAnnotatedRegion(el, 1)
expect(compressDataUrl).toHaveBeenCalledWith('data:image/png;base64,RAW')
expect(result).toBe('c:data:image/png;base64,RAW')
})
})