diff --git a/src/components/canvas/headless-graph-canvas.tsx b/src/components/canvas/headless-graph-canvas.tsx index 838f52b..1444865 100644 --- a/src/components/canvas/headless-graph-canvas.tsx +++ b/src/components/canvas/headless-graph-canvas.tsx @@ -1,7 +1,7 @@ "use client"; import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, type ContextType } from "react"; -import { Background, BackgroundVariant, ConnectionMode, ReactFlow, ReactFlowProvider, useReactFlow, type Connection } from "@xyflow/react"; +import { Background, BackgroundVariant, ConnectionMode, ReactFlow, ReactFlowProvider, useReactFlow, type Connection,type Viewport } from "@xyflow/react"; import { Copy, Download, Frame, Hand, ImagePlus, Maximize, Minus, Ratio, MousePointer2, Plus, Redo2, Sparkles, StickyNote, Type, Undo2 } from "lucide-react"; import type { Board } from "@/lib/workspace"; import type { ReactRenderers } from "@/lib/canvas-bindings/react-renderers"; @@ -261,8 +261,9 @@ function CanvasView({ engine, board, viewId, renderers }: { engine: ProjectEngin {error &&
{error} {displayState.engine===engine&&displayState.viewId===viewId&&displayState.failures>0&&}
} ; } -export default function HeadlessGraphCanvas({ engine, board, renderers=defaultCanvasRenderers }: { engine: ProjectEngine; board: Board; renderers?:ReactRenderers }) { +export default function HeadlessGraphCanvas({ engine, board, renderers=defaultCanvasRenderers,initialViewport }: { engine: ProjectEngine; board: Board; renderers?:ReactRenderers;initialViewport?:Viewport }) { + const [handoffViewport]=useState(initialViewport); const [viewId] = useState(() => crypto.randomUUID()), [attached, setAttached] = useState(false); - useEffect(() => { const detach = engine.sessions.attach(viewId, board.id); setAttached(true); return () => { setAttached(false); detach(); }; }, [engine, viewId, board.id]); + useEffect(() => { const detach = engine.sessions.attach(viewId, board.id);if(handoffViewport)engine.sessions.viewport(viewId,handoffViewport); setAttached(true); return () => { setAttached(false); detach(); }; }, [engine, viewId, board.id,handoffViewport]); return attached ? :

正在准备画布…

; } diff --git a/src/components/canvas/loading-graph-canvas.tsx b/src/components/canvas/loading-graph-canvas.tsx new file mode 100644 index 0000000..525256d --- /dev/null +++ b/src/components/canvas/loading-graph-canvas.tsx @@ -0,0 +1,40 @@ +"use client"; + +import {useEffect,useMemo,useRef} from 'react'; +import {Background,BackgroundVariant,ReactFlow,useReactFlow,type Viewport,type NodeTypes} from '@xyflow/react'; +import type {Registry} from '@/lib/canvas-core/types'; +import type {ModelPreview} from '@/lib/canvas-runtime/model-preview'; +import type {ReactRenderers} from '@/lib/canvas-bindings/react-renderers'; +import {projectCanvas} from '@/lib/canvas-bindings/projection'; +import {CanvasRendererCommandsContext,type CanvasRendererCommands} from '@/lib/canvas-bindings/renderer-context'; +import {UnsupportedNode,UnsupportedEdge} from './unsupported-elements'; +import styles from './canvas.module.css'; + +const reject=()=>({status:'rejected' as const,code:'model_loading',targets:[]}); +const commands:CanvasRendererCommands=Object.freeze({dispatch:reject,transaction:reject}); +function InitialPreviewViewport({onViewport,nodeCount}:{onViewport(viewport:Viewport):void;nodeCount:number}){ + const flow=useReactFlow(),fitted=useRef(false),notify=useRef(onViewport);notify.current=onViewport; + useEffect(()=>{if(!flow.viewportInitialized||fitted.current||!nodeCount)return;fitted.current=true;let active=true;void flow.fitView({padding:.2,maxZoom:1}).then(()=>{if(active)notify.current(flow.getViewport());});return()=>{active=false;};},[flow,nodeCount]); + return null; +} +/** Render the validated read prefix directly. No temporary editor, ACK, ledger or + * writable engine is constructed, and no partial model enters durable storage. */ +export default function LoadingGraphCanvas({preview,canvasId,registry,renderers,workspaceId,onViewport}: {preview:ModelPreview;canvasId:string;workspaceId:string;registry:Registry;renderers:ReactRenderers;onViewport(viewport:Viewport):void}){ + // Without a declared reference schema, use the existing opaque placeholder + // until the complete document is available to the installed renderer. + const catalog=useMemo(()=>({...renderers.catalog,resolve(element:Parameters[0]){return registry.components.get(element.componentId)?.copy?renderers.catalog.resolve(element):undefined;}}),[registry,renderers]); + const projection=useMemo(()=>{ + const value=projectCanvas(preview.document,canvasId,registry,new Set(),()=>undefined,undefined,catalog); + return{nodes:value.nodes.map(node=>({...node,draggable:false,selectable:false,focusable:false,connectable:false,style:{...node.style,pointerEvents:'none' as const},data:{...node.data,...(registry.resolve(preview.document.elements.get(node.id)!)==='known'&&typeof node.data.assetId==='string'&®istry.components.get(preview.document.elements.get(node.id)!.componentId)?.resourceFields?.includes('assetId')?{src:`/api/foundry/workspaces/${encodeURIComponent(workspaceId)}/assets/${encodeURIComponent(node.data.assetId)}?variant=preview`}:{})}})),edges:value.edges.map(edge=>({...edge,selectable:false,focusable:false}))}; + },[preview.document,canvasId,workspaceId,registry,catalog]); + const nodeTypes=useMemo(()=>{const result:NodeTypes={};for(const [id,Renderer]of Object.entries({...renderers.nodeTypes,unsupported:UnsupportedNode}))result[id]=props=>
;return result;},[renderers]),edgeTypes=useMemo(()=>({...renderers.edgeTypes,unsupported:UnsupportedEdge}),[renderers]); + return
+ + onViewport(viewport)}> + + + + +

正在加载… {preview.receivedElements}/{preview.totalElements},加载期间可浏览

+
; +} diff --git a/src/components/workspace/graph-page.tsx b/src/components/workspace/graph-page.tsx index 9250621..ab6f68b 100644 --- a/src/components/workspace/graph-page.tsx +++ b/src/components/workspace/graph-page.tsx @@ -1,6 +1,8 @@ "use client"; import { useEffect, useRef, useState } from "react"; +import type {Viewport} from '@xyflow/react'; +import type {ModelPreview} from '@/lib/canvas-runtime/model-preview'; import { useRouter, useSearchParams } from "next/navigation"; import { api, errorText } from "@/lib/api"; import { readDemoBoards } from "@/lib/demo-workspace"; @@ -15,6 +17,7 @@ import dynamic from "next/dynamic"; const GraphCanvas = dynamic(() => import("@/components/canvas/graph-canvas"), { ssr: false, loading: () =>

正在准备画布…

}); const HeadlessGraphCanvas = dynamic(() => import("@/components/canvas/headless-graph-canvas"), { ssr: false, loading: () =>

正在准备画布…

}); +const LoadingGraphCanvas=dynamic(()=>import("@/components/canvas/loading-graph-canvas"),{ssr:false}); type Loaded = { board: Board; snapshot?: CanvasSnapshot; route: string; engine?: ProjectEngine }; export default function GraphPage({ workspaceId, boardId }: { workspaceId: string; boardId: string }) { const renderers=useCanvasRenderers(); @@ -24,11 +27,13 @@ export default function GraphPage({ workspaceId, boardId }: { workspaceId: strin const route = workspaceId + ":" + boardId; const { board: activeBoard, setBoard: setActiveBoard } = useActiveBoard(); const [loaded, setLoaded] = useState(null); + const [preview,setPreview]=useState<{board:Board;value:ModelPreview;route:string}|null>(null); + const previewViewport=useRef(undefined); const [error, setError] = useState(""); const [retry, setRetry] = useState(0); const navigationGeneration=useRef(0); useEffect(() => { - setLoaded(null); setError(""); + setLoaded(null);setPreview(null);previewViewport.current=undefined; setError(""); if (demo) { const item = readDemoBoards(workspaceId).find((board) => matchesRef(board, boardId)); if (item) setLoaded({ board: item, route }); @@ -39,25 +44,27 @@ export default function GraphPage({ workspaceId, boardId }: { workspaceId: strin const controller = new AbortController(); const startingIdentity=runtimeHost.identity();let currentIdentity:(()=>boolean)|undefined; loadCanvasEntry({ request: api, workspaceRef: workspaceId, boardRef: boardId, registry: runtimeHost.registry, signal: controller.signal,navigationGeneration:++navigationGeneration.current, - readModel:input=>{const identity=runtimeHost.identity();if(identity.authEpoch!==startingIdentity.authEpoch&&identity.accountId!==input.accountId)throw new Error('identity_changed');currentIdentity=runtimeHost.bindAccount(input.accountId);return runtimeHost.loadModel(input.accountId,input.workspaceId,input.canvasId,{signal:input.signal,navigationGeneration:input.navigationGeneration});}, + onPreview:(value,board)=>{if(active&&(!currentIdentity||currentIdentity())&&value.document.canvases.has(board.id))setPreview({board,value,route});}, + readModel:input=>{const identity=runtimeHost.identity();if(identity.authEpoch!==startingIdentity.authEpoch&&identity.accountId!==input.accountId)throw new Error('identity_changed');currentIdentity=runtimeHost.bindAccount(input.accountId);return runtimeHost.loadModel(input.accountId,input.workspaceId,input.canvasId,{signal:input.signal,navigationGeneration:input.navigationGeneration,onPreview:input.onPreview});}, }).then(async result => { if (!active||currentIdentity&&!currentIdentity()) return; - if (result.mode === 'legacy') { setLoaded({ board: result.board, snapshot: result.snapshot, route }); return; } + if (result.mode === 'legacy') { setLoaded({ board: result.board, snapshot: result.snapshot, route });setPreview(null); return; } const engine = await runtimeHost.workspace(result.profile.id, result.board.workspace_id).open(result.model.document, result.model.versions); - if (active&&(!currentIdentity||currentIdentity())) setLoaded({ board: result.board, engine, route }); + if (active&&(!currentIdentity||currentIdentity())){setLoaded({ board: result.board, engine, route });setPreview(null);} }) .catch((error) => { if (active) { setError(errorText(error)); if (error.message === "invalid_session") router.replace("/login"); } }); return () => { active = false; controller.abort(); }; }, [workspaceId, boardId, route, demo, retry, router, runtimeHost]); const current = loaded?.route === route ? loaded : null; - const board = current?.board; + const currentPreview=preview?.route===route?preview:null; + const board = current?.board??currentPreview?.board; useEffect(() => { if (board) setActiveBoard({ id: board.id, slug: board.slug, workspace_id: board.workspace_id, workspace_ref: workspaceId, title: board.title }); return () => setActiveBoard(null); }, [board, workspaceId, setActiveBoard]); const displayBoard = board && activeBoard?.id === board.id && activeBoard.workspace_id === board.workspace_id ? { ...board, title: activeBoard.title } : board; - return
- {displayBoard && current?.engine ? : displayBoard ? :
} + return
+ {displayBoard && current?.engine ? : displayBoard&¤tPreview ? {previewViewport.current=viewport;}} /> : displayBoard ? :
} {error ?
{error}
: !board &&

正在打开图谱…

}
; } diff --git a/src/lib/api.ts b/src/lib/api.ts index 77f17a6..76b64e8 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -59,6 +59,12 @@ export async function startOAuth(provider: "google" | "slack") { export const errorText = (error: unknown) => { const code = error instanceof Error ? error.message : ""; const messages: Record = { + invalid_model_stream: "画布返回的数据无法解析,请重新加载。", + incomplete_model_stream: "画布数据未加载完整,请重新加载。", + unsupported_model_stream: "当前画布加载格式暂不受支持,请更新后重试。", + model_stream_budget: "画布数据量超过当前加载上限,请联系支持。", + load_snapshot_changed: "加载期间画布快照发生变化,请重新加载。", + model_loading: "画布尚在加载,请等待加载完成后编辑。", identity_changed: "账户已变化,当前画布已停止同步,请重新打开。", engine_closing: "画布正在关闭,本地修改会保留用于恢复。", storage_scope_in_use: "这张图谱已在另一个标签页中打开,请先关闭那里的编辑器。", diff --git a/src/lib/canvas-core/document.ts b/src/lib/canvas-core/document.ts index 5a83ae0..6a3397d 100644 --- a/src/lib/canvas-core/document.ts +++ b/src/lib/canvas-core/document.ts @@ -34,6 +34,17 @@ export function commonCanvas(document: Document, left: Id, right: Id): Id { if (!result) throw fault('no_common_canvas', [left, right]); return result; } +/** Validate one record before its graph dependencies have arrived. This does not + * prove owner membership, endpoint existence or connection common ownership. */ +export function validateElementRecord(element: ElementRecord, registry: Registry, tombstones: ReadonlyMap = new Map(), componentData = true): void { + if (!element || !validJson(element) || typeof element.id !== 'string' || !element.id || typeof element.ownerCanvasId !== 'string' || !element.ownerCanvasId || typeof element.componentId !== 'string' || !element.componentId || !['node','connection'].includes(element.structure) || !Number.isSafeInteger(element.incarnation) || element.incarnation < 1 || !Number.isSafeInteger(element.dataVersion) || element.dataVersion < 1 || !element.data || typeof element.data !== 'object' || Array.isArray(element.data) || !validJson(element.data)) throw fault('invalid_element', [element?.id]); + const dead=tombstones.get(element.id);if(dead!==undefined&&element.incarnation<=dead)throw fault('stale_incarnation',[element.id]); + const definition=registry.components.get(element.componentId); + if(componentData&®istry.resolve(element)==='known'&&(!definition||definition.structure!==element.structure||!definition.validate(element.data)))throw fault('invalid_component_data',[element.id]); + if(element.structure==='node'){if(!geometryValid(element.geometry))throw fault('invalid_geometry',[element.id]);} + else if(!element.source||!element.target||typeof element.source.elementId!=='string'||!element.source.elementId||typeof element.target.elementId!=='string'||!element.target.elementId||[element.source,element.target].some(endpoint=>endpoint.portId!==undefined&&(typeof endpoint.portId!=='string'||!endpoint.portId))||!validJson(element.path))throw fault('invalid_endpoint',[element.id]); +} + /** Cooperative validation over an immutable document; synchronous callers drain the same checks. */ function* validateDocumentChecks(document: Document, registry: Registry, componentData:boolean): Generator { if (!document.canvases.size) throw fault('missing_root'); @@ -52,15 +63,9 @@ function* validateDocumentChecks(document: Document, registry: Registry, compone if (roots !== 1) throw fault('invalid_roots'); for (const element of document.elements.values()) { yield; - if (typeof element.id !== 'string' || !element.id || typeof element.componentId !== 'string' || !element.componentId || !['node','connection'].includes(element.structure) || !document.canvases.has(element.ownerCanvasId) || !Number.isSafeInteger(element.incarnation) || element.incarnation < 1 || !Number.isSafeInteger(element.dataVersion) || element.dataVersion < 1 || !element.data || typeof element.data !== 'object' || Array.isArray(element.data) || !validJson(element.data)) throw fault('invalid_element', [element.id]); - const dead = document.tombstones.get(element.id); - if (dead !== undefined && element.incarnation <= dead) throw fault('stale_incarnation', [element.id]); - const definition = registry.components.get(element.componentId); - if (componentData && registry.resolve(element) === 'known' && (!definition || definition.structure !== element.structure || !definition.validate(element.data))) throw fault('invalid_component_data', [element.id]); - if (element.structure === 'node') { - if (!geometryValid(element.geometry)) throw fault('invalid_geometry', [element.id]); - } else { - if (!element.source || !element.target || typeof element.source.elementId !== 'string' || typeof element.target.elementId !== 'string' || [element.source,element.target].some(endpoint => endpoint.portId !== undefined && (typeof endpoint.portId !== 'string' || !endpoint.portId))) throw fault('invalid_endpoint', [element.id]); + validateElementRecord(element, registry, document.tombstones, componentData); + if (!document.canvases.has(element.ownerCanvasId)) throw fault('invalid_element', [element.id]); + if (element.structure === 'connection') { const source = document.elements.get(element.source.elementId), target = document.elements.get(element.target.elementId); if (!source || !target || source.structure !== 'node' || target.structure !== 'node' || !validJson(element.path)) throw fault('invalid_endpoint', [element.id]); if (commonCanvas(document, source.ownerCanvasId, target.ownerCanvasId) !== element.ownerCanvasId) throw fault('invalid_connection_owner', [element.id]); diff --git a/src/lib/canvas-entry.ts b/src/lib/canvas-entry.ts index b91b93a..bbe983f 100644 --- a/src/lib/canvas-entry.ts +++ b/src/lib/canvas-entry.ts @@ -1,3 +1,4 @@ +import type {ModelPreview} from './canvas-runtime/model-preview.ts'; import type { Profile } from './api.ts'; import type { CanvasRequest } from './canvas-sync.ts'; import { decodeSnapshot } from './canvas-sync.ts'; @@ -7,14 +8,14 @@ import type { Registry } from './canvas-core/types.ts'; import { loadFoundryModel, type ModelLoad, type ServiceTransport } from './canvas-service/foundry.ts'; /** Read canonical first: a migrated project must never be opened through snapshot writers. */ -export async function loadCanvasEntry(options: { request: CanvasRequest; workspaceRef: string; boardRef: string; registry: Registry; transport?(accountId: string): ServiceTransport; readModel?(input:{accountId:string;workspaceId:string;canvasId:string;signal?:AbortSignal;navigationGeneration:number}):Promise;signal?: AbortSignal;navigationGeneration?:number }) { +export async function loadCanvasEntry(options: { request: CanvasRequest; workspaceRef: string; boardRef: string; registry: Registry; transport?(accountId: string): ServiceTransport; readModel?(input:{accountId:string;workspaceId:string;canvasId:string;signal?:AbortSignal;navigationGeneration:number;onPreview?:(preview:ModelPreview)=>void}):Promise;onPreview?:(preview:ModelPreview,board:Board,profile:Profile)=>void;signal?: AbortSignal;navigationGeneration?:number }) { const path = `workspaces/${encodeURIComponent(options.workspaceRef)}/materials/${encodeURIComponent(options.boardRef)}`; const [board, profile] = await Promise.all([options.request(path, { signal: options.signal }), options.request('profile', { signal: options.signal })]); options.signal?.throwIfAborted(); if (board.kind !== 'board' || !matchesRef(board, options.boardRef) || !board.workspace_id || !profile.id) throw new Error('invalid_reference'); if(!options.readModel&&!options.transport)throw new Error('model_loader_unavailable'); try { - const model = options.readModel?await options.readModel({accountId:profile.id,workspaceId:board.workspace_id,canvasId:board.id,signal:options.signal,navigationGeneration:options.navigationGeneration??0}):await loadFoundryModel(options.transport!(profile.id), board.workspace_id, board.id, options.registry, options.signal); + const model = options.readModel?await options.readModel({accountId:profile.id,workspaceId:board.workspace_id,canvasId:board.id,signal:options.signal,navigationGeneration:options.navigationGeneration??0,...(options.onPreview?{onPreview:(preview:ModelPreview)=>options.onPreview!(preview,board,profile)}:{})}):await loadFoundryModel(options.transport!(profile.id), board.workspace_id, board.id, options.registry, options.signal); options.signal?.throwIfAborted(); if (model.status === 'ready') return { mode: 'headless' as const, board, profile, model }; // Existing projects keep their current editor until the user explicitly migrates them. diff --git a/src/lib/canvas-runtime/host.ts b/src/lib/canvas-runtime/host.ts index 8d689cd..1296e6d 100644 --- a/src/lib/canvas-runtime/host.ts +++ b/src/lib/canvas-runtime/host.ts @@ -1,3 +1,4 @@ +import {createModelPreview,type ModelPreview} from './model-preview.ts'; import type { Id, Registry } from '../canvas-core/types.ts'; import { fault, readMap } from '../canvas-core/values.ts'; import type { WorkspaceRuntime } from './workspace.ts'; @@ -5,7 +6,7 @@ import { createScopedLoader, type LoadPort, type ScopedLoader } from './loader.t import type { CanonicalReadUpdate } from './contracts.ts'; /** Host lifetime matches the signed-in application, independently of navigation. */ -export function createRuntimeHost(options: { registry: Registry; workspace(accountId: Id, workspaceId: Id, authEpoch: number, current: () => boolean): WorkspaceRuntime; modelLoader?(accountId:Id,workspaceId:Id,authEpoch:number,current:()=>boolean):LoadPort; onError?: (error: unknown) => void }) { +export function createRuntimeHost(options: { registry: Registry; workspace(accountId: Id, workspaceId: Id, authEpoch: number, current: () => boolean): WorkspaceRuntime; modelLoader?(accountId:Id,workspaceId:Id,authEpoch:number,current:()=>boolean):LoadPort; onError?: (error: unknown) => void; onObserverError?: (error: unknown) => void }) { let accountId: Id | null = null, authEpoch = 0, disposed = false; const workspaces = new Map(), retired = new Set(); const readers = new Map>(); @@ -27,14 +28,20 @@ export function createRuntimeHost(options: { registry: Registry; workspace(accou bindAccount(account: Id) { activate(account); const epoch = authEpoch; return () => !disposed && accountId === account && authEpoch === epoch; }, identity: () => Object.freeze({ accountId, authEpoch }), getWorkspaces: () => readMap(workspaces), - async loadModel(account:Id,workspace:Id,canvas:Id,settings:{signal?:AbortSignal;navigationGeneration?:number}={}) { + async loadModel(account:Id,workspace:Id,canvas:Id,settings:{signal?:AbortSignal;navigationGeneration?:number;onPreview?:(preview:ModelPreview)=>void}={}) { activate(account); const readEpoch=authEpoch; if(!options.modelLoader)throw fault('model_loader_unavailable'); let reader=readers.get(workspace); if(!reader){const epoch=authEpoch,current=()=>!disposed&&accountId===account&&authEpoch===epoch;reader=createScopedLoader({load:options.modelLoader(account,workspace,epoch,current),current:()=>current(),maxCacheEntries:0});readers.set(workspace,reader);} - const handle=reader.acquire({accountId:account,workspaceId:workspace,projectId:null,canvasId:canvas,viewKey:'canonical-project',format:'canonical-model-v1',queryFingerprint:'full-project',source:'foundry',authEpoch,navigationGeneration:settings.navigationGeneration??0},{signal:settings.signal}); - try{const snapshot=await handle.ready;if(disposed||accountId!==account||authEpoch!==readEpoch)throw fault('identity_changed');const last=snapshot.chunks.at(-1);if(!last||!last.complete||last.value.status==='receiving')throw fault('incomplete_load');return last.value;}finally{handle.release();} + const preview=settings.onPreview?createModelPreview(options.registry):undefined;let seen=0,previewFailed=false; + const onUpdate=(state:import('./loader.ts').LoadSnapshot)=>{ + if(!preview||previewFailed||disposed||accountId!==account||authEpoch!==readEpoch||settings.signal?.aborted)return; + try{for(;seen=new Map(),published:Document|undefined,snapshot:ModelPreview|undefined; + const canvases=new Map(),records=new Map(); + const key=(kind:'canvas'|'element',id:Id)=>JSON.stringify([kind,id]); + const known=new Set(); + function update(value:CanonicalReadUpdate):ModelPreview|undefined{ + if(finished)throw fault('model_preview_closed'); + if(value.status!=='receiving'){ + if(header&&(value.status!=='ready'||value.document.projectId!==projectId||received!==expected||value.document.canvases.size!==canvases.size||[...canvases].some(([id,canvas])=>!equal(value.document.canvases.get(id),canvas))||!equal(Object.fromEntries(value.document.tombstones),Object.fromEntries(tombstones))||value.document.elements.size!==expected||[...(published?.elements??[]),...records].some(([id,record])=>!equal(value.document.elements.get(id),record))))throw fault('model_preview_changed'); + finished=true;return snapshot; + } + if(!header){ + if(value.receivedElements!==0||value.elements.length||!value.canvases.length||!Number.isSafeInteger(value.totalElements)||value.totalElements<0)throw fault('invalid_model_preview'); + projectId=value.projectId;expected=value.totalElements;tombstones=readMap(new Map(value.tombstones)); + for(const canvas of value.canvases){if(canvases.has(canvas.id))throw fault('invalid_model_preview');canvases.set(canvas.id,freeze(clone(canvas)));} + const topology=createDocument(projectId,[...canvases.values()]);let roots=0;const entries=new Set(); + for(const canvas of canvases.values()){ + if(!canvas.id||!Object.hasOwn(canvas,'parent'))throw fault('invalid_canvas'); + ancestors(topology,canvas.id); + if(canvas.parent===null){roots++;if(canvas.id!==projectId)throw fault('project_mismatch');} + else if(!canvas.parent||typeof canvas.parent.entryElementId!=='string'||!canvas.parent.entryElementId||entries.has(canvas.parent.entryElementId))throw fault('invalid_canvas_entry'); + else entries.add(canvas.parent.entryElementId); + } + if(roots!==1)throw fault('invalid_roots');header=true; + }else if(value.projectId!==projectId||value.totalElements!==expected||value.canvases.length||value.tombstones.size)throw fault('model_preview_changed'); + if(value.receivedElements!==received+value.elements.length||value.receivedElements>expected)throw fault('invalid_model_preview'); + // Validate the entire incoming batch before publishing any prefix from it. + const incoming=new Set(); + for(const record of value.elements){if(records.has(record.id)||known.has(key('element',record.id))||incoming.has(record.id)||!canvases.has(record.ownerCanvasId))throw fault('invalid_model_preview');validateElementRecord(record,registry,tombstones);incoming.add(record.id);} + for(const record of value.elements)records.set(record.id,freeze(clone(record)));received=value.receivedElements; + // Greatest closed subset: removing an unavailable dependency also removes its + // dependants. Fully received cycles survive and publish in one legal prefix. + const candidates=new Map}>(); + for(const canvas of canvases.values())if(!known.has(key('canvas',canvas.id)))candidates.set(key('canvas',canvas.id),{canvas,deps:new Set(canvas.parent?[key('canvas',canvas.parent.canvasId),key('element',canvas.parent.entryElementId)]:[])}); + for(const element of records.values())if(!known.has(key('element',element.id))){ + const deps=new Set([key('canvas',element.ownerCanvasId)]); + if(element.structure==='connection'){deps.add(key('element',element.source.elementId));deps.add(key('element',element.target.elementId));} + const definition=registry.resolve(element)==='known'?registry.components.get(element.componentId):undefined; + for(const ref of definition?.copy?.references??[])for(const id of copyReferenceIds(element.data,ref))deps.add(key(ref.target,id)); + candidates.set(key('element',element.id),{element,deps}); + } + const dependants=new Map>(),blocked=new Set(),queue:string[]=[]; + for(const [id,item]of candidates)for(const dependency of item.deps){let users=dependants.get(dependency);if(!users){users=new Set();dependants.set(dependency,users);}users.add(id);if(!known.has(dependency)&&!candidates.has(dependency)&&!blocked.has(id)){blocked.add(id);queue.push(id);}} + for(let index=0;indexsnapshot,close(){finished=true;records.clear();canvases.clear();known.clear();published=undefined;snapshot=undefined;}}); +} diff --git a/src/lib/canvas-service/model-stream.ts b/src/lib/canvas-service/model-stream.ts index 4533264..cc6b9fe 100644 --- a/src/lib/canvas-service/model-stream.ts +++ b/src/lib/canvas-service/model-stream.ts @@ -9,18 +9,21 @@ const uuid=/^[a-f\d]{8}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{12}$/i; const object=(value:unknown):Record=>{if(!value||typeof value!=='object'||Array.isArray(value)||!validJson(value))throw fault('invalid_model_stream');return value as Record;}; /** Bounded NDJSON framing. EOF is required even after a terminal record. */ async function* lines(source:AsyncIterable,signal:AbortSignal){ - const decoder=new TextDecoder('utf-8',{fatal:true});let pending='',total=0,lineBytes=0,count=0; + const decoder=new TextDecoder('utf-8',{fatal:true}); + const decode=(bytes?:Uint8Array,stream=false)=>{try{return decoder.decode(bytes,{stream});}catch{throw fault('invalid_model_stream');}}; + let pending='',total=0,lineBytes=0,count=0; for await(const bytes of source){ signal.throwIfAborted();if(!(bytes instanceof Uint8Array))throw fault('invalid_model_stream');total+=bytes.byteLength;if(total>maxBytes)throw fault('model_stream_budget'); let start=0; for(let index=0;indexmaxLine)throw fault('model_stream_budget'); - pending+=decoder.decode(bytes.subarray(start,index),{stream:true});pending+=decoder.decode();if(++count>maxLines||!pending)throw fault('invalid_model_stream'); - yield{raw:JSON.parse(pending),byteLength:lineBytes+1};pending='';lineBytes=0;start=index+1; + pending+=decode(bytes.subarray(start,index),true);pending+=decode();if(++count>maxLines||!pending)throw fault('invalid_model_stream'); + let raw:unknown;try{raw=JSON.parse(pending);}catch{throw fault('invalid_model_stream');} + yield{raw,byteLength:lineBytes+1};pending='';lineBytes=0;start=index+1; } - lineBytes+=bytes.length-start;if(lineBytes>maxLine)throw fault('model_stream_budget');pending+=decoder.decode(bytes.subarray(start),{stream:true}); + lineBytes+=bytes.length-start;if(lineBytes>maxLine)throw fault('model_stream_budget');pending+=decode(bytes.subarray(start),true); } - signal.throwIfAborted();pending+=decoder.decode();if(pending||lineBytes)throw fault('incomplete_model_stream'); + signal.throwIfAborted();pending+=decode();if(pending||lineBytes)throw fault('incomplete_model_stream'); } export async function* decodeModelStream(source:AsyncIterable,canvasId:Id,registry:Registry,signal:AbortSignal){ let sequence=0,token='',projectId='',expected=0,ended=false; diff --git a/tests/headless/model-loader.test.mjs b/tests/headless/model-loader.test.mjs index 833f898..7345e9b 100644 --- a/tests/headless/model-loader.test.mjs +++ b/tests/headless/model-loader.test.mjs @@ -56,3 +56,30 @@ test('entry injection uses resolved IDs and navigation context while preserving const result = await loadCanvasEntry(options); assert.equal(result.mode, 'headless'); assert.deepEqual(inputs[0], { accountId: account, workspaceId: workspace, canvasId: root, signal: undefined, navigationGeneration: 9 }); assert.equal(reads.some(path => path.endsWith('/canvas')), false); await assert.rejects(() => loadCanvasEntry({ ...options, readModel: async () => { throw Object.assign(Error('forbidden'), { code: 'forbidden' }); } }), { code: 'forbidden' }); }); + +test('Host previews share one source but cancelled observers never receive a late prefix or open storage',async()=>{ + const gate=deferred(),aStates=[],bStates=[];let reads=0; + const first=reply().body.model.elements[0],second={...first,id:'second'},canvases=[{id:root,parent:null}],serverVersions={[root]:1}; + const host=createRuntimeHost({registry,workspace(){assert.fail('preview opened storage');},modelLoader(){return async function*(scope){ + reads++;const part=(sequence,value,complete=false)=>({queryKey:loadQueryKey(scope),projectId:root,snapshotToken:'one-read',serverVersions,permissionEpoch:scope.authEpoch,sequence,coverage:[],complete,byteLength:1,value}); + yield part(0,{status:'receiving',projectId:root,canvases,elements:[],tombstones:new Map(),versions:new Map([[root,1]]),receivedElements:0,totalElements:2}); + yield part(1,{status:'receiving',projectId:root,canvases:[],elements:[first],tombstones:new Map(),versions:new Map([[root,1]]),receivedElements:1,totalElements:2}); + await gate.promise; + yield part(2,{status:'receiving',projectId:root,canvases:[],elements:[second],tombstones:new Map(),versions:new Map([[root,1]]),receivedElements:2,totalElements:2}); + yield part(3,{status:'ready',document:(await import('../../src/lib/canvas-core/document.ts')).createDocument(root,canvases,[first,second]),versions:new Map([[root,1]])},true); + };}}); + const abort=new AbortController();const a=host.loadModel(account,workspace,root,{signal:abort.signal,onPreview:value=>aStates.push(value)}),b=host.loadModel(account,workspace,root,{onPreview:value=>bStates.push(value)}); + try{while(!aStates.some(value=>value.receivedElements===1))await tick();assert.equal(reads,1);assert.equal(aStates.at(-1).document.elements.size,1);const rejected=assert.rejects(a,{code:'load_aborted'});abort.abort();await rejected;const count=aStates.length;gate.resolve();const complete=await b;assert.equal(complete.document.elements.size,2);assert.equal(aStates.length,count);assert.equal(bStates.at(-1).document.elements.size,2);assert.equal(host.getWorkspaces().size,0);}finally{gate.resolve();await host.close();} +}); +test('preview callback failures do not stop canonical loading and identity retirement fences future previews',async()=>{ + const gate=deferred(),secondGate=deferred(),errors=[],states=[];let reading=0; + const host=createRuntimeHost({registry,workspace(){assert.fail('preview opened a runtime');},onObserverError:error=>errors.push(error),modelLoader(){return async function*(scope){ + const pass=++reading;const part=(sequence,value,complete=false)=>({queryKey:loadQueryKey(scope),projectId:root,snapshotToken:'read',serverVersions:{[root]:1},permissionEpoch:scope.authEpoch,sequence,coverage:[],complete,byteLength:1,value}); + yield part(0,{status:'receiving',projectId:root,canvases:[{id:root,parent:null}],elements:[],tombstones:new Map(),versions:new Map([[root,1]]),receivedElements:0,totalElements:0});await(pass===1?gate.promise:secondGate.promise); + yield part(1,{status:'ready',document:(await import('../../src/lib/canvas-core/document.ts')).createDocument(root,[{id:root,parent:null}]),versions:new Map([[root,1]])},true); + };}}); + try{ + const read=host.loadModel(account,workspace,root,{onPreview(){throw Error('observer');}});await tick();gate.resolve();assert.equal((await read).status,'ready');assert.equal(errors.length,1); + const next=host.loadModel(account,workspace,root,{onPreview:value=>states.push(value)});const rejected=assert.rejects(next,{code:'loader_closed'});while(!states.length)await tick();await host.retireIdentity();secondGate.resolve();await rejected;const count=states.length;await tick();assert.equal(states.length,count); + }finally{gate.resolve();secondGate.resolve();await host.close();} +}); diff --git a/tests/headless/model-preview.test.mjs b/tests/headless/model-preview.test.mjs new file mode 100644 index 0000000..9977300 --- /dev/null +++ b/tests/headless/model-preview.test.mjs @@ -0,0 +1,44 @@ +import {test} from 'node:test'; +import assert from 'node:assert/strict'; +import {createRegistry,builtinComponents,createDocument,validateDocument} from '../../src/lib/canvas-core/index.ts'; +import {createModelPreview} from '../../src/lib/canvas-runtime/model-preview.ts'; +const registry=createRegistry(builtinComponents()),root='root',child='child'; +const node=(id,ownerCanvasId=root)=>({id,incarnation:1,ownerCanvasId,componentId:'text',dataVersion:1,structure:'node',data:{title:id,text:''},geometry:{x:0,y:0,width:100,height:80,rotation:0}}); +const frame={...node('F'),componentId:'frame',data:{title:'F',text:'',ports:[{id:'p',nodeId:'A',direction:'output',handle:'right'}]}}; +const a=node('A',child),b=node('B'),edge={id:'E',incarnation:1,ownerCanvasId:root,componentId:'connection',dataVersion:1,structure:'connection',data:{},source:{elementId:'A'},target:{elementId:'B'},path:null}; +const canvases=[{id:root,parent:null},{id:child,parent:{canvasId:root,entryElementId:'F'}}],versions=new Map([[root,1],[child,1]]); +const head=(total=4)=>({status:'receiving',projectId:root,canvases,elements:[],tombstones:new Map(),versions,receivedElements:0,totalElements:total}); +const delta=(elements,received,total=4)=>({...head(total),canvases:[],elements,receivedElements:received}); + +test('partial model waits for entry/owner/port cycles and edge endpoints; publishes one closed legal prefix',()=>{ + const preview=createModelPreview(registry);const first=preview.update(head());validateDocument(first.document,registry);assert.equal(first.document.canvases.size,1); + const second=preview.update(delta([a,edge],2));assert.equal(second.document.elements.size,0);assert.equal(second.waitingElements,2);assert.strictEqual(second.document,first.document); + const third=preview.update(delta([b],3));assert.deepEqual([...third.document.elements.keys()],['B']);assert.equal(third.waitingElements,2); + const fourth=preview.update(delta([frame],4));validateDocument(fourth.document,registry);assert.equal(fourth.document.elements.size,4);assert.equal(fourth.document.canvases.size,2);assert.equal(fourth.waitingElements,0);assert.equal(fourth.document.elements.get('E').ownerCanvasId,root); + assert.equal(first.document.elements.size,0);assert.equal(third.document.elements.size,1);assert.equal(fourth.complete,false);preview.close();assert.throws(()=>preview.update(delta([],4)),{code:'model_preview_closed'}); +}); +test('declared external reference waits; unsupported component data remains an opaque read-only candidate',()=>{ + const preview=createModelPreview(registry);preview.update(head(2));const future={...node('future'),componentId:'plugin.future',dataVersion:99,data:{opaque:{ref:'absent',assetId:'secret'}}}; + const shown=preview.update(delta([future],1,2));assert.deepEqual(shown.document.elements.get('future'),future);assert.equal(registry.resolve(shown.document.elements.get('future')),'unknown-component'); + const delayed={...node('delayed'),componentId:'frame',data:{title:'delayed',text:'',ports:[{nodeId:'absent'}]}}; + const next=preview.update(delta([delayed],2,2));assert.equal(next.document.elements.size,1);assert.equal(next.waitingElements,1); +}); +test('invalid batches do not replace the last legal prefix or publish a partial batch',()=>{ + for(const bad of [{...node('bad'),geometry:{...node('bad').geometry,width:-1}},{...node('bad'),incarnation:0},{...node('bad'),ownerCanvasId:'foreign'}]){ + const preview=createModelPreview(registry),first=preview.update(head(2));assert.throws(()=>preview.update(delta([node('good'),bad],2,2)));assert.strictEqual(preview.getSnapshot(),first);assert.equal(first.document.elements.size,0); + } + const preview=createModelPreview(registry),first=preview.update(head(2));assert.throws(()=>preview.update(delta([node('same'),node('same')],2,2)));assert.strictEqual(preview.getSnapshot(),first); +}); +test('bad topology and version/source switches cannot silently alter a preview',()=>{ + for(const topology of [[{id:root,parent:{canvasId:child,entryElementId:'F'}},{id:child,parent:{canvasId:root,entryElementId:'G'}}],[{id:root,parent:null},{id:child,parent:null}]])assert.throws(()=>createModelPreview(registry).update({...head(),canvases:topology})); + const preview=createModelPreview(registry);preview.update(head());assert.throws(()=>preview.update({...delta([b],1),projectId:'other'})); +}); + +test('owned preview records do not freeze or retain mutable caller payloads; final completion must match',()=>{ + const preview=createModelPreview(registry),header=structuredClone(head(1)),element=node('one');preview.update(header);const state=preview.update(delta([element],1,1)); + element.data.text='changed by caller';header.canvases[0].id='changed';assert.equal(state.document.elements.get('one').data.text,'');assert.equal(state.document.canvases.has(root),true); + const final=createDocument(root,[{id:root,parent:null},{id:child,parent:{canvasId:root,entryElementId:'F'}}],[element]);assert.throws(()=>preview.update({status:'ready',document:final,versions}),{code:'model_preview_changed'}); +}); +test('published identities cannot be repeated in later chunks and invalid input closes the projection',()=>{ + const preview=createModelPreview(registry);preview.update({...head(2),canvases:[{id:root,parent:null}]});preview.update(delta([node('one')],1,2));assert.throws(()=>preview.update(delta([node('one')],2,2)));assert.throws(()=>preview.update(delta([node('two')],2,2)),{code:'model_preview_closed'}); +}); diff --git a/tests/headless/renderer.integration.mjs b/tests/headless/renderer.integration.mjs index 7a09b05..2a2a423 100644 --- a/tests/headless/renderer.integration.mjs +++ b/tests/headless/renderer.integration.mjs @@ -22,6 +22,8 @@ if(performanceMode){ } 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 brokenStreamRoot=null; +let releaseFirstModel;const firstModelGate=new Promise(resolve=>{releaseFirstModel=resolve;});let gatedFirstModel=!performanceMode; 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; }); @@ -44,7 +46,10 @@ const upstream = createServer(async (request, response) => { response.writeHead(200,{'Content-Type':'application/vnd.foundry.canvas-model+ndjson;version=1'}); const line=record=>response.write(JSON.stringify({streamVersion:1,snapshotToken:token,sequence:sequence++,...record})+'\n'); line({kind:'header',documentFormatVersion:1,projectId:value.projectId,canvases:value.canvases,versions:value.versions,tombstones:value.tombstones,removedCanvasIds:value.removedCanvasIds,elementCount:value.elements.length}); - for(let offset=0;offsetcall.method==='POST'&&call.path.endsWith('/model/operations')).length,0); + assert.equal(await evaluate('document.querySelector("[data-canvas-loading-preview] textarea").readOnly'),true); + await until('document.querySelector("[data-canvas-loading-preview] .react-flow__node[data-id=a]").getBoundingClientRect().x>300','initial preview node remained under the sidebar'); + await call('Input.dispatchMouseEvent',{type:'mousePressed',x:1050,y:650,button:'left',buttons:1}); + await call('Input.dispatchMouseEvent',{type:'mouseMoved',x:1070,y:630,button:'left',buttons:1}); + await call('Input.dispatchMouseEvent',{type:'mouseReleased',x:1070,y:630,button:'left',buttons:0});await pause(100); + const previewTransform=await evaluate('document.querySelector("[data-canvas-loading-preview] .react-flow__viewport").style.transform'); + await writeFile('/private/tmp/headless-loading-preview.png',Buffer.from((await call('Page.captureScreenshot',{format:'png'})).data,'base64')); + releaseFirstModel(); + console.log('PASS actual GraphPage displays the first legal model prefix before the gated tail, without opening a writable engine or issuing an edit'); await until('!!document.querySelector("[data-headless-canvas]") && document.querySelectorAll(".react-flow__node").length === 2', 'actual headless canvas did not open'); + assert.equal(await evaluate('document.querySelector("[data-headless-canvas] .react-flow__viewport").style.transform'),previewTransform,'preview viewport was lost during editor handoff'); 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()`); @@ -184,14 +203,12 @@ try { await until(`!!document.querySelector('[aria-label="曲线点 1"]')`,'pane near-curve hit did not select the edge'); assert.equal(calls.filter(call=>call.method==='POST'&&call.path.endsWith('/model/operations')).length,beforeHit,'read-only hit query created a document operation'); console.log('PASS screen-tolerance pane hit selects the displayed curve beyond native SVG stroke without a model operation'); - 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 curveHandle=await evaluate(`(()=>{const r=document.querySelector('[aria-label="曲线点 1"]').getBoundingClientRect();return{x:r.x+r.width/2,y:r.y+r.height/2};})()`); + assert.equal(await evaluate(`document.elementFromPoint(${curveHandle.x},${curveHandle.y})?.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 }); + await call('Input.dispatchMouseEvent', { type: 'mousePressed', ...curveHandle, button: 'left', buttons: 1, clickCount: 1 }); + await call('Input.dispatchMouseEvent', { type: 'mouseMoved', x: curveHandle.x+20, y: curveHandle.y+10, button: 'left', buttons: 1 }); + await call('Input.dispatchMouseEvent', { type: 'mouseReleased', x: curveHandle.x+40, y: curveHandle.y+15, 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); @@ -330,6 +347,15 @@ try { await click('撤销');for(let i=0;i<100&&documents.get(extensionRoot).elements.get('meter-one').data.metric!==3;i++)await pause(20);assert.equal(documents.get(extensionRoot).elements.get('meter-one').data.metric,3);assert.deepEqual(documents.get(extensionRoot).elements.get('meter-one').data.raw,{retained:true});await click('重做');for(let i=0;i<100&&documents.get(extensionRoot).elements.get('meter-one').data.metric!==4;i++)await pause(20);assert.equal(documents.get(extensionRoot).elements.get('meter-one').data.metric,4);await call('Page.reload');await until(`document.querySelector('[data-id="meter-one"] [data-renderer-fixture="meter"]')?.textContent.includes('Metric 4')&&!!document.querySelector('[data-renderer-fixture="metric-edge"]')`,'registered renderer edits did not recover'); console.log('PASS Host-installed custom model/node/edge definitions render without core type switches; command-backed edits preserve raw fields and recover through undo/redo/reload'); + brokenStreamRoot=id(830);const brokenRoot=brokenStreamRoot; + documents.set(brokenRoot,createDocument(brokenRoot,[{id:brokenRoot,parent:null}],[{...node('prefix-visible',brokenRoot,80),data:{title:'Preserved prefix',text:'prefix-kept'}},node('tail-node',brokenRoot,450)]));versions.set(brokenRoot,1);boards.push({id:brokenRoot,workspace_id:workspace,kind:'board',version:1,title:'Interrupted board'}); + await call('Page.navigate',{url:`http://127.0.0.1:${port}/dashboard/${workspace}/${brokenRoot}`}); + await until(`document.querySelector('[data-canvas-loading-preview] textarea')?.value==='prefix-kept'&&!!document.querySelector('[role="alert"]')`,'malformed model tail hid the legal prefix or failed to report');assert.equal(await evaluate(`!!document.querySelector('[data-headless-canvas]')`),false);assert.equal(calls.filter(call=>call.method==='POST'&&call.path.includes('/'+brokenRoot+'/canvas/model/operations')).length,0); + assert.ok((await evaluate(`document.querySelector('[role="alert"]').textContent`)).includes('画布返回的数据无法解析')); + brokenStreamRoot=null;await evaluate(`[...document.querySelectorAll('button')].find(button=>button.textContent.trim()==='重新加载').click()`); + await until(`!!document.querySelector('[data-headless-canvas]')&&document.querySelectorAll('.react-flow__node').length===2&&!document.querySelector('[role="alert"]')`,'retry did not replace the failed prefix with a complete editor'); + console.log('PASS malformed model tail retains its legal read-only prefix, reports a readable error and retries into the complete editor without model writes'); + 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'); assert.ok(calls.some(call=>call.path.endsWith('/canvas/model')&&call.headers.accept==='application/vnd.foundry.canvas-model+ndjson;version=1')); @@ -337,7 +363,7 @@ try { console.log('PASS logout retires the runtime before dropping its view without a render error'); } } finally { - releaseImport();releaseApply();releaseUpload(); socket?.close(); + releaseFirstModel();releaseImport();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));