// Run with: node tests/magic-canvas-wheel.electron.mjs import assert from 'node:assert/strict'; import console from 'node:console'; import { spawn } from 'node:child_process'; import { copyFile, mkdtemp, readFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import process from 'node:process'; import { clearTimeout, setTimeout } from 'node:timers'; import { fileURLToPath, URL } from 'node:url'; const root = resolve(fileURLToPath(new URL('..', import.meta.url))); const sleep = (ms) => new Promise((done) => setTimeout(done, ms)); if (!process.env.CANVAS_WHEEL_DRIVER) { console.log('Starting canvas wheel Electron regression'); const { createServer } = await import('vite'); const electron = join(root, 'node_modules/electron/dist', (await readFile(join(root, 'node_modules/electron/path.txt'), 'utf8')).trim()); const profile = await mkdtemp(join(process.env.CANVAS_WHEEL_TMP || tmpdir(), 'goodbuddy-canvas-wheel-')); const server = await createServer({ configFile: false, root, cacheDir: join(profile, 'vite'), server: { host: '127.0.0.1', port: 0, watch: null }, optimizeDeps: { noDiscovery: true, include: ['fabric', 'quill', 'jspdf'] }, plugins: [{ name: 'canvas-wheel-fixture', configureServer(server) { server.middlewares.use('/wheel.html', async (_request, response) => { response.setHeader('Content-Type', 'text/html'); response.end(await server.transformIndexHtml('/wheel.html', `
`)); }); } }] }); try { await server.listen(); console.log('Fixture ready', server.resolvedUrls.local[0]); const env = { ...process.env }; delete env.ELECTRON_RUN_AS_NODE; env.CANVAS_WHEEL_DRIVER = import.meta.url; env.CANVAS_WHEEL_PROFILE = profile; const bootstrap = join(profile, 'canvas-wheel-electron.cjs'); await copyFile(join(root, 'tests/support/canvas-wheel-electron.cjs'), bootstrap); const child = spawn(electron, [bootstrap, server.resolvedUrls.local[0] + 'wheel.html'], { env, stdio: 'inherit' }); const timeout = setTimeout(() => child.kill(), 90000); process.exitCode = await new Promise((done, reject) => { child.once('error', reject); child.once('exit', (code) => done(code ?? 1)); }); clearTimeout(timeout); console.log('Electron exit', process.exitCode); } finally { await server.close(); await rm(profile, { recursive: true, force: true }); } } else { const { app, BrowserWindow } = await import('electron'); await app.whenReady(); const win = new BrowserWindow({ width: 1000, height: 800, show: true, webPreferences: { sandbox: true, contextIsolation: true, nodeIntegration: false, backgroundThrottling: false } }); const run = (code) => win.webContents.executeJavaScript(code); win.webContents.on('console-message', (event) => { if (event.level === 'error') console.error('Renderer:', event.message); }); const failures = []; let passed = 0; async function check(name, action) { try { await action(); passed++; console.log('PASS', name); } catch (error) { failures.push(name); console.error('FAIL', name, error.message); } } async function wheel(deltaX, deltaY, modifiers = []) { const point = await run(`(() => { const r = viewport.getBoundingClientRect(); return { x: Math.round(r.x + 160), y: Math.round(r.y + 110) }; })()`); win.webContents.sendInputEvent({ type: 'mouseMove', ...point }); win.webContents.sendInputEvent({ type: 'mouseWheel', ...point, deltaX: -deltaX, deltaY: -deltaY, canScroll: true, modifiers }); await sleep(220); return run('snapshot()'); } try { await win.loadURL(process.argv[2]); for (let i = 0; i < 200 && !await run('typeof setup === "function"'); i++) await sleep(50); for (const mode of ['drawing', 'flow', 'readonly']) { await run(`setup('${mode}')`); await check(`${mode}: native deltaX forward/backward`, async () => { const right = await wheel(100, 0); assert.ok(right.x > 0 && right.y === 0, JSON.stringify(right)); assert.ok(Math.abs(right.x - 100) < 2, 'Wheel must not scroll twice'); const left = await wheel(-60, 0); assert.ok(left.x < right.x && left.y === 0, JSON.stringify(left)); assert.ok(left.wheels.every(e => e.trusted)); }); await run('viewport.scrollTo(0, 0)'); await sleep(100); await check(`${mode}: Shift+deltaY horizontal only`, async () => { const state = await wheel(0, 100, ['shift']); assert.ok(state.x > 0 && state.y === 0, JSON.stringify(state)); }); await check(`${mode}: Shift with native deltaX retains its axis`, async () => { const before = await run('snapshot()'); const state = await wheel(-50, 0, ['shift']); assert.ok(state.x < before.x && state.y === 0, JSON.stringify(state)); }); await run('viewport.scrollTo(0, 0)'); await sleep(100); await check(`${mode}: vertical and diagonal trackpad deltas`, async () => { const vertical = await wheel(0, 80); assert.ok(vertical.y > 0 && vertical.x === 0, JSON.stringify(vertical)); const diagonal = await wheel(70, 60); assert.ok(diagonal.x > 0 && diagonal.y > vertical.y, JSON.stringify(diagonal)); }); await check(`${mode}: Ctrl wheel zooms without scrolling or editing`, async () => { const before = await run('snapshot()'); const state = await wheel(0, 60, ['control']); assert.equal(state.wheels.at(-1).ctrl, true); assert.equal(state.wheels.at(-1).prevented, true); assert.equal(state.x, before.x); assert.equal(state.y, before.y); assert.ok(await run('parseFloat(document.querySelector(".canvas-note-zoom-reset").textContent) < 100')); await run('document.querySelector(".canvas-note-zoom-reset").click()'); }); await check(`${mode}: right edge chains to outer page`, async () => { await run('viewport.scrollLeft = viewport.scrollWidth'); await sleep(100); const state = await wheel(100, 0); assert.ok(state.outerX > 0, JSON.stringify(state)); assert.equal(state.wheels.at(-1).prevented, false); await run('document.querySelector("#outer").scrollTo(0, 0)'); await sleep(100); }); await check(`${mode}: bottom edge chains to outer page`, async () => { await run('viewport.scrollTop = viewport.scrollHeight'); await sleep(100); const state = await wheel(0, 100); assert.ok(state.outerY > 0, JSON.stringify(state)); }); await check(`${mode}: scrolling does not edit content`, async () => { assert.equal(await run('JSON.stringify(editor.content()) === beforeContent && changes === 0'), true); }); await run(`setup('${mode}', true)`); await check(`${mode}: no horizontal range leaves horizontal page scrolling available`, async () => { const state = await wheel(100, 0); assert.equal(state.maxX, 0); assert.ok(state.outerX > 0, JSON.stringify(state)); assert.equal(state.wheels.at(-1).prevented, false); await run('document.querySelector("#outer").scrollTo(0, 0)'); await sleep(100); }); await check(`${mode}: no scroll range leaves page scrolling available`, async () => { const state = await wheel(0, 100); assert.equal(state.maxY, 0); assert.ok(state.outerY > 0, JSON.stringify(state)); assert.equal(state.wheels.at(-1).prevented, false); }); } for (const saved of [false, true]) { for (const width of [900, 360]) { await check(`${saved ? 'saved' : 'editor'}: full current page at ${width}px`, async () => { await run(`setupLayout(${width}, ${saved})`); const portrait = await run('geometry()'); assert.ok(Math.abs(portrait.pageHeight - 1123) < 1); assert.equal(portrait.maxY, 0, JSON.stringify(portrait)); assert.equal(portrait.outerMaxX, 0, JSON.stringify(portrait)); assert.ok(portrait.outerMaxY > 0 && portrait.rootHeight > 1123); assert.ok(portrait.pageBottom <= portrait.viewBottom); if (width === 360) assert.ok(portrait.maxX > 0); await wheel(0, 100); assert.ok(await run('document.querySelector("#layout").scrollTop > 0')); await run('document.querySelector("#layout").scrollTop = 0; document.querySelector("#layout [title=下一页]").click(); editor.flush()'); const landscape = await run('geometry()'); assert.ok(Math.abs(landscape.pageHeight - 794) < 1); assert.equal(landscape.maxY, 0, JSON.stringify(landscape)); assert.equal(landscape.outerMaxX, 0, JSON.stringify(landscape)); assert.ok(landscape.rootHeight < portrait.rootHeight); assert.equal(await run('document.querySelectorAll("#layout .canvas-note-page").length'), 1); }); } } for (const scale of [0.5, 2]) { await run(`setupZoom(${scale})`); await check(`zoom ${scale}: native pen coordinates and selection transforms`, async () => { const point = async (x, y) => run(`zoomFixture.point(${x}, ${y})`); const drag = async (from, to) => { win.webContents.sendInputEvent({ type: 'mouseMove', ...from }); win.webContents.sendInputEvent({ type: 'mouseDown', ...from, button: 'left', clickCount: 1 }); for (let step = 1; step <= 5; step++) { win.webContents.sendInputEvent({ type: 'mouseMove', x: Math.round(from.x + (to.x - from.x) * step / 5), y: Math.round(from.y + (to.y - from.y) * step / 5), button: 'left' }); await sleep(20); } win.webContents.sendInputEvent({ type: 'mouseUp', ...to, button: 'left', clickCount: 1 }); await sleep(80); }; await drag(await point(50, 50), await point(100, 80)); const pen = await run('editor.flush().then(content => content.pages[0].objects.at(-1))'); assert.equal(pen.canvasKind, 'pen'); assert.ok(Math.abs(pen.path[0][1] - 50) < 2 && Math.abs(pen.path[0][2] - 50) < 2, JSON.stringify(pen)); assert.ok(Math.abs(pen.width - 50) < 2 && Math.abs(pen.height - 30) < 2, JSON.stringify(pen)); await run('zoomFixture.tool("select")'); await drag(await point(200, 130), await point(220, 150)); const moved = await run('editor.flush().then(content => content.pages[0].objects[0])'); assert.ok(Math.abs(moved.left - 180) < 2 && Math.abs(moved.top - 120) < 2, JSON.stringify(moved)); await drag(await point(261, 181), await point(281, 201)); const resized = await run('editor.flush().then(content => content.pages[0].objects[0])'); assert.ok(resized.scaleX > moved.scaleX && resized.scaleY > moved.scaleY, JSON.stringify(resized)); }); await check(`zoom ${scale}: PDF, flow, export, pagination and no dirty`, async () => { const result = await run('zoomFixture.verify()'); assert.deepEqual(result.errors, []); assert.equal(result.sameContent, true, JSON.stringify(result)); assert.equal(result.changes, 0); assert.equal(result.samePng, true, JSON.stringify(result)); assert.deepEqual(result.pngSize, [794, 1123]); assert.equal(result.pdfInk, true, JSON.stringify(result)); assert.equal(result.maxY, 0); assert.ok(Math.abs(result.pageHeight - 1123 * scale) < 1); assert.equal(result.pageCount, 2); assert.equal(result.followedPage, '2 / 2'); assert.equal(result.retainedZoom, true, JSON.stringify(result)); }); await check(`zoom ${scale}: native flow click and typing on page two`, async () => { const point = await run('zoomFixture.flowPoint()'); win.webContents.sendInputEvent({ type: 'mouseMove', ...point }); win.webContents.sendInputEvent({ type: 'mouseDown', ...point, button: 'left', clickCount: 1 }); win.webContents.sendInputEvent({ type: 'mouseUp', ...point, button: 'left', clickCount: 1 }); await win.webContents.insertText('Typed zoom '); await run('editor.flush()'); await sleep(100); assert.equal(await run('editor.content().flow.ops.some(op => typeof op.insert === "string" && op.insert.includes("Typed zoom "))'), true); assert.equal(await run('document.querySelector("#layout .canvas-note-page-counter").textContent'), '2 / 2'); }); } await check('zoom: readonly, fit resize, different page widths, narrow dark and full height', async () => { const result = await run('zoomFixture.verifyLayout()'); assert.deepEqual(result.errors, []); for (const state of result.states) { assert.equal(state.maxY, 0, JSON.stringify(state)); assert.equal(state.maxX, 0, JSON.stringify(state)); assert.equal(state.outerX, 0, JSON.stringify(state)); assert.ok(Math.abs(state.pageWidth - state.available) < 1, JSON.stringify(state)); assert.ok(Math.abs(state.pageHeight - state.logicalHeight * state.scale) < 1, JSON.stringify(state)); assert.equal(state.enabled, true); assert.equal(state.visible, true); } assert.equal(result.sameContent, true); assert.equal(result.changes, 0); win.setSize(560, 800); await sleep(220); assert.equal(await run('zoomFixture.resizeCheck()'), true, 'Fit must follow responsive padding even at an unchanged host width'); await run('editor.destroy()'); }); console.log(JSON.stringify({ electron: process.versions.electron, passed, failures })); win.destroy(); app.exit(failures.length ? 1 : 0); } catch (error) { console.error(error); win.destroy(); app.exit(1); } }