236 lines
38 KiB
JavaScript
236 lines
38 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), partition = id(5), calls = [], uploads = [], documents = new Map(), versions = new Map([[root, 1], [other, 1]]), assets = new Map(), aiRuns = 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 releaseApply;const applyGate=new Promise(resolve=>{releaseApply=resolve;});
|
||
let releaseUpload; const uploadGate = new Promise(resolve => { releaseUpload = resolve; });
|
||
const model = (document, complete = true, participants=[...document.canvases.keys()]) => ({ documentFormatVersion: 1, projectId: document.projectId, complete, canvases: [...document.canvases.values()].filter(canvas=>participants.includes(canvas.id)), elements: [...document.elements.values()].filter(element=>participants.includes(element.ownerCanvasId)), tombstones: Object.fromEntries(document.tombstones), versions: Object.fromEntries(participants.map(id => [id, versions.get(id)])), removedCanvasIds: [] });
|
||
let rejectNextEdit=false,rejectedEdit;
|
||
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);
|
||
if(rejectNextEdit){rejectNextEdit=false;rejectedEdit=batch;const before=previous.elements.get('b');documents.set(write[1],applyChanges(previous,[{kind:'field',target:{id:'b',incarnation:1},field:'data.text',before:before.data.text,after:'Remote reviewed',beforePresent:true,afterPresent:true}]));versions.set(root,versions.get(root)+1);return reply(409,{protocol_version:2,request_id:batch.request_id,status:'not-applied',terminal:true,error:'version_conflict'});}
|
||
for(const [id,version] of Object.entries(batch.expected_versions))assert.equal(version,versions.get(id));
|
||
let next = previous; for (const operation of batch.operations) next = applyChanges(next, operation.changes);
|
||
documents.set(write[1], next);for(const id of Object.keys(batch.expected_versions))versions.set(id,versions.get(id)+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,Object.keys(batch.expected_versions)) });
|
||
}
|
||
if(path.endsWith('/canvas/ai/models'))return reply(200,{models:[{id:'fixture-text',label:'Fixture text',available:true,input_modalities:['text'],output_modalities:['text']}]});
|
||
if(path.endsWith('/canvas/ai/runs')&&request.method==='POST'){
|
||
const input=JSON.parse(bytes.toString()),owner=documents.get(root).elements.get(input.node_id).ownerCanvasId;assert.equal(request.headers['x-expected-account'],account);assert.equal(request.headers['idempotency-key'],input.request_id);assert.equal(request.headers['if-match'],'"'+versions.get(owner)+'"');assert.deepEqual(input.expected_versions,Object.fromEntries([...documents.get(root).canvases.keys()].map(id=>[id,versions.get(id)])));
|
||
const previous=documents.get(root),element=previous.elements.get(input.node_id),next=applyChanges(previous,[{kind:'field',target:{id:element.id,incarnation:element.incarnation},field:'data.ai',before:element.data.ai??null,beforePresent:element.data.ai!==undefined,after:{enabled:true,modelId:input.model_id,prompt:input.prompt,parameters:input.parameters,propagate:input.propagate},afterPresent:true}]);documents.set(root,next);versions.set(owner,versions.get(owner)+1);
|
||
const runId=id(700+aiRuns.size*10),plan={steps:[{step_id:'step-b',board_id:owner,node_id:input.node_id,canonical_root:root,target_incarnation:input.target_incarnation,kind:'text',model_id:input.model_id,status:'queued',dependencies:[],static_inputs:[]}],edges:[],frozen_versions:Object.fromEntries([...next.canvases.keys()].map(id=>[id,versions.get(id)]))};
|
||
const admitted={protocol_version:2,request_id:input.request_id,operation_id:input.operation_id,admission:'accepted',status:'queued',run_id:runId,plan,canvas_version:versions.get(owner),document_format_version:1,target_incarnation:input.target_incarnation,model:model(next,false,[owner])};
|
||
aiRuns.set(runId,{status:'queued',plan,admitted,events:[{seq:1,run_id:runId,type:'run.queued',status:'queued'},{seq:2,run_id:runId,type:'step.queued',step_id:'step-b',board_id:owner,node_id:input.node_id,status:'queued'}]});return reply(202,admitted);
|
||
}
|
||
const aiAction=path.match(/\/canvas\/ai\/runs\/([^/]+)\/(cancel|apply)$/);
|
||
if(aiAction){
|
||
const run=aiRuns.get(aiAction[1]),input=JSON.parse(bytes.toString()),base={protocol_version:2,request_id:input.request_id,operation_id:input.operation_id,action:'run.'+aiAction[2],status:'applied',run_id:aiAction[1]};
|
||
assert.equal(request.headers['idempotency-key'],input.request_id);assert.equal(request.headers['x-expected-account'],account);
|
||
if(aiAction[2]==='cancel'){run.status='cancelled';run.events.push({seq:run.events.length+1,run_id:aiAction[1],type:'run.completed',status:'cancelled'});return reply(200,{...base,run_status:'cancelled'});}
|
||
const owner=documents.get(root).elements.get(input.node_id).ownerCanvasId;assert.equal(input.target_canvas_id,owner);assert.equal(input.target_incarnation,1);assert.equal(request.headers['if-match'],'"'+versions.get(owner)+'"');
|
||
const output=run.events.find(event=>event.type==='step.output'&&event.output.output_id===input.output_id).output,previous=documents.get(root),element=previous.elements.get(input.node_id),next=applyChanges(previous,[{kind:'field',target:{id:element.id,incarnation:element.incarnation},field:'data.text',before:element.data.text,after:output.text,beforePresent:true,afterPresent:true}]);documents.set(root,next);versions.set(owner,versions.get(owner)+1);
|
||
const applied={...base,step_id:'step-b',board_id:owner,origin_board_id:run.plan.steps[0].board_id,canonical_root:root,node_id:input.node_id,output_id:input.output_id,document_format_version:1,target_incarnation:1,canvas_version:versions.get(owner),model:model(next,false,[owner])};run.events.push({...applied,seq:run.events.length+1,type:'step.applied'});await applyGate;return reply(200,applied);
|
||
}
|
||
const aiPoll=path.match(/\/canvas\/ai\/runs\/([^/]+)$/);
|
||
if(aiPoll){const run=aiRuns.get(aiPoll[1]),after=Number(url.searchParams.get('after_seq')||0),events=run.events.filter(event=>event.seq>after);return reply(200,{run_id:aiPoll[1],status:run.status,plan:run.plan,steps:[],events,next_seq:events.at(-1)?.seq??after,has_more:false,outputs:[]});}
|
||
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 call('Input.dispatchMouseEvent',{type:'mousePressed',x:1050,y:650,button:'left',buttons:1,clickCount:1});await call('Input.dispatchMouseEvent',{type:'mouseReleased',x:1050,y:650,button:'left',buttons:0,clickCount:1});await pause(50);
|
||
const recoveryPoint=await evaluate(`(()=>{const r=document.querySelector('.react-flow__node[data-id=b] [aria-label^="单击选中"]').getBoundingClientRect();return {x:r.x+50,y:r.y+30}})()`);
|
||
await call('Input.dispatchMouseEvent',{type:'mousePressed',...recoveryPoint,button:'left',buttons:1,clickCount:2});await call('Input.dispatchMouseEvent',{type:'mouseReleased',...recoveryPoint,button:'left',buttons:0,clickCount:2});
|
||
await until(`document.activeElement===document.querySelector('.react-flow__node[data-id=b] textarea')&&!document.activeElement.readOnly`,'recovery edit did not open');
|
||
rejectNextEdit=true;await call('Input.insertText',{text:'Local recovery'});await until(`!!document.querySelector('[aria-label="处理同步修改"]')`,'rejected content did not expose recovery');
|
||
const localRecoveryText=await evaluate(`document.querySelector('.react-flow__node[data-id=b] textarea').value`),rejectedId=rejectedEdit.request_id;
|
||
await click('处理同步修改');await until(`!!document.querySelector('select[aria-label="处理第1项修改"]')`,'recovery choices did not load');
|
||
assert.ok(await evaluate(`document.querySelector('[role=dialog]').innerText.includes('Remote reviewed')`),'review omitted the server value');
|
||
await evaluate(`(()=>{const select=document.querySelector('select[aria-label="处理第1项修改"]');select.value='keep-local';select.dispatchEvent(new Event('change',{bubbles:true}));})()`);
|
||
await click('预览处理方案');await until(`!document.querySelector('[aria-label="应用处理方案"]').disabled`,'reviewed recovery plan did not become applicable');await click('应用处理方案');
|
||
await until(`!document.querySelector('[role=dialog]')`,'recovery dialog did not close after persistence');
|
||
for(let i=0;i<100&&documents.get(root).elements.get('b').data.text!==localRecoveryText;i++)await pause(20);assert.equal(documents.get(root).elements.get('b').data.text,localRecoveryText);
|
||
const recompiled=calls.filter(call=>call.method==='POST'&&call.path.endsWith('/model/operations')).at(-1),recompiledBody=JSON.parse(recompiled.bytes.toString());assert.notEqual(recompiledBody.request_id,rejectedId);assert.notEqual(recompiledBody.operations[0].id,rejectedEdit.operations[0].id);assert.equal(recompiledBody.operations[0].changes[0].before,'Remote reviewed');
|
||
await click('撤销');for(let i=0;i<100&&documents.get(root).elements.get('b').data.text!=='Remote reviewed';i++)await pause(20);assert.equal(documents.get(root).elements.get('b').data.text,'Remote reviewed');
|
||
console.log('PASS actual recovery dialog reviews remote/local content, recompiles with new identities, saves before dispatch and preserves the reviewed undo baseline');
|
||
rejectNextEdit=true;await click('添加文本卡片');await until(`!!document.querySelector('[aria-label="处理同步修改"]')`,'rejected creation did not expose recovery');const createdId=rejectedEdit.operations[0].changes.find(change=>change.kind==='element'&&change.before===null).id;
|
||
await click('处理同步修改');await until(`!!document.querySelector('select[aria-label="处理第1项修改"]')&&document.querySelector('[role=dialog]').innerText.includes('创建元素')`,'creation review omitted the structural change');
|
||
await evaluate(`(()=>{const select=document.querySelector('select[aria-label="处理第1项修改"]');select.value='keep-local';select.dispatchEvent(new Event('change',{bubbles:true}));})()`);await click('预览处理方案');await until(`document.querySelector('[role=dialog]').innerText.includes('关联结构变更')&&!document.querySelector('[aria-label="应用处理方案"]').disabled`,'structural plan did not expose related changes');await click('应用处理方案');await until(`!document.querySelector('[role=dialog]')`,'structural review did not finish');for(let i=0;i<100&&!documents.get(root).elements.has(createdId);i++)await pause(20);assert.ok(documents.get(root).elements.has(createdId));await click('撤销');for(let i=0;i<100&&documents.get(root).elements.has(createdId);i++)await pause(20);assert.equal(documents.get(root).elements.has(createdId),false);
|
||
console.log('PASS actual structural recovery dialog reviews a rejected creation and related changes, applies it with a new identity and retains undo');
|
||
const selectB=async()=>{await call('Input.dispatchMouseEvent',{type:'mousePressed',x:1050,y:650,button:'left',buttons:1,clickCount:1});await call('Input.dispatchMouseEvent',{type:'mouseReleased',x:1050,y:650,button:'left',buttons:0,clickCount:1});await pause(50);const point=await evaluate(`(()=>{const r=document.querySelector('.react-flow__node[data-id=b]').getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height-30}})()`);await call('Input.dispatchMouseEvent',{type:'mousePressed',...point,button:'left',buttons:1,clickCount:1});await call('Input.dispatchMouseEvent',{type:'mouseReleased',...point,button:'left',buttons:0,clickCount:1});await until(`!document.querySelector('[aria-label="AI 创作"]').disabled`,'AI tool did not accept the selected element');};
|
||
await selectB();await click('AI 创作');await until(`!!document.querySelector('[aria-label="AI 模型"] option[value="fixture-text"]')`,'AI catalog did not load through the host');
|
||
await evaluate(`(()=>{const input=document.querySelector('[aria-label="AI 指令"]');Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype,'value').set.call(input,'Summarize this content');input.dispatchEvent(new Event('input',{bubbles:true}));})()`);
|
||
await until(`!document.querySelector('[aria-label="执行 AI"]').disabled`,'AI command did not become executable');await click('执行 AI');
|
||
await until(`document.body.innerText.includes('已受理')`,'AI admission did not persist');assert.equal(aiRuns.size,1);
|
||
await evaluate(`document.querySelector('a[href="/dashboard/${workspace}/${other}"]').click()`);await until(`location.pathname.endsWith('${other}')&&document.querySelectorAll('.react-flow__node').length===0`,'AI view detach failed');
|
||
const run=aiRuns.get(id(700)),beforeAI=documents.get(root),beforeNode=beforeAI.elements.get('b');
|
||
const frame={...node('relocation-frame',root,30),componentId:'frame',geometry:{x:30,y:50,width:1000,height:600,rotation:0},data:{title:'Relocated partition',text:''}};
|
||
const movedModel=applyChanges(beforeAI,[{kind:'element',id:frame.id,before:null,after:frame},{kind:'canvas',id:partition,before:null,after:{id:partition,parent:{canvasId:root,entryElementId:frame.id}}},{kind:'field',target:{id:'b',incarnation:1},field:'ownerCanvasId',before:root,after:partition},{kind:'field',target:{id:'b',incarnation:1},field:'geometry',before:beforeNode.geometry,after:{...beforeNode.geometry,x:beforeNode.geometry.x-54,y:beforeNode.geometry.y-86}}]);
|
||
const generated=applyChanges(movedModel,[{kind:'field',target:{id:'b',incarnation:1},field:'data.text',before:beforeNode.data.text,after:'Generated while another canvas was open',beforePresent:true,afterPresent:true}]);documents.set(root,generated);versions.set(root,versions.get(root)+1);versions.set(partition,2);run.status='succeeded';
|
||
run.events.push({seq:3,run_id:id(700),type:'step.output',step_id:'step-b',board_id:root,node_id:'b',output:{output_id:id(701),kind:'text',text:'Generated while another canvas was open'}},{seq:4,run_id:id(700),type:'step.applied',step_id:'step-b',board_id:partition,origin_board_id:root,canonical_root:root,node_id:'b',canvas_version:versions.get(partition),document_format_version:1,target_incarnation:1,model:model(generated,true)},{seq:5,run_id:id(700),type:'run.completed',status:'succeeded'});
|
||
const pollsBefore=calls.filter(call=>call.path.includes('/ai/runs/')).length;
|
||
for(let i=0;i<200&&calls.filter(call=>call.path.includes('/ai/runs/')).length===pollsBefore;i++)await pause(20);
|
||
assert.ok(calls.filter(call=>call.path.includes('/ai/runs/')).length>pollsBefore,'view detach stopped persistent AI polling');await pause(200);
|
||
assert.equal(await evaluate('document.querySelectorAll(".react-flow__node").length'),0,'AI output leaked into a different project view');
|
||
await call('Page.reload');await until(`location.pathname.endsWith('${other}')&&!!document.querySelector('[data-headless-canvas]')`,'other project reload failed');
|
||
await evaluate(`document.querySelector('a[href="/dashboard/${workspace}/${root}"]').click()`);await until(`!!document.querySelector('.react-flow__node[data-id=b]')&&document.querySelector('.react-flow__node[data-id=b] textarea')?.value==='Generated while another canvas was open'`,'AI output did not survive reload');
|
||
await selectB();await click('AI 创作');await until(`document.body.innerText.includes('已更新当前元素')`,'durable RunRecord did not recover');await click('关闭 AI 工具');
|
||
assert.equal(calls.filter(call=>call.method==='POST'&&call.path.endsWith('/ai/runs')).length,1,'AI recovery invoked generation twice');assert.equal(documents.get(root).elements.get('b').geometry.width,300);assert.equal(documents.get(root).elements.get('b').ownerCanvasId,partition);assert.equal(await evaluate(`document.querySelectorAll('.react-flow__node[data-id=b]').length`),1);
|
||
console.log('PASS actual AI panel follows a remote owner move into a new partition, polls after navigation, and restores one element/RunRecord after reload without re-execution');
|
||
await selectB();await click('AI 创作');await until(`!!document.querySelector('[aria-label="执行 AI"]')&&!document.querySelector('[aria-label="执行 AI"]').disabled`,'AI action did not reopen');await click('执行 AI');await until(`!!document.querySelector('[aria-label="取消生成"]')`,'cancel control did not appear');await click('取消生成');await until(`document.body.innerText.includes('已取消生成')`,'durable cancellation did not finish');assert.equal(documents.get(root).elements.get('b').data.text,'Generated while another canvas was open');
|
||
await until(`!document.querySelector('[aria-label="执行 AI"]').disabled`,'cancelled run did not release execution');await click('执行 AI');
|
||
for(let i=0;i<100&&!aiRuns.has(id(720));i++)await pause(20);assert.ok(aiRuns.has(id(720)));
|
||
const conflicted=aiRuns.get(id(720));conflicted.status='conflict';conflicted.events.push({seq:3,run_id:id(720),type:'step.output',step_id:'step-b',board_id:partition,node_id:'b',output:{output_id:id(721),kind:'text',text:'Chosen conflict result'}},{seq:4,run_id:id(720),type:'step.conflict',step_id:'step-b',board_id:partition,node_id:'b',status:'conflict',error:'target_changed'},{seq:5,run_id:id(720),type:'run.completed',status:'conflict'});
|
||
await until(`[...document.querySelectorAll('button')].some(button=>button.textContent==='使用此结果替换当前内容')`,'conflict result did not expose explicit application');await evaluate(`[...document.querySelectorAll('button')].find(button=>button.textContent==='使用此结果替换当前内容').click()`);
|
||
await until(`document.querySelector('.react-flow__node[data-id=b] textarea').value==='Chosen conflict result'`,'chosen output was not optimistic');await click('撤销');await until(`document.querySelector('.react-flow__node[data-id=b] textarea').value==='Generated while another canvas was open'`,'unknown apply could not be locally undone');releaseApply();
|
||
for(let i=0;i<200&&documents.get(root).elements.get('b').data.text!=='Generated while another canvas was open';i++)await pause(20);assert.equal(documents.get(root).elements.get('b').data.text,'Generated while another canvas was open');await click('重做');
|
||
for(let i=0;i<200&&documents.get(root).elements.get('b').data.text!=='Chosen conflict result';i++)await pause(20);assert.equal(documents.get(root).elements.get('b').data.text,'Chosen conflict result');assert.equal(calls.filter(call=>call.method==='POST'&&call.path.endsWith('/cancel')).length,1);assert.equal(calls.filter(call=>call.method==='POST'&&call.path.endsWith('/apply')).length,1);assert.equal(calls.filter(call=>call.method==='POST'&&call.path.endsWith('/ai/runs')).length,3);await click('关闭 AI 工具');
|
||
console.log('PASS actual AI cancellation and optimistic conflict application use durable actions; undo/redo while awaiting acknowledgment never regenerate');
|
||
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 === 5 && [...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, 5);
|
||
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 !== 6; i++) await pause(20);
|
||
assert.equal(documents.get(root).elements.size, 6, '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 === 5', 'returning project lost its engine');
|
||
assert.equal(uploads.length, 1);
|
||
await call('Page.reload'); await until('document.querySelectorAll(".react-flow__node").length === 5', '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 {
|
||
releaseApply();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 });
|
||
}
|