Files
Endoleap/tests/headless/recovery-encoding.test.mjs

192 lines
19 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 恢复值编码与同步/协作式封存测试:JSON 语义、不可变缓存、所有权、深度限制和精确 UTF-8 计数必须同时成立。
* 未知 getter、toJSON、Proxy 和可变内建对象可能执行调用者逻辑,不能仅凭 Object.freeze 就跳过捕获。
* 协作式路径对安全内部数据按预算让出,对未知回调维持同步捕获顺序;全局探针均需恢复。
*/
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {freeze,validJson} from '../../src/lib/canvas-core/values.ts';
import {encodeRecoveryValue,sealRecoveryImage,isSealedRecoveryImage} from '../../src/lib/canvas-runtime/recovery-encoding.ts';
// 用原生 stringify/parse 作为 JSON 规范化参照,包括省略字段、非有限数和自定义 toJSON。
const json=value=>JSON.parse(JSON.stringify(value));
// 以原生 JSON 为参照检查省略、稀疏/不可编码数组项、日期、装箱值、自定义 toJSON 和 own __proto__,输出须合法且冻结。
test('recovery encoding preserves JSON value semantics including omitted, sparse, special-key and custom values',()=>{
const value={number:-0,infinite:Infinity,missing:undefined,symbol:Symbol('s'),fn(){},array:[,undefined,()=>{},Symbol('s'),NaN],date:new Date('2026-01-01T00:00:00Z'),boxed:new Number(4),custom:{toJSON(key){return{key,text:'custom'};}},['__proto__']:{safe:true}};
const encoded=encodeRecoveryValue(value);assert.deepEqual(encoded,json(value));assert.equal(validJson(encoded),true);assert.equal(Object.getPrototypeOf(encoded),Object.prototype);assert.equal(Object.hasOwn(encoded,'__proto__'),true);assert.ok(Object.isFrozen(encoded.array));
});
// 安全深冻结数据可复用已编码副本;浅冻结外壳包着可变子项时必须重读,编码结果也不能反向修改输入。
test('only frozen data subtrees are reused and the encoded image is detached and immutable',()=>{
const shared=freeze({items:[{title:'stable',unknown:{retained:true}}]}),left=encodeRecoveryValue({shared}),right=encodeRecoveryValue({shared});assert.equal(left.shared,right.shared);assert.notEqual(left.shared,shared);assert.throws(()=>{left.shared.items[0].title='changed';},TypeError);
const mutable={title:'before'},shallow=Object.freeze({mutable});assert.equal(encodeRecoveryValue(shallow).mutable.title,'before');mutable.title='after';assert.equal(encodeRecoveryValue(shallow).mutable.title,'after');assert.equal(encodeRecoveryValue(shallow).mutable===mutable,false);
});
// 冻结 getter、动态 toJSON、Date 内部状态和继承的数组项仍可能变化;每次编码都应观察当时值。
test('frozen getters, custom prototypes and internally mutable objects are evaluated again',()=>{
let current='first';const getter=Object.freeze({get title(){return current;}});assert.equal(encodeRecoveryValue(getter).title,'first');current='second';assert.equal(encodeRecoveryValue(getter).title,'second');
let enabled=false;const dynamic={value:'ordinary'};Object.defineProperty(dynamic,'toJSON',{get(){return enabled?()=>({changed:true}):undefined;}});Object.freeze(dynamic);assert.deepEqual(encodeRecoveryValue(dynamic),{value:'ordinary'});enabled=true;assert.deepEqual(encodeRecoveryValue(dynamic),{changed:true});
const date=Object.freeze(new Date('2026-01-01Z'));const parent=Object.freeze({date});assert.deepEqual(encodeRecoveryValue(parent),json(parent));date.setUTCFullYear(2027);assert.deepEqual(encodeRecoveryValue(parent),json(parent));
const prototype=Object.create(Array.prototype);prototype[0]='old';const array=new Array(1);Object.setPrototypeOf(array,prototype);Object.freeze(array);assert.deepEqual(encodeRecoveryValue(array),['old']);prototype[0]='new';assert.deepEqual(encodeRecoveryValue(array),['new']);
});
// 缓存命中不能绕过深度上限;对象 undefined 字段省略,数组 undefined 项成为 null,循环和 BigInt 都不能正常编码。
test('cached subtree height cannot bypass the depth bound and omitted object fields do not count',()=>{
// 构造冻结包装层检查总深度;预热 leaf 缓存后仍须把缓存子树高度计入外层限制。
const nest=(count,leaf)=>{for(let i=0;i<count;i++)leaf=Object.freeze({child:leaf});return leaf;},leaf=freeze({value:1});encodeRecoveryValue(leaf);assert.equal(validJson(encodeRecoveryValue(nest(63,leaf))),true);assert.throws(()=>encodeRecoveryValue(nest(64,leaf)),/invalid_recovery_payload/);
assert.equal(validJson(encodeRecoveryValue(nest(64,Object.freeze({optional:undefined})))),true);assert.throws(()=>encodeRecoveryValue(nest(64,Object.freeze([undefined]))),/invalid_recovery_payload/);
const cycle={};cycle.self=cycle;assert.throws(()=>encodeRecoveryValue(cycle),/invalid_recovery_payload/);assert.throws(()=>encodeRecoveryValue({big:1n}));
});
// 只有工厂封存的对象被识别为 sealed;克隆和浅冻结仿造对象没有所有权,非法修订及格式版本被拒绝。
test('only factory-owned images are recognized; immutable-looking impostors retain defensive-copy handling',()=>{
const mutable={title:'before'},image=sealRecoveryImage(2,3,2,{mutable});assert.equal(isSealedRecoveryImage(image),true);assert.equal(isSealedRecoveryImage(structuredClone(image)),false);assert.equal(isSealedRecoveryImage(Object.freeze({...image})),false);mutable.title='after';assert.equal(image.payload.mutable.title,'before');assert.ok(Object.isFrozen(image.payload.mutable));assert.throws(()=>sealRecoveryImage(2,1,2,{}),/corrupt_recovery_image/);assert.equal(isSealedRecoveryImage(sealRecoveryImage(3,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(4,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(5,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(6,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(7,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(8,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(9,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(10,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(11,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(12,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(13,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(14,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(15,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(16,1,1,{})),true);assert.equal(isSealedRecoveryImage(sealRecoveryImage(17,1,1,{})),true);assert.throws(()=>sealRecoveryImage(18,1,1,{}),/corrupt_recovery_image/);
});
// 对共享子树、Unicode/孤立代理字符和各类规范化输入核对 UTF-8 字节数;克隆 image 不继承内部计数缓存。
test('sealed image byte sizes match UTF-8 JSON for reused data and all codec normalization paths',async()=>{
const {sealedRecoveryByteLength}=await import('../../src/lib/canvas-runtime/recovery-encoding.ts'),shared=freeze({text:'中文😀\ud800\n"\\',items:[null,1,true]}),payloads=[null,'😀',-0,{shared,omitted:undefined,array:[,undefined,NaN]},new Date('2026-01-01Z'),{['__proto__']:'保留'},Object.freeze({get text(){return '动态😀';}})];
for(const payload of payloads)for(let revision=0;revision<3;revision++){const image=sealRecoveryImage(17,revision,revision,payload);assert.equal(sealedRecoveryByteLength(image),new TextEncoder().encode(JSON.stringify(image)).byteLength);assert.equal(sealedRecoveryByteLength(structuredClone(image)),undefined);}
});
// 未知 getter 与 toJSON 在首次让出前捕获,之后外部及 yield 回调改值不影响结果;粗时钟的大安全数组仍会主动让出。
test('cooperative sealing captures unknown values before yielding and retains exact bytes',async()=>{
const {dataArray,dataRecord}=await import('../../src/lib/canvas-core/data-containers.ts');
const {sealRecoveryImageAsync,sealedRecoveryByteLength}=await import('../../src/lib/canvas-runtime/recovery-encoding.ts');
let current='before',calls=[];
const mutable={title:'before'},shallow=Object.freeze({mutable}),date=Object.freeze(new Date('2026-01-01Z'));
const custom=Object.freeze({toJSON(key){calls.push(key);return {key,current};}});
const getter=Object.freeze({get title(){calls.push('getter');return current;}});
const rows=dataArray(Array.from({length:1000},(_,i)=>dataRecord({id:i,text:'中文😀\ud800'})));
const payload=dataRecord({rows,shallow,date,getter,left:custom,right:custom,['__proto__']:{safe:true}});
const expected=sealRecoveryImage(17,2,1,payload);calls=[];let yields=0;
const pending=sealRecoveryImageAsync(17,2,1,dataRecord({...payload,rows:dataArray(rows)}),{now:(()=>{let t=0;return()=>t++;})(),frameBudgetMs:1,yieldTask:async()=>{yields++;current='after';mutable.title='after';date.setUTCFullYear(2027);}});
assert.deepEqual(calls,['getter','left','right']);
mutable.title='outside';current='outside';
const image=await pending;
// A second uncached table below exercises the work ceiling with a coarse clock.
assert.ok(yields>0);assert.deepEqual(image,expected);assert.equal(isSealedRecoveryImage(image),true);
assert.equal(sealedRecoveryByteLength(image),new TextEncoder().encode(JSON.stringify(image)).byteLength);
const beforeCoarse=yields;
const large=dataArray(Array.from({length:20000},(_,i)=>i));
const more=await sealRecoveryImageAsync(17,3,2,large,{now:()=>100,frameBudgetMs:1,yieldTask:async()=>{yields++;}});
assert.ok(yields>beforeCoarse);assert.deepEqual(more.payload,large);
});
// 同一未知值的每次出现都独立求值;让出失败、过深数据及非法修订拒绝,不产生可用的半成品 image。
test('cooperative preparation evaluates every unsafe occurrence and rejects errors without an image',async()=>{
const {dataArray,dataRecord}=await import('../../src/lib/canvas-core/data-containers.ts');
const {sealRecoveryImageAsync}=await import('../../src/lib/canvas-runtime/recovery-encoding.ts');
let value=0;
const dynamic=Object.freeze({get title(){return ++value;}}),shared=dataRecord({dynamic});
const image=await sealRecoveryImageAsync(17,1,1,dataRecord({left:shared,right:shared}));
assert.deepEqual(image.payload,{left:{dynamic:{title:1}},right:{dynamic:{title:2}}});
let ticks=0;
await assert.rejects(sealRecoveryImageAsync(17,1,1,dataArray(Array.from({length:1000},(_,i)=>i)),{now:()=>ticks++,frameBudgetMs:1,yieldTask:async()=>{throw Error('yield_failed');}}),/yield_failed/);
// 异步深度检查使用内部 dataRecord 包装;省略的对象字段和变成 null 的数组项占用深度不同。
const nested=(n,leaf)=>{for(let i=0;i<n;i++)leaf=dataRecord({child:leaf});return leaf;};
const leaf=freeze({value:1});encodeRecoveryValue(leaf);
await assert.rejects(sealRecoveryImageAsync(17,1,1,nested(64,leaf)),/invalid_recovery_payload/);
await assert.rejects(sealRecoveryImageAsync(17,1,1,nested(64,dataArray([undefined]))),/invalid_recovery_payload/);
assert.ok(await sealRecoveryImageAsync(17,1,1,nested(64,dataRecord({omitted:undefined}))));
await assert.rejects(sealRecoveryImageAsync(17,0,1,{}),/corrupt_recovery_image/);
});
// 临时拦截字符串 stringify 计数,证明标量规范化也分布在预算切片内,并在 finally 恢复全局函数。
test('primitive normalization and UTF-8 sizing occur inside the cooperative slice',async()=>{
const {dataArray}=await import('../../src/lib/canvas-core/data-containers.ts');
const {sealRecoveryImageAsync,sealedRecoveryByteLength}=await import('../../src/lib/canvas-runtime/recovery-encoding.ts');
const input=dataArray(Array.from({length:1000},(_,i)=>'budgeted-中文😀-'+i)),stringify=JSON.stringify;
let strings=0,tick=0,atFirstYield;
JSON.stringify=function(value,...args){if(typeof value==='string'&&value.startsWith('budgeted-'))strings++;return Reflect.apply(stringify,this,[value,...args]);};
try{
const image=await sealRecoveryImageAsync(17,1,1,input,{now:()=>tick++,frameBudgetMs:1,yieldTask:async()=>{atFirstYield??=strings;}});
assert.ok(atFirstYield>0&&atFirstYield<=128,'all scalar encoding ran before the first yield');
assert.equal(strings,1000);assert.deepEqual(image.payload,input);
assert.equal(sealedRecoveryByteLength(image),new TextEncoder().encode(stringify(image)).byteLength);
}finally{JSON.stringify=stringify;}
});
// 记录未知 Proxy 的所有 trap,要求异步封存与同步封存的调用次序、次数和输出相同。
test('unknown proxies retain the synchronous codec trap order and callback count',async()=>{
const {dataRecord}=await import('../../src/lib/canvas-core/data-containers.ts');
const {sealRecoveryImageAsync}=await import('../../src/lib/canvas-runtime/recovery-encoding.ts');
let events=[];
const proxy=new Proxy({text:'external'},Object.fromEntries(['get','getPrototypeOf','getOwnPropertyDescriptor','ownKeys','isExtensible'].map(name=>[name,(...args)=>{events.push([name,String(args[1]??'')]);return Reflect[name](...args);}])));
const payload=dataRecord({proxy}),expected=sealRecoveryImage(17,1,1,payload),before=events;events=[];
const actual=await sealRecoveryImageAsync(17,1,1,payload);
assert.deepEqual(actual,expected);assert.deepEqual(events,before);
});
// 同一异步 image 内重复安全容器可复用引用及计数;不安全后代在每次出现时仍独立捕获。
test('repeated owned containers reuse facts assembled earlier in the same asynchronous image',async()=>{
const {dataArray,dataRecord}=await import('../../src/lib/canvas-core/data-containers.ts');
const {sealRecoveryImageAsync,sealedRecoveryByteLength}=await import('../../src/lib/canvas-runtime/recovery-encoding.ts');
const rows=dataArray(Array.from({length:1000},(_,i)=>dataRecord({index:i,text:'shared-中文😀'})));
let tick=0,yields=0;
const image=await sealRecoveryImageAsync(17,1,1,dataRecord({left:rows,right:rows}),{now:()=>tick++,frameBudgetMs:1,yieldTask:async()=>{yields++;}});
assert.ok(yields>0);assert.equal(image.payload.left,image.payload.right);
assert.equal(image.payload.left.length,1000);assert.equal(image.payload.left[999].index,999);
assert.equal(sealedRecoveryByteLength(image),new TextEncoder().encode(JSON.stringify(image)).byteLength);
// Unsafe descendants still need independent values for every occurrence.
let n=0;const unsafe=dataRecord({getValue:Object.freeze({get value(){return ++n;}})});
const captured=await sealRecoveryImageAsync(17,1,1,dataArray([unsafe,unsafe]));
assert.deepEqual(captured.payload,[{getValue:{value:1}},{getValue:{value:2}}]);
});
// 生成首次进入 codec 的大元素数据,让测试能区分第一次规范化与已预热缓存的复用。
const ownedRecord=async()=>{
const {detachElementData}=await import('../../src/lib/canvas-core/element-data.ts');
return detachElementData({id:'large',incarnation:1,structure:'node',ownerCanvasId:'canvas',componentId:'text',dataVersion:1,geometry:{x:0,y:0,width:100,height:100,rotation:0},data:{text:'',rows:Array.from({length:1000},(_,i)=>({index:i,text:'owned-中文😀-'+i}))}});
};
// 首次处理真实内部拥有的大元素时,首次让出前只编码有限标量;总计仍处理全部字符串并精确计数。
test('first-time detached element JSON is normalized inside slices rather than captured in one walk',async()=>{
const {sealRecoveryImageAsync,sealedRecoveryByteLength}=await import('../../src/lib/canvas-runtime/recovery-encoding.ts');
const element=await ownedRecord(),stringify=JSON.stringify;let strings=0,first,tick=0;
JSON.stringify=function(value,...args){if(typeof value==='string'&&value.startsWith('owned-'))strings++;return Reflect.apply(stringify,this,[value,...args]);};
try{
const image=await sealRecoveryImageAsync(17,1,1,element,{now:()=>tick++,frameBudgetMs:1,yieldTask:async()=>{first??=strings;}});
assert.ok(first>=0&&first<128,'detached data was fully encoded before the first yield');assert.equal(strings,1000);
assert.deepEqual(image.payload,element);assert.equal(sealedRecoveryByteLength(image),new TextEncoder().encode(stringify(image)).byteLength);
}finally{JSON.stringify=stringify;}
});
// 准入前原型链已有 toJSON getter 时,即使数据由内部拥有也走同步捕获,调用次数与同步路径相同且不让出。
test('inherited toJSON accessors at admission keep detached elements on the synchronous capture path',async()=>{
const {sealRecoveryImageAsync}=await import('../../src/lib/canvas-runtime/recovery-encoding.ts');
const left=await ownedRecord(),right=await ownedRecord(),previous=Object.getOwnPropertyDescriptor(Object.prototype,'toJSON');let calls=0,yields=0,tick=0;
try{
Object.defineProperty(Object.prototype,'toJSON',{configurable:true,get(){calls++;return undefined;}});
const expected=sealRecoveryImage(17,1,1,left),syncCalls=calls;calls=0;
const image=await sealRecoveryImageAsync(17,1,1,right,{now:()=>tick++,frameBudgetMs:1,yieldTask:async()=>{yields++;}});
assert.equal(calls,syncCalls);assert.equal(yields,0);assert.deepEqual(image.payload,expected.payload);
}finally{if(previous)Object.defineProperty(Object.prototype,'toJSON',previous);else delete Object.prototype.toJSON;}
});
// 未知回调在捕获中改变数组 toJSON 时,已捕获 left 保持原值,后续 right 使用新语义,整体与同步 codec 相同。
test('unknown callbacks invalidate the detached JSON environment without changing earlier captures',async()=>{
const {dataRecord}=await import('../../src/lib/canvas-core/data-containers.ts');
const {sealRecoveryImageAsync}=await import('../../src/lib/canvas-runtime/recovery-encoding.ts');
const previous=Object.getOwnPropertyDescriptor(Array.prototype,'toJSON');
// 恢复 Array.prototype.toJSON 的原描述符;测试内与 finally 都调用,防止环境探针影响其他用例。
const cleanup=()=>{if(previous)Object.defineProperty(Array.prototype,'toJSON',previous);else delete Array.prototype.toJSON;};
// left 先捕获,bridge getter 改写数组 toJSON,right 后捕获;同一 image 内顺序变化必须与同步 codec 一致。
const make=async()=>dataRecord({left:await ownedRecord(),bridge:{get value(){Object.defineProperty(Array.prototype,'toJSON',{configurable:true,value(){return ['changed-by-callback'];}});return 'bridge';}},right:await ownedRecord()});
try{
const expected=sealRecoveryImage(17,1,1,await make());cleanup();
const payload=await make();let tick=0,yields=0;
const pending=sealRecoveryImageAsync(17,1,1,payload,{now:()=>tick++,frameBudgetMs:1,yieldTask:async()=>{yields++;}});
cleanup();const image=await pending;assert.ok(yields>0);assert.deepEqual(image.payload,expected.payload);
assert.equal(image.payload.left.data.rows.length,1000);assert.deepEqual(image.payload.right.data.rows,['changed-by-callback']);
}finally{cleanup();}
});
// 完整元素藏在深层包装下时,无论未缓存还是已缓存,均须计入内部深度并拒绝越界。
test('deferred detached subtrees enforce the depth bound both cold and after cache reuse',async()=>{
const {dataRecord}=await import('../../src/lib/canvas-core/data-containers.ts');
const {sealRecoveryImageAsync}=await import('../../src/lib/canvas-runtime/recovery-encoding.ts');
// 用 61 层内部容器包裹完整元素,使其内部数据深度也触及限制;冷缓存与热缓存都要拒绝。
const nest=leaf=>{for(let i=0;i<61;i++)leaf=dataRecord({child:leaf});return leaf;};
const cold=await ownedRecord();await assert.rejects(sealRecoveryImageAsync(17,1,1,nest(cold)),/invalid_recovery_payload/);
const warm=await ownedRecord();await sealRecoveryImageAsync(17,1,1,warm);
await assert.rejects(sealRecoveryImageAsync(17,1,1,nest(warm)),/invalid_recovery_payload/);
});