feat(canvas): gate legacy editing and hand off explicit migration safely
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {claimLegacyCanvasEditing,createBrowserCanvasMigration} from '../../src/lib/browser-canvas-migration.ts';
|
||||
import {canvasMigrationStorageKey} from '../../src/lib/canvas-migration.ts';
|
||||
import {createRegistry,builtinComponents} from '../../src/lib/canvas-core/index.ts';
|
||||
const id=n=>`10000000-0000-4000-8000-${String(n).padStart(12,'0')}`,scope={accountId:id(1),workspaceId:id(2)},root=id(3),registry=createRegistry(builtinComponents());
|
||||
function fixture(){
|
||||
const records=new Map(),held=new Map(),calls=[];
|
||||
const storage={getItem:key=>records.get(key)??null,setItem:(key,value)=>records.set(key,value),removeItem:key=>records.delete(key)};
|
||||
const locks={request(name,options,work){calls.push({name,options});return Promise.resolve().then(async()=>{
|
||||
const state=held.get(name)??{shared:0,exclusive:false},granted=options.mode==='shared'?!state.exclusive:!state.exclusive&&!state.shared;
|
||||
if(!granted)return work(null);
|
||||
if(options.mode==='shared')state.shared++;else state.exclusive=true;held.set(name,state);
|
||||
try{return await work({name,mode:options.mode});}finally{if(options.mode==='shared')state.shared--;else state.exclusive=false;if(!state.shared&&!state.exclusive)held.delete(name);}
|
||||
});}};
|
||||
const controller=new AbortController(),options={scope,registry,locks,storage,signal:controller.signal,isCurrentIdentity:()=>true};
|
||||
return{records,held,calls,controller,options};
|
||||
}
|
||||
test('editable legacy views share a lease and exclude migration until every editor releases',async()=>{
|
||||
const f=fixture(),one=await claimLegacyCanvasEditing({...f.options,canvasId:root}),two=await claimLegacyCanvasEditing({...f.options,canvasId:root});
|
||||
const migration=createBrowserCanvasMigration(f.options);
|
||||
await assert.rejects(()=>migration.resume(),/migration_busy/);await one.release();await assert.rejects(()=>migration.resume(),/migration_busy/);
|
||||
await two.release();assert.equal((await migration.resume()).status,'idle');assert.equal(f.held.size,0);
|
||||
assert.ok(f.calls.every(call=>call.name===canvasMigrationStorageKey(scope)+':editing'&&call.options.ifAvailable===true));
|
||||
assert.deepEqual(f.calls.slice(0,2).map(call=>call.options.mode),['shared','shared']);assert.ok(f.calls.slice(2).every(call=>call.options.mode==='exclusive'));
|
||||
});
|
||||
test('pending migration blocks affected old views and corrupt records cannot enable editing',async()=>{
|
||||
const f=fixture(),key=canvasMigrationStorageKey(scope),job={version:1,...scope,rootId:root,id:id(4),phase:'pending',body:JSON.stringify({protocol_version:2,request_id:id(4),expected_versions:{[root]:1}})};
|
||||
f.storage=f.options.storage;f.storage.setItem(key,JSON.stringify(job));
|
||||
await assert.rejects(()=>claimLegacyCanvasEditing({...f.options,canvasId:root}),/migration_response_unknown/);assert.equal(f.held.size,0);
|
||||
const unrelated=await claimLegacyCanvasEditing({...f.options,canvasId:id(99)});await unrelated.release();
|
||||
const saved=JSON.stringify({version:2,data:'retain'});f.storage.setItem(key,saved);
|
||||
await assert.rejects(()=>claimLegacyCanvasEditing({...f.options,canvasId:root}),/migration_interpreter_unavailable/);assert.equal(f.storage.getItem(key),saved);assert.equal(f.held.size,0);
|
||||
});
|
||||
test('aborted or stale view lifetimes release leases and cannot acquire a late editing grant',async()=>{
|
||||
const f=fixture(),lease=await claimLegacyCanvasEditing({...f.options,canvasId:root});f.controller.abort();await lease.release();assert.equal(f.held.size,0);await lease.release();
|
||||
const g=fixture(),waiting=claimLegacyCanvasEditing({...g.options,canvasId:root});g.controller.abort();await assert.rejects(waiting,{name:'AbortError'});assert.equal(g.held.size,0);
|
||||
const h=fixture();let current=true;const stale=claimLegacyCanvasEditing({...h.options,canvasId:root,isCurrentIdentity:()=>current});current=false;await assert.rejects(stale,/identity_changed/);assert.equal(h.held.size,0);
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {writeFile} from 'node:fs/promises';
|
||||
import {createDocument} from '../../src/lib/canvas-core/index.ts';
|
||||
export function createRendererMigrationFixture({id,account,workspace,boards,documents,versions,model}){
|
||||
const states=Array.from({length:5},(_,index)=>{
|
||||
const root=id(7000+index*10),element=id(7001+index*10);
|
||||
boards.push({id:root,workspace_id:workspace,kind:'board',version:1,title:'Legacy upgrade '+index});
|
||||
return{root,element,version:1,ready:false,loss:undefined,holdPatch:false,patchWait:undefined,releasePatch:undefined,receipts:new Map(),keys:new Map(),patches:0,posts:[],document:{schema_version:2,nodes:[{id:'old-card',type:'plainText',position:{x:500,y:200},width:240,height:100,data:{kind:'plain-text',title:'Old card',text:'Legacy content'}}],edges:[],viewport:{x:0,y:0,zoom:1}}};
|
||||
});
|
||||
return{states,async handle(request,path,reply,bytes){
|
||||
const state=states.find(value=>path.includes('/materials/'+value.root+'/canvas'));if(!state)return false;
|
||||
const base=`/materials/${state.root}/canvas`,raw=bytes.length?JSON.parse(bytes.toString()):undefined;
|
||||
if(path.endsWith(base+'/model')&&!state.ready){reply(409,{error:'canonical_migration_required',root_id:state.root,document_format_version:1,source_versions:{[state.root]:state.version}});return true;}
|
||||
if(path.endsWith(base)){
|
||||
if(request.method==='PATCH'){
|
||||
assert.equal(request.headers['x-expected-account'],account);assert.equal(request.headers['if-match'],`"${state.version}"`);assert.equal(state.ready,false,'retired snapshot writer sent after migration');
|
||||
state.document=raw.document;state.version++;state.patches++;
|
||||
if(state.holdPatch){state.patchWait=new Promise(resolve=>state.releasePatch=resolve);await state.patchWait;}
|
||||
reply(200,{version:state.version});
|
||||
}else reply(200,{version:state.version,document:state.document});return true;
|
||||
}
|
||||
if(path.endsWith(base+'/capabilities')){reply(200,{subgraphs:{member_positions:true}});return true;}
|
||||
if(path.endsWith(base+'/ai/runs')&&request.method==='GET'){reply(200,{runs:[]});return true;}
|
||||
if(path.includes(base+'/model/operations/')&&request.method==='GET'){
|
||||
const key=path.split('/').at(-1),receipt=state.receipts.get(key);reply(receipt?200:404,receipt??{status:'unknown',request_id:key});return true;
|
||||
}
|
||||
if(path.endsWith(base+'/model/migrate')){
|
||||
assert.equal(request.headers['x-expected-account'],account);assert.equal(request.headers['idempotency-key'],raw.request_id);
|
||||
state.posts.push({id:raw.request_id,body:bytes.toString()});
|
||||
if(state.loss==='before'){state.loss=undefined;reply(503,{error:'upstream_unavailable'});return true;}
|
||||
let receipt=state.receipts.get(raw.request_id);
|
||||
if(receipt){assert.equal(state.keys.get(raw.request_id),bytes.toString());}
|
||||
else if(raw.expected_versions[state.root]!==state.version){
|
||||
receipt={protocol_version:2,request_id:raw.request_id,status:'not-applied',terminal:true,error:'migration_version_conflict'};
|
||||
}else{
|
||||
const old=state.document.nodes[0];state.version++;state.ready=true;versions.set(state.root,state.version);
|
||||
documents.set(state.root,createDocument(state.root,[{id:state.root,parent:null}],[{id:state.element,incarnation:1,ownerCanvasId:state.root,componentId:'plain-text',dataVersion:1,structure:'node',data:{title:old.data.title,text:old.data.text},geometry:{...old.position,width:old.width,height:old.height,rotation:0}}]));
|
||||
receipt={protocol_version:2,request_id:raw.request_id,status:'applied',source_versions:raw.expected_versions,identity_map:[{sourceCanvasId:state.root,sourceId:old.id,structure:'node',elementId:state.element}],model:model(documents.get(state.root))};
|
||||
}
|
||||
state.receipts.set(raw.request_id,receipt);state.keys.set(raw.request_id,bytes.toString());
|
||||
if(state.loss==='after'){state.loss=undefined;reply(503,{error:'upstream_unavailable'});return true;}
|
||||
reply(receipt.status==='applied'?200:412,receipt);return true;
|
||||
}
|
||||
return false;
|
||||
}};
|
||||
}
|
||||
|
||||
export async function verifyRendererMigration({fixture,call,evaluate,until,port,workspace,account,pause}){
|
||||
const route=root=>`http://127.0.0.1:${port}/dashboard/${workspace}/${root}`;
|
||||
const button=label=>`[...document.querySelectorAll('[aria-label="画布升级"] button')].find(button=>button.textContent.trim()===${JSON.stringify(label)})`;
|
||||
async function click(label){const point=await evaluate(`(()=>{const button=${button(label)};if(!button||button.disabled)throw Error('missing enabled upgrade button');const r=button.getBoundingClientRect();return{x:r.x+r.width/2,y:r.y+r.height/2}})()`);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});}
|
||||
const editable=()=>`!!${button('升级画布')}&&!document.querySelector('.react-flow').parentElement.inert`;
|
||||
const storageKey=`endoleap_canvas_migration_v1:${account}:${workspace}`;
|
||||
const[a,b,c,d,e]=fixture.states;
|
||||
await until(editable(),'legacy editor did not claim an editing lease');
|
||||
const point=await evaluate(`(()=>{const r=document.querySelector('.react-flow__node[data-id="old-card"] [aria-label^="单击选中"]').getBoundingClientRect();return{x:r.x+r.width*.4,y:r.y+Math.min(25,r.height*.3)}})()`);
|
||||
assert.equal(await evaluate(`document.elementFromPoint(${point.x},${point.y})?.closest('.react-flow__node')?.getAttribute('data-id')`),'old-card','legacy edit point is covered by another surface');
|
||||
await call('Input.dispatchMouseEvent',{type:'mousePressed',...point,button:'left',buttons:1,clickCount:2});await call('Input.dispatchMouseEvent',{type:'mouseReleased',...point,button:'left',buttons:0,clickCount:2});
|
||||
await until(`document.activeElement?.tagName==='TEXTAREA'&&!document.activeElement.readOnly`,'legacy text did not enter editing');
|
||||
await call('Input.insertText',{text:' migration edit'});const edited=await evaluate('document.activeElement.value');
|
||||
a.holdPatch=true;a.loss='after';await click('升级画布');
|
||||
for(let i=0;i<100&&!a.releasePatch;i++)await pause(20);assert.ok(a.releasePatch,'upgrade did not drain its dirty legacy writer');assert.equal(a.posts.length,0,'migration crossed an unacknowledged legacy PATCH');assert.equal(a.document.nodes[0].data.text,edited);
|
||||
a.holdPatch=false;a.releasePatch();
|
||||
await until(`!!${button('继续查询升级')}`,'lost migration acknowledgement did not leave a resumable UI');
|
||||
assert.equal(a.posts.length,1);assert.equal(await evaluate(`document.querySelector('.react-flow').parentElement.inert`),true);
|
||||
const frozen=await evaluate(`JSON.parse(localStorage.getItem(${JSON.stringify(storageKey)}))`);assert.equal(frozen.body,a.posts[0].body);
|
||||
await writeFile('/private/tmp/headless-migration-unknown.png',Buffer.from((await call('Page.captureScreenshot',{format:'png'})).data,'base64'));
|
||||
await call('Page.reload');await until(`!!document.querySelector('[data-headless-canvas] .react-flow__node[data-id="${a.element}"]')&&!localStorage.getItem(${JSON.stringify(storageKey)})`,'canonical reload did not reconcile its saved migration');
|
||||
assert.equal(a.posts.length,1);assert.equal(a.patches,1);assert.equal(a.version,3);assert.equal(JSON.parse(a.posts[0].body).expected_versions[a.root],2);
|
||||
assert.equal(await evaluate(`document.querySelector('.react-flow__node[data-id="${a.element}"] textarea').value`),edited);
|
||||
console.log('PASS legacy dirty writer drains before identity migration; lost ACK reload queries once and retires snapshot writes');
|
||||
|
||||
await call('Page.navigate',{url:route(b.root)});await until(editable(),'second legacy editor did not open');
|
||||
await evaluate(`(()=>{const frame=document.createElement('iframe');frame.id='migration-peer';frame.src=location.href;frame.style.display='none';document.body.append(frame);})()`);
|
||||
await until(`(()=>{const doc=document.getElementById('migration-peer')?.contentDocument;return !!doc?.querySelector('[aria-label="画布升级"] button')&&!doc.querySelector('.react-flow')?.parentElement.inert;})()`,'another legacy view did not acquire its shared lease');
|
||||
await click('升级画布');await until(`document.querySelector('[aria-label="画布升级"]').textContent.includes('其他旧画布页面')&&!document.querySelector('.react-flow').parentElement.inert`,'another editing view did not prevent the identity handoff');assert.equal(b.posts.length,0);
|
||||
await evaluate(`document.getElementById('migration-peer').remove()`);
|
||||
await until(`navigator.locks.query().then(value=>value.held.filter(lock=>lock.name===${JSON.stringify(storageKey+':editing')}).length===1)`,'peer editing lease was not released');
|
||||
await click('升级画布');await until(`!!document.querySelector('[data-headless-canvas] .react-flow__node[data-id="${b.element}"]')`,'upgrade did not open the canonical editor after the peer closed');assert.equal(b.posts.length,1);
|
||||
console.log('PASS actual Web Locks exclude identity migration while another legacy view is editable');
|
||||
|
||||
await call('Page.navigate',{url:route(c.root)});await until(editable(),'third legacy editor did not open');c.loss='before';await click('升级画布');await until(`!!${button('继续查询升级')}`,'uncommitted request did not remain resumable');
|
||||
const original=c.posts[0];c.version++;c.document.nodes[0].data.text='Remote edit before recovery';
|
||||
await call('Page.reload');await until(`!!${button('继续查询升级')}&&document.querySelector('.react-flow').parentElement.inert`,'reload enabled old writes before resolving the frozen migration');await pause(1000);assert.equal(c.patches,0);
|
||||
await click('继续查询升级');await until(`!!${button('重新准备升级')}&&!document.querySelector('.react-flow').parentElement.inert`,'known zero-effect rejection did not release editing');
|
||||
assert.equal(c.posts.length,2);assert.deepEqual(c.posts[1],original,'recovery rebuilt a request with newer source versions');
|
||||
await click('重新准备升级');await until(`!!document.querySelector('[data-headless-canvas] .react-flow__node[data-id="${c.element}"]')`,'explicit fresh preparation did not upgrade after a version conflict');
|
||||
assert.equal(c.posts.length,3);assert.notEqual(c.posts[2].id,original.id);assert.equal(JSON.parse(c.posts[2].body).expected_versions[c.root],2);
|
||||
assert.equal(c.document.nodes[0].data.text,'Remote edit before recovery');
|
||||
assert.equal(await evaluate(`document.querySelector('.react-flow__node[data-id="${c.element}"] textarea').value`),'Remote edit before recovery');
|
||||
console.log('PASS legacy reload remains frozen until exact retry resolves; known rejection requires explicit fresh preparation');
|
||||
|
||||
await call('Page.navigate',{url:route(d.root)});await until(editable(),'draft conflict fixture did not open');
|
||||
const draft=structuredClone(d.document);draft.nodes[0].data.text='Draft from another closed editor';
|
||||
await evaluate(`localStorage.setItem(${JSON.stringify(`endoleap_canvas_v1:${workspace}:${d.root}:recovery`)},${JSON.stringify(JSON.stringify({document:draft,baseVersion:1,dirty:true}))})`);
|
||||
await click('升级画布');await until(`!!document.querySelector('dialog[open]')&&!document.querySelector('.react-flow').parentElement.inert`,'different cached draft was not surfaced before migration');
|
||||
assert.equal(d.posts.length,0);assert.equal(d.patches,0);
|
||||
const restorePoint=await evaluate(`(()=>{const button=[...document.querySelectorAll('dialog button')].find(button=>button.textContent==='恢复本地草稿');const r=button.getBoundingClientRect();return{x:r.x+r.width/2,y:r.y+r.height/2}})()`);
|
||||
await call('Input.dispatchMouseEvent',{type:'mousePressed',...restorePoint,button:'left',buttons:1,clickCount:1});await call('Input.dispatchMouseEvent',{type:'mouseReleased',...restorePoint,button:'left',buttons:0,clickCount:1});
|
||||
await until(`!document.querySelector('dialog[open]')`,'explicit draft choice did not close recovery');
|
||||
await click('升级画布');await until(`!!document.querySelector('[data-headless-canvas] .react-flow__node[data-id="${d.element}"]')`,'recovered draft did not survive migration');
|
||||
assert.equal(d.document.nodes[0].data.text,draft.nodes[0].data.text);assert.equal(d.posts.length,1);
|
||||
assert.equal(await evaluate(`document.querySelector('.react-flow__node[data-id="${d.element}"] textarea').value`),draft.nodes[0].data.text);
|
||||
console.log('PASS a different cached legacy draft blocks migration until explicit recovery and survives the handoff');
|
||||
|
||||
await call('Page.navigate',{url:route(e.root)});await until(editable(),'pending draft fixture did not open');
|
||||
e.loss='before';await click('升级画布');await until(`!!${button('继续查询升级')}`,'pending draft fixture did not retain the original upgrade');
|
||||
const retained=structuredClone(e.document);retained.nodes[0].data.text='Retained while migration is unknown';
|
||||
const recoveryKey=`endoleap_canvas_v1:${workspace}:${e.root}:recovery`;
|
||||
await evaluate(`localStorage.setItem(${JSON.stringify(recoveryKey)},${JSON.stringify(JSON.stringify({document:retained,baseVersion:e.version,dirty:true}))})`);
|
||||
e.version++;e.document.nodes[0].data.text='Remote version invalidates the pending upgrade';
|
||||
await call('Page.reload');await until(`!!${button('继续查询升级')}&&document.querySelector('.react-flow').parentElement.inert`,'pending upgrade did not keep its editing gate');
|
||||
assert.equal(await evaluate(`!!document.querySelector('dialog[open]')`),false,'draft dialog trapped the original receipt query behind a modal');
|
||||
assert.equal(await evaluate(`JSON.parse(localStorage.getItem(${JSON.stringify(recoveryKey)})).document.nodes[0].data.text`),retained.nodes[0].data.text);assert.equal(e.patches,0);
|
||||
await click('继续查询升级');await until(`!!document.querySelector('dialog[open]')&&!document.querySelector('.react-flow').parentElement.inert`,'terminal rejection did not make the retained draft recoverable');
|
||||
assert.equal(e.posts.length,2);assert.deepEqual(e.posts[1],e.posts[0]);
|
||||
const retainedPoint=await evaluate(`(()=>{const button=[...document.querySelectorAll('dialog button')].find(button=>button.textContent==='恢复本地草稿');const r=button.getBoundingClientRect();return{x:r.x+r.width/2,y:r.y+r.height/2}})()`);
|
||||
await call('Input.dispatchMouseEvent',{type:'mousePressed',...retainedPoint,button:'left',buttons:1,clickCount:1});await call('Input.dispatchMouseEvent',{type:'mouseReleased',...retainedPoint,button:'left',buttons:0,clickCount:1});
|
||||
await until(`!document.querySelector('dialog[open]')`,'retained draft choice did not close recovery');
|
||||
await click('重新准备升级');await until(`!!document.querySelector('[data-headless-canvas] .react-flow__node[data-id="${e.element}"]')`,'explicitly recovered pending draft did not reach the new editor');
|
||||
assert.equal(e.posts.length,3);assert.notEqual(e.posts[2].id,e.posts[0].id);
|
||||
assert.equal(await evaluate(`document.querySelector('.react-flow__node[data-id="${e.element}"] textarea').value`),retained.nodes[0].data.text);
|
||||
console.log('PASS a retained draft cannot trap receipt recovery behind a modal and is restored only after terminal rejection');
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import {execFileSync} from 'node:child_process';
|
||||
import {createCdpCalls} from './cdp-client.mjs';
|
||||
import {measureRenderer} from './renderer-performance.mjs';
|
||||
import {createRendererMigrationFixture,verifyRendererMigration} from './renderer-migration.mjs';
|
||||
/** Actual dashboard, GraphPage, headless renderer, browser storage and Next proxy. */
|
||||
import assert from 'node:assert/strict';
|
||||
import { createServer } from 'node:http';
|
||||
@@ -12,7 +13,9 @@ import { setTimeout as pause } from 'node:timers/promises';
|
||||
import { createDocument, applyChanges } from '../../src/lib/canvas-core/index.ts';
|
||||
const bundler=process.argv.includes('--turbopack')?'turbopack':'webpack';
|
||||
const productionMode=process.argv.includes('--production');if(productionMode&&!process.argv.includes('--performance'))throw Error('--production requires --performance');
|
||||
const migrationOnly=process.argv.includes('--migration-only');
|
||||
const hooksOnly=process.argv.includes('--hooks-only'),performanceMode=process.argv.includes('--performance'),performanceCount=Number(process.env.HEADLESS_PERFORMANCE_COUNT??1000);
|
||||
if(migrationOnly&&(hooksOnly||performanceMode))throw Error('migration-only requires its own functional entry');
|
||||
if(performanceMode&&(!Number.isSafeInteger(performanceCount)||performanceCount<1||performanceCount>10000))throw Error('invalid performance count');
|
||||
if(process.env.HEADLESS_PERFORMANCE_EDIT_DURING_COPY==='1'&&(!performanceMode||performanceCount<1000))throw Error('concurrent input requires --performance and at least 1000 nodes');
|
||||
const baseline=process.env.HEADLESS_PERFORMANCE_BASELINE;if(baseline&&!/^[a-f0-9]{7,40}$/.test(baseline))throw Error('invalid diagnostic baseline');
|
||||
@@ -35,12 +38,13 @@ const boards = [root, other].map((id, i) => ({ id, workspace_id: workspace, kind
|
||||
const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aB1sAAAAASUVORK5CYII=', 'base64');
|
||||
let scopeStreamRoot=null,releaseScopeStream;const scopeStreamGate=new Promise(resolve=>releaseScopeStream=resolve);
|
||||
let brokenStreamRoot=null,abandonedRoot=null,releaseAbandoned;const abandonedGate=new Promise(resolve=>releaseAbandoned=resolve);
|
||||
let releaseFirstModel;const firstModelGate=new Promise(resolve=>{releaseFirstModel=resolve;});let gatedFirstModel=!performanceMode;
|
||||
let releaseFirstModel;const firstModelGate=new Promise(resolve=>{releaseFirstModel=resolve;});let gatedFirstModel=!performanceMode&&!migrationOnly;
|
||||
let releaseImport;const importGate=new Promise(resolve=>{releaseImport=resolve;});let importWriteSeen=false;const editReceipts=new Map();
|
||||
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,denyAssetMetadata=false,metadataWait,releaseMetadata;
|
||||
const migrationFixture=migrationOnly?createRendererMigrationFixture({id,account,workspace,boards,documents,versions,model}):undefined;
|
||||
const upstream = createServer(async (request, response) => {
|
||||
try {
|
||||
const url = new URL(request.url, 'http://fixture'), path = url.pathname, chunks = [];
|
||||
@@ -52,6 +56,7 @@ const upstream = createServer(async (request, response) => {
|
||||
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);
|
||||
if(await migrationFixture?.handle(request,path,reply,bytes))return;
|
||||
const read = path.match(/\/materials\/([^/]+)\/canvas\/model$/);
|
||||
if(read&&request.headers.accept==='application/vnd.foundry.canvas-model+ndjson;version=1'){
|
||||
const value=model(documents.get(read[1])),token='fixture-snapshot-'+calls.length;let sequence=0;
|
||||
@@ -179,10 +184,11 @@ module.exports=function(source){return nativeTransform.call(this,source).replace
|
||||
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' + JSON.stringify(await evaluate(`[...window.__fixtureActualEngine?.imports.getJobs().values()??[]].map(job=>({id:job.id,status:job.status,error:job.error,sourceError:job.source?.integrityError}))`)) + '\n' + await evaluate('document.body.innerText') + '\n' + logs); }
|
||||
await call('Runtime.enable');await call('Page.enable');await call('Target.setDiscoverTargets',{discover:true}); await call('Emulation.setDeviceMetricsOverride', { width: 1280, height: 900, deviceScaleFactor: 1, mobile: false });
|
||||
const route = `http://127.0.0.1:${port}/dashboard/${workspace}/${root}`;
|
||||
const route = `http://127.0.0.1:${port}/dashboard/${workspace}/${migrationFixture?.states[0].root??root}`;
|
||||
await call('Page.navigate', { url: route });
|
||||
if(performanceMode){
|
||||
await measureRenderer({call,evaluate,documents,calls,root,partition,count:performanceCount,exceptions,browserLogs:()=>browserLogs,baseline,buildMode:productionMode?'production':'development',bundler,buildMs,reportPath:process.env.HEADLESS_PERFORMANCE_REPORT??join(tmpdir(),'headless-renderer-performance.json')});
|
||||
}else if(migrationOnly){await verifyRendererMigration({fixture:migrationFixture,call,evaluate,until,port,workspace,account,pause});
|
||||
}else if(hooksOnly){await until('typeof window.__runCanvasHookChecks==="function"','hook fixture did not mount');const reports=await evaluate('window.__runCanvasHookChecks()');for(const report of reports)console.log('PASS React hooks: '+report);
|
||||
}else{
|
||||
await until('!!document.querySelector("[data-headless-canvas][data-model-loading] .react-flow__node[data-id=a]")','first editable model prefix did not open before the gated tail');
|
||||
|
||||
Reference in New Issue
Block a user