Files
Endoleap/tests/headless/renderer.integration.mjs
T

215 lines
33 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** 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: [] });
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);
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');
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 });
}