181 lines
12 KiB
JavaScript
181 lines
12 KiB
JavaScript
import {test} from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {readFile} from 'node:fs/promises';
|
|
import ts from 'typescript';
|
|
|
|
async function moduleUrl(path, transform=code=>code){
|
|
const source=await readFile(new URL(path,import.meta.url),'utf8');
|
|
const {outputText}=ts.transpileModule(source,{compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.ESNext}});
|
|
return 'data:text/javascript;base64,'+Buffer.from(transform(outputText)+'\n//# sourceURL='+new URL(path,import.meta.url).pathname).toString('base64');
|
|
}
|
|
const protocolUrl=await moduleUrl('../src/lib/api/protocol.ts');
|
|
const imagesUrl=await moduleUrl('../src/lib/client/images.ts',code=>code.replace(/from ['"]\.\.\/api\/protocol['"]/,`from '${protocolUrl}'`));
|
|
const {decodeImage,ImageLoader,shouldDecryptImage,startImageLoading}=await import(imagesUrl);
|
|
const png=Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aV1sAAAAASUVORK5CYII=','base64');
|
|
// Known output of the reference XOR routine, with extra bytes beyond its 100-byte prefix.
|
|
const encrypted=Buffer.from('u2B/fnR5e3pwNzU/fnp0Yzl5c2BwcDc0OjMyMDGMZX9jcHA3Pntzc2RJ4xqPnm9wNDYwN92qZmJ5c2FwOXJ7dplwULM5eHFidHUxMjo+ODs9NHd8cWFiJCEnISUoKCNib3xubyAhIiMkJSYnKCkqKywtLi8wMTIzNDU2Nzg5Ojs8PT4/QEFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFlaW1xdXl9gYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXp7fH1+f4CBgoOEhYaHiImKiw==','base64');
|
|
const arrayBuffer=bytes=>Uint8Array.from(bytes).buffer;
|
|
const response=()=>new Response(encrypted);
|
|
const flush=()=>new Promise(resolve=>setImmediate(resolve));
|
|
|
|
class Element extends EventTarget {
|
|
tagName;attrs=new Map();children=[];onerror=null;
|
|
style={values:new Map(),setProperty(key,value){this.values.set(key,value);}};
|
|
constructor(tagName='IMG',attrs={}){super();this.tagName=tagName;for(const [key,value] of Object.entries(attrs))this.setAttribute(key,value);}
|
|
setAttribute(key,value){this.attrs.set(key,String(value));}
|
|
getAttribute(key){return this.attrs.get(key)??null;}
|
|
removeAttribute(key){this.attrs.delete(key);}
|
|
matches(){return this.tagName==='IMG'&&this.attrs.has('data-src')||this.tagName==='VIDEO'&&this.attrs.has('data-poster');}
|
|
querySelectorAll(){return this.children.flatMap(child=>[...(child.matches()?[child]:[]),...child.querySelectorAll()]);}
|
|
}
|
|
|
|
test('reference ciphertext decrypts exactly, leaves bytes after 100 unchanged and does not mutate input',async()=>{
|
|
const input=arrayBuffer(encrypted),before=Buffer.from(input).toString('base64');
|
|
const blob=decodeImage(input),plain=Buffer.from(await blob.arrayBuffer());
|
|
assert.equal(blob.type,'image/png');
|
|
assert.deepEqual(plain,Buffer.concat([png,Buffer.from(Array.from({length:140},(_,index)=>index))]));
|
|
assert.deepEqual(plain.subarray(100),encrypted.subarray(100));
|
|
assert.equal(Buffer.from(input).toString('base64'),before);
|
|
});
|
|
|
|
test('ordinary images retain their bytes and MIME instead of being XORed again',async()=>{
|
|
const formats=[
|
|
[png,'image/png'],[Buffer.from([0xff,0xd8,0xff,0xe0,0]),'image/jpeg'],
|
|
[Buffer.from('GIF89a plain'),'image/gif'],[Buffer.from('RIFF0000WEBPplain'),'image/webp'],
|
|
[Buffer.concat([Buffer.from('RIFF'),Buffer.from([0xc2,0xa0,0,0]),Buffer.from('WEBPplain')]),'image/webp'],
|
|
[Buffer.from('<?xml version="1.0"?><svg xmlns="http://www.w3.org/2000/svg"></svg>'),'image/svg+xml'],
|
|
[Buffer.from('\0\0\0\x20ftypavif\0\0\0\0'),'image/avif'],
|
|
];
|
|
for(const [bytes,mime] of formats){
|
|
const blob=decodeImage(arrayBuffer(bytes));assert.equal(blob.type,mime);assert.deepEqual(Buffer.from(await blob.arrayBuffer()),bytes);
|
|
}
|
|
for(const bytes of [Buffer.alloc(0),Buffer.from('<html>Error</html>'),Buffer.from([1,2])])assert.throws(()=>decodeImage(arrayBuffer(bytes)),/图片格式无效/);
|
|
});
|
|
|
|
test('only HTTP images need fetching; local assets, blobs and data images bypass decryption',async()=>{
|
|
let calls=0;const loader=new ImageLoader({request:async()=>{calls++;return response();}});
|
|
try{
|
|
for(const url of ['/assets/logo.svg','/icon.svg','data:image/png;base64,abc','blob:https://example.com/id']){
|
|
const node=new Element();assert.equal(shouldDecryptImage(url),false);
|
|
assert.equal(await loader.processImage(url,node),url);assert.equal(node.getAttribute('src'),url);
|
|
}
|
|
assert.equal(calls,0);assert.equal(shouldDecryptImage('https://example.com/a'),true);
|
|
const node=new Element();assert.equal(await loader.processImage('javascript:alert(1)',node),'');assert.equal(node.getAttribute('data-decrypt-failed'),'1');
|
|
}finally{loader.dispose();}
|
|
});
|
|
|
|
test('duplicate images share one request and decrypted blob cache across DOM targets',async()=>{
|
|
let calls=0;const loader=new ImageLoader({request:async()=>{calls++;return response();}});
|
|
try{
|
|
const nodes=[new Element(),new Element(),new Element('VIDEO')];
|
|
const urls=await Promise.all(nodes.map((node,index)=>loader.processImage('https://example.com/shared',node,index===2?'poster':'img')));
|
|
assert.equal(calls,1);assert.match(urls[0],/^blob:/);assert.ok(urls.every(url=>url===urls[0]));
|
|
assert.equal(nodes[2].getAttribute('poster'),urls[0]);
|
|
assert.equal((await fetch(urls[0])).headers.get('content-type'),'image/png');
|
|
assert.equal(await loader.fetchDecrypt('https://example.com/shared'),urls[0]);assert.equal(calls,1);
|
|
}finally{loader.dispose();}
|
|
});
|
|
|
|
test('fetch concurrency is bounded and high-priority images precede queued list covers',async()=>{
|
|
const pending=new Map(),started=[];let active=0,maxActive=0;
|
|
const loader=new ImageLoader({concurrency:2,request:async url=>{
|
|
started.push(url);active++;maxActive=Math.max(maxActive,active);
|
|
return new Promise(resolve=>pending.set(url,()=>{active--;resolve(response());}));
|
|
}});
|
|
try{
|
|
const jobs=['a','b','c'].map(key=>loader.fetchDecrypt('https://example.com/'+key));
|
|
jobs.push(loader.fetchDecrypt('https://example.com/hero',true));
|
|
assert.deepEqual(started,['https://example.com/a','https://example.com/b']);
|
|
pending.get(started[0])();await jobs[0];await flush();
|
|
assert.equal(started[2],'https://example.com/hero');
|
|
pending.get(started[1])();await jobs[1];await flush();assert.equal(started[3],'https://example.com/c');
|
|
pending.get(started[2])();pending.get(started[3])();await Promise.all(jobs);
|
|
assert.equal(maxActive,2);
|
|
}finally{loader.dispose();}
|
|
});
|
|
|
|
test('request failures show a fallback, release the queue and remain retryable',async()=>{
|
|
let attempts=0;
|
|
const loader=new ImageLoader({concurrency:1,request:async()=>{
|
|
attempts++;if(attempts===1)return new Response('error',{status:503});if(attempts===2)throw Error('offline');return response();
|
|
}});
|
|
try{
|
|
const node=new Element();
|
|
for(let index=0;index<2;index++){
|
|
assert.equal(await loader.processImage('https://example.com/retry',node,'img','/icon.svg'),'');
|
|
assert.equal(node.getAttribute('src'),'/icon.svg');assert.equal(node.getAttribute('data-decrypt-failed'),'1');
|
|
}
|
|
const url=await loader.processImage('https://example.com/retry',node);
|
|
assert.match(url,/^blob:/);assert.equal(node.getAttribute('data-decrypt-failed'),null);assert.equal(attempts,3);
|
|
node.onerror();assert.equal(node.getAttribute('src'),'/placeholder.png');assert.equal(node.onerror,null);
|
|
}finally{loader.dispose();}
|
|
});
|
|
|
|
test('timed-out requests abort and allow later images to load',async()=>{
|
|
let aborted=false;
|
|
const loader=new ImageLoader({concurrency:1,timeoutMs:5,request:async(url,{signal})=>{
|
|
if(url.endsWith('/hang'))return new Promise((_resolve,reject)=>signal.addEventListener('abort',()=>{aborted=true;reject(new DOMException('Aborted','AbortError'));},{once:true}));
|
|
return response();
|
|
}});
|
|
try{
|
|
const failed=assert.rejects(loader.fetchDecrypt('https://example.com/hang'),{name:'AbortError'});
|
|
const next=loader.fetchDecrypt('https://example.com/next');
|
|
await failed;assert.equal(aborted,true);assert.match(await next,/^blob:/);
|
|
}finally{loader.dispose();}
|
|
});
|
|
|
|
test('late image results cannot overwrite a newer source or its fallback',async()=>{
|
|
const pending=new Map();
|
|
const loader=new ImageLoader({request:async url=>new Promise(resolve=>pending.set(url,resolve))});
|
|
try{
|
|
const node=new Element();
|
|
const old=loader.processImage('https://example.com/old',node);
|
|
const current=loader.processImage('https://example.com/new',node);
|
|
pending.get('https://example.com/new')(response());const url=await current;
|
|
pending.get('https://example.com/old')(new Response('error',{status:500}));assert.equal(await old,'');
|
|
assert.equal(node.getAttribute('src'),url);assert.equal(node.getAttribute('data-decrypt-failed'),null);
|
|
}finally{loader.dispose();}
|
|
});
|
|
|
|
test('disposing the loader aborts requests, rejects queued work and revokes cached blobs',async()=>{
|
|
const loader=new ImageLoader({concurrency:1,request:async(url,{signal})=>url.endsWith('/ready')?response():new Promise((_resolve,reject)=>signal.addEventListener('abort',()=>reject(new DOMException('Aborted','AbortError')),{once:true}))});
|
|
const url=await loader.fetchDecrypt('https://example.com/ready');
|
|
const active=assert.rejects(loader.fetchDecrypt('https://example.com/active'));
|
|
const queued=assert.rejects(loader.fetchDecrypt('https://example.com/queued'),/已停止/);
|
|
loader.dispose();await active;await queued;
|
|
await assert.rejects(fetch(url));await assert.rejects(loader.fetchDecrypt('https://example.com/ready'),/已停止/);
|
|
});
|
|
|
|
test('initial and dynamic markup load, lazy images wait for visibility, and changed sources reload',async()=>{
|
|
const originals=Object.fromEntries(['window','document','HTMLElement','MutationObserver','IntersectionObserver','fetch'].map(key=>[key,globalThis[key]]));
|
|
const requests=[];let mutations,intersection;
|
|
class MutationObserver {constructor(callback){this.callback=callback;mutations=this;}observe(){}disconnect(){}}
|
|
class IntersectionObserver {
|
|
observed=new Set();constructor(callback,options){this.callback=callback;this.options=options;intersection=this;}
|
|
observe(node){this.observed.add(node);}unobserve(node){this.observed.delete(node);}disconnect(){this.observed.clear();}
|
|
show(node){this.callback([{target:node,isIntersecting:true}]);}
|
|
}
|
|
const hero=new Element('IMG',{'data-src':'https://example.com/hero',fetchpriority:'high'});
|
|
const card=new Element('IMG',{'data-src':'https://example.com/card',loading:'lazy'});
|
|
const video=new Element('VIDEO',{'data-poster':'https://example.com/poster'});
|
|
const body=new Element('BODY');body.children=[hero,card,video];
|
|
globalThis.HTMLElement=Element;globalThis.MutationObserver=MutationObserver;globalThis.IntersectionObserver=IntersectionObserver;
|
|
globalThis.document={body,querySelectorAll:()=>body.querySelectorAll()};globalThis.window=new EventTarget();globalThis.window.matchMedia=()=>({matches:true});
|
|
globalThis.fetch=async url=>{requests.push(url);return response();};
|
|
let runtime;
|
|
try{
|
|
runtime=startImageLoading();assert.equal(startImageLoading(),runtime);await flush();
|
|
assert.deepEqual(requests,['https://example.com/hero','https://example.com/poster']);assert.match(video.getAttribute('poster'),/^blob:/);
|
|
assert.equal(intersection.options.rootMargin,'180px 0px');assert.equal(intersection.observed.has(card),true);
|
|
intersection.show(card);await flush();assert.equal(requests.at(-1),'https://example.com/card');
|
|
const added=new Element('IMG',{'data-src':'https://example.com/dynamic'});
|
|
mutations.callback([{type:'childList',addedNodes:[added],removedNodes:[]}]);await flush();assert.match(added.getAttribute('src'),/^blob:/);
|
|
added.setAttribute('data-src','https://example.com/changed');mutations.callback([{type:'attributes',target:added}]);await flush();assert.equal(requests.at(-1),'https://example.com/changed');
|
|
const reinserted=new Element('IMG',{'data-src':'https://example.com/reinserted',loading:'lazy'});
|
|
mutations.callback([{type:'childList',addedNodes:[reinserted],removedNodes:[]}]);assert.equal(intersection.observed.has(reinserted),true);
|
|
mutations.callback([{type:'childList',addedNodes:[],removedNodes:[reinserted]}]);assert.equal(intersection.observed.has(reinserted),false);
|
|
mutations.callback([{type:'childList',addedNodes:[reinserted],removedNodes:[]}]);intersection.show(reinserted);await flush();assert.equal(requests.at(-1),'https://example.com/reinserted');
|
|
assert.equal(typeof globalThis.window.processImage,'function');
|
|
}finally{runtime?.dispose();for(const [key,value] of Object.entries(originals)){if(value===undefined)delete globalThis[key];else globalThis[key]=value;}}
|
|
});
|