Files
goodbuddy/src/main/magic-notes/canvas-ipc.integration.test.ts
T
mesalogo bf1d34e897 feat: configure canvas pages sent for note analysis
Canvas analysis previously sent every page. Add a Magic Notes setting for
1-8 canvas pages, defaulting to 1 for new and historical settings. Select
the first N pages in current canvas order before capture and use the same
text/image range for saved entries, drafts and canvas-source todos.

Keep full 50-page saving/export, expose the same setting through config
tools, and use the shared settings select with an inline help icon.
Include pagination, persistence, production IPC and isolated Electron
regressions, including text-only input and scaled page boundaries.

Validation: focused feature tests and isolated Electron regressions passed;
the final compact UI passed 14 tests. Typecheck and lint were reported
passing by the coordinating agent. No full-suite pass is claimed because
the completed full test command exposed no summary. No live model calls
were made for this commit preparation.

Release note: 魔法笔记新增“发送画布页数”设置,可选 1~8 页,默认 1 页,按当前画布顺序发送前 N 页的文字和图片;标题旁问号可查看说明,保存和导出完整画布不受影响。
2026-09-20 00:07:07 +08:00

324 lines
18 KiB
TypeScript

// @vitest-environment node
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { DesktopApi } from '../../shared/contracts'
import type { MagicNoteCanvasContent, MagicNoteDetail } from '../../shared/magic-notes-contracts'
import { AssistantDatabase } from '../assistant/assistant-database'
import { registerIpcHandlers } from '../ipc'
import type { AgentExecutionRequest } from '../agent/runtime'
import { ApplicationSettingsStore } from '../application-settings-store'
const modelFactory = vi.hoisted(() => vi.fn())
vi.mock('../agent/create-runtime', async (importOriginal) => ({
...await importOriginal<typeof import('../agent/create-runtime')>(),
createDefaultModelRuntime: modelFactory
}))
const bridge = vi.hoisted(() => ({
handlers: new Map<string, (...args: unknown[]) => unknown>(),
api: undefined as DesktopApi | undefined,
event: undefined as unknown
}))
vi.mock('electron', () => ({
app: { getName: () => 'Canvas integration', getVersion: () => '0.0.0' },
BrowserWindow: class {},
Notification: class { static isSupported() { return false } },
nativeImage: {}, clipboard: {}, dialog: {}, shell: {}, webUtils: {},
ipcMain: {
handle: (channel: string, handler: (...args: unknown[]) => unknown) => bridge.handlers.set(channel, handler),
removeHandler: (channel: string) => bridge.handlers.delete(channel)
},
ipcRenderer: {
invoke: async (channel: string, ...args: unknown[]) => {
const handler = bridge.handlers.get(channel)
if (!handler) throw new Error(`Missing production handler: ${channel}`)
return structuredClone(await handler(bridge.event, ...structuredClone(args)))
},
on: vi.fn(), removeListener: vi.fn()
},
contextBridge: { exposeInMainWorld: (_name: string, api: DesktopApi) => { bridge.api = api } }
}))
// Disable unrelated environment integrations; persistence, schemas and handlers are real.
vi.mock('../channels/channel-env', () => ({ startEnvironmentChannels: () => [], isReadOnlyChannelMessage: () => true }))
const png = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII='
function canvas(): MagicNoteCanvasContent {
return {
version: 2, kind: 'paged-canvas',
pages: [{ id: 'page-1', width: 794, height: 1123,
background: { type: 'template', template: 'grid' },
objects: [{ type: 'IText', text: 'Annotation', left: 24 }, { type: 'Image', src: png }] }],
flow: { version: 1, ops: [{ insert: 'First task' }, { insert: '\n', attributes: { list: 'unchecked' } }] },
assets: [{ id: 'image-1', name: 'pixel.png', mimeType: 'image/png', dataUrl: png }]
}
}
describe('production preload -> registered IPC -> SQLite canvas persistence', () => {
let directory: string
let database: AssistantDatabase
let dispose: (() => Promise<void>) | undefined
let api: DesktopApi['magicNotes']
let applicationSettings: ApplicationSettingsStore
const webContents = { id: 91, mainFrame: { url: 'file:///canvas-test/index.html' },
getURL: () => 'file:///canvas-test/index.html', isDestroyed: () => false, send: vi.fn() }
const window = { webContents, isDestroyed: () => false, isMaximized: () => false,
on: vi.fn(), removeListener: vi.fn() }
const getResolvedSettings = vi.fn()
function open() {
database = new AssistantDatabase(join(directory, 'assistant.sqlite'))
database.initialize(directory)
dispose = registerIpcHandlers(window as never, { capability: 'text' } as never,
'CommandOrControl+Shift+Space', { getResolvedSettings } as never, {} as never,
{ clear: vi.fn(), cancelImport: vi.fn() } as never, {} as never, database,
{ clear: vi.fn() } as never, {} as never, async () => {}, undefined, undefined, undefined, undefined, applicationSettings)
}
async function reopen() {
await dispose?.()
database.close()
open()
}
beforeEach(async () => {
getResolvedSettings.mockReset()
modelFactory.mockReset()
directory = await mkdtemp(join(tmpdir(), 'goodbuddy-canvas-ipc-'))
applicationSettings = new ApplicationSettingsStore(join(directory, 'application.json'))
bridge.event = { sender: webContents, senderFrame: webContents.mainFrame }
await import('../../preload/index')
api = bridge.api!.magicNotes
open()
})
afterEach(async () => {
await dispose?.()
database?.close()
bridge.handlers.clear()
await rm(directory, { recursive: true, force: true })
})
it.each([true, false])('limits saved, draft and todo input using persisted page order and count (vision=%s)', async (supportsImageInput) => {
getResolvedSettings.mockResolvedValue({ workspacePath: directory, supportsImageInput })
const requests: AgentExecutionRequest[] = []
modelFactory.mockReturnValue({ dispose: vi.fn(), async *run(request: AgentExecutionRequest) {
requests.push(request)
yield { type: 'text', delta: '{"comments":[{"kind":"summary","content":"Reviewed"}]}' }
yield { type: 'done' }
} })
const content = canvas()
content.pages = Array.from({ length: 50 }, (_, index) => ({ ...content.pages[0]!, id: `page-${50 - index}`,
background: { type: 'template', template: 'blank' },
objects: [{ type: 'IText', text: `OBJECT_TOKEN_${index + 1}_END` }] }))
const note = await api.create({ title: 'Fifty pages' })
const saved = await api.createEntry({ noteId: note.id, content })
const entry = saved.entries[0]!
const todo = (await api.listTodos()).todos[0]!
for (const count of [1, 8]) {
await applicationSettings.update({ magicNoteCanvasPageCount: count })
const options = { requestId: crypto.randomUUID(), direction: 'general' as const, format: 'structured' as const,
canvasImages: [...content.pages].reverse().map(page => ({ pageId: page.id, dataUrl: png })),
canvasPageText: content.pages.slice(0, count).map((page, index) => ({ pageId: page.id, text: `FLOW_TOKEN_${index + 1}_END` })) }
await api.analyze(entry.id, { ...options, expectedRevision: database.getMagicNoteEntry(entry.id).revision })
await api.analyzeDraft(content, { ...options, requestId: crypto.randomUUID() })
await api.analyzeTodo(todo.id, { ...options, requestId: crypto.randomUUID(), sourceEntryRevision: database.getMagicNoteEntry(entry.id).revision })
for (const request of requests.slice(-3)) {
expect(request.prompt).toContain(`OBJECT_TOKEN_${count}_END`)
expect(request.prompt).toContain(`FLOW_TOKEN_${count}_END`)
expect(request.prompt).not.toContain(`OBJECT_TOKEN_${count + 1}_END`)
expect(request.prompt).not.toContain(`FLOW_TOKEN_${count + 1}_END`)
expect(request.images?.length ?? 0).toBe(supportsImageInput ? count : 0)
}
}
expect(database.getMagicNoteEntry(entry.id).content).toEqual(content)
expect(requests).toHaveLength(6)
})
it.each([
{ canvasSource: true, supportsImageInput: true, inputMode: 'canvas-images' },
{ canvasSource: true, supportsImageInput: false, inputMode: 'text-fallback' },
{ canvasSource: false, supportsImageInput: true, inputMode: 'text' }
])('routes todo analysis through its source entry and the same resolved settings: $inputMode', async ({ canvasSource, supportsImageInput, inputMode }) => {
const settings = { workspacePath: directory, supportsImageInput }
getResolvedSettings.mockResolvedValue(settings)
const requests: AgentExecutionRequest[] = []
const runtime = {
releaseConversation: vi.fn(async () => {}),
dispose: vi.fn(async () => {}),
async *run(request: AgentExecutionRequest) {
requests.push(request)
yield { requestId: request.requestId, type: 'text', delta: '{"comments":[{"kind":"summary","content":"Review the task."}]}' }
yield { requestId: request.requestId, type: 'done' }
}
}
modelFactory.mockReturnValue(runtime)
const note = await api.create({ title: 'Todo analysis' })
const saved = await api.createEntry({ noteId: note.id, content: canvasSource ? canvas() : {
version: 1, ops: [{ insert: 'First task' }, { insert: '\n', attributes: { list: 'unchecked' } }]
} })
const todo = (await api.listTodos()).todos.find((item) => item.entryId === saved.entries[0]!.id)!
const result = await api.analyzeTodo(todo.id, {
requestId: '00000000-0000-4000-8000-000000000709',
direction: 'general', format: 'structured',
sourceEntryRevision: saved.entries[0]!.revision,
canvasImages: [{ pageId: 'page-1', dataUrl: png }]
})
expect(getResolvedSettings).toHaveBeenCalledOnce()
expect(modelFactory).toHaveBeenCalledWith(directory, settings)
expect(modelFactory.mock.calls[0]![1]).toBe(settings)
expect(requests).toHaveLength(1)
expect(requests[0]?.prompt).toContain(todo.title)
expect(requests[0]?.workMode).toBe('ask')
if (canvasSource) expect(requests[0]?.prompt).toContain('Annotation')
if (inputMode === 'canvas-images') {
expect(requests[0]?.images).toEqual([{ name: 'page-1.png', mediaType: 'image/png', data: png.split(',')[1] }])
} else {
expect(requests[0]?.images).toBeUndefined()
if (canvasSource) expect(requests[0]?.prompt).toContain('未查看页面图像')
}
expect(result.comments[0]?.inputMode).toBe(inputMode)
expect(database.getMagicTodo(todo.id).comments[0]?.inputMode).toBe(inputMode)
expect(result.revision).toBe(todo.revision + 1)
expect(runtime.releaseConversation).toHaveBeenCalledWith(`magic-todos:${todo.id}`)
expect(runtime.dispose).toHaveBeenCalledOnce()
})
it.each(['entry', 'todo'] as const)('rejects missing and stale screenshot revisions before %s model calls', async (kind) => {
getResolvedSettings.mockResolvedValue({ workspacePath: directory, supportsImageInput: true })
const note = await api.create({ title: 'Stale captures' })
const saved = await api.createEntry({ noteId: note.id, content: canvas() })
const entry = saved.entries[0]!
const todo = database.listMagicTodos()[0]!
const options = { requestId: '00000000-0000-4000-8000-000000000710', direction: 'general' as const,
format: 'structured' as const, canvasImages: [{ pageId: 'page-1', dataUrl: png }] }
const analyze = (revision?: number) => kind === 'entry'
? api.analyze(entry.id, { ...options, expectedRevision: revision })
: api.analyzeTodo(todo.id, { ...options, sourceEntryRevision: revision })
await expect(analyze()).rejects.toThrow('版本')
const content = canvas()
content.pages[0]!.objects[0]!.left = 500
await api.updateEntry({ entryId: entry.id, expectedRevision: entry.revision, content })
await expect(analyze(entry.revision)).rejects.toThrow('已被更新')
expect(modelFactory).not.toHaveBeenCalled()
})
it.each(['entry', 'todo'] as const)('rejects late %s analysis after a source edit with no existing comments', async (kind) => {
getResolvedSettings.mockResolvedValue({ workspacePath: directory, supportsImageInput: false })
const note = await api.create({ title: 'Late analysis' })
const saved = await api.createEntry({ noteId: note.id, content: canvas() })
const entry = saved.entries[0]!
const todo = database.listMagicTodos()[0]!
modelFactory.mockReturnValue({ dispose: vi.fn(), async *run() {
const content = canvas()
content.pages[0]!.objects[0]!.text = 'Changed context'
database.updateMagicNoteEntry({ entryId: entry.id, expectedRevision: entry.revision, content, plainText: '' })
yield { type: 'text', delta: '{"comments":[{"kind":"summary","content":"Old result"}]}' }
yield { type: 'done' }
} })
const options = { requestId: '00000000-0000-4000-8000-000000000711', direction: 'general' as const, format: 'structured' as const }
await expect(kind === 'entry' ? api.analyze(entry.id, options) : api.analyzeTodo(todo.id, options)).rejects.toThrow('已被更新')
expect(database.getMagicTodo(todo.id).comments).toEqual([])
expect(database.getMagicNoteEntry(entry.id).comments).toEqual([])
})
it('returns the exact created ID through production IPC after an Agent append', async () => {
const note = await api.create({ title: 'Concurrent creators' })
const agent = database.createMagicNoteEntry({ noteId: note.id, content: { version: 1, ops: [{ insert: 'Agent A\n' }] }, plainText: 'Agent A' })
const local = await api.createEntry({ noteId: note.id, content: { version: 1, ops: [{ insert: 'Local B\n' }] } })
expect(local.createdEntryId).not.toBe(agent.createdEntryId)
expect(local.entries.find((entry) => entry.id === local.createdEntryId)?.plainText).toBe('Local B')
expect(local.entries.find((entry) => entry.id === agent.createdEntryId)?.plainText).toBe('Agent A')
expect(await api.get(note.id)).toEqual(expect.objectContaining({ entries: local.entries }))
})
it('reopens canvas images, edits flow, writes todo completion back and preserves legacy rich entries', async () => {
const note = await api.create({ title: 'Canvas integration' })
const { createdEntryId, ...initial } = await api.createEntry({ noteId: note.id, content: canvas() })
let saved: MagicNoteDetail = initial
await reopen()
expect(await api.get(note.id)).toEqual(saved)
const entry = saved.entries.find((entry) => entry.id === createdEntryId)!
const content = structuredClone(entry.content) as MagicNoteCanvasContent
content.flow!.ops[0] = { insert: 'Updated task' }
saved = await api.updateEntry({ entryId: entry.id, expectedRevision: entry.revision, content })
expect(saved.entries[0]!.plainText).toContain('Updated task')
const todo = (await api.listTodos()).todos.find(item => item.title === 'Updated task')!
expect(todo).toBeDefined()
await api.updateTodo({ todoId: todo.id, completed: true, expectedRevision: todo.revision })
await reopen()
const completed = (await api.get(note.id)).entries[0]!
expect(completed.content).toMatchObject({ pages: content.pages, assets: content.assets,
flow: { ops: [{ insert: 'Updated task' }, { insert: '\n', attributes: { list: 'checked' } }] } })
await expect(api.updateEntry({ entryId: entry.id, expectedRevision: entry.revision, content })).rejects.toThrow()
const rich = { version: 1 as const, ops: [{ insert: 'Legacy rich\n' }] }
saved = await api.createEntry({ noteId: note.id, content: rich })
const legacy = saved.entries.find(item => item.content.version === 1)!
saved = await api.updateEntry({ entryId: legacy.id, expectedRevision: legacy.revision,
content: { version: 1, ops: [{ insert: 'Legacy updated\n', attributes: { bold: true } }] } })
await reopen()
expect(await api.get(note.id)).toEqual(saved)
})
it('saves and reopens a 20 MiB canvas image through create and update IPC', async () => {
const content = canvas()
const bytes = Buffer.alloc(20 * 1024 * 1024)
Buffer.from(png.split(',')[1]!, 'base64').copy(bytes)
content.assets[0]!.dataUrl = `data:image/png;base64,${bytes.toString('base64')}`
const note = await api.create({ title: 'Large canvas image' })
const created = await api.createEntry({ noteId: note.id, content })
await reopen()
const entry = (await api.get(note.id)).entries.find(item => item.id === created.createdEntryId)!
expect(entry.content).toEqual(content)
content.flow!.ops[0] = { insert: 'Edited large image note' }
await api.updateEntry({ entryId: entry.id, expectedRevision: entry.revision, content })
await reopen()
expect((await api.get(note.id)).entries.find(item => item.id === entry.id)!.content).toEqual(content)
})
it('preserves a real generated PDF and all page references through three reopen/save cycles', async () => {
const { jsPDF } = await import('jspdf')
const pdf = new jsPDF()
pdf.text('First PDF page', 20, 20)
pdf.addPage()
pdf.text('Second PDF page', 20, 20)
const dataUrl = `data:application/pdf;base64,${Buffer.from(pdf.output('arraybuffer')).toString('base64')}`
const content = canvas()
content.assets.push({ id: 'pdf-1', name: 'two-pages.pdf', mimeType: 'application/pdf', dataUrl })
content.pages = [1, 2].map(number => ({ id: `pdf-page-${number}`, width: 794, height: 1123,
background: { type: 'pdf', assetId: 'pdf-1', pageNumber: number, text: `PDF page ${number}` }, objects: [] }))
const note = await api.create({ title: 'PDF integration' })
const { createdEntryId, ...initial } = await api.createEntry({ noteId: note.id, content })
let saved: MagicNoteDetail = initial
expect(saved.entries[0]?.id).toBe(createdEntryId)
for (let cycle = 0; cycle < 3; cycle++) {
await reopen()
expect(await api.get(note.id)).toEqual(saved)
const entry = (await api.get(note.id)).entries[0]!
const next = structuredClone(entry.content) as MagicNoteCanvasContent
next.pages[0]!.objects.push({ type: 'IText', text: `Edit ${cycle}`, left: cycle * 10 })
saved = await api.updateEntry({ entryId: entry.id, expectedRevision: entry.revision, content: next })
expect((saved.entries[0]!.content as MagicNoteCanvasContent).assets).toEqual(content.assets)
}
await reopen()
expect(await api.get(note.id)).toEqual(saved)
})
it('rejects invalid assets and references on create and update without changing persisted content', async () => {
const note = await api.create({ title: 'Asset validation' })
const { createdEntryId, ...saved } = await api.createEntry({ noteId: note.id, content: canvas() })
const entry = saved.entries.find((entry) => entry.id === createdEntryId)!
const invalid: MagicNoteCanvasContent[] = []
let content = canvas(); content.assets[0]!.dataUrl = 'data:image/png;base64,aGVsbG8='; invalid.push(content)
content = canvas(); content.assets[0]!.mimeType = 'image/jpeg'; invalid.push(content)
content = canvas(); content.pages[0]!.objects = [{ type: 'Image', assetId: 'missing' }]; invalid.push(content)
content = canvas(); content.pages[0]!.background = { type: 'pdf', assetId: 'image-1', pageNumber: 1 }; invalid.push(content)
content = canvas(); content.assets.push({ ...content.assets[0]! }); invalid.push(content)
content = canvas(); content.pages[0]!.objects = [{ type: 'Image', src: 'https://example.com/image.png' }]; invalid.push(content)
for (const content of invalid) {
await expect(api.createEntry({ noteId: note.id, content })).rejects.toThrow()
await expect(api.updateEntry({ entryId: entry.id, expectedRevision: entry.revision, content })).rejects.toThrow()
}
await reopen()
expect(await api.get(note.id)).toEqual(saved)
})
})