diff --git a/src/lib/canvas-core/document.ts b/src/lib/canvas-core/document.ts index 2ebb46d..b7aed37 100644 --- a/src/lib/canvas-core/document.ts +++ b/src/lib/canvas-core/document.ts @@ -34,12 +34,14 @@ export function commonCanvas(document: Document, left: Id, right: Id): Id { if (!result) throw fault('no_common_canvas', [left, right]); return result; } -export function validateDocument(document: Document, registry: Registry): void { +/** Cooperative validation over an immutable document; synchronous callers drain the same checks. */ +export function* validateDocumentSteps(document: Document, registry: Registry): Generator { if (!document.canvases.size) throw fault('missing_root'); - for (const [id, incarnation] of document.tombstones) if (!id || !Number.isSafeInteger(incarnation) || incarnation < 1) throw fault('invalid_tombstone', [id]); + for (const [id, incarnation] of document.tombstones) { yield; if (!id || !Number.isSafeInteger(incarnation) || incarnation < 1) throw fault('invalid_tombstone', [id]); } const entries = new Set(); let roots = 0; for (const canvas of document.canvases.values()) { + yield; if (typeof canvas.id !== 'string' || !canvas.id || !Object.hasOwn(canvas,'parent') || canvas.parent !== null && (!canvas.parent || typeof canvas.parent.canvasId !== 'string' || typeof canvas.parent.entryElementId !== 'string')) throw fault('invalid_canvas'); ancestors(document, canvas.id); if (!canvas.parent) { roots++; continue; } @@ -49,6 +51,7 @@ export function validateDocument(document: Document, registry: Registry): void { } 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]); @@ -64,6 +67,9 @@ export function validateDocument(document: Document, registry: Registry): void { } } } +export function validateDocument(document: Document, registry: Registry): void { + for (const _ of validateDocumentSteps(document, registry)) { /* Drain identical validation without scheduling. */ } +} export function applyChanges(document: Document, changes: readonly Change[], conditional = true): Document { const elements = new Map(document.elements), canvases = new Map(document.canvases), tombstones = new Map(document.tombstones); for (const change of changes) { diff --git a/src/lib/canvas-core/duplicate.ts b/src/lib/canvas-core/duplicate.ts index dca1eec..7741030 100644 --- a/src/lib/canvas-core/duplicate.ts +++ b/src/lib/canvas-core/duplicate.ts @@ -1,5 +1,5 @@ import type { CanvasRecord, Change, Data, Document, ElementRecord, Id, Json, Point, Registry, Target } from './types.ts'; -import { ancestors, applyChanges, commonCanvas, targetElement, validateDocument } from './document.ts'; +import { ancestors, commonCanvas, targetElement, validateDocumentSteps } from './document.ts'; import { connectionPathValid } from './features.ts'; import { canvasTransform, convertPath, toLocal, toWorld } from './coordinates.ts'; import { clone, fault, freeze } from './values.ts'; @@ -34,45 +34,51 @@ export function remapCopyData(element: ElementRecord, registry: Registry, elemen } /** Pure, complete-snapshot planning. Runtime owns time slicing, durable source/mapping and partial progress. */ -export function planDuplicate(document: Document, registry: Registry, selection: readonly Target[], options: DuplicateOptions): DuplicatePlan { - validateDocument(document, registry); +export function* planDuplicateSteps(document: Document, registry: Registry, selection: readonly Target[], options: DuplicateOptions): Generator { + yield* validateDocumentSteps(document, registry); const offset = options.worldOffset ?? { x: 32, y: 32 }; if (![offset.x, offset.y].every(Number.isFinite)) throw fault('invalid_copy_offset'); - const selected = new Set(selection.map(target => targetElement(document, target).id)); + const selected = new Set(); + for (const target of selection) { yield; selected.add(targetElement(document, target).id); } if (!selected.size) throw fault('empty_copy_selection'); const childByEntry = new Map(), members = new Map(); - for (const canvas of document.canvases.values()) if (canvas.parent) childByEntry.set(canvas.parent.entryElementId, canvas); - for (const element of document.elements.values()) { const list = members.get(element.ownerCanvasId) ?? []; list.push(element); members.set(element.ownerCanvasId, list); } - const roots = [...selected].filter(id => !ancestors(document, document.elements.get(id)!.ownerCanvasId).some(canvas => { const parent = document.canvases.get(canvas)!.parent; return parent && selected.has(parent.entryElementId); })); + for (const canvas of document.canvases.values()) { yield; if (canvas.parent) childByEntry.set(canvas.parent.entryElementId, canvas); } + for (const element of document.elements.values()) { yield; const list = members.get(element.ownerCanvasId) ?? []; list.push(element); members.set(element.ownerCanvasId, list); } + const roots: Id[] = []; + for (const id of selected) { yield; if (!ancestors(document, document.elements.get(id)!.ownerCanvasId).some(canvas => { const parent = document.canvases.get(canvas)!.parent; return parent && selected.has(parent.entryElementId); })) roots.push(id); } const copied = new Set(), copiedCanvases = new Set(), order: ({ kind: 'element'; record: ElementRecord } | { kind: 'canvas'; record: CanvasRecord })[] = []; - const stack = roots.filter(id => document.elements.get(id)!.structure === 'node').reverse().map(id => ({ kind: 'element' as const, record: document.elements.get(id)! })); - const work: typeof order = stack; + const work: typeof order = []; + for (let index = roots.length - 1; index >= 0; index--) { yield; const record = document.elements.get(roots[index])!; if (record.structure === 'node') work.push({kind:'element',record}); } while (work.length) { + yield; const item = work.pop()!; if (item.kind === 'canvas') { if (copiedCanvases.has(item.record.id)) continue; copiedCanvases.add(item.record.id); order.push(item); - for (const member of [...(members.get(item.record.id) ?? [])].reverse()) if (member.structure === 'node') work.push({ kind: 'element', record: member }); + const list = members.get(item.record.id) ?? []; + for (let index = list.length - 1; index >= 0; index--) { yield; const member = list[index]; if (member.structure === 'node') work.push({ kind: 'element', record: member }); } } else { if (copied.has(item.record.id)) continue; copied.add(item.record.id); order.push(item); const child = childByEntry.get(item.record.id); if (child) work.push({ kind: 'canvas', record: child }); } } - const edges = [...document.elements.values()].filter(element => element.structure === 'connection' && (selected.has(element.id) || copiedCanvases.has(element.ownerCanvasId) || copied.has(element.target.elementId))); - for (const edge of edges) copied.add(edge.id); - const elementIds = new Map(), canvasIds = new Map(), used = new Set([...document.elements.keys(), ...document.canvases.keys(), ...document.tombstones.keys()]); + const edges: ElementRecord[] = []; + for (const element of document.elements.values()) { yield; if (element.structure === 'connection' && (selected.has(element.id) || copiedCanvases.has(element.ownerCanvasId) || copied.has(element.target.elementId))) { edges.push(element); copied.add(element.id); } } + const elementIds = new Map(), canvasIds = new Map(), used = new Set(); + for (const map of [document.elements, document.canvases, document.tombstones]) for (const id of map.keys()) { yield; used.add(id); } const allocate = (source: Id, kind: 'elements' | 'canvases') => { const supplied = options.mapping?.[kind]; const id = supplied && Object.hasOwn(supplied, source) ? supplied[source] : options.createId(); if (typeof id !== 'string' || !id || used.has(id)) throw fault('duplicate_identity', [id]); used.add(id); return id; }; - for (const id of copied) elementIds.set(id, allocate(id, 'elements')); - for (const id of copiedCanvases) canvasIds.set(id, allocate(id, 'canvases')); - if (options.mapping && (Object.keys(options.mapping.elements).some(id => !copied.has(id)) || Object.keys(options.mapping.canvases).some(id => !copiedCanvases.has(id)))) throw fault('copy_mapping_scope_mismatch'); + for (const id of copied) { yield; elementIds.set(id, allocate(id, 'elements')); } + for (const id of copiedCanvases) { yield; canvasIds.set(id, allocate(id, 'canvases')); } + if (options.mapping) for (const [mapping, scope] of [[options.mapping.elements,copied],[options.mapping.canvases,copiedCanvases]] as const) for (const id in mapping) { yield; if (Object.hasOwn(mapping,id) && !scope.has(id)) throw fault('copy_mapping_scope_mismatch'); } const changes: Change[] = []; for (const item of order) { + yield; if (item.kind === 'canvas') { const parent = item.record.parent!; const after = { ...clone(item.record), id: canvasIds.get(item.record.id)!, parent: { ...parent, canvasId: canvasIds.get(parent.canvasId) ?? parent.canvasId, entryElementId: elementIds.get(parent.entryElementId)! } }; @@ -88,8 +94,11 @@ export function planDuplicate(document: Document, registry: Registry, selection: changes.push({ kind: 'element', id: after.id, before: null, after }); } } - const nodes = applyChanges(document, changes); + const nodeElements = new Map(), nodeCanvases = new Map(); + for (const change of changes) { yield; if (change.kind === 'element' && change.after) nodeElements.set(change.id, change.after); else if (change.kind === 'canvas' && change.after) nodeCanvases.set(change.id, change.after); } + const nodes: Document = { ...document, elements: overlay(document.elements, nodeElements), canvases: overlay(document.canvases, nodeCanvases) }; for (const edge of edges) { + yield; if (edge.structure !== 'connection') continue; if (!connectionPathValid(edge.path)) throw fault('unsupported_copy_path', [edge.id]); const source = { ...edge.source, elementId: elementIds.get(edge.source.elementId) ?? edge.source.elementId }, target = { ...edge.target, elementId: elementIds.get(edge.target.elementId) ?? edge.target.elementId }; @@ -107,7 +116,33 @@ export function planDuplicate(document: Document, registry: Registry, selection: } const after: ElementRecord = { ...clone(edge), id: elementIds.get(edge.id)!, incarnation: 1, ownerCanvasId: owner, source, target, path, data: remapCopyData(edge, registry, elementIds, canvasIds, document) }; changes.push({ kind: 'element', id: after.id, before: null, after }); + nodeElements.set(after.id, after); } - validateDocument(applyChanges(document, changes), registry); - return freeze({ version: 1, roots: roots.map(id => elementIds.get(id)!), elementIds: Object.fromEntries(elementIds), canvasIds: Object.fromEntries(canvasIds), changes }); + yield* validateDocumentSteps(nodes, registry); + const elementMapping: Record = {}, canvasMapping: Record = {}, copiedRoots: Id[] = []; + for (const [id, value] of elementIds) { yield; Object.defineProperty(elementMapping,id,{value,enumerable:true}); } + for (const [id, value] of canvasIds) { yield; Object.defineProperty(canvasMapping,id,{value,enumerable:true}); } + for (const id of roots) { yield; copiedRoots.push(elementIds.get(id)!); } + for (const change of changes) { yield; freeze(change); } + return Object.freeze({ version: 1, roots: Object.freeze(copiedRoots), elementIds: Object.freeze(elementMapping), canvasIds: Object.freeze(canvasMapping), changes: Object.freeze(changes) }); +} + +/** Internal overlay avoids an O(document size) clone between planning phases. Additions own unique IDs. */ +function overlay(base: ReadonlyMap, additions: ReadonlyMap): ReadonlyMap { + const view: ReadonlyMap = { + get size() { return base.size + additions.size; }, + get: key => additions.has(key) ? additions.get(key) : base.get(key), has: key => additions.has(key) || base.has(key), + *entries(): Generator<[K,V],undefined> { yield* base; yield* additions; return undefined; }, + *keys(): Generator { yield* base.keys(); yield* additions.keys(); return undefined; }, + *values(): Generator { yield* base.values(); yield* additions.values(); return undefined; }, + [Symbol.iterator]() { return this.entries(); }, + forEach(callback,thisArg) { for (const [key,value] of view) callback.call(thisArg,value,key,view); }, + }; + return Object.freeze(view); +} + +/** Synchronous API retained for small snapshots and callers that own scheduling. */ +export function planDuplicate(document: Document, registry: Registry, selection: readonly Target[], options: DuplicateOptions): DuplicatePlan { + const steps = planDuplicateSteps(document,registry,selection,options); + while (true) { const step = steps.next(); if (step.done) return step.value; } } diff --git a/src/lib/canvas-runtime/duplicate.ts b/src/lib/canvas-runtime/duplicate.ts new file mode 100644 index 0000000..d26d4ac --- /dev/null +++ b/src/lib/canvas-runtime/duplicate.ts @@ -0,0 +1,44 @@ +import type { Document, Registry, Target } from '../canvas-core/types.ts'; +import { planDuplicateSteps, type DuplicateOptions, type DuplicatePlan } from '../canvas-core/duplicate.ts'; +import { clone, fault, freeze } from '../canvas-core/values.ts'; + +export type DuplicateScheduling = { + readonly signal?: AbortSignal; + readonly frameBudgetMs?: number; + readonly maxStepsPerSlice?: number; + readonly now?: () => number; + readonly yieldTask?: () => Promise; +}; + +/** + * Cooperative planning from a captured immutable StateRoot document, not a live editor getter. + * No document publication or network effects. The caller must persist source/mapping and + * revalidate external identities before applying a plan; this is not the import executor. + */ +export async function planDuplicateAsync(document: Document, registry: Registry, selection: readonly Target[], options: DuplicateOptions, scheduling: DuplicateScheduling = {}): Promise { + const budget = scheduling.frameBudgetMs ?? 6, limit = scheduling.maxStepsPerSlice ?? 512; + if (!Number.isFinite(budget) || budget <= 0 || !Number.isSafeInteger(limit) || limit < 1) throw fault('invalid_copy_budget'); + const signal = scheduling.signal, now = scheduling.now ?? (() => performance.now()), yieldTask = scheduling.yieldTask ?? (() => new Promise(resolve => setTimeout(resolve, 0))); + const check = () => { if (signal?.aborted) throw fault('copy_aborted'); }; + check(); + // Capture caller-owned small inputs before the first await. Document/registry are immutable core snapshots. + const targets = freeze(clone(selection)), settings: DuplicateOptions = Object.freeze({ createId: options.createId, ...(options.worldOffset ? {worldOffset: freeze(clone(options.worldOffset))} : {}), ...(options.mapping ? {mapping: freeze(clone(options.mapping))} : {}) }); + const steps = planDuplicateSteps(document, registry, targets, settings); + try { + while (true) { + check(); + const start = now(); let count = 0; + do { + check(); + const step = steps.next(); + check(); + if (step.done) return step.value; + count++; + } while (count < limit && now() - start < budget); + await yieldTask(); + } + } finally { + // Release snapshot references after cancellation or host scheduling failures. + steps.return(undefined as never); + } +} diff --git a/tests/headless/browser-storage-cases.mjs b/tests/headless/browser-storage-cases.mjs index 3240e84..b54ddd9 100644 --- a/tests/headless/browser-storage-cases.mjs +++ b/tests/headless/browser-storage-cases.mjs @@ -1,3 +1,4 @@ +import {planDuplicateAsync} from '../../src/lib/canvas-runtime/duplicate.ts'; import {replayHistoryEvidence,sameHistory} from '../../src/lib/canvas-runtime/history-replay.ts'; import { createBrowserStorage } from '../../src/lib/canvas-runtime/storage.ts'; import { openProjectEngine } from '../../src/lib/canvas-runtime/engine.ts'; @@ -6,6 +7,18 @@ import { createFoundryRuns } from '../../src/lib/canvas-service/runs.ts'; import { createFoundryAssets } from '../../src/lib/canvas-service/assets.ts'; import { createFoundryPersistence } from '../../src/lib/canvas-service/foundry.ts'; +async function cooperativeCopyCase(assert) { + const registry=createRegistry(builtinComponents()),source=createDocument('copy-project',[{id:'copy-root',parent:null}],Array.from({length:1500},(_,index)=>({id:`source-${index}`,incarnation:1,structure:'node',ownerCanvasId:'copy-root',componentId:'text',dataVersion:1,data:{title:`item ${index}`,text:''},geometry:{x:index,y:0,width:100,height:100,rotation:0}}))); + let next=0,timerRan=false; + const timer=setTimeout(()=>{timerRan=true;},0); + try { + const plan=await planDuplicateAsync(source,registry,[{id:'source-0',incarnation:1}],{createId:()=>`copy-${++next}`},{maxStepsPerSlice:512}); + assert(timerRan,'copy planning starved a real browser task');assert(plan.changes.length===1,'copy planning changed selection scope');assert(source.elements.size===1500,'copy planning mutated its source'); + const controller=new AbortController(),cancel=setTimeout(()=>controller.abort(),0); + try { let code;try { await planDuplicateAsync(source,registry,[{id:'source-0',incarnation:1}],{createId:()=>`unused-${++next}`},{signal:controller.signal,maxStepsPerSlice:128}); } catch(error){code=error.code;}assert(code==='copy_aborted','browser cancellation did not stop planning'); } finally {clearTimeout(cancel);} + } finally {clearTimeout(timer);} +} + async function adoptionConsumerCase(name,assert){ for(const structural of [false,true]){ const root=crypto.randomUUID(),child=crypto.randomUUID(),workspace=crypto.randomUUID(),account=crypto.randomUUID(),registry=createRegistry(builtinComponents()),node=(id,owner=root)=>({id,incarnation:1,structure:'node',componentId:'text',dataVersion:1,ownerCanvasId:owner,geometry:{x:0,y:0,width:200,height:100,rotation:0},data:{title:id,text:''}}),original=createDocument(root,[{id:root,parent:null}],[node('a')]),database=`${name}-adoption-consumer-${structural}`,storage=createBrowserStorage({name:database}),calls=[],saved=new Map();let document=original,versions=new Map([[root,1]]),reject=true,engine; @@ -481,6 +494,8 @@ export async function run() { results.push('project engine reopens real IndexedDB with one history unit and the original request; ACK resolves without duplicate edit'); await resourceRecoveryCase(name, assert); 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 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/duplicate-scheduling.test.mjs b/tests/headless/duplicate-scheduling.test.mjs new file mode 100644 index 0000000..246dc07 --- /dev/null +++ b/tests/headless/duplicate-scheduling.test.mjs @@ -0,0 +1,57 @@ +import {test} from 'node:test'; +import assert from 'node:assert/strict'; +import {createDocument,createRegistry,builtinComponents,planDuplicate,planDuplicateSteps,applyChanges,validateDocument,validateDocumentSteps,validJson} from '../../src/lib/canvas-core/index.ts'; +import {planDuplicateAsync} from '../../src/lib/canvas-runtime/duplicate.ts'; +const registry=createRegistry(builtinComponents()); +const node=(id,owner='P',componentId='text')=>({id,incarnation:1,ownerCanvasId:owner,componentId,dataVersion:1,structure:'node',data:{title:id,text:'copy'},geometry:{x:0,y:0,width:100,height:100,rotation:0}}); +const edge={id:'edge',incarnation:1,ownerCanvasId:'P',componentId:'connection',dataVersion:1,structure:'connection',source:{elementId:'C'},target:{elementId:'F'},path:null,data:{title:''}}; +const document=createDocument('project',[{id:'P',parent:null},{id:'S',parent:{canvasId:'P',entryElementId:'F'}}],[node('F','P','frame'),node('C'),...Array.from({length:2000},(_,i)=>node(`n${i}`,'S')),edge]); +const selection=[{id:'F',incarnation:1}],options=()=>{let id=0;return{createId:()=>`new-${++id}`};}; + +test('cooperative deep planning yields throughout scanning, allocation and remapping and matches the synchronous plan',async()=>{ + let slices=0;const plan=await planDuplicateAsync(document,registry,selection,options(),{now:()=>0,maxStepsPerSlice:64,yieldTask:async()=>{slices++;}}); + assert.ok(slices>200);assert.deepEqual(plan,planDuplicate(document,registry,selection,options()));assert.equal(validJson(plan),true); + const after=applyChanges(document,plan.changes);validateDocument(after,registry); + assert.equal(after.elements.get(plan.elementIds.edge).source.elementId,'C');assert.equal(after.elements.get(plan.elementIds.edge).target.elementId,plan.elementIds.F); + assert.equal(Object.isFrozen(plan.changes[0].after.data),true);assert.equal(document.elements.size,2003); +}); + +test('default task yielding lets actual timers cancel a large copy without publishing content',async()=>{ + const controller=new AbortController();let ids=0,ran=false; + const timer=setTimeout(()=>{ran=true;controller.abort();},0); + try{await assert.rejects(planDuplicateAsync(document,registry,selection,{createId:()=>`unused-${++ids}`},{signal:controller.signal,maxStepsPerSlice:16}),/copy_aborted/);assert.equal(ran,true);assert.equal(ids,0);assert.equal(document.elements.size,2003);}finally{clearTimeout(timer);} +}); + +test('cancellation during allocation never returns a partial plan or changes the source',async()=>{ + const controller=new AbortController();let allocated=0,slices=0; + await assert.rejects(planDuplicateAsync(document,registry,selection,{createId:()=>`new-${++allocated}`},{signal:controller.signal,now:()=>0,maxStepsPerSlice:32,yieldTask:async()=>{slices++;if(allocated>20)controller.abort();}}),/copy_aborted/); + assert.ok(allocated>20&&allocated<2003);assert.ok(slices>1);assert.equal(document.elements.has('new-1'),false); +}); + +test('planning captures caller inputs and retains the original document when the live root changes',async()=>{ + const small=createDocument('project',[{id:'P',parent:null}],[node('A')]);let live=small,changed=false; + const targets=[{id:'A',incarnation:1}],settings={...options(),worldOffset:{x:40,y:50}}; + const result=await planDuplicateAsync(live,registry,targets,settings,{now:()=>0,maxStepsPerSlice:1,yieldTask:async()=>{if(changed)return;changed=true;targets[0].id='missing';settings.worldOffset.x=999;live=applyChanges(small,[{kind:'element',id:'A',before:small.elements.get('A'),after:null}]);}}); + assert.equal(live.elements.size,0);assert.equal(result.changes[0].after.geometry.x,40);assert.equal(result.changes[0].after.geometry.y,50);assert.equal(small.elements.size,1); +}); + +test('time budget forces a task yield even when the step limit is high and scheduler errors propagate',async()=>{ + let tick=0,yields=0; + await planDuplicateAsync(createDocument('p',[{id:'P',parent:null}],[node('A')]),registry,[{id:'A',incarnation:1}],options(),{now:()=>++tick,frameBudgetMs:1,maxStepsPerSlice:99999,yieldTask:async()=>{yields++;}});assert.ok(yields>10); + await assert.rejects(planDuplicateAsync(document,registry,selection,options(),{maxStepsPerSlice:1,yieldTask:async()=>{throw Error('host stopped');}}),/host stopped/); + for(const value of [0,-1,NaN,Infinity])await assert.rejects(planDuplicateAsync(document,registry,selection,options(),{frameBudgetMs:value}),/invalid_copy_budget/); + const controller=new AbortController();controller.abort();await assert.rejects(planDuplicateAsync(document,registry,selection,options(),{signal:controller.signal}),/copy_aborted/); +}); + +test('cooperative validation and planning retain invalid-input errors rather than skipping failed records',async()=>{ + const invalid={...document,canvases:new Map([...document.canvases,['bad',{id:'bad',parent:null}]])}; + assert.throws(()=>validateDocument(invalid,registry),/invalid_roots/);assert.throws(()=>[...validateDocumentSteps(invalid,registry)],/invalid_roots/); + await assert.rejects(planDuplicateAsync(invalid,registry,selection,options(),{maxStepsPerSlice:10,yieldTask:async()=>{}}),/invalid_roots/); + assert.throws(()=>[...planDuplicateSteps(document,registry,[{id:'F',incarnation:9}],options())],/stale_incarnation/); +}); + +test('mapping output preserves special source IDs as own JSON fields without prototype mutation',async()=>{ + const source=createDocument('special',[{id:'P',parent:null}],[node('__proto__'),node('constructor')]); + const plan=await planDuplicateAsync(source,registry,[{id:'__proto__',incarnation:1},{id:'constructor',incarnation:1}],options()); + assert.equal(Object.getPrototypeOf(plan.elementIds),Object.prototype);assert.equal(Object.hasOwn(plan.elementIds,'__proto__'),true);assert.equal(validJson(plan),true);assert.deepEqual(JSON.parse(JSON.stringify(plan.elementIds)),{['__proto__']:'new-1',constructor:'new-2'}); +});