From 7ed0aa61cb49ead5a139e7e715b709ad8e7635fc Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Sun, 8 Nov 2026 15:59:15 +0800 Subject: [PATCH] feat(canvas): persist and execute deep duplicate tasks --- .../canvas/headless-graph-canvas.tsx | 10 ++- src/components/canvas/headless-imports.tsx | 4 +- src/lib/canvas-runtime/engine.ts | 39 +++++++++--- src/lib/canvas-runtime/import-duplicate.ts | 63 +++++++++++++++++++ src/lib/canvas-runtime/imports.ts | 7 ++- tests/headless/browser-storage-cases.mjs | 20 ++++++ tests/headless/imports-engine.test.mjs | 55 +++++++++++++++- tests/headless/renderer.integration.mjs | 8 +++ 8 files changed, 188 insertions(+), 18 deletions(-) create mode 100644 src/lib/canvas-runtime/import-duplicate.ts diff --git a/src/components/canvas/headless-graph-canvas.tsx b/src/components/canvas/headless-graph-canvas.tsx index 06c6493..7ba3eb1 100644 --- a/src/components/canvas/headless-graph-canvas.tsx +++ b/src/components/canvas/headless-graph-canvas.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; import { Background, BackgroundVariant, ConnectionMode, ReactFlow, ReactFlowProvider, useReactFlow, type Connection, type NodeProps } from "@xyflow/react"; -import { Download, Frame, Hand, ImagePlus, Maximize, Minus, MousePointer2, Plus, Redo2, Sparkles, StickyNote, Type, Undo2 } from "lucide-react"; +import { Copy, Download, Frame, Hand, ImagePlus, Maximize, Minus, MousePointer2, Plus, Redo2, Sparkles, StickyNote, Type, Undo2 } from "lucide-react"; import type { Board } from "@/lib/workspace"; import type { Data, ElementRecord, Geometry, Id, Json, Result } from "@/lib/canvas-core/types"; import { equal } from "@/lib/canvas-core/values"; @@ -39,6 +39,7 @@ function CanvasView({ engine, board, viewId }: { engine: ProjectEngine; board: B const liveView = sessions.views.get(viewId), lastView = useRef(liveView); if (liveView) lastView.current = liveView; const view = liveView ?? lastView.current!, flow = useReactFlow(); + const [copying, setCopying] = useState(false); const [showAI, setShowAI] = useState(false), [showRecovery, setShowRecovery] = useState(false); const [error, setError] = useState(""), [sources, setSources] = useState(new Map()); const pointerStart = useRef<{ x: number; y: number } | null>(null), drag = useRef<{ token: number; start: { x: number; y: number }; positions: Map } | null>(null); @@ -51,7 +52,7 @@ function CanvasView({ engine, board, viewId }: { engine: ProjectEngine; board: B for (const node of result.nodes) { const cached = measurements.current.get(node.id); if (cached && cached.width === node.width && cached.height === node.height) node.measured = cached.measured; } return result; }, [engine, root.document, sessions, view]); - const current = useRef({ root, projection, view }); current.current = { root, projection, view }; + const current = useRef({ root, projection, view, engine }); current.current = { root, projection, view, engine }; const report = useCallback((result: Result | { enabled: boolean; code?: string }) => { if ('status' in result && result.status === 'rejected' || 'enabled' in result && !result.enabled) setError(errorText(new Error(result.code))); }, []); @@ -194,6 +195,11 @@ function CanvasView({ engine, board, viewId }: { engine: ProjectEngine; board: B
report(engine.editor.history.undo())} /> report(engine.editor.history.redo())} /> + { + const captured = current.current, selection = [...captured.view.selection].map(id => captured.root.document.elements.get(id)).filter((item): item is ElementRecord => !!item).map(({id, incarnation}) => ({id, incarnation})); + setCopying(true); setError(""); + void engine.imports.duplicate(selection, {viewCanvasId: captured.view.canvasId}).catch(error => { if (current.current.engine === engine && current.current.view.id === viewId) setError(errorText(error)); }).finally(() => { if (current.current.engine === engine && current.current.view.id === viewId) setCopying(false); }); + }} /> flow.zoomOut()} /> flow.zoomIn()} /> flow.fitView({ padding: .2 })} /> { const snapshot = engine.getSnapshot(), url = URL.createObjectURL(new Blob([JSON.stringify({ documentFormatVersion: 1, projectId: snapshot.document.projectId, canvases: [...snapshot.document.canvases.values()], elements: [...snapshot.document.elements.values()], tombstones: Object.fromEntries(snapshot.document.tombstones) })], { type: 'application/json' })); const a = window.document.createElement('a'); a.href = url; a.download = board.title + '.json'; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); }} />

{blocked ? '有修改需要处理' : pending.length ? '正在同步…' : '已同步'}{blocked && <>}

diff --git a/src/components/canvas/headless-imports.tsx b/src/components/canvas/headless-imports.tsx index d6df2ce..a03e4e7 100644 --- a/src/components/canvas/headless-imports.tsx +++ b/src/components/canvas/headless-imports.tsx @@ -7,7 +7,7 @@ import { IconButton } from "@/components/ui/icon-button"; import { errorText } from "@/lib/api"; import styles from "./canvas.module.css"; -const reason = (code: string) => ({ invalid_json: "记录格式错误", invalid_json_value: "记录包含不支持的数据", invalid_utf8: "文件编码不是 UTF-8", record_too_large: "单条记录过大", unresolved_dependency: "引用的记录未加入", invalid_native_import_record: "不是画布记录", import_component_unavailable: "元素类型暂不支持", invalid_geometry: "元素尺寸不合法", invalid_endpoint: "连线端点已不存在", duplicate_import_source_key: "记录标识重复", import_source_unavailable: "导入源文件不可用", import_source_changed: "导入源或解析规则已变化", import_target_missing: "目标分区已不存在", import_target_changed: "目标分区已改变", import_dependency_changed: "引用对象已删除或恢复为新版本", dependency_capacity: "等待引用过多,请调整文件顺序", invalid_import_source: "文件过大或格式不支持" } as Record)[code] ?? "这条记录暂时无法加入"; +const reason = (code: string) => ({ invalid_json: "记录格式错误", invalid_json_value: "记录包含不支持的数据", invalid_utf8: "文件编码不是 UTF-8", record_too_large: "单条记录过大", unresolved_dependency: "引用的记录未加入", invalid_native_import_record: "不是画布记录", import_component_unavailable: "元素类型暂不支持", invalid_geometry: "元素尺寸不合法", invalid_endpoint: "连线端点已不存在", duplicate_import_source_key: "记录标识重复", import_source_unavailable: "导入源文件不可用", import_source_changed: "导入源或解析规则已变化", import_target_missing: "目标分区已不存在", import_target_changed: "目标分区已改变", import_dependency_changed: "引用对象已删除或恢复为新版本", dependency_capacity: "等待引用过多,请调整文件顺序", copy_external_changed: "原图引用已删除、移动或恢复为新版本", invalid_duplicate_source: "复制源数据不可用", invalid_import_source: "文件过大或格式不支持" } as Record)[code] ?? "这条记录暂时无法加入"; export function HeadlessImports({ engine, canvasId }: { engine: ProjectEngine; canvasId: string }) { const subscribe = useCallback((notify: () => void) => engine.editor.subscribe(notify), [engine]); @@ -22,7 +22,7 @@ export function HeadlessImports({ engine, canvasId }: { engine: ProjectEngine; c const file = event.target.files?.[0]; event.target.value = ""; if (!file) return; try { engine.imports.startNdjson(file, canvasId); setOpen(true); setError(""); } catch (error) { setError(errorText(error)); } }} /> - {jobs.length > 0 &&
setOpen(event.currentTarget.open)}>导入任务 · {jobs.reduce((count, job) => count + job.committedSourceKeys.length, 0)} 项已加入 + {jobs.length > 0 &&
setOpen(event.currentTarget.open)}>导入与复制任务 · {jobs.reduce((count, job) => count + job.committedSourceKeys.length, 0)} 项已加入 {jobs.map((job, index) =>
{job.source?.name || `导入任务 ${index + 1}`}

{job.status === "running" ? "正在加入" : job.status === "paused" ? "已暂停" : job.status === "cancelled" ? "已停止" : job.status === "partial" ? "部分加入" : "已加入画布"} · {job.committedSourceKeys.length} 项{job.pending.length > 0 && `,${job.pending.length} 项等待`}

diff --git a/src/lib/canvas-runtime/engine.ts b/src/lib/canvas-runtime/engine.ts index 2e11647..8c7cae9 100644 --- a/src/lib/canvas-runtime/engine.ts +++ b/src/lib/canvas-runtime/engine.ts @@ -1,7 +1,9 @@ +import { planDuplicateAsync } from './duplicate.ts'; +import { encodeDuplicateSource, prepareDuplicateImport, assertDuplicateExternal } from './import-duplicate.ts'; import { createImportJob, createImportRunner, createNdjsonImportSource, type ImportJob } from './imports.ts'; import { assertNativeImportDependencies, assertNativeImportTarget, prepareNativeImport } from './import-native.ts'; import { elisionBatchLimit, findElision } from './elision.ts'; -import type { Document, Id, Registry, Result, RunInput, RunApplyInput, RunCancelInput, Json, OperationIntent, Data } from '../canvas-core/types.ts'; +import type { Document, Id, Registry, Result, RunInput, RunApplyInput, RunCancelInput, Target, Point, Json, OperationIntent, Data } from '../canvas-core/types.ts'; import { createEditor, type EditorOptions, type Transaction } from '../canvas-core/editor.ts'; import { createDocument } from '../canvas-core/document.ts'; import { createSessions } from '../canvas-core/gestures.ts'; @@ -55,6 +57,7 @@ export async function openProjectEngine(options: EngineOptions) { let staged: readonly ResourceRecord[] = []; let applying: RunApplyInput | undefined; let importing: { job: ImportJob; expectedGeneration: number } | undefined; + const copyPreparations=new Map>(); const importRunners=new Map>(),importLoads=new Map>(),importHolds=new Mapvoid>>(); let recovery: { controller: AbortController; release(): void; cancel():Promise } | undefined; const assertWritable = () => { if(closing||closed)throw fault('engine_closing');if(recovery)throw fault('recovery_in_progress'); }; @@ -321,18 +324,18 @@ export async function openProjectEngine(options: EngineOptions) { if(importRunners.has(id)){void importRunners.get(id)!.start();return Promise.resolve();} const expectedFingerprint=getImport(id).sourceFingerprint; const task=(async()=>{ - const job=getImport(id),source=job.source;if(!source||source.kind!=='native-ndjson'||job.parserVersion!==1)throw fault('import_interpreter_unavailable'); + const job=getImport(id),source=job.source;if(!source||!['native-ndjson','duplicate-ndjson'].includes(source.kind)||job.parserVersion!==1)throw fault('import_interpreter_unavailable'); const bytes=fileWrites.get(source.fileId)??await options.storage.readFile(leader,source.fileId); if(closing||closed)return; const latest=getImport(id);if(latest.sourceFingerprint!==job.sourceFingerprint||latest.source?.fileId!==source.fileId||['completed','partial','cancelled'].includes(latest.status))return; if(!bytes)throw fault('import_source_unavailable');if(bytes.size!==source.byteLength)throw fault('import_source_changed'); const runner=createImportRunner({optimisticLocal:true,source:createNdjsonImportSource({bytes,fingerprint:job.sourceFingerprint}),getJob:()=>getImport(id),createId, - publish:publishImport,prepare:(line,job,reserve)=>prepareNativeImport(line,job,reserve,editor.getSnapshot().document,options.registry),persist,onError:report, + publish:publishImport,prepare:(line,job,reserve)=>job.source?.kind==='duplicate-ndjson'?prepareDuplicateImport(line,job,reserve):prepareNativeImport(line,job,reserve,editor.getSnapshot().document,options.registry),persist,onError:report, commit(units,next,expected){ assertWritable();if(importing)throw fault('reentrant_dispatch');const current=getImport(id);if(current.generation!==expected)throw fault('stale_import_job'); - try{assertNativeImportTarget(current,editor.getSnapshot().document);for(const unit of units)assertNativeImportDependencies(current,editor.getSnapshot().document,unit.dependencies);}catch(error){return reject(error);} + try{assertNativeImportTarget(current,editor.getSnapshot().document);for(const unit of units){assertNativeImportDependencies(current,editor.getSnapshot().document,unit.dependencies);if(current.source?.kind==='duplicate-ndjson')assertDuplicateExternal({cursor:0,nextCursor:1,key:unit.key,value:unit.guard},current,editor.getSnapshot().document);}}catch(error){return reject(error);} importing={job:next,expectedGeneration:expected}; - try{return editor.transaction(tx=>{for(const unit of units)for(const change of unit.changes){if(change.kind==='element'&&change.before===null&&change.after)tx.dispatch({type:'element.create',element:change.after});else if(change.kind==='canvas'&&change.before===null&&change.after)tx.dispatch({type:'canvas.create',canvas:change.after});else throw fault('invalid_native_import_unit');}},{label:'Import canvas content',groupId:`import:${id}`});}finally{importing=undefined;} + try{return editor.transaction(tx=>{for(const unit of units)for(const change of unit.changes){if(change.kind==='element'&&change.before===null&&change.after)tx.dispatch({type:'element.create',element:change.after});else if(change.kind==='canvas'&&change.before===null&&change.after)tx.dispatch({type:'canvas.create',canvas:change.after});else throw fault('invalid_native_import_unit');}},{label:current.source?.kind==='duplicate-ndjson'?'Duplicate selection':'Import canvas content',groupId:`import:${id}`});}finally{importing=undefined;} }, }); importRunners.set(id,runner);for(const releases of importHolds.values())releases.set(id,runner.hold()); @@ -343,7 +346,7 @@ export async function openProjectEngine(options: EngineOptions) { const imports=Object.freeze({ getJobs:()=>readMap(editor.getSnapshot().runtime.imports??new Map()), startNdjson(bytes:Blob,targetCanvasId:Id):Id{ - assertWritable();const canvas=editor.getSnapshot().document.canvases.get(targetCanvasId);if(!canvas)throw fault('canvas_not_loaded',[targetCanvasId]);if(!(bytes instanceof Blob)||bytes.size>64*1024*1024)throw fault('invalid_import_source'); + assertWritable();const permission=options.policy?.({type:'changes.apply',changes:[]},editor.getSnapshot().document);if(permission&&!permission.enabled)throw fault(permission.code,permission.targets);const canvas=editor.getSnapshot().document.canvases.get(targetCanvasId);if(!canvas)throw fault('canvas_not_loaded',[targetCanvasId]);if(!(bytes instanceof Blob)||bytes.size>64*1024*1024)throw fault('invalid_import_source'); const id=createId(),fileId=createId(),current=editor.getSnapshot().runtime;if(!id||!fileId||id===fileId||current.imports?.has(id)||current.resources.has(fileId)||fileWrites.has(fileId)||[...(current.imports?.values()??[])].some(job=>job.source?.fileId===fileId))throw fault('import_identity_conflict'); const entry=canvas.parent?editor.getSnapshot().document.elements.get(canvas.parent.entryElementId)!:undefined; const job=freeze({...createImportJob({id,projectId:options.scope.projectId,sourceFingerprint:`local-file:${fileId}:${bytes.size}`,parserVersion:1,source:{kind:'native-ndjson',name:((bytes as Blob&{name?:string}).name||'画布数据').slice(0,255),fileId,byteLength:bytes.size,targetCanvasId,...(entry?{targetEntry:{id:entry.id,incarnation:entry.incarnation}}:{})}}),status:'running' as const}); @@ -351,6 +354,26 @@ export async function openProjectEngine(options: EngineOptions) { const result=editor.updateRuntime(previous=>{const jobs=new Map(previous.imports??[]);jobs.set(id,job);return Object.freeze({...previous,imports:readMap(jobs)});}); if(result.status==='rejected'){fileWrites.delete(fileId);throw fault(result.code,result.targets);}void loadImport(id);return id; }, + async duplicate(selection:readonly Target[],settings:{readonly worldOffset?:Point;readonly signal?:AbortSignal;readonly viewCanvasId?:Id}={}):Promise{ + assertWritable();const permission=options.policy?.({type:'changes.apply',changes:[]},editor.getSnapshot().document);if(permission&&!permission.enabled)throw fault(permission.code,permission.targets);const captured=editor.getSnapshot(),target=settings.viewCanvasId??(selection[0]?captured.document.elements.get(selection[0].id)?.ownerCanvasId:undefined),signal=settings.signal,controller=new AbortController(),abort=()=>controller.abort(); + signal?.addEventListener('abort',abort,{once:true});if(signal?.aborted)controller.abort(); + const task=(async()=>{ + const plan=await planDuplicateAsync(captured.document,options.registry,selection,{createId,worldOffset:settings.worldOffset},{signal:controller.signal}); + const bytes=await encodeDuplicateSource(captured.document,options.registry,plan,controller.signal); + assertWritable();if(controller.signal.aborted)throw fault('copy_aborted');const permission=options.policy?.({type:'changes.apply',changes:plan.changes},editor.getSnapshot().document);if(permission&&!permission.enabled)throw fault(permission.code,permission.targets); + // Task belongs to the initiating view; copied records retain their mapped document owners. + if(!target)throw fault('empty_copy_selection'); + const canvas=editor.getSnapshot().document.canvases.get(target);if(!canvas)throw fault('copy_external_changed',[target]); + const id=createId(),fileId=createId(),current=editor.getSnapshot().runtime; + if(!id||!fileId||id===fileId||current.imports?.has(id)||current.resources.has(fileId)||fileWrites.has(fileId)||[...(current.imports?.values()??[])].some(job=>job.source?.fileId===fileId))throw fault('import_identity_conflict'); + const entry=canvas.parent?editor.getSnapshot().document.elements.get(canvas.parent.entryElementId)!:undefined; + const stableIds=Object.fromEntries([...Object.entries(plan.elementIds).map(([old,id])=>[`element:${old}`,id]),...Object.entries(plan.canvasIds).map(([old,id])=>[`canvas:${old}`,id])]); + const job=freeze({...createImportJob({id,projectId:options.scope.projectId,sourceFingerprint:`local-file:${fileId}:${bytes.size}`,parserVersion:1,source:{kind:'duplicate-ndjson',name:'复制选中内容',fileId,byteLength:bytes.size,targetCanvasId:target,copy:{sourceRevision:captured.revision,roots:plan.roots},...(entry?{targetEntry:{id:entry.id,incarnation:entry.incarnation}}:{})}}),stableIds,status:'running' as const}); + fileWrites.set(fileId,bytes);const result=editor.updateRuntime(previous=>{const jobs=new Map(previous.imports??[]);jobs.set(id,job);return Object.freeze({...previous,imports:readMap(jobs)});}); + if(result.status==='rejected'){fileWrites.delete(fileId);throw fault(result.code,result.targets);}void loadImport(id);return id; + })(); + copyPreparations.set(controller,task);void task.finally(()=>{copyPreparations.delete(controller);signal?.removeEventListener('abort',abort);}).catch(()=>{});return task; + }, async pause(id:Id){assertWritable();const runner=importRunners.get(id);if(runner)await runner.pause();else{const job=getImport(id);if(job.status==='running'){publishImport(freeze({...job,generation:job.generation+1,status:'paused'}),job.generation);await persist();}}}, async resume(id:Id){assertWritable();const job=getImport(id);if(['completed','partial','cancelled'].includes(job.status))return;if(job.status==='running'){await loadImport(id);return;}await importRunners.get(id)?.settle();await persist();await scheduler.resume();assertWritable();const current=getImport(id);if(current.status!=='paused')return;const next={...current,generation:current.generation+1,status:'running' as const};delete next.error;publishImport(freeze(next),current.generation);await loadImport(id);}, async cancel(id:Id){assertWritable();const runner=importRunners.get(id);if(runner)await runner.cancel();else{const job=getImport(id);if(['running','paused'].includes(job.status)){const next={...job,generation:job.generation+1,status:'cancelled' as const};delete next.error;publishImport(freeze(next),job.generation);await persist();}}}, @@ -520,10 +543,10 @@ export async function openProjectEngine(options: EngineOptions) { cancelRecovery:async()=>{await recovery?.cancel();}, getDurability: () => Object.freeze({ rootRevision: editor.getSnapshot().rootRevision, durableRootRevision }), retry: async () => { if(recovery)await recovery.cancel();assertWritable();await scheduler.resume();await runTracker.retry(); }, - async settle() { await Promise.resolve();while(importLoads.size)await Promise.all([...importLoads.values()]);await Promise.all([...importRunners.values()].map(runner=>runner.settle())); while (digestTasks.size) await Promise.all([...digestTasks.values()]); await scheduler.settle(); await runTracker.settle(); await persist(); }, + async settle() { await Promise.resolve();await Promise.allSettled([...copyPreparations.values()]);while(importLoads.size)await Promise.all([...importLoads.values()]);await Promise.all([...importRunners.values()].map(runner=>runner.settle())); while (digestTasks.size) await Promise.all([...digestTasks.values()]); await scheduler.settle(); await runTracker.settle(); await persist(); }, async close() { if (closed) return; - closing = true; sessions.dispose(); scheduler.stop(); runTracker.stop(); + closing = true;for(const controller of copyPreparations.keys())controller.abort();await Promise.allSettled([...copyPreparations.values()]); sessions.dispose(); scheduler.stop(); runTracker.stop(); await Promise.all([...importRunners.values()].map(runner=>runner.close()));while(importLoads.size)await Promise.all([...importLoads.values()]); recovery?.controller.abort();recovery?.release(); for (const release of [...displays]) release(); diff --git a/src/lib/canvas-runtime/import-duplicate.ts b/src/lib/canvas-runtime/import-duplicate.ts new file mode 100644 index 0000000..bd15759 --- /dev/null +++ b/src/lib/canvas-runtime/import-duplicate.ts @@ -0,0 +1,63 @@ +import type { CanvasRecord, Change, Document, Id, Json, Registry } from '../canvas-core/types.ts'; +import type { DuplicatePlan } from '../canvas-core/duplicate.ts'; +import { clone, equal, fault, validJson } from '../canvas-core/values.ts'; +import type { ImportJob, ImportLine, ImportUnit } from './imports.ts'; + +type ExternalElement = {id: Id; incarnation: number; ownerCanvasId: Id}; +type ExternalCanvas = {id: Id; parent: CanvasRecord['parent']; entryIncarnation?: number}; +type CopyLine = {key: string; change: Exclude; dependencies: string[]; externalElements: ExternalElement[]; externalCanvases: ExternalCanvas[]}; + +/** Internal source: already mapped records, with explicit guards for every retained external reference. */ +export async function encodeDuplicateSource(document: Document, registry: Registry, plan: DuplicatePlan, signal: AbortSignal): Promise { + const keys = new Map(),parts: BlobPart[] = []; + for (const [id,mapped] of Object.entries(plan.elementIds)) keys.set(mapped,`element:${id}`); + for (const [id,mapped] of Object.entries(plan.canvasIds)) keys.set(mapped,`canvas:${id}`); + let bytes=0,count=0; + for (const change of plan.changes) { + if(signal.aborted)throw fault('copy_aborted'); + if(change.kind==='field'||!change.after||change.before!==null)throw fault('invalid_duplicate_plan'); + const dependencies=new Set(),elements=new Map(),canvases=new Map(); + const canvas=(id:Id)=>{ + const key=keys.get(id);if(key){dependencies.add(key);return;} + let next:Id|undefined=id; + while(next&&!canvases.has(next)){ + const original=document.canvases.get(next);if(!original)throw fault('copy_external_changed',[next]); + const entry=original.parent?document.elements.get(original.parent.entryElementId):undefined; + canvases.set(next,{id:next,parent:clone(original.parent),...(entry?{entryIncarnation:entry.incarnation}:{})}); + if(entry)elements.set(entry.id,{id:entry.id,incarnation:entry.incarnation,ownerCanvasId:entry.ownerCanvasId}); + next=original.parent?.canvasId; + } + }; + const element=(id:Id)=>{const key=keys.get(id);if(key){dependencies.add(key);return;}const original=document.elements.get(id);if(!original)throw fault('copy_external_changed',[id]);elements.set(id,{id,incarnation:original.incarnation,ownerCanvasId:original.ownerCanvasId});canvas(original.ownerCanvasId);}; + if(change.kind==='canvas'){if(!change.after.parent)throw fault('invalid_duplicate_plan');canvas(change.after.parent.canvasId);element(change.after.parent.entryElementId);} + else { + const record=change.after;canvas(record.ownerCanvasId); + if(record.structure==='connection'){element(record.source.elementId);element(record.target.elementId);} + const schema=registry.components.get(record.componentId)?.copy;if(!schema)throw fault('copy_schema_unavailable'); + for(const ref of schema.references){let value:Json|undefined=record.data;for(const part of ref.path){if(!value||typeof value!=='object'||!Object.hasOwn(value,part)){value=undefined;break;}value=(value as Record)[part];}if(value===undefined||value===null)continue;for(const id of ref.many?value as Json[]:[value]){if(typeof id!=='string')throw fault('invalid_copy_reference');if(ref.target==='element')element(id);else canvas(id);}} + } + const key=keys.get(change.id);if(!key)throw fault('invalid_duplicate_plan');dependencies.delete(key); + const line:CopyLine={key,change,dependencies:[...dependencies],externalElements:[...elements.values()],externalCanvases:[...canvases.values()]}; + const text=JSON.stringify(line)+'\n',size=new TextEncoder().encode(text).byteLength;if(size>256*1024)throw fault('record_too_large');bytes+=size;if(bytes>64*1024*1024)throw fault('invalid_import_source');parts.push(text); + if(++count%32===0)await new Promise(resolve=>setTimeout(resolve,0)); + } + if(signal.aborted)throw fault('copy_aborted');return new Blob(parts,{type:'application/x-ndjson'}); +} + +function copyLine(line:ImportLine,job:ImportJob):CopyLine { + const value=line.value as unknown as CopyLine; + if(job.source?.kind!=='duplicate-ndjson'||!value||!validJson(value)||typeof value.key!=='string'||!Array.isArray(value.dependencies)||!Array.isArray(value.externalElements)||!Array.isArray(value.externalCanvases)||!value.change||value.change.kind!=='element'&&value.change.kind!=='canvas'||value.change.before!==null||!value.change.after||!Object.hasOwn(job.stableIds,value.key)||job.stableIds[value.key]!==value.change.id||value.change.after.id!==value.change.id||!value.key.startsWith(`${value.change.kind}:`))throw fault('invalid_duplicate_source'); + if(value.change.kind==='element'&&value.change.after?.incarnation!==1)throw fault('invalid_duplicate_source'); + return value; +} +export function prepareDuplicateImport(line:ImportLine,job:ImportJob,reserve:(key:string)=>Id):ImportUnit { + const value=copyLine(line,job);if(reserve(value.key)!==value.change.id)throw fault('invalid_duplicate_source'); + for(const key of value.dependencies)if(!Object.hasOwn(job.stableIds,key))throw fault('invalid_duplicate_source'); + return{key:value.key,dependencies:value.dependencies,changes:[value.change],guard:clone(line.value!)}; +} +/** Check at the synchronous commit boundary, never only when the source was first parsed. */ +export function assertDuplicateExternal(line:ImportLine,job:ImportJob,document:Document){ + const value=copyLine(line,job); + for(const guard of value.externalElements){const record=document.elements.get(guard.id);if(!record||record.incarnation!==guard.incarnation||record.ownerCanvasId!==guard.ownerCanvasId)throw fault('copy_external_changed',[guard.id]);} + for(const guard of value.externalCanvases){const record=document.canvases.get(guard.id);if(!record||!equal(record.parent,guard.parent)||guard.parent&&document.elements.get(guard.parent.entryElementId)?.incarnation!==guard.entryIncarnation)throw fault('copy_external_changed',[guard.id]);} +} diff --git a/src/lib/canvas-runtime/imports.ts b/src/lib/canvas-runtime/imports.ts index 2966449..992c50b 100644 --- a/src/lib/canvas-runtime/imports.ts +++ b/src/lib/canvas-runtime/imports.ts @@ -3,10 +3,10 @@ import { clone, equal, fault, freeze, validJson } from '../canvas-core/values.ts export type ImportLine = { readonly cursor: number; readonly nextCursor: number; readonly key: string; readonly value?: Json; readonly error?: string; readonly skip?: boolean }; export type ImportSource = { readonly fingerprint: string; readonly parserVersion: number; read(cursor: number, signal: AbortSignal): Promise }; -export type ImportUnit = { readonly key: string; readonly dependencies: readonly string[]; readonly changes: readonly Change[] }; +export type ImportUnit = { readonly key: string; readonly dependencies: readonly string[]; readonly changes: readonly Change[]; readonly guard?: Json }; export type ImportLocator = { readonly key: string; readonly cursor: number; readonly nextCursor: number; readonly dependencies: readonly string[]; readonly digest: string; readonly byteLength: number; readonly changeCount: number }; export type ImportFailure = { readonly key: string; readonly cursor: number; readonly code: string; readonly dependencies?: readonly string[] }; -export type NativeImportSource = { readonly kind: 'native-ndjson'; readonly name?:string; readonly fileId: Id; readonly byteLength: number; readonly targetCanvasId: Id; readonly targetEntry?: {readonly id:Id;readonly incarnation:number} }; +export type NativeImportSource = { readonly kind: 'native-ndjson' | 'duplicate-ndjson'; readonly copy?: {readonly sourceRevision:number; readonly roots:readonly Id[]}; readonly name?:string; readonly fileId: Id; readonly byteLength: number; readonly targetCanvasId: Id; readonly targetEntry?: {readonly id:Id;readonly incarnation:number} }; export type ImportBinding = {readonly kind:'element';readonly incarnation:number}|{readonly kind:'canvas';readonly entryElementId?:Id}; export type ImportJob = { readonly version: 1; readonly id: Id; readonly projectId: Id; readonly sourceFingerprint: string; readonly parserVersion: number; @@ -24,9 +24,10 @@ export function validateImportJob(job: ImportJob): void { if (job.version !== 1) throw fault('import_interpreter_unavailable'); createImportJob(job); if (!Number.isSafeInteger(job.generation) || job.generation<0 || !Number.isSafeInteger(job.cursor) || job.cursor<0 || typeof job.eof!=='boolean' || !['paused','running','completed','partial','cancelled'].includes(job.status)) throw fault('invalid_import_job'); - if(job.source&&(job.source.kind!=='native-ndjson'||job.source.name!==undefined&&(typeof job.source.name!=='string'||job.source.name.length>255)||!job.source.fileId||!job.source.targetCanvasId||!Number.isSafeInteger(job.source.byteLength)||job.source.byteLength<0||job.source.targetEntry&&(!job.source.targetEntry.id||!Number.isSafeInteger(job.source.targetEntry.incarnation)||job.source.targetEntry.incarnation<1)))throw fault('invalid_import_source'); + if(job.source&&(!['native-ndjson','duplicate-ndjson'].includes(job.source.kind)||job.source.kind==='duplicate-ndjson'&&(!job.source.copy||!Number.isSafeInteger(job.source.copy.sourceRevision)||job.source.copy.sourceRevision<0||!Array.isArray(job.source.copy.roots)||!job.source.copy.roots.length||job.source.copy.roots.some(id=>typeof id!=='string'||!id))||job.source.name!==undefined&&(typeof job.source.name!=='string'||job.source.name.length>255)||!job.source.fileId||!job.source.targetCanvasId||!Number.isSafeInteger(job.source.byteLength)||job.source.byteLength<0||job.source.targetEntry&&(!job.source.targetEntry.id||!Number.isSafeInteger(job.source.targetEntry.incarnation)||job.source.targetEntry.incarnation<1)))throw fault('invalid_import_source'); const keys=new Set(),ids=Object.values(job.stableIds),stableIdSet=new Set(ids); if (stableIdSet.size!==ids.length || ids.some(id=>typeof id!=='string'||!id) || Object.keys(job.stableIds).some(key=>!key)) throw fault('invalid_import_job'); + if(job.source?.kind==='duplicate-ndjson'&&(!job.source.copy||new Set(job.source.copy.roots).size!==job.source.copy.roots.length||job.source.copy.roots.some(id=>!stableIdSet.has(id))))throw fault('invalid_import_source'); for(const[id,binding]of Object.entries(job.bindings??{}))if(!id||!binding||!stableIdSet.has(id)||!['element','canvas'].includes(binding.kind)||binding.kind==='element'&&(!Number.isSafeInteger(binding.incarnation)||binding.incarnation<1)||binding.kind==='canvas'&&binding.entryElementId!==undefined&&typeof binding.entryElementId!=='string')throw fault('invalid_import_job'); for(const key of job.committedSourceKeys){if(!key||keys.has(key))throw fault('invalid_import_job');keys.add(key);} if(job.source)for(const key of job.committedSourceKeys){const id=job.stableIds[key],binding=job.bindings?.[id];if(!Object.hasOwn(job.stableIds,key)||!binding||!key.startsWith(`${binding.kind}:`)||binding.kind==='canvas'&&(!binding.entryElementId||job.bindings?.[binding.entryElementId]?.kind!=='element'))throw fault('invalid_import_job');} diff --git a/tests/headless/browser-storage-cases.mjs b/tests/headless/browser-storage-cases.mjs index b54ddd9..c0bf4e7 100644 --- a/tests/headless/browser-storage-cases.mjs +++ b/tests/headless/browser-storage-cases.mjs @@ -307,6 +307,24 @@ async function resourceRecoveryCase(name, assert) { } +async function duplicateRecoveryCase(name,assert){ + const root=crypto.randomUUID(),child=crypto.randomUUID(),workspace=crypto.randomUUID(),account=crypto.randomUUID(),registry=createRegistry(builtinComponents()),database=`${name}-duplicate`,storage=createBrowserStorage({name:database}); + const node=(id,owner=root,componentId='text')=>({id,incarnation:1,structure:'node',ownerCanvasId:owner,componentId,dataVersion:1,data:{title:id,text:'',...(componentId==='image'?{assetId:'shared',src:'asset:shared',runId:'do-not-run'}:{})},geometry:{x:0,y:0,width:200,height:100,rotation:0}}); + const edge={id:'incoming',incarnation:1,structure:'connection',ownerCanvasId:root,componentId:'connection',dataVersion:1,source:{elementId:'C'},target:{elementId:'F'},path:null,data:{title:''}},original=createDocument(root,[{id:root,parent:null},{id:child,parent:{canvasId:root,entryElementId:'F'}}],[node('C'),node('F',root,'frame'),node('A',child,'image'),edge]); + let document=original,versions=new Map([[root,1],[child,1]]),engine; + const model=participants=>({documentFormatVersion:1,projectId:root,complete:false,removedCanvasIds:[],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)]))}); + const transport=async request=>{const body=JSON.parse(request.body);for(const[id,version]of Object.entries(body.expected_versions))assert(version===(versions.get(id)??0),'copy lost expected version');for(const operation of body.operations)document=applyChanges(document,operation.changes);for(const id of Object.keys(body.expected_versions))versions.set(id,(versions.get(id)??0)+1);return{status:200,body:{protocol_version:2,request_id:request.requestId,status:'applied',operation_ids:body.operations.map(operation=>operation.id),model:model(Object.keys(body.expected_versions))}};}; + const options={scope:{accountId:account,workspaceId:workspace,projectId:root},registry,document:original,versions:new Map(versions),storage,persistence:createFoundryPersistence({workspaceId:workspace,rootId:root,registry,transport})}; + try{ + engine=await openProjectEngine(options);const id=await engine.imports.duplicate([{id:'F',incarnation:1},{id:'A',incarnation:1}]); + await new Promise(resolve=>{if(engine.imports.getJobs().get(id).committedSourceKeys.length){resolve();return;}const release=engine.editor.subscribe((_,snapshot)=>{if(snapshot.runtime.imports.get(id).committedSourceKeys.length===1){release();resolve();}});});await engine.imports.pause(id);await engine.settle(); + const before=engine.imports.getJobs().get(id),copied=before.stableIds['element:F'];assert(before.status==='paused','copy pause lost');engine.editor.dispatch({type:'element.data',target:{id:copied,incarnation:1},patch:{title:'edited copy'}});await engine.settle();await engine.close();engine=await openProjectEngine(options); + const restored=engine.imports.getJobs().get(id);assert(restored.source.kind==='duplicate-ndjson'&&JSON.stringify(restored.stableIds)===JSON.stringify(before.stableIds),'copy source or mapping changed on reopen');await engine.imports.resume(id);await engine.settle();const job=engine.imports.getJobs().get(id),image=document.elements.get(job.stableIds['element:A']),incoming=document.elements.get(job.stableIds['element:incoming']); + assert(job.status==='completed'&&job.failures.length===0,'copy did not complete after reopening');assert(document.elements.get(copied).data.title==='edited copy','copy replay overwrote an edit');assert(image.data.assetId==='shared'&&!Object.hasOwn(image.data,'runId'),'copy reissued image execution');assert(incoming.source.elementId==='C'&&incoming.target.elementId===copied&&incoming.ownerCanvasId===root,'copy lost upper incoming source');assert(document.elements.has('incoming')&&document.canvases.has(job.stableIds[`canvas:${child}`]),'copy changed originals or lost its child canvas'); + assert(engine.editor.history.undo().status==='applied','copy undo failed');await engine.settle();const count=document.elements.size;await engine.close();engine=await openProjectEngine(options);await engine.settle();assert(document.elements.size===count&&!document.elements.has(job.stableIds['element:A']),'terminal copy revived undone records'); + }finally{await engine?.close();storage.close();indexedDB.deleteDatabase(database);} +} + async function nativeImportRecoveryCase(name,assert){ const root=crypto.randomUUID(),workspace=crypto.randomUUID(),account=crypto.randomUUID(),registry=createRegistry(builtinComponents()),original=createDocument(root,[{id:root,parent:null}],[]),database=`${name}-native-import`,storage=createBrowserStorage({name:database}),calls=[];let document=original,versions=new Map([[root,1]]),engine; const model=(complete,participants=[...versions.keys()])=>({documentFormatVersion:1,projectId:root,complete,removedCanvasIds:participants.filter(id=>!document.canvases.has(id)),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)]))}); @@ -496,6 +514,8 @@ export async function run() { results.push('staged file and image recover from real IndexedDB; lost upload ACK queries stable resource identity'); await cooperativeCopyCase(assert); results.push('cooperative duplicate planning yields to actual browser tasks and cancellation without mutating the source'); + await duplicateRecoveryCase(name,assert); + results.push('deep copy source/mapping survive IndexedDB pause/reopen/resume, retain C to F-prime and shared assets, preserve edits and do not revive undone content'); await nativeImportRecoveryCase(name,assert); results.push('native import source/cursor/IDs survive real IndexedDB pause/reopen/resume, preserve user edits, report partial failures and do not revive undone records'); await elisionRecoveryCase(name,assert); diff --git a/tests/headless/imports-engine.test.mjs b/tests/headless/imports-engine.test.mjs index 19884af..7434f7f 100644 --- a/tests/headless/imports-engine.test.mjs +++ b/tests/headless/imports-engine.test.mjs @@ -8,11 +8,11 @@ const node=id=>({kind:'element',key:id,record:{id,incarnation:8,structure:'node' const file=records=>new Blob([records.map(value=>typeof value==='string'?value:JSON.stringify(value)).join('\n')]); const deferred=()=>{let resolve;const promise=new Promise(yes=>resolve=yes);return{promise,resolve};}; function fixture(){ - let image=null,leader,next=0,server=original,version=1;const files=new Map(),requests=[],receipts=new Map(),errors=[]; + let image=null,leader,next=0,server=original;const versions=new Map([['canvas',1]]);const files=new Map(),requests=[],receipts=new Map(),errors=[]; const f={files,requests,errors,gate:null,broken:false, storage:{async claim(){assert.equal(leader,undefined);const value={scope,fence:1,active:true,async release(){leader=undefined;value.active=false;}};leader=value;return value;},async recover(){return image&&structuredClone(image);},async commit(owner,batch){assert.equal(owner,leader);if(f.broken)throw Error('quota');for(const part of batch.files??[])files.set(part.id,part.bytes);image=structuredClone(batch.image);return{durableRootRevision:batch.image.rootRevision};},async readFile(owner,id){assert.equal(owner,leader);return files.get(id);},close(){}}, - port:{compile(intent,requestId,snapshot){return{payload:{requestId,intent},canvasIds:intent.canvasIds,expectedVersions:Object.fromEntries(intent.canvasIds.map(id=>[id,snapshot.runtime.versions.get(id)??0]))};},async submit(request){requests.push(request);if(f.gate)await f.gate.promise;const{intent}=JSON.parse(request.body);if(!receipts.has(request.id)){server=applyChanges(server,intent.changes);version++;receipts.set(request.id,{document:server,versions:new Map([['canvas',version]])});}return{outcome:'applied',canonical:{requestId:request.id}};},async query(request){return receipts.has(request.id)?{outcome:'applied',canonical:{requestId:request.id}}:{outcome:'unresolved'};},canonical(request){return receipts.get(request.id);},async read(){return{document:server,versions:new Map([['canvas',version]])};}}, - image:()=>image,server:()=>server,open:()=>openProjectEngine({scope,registry,document:original,versions:new Map([['canvas',1]]),storage:f.storage,persistence:f.port,createId:()=>`identity-${++next}`,onError:error=>errors.push(error)})};return f; + port:{compile(intent,requestId,snapshot){return{payload:{requestId,intent},canvasIds:intent.canvasIds,expectedVersions:Object.fromEntries(intent.canvasIds.map(id=>[id,snapshot.runtime.versions.get(id)??0]))};},async submit(request){requests.push(request);if(f.gate)await f.gate.promise;const{intent}=JSON.parse(request.body);if(!receipts.has(request.id)){server=applyChanges(server,intent.changes);for(const canvasId of intent.canvasIds)versions.set(canvasId,(versions.get(canvasId)??0)+1);receipts.set(request.id,{document:server,versions:new Map(versions)});}return{outcome:'applied',canonical:{requestId:request.id}};},async query(request){return receipts.has(request.id)?{outcome:'applied',canonical:{requestId:request.id}}:{outcome:'unresolved'};},canonical(request){return receipts.get(request.id);},async read(){return{document:server,versions:new Map(versions)};}}, + image:()=>image,server:()=>server,open:(extra={})=>openProjectEngine({scope,registry,document:original,versions:new Map([['canvas',1]]),storage:f.storage,persistence:f.port,createId:()=>`identity-${++next}`,onError:error=>errors.push(error),...extra})};return f; } function until(engine,predicate){if(predicate(engine.getSnapshot()))return Promise.resolve();return new Promise((resolve,reject)=>{const timer=setTimeout(()=>{unsubscribe();reject(Error('import condition timed out'));},5000),unsubscribe=engine.editor.subscribe((_,snapshot)=>{if(predicate(snapshot)){clearTimeout(timer);unsubscribe();resolve();}});});} @@ -76,3 +76,52 @@ test('resuming after a failed source write preserves IDs and wakes the original const f=fixture(),engine=await f.open(); try{await engine.persist();f.broken=true;const id=engine.imports.startNdjson(file([node('one'),node('two')]),'canvas');await until(engine,snapshot=>snapshot.runtime.imports.get(id).status==='paused');const first=engine.imports.getJobs().get(id).stableIds['element:one'];assert.equal(f.requests.length,0);assert.equal(engine.getSnapshot().document.elements.size,1);f.broken=false;await engine.imports.resume(id);await engine.settle();await until(engine,()=>f.server().elements.size===2);assert.equal(engine.imports.getJobs().get(id).status,'completed');assert.equal(engine.imports.getJobs().get(id).stableIds['element:one'],first);assert.equal(f.files.size,1);assert.equal(new Set(f.requests.map(request=>request.id)).size,f.requests.length);}finally{f.broken=false;await engine.close();} }); + +const copyNode=(id,owner='canvas',componentId='text')=>({id,incarnation:1,structure:'node',ownerCanvasId:owner,componentId,dataVersion:1,data:{title:id,text:'source',...(componentId==='image'?{assetId:'shared',src:'asset:shared',runId:'paid-run',taskId:'task'}:{})},geometry:{x:0,y:0,width:200,height:100,rotation:0}}); +async function seedCopy(engine){ + const records=[copyNode('C'),copyNode('F','canvas','frame'),copyNode('A','child','image'),copyNode('B','child')]; + const edge={id:'incoming',incarnation:1,structure:'connection',ownerCanvasId:'canvas',componentId:'connection',dataVersion:1,source:{elementId:'C'},target:{elementId:'F'},path:null,data:{title:''}}; + const result=engine.editor.transaction(tx=>{tx.dispatch({type:'element.create',element:records[0]});tx.dispatch({type:'element.create',element:records[1]});tx.dispatch({type:'canvas.create',canvas:{id:'child',parent:{canvasId:'canvas',entryElementId:'F'}}});for(const element of records.slice(2))tx.dispatch({type:'element.create',element});tx.dispatch({type:'element.create',element:edge});},{label:'Seed copy source'});assert.equal(result.status,'applied');await engine.settle(); +} + +test('deep copy source/mapping recover and preserve upper incoming source, shared assets and user edits',async()=>{ + const f=fixture();let engine=await f.open(); + try{ + await seedCopy(engine);const id=await engine.imports.duplicate([{id:'F',incarnation:1},{id:'A',incarnation:1}]); + await until(engine,snapshot=>snapshot.runtime.imports.get(id).committedSourceKeys.length===1);await engine.imports.pause(id);await engine.settle(); + const before=engine.imports.getJobs().get(id),copied=before.stableIds['element:F'];assert.equal(before.source.kind,'duplicate-ndjson');assert.equal(before.source.copy.roots.length,1);assert.equal(before.status,'paused'); + engine.editor.dispatch({type:'element.data',target:{id:copied,incarnation:1},patch:{title:'edited copy'}});await engine.settle();await engine.close();engine=await f.open();await engine.imports.resume(id);await engine.settle(); + const job=engine.imports.getJobs().get(id),document=engine.getSnapshot().document,ids=job.stableIds;assert.equal(job.status,'completed');assert.deepEqual(ids,before.stableIds);assert.equal(document.elements.get(copied).data.title,'edited copy'); + assert.equal(document.canvases.get(ids['canvas:child']).parent.entryElementId,copied);assert.equal(document.elements.get(ids['element:A']).ownerCanvasId,ids['canvas:child']);assert.equal(document.elements.get(ids['element:A']).data.assetId,'shared');assert.equal(document.elements.get(ids['element:A']).data.runId,undefined);assert.equal(document.elements.get(ids['element:A']).data.taskId,undefined); + const edge=document.elements.get(ids['element:incoming']);assert.equal(edge.source.elementId,'C');assert.equal(edge.target.elementId,copied);assert.equal(edge.ownerCanvasId,'canvas');assert.ok(document.elements.has('incoming'));assert.equal(job.failures.length,0);assert.equal(f.errors.length,0);assert.ok(f.server().elements.has(ids['element:incoming']));decodeRecovery(f.image(),registry); + }finally{await engine.close();} +}); + +test('late copied edge cannot attach to an external endpoint deleted and restored as a new incarnation',async()=>{ + const f=fixture(),engine=await f.open();try{await seedCopy(engine);const id=await engine.imports.duplicate([{id:'F',incarnation:1}]);await until(engine,snapshot=>snapshot.runtime.imports.get(id).committedSourceKeys.length===1);await engine.imports.pause(id);await engine.settle(); + assert.equal(engine.editor.dispatch({type:'element.remove',target:{id:'C',incarnation:1}}).status,'applied');assert.equal(engine.editor.history.undo().status,'applied');assert.equal(engine.getSnapshot().document.elements.get('C').incarnation,2);await engine.settle();await engine.imports.resume(id);await engine.settle(); + const job=engine.imports.getJobs().get(id);assert.equal(job.status,'partial');assert.ok(job.failures.some(failure=>failure.code==='copy_external_changed'));assert.equal(engine.getSnapshot().document.elements.has(job.stableIds['element:incoming']),false);assert.ok(engine.getSnapshot().document.elements.has(job.stableIds['element:A'])); + }finally{await engine.close();} +}); + +test('copy cancellation retains the published prefix and does not resume on reopening',async()=>{ + const f=fixture();let engine=await f.open();try{await seedCopy(engine);const initial=engine.getSnapshot().document.elements.size,id=await engine.imports.duplicate([{id:'F',incarnation:1}]);await until(engine,snapshot=>snapshot.runtime.imports.get(id).committedSourceKeys.length===1);await engine.imports.cancel(id);await engine.settle();await engine.close();engine=await f.open();await engine.imports.resume(id);await engine.settle();assert.equal(engine.imports.getJobs().get(id).status,'cancelled');assert.equal(engine.getSnapshot().document.elements.size,initial+1);}finally{await engine.close();} +}); + +test('preparation abort and project close do not admit a late copy job',async()=>{ + const f=fixture(),engine=await f.open();await seedCopy(engine);const controller=new AbortController();controller.abort();await assert.rejects(engine.imports.duplicate([{id:'F',incarnation:1}],{signal:controller.signal}),/copy_aborted/);assert.equal(engine.imports.getJobs().size,0); + const pending=engine.imports.duplicate([{id:'F',incarnation:1}]);const rejected=assert.rejects(pending,/copy_aborted|engine_closing/);await engine.close();await rejected;const reopened=await f.open();try{assert.equal(reopened.imports.getJobs().size,0);}finally{await reopened.close();} +}); + +test('copy publishes independently of backend ACK and associates task visibility with its initiating view',async()=>{ + const f=fixture(),engine=await f.open(),gate=deferred();try{await seedCopy(engine);f.gate=gate;const before=f.server().elements.size,id=await engine.imports.duplicate([{id:'A',incarnation:1}],{viewCanvasId:'canvas'});await until(engine,snapshot=>snapshot.runtime.imports.get(id).status==='completed');const job=engine.imports.getJobs().get(id);assert.equal(job.source.targetCanvasId,'canvas');assert.equal(engine.getSnapshot().document.elements.get(job.stableIds['element:A']).ownerCanvasId,'child');assert.equal(f.server().elements.size,before);gate.resolve();await engine.settle();assert.equal(f.server().elements.size,before+1);assert.equal(f.errors.length,0);}finally{gate.resolve();await engine.close();} +}); + +test('copy source write failure gates network and resume preserves the planned identities',async()=>{ + const f=fixture(),engine=await f.open();try{await seedCopy(engine);const sent=f.requests.length;f.broken=true;const id=await engine.imports.duplicate([{id:'F',incarnation:1}]);await until(engine,snapshot=>snapshot.runtime.imports.get(id).status==='paused');const before=engine.imports.getJobs().get(id);assert.equal(f.requests.length,sent);f.broken=false;await engine.imports.resume(id);await engine.settle();assert.equal(engine.imports.getJobs().get(id).status,'completed');assert.deepEqual(engine.imports.getJobs().get(id).stableIds,before.stableIds);}finally{f.broken=false;await engine.close();} +}); + + +test('read-only policy rejects copy/import admission without storing a source or a running job',async()=>{ + const f=fixture(),engine=await f.open({policy:()=>({enabled:false,code:'read_only',targets:[]})});try{await assert.rejects(engine.imports.duplicate([{id:'missing',incarnation:1}]),/read_only/);assert.throws(()=>engine.imports.startNdjson(file([node('one')]),'canvas'),/read_only/);assert.equal(engine.imports.getJobs().size,0);await engine.settle();assert.equal(f.files.size,0);assert.equal(f.requests.length,0);}finally{await engine.close();} +}); diff --git a/tests/headless/renderer.integration.mjs b/tests/headless/renderer.integration.mjs index 5d9b4ba..eaf6f54 100644 --- a/tests/headless/renderer.integration.mjs +++ b/tests/headless/renderer.integration.mjs @@ -240,6 +240,14 @@ try { for(let i=0;i<200&&documents.get(root).elements.size!==beforeImport+1;i++)await pause(20);assert.equal(documents.get(root).elements.size,beforeImport+1);await pause(200);await call('Page.reload');await until(`document.querySelectorAll('.react-flow__node').length===6&&document.querySelector('[aria-label="画布导入"]')?.textContent.includes('部分加入')`,'import task and content did not recover after reload'); await click('撤销');await until(`document.querySelectorAll('.react-flow__node').length===5`,'import undo did not remove its successful content');await pause(200);await call('Page.reload');await until(`document.querySelectorAll('.react-flow__node').length===5`,'import recovered an undone record'); console.log('PASS actual NDJSON import displays before backend ACK, reports a failed record, restores task/source state and never revives undone content'); + await evaluate(`document.querySelector('.react-flow__node[data-id=b]').dispatchEvent(new MouseEvent('click',{bubbles:true}))`); + await until(`!document.querySelector('[aria-label="深复制选中内容"]').disabled`,'copy selection button remained disabled'); + await click('深复制选中内容');await until(`document.querySelectorAll('.react-flow__node').length===6&&document.querySelector('[aria-label="画布导入"]').textContent.includes('复制选中内容')`,'copy did not publish through the durable task pipeline'); + await until(`document.querySelector('[aria-label="深复制选中内容"]')&&!document.querySelector('[aria-label="正在准备复制"]')`,'copy preparation did not finish'); + await pause(300);await call('Page.reload');await until(`document.querySelectorAll('.react-flow__node').length===6&&document.querySelector('[aria-label="画布导入"]').textContent.includes('复制选中内容')`,'copy task did not recover after reload'); + await click('撤销');await until(`document.querySelectorAll('.react-flow__node').length===5`,'copy history did not undo its published content');await pause(200);await call('Page.reload');await until(`document.querySelectorAll('.react-flow__node').length===5`,'copy replay revived an undone record'); + console.log('PASS actual deep copy toolbar action publishes a durable task, survives reload and does not revive its undone content'); + 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');