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)).toString('base64'); } const protocolUrl=await moduleUrl('../src/lib/api/protocol.ts'); const startupUrl=await moduleUrl('../src/lib/client/startup.ts',code=>code.replace(/from ['"]\.\.\/api\/protocol['"]/,`from '${protocolUrl}'`)); const {StartupDialogs,loadStartupImage,startStartupDialogs}=await import(startupUrl); const flush=()=>new Promise(resolve=>setImmediate(resolve)); const ad=id=>({id,title:id,images:`https://example.com/${id}.png`,link:`https://example.com/${id}`}); const ads=[ad('first'),ad('second')]; function session(initial={}){ const data=new Map(Object.entries(initial)); return {data,getItem:key=>data.get(key)||null,setItem:(key,value)=>data.set(key,value),removeItem:key=>data.delete(key)}; } function fixture(storage=session(),load=async()=>{},loadGrid=async items=>items){ const state={age:false,current:null,shown:[],loaded:[],grid:null,grids:[]}; const view={ showAge(){state.age=true;},closeAge(){state.age=false;}, prepareAdvertisement(){state.current=null;}, async loadAdvertisement(item,signal){state.loaded.push(item.id);await load(item,signal);}, showAdvertisement(item){state.current=item.id;state.shown.push(item.id);}, hideAdvertisement(){state.current=null;}, loadGrid, showGrid(items){state.grid=items.map(item=>item.id);state.grids.push(state.grid);}, hideGrid(){state.grid=null;}, }; return {state,storage,controller:new StartupDialogs(storage,view)}; } test('first session asks for age, then shows every ad in order and remembers completion',async()=>{ const f=fixture();assert.equal(f.state.age,true); f.controller.setAdvertisements(ads);await flush(); assert.equal(f.state.current,null);assert.deepEqual(f.state.loaded,['first']); f.controller.confirmAge(); assert.equal(f.state.age,false);assert.equal(f.storage.getItem('hg_age_ok'),'1');assert.equal(f.state.current,'first'); f.controller.confirmAge();assert.deepEqual(f.state.shown,['first']); f.controller.nextAdvertisement();await flush(); assert.equal(f.state.current,'second'); assert.deepEqual(JSON.parse(f.storage.getItem('dialogAdProgress')),{signature:'2|first|second',index:1}); f.controller.nextAdvertisement();await flush(); assert.equal(f.state.current,null);assert.equal(f.storage.getItem('dialogAdDismissed'),'2|first|second'); assert.equal(f.storage.getItem('dialogAdProgress'),null); const reload=fixture(f.storage);reload.controller.setAdvertisements(ads);await flush(); assert.equal(reload.state.age,false);assert.deepEqual(reload.state.loaded,[]);assert.equal(reload.state.current,null); }); test('grid follows age confirmation and the entire big-ad queue, then stays dismissed for the session',async()=>{ const f=fixture(); f.controller.setAdvertisements(ads,[ad('app-one'),ad('app-two')]);await flush(); assert.equal(f.state.grid,null); f.controller.confirmAge();assert.equal(f.state.current,'first');assert.equal(f.state.grid,null); f.controller.nextAdvertisement();await flush();assert.equal(f.state.current,'second');assert.equal(f.state.grid,null); f.controller.nextAdvertisement();assert.deepEqual(f.state.grid,['app-one','app-two']);assert.equal(f.state.current,null); f.controller.confirmAge();f.controller.nextAdvertisement();assert.equal(f.state.grids.length,1); f.controller.dismissGrid();assert.equal(f.state.grid,null);assert.ok(f.storage.getItem('gridAdDismissed')); f.controller.setAdvertisements(ads,[ad('app-one'),ad('app-two')]);await flush();assert.equal(f.state.grid,null); const reload=fixture(f.storage);reload.controller.setAdvertisements(ads,[ad('app-one'),ad('app-two')]);await flush(); assert.equal(reload.state.grid,null);assert.deepEqual(reload.state.loaded,[]); reload.controller.setAdvertisements(ads,[ad('updated-app')]);await flush();assert.deepEqual(reload.state.grid,['updated-app']); }); test('grid works without big ads, with late configuration, and after already-dismissed big ads',async()=>{ for(const bigAds of [[],ads]){ const store=session({dialogAdDismissed:'2|first|second'}),f=fixture(store); f.controller.setAdvertisements(bigAds,[ad('app')]);await flush();assert.equal(f.state.grid,null); f.controller.confirmAge();assert.deepEqual(f.state.grid,['app']); const reload=fixture(store);reload.controller.setAdvertisements(bigAds,[ad('app')]);await flush(); assert.deepEqual(reload.state.grid,['app'],'refresh before closing the grid resumes it'); } const f=fixture();f.controller.confirmAge();f.controller.setAdvertisements([],[ad('late-app')]);await flush(); assert.deepEqual(f.state.grid,['late-app']); }); test('empty and malformed grids do not open, and failed big ads do not prevent a valid grid',async()=>{ const f=fixture(session({hg_age_ok:'1'}),async()=>{throw Error('failed');}); for(const list of [null,{},[null,3,[]], [{...ad('unsafe'),images:'javascript:alert(1)'}]]){ f.controller.setAdvertisements([],list);await flush();assert.equal(f.state.grid,null); } f.controller.setAdvertisements(ads,[null,ad('app'),{}]);await flush(); assert.deepEqual(f.state.grid,['app']);assert.equal(f.storage.getItem('dialogAdDismissed'),null); }); test('grid failures stay retryable on refresh, stale results are ignored, and removing config hides the popup',async()=>{ const store=session({hg_age_ok:'1'}),pending=new Map(); const f=fixture(store,undefined,(items,signal)=>new Promise(resolve=>pending.set(items[0].id,{resolve,signal}))); f.controller.setAdvertisements([],[ad('old')]);f.controller.setAdvertisements([],[ad('new')]); assert.equal(pending.get('old').signal.aborted,true); pending.get('old').resolve([ad('old')]);await flush();assert.equal(f.state.grid,null); pending.get('new').resolve([ad('new')]);await flush();assert.deepEqual(f.state.grid,['new']); f.controller.setAdvertisements([],[]);assert.equal(f.state.grid,null);assert.equal(store.getItem('gridAdDismissed'),null); const failed=fixture(store,undefined,async()=>[]);failed.controller.setAdvertisements([],[ad('app')]);await flush();assert.equal(failed.state.grid,null); const reload=fixture(store);reload.controller.setAdvertisements([],[ad('app')]);await flush();assert.deepEqual(reload.state.grid,['app']); }); test('a new big-ad queue pauses a ready grid, and storage restrictions never reopen a dismissed grid in the same document',async()=>{ const fail=()=>{throw Error('storage disabled');},f=fixture({getItem:fail,setItem:fail,removeItem:fail}); f.controller.confirmAge();f.controller.setAdvertisements([],[ad('app')]);await flush();assert.deepEqual(f.state.grid,['app']); f.controller.setAdvertisements([ad('big')],[ad('app')]);await flush();assert.equal(f.state.grid,null);assert.equal(f.state.current,'big'); f.controller.nextAdvertisement();assert.deepEqual(f.state.grid,['app']);f.controller.dismissGrid(); f.controller.setAdvertisements([ad('big')],[ad('app')]);f.controller.confirmAge();await flush();assert.equal(f.state.grid,null); }); test('accepting age before slow config arrives does not miss the ad queue',async()=>{ const f=fixture();f.controller.confirmAge();await flush();assert.equal(f.state.current,null); f.controller.setAdvertisements(ads);await flush();assert.equal(f.state.current,'first'); }); test('refresh resumes the current ad instead of restarting the completed prefix',async()=>{ const store=session({hg_age_ok:'1',dialogAdProgress:JSON.stringify({signature:'2|first|second',index:1})}); const f=fixture(store);f.controller.setAdvertisements(ads);await flush(); assert.equal(f.state.age,false);assert.deepEqual(f.state.loaded,['second']);assert.equal(f.state.current,'second'); f.controller.nextAdvertisement();assert.equal(store.getItem('dialogAdDismissed'),'2|first|second'); }); test('changed ad codes reset the queue; the same configuration does not reopen it',async()=>{ const store=session({hg_age_ok:'1',dialogAdDismissed:'2|first|second'}); const f=fixture(store);f.controller.setAdvertisements(ads);await flush();assert.deepEqual(f.state.loaded,[]); const changed={...ad('creative'),id:'old-id',advertiseCode:' new-code '}; f.controller.setAdvertisements([changed]);await flush();assert.equal(f.state.current,'new-code'); f.controller.setAdvertisements([changed]);await flush();assert.deepEqual(f.state.shown,['new-code']); f.controller.nextAdvertisement();assert.equal(store.getItem('dialogAdDismissed'),'2|new-code'); f.controller.setAdvertisements([{...ad('fallback-id'),advertiseCode:' '}]);await flush(); assert.equal(f.state.current,'fallback-id'); }); test('invalid image URLs and failed images are skipped without changing remaining order',async()=>{ const f=fixture(session({hg_age_ok:'1'}),async item=>{if(item.id==='broken')throw Error('decode failed');}); f.controller.setAdvertisements([{...ad('unsafe'),images:'javascript:alert(1)'},ad('broken'),ad('working')]);await flush(); assert.deepEqual(f.state.loaded,['broken','working']);assert.deepEqual(f.state.shown,['working']); f.controller.nextAdvertisement();assert.equal(f.storage.getItem('dialogAdDismissed'),'2|broken|working'); }); test('null ad lists and malformed rows do not prevent valid popup ads from displaying',async()=>{ const f=fixture(session({hg_age_ok:'1'})); for(const value of [null,undefined,{},'bad']){ f.controller.setAdvertisements(value);await flush();assert.equal(f.state.current,null); } f.controller.setAdvertisements([null,undefined,3,[],ad('working')]);await flush(); assert.deepEqual(f.state.loaded,['working']);assert.equal(f.state.current,'working'); f.controller.nextAdvertisement();assert.equal(f.storage.getItem('dialogAdDismissed'),'2|working'); }); test('when every image fails the queue remains retryable on a new page',async()=>{ const store=session({hg_age_ok:'1'}); const f=fixture(store,async()=>{throw Error('network unavailable');});f.controller.setAdvertisements(ads);await flush(); assert.equal(f.state.current,null);assert.equal(store.getItem('dialogAdDismissed'),null);assert.equal(store.getItem('dialogAdProgress'),null); const reload=fixture(store);reload.controller.setAdvertisements(ads);await flush();assert.equal(reload.state.current,'first'); }); test('disabled sessionStorage still permits confirming age and closing all ads',async()=>{ const fail=()=>{throw Error('storage blocked');}; const f=fixture({getItem:fail,setItem:fail,removeItem:fail}); f.controller.setAdvertisements(ads);await flush();f.controller.confirmAge();assert.equal(f.state.current,'first'); f.controller.nextAdvertisement();await flush();assert.equal(f.state.current,'second'); f.controller.nextAdvertisement();assert.equal(f.state.current,null); }); test('outdated async image results cannot replace a newer configuration',async()=>{ const pending=new Map(); const f=fixture(session({hg_age_ok:'1'}),(item,signal)=>new Promise(resolve=>pending.set(item.id,{resolve,signal}))); f.controller.setAdvertisements([ad('old')]);f.controller.setAdvertisements([ad('new')]); assert.equal(pending.get('old').signal.aborted,true); pending.get('old').resolve();await flush();assert.equal(f.state.current,null); pending.get('new').resolve();await flush();assert.equal(f.state.current,'new');assert.deepEqual(f.state.shown,['new']); }); test('malformed or unrelated progress starts at the first ad',async()=>{ for(const progress of ['bad json',JSON.stringify({signature:'other',index:1}),JSON.stringify({signature:'2|first|second',index:-1}),JSON.stringify({signature:'2|first|second',index:'bad'})]){ const f=fixture(session({hg_age_ok:'1',dialogAdProgress:progress}));f.controller.setAdvertisements(ads);await flush(); assert.equal(f.state.current,'first'); } }); class FakeElement extends EventTarget { attrs=new Map();dataset={};hidden=false;disabled=false;children=[]; classes=new Set(); classList={add:key=>this.classes.add(key),remove:key=>this.classes.delete(key),contains:key=>this.classes.has(key)}; append(...items){this.children.push(...items);for(const item of items)item.parent=this;} replaceChildren(...items){this.children=[];this.append(...items);} querySelectorAll(){return this.children;} closest(selector){return this.className==='startup-promotion'&&selector==='.startup-promotion[href]'&&this.hasAttribute('href')?this:this.parent?.closest(selector);} setAttribute(key,value){this.attrs.set(key,String(value));} removeAttribute(key){this.attrs.delete(key);} hasAttribute(key){return this.attrs.has(key);} set href(value){this.attrs.set('href',value);} get href(){return this.attrs.get('href');} click(){this.dispatchEvent(new Event('click'));} } class FakeImage extends FakeElement { complete=false;naturalWidth=0;mode='load'; constructor(mode='load'){super();this.mode=mode;} set src(value){ this.source=value;this.complete=false;this.naturalWidth=0; const finish=()=>{this.complete=true;this.naturalWidth=this.mode==='error'?0:400;this.dispatchEvent(new Event(this.mode==='error'?'error':'load'));}; if(this.mode==='cached')finish();else if(this.mode!=='pending')queueMicrotask(finish); } cloneNode(){return new FakeImage(this.mode);} replaceWith(image){this.replacement=image;} } test('image loading supports cached images, errors, timeouts and cancellation',async()=>{ const previous=globalThis.window;globalThis.window={}; try{ for(const mode of ['load','cached'])await loadStartupImage(new FakeImage(mode),'https://example.com/ad.png',new AbortController().signal); await assert.rejects(loadStartupImage(new FakeImage('error'),'https://example.com/ad.png',new AbortController().signal),/加载失败|无法解码/); await assert.rejects(loadStartupImage(new FakeImage('pending'),'https://example.com/ad.png',new AbortController().signal,5),/超时/); const controller=new AbortController();const promise=loadStartupImage(new FakeImage('pending'),'https://example.com/ad.png',controller.signal); controller.abort();await assert.rejects(promise,{name:'AbortError'}); globalThis.window.processImage=async()=>false; await assert.rejects(loadStartupImage(new FakeImage('pending'),'https://example.com/ad.png',new AbortController().signal),/解码失败/); }finally{if(previous===undefined)delete globalThis.window;else globalThis.window=previous;} }); test('dialog controls enforce age confirmation, advance ads on Escape, emit no tracking events, and use the reference exit',async()=>{ class Dialog extends FakeElement { open=false;nodes=new Map(); showModal(){this.open=true;} close(){this.open=false;} querySelector(selector){return this.nodes.get(selector);} querySelectorAll(){return [exit,ageClose];} } const age=new Dialog(),popup=new Dialog(),grid=new Dialog(); const enter=new FakeElement(),exit=new FakeElement(),ageClose=new FakeElement(),close=new FakeElement(),media=new FakeElement(),loading=new FakeElement(),image=new FakeImage(),promotions=new FakeElement(),gridClose=new FakeElement(); age.nodes.set('.startup-age-confirm',enter); popup.nodes.set('.startup-ad-image',image);popup.nodes.set('.startup-ad-media',media);popup.nodes.set('.startup-ad-loading',loading);popup.nodes.set('.startup-close',close); grid.nodes.set('.startup-promotions',promotions);grid.nodes.set('.startup-close',gridClose); const doc=new EventTarget(),classes=new Set();const store=session(),exits=[],tracks=[]; doc.getElementById=id=>({'startup-age':age,'startup-ad':popup,'startup-grid':grid}[id]); doc.createElement=tag=>tag==='img'?new FakeImage():new FakeElement(); doc.documentElement={classList:{add:key=>classes.add(key),contains:key=>classes.has(key)}}; doc.addEventListener('external-ad:track',event=>tracks.push(event.detail.eventType)); const originals={window:globalThis.window,document:globalThis.document,location:globalThis.location,Element:globalThis.Element}; globalThis.Element=FakeElement; globalThis.window={sessionStorage:store};globalThis.document=doc;globalThis.location={assign:url=>exits.push(url)}; try{ const controller=startStartupDialogs();controller.setAdvertisements(ads,[ad('app')]);await flush(); assert.equal(age.open,true);assert.equal(popup.open,false); const cancelAge=new Event('cancel',{cancelable:true});age.dispatchEvent(cancelAge);assert.equal(cancelAge.defaultPrevented,true);assert.equal(age.open,true); enter.click();assert.equal(age.open,false);assert.equal(popup.open,true);assert.equal(media.href,'https://example.com/first');assert.equal(store.getItem('hg_age_ok'),'1'); media.click();assert.equal(popup.open,true); const cancelAd=new Event('cancel',{cancelable:true});popup.dispatchEvent(cancelAd);await flush(); assert.equal(cancelAd.defaultPrevented,true);assert.equal(media.href,'https://example.com/second');assert.equal(popup.open,true); close.click();assert.equal(popup.open,false);assert.equal(store.getItem('dialogAdDismissed'),'2|first|second'); assert.deepEqual(tracks,[]); assert.equal(grid.open,true);assert.equal(promotions.children.length,1); const link=promotions.children[0];assert.equal(link.href,'https://example.com/app'); assert.equal(link.rel,'sponsored nofollow noopener noreferrer');assert.deepEqual({...link.dataset},{}); assert.equal(link.children[0].alt,'app');assert.equal(link.children[1].textContent,'app'); const keyboard=new Event('keydown');keyboard.key='Tab';grid.dispatchEvent(keyboard);assert.equal(grid.classList.contains('startup-keyboard-focus'),true); grid.dispatchEvent(new Event('pointerdown'));assert.equal(grid.classList.contains('startup-keyboard-focus'),false); gridClose.click();assert.equal(grid.open,false); controller.setAdvertisements([], [ad('next-app')]);await flush();assert.equal(grid.open,true); const cancelGrid=new Event('cancel',{cancelable:true});grid.dispatchEvent(cancelGrid);assert.equal(cancelGrid.defaultPrevented,true);assert.equal(grid.open,false); controller.setAdvertisements([], [ad('third-app')]);await flush();assert.equal(grid.open,true); const click=new Event('click');Object.defineProperty(click,'target',{value:promotions.children[0].children[0]});promotions.dispatchEvent(click);assert.equal(grid.open,false); const decoded=[]; window.processImage=async(source,node)=>{ decoded.push(source); if(source.includes('broken'))return ''; node.src=source;return source; }; controller.setAdvertisements([],[ad('broken'),{...ad('no-link'),link:'javascript:alert(1)',title:'',advertiseCode:'grid-code',advertiseLocationCode:'slot-3',advertiseLocationName:'应用推广',location:3},ad('last')]);await flush(); assert.equal(grid.open,true);assert.equal(promotions.children.length,2);assert.equal(decoded.length,3); const noLink=promotions.children[0];assert.equal(noLink.hasAttribute('href'),false);assert.deepEqual({...noLink.dataset},{}); assert.equal(noLink.children[0].alt,'');assert.equal(noLink.children[1].textContent,''); assert.equal(promotions.children[1].href,'https://example.com/last'); const inertClick=new Event('click');Object.defineProperty(inertClick,'target',{value:noLink.children[0]});promotions.dispatchEvent(inertClick);assert.equal(grid.open,true); gridClose.click(); exit.click();ageClose.click();assert.deepEqual(exits,['https://www.google.com','https://www.google.com']); }finally{for(const [key,value] of Object.entries(originals)){if(value===undefined)delete globalThis[key];else globalThis[key]=value;}} });