Files
2026-11-06 00:13:27 +07:00

279 lines
20 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)).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 analytics, 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.deepEqual(tracks,[]);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,[],'startup ads emit no analytics events');
assert.equal(grid.open,true);assert.equal(promotions.children.length,1);assert.equal(media.dataset.adPlacement,undefined);
const link=promotions.children[0];assert.equal(link.href,'https://example.com/app');
assert.equal(link.rel,'sponsored nofollow noopener noreferrer');assert.equal(link.dataset.adType,undefined);assert.equal(link.dataset.trackAd,undefined);assert.equal(link.dataset.adPlacement,undefined);
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:'<script>inert text</script>',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(Object.keys(noLink.dataset),[]);
assert.equal(noLink.children[0].alt,'<script>inert text</script>');assert.equal(noLink.children[1].textContent,'<script>inert text</script>');
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;}}
});