feat(canvas): gate legacy editing and hand off explicit migration safely

This commit is contained in:
2026-11-11 18:05:22 +08:00
parent 1f98d8d61f
commit 24c4592060
11 changed files with 440 additions and 33 deletions
+8 -2
View File
@@ -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');