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(''),'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('Error'),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'),'/assets/poster-placeholder.svg');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;}} });