// 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 is not consumed`, 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, false); assert.equal(state.x, before.x); assert.equal(state.y, before.y); }); 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); }); } } 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); } }