192 lines
19 KiB
JavaScript
192 lines
19 KiB
JavaScript
/**
|
||
* 恢复值编码与同步/协作式封存测试: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/);
|
||
});
|