166 lines
21 KiB
JavaScript
166 lines
21 KiB
JavaScript
/** 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 });
|
||
}
|