/** Actual dashboard, GraphPage, headless renderer, browser storage and Next proxy. */ import assert from 'node:assert/strict'; import { createServer } from 'node:http'; import { mkdtemp, cp, copyFile, symlink, readFile, writeFile, rm } from 'node:fs/promises'; import { join, resolve } from 'node:path'; import { tmpdir } from 'node:os'; import { spawn } from 'node:child_process'; import { setTimeout as pause } from 'node:timers/promises'; import { createDocument, applyChanges } from '../../src/lib/canvas-core/index.ts'; const source = resolve(import.meta.dirname, '../..'), directory = await mkdtemp(join(tmpdir(), 'endoleap-renderer-')), profile = await mkdtemp(join(tmpdir(), 'endoleap-renderer-browser-')); const id = n => `10000000-0000-4000-8000-${String(n).padStart(12, '0')}`; const account = id(1), workspace = id(2), root = id(3), other = id(4), calls = [], uploads = [], documents = new Map(), versions = new Map([[root, 1], [other, 1]]), assets = new Map(); const node = (id, ownerCanvasId, x) => ({ id, ownerCanvasId, incarnation: 1, componentId: 'text', dataVersion: 1, structure: 'node', data: { title: 'Card ' + x, text: 'Editable content' }, geometry: { x, y: 150, width: 260, height: 200, rotation: 0 } }); documents.set(root, createDocument(root, [{ id: root, parent: null }], [node('a', root, 80), node('b', root, 450), { id: 'edge', incarnation: 1, componentId: 'connection', dataVersion: 1, structure: 'connection', ownerCanvasId: root, source: { elementId: 'a' }, target: { elementId: 'b' }, data: {}, path: { type: 'bezier-chain', knots: [{ id: 'k', mode: 'corner', position: { x: 400, y: 250 }, in: { x: -20, y: 0 }, out: { x: 20, y: 0 } }] } }])); documents.set(other, createDocument(other, [{ id: other, parent: null }], [])); const boards = [root, other].map((id, i) => ({ id, workspace_id: workspace, kind: 'board', version: 1, title: i ? 'Other board' : 'Headless board' })); const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aB1sAAAAASUVORK5CYII=', 'base64'); let releaseUpload; const uploadGate = new Promise(resolve => { releaseUpload = resolve; }); const model = (document, complete = true) => ({ documentFormatVersion: 1, projectId: document.projectId, complete, canvases: [...document.canvases.values()], elements: [...document.elements.values()], tombstones: Object.fromEntries(document.tombstones), versions: Object.fromEntries([...document.canvases.keys()].map(id => [id, versions.get(id)])), removedCanvasIds: [] }); const upstream = createServer(async (request, response) => { try { const url = new URL(request.url, 'http://fixture'), path = url.pathname, chunks = []; for await (const chunk of request) chunks.push(chunk); const bytes = Buffer.concat(chunks); calls.push({ method: request.method, path, bytes, headers: request.headers }); const reply = (status, body) => { response.writeHead(status, { 'Content-Type': 'application/json' }); response.end(JSON.stringify(body)); }; if (path.endsWith('/auth/logout')) return reply(204, null); if (path.endsWith('/profile')) return reply(200, { id: account, username: 'fixture', display_name: 'Creator', email: null }); if (path.endsWith('/workspaces')) return reply(200, [{ id: workspace, name: 'Fixture workspace', role: 'admin' }]); if (path.endsWith('/materials')) return reply(200, { items: boards, next_offset: null }); const board = boards.find(board => path.endsWith('/materials/' + board.id)); if (board) return reply(200, board); const read = path.match(/\/materials\/([^/]+)\/canvas\/model$/); if (read) return reply(200, { model: model(documents.get(read[1])) }); const write = path.match(/\/materials\/([^/]+)\/canvas\/model\/operations$/); if (write) { const batch = JSON.parse(bytes.toString()), previous = documents.get(write[1]); assert.equal(request.headers['x-expected-account'], account); assert.equal(request.headers['idempotency-key'], batch.request_id); assert.equal(batch.expected_versions[write[1]], versions.get(write[1])); let next = previous; for (const operation of batch.operations) next = applyChanges(next, operation.changes); documents.set(write[1], next); versions.set(write[1], versions.get(write[1]) + 1); return reply(200, { protocol_version: 2, request_id: batch.request_id, status: 'applied', operation_ids: batch.operations.map(operation => operation.id), model: model(next, false) }); } if (path.endsWith('/assets') && request.method === 'POST') { const upload = new Request('http://fixture', { method: 'POST', body: bytes, headers: { 'Content-Type': request.headers['content-type'] } }), form = await upload.formData(), file = form.get('image'); const asset = { id: request.headers['x-resource-id'], bytes: file.size, mime_type: 'image/png', width: 1, height: 1, url: `https://api.foundry.entroia.com/projects/endoleap/workspaces/${workspace}/assets/${request.headers['x-resource-id']}` }; uploads.push(asset); assets.set(asset.id, asset); await uploadGate; return reply(201, { protocol_version: 2, request_id: request.headers['idempotency-key'], status: 'applied', asset }); } if (/\/assets\/[^/]+$/.test(path)) { response.writeHead(200, { 'Content-Type': 'image/png' }); return response.end(png); } reply(404, { error: 'not_found' }); } catch (error) { response.writeHead(500, { 'Content-Type': 'application/json' }); response.end(JSON.stringify({ error: error.message })); } }); await new Promise(resolve => upstream.listen(0, '127.0.0.1', resolve)); const reserve = createServer(); await new Promise(resolve => reserve.listen(0, '127.0.0.1', resolve)); const port = reserve.address().port; await new Promise(resolve => reserve.close(resolve)); let next, chrome, socket, logs = ''; try { await cp(join(source, 'src'), join(directory, 'src'), { recursive: true }); await cp(join(source, 'public'), join(directory, 'public'), { recursive: true }); for (const file of ['package.json', 'tsconfig.json', 'postcss.config.mjs']) await copyFile(join(source, file), join(directory, file)); await symlink(join(source, 'node_modules'), join(directory, 'node_modules')); next = spawn(process.execPath, [join(source, 'node_modules/next/dist/bin/next'), 'dev', '--webpack', '--hostname', '127.0.0.1', '--port', String(port)], { cwd: directory, detached: true, env: { ...process.env, NEXT_TELEMETRY_DISABLED: '1', FOUNDRY_API_ORIGIN: `http://127.0.0.1:${upstream.address().port}`, NEXT_PUBLIC_FOUNDRY_PROJECT: 'endoleap' }, stdio: ['ignore', 'pipe', 'pipe'] }); next.stdout.on('data', chunk => { logs = (logs + chunk).slice(-16000); }); next.stderr.on('data', chunk => { logs = (logs + chunk).slice(-16000); }); for (let i = 0; i < 120; i++) { if (next.exitCode !== null) throw new Error(logs); try { const r = await fetch(`http://127.0.0.1:${port}/api/foundry/profile`, { signal: AbortSignal.timeout(1000) }); if (r.ok) break; } catch {} await pause(250); } chrome = spawn(process.env.CHROME_BIN ?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', ['--headless=new', '--no-first-run', '--disable-background-networking', '--disable-component-update', '--remote-debugging-port=0', `--user-data-dir=${profile}`, 'about:blank'], { stdio: 'ignore' }); let debugPort; for (let i = 0; i < 100; i++) { try { debugPort = Number((await readFile(join(profile, 'DevToolsActivePort'), 'utf8')).split('\n')[0]); break; } catch { await pause(100); } } assert.ok(debugPort, 'Chrome did not start'); const tabs = await fetch(`http://127.0.0.1:${debugPort}/json/list`).then(response => response.json()); socket = new WebSocket(tabs.find(tab => tab.type === 'page').webSocketDebuggerUrl); await new Promise((resolve, reject) => { socket.onopen = resolve; socket.onerror = reject; }); let serial = 0; const pending = new Map(), exceptions = []; socket.onmessage = event => { const message = JSON.parse(event.data); if(message.method==='Runtime.consoleAPICalled' && message.params.type==='error') console.log('BROWSER ERROR',message.params.args.map(arg=>arg.description??arg.value).join(' ')); if (message.method === 'Runtime.exceptionThrown') exceptions.push(message.params.exceptionDetails.exception?.description ?? message.params.exceptionDetails.text); if (!message.id) return; const p = pending.get(message.id); pending.delete(message.id); message.error ? p.reject(new Error(JSON.stringify(message.error))) : p.resolve(message.result); }; const call = (method, params = {}) => new Promise((resolve, reject) => { const id = ++serial; pending.set(id, { resolve, reject }); socket.send(JSON.stringify({ id, method, params })); }); const evaluate = async expression => { const r = await call('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true }); if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description ?? r.exceptionDetails.text); return r.result?.value; }; async function until(expression, label) { for (let i = 0; i < 200; i++) { if (exceptions.length) throw new Error(exceptions.join('\n')); if (await evaluate(expression)) return; await pause(100); } throw new Error(label + '\n' + await evaluate('document.body.innerText') + '\n' + logs); } await call('Runtime.enable'); await call('Emulation.setDeviceMetricsOverride', { width: 1280, height: 900, deviceScaleFactor: 1, mobile: false }); const route = `http://127.0.0.1:${port}/dashboard/${workspace}/${root}`; await call('Page.navigate', { url: route }); await until('!!document.querySelector("[data-headless-canvas]") && document.querySelectorAll(".react-flow__node").length === 2', 'actual headless canvas did not open'); assert.equal(calls.some(call => call.path.endsWith('/canvas')), false, 'canonical project read the legacy snapshot'); assert.equal(await evaluate('!!document.querySelector("[aria-label=画布快捷操作]") && !document.querySelector("[aria-label=展开侧边栏]")'), true); const click = label => evaluate(`document.querySelector('[aria-label=${JSON.stringify(label)}]').click()`); await click('添加文本卡片'); await until('document.querySelectorAll(".react-flow__node").length === 3', 'create did not render'); for (let i = 0; i < 100 && documents.get(root).elements.size !== 4; i++) await pause(20); assert.equal(documents.get(root).elements.size, 4); await click('撤销'); await until('document.querySelectorAll(".react-flow__node").length === 2', 'undo did not render'); for (let i = 0; i < 100 && documents.get(root).elements.size !== 3; i++) await pause(20); assert.equal(documents.get(root).elements.size, 3); await click('重做'); await until('document.querySelectorAll(".react-flow__node").length === 3', 'redo did not render'); for (let i = 0; i < 100 && documents.get(root).elements.size !== 4; i++) await pause(20); const beforeDrag = calls.filter(call => call.method === 'POST' && call.path.endsWith('/model/operations')).length; await evaluate(`window.inputEvents=[];for(const type of ['pointerdown','mousedown','pointerup','mouseup','pointercancel','lostpointercapture'])window.addEventListener(type,event=>window.inputEvents.push({type,x:event.clientX,y:event.clientY,target:event.target.outerHTML?.slice(0,120)}),true);`); const rect = await evaluate('(()=>{const r=document.querySelector(".react-flow__node[data-id=b]").getBoundingClientRect();return {x:r.x+80,y:r.y+30}})()'); await call('Input.dispatchMouseEvent', { type: 'mousePressed', x: rect.x, y: rect.y, button: 'left', buttons: 1, clickCount: 1 }); for (let step = 1; step <= 10; step++) await call('Input.dispatchMouseEvent', { type: 'mouseMoved', x: rect.x + step * 5, y: rect.y + step * 3, button: 'left', buttons: 1 }); await call('Input.dispatchMouseEvent', { type: 'mouseReleased', x: rect.x + 50, y: rect.y + 30, button: 'left', buttons: 0 }); for (let i = 0; i < 100 && documents.get(root).elements.get('b').geometry.x === 450; i++) await pause(20); assert.ok(Math.abs(documents.get(root).elements.get('b').geometry.x - 500) < 1, JSON.stringify({ geometry: documents.get(root).elements.get('b').geometry, rect, writes: calls.filter(call => call.path.endsWith('/model/operations')).map(call => JSON.parse(call.bytes.toString()).operations), inputs: await evaluate('window.inputEvents'), text: await evaluate('document.body.innerText') })); assert.equal(calls.filter(call => call.method === 'POST' && call.path.endsWith('/model/operations')).length, beforeDrag + 1, 'pointer frames became document requests'); const beforeCancel = calls.filter(call => call.method === 'POST' && call.path.endsWith('/model/operations')).length; const moved = await evaluate('(()=>{const r=document.querySelector(".react-flow__node[data-id=b]").getBoundingClientRect();return {x:r.x+80,y:r.y+30}})()'); await call('Input.dispatchMouseEvent', { type: 'mousePressed', x: moved.x, y: moved.y, button: 'left', buttons: 1, clickCount: 1 }); await call('Input.dispatchMouseEvent', { type: 'mouseMoved', x: moved.x + 35, y: moved.y + 25, button: 'left', buttons: 1 }); await call('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 }); await call('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 }); await call('Input.dispatchMouseEvent', { type: 'mouseReleased', x: moved.x + 35, y: moved.y + 25, button: 'left', buttons: 0 }); await pause(100); assert.equal(documents.get(root).elements.get('b').geometry.x, 500); assert.equal(calls.filter(call => call.method === 'POST' && call.path.endsWith('/model/operations')).length, beforeCancel); const corner = await evaluate(`(()=>{const r=document.querySelector('.react-flow__node[data-id=b] [aria-label="拖动右下角调整大小"]').getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height/2}})()`); await call('Input.dispatchMouseEvent', { type: 'mousePressed', x: corner.x, y: corner.y, button: 'left', buttons: 1, clickCount: 1 }); await call('Input.dispatchMouseEvent', { type: 'mouseMoved', x: corner.x + 20, y: corner.y + 10, button: 'left', buttons: 1 }); await call('Input.dispatchMouseEvent', { type: 'mouseReleased', x: corner.x + 40, y: corner.y + 20, button: 'left', buttons: 0 }); for (let i=0;i<100&&documents.get(root).elements.get('b').geometry.width!==300;i++)await pause(20); assert.equal(documents.get(root).elements.get('b').geometry.width,300,JSON.stringify({geometry:documents.get(root).elements.get('b').geometry,inputs:await evaluate('window.inputEvents'),text:await evaluate('document.body.innerText')}));assert.equal(documents.get(root).elements.get('b').geometry.height,220); assert.equal(calls.filter(call => call.method === 'POST' && call.path.endsWith('/model/operations')).length, beforeCancel+1); const textPoint = await evaluate(`(()=>{const r=document.querySelector('.react-flow__node[data-id=b] [aria-label^="单击选中"]').getBoundingClientRect();return {x:r.x+60,y:r.y+30}})()`); await call('Input.dispatchMouseEvent', { type: 'mousePressed', ...textPoint, button: 'left', buttons: 1, clickCount: 2 }); await call('Input.dispatchMouseEvent', { type: 'mouseReleased', ...textPoint, button: 'left', buttons: 0, clickCount: 2 }); await until(`document.activeElement===document.querySelector('.react-flow__node[data-id=b] textarea')&&!document.activeElement.readOnly`, 'text did not enter edit mode'); await call('Input.imeSetComposition', { text: '中', selectionStart: 1, selectionEnd: 1 }); await call('Input.insertText', { text: '中文' }); for(let i=0;i<100&&!documents.get(root).elements.get('b').data.text.endsWith('中文');i++)await pause(20); assert.ok(documents.get(root).elements.get('b').data.text.endsWith('中文')); await call('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 }); await call('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 }); await click('撤销'); for(let i=0;i<100&&documents.get(root).elements.get('b').data.text!=='Editable content';i++)await pause(20); assert.equal(documents.get(root).elements.get('b').data.text,'Editable content');assert.equal(documents.get(root).elements.get('b').geometry.width,300); await call('Input.dispatchMouseEvent', { type: 'mousePressed', x: 400, y: 250, button: 'left', buttons: 1, clickCount: 1 }); await call('Input.dispatchMouseEvent', { type: 'mouseReleased', x: 400, y: 250, button: 'left', buttons: 0, clickCount: 1 }); await until(`!!document.querySelector('[aria-label="曲线点 1"]')`, 'curve did not become selected'); assert.equal(await evaluate(`document.elementFromPoint(400,250)?.getAttribute('aria-label')`),'曲线点 1','curve drag handle is not hit-testable'); const beforeCurve = calls.filter(call => call.method === 'POST' && call.path.endsWith('/model/operations')).length; await call('Input.dispatchMouseEvent', { type: 'mousePressed', x: 400, y: 250, button: 'left', buttons: 1, clickCount: 1 }); await call('Input.dispatchMouseEvent', { type: 'mouseMoved', x: 420, y: 260, button: 'left', buttons: 1 }); await call('Input.dispatchMouseEvent', { type: 'mouseReleased', x: 440, y: 265, button: 'left', buttons: 0 }); for(let i=0;i<100&&documents.get(root).elements.get('edge').path.knots[0].position.x!==440;i++)await pause(20); assert.deepEqual(documents.get(root).elements.get('edge').path.knots[0].position,{x:440,y:265},JSON.stringify({inputs:await evaluate('window.inputEvents'),text:await evaluate('document.body.innerText'),writes:calls.filter(call=>call.method==='POST'&&call.path.endsWith('/model/operations')).map(call=>JSON.parse(call.bytes.toString()).operations)})); assert.equal(calls.filter(call => call.method === 'POST' && call.path.endsWith('/model/operations')).length,beforeCurve+1); console.log('PASS actual GraphPage chooses canonical editor; create/undo/redo, drag/resize/curve final coordinates, Escape and IME text history publish correctly'); await evaluate(`(()=>{const bytes=Uint8Array.from(atob('${png.toString('base64')}'),c=>c.charCodeAt(0));const transfer=new DataTransfer();transfer.items.add(new File([bytes],'pixel.png',{type:'image/png'}));const input=document.querySelector('[data-headless-canvas] input[type=file]');input.files=transfer.files;input.dispatchEvent(new Event('change',{bubbles:true}));})()`); await until(`document.querySelectorAll('.react-flow__node').length === 4 && [...document.querySelectorAll('[data-headless-canvas] img')].some(image=>image.getAttribute('src')?.startsWith('blob:'))`, 'pending image did not appear before upload'); const screenshot=await call('Page.captureScreenshot',{format:'png'});await writeFile('/private/tmp/headless-canvas-integration.png',Buffer.from(screenshot.data,'base64')); for (let i = 0; i < 100 && !uploads.length; i++) await pause(20); assert.equal(uploads.length, 1); assert.equal(documents.get(root).elements.size, 4); await evaluate(`document.querySelector('a[href="/dashboard/${workspace}/${other}"]').click()`); await until(`location.pathname.endsWith('${other}') && !!document.querySelector('[data-headless-canvas]') && document.querySelectorAll('.react-flow__node').length === 0`, 'other board did not open'); releaseUpload(); for (let i = 0; i < 200 && documents.get(root).elements.size !== 5; i++) await pause(20); assert.equal(documents.get(root).elements.size, 5, 'view detach aborted the upload or document operation'); assert.equal(await evaluate('document.querySelectorAll(".react-flow__node").length'), 0, 'late result appeared on the wrong board'); await evaluate(`document.querySelector('a[href="/dashboard/${workspace}/${root}"]').click()`); await until('document.querySelectorAll(".react-flow__node").length === 4', 'returning project lost its engine'); assert.equal(uploads.length, 1); await call('Page.reload'); await until('document.querySelectorAll(".react-flow__node").length === 4', 'page reload did not recover the document'); assert.equal(uploads.length, 1); assert.equal(calls.some(call => call.path.endsWith('/canvas')), false); console.log('PASS pending image displays before upload; navigation preserves work and fences late view updates; reload retains resource identity'); console.log('PASS canvas floating controls remain visible while the sidebar is expanded'); await click('账户和工作空间');await until(`!!document.querySelector('#workspace-account:popover-open')`,'account menu did not open');await evaluate(`[...document.querySelectorAll('button')].find(button=>button.textContent.trim()==='退出登录').click()`); await until(`location.pathname==='/login'&&!document.body.innerText.includes('This page couldn’t load')`,'logout did not safely detach the view'); console.log('PASS logout retires the runtime before dropping its view without a render error'); } finally { releaseUpload(); socket?.close(); if (chrome) { const exit = new Promise(resolve => { if (chrome.exitCode !== null) resolve(); else chrome.once('exit', resolve); }); chrome.kill(); await exit; } if (next?.pid) { const exit = new Promise(resolve => { if (next.exitCode !== null) resolve(); else next.once('exit', resolve); }); try { process.kill(-next.pid, 'SIGTERM'); } catch {} await Promise.race([exit, pause(3000)]); if (next.exitCode === null) try { process.kill(-next.pid, 'SIGKILL'); } catch {} } upstream.closeAllConnections(); await new Promise(resolve => upstream.close(resolve)); await rm(directory, { recursive: true, force: true, maxRetries: 5 }); await rm(profile, { recursive: true, force: true, maxRetries: 5 }); }