From 361e636ce7451108279c95945e36810ca41b569f Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Sun, 8 Nov 2026 18:45:10 +0800 Subject: [PATCH] Preserve opaque canvas elements with safe renderer fallbacks --- src/components/canvas/canvas-edge.tsx | 7 +++- .../canvas/headless-graph-canvas.tsx | 6 ++- .../canvas/unsupported-elements.tsx | 21 ++++++++++ src/lib/canvas-bindings/projection.ts | 40 ++++++++++++++----- tests/headless/bindings.test.mjs | 15 +++++++ tests/headless/renderer.integration.mjs | 12 ++++++ 6 files changed, 88 insertions(+), 13 deletions(-) create mode 100644 src/components/canvas/unsupported-elements.tsx diff --git a/src/components/canvas/canvas-edge.tsx b/src/components/canvas/canvas-edge.tsx index 5f9357f..844d395 100644 --- a/src/components/canvas/canvas-edge.tsx +++ b/src/components/canvas/canvas-edge.tsx @@ -3,6 +3,7 @@ import { useContext, useEffect, useRef, useState, type PointerEvent } from "react"; import { BaseEdge, useInternalNode, useReactFlow, useViewport, type ConnectionLineComponentProps, type EdgeProps, type InternalNode } from "@xyflow/react"; import { useReducedMotion } from "motion/react"; +import type { ProjectionNode } from "@/lib/canvas-bindings/projection"; import type { CanvasNode } from "@/lib/canvas"; import { connectorBezier, nodeConnector, type ConnectorSide, type Connector } from "@/lib/canvas-geometry"; import { curvePath, curveSegments, cubicPoint, insertCurveKnot, minus, plus } from "@/lib/canvas-curves"; @@ -13,8 +14,10 @@ import { EdgeToolbarControls } from "./edge-toolbar"; import styles from "./canvas.module.css"; function connector(node: InternalNode, handle: string | null | undefined, fallback: ConnectorSide): Connector { - const port = node.data.ports?.find((port) => `port:${port.id}` === handle); - const side = handle === "top" || handle === "right" || handle === "bottom" || handle === "left" ? handle : port?.direction === "input" ? "left" : port ? "right" : fallback; + const port = Array.isArray(node.data.ports)?node.data.ports.find((port) => port && `port:${port.id}` === handle):undefined; + const hints=(node.data as ProjectionNode['data']).__headlessFallback?.handles; + const projectedSide=Array.isArray(hints)?hints.find(item=>item&&item.id===handle&&['top','right','bottom','left'].includes(item.side))?.side:undefined; + const side = projectedSide ?? (handle === "top" || handle === "right" || handle === "bottom" || handle === "left" ? handle : port?.direction === "input" ? "left" : port ? "right" : fallback); const result = nodeConnector({ ...node, position: node.internals.positionAbsolute }, side); if (port) { const peers = node.data.ports!.filter((item) => item.direction === port.direction); diff --git a/src/components/canvas/headless-graph-canvas.tsx b/src/components/canvas/headless-graph-canvas.tsx index 228a651..8629f40 100644 --- a/src/components/canvas/headless-graph-canvas.tsx +++ b/src/components/canvas/headless-graph-canvas.tsx @@ -20,6 +20,7 @@ import { EdgeEditing } from "./edge-editing"; import { HeadlessAIPanel } from "./headless-ai-panel"; import { HeadlessImports } from "./headless-imports"; import { HeadlessRecoveryDialog } from "./headless-recovery-dialog"; +import { UnsupportedNode, UnsupportedEdge } from "./unsupported-elements"; import { CanvasEdge } from "./canvas-edge"; import { MaterialNode } from "./material-node"; import { PlainTextNode } from "./plain-text-node"; @@ -31,9 +32,9 @@ import styles from "./canvas.module.css"; function HeadlessFrameNode(props: NodeProps) { return ; } const nodeTypes = { material: MaterialNode, plainText: PlainTextNode, plainImage: PlainImageNode, frame: HeadlessFrameNode, icon: IconNode, - unsupported: ({ data }: NodeProps) =>
{data.title}此元素需要更新版本才能编辑
, + unsupported: UnsupportedNode, }; -const edgeTypes = { default: CanvasEdge }; +const edgeTypes = { default: CanvasEdge, unsupported: UnsupportedEdge }; const json = (value: unknown): Data => JSON.parse(JSON.stringify(value)); function CanvasView({ engine, board, viewId }: { engine: ProjectEngine; board: Board; viewId: Id }) { const subscribe = useCallback((notify: () => void) => engine.editor.subscribe(notify), [engine]); @@ -103,6 +104,7 @@ function CanvasView({ engine, board, viewId }: { engine: ProjectEngine; board: B function connect(connection: Connection) { const document = engine.getSnapshot().document, source = document.elements.get(connection.source), destination = document.elements.get(connection.target); if (!source || !destination) return; + if([source.id,destination.id].some(id=>projectionCache.current!.getNode(id)?.type==='unsupported')){setError('此元素暂不支持创建新连线');return;} report(engine.editor.dispatch({ type: 'element.create', element: { id: crypto.randomUUID(), incarnation: 1, componentId: 'connection', dataVersion: 1, structure: 'connection', ownerCanvasId: commonCanvas(document, source.ownerCanvasId, destination.ownerCanvasId), source: { elementId: source.id, ...(connection.sourceHandle ? { portId: connection.sourceHandle } : {}) }, target: { elementId: destination.id, ...(connection.targetHandle ? { portId: connection.targetHandle } : {}) }, data: {}, path: null } })); } const resourceKey = JSON.stringify(projection.nodes.filter(node => node.data.assetId).map(node => [node.id, node.data.assetId])); diff --git a/src/components/canvas/unsupported-elements.tsx b/src/components/canvas/unsupported-elements.tsx new file mode 100644 index 0000000..f56aec9 --- /dev/null +++ b/src/components/canvas/unsupported-elements.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { BaseEdge, Handle, Position, getBezierPath, type EdgeProps, type NodeProps } from "@xyflow/react"; +import type { ProjectionNode } from "@/lib/canvas-bindings/projection"; +import type { CanvasEdgeModel } from "@/lib/canvas-v2"; +import styles from "./canvas.module.css"; +const positions={top:Position.Top,right:Position.Right,bottom:Position.Bottom,left:Position.Left}; + +/** Render only the known envelope and derived endpoints, never interpret opaque payload fields. */ +export function UnsupportedNode({data,selected}:NodeProps){ + const fallback=data.__headlessFallback; + return
+ {data.title}此元素需要兼容的组件版本才能编辑 + {fallback?.reason==='renderer-unavailable'?'当前视图暂无此类型的编辑器':'原始内容已保留'} + {fallback?.handles.map(handle=>)} +
; +} +export function UnsupportedEdge(props:EdgeProps){ + const [path]=getBezierPath(props); + return 原始连线内容已保留; +} diff --git a/src/lib/canvas-bindings/projection.ts b/src/lib/canvas-bindings/projection.ts index 6f3cbf3..3e3c40f 100644 --- a/src/lib/canvas-bindings/projection.ts +++ b/src/lib/canvas-bindings/projection.ts @@ -8,8 +8,9 @@ import type { CanvasEdgeModel, EdgeData } from '../canvas-v2.ts'; import { childCanvasTransform, toWorld, transformPath, type Transform } from '../canvas-core/coordinates.ts'; export { toWorld, toLocal, transformPath, type Transform } from '../canvas-core/coordinates.ts'; const origin:Transform={x:0,y:0,scale:1,rotation:0}; -export type ProjectionNode = Omit & { type: NonNullable | 'unsupported' }; -const nodeTypes: Record> = { 'plain-text': 'plainText', 'plain-image': 'plainImage', frame: 'frame', icon: 'icon' }; +export type FallbackProjection = {reason:'unknown-component'|'unsupported-version'|'renderer-unavailable';componentId:string;dataVersion:number;handles:{id:string;side:'top'|'right'|'bottom'|'left'}[]}; +export type ProjectionNode = Omit & { type: NonNullable | 'unsupported';data:CanvasNode['data']&{__headlessFallback?:FallbackProjection} }; +const nodeTypes: Record> = { text:'material', image:'material', script:'material', 'plain-text': 'plainText', 'plain-image': 'plainImage', frame: 'frame', icon: 'icon' }; const object = (value: Json | undefined) => value && typeof value === 'object' && !Array.isArray(value) ? value : {}; /** Renderer projection retains identity/owner in the model, including nested and collapsed canvases. */ @@ -25,16 +26,18 @@ export function projectCanvas(document: Document, canvasId: Id, registry: Regist for (const element of members.get(id) ?? []) if (element.structure === 'node') { const g = geometry(element.id) ?? element.geometry, center = toWorld(transform, { x: g.x + g.width / 2, y: g.y + g.height / 2 }); const width = g.width * transform.scale, height = g.height * transform.scale, known = registry.resolve(element) === 'known'; - const type = !known ? 'unsupported' : nodeTypes[element.componentId] ?? 'material'; + const resolved=registry.resolve(element),renderer=element.dataVersion===1?nodeTypes[element.componentId]:undefined; + const fallback:FallbackProjection|undefined=!known||!renderer?{reason:resolved==='known'?'renderer-unavailable':resolved,componentId:element.componentId,dataVersion:element.dataVersion,handles:[]}:undefined; + const type = fallback ? 'unsupported' : renderer!; const child = childByEntry.get(element.id), data = element.data; const projected: ProjectionNode = { id: element.id, type, position: { x: center.x - width / 2, y: center.y - height / 2 }, width, height, selected: selected.has(element.id), draggable: known && registry.supports(element, 'geometry.move'), zIndex: element.componentId === 'frame' ? depth - 100 : depth * 2, - style: { width, height, ...(element.componentId === 'frame' ? { pointerEvents: 'none' as const } : {}) }, - data: { ...data, kind: element.componentId as CanvasNode['data']['kind'], title: typeof data.title === 'string' ? data.title : element.componentId, text: typeof data.text === 'string' ? data.text : '', rotation: (g.rotation + transform.rotation) % 360, viewScale: transform.scale*(known?unitScale(data.presentation):1), ...(child ? { subgraph: { boardId: child } } : {}) } as CanvasNode['data'], + style: { width, height, ...(element.componentId === 'frame'&&!fallback ? { pointerEvents: 'none' as const } : {}) }, + data: { ...(fallback?{}:data), kind: element.componentId as CanvasNode['data']['kind'], title: typeof data.title === 'string' ? data.title : element.componentId, text: !fallback&&typeof data.text === 'string' ? data.text : '', __headlessFallback:fallback, rotation: (g.rotation + transform.rotation) % 360, viewScale: transform.scale*(!fallback?unitScale(data.presentation):1), ...(child ? { subgraph: { boardId: child } } : {}) } as CanvasNode['data'], }; nodes.push(projected); visible.add(element.id); if (child) { const presentation = object(data.presentation); - if (presentation.collapsed === true || !known) { + if (!!fallback || presentation.collapsed === true) { const stack = [child]; while (stack.length) { const hidden = stack.pop()!; hiddenBy.set(hidden, element.id); for (const canvas of document.canvases.values()) if (canvas.parent?.canvasId === hidden) stack.push(canvas.id); } } else { transforms.set(child, childCanvasTransform(transform,g,data.presentation)); visit(child, depth + 1); @@ -48,10 +51,29 @@ export function projectCanvas(document: Document, canvasId: Id, registry: Regist const endpoint = (id: Id) => visible.has(id) ? id : hiddenBy.get(document.elements.get(id)?.ownerCanvasId ?? ''); const source = endpoint(element.source.elementId), target = endpoint(element.target.elementId); if (!source || !target || source === target) continue; - const path = transformPath(edgePreview?.target.id === element.id ? edgePreview.path : element.path, transforms.get(element.ownerCanvasId)!); - const renderPath = registry.resolve(element) === 'known' && connectionPathValid(path) ? path : undefined; + const fallbackEdge=registry.resolve(element)!=='known'||element.componentId!=='connection'||element.dataVersion!==1; + const path = fallbackEdge ? undefined : transformPath(edgePreview?.target.id === element.id ? edgePreview.path : element.path, transforms.get(element.ownerCanvasId)!); + const renderPath = path!==undefined&&connectionPathValid(path) ? path : undefined; const { path: unusedPath, ...edgeData } = element.data; void unusedPath; - edges.push({ id: element.id, source, target, sourceHandle: source === element.source.elementId ? element.source.portId : undefined, targetHandle: target === element.target.elementId ? element.target.portId : undefined, selected: selected.has(element.id), data: { ...edgeData, ...(renderPath ? { path: renderPath } : {}) } as EdgeData }); + edges.push({ id: element.id, ...(fallbackEdge?{type:'unsupported'}:{}), source, target, sourceHandle: source === element.source.elementId ? element.source.portId : undefined, targetHandle: target === element.target.elementId ? element.target.portId : undefined, selected: selected.has(element.id), data: fallbackEdge?{}:{ ...edgeData, ...(renderPath ? { path: renderPath } : {}) } as EdgeData }); + } + // Handles come from known envelope endpoints, never from opaque component payloads. + const nodesById=new Map(nodes.map(node=>[node.id,node])); + const fallbackNodes=new Map(nodes.filter(node=>node.data.__headlessFallback).map(node=>[node.id,node.data.__headlessFallback!])); + const handleIds=new Map>(); + for(const [id,fallback] of fallbackNodes){fallback.handles=(['top','right','bottom','left'] as const).map(side=>({id:side,side}));handleIds.set(id,new Set(fallback.handles.map(handle=>handle.id)));} + for(const edge of edges)for(const end of ['source','target'] as const){ + const fallback=fallbackNodes.get(edge[end]); + if(!fallback){ + if(edge.type==='unsupported'){ + const node=nodesById.get(edge[end]),field=end==='source'?'sourceHandle':'targetHandle',handle=edge[field]; + if(!handleIds.has(edge[end])){const ids=new Set(['top','right','bottom','left']);if(Array.isArray(node?.data.ports))for(const port of node.data.ports)if(port&&typeof port.id==='string')ids.add(`port:${port.id}`);handleIds.set(edge[end],ids);} + if(handle&&!handleIds.get(edge[end])!.has(handle))edge[field]=undefined; + } + continue; + } + const field=end==='source'?'sourceHandle':'targetHandle',handle=edge[field]|| (end==='source'?'right':'left');edge[field]=handle; + const seen=handleIds.get(edge[end])!;if(!seen.has(handle)){seen.add(handle);fallback.handles.push({id:handle,side:end==='source'?'right':'left'});} } return { nodes, edges, transforms }; } diff --git a/tests/headless/bindings.test.mjs b/tests/headless/bindings.test.mjs index 2f3dc40..814e6ee 100644 --- a/tests/headless/bindings.test.mjs +++ b/tests/headless/bindings.test.mjs @@ -106,3 +106,18 @@ test('a canceled pointer lease cannot update, finish or cancel a newer gesture i second.commit(); assert.equal(editor.getSnapshot().document.elements.get('outside').geometry.x, 200); assert.equal(editor.getSnapshot().history.undo.length, 1); sessions.dispose(); }); + +test('unknown payloads project safe placeholders and retained endpoint handles without interpreting their fields',()=>{ + const unknown={...node('unknown','root','plugin:future'),dataVersion:7,data:{title:'Unknown',ports:{find:'opaque'},presentation:{unitScale:'future'},assetId:{future:true},__headlessFallback:{handles:'opaque'},opaque:{retained:true}}},future={...node('future','root','text'),dataVersion:99,data:{title:'Future',text:{opaque:true},ports:'opaque'}},connection={...edge,componentId:'plugin:edge',dataVersion:7,source:{elementId:'unknown',portId:'plugin:out'},target:{elementId:'future',portId:'plugin:in'},data:{appearance:{color:{future:true}}},path:{type:'future'}}; + const source=createDocument('project',[{id:'root',parent:null}],[unknown,future,connection,{...connection,id:'repeat'}]),before=JSON.stringify([...source.elements]),p=projectCanvas(source,'root',registry,new Set(),()=>undefined),u=p.nodes.find(row=>row.id==='unknown'),f=p.nodes.find(row=>row.id==='future'); + assert.equal(u.type,'unsupported');assert.equal(u.draggable,false);assert.equal(u.data.ports,undefined);assert.equal(u.data.assetId,undefined);assert.equal(u.data.__headlessFallback.reason,'unknown-component');assert.equal(u.data.__headlessFallback.handles.filter(row=>row.id==='plugin:out').length,1);assert.ok(u.data.__headlessFallback.handles.some(row=>row.id==='plugin:out'&&row.side==='right'));assert.equal(f.data.__headlessFallback.reason,'unsupported-version');assert.ok(f.data.__headlessFallback.handles.some(row=>row.id==='plugin:in'&&row.side==='left'));assert.equal(p.edges[0].type,'unsupported');assert.deepEqual(p.edges[0].data,{});assert.equal(JSON.stringify([...source.elements]),before); +}); + +test('future partition placeholders collapse only the display tree and expose redirected upper-canvas edges',()=>{ + const future=createDocument('project',[...document.canvases.values()],[...document.elements.values()].map(row=>row.id==='frame'?{...row,dataVersion:99,data:{...row.data,ports:{opaque:true},presentation:{unitScale:'future'}}}:row)),p=projectCanvas(future,'root',registry,new Set(),()=>undefined),frame=p.nodes.find(row=>row.id==='frame');assert.equal(frame.type,'unsupported');assert.equal(frame.style.pointerEvents,undefined);assert.equal(p.nodes.some(row=>row.id==='inside'),false);assert.equal(p.edges[0].target,'frame');assert.equal(p.edges[0].targetHandle,'left');assert.ok(frame.data.__headlessFallback.handles.some(row=>row.id==='left'));assert.equal(future.elements.get('edge').target.elementId,'inside'); +}); + +test('known components without a matching renderer retain registered geometry capability instead of using the text editor',async()=>{ + const {defineComponent,movable}=await import('../../src/lib/canvas-core/index.ts'),custom=defineComponent({id:'plugin:known',dataVersion:1,structure:'node',validate:()=>true,features:[movable()]}),r=createRegistry([...builtinComponents(),custom]),source=createDocument('project',[{id:'root',parent:null}],[node('custom','root','plugin:known')]); + const projected=projectCanvas(source,'root',r,new Set(),()=>undefined).nodes[0];assert.equal(projected.type,'unsupported');assert.equal(projected.draggable,true);assert.equal(projected.data.__headlessFallback.reason,'renderer-unavailable');const editor=createEditor({document:source,registry:r});assert.equal(editor.dispatch({type:'element.geometry',target:{id:'custom',incarnation:1},capability:'geometry.move',patch:{x:20}}).status,'applied'); +}); diff --git a/tests/headless/renderer.integration.mjs b/tests/headless/renderer.integration.mjs index f7321b1..0df34f3 100644 --- a/tests/headless/renderer.integration.mjs +++ b/tests/headless/renderer.integration.mjs @@ -274,6 +274,18 @@ try { 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 until(`document.querySelectorAll('.react-flow__node').length===0`,'viewport culling did not resume after IME editing');await evaluate(panHome);await until(`!!document.querySelector('.react-flow__node[data-id=b] textarea')`,'edited node did not remount');assert.ok(await evaluate(`document.querySelector('.react-flow__node[data-id=b] textarea').value.endsWith('保留输入')`)); console.log('PASS native viewport culling retains unsaved portalled frame titles and active IME across pan, then resumes and remounts saved content'); + // Read a fresh project whose known envelope contains opaque component/version payloads. + const opaqueRoot=id(800),opaqueChild=id(801),opaqueU={...node('opaque-u',opaqueRoot,50),componentId:'plugin:future',dataVersion:9,data:{title:'Opaque source',ports:{find:'future field'},presentation:{unitScale:'future'},assetId:'opaque-asset',__headlessFallback:{handles:'untrusted'},opaque:{preserve:true}}},opaqueV={...node('opaque-v',opaqueRoot,400),dataVersion:99,data:{title:'Future card',text:{preserve:true},ports:'future field'}},opaqueFrame={...node('opaque-frame',opaqueRoot,100),componentId:'frame',dataVersion:99,geometry:{x:100,y:450,width:350,height:220,rotation:0},data:{title:'Future partition',ports:{preserve:true},presentation:{unitScale:'future'}}},opaqueKnown=node('opaque-known',opaqueRoot,800),opaqueMember=node('opaque-member',opaqueChild,20),opaqueEdge={id:'opaque-edge',incarnation:1,structure:'connection',componentId:'plugin:connection',dataVersion:9,ownerCanvasId:opaqueRoot,source:{elementId:opaqueU.id,portId:'custom-out'},target:{elementId:opaqueV.id,portId:'custom-in'},path:{type:'future',preserve:true},data:{appearance:{color:{preserve:true}}}},regularEdge={...opaqueEdge,id:'opaque-regular',componentId:'connection',dataVersion:1,target:{elementId:opaqueKnown.id,portId:'left'},path:null,data:{}},partitionEdge={...regularEdge,id:'opaque-partition',source:{elementId:opaqueKnown.id,portId:'bottom'},target:{elementId:opaqueMember.id}}; + const opaqueOriginal=createDocument(opaqueRoot,[{id:opaqueRoot,parent:null},{id:opaqueChild,parent:{canvasId:opaqueRoot,entryElementId:opaqueFrame.id}}],[opaqueU,opaqueV,opaqueFrame,opaqueKnown,opaqueMember,opaqueEdge,regularEdge,partitionEdge]);documents.set(opaqueRoot,opaqueOriginal);versions.set(opaqueRoot,1);versions.set(opaqueChild,1);boards.push({id:opaqueRoot,workspace_id:workspace,kind:'board',version:1,title:'Compatibility board'}); + await call('Page.navigate',{url:`http://127.0.0.1:${port}/dashboard/${workspace}/${opaqueRoot}`});await until(`document.querySelectorAll('[aria-label="暂不支持编辑的元素"]').length===3&&document.querySelectorAll('.react-flow__edge').length===3`,'opaque placeholders or their original connections did not render'); + assert.equal(await evaluate(`document.querySelectorAll('.react-flow__node').length`),4);assert.equal(await evaluate(`!!document.querySelector('.react-flow__node[data-id="opaque-member"]')`),false);assert.equal(await evaluate(`document.querySelector('.react-flow__node[data-id="opaque-u"]').classList.contains('draggable')`),false);assert.equal(await evaluate(`document.querySelector('.react-flow__node[data-id="opaque-u"]').querySelectorAll('textarea,input').length`),0);assert.ok(await evaluate(`[...document.querySelector('.react-flow__node[data-id="opaque-u"]').querySelectorAll('[data-handleid]')].some(handle=>handle.dataset.handleid==='custom-out'&&!handle.classList.contains('connectablestart')&&!handle.classList.contains('connectableend'))`)); + const opaqueBorder=await evaluate(`getComputedStyle(document.querySelector('[data-id="opaque-u"] [role=note]')).borderColor`);await evaluate(`document.querySelector('.react-flow__node[data-id="opaque-u"]').dispatchEvent(new MouseEvent('click',{bubbles:true}))`);await until(`document.querySelector('.react-flow__node[data-id="opaque-u"]').classList.contains('selected')`,'opaque selection did not publish');assert.notEqual(await evaluate(`getComputedStyle(document.querySelector('[data-id="opaque-u"] [role=note]')).borderColor`),opaqueBorder,'placeholder selection was not visible'); + await evaluate(`(()=>{const card=document.querySelector('.react-flow__node[data-id="opaque-known"]');card.dispatchEvent(new MouseEvent('click',{bubbles:true}));card.querySelector('[aria-label^="单击选中"]').dispatchEvent(new MouseEvent('dblclick',{bubbles:true}));})()`);await until(`document.activeElement===document.querySelector('.react-flow__node[data-id="opaque-known"] textarea')&&!document.activeElement.readOnly`,'known neighbour was not editable');await evaluate('document.activeElement.select()');await call('Input.insertText',{text:'Known edit with opaque neighbours'});await evaluate('document.activeElement.blur()');for(let i=0;i<100&&documents.get(opaqueRoot).elements.get('opaque-known').data.text!=='Known edit with opaque neighbours';i++)await pause(20);assert.equal(documents.get(opaqueRoot).elements.get('opaque-known').data.text,'Known edit with opaque neighbours');for(const record of [opaqueU,opaqueV,opaqueFrame,opaqueEdge])assert.deepEqual(documents.get(opaqueRoot).elements.get(record.id),record);assert.equal(calls.some(call=>call.path.endsWith('/assets/opaque-asset')),false); + await call('Page.reload');await until(`document.querySelectorAll('[aria-label="暂不支持编辑的元素"]').length===3&&document.querySelectorAll('.react-flow__edge').length===3`,'opaque content or connections did not survive recovery');assert.equal(documents.get(opaqueRoot).elements.get('opaque-partition').target.elementId,'opaque-member'); + await click('适应画布');await pause(100);await click('缩小');await pause(100);await click('缩小');await pause(100);await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))'); + await writeFile(join(tmpdir(),'headless-opaque-renderer.png'),Buffer.from((await call('Page.captureScreenshot',{format:'png'})).data,'base64')); + console.log('PASS unknown types and future versions render safe read-only placeholders with custom/partition connections, preserve opaque payloads through edits and recovery, and never load opaque asset fields'); + 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');