feat: add persistent document handling and protect note drafts

Add HTTP PaddleOCR-VL settings and shared document previews for chat and knowledge imports. Preserve attachment originals and parsed assets across drafts, messages and queues; support explicit image OCR, selecting document images into a conversation draft, and restoring queued input for editing.

Protect unsaved note bodies and titles during navigation and asynchronous updates. Match canvas analysis to source revisions, invalidate affected comments, preserve annotations during pagination, and provide a resizable record index with saved width preferences.

Existing OCR settings retain local behavior. Historical attachments without saved originals are not reconstructed. Advanced OCR options depend on the configured service; image sending depends on the effective model and Runtime.

Validation: full typecheck and lint passed; 477 focused tests passed across 12 files; staged whitespace checks passed. The full repository suite was not rerun for this commit.

Release note: Keep note drafts safe while switching workspaces, resize the record index, and revisit saved document attachments with OCR previews and selected images in chat.
This commit is contained in:
mesalogo
2026-09-19 16:51:10 +08:00
parent 17186b0c11
commit 935a8f56bf
95 changed files with 6232 additions and 553 deletions
+93
View File
@@ -0,0 +1,93 @@
// @vitest-environment node
import { spawn } from 'node:child_process'
import { createRequire } from 'node:module'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { createServer } from 'vite'
import react from '@vitejs/plugin-react'
import { expect, it } from 'vitest'
it('keeps real conversation attachment actions compact at narrow widths and keyboard accessible', async () => {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-attachment-layout-'))
const server = await createServer({ configFile: false, root: resolve('.'), cacheDir: join(directory, 'vite'),
plugins: [react(), { name: 'attachment-layout', configureServer(server) {
server.middlewares.use('/attachment.html', async (_request, response) => {
response.setHeader('Content-Type', 'text/html')
response.end(await server.transformIndexHtml('/attachment.html',
'<html><body><div id="root"></div><script type="module" src="/tests/support/attachment-layout-regression.tsx"></script></body></html>'))
})
} }], server: { host: '127.0.0.1', port: 0, watch: null } })
try {
await server.listen()
const driver = join(directory, 'driver.cjs')
await writeFile(driver, `
const { app, BrowserWindow } = require('electron');
const assert = require('node:assert/strict');
app.commandLine.appendSwitch('force-device-scale-factor', '1');
app.setPath('userData', ${JSON.stringify(join(directory, 'profile'))});
app.whenReady().then(async () => {
const win = new BrowserWindow({ show: false, width: 1000, height: 720, useContentSize: true,
webPreferences: { sandbox: true, contextIsolation: true, nodeIntegration: false, backgroundThrottling: false } });
win.webContents.on('console-message', (_event, details) => console.log(details.message));
const js = code => win.webContents.executeJavaScript(code);
const settle = () => js('new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))');
const wait = async code => { for (let i = 0; i < 800; i++) {
if (await js(code)) return; await new Promise(resolve => setTimeout(resolve, 25));
} throw new Error('Timeout: ' + code); };
await win.loadURL(${JSON.stringify(server.resolvedUrls!.local[0] + 'attachment.html')});
await wait('document.querySelectorAll(".attachment-action").length === 2');
for (const theme of ['light', 'dark']) {
await js('document.documentElement.dataset.theme = ' + JSON.stringify(theme));
for (const width of [1000, 720, 360]) {
win.setContentSize(width, 720); await settle();
const geometry = await js(
'(() => { const card = document.querySelector(".message-attachment");' +
'const buttons = [...card.querySelectorAll(".attachment-action")];' +
'return { overflow: document.documentElement.scrollWidth > innerWidth,' +
'card: card.getBoundingClientRect().toJSON(),' +
'buttons: buttons.map(b => b.getBoundingClientRect().toJSON()),' +
'nameClipped: card.querySelector("strong").scrollWidth > card.querySelector("strong").clientWidth }; })()');
assert.equal(geometry.overflow, false, JSON.stringify(geometry));
assert(geometry.card.height < 110, JSON.stringify(geometry));
assert.equal(geometry.buttons[0].y, geometry.buttons[1].y);
for (const button of geometry.buttons) {
assert.equal(button.width, 32); assert.equal(button.height, 32);
assert(button.right <= geometry.card.right && button.left >= geometry.card.left);
}
assert(geometry.nameClipped);
}
}
win.webContents.setZoomFactor(2); win.setContentSize(720, 720); await settle();
await wait('innerWidth === 360'); await settle();
assert(await js('document.documentElement.scrollWidth <= innerWidth'), await js('JSON.stringify({width: innerWidth, scroll: document.documentElement.scrollWidth, overflowing: [...document.querySelectorAll("body *")].filter(e => e.getBoundingClientRect().right > innerWidth).map(e => [e.className, e.getBoundingClientRect().right])})'));
win.focus(); win.webContents.focus();
await js('document.querySelector(".attachment-action").focus()');
win.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Tab' });
win.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'Tab' }); await settle();
assert(await js('document.activeElement.matches(".attachment-action[aria-haspopup=menu]")'));
assert(await js('getComputedStyle(document.activeElement, "::after").content.includes(document.activeElement.dataset.tooltip)'));
win.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Space' });
win.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'Space' });
await wait('!!document.querySelector("[role=menu]")');
assert(await js('document.activeElement.matches("[role=menuitem]")'));
win.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' });
win.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'Escape' }); await settle();
assert(await js('!document.querySelector("[role=menu]") && document.activeElement.matches(".attachment-action[aria-haspopup=menu]")'));
console.log('Attachment layout: light/dark, 1000/720/360px, 200% zoom, 32px actions, native keyboard passed');
app.exit(0);
}).catch(error => { console.error(error); app.exit(1); });
`)
const env = { ...process.env }
delete env.ELECTRON_RUN_AS_NODE
const child = spawn(createRequire(import.meta.url)('electron'), [driver], { env, stdio: ['ignore', 'pipe', 'pipe'] })
let output = ''
child.stdout.on('data', data => { output += data })
child.stderr.on('data', data => { output += data })
const timeout = setTimeout(() => child.kill(), 60000)
try {
const code = await new Promise((resolve, reject) => { child.once('exit', resolve); child.once('error', reject) })
expect(code, output).toBe(0)
} finally { clearTimeout(timeout) }
} finally { await server.close(); await rm(directory, { recursive: true, force: true }) }
}, 90000)
+75
View File
@@ -0,0 +1,75 @@
// @vitest-environment node
import { spawn } from 'node:child_process'
import { createRequire } from 'node:module'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { createServer } from 'vite'
import react from '@vitejs/plugin-react'
import { expect, it } from 'vitest'
it('preserves annotations when flow removes the active page and renders scoped thumbnails', async () => {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-canvas-core-'))
const server = await createServer({
configFile: false, root: resolve('.'), cacheDir: join(directory, 'vite'),
plugins: [react(), { name: 'canvas-regression', configureServer(server) {
server.middlewares.use('/canvas-regression.html', async (_request, response) => {
response.setHeader('Content-Type', 'text/html')
response.end(await server.transformIndexHtml('/canvas-regression.html',
'<html><body><script type="module" src="/tests/support/canvas-core-regression.tsx"></script></body></html>'))
})
} }],
server: { host: '127.0.0.1', port: 0, watch: null },
optimizeDeps: { noDiscovery: true, include: ['fabric', 'quill', 'jspdf', 'react', 'react-dom/client', 'react-i18next'] }
})
try {
await server.listen()
const driver = join(directory, 'driver.cjs')
await writeFile(driver, `
const { app, BrowserWindow } = require('electron');
const fs = require('node:fs');
app.setPath('userData', ${JSON.stringify(join(directory, 'profile'))});
app.whenReady().then(async () => {
const win = new BrowserWindow({ show: false, width: 1200, height: 950,
webPreferences: { sandbox: true, contextIsolation: true, nodeIntegration: false, backgroundThrottling: false } });
await win.loadURL(${JSON.stringify(server.resolvedUrls!.local[0] + 'canvas-regression.html')});
for (let i = 0; i < 400; i++) {
if (await win.webContents.executeJavaScript('typeof window.canvasRegression === "function"')) break;
await new Promise(resolve => setTimeout(resolve, 50));
}
const result = await win.webContents.executeJavaScript('window.canvasRegression()');
fs.writeFileSync(${JSON.stringify(join(directory, 'result.json'))}, JSON.stringify(result));
app.exit(0);
}).catch(error => { console.error(error); app.exit(1); });
`)
const env = { ...process.env }
delete env.ELECTRON_RUN_AS_NODE
const child = spawn(createRequire(import.meta.url)('electron'), [driver], { env, stdio: ['ignore', 'pipe', 'pipe'] })
let output = ''
child.stdout.on('data', (data) => { output += data })
child.stderr.on('data', (data) => { output += data })
const timeout = setTimeout(() => child.kill(), 60000)
try {
const code = await new Promise((resolve, reject) => { child.once('exit', resolve); child.once('error', reject) })
expect(code, output).toBe(0)
} finally { clearTimeout(timeout) }
const result = JSON.parse(await readFile(join(directory, 'result.json'), 'utf8'))
console.info('Canvas Electron regression:', JSON.stringify(result))
expect(result.errors).toEqual([])
expect(result.initialPages).toBe(2)
expect(result.beforeShrink).toBe('2 / 2')
expect(result.afterShrink).toBe('1 / 1')
expect(result.savedPages).toBe(1)
expect(result.savedObjects).toEqual(result.initialObjects)
expect(result.reopenedObjects).toEqual(result.initialObjects)
expect(result.visibleInkAfter).toBe(result.visibleInkBefore)
expect(result.reopenedInk).toBe(result.visibleInkBefore)
expect(result.visibleInkBefore).toBeGreaterThan(0)
expect(result.flowTransform).toBe('translateX(0px)')
expect(result.thumbnailStyles).toEqual(result.editorStyles)
expect(result.thumbnailMatchesEditor).toBe(true)
} finally {
await server.close()
await rm(directory, { recursive: true, force: true })
}
}, 90000)
@@ -0,0 +1,64 @@
// @vitest-environment node
import { spawn } from 'node:child_process'
import { createRequire } from 'node:module'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import { build } from 'esbuild'
import { createServer } from 'vite'
import react from '@vitejs/plugin-react'
import { expect, it } from 'vitest'
it('analyzes saved, edited, created and todo canvases through production Electron IPC', async () => {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-notes-analysis-'))
const server = await createServer({ configFile: false, root: resolve('.'), cacheDir: join(directory, 'vite'),
plugins: [react(), { name: 'notes-analysis', configureServer(server) {
server.middlewares.use('/analysis.html', async (_request, response) => {
response.setHeader('Content-Type', 'text/html')
response.end(await server.transformIndexHtml('/analysis.html', '<html><body><script type="module" src="/tests/support/magic-notes-analysis-regression.tsx"></script></body></html>'))
})
} }], server: { host: '127.0.0.1', port: 0, watch: null },
optimizeDeps: { include: ['fabric', 'quill', 'quill-delta', 'jspdf', 'react', 'react-dom/client', 'react-i18next'] }
})
try {
const driver = join(directory, 'main.mjs'), preload = join(directory, 'preload.cjs')
await build({ entryPoints: ['tests/support/magic-notes-analysis-main.ts'], outfile: driver, bundle: true, platform: 'node', format: 'esm', external: ['electron'],
plugins: [{ name: 'isolated-services', setup(build) {
build.onResolve({ filter: /channels\/channel-env$/ }, () => ({ path: 'channels', namespace: 'probe' }))
build.onResolve({ filter: /agent\/create-runtime$/ }, () => ({ path: 'runtime', namespace: 'probe' }))
build.onLoad({ filter: /.*/, namespace: 'probe' }, ({ path }) => ({ contents: path === 'channels'
? 'export const startEnvironmentChannels=()=>[]; export const isReadOnlyChannelMessage=()=>true;'
: `export const createDefaultModelRuntime=()=>({ dispose:async()=>{}, releaseConversation:async()=>{}, async *run(request) {
if (!request.images?.length) throw new Error('Expected real canvas captures');
yield {requestId:request.requestId,type:'text',delta:'{"comments":[{"kind":"summary","content":"Canvas reviewed."}]}'};
yield {requestId:request.requestId,type:'done'};
}}); export const createModelProfileRuntime=createDefaultModelRuntime;` }))
} }, { name: 'external-packages', setup(build) {
build.onResolve({ filter: /^[^./]/ }, async args => {
if (args.pluginData?.resolved || /^[A-Za-z]:/.test(args.path) || args.path.startsWith('node:') || args.path === 'electron') return
const result = await build.resolve(args.path, { resolveDir: resolve('.'), kind: 'import-statement', pluginData: { resolved: true } })
return result.errors.length ? { errors: result.errors } : { path: result.external ? result.path : pathToFileURL(result.path).href, external: true }
})
} }] })
await build({ entryPoints: ['src/preload/index.ts'], outfile: preload, bundle: true, platform: 'node', format: 'cjs', external: ['electron'] })
await server.listen()
const bootstrap = join(directory, 'bootstrap.cjs')
await writeFile(bootstrap, `import(${JSON.stringify(pathToFileURL(driver).href)}).catch(error => { console.error(error); require('electron').app.exit(1) })`)
const env: NodeJS.ProcessEnv = { ...process.env, GB_NOTES_PROBE_DIRECTORY: directory, GB_NOTES_PROBE_URL: server.resolvedUrls!.local[0] + 'analysis.html' }
delete env.ELECTRON_RUN_AS_NODE
const child = spawn(createRequire(import.meta.url)('electron'), [bootstrap], { env, stdio: ['ignore', 'pipe', 'pipe'] })
let output = ''
child.stdout.on('data', data => { output += data })
child.stderr.on('data', data => { output += data })
const timeout = setTimeout(() => child.kill(), 60000)
try {
expect(await new Promise((resolve, reject) => { child.once('exit', resolve); child.once('error', reject) }), output).toBe(0)
} finally { clearTimeout(timeout) }
const result = JSON.parse(await readFile(join(directory, 'result.json'), 'utf8'))
expect(result).toEqual({ errors: [], manualRevision: 1, editedRevision: 3, createdAnalyzed: true, todoAnalyzed: 'canvas-images' })
} finally {
await server.close()
await rm(directory, { recursive: true, force: true })
}
}, 90000)
+116
View File
@@ -0,0 +1,116 @@
// @vitest-environment node
import { spawn } from 'node:child_process'
import { createRequire } from 'node:module'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { createServer } from 'vite'
import react from '@vitejs/plugin-react'
import { expect, it } from 'vitest'
it('resizes record columns with native Electron input and preserves desktop widths across narrow layouts', async () => {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-notes-layout-'))
const server = await createServer({
configFile: false, root: resolve('.'), cacheDir: join(directory, 'vite'),
plugins: [react(), { name: 'notes-layout', configureServer(server) {
server.middlewares.use('/layout.html', async (_request, response) => {
response.setHeader('Content-Type', 'text/html')
response.end(await server.transformIndexHtml('/layout.html',
'<html><body><script type="module" src="/tests/support/magic-notes-layout-regression.tsx"></script></body></html>'))
})
} }],
server: { host: '127.0.0.1', port: 0, watch: null }
})
try {
await server.listen()
const driver = join(directory, 'driver.cjs')
await writeFile(driver, `
const { app, BrowserWindow } = require('electron');
const fs = require('node:fs');
const assert = require('node:assert/strict');
app.commandLine.appendSwitch('force-device-scale-factor', '1');
app.setPath('userData', ${JSON.stringify(join(directory, 'profile'))});
app.whenReady().then(async () => {
const win = new BrowserWindow({ show: false, width: 1280, height: 800, useContentSize: true,
webPreferences: { sandbox: true, contextIsolation: true, nodeIntegration: false, backgroundThrottling: false } });
const js = code => win.webContents.executeJavaScript(code).catch(error => { throw new Error(code + ': ' + error.message); });
const settle = () => js('new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))');
const wait = async code => { for (let i = 0; i < 400; i++) {
if (await js(code)) return;
await new Promise(resolve => setTimeout(resolve, 25));
} throw new Error('Timeout: ' + code); };
const click = async selector => { await js('document.querySelector(' + JSON.stringify(selector) + ').click()'); await settle(); };
const rect = selector => js('(() => { const r = document.querySelector(' + JSON.stringify(selector) + ').getBoundingClientRect(); return { x: r.x, y: r.y, width: r.width, height: r.height }; })()');
const drag = async (selector, delta) => {
const r = await rect(selector); const x = Math.round(r.x + r.width / 2), y = Math.round(r.y + 80);
win.webContents.sendInputEvent({ type: 'mouseMove', x, y });
win.webContents.sendInputEvent({ type: 'mouseDown', x, y, button: 'left', clickCount: 1 });
await settle();
win.webContents.sendInputEvent({ type: 'mouseMove', x: x + delta, y, button: 'left' });
await settle();
await new Promise(resolve => setTimeout(resolve, 80));
win.webContents.sendInputEvent({ type: 'mouseUp', x: x + delta, y, button: 'left', clickCount: 1 });
await settle();
};
const key = async (selector, keyCode) => {
await js('document.querySelector(' + JSON.stringify(selector) + ').focus()');
win.webContents.sendInputEvent({ type: 'keyDown', keyCode });
win.webContents.sendInputEvent({ type: 'keyUp', keyCode }); await settle();
};
const open = async () => { await wait('!!document.querySelector("[id^=magic-note-select-]")');
await click('[id^=magic-note-select-]'); await wait('!!document.querySelector(".magic-note-record")'); await settle(); };
await win.loadURL(${JSON.stringify(server.resolvedUrls!.local[0] + 'layout.html')});
await open(); await js('document.fonts.ready'); win.focus(); win.webContents.focus();
const index = '.magic-notes-index-pane', handle = '.magic-notes-index-resize-handle', stream = '.magic-notes-stream-pane';
assert.equal((await rect(index)).width, 168);
await drag(handle, 80); const dragged = (await rect(index)).width; assert(dragged > 240 && dragged < 260);
await key(handle, 'Home'); assert.equal((await rect(index)).width, 140);
await key(handle, 'Right'); assert.equal((await rect(index)).width, 156);
await key(handle, 'End'); assert.equal((await rect(index)).width, 320);
await drag('.magic-notes-ai-resize-handle', -100);
assert((await rect('.magic-notes-ai-pane')).width > 350, JSON.stringify({ ai: await rect('.magic-notes-ai-pane'), handle: await rect('.magic-notes-ai-resize-handle'), layout: await rect('.magic-notes-layout') }));
const before = (await rect(stream)).width;
await click('#magic-notes-index-toggle'); assert.equal((await rect(index)).width, 0);
assert.equal((await rect(stream)).width - before, 329);
assert.equal(await js('document.querySelector(".magic-notes-index-resize-handle")'), null);
await click('#magic-notes-index-toggle');
await click('button[aria-controls="magic-notes-ai-pane"]'); await drag(handle, -80);
const withoutAi = (await rect(index)).width; assert(withoutAi < 250 && withoutAi > 230);
await click('button[aria-controls="magic-notes-ai-pane"]'); assert.equal((await rect(index)).width, withoutAi);
await win.loadURL(${JSON.stringify(server.resolvedUrls!.local[0] + 'layout.html')}); await open(); assert.equal((await rect(index)).width, withoutAi);
win.setContentSize(820, 720); await settle(); await settle();
assert((await rect(stream)).width >= 299);
const constrained = { index: (await rect(index)).width, editor: (await rect(stream)).width, ai: (await rect('.magic-notes-ai-pane')).width };
win.setContentSize(720, 640); await settle(); await settle();
assert.equal((await rect(index)).width, 0);
await click('#magic-notes-index-toggle'); assert.equal((await rect(index)).width, 168);
assert.equal(await js('document.querySelector(".magic-notes-index-resize-handle")'), null);
assert.equal(await js('getComputedStyle(document.querySelector(".magic-notes-ai-resize-handle")).display'), 'none');
assert(await js('document.documentElement.scrollWidth <= innerWidth'));
const narrowStream = (await rect(stream)).width;
await click('#magic-notes-index-toggle'); assert.equal((await rect(stream)).width, narrowStream);
win.setContentSize(1280, 800); await settle(); await settle();
assert.equal((await rect(index)).width, withoutAi);
fs.writeFileSync(${JSON.stringify(join(directory, 'result.json'))}, JSON.stringify({ dragged, withoutAi, constrained, narrowStream, persisted: true }));
app.exit(0);
}).catch(error => { console.error(error); app.exit(1); });
`)
const env = { ...process.env }
delete env.ELECTRON_RUN_AS_NODE
const child = spawn(createRequire(import.meta.url)('electron'), [driver], { env, stdio: ['ignore', 'pipe', 'pipe'] })
let output = ''
child.stdout.on('data', data => { output += data })
child.stderr.on('data', data => { output += data })
const timeout = setTimeout(() => child.kill(), 60000)
try {
const code = await new Promise((resolve, reject) => { child.once('exit', resolve); child.once('error', reject) })
expect(code, output).toBe(0)
} finally { clearTimeout(timeout) }
const result = JSON.parse(await readFile(join(directory, 'result.json'), 'utf8'))
console.info('Notes layout Electron geometry:', result)
expect(result.persisted).toBe(true)
} finally {
await server.close()
await rm(directory, { recursive: true, force: true })
}
}, 90000)
@@ -0,0 +1,64 @@
// @vitest-environment node
import { spawn } from 'node:child_process'
import { createRequire } from 'node:module'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { createServer } from 'vite'
import react from '@vitejs/plugin-react'
import { expect, it } from 'vitest'
it('guards real canvas input and preserves titles during AI in Electron', async () => {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-notes-navigation-'))
const server = await createServer({
configFile: false, root: resolve('.'), cacheDir: join(directory, 'vite'),
plugins: [react(), { name: 'notes-navigation-regression', configureServer(server) {
server.middlewares.use('/notes-regression.html', async (_request, response) => {
response.setHeader('Content-Type', 'text/html')
response.end(await server.transformIndexHtml('/notes-regression.html',
'<html><body><script type="module" src="/tests/support/magic-notes-navigation-regression.tsx"></script></body></html>'))
})
} }],
server: { host: '127.0.0.1', port: 0, watch: null },
optimizeDeps: { include: ['fabric', 'quill', 'quill-delta', 'jspdf', 'react', 'react-dom/client', 'react-i18next'] },
})
try {
await server.listen()
const driver = join(directory, 'driver.cjs')
await writeFile(driver, `
const { app, BrowserWindow } = require('electron');
const fs = require('node:fs');
app.setPath('userData', ${JSON.stringify(join(directory, 'profile'))});
app.whenReady().then(async () => {
const win = new BrowserWindow({ show: false, width: 1400, height: 1000,
webPreferences: { sandbox: true, contextIsolation: true, nodeIntegration: false, backgroundThrottling: false } });
win.webContents.on('console-message', event => console.log(event.message));
await win.loadURL(${JSON.stringify(server.resolvedUrls!.local[0] + 'notes-regression.html')});
for (let i = 0; i < 400; i++) {
if (await win.webContents.executeJavaScript('typeof window.notesNavigationRegression === "function"')) break;
await new Promise(resolve => setTimeout(resolve, 50));
}
const result = await win.webContents.executeJavaScript('window.notesNavigationRegression().catch(error => ({ error: error.stack, body: document.body.innerText }))');
fs.writeFileSync(${JSON.stringify(join(directory, 'result.json'))}, JSON.stringify(result));
app.exit(0);
}).catch(error => { console.error(error); app.exit(1); });
`)
const env = { ...process.env }
delete env.ELECTRON_RUN_AS_NODE
const child = spawn(createRequire(import.meta.url)('electron'), [driver], { env, stdio: ['ignore', 'pipe', 'pipe'] })
let output = ''
child.stdout.on('data', data => { output += data })
child.stderr.on('data', data => { output += data })
const timeout = setTimeout(() => child.kill(), 60000)
try {
const code = await new Promise((resolve, reject) => { child.once('exit', resolve); child.once('error', reject) })
expect(code, output).toBe(0)
} finally { clearTimeout(timeout) }
const result = JSON.parse(await readFile(join(directory, 'result.json'), 'utf8'))
console.info('Notes navigation Electron regression:', JSON.stringify(result))
expect(result).toEqual({ errors: [], titlePreserved: true, continuedTitle: 'Title during AI', canvasGuarded: true, canvasPreserved: true, leaves: 1 })
} finally {
await server.close()
await rm(directory, { recursive: true, force: true })
}
}, 90000)
@@ -0,0 +1,24 @@
import { createRoot } from 'react-dom/client'
import { ChatMessageRow } from '../../src/renderer/src/ChatTimeline'
import '../../src/renderer/src/i18n'
import '../../src/renderer/src/styles.css'
const noop = (): void => undefined
const done = async (): Promise<void> => undefined
const name = 'b4307bf0-b49d-4242-a8ec-cb64d487-long-original-filename.pdf'
Object.defineProperty(window, 'goodbuddy', { value: {
documentParsing: { getSnapshot: async () => ({ settings: { ocrProvider: 'paddleocr-vl' } }) }
} })
createRoot(document.getElementById('root')!).render(
<div style={{ width: '100%', maxWidth: 820, padding: 16, margin: 'auto' }}>
<ChatMessageRow artifactById={new Map()} canRetry={false} conversationId="conversation"
greeting={false} locale="zh-CN" onArticleRef={noop} onCopyMessage={async () => true}
onDownloadImage={noop} onOpenCitationContext={done} onOpenCitationSource={done}
onOpenImage={noop} onRespondApproval={done} onRespondQuestion={done} onRetry={noop}
message={{ id: 'message', role: 'user', content: '', state: 'complete', createdAt: 0,
attachments: [{ id: 'asset', resourceId: 'asset', resultId: 'result', name,
kind: 'text', size: 1024, preview: 'Saved document', completeness: 'complete' }] }} />
</div>
)
+75
View File
@@ -0,0 +1,75 @@
import Quill from 'quill'
import { createRoot } from 'react-dom/client'
import { mountCanvasNote } from '../../src/renderer/src/magic-canvas/canvas-note.mjs'
import { MagicCanvasThumbnail } from '../../src/renderer/src/MagicCanvasThumbnail'
import { fromCoreContent } from '../../src/renderer/src/magic-canvas/model'
import '../../src/renderer/src/i18n'
import 'quill/dist/quill.snow.css'
import '../../src/renderer/src/styles.css'
import '../../src/renderer/src/magic-canvas.css'
async function canvasRegression() {
const errors: string[] = []
const host = document.createElement('div')
host.className = 'magic-canvas-editor'
document.body.append(host)
let editor = mountCanvasNote(host, { version: 2, pages: [{
id: 'first', width: 794, height: 1123,
background: { type: 'template', template: 'blank' },
objects: [{ type: 'Path', canvasKind: 'pen', path: [['M', 20, 20], ['L', 150, 80]],
stroke: '#ff0000', strokeWidth: 4, fill: null }]
}], flow: { version: 1, ops: [{ insert: 'Flow line\n'.repeat(50) }] } }, { onError: (error) => errors.push(error) })
const initial = await editor.flush()
const ink = async () => {
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))
const canvas = host.querySelector<HTMLCanvasElement>('.lower-canvas')!
const pixels = canvas.getContext('2d')!.getImageData(0, 0, canvas.width, canvas.height).data
let count = 0
for (let i = 3; i < pixels.length; i += 4) if (pixels[i]) count++
return count
}
const visibleInkBefore = await ink()
host.querySelector<HTMLButtonElement>('[data-tool="flow-text"]')!.click()
host.querySelector<HTMLButtonElement>('.canvas-note-next-page')!.click()
await editor.flush()
const beforeShrink = host.querySelector('.canvas-note-page-counter')!.textContent
const quill = Quill.find(host.querySelector('.canvas-flow-quill')!) as Quill
quill.setText('Short flow\n', 'user')
const saved = JSON.parse(JSON.stringify(await editor.flush()))
const afterShrink = host.querySelector('.canvas-note-page-counter')!.textContent
const visibleInkAfter = await ink()
const flowTransform = host.querySelector<HTMLElement>('.canvas-flow-surface')!.style.transform
await editor.destroy()
editor = mountCanvasNote(host, saved, { onError: (error) => errors.push(error) })
const reopened = await editor.flush()
const reopenedInk = await ink()
const styles = (surface: Element) => {
const style = getComputedStyle(surface)
return { fontSize: style.fontSize, lineHeight: style.lineHeight, columnFill: style.columnFill, padding: style.padding }
}
const editorStyles = styles(host.querySelector('.canvas-flow-surface')!)
const [expectedThumbnail] = await editor.capturePages({ firstPageOnly: true, thumbnailWidth: 240 })
await editor.destroy()
const thumbnailHost = document.createElement('div')
document.body.append(thumbnailHost)
let thumbnailStyles: ReturnType<typeof styles> | undefined
const observer = new MutationObserver(() => {
const surface = document.querySelector('.magic-note-thumbnail-renderer .canvas-flow-surface')
if (surface) thumbnailStyles = styles(surface)
})
observer.observe(document.body, { childList: true, subtree: true })
const root = createRoot(thumbnailHost)
root.render(<MagicCanvasThumbnail content={fromCoreContent(saved, [])} />)
for (let i = 0; i < 400 && !thumbnailHost.querySelector('img'); i++) await new Promise(resolve => setTimeout(resolve, 25))
const thumbnailMatchesEditor = thumbnailHost.querySelector('img')?.src === expectedThumbnail.dataUrl
root.unmount()
observer.disconnect()
thumbnailHost.remove()
host.remove()
return { errors, initialPages: initial.pages.length, initialObjects: initial.pages[0].objects,
beforeShrink, afterShrink, savedPages: saved.pages.length, savedObjects: saved.pages[0].objects,
reopenedObjects: reopened.pages[0].objects, visibleInkBefore, visibleInkAfter, reopenedInk, flowTransform,
editorStyles, thumbnailStyles, thumbnailMatchesEditor }
}
Object.assign(window, { canvasRegression })
@@ -0,0 +1,39 @@
import { app, BrowserWindow } from 'electron'
import { writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { registerIpcHandlers } from '../../src/main/ipc'
import { AssistantDatabase } from '../../src/main/assistant/assistant-database'
const directory = process.env.GB_NOTES_PROBE_DIRECTORY!
const url = process.env.GB_NOTES_PROBE_URL!
const preload = join(directory, 'preload.cjs')
app.setPath('userData', join(directory, 'profile'))
app.whenReady().then(async () => {
const win = new BrowserWindow({ show: false, width: 1400, height: 1000, webPreferences: {
sandbox: true, contextIsolation: true, nodeIntegration: false, backgroundThrottling: false, preload
} })
const database = new AssistantDatabase(join(directory, 'assistant.sqlite'))
database.initialize(directory)
const runtimeSettings = { workspacePath: directory, supportsImageInput: true,
defaultModelProfileId: 'vision', modelProfiles: [{ id: 'vision', supportsImageInput: true }] }
const dispose = registerIpcHandlers(win, { capability: 'text' } as never, 'CommandOrControl+Shift+Space',
{ getResolvedSettings: async () => runtimeSettings, getPublicSettings: async () => runtimeSettings } as never,
{} as never, { clear() {}, cancelImport() {} } as never, {} as never, database,
{ clear() {} } as never, {} as never, async () => {}, undefined, undefined, undefined, undefined,
{ get: async () => ({ magicNoteCommentMode: 'after-save-auto', magicNoteCommentFormat: 'structured' }), onChanged: () => () => {} } as never)
win.webContents.on('console-message', event => console.log(event.message))
try {
await win.loadURL(url)
for (let i = 0; i < 400; i++) {
if (await win.webContents.executeJavaScript('typeof window.notesAnalysisRegression === "function"')) break
await new Promise(resolve => setTimeout(resolve, 50))
}
const result = await win.webContents.executeJavaScript('window.notesAnalysisRegression().catch(error => ({ error: error.stack, body: document.body.innerText }))')
writeFileSync(join(directory, 'result.json'), JSON.stringify(result))
} finally {
await dispose()
database.close()
win.destroy()
}
app.exit(0)
}).catch(error => { console.error(error); app.exit(1) })
@@ -0,0 +1,75 @@
import Quill from 'quill'
import { createRoot } from 'react-dom/client'
import { MagicNotesWorkspace } from '../../src/renderer/src/MagicNotesWorkspace'
import type { MagicNoteCanvasContent } from '../../src/shared/magic-notes-contracts'
import i18n from '../../src/renderer/src/i18n'
import '../../src/renderer/src/styles.css'
import '../../src/renderer/src/magic-canvas.css'
async function notesAnalysisRegression() {
await i18n.changeLanguage('en-US')
const api = window.goodbuddy.magicNotes
const note = await api.create({ title: 'IPC canvas analysis' })
const content: MagicNoteCanvasContent = { version: 2, kind: 'paged-canvas', assets: [],
pages: [{ id: 'page-1', width: 794, height: 1123, background: { type: 'template', template: 'blank' }, objects: [] }],
flow: { version: 1, ops: [{ insert: 'Review task' }, { insert: '\n', attributes: { list: 'unchecked' } }] } }
const seeded = await api.createEntry({ noteId: note.id, content })
const entryId = seeded.createdEntryId
const host = document.createElement('div')
document.body.append(host)
const errors: string[] = []
const root = createRoot(host)
root.render(<MagicNotesWorkspace onNotify={notification => { if (notification.tone === 'error') errors.push(notification.message) }} />)
const wait = async (predicate: () => unknown | Promise<unknown>) => {
for (let i = 0; i < 400; i++) {
if (await predicate()) return
if (errors.length) throw new Error(errors.join('\n'))
await new Promise(resolve => setTimeout(resolve, 25))
}
throw new Error(`Timed out: ${predicate}`)
}
const button = (label: string, parent: ParentNode = host) => [...parent.querySelectorAll<HTMLButtonElement>('button')].find(node => node.textContent?.trim() === label)!
const click = async (label: string, parent: ParentNode = host) => {
await wait(() => button(label, parent) && !button(label, parent).disabled)
button(label, parent).click()
}
const entry = async () => (await api.get(note.id)).entries.find(item => item.id === entryId)!
await wait(() => host.querySelector('[id^="magic-note-select-"]'))
host.querySelector<HTMLButtonElement>('[id^="magic-note-select-"]')!.click()
await click('Analyze with AI')
await wait(async () => (await entry()).comments[0]?.inputMode === 'canvas-images')
const manualRevision = (await entry()).revision
await click('Expand canvas editor')
const editor = () => host.querySelector<HTMLElement>('.magic-note-entry__editor')!
await wait(() => editor()?.querySelector<HTMLButtonElement>('[data-tool="flow-text"]')?.disabled === false)
editor().querySelector<HTMLButtonElement>('[data-tool="flow-text"]')!.click()
await wait(() => editor().querySelector('.canvas-flow-quill'))
const quill = Quill.find(editor().querySelector('.canvas-flow-quill')!) as Quill
quill.insertText(0, 'Updated ', 'user')
await click('Save changes')
await wait(async () => (await entry()).revision >= manualRevision + 2)
const editedRevision = (await entry()).revision
await click('Cancel')
await wait(() => host.querySelector('.magic-note-composer'))
await click('Canvas', host.querySelector('.magic-note-composer')!)
const composer = () => host.querySelector<HTMLElement>('.magic-note-composer')!
await wait(() => composer().querySelector<HTMLButtonElement>('[data-tool="flow-text"]')?.disabled === false)
composer().querySelector<HTMLButtonElement>('[data-tool="flow-text"]')!.click()
await wait(() => composer().querySelector('.canvas-flow-quill'))
;(Quill.find(composer().querySelector('.canvas-flow-quill')!) as Quill).setText('Created canvas\n', 'user')
await click('Save entry', composer())
await wait(async () => (await api.get(note.id)).entries.some(item => item.id !== entryId && item.comments[0]?.inputMode === 'canvas-images'))
await click('Back to overview')
await click('Switch to to-dos')
await wait(() => host.querySelector<HTMLButtonElement>('.magic-todo-list-item__content'))
host.querySelector<HTMLButtonElement>('.magic-todo-list-item__content')!.click()
await click('Analyze with AI')
await wait(async () => (await api.listTodos()).todos[0]?.comments[0]?.inputMode === 'canvas-images')
const todo = (await api.listTodos()).todos[0]!
const result = { errors, manualRevision, editedRevision, createdAnalyzed: true, todoAnalyzed: todo.comments[0]?.inputMode }
root.unmount()
host.remove()
return result
}
Object.assign(window, { notesAnalysisRegression })
@@ -0,0 +1,26 @@
import { createRoot } from 'react-dom/client'
import { MagicNotesWorkspace } from '../../src/renderer/src/MagicNotesWorkspace'
import type { DesktopApi } from '../../src/shared/contracts'
import i18n from '../../src/renderer/src/i18n'
import '../../src/renderer/src/styles.css'
import '../../src/renderer/src/magic-canvas.css'
await i18n.changeLanguage('en-US')
const note = {
id: 'layout-note', title: 'Layout note', preview: 'Record preview', entryCount: 1,
pinned: false, revision: 1, createdAt: '2026-09-19T00:00:00Z', updatedAt: '2026-09-19T00:00:00Z',
entries: [{ id: 'layout-entry', noteId: 'layout-note', revision: 1,
createdAt: '2026-09-19T00:00:00Z', updatedAt: '2026-09-19T00:00:00Z',
content: { version: 1, ops: [{ insert: 'Record preview\n' }] }, plainText: 'Record preview', comments: [] }]
}
window.goodbuddy = {
magicNotes: {
list: async () => ({ notes: [note] }), get: async () => note, listTodos: async () => ({ todos: [] }),
onChanged: () => () => {}, onAnalysisEvent: () => () => {}
},
updates: { getSettings: async () => ({ magicNoteCommentMode: 'after-save-manual', magicNoteCommentFormat: 'combined' }) }
} as unknown as DesktopApi
const host = document.createElement('div')
host.style.height = '100vh'
document.body.append(host)
createRoot(host).render(<MagicNotesWorkspace onNotify={() => {}} />)
@@ -0,0 +1,96 @@
import Quill from 'quill'
import { createRoot } from 'react-dom/client'
import { MagicNotesWorkspace } from '../../src/renderer/src/MagicNotesWorkspace'
import type { DesktopApi } from '../../src/shared/contracts'
import type { MagicNoteDetail } from '../../src/shared/magic-notes-contracts'
import { magicNoteAnalyzeSchema } from '../../src/shared/magic-notes-contracts'
import i18n from '../../src/renderer/src/i18n'
import 'quill/dist/quill.snow.css'
import '../../src/renderer/src/styles.css'
import '../../src/renderer/src/magic-canvas.css'
async function notesNavigationRegression() {
await i18n.changeLanguage('en-US')
const note: MagicNoteDetail = {
id: '00000000-0000-4000-8000-000000000601', title: 'Navigation note', preview: '',
entryCount: 1, pinned: false, revision: 1, createdAt: '2026-08-01T00:00:00.000Z',
updatedAt: '2026-08-01T00:00:00.000Z', entries: [{
id: '00000000-0000-4000-8000-000000000602', noteId: '00000000-0000-4000-8000-000000000601',
content: { version: 1, ops: [{ insert: 'Saved entry\n' }] }, plainText: 'Saved entry',
comments: [], revision: 1, createdAt: '2026-08-01T00:00:00.000Z', updatedAt: '2026-08-01T00:00:00.000Z',
}],
}
let finishAnalysis!: (detail: MagicNoteDetail) => void
let requestLeave: ((leave: () => void) => void) | undefined
let leaves = 0
const errors: string[] = []
window.goodbuddy = {
magicNotes: {
list: async () => ({ notes: [note] }), get: async () => note,
listTodos: async () => ({ todos: [] }),
analyze: (...[entryId, options]: Parameters<DesktopApi['magicNotes']['analyze']>) => {
magicNoteAnalyzeSchema.parse({ entryId, ...options })
return new Promise(resolve => { finishAnalysis = resolve })
},
onChanged: () => () => {}, onAnalysisEvent: () => () => {},
},
updates: { getSettings: async () => ({ magicNoteCommentMode: 'after-save-manual', magicNoteCommentFormat: 'combined' }) },
} as unknown as DesktopApi
const host = document.createElement('div')
document.body.append(host)
const root = createRoot(host)
root.render(<MagicNotesWorkspace onBeforeLeave={requester => { requestLeave = requester }} onNotify={message => {
if (message.tone === 'error') errors.push(message.message)
}} />)
const wait = async (predicate: () => unknown) => {
for (let i = 0; i < 400; i++) {
if (predicate()) return
await new Promise(resolve => setTimeout(resolve, 25))
}
throw new Error('Timed out waiting for notes UI')
}
const button = (text: string) => [...host.querySelectorAll<HTMLButtonElement>('button')].find(item => item.textContent?.trim() === text)!
const frame = () => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))
await wait(() => host.querySelector('[id^="magic-note-select-"]'))
host.querySelector<HTMLButtonElement>('[id^="magic-note-select-"]')!.click()
await wait(() => button('Analyze with AI'))
button('Analyze with AI').click()
await wait(() => finishAnalysis)
const title = host.querySelector<HTMLInputElement>('.magic-note-detail-header input')!
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(title, 'Title during AI')
title.dispatchEvent(new Event('input', { bubbles: true }))
await frame()
finishAnalysis({ ...note, revision: 2 })
await frame()
const titlePreserved = title.value === 'Title during AI'
requestLeave!(() => { leaves++ })
await wait(() => host.querySelector('[role="alertdialog"]'))
button('Continue editing').click()
await frame()
const continuedTitle = title.value
// Restore the title without saving, then exercise the actual canvas flush path.
title.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
await frame()
button('Canvas').click()
await wait(() => host.querySelector<HTMLButtonElement>('[data-tool="flow-text"]')?.disabled === false)
host.querySelector<HTMLButtonElement>('[data-tool="flow-text"]')!.click()
await wait(() => host.querySelector('.canvas-flow-quill'))
await frame()
const quill = Quill.find(host.querySelector('.canvas-flow-quill')!) as Quill
quill.setText('Pending canvas flow\n', 'user')
requestLeave!(() => { leaves++ })
await wait(() => host.querySelector('[role="alertdialog"]'))
const canvasGuarded = leaves === 0
button('Continue editing').click()
await frame()
const canvasPreserved = quill.getText().includes('Pending canvas flow')
requestLeave!(() => { leaves++ })
await wait(() => host.querySelector('[role="alertdialog"]'))
button('Discard and switch').click()
await frame()
root.unmount()
host.remove()
return { errors, titlePreserved, continuedTitle, canvasGuarded, canvasPreserved, leaves }
}
Object.assign(window, { notesNavigationRegression })