@@ -0,0 +1,280 @@
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFile} from 'node:fs/promises';
|
||||
import ts from 'typescript';
|
||||
import {runInNewContext} from 'node:vm';
|
||||
|
||||
// Run the bundled SDK's real named-event validators without initializing it:
|
||||
// accepted events stay in its pre-init queue and never contact an analytics server.
|
||||
const sdkLogs=[];
|
||||
const sdkSandbox={console:{warn:(...args)=>sdkLogs.push(args)}};
|
||||
runInNewContext(await readFile(new URL('../public/scripts/web-sdk-v1.2.0.js',import.meta.url),'utf8'),sdkSandbox);
|
||||
|
||||
async function moduleUrl(file,replace=code=>code){
|
||||
const code=ts.transpileModule(await readFile(new URL(file,import.meta.url),'utf8'),{compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.ESNext}}).outputText;
|
||||
return 'data:text/javascript;base64,'+Buffer.from(replace(code)).toString('base64');
|
||||
}
|
||||
const trackingUrl=await moduleUrl('../src/lib/tracking.ts');
|
||||
const reportUrl=await moduleUrl('../src/lib/analytics/report-fields.ts');
|
||||
const {TRACKER_APP_ID,COMMON_KEYS,SDK_COMMON_KEYS,base64JSON,trackingPage}=await import(trackingUrl);
|
||||
const authUrl='data:text/javascript,export function authMetadata(){return {channel:"paid_1050",devID:"business-device",deviceId:"business-device"}}';
|
||||
const userAgentUrl=await moduleUrl('../src/lib/api/user-agent.ts');
|
||||
const analyticsUrl=await moduleUrl('../src/lib/client/analytics.ts',code=>code.replace("'../tracking'",JSON.stringify(trackingUrl)).replace("'./auth'",JSON.stringify(authUrl)).replace("'../api/user-agent'",JSON.stringify(userAgentUrl)).replace("'../analytics/report-fields'",JSON.stringify(reportUrl)));
|
||||
const {SiteTracker}=await import(analyticsUrl);
|
||||
const {trackingHeaders}=await import(await moduleUrl('../src/lib/api/tracking-headers.ts',code=>code.replace("'../tracking'",JSON.stringify(trackingUrl))));
|
||||
const flush=()=>new Promise(resolve=>setImmediate(resolve));
|
||||
const common=()=>({app_id:TRACKER_APP_ID,uid:'',channel:'paid_1050',sid:'sid-raw',client_ts:Math.floor(Date.now()/1000),device:'PC',device_id:'sdk-device',device_brand:'',device_model:'',device_fingerprint:'raw-fingerprint',fp_version:'3.0.1',user_agent:'test UA',sdk_version:'1.2.0',app_version:'1.0.0'});
|
||||
|
||||
class Element extends EventTarget {
|
||||
dataset={};hidden=false;attributes=new Map();
|
||||
getClientRects(){return this.hidden?[]:[{}];}
|
||||
closest(selector){
|
||||
if(selector.includes('[hidden]'))return this.hidden||this.closedDialog?this:null;
|
||||
if(selector==='[data-track-item]'&&this.hasAttribute('data-track-item'))return this;
|
||||
if(selector==='[data-track-ad="external"]'&&this.getAttribute('data-track-ad')==='external')return this;
|
||||
if(selector.includes('a[data-app-download]')&&this.hasAttribute('data-app-download'))return this;
|
||||
if(selector.includes('a[data-track-navigation]')&&this.dataset.trackNavigation)return this;
|
||||
return this.parents?.[selector]||null;
|
||||
}
|
||||
getAttribute(name){return this.attributes.get(name)||null;}
|
||||
hasAttribute(name){return this.attributes.has(name);}
|
||||
}
|
||||
class Video extends Element {currentTime=0;duration=100;playbackRate=1.25;paused=false;ended=false;seeking=false;}
|
||||
function fixture(){
|
||||
const saved=new Map(),events=[],calls=[],nodes=[],results=[],diagnostics=[],observed=new Set(),consoleCalls=[];
|
||||
let clock=1000,video,intersection,mutation,modal=null;
|
||||
const fields=common();
|
||||
const sdk={init(options){calls.push(options);},track:new Proxy({}, {get:(_,method)=>async payload=>{events.push({method,payload});const result=JSON.parse(JSON.stringify(await sdkSandbox.WebSDK.track[method](payload)));results.push({method,...result});return result;}}),getCommonFields:async()=>({...fields}),setUid(uid){fields.uid=uid;},setChannel(channel){fields.channel=channel;},notifyPageLoad(){calls.push('route');},flush:async()=>{calls.push('flush');}};
|
||||
const win=new EventTarget();win.WebSDK=sdk;win.console={warn:(...args)=>consoleCalls.push(args)};
|
||||
const document=new EventTarget();Object.assign(document,{referrer:'https://search.example/',documentElement:new Element(),head:{append(){}},querySelector:selector=>selector==='dialog:modal'?modal:video||null,querySelectorAll:()=>nodes});
|
||||
document.addEventListener('tracker:diagnostic',event=>diagnostics.push(event.detail));
|
||||
const values={window:win,document,location:{pathname:'/',search:'',origin:'https://site.example',href:'https://site.example/'},performance:{now:()=>clock,getEntriesByType:()=>[{duration:42}]},Element,HTMLVideoElement:Video,IntersectionObserver:class{constructor(callback){intersection=callback;}observe(node){observed.add(node);}unobserve(node){observed.delete(node);}disconnect(){observed.clear();}},MutationObserver:class{constructor(callback){mutation=callback;}observe(){}},innerWidth:1000,innerHeight:800,screen:{width:1000,height:800}};
|
||||
for(const [key,value] of Object.entries(values)){saved.set(key,Object.getOwnPropertyDescriptor(globalThis,key));Object.defineProperty(globalThis,key,{configurable:true,writable:true,value});}
|
||||
const tracker=new SiteTracker();win.tracker=tracker;
|
||||
return {tracker,events,calls,fields,nodes,win,document,results,diagnostics,observed,sdk,consoleCalls,location:values.location,mutate:()=>mutation([]),setModal:value=>{modal=value;},setClock:value=>{clock=value;},setVideo:value=>{video=value;},intersect:node=>intersection([{target:node,isIntersecting:true}]),close(){for(const [key,descriptor] of saved)if(descriptor)Object.defineProperty(globalThis,key,descriptor);else delete globalThis[key];}};
|
||||
}
|
||||
const drama={id:23456,title:'短剧标题',totalEpisode:12,themeIds:[8],themeNames:['都市'],tagIds:[9],tagNames:['爱情']};
|
||||
const chapters=[{id:90001,currentEpisode:1,mediaTime:100},{id:90002,currentEpisode:2,mediaTime:100}];
|
||||
|
||||
function advertisement(placement='inline'){
|
||||
const node=new Element();node.attributes.set('data-track-ad','external');
|
||||
node.dataset={adId:'same-code',adSlotKey:'slot-3',adSlotName:'推荐应用',adType:'icon',adClickable:'1',adPlacement:placement};
|
||||
return node;
|
||||
}
|
||||
|
||||
test('real SDK accepts content exposures, clicks, search and navigation fields including the root parent id',async()=>{
|
||||
const f=fixture();try{
|
||||
f.tracker.configure({reportSdkConfig:'configured-cipher'});await flush();
|
||||
const item=new Element();item.attributes.set('data-track-item','');item.dataset={itemId:'23456',itemType:'short_drama',itemName:'剧名'};
|
||||
f.intersect(item);f.tracker.click({target:item,clientX:10,clientY:10});
|
||||
f.tracker.search('短剧',28,1);
|
||||
const nav=new Element();nav.dataset.trackNavigation='tg_group';nav.textContent='官方TG群';nav.href='https://t.me/example';nav.attributes.set('href',nav.href);
|
||||
f.tracker.click({target:nav,clientX:10,clientY:10});
|
||||
await flush();
|
||||
for(const method of ['appPageView','appPageitemExposure','appPageitemClick','keywordSearch','navigation','pageClick'])assert.ok(f.results.some(result=>result.method===method&&result.ok),method);
|
||||
assert.ok(f.events.filter(e=>e.method.startsWith('appPageitem')).every(e=>e.payload.parent_item_id==='0'));
|
||||
assert.deepEqual(f.results.filter(result=>!result.ok),[]);assert.deepEqual(f.diagnostics,[]);
|
||||
}finally{f.close();}
|
||||
});
|
||||
|
||||
test('home, large-popup and grid-popup advertisements report independently without duplicating each placement',async()=>{
|
||||
const f=fixture();try{
|
||||
const nodes=['inline','startup-ad','startup-grid'].map(advertisement);
|
||||
// Popup events can arrive before the asynchronous SDK configuration.
|
||||
for(const node of nodes){f.tracker.externalAd(node,'show');f.tracker.externalAd(node,'show');f.tracker.externalAd(node,'click');}
|
||||
assert.equal(f.events.length,0);
|
||||
f.tracker.configure({reportSdkConfig:'configured-cipher'});await flush();
|
||||
assert.equal(f.events.filter(e=>e.method==='adImpression').length,3);assert.equal(f.events.filter(e=>e.method==='adClick').length,3);
|
||||
assert.ok(f.events.filter(e=>e.method==='adImpression').every(e=>e.payload.ad_slot_key==='slot-3'),'retain API advertisement slot codes');
|
||||
assert.deepEqual(f.results.filter(result=>!result.ok),[]);
|
||||
const count=f.events.length;f.tracker.externalAd(nodes[1],'close');assert.equal(f.events.length,count,'closing is not an ad click');
|
||||
for(const node of nodes.slice(1)){f.tracker.advertising(node,'close');f.tracker.advertising(node,'close');}
|
||||
const popup=f.events.filter(e=>e.method==='advertising');
|
||||
assert.equal(popup.filter(e=>e.payload.event_type==='show').length,2);
|
||||
assert.equal(popup.filter(e=>e.payload.event_type==='click').length,2);
|
||||
assert.equal(popup.filter(e=>e.payload.event_type==='close').length,2);
|
||||
await flush();assert.deepEqual(f.results.filter(result=>!result.ok),[]);
|
||||
}finally{f.close();}
|
||||
});
|
||||
|
||||
test('closed dialogs and obscured background content are observed again when the modal closes',async()=>{
|
||||
const f=fixture();try{
|
||||
f.tracker.configure({reportSdkConfig:'configured-cipher'});await flush();
|
||||
const background=advertisement(),grid=advertisement('startup-grid'),offscreen=advertisement('offscreen');offscreen.dataset.adId='offscreen';
|
||||
f.nodes.push(background,grid,offscreen);grid.closedDialog=true;f.mutate();
|
||||
const modal={contains:node=>node===grid||node===offscreen};f.setModal(modal);grid.closedDialog=false;f.mutate();
|
||||
assert.equal(f.observed.has(background),false);assert.ok(f.observed.has(grid));
|
||||
f.intersect(background);assert.equal(f.events.filter(e=>e.method==='adImpression').length,0);
|
||||
f.intersect(grid);assert.equal(f.events.filter(e=>e.method==='adImpression').length,1);
|
||||
assert.equal(f.events.some(e=>e.payload.ad_id==='offscreen'),false,'a scrollable grid must only expose intersecting tiles');
|
||||
f.intersect(offscreen);assert.equal(f.events.filter(e=>e.method==='adImpression').length,2);
|
||||
grid.closedDialog=true;offscreen.closedDialog=true;f.setModal(null);f.mutate();
|
||||
assert.ok(f.observed.has(background));f.intersect(background);assert.equal(f.events.filter(e=>e.method==='adImpression').length,3);
|
||||
f.document.hidden=true;const hidden=advertisement('hidden');hidden.dataset.adId='hidden';const before=f.events.length;
|
||||
f.tracker.externalAd(hidden,'show');f.intersect(hidden);assert.equal(f.events.length,before);
|
||||
f.nodes.push(hidden);f.document.hidden=false;f.document.dispatchEvent(new Event('visibilitychange'));
|
||||
assert.ok(f.observed.has(hidden));f.intersect(hidden);assert.equal(f.events.filter(e=>e.method==='adImpression').length,4);
|
||||
}finally{f.close();}
|
||||
});
|
||||
|
||||
test('SDK validation errors and denied event codes produce sanitized diagnostics instead of disappearing silently',async()=>{
|
||||
const f=fixture();try{
|
||||
f.tracker.configure({reportSdkConfig:'configured-cipher'});await flush();
|
||||
const broken=new Element();broken.attributes.set('data-track-item','');broken.dataset={itemId:'7',itemType:'short_drama'};
|
||||
f.intersect(broken);await flush();assert.equal(f.diagnostics[0].reason,'event_validation_failed');assert.ok(f.diagnostics[0].missingFields.includes('item_name'));
|
||||
f.sdk.track={adImpression:async()=>({ok:false,reason:'event_code_not_allowed',encryptedConfig:'never-log-cipher',payload:{uid:'never-log-uid'}})};
|
||||
f.tracker.externalAd(advertisement(),'show');await flush();
|
||||
assert.equal(f.diagnostics.at(-1).reason,'event_code_not_allowed');assert.ok(!JSON.stringify(f.diagnostics).includes('never-log'));
|
||||
f.tracker.externalAd(advertisement('next'),'show');await flush();assert.equal(f.diagnostics.filter(d=>d.reason==='event_code_not_allowed').length,1);
|
||||
assert.deepEqual(f.consoleCalls,[]);assert.deepEqual(sdkLogs,[]);
|
||||
}finally{f.close();}
|
||||
});
|
||||
|
||||
test('bootstrap SDK configuration requests bypass SDK readiness, and delayed config preserves more than 200 queued events',async()=>{
|
||||
const f=fixture();try{
|
||||
f.sdk.getCommonFields=()=>{throw Error('configuration must not depend on SDK fields');};
|
||||
assert.deepEqual(await f.tracker.requestHeaders('config/info','GET',{}),{});
|
||||
for(let n=0;n<220;n++){const node=advertisement();node.dataset.adId='ad-'+n;f.tracker.externalAd(node,'show');}
|
||||
f.tracker.externalAd(advertisement('startup-grid'),'click');
|
||||
f.tracker.configure({reportSdkConfig:'configured-cipher'});await flush();
|
||||
assert.equal(f.events.filter(e=>e.method==='adImpression').length,221);assert.equal(f.events.filter(e=>e.method==='adClick').length,1);
|
||||
assert.deepEqual(f.results.filter(result=>!result.ok),[]);
|
||||
}finally{f.close();}
|
||||
});
|
||||
|
||||
test('all playback behavior and episode status payloads pass the real SDK validator',async()=>{
|
||||
const f=fixture();try{
|
||||
f.tracker.configure({reportSdkConfig:'configured-cipher'});await flush();
|
||||
f.tracker.setDrama(drama,chapters[0],chapters);
|
||||
const video=new Video(),mode=new Element();mode.dataset.playMode='main';video.parents={'[data-play-mode]':mode};f.setVideo(video);
|
||||
for(const behavior of ['ep_play','ep_pause','ep_forward','ep_rewind','ep_complete','ep_share','ep_failed'])f.tracker.dramaEvent(behavior,video);
|
||||
for(const behavior of ['ep_previous','ep_next','ep_switch','ep_unlock','ep_auto_play'])f.tracker.dramaStatusChange(chapters[1],behavior);
|
||||
await flush();assert.deepEqual(f.results.filter(result=>!result.ok),[]);assert.deepEqual(f.diagnostics,[]);
|
||||
assert.ok(f.results.some(result=>result.method==='dramaEvent'));assert.ok(f.results.some(result=>result.method==='dramaStatusChange'));
|
||||
}finally{f.close();}
|
||||
});
|
||||
|
||||
test('SDK loading can recover after the first script was unavailable and a failed init leaves queued events retryable',async()=>{
|
||||
const f=fixture();try{
|
||||
delete f.win.WebSDK;let loaded=0;
|
||||
f.document.createElement=()=>({remove(){}});
|
||||
f.document.head.append=script=>{loaded++;queueMicrotask(()=>{f.win.WebSDK=f.sdk;script.onload();});};
|
||||
const initialize=f.sdk.init;let fail=true;
|
||||
f.sdk.init=options=>{if(fail)throw Error('private-config-must-not-appear');return initialize(options);};
|
||||
f.tracker.externalAd(advertisement('startup-ad'),'show');f.tracker.configure({reportSdkConfig:'configured-cipher'});await flush();
|
||||
assert.equal(loaded,1);assert.equal(f.events.length,0);assert.equal(f.diagnostics[0].reason,'sdk_initialization_failed');
|
||||
fail=false;f.tracker.configure({reportSdkConfig:'configured-cipher'});await flush();
|
||||
assert.equal(f.events.filter(e=>e.method==='appPageView').length,1);assert.equal(f.events.filter(e=>e.method==='adImpression').length,1);
|
||||
assert.ok(!JSON.stringify(f.diagnostics).includes('private-config'));
|
||||
}finally{f.close();}
|
||||
});
|
||||
|
||||
test('SDK initializes once from dynamic config, queues the first PV and tracks each native route once',async()=>{
|
||||
const f=fixture();try{
|
||||
f.tracker.configure({});await flush();assert.equal(f.calls.length,0);assert.equal(f.events.length,0);
|
||||
f.tracker.configure({reportSdkConfig:'configured-cipher'});await flush();
|
||||
assert.equal(f.calls[0].appId,TRACKER_APP_ID);assert.equal(f.calls[0].encryptedConfig,'configured-cipher');assert.deepEqual(f.calls[0].disableAutoEvents,['app_page_view','page_click']);
|
||||
assert.equal(f.events.filter(e=>e.method==='appPageView').length,1);
|
||||
f.tracker.configure({reportSdkConfig:'configured-cipher'});f.tracker.pageView();assert.equal(f.events.length,1);
|
||||
f.location.pathname='/rank/new/page/2/';f.tracker.pageView();
|
||||
const pv=f.events.at(-1);assert.equal(pv.payload.page_key,'rank_new');assert.equal(pv.payload.referrer_page_key,'home');assert.equal(f.calls.filter(c=>c==='route').length,1);
|
||||
f.tracker.syncUser({userId:123456});assert.equal(f.fields.uid,'123456');f.tracker.syncUser(null);assert.equal(f.fields.uid,'');
|
||||
for(const path of ['/navigation/','/info/about-us/','/member/settings/','/topics/3/','/video/8/','/richtext/关于我们/','/richetext/关于我们/'])assert.notEqual(trackingPage(path).key,'internal_other');
|
||||
assert.equal(trackingPage('/richtext/关于我们/').key,'rich_text');
|
||||
const count=f.events.filter(e=>e.method==='appPageView').length;
|
||||
f.tracker.resumePage();assert.equal(f.events.filter(e=>e.method==='appPageView').length,count+1);
|
||||
f.tracker.pageView();assert.equal(f.events.filter(e=>e.method==='appPageView').length,count+1,'ordinary bindings must not duplicate the restored page view');
|
||||
}finally{f.close();}
|
||||
});
|
||||
|
||||
test('ad visibility and clicks deduplicate across DOM replacements, resetting only on a new page',async()=>{
|
||||
const f=fixture();try{
|
||||
f.tracker.configure({reportSdkConfig:'configured-cipher'});await flush();
|
||||
const ad=new Element();ad.attributes.set('data-track-ad','external');ad.dataset={adId:'advertise-code',adSlotKey:'slot',adSlotName:'广告位',adType:'banner',adClickable:'1'};
|
||||
ad.hidden=true;f.intersect(ad);assert.equal(f.events.filter(e=>e.method==='adImpression').length,0);
|
||||
ad.hidden=false;f.intersect(ad);f.tracker.externalAd(ad,'show');f.tracker.externalAd(ad,'click');
|
||||
assert.equal(f.events.filter(e=>e.method==='adImpression').length,1);assert.equal(f.events.filter(e=>e.method==='adClick').length,1);
|
||||
f.location.pathname='/shorts/';f.tracker.pageView();f.tracker.externalAd(ad,'show');assert.equal(f.events.filter(e=>e.method==='adImpression').length,2);
|
||||
f.location.pathname='/';f.tracker.pageView();f.tracker.externalAd(ad,'show');assert.equal(f.events.filter(e=>e.method==='adImpression').length,2,'returning to a previously seen tab must not repeat its ads');
|
||||
}finally{f.close();}
|
||||
});
|
||||
|
||||
test('play durations exclude buffering and preview, speed is scaled, and seek/completion/status use named events',async()=>{
|
||||
const f=fixture();try{
|
||||
f.tracker.configure({reportSdkConfig:'configured-cipher'});await flush();
|
||||
f.tracker.setDrama(drama,chapters[0],chapters);
|
||||
const video=new Video(),mode=new Element();mode.dataset.playMode='main';video.parents={'[data-play-mode]':mode};f.setVideo(video);
|
||||
const emit=type=>f.tracker.videoEvent({type,target:video});
|
||||
emit('play');emit('playing');f.setClock(5000);emit('waiting');f.setClock(15000);emit('playing');f.setClock(18000);video.paused=true;video.currentTime=30;emit('pause');
|
||||
const pause=f.events.filter(e=>e.payload.play_behavior_key==='ep_pause').at(-1);assert.equal(pause.payload.play_duration,7);assert.equal(pause.payload.play_speed,125);assert.equal(pause.payload.play_progress,30);
|
||||
const count=f.events.length;mode.dataset.playMode='preview';emit('play');emit('playing');assert.equal(f.events.length,count);mode.dataset.playMode='main';
|
||||
video.currentTime=20;emit('timeupdate');emit('seeking');video.currentTime=60;emit('seeked');assert.equal(f.events.at(-1).payload.play_behavior_key,'ep_forward');
|
||||
video.currentTime=100;video.ended=true;emit('ended');assert.equal(f.events.at(-2).payload.play_behavior_key,'ep_complete');assert.equal(f.events.at(-1).payload.play_behavior_key,'ep_play');assert.equal(f.events.at(-1).payload.play_progress,100);
|
||||
f.tracker.dramaStatusChange(chapters[1]);assert.equal(f.events.at(-1).payload.drama_behavior_key,'ep_next');f.tracker.dramaStatusChange(chapters[1],'ep_unlock');assert.equal(f.events.at(-1).payload.drama_behavior_key,'ep_unlock');
|
||||
assert.ok(!f.events.some(e=>['dramaLike','dramaCollect','dramaPurchase','dramaComment'].includes(e.method)));
|
||||
}finally{f.close();}
|
||||
});
|
||||
|
||||
test('request headers preserve SDK values and snapshot the selected purchase chapter independently of route changes',async()=>{
|
||||
const f=fixture();try{
|
||||
f.tracker.configure({reportSdkConfig:'configured-cipher'});await flush();f.tracker.setDrama(drama,chapters[0],chapters);
|
||||
const pending=f.tracker.requestHeaders('playlet/chapter/unlock','POST',{playletId:23456,chapterId:90002});
|
||||
f.location.pathname='/shorts/';f.tracker.pageView();
|
||||
const headers=await pending,context=JSON.parse(Buffer.from(headers['X-DC-Request-Context'],'base64url'));
|
||||
assert.deepEqual(context.common,f.fields);assert.equal(context.drama.drama_id,'23456');assert.equal(context.drama.video_id,'90002');assert.equal(context.drama.episode,2);
|
||||
assert.equal(context.event_name,'drama_purchase');assert.match(context.request_id,/^[a-f0-9]{32}$/);assert.equal(context.request_id,headers['X-Tracker-Request-Id']);
|
||||
assert.deepEqual(JSON.parse(Buffer.from(headers['X-SDK-Common'],'base64')),f.fields);
|
||||
const videoComment=await f.tracker.requestHeaders('comment/send','POST',{objectId:2,objectType:20});assert.equal(videoComment['X-DC-Request-Context'],undefined);
|
||||
const order=await f.tracker.requestHeaders('coin/buy','POST',{});assert.ok(order['X-SDK-Common']);assert.equal(order['X-Tracker-Event-Name'],undefined);
|
||||
}finally{f.close();}
|
||||
});
|
||||
|
||||
test('terminal playback errors report failed once per attempt, exclude preview and use the video dictionary',async()=>{
|
||||
const f=fixture();try{
|
||||
f.tracker.configure({reportSdkConfig:'configured-cipher'});await flush();
|
||||
f.tracker.setDrama(drama,chapters[0],chapters);
|
||||
const video=new Video(),mode=new Element();mode.dataset.playMode='main';video.parents={'[data-play-mode]':mode};f.setVideo(video);
|
||||
f.tracker.videoEvent({type:'playing',target:video});f.setClock(5000);video.currentTime=30;
|
||||
f.tracker.playbackFailed(video);f.tracker.playbackFailed(video);
|
||||
const failed=f.events.filter(e=>e.payload.play_behavior_key==='ep_failed');assert.equal(failed.length,1);assert.equal(failed[0].payload.play_behavior_name,'播放失败');assert.equal(failed[0].payload.play_duration,4);
|
||||
mode.dataset.playMode='preview';f.tracker.prepareDramaModeChange();f.tracker.playbackFailed(video);assert.equal(f.events.filter(e=>e.payload.play_behavior_key==='ep_failed').length,1);
|
||||
mode.dataset.playMode='main';f.tracker.prepareDramaModeChange();f.tracker.playbackFailed(video);assert.equal(f.events.filter(e=>e.payload.play_behavior_key==='ep_failed').length,2);
|
||||
f.tracker.setDrama(drama,null,[],true);assert.equal(f.events.at(-1).payload.video_behavior_name,'视频展示');
|
||||
f.tracker.prepareDramaModeChange();f.tracker.playbackFailed(video);assert.equal(f.events.at(-1).method,'videoEvent');assert.equal(f.events.at(-1).payload.video_behavior_key,'video_failed');assert.equal(f.events.at(-1).payload.video_behavior_name,'播放失败');
|
||||
}finally{f.close();}
|
||||
});
|
||||
|
||||
test('completion includes 100 percent play progress while preserving the final playback interval',async()=>{
|
||||
const f=fixture();try{
|
||||
f.tracker.configure({reportSdkConfig:'configured-cipher'});await flush();f.tracker.setDrama(drama,chapters[0],chapters);
|
||||
const video=new Video(),mode=new Element();mode.dataset.playMode='main';video.parents={'[data-play-mode]':mode};f.setVideo(video);
|
||||
f.tracker.videoEvent({type:'playing',target:video});f.setClock(9000);video.currentTime=100;video.ended=true;f.tracker.videoEvent({type:'ended',target:video});
|
||||
const [complete,progress]=f.events.slice(-2);assert.equal(complete.payload.play_behavior_key,'ep_complete');assert.equal(progress.payload.play_behavior_key,'ep_play');
|
||||
assert.equal(complete.payload.play_progress,100);assert.equal(progress.payload.play_progress,100);assert.equal(complete.payload.play_duration,8);assert.equal(progress.payload.play_duration,8);
|
||||
const count=f.events.length;f.tracker.reportProgress();assert.equal(f.events.length,count,'the final playback interval must be consumed after progress reporting');
|
||||
}finally{f.close();}
|
||||
});
|
||||
|
||||
test('all SDK public fields reach login and order requests unchanged including numeric timestamps and optional system metadata',async()=>{
|
||||
const f=fixture();try{
|
||||
Object.assign(f.fields,{device_brand:'品牌',device_model:'model',system_name:'test-system',system_version:'1.2.3'});
|
||||
for(const path of ['auth/login','auth/register','coin/buy','vip/pay']){
|
||||
const headers=await f.tracker.requestHeaders(path,'POST',{}),forwarded=trackingHeaders(new Headers(headers),path,'POST');
|
||||
assert.deepEqual(JSON.parse(Buffer.from(forwarded['X-SDK-Common'],'base64')),f.fields);assert.equal(forwarded['X-DC-Request-Context'],undefined);
|
||||
assert.match(headers['X-User-Agent'],/^DevID=business-device;DevType=PC;SysType=h5_pc;Ver=1.0.0;BuildID=web;/);
|
||||
}
|
||||
}finally{f.close();}
|
||||
});
|
||||
|
||||
test('the BFF accepts only the correct route, app, common fields and context schema without blocking business data',()=>{
|
||||
const fields=common(),requestId='1234567890abcdef1234567890abcdef';
|
||||
const context={v:1,request_id:requestId,event_name:'drama_purchase',common:fields,drama:{media_id:'',drama_id:'23456',episode:2,video_id:'90002',recommend_trace_id:''}};
|
||||
const source=new Headers({'X-SDK-Common':base64JSON(Object.fromEntries(SDK_COMMON_KEYS.map(k=>[k,fields[k]]))),'X-Tracker-Request-Id':requestId,'X-Tracker-Event-Name':'drama_purchase','X-DC-Request-Context':base64JSON(context,true)});
|
||||
const forwarded=trackingHeaders(source,'playlet/chapter/unlock','POST');assert.deepEqual(JSON.parse(Buffer.from(forwarded['X-DC-Request-Context'],'base64url')),context);
|
||||
assert.equal(JSON.parse(Buffer.from(forwarded['X-SDK-Common'],'base64')).sid,'sid-raw');assert.equal(forwarded.Authorization,undefined);
|
||||
for(const [path,method] of [['playlet/collect/set','POST'],['config/info','GET'],['playlet/chapter/unlock','GET']])assert.equal(trackingHeaders(source,path,method)['X-DC-Request-Context'],undefined);
|
||||
for(const invalid of [{...context,request_id:'other'},{...context,common:{...fields,app_id:'JHA-1050'}},{...context,common:{...fields,token:'secret'}},{...context,drama:{...context.drama,episode:0}}]){
|
||||
const headers=new Headers(source);headers.set('X-DC-Request-Context',base64JSON(invalid,true));const result=trackingHeaders(headers,'playlet/chapter/unlock','POST');assert.equal(result['X-DC-Request-Context'],undefined);assert.ok(result['X-SDK-Common']);
|
||||
}
|
||||
assert.deepEqual(trackingHeaders(new Headers({'X-SDK-Common':'invalid','X-DC-Request-Context':'x'.repeat(9000)}),'playlet/chapter/unlock','POST'),{});
|
||||
});
|
||||
@@ -0,0 +1,423 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { isIP } from "node:net";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import vm from "node:vm";
|
||||
import ts from "typescript";
|
||||
|
||||
const environment = { PUBLIC_DATA_CACHE_TTL_MS: "60000" };
|
||||
const sharedDependencies = { "node:net": { isIP } };
|
||||
async function compile(path, dependencies = {}, extra = {}) {
|
||||
const exports = {};
|
||||
const { outputText } = ts.transpileModule(
|
||||
await readFile(
|
||||
new URL("../src/lib/api/" + path + ".ts", import.meta.url),
|
||||
"utf8",
|
||||
),
|
||||
{
|
||||
compilerOptions: {
|
||||
target: ts.ScriptTarget.ES2022,
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
},
|
||||
},
|
||||
);
|
||||
vm.runInNewContext(outputText, {
|
||||
exports,
|
||||
URL,
|
||||
Headers,
|
||||
Request,
|
||||
Response,
|
||||
FormData,
|
||||
AbortSignal,
|
||||
TextDecoder,
|
||||
Buffer,
|
||||
structuredClone,
|
||||
process: { env: environment },
|
||||
require: (name) => {
|
||||
if (name in dependencies) return dependencies[name];
|
||||
if (name in sharedDependencies) return sharedDependencies[name];
|
||||
throw new Error("Unknown dependency: " + name);
|
||||
},
|
||||
...extra,
|
||||
});
|
||||
return exports;
|
||||
}
|
||||
const config = await compile("config");
|
||||
const protocol = await compile("protocol");
|
||||
const cache = await compile("public-cache");
|
||||
sharedDependencies["./user-agent"] = await compile("user-agent");
|
||||
sharedDependencies["../tracking"] = await compile("../tracking");
|
||||
sharedDependencies["./tracking-headers"] = await compile("tracking-headers");
|
||||
sharedDependencies["./client-headers"] = await compile("client-headers");
|
||||
|
||||
test("development uses its configured domain and supports legacy overrides", () => {
|
||||
delete environment.UPSTREAM_API_URL;
|
||||
delete environment.API_BASE_URL;
|
||||
assert.equal(config.getApiBaseUrl(), "http://172.104.181.99:26792");
|
||||
environment.APP_ENV = "test";
|
||||
assert.equal(config.getApiBaseUrl(), "http://172.104.181.99:26792");
|
||||
environment.APP_ENV = "production";
|
||||
assert.equal(config.getApiBaseUrl(), "http://139.162.49.252:21040");
|
||||
delete environment.APP_ENV;
|
||||
environment.API_BASE_URL = "https://legacy-api.example/";
|
||||
assert.equal(config.getApiBaseUrl(), "https://legacy-api.example");
|
||||
environment.UPSTREAM_API_URL = " http://127.0.0.1:21111/ ";
|
||||
assert.equal(config.getApiBaseUrl(), "http://127.0.0.1:21111");
|
||||
for (const value of [
|
||||
"",
|
||||
"javascript:bad",
|
||||
"https://user:secret@api.example",
|
||||
"https://api.example/?query=1",
|
||||
"https://api.example/#fragment",
|
||||
]) {
|
||||
environment.UPSTREAM_API_URL = value;
|
||||
assert.throws(() => config.getApiBaseUrl(), /UPSTREAM_API_URL/);
|
||||
}
|
||||
});
|
||||
|
||||
test("explicit current mode resolves the current domain per request, including protocol and port", () => {
|
||||
environment.UPSTREAM_API_URL = "current";
|
||||
delete environment.API_BASE_URL;
|
||||
for (const APP_ENV of ["test", "production"]) {
|
||||
environment.APP_ENV = APP_ENV;
|
||||
assert.equal(config.usesCurrentApiOrigin(), true);
|
||||
assert.equal(
|
||||
config.getApiBaseUrl({
|
||||
url: "http://0.0.0.0:21030/api/app/config/info",
|
||||
headers: new Headers({
|
||||
Host: "first.example:8443",
|
||||
"X-Forwarded-Proto": "https",
|
||||
"X-Forwarded-Host": "ignored.example",
|
||||
}),
|
||||
}),
|
||||
"https://first.example:8443",
|
||||
);
|
||||
assert.equal(
|
||||
config.getApiBaseUrl({
|
||||
url: "http://second.example:21030/api/app/config/info",
|
||||
headers: new Headers(),
|
||||
}),
|
||||
"http://second.example:21030",
|
||||
);
|
||||
assert.equal(
|
||||
config.getApiBaseUrl({
|
||||
headers: new Headers({
|
||||
"X-Forwarded-Host": "fallback.example",
|
||||
"X-Forwarded-Proto": "https",
|
||||
}),
|
||||
}),
|
||||
"https://fallback.example",
|
||||
);
|
||||
assert.throws(() => config.getApiBaseUrl(), /请求域名/);
|
||||
for (const Host of [
|
||||
"user:secret@api.test",
|
||||
"api.test/path",
|
||||
"api.test?x=1",
|
||||
"api.test,other.test",
|
||||
"api.test\\other",
|
||||
]) {
|
||||
assert.throws(() =>
|
||||
config.getApiBaseUrl({ headers: new Headers({ Host }) }),
|
||||
);
|
||||
}
|
||||
assert.throws(() =>
|
||||
config.getApiBaseUrl({
|
||||
headers: new Headers({ Host: "api.test", "X-Forwarded-Proto": "ftp" }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
delete environment.APP_ENV;
|
||||
});
|
||||
|
||||
test("SSR and API proxy share the configured upstream while cache entries stay isolated by backend", async () => {
|
||||
const requests = [];
|
||||
const incoming = new Headers({
|
||||
"User-Agent": "visitor-ua",
|
||||
"X-Real-IP": "203.0.113.9",
|
||||
});
|
||||
const server = await compile(
|
||||
"server",
|
||||
{
|
||||
"./protocol": protocol,
|
||||
"./config": config,
|
||||
"./public-cache": cache,
|
||||
react: { cache: (fn) => fn },
|
||||
"next/headers": { headers: async () => incoming },
|
||||
},
|
||||
{
|
||||
fetch: async (url, options) => {
|
||||
requests.push({ url: String(url), ...options });
|
||||
return Response.json({
|
||||
code: 200,
|
||||
data: { source: new URL(url).origin },
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
environment.UPSTREAM_API_URL = "https://api-a.example";
|
||||
const first = await server.upstream("config/info");
|
||||
assert.equal(first.data.source, "https://api-a.example");
|
||||
assert.equal(requests[0].headers["User-Agent"], "visitor-ua");
|
||||
assert.equal(requests[0].headers["X-Real-IP"], "203.0.113.9");
|
||||
environment.UPSTREAM_API_URL = "https://api-b.example";
|
||||
assert.equal(
|
||||
(await server.upstream("config/info")).data.source,
|
||||
"https://api-b.example",
|
||||
);
|
||||
environment.UPSTREAM_API_URL = "https://api-a.example";
|
||||
assert.equal(
|
||||
(await server.upstream("config/info")).data.source,
|
||||
"https://api-a.example",
|
||||
);
|
||||
assert.equal(requests.length, 2);
|
||||
await server.upstream("auth/getInfo", { token: "server-only-token" });
|
||||
assert.equal(
|
||||
requests.at(-1).url,
|
||||
"https://api-a.example/api/app/auth/getInfo",
|
||||
);
|
||||
assert.equal(requests.at(-1).headers.Authorization, "server-only-token");
|
||||
});
|
||||
|
||||
test("SSR, API handlers and private requests use the current origin without mixing public caches", async () => {
|
||||
environment.UPSTREAM_API_URL = "current";
|
||||
let incoming = new Headers({
|
||||
Host: "site-a.example",
|
||||
"X-Forwarded-Proto": "https",
|
||||
});
|
||||
const requests = [];
|
||||
const server = await compile(
|
||||
"server",
|
||||
{
|
||||
"./protocol": protocol,
|
||||
"./config": config,
|
||||
"./public-cache": cache,
|
||||
react: { cache: (fn) => fn },
|
||||
"next/headers": { headers: async () => incoming },
|
||||
},
|
||||
{
|
||||
fetch: async (url, options) => {
|
||||
requests.push({ url: String(url), ...options });
|
||||
return Response.json({
|
||||
code: 200,
|
||||
data: { source: new URL(url).origin },
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.equal(
|
||||
(await server.upstream("config/info")).data.source,
|
||||
"https://site-a.example",
|
||||
);
|
||||
incoming = new Headers({
|
||||
Host: "site-b.example",
|
||||
"X-Forwarded-Proto": "http",
|
||||
});
|
||||
assert.equal(
|
||||
(await server.upstream("config/info")).data.source,
|
||||
"http://site-b.example",
|
||||
);
|
||||
const request = new Request("https://site-a.example/api/app/config/info");
|
||||
assert.equal(
|
||||
(await server.upstream("config/info", { request })).data.source,
|
||||
"https://site-a.example",
|
||||
);
|
||||
assert.equal(requests.length, 2);
|
||||
await server.upstream("auth/getInfo", {
|
||||
request,
|
||||
token: "private-current-token",
|
||||
});
|
||||
assert.equal(
|
||||
requests.at(-1).url,
|
||||
"https://site-a.example/api/app/auth/getInfo",
|
||||
);
|
||||
assert.equal(requests.at(-1).headers.Authorization, "private-current-token");
|
||||
assert.equal(requests.at(-1).headers[config.UPSTREAM_HOP_HEADER], "1");
|
||||
});
|
||||
|
||||
test("current-origin media preserves signatures and fails immediately if the upstream returns to Next", async () => {
|
||||
environment.UPSTREAM_API_URL = "current";
|
||||
const requests = [];
|
||||
const media = await compile(
|
||||
"media",
|
||||
{ "node:net": { isIP }, "./config": config },
|
||||
{
|
||||
fetch: async (url, options) => {
|
||||
requests.push({ url: String(url), ...options });
|
||||
return new Response(new Uint8Array(16));
|
||||
},
|
||||
},
|
||||
);
|
||||
const request = new Request(
|
||||
"http://0.0.0.0:21030/enkey?id=1&ticket=a%2Fb+1",
|
||||
{
|
||||
headers: {
|
||||
Host: "deployed.example",
|
||||
"X-Forwarded-Proto": "https",
|
||||
Cookie: "drama_session=secret",
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.equal((await media.proxyMedia(request, "/enkey", true)).status, 200);
|
||||
assert.equal(
|
||||
requests[0].url,
|
||||
"https://deployed.example/enkey?id=1&ticket=a%2Fb+1",
|
||||
);
|
||||
assert.equal(requests[0].headers.get("cookie"), null);
|
||||
assert.equal(requests[0].headers.get(config.UPSTREAM_HOP_HEADER), "1");
|
||||
const loop = new Request("https://deployed.example/enkey", {
|
||||
headers: { [config.UPSTREAM_HOP_HEADER]: "1" },
|
||||
});
|
||||
const response = await media.proxyMedia(loop, "/enkey", true);
|
||||
assert.equal(response.status, 502);
|
||||
assert.match(await response.text(), /转发配置/);
|
||||
assert.equal(requests.length, 1);
|
||||
});
|
||||
|
||||
test("media and keys use the configured upstream while preserving signatures, byte ranges and credential isolation", async () => {
|
||||
const reads = [];
|
||||
const media = await compile(
|
||||
"media",
|
||||
{ "node:net": { isIP }, "./config": config },
|
||||
{
|
||||
fetch: async (url, options) => {
|
||||
reads.push({ url: String(url), headers: options.headers });
|
||||
return new Response(
|
||||
new URL(url).pathname === "/enkey/"
|
||||
? new Uint8Array(16)
|
||||
: "#EXTM3U\n#EXT-X-ENDLIST\n",
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
environment.UPSTREAM_API_URL = "http://127.0.0.1:21111";
|
||||
const incoming = new Request(
|
||||
"https://current-site.example/h5/m3u8/video.m3u8?md=a%2Fb+value&x=1&x=2",
|
||||
{
|
||||
headers: {
|
||||
Range: "bytes=0-2",
|
||||
Cookie: "drama_session=secret",
|
||||
Authorization: "secret",
|
||||
"X-Real-IP": "203.0.113.4",
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.equal(
|
||||
(await media.proxyMedia(incoming, "/h5/m3u8/video.m3u8")).status,
|
||||
200,
|
||||
);
|
||||
assert.equal(
|
||||
reads.at(-1).url,
|
||||
"http://127.0.0.1:21111/h5/m3u8/video.m3u8?md=a%2Fb+value&x=1&x=2",
|
||||
);
|
||||
assert.equal(reads.at(-1).headers.get("range"), "bytes=0-2");
|
||||
assert.equal(reads.at(-1).headers.get("x-real-ip"), "203.0.113.4");
|
||||
assert.equal(reads.at(-1).headers.get("x-forwarded-for"), null);
|
||||
assert.equal(reads.at(-1).headers.get("authorization"), null);
|
||||
assert.equal(reads.at(-1).headers.get("cookie"), null);
|
||||
const key = await media.proxyMedia(
|
||||
new Request("https://current-site.example/enkey?token=a%2Fb"),
|
||||
"/enkey/",
|
||||
true,
|
||||
);
|
||||
assert.equal(key.status, 200);
|
||||
assert.equal((await key.arrayBuffer()).byteLength, 16);
|
||||
assert.equal(reads.at(-1).url, "http://127.0.0.1:21111/enkey/?token=a%2Fb");
|
||||
});
|
||||
|
||||
test("key proxies follow one same-origin slash change in either direction without changing signed queries or headers", async () => {
|
||||
environment.UPSTREAM_API_URL = "https://key-api.example";
|
||||
const query = "?id=12700&t=123&ticket=a%2Fb+1&ticket=x%252Fy";
|
||||
for (const pathname of ["/enkey", "/enkey/", "/newenkey", "/newenkey/"]) {
|
||||
const alternate = pathname.endsWith("/")
|
||||
? pathname.slice(0, -1)
|
||||
: pathname + "/";
|
||||
const reads = [];
|
||||
const media = await compile(
|
||||
"media",
|
||||
{ "node:net": { isIP }, "./config": config },
|
||||
{
|
||||
fetch: async (url, options) => {
|
||||
reads.push({ url: String(url), ...options });
|
||||
return reads.length === 1
|
||||
? new Response("", {
|
||||
status: 301,
|
||||
headers: {
|
||||
Location: alternate + (pathname.endsWith("/") ? "" : query),
|
||||
},
|
||||
})
|
||||
: new Response(new Uint8Array(16));
|
||||
},
|
||||
},
|
||||
);
|
||||
const result = await media.proxyMedia(
|
||||
new Request("http://local.test" + pathname + query, {
|
||||
headers: {
|
||||
"X-Real-IP": "203.0.113.7",
|
||||
"User-Agent": "key-test",
|
||||
Cookie: "drama_session=private",
|
||||
Authorization: "private",
|
||||
},
|
||||
}),
|
||||
pathname,
|
||||
true,
|
||||
);
|
||||
assert.equal(result.status, 200);
|
||||
assert.equal((await result.arrayBuffer()).byteLength, 16);
|
||||
assert.deepEqual(
|
||||
reads.map((item) => item.url),
|
||||
[
|
||||
"https://key-api.example" + pathname + query,
|
||||
"https://key-api.example" + alternate + query,
|
||||
],
|
||||
);
|
||||
for (const read of reads) {
|
||||
assert.equal(read.redirect, "manual");
|
||||
assert.equal(read.cache, "no-store");
|
||||
assert.equal(read.headers.get("X-Real-IP"), "203.0.113.7");
|
||||
assert.equal(read.headers.get("User-Agent"), "key-test");
|
||||
assert.equal(
|
||||
read.headers.get("X-User-Agent"),
|
||||
"DevType=desktop;SysType=h5_pc;Ver=1.0.0;BuildID=;device_brand=;device_model=;system_name=Other;system_version=;sid=",
|
||||
);
|
||||
assert.equal(read.headers.get("Cookie"), null);
|
||||
assert.equal(read.headers.get("Authorization"), null);
|
||||
}
|
||||
assert.equal(reads[0].signal, reads[1].signal);
|
||||
}
|
||||
});
|
||||
|
||||
test("key proxies reject unrelated, cross-origin and repeated redirects before forwarding credentials or signatures", async () => {
|
||||
environment.UPSTREAM_API_URL = "https://key-api.example";
|
||||
for (const [location, repeated] of [
|
||||
["https://external.example/enkey/", false],
|
||||
["/newenkey/", false],
|
||||
["/enkey/?ticket=changed", false],
|
||||
["https://user:secret@key-api.example/enkey/", false],
|
||||
["/enkey/", true],
|
||||
[null, false],
|
||||
]) {
|
||||
const reads = [];
|
||||
const media = await compile(
|
||||
"media",
|
||||
{ "node:net": { isIP }, "./config": config },
|
||||
{
|
||||
fetch: async (url) => {
|
||||
reads.push(String(url));
|
||||
return new Response("", {
|
||||
status: 302,
|
||||
headers: location
|
||||
? { Location: reads.length === 1 ? location : "/enkey" }
|
||||
: {},
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
const result = await media.proxyMedia(
|
||||
new Request("http://local.test/enkey?ticket=original"),
|
||||
"/enkey",
|
||||
true,
|
||||
);
|
||||
assert.equal(result.status, 502, String(location));
|
||||
assert.equal(reads.length, repeated ? 2 : 1, String(location));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFile} from 'node:fs/promises';
|
||||
import vm from 'node:vm';
|
||||
import ts from 'typescript';
|
||||
|
||||
const contract=JSON.parse(await readFile(new URL('./fixtures/sgdj-app-contract.json',import.meta.url),'utf8')).operations;
|
||||
const compile=async file=>ts.transpileModule(await readFile(new URL(file,import.meta.url),'utf8'),{compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.CommonJS}}).outputText;
|
||||
const protocol={},routing={},rendering={},channel={},server={};
|
||||
vm.runInNewContext(await compile('../src/lib/api/protocol.ts'),{exports:protocol,URL});
|
||||
vm.runInNewContext(await compile('../src/lib/routing.ts'),{exports:routing,URL,URLSearchParams});
|
||||
vm.runInNewContext(await compile('../src/lib/client/render.ts'),{exports:rendering,URL,require:key=>key==='../routing'?routing:protocol});
|
||||
vm.runInNewContext(await compile('../src/lib/client/channel.ts'),{exports:channel,URL,URLSearchParams,require:key=>key==='../routing'?routing:key==='../api/protocol'?protocol:key==='./render'?rendering:{}});
|
||||
vm.runInNewContext(await compile('../src/lib/api/server.ts'),{exports:server,require:key=>key==='react'?{cache:fn=>fn}:protocol});
|
||||
const plain=value=>JSON.parse(JSON.stringify(value));
|
||||
|
||||
test('proxy methods and public endpoints are backed by the reviewed API contract',async()=>{
|
||||
for(const path of server.PUBLIC_PATHS)assert.ok(contract['GET '+path],path);
|
||||
const code=ts.createSourceFile('server.ts',await readFile(new URL('../src/lib/api/server.ts',import.meta.url),'utf8'),ts.ScriptTarget.Latest,true);
|
||||
const walk=node=>{
|
||||
if(ts.isStringLiteral(node)&&node.text.includes('/')&&server.allowedRequest(node.text,'POST'))assert.ok(contract['POST '+node.text],node.text);
|
||||
ts.forEachChild(node,walk);
|
||||
};walk(code);
|
||||
for(const method of ['GET','POST'])for(const path of ['video/detail','video/all/list','playlet/hot/list','playlet/home/config/detail/list','statcenter/sync'])assert.equal(server.allowedRequest(path,method),false,method+' '+path);
|
||||
});
|
||||
|
||||
test('the documented likes list is an authenticated GET endpoint',()=>{
|
||||
assert.equal(server.allowedRequest('playlet/like/list','GET'),true);
|
||||
assert.equal(server.allowedRequest('playlet/like/list','POST'),false);
|
||||
assert.equal(server.PUBLIC_PATHS.has('playlet/like/list'),false);
|
||||
assert.deepEqual(contract['GET playlet/like/list'].query,['pageNum','pageSize']);
|
||||
});
|
||||
|
||||
test('all public list routes send only supported query fields with their native page numbers',()=>{
|
||||
for(const path of ['/shorts/page/2/','/rank/hot/page/2/','/rank/new/page/2/','/rank/follow/page/2/','/topics/page/2/','/topics/10404/page/2/','/theme/10404/page/2/','/tag/6/page/2/','/config/10081/']){
|
||||
const route=routing.parseSitePath(path),request=channel.channelRequest(route,'?sort=2'),operation=contract['GET '+request.endpoint];
|
||||
assert.ok(operation,path);
|
||||
for(const key of Object.keys(request.query))assert.ok(operation.query.includes(key),path+' unsupported '+key);
|
||||
for(const key of operation.requiredQuery)assert.notEqual(request.query[key],undefined,path+' missing '+key);
|
||||
if(route.key!=='config')assert.equal(request.query.pageNum,2,path);
|
||||
}
|
||||
assert.deepEqual(plain(channel.channelRequest(routing.parseSitePath('/shorts/'),'?sort=2')),{endpoint:'playlet/operate/list',query:{pageNum:1,pageSize:24,type:2}});
|
||||
});
|
||||
|
||||
test('shorts random recommendations send operate type 4 and preserve selection and pagination filters',async()=>{
|
||||
const route=routing.parseSitePath('/shorts/page/2/'),calls=[];
|
||||
const data=await channel.loadChannel({get:async(endpoint,query)=>{calls.push({endpoint,query});return {list:[{id:8,title:'随机短剧'}],totalCount:72};}},route,'?sort=4');
|
||||
assert.deepEqual(plain(calls),[{endpoint:'playlet/operate/list',query:{pageNum:2,pageSize:24,type:4}}]);
|
||||
const view=channel.channelView(route,data,'/shorts/page/2/?sort=4');
|
||||
assert.ok(view.html.includes('data-sort="4" aria-pressed="true">随机推荐</button>'));
|
||||
assert.equal((view.html.match(/aria-pressed="true"/g)||[]).length,1);
|
||||
assert.ok(view.html.includes('href="/shorts/page/3/?sort=4"'));
|
||||
assert.equal(routing.paginationPath('/shorts/page/2/?sort=2',1,{sort:4}),'/shorts/?sort=4');
|
||||
for(const sort of [1,2,3,4])assert.equal(channel.channelRequest(route,'?sort='+sort).query.type,sort);
|
||||
for(const sort of ['0','5','NaN','1.5'])assert.equal(channel.channelRequest(route,'?sort='+sort).query.type,1);
|
||||
assert.equal(channel.channelRequest(routing.parseSitePath('/tag/6/'),'?sort=4').query.sortType,1);
|
||||
});
|
||||
|
||||
test('home more uses the documented paginated config endpoint and actual totals',async()=>{
|
||||
const calls=[];
|
||||
const api={get:async(endpoint,query)=>{calls.push({endpoint,query});return {config:{id:1,slug:'editors',title:'正确栏目',desc:'栏目介绍',moduleType:1},list:[{id:8,title:'当前页短剧'}],totalCount:72,pageNum:query.pageNum,pageSize:24};}};
|
||||
const data=await channel.loadChannel(api,routing.parseSitePath('/config/editors/page/2/'));
|
||||
assert.equal(data.name,'正确栏目');assert.equal(data.totalCount,72);assert.equal(data.list[0].id,8);
|
||||
assert.deepEqual(plain(calls),[{endpoint:'playlet/home-config/list',query:{pageNum:2,pageSize:24,slug:'editors'}}]);
|
||||
const view=channel.channelView(routing.parseSitePath('/config/editors/page/2/'),data,'/config/editors/page/2/');
|
||||
assert.ok(view.html.includes('href="/config/editors/page/3/"'));
|
||||
assert.equal((await channel.loadChannel({get:async()=>({})},routing.parseSitePath('/config/2/'))).missing,true);
|
||||
assert.equal(routing.parseSitePath('/series/'),null);assert.equal(routing.parseSitePath('/video/7/'),null);
|
||||
});
|
||||
|
||||
test('topic groups keep their names, card identities and per-group pagination boundaries',async()=>{
|
||||
const groups=[{id:1,name:'热门剧单',desc:'区块介绍',totalCount:49,list:[{id:7,name:'都市精选',slug:'city',playletCount:12}]},{id:2,name:'本周推荐',totalCount:25,list:[{id:8,name:'古装精选'}]}];
|
||||
const route=routing.parseSitePath('/topics/page/2/');
|
||||
const data=await channel.loadChannel({get:async()=>({list:groups,pageNum:2,pageSize:24})},route);
|
||||
assert.equal(data.totalCount,49);assert.deepEqual(plain(data.list.map(item=>item.id)),[7,8]);
|
||||
const view=channel.channelView(route,data,'/topics/page/2/');
|
||||
assert.ok(view.html.includes('热门剧单'));assert.ok(view.html.includes('本周推荐'));assert.ok(view.html.includes('/topics/city/'));assert.ok(!view.html.includes('/topics/1/'));
|
||||
assert.ok(view.html.includes('/topics/page/3/'));assert.ok(!view.html.includes('/topics/page/4/'));
|
||||
assert.ok(view.html.includes('channel-topic-group'));
|
||||
assert.ok(!view.html.includes('channel-summary-count'));
|
||||
assert.equal(view.subtitle,'围绕一个主题,发现一组好故事。');
|
||||
});
|
||||
|
||||
test('topic detail shows playlet count subtitle, drama list summary and moreTopics',()=>{
|
||||
const route=routing.parseSitePath('/topics/city/');
|
||||
const view=channel.channelView(route,{
|
||||
id:7,slug:'city',name:'都市精选',desc:'都市逆袭合集',playletCount:9,totalCount:9,
|
||||
list:[{id:101,title:'归家'}],
|
||||
moreTopics:[{id:8,name:'古装精选',slug:'costume',playletCount:6}],
|
||||
},'/topics/city/');
|
||||
assert.equal(view.title,'都市精选');
|
||||
assert.equal(view.subtitle,'都市逆袭合集 · 9 部精选');
|
||||
assert.ok(view.html.includes('共 9 部作品'));
|
||||
assert.ok(view.html.includes('更多专题'));
|
||||
assert.ok(view.html.includes('/topics/costume/'));
|
||||
assert.ok(view.html.includes('6部短剧'));
|
||||
});
|
||||
|
||||
test('topic pages omit empty list, summary, moreTopics and zero playlet badges',()=>{
|
||||
const list=channel.channelView(routing.parseSitePath('/topics/'),{list:[],topicGroups:[{id:1,name:'空分组',totalCount:0,list:[]}],totalCount:0},'/topics/');
|
||||
assert.equal(list.html,'');
|
||||
assert.equal(list.subtitle,'');
|
||||
const detail=channel.channelView(routing.parseSitePath('/topics/city/'),{id:7,slug:'city',name:'都市精选',list:[],totalCount:0,moreTopics:[]},'/topics/city/');
|
||||
assert.ok(!detail.html.includes('channel-summary'));
|
||||
assert.ok(!detail.html.includes('class="cards"'));
|
||||
assert.ok(!detail.html.includes('更多专题'));
|
||||
assert.ok(!detail.html.includes('ui-empty-state'));
|
||||
const card=channel.channelView(routing.parseSitePath('/topics/'),{topicGroups:[{id:1,name:'有专题',totalCount:1,list:[{id:8,name:'无数量专题',slug:'empty'}]}]},'/topics/');
|
||||
assert.ok(card.html.includes('无数量专题'));
|
||||
assert.ok(!card.html.includes('0部短剧'));
|
||||
});
|
||||
|
||||
test('theme filters use the actual theme catalog and all/list rather than topic detail',async()=>{
|
||||
const calls=[];
|
||||
const data=await channel.loadChannel({get:async(endpoint,query)=>{calls.push({endpoint,query});return endpoint==='theme/list'?{list:[{id:9,slug:'urban',name:'都市题材'}]}:{list:[{id:7}],totalCount:30};}},routing.parseSitePath('/theme/urban/page/2/'),'?sort=3&updateStatus=2');
|
||||
assert.deepEqual(plain(calls),[{endpoint:'theme/list',query:{}},{endpoint:'playlet/all/list',query:{pageNum:2,pageSize:24,themeSlug:'urban',sortType:3,updateStatus:2}}]);
|
||||
assert.equal(data.id,9);assert.equal(data.name,'都市题材');
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,91 @@
|
||||
import {routingModuleUrl} from './helpers/routing.mjs';
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFile} from 'node:fs/promises';
|
||||
import ts from 'typescript';
|
||||
|
||||
async function moduleUrl(file,replace=code=>code){
|
||||
const filename=new URL(file,import.meta.url);
|
||||
const {outputText}=ts.transpileModule(await readFile(filename,'utf8'),{compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.ESNext}});
|
||||
return 'data:text/javascript;base64,'+Buffer.from(replace(outputText).replace(/(['"])\.\.\/routing\1/g,JSON.stringify(routingModuleUrl))+'\n//# sourceURL='+filename.pathname).toString('base64');
|
||||
}
|
||||
const protocol=await moduleUrl('../src/lib/api/protocol.ts');
|
||||
const render=await moduleUrl('../src/lib/client/render.ts',code=>code.replace('"../api/protocol"',JSON.stringify(protocol)));
|
||||
const apps=await moduleUrl('../src/lib/client/apps.ts',code=>code.replace("'../api/protocol'",JSON.stringify(protocol)).replace("'./render'",JSON.stringify(render)));
|
||||
const {loadAppCategories,renderAppDirectory,scrollToAppCategory}=await import(apps);
|
||||
const app=id=>({id,name:'应用 '+id,url:'https://example.com/app/'+id,logo:'https://example.com/icon.png'});
|
||||
const category=(id,appList)=>({id,title:'分类 '+id,...(appList===undefined?{}:{appList})});
|
||||
|
||||
test('benefit applications report linked advertisements with their API ids and ordinary applications as content',()=>{
|
||||
const html=renderAppDirectory([category(3,[app(11),{...app(12),advitiseId:'ad-12',location:8}])]);
|
||||
assert.match(html,/data-track-item data-item-id="11" data-item-name="应用 11" data-item-type="app" data-parent-item-id="3"/);
|
||||
assert.match(html,/data-track-ad="external" data-ad-id="ad-12" data-ad-clickable="1" data-ad-slot-key="8" data-ad-slot-name="福利应用" data-ad-type="icon"/);
|
||||
});
|
||||
|
||||
test('loads every missing category once, preserves directory order, and omits empty categories',async()=>{
|
||||
const calls=[],controller=new AbortController();
|
||||
const api={async get(path,query,signal){
|
||||
assert.equal(path,'applist/list');assert.equal(signal,controller.signal);calls.push(query.categoryId);
|
||||
if(query.categoryId===0)return {categoryList:[category(2,[]),category(1),category(3,[]),category(4),category(2)],category:category(1,[app(11)])};
|
||||
return {category:category(query.categoryId,query.categoryId===2?[app(22)]:[])};
|
||||
}};
|
||||
const result=await loadAppCategories(api,controller.signal);
|
||||
assert.deepEqual(calls,[0,2,3,4]);assert.deepEqual(result.map(item=>item.id),[2,1]);
|
||||
const html=renderAppDirectory(result);
|
||||
for(const id of [2,1]){assert.ok(html.includes(`aria-controls="apps-${id}"`));assert.ok(html.includes(`id="apps-${id}"`));}
|
||||
for(const id of [3,4]){assert.ok(!html.includes(`data-app-category="${id}"`));assert.ok(!html.includes(`id="apps-${id}"`));}
|
||||
assert.equal((html.match(/class="benefit-section"/g)||[]).length,2);
|
||||
});
|
||||
|
||||
test('uses embedded applications without additional requests and escapes API text and links',async()=>{
|
||||
let calls=0;
|
||||
const result=await loadAppCategories({async get(){calls++;return {categoryList:[{...category(7,[{...app(1),name:'应用 <script>',url:'https://example.com/?a=1&b=2'}]),title:'分类 <img>'},category(8,[app(2)])]};}},new AbortController().signal);
|
||||
assert.equal(calls,1);
|
||||
const html=renderAppDirectory(result);
|
||||
assert.ok(html.includes('分类 <img>'));assert.ok(html.includes('应用 <script>'));assert.ok(html.includes('?a=1&b=2'));
|
||||
assert.ok(html.includes('data-src="https://example.com/icon.png"'));assert.ok(!html.includes('<script>'));
|
||||
});
|
||||
|
||||
test('empty and malformed application lists create no category options or empty sections',async()=>{
|
||||
for(const data of [null,{}, {categoryList:null},{categoryList:[null,{},category(1,[]),category(2,[null,{},false]),category(3,'invalid')]}]){
|
||||
const result=await loadAppCategories({async get(){return data;}},new AbortController().signal);
|
||||
assert.deepEqual(result,[]);
|
||||
const html=renderAppDirectory(result);assert.ok(html.includes('暂无福利应用'));
|
||||
assert.ok(!html.includes('data-app-category'));assert.ok(!html.includes('benefit-section'));
|
||||
}
|
||||
});
|
||||
|
||||
test('keeps available groups after a category failure and reports failure when none can load',async()=>{
|
||||
const unavailable=new Error('category unavailable');
|
||||
for(const available of [true,false]){
|
||||
const api={async get(_,query){
|
||||
if(query.categoryId===0)return {categoryList:[category(1),category(2)],category:available?category(1,[app(1)]):undefined};
|
||||
throw unavailable;
|
||||
}};
|
||||
if(available)assert.deepEqual((await loadAppCategories(api,new AbortController().signal)).map(item=>item.id),[1]);
|
||||
else await assert.rejects(loadAppCategories(api,new AbortController().signal),unavailable);
|
||||
}
|
||||
});
|
||||
|
||||
test('aborted navigation discards late category results',async()=>{
|
||||
const controller=new AbortController();let finish;
|
||||
const result=loadAppCategories({async get(_,query){
|
||||
if(query.categoryId===0)return {categoryList:[category(1)]};
|
||||
return new Promise(resolve=>{finish=()=>resolve({category:category(1,[app(1)])});});
|
||||
}},controller.signal);
|
||||
await new Promise(resolve=>setImmediate(resolve));controller.abort();finish();
|
||||
assert.deepEqual(await result,[]);
|
||||
});
|
||||
|
||||
test('category selection scrolls to existing sections and updates selection without replacing content',()=>{
|
||||
const previousWindow=globalThis.window,scrolls=[];
|
||||
const buttons=[1,2].map(id=>({dataset:{appCategory:String(id)},setAttribute(key,value){this[key]=value;}}));
|
||||
const root={querySelector:selector=>selector==='#apps-2'?{scrollIntoView:options=>scrolls.push(options)}:null,querySelectorAll:()=>buttons};
|
||||
try{
|
||||
globalThis.window={matchMedia:()=>({matches:false})};
|
||||
scrollToAppCategory(root,'2');assert.deepEqual(scrolls,[{behavior:'smooth',block:'start'}]);
|
||||
assert.equal(buttons[0]['aria-pressed'],'false');assert.equal(buttons[1]['aria-pressed'],'true');
|
||||
globalThis.window.matchMedia=()=>({matches:true});scrollToAppCategory(root,'2');assert.equal(scrolls[1].behavior,'auto');
|
||||
scrollToAppCategory(root,'3');scrollToAppCategory(root,'invalid');assert.equal(scrolls.length,2);
|
||||
}finally{globalThis.window=previousWindow;}
|
||||
});
|
||||
@@ -0,0 +1,528 @@
|
||||
import {routingModuleUrl} from './helpers/routing.mjs';
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFile} from 'node:fs/promises';
|
||||
import ts from 'typescript';
|
||||
|
||||
async function moduleUrl(file,replace=code=>code){
|
||||
const filename=new URL(file,import.meta.url);
|
||||
const {outputText}=ts.transpileModule(await readFile(filename,'utf8'),{compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.ESNext}});
|
||||
return 'data:text/javascript;base64,'+Buffer.from(replace(outputText).replace(/(['"])\.\.\/routing\1/g,JSON.stringify(routingModuleUrl))+'\n//# sourceURL='+filename.pathname).toString('base64');
|
||||
}
|
||||
const protocolUrl=await moduleUrl('../src/lib/api/protocol.ts');
|
||||
const authUrl=await moduleUrl('../src/lib/client/auth.ts');
|
||||
const {authFormPayload}=await import(authUrl);
|
||||
let sequence=0;
|
||||
async function client(){
|
||||
const url=await moduleUrl('../src/lib/client/api.ts',code=>code.replace('"../api/protocol"',JSON.stringify(protocolUrl)).replace('"./auth"',JSON.stringify(authUrl+'#'+sequence++)));
|
||||
return new (await import(url)).ApiClient();
|
||||
}
|
||||
const guest={userId:7,type:0,isRegister:false};
|
||||
const member={userId:8,type:1,isRegister:true};
|
||||
const response=(data,code=200,headers={})=>new Response(JSON.stringify({code,data,msg:code===200?'ok':'session expired'}),{status:code===1000?401:200,headers:{'Content-Type':'application/json',...headers}});
|
||||
const deferred=()=>{let resolve;const promise=new Promise(done=>resolve=done);return {promise,resolve};};
|
||||
async function withFetch(fetcher,work){const old=globalThis.fetch;globalThis.fetch=fetcher;try{await work();}finally{globalThis.fetch=old;}}
|
||||
|
||||
test('login accepts account/email, trims identifiers, and preserves password bytes',()=>{
|
||||
assert.deepEqual(authFormPayload('login',{account:' test ',password:' 1234 '}),{account:'test',password:' 1234 '});
|
||||
assert.deepEqual(authFormPayload('login',{account:'me@example.com',password:'1234'}),{account:'me@example.com',password:'1234'});
|
||||
assert.throws(()=>authFormPayload('login',{account:'bad@',password:'1234'}),/有效邮箱/);
|
||||
assert.throws(()=>authFormPayload('login',{account:'test',password:'123'}),/密码/);
|
||||
const account='abcdefghijklmnopqrst',password='SafePassword12345678';
|
||||
assert.equal(authFormPayload('login',{account,password}).account,account);
|
||||
});
|
||||
|
||||
test('guest binding validates confirmation and agreement without requiring an email',()=>{
|
||||
const values={account:' player01 ',password:'Secure123',passwordConfirm:'Secure123',agree:'on'};
|
||||
assert.deepEqual(authFormPayload('register',values),{accountNo:'player01',password:'Secure123'});
|
||||
for(const change of [{account:'a_b'},{password:'abcdefgh',passwordConfirm:'abcdefgh'},{passwordConfirm:'Wrong123'},{agree:''}])assert.throws(()=>authFormPayload('register',{...values,...change}));
|
||||
assert.deepEqual(authFormPayload('resetPassword',{account:' player01 ',email:' me@example.com ',password:'NewPass123',passwordConfirm:'NewPass123'}),{accountNo:'player01',email:'me@example.com',newPassword:'NewPass123'});
|
||||
});
|
||||
|
||||
test('device identity, first channel and invite survive page navigation without persisting credentials',async()=>{
|
||||
const originals=Object.fromEntries(['document','localStorage','location'].map(key=>[key,Object.getOwnPropertyDescriptor(globalThis,key)]));
|
||||
const storage=new Map([['uuid','old-device']]);
|
||||
Object.defineProperty(globalThis,'document',{configurable:true,value:{cookie:'drama_device=cookie-device'}});
|
||||
Object.defineProperty(globalThis,'localStorage',{configurable:true,value:{getItem:key=>storage.get(key),setItem:(key,value)=>storage.set(key,value)}});
|
||||
Object.defineProperty(globalThis,'location',{configurable:true,value:{search:'?dc=first&inviteCode=INV123'}});
|
||||
try{
|
||||
const {authMetadata}=await import(authUrl+'#attribution');
|
||||
assert.deepEqual(authMetadata(),{devID:'cookie-device',deviceId:'cookie-device',channel:'first',agentInviteCode:'INV123'});
|
||||
globalThis.location.search='?dc=later';
|
||||
assert.equal(authMetadata().channel,'first');assert.equal(authMetadata().agentInviteCode,'INV123');
|
||||
assert.deepEqual([...storage.keys()].sort(),['channel','uuid']);
|
||||
globalThis.localStorage.getItem=()=>{throw new Error('blocked');};
|
||||
globalThis.localStorage.setItem=()=>{throw new Error('blocked');};
|
||||
globalThis.document.cookie='';
|
||||
const fallback=(await import(authUrl+'#blocked')).authMetadata;
|
||||
assert.equal(fallback().devID,fallback().devID);
|
||||
}finally{for(const [key,descriptor] of Object.entries(originals)){if(descriptor)Object.defineProperty(globalThis,key,descriptor);else delete globalThis[key];}}
|
||||
});
|
||||
|
||||
test('concurrent private requests share one guest initialization and public data needs no session',async()=>{
|
||||
const api=await client(),gate=deferred(),calls=[];
|
||||
await withFetch(async(url,options)=>{
|
||||
calls.push({url,body:options.body&&JSON.parse(options.body)});
|
||||
if(url.endsWith('/auth/guest')){await gate.promise;return response(guest);}
|
||||
return response({});
|
||||
},async()=>{
|
||||
const first=api.get('user/center/data'),second=api.get('playlet/collect/list');
|
||||
await Promise.resolve();assert.equal(calls.length,1);gate.resolve();await Promise.all([first,second]);
|
||||
assert.equal(calls.filter(call=>call.url.endsWith('/auth/guest')).length,1);
|
||||
assert.ok(calls[0].body.devID);assert.equal(api.user.userId,7);
|
||||
const anonymous=await client();await anonymous.get('playlet/home');assert.equal(anonymous.user,null);
|
||||
});
|
||||
});
|
||||
|
||||
test('history restoration revalidates current cookies and cannot overwrite a newer login',async()=>{
|
||||
const api=await client();api.seedSession(guest);
|
||||
await withFetch(async(_url,options)=>{assert.equal(JSON.parse(options.body).automatic,true);return response(null);},async()=>{
|
||||
await api.revalidateSession();assert.equal(api.user,null);assert.equal(await api.initializeSession(),null);
|
||||
});
|
||||
const gate=deferred();api.seedSession(guest);
|
||||
await withFetch(async()=>{await gate.promise;return response(guest);},async()=>{
|
||||
const refresh=api.revalidateSession();await Promise.resolve();api.setUser(member);gate.resolve();await refresh;assert.equal(api.user.userId,member.userId);
|
||||
});
|
||||
});
|
||||
|
||||
test('search and suggestions wait for a shared guest session before requesting protected search endpoints',async()=>{
|
||||
const api=await client(),gate=deferred(),calls=[];
|
||||
await withFetch(async url=>{
|
||||
calls.push(url);
|
||||
if(url.endsWith('/auth/guest')){await gate.promise;return response(guest);}
|
||||
return response({list:[],totalCount:0});
|
||||
},async()=>{
|
||||
const search=api.get('playlet/search',{keyword:'都市',pageNum:2,pageSize:25});
|
||||
const suggestions=api.get('playlet/search/suggest',{keyword:'都市'});
|
||||
await Promise.resolve();assert.deepEqual(calls,['/api/app/auth/guest']);
|
||||
gate.resolve();await Promise.all([search,suggestions]);
|
||||
assert.equal(calls.length,3);assert.ok(calls[1].includes('playlet/search?'));assert.ok(calls[1].includes('pageSize=25'));assert.ok(calls[2].includes('playlet/search/suggest?'));
|
||||
});
|
||||
});
|
||||
|
||||
test('slow guest restoration finishes before login and binding waits for a guest identity',async()=>{
|
||||
const api=await client(),gate=deferred(),calls=[];
|
||||
await withFetch(async(url,options)=>{
|
||||
calls.push({url,body:JSON.parse(options.body)});
|
||||
if(url.endsWith('/auth/guest')){await gate.promise;return response(guest);}
|
||||
return response(member);
|
||||
},async()=>{
|
||||
const restore=api.initializeSession(),login=api.post('auth/login',{account:'player',password:'Secure123'});
|
||||
await Promise.resolve();assert.equal(calls.length,1);gate.resolve();await Promise.all([restore,login]);
|
||||
assert.equal(calls[1].body.devID,calls[0].body.devID);assert.equal(api.user.userId,8);
|
||||
const binding=await client();await binding.post('auth/register',{accountNo:'player',password:'Secure123'});
|
||||
assert.deepEqual(calls.slice(2).map(call=>call.url),['/api/app/auth/guest','/api/app/auth/register']);
|
||||
assert.equal(binding.user.userId,8);
|
||||
});
|
||||
});
|
||||
|
||||
test('invalid credentials preserve the current identity; late expired requests cannot clear a new login',async()=>{
|
||||
const api=await client();api.setUser(guest);
|
||||
const gate=deferred();let invalid=true;
|
||||
await withFetch(async url=>{
|
||||
if(url.endsWith('/auth/login'))return invalid?response(null,1000):response(member);
|
||||
await gate.promise;return response(null,1000);
|
||||
},async()=>{
|
||||
await assert.rejects(api.post('auth/login',{account:'player',password:'bad'}));assert.equal(api.user.userId,7);
|
||||
const pending=api.get('user/center/data');await Promise.resolve();invalid=false;
|
||||
await api.post('auth/login',{account:'player',password:'Secure123'});gate.resolve();await assert.rejects(pending);
|
||||
assert.equal(api.user.userId,8);
|
||||
});
|
||||
});
|
||||
|
||||
test('registered expiry prompts once and blocks automatic guest downgrade',async()=>{
|
||||
const api=await client();api.setUser(member);let prompts=0,calls=0;api.addEventListener('expired',()=>prompts++);
|
||||
await withFetch(async()=>{calls++;return response(null,1000,{'X-Auth-State':'login-required'});},async()=>{
|
||||
await assert.rejects(api.get('user/center/data'));
|
||||
await new Promise(resolve=>setImmediate(resolve));
|
||||
assert.equal(api.user,null);assert.equal(prompts,1);
|
||||
assert.equal(await api.initializeSession(),null);
|
||||
await assert.rejects(api.ensureSession());await assert.rejects(api.ensureSession());
|
||||
assert.equal(calls,2);assert.equal(prompts,1);
|
||||
});
|
||||
});
|
||||
|
||||
test('a stale expired action keeps the signed-in user when the account cookie is still valid',async()=>{
|
||||
const api=await client();api.setUser(member);const calls=[];
|
||||
await withFetch(async url=>{
|
||||
calls.push(url);
|
||||
return url.endsWith('/auth/getInfo')?response(member):response(null,1000,{'X-Auth-State':'login-required'});
|
||||
},async()=>{
|
||||
await assert.rejects(api.get('user/center/data'));
|
||||
await new Promise(resolve=>setImmediate(resolve));
|
||||
assert.equal(api.user.userId,member.userId);
|
||||
assert.ok(calls[0].endsWith('/user/center/data'));assert.ok(calls[1].endsWith('/auth/getInfo'));
|
||||
});
|
||||
});
|
||||
|
||||
test('optional account-menu stats never confirm session with getInfo',async()=>{
|
||||
const api=await client();api.setUser(guest);const calls=[];
|
||||
await withFetch(async url=>{
|
||||
calls.push(url);
|
||||
return response(null,1000);
|
||||
},async()=>{
|
||||
assert.equal(await api.tryGet('user/center/data'),undefined);
|
||||
await new Promise(resolve=>setImmediate(resolve));
|
||||
assert.equal(api.user.userId,guest.userId);
|
||||
assert.deepEqual(calls,['/api/app/user/center/data']);
|
||||
});
|
||||
});
|
||||
|
||||
test('restoring an expired registered cookie prompts for login even before a user is loaded',async()=>{
|
||||
const api=await client();let prompts=0;api.addEventListener('expired',()=>prompts++);
|
||||
await withFetch(async()=>response(null,1000,{'X-Auth-State':'login-required'}),async()=>{
|
||||
await assert.rejects(api.initializeSession());await assert.rejects(api.ensureSession());
|
||||
assert.equal(prompts,1);assert.equal(api.user,null);
|
||||
});
|
||||
});
|
||||
|
||||
test('auth dialog renders binding confirmation, blocks duplicate submission, and clears revealed passwords on close',async()=>{
|
||||
const renderUrl=await moduleUrl('../src/lib/client/render.ts',code=>code.replace('"../api/protocol"',JSON.stringify(protocolUrl)));
|
||||
const trackingUrl=await moduleUrl('../src/lib/tracking.ts');
|
||||
const accountUrl=await moduleUrl('../src/lib/client/account.ts',code=>code.replace('"../api/protocol"',JSON.stringify(protocolUrl)).replace('"./render"',JSON.stringify(renderUrl)).replace("'./auth'",JSON.stringify(authUrl)).replace("'../tracking'",JSON.stringify(trackingUrl)));
|
||||
const {bindAccountUI,openAuth}=await import(accountUrl);
|
||||
const oldDocument=globalThis.document,OldFormData=globalThis.FormData;
|
||||
const listeners={},error={textContent:''},button={textContent:'绑定账号',disabled:false};
|
||||
const password={value:'Secure123',type:'password'},confirmation={value:'Secure123',type:'password'};
|
||||
const form={dataset:{mode:'register'},isConnected:true,reportValidity:()=>true,
|
||||
querySelector:selector=>selector==='#auth-error'?error:button,querySelectorAll:()=>[password,confirmation]};
|
||||
const dialog={id:'member-auth-dialog',innerHTML:'',open:false,classList:{toggle(){}},querySelector:selector=>selector==='#auth-account'?{focus(){}}:selector==='#auth-password'?password:null,
|
||||
querySelectorAll:()=>[password,confirmation],addEventListener:(name,handler)=>listeners[name]=handler,showModal(){this.open=true;},close(){this.open=false;listeners.close();}};
|
||||
const gate=deferred();let submitted=0,routed=0,refreshed=0;const bindState={textContent:'未绑定'};
|
||||
const app={api:{post:async(path,payload)=>{submitted++;assert.equal(path,'auth/register');assert.equal(payload.accountNo,'player01');await gate.promise;},refreshUser:async()=>{refreshed++;}},notify(){},async route(){routed++;}};
|
||||
globalThis.document={getElementById:id=>id==='member-auth-dialog'?dialog:null,querySelector:selector=>selector==='[data-bind-state]'?bindState:null};
|
||||
globalThis.FormData=class extends OldFormData{constructor(){super();for(const [key,value] of Object.entries({account:'player01',password:'Secure123',passwordConfirm:'Secure123',agree:'on'}))this.append(key,value);}};
|
||||
try{
|
||||
bindAccountUI(app);openAuth('bind');assert.ok(dialog.open);
|
||||
assert.ok(dialog.innerHTML.includes('name="passwordConfirm"'));assert.ok(!dialog.innerHTML.includes('name="email"'));
|
||||
const toggle={dataset:{authPasswordToggle:'auth-password'},setAttribute(){}};
|
||||
listeners.click({target:{closest:()=>toggle}});assert.equal(password.type,'text');
|
||||
const event={target:form,preventDefault(){}};listeners.submit(event);listeners.submit(event);
|
||||
assert.equal(submitted,1);assert.equal(button.disabled,true);
|
||||
gate.resolve();await new Promise(resolve=>setImmediate(resolve));
|
||||
// Bind stays on the settings page — no full reload that would replay a stale drama_session.
|
||||
assert.equal(routed,0);assert.equal(refreshed,1);assert.equal(button.disabled,false);assert.equal(password.value,'');assert.equal(confirmation.value,'');assert.equal(bindState.textContent,'已绑定');
|
||||
openAuth('switch');assert.ok(dialog.innerHTML.includes('切换账号'));
|
||||
assert.ok(dialog.innerHTML.includes('data-mode="login"'));assert.ok(dialog.innerHTML.includes('data-auth-switch="1"'));
|
||||
form.dataset.mode='login';form.dataset.authSwitch='1';
|
||||
const current={userId:7};app.api.user=current;
|
||||
app.api.post=async(path,payload)=>{assert.equal(path,'auth/login');assert.equal(payload.switchAccount,true);assert.equal(payload.account,'player01');return {userId:9};};
|
||||
listeners.submit(event);await new Promise(resolve=>setImmediate(resolve));
|
||||
assert.equal(routed,1);assert.equal(refreshed,2);
|
||||
app.api.post=async()=>current;
|
||||
listeners.submit(event);await new Promise(resolve=>setImmediate(resolve));
|
||||
assert.equal(routed,1);assert.equal(error.textContent,'切换失败,请确认账号和密码');
|
||||
}finally{globalThis.document=oldDocument;globalThis.FormData=OldFormData;}
|
||||
});
|
||||
|
||||
|
||||
test('a server-prepared session is reused without another guest handshake and a sign-in snapshot blocks automatic downgrade',async()=>{
|
||||
await withFetch(()=>assert.fail('prepared sessions must not refetch auth'),async()=>{
|
||||
const ready=await client();ready.seedSession(member);assert.equal((await ready.initializeSession()).userId,member.userId);
|
||||
const signedOut=await client();signedOut.seedSession(null);assert.equal(await signedOut.initializeSession(),null);
|
||||
});
|
||||
});
|
||||
|
||||
test('an unavailable SSR snapshot still performs guest login on initializeSession',async()=>{
|
||||
const api=await client();const calls=[];
|
||||
await withFetch(async url=>{
|
||||
calls.push(url);
|
||||
assert.ok(url.endsWith('/auth/guest'));
|
||||
return response(guest);
|
||||
},async()=>{
|
||||
assert.equal((await api.initializeSession()).userId,guest.userId);
|
||||
assert.deepEqual(calls,['/api/app/auth/guest']);
|
||||
});
|
||||
});
|
||||
|
||||
test('bootstrapGuestSession guests then soft-refreshes getInfo like jha-1048 enter-site auth',async()=>{
|
||||
const api=await client();const calls=[];
|
||||
const refreshed={...guest,nickName:'游客昵称',recharge:12};
|
||||
await withFetch(async url=>{
|
||||
calls.push(url.pathname||url);
|
||||
if(String(url).endsWith('/auth/guest'))return response(guest);
|
||||
assert.ok(String(url).endsWith('/auth/getInfo'));
|
||||
return response(refreshed);
|
||||
},async()=>{
|
||||
assert.equal((await api.bootstrapGuestSession()).nickName,'游客昵称');
|
||||
assert.equal(api.user.recharge,12);
|
||||
assert.deepEqual(calls.map(url=>String(url).replace(/^.*\/api\/app\//,'')),['auth/guest','auth/getInfo']);
|
||||
});
|
||||
});
|
||||
|
||||
test('bootstrapGuestSession guests once when SSR seeded a display identity without a client bearer',async()=>{
|
||||
const api=await client();api.seedSession(guest);const calls=[];
|
||||
await withFetch(async(url,options)=>{
|
||||
calls.push({url:String(url),authorization:options.headers?.Authorization||options.headers?.authorization});
|
||||
if(String(url).endsWith('/auth/guest'))return response({...guest,token:'seed-jwt'});
|
||||
assert.ok(String(url).endsWith('/auth/getInfo'));
|
||||
return response({...guest,nickName:'刷新后'});
|
||||
},async()=>{
|
||||
assert.equal((await api.bootstrapGuestSession()).nickName,'刷新后');
|
||||
// No drama_bearer yet → same as jha-1048 first visit: guest then soft getInfo.
|
||||
assert.deepEqual(calls.map(call=>call.url.replace(/^.*\/api\/app\//,'')),['auth/guest','auth/getInfo']);
|
||||
assert.equal(calls[1].authorization,'seed-jwt');
|
||||
});
|
||||
});
|
||||
|
||||
test('bootstrapGuestSession keeps the guest seed when soft getInfo fails without prompting login',async()=>{
|
||||
const api=await client();const calls=[];let prompts=0;api.addEventListener('expired',()=>prompts++);
|
||||
await withFetch(async url=>{
|
||||
calls.push(String(url));
|
||||
if(String(url).endsWith('/auth/guest'))return response({...guest,token:'guest-jwt'});
|
||||
assert.ok(String(url).endsWith('/auth/getInfo'));
|
||||
return response(null,1000);
|
||||
},async()=>{
|
||||
assert.equal((await api.bootstrapGuestSession()).userId,guest.userId);
|
||||
assert.equal(calls.filter(url=>url.endsWith('/auth/guest')).length,1);
|
||||
assert.ok(calls.some(url=>url.endsWith('/auth/getInfo')));
|
||||
assert.equal(prompts,0);
|
||||
});
|
||||
});
|
||||
|
||||
test('valid guest bearer with SSR seed only soft-refreshes getInfo and does not re-guest',async()=>{
|
||||
const originals=Object.fromEntries(['document','localStorage','location'].map(key=>[key,Object.getOwnPropertyDescriptor(globalThis,key)]));
|
||||
const storage=new Map([['drama_bearer','live-guest-jwt'],['uuid','guest-device']]);
|
||||
let cookieJar='drama_device=guest-device; drama_bearer=live-guest-jwt';
|
||||
Object.defineProperty(globalThis,'document',{configurable:true,value:{
|
||||
get cookie(){return cookieJar;},
|
||||
set cookie(value){
|
||||
const raw=String(value);
|
||||
const [pair]=raw.split(';');
|
||||
const sep=pair.indexOf('=');
|
||||
const name=pair.slice(0,sep);
|
||||
const body=pair.slice(sep+1);
|
||||
const parts=cookieJar?cookieJar.split('; ').filter(item=>!item.startsWith(name+'=')):[];
|
||||
if(body&&!raw.includes('Max-Age=0'))parts.push(`${name}=${body}`);
|
||||
cookieJar=parts.join('; ');
|
||||
},
|
||||
}});
|
||||
Object.defineProperty(globalThis,'localStorage',{configurable:true,value:{getItem:key=>storage.get(key)??null,setItem:(key,value)=>storage.set(key,String(value)),removeItem:key=>storage.delete(key)}});
|
||||
Object.defineProperty(globalThis,'location',{configurable:true,value:{protocol:'https:',search:''}});
|
||||
try{
|
||||
const api=await client();api.seedSession(guest);const calls=[];
|
||||
await withFetch(async(url,options)=>{
|
||||
const path=String(url);
|
||||
calls.push({path,authorization:options.headers?.Authorization||options.headers?.authorization});
|
||||
assert.ok(path.endsWith('/auth/getInfo'));
|
||||
assert.equal(options.headers?.Authorization||options.headers?.authorization,'live-guest-jwt');
|
||||
return response({...guest,nickName:'缓存游客',token:''});
|
||||
},async()=>{
|
||||
assert.equal((await api.bootstrapGuestSession()).nickName,'缓存游客');
|
||||
assert.deepEqual(calls.map(call=>call.path.replace(/^.*\/api\/app\//,'')),['auth/getInfo']);
|
||||
assert.equal(storage.get('drama_bearer'),'live-guest-jwt');
|
||||
});
|
||||
}finally{
|
||||
for(const [key,descriptor] of Object.entries(originals)){
|
||||
if(descriptor)Object.defineProperty(globalThis,key,descriptor);
|
||||
else delete globalThis[key];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('stale guest bearer is treated as logged out then guests once like jha-1048',async()=>{
|
||||
const originals=Object.fromEntries(['document','localStorage','location'].map(key=>[key,Object.getOwnPropertyDescriptor(globalThis,key)]));
|
||||
const storage=new Map([['drama_bearer','stale-guest-jwt'],['uuid','guest-device']]);
|
||||
let cookieJar='drama_device=guest-device; drama_bearer=stale-guest-jwt';
|
||||
Object.defineProperty(globalThis,'document',{configurable:true,value:{
|
||||
get cookie(){return cookieJar;},
|
||||
set cookie(value){
|
||||
const raw=String(value);
|
||||
const [pair]=raw.split(';');
|
||||
const sep=pair.indexOf('=');
|
||||
const name=pair.slice(0,sep);
|
||||
const body=pair.slice(sep+1);
|
||||
const parts=cookieJar?cookieJar.split('; ').filter(item=>!item.startsWith(name+'=')):[];
|
||||
if(body&&!raw.includes('Max-Age=0'))parts.push(`${name}=${body}`);
|
||||
cookieJar=parts.join('; ');
|
||||
},
|
||||
}});
|
||||
Object.defineProperty(globalThis,'localStorage',{configurable:true,value:{getItem:key=>storage.get(key)??null,setItem:(key,value)=>storage.set(key,String(value)),removeItem:key=>storage.delete(key)}});
|
||||
Object.defineProperty(globalThis,'location',{configurable:true,value:{protocol:'https:',search:''}});
|
||||
try{
|
||||
const api=await client();api.seedSession(guest);const calls=[];
|
||||
await withFetch(async(url,options)=>{
|
||||
const path=String(url);
|
||||
const authorization=options.headers?.Authorization||options.headers?.authorization;
|
||||
calls.push({path,authorization});
|
||||
if(path.endsWith('/auth/getInfo')){
|
||||
if(authorization==='stale-guest-jwt')return response(null,1000);
|
||||
assert.equal(authorization,'fresh-guest-jwt');
|
||||
return response({...guest,nickName:'新游客',token:''});
|
||||
}
|
||||
if(path.endsWith('/auth/guest')){
|
||||
assert.equal(authorization,undefined);
|
||||
return response({...guest,nickName:'新游客',token:'fresh-guest-jwt'});
|
||||
}
|
||||
return response({});
|
||||
},async()=>{
|
||||
assert.equal((await api.bootstrapGuestSession()).nickName,'新游客');
|
||||
assert.deepEqual(calls.map(call=>call.path.replace(/^.*\/api\/app\//,'')),['auth/getInfo','auth/guest','auth/getInfo']);
|
||||
assert.equal(storage.get('drama_bearer'),'fresh-guest-jwt');
|
||||
});
|
||||
}finally{
|
||||
for(const [key,descriptor] of Object.entries(originals)){
|
||||
if(descriptor)Object.defineProperty(globalThis,key,descriptor);
|
||||
else delete globalThis[key];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('bootstrapGuestSession does not downgrade a login-required snapshot',async()=>{
|
||||
await withFetch(()=>assert.fail('login-required must not call auth'),async()=>{
|
||||
const api=await client();api.seedSession(null);
|
||||
assert.equal(await api.bootstrapGuestSession(),null);
|
||||
});
|
||||
});
|
||||
|
||||
test('guest token is kept as Authorization and stripped from the public user snapshot',async()=>{
|
||||
const api=await client();const calls=[];
|
||||
await withFetch(async(url,options)=>{
|
||||
calls.push({url,authorization:options.headers?.Authorization||options.headers?.authorization});
|
||||
if(String(url).endsWith('/auth/guest'))return response({...guest,token:'guest-jwt'});
|
||||
if(String(url).endsWith('/auth/getInfo'))return response({...guest,nickName:'游客',token:''});
|
||||
return response({ok:true});
|
||||
},async()=>{
|
||||
assert.equal((await api.bootstrapGuestSession()).nickName,'游客');
|
||||
assert.equal(api.user.token,undefined);
|
||||
await api.post('playlet/like/set',{id:1,isLike:true});
|
||||
const like=calls.find(call=>String(call.url).includes('playlet/like/set'));
|
||||
assert.equal(like.authorization,'guest-jwt');
|
||||
});
|
||||
});
|
||||
|
||||
test('expired guest actions recover once with Authorization when the BFF cookie path is unavailable',async()=>{
|
||||
const api=await client();api.seedSession(guest);const calls=[];
|
||||
await withFetch(async(url,options)=>{
|
||||
const path=String(url);
|
||||
calls.push({path,authorization:options.headers?.Authorization||options.headers?.authorization});
|
||||
if(path.endsWith('/auth/guest'))return response({...guest,token:'recovered-jwt'});
|
||||
if(path.includes('playlet/like/set')){
|
||||
if(!options.headers?.Authorization&&!options.headers?.authorization)return response(null,1000);
|
||||
return response({isLike:1});
|
||||
}
|
||||
return response(null,1000);
|
||||
},async()=>{
|
||||
assert.deepEqual(await api.post('playlet/like/set',{id:9,isLike:true}),{isLike:1});
|
||||
assert.ok(calls.some(call=>call.path.endsWith('/auth/guest')));
|
||||
assert.ok(calls.some(call=>call.path.includes('playlet/like/set')&&call.authorization==='recovered-jwt'));
|
||||
});
|
||||
});
|
||||
|
||||
test('registered expiry still never recovers as a guest',async()=>{
|
||||
const api=await client();api.setUser(member);const calls=[];
|
||||
await withFetch(async url=>{
|
||||
calls.push(String(url));
|
||||
return response(null,1000,{'X-Auth-State':'login-required'});
|
||||
},async()=>{
|
||||
await assert.rejects(api.post('playlet/like/set',{id:9,isLike:true}));
|
||||
await new Promise(resolve=>setImmediate(resolve));
|
||||
assert.ok(!calls.some(url=>url.endsWith('/auth/guest')));
|
||||
});
|
||||
});
|
||||
|
||||
test('binding replaces the guest bearer with the new register token and getInfo uses it',async()=>{
|
||||
const originals=Object.fromEntries(['document','localStorage','location'].map(key=>[key,Object.getOwnPropertyDescriptor(globalThis,key)]));
|
||||
const storage=new Map([['uuid','bind-device']]);
|
||||
let cookieJar='drama_device=bind-device';
|
||||
Object.defineProperty(globalThis,'document',{configurable:true,value:{
|
||||
get cookie(){return cookieJar;},
|
||||
set cookie(value){
|
||||
const [pair]=String(value).split(';');
|
||||
const sep=pair.indexOf('=');
|
||||
const name=pair.slice(0,sep);
|
||||
const body=pair.slice(sep+1);
|
||||
const parts=cookieJar?cookieJar.split('; ').filter(item=>!item.startsWith(name+'=')):[];
|
||||
if(body&&!String(value).includes('Max-Age=0'))parts.push(`${name}=${body}`);
|
||||
cookieJar=parts.join('; ');
|
||||
},
|
||||
}});
|
||||
Object.defineProperty(globalThis,'localStorage',{configurable:true,value:{getItem:key=>storage.get(key)??null,setItem:(key,value)=>storage.set(key,String(value)),removeItem:key=>storage.delete(key)}});
|
||||
Object.defineProperty(globalThis,'location',{configurable:true,value:{protocol:'https:',search:''}});
|
||||
try{
|
||||
const api=await client();const calls=[];
|
||||
await withFetch(async(url,options)=>{
|
||||
const path=String(url);
|
||||
const authorization=options.headers?.Authorization||options.headers?.authorization;
|
||||
calls.push({path,authorization});
|
||||
if(path.endsWith('/auth/guest'))return response({...guest,token:'guest-before-bind'});
|
||||
if(path.endsWith('/auth/register')){
|
||||
assert.equal(authorization,'guest-before-bind');
|
||||
// Upstream invalidates the guest JWT and returns a new registered token.
|
||||
return response({...member,token:'bound-after-register',accountNo:'player01'});
|
||||
}
|
||||
if(path.endsWith('/auth/getInfo')){
|
||||
// Bootstrap uses the guest token; post-bind refresh must use the new register token.
|
||||
if(authorization==='guest-before-bind')return response({...guest,token:''});
|
||||
assert.equal(authorization,'bound-after-register');
|
||||
return response({...member,accountNo:'player01',token:''});
|
||||
}
|
||||
return response({});
|
||||
},async()=>{
|
||||
await api.bootstrapGuestSession();
|
||||
await api.post('auth/register',{accountNo:'player01',password:'Secure123'});
|
||||
await api.refreshUser();
|
||||
assert.equal(api.user.accountNo,'player01');
|
||||
assert.equal(api.user.type,1);
|
||||
assert.equal(storage.get('drama_bearer'),'bound-after-register');
|
||||
assert.ok(calls.some(call=>call.path.endsWith('/auth/register')&&call.authorization==='guest-before-bind'));
|
||||
assert.ok(calls.some(call=>call.path.endsWith('/auth/getInfo')&&call.authorization==='bound-after-register'));
|
||||
});
|
||||
}finally{
|
||||
for(const [key,descriptor] of Object.entries(originals)){
|
||||
if(descriptor)Object.defineProperty(globalThis,key,descriptor);
|
||||
else delete globalThis[key];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('persisted bearer restores getInfo after reload without opening the login dialog',async()=>{
|
||||
const originals=Object.fromEntries(['document','localStorage','location'].map(key=>[key,Object.getOwnPropertyDescriptor(globalThis,key)]));
|
||||
const storage=new Map([['drama_bearer','bound-jwt'],['uuid','bind-device']]);
|
||||
let cookieJar='drama_device=bind-device';
|
||||
let prompts=0;
|
||||
Object.defineProperty(globalThis,'document',{configurable:true,value:{get cookie(){return cookieJar;},set cookie(value){cookieJar=String(value);}}});
|
||||
Object.defineProperty(globalThis,'localStorage',{configurable:true,value:{getItem:key=>storage.get(key)??null,setItem:(key,value)=>storage.set(key,String(value)),removeItem:key=>storage.delete(key)}});
|
||||
Object.defineProperty(globalThis,'location',{configurable:true,value:{protocol:'https:',search:''}});
|
||||
try{
|
||||
const api=await client();api.addEventListener('expired',()=>prompts++);
|
||||
const calls=[];
|
||||
await withFetch(async(url,options)=>{
|
||||
calls.push({path:String(url),authorization:options.headers?.Authorization||options.headers?.authorization});
|
||||
assert.ok(String(url).endsWith('/auth/getInfo'));
|
||||
assert.equal(options.headers?.Authorization||options.headers?.authorization,'bound-jwt');
|
||||
return response({...member,accountNo:'player01',token:''});
|
||||
},async()=>{
|
||||
assert.equal((await api.bootstrapGuestSession()).accountNo,'player01');
|
||||
assert.equal(prompts,0);
|
||||
assert.deepEqual(calls.map(call=>call.path.replace(/^.*\/api\/app\//,'')),['auth/getInfo']);
|
||||
});
|
||||
}finally{
|
||||
for(const [key,descriptor] of Object.entries(originals)){
|
||||
if(descriptor)Object.defineProperty(globalThis,key,descriptor);
|
||||
else delete globalThis[key];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('soft bootstrap getInfo failure after bind never opens the login dialog',async()=>{
|
||||
const originals=Object.fromEntries(['document','localStorage','location'].map(key=>[key,Object.getOwnPropertyDescriptor(globalThis,key)]));
|
||||
const storage=new Map([['drama_bearer','stale-bound-jwt']]);
|
||||
Object.defineProperty(globalThis,'document',{configurable:true,value:{cookie:''}});
|
||||
Object.defineProperty(globalThis,'localStorage',{configurable:true,value:{getItem:key=>storage.get(key)??null,setItem:(key,value)=>storage.set(key,String(value)),removeItem:key=>storage.delete(key)}});
|
||||
Object.defineProperty(globalThis,'location',{configurable:true,value:{protocol:'https:',search:''}});
|
||||
try{
|
||||
const api=await client();api.seedSession(member);let prompts=0;api.addEventListener('expired',()=>prompts++);
|
||||
await withFetch(async()=>response(null,1000),async()=>{
|
||||
assert.equal((await api.bootstrapGuestSession())?.userId,member.userId);
|
||||
assert.equal(prompts,0);
|
||||
assert.equal(api.user.userId,member.userId);
|
||||
assert.equal(storage.has('drama_bearer'),false);
|
||||
});
|
||||
}finally{
|
||||
for(const [key,descriptor] of Object.entries(originals)){
|
||||
if(descriptor)Object.defineProperty(globalThis,key,descriptor);
|
||||
else delete globalThis[key];
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
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,imports={}){
|
||||
let code=ts.transpileModule(await readFile(new URL('../src/lib/'+path+'.ts',import.meta.url),'utf8'),{compilerOptions:{module:ts.ModuleKind.ESNext,target:ts.ScriptTarget.ES2022}}).outputText;
|
||||
for(const [name,url] of Object.entries(imports))code=code.replace("'"+name+"'",JSON.stringify(url));
|
||||
return 'data:text/javascript;base64,'+Buffer.from(code).toString('base64');
|
||||
}
|
||||
const uaUrl=await moduleUrl('api/user-agent');
|
||||
const {buildUserAgent,buildSdkUserAgent,userAgentSid}=await import(uaUrl);
|
||||
const trackingUrl=await moduleUrl('api/tracking-headers',{'../tracking':await moduleUrl('tracking')});
|
||||
const {clientHeaders}=await import(await moduleUrl('api/client-headers',{'./user-agent':uaUrl,'./tracking-headers':trackingUrl}));
|
||||
|
||||
// Generated by running the reference Go BuildXUserAgent function without changing its logic.
|
||||
const samples=JSON.parse(await readFile(new URL('./fixtures/request-user-agents.json',import.meta.url),'utf8'));
|
||||
for(const sample of samples)test('reference Go UA output matches '+sample.name,()=>{
|
||||
assert.equal(buildUserAgent(sample.ua,sample.sid,sample.version),sample.expected);
|
||||
});
|
||||
|
||||
test('existing X-User-Agent wins over SDK fields and network headers remain separate from credentials',()=>{
|
||||
const xua='DevID=client-device;DevType=iPhone;SysType=h5_ios;Ver=2.0;BuildID=client;sid=client-sid';
|
||||
const fields={sid:'sdk-sid',device_id:'sdk-device',device:'Android',device_fingerprint:'raw',fp_version:'3',app_version:'1.0.0'};
|
||||
const headers=clientHeaders(new Headers({'User-Agent':' Browser UA ','X-User-Agent':xua,'X-Real-IP':'203.0.113.9','X-Forwarded-For':'203.0.113.8, 10.0.0.1','X-SDK-Common':Buffer.from(JSON.stringify(fields)).toString('base64'),Cookie:'drama_session=private',Authorization:'private'}));
|
||||
assert.equal(headers['User-Agent'],'Browser UA');assert.equal(headers['X-User-Agent'],xua);assert.equal(headers['X-Real-IP'],'203.0.113.9');
|
||||
assert.deepEqual(JSON.parse(Buffer.from(headers['X-SDK-Common'],'base64')),fields);
|
||||
for(const key of ['Cookie','Authorization','X-Forwarded-For'])assert.equal(headers[key],undefined);
|
||||
});
|
||||
|
||||
test('business drama_device pins DevID so SDK fingerprints cannot invalidate the guest token',()=>{
|
||||
const xua='DevID=sdk-device;DevType=PC;SysType=h5_pc;Ver=1.0.0;BuildID=web;sid=sdk-sid';
|
||||
const headers=clientHeaders(new Headers({'User-Agent':'Mozilla/5.0','X-User-Agent':xua,Cookie:'drama_session=secret; drama_device=business-device'}));
|
||||
assert.equal(headers['X-User-Agent'],'DevID=business-device;DevType=PC;SysType=h5_pc;Ver=1.0.0;BuildID=web;sid=sdk-sid');
|
||||
const sample=samples.find(item=>item.name==='iphone');
|
||||
const fallback=clientHeaders(new Headers({'User-Agent':sample.ua,Cookie:'sid=reference-sid; drama_device=only-device'}));
|
||||
assert.equal(fallback['X-User-Agent'],sample.expected,'SSR UA without DevID stays unchanged');
|
||||
});
|
||||
|
||||
test('SSR and media fallback use the UA, SID cookie and first forwarded IP without a fabricated identity',()=>{
|
||||
const sample=samples.find(item=>item.name==='iphone');
|
||||
const headers=clientHeaders(new Headers({'User-Agent':sample.ua,'X-Real-IP':'invalid','X-Forwarded-For':'203.0.113.7, 10.0.0.1',Cookie:'sid=reference-sid; drama_device=business-device'}));
|
||||
assert.equal(headers['X-User-Agent'],sample.expected);assert.equal(headers['X-Real-IP'],'203.0.113.7');
|
||||
const empty=clientHeaders(undefined);assert.equal(empty['User-Agent'],undefined);assert.ok(!empty['X-User-Agent'].includes('DevID='));
|
||||
assert.equal(userAgentSid('sid=first; _s_token_=second'),'first');
|
||||
assert.equal(userAgentSid('_s_token_={"sid":"json-sid"}'),'json-sid');
|
||||
assert.equal(userAgentSid('_s_token_=plain-sid'),'plain-sid');
|
||||
});
|
||||
|
||||
test('browser SDK XUA uses the reference field order and cleans header delimiters without altering the snapshot',()=>{
|
||||
const fields={device_id:'sdk-device',device:'Android',app_version:'2.3',device_brand:'Sam;sung',device_model:'model=1',sid:'sdk-sid'};
|
||||
assert.equal(buildSdkUserAgent(fields),'DevID=sdk-device;DevType=Android;SysType=h5_android;Ver=2.3;BuildID=web;device_brand=Sam sung;device_model=model 1;sid=sdk-sid');
|
||||
assert.equal(fields.device_brand,'Sam;sung');assert.equal(fields.device_model,'model=1');
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFile} from 'node:fs/promises';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const fullBuild=await readFile(new URL('../node_modules/hls.js/dist/hls.js',import.meta.url),'utf8');
|
||||
const variants=[
|
||||
{codec:'avc1.42E01E',path:'h264.m3u8'},
|
||||
{codec:'hvc1.1.6.L93.B0',path:'h265-hvc1.m3u8'},
|
||||
{codec:'hev1.1.6.L93.B0',path:'h265-hev1.m3u8'},
|
||||
];
|
||||
|
||||
// Run the real bundled hls.js parser/controller with deterministic platform capabilities.
|
||||
// The loader only returns this local manifest; no network or browser session is used.
|
||||
async function parseManifest(supported,items=variants){
|
||||
const manifest='#EXTM3U\n'+items.map((item,index)=>`#EXT-X-STREAM-INF:BANDWIDTH=${(index+1)*500000},CODECS="${item.codec},mp4a.40.2"\n${item.path}\n`).join('');
|
||||
const sandbox={module:{exports:{}},exports:{},navigator:{userAgent:'Chrome/130'},performance,URL,URLSearchParams,TextDecoder,TextEncoder,setTimeout,clearTimeout,setInterval,clearInterval,queueMicrotask};
|
||||
sandbox.self=sandbox;sandbox.window=sandbox;sandbox.location={href:'https://example.test/'};
|
||||
sandbox.MediaSource=class {
|
||||
static isTypeSupported(type){
|
||||
const codecs=type.match(/codecs=["']?([^"']+)/)?.[1]?.split(',')||[];
|
||||
return codecs.length>0&&codecs.every(codec=>codec.startsWith('mp4a')||supported.some(prefix=>codec.startsWith(prefix)));
|
||||
}
|
||||
};
|
||||
vm.runInNewContext(fullBuild,sandbox,{timeout:1000});
|
||||
const Hls=sandbox.module.exports;
|
||||
class ManifestLoader {
|
||||
constructor(){this.stats={aborted:false,loaded:manifest.length,total:manifest.length,retry:0,chunkCount:1,loading:{start:1,first:2,end:3},parsing:{start:0,end:0},buffering:{start:0,first:0,end:0}};}
|
||||
load(context,_config,callbacks){
|
||||
assert.equal(context.url,'https://example.test/master.m3u8');
|
||||
this.context=context;
|
||||
queueMicrotask(()=>callbacks.onSuccess({url:context.url,data:manifest},this.stats,context,null));
|
||||
}
|
||||
abort(){}destroy(){}getCacheAge(){return 0;}
|
||||
}
|
||||
const hls=new Hls({autoStartLoad:false,enableWorker:false,pLoader:ManifestLoader});
|
||||
const done=new Promise(resolve=>{
|
||||
hls.on(Hls.Events.MANIFEST_PARSED,(_event,data)=>resolve({codecs:Array.from(data.levels,level=>level.videoCodec)}));
|
||||
hls.on(Hls.Events.ERROR,(_event,data)=>{if(data.fatal)resolve({error:data.details});});
|
||||
});
|
||||
hls.loadSource('https://example.test/master.m3u8');
|
||||
try{return await done;}finally{hls.destroy();}
|
||||
}
|
||||
|
||||
test('real HLS manifest filtering retains H.264 when a device cannot decode HEVC',{timeout:2000},async()=>{
|
||||
assert.deepEqual(await parseManifest(['avc1']),{codecs:['avc1.42E01E']});
|
||||
});
|
||||
|
||||
test('real HLS manifest filtering retains AVC, hvc1 and hev1 when all are supported',{timeout:2000},async()=>{
|
||||
assert.deepEqual(await parseManifest(['avc1','hvc1','hev1']),{codecs:variants.map(item=>item.codec)});
|
||||
});
|
||||
|
||||
test('HEVC-only playlists are accepted on capable devices and report incompatibility elsewhere',{timeout:2000},async()=>{
|
||||
assert.deepEqual(await parseManifest(['hvc1'],[variants[1]]),{codecs:['hvc1.1.6.L93.B0']});
|
||||
assert.deepEqual(await parseManifest(['avc1'],[variants[1]]),{error:'manifestIncompatibleCodecsError'});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {mkdtemp,mkdir,writeFile,readFile,rm,symlink,realpath,lstat} from 'node:fs/promises';
|
||||
import {join} from 'node:path';
|
||||
import {tmpdir} from 'node:os';
|
||||
import {copyReleaseTree} from '../scripts/copy-release-tree.mjs';
|
||||
|
||||
test('copyReleaseTree rewrites absolute in-tree links into relocatable relative links',async t=>{
|
||||
const root=await mkdtemp(join(tmpdir(),'91-copy-tree-'));t.after(()=>rm(root,{recursive:true,force:true}));
|
||||
const source=join(root,'standalone'),dest=join(root,'release');
|
||||
await mkdir(join(source,'.pnpm/pkg@1/node_modules/pkg'),{recursive:true});
|
||||
await writeFile(join(source,'.pnpm/pkg@1/node_modules/pkg/index.js'),'module.exports=1;');
|
||||
await mkdir(join(source,'node_modules'),{recursive:true});
|
||||
// Absolute link back into the standalone tree (what Node cp would preserve).
|
||||
await symlink(join(source,'.pnpm/pkg@1/node_modules/pkg'),join(source,'node_modules/pkg'));
|
||||
await copyReleaseTree(source,dest,{root});
|
||||
const link=join(dest,'node_modules/pkg');
|
||||
assert.ok((await lstat(link)).isSymbolicLink());
|
||||
assert.equal(await readFile(join(link,'index.js'),'utf8'),'module.exports=1;');
|
||||
const resolved=await realpath(link),releaseRoot=await realpath(dest);
|
||||
assert.ok(resolved===releaseRoot||resolved.startsWith(releaseRoot+'/'));
|
||||
assert.equal(await readFile(join(resolved,'index.js'),'utf8'),'module.exports=1;');
|
||||
});
|
||||
|
||||
test('copyReleaseTree rejects symlinks that escape the project root',async t=>{
|
||||
const root=await mkdtemp(join(tmpdir(),'91-copy-escape-'));t.after(()=>rm(root,{recursive:true,force:true}));
|
||||
const outside=await mkdtemp(join(tmpdir(),'91-outside-'));t.after(()=>rm(outside,{recursive:true,force:true}));
|
||||
await writeFile(join(outside,'secret'),'nope');
|
||||
const source=join(root,'standalone'),dest=join(root,'release');
|
||||
await mkdir(join(source,'node_modules'),{recursive:true});
|
||||
await writeFile(join(source,'ok.js'),'1');
|
||||
await symlink(join(outside,'secret'),join(source,'node_modules/escape'));
|
||||
await assert.rejects(copyReleaseTree(source,dest,{root}),/Release source contains a symbolic link/);
|
||||
});
|
||||
@@ -0,0 +1,917 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
mkdtemp,
|
||||
mkdir,
|
||||
writeFile,
|
||||
readFile,
|
||||
cp,
|
||||
symlink,
|
||||
rm,
|
||||
stat,
|
||||
lstat,
|
||||
realpath,
|
||||
} from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { gzipSync } from "node:zlib";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { packageStandalone } from "../scripts/package-standalone.mjs";
|
||||
import {
|
||||
LOCKED_RUNTIME,
|
||||
packageLockVersions,
|
||||
} from "../scripts/check-lock-sync.mjs";
|
||||
const projectRoot = new URL("../", import.meta.url);
|
||||
async function fixture(t) {
|
||||
const root = await mkdtemp(join(tmpdir(), "91-release-"));
|
||||
t.after(() => rm(root, { recursive: true, force: true }));
|
||||
async function put(path, text = "fixture") {
|
||||
await mkdir(join(root, path, ".."), { recursive: true });
|
||||
await writeFile(join(root, path), text);
|
||||
}
|
||||
for (const [env, build] of [
|
||||
["test", ".next-test"],
|
||||
["production", ".next"],
|
||||
]) {
|
||||
await put(`${build}/standalone/server.js`, "module.exports=true;");
|
||||
await put(`${build}/standalone/${build}/BUILD_ID`, "build-" + env);
|
||||
await put(`${build}/standalone/${build}/cache/seo/private.json`);
|
||||
await put(`${build}/standalone/.env.local`, "SENSITIVE=do-not-ship");
|
||||
await put(`${build}/standalone/config/secrets/private-key`);
|
||||
await put(
|
||||
`${build}/standalone/dist/production/config/production.local.env`,
|
||||
);
|
||||
await put(`${build}/standalone/tests/fixtures/private.json`);
|
||||
await put(
|
||||
`${build}/standalone/${build === ".next" ? ".next-test" : ".next"}/cache/seo/old.json`,
|
||||
);
|
||||
await put(`${build}/static/chunks/app.js`);
|
||||
await put(
|
||||
`config/${env}.env`,
|
||||
`API_BASE_URL=https://api.test\nPORT=21030\n`,
|
||||
);
|
||||
await put(`config/${env}.local.env`, "SENSITIVE=do-not-ship");
|
||||
}
|
||||
await put("public/assets/logo.svg");
|
||||
await put("public/._logo.svg");
|
||||
// Mirror runtime lock versions so deploy check_local accepts npm or pnpm-style installs.
|
||||
const versions = packageLockVersions(fileURLToPath(projectRoot));
|
||||
await put(
|
||||
"package.json",
|
||||
JSON.stringify(
|
||||
{
|
||||
name: "jha-1050-jgdj-web",
|
||||
private: true,
|
||||
dependencies: Object.fromEntries(
|
||||
LOCKED_RUNTIME.map((name) => [name, versions[name]]),
|
||||
),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
await put(
|
||||
"package-lock.json",
|
||||
JSON.stringify(
|
||||
{
|
||||
lockfileVersion: 3,
|
||||
packages: Object.fromEntries(
|
||||
LOCKED_RUNTIME.map((name) => [
|
||||
`node_modules/${name}`,
|
||||
{ version: versions[name] },
|
||||
]),
|
||||
),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
for (const name of LOCKED_RUNTIME) {
|
||||
await put(
|
||||
`node_modules/${name}/package.json`,
|
||||
JSON.stringify({ name, version: versions[name] }),
|
||||
);
|
||||
}
|
||||
await cp(new URL("scripts", projectRoot), join(root, "scripts"), {
|
||||
recursive: true,
|
||||
});
|
||||
// pnpm links dotenv; copy the real package so the fixture mirrors a concrete release tree.
|
||||
await cp(
|
||||
await realpath(new URL("node_modules/dotenv", projectRoot)),
|
||||
join(root, "node_modules/dotenv"),
|
||||
{ recursive: true, dereference: true },
|
||||
);
|
||||
return { root, put };
|
||||
}
|
||||
|
||||
test("standalone releases include only their profile and assets, exclude local secrets/cache, and preserve other releases", async (t) => {
|
||||
const { root, put } = await fixture(t);
|
||||
for (const [env, build] of [
|
||||
["test", ".next-test"],
|
||||
["production", ".next"],
|
||||
]) {
|
||||
const release = await packageStandalone(env, { root });
|
||||
for (const file of [
|
||||
"start.mjs",
|
||||
"server.js",
|
||||
"package-lock.json",
|
||||
"node_modules/dotenv/package.json",
|
||||
"scripts/check-health.mjs",
|
||||
build + "/BUILD_ID",
|
||||
build + "/static/chunks/app.js",
|
||||
"public/assets/logo.svg",
|
||||
`config/${env}.env`,
|
||||
])
|
||||
assert.ok((await stat(join(release, file))).isFile(), file);
|
||||
for (const file of [
|
||||
".env.local",
|
||||
"public/._logo.svg",
|
||||
`config/${env}.local.env`,
|
||||
"config/secrets",
|
||||
"dist",
|
||||
"tests",
|
||||
build + "/cache",
|
||||
build === ".next" ? ".next-test" : ".next",
|
||||
])
|
||||
await assert.rejects(stat(join(release, file)), { code: "ENOENT" });
|
||||
const launch = spawnSync(process.execPath, [join(release, "start.mjs")], {
|
||||
encoding: "utf8",
|
||||
env: { PATH: process.env.PATH },
|
||||
});
|
||||
assert.equal(launch.status, 0, launch.stderr);
|
||||
}
|
||||
await put("dist/production/keep", "previous production");
|
||||
await put("dist/test/stale", "remove stale file");
|
||||
await packageStandalone("test", { root });
|
||||
assert.equal(
|
||||
await readFile(join(root, "dist/production/keep"), "utf8"),
|
||||
"previous production",
|
||||
);
|
||||
await assert.rejects(stat(join(root, "dist/test/stale")), { code: "ENOENT" });
|
||||
});
|
||||
|
||||
test("invalid environments and symlink destinations fail without deleting unrelated data", async (t) => {
|
||||
const { root, put } = await fixture(t);
|
||||
await put("outside/keep", "do not delete");
|
||||
await mkdir(join(root, "dist"));
|
||||
await symlink(join(root, "outside"), join(root, "dist/test"));
|
||||
await assert.rejects(
|
||||
packageStandalone("test", { root }),
|
||||
/Unsafe release directory/,
|
||||
);
|
||||
await assert.rejects(packageStandalone("../outside", { root }), /Usage/);
|
||||
assert.equal(
|
||||
await readFile(join(root, "outside/keep"), "utf8"),
|
||||
"do not delete",
|
||||
);
|
||||
await rm(join(root, "dist"), { recursive: true });
|
||||
await symlink(join(root, "outside"), join(root, "dist"));
|
||||
await assert.rejects(
|
||||
packageStandalone("production", { root }),
|
||||
/Unsafe release directory/,
|
||||
);
|
||||
assert.equal(
|
||||
await readFile(join(root, "outside/keep"), "utf8"),
|
||||
"do not delete",
|
||||
);
|
||||
});
|
||||
|
||||
test("in-tree pnpm-style module links are preserved while escaping links are rejected", async (t) => {
|
||||
const { root, put } = await fixture(t);
|
||||
const modules = join(root, ".next-test/standalone/node_modules");
|
||||
await put(
|
||||
".next-test/standalone/node_modules/.pnpm/pkg@1/node_modules/pkg/index.js",
|
||||
"module.exports=1;",
|
||||
);
|
||||
await mkdir(join(modules, ".pnpm/node_modules"), { recursive: true });
|
||||
await symlink(
|
||||
"../pkg@1/node_modules/pkg",
|
||||
join(modules, ".pnpm/node_modules/pkg"),
|
||||
);
|
||||
await symlink(".pnpm/pkg@1/node_modules/pkg", join(modules, "pkg"));
|
||||
const release = await packageStandalone("test", { root });
|
||||
const link = join(release, "node_modules/pkg");
|
||||
assert.ok((await lstat(link)).isSymbolicLink());
|
||||
assert.equal(
|
||||
await readFile(join(link, "index.js"), "utf8"),
|
||||
"module.exports=1;",
|
||||
);
|
||||
const outside = await mkdtemp(join(tmpdir(), "91-escape-"));
|
||||
t.after(() => rm(outside, { recursive: true, force: true }));
|
||||
await writeFile(join(outside, "secret"), "escape");
|
||||
await mkdir(join(root, ".next/standalone/node_modules"), {
|
||||
recursive: true,
|
||||
});
|
||||
await symlink(
|
||||
join(outside, "secret"),
|
||||
join(root, ".next/standalone/node_modules/escape-link"),
|
||||
);
|
||||
await assert.rejects(
|
||||
packageStandalone("production", { root }),
|
||||
/Release source contains a symbolic link/,
|
||||
);
|
||||
});
|
||||
|
||||
test("dry runs reflect current configuration, allow overrides and never build/upload; invalid deployment targets fail early", async (t) => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "91-deploy-stubs-"));
|
||||
t.after(() => rm(dir, { recursive: true, force: true }));
|
||||
const marker = join(dir, "executed");
|
||||
for (const command of ["npm", "ssh", "scp"])
|
||||
await writeFile(
|
||||
join(dir, command),
|
||||
`#!/bin/bash\nprintf '%s\\n' '${command}' >> "$MARKER"\nexit 99\n`,
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
const env = {
|
||||
...process.env,
|
||||
PATH: dir + ":" + process.env.PATH,
|
||||
MARKER: marker,
|
||||
DEPLOY_DRY_RUN: "0",
|
||||
};
|
||||
for (const key of [
|
||||
"DEPLOY_HOST",
|
||||
"DEPLOY_DIR",
|
||||
"DEPLOY_RUN_DIR",
|
||||
"DEPLOY_JUMP_HOST",
|
||||
"DEPLOY_JUMP_DIR",
|
||||
"DEPLOY_SSH_PORT",
|
||||
"DEPLOY_SCP_PORT",
|
||||
"DEPLOY_CHECK_URL",
|
||||
"DEPLOY_PROGRAM",
|
||||
"DEPLOY_PACKAGE_NAME",
|
||||
"DEPLOY_TARGET_ARCH",
|
||||
"DEPLOY_TARGET_LIBC",
|
||||
])
|
||||
delete env[key];
|
||||
// Deployment addresses/ports are operator configuration, not application invariants.
|
||||
// Verify reporting consistency and explicit overrides rather than pinning a server.
|
||||
for (const script of ["deploy_test.sh", "deploy_prod.sh"]) {
|
||||
for (const args of [["--dry-run"], ["--package-only", "--dry-run"]]) {
|
||||
const result = spawnSync("bash", [script, ...args], {
|
||||
encoding: "utf8",
|
||||
env,
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /API=(?:current|https?:\/\/\S+)\s+PORT=\d+/);
|
||||
assert.doesNotMatch(result.stdout, /undefined/);
|
||||
if (!args.includes("--package-only")) {
|
||||
const help = spawnSync("bash", [script, "--help"], {
|
||||
encoding: "utf8",
|
||||
env,
|
||||
});
|
||||
assert.equal(help.status, 0, help.stderr);
|
||||
if (script === "deploy_test.sh") {
|
||||
assert.match(
|
||||
result.stdout,
|
||||
/上传 \S+\.tar\.gz、\S+\.tar\.gz\.sha256 和 up_next_h5\.sh/,
|
||||
);
|
||||
assert.doesNotMatch(result.stdout, /dist\.zip/);
|
||||
const target = result.stdout.match(
|
||||
/发布目标:(\S+):(\/[^(\n]+)(SSH (\d+))。/,
|
||||
);
|
||||
assert.ok(target, `${script} 未报告有效上传目标:${result.stdout}`);
|
||||
const [, host, directory, port] = target;
|
||||
assert.ok(Number(port) > 0 && Number(port) <= 65535);
|
||||
assert.ok(
|
||||
help.stdout.includes(
|
||||
`${host}:${port} ${directory.replace(/\/$/, "")}/`,
|
||||
),
|
||||
help.stdout,
|
||||
);
|
||||
const execute = result.stdout.match(
|
||||
/上传成功后:ssh -p (\d+) (\S+) "cd (\/\S+) && bash run\.sh"/,
|
||||
);
|
||||
assert.ok(execute, result.stdout);
|
||||
assert.equal(execute[1], port);
|
||||
assert.equal(execute[2], host);
|
||||
assert.ok(
|
||||
help.stdout.includes(`DEPLOY_RUN_DIR(${execute[3]})`),
|
||||
help.stdout,
|
||||
);
|
||||
} else {
|
||||
const upload = result.stdout.match(
|
||||
/^\s+scp -P (\d+) (\S+) (\S+) ([^:\s]+):(\/\S*)$/m,
|
||||
);
|
||||
const execute = result.stdout.match(
|
||||
/^\s+ssh -p (\d+) (\S+) "cd (\/\S+) && bash rsupdate\.sh"$/m,
|
||||
);
|
||||
assert.ok(upload, `${script} 未报告有效上传命令:${result.stdout}`);
|
||||
assert.ok(execute, `${script} 未报告有效执行命令:${result.stdout}`);
|
||||
const [, scpPort, archive, checksum, host, directory] = upload;
|
||||
const [, sshPort, sshHost, runDirectory] = execute;
|
||||
assert.equal(sshHost, host);
|
||||
assert.equal(runDirectory, directory.replace(/\/$/, ""));
|
||||
assert.equal(checksum, archive.replace(/\.tar\.gz$/, ".md5"));
|
||||
assert.ok(help.stdout.includes(`${host}:${directory}`), help.stdout);
|
||||
for (const [key, value] of [
|
||||
["DEPLOY_SCP_PORT", scpPort],
|
||||
["DEPLOY_SSH_PORT", sshPort],
|
||||
["DEPLOY_PACKAGE_NAME", archive],
|
||||
])
|
||||
assert.ok(help.stdout.includes(`${key}(${value})`), help.stdout);
|
||||
}
|
||||
}
|
||||
}
|
||||
const customEnv = {
|
||||
...env,
|
||||
DEPLOY_HOST: "user@custom-test",
|
||||
DEPLOY_DIR: "/srv/custom-test",
|
||||
DEPLOY_RUN_DIR: "/srv/custom-run",
|
||||
DEPLOY_JUMP_HOST: "user@custom-prod",
|
||||
DEPLOY_JUMP_DIR: "/srv/custom-prod/",
|
||||
DEPLOY_SSH_PORT: "2202",
|
||||
DEPLOY_SCP_PORT: "2203",
|
||||
DEPLOY_PACKAGE_NAME: "custom_package.tar.gz",
|
||||
};
|
||||
const custom = spawnSync("bash", [script, "--dry-run"], {
|
||||
encoding: "utf8",
|
||||
env: customEnv,
|
||||
});
|
||||
assert.equal(custom.status, 0, custom.stderr);
|
||||
assert.ok(
|
||||
custom.stdout.includes(
|
||||
script === "deploy_test.sh"
|
||||
? "user@custom-test:/srv/custom-test"
|
||||
: "user@custom-prod:/srv/custom-prod/",
|
||||
),
|
||||
);
|
||||
assert.ok(custom.stdout.includes("2202"));
|
||||
if (script === "deploy_prod.sh") assert.ok(custom.stdout.includes("2203"));
|
||||
assert.ok(custom.stdout.includes("custom_package.tar.gz"));
|
||||
if (script === "deploy_test.sh")
|
||||
assert.ok(custom.stdout.includes("custom_package.tar.gz.sha256"));
|
||||
const customHelp = spawnSync("bash", [script, "--help"], {
|
||||
encoding: "utf8",
|
||||
env: customEnv,
|
||||
});
|
||||
assert.equal(customHelp.status, 0, customHelp.stderr);
|
||||
assert.ok(customHelp.stdout.includes("2202"));
|
||||
assert.ok(
|
||||
customHelp.stdout.includes(
|
||||
script === "deploy_test.sh"
|
||||
? "/srv/custom-run"
|
||||
: "custom_package.tar.gz",
|
||||
),
|
||||
);
|
||||
if (script === "deploy_test.sh")
|
||||
assert.match(
|
||||
custom.stdout,
|
||||
/ssh -p 2202 user@custom-test "cd \/srv\/custom-run && bash run\.sh"/,
|
||||
);
|
||||
else
|
||||
assert.match(
|
||||
custom.stdout,
|
||||
/ssh -p 2202 user@custom-prod "cd \/srv\/custom-prod && bash rsupdate\.sh"/,
|
||||
);
|
||||
assert.ok(customHelp.stdout.includes("custom_package.tar.gz"));
|
||||
const invalidArchive = spawnSync("bash", [script, "--package-only"], {
|
||||
encoding: "utf8",
|
||||
env: { ...env, DEPLOY_PACKAGE_NAME: "../invalid.zip" },
|
||||
});
|
||||
assert.notEqual(invalidArchive.status, 0);
|
||||
assert.match(invalidArchive.stderr, /\.tar\.gz 文件名/);
|
||||
const invalidHost = spawnSync("bash", [script], {
|
||||
encoding: "utf8",
|
||||
env: { ...env, DEPLOY_HOST: "bad;host", DEPLOY_JUMP_HOST: "bad;host" },
|
||||
});
|
||||
assert.notEqual(invalidHost.status, 0);
|
||||
assert.match(invalidHost.stderr, /格式无效/);
|
||||
const invalid = spawnSync("bash", [script], {
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
...env,
|
||||
DEPLOY_HOST: "user@server",
|
||||
DEPLOY_DIR: "/srv/../other",
|
||||
DEPLOY_JUMP_HOST: "user@server",
|
||||
DEPLOY_JUMP_DIR: "/srv/../other",
|
||||
},
|
||||
});
|
||||
assert.notEqual(invalid.status, 0);
|
||||
assert.match(invalid.stderr, /绝对路径/);
|
||||
const invalidPort = spawnSync("bash", [script], {
|
||||
encoding: "utf8",
|
||||
env: { ...env, DEPLOY_SSH_PORT: "0" },
|
||||
});
|
||||
assert.notEqual(invalidPort.status, 0);
|
||||
assert.match(invalidPort.stderr, /1–65535/);
|
||||
if (script === "deploy_test.sh") {
|
||||
const invalidRun = spawnSync("bash", [script], {
|
||||
encoding: "utf8",
|
||||
env: { ...env, DEPLOY_RUN_DIR: "/srv/../other" },
|
||||
});
|
||||
assert.notEqual(invalidRun.status, 0);
|
||||
assert.match(invalidRun.stderr, /DEPLOY_RUN_DIR 必须是普通绝对路径/);
|
||||
}
|
||||
}
|
||||
await assert.rejects(stat(marker), { code: "ENOENT" });
|
||||
});
|
||||
|
||||
test("test deployment uploads tar.gz and checksum before run.sh, preserves its execution directory, and stops on failure", async (t) => {
|
||||
const { root } = await fixture(t),
|
||||
bin = join(root, "bin"),
|
||||
marker = join(root, "commands.jsonl");
|
||||
await mkdir(bin);
|
||||
await mkdir(join(root, "deploy"));
|
||||
await cp(
|
||||
new URL("deploy_test.sh", projectRoot),
|
||||
join(root, "deploy_test.sh"),
|
||||
);
|
||||
await cp(
|
||||
new URL("deploy/up_next_h5.sh", projectRoot),
|
||||
join(root, "deploy/up_next_h5.sh"),
|
||||
);
|
||||
for (const name of ["npm", "ssh", "scp"]) {
|
||||
const code =
|
||||
`const fs=require('node:fs'),args=process.argv.slice(2);fs.appendFileSync(process.env.MARKER,JSON.stringify({command:${JSON.stringify(name)},args,testEnvironment:process.env.TEST_APP_ENV})+'\\n');` +
|
||||
(name === "npm"
|
||||
? `if(args.join(' ')==='run bundle:test'){const r=require('node:child_process').spawnSync(process.execPath,['scripts/package-standalone.mjs','test'],{stdio:'inherit'});process.exit(r.status??1);}`
|
||||
: name === "scp"
|
||||
? `process.exit(Number(process.env.SCP_EXIT||0));`
|
||||
: `if(args.at(-1).includes('bash run.sh'))process.exit(Number(process.env.RUN_EXIT||0));`);
|
||||
await writeFile(
|
||||
join(bin, name),
|
||||
"#!" + process.execPath + "\n" + code + "\n",
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
}
|
||||
const archive = "fixture_test.tar.gz";
|
||||
const env = {
|
||||
...process.env,
|
||||
PATH: bin + ":" + process.env.PATH,
|
||||
MARKER: marker,
|
||||
DEPLOY_DRY_RUN: "0",
|
||||
DEPLOY_HOST: "fixture-host",
|
||||
DEPLOY_DIR: "/srv/fixture",
|
||||
DEPLOY_SSH_PORT: "2202",
|
||||
DEPLOY_PACKAGE_NAME: archive,
|
||||
DEPLOY_CHECK_URL: "",
|
||||
SCP_EXIT: "0",
|
||||
RUN_EXIT: "0",
|
||||
};
|
||||
delete env.DEPLOY_RUN_DIR;
|
||||
const run = (options = {}, args = []) =>
|
||||
spawnSync("bash", ["deploy_test.sh", ...args], {
|
||||
cwd: root,
|
||||
encoding: "utf8",
|
||||
env: { ...env, ...options },
|
||||
});
|
||||
const commands = async () =>
|
||||
(await readFile(marker, "utf8"))
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line));
|
||||
// Server directories are operator configuration. Compare actual execution with
|
||||
// the script's advertised default rather than pinning a historical path.
|
||||
const defaultRunDirectory = () => {
|
||||
const help = run({}, ["--help"]);
|
||||
assert.equal(help.status, 0, help.stderr);
|
||||
const configured = help.stdout.match(/DEPLOY_RUN_DIR(([^)]+))/);
|
||||
assert.ok(configured, help.stdout);
|
||||
return configured[1];
|
||||
};
|
||||
const runDirectory = defaultRunDirectory();
|
||||
const result = run();
|
||||
assert.equal(result.status, 0, result.stdout + result.stderr);
|
||||
const calls = await commands();
|
||||
const npm = calls.filter((call) => call.command === "npm");
|
||||
assert.deepEqual(
|
||||
npm.map((call) => call.args),
|
||||
[["run", "build:test"], ["test"], ["run", "bundle:test"]],
|
||||
);
|
||||
assert.equal(npm[1].testEnvironment, "test");
|
||||
assert.deepEqual(
|
||||
calls.filter((call) => call.command === "scp").map((call) => call.args),
|
||||
[
|
||||
[
|
||||
"-P",
|
||||
"2202",
|
||||
archive,
|
||||
archive + ".sha256",
|
||||
"deploy/up_next_h5.sh",
|
||||
"fixture-host:/srv/fixture/",
|
||||
],
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
calls.filter((call) => call.command === "ssh").map((call) => call.args),
|
||||
[
|
||||
["-p", "2202", "fixture-host", "mkdir -p '/srv/fixture'"],
|
||||
["-p", "2202", "fixture-host", `cd '${runDirectory}' && bash run.sh`],
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
calls.map((call) => call.command),
|
||||
["npm", "npm", "npm", "ssh", "scp", "ssh"],
|
||||
);
|
||||
assert.match(result.stdout, /测试服 run\.sh 执行完成/);
|
||||
const bytes = await readFile(join(root, archive));
|
||||
assert.equal(bytes.subarray(0, 2).toString("hex"), "1f8b");
|
||||
assert.equal(
|
||||
await readFile(join(root, archive + ".sha256"), "utf8"),
|
||||
createHash("sha256").update(bytes).digest("hex") + " " + archive + "\n",
|
||||
);
|
||||
assert.equal(
|
||||
await readFile(join(root, archive.replace(/\.tar\.gz$/, ".md5")), "utf8"),
|
||||
createHash("md5").update(bytes).digest("hex") + "\n",
|
||||
);
|
||||
const contents = spawnSync("tar", ["-tzf", join(root, archive)], {
|
||||
encoding: "utf8",
|
||||
});
|
||||
assert.equal(contents.status, 0, contents.stderr);
|
||||
assert.match(contents.stdout, /\.next-test\/BUILD_ID/);
|
||||
assert.match(contents.stdout, /config\/test\.env/);
|
||||
assert.doesNotMatch(contents.stdout, /node_modules|\.local\.env|\/cache\//);
|
||||
for (const [name, options, args, status] of [
|
||||
["custom-run-directory", { DEPLOY_RUN_DIR: "/srv/custom-run" }, [], 0],
|
||||
["upload-failed", { SCP_EXIT: "23" }, [], 23],
|
||||
["run-failed", { RUN_EXIT: "42" }, [], 42],
|
||||
["package-only", {}, ["--package-only"], 0],
|
||||
])
|
||||
await t.test(name, async () => {
|
||||
await writeFile(marker, "");
|
||||
const result = run(options, args);
|
||||
assert.equal(result.status, status, result.stdout + result.stderr);
|
||||
const calls = await commands(),
|
||||
ssh = calls.filter((call) => call.command === "ssh");
|
||||
if (name === "package-only")
|
||||
assert.ok(!calls.some((call) => ["ssh", "scp"].includes(call.command)));
|
||||
else if (name === "upload-failed") {
|
||||
assert.equal(ssh.length, 1);
|
||||
assert.doesNotMatch(ssh[0].args.at(-1), /run\.sh/);
|
||||
assert.doesNotMatch(result.stdout, /上传完成|执行完成/);
|
||||
} else {
|
||||
assert.equal(ssh.length, 2);
|
||||
assert.equal(calls.at(-1).command, "ssh");
|
||||
assert.equal(
|
||||
ssh[1].args.at(-1),
|
||||
`cd '${options.DEPLOY_RUN_DIR || runDirectory}' && bash run.sh`,
|
||||
);
|
||||
if (name === "run-failed")
|
||||
assert.doesNotMatch(result.stdout, /执行完成/);
|
||||
}
|
||||
});
|
||||
await t.test(
|
||||
"changing the configured default directory does not require updating path assertions",
|
||||
async () => {
|
||||
const script = join(root, "deploy_test.sh"),
|
||||
original = await readFile(script, "utf8");
|
||||
const changed = original.replace(
|
||||
/^RUN_DIR=.*$/m,
|
||||
'RUN_DIR="${DEPLOY_RUN_DIR:-/srv/operator-default}"',
|
||||
);
|
||||
assert.notEqual(changed, original);
|
||||
try {
|
||||
await writeFile(script, changed);
|
||||
await writeFile(marker, "");
|
||||
const directory = defaultRunDirectory();
|
||||
assert.equal(directory, "/srv/operator-default");
|
||||
const result = run();
|
||||
assert.equal(result.status, 0, result.stdout + result.stderr);
|
||||
const calls = await commands();
|
||||
assert.equal(calls.at(-1).command, "ssh");
|
||||
assert.equal(
|
||||
calls.at(-1).args.at(-1),
|
||||
`cd '${directory}' && bash run.sh`,
|
||||
);
|
||||
} finally {
|
||||
await writeFile(script, original);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("production uploads before running the exact allowed SSH command, and stops on failure", async (t) => {
|
||||
const { root } = await fixture(t),
|
||||
bin = join(root, "bin"),
|
||||
marker = join(root, "production-commands.jsonl");
|
||||
await mkdir(bin);
|
||||
await cp(
|
||||
new URL("deploy_prod.sh", projectRoot),
|
||||
join(root, "deploy_prod.sh"),
|
||||
);
|
||||
for (const name of ["npm", "ssh", "scp"]) {
|
||||
const code =
|
||||
`const fs=require('node:fs'),args=process.argv.slice(2);fs.appendFileSync(process.env.MARKER,JSON.stringify({command:${JSON.stringify(name)},args})+'\\n');` +
|
||||
(name === "npm"
|
||||
? `if(args.join(' ')==='run bundle'){const result=require('node:child_process').spawnSync(process.execPath,['scripts/package-standalone.mjs','production'],{stdio:'inherit'});process.exit(result.status??1);}if(args[1]==='bundle:linux'){const destination='dist/production-linux-x64-glibc';fs.rmSync(destination,{recursive:true,force:true});fs.cpSync('dist/production',destination,{recursive:true});fs.writeFileSync(destination+'/runtime-target.json',JSON.stringify({platform:'linux',arch:'x64',libc:'glibc'}));}`
|
||||
: name === "scp"
|
||||
? `process.exit(Number(process.env.SCP_EXIT||0));`
|
||||
: `if(args.at(-1)!==process.env.ALLOWED_REMOTE_COMMAND){console.error('权限不足');process.exit(1);}process.stdout.write(process.env.RUN_OUTPUT||'');process.exit(Number(process.env.RUN_EXIT||0));`);
|
||||
await writeFile(
|
||||
join(bin, name),
|
||||
"#!" + process.execPath + "\n" + code + "\n",
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
}
|
||||
const archive = "fixture_production.tar.gz";
|
||||
const env = {
|
||||
...process.env,
|
||||
PATH: bin + ":" + process.env.PATH,
|
||||
MARKER: marker,
|
||||
DEPLOY_DRY_RUN: "0",
|
||||
DEPLOY_JUMP_HOST: "fixture-host",
|
||||
DEPLOY_JUMP_DIR: "/srv/production/",
|
||||
DEPLOY_SCP_PORT: "2203",
|
||||
DEPLOY_SSH_PORT: "2202",
|
||||
DEPLOY_TARGET_ARCH: "x64",
|
||||
DEPLOY_TARGET_LIBC: "glibc",
|
||||
DEPLOY_PACKAGE_NAME: archive,
|
||||
ALLOWED_REMOTE_COMMAND: "cd /srv/production && bash rsupdate.sh",
|
||||
SCP_EXIT: "0",
|
||||
RUN_EXIT: "0",
|
||||
RUN_OUTPUT: "",
|
||||
};
|
||||
const run = (options = {}, args = []) =>
|
||||
spawnSync("bash", ["deploy_prod.sh", ...args], {
|
||||
cwd: root,
|
||||
encoding: "utf8",
|
||||
env: { ...env, ...options },
|
||||
});
|
||||
const commands = async () =>
|
||||
(await readFile(marker, "utf8"))
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line));
|
||||
const result = run();
|
||||
assert.equal(result.status, 0, result.stdout + result.stderr);
|
||||
const calls = await commands();
|
||||
assert.deepEqual(
|
||||
calls.map((call) => call.command),
|
||||
["npm", "npm", "npm", "scp", "ssh"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
calls.filter((call) => call.command === "npm").map((call) => call.args),
|
||||
[
|
||||
["run", "check"],
|
||||
["run", "bundle"],
|
||||
["run", "bundle:linux", "--", "x64", "glibc"],
|
||||
],
|
||||
);
|
||||
assert.deepEqual(calls.find((call) => call.command === "scp").args, [
|
||||
"-P",
|
||||
"2203",
|
||||
archive,
|
||||
"fixture_production.md5",
|
||||
"fixture-host:/srv/production/",
|
||||
]);
|
||||
assert.deepEqual(calls.find((call) => call.command === "ssh").args, [
|
||||
"-p",
|
||||
"2202",
|
||||
"fixture-host",
|
||||
env.ALLOWED_REMOTE_COMMAND,
|
||||
]);
|
||||
assert.ok(result.stdout.includes("远端命令:" + env.ALLOWED_REMOTE_COMMAND));
|
||||
assert.match(result.stdout, /正式服 rsupdate\.sh 执行完成/);
|
||||
assert.equal(
|
||||
await readFile(join(root, "fixture_production.md5"), "utf8"),
|
||||
createHash("md5")
|
||||
.update(await readFile(join(root, archive)))
|
||||
.digest("hex") + "\n",
|
||||
);
|
||||
for (const [name, options, args, status] of [
|
||||
["upload-failed", { SCP_EXIT: "23" }, [], 23],
|
||||
["remote-failed", { RUN_EXIT: "42" }, [], 1],
|
||||
[
|
||||
"restricted-ssh-denied",
|
||||
{ ALLOWED_REMOTE_COMMAND: "a different command" },
|
||||
[],
|
||||
1,
|
||||
],
|
||||
[
|
||||
"remote-error-output",
|
||||
{ RUN_OUTPUT: "FATAL: service did not start\n" },
|
||||
[],
|
||||
1,
|
||||
],
|
||||
[
|
||||
"remote-curl-401",
|
||||
{
|
||||
RUN_OUTPUT:
|
||||
"curl: (22) The requested URL returned error: 401\n发布成功\n",
|
||||
},
|
||||
[],
|
||||
1,
|
||||
],
|
||||
[
|
||||
"custom-directory",
|
||||
{
|
||||
DEPLOY_JUMP_DIR: "/srv/custom-production/",
|
||||
ALLOWED_REMOTE_COMMAND: "cd /srv/custom-production && bash rsupdate.sh",
|
||||
},
|
||||
[],
|
||||
0,
|
||||
],
|
||||
["package-only", {}, ["--package-only"], 0],
|
||||
])
|
||||
await t.test(name, async () => {
|
||||
await writeFile(marker, "");
|
||||
const result = run(options, args);
|
||||
assert.equal(result.status, status, result.stdout + result.stderr);
|
||||
const calls = await commands();
|
||||
if (name === "package-only")
|
||||
assert.ok(!calls.some((call) => ["scp", "ssh"].includes(call.command)));
|
||||
else if (name === "upload-failed")
|
||||
assert.ok(!calls.some((call) => call.command === "ssh"));
|
||||
else assert.equal(calls.at(-1).command, "ssh");
|
||||
if (name === "restricted-ssh-denied")
|
||||
assert.match(result.stdout, /权限不足/);
|
||||
if (name === "custom-directory") {
|
||||
assert.equal(calls.at(-1).args.at(-1), options.ALLOWED_REMOTE_COMMAND);
|
||||
assert.equal(
|
||||
calls.find((call) => call.command === "scp").args.at(-1),
|
||||
"fixture-host:/srv/custom-production/",
|
||||
);
|
||||
}
|
||||
if (status) assert.doesNotMatch(result.stdout, /执行完成/);
|
||||
});
|
||||
});
|
||||
|
||||
test("tar.gz server updates reject invalid archives before stopping services and roll back unhealthy replacements", async (t) => {
|
||||
for (const scenario of [
|
||||
"checksum",
|
||||
"archive",
|
||||
"link",
|
||||
"traversal",
|
||||
"install",
|
||||
"health",
|
||||
"success",
|
||||
"custom-name",
|
||||
])
|
||||
await t.test(scenario, async (t) => {
|
||||
const root = await mkdtemp(join(tmpdir(), "91-server-update-"));
|
||||
t.after(() => rm(root, { recursive: true, force: true }));
|
||||
const bin = join(root, "bin"),
|
||||
upload = join(root, "package");
|
||||
await mkdir(bin);
|
||||
await mkdir(upload);
|
||||
async function put(base, path, text = "fixture") {
|
||||
await mkdir(join(base, path, ".."), { recursive: true });
|
||||
await writeFile(join(base, path), text);
|
||||
}
|
||||
await cp(
|
||||
new URL("deploy/up_next_h5.sh", projectRoot),
|
||||
join(root, "up_next_h5.sh"),
|
||||
);
|
||||
await put(root, "next/version", "previous");
|
||||
await put(root, "next/config/test.local.env", "PORT=21022\n");
|
||||
await put(
|
||||
root,
|
||||
"next/.next-test/cache/seo/keep.json",
|
||||
"previous public SEO",
|
||||
);
|
||||
for (const file of [
|
||||
"start.mjs",
|
||||
"server.js",
|
||||
".next-test/BUILD_ID",
|
||||
".next-test/static/app.js",
|
||||
"public/logo.svg",
|
||||
"package-lock.json",
|
||||
])
|
||||
await put(upload, file);
|
||||
await put(upload, "package.json", "{}");
|
||||
await put(upload, "version", "replacement");
|
||||
await put(
|
||||
upload,
|
||||
"config/test.env",
|
||||
"API_BASE_URL=https://fixture.test\nPORT=21030\n",
|
||||
);
|
||||
await cp(
|
||||
new URL("scripts/next-env.mjs", projectRoot),
|
||||
join(upload, "scripts/next-env.mjs"),
|
||||
);
|
||||
await put(
|
||||
upload,
|
||||
"scripts/check-health.mjs",
|
||||
"process.exit(Number(process.env.HEALTH_EXIT||0));",
|
||||
);
|
||||
const packageName =
|
||||
scenario === "custom-name"
|
||||
? "custom_release.tar.gz"
|
||||
: "web_jgdj_h5.tar.gz";
|
||||
if (scenario === "link")
|
||||
await symlink(
|
||||
join(root, "untouched"),
|
||||
join(upload, "public/unsafe-link"),
|
||||
);
|
||||
const packed = spawnSync(
|
||||
"tar",
|
||||
["--no-xattrs", "-czf", join(root, packageName), "-C", upload, "."],
|
||||
{ encoding: "utf8", env: { ...process.env, COPYFILE_DISABLE: "1" } },
|
||||
);
|
||||
assert.equal(packed.status, 0, packed.stderr);
|
||||
if (scenario === "archive")
|
||||
await writeFile(join(root, packageName), "not a gzip archive");
|
||||
if (scenario === "traversal") {
|
||||
const header = Buffer.alloc(512);
|
||||
for (const [offset, value] of [
|
||||
[0, "../escaped-file"],
|
||||
[100, "0000644\0"],
|
||||
[108, "0000000\0"],
|
||||
[116, "0000000\0"],
|
||||
[124, "00000000000\0"],
|
||||
[136, "00000000000\0"],
|
||||
[257, "ustar\0"],
|
||||
[263, "00"],
|
||||
])
|
||||
header.write(value, offset);
|
||||
header.fill(32, 148, 156);
|
||||
header[156] = 48;
|
||||
header.write(
|
||||
[...header]
|
||||
.reduce((total, byte) => total + byte, 0)
|
||||
.toString(8)
|
||||
.padStart(6, "0") + "\0 ",
|
||||
148,
|
||||
);
|
||||
await writeFile(
|
||||
join(root, packageName),
|
||||
gzipSync(Buffer.concat([header, Buffer.alloc(1024)])),
|
||||
);
|
||||
}
|
||||
const hash = createHash("sha256")
|
||||
.update(await readFile(join(root, packageName)))
|
||||
.digest("hex");
|
||||
await writeFile(
|
||||
join(root, packageName + ".sha256"),
|
||||
(scenario === "checksum" ? "0".repeat(64) : hash) +
|
||||
" " +
|
||||
packageName +
|
||||
"\n",
|
||||
);
|
||||
const stubs = {
|
||||
npm: `const fs=require('node:fs');fs.appendFileSync(process.env.MARKER,'npm\\n');if(process.env.INSTALL_FAIL==='1')process.exit(1);fs.mkdirSync('node_modules',{recursive:true});fs.cpSync(process.env.DOTENV_SOURCE,'node_modules/dotenv',{recursive:true});`,
|
||||
supervisorctl: `const fs=require('node:fs');fs.appendFileSync(process.env.MARKER,process.argv.slice(2).join(' ')+'\\n');if(process.argv[2]==='status')console.log('jgdj_next_h5 RUNNING pid 100');`,
|
||||
sha256sum: `const fs=require('node:fs'),crypto=require('node:crypto');const expected=fs.readFileSync(process.argv[3],'utf8').split(/\\s/)[0],actual=crypto.createHash('sha256').update(fs.readFileSync(process.argv[3].replace(/\\.sha256$/,''))).digest('hex');process.exit(expected===actual?0:1);`,
|
||||
};
|
||||
for (const [name, code] of Object.entries(stubs))
|
||||
await writeFile(
|
||||
join(bin, name),
|
||||
"#!" + process.execPath + "\n" + code + "\n",
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
const marker = join(root, "commands");
|
||||
// The fake Supervisor has its own service; caller deployment overrides must not select a real service here.
|
||||
const result = spawnSync("bash", [join(root, "up_next_h5.sh")], {
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
...process.env,
|
||||
DEPLOY_PROGRAM: "jgdj_next_h5",
|
||||
DEPLOY_PACKAGE_NAME: packageName,
|
||||
PATH: bin + ":" + process.env.PATH,
|
||||
MARKER: marker,
|
||||
DOTENV_SOURCE: new URL("node_modules/dotenv", projectRoot).pathname,
|
||||
INSTALL_FAIL: scenario === "install" ? "1" : "0",
|
||||
HEALTH_EXIT: scenario === "health" ? "1" : "0",
|
||||
},
|
||||
});
|
||||
const commands = await readFile(marker, "utf8");
|
||||
if (["success", "custom-name"].includes(scenario)) {
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(
|
||||
await readFile(join(root, "next/version"), "utf8"),
|
||||
"replacement",
|
||||
);
|
||||
assert.equal(
|
||||
await readFile(join(root, "next/config/test.local.env"), "utf8"),
|
||||
"PORT=21022\n",
|
||||
);
|
||||
assert.equal(
|
||||
await readFile(
|
||||
join(root, "next/.next-test/cache/seo/keep.json"),
|
||||
"utf8",
|
||||
),
|
||||
"previous public SEO",
|
||||
);
|
||||
} else {
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.equal(
|
||||
await readFile(join(root, "next/version"), "utf8"),
|
||||
"previous",
|
||||
);
|
||||
if (scenario === "health")
|
||||
assert.equal(
|
||||
commands.match(/start jgdj_next_h5/g)?.length,
|
||||
2,
|
||||
commands,
|
||||
);
|
||||
else assert.doesNotMatch(commands, /stop |start /);
|
||||
if (["checksum", "archive", "link", "traversal"].includes(scenario))
|
||||
assert.doesNotMatch(commands, /npm/);
|
||||
if (scenario === "archive")
|
||||
assert.match(result.stderr, /tar.gz 压缩包损坏/);
|
||||
if (scenario === "link") assert.match(result.stderr, /不允许链接/);
|
||||
if (scenario === "traversal")
|
||||
assert.match(result.stderr, /非法 tar.gz 路径/);
|
||||
}
|
||||
await assert.rejects(stat(join(root, "escaped-file")), {
|
||||
code: "ENOENT",
|
||||
});
|
||||
await assert.rejects(stat(join(root, ".jgdj_next_h5.lock")), {
|
||||
code: "ENOENT",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import {routingModuleUrl} from './helpers/routing.mjs';
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFile} from 'node:fs/promises';
|
||||
import ts from 'typescript';
|
||||
|
||||
async function moduleUrl(file,replace=code=>code){
|
||||
const filename=new URL(file,import.meta.url);
|
||||
const {outputText}=ts.transpileModule(await readFile(filename,'utf8'),{compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.ESNext}});
|
||||
return 'data:text/javascript;base64,'+Buffer.from(replace(outputText).replace(/(['"])\.\.\/routing\1/g,JSON.stringify(routingModuleUrl))+'\n//# sourceURL='+filename.pathname).toString('base64');
|
||||
}
|
||||
const protocolUrl=await moduleUrl('../src/lib/api/protocol.ts');
|
||||
const renderUrl=await moduleUrl('../src/lib/client/render.ts',code=>code.replace('"../api/protocol"',JSON.stringify(protocolUrl)));
|
||||
const detailUrl=await moduleUrl('../src/lib/client/detail.ts',code=>code.replace('"../api/protocol"',JSON.stringify(protocolUrl)).replace('"./render"',JSON.stringify(renderUrl)));
|
||||
const {overviewMarkup,officialLinks,bindSeriesDescription,detailActionIcon}=await import(detailUrl);
|
||||
|
||||
test('overview retains API data, encrypted image loading and real chapter links in the original layout',()=>{
|
||||
const html=overviewMarkup({id:23456,title:'剧名 <script>',coverImg:'https://cdn.example/encrypted.webp',description:'真实简介 <img onerror=alert(1)>',themeNames:['古装','爱情'],isNew:true,countCollect:23000,countLike:100,heatValue:999},{id:90042,currentEpisode:1},'<div class="play-detail-actions">actions</div>');
|
||||
assert.ok(html.includes('剧名 <script>'));assert.ok(!html.includes('<script>'));
|
||||
assert.ok(html.includes('真实简介 <img onerror=alert(1)>'));assert.ok(html.includes('古装 · 爱情'));
|
||||
assert.ok(html.includes('data-src="https://cdn.example/encrypted.webp"'));assert.ok(!/\ssrc="https:\/\/cdn\.example/.test(html));
|
||||
assert.ok(html.includes('href="/play/23456/90042/"'));assert.ok(html.includes('series-badge--new'));
|
||||
assert.ok(html.includes('<div class="series-action-bar"><div class="series-actions">'));
|
||||
assert.ok(html.includes('aria-controls="series-description-text"'));assert.ok(html.includes('data-app-download-entry'));
|
||||
assert.ok(!overviewMarkup({id:7,title:'无章节'},undefined,'').includes('href="/play/'));
|
||||
const rich=overviewMarkup({id:8,title:'完结剧',totalEpisode:10,updateStatus:2,countBrowse:167,anchor:'演员 <b>',showOriginal:true,operateType:2,tagNames:['家庭'],tagSlugs:['jiating'],themeNames:['家庭']},{id:1},'');
|
||||
for(const text of ['全10集','已完结','167次观看','演员 演员 <b>','series-badge--original','series-badge--comic','href="/tag/jiating/"'])assert.ok(rich.includes(text),text);
|
||||
assert.equal(rich.split('家庭').length-1,1);
|
||||
});
|
||||
|
||||
test('official links use configured safe URLs and retain the original feedback entrance',()=>{
|
||||
const html=officialLinks({config:{newUrlAddr:'https://entry.example',foreverUrlAddr:'https://permanent.example',tgGroup:'https://t.me/example',telegram:'https://contact.example'}});
|
||||
for(const url of ['https://entry.example/','https://permanent.example/','https://t.me/example','https://contact.example/'])assert.ok(html.includes(`href="${url}"`));
|
||||
const fallback=officialLinks({config:{newUrlAddr:'javascript:alert(1)',tgGroup:'javascript:alert(1)'},navPageList:[{line:1,url:'https://backup.example'}]});
|
||||
assert.ok(fallback.includes('href="https://backup.example/"'));assert.ok(fallback.includes('href="/info/feedback/"'));assert.ok(!fallback.includes('javascript:'));
|
||||
assert.ok(!fallback.includes('huangguo.me'));assert.ok(!fallback.includes('huangguo.tv'));
|
||||
});
|
||||
|
||||
test('original action SVGs remain accessible and inherit the active colors',()=>{
|
||||
for(const kind of ['like','save','share','feedback']){
|
||||
const html=detailActionIcon(kind);assert.match(html,/<svg class="play-action-icon"/);assert.match(html,/aria-hidden="true"/);assert.ok(html.includes('<path d="'));
|
||||
}
|
||||
});
|
||||
|
||||
test('description expands only when overflowing three lines and resets when the layout becomes wider',()=>{
|
||||
const originals={getComputedStyle:globalThis.getComputedStyle,ResizeObserver:globalThis.ResizeObserver};
|
||||
let update,disconnects=0;
|
||||
const classes=new Set(),attributes=new Map();
|
||||
const text={clientWidth:200,scrollHeight:160,classList:{remove:key=>classes.delete(key),toggle:(key,on)=>on?classes.add(key):classes.delete(key)}};
|
||||
const toggle=new EventTarget();toggle.hidden=true;toggle.textContent='展开';toggle.getAttribute=key=>attributes.get(key);toggle.setAttribute=(key,value)=>attributes.set(key,value);
|
||||
const root={querySelector:selector=>selector==='#series-description-text'?text:toggle};
|
||||
const controller=new AbortController();
|
||||
globalThis.getComputedStyle=()=>({lineHeight:'24px'});
|
||||
globalThis.ResizeObserver=class {constructor(callback){update=callback;}observe(){}disconnect(){disconnects++;}};
|
||||
try{
|
||||
bindSeriesDescription(root,controller.signal);assert.equal(toggle.hidden,false);
|
||||
toggle.dispatchEvent(new Event('click'));assert.equal(classes.has('is-expanded'),true);assert.equal(toggle.textContent,'收起');
|
||||
toggle.dispatchEvent(new Event('click'));assert.equal(classes.has('is-expanded'),false);assert.equal(toggle.textContent,'展开');
|
||||
text.scrollHeight=60;update();assert.equal(toggle.hidden,true);assert.equal(attributes.get('aria-expanded'),'false');
|
||||
text.scrollHeight=160;update();assert.equal(toggle.hidden,false);
|
||||
controller.abort();assert.equal(disconnects,1);toggle.dispatchEvent(new Event('click'));assert.equal(classes.has('is-expanded'),false);
|
||||
}finally{for(const [key,value] of Object.entries(originals)){if(value===undefined)delete globalThis[key];else globalThis[key]=value;}}
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
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 appDownloadUrl=await moduleUrl('../src/lib/app-download.ts',code=>code.replace(/from ['"]\.\/api\/protocol['"]/,`from '${protocolUrl}'`));
|
||||
const {downloadUrl}=await import(appDownloadUrl);
|
||||
const clientUrl=await moduleUrl('../src/lib/client/download.ts',code=>code.replace(/from ['"]\.\.\/app-download['"]/,`from '${appDownloadUrl}'`));
|
||||
const {configureDownloadLinks,shouldUseDownloadDialog}=await import(clientUrl);
|
||||
const click={defaultPrevented:false,button:0,metaKey:false,ctrlKey:false,shiftKey:false,altKey:false};
|
||||
|
||||
test('download configuration selects the first valid HTTP URL and rejects empty or unsafe values',()=>{
|
||||
assert.equal(downloadUrl({}),'');
|
||||
assert.equal(downloadUrl({config:{appAddr:''}}),'');
|
||||
assert.equal(downloadUrl({config:{officialAppAddr:'javascript:alert(1)',appAddr:'https://example.com/app.apk'}}),'https://example.com/app.apk');
|
||||
assert.equal(downloadUrl({config:{appAddr:' '},newAddressInfo:{appAddr:'https://example.com/fallback.apk'}}),'https://example.com/fallback.apk');
|
||||
assert.equal(downloadUrl({config:{officialAppAddr:'https://example.com/official',appAddr:'https://example.com/other'}}),'https://example.com/official');
|
||||
for(const value of ['javascript:alert(1)','data:text/plain,app','ftp://example.com/app','https://user:password@example.com/app']){
|
||||
assert.equal(downloadUrl({config:{appAddr:value}}),'');
|
||||
}
|
||||
});
|
||||
|
||||
test('all download entries stay hidden until configured and are hidden again if configuration is removed',()=>{
|
||||
const entries=Array.from({length:4},()=>({hidden:true}));
|
||||
const links=Array.from({length:4},()=>({href:undefined,removeAttribute(name){if(name==='href')this.href=undefined;}}));
|
||||
const root={querySelectorAll:selector=>selector==='[data-app-download-entry]'?entries:links};
|
||||
configureDownloadLinks({},root);assert.ok(entries.every(entry=>entry.hidden));assert.ok(links.every(link=>link.href===undefined));
|
||||
configureDownloadLinks({config:{appAddr:'https://example.com/app.apk'}},root);
|
||||
assert.ok(entries.every(entry=>!entry.hidden));assert.ok(links.every(link=>link.href==='https://example.com/app.apk'));
|
||||
const dynamicEntry={hidden:true},dynamicLink={href:undefined,removeAttribute(name){if(name==='href')this.href=undefined;}};
|
||||
entries.push(dynamicEntry);links.push(dynamicLink);
|
||||
configureDownloadLinks({config:{appAddr:'https://example.com/new.apk'}},root);
|
||||
assert.equal(dynamicEntry.hidden,false);assert.equal(dynamicLink.href,'https://example.com/new.apk');
|
||||
configureDownloadLinks({},root);assert.ok(entries.every(entry=>entry.hidden));assert.ok(links.every(link=>link.href===undefined));
|
||||
});
|
||||
|
||||
test('desktop downloads use the link directly regardless of touch support',()=>{
|
||||
for(const agent of ['Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)','Mozilla/5.0 (Windows NT 10.0; Win64; x64)','Mozilla/5.0 (X11; Linux x86_64)']){
|
||||
assert.equal(shouldUseDownloadDialog(click,agent,0),false);
|
||||
}
|
||||
assert.equal(shouldUseDownloadDialog(click,'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',5),false);
|
||||
assert.equal(click.defaultPrevented,false);
|
||||
});
|
||||
|
||||
test('mobile click dialogs preserve modified clicks and other native browser actions',()=>{
|
||||
const phone='Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) Mobile';
|
||||
assert.equal(shouldUseDownloadDialog(click,phone,5),true);
|
||||
assert.equal(shouldUseDownloadDialog(click,'Mozilla/5.0 (Linux; Android 14)',5),true);
|
||||
assert.equal(shouldUseDownloadDialog(click,'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)',5),true);
|
||||
for(const key of ['defaultPrevented','metaKey','ctrlKey','shiftKey','altKey'])assert.equal(shouldUseDownloadDialog({...click,[key]:true},phone,5),false);
|
||||
assert.equal(shouldUseDownloadDialog({...click,button:1},phone,5),false);
|
||||
});
|
||||
@@ -0,0 +1,310 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
mkdtemp,
|
||||
mkdir,
|
||||
writeFile,
|
||||
readFile,
|
||||
realpath,
|
||||
rm,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import ts from "typescript";
|
||||
import vm from "node:vm";
|
||||
import { nextCommand, runNext } from "../scripts/next-env.mjs";
|
||||
|
||||
async function fixture(t) {
|
||||
const root = await mkdtemp(join(tmpdir(), "91-environments-"));
|
||||
t.after(() => rm(root, { recursive: true, force: true }));
|
||||
await mkdir(join(root, "config"));
|
||||
for (const environment of ["development", "test", "production"])
|
||||
await writeFile(
|
||||
join(root, "config", environment + ".env"),
|
||||
`API_BASE_URL=https://${environment}.api.test\nPORT=21030\nNEXT_PUBLIC_TRACKER_APP_ID=1050\n`,
|
||||
);
|
||||
return root;
|
||||
}
|
||||
|
||||
test("environment defaults, personal overrides and process values use the reference precedence without changing NODE_ENV semantics", async (t) => {
|
||||
const root = await fixture(t);
|
||||
await writeFile(
|
||||
join(root, "config/test.local.env"),
|
||||
"API_BASE_URL=https://override.api.test\nPORT=21022\nLOCAL_MARKER=yes\n",
|
||||
);
|
||||
const base = nextCommand("test", "dev", [], { root, variables: {} });
|
||||
assert.equal(base.env.API_BASE_URL, "https://override.api.test");
|
||||
assert.equal(base.env.PORT, "21022");
|
||||
assert.equal(base.env.LOCAL_MARKER, "yes");
|
||||
assert.equal(base.env.APP_ENV, "test");
|
||||
assert.equal(base.env.NODE_ENV, "development");
|
||||
assert.equal(base.env.APP_DIST_DIR, ".next-test");
|
||||
const processOverride = nextCommand("test", "start", [], {
|
||||
root,
|
||||
variables: {
|
||||
API_BASE_URL: "https://process.api.test/",
|
||||
PORT: "31021",
|
||||
APP_ENV: "production",
|
||||
NODE_ENV: "test",
|
||||
APP_DIST_DIR: "outside",
|
||||
},
|
||||
});
|
||||
assert.equal(processOverride.env.API_BASE_URL, "https://process.api.test");
|
||||
assert.equal(processOverride.env.PORT, "31021");
|
||||
assert.equal(processOverride.env.APP_ENV, "test");
|
||||
assert.equal(processOverride.env.NODE_ENV, "production");
|
||||
assert.equal(processOverride.env.APP_DIST_DIR, ".next-test");
|
||||
});
|
||||
|
||||
test("development supports legacy .env.local while test and production isolate local overrides", async (t) => {
|
||||
const root = await fixture(t);
|
||||
await writeFile(
|
||||
join(root, ".env.local"),
|
||||
"API_BASE_URL=https://legacy.api.test\nLEGACY_MARKER=dev-only\n",
|
||||
);
|
||||
await writeFile(
|
||||
join(root, "config/development.local.env"),
|
||||
"API_BASE_URL=https://development-local.api.test\n",
|
||||
);
|
||||
const dev = nextCommand("development", "dev", [], { root, variables: {} });
|
||||
assert.equal(dev.env.API_BASE_URL, "https://development-local.api.test");
|
||||
assert.equal(dev.env.LEGACY_MARKER, "dev-only");
|
||||
for (const environment of ["test", "production"]) {
|
||||
const result = nextCommand(environment, "build", [], {
|
||||
root,
|
||||
variables: {},
|
||||
});
|
||||
assert.equal(result.env.API_BASE_URL, `https://${environment}.api.test`);
|
||||
assert.equal(result.env.LEGACY_MARKER, undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test("UPSTREAM_API_URL follows profile, local and process precedence while preserving legacy process overrides", async (t) => {
|
||||
const root = await fixture(t);
|
||||
await writeFile(
|
||||
join(root, "config/test.env"),
|
||||
"UPSTREAM_API_URL=http://127.0.0.1:21111\nPORT=21030\n",
|
||||
);
|
||||
await writeFile(
|
||||
join(root, "config/test.local.env"),
|
||||
"UPSTREAM_API_URL=http://127.0.0.1:21112/\n",
|
||||
);
|
||||
let result = nextCommand("test", "start", [], { root, variables: {} });
|
||||
assert.equal(result.env.UPSTREAM_API_URL, "http://127.0.0.1:21112");
|
||||
assert.equal(result.env.API_BASE_URL, result.env.UPSTREAM_API_URL);
|
||||
result = nextCommand("test", "start", [], {
|
||||
root,
|
||||
variables: {
|
||||
UPSTREAM_API_URL: "https://primary.api.test/",
|
||||
API_BASE_URL: "https://ignored.api.test",
|
||||
},
|
||||
});
|
||||
assert.equal(result.env.UPSTREAM_API_URL, "https://primary.api.test");
|
||||
assert.equal(result.env.API_BASE_URL, result.env.UPSTREAM_API_URL);
|
||||
result = nextCommand("test", "start", [], {
|
||||
root,
|
||||
variables: { API_BASE_URL: "https://legacy-process.api.test" },
|
||||
});
|
||||
assert.equal(result.env.UPSTREAM_API_URL, "https://legacy-process.api.test");
|
||||
for (const UPSTREAM_API_URL of [
|
||||
"",
|
||||
"javascript:bad",
|
||||
"https://user:password@api.test",
|
||||
"https://api.test/?query=1",
|
||||
"https://api.test/#fragment",
|
||||
]) {
|
||||
assert.throws(
|
||||
() =>
|
||||
nextCommand("test", "start", [], {
|
||||
root,
|
||||
variables: { UPSTREAM_API_URL },
|
||||
}),
|
||||
/UPSTREAM_API_URL/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("each environment selects its own build directory and supports port/hostname command overrides", async (t) => {
|
||||
const root = await fixture(t);
|
||||
for (const [environment, directory] of [
|
||||
["development", ".next-dev"],
|
||||
["test", ".next-test"],
|
||||
["production", ".next"],
|
||||
]) {
|
||||
for (const command of ["dev", "build", "start"]) {
|
||||
const result = nextCommand(environment, command, [], {
|
||||
root,
|
||||
variables: {},
|
||||
});
|
||||
assert.equal(result.env.APP_DIST_DIR, directory);
|
||||
assert.equal(result.cwd, root);
|
||||
assert.equal(
|
||||
result.env.NODE_ENV,
|
||||
command === "dev" ? "development" : "production",
|
||||
);
|
||||
if (command === "build")
|
||||
assert.deepEqual(result.args.slice(1), ["build"]);
|
||||
else
|
||||
assert.deepEqual(result.args.slice(2), [
|
||||
"--hostname",
|
||||
"0.0.0.0",
|
||||
"--port",
|
||||
"21030",
|
||||
]);
|
||||
}
|
||||
}
|
||||
const explicit = nextCommand(
|
||||
"test",
|
||||
"dev",
|
||||
["--port", "31022", "--hostname=127.0.0.1", "--webpack"],
|
||||
{ root, variables: {} },
|
||||
);
|
||||
assert.deepEqual(explicit.args.slice(1), [
|
||||
"dev",
|
||||
"--port",
|
||||
"31022",
|
||||
"--hostname=127.0.0.1",
|
||||
"--webpack",
|
||||
]);
|
||||
});
|
||||
|
||||
test("current-domain profiles preserve runtime resolution instead of requiring a build-time URL", async (t) => {
|
||||
const root = await fixture(t);
|
||||
for (const environment of ["test", "production"]) {
|
||||
await writeFile(
|
||||
join(root, "config", environment + ".env"),
|
||||
"UPSTREAM_API_URL=current\nPORT=21030\n",
|
||||
);
|
||||
for (const command of ["dev", "build", "start"]) {
|
||||
const result = nextCommand(environment, command, [], {
|
||||
root,
|
||||
variables: {},
|
||||
});
|
||||
assert.equal(result.env.UPSTREAM_API_URL, "current");
|
||||
assert.equal(result.env.API_BASE_URL, "current");
|
||||
}
|
||||
const override = nextCommand(environment, "start", [], {
|
||||
root,
|
||||
variables: { UPSTREAM_API_URL: "http://127.0.0.1:21111" },
|
||||
});
|
||||
assert.equal(override.env.UPSTREAM_API_URL, "http://127.0.0.1:21111");
|
||||
}
|
||||
});
|
||||
|
||||
test("invalid environment, command, API or port fails before a child process is started", async (t) => {
|
||||
const root = await fixture(t);
|
||||
for (const [environment, command] of [
|
||||
["staging", "dev"],
|
||||
["../test", "dev"],
|
||||
["development", "deploy"],
|
||||
])
|
||||
assert.throws(
|
||||
() => nextCommand(environment, command, [], { root, variables: {} }),
|
||||
/Usage:/,
|
||||
);
|
||||
for (const API_BASE_URL of [
|
||||
"",
|
||||
"javascript:bad",
|
||||
"https://name:password@api.test",
|
||||
"https://api.test/#fragment",
|
||||
])
|
||||
assert.throws(
|
||||
() =>
|
||||
nextCommand("test", "dev", [], { root, variables: { API_BASE_URL } }),
|
||||
/API_BASE_URL/,
|
||||
);
|
||||
for (const PORT of ["0", "65536", "not-a-port", "-1"])
|
||||
assert.throws(
|
||||
() => nextCommand("test", "dev", [], { root, variables: { PORT } }),
|
||||
/PORT/,
|
||||
);
|
||||
await rm(join(root, "config/test.env"));
|
||||
assert.throws(
|
||||
() => nextCommand("test", "dev", [], { root, variables: {} }),
|
||||
/缺少环境配置/,
|
||||
);
|
||||
});
|
||||
|
||||
test("real child execution receives the selected environment, preserves args and propagates its exit status", async (t) => {
|
||||
const root = await fixture(t),
|
||||
bin = join(root, "node_modules/next/dist/bin");
|
||||
await mkdir(bin, { recursive: true });
|
||||
const output = join(root, "captured.json");
|
||||
await writeFile(
|
||||
join(bin, "next"),
|
||||
`require('node:fs').writeFileSync(process.env.CAPTURE,JSON.stringify({app:process.env.APP_ENV,node:process.env.NODE_ENV,api:process.env.API_BASE_URL,args:process.argv.slice(2),cwd:process.cwd()}));process.exit(7);`,
|
||||
);
|
||||
assert.equal(
|
||||
await runNext("test", "start", ["--port", "31021"], {
|
||||
root,
|
||||
variables: { CAPTURE: output },
|
||||
}),
|
||||
7,
|
||||
);
|
||||
const saved = JSON.parse(await readFile(output, "utf8"));
|
||||
assert.deepEqual(saved, {
|
||||
app: "test",
|
||||
node: "production",
|
||||
api: "https://test.api.test",
|
||||
args: ["start", "--hostname", "0.0.0.0", "--port", "31021"],
|
||||
cwd: await realpath(root),
|
||||
});
|
||||
});
|
||||
|
||||
test("project profiles and Next configuration keep the requested API and port with distinct artifacts", async () => {
|
||||
const source = ts.transpileModule(
|
||||
await readFile(new URL("../next.config.ts", import.meta.url), "utf8"),
|
||||
{
|
||||
compilerOptions: {
|
||||
target: ts.ScriptTarget.ES2022,
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
},
|
||||
},
|
||||
).outputText;
|
||||
for (const [environment, directory] of [
|
||||
["development", ".next-dev"],
|
||||
["test", ".next-test"],
|
||||
["production", ".next"],
|
||||
]) {
|
||||
const command = nextCommand(environment, "build", [], { variables: {} }),
|
||||
exports = {};
|
||||
vm.runInNewContext(source, {
|
||||
exports,
|
||||
process: { env: command.env, cwd: () => process.cwd() },
|
||||
});
|
||||
assert.equal(exports.default.distDir, directory);
|
||||
assert.equal(
|
||||
command.env.UPSTREAM_API_URL,
|
||||
environment === "production"
|
||||
? "http://139.162.49.252:21040"
|
||||
: "http://172.104.181.99:26792",
|
||||
);
|
||||
assert.equal(command.env.API_BASE_URL, command.env.UPSTREAM_API_URL);
|
||||
assert.equal(
|
||||
command.env.PORT,
|
||||
environment === "production" ? "21042" : "21030",
|
||||
);
|
||||
assert.equal(command.env.NEXT_PUBLIC_TRACKER_APP_ID, "1050");
|
||||
const ignored = spawnSync(
|
||||
"git",
|
||||
["check-ignore", "--no-index", `config/${environment}.local.env`],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
assert.equal(ignored.status, 0);
|
||||
}
|
||||
const pkg = JSON.parse(
|
||||
await readFile(new URL("../package.json", import.meta.url), "utf8"),
|
||||
);
|
||||
for (const name of [
|
||||
"dev",
|
||||
"dev:test",
|
||||
"build",
|
||||
"build:dev",
|
||||
"build:test",
|
||||
"start",
|
||||
"start:dev",
|
||||
"start:test",
|
||||
])
|
||||
assert.match(pkg.scripts[name], /next-env\.mjs/);
|
||||
});
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
[
|
||||
{
|
||||
"name": "empty",
|
||||
"ua": "",
|
||||
"sid": "reference-sid",
|
||||
"version": "",
|
||||
"expected": "DevType=desktop;SysType=h5_pc;Ver=1.0.0;BuildID=;device_brand=;device_model=;system_name=Other;system_version=;sid=reference-sid"
|
||||
},
|
||||
{
|
||||
"name": "iphone",
|
||||
"ua": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 Version/17.5 Mobile/15E148 Safari/604.1",
|
||||
"sid": "reference-sid",
|
||||
"version": "",
|
||||
"expected": "DevType=Apple iPhone mobile;SysType=h5_ios;Ver=1.0.0;BuildID=Safari 17.5;device_brand=Apple;device_model=iPhone;system_name=iOS;system_version=17.5;sid=reference-sid"
|
||||
},
|
||||
{
|
||||
"name": "ipad",
|
||||
"ua": "Mozilla/5.0 (iPad; CPU OS 16_7 like Mac OS X) AppleWebKit/605.1.15 Version/16.7 Safari/605.1.15",
|
||||
"sid": "reference-sid",
|
||||
"version": "",
|
||||
"expected": "DevType=Apple iPad tablet;SysType=h5_ios;Ver=1.0.0;BuildID=Safari 16.7;device_brand=Apple;device_model=iPad;system_name=iOS;system_version=16.7;sid=reference-sid"
|
||||
},
|
||||
{
|
||||
"name": "android-pixel",
|
||||
"ua": "Mozilla/5.0 (Linux; Android 14; Pixel 8 Build/AP1A) AppleWebKit/537.36 Chrome/124.0.0.0 Mobile Safari/537.36",
|
||||
"sid": "reference-sid",
|
||||
"version": "",
|
||||
"expected": "DevType=Google Pixel 8 mobile;SysType=h5_android;Ver=1.0.0;BuildID=Chrome 124.0.0.0;device_brand=Google;device_model=Pixel 8;system_name=Android;system_version=14;sid=reference-sid"
|
||||
},
|
||||
{
|
||||
"name": "android-tablet",
|
||||
"ua": "Mozilla/5.0 (Linux; Android 13; SM-X700) AppleWebKit/537.36 Chrome/123.0.0.0 Safari/537.36",
|
||||
"sid": "reference-sid",
|
||||
"version": "",
|
||||
"expected": "DevType=Samsung SM-X700 tablet;SysType=h5_android;Ver=1.0.0;BuildID=Chrome 123.0.0.0;device_brand=Samsung;device_model=SM-X700;system_name=Android;system_version=13;sid=reference-sid"
|
||||
},
|
||||
{
|
||||
"name": "android-honor",
|
||||
"ua": "Mozilla/5.0 (Linux; Android 12; HONOR 70 Build/HONORFNE) AppleWebKit/537.36 Chrome/120.0.0.0 Mobile Safari/537.36",
|
||||
"sid": "reference-sid",
|
||||
"version": "",
|
||||
"expected": "DevType=Honor HONOR 70 mobile;SysType=h5_android;Ver=1.0.0;BuildID=Chrome 120.0.0.0;device_brand=Honor;device_model=HONOR 70;system_name=Android;system_version=12;sid=reference-sid"
|
||||
},
|
||||
{
|
||||
"name": "windows-edge",
|
||||
"ua": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/124.0.0.0 Safari/537.36 Edg/124.0.2478.80",
|
||||
"sid": "reference-sid",
|
||||
"version": "",
|
||||
"expected": "DevType=desktop;SysType=h5_pc;Ver=1.0.0;BuildID=Edge 124.0.2478.80;device_brand=;device_model=;system_name=Windows;system_version=10.0;sid=reference-sid"
|
||||
},
|
||||
{
|
||||
"name": "mac-safari",
|
||||
"ua": "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/605.1.15 Version/17.5 Safari/605.1.15",
|
||||
"sid": "reference-sid",
|
||||
"version": "",
|
||||
"expected": "DevType=Apple Mac desktop;SysType=h5_pc;Ver=1.0.0;BuildID=Safari 17.5;device_brand=Apple;device_model=Mac;system_name=macOS;system_version=14.5;sid=reference-sid"
|
||||
},
|
||||
{
|
||||
"name": "linux-firefox",
|
||||
"ua": "Mozilla/5.0 (X11; Linux x86_64; rv:126.0) Gecko/20100101 Firefox/126.0",
|
||||
"sid": "reference-sid",
|
||||
"version": "",
|
||||
"expected": "DevType=desktop;SysType=h5_pc;Ver=1.0.0;BuildID=Firefox 126.0;device_brand=;device_model=;system_name=Linux;system_version=;sid=reference-sid"
|
||||
},
|
||||
{
|
||||
"name": "opera",
|
||||
"ua": "Mozilla/5.0 (Windows NT 10.0) Chrome/124.0.0.0 Safari/537.36 OPR/110.0.0.0",
|
||||
"sid": "reference-sid",
|
||||
"version": "",
|
||||
"expected": "DevType=desktop;SysType=h5_pc;Ver=1.0.0;BuildID=Opera 110.0.0.0;device_brand=;device_model=;system_name=Windows;system_version=10.0;sid=reference-sid"
|
||||
},
|
||||
{
|
||||
"name": "ie",
|
||||
"ua": "Mozilla/5.0 (Windows NT 6.1; Trident/7.0; rv:11.0) like Gecko",
|
||||
"sid": "reference-sid",
|
||||
"version": "",
|
||||
"expected": "DevType=desktop;SysType=h5_pc;Ver=1.0.0;BuildID=IE 11.0;device_brand=;device_model=;system_name=Windows;system_version=6.1;sid=reference-sid"
|
||||
},
|
||||
{
|
||||
"name": "crawler",
|
||||
"ua": "ExampleBot/1.0",
|
||||
"sid": "reference-sid",
|
||||
"version": "2.3.4",
|
||||
"expected": "DevType=desktop;SysType=h5_pc;Ver=2.3.4;BuildID=;device_brand=;device_model=;system_name=Other;system_version=;sid=reference-sid"
|
||||
}
|
||||
]
|
||||
+706
@@ -0,0 +1,706 @@
|
||||
{
|
||||
"source": "https://sgdj-h5.80sygtm.com/swagger/doc.json",
|
||||
"reviewed": "2026-10-26",
|
||||
"operations": {
|
||||
"GET applist/list": {
|
||||
"query": [
|
||||
"categoryId"
|
||||
],
|
||||
"requiredQuery": [],
|
||||
"body": [],
|
||||
"requiredBody": []
|
||||
},
|
||||
"GET auth/getInfo": {
|
||||
"query": [],
|
||||
"requiredQuery": [],
|
||||
"body": [],
|
||||
"requiredBody": []
|
||||
},
|
||||
"GET coin/orders": {
|
||||
"query": [
|
||||
"page",
|
||||
"pageSize"
|
||||
],
|
||||
"requiredQuery": [],
|
||||
"body": [],
|
||||
"requiredBody": []
|
||||
},
|
||||
"GET coin/trans/export": {
|
||||
"query": [
|
||||
"type"
|
||||
],
|
||||
"requiredQuery": [],
|
||||
"body": [],
|
||||
"requiredBody": []
|
||||
},
|
||||
"GET coin/trans/list": {
|
||||
"query": [
|
||||
"page",
|
||||
"pageSize",
|
||||
"type"
|
||||
],
|
||||
"requiredQuery": [],
|
||||
"body": [],
|
||||
"requiredBody": []
|
||||
},
|
||||
"GET comment/list": {
|
||||
"query": [
|
||||
"objectId",
|
||||
"objectType",
|
||||
"pageNum",
|
||||
"pageSize",
|
||||
"sortOrder"
|
||||
],
|
||||
"requiredQuery": [
|
||||
"objectId",
|
||||
"objectType"
|
||||
],
|
||||
"body": [],
|
||||
"requiredBody": []
|
||||
},
|
||||
"GET config/footer/info": {
|
||||
"query": [],
|
||||
"requiredQuery": [],
|
||||
"body": [],
|
||||
"requiredBody": []
|
||||
},
|
||||
"GET config/info": {
|
||||
"query": [],
|
||||
"requiredQuery": [],
|
||||
"body": [],
|
||||
"requiredBody": []
|
||||
},
|
||||
"GET config/richtext/list": {
|
||||
"query": [
|
||||
"ids",
|
||||
"titles"
|
||||
],
|
||||
"requiredQuery": [],
|
||||
"body": [],
|
||||
"requiredBody": []
|
||||
},
|
||||
"GET navigation/header": {
|
||||
"query": [],
|
||||
"requiredQuery": [],
|
||||
"body": [],
|
||||
"requiredBody": []
|
||||
},
|
||||
"GET playlet/all/list": {
|
||||
"query": [
|
||||
"pageNum",
|
||||
"pageSize",
|
||||
"sortType",
|
||||
"themeId",
|
||||
"themeSlug",
|
||||
"updateStatus"
|
||||
],
|
||||
"requiredQuery": [],
|
||||
"body": [],
|
||||
"requiredBody": []
|
||||
},
|
||||
"GET playlet/bought/list": {
|
||||
"query": [
|
||||
"pageNum",
|
||||
"pageSize"
|
||||
],
|
||||
"requiredQuery": [],
|
||||
"body": [],
|
||||
"requiredBody": []
|
||||
},
|
||||
"GET playlet/chapter/collect/list": {
|
||||
"query": [
|
||||
"pageNum",
|
||||
"pageSize"
|
||||
],
|
||||
"requiredQuery": [],
|
||||
"body": [],
|
||||
"requiredBody": []
|
||||
},
|
||||
"GET playlet/chapter/detail": {
|
||||
"query": [
|
||||
"chapterId",
|
||||
"playletId",
|
||||
"playletSlug"
|
||||
],
|
||||
"requiredQuery": [
|
||||
"chapterId"
|
||||
],
|
||||
"body": [],
|
||||
"requiredBody": []
|
||||
},
|
||||
"GET playlet/chapter/list": {
|
||||
"query": [
|
||||
"playletId",
|
||||
"playletSlug"
|
||||
],
|
||||
"requiredQuery": [],
|
||||
"body": [],
|
||||
"requiredBody": []
|
||||
},
|
||||
"GET playlet/collect/list": {
|
||||
"query": [
|
||||
"pageNum",
|
||||
"pageSize",
|
||||
"watchStatus"
|
||||
],
|
||||
"requiredQuery": [],
|
||||
"body": [],
|
||||
"requiredBody": []
|
||||
},
|
||||
"GET playlet/detail": {
|
||||
"query": [
|
||||
"id",
|
||||
"slug"
|
||||
],
|
||||
"requiredQuery": [],
|
||||
"body": [],
|
||||
"requiredBody": []
|
||||
},
|
||||
"GET playlet/guess-like/list": {
|
||||
"query": [
|
||||
"pageNum",
|
||||
"pageSize",
|
||||
"playletId"
|
||||
],
|
||||
"requiredQuery": [
|
||||
"playletId"
|
||||
],
|
||||
"body": [],
|
||||
"requiredBody": []
|
||||
},
|
||||
"GET playlet/history/list": {
|
||||
"query": [
|
||||
"pageNum",
|
||||
"pageSize"
|
||||
],
|
||||
"requiredQuery": [],
|
||||
"body": [],
|
||||
"requiredBody": []
|
||||
},
|
||||
"GET playlet/home": {
|
||||
"query": [],
|
||||
"requiredQuery": [],
|
||||
"body": [],
|
||||
"requiredBody": []
|
||||
},
|
||||
"GET playlet/home-config/list": {
|
||||
"query": [
|
||||
"id",
|
||||
"pageNum",
|
||||
"pageSize",
|
||||
"slug"
|
||||
],
|
||||
"requiredQuery": [],
|
||||
"body": [],
|
||||
"requiredBody": []
|
||||
},
|
||||
"GET playlet/hot-like": {
|
||||
"query": [],
|
||||
"requiredQuery": [],
|
||||
"body": [],
|
||||
"requiredBody": []
|
||||
},
|
||||
"GET playlet/hot/rank/list": {
|
||||
"query": [
|
||||
"pageNum",
|
||||
"pageSize",
|
||||
"rankType"
|
||||
],
|
||||
"requiredQuery": [],
|
||||
"body": [],
|
||||
"requiredBody": []
|
||||
},
|
||||
"GET playlet/like/list": {
|
||||
"query": [
|
||||
"pageNum",
|
||||
"pageSize"
|
||||
],
|
||||
"requiredQuery": [],
|
||||
"body": [],
|
||||
"requiredBody": []
|
||||
},
|
||||
"GET playlet/operate/list": {
|
||||
"query": [
|
||||
"pageNum",
|
||||
"pageSize",
|
||||
"type"
|
||||
],
|
||||
"requiredQuery": [],
|
||||
"body": [],
|
||||
"requiredBody": []
|
||||
},
|
||||
"GET playlet/rank": {
|
||||
"query": [
|
||||
"pageNum",
|
||||
"pageSize",
|
||||
"sortOrder"
|
||||
],
|
||||
"requiredQuery": [],
|
||||
"body": [],
|
||||
"requiredBody": []
|
||||
},
|
||||
"GET playlet/search": {
|
||||
"query": [
|
||||
"keyword",
|
||||
"pageNum",
|
||||
"pageSize"
|
||||
],
|
||||
"requiredQuery": [],
|
||||
"body": [],
|
||||
"requiredBody": []
|
||||
},
|
||||
"GET playlet/search/suggest": {
|
||||
"query": [
|
||||
"keyword"
|
||||
],
|
||||
"requiredQuery": [
|
||||
"keyword"
|
||||
],
|
||||
"body": [],
|
||||
"requiredBody": []
|
||||
},
|
||||
"GET playlet/tag/list": {
|
||||
"query": [
|
||||
"pageNum",
|
||||
"pageSize",
|
||||
"sortType",
|
||||
"tagId",
|
||||
"tagSlug"
|
||||
],
|
||||
"requiredQuery": [],
|
||||
"body": [],
|
||||
"requiredBody": []
|
||||
},
|
||||
"GET playlet/tag/options": {
|
||||
"query": [],
|
||||
"requiredQuery": [],
|
||||
"body": [],
|
||||
"requiredBody": []
|
||||
},
|
||||
"GET playlet/today-update": {
|
||||
"query": [
|
||||
"pageNum",
|
||||
"pageSize"
|
||||
],
|
||||
"requiredQuery": [],
|
||||
"body": [],
|
||||
"requiredBody": []
|
||||
},
|
||||
"GET playlet/topic/detail": {
|
||||
"query": [
|
||||
"dataType",
|
||||
"id",
|
||||
"pageNum",
|
||||
"pageSize",
|
||||
"sortType",
|
||||
"topicSlug"
|
||||
],
|
||||
"requiredQuery": [],
|
||||
"body": [],
|
||||
"requiredBody": []
|
||||
},
|
||||
"GET playlet/topic/list": {
|
||||
"query": [
|
||||
"pageNum",
|
||||
"pageSize"
|
||||
],
|
||||
"requiredQuery": [],
|
||||
"body": [],
|
||||
"requiredBody": []
|
||||
},
|
||||
"GET recharge/package/list": {
|
||||
"query": [],
|
||||
"requiredQuery": [],
|
||||
"body": [],
|
||||
"requiredBody": []
|
||||
},
|
||||
"GET theme/list": {
|
||||
"query": [],
|
||||
"requiredQuery": [],
|
||||
"body": [],
|
||||
"requiredBody": []
|
||||
},
|
||||
"GET translation/category/list": {
|
||||
"query": [],
|
||||
"requiredQuery": [],
|
||||
"body": [],
|
||||
"requiredBody": []
|
||||
},
|
||||
"GET user/center/data": {
|
||||
"query": [],
|
||||
"requiredQuery": [],
|
||||
"body": [],
|
||||
"requiredBody": []
|
||||
},
|
||||
"GET video/all/list": {
|
||||
"query": [
|
||||
"mediaType",
|
||||
"pageNum",
|
||||
"pageSize",
|
||||
"sortType",
|
||||
"topicId",
|
||||
"topicSlug"
|
||||
],
|
||||
"requiredQuery": [],
|
||||
"body": [],
|
||||
"requiredBody": []
|
||||
},
|
||||
"GET video/collect/list": {
|
||||
"query": [
|
||||
"pageNum",
|
||||
"pageSize"
|
||||
],
|
||||
"requiredQuery": [],
|
||||
"body": [],
|
||||
"requiredBody": []
|
||||
},
|
||||
"GET video/detail": {
|
||||
"query": [
|
||||
"id",
|
||||
"slug"
|
||||
],
|
||||
"requiredQuery": [],
|
||||
"body": [],
|
||||
"requiredBody": []
|
||||
},
|
||||
"GET video/history/list": {
|
||||
"query": [
|
||||
"pageNum",
|
||||
"pageSize"
|
||||
],
|
||||
"requiredQuery": [],
|
||||
"body": [],
|
||||
"requiredBody": []
|
||||
},
|
||||
"GET video/list": {
|
||||
"query": [
|
||||
"pageNum",
|
||||
"pageSize"
|
||||
],
|
||||
"requiredQuery": [],
|
||||
"body": [],
|
||||
"requiredBody": []
|
||||
},
|
||||
"GET video/topic/list": {
|
||||
"query": [
|
||||
"mediaType"
|
||||
],
|
||||
"requiredQuery": [],
|
||||
"body": [],
|
||||
"requiredBody": []
|
||||
},
|
||||
"GET vip/orders": {
|
||||
"query": [
|
||||
"page",
|
||||
"pageSize"
|
||||
],
|
||||
"requiredQuery": [],
|
||||
"body": [],
|
||||
"requiredBody": []
|
||||
},
|
||||
"POST auth/guest": {
|
||||
"query": [],
|
||||
"requiredQuery": [],
|
||||
"body": [
|
||||
"channel",
|
||||
"devID",
|
||||
"deviceId",
|
||||
"mac"
|
||||
],
|
||||
"requiredBody": []
|
||||
},
|
||||
"POST auth/login": {
|
||||
"query": [],
|
||||
"requiredQuery": [],
|
||||
"body": [
|
||||
"account",
|
||||
"channel",
|
||||
"devID",
|
||||
"password"
|
||||
],
|
||||
"requiredBody": [
|
||||
"account",
|
||||
"password"
|
||||
]
|
||||
},
|
||||
"POST auth/profile": {
|
||||
"query": [],
|
||||
"requiredQuery": [],
|
||||
"body": [
|
||||
"avatar",
|
||||
"introduction",
|
||||
"newPassword",
|
||||
"nickName",
|
||||
"oldPassword",
|
||||
"privacyMode"
|
||||
],
|
||||
"requiredBody": []
|
||||
},
|
||||
"POST auth/register": {
|
||||
"query": [],
|
||||
"requiredQuery": [],
|
||||
"body": [
|
||||
"accountNo",
|
||||
"agentInviteCode",
|
||||
"channel",
|
||||
"devID",
|
||||
"email",
|
||||
"password"
|
||||
],
|
||||
"requiredBody": [
|
||||
"accountNo",
|
||||
"email",
|
||||
"password"
|
||||
]
|
||||
},
|
||||
"POST auth/resetPassword": {
|
||||
"query": [],
|
||||
"requiredQuery": [],
|
||||
"body": [
|
||||
"accountNo",
|
||||
"email",
|
||||
"newPassword"
|
||||
],
|
||||
"requiredBody": [
|
||||
"accountNo",
|
||||
"email",
|
||||
"newPassword"
|
||||
]
|
||||
},
|
||||
"POST coin/buy": {
|
||||
"query": [],
|
||||
"requiredQuery": [],
|
||||
"body": [
|
||||
"packageId",
|
||||
"payMode"
|
||||
],
|
||||
"requiredBody": [
|
||||
"packageId",
|
||||
"payMode"
|
||||
]
|
||||
},
|
||||
"POST comment/like/set": {
|
||||
"query": [],
|
||||
"requiredQuery": [],
|
||||
"body": [
|
||||
"commentId",
|
||||
"isLike"
|
||||
],
|
||||
"requiredBody": [
|
||||
"commentId",
|
||||
"isLike"
|
||||
]
|
||||
},
|
||||
"POST comment/send": {
|
||||
"query": [],
|
||||
"requiredQuery": [],
|
||||
"body": [
|
||||
"objectId",
|
||||
"objectType",
|
||||
"parentsId",
|
||||
"replyCommentId",
|
||||
"text"
|
||||
],
|
||||
"requiredBody": []
|
||||
},
|
||||
"POST contactus/add": {
|
||||
"query": [],
|
||||
"requiredQuery": [],
|
||||
"body": [
|
||||
"attachments",
|
||||
"content",
|
||||
"email",
|
||||
"title",
|
||||
"type"
|
||||
],
|
||||
"requiredBody": [
|
||||
"content",
|
||||
"email",
|
||||
"title",
|
||||
"type"
|
||||
]
|
||||
},
|
||||
"POST feedback/add": {
|
||||
"query": [],
|
||||
"requiredQuery": [],
|
||||
"body": [
|
||||
"contact",
|
||||
"content",
|
||||
"screenshots",
|
||||
"type"
|
||||
],
|
||||
"requiredBody": [
|
||||
"content",
|
||||
"type"
|
||||
]
|
||||
},
|
||||
"POST playlet/chapter/collect/set": {
|
||||
"query": [],
|
||||
"requiredQuery": [],
|
||||
"body": [
|
||||
"chapterId",
|
||||
"isCollect"
|
||||
],
|
||||
"requiredBody": [
|
||||
"chapterId",
|
||||
"isCollect"
|
||||
]
|
||||
},
|
||||
"POST playlet/chapter/like/set": {
|
||||
"query": [],
|
||||
"requiredQuery": [],
|
||||
"body": [
|
||||
"chapterId",
|
||||
"isLike"
|
||||
],
|
||||
"requiredBody": [
|
||||
"chapterId",
|
||||
"isLike"
|
||||
]
|
||||
},
|
||||
"POST playlet/chapter/unlock": {
|
||||
"query": [],
|
||||
"requiredQuery": [],
|
||||
"body": [
|
||||
"chapterId",
|
||||
"playletId"
|
||||
],
|
||||
"requiredBody": [
|
||||
"chapterId",
|
||||
"playletId"
|
||||
]
|
||||
},
|
||||
"POST playlet/chapter/watch/report": {
|
||||
"query": [],
|
||||
"requiredQuery": [],
|
||||
"body": [
|
||||
"chapterId",
|
||||
"playletId"
|
||||
],
|
||||
"requiredBody": [
|
||||
"chapterId"
|
||||
]
|
||||
},
|
||||
"POST playlet/collect/set": {
|
||||
"query": [],
|
||||
"requiredQuery": [],
|
||||
"body": [
|
||||
"id",
|
||||
"isCollect"
|
||||
],
|
||||
"requiredBody": [
|
||||
"id",
|
||||
"isCollect"
|
||||
]
|
||||
},
|
||||
"POST playlet/forward/heat": {
|
||||
"query": [],
|
||||
"requiredQuery": [],
|
||||
"body": [
|
||||
"id"
|
||||
],
|
||||
"requiredBody": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"POST playlet/history/clear": {
|
||||
"query": [],
|
||||
"requiredQuery": [],
|
||||
"body": [],
|
||||
"requiredBody": []
|
||||
},
|
||||
"POST playlet/history/delete": {
|
||||
"query": [],
|
||||
"requiredQuery": [],
|
||||
"body": [
|
||||
"playletId"
|
||||
],
|
||||
"requiredBody": [
|
||||
"playletId"
|
||||
]
|
||||
},
|
||||
"POST playlet/like/set": {
|
||||
"query": [],
|
||||
"requiredQuery": [],
|
||||
"body": [
|
||||
"id",
|
||||
"isLike"
|
||||
],
|
||||
"requiredBody": [
|
||||
"id",
|
||||
"isLike"
|
||||
]
|
||||
},
|
||||
"POST statcenter/sync": {
|
||||
"query": [],
|
||||
"requiredQuery": [],
|
||||
"body": [
|
||||
"job",
|
||||
"maxSize",
|
||||
"platformId",
|
||||
"successTime",
|
||||
"userId"
|
||||
],
|
||||
"requiredBody": []
|
||||
},
|
||||
"POST upload/image": {
|
||||
"query": [],
|
||||
"requiredQuery": [],
|
||||
"body": [],
|
||||
"requiredBody": []
|
||||
},
|
||||
"POST video/collect/set": {
|
||||
"query": [],
|
||||
"requiredQuery": [],
|
||||
"body": [
|
||||
"id",
|
||||
"isCollect"
|
||||
],
|
||||
"requiredBody": [
|
||||
"id",
|
||||
"isCollect"
|
||||
]
|
||||
},
|
||||
"POST video/history/clear": {
|
||||
"query": [],
|
||||
"requiredQuery": [],
|
||||
"body": [],
|
||||
"requiredBody": []
|
||||
},
|
||||
"POST video/history/delete": {
|
||||
"query": [],
|
||||
"requiredQuery": [],
|
||||
"body": [
|
||||
"videoId"
|
||||
],
|
||||
"requiredBody": [
|
||||
"videoId"
|
||||
]
|
||||
},
|
||||
"POST video/like/set": {
|
||||
"query": [],
|
||||
"requiredQuery": [],
|
||||
"body": [
|
||||
"id",
|
||||
"isLike"
|
||||
],
|
||||
"requiredBody": [
|
||||
"id",
|
||||
"isLike"
|
||||
]
|
||||
},
|
||||
"POST vip/pay": {
|
||||
"query": [],
|
||||
"requiredQuery": [],
|
||||
"body": [
|
||||
"packageId",
|
||||
"payMode"
|
||||
],
|
||||
"requiredBody": [
|
||||
"packageId",
|
||||
"payMode"
|
||||
]
|
||||
}
|
||||
},
|
||||
"sourceSha256": "b146e8c8175a196adf1c17d6d16aba2503d82e21efa4b15e29c39867342d9837"
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFile} from 'node:fs/promises';
|
||||
import ts from 'typescript';
|
||||
|
||||
const code=ts.transpileModule(await readFile(new URL('../src/lib/footer-links.ts',import.meta.url),'utf8'),{compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.ESNext}}).outputText;
|
||||
const {footerPolicyLinks,footerLinkHref,footerLinkCurrent}=await import('data:text/javascript;base64,'+Buffer.from(code).toString('base64'));
|
||||
|
||||
test('footer links use rich text paths and drop unsafe addresses',()=>{
|
||||
assert.equal(footerLinkHref('/richetext/关于我们'),'/richtext/'+encodeURIComponent('关于我们')+'/');
|
||||
assert.equal(footerLinkHref('/richtext/'+encodeURIComponent('用户协议')+'/'),'/richtext/'+encodeURIComponent('用户协议')+'/');
|
||||
assert.equal(footerLinkHref('常见问题'),'/richtext/'+encodeURIComponent('常见问题')+'/');
|
||||
assert.equal(footerLinkHref('https://example.com/rules'),'https://example.com/rules');
|
||||
assert.equal(footerLinkHref('javascript:alert(1)'),'');
|
||||
assert.equal(footerLinkHref('/richtext/'),'');
|
||||
const links=footerPolicyLinks({footerInfo:{navList:[
|
||||
{id:1,name:'规则',urlValue:'/ignored/',translationList:[{id:2,name:'关于我们',urlValue:'/richetext/关于我们'},{id:3,name:'脚本',urlValue:'javascript:alert(1)'}]},
|
||||
{id:5,name:'用户协议',urlValue:'用户协议'},
|
||||
]}});
|
||||
assert.deepEqual(links.map(link=>[link.name,link.href,link.external]),[
|
||||
['关于我们','/richtext/'+encodeURIComponent('关于我们')+'/',false],
|
||||
['用户协议','/richtext/'+encodeURIComponent('用户协议')+'/',false],
|
||||
]);
|
||||
assert.equal(footerLinkCurrent('/richtext/'+encodeURIComponent('关于我们')+'/?x=1','/richtext/关于我们/'),true);
|
||||
assert.equal(footerLinkCurrent('/','/richtext/关于我们/'),false);
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
import {readFile} from 'node:fs/promises';
|
||||
import ts from 'typescript';
|
||||
|
||||
const {outputText}=ts.transpileModule(await readFile(new URL('../../src/lib/routing.ts',import.meta.url),'utf8'),{compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.ESNext}});
|
||||
export const routingModuleUrl='data:text/javascript;base64,'+Buffer.from(outputText).toString('base64');
|
||||
@@ -0,0 +1,28 @@
|
||||
import {readFile} from 'node:fs/promises';
|
||||
import ts from 'typescript';
|
||||
|
||||
const compile=async path=>ts.transpileModule(await readFile(new URL('../../'+path,import.meta.url),'utf8'),{compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.ESNext}}).outputText;
|
||||
const moduleUrl=source=>'data:text/javascript;base64,'+Buffer.from(source).toString('base64');
|
||||
const routingUrl=moduleUrl(await compile('src/lib/routing.ts'));
|
||||
const protocolUrl=moduleUrl(await compile('src/lib/api/protocol.ts'));
|
||||
const playbackUrl=moduleUrl((await compile('src/lib/client/playback.ts')).replace("'../api/protocol'",JSON.stringify(protocolUrl)));
|
||||
export const structuredUrl=moduleUrl((await compile('src/lib/structured-data.ts')).replace("'./routing'",JSON.stringify(routingUrl)).replace("'./api/protocol'",JSON.stringify(protocolUrl)));
|
||||
export const structuredData=await import(structuredUrl);
|
||||
export const seoUrl=moduleUrl((await compile('src/lib/seo.ts')).replace("'./routing'",JSON.stringify(routingUrl)).replace("'./structured-data'",JSON.stringify(structuredUrl)).replace("'./client/playback'",JSON.stringify(playbackUrl)));
|
||||
export const seo=await import(seoUrl);
|
||||
export const clientSeo=await import(moduleUrl((await compile('src/lib/client/seo.ts')).replace("'../structured-data'",JSON.stringify(structuredUrl))));
|
||||
|
||||
export function headDocument(){
|
||||
const nodes=[];
|
||||
const doc={title:'',head:{
|
||||
querySelectorAll(selector){
|
||||
const match=selector.match(/^(meta|link|script)\[([\w-]+)(?:="([^"]+)")?\]$/);
|
||||
return match?nodes.filter(node=>node.tagName===match[1]&&(match[3]===undefined?Object.hasOwn(node.attributes,match[2]):node.attributes[match[2]]===match[3])):[];
|
||||
},
|
||||
appendChild(node){nodes.push(node);},
|
||||
},createElement(tagName){return {tagName,attributes:{},setAttribute(name,value){this.attributes[name]=value;},remove(){const i=nodes.indexOf(this);if(i>=0)nodes.splice(i,1);}};}};
|
||||
doc.value=selector=>doc.head.querySelectorAll(selector)[0]?.attributes[selector.startsWith('link')?'href':'content'];
|
||||
doc.querySelectorAll=selector=>doc.head.querySelectorAll(selector);
|
||||
doc.nodes=nodes;
|
||||
return doc;
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFile} from 'node:fs/promises';
|
||||
import vm from 'node:vm';
|
||||
import ts from 'typescript';
|
||||
import {seo} from './helpers/seo.mjs';
|
||||
|
||||
const sourceUrl=new URL('../src/lib/client/application.ts',import.meta.url);
|
||||
const {outputText}=ts.transpileModule(await readFile(sourceUrl,'utf8'),{compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.CommonJS}});
|
||||
const flush=()=>new Promise(resolve=>setImmediate(resolve));
|
||||
|
||||
function fixture({reducedMotion=false,load=async()=>{},pathname='/missing/',hidden=false,bannerCount=3}={}){
|
||||
const animations=[],overlays=[],nodes=new Map(),timers=new Map();let timerId=0;
|
||||
const target={isConnected:true,current:0,replaceChildren(frame){this.current=frame.selected;},append(node){overlays.push(node);},cloneNode(){
|
||||
return {current:this.current,inert:false,classList:{add(){}},removeAttribute(){},setAttribute(){},querySelectorAll:()=>[],
|
||||
remove(){const index=overlays.indexOf(this);if(index>=0)overlays.splice(index,1);},
|
||||
animate(frames,options){
|
||||
let finish,reject;
|
||||
const animation={frames,options,finished:new Promise((done,fail)=>{finish=done;reject=fail;}),finish:()=>finish(),cancel(){reject(new Error('cancelled'));}};
|
||||
animations.push(animation);return animation;
|
||||
}};
|
||||
}};
|
||||
const document={hidden,querySelector(selector){
|
||||
if(selector==='#hero-feature-carousel')return target;
|
||||
if(!nodes.has(selector))nodes.set(selector,{});return nodes.get(selector);
|
||||
},querySelectorAll:()=>[],createElement(){
|
||||
return {set innerHTML(value){this.selected=Number(value);this.childNodes=[{selected:this.selected}];},querySelector(){
|
||||
const source='image-'+this.selected;
|
||||
return {getAttribute:()=>source,decode:async()=>{}};
|
||||
}};
|
||||
}};
|
||||
const exports={};
|
||||
const require=specifier=>{
|
||||
if(specifier==='./api')return {ApiClient:class {}};
|
||||
if(specifier==='./render')return {activeBanners:home=>home.homeBannerData,hero:(_,selected)=>String(selected),empty:()=>'',escape:String};
|
||||
if(specifier==='./images')return {processImage:load};
|
||||
if(specifier==='../routing')return {parseSitePath:()=>null,topNavigationSection:()=>''};
|
||||
if(specifier==='../seo')return seo;
|
||||
if(specifier==='./seo')return {applyPageHead:head=>{document.title=head.title;}};
|
||||
return {};
|
||||
};
|
||||
const location={pathname,search:''};
|
||||
vm.runInNewContext(outputText,{exports,require,AbortController,document,window:{matchMedia:()=>({matches:reducedMotion}),scrollTo(){}},location,
|
||||
setInterval(callback,delay){const id=++timerId;timers.set(id,{callback,delay});return id;},clearInterval(id){timers.delete(id);}
|
||||
},{filename:sourceUrl.pathname});
|
||||
const app=new exports.Application({homeBannerData:Array.from({length:bannerCount},(_,index)=>({id:index+1}))});
|
||||
return {app,target,animations,overlays,timers,document,location,tick:()=>timers.values().next().value.callback()};
|
||||
}
|
||||
|
||||
test('keeps the old frame until its replacement image is ready, then fades it out and removes it',async()=>{
|
||||
let ready;
|
||||
const f=fixture({load:()=>new Promise(resolve=>ready=resolve)});
|
||||
const changing=f.app.selectHero(1);
|
||||
assert.equal(f.target.current,0);assert.equal(f.overlays.length,0);
|
||||
ready();await changing;
|
||||
assert.equal(f.target.current,1);assert.equal(f.overlays[0].current,0);assert.equal(f.overlays[0].inert,true);
|
||||
assert.equal(f.animations[0].options.duration,500);
|
||||
assert.deepEqual(Array.from(f.animations[0].frames,frame=>frame.opacity),[1,0]);
|
||||
f.animations[0].finish();await flush();assert.equal(f.overlays.length,0);
|
||||
});
|
||||
|
||||
test('rapid switches keep the latest request and replace any unfinished fade without stacking snapshots',async()=>{
|
||||
const pending=new Map();
|
||||
const f=fixture({load:source=>new Promise(resolve=>pending.set(source,resolve))});
|
||||
const first=f.app.selectHero(1),second=f.app.selectHero(2);
|
||||
pending.get('image-2')();await second;assert.equal(f.target.current,2);
|
||||
pending.get('image-1')();await first;assert.equal(f.target.current,2);assert.equal(f.overlays.length,1);
|
||||
const third=f.app.selectHero(0);pending.get('image-0')();await third;await flush();
|
||||
assert.equal(f.target.current,0);assert.equal(f.overlays.length,1);assert.equal(f.overlays[0].current,2);
|
||||
f.animations.at(-1).finish();await flush();assert.equal(f.overlays.length,0);
|
||||
});
|
||||
|
||||
test('document disposal cancels pending image commits and clean up a running fade',async()=>{
|
||||
let ready;
|
||||
const f=fixture({load:()=>new Promise(resolve=>ready=resolve)});
|
||||
const changing=f.app.selectHero(1);f.app.disposePage();ready();await changing;
|
||||
assert.equal(f.target.current,0);assert.equal(f.animations.length,0);assert.equal(f.app.feature,0);
|
||||
const running=fixture();await running.app.selectHero(1);assert.equal(running.overlays.length,1);
|
||||
running.app.disposePage();await flush();assert.equal(running.overlays.length,0);
|
||||
});
|
||||
|
||||
test('reduced motion switches directly, and selecting the current slide leaves the frame intact',async()=>{
|
||||
const f=fixture({reducedMotion:true});
|
||||
await f.app.selectHero(0);assert.equal(f.target.current,0);assert.equal(f.animations.length,0);
|
||||
await f.app.selectHero(1);assert.equal(f.target.current,1);assert.equal(f.animations.length,0);assert.equal(f.overlays.length,0);
|
||||
});
|
||||
|
||||
test('autoplay switches every three seconds, wraps around, and restarts the wait after manual selection',async()=>{
|
||||
const f=fixture({pathname:'/'});f.app.restartHeroAutoplay();
|
||||
assert.equal(f.timers.size,1);assert.equal(f.timers.values().next().value.delay,3000);
|
||||
for(const expected of [1,2,0]){await f.tick();assert.equal(f.target.current,expected);assert.equal(f.timers.size,1);}
|
||||
const previousTimer=[...f.timers.keys()][0];
|
||||
await f.app.selectHero(2);assert.equal(f.target.current,2);
|
||||
assert.notEqual([...f.timers.keys()][0],previousTimer);
|
||||
await f.tick();assert.equal(f.target.current,0);
|
||||
f.app.disposePage();assert.equal(f.timers.size,0);await flush();
|
||||
});
|
||||
|
||||
test('slow artwork keeps the current slide visible without skipping requests, then grants the new slide three seconds',async()=>{
|
||||
const pending=new Map();
|
||||
const f=fixture({pathname:'/',load:source=>new Promise(resolve=>pending.set(source,resolve))});
|
||||
f.app.restartHeroAutoplay();const previousTimer=[...f.timers.keys()][0];
|
||||
const changing=f.tick();await f.tick();await f.tick();
|
||||
assert.deepEqual([...pending.keys()],['image-1']);assert.equal(f.target.current,0);
|
||||
pending.get('image-1')();await changing;
|
||||
assert.equal(f.target.current,1);assert.notEqual([...f.timers.keys()][0],previousTimer);
|
||||
assert.equal(f.timers.values().next().value.delay,3000);
|
||||
const manual=f.app.selectHero(0);await f.tick();
|
||||
assert.deepEqual([...pending.keys()],['image-1','image-0']);
|
||||
pending.get('image-0')();await manual;assert.equal(f.target.current,0);
|
||||
f.app.disposePage();await flush();
|
||||
});
|
||||
|
||||
test('autoplay stops in the background, on other pages, with fewer than two slides, and after disposal',()=>{
|
||||
for(const options of [{pathname:'/shorts/'},{pathname:'/',hidden:true},{pathname:'/',bannerCount:1},{pathname:'/',bannerCount:0}]){
|
||||
const f=fixture(options);f.app.restartHeroAutoplay();assert.equal(f.timers.size,0);
|
||||
}
|
||||
const f=fixture({pathname:'/'});f.app.restartHeroAutoplay();assert.equal(f.timers.size,1);
|
||||
f.document.hidden=true;f.app.restartHeroAutoplay();assert.equal(f.timers.size,0);
|
||||
f.document.hidden=false;f.app.restartHeroAutoplay();assert.equal(f.timers.size,1);
|
||||
f.app.disposePage();f.app.restartHeroAutoplay();assert.equal(f.timers.size,0);
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFile} from 'node:fs/promises';
|
||||
import ts from 'typescript';
|
||||
|
||||
const sourceUrl=new URL('../src/lib/client/hero-swipe.ts',import.meta.url);
|
||||
const {outputText}=ts.transpileModule(await readFile(sourceUrl,'utf8'),{compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.ESNext}});
|
||||
const {bindHeroSwipe}=await import('data:text/javascript;base64,'+Buffer.from(outputText+'\n//# sourceURL='+sourceUrl.pathname).toString('base64'));
|
||||
|
||||
function fixture(){
|
||||
const view=new EventTarget(),document=new EventTarget(),root=new EventTarget(),controller=new AbortController();
|
||||
const classes=new Set();
|
||||
const hero={classList:{add:name=>classes.add(name),remove:name=>classes.delete(name)}};
|
||||
document.defaultView=view;document.hidden=false;root.ownerDocument=document;
|
||||
root.closest=selector=>root.inHero&&(selector==='#hero'||selector==='#hero-feature-carousel'&&root.inCarousel)?root.hero:null;
|
||||
root.inHero=true;root.inCarousel=true;root.hero=hero;
|
||||
const state={steps:[],starts:0,ends:0,enabled:true,paused:false};
|
||||
const options={enabled:()=>state.enabled,onStart(){state.starts++;state.paused=true;},onStep:step=>state.steps.push(step),onEnd(){state.ends++;state.paused=false;}};
|
||||
bindHeroSwipe(root,options,controller.signal);
|
||||
function send(target,type,properties={}){
|
||||
const event=new Event(type,{cancelable:true});
|
||||
for(const [key,value] of Object.entries({pointerId:1,isPrimary:true,button:0,clientX:150,clientY:100,pointerType:'touch',detail:1,...properties}))Object.defineProperty(event,key,{value});
|
||||
target.dispatchEvent(event);return event;
|
||||
}
|
||||
return {root,view,document,controller,options,state,classes,send,
|
||||
down:properties=>send(root,'pointerdown',properties),move:properties=>send(view,'pointermove',properties),up:properties=>send(view,'pointerup',properties),click:properties=>send(root,'click',properties)};
|
||||
}
|
||||
|
||||
test('touch and mouse drags switch in both directions and pause autoplay until release',()=>{
|
||||
for(const pointerType of ['touch','mouse']){
|
||||
const f=fixture();
|
||||
f.down({pointerType});assert.equal(f.state.paused,true);
|
||||
const move=f.move({pointerType,clientX:50});assert.equal(move.defaultPrevented,true);assert.ok(f.classes.has('is-swiping'));
|
||||
f.up({pointerType,clientX:50});assert.deepEqual(f.state.steps,[1]);assert.equal(f.state.paused,false);assert.equal(f.classes.size,0);
|
||||
f.down({pointerType});f.move({pointerType,clientX:250});f.up({pointerType,clientX:250});
|
||||
assert.deepEqual(f.state.steps,[1,-1]);assert.equal(f.state.starts,2);assert.equal(f.state.ends,2);
|
||||
f.controller.abort();
|
||||
}
|
||||
});
|
||||
|
||||
test('vertical scrolling, diagonal scrolling, taps, and short gestures do not switch slides',()=>{
|
||||
const f=fixture();
|
||||
for(const [clientX,clientY] of [[160,220],[210,170]]){
|
||||
f.down();assert.equal(f.move({clientX,clientY}).defaultPrevented,false);
|
||||
f.up({clientX:400,clientY:100});assert.equal(f.state.paused,false);
|
||||
}
|
||||
f.down();f.up();assert.equal(f.click().defaultPrevented,false);
|
||||
f.down();f.move({clientX:120});f.up({clientX:120});
|
||||
assert.deepEqual(f.state.steps,[]);assert.equal(f.state.starts,f.state.ends);
|
||||
f.controller.abort();
|
||||
});
|
||||
|
||||
test('dragging the adjacent recommendation cards does not pause or switch the carousel',()=>{
|
||||
const f=fixture();f.root.inCarousel=false;
|
||||
f.down();f.move({clientX:50});f.up({clientX:50});
|
||||
assert.deepEqual(f.state.steps,[]);assert.equal(f.state.starts,0);assert.equal(f.click().defaultPrevented,false);
|
||||
f.controller.abort();
|
||||
});
|
||||
|
||||
test('swipes suppress the follow-up click without blocking keyboard activation or the next tap',()=>{
|
||||
const f=fixture();let activated=0;
|
||||
f.root.addEventListener('click',()=>activated++);
|
||||
f.down();f.move({clientX:50});f.up({clientX:50});
|
||||
assert.equal(f.click({detail:0}).defaultPrevented,false);assert.equal(activated,1);
|
||||
assert.equal(f.click().defaultPrevented,true);assert.equal(activated,1);
|
||||
f.down();f.up();assert.equal(f.click().defaultPrevented,false);assert.equal(activated,2);
|
||||
f.down();assert.equal(f.send(f.root,'dragstart').defaultPrevented,true);
|
||||
f.up();assert.equal(f.send(f.root,'dragstart').defaultPrevented,false);
|
||||
f.controller.abort();
|
||||
});
|
||||
|
||||
test('cancelled pointers, multitouch, backgrounding, and lost window focus end gestures safely',()=>{
|
||||
const f=fixture();
|
||||
const cancel=[()=>f.send(f.view,'pointercancel'),()=>f.down({pointerId:2,isPrimary:false}),()=>{f.document.hidden=true;f.send(f.document,'visibilitychange');},()=>f.send(f.view,'blur')];
|
||||
for(const action of cancel){
|
||||
f.document.hidden=false;f.down();f.move({clientX:80});action();f.up({clientX:20});
|
||||
assert.equal(f.state.paused,false);assert.equal(f.classes.size,0);
|
||||
}
|
||||
assert.deepEqual(f.state.steps,[]);assert.equal(f.state.starts,f.state.ends);
|
||||
f.controller.abort();
|
||||
});
|
||||
|
||||
test('delegated gestures respect availability and replacement content, and route abort removes handlers',()=>{
|
||||
const f=fixture();
|
||||
f.state.enabled=false;f.down();f.state.enabled=true;
|
||||
f.root.inHero=false;f.down();f.root.inHero=true;
|
||||
f.down({button:2,pointerType:'mouse'});f.down({isPrimary:false});assert.equal(f.state.starts,0);
|
||||
f.root.hero={classList:{add(){},remove(){}}};
|
||||
f.down();f.move({clientX:50});f.up({clientX:50});assert.deepEqual(f.state.steps,[1]);
|
||||
f.down();f.move({clientX:50});f.controller.abort();assert.equal(f.state.paused,false);
|
||||
f.up({clientX:50});f.down();assert.equal(f.click().defaultPrevented,false);assert.deepEqual(f.state.steps,[1]);
|
||||
const next=new AbortController();bindHeroSwipe(f.root,f.options,next.signal);
|
||||
f.down();f.move({clientX:250});f.up({clientX:250});assert.deepEqual(f.state.steps,[1,-1]);next.abort();
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
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;}}
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {mkdtemp,mkdir,writeFile,readFile,rm,symlink,cp,realpath} from 'node:fs/promises';
|
||||
import {join} from 'node:path';
|
||||
import {tmpdir} from 'node:os';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import {checkLockSync,packageLockVersions,pnpmLockVersions,LOCKED_RUNTIME} from '../scripts/check-lock-sync.mjs';
|
||||
|
||||
const projectRoot=fileURLToPath(new URL('../',import.meta.url));
|
||||
|
||||
test('repository npm and pnpm locks agree with the installed runtime packages',()=>{
|
||||
const result=checkLockSync(projectRoot);
|
||||
assert.equal(result.expected.next,result.installed.next);
|
||||
assert.ok(result.pnpm);
|
||||
assert.equal(result.pnpm.next,result.expected.next);
|
||||
for(const name of LOCKED_RUNTIME)assert.match(result.expected[name],/^\d+\.\d+\.\d+/);
|
||||
});
|
||||
|
||||
test('lock sync rejects an installed next that drifts from package-lock.json',async t=>{
|
||||
const root=await mkdtemp(join(tmpdir(),'91-lock-sync-'));t.after(()=>rm(root,{recursive:true,force:true}));
|
||||
await writeFile(join(root,'package.json'),JSON.stringify({name:'fixture',type:'module',dependencies:{next:'16.4.3'}}));
|
||||
await writeFile(join(root,'package-lock.json'),JSON.stringify({
|
||||
lockfileVersion:3,
|
||||
packages:{
|
||||
'node_modules/next':{version:'16.4.3'},
|
||||
'node_modules/react':{version:'19.3.1'},
|
||||
'node_modules/react-dom':{version:'19.3.1'},
|
||||
'node_modules/dotenv':{version:'17.2.3'},
|
||||
'node_modules/hls.js':{version:'1.7.3'},
|
||||
'node_modules/qrcode':{version:'1.5.4'},
|
||||
},
|
||||
}));
|
||||
for(const [name,version] of Object.entries(packageLockVersions(root))){
|
||||
const dir=join(root,'node_modules',name);
|
||||
await mkdir(dir,{recursive:true});
|
||||
await writeFile(join(dir,'package.json'),JSON.stringify({name,version:name==='next'?'16.4.2':version}));
|
||||
}
|
||||
assert.throws(()=>checkLockSync(root),/已安装 16\.4\.2[\s\S]*package-lock\.json 为 16\.4\.3/);
|
||||
});
|
||||
|
||||
test('lock sync rejects pnpm-lock next that drifts from package-lock.json',async t=>{
|
||||
const root=await mkdtemp(join(tmpdir(),'91-lock-pnpm-'));t.after(()=>rm(root,{recursive:true,force:true}));
|
||||
const expected=packageLockVersions(projectRoot);
|
||||
await writeFile(join(root,'package.json'),JSON.stringify({name:'fixture',type:'module'}));
|
||||
await writeFile(join(root,'package-lock.json'),JSON.stringify({
|
||||
lockfileVersion:3,
|
||||
packages:Object.fromEntries(LOCKED_RUNTIME.map(name=>[`node_modules/${name}`,{version:expected[name]}])),
|
||||
}));
|
||||
for(const name of LOCKED_RUNTIME){
|
||||
const dir=join(root,'node_modules',name);
|
||||
await mkdir(dir,{recursive:true});
|
||||
await writeFile(join(dir,'package.json'),JSON.stringify({name,version:expected[name]}));
|
||||
}
|
||||
const pnpm=[
|
||||
'lockfileVersion: \'9.0\'','','importers:','', ' .:',' dependencies:',
|
||||
...LOCKED_RUNTIME.flatMap(name=>[
|
||||
` ${name}:`,
|
||||
` specifier: ${expected[name]}`,
|
||||
` version: ${name==='next'?'16.0.0':expected[name]}`,
|
||||
]),
|
||||
'',
|
||||
].join('\n');
|
||||
await writeFile(join(root,'pnpm-lock.yaml'),pnpm);
|
||||
assert.equal(pnpmLockVersions(root).next,'16.0.0');
|
||||
assert.throws(()=>checkLockSync(root),/pnpm-lock\.yaml 为 16\.0\.0/);
|
||||
});
|
||||
|
||||
test('dotenv copy source resolves under both real directories and pnpm links',async t=>{
|
||||
const root=await mkdtemp(join(tmpdir(),'91-dotenv-'));t.after(()=>rm(root,{recursive:true,force:true}));
|
||||
const real=join(root,'.store/dotenv');
|
||||
await mkdir(real,{recursive:true});
|
||||
await writeFile(join(real,'package.json'),JSON.stringify({name:'dotenv',version:'17.2.3'}));
|
||||
await mkdir(join(root,'node_modules'),{recursive:true});
|
||||
await symlink(real,join(root,'node_modules/dotenv'));
|
||||
const destination=join(root,'out/node_modules/dotenv');
|
||||
await mkdir(join(root,'out/node_modules'),{recursive:true});
|
||||
await cp(await realpath(join(root,'node_modules/dotenv')),destination,{recursive:true,dereference:true});
|
||||
assert.equal(JSON.parse(await readFile(join(destination,'package.json'),'utf8')).version,'17.2.3');
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFile} from 'node:fs/promises';
|
||||
import ts from 'typescript';
|
||||
|
||||
async function moduleUrl(file,replace=code=>code){
|
||||
const source=await readFile(new URL(file,import.meta.url),'utf8');
|
||||
return 'data:text/javascript;base64,'+Buffer.from(replace(ts.transpileModule(source,{compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.ESNext}}).outputText)).toString('base64');
|
||||
}
|
||||
const protocol=await moduleUrl('../src/lib/api/protocol.ts');
|
||||
const {navigationPageData}=await import(await moduleUrl('../src/lib/navigation-page.ts',code=>code.replace("'./api/protocol'",JSON.stringify(protocol))));
|
||||
|
||||
test('navigation renders configured lines in order without duplicating positional fallbacks or invalid URLs',()=>{
|
||||
const config={navPageList:[{line:2,url:'https://two.test/path'},{line:1,url:'javascript:alert(1)'},{line:1,url:'https://one.test'},{line:1,url:'https://duplicate.test'},{line:3,url:''},{line:0,url:'https://invalid.test'}]};
|
||||
assert.deepEqual(navigationPageData(config).routes,[{line:1,href:'https://one.test/',label:'线路一'},{line:2,href:'https://two.test/path',label:'线路二'}]);
|
||||
});
|
||||
|
||||
test('legacy navigation fields and address sections supply valid configured destinations only',()=>{
|
||||
const data=navigationPageData({config:{dhNewUrlAddr1:'https://nav-one.test',dhNewUrlAddr2:'javascript:bad',newUrlAddr1:'https://backup.test',foreverUrlAddr:''},newAddressInfo:{foreverUrlAddr:'https://permanent.test/entry',dhNewUrlAddr2:'https://nav-two.test'}});
|
||||
assert.deepEqual(data.routes.map(route=>route.href),['https://nav-one.test/','https://nav-two.test/']);
|
||||
assert.equal(data.permanent,'https://permanent.test/entry');
|
||||
const empty=navigationPageData({navPageList:null});
|
||||
assert.deepEqual(empty.routes,[]);for(const key of ['permanent','telegram','tgGroup','twitter'])assert.equal(empty[key],'');
|
||||
});
|
||||
|
||||
test('navigation identity and contacts use configuration, support TG handles, and omit demo accounts',()=>{
|
||||
const data=navigationPageData({seoNavInfo:JSON.stringify({dhSiteName:'配置导航站',dhDescription:'配置说明'}),config:{telegram:'javascript:bad',telegramAdd:'@business_91',tgGroup:'',twitterAddr:'data:text/html,bad'},newAddressInfo:{tgGroup:'@group_91',twitterAddr:'https://x.com/configured'}});
|
||||
assert.equal(data.siteName,'配置导航站');assert.equal(data.description,'配置说明');
|
||||
assert.equal(data.telegram,'https://t.me/business_91');assert.equal(data.tgGroup,'https://t.me/group_91');assert.equal(data.twitter,'https://x.com/configured');
|
||||
assert.equal(navigationPageData({config:{dhSiteName:'配置名称'}}).siteName,'配置名称');
|
||||
assert.equal(navigationPageData({seoInfo:{webSiteName:'站点名称'}}).siteName,'站点名称');
|
||||
});
|
||||
@@ -0,0 +1,220 @@
|
||||
import {routingModuleUrl} from './helpers/routing.mjs';
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {File as NodeFile} from 'node:buffer';
|
||||
import {readFile} from 'node:fs/promises';
|
||||
import ts from 'typescript';
|
||||
|
||||
if(typeof globalThis.File==='undefined')globalThis.File=NodeFile;
|
||||
|
||||
async function moduleUrl(file,replace=code=>code){
|
||||
const filename=new URL(file,import.meta.url);
|
||||
const {outputText}=ts.transpileModule(await readFile(filename,'utf8'),{compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.ESNext}});
|
||||
return 'data:text/javascript;base64,'+Buffer.from(replace(outputText).replace(/(['"])\.\.\/routing\1/g,JSON.stringify(routingModuleUrl))+'\n//# sourceURL='+filename.pathname).toString('base64');
|
||||
}
|
||||
const protocolUrl=await moduleUrl('../src/lib/api/protocol.ts');
|
||||
const renderUrl=await moduleUrl('../src/lib/client/render.ts',code=>code.replace('"../api/protocol"',JSON.stringify(protocolUrl)));
|
||||
const authUrl=await moduleUrl('../src/lib/client/auth.ts');
|
||||
const trackingUrl=await moduleUrl('../src/lib/tracking.ts');
|
||||
const accountUrl=await moduleUrl('../src/lib/client/account.ts',code=>code.replace('"../api/protocol"',JSON.stringify(protocolUrl)).replace('"./render"',JSON.stringify(renderUrl)).replace("'./auth'",JSON.stringify(authUrl)).replace("'../tracking'",JSON.stringify(trackingUrl)));
|
||||
const feedbackUrl=await moduleUrl('../src/lib/client/feedback.ts',code=>code.replace('"./render"',JSON.stringify(renderUrl)));
|
||||
const {card}=await import(renderUrl);
|
||||
const {accountMenuMarkup,settingsMarkup,memberMarkup,renderMember}=await import(accountUrl);
|
||||
const {feedbackPayload,feedbackMarkup,renderFeedbackPage}=await import(feedbackUrl);
|
||||
const flush=()=>new Promise(resolve=>setImmediate(resolve));
|
||||
|
||||
test('ranking cards retain original poster frames and global positions, and search highlights only escaped titles',()=>{
|
||||
const drama={id:7,title:'测试 <短剧>',cover:'https://cdn.example/cover.webp',totalEpisode:10};
|
||||
const ranked=card(drama,false,{rank:25});
|
||||
assert.ok(ranked.includes('class="ranking-poster-frame"'));assert.ok(ranked.includes('data-rank="25"'));assert.ok(ranked.includes('第 25 名'));assert.ok(ranked.includes('>25</span>'));
|
||||
assert.ok(ranked.includes('href="/detail/7/"'));assert.ok(ranked.includes('data-src="https://cdn.example/cover.webp"'));
|
||||
assert.ok(card(drama,false,{rank:1}).includes('>01</span>'));
|
||||
assert.ok(!card(drama,false,{rank:-1}).includes('rank-home-badge'));
|
||||
const search=card(drama,false,{keyword:'<短剧>'});
|
||||
assert.ok(search.includes('<span class="search-keyword"><短剧></span>'));assert.ok(!search.includes('<短剧>'));
|
||||
assert.ok(search.includes('aria-label="查看测试 <短剧>"'));
|
||||
});
|
||||
|
||||
test('personal pages use actual library data and route links inside the original summary and tabs',async()=>{
|
||||
const oldDocument=globalThis.document,root={innerHTML:''},calls=[];
|
||||
const user={userId:88,nickName:'真实昵称 <script>',recharge:2400,avatar:'https://cdn.example/avatar.webp'};
|
||||
const app={signal:new AbortController().signal,home:{},api:{ensureSession:async()=>user,get:async(path,query)=>{calls.push({path,query});return {list:[{id:7,title:'已看过的短剧'}],totalCount:72};}}};
|
||||
globalThis.document={getElementById:()=>root};
|
||||
try{
|
||||
await renderMember(app,'center',1,()=>'/member/center/');
|
||||
assert.equal(calls.at(-1).path,'playlet/history/list');
|
||||
for(const className of ['member-name-row','member-settings-link','member-wallet','coin-soft-star','member-library-tabs','member-library-toolbar','member-library-cards','member-library-item'])assert.ok(root.innerHTML.includes(className),className);
|
||||
assert.ok(root.innerHTML.includes('真实昵称 <script>'));assert.ok(root.innerHTML.includes('2,400'));assert.ok(root.innerHTML.includes('ID:88'));
|
||||
assert.ok(root.innerHTML.includes('href="/member/settings/"'));assert.ok(root.innerHTML.includes('href="/member/history/page/2/"'));assert.ok(!root.innerHTML.includes('/member/center/page/'));
|
||||
assert.ok(root.innerHTML.includes('href="/member/favorites/"'));assert.ok(root.innerHTML.includes('href="/member/likes/"'));assert.ok(root.innerHTML.includes('href="/member/purchased/"'));
|
||||
await renderMember(app,'favorites',2,page=>`/member/favorites/page/${page}/`);
|
||||
assert.equal(calls.at(-1).path,'playlet/collect/list');assert.equal(calls.at(-1).query.pageNum,2);
|
||||
assert.ok(root.innerHTML.includes('href="/member/favorites/" aria-current="page"'));
|
||||
assert.ok(!root.innerHTML.includes('吴秀英'));assert.ok(!root.innerHTML.includes('1,280'));
|
||||
const icons=Array.from({length:18},(_,index)=>({id:`icon-${index}`,title:`图标${String(index).padStart(2,'0')}`,images:`https://cdn.example/${index}.png`,link:`https://example.com/${index}`}));
|
||||
const marked=memberMarkup(user,'center',{list:[],totalCount:0},{advitise:{nineGridList:[null,{id:'bad',title:'坏图',images:'javascript:alert(1)'},...icons]}});
|
||||
assert.equal((marked.match(/class="member-ad-tile"/g)||[]).length,16);
|
||||
assert.ok(marked.indexOf('member-ad-grid')<marked.indexOf('member-summary'));
|
||||
assert.ok(marked.includes('图标00'));assert.ok(marked.includes('图标15'));
|
||||
assert.ok(!marked.includes('图标16'));assert.ok(!marked.includes('javascript:'));
|
||||
assert.ok(marked.includes('data-ad-type="icon"'));assert.ok(marked.includes('width="76"'));
|
||||
assert.ok(!memberMarkup(user,'center',{}).includes('member-ad-grid'));
|
||||
assert.ok(!memberMarkup(user,'settings',{},{advitise:{nineGridList:icons}}).includes('member-ad-tile'));
|
||||
}finally{globalThis.document=oldDocument;}
|
||||
});
|
||||
|
||||
test('likes use the protected list endpoint, native pagination and the reference library markup',async()=>{
|
||||
const previous=globalThis.document,root={innerHTML:''},calls=[];
|
||||
const user={userId:8,nickName:'点赞用户'};
|
||||
const app={signal:new AbortController().signal,home:{},api:{ensureSession:async()=>user,get:async(path,query)=>{
|
||||
calls.push({path,query});return {list:[{id:7,slug:'liked-story',title:'点赞 <短剧>',totalEpisode:8}],totalCount:72};
|
||||
}}};
|
||||
globalThis.document={getElementById:()=>root};
|
||||
try{
|
||||
await renderMember(app,'likes',2,page=>page===1?'/member/likes/':`/member/likes/page/${page}/`);
|
||||
assert.deepEqual(calls.at(-1),{path:'playlet/like/list',query:{pageNum:2,pageSize:24}});
|
||||
assert.ok(root.innerHTML.includes('href="/member/likes/" aria-current="page"><h2 id="member-library-title"'));
|
||||
assert.ok(root.innerHTML.includes('共 72 部短剧'));assert.ok(root.innerHTML.includes('href="/member/likes/page/3/"'));
|
||||
assert.ok(root.innerHTML.includes('href="/detail/liked-story/"'));assert.ok(root.innerHTML.includes('点赞 <短剧>'));
|
||||
for(const tab of ['history','favorites','likes','purchased'])assert.ok(root.innerHTML.includes(`href="/member/${tab}/"`));
|
||||
const initial={user,list:{list:[],totalCount:0}};calls.length=0;
|
||||
await renderMember(app,'likes',1,()=>'/member/likes/',initial);
|
||||
assert.equal(calls.length,0,'SSR data must be reused without a second list request');
|
||||
assert.ok(root.innerHTML.includes('member-library-empty'));assert.ok(root.innerHTML.includes('暂无点赞的短剧'));
|
||||
assert.ok(root.innerHTML.includes('href="/shorts/">去发现好剧</a>'));
|
||||
assert.ok(!memberMarkup(user,'likes',{list:null,totalCount:0}).includes('undefined'));
|
||||
}finally{globalThis.document=previous;}
|
||||
});
|
||||
|
||||
test('the account menu exposes a native likes entry for guests and registered users',()=>{
|
||||
for(const user of [null,{userId:8,nickName:'用户 <script>',isRegister:true}]){
|
||||
const html=accountMenuMarkup(user);
|
||||
assert.ok(html.includes('href="/member/likes/"'));
|
||||
assert.ok(html.includes('我的点赞'));
|
||||
assert.ok(html.includes('class="profile-row"'));
|
||||
assert.ok(html.includes('account-collection-icon'));
|
||||
assert.ok(html.includes('data-auth-mode="login"'));
|
||||
assert.ok(html.includes('data-auth-mode="bind"'));
|
||||
assert.ok(html.includes('class="profile-switch"'));
|
||||
assert.ok(html.indexOf('/member/favorites/')<html.indexOf('/member/likes/'));
|
||||
assert.ok(html.indexOf('/member/likes/')<html.indexOf('/member/history/'));
|
||||
assert.ok(html.indexOf('/member/history/')<html.indexOf('/info/deposit/'));
|
||||
assert.ok(html.indexOf('/info/deposit/')<html.indexOf('data-auth-mode="switch"'));
|
||||
assert.ok(!html.includes('已购剧集'));
|
||||
assert.ok(!html.includes('账号设置'));
|
||||
assert.ok(!html.includes('退出登录'));
|
||||
assert.ok(!html.includes('data-logout'));
|
||||
assert.ok(!html.includes('<script>'));
|
||||
if(user)assert.ok(html.includes('用户 <script>'));
|
||||
}
|
||||
const counted=accountMenuMarkup({userId:8,nickName:'甲',recharge:1280},{collectCount:3,likeCount:5});
|
||||
assert.ok(counted.includes('id="profile-saved-count">3'));
|
||||
assert.ok(counted.includes('id="profile-likes-count">5'));
|
||||
assert.ok(!counted.includes('profile-watch-count'));
|
||||
assert.ok(!counted.includes('profile-bought-count'));
|
||||
assert.ok(counted.includes('id="profile-coin-count">1,280')||counted.includes('id="profile-coin-count">1280'));
|
||||
assert.ok(!counted.includes('data-logout'));
|
||||
});
|
||||
|
||||
test('settings preserve actual profile fields and auth actions inside the reference cards',()=>{
|
||||
const html=settingsMarkup({userId:8,nickName:'用户 <script>',introduction:'简介 <img>',avatar:'https://cdn.example/avatar.webp',isRegister:true,hasPassword:true});
|
||||
for(const text of ['member-settings-card','member-nickname-editor','id="member-nickname-form"','账号与安全','data-member-settings="bind"','data-auth-mode="register"','data-member-settings="switch"','data-auth-mode="switch"','ui-chevron-right','maxlength="20"','支持 1–20 个字符'])assert.ok(html.includes(text),text);
|
||||
assert.ok(html.includes('value="用户 <script>"'));assert.ok(html.includes('data-bind-state>已绑定'));
|
||||
assert.ok(settingsMarkup({userId:9,nickName:'游客',type:0,isRegister:false,hasPassword:false}).includes('data-bind-state>未绑定'));
|
||||
assert.ok(settingsMarkup({userId:10,nickName:'会员',type:1}).includes('data-bind-state>已绑定'));
|
||||
assert.ok(!html.includes('个人简介'));assert.ok(!html.includes('data-logout'));
|
||||
assert.ok(!html.includes('修改密码'));assert.ok(!html.includes('id="security-form"'));assert.ok(!html.includes('保存密码'));
|
||||
assert.ok(html.includes('class="member-settings-avatar"'));assert.ok(html.includes('alt="当前头像"'));assert.ok(html.includes('data-src="https://cdn.example/avatar.webp"'));
|
||||
assert.ok(!html.includes('更换头像'));assert.ok(!html.includes('member-avatar-file'));assert.ok(!html.includes('data-avatar-action'));
|
||||
});
|
||||
|
||||
test('feedback options map to the real API type and keep environment details with actual attachments',()=>{
|
||||
const html=feedbackMarkup();
|
||||
for(const text of ['feedback-reference','feedback-options','feedback-panel','feedback-description-wrap','feedback-upload-box','feedback-submit-button'])assert.ok(html.includes(text),text);
|
||||
assert.ok(!html.includes('video/mp4'));assert.ok(!html.includes('保存到本地'));
|
||||
const data=new FormData();for(const [key,value] of Object.entries({category:'登录异常',content:'播放时一直缓冲,无法正常观看视频。',contact:' me@example.com ',network:'Wi-Fi',device:'iPhone'}))data.set(key,value);
|
||||
const body=feedbackPayload(data,['/files/real.png']);assert.equal(body.type,3);assert.equal(body.contact,'me@example.com');assert.deepEqual(body.screenshots,['/files/real.png']);
|
||||
assert.ok(body.content.includes('网络类型:Wi-Fi'));assert.ok(body.content.includes('设备类型:iPhone'));
|
||||
data.set('category','无法播放');assert.equal(feedbackPayload(data).type,2);data.set('category','其他问题');assert.equal(feedbackPayload(data).type,4);data.set('category','闪退 / 崩溃');assert.equal(feedbackPayload(data).type,4);
|
||||
data.set('content','长'.repeat(500));assert.equal(feedbackPayload(data).content,'长'.repeat(500));
|
||||
data.set('content','中'.repeat(490));assert.equal(feedbackPayload(data).content.length,500);
|
||||
data.set('content','太短');assert.throws(()=>feedbackPayload(data),/10–500/);
|
||||
});
|
||||
|
||||
class Element extends EventTarget {
|
||||
nodes=new Map();dataset={};innerHTML='';textContent='';disabled=false;value='';files=[];
|
||||
querySelector(selector){return this.nodes.get(selector)||null;}
|
||||
querySelectorAll(){return [];}
|
||||
reportValidity(){return true;}
|
||||
reset(){this.resetCount=(this.resetCount||0)+1;}
|
||||
}
|
||||
function emit(element,type){element.dispatchEvent(new Event(type,{cancelable:true}));}
|
||||
|
||||
test('nickname settings save the nickname once and keep an existing introduction',async()=>{
|
||||
const oldDocument=globalThis.document,OldFormData=globalThis.FormData;
|
||||
const controller=new AbortController(),root=new Element(),profile=new Element(),status=new Element();
|
||||
const values=new Map([[profile,{nickname:'新昵称'}]]);
|
||||
profile.nodes.set('[type="submit"]',new Element());profile.nodes.set('#member-nickname-status',status);
|
||||
root.nodes.set('#member-nickname-form',profile);
|
||||
const calls=[];let finish,refreshes=0,routes=0;
|
||||
const app={signal:controller.signal,home:{},api:{ensureSession:async()=>({userId:8,introduction:'原简介'}),get:async()=>({}),post:async(path,body)=>{calls.push({path,body});if(calls.length===1)await new Promise(resolve=>finish=resolve);},refreshUser:async()=>refreshes++},notify(){},async route(){routes++;}};
|
||||
globalThis.document={getElementById:()=>root};globalThis.FormData=class extends OldFormData{constructor(form){super();if(form)for(const [key,value] of Object.entries(values.get(form)))this.set(key,value);}};
|
||||
try{
|
||||
await renderMember(app,'settings',1,()=>'/member/settings/');
|
||||
emit(profile,'submit');emit(profile,'submit');assert.equal(calls.length,1);assert.equal(profile.querySelector('[type="submit"]').disabled,true);
|
||||
finish();await flush();assert.deepEqual(calls[0],{path:'auth/profile',body:{nickName:'新昵称',introduction:'原简介'}});assert.equal(refreshes,1);assert.equal(routes,1);
|
||||
}finally{controller.abort();globalThis.document=oldDocument;globalThis.FormData=OldFormData;}
|
||||
});
|
||||
|
||||
function feedbackFixture(post){
|
||||
const oldDocument=globalThis.document,OldFormData=globalThis.FormData;
|
||||
const controller=new AbortController(),root=new Element(),form=new Element(),input=new Element(),list=new Element(),status=new Element(),error=new Element(),content=new Element(),count=new Element(),submit=new Element();
|
||||
const values={content:'播放视频时持续缓冲,无法正常观看。',category:'无法播放',contact:'me@example.com',network:'Wi-Fi'};
|
||||
root.nodes.set('#feedback-form',form);
|
||||
for(const [selector,node] of [['#feedback-files',input],['#feedback-file-list',list],['#feedback-status',status],['#feedback-file-error',error],['[name="content"]',content],['#feedback-count',count],['[type="submit"]',submit]])form.nodes.set(selector,node);
|
||||
globalThis.document={getElementById:()=>root};globalThis.FormData=class extends OldFormData{constructor(form){super();if(form)for(const [key,value] of Object.entries(values))this.set(key,value);}};
|
||||
const calls=[];renderFeedbackPage({signal:controller.signal,api:{post:async(path,body)=>{calls.push({path,body});return post(path,body);}}});
|
||||
return {controller,form,input,list,status,error,submit,calls,dispose(){controller.abort();globalThis.document=oldDocument;globalThis.FormData=OldFormData;}};
|
||||
}
|
||||
|
||||
test('reference feedback form uploads images only on submit and blocks duplicate feedback writes',async()=>{
|
||||
let finish;const f=feedbackFixture(path=>path==='upload/image'?new Promise(resolve=>finish=resolve):Promise.resolve({}));
|
||||
try{
|
||||
f.input.files=[new File(['image'],'shot.png',{type:'image/png'})];emit(f.input,'change');assert.equal(f.calls.length,0);assert.ok(f.list.innerHTML.includes('shot.png'));
|
||||
emit(f.form,'submit');emit(f.form,'submit');assert.equal(f.calls.length,1);assert.equal(f.calls[0].path,'upload/image');assert.equal(f.submit.disabled,true);
|
||||
finish({sourceNameFs:'/uploaded/shot.png'});await flush();
|
||||
assert.equal(f.calls.length,2);assert.equal(f.calls[1].path,'feedback/add');assert.deepEqual(f.calls[1].body.screenshots,['/uploaded/shot.png']);assert.equal(f.calls[1].body.type,2);
|
||||
assert.equal(f.form.resetCount,1);assert.match(f.status.textContent,/反馈已提交/);assert.equal(f.submit.disabled,false);
|
||||
}finally{f.dispose();}
|
||||
});
|
||||
|
||||
test('later feedback images are kept alongside earlier ones until three are selected',()=>{
|
||||
const f=feedbackFixture(async()=>({}));
|
||||
const image=(name)=>new File(['image'],name,{type:'image/png'});
|
||||
try{
|
||||
f.input.files=[image('one.png')];emit(f.input,'change');
|
||||
f.input.files=[image('two.png')];emit(f.input,'change');
|
||||
assert.ok(f.list.innerHTML.includes('one.png'));assert.ok(f.list.innerHTML.includes('two.png'));assert.equal(f.error.textContent,'');
|
||||
f.input.files=[image('three.png'),image('four.png')];emit(f.input,'change');
|
||||
assert.match(f.error.textContent,/最多上传 3 张/);assert.ok(!f.list.innerHTML.includes('three.png'));assert.ok(f.list.innerHTML.includes('one.png'));
|
||||
f.input.files=[image('three.png')];emit(f.input,'change');
|
||||
assert.equal(f.error.textContent,'');assert.ok(f.list.innerHTML.includes('three.png'));assert.equal(f.input.disabled,true);
|
||||
f.list.closest=()=>({dataset:{removeFeedbackFile:'0'}});
|
||||
f.list.dispatchEvent(new Event('click'));
|
||||
assert.ok(!f.list.innerHTML.includes('one.png'));assert.ok(f.list.innerHTML.includes('two.png'));assert.equal(f.input.disabled,false);
|
||||
}finally{f.dispose();}
|
||||
});
|
||||
|
||||
test('unsupported files, failed uploads and navigation cannot silently submit feedback',async()=>{
|
||||
const failed=feedbackFixture(async()=>({}));
|
||||
try{
|
||||
failed.input.files=[new File(['bad'],'clip.mp4',{type:'video/mp4'})];emit(failed.input,'change');assert.match(failed.error.textContent,/JPG/);assert.equal(failed.calls.length,0);
|
||||
failed.input.files=[new File(['valid'],'shot.png',{type:'image/png'})];emit(failed.input,'change');emit(failed.form,'submit');await flush();
|
||||
assert.deepEqual(failed.calls.map(call=>call.path),['upload/image']);assert.match(failed.status.textContent,/上传失败/);assert.equal(failed.submit.disabled,false);assert.equal(failed.form.resetCount,undefined);
|
||||
}finally{failed.dispose();}
|
||||
let finish;const late=feedbackFixture(()=>new Promise(resolve=>finish=resolve));
|
||||
try{
|
||||
late.input.files=[new File(['valid'],'shot.png',{type:'image/png'})];emit(late.input,'change');emit(late.form,'submit');late.controller.abort();finish({sourceNameFs:'/uploaded/late.png'});await flush();
|
||||
assert.deepEqual(late.calls.map(call=>call.path),['upload/image']);assert.equal(late.form.resetCount,undefined);
|
||||
}finally{late.dispose();}
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import {routingModuleUrl} from './helpers/routing.mjs';
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFile} from 'node:fs/promises';
|
||||
import ts from 'typescript';
|
||||
|
||||
async function moduleUrl(file,replace=code=>code){
|
||||
const filename=new URL(file,import.meta.url);
|
||||
const {outputText}=ts.transpileModule(await readFile(filename,'utf8'),{compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.ESNext}});
|
||||
return 'data:text/javascript;base64,'+Buffer.from(replace(outputText).replace(/(['"])\.\.\/routing\1/g,JSON.stringify(routingModuleUrl))+'\n//# sourceURL='+filename.pathname).toString('base64');
|
||||
}
|
||||
const routingUrl=await moduleUrl('../src/lib/routing.ts');
|
||||
const {routeFromSegments,parseSitePath,paginationPath,internalPagePath,searchPath}=await import(routingUrl);
|
||||
const protocolUrl=await moduleUrl('../src/lib/api/protocol.ts');
|
||||
const renderUrl=await moduleUrl('../src/lib/client/render.ts',code=>code.replace('"../api/protocol"',JSON.stringify(protocolUrl)));
|
||||
const {pagination}=await import(renderUrl);
|
||||
const {bindPaginationJumps}=await import(await moduleUrl('../src/lib/client/navigation.ts'));
|
||||
const authUrl=await moduleUrl('../src/lib/client/auth.ts');
|
||||
const trackingUrl=await moduleUrl('../src/lib/tracking.ts');
|
||||
const accountUrl=await moduleUrl('../src/lib/client/account.ts',code=>code.replace('"../api/protocol"',JSON.stringify(protocolUrl)).replace('"./render"',JSON.stringify(renderUrl)).replace("'./auth'",JSON.stringify(authUrl)).replace("'../tracking'",JSON.stringify(trackingUrl)));
|
||||
const {renderMember,renderWallet}=await import(accountUrl);
|
||||
|
||||
test('every paginated page type resolves directly from the page suffix',()=>{
|
||||
const keyword='都市 / page/7/?#%2F';
|
||||
const bases=['/shorts/','/topics/','/topics/7/','/theme/8/','/tag/9/','/config/31/','/rank/','/rank/hot/','/rank/new/','/rank/follow/','/search/'+encodeURIComponent(keyword)+'/', '/member/favorites/','/member/history/','/member/likes/','/member/purchased/','/info/deposit/','/info/deposit/vip/','/detail/23456/','/play/23456/90001/'];
|
||||
for(const base of bases){
|
||||
for(const page of [1,2,37]){
|
||||
const path=base+'page/'+page+'/';
|
||||
assert.deepEqual(parseSitePath(path),{...parseSitePath(base),page},path);
|
||||
assert.deepEqual(parseSitePath(path.slice(0,-1)),parseSitePath(path),path);
|
||||
assert.equal(internalPagePath(path,'https://drama.example/'),path,path);
|
||||
assert.equal(paginationPath(path,1),base.startsWith('/search/')?searchPath(keyword):base,path);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(routeFromSegments(['search',keyword,'page','3']),{group:'search',key:keyword,page:3});
|
||||
});
|
||||
|
||||
test('invalid page numbers, nested page suffixes, and non-paginated sections are rejected',()=>{
|
||||
for(const value of ['0','-1','1.5','02','NaN','9007199254740992'])assert.equal(parseSitePath(`/shorts/page/${value}/`),null,value);
|
||||
for(const base of ['/','/apps/','/home-road/','/navigation/','/member/center/','/member/settings/','/info/about-us/'])assert.equal(parseSitePath(base+'page/2/'),null,base);
|
||||
assert.equal(parseSitePath('/shorts/page/2/page/3/'),null);
|
||||
assert.throws(()=>paginationPath('/shorts/',0));assert.throws(()=>paginationPath('/apps/',2));
|
||||
});
|
||||
|
||||
test('page links retain filters and encoded search terms and sorting resets to the first page',()=>{
|
||||
assert.equal(paginationPath('/shorts/page/4/?sort=3&source=menu',2),'/shorts/page/2/?sort=3&source=menu');
|
||||
assert.equal(paginationPath('/shorts/page/4/?sort=3&source=menu',1,{sort:2}),'/shorts/?sort=2&source=menu');
|
||||
assert.equal(paginationPath('/shorts/page/4/?sort=3',1,{sort:null}),'/shorts/');
|
||||
const base='/search/'+encodeURIComponent('都市 / 爱情?#%')+'/';
|
||||
assert.equal(paginationPath(base,2),searchPath('都市 / 爱情?#%',2));
|
||||
const url=new URL(paginationPath(base,2),'https://drama.example');
|
||||
assert.equal(parseSitePath(url.pathname,url.search).key,'都市 / 爱情?#%');
|
||||
assert.equal(paginationPath('/play/7/90001/page/3/?commentSort=1',1,{commentSort:null}),'/play/7/90001/');
|
||||
});
|
||||
|
||||
test('pagination renders real page links with correct boundaries, current page, and bounded page numbers',()=>{
|
||||
const href=page=>paginationPath('/rank/new/page/5/?sort=2&from=menu',page);
|
||||
const html=pagination(240,5,href);
|
||||
assert.ok(html.includes('href="/rank/new/page/4/?sort=2&from=menu" rel="prev"'));
|
||||
assert.ok(html.includes('href="/rank/new/page/6/?sort=2&from=menu" rel="next"'));
|
||||
assert.ok(html.includes('href="/rank/new/page/5/?sort=2&from=menu" aria-current="page"'));
|
||||
assert.equal((html.match(/<button/g)||[]).length,1);assert.ok(html.includes('<button type="submit">确定</button>'));assert.ok(!html.includes('data-page-number'));
|
||||
assert.ok(html.includes('data-page-base="/rank/new/?sort=2&from=menu"'));
|
||||
assert.ok(html.includes('name="page" min="1" max="10" step="1"'));
|
||||
assert.ok((html.match(/<a /g)||[]).length<=7);
|
||||
assert.ok(pagination(240,1,href).includes('aria-disabled="true" aria-label="上一页"'));
|
||||
assert.ok(pagination(240,10,href).includes('aria-disabled="true" aria-label="下一页"'));
|
||||
assert.equal(pagination(0,1,href),'');assert.equal(pagination(24,1,href),'');
|
||||
assert.ok(pagination(0,9,href).includes('href="/rank/new/?sort=2&from=menu"'));
|
||||
});
|
||||
|
||||
test('jump forms navigate to independent routes, retain canonical slugs and filters, and reject invalid pages',()=>{
|
||||
const previousForm=globalThis.HTMLFormElement,previousInput=globalThis.HTMLInputElement;
|
||||
class Input {valueAsNumber=3;max='10';}
|
||||
class Form {dataset={pageBase:'/topics/sweet-love/?sort=2&source=menu'};input=new Input();valid=true;elements={namedItem:()=>this.input};matches=()=>true;reportValidity=()=>this.valid;}
|
||||
globalThis.HTMLFormElement=Form;globalThis.HTMLInputElement=Input;
|
||||
try{
|
||||
let submit;const paths=[];
|
||||
bindPaginationJumps({addEventListener:(type,handler)=>{assert.equal(type,'submit');submit=handler;}},path=>paths.push(path));
|
||||
const form=new Form();let prevented=0;
|
||||
const send=()=>submit({target:form,preventDefault(){prevented++;}});
|
||||
send();assert.equal(paths.at(-1),'/topics/sweet-love/page/3/?sort=2&source=menu');
|
||||
form.input.valueAsNumber=1;send();assert.equal(paths.at(-1),'/topics/sweet-love/?sort=2&source=menu');
|
||||
form.dataset.pageBase=searchPath('都市 / 爱情');form.input.valueAsNumber=2;send();assert.equal(paths.at(-1),searchPath('都市 / 爱情',2));
|
||||
const count=paths.length;
|
||||
for(const value of [NaN,0,-1,1.5,11,Infinity,Number.MAX_SAFE_INTEGER+1]){form.input.valueAsNumber=value;send();}
|
||||
form.input.valueAsNumber=2;form.valid=false;send();assert.equal(paths.length,count);
|
||||
assert.equal(prevented,11);
|
||||
// The delegated listener works for forms added after it was registered.
|
||||
const added=new Form();submit({target:added,preventDefault(){}});assert.equal(paths.length,count+1);
|
||||
submit({target:{},preventDefault(){assert.fail('unrelated form');}});
|
||||
}finally{
|
||||
if(previousForm===undefined)delete globalThis.HTMLFormElement;else globalThis.HTMLFormElement=previousForm;
|
||||
if(previousInput===undefined)delete globalThis.HTMLInputElement;else globalThis.HTMLInputElement=previousInput;
|
||||
}
|
||||
});
|
||||
|
||||
test('personal library and wallet requests and page links use the route page',async()=>{
|
||||
const previousDocument=globalThis.document,calls=[];
|
||||
const root={innerHTML:'',querySelector:()=>null,addEventListener(){}};
|
||||
const user={userId:7,nickName:'测试用户'};
|
||||
const api={user,ensureSession:async()=>user,async get(path,query){
|
||||
calls.push({path,...query});
|
||||
if(path==='user/center/data')return {};
|
||||
if(path==='recharge/package/list')return {coinList:[],vipList:[]};
|
||||
return {list:[],totalCount:120};
|
||||
}};
|
||||
const app={api,signal:new AbortController().signal};
|
||||
globalThis.document={getElementById:()=>root};
|
||||
try{
|
||||
for(const [tab,endpoint] of [['favorites','playlet/collect/list'],['history','playlet/history/list'],['likes','playlet/like/list'],['purchased','playlet/bought/list']]){
|
||||
await renderMember(app,tab,3,page=>paginationPath(`/member/${tab}/page/3/`,page));
|
||||
const call=calls.at(-1);assert.equal(call.path,endpoint);assert.equal(call.pageNum,3);
|
||||
assert.ok(root.innerHTML.includes(`href="/member/${tab}/page/2/"`));
|
||||
}
|
||||
await renderWallet(app,true,3,page=>paginationPath('/info/deposit/vip/page/3/',page));
|
||||
assert.equal(calls.at(-1).path,'coin/trans/list');assert.equal(calls.at(-1).page,3);
|
||||
assert.ok(root.innerHTML.includes('href="/info/deposit/vip/page/4/"'));
|
||||
}finally{if(previousDocument===undefined)delete globalThis.document;else globalThis.document=previousDocument;}
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import {routingModuleUrl} from './helpers/routing.mjs';
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFile} from 'node:fs/promises';
|
||||
import ts from 'typescript';
|
||||
|
||||
async function moduleUrl(file,replace=code=>code){
|
||||
const filename=new URL(file,import.meta.url);
|
||||
const {outputText}=ts.transpileModule(await readFile(filename,'utf8'),{compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.ESNext}});
|
||||
return 'data:text/javascript;base64,'+Buffer.from(replace(outputText).replace(/(['"])\.\.\/routing\1/g,JSON.stringify(routingModuleUrl))+'\n//# sourceURL='+filename.pathname).toString('base64');
|
||||
}
|
||||
const protocolUrl=await moduleUrl('../src/lib/api/protocol.ts');
|
||||
const renderUrl=await moduleUrl('../src/lib/client/render.ts',code=>code.replace('"../api/protocol"',JSON.stringify(protocolUrl)));
|
||||
const authUrl=await moduleUrl('../src/lib/client/auth.ts');
|
||||
const trackingUrl=await moduleUrl('../src/lib/tracking.ts');
|
||||
const accountUrl=await moduleUrl('../src/lib/client/account.ts',code=>code.replace('"../api/protocol"',JSON.stringify(protocolUrl)).replace('"./render"',JSON.stringify(renderUrl)).replace("'./auth'",JSON.stringify(authUrl)).replace("'../tracking'",JSON.stringify(trackingUrl)));
|
||||
const {paymentDialogMarkup,renderWallet}=await import(accountUrl);
|
||||
const coinPackage={id:31,name:'金币套餐',packageType:2,price:10000,baseCoin:100,giftCoin:999,totalCoin:1099,payMethods:[{type:'alipay',typeName:'支付宝'},{type:'wechat',typeName:'微信'}]};
|
||||
const flush=()=>new Promise(resolve=>setImmediate(resolve));
|
||||
|
||||
test('confirmation displays actual base/gift/total coins and the price in yuan with original payment icons',()=>{
|
||||
const html=paymentDialogMarkup(coinPackage,false);
|
||||
assert.ok(html.includes('<strong>100 金币</strong>'));assert.ok(html.includes('赠送 999 金币 · 合计 1099 金币'));
|
||||
assert.ok(html.includes('<b>¥100</b>'));assert.ok(html.includes('5652fcead17188e8.svg'));assert.ok(html.includes('2bead9ea5c435c98.svg'));
|
||||
assert.ok(!html.includes('7848816d05aa8591.svg'));assert.ok(!html.includes('type="radio"'));
|
||||
assert.ok(html.includes('name="payMode" value="alipay"'));assert.ok(html.includes('class="wallet-payment-title"'));
|
||||
const derived=paymentDialogMarkup({...coinPackage,baseCoin:undefined,price:12345},false);
|
||||
assert.ok(derived.includes('<strong>100 金币</strong>'));assert.ok(derived.includes('<b>¥123.45</b>'));
|
||||
});
|
||||
|
||||
test('VIP and missing payment data never introduce demo bonuses or unconfigured channels',()=>{
|
||||
const vip=paymentDialogMarkup({id:7,name:'月卡 <script>',packageType:1,price:3000},true);
|
||||
assert.ok(vip.includes('会员畅享'));assert.ok(vip.includes('月卡 <script>'));assert.ok(!vip.includes('赠送 999'));
|
||||
assert.ok(vip.includes('暂无可用支付方式'));assert.match(vip,/<button class="wallet-submit wallet-payment-go" type="submit" disabled>/);
|
||||
assert.ok(!vip.includes('data-wallet-method='));
|
||||
const unknown=paymentDialogMarkup({...coinPackage,payMethods:[null,{type:'',typeName:'空'},{type:'union',typeName:'银联 <img>'},{type:'union',typeName:'重复'}]},false);
|
||||
assert.equal((unknown.match(/data-wallet-method=/g)||[]).length,1);assert.ok(unknown.includes('银联 <img>'));assert.ok(unknown.includes('wallet-pay-logo--other'));
|
||||
});
|
||||
|
||||
class Element extends EventTarget {
|
||||
dataset={};attrs=new Map();nodes=new Map();lists=new Map();innerHTML='';textContent='';disabled=false;isConnected=true;value='';
|
||||
querySelector(selector){return this.nodes.get(selector)||null;}
|
||||
querySelectorAll(selector){return this.lists.get(selector)||[];}
|
||||
setAttribute(key,value){this.attrs.set(key,String(value));}
|
||||
getAttribute(key){return this.attrs.get(key)||null;}
|
||||
closest(){return this;}
|
||||
reportValidity(){return true;}
|
||||
}
|
||||
function emit(element,type,target=element){const event=new Event(type,{cancelable:true});Object.defineProperty(event,'target',{value:target});element.dispatchEvent(event);}
|
||||
async function fixture({vip=false,post=async()=>({payUrl:'https://pay.example/checkout',tradeNo:'order-1'}),methods=coinPackage.payMethods,packages,transactions={list:[],totalCount:0},transactionError=false,balance=90,openDialog=true}={}){
|
||||
const oldDocument=globalThis.document,OldFormData=globalThis.FormData;
|
||||
const controller=new AbortController(),root=new Element(),dialog=new Element(),form=new Element(),hidden=new Element(),confirm=new Element(),error=new Element(),refresh=new Element();
|
||||
const item={...coinPackage,payMethods:methods};hidden.value=methods[0]?.type||'';
|
||||
const items=packages??[item],pay=new Element(),selection=new Element();pay.dataset.walletAction='pay';
|
||||
const packageButtons=items.map((item,index)=>{const button=new Element();button.dataset.buyPackage=String(item.id);button.setAttribute('aria-pressed',String(index===0));return button;});
|
||||
root.nodes.set('[data-wallet-action="pay"]',pay);root.nodes.set('#wallet-selection',selection);root.lists.set('[data-buy-package]',packageButtons);
|
||||
const choices=methods.map(method=>{const button=new Element();button.dataset.walletMethod=method.type;button.nodes.set('.wallet-method-check',new Element());return button;});
|
||||
form.nodes.set('[name="payMode"]',hidden);form.nodes.set('[type="submit"]',confirm);form.nodes.set('.api-error',error);form.nodes.set('[data-payment-refresh]',refresh);form.lists.set('[data-wallet-method]',choices);
|
||||
dialog.nodes.set('form',form);dialog.open=false;dialog.showModal=()=>dialog.open=true;dialog.close=()=>dialog.open=false;
|
||||
const posts=[];let refreshes=0,routes=0;
|
||||
const user={recharge:balance};
|
||||
const app={signal:controller.signal,api:{user,ensureSession:async()=>user,get:async path=>{if(path==='recharge/package/list')return {coinList:items,vipList:items};if(transactionError)throw new Error('offline');return transactions;},post:async(path,body)=>{posts.push({path,body});return post();},refreshUser:async()=>refreshes++},notify(){},async route(){routes++;}};
|
||||
globalThis.document={getElementById:id=>id==='info-content'?root:dialog};
|
||||
globalThis.FormData=class extends OldFormData{constructor(form){super();this.append('payMode',form.querySelector('[name="payMode"]').value);}};
|
||||
await renderWallet(app,vip,1,()=>'/info/deposit/');
|
||||
if(openDialog)emit(root,'click',pay);
|
||||
return {app,root,dialog,form,hidden,confirm,error,refresh,choices,posts,pay,selection,packageButtons,get refreshes(){return refreshes;},get routes(){return routes;},dispose(){controller.abort();globalThis.document=oldDocument;globalThis.FormData=OldFormData;}};
|
||||
}
|
||||
|
||||
test('recharge page uses the HTML balance, package and transaction layout with actual API values',async()=>{
|
||||
const f=await fixture({openDialog:false,balance:2400,packages:[{...coinPackage,baseCoin:250,giftCoin:25,totalCoin:275,price:12345}],transactions:{list:[
|
||||
{title:'解锁 <短剧>',createdAt:'2026-10-20T13:24:00+08:00',flowDir:-1,amount:30,changeAmount:0},
|
||||
{tranTypeName:'充值到账',createdAt:'2026-10-20 12:00:00',flowDir:1,amount:100,changeAmount:125},
|
||||
{remark:'余额校验',flowDir:1,amount:0,coin:999},
|
||||
],totalCount:3}});
|
||||
try{
|
||||
const html=f.root.innerHTML;
|
||||
for(const className of ['wallet-balance-card','wallet-balance-summary','wallet-usage','wallet-pack-panel','wallet-pack-heading','wallet-package-coin','wallet-double-badge','wallet-mobile-selected','wallet-pay-area','wallet-transaction-list'])assert.ok(html.includes(className),className);
|
||||
assert.ok(html.includes('<strong>2,400</strong><b>金币</b>'));assert.ok(html.includes('>250金币</span>'));assert.ok(html.includes('>赠送 25金币</span>'));assert.ok(html.includes('立即支付 ¥123.45'));
|
||||
assert.ok(html.includes('已选择 250金币 + 赠送25金币'));assert.ok(html.includes('aria-pressed="true"'));
|
||||
assert.ok(html.includes('解锁 <短剧>'));assert.ok(html.includes('2026/10/20 13:24'));assert.ok(html.includes('datetime="2026-10-20T05:24:00.000Z"'));
|
||||
assert.ok(html.includes('expense">−30</span>'));assert.ok(html.includes('income">+125</span>'));assert.ok(html.includes('income">0</span>'));
|
||||
assert.ok(html.includes('href="/info/feedback/"'));assert.ok(!html.includes('模拟'));assert.ok(!html.includes('首次充值赠送10%'));assert.ok(!html.includes('1,280'));
|
||||
assert.equal(f.dialog.open,false);assert.equal(f.posts.length,0);
|
||||
}finally{f.dispose();}
|
||||
});
|
||||
|
||||
test('package click only updates selection and the pay action confirms the selected package ID',async()=>{
|
||||
const second={...coinPackage,id:84,baseCoin:300,giftCoin:0,totalCoin:300,price:4599};
|
||||
const f=await fixture({openDialog:false,packages:[coinPackage,second]});
|
||||
try{
|
||||
emit(f.root,'click',f.packageButtons[1]);
|
||||
assert.equal(f.dialog.open,false);assert.equal(f.posts.length,0);
|
||||
assert.equal(f.packageButtons[0].getAttribute('aria-pressed'),'false');assert.equal(f.packageButtons[1].getAttribute('aria-pressed'),'true');
|
||||
assert.equal(f.pay.textContent,'立即支付 ¥45.99');assert.equal(f.selection.textContent,'已选择 300金币 · 点击后选择支付方式');
|
||||
emit(f.root,'click',f.pay);assert.equal(f.dialog.open,true);assert.ok(f.dialog.innerHTML.includes('<strong>300 金币</strong>'));assert.ok(f.dialog.innerHTML.includes('<b>¥45.99</b>'));
|
||||
emit(f.form,'submit');await flush();assert.deepEqual(f.posts,[{path:'coin/buy',body:{packageId:84,payMode:'alipay',sourcePageKey:'deposit',sourcePageName:'充值中心'}}]);
|
||||
}finally{f.dispose();}
|
||||
});
|
||||
|
||||
test('empty packages and unavailable transactions show empty states without sample data or an order',async()=>{
|
||||
const f=await fixture({openDialog:false,packages:[],transactionError:true,balance:0});
|
||||
try{
|
||||
assert.ok(f.root.innerHTML.includes('<strong>0</strong><b>金币</b>'));assert.ok(f.root.innerHTML.includes('暂无可购买套餐'));assert.ok(f.root.innerHTML.includes('余额明细暂不可用,请稍后刷新'));
|
||||
assert.match(f.root.innerHTML, /data-wallet-action="pay" disabled/);assert.ok(!f.root.innerHTML.includes('data-buy-package='));assert.ok(!f.root.innerHTML.includes('wallet-transaction-row'));
|
||||
emit(f.root,'click',f.pay);assert.equal(f.dialog.open,false);assert.equal(f.posts.length,0);
|
||||
}finally{f.dispose();}
|
||||
});
|
||||
|
||||
test('selecting a styled method updates the actual order payload and duplicate submission is blocked',async()=>{
|
||||
let finish;const f=await fixture({post:()=>new Promise(resolve=>finish=resolve)});
|
||||
try{
|
||||
assert.equal(f.dialog.open,true);assert.equal(f.posts.length,0);
|
||||
emit(f.form,'click',f.choices[1]);assert.equal(f.hidden.value,'wechat');assert.equal(f.choices[1].getAttribute('aria-pressed'),'true');assert.equal(f.choices[1].querySelector('.wallet-method-check').textContent,'✓');
|
||||
emit(f.form,'submit');emit(f.form,'submit');assert.deepEqual(f.posts,[{path:'coin/buy',body:{packageId:31,payMode:'wechat',sourcePageKey:'deposit',sourcePageName:'充值中心'}}]);
|
||||
assert.equal(f.confirm.disabled,true);emit(f.form,'click',f.choices[0]);assert.equal(f.hidden.value,'wechat');
|
||||
finish({payUrl:'https://pay.example/checkout',tradeNo:'order-1'});await flush();
|
||||
assert.ok(f.form.innerHTML.includes('打开支付页面'));assert.equal(f.refreshes,0);assert.equal(f.app.api.user.recharge,90);
|
||||
emit(f.refresh,'click');await flush();assert.equal(f.refreshes,1);assert.equal(f.dialog.open,false);assert.equal(f.routes,1);
|
||||
}finally{f.dispose();}
|
||||
});
|
||||
|
||||
test('VIP uses its existing endpoint and an invalid payment URL leaves the form available to retry',async()=>{
|
||||
const f=await fixture({vip:true,post:async()=>({payUrl:'javascript:alert(1)'})});
|
||||
try{
|
||||
emit(f.form,'submit');await flush();assert.deepEqual(f.posts,[{path:'vip/pay',body:{packageId:31,payMode:'alipay',sourcePageKey:'deposit',sourcePageName:'充值中心'}}]);
|
||||
assert.match(f.error.textContent,/有效地址/);assert.equal(f.confirm.disabled,false);assert.equal(f.choices[0].disabled,false);assert.equal(f.refreshes,0);
|
||||
assert.ok(!f.form.innerHTML.includes('javascript:'));
|
||||
}finally{f.dispose();}
|
||||
});
|
||||
|
||||
test('missing methods cannot create an order and a replaced form ignores late order responses',async()=>{
|
||||
const empty=await fixture({methods:[]});
|
||||
try{emit(empty.form,'submit');assert.equal(empty.posts.length,0);}finally{empty.dispose();}
|
||||
let finish;const late=await fixture({post:()=>new Promise(resolve=>finish=resolve)});
|
||||
try{
|
||||
emit(late.form,'submit');late.form.isConnected=false;finish({payUrl:'https://pay.example',tradeNo:'old-order'});await flush();
|
||||
assert.equal(late.form.innerHTML,'');assert.equal(late.refreshes,0);
|
||||
}finally{late.dispose();}
|
||||
});
|
||||
@@ -0,0 +1,721 @@
|
||||
import {routingModuleUrl} from './helpers/routing.mjs';
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFile} from 'node:fs/promises';
|
||||
import ts from 'typescript';
|
||||
import {setMaxListeners} from 'node:events';
|
||||
import vm from 'node:vm';
|
||||
import {seoUrl} from './helpers/seo.mjs';
|
||||
|
||||
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).replace(/(['"])\.\.\/routing\1/g,JSON.stringify(routingModuleUrl))+'\n//# sourceURL='+new URL(path,import.meta.url).pathname).toString('base64');
|
||||
}
|
||||
const protocolUrl=await moduleUrl('../src/lib/api/protocol.ts');
|
||||
const playbackModuleUrl=await moduleUrl('../src/lib/client/playback.ts',code=>code.replace(/from ['"]\.\.\/api\/protocol['"]/,`from '${protocolUrl}'`));
|
||||
const {playbackUrl,chapterAccess,chapterListGate,chapterUnlockCost,initialPlayMode,preferNativeHls,isCodecFailure,MediaPlayback}=await import(playbackModuleUrl);
|
||||
const renderUrl=await moduleUrl('../src/lib/client/render.ts',code=>code.replace(/from ['"]\.\.\/api\/protocol['"]/,`from '${protocolUrl}'`));
|
||||
const appDownloadUrl=await moduleUrl('../src/lib/app-download.ts',code=>code.replace(/from ['"]\.\/api\/protocol['"]/,`from '${protocolUrl}'`));
|
||||
const downloadUrl=await moduleUrl('../src/lib/client/download.ts',code=>code.replace(/from ['"]\.\.\/app-download['"]/,`from '${appDownloadUrl}'`));
|
||||
const detailUrl=await moduleUrl('../src/lib/client/detail.ts',code=>code.replace(/from ['"]\.\.\/api\/protocol['"]/,`from '${protocolUrl}'`).replace(/from ['"]\.\/render['"]/,`from '${renderUrl}'`));
|
||||
const unlockUrl=await moduleUrl('../src/lib/client/unlock.ts',code=>code.replace(/from ['"]\.\.\/api\/protocol['"]/,`from '${protocolUrl}'`).replace(/from ['"]\.\/playback['"]/,`from '${playbackModuleUrl}'`));
|
||||
const feedbackUrl=await moduleUrl('../src/lib/client/request-feedback.ts');
|
||||
const markupUrl=await moduleUrl('../src/lib/client/player-markup.ts',code=>code
|
||||
.replace(/from ['"]\.\.\/api\/protocol['"]/,`from '${protocolUrl}'`)
|
||||
.replace(/from ['"]\.\/render['"]/,`from '${renderUrl}'`)
|
||||
.replace(/from ['"]\.\/detail['"]/,`from '${detailUrl}'`)
|
||||
.replace(/from ['"]\.\/playback['"]/,`from '${playbackModuleUrl}'`));
|
||||
const playerDataUrl=await moduleUrl('../src/lib/player-data.ts',code=>code
|
||||
.replace(/from ['"]\.\/api\/protocol['"]/,`from '${protocolUrl}'`)
|
||||
.replace(/from ['"]\.\/seo['"]/,`from '${seoUrl}'`)
|
||||
.replace(/from ['"]\.\/client\/playback['"]/,`from '${playbackModuleUrl}'`));
|
||||
const {playerInitialData}=await import(playerDataUrl);
|
||||
const playerUrl=await moduleUrl('../src/lib/client/player.ts',code=>code
|
||||
.replace(/from ['"]\.\/player-markup['"]/,`from '${markupUrl}'`)
|
||||
.replace(/from ['"]\.\.\/player-data['"]/,`from '${playerDataUrl}'`)
|
||||
.replace(/from ['"]\.\.\/seo['"]/,`from '${seoUrl}'`)
|
||||
.replace(/from ['"]\.\.\/api\/protocol['"]/,`from '${protocolUrl}'`)
|
||||
.replace(/from ['"]\.\/render['"]/,`from '${renderUrl}'`)
|
||||
.replace(/from ['"]\.\/download['"]/,`from '${downloadUrl}'`)
|
||||
.replace(/from ['"]\.\/detail['"]/,`from '${detailUrl}'`)
|
||||
.replace(/from ['"]\.\/unlock['"]/,`from '${unlockUrl}'`)
|
||||
.replace(/from ['"]\.\/request-feedback['"]/,`from '${feedbackUrl}'`)
|
||||
.replace(/from ['"]\.\/playback['"]/,`from '${playbackModuleUrl}'`));
|
||||
const {renderPlayer}=await import(playerUrl);
|
||||
const {playerMarkup}=await import(markupUrl);
|
||||
const nav=(userAgent,maxTouchPoints=0)=>({userAgent,maxTouchPoints});
|
||||
const chrome=nav('Mozilla/5.0 Chrome/130 Safari/537.36');
|
||||
const safari=nav('Mozilla/5.0 Macintosh Version/18 Safari/605.1.15');
|
||||
const flush=()=>new Promise(resolve=>setImmediate(resolve));
|
||||
|
||||
class Element extends EventTarget {
|
||||
tagName;dataset={};attrs=new Map();nodes=new Map();lists=new Map();children=[];hidden=false;disabled=false;textContent='';innerHTML='';
|
||||
classList={toggle(){}};
|
||||
style={setProperty(){}};
|
||||
constructor(tagName='DIV'){super();this.tagName=tagName;}
|
||||
setAttribute(key,value){this.attrs.set(key,String(value));}
|
||||
getAttribute(key){return this.attrs.get(key)??null;}
|
||||
hasAttribute(key){return this.attrs.has(key);}
|
||||
removeAttribute(key){this.attrs.delete(key);}
|
||||
querySelector(selector){return this.nodes.get(selector)??null;}
|
||||
querySelectorAll(selector){return this.lists.get(selector)||[];}
|
||||
matches(){return false;}
|
||||
append(child){this.children.push(child);}
|
||||
closest(){return this;}
|
||||
insertAdjacentHTML(_position,html){this.innerHTML+=html;}
|
||||
}
|
||||
class Video extends Element {
|
||||
paused=true;controls=false;native=false;plays=0;loads=0;sourceWrites=[];rejectPlay=false;error=null;
|
||||
constructor(native=false){super('VIDEO');this.native=native;}
|
||||
canPlayType(){return this.native?'maybe':'';}
|
||||
pause(){this.paused=true;}
|
||||
load(){this.loads++;this.error=null;}
|
||||
async play(){this.plays++;if(this.rejectPlay)throw new Error('NotAllowed');this.paused=false;this.dispatchEvent(new Event('playing'));}
|
||||
set src(value){this.sourceWrites.push(value);this.setAttribute('src',value);}
|
||||
get src(){return this.getAttribute('src')||'';}
|
||||
}
|
||||
function hlsClass(supported=true){
|
||||
return class {
|
||||
static instances=[];static Events={MANIFEST_PARSED:'manifest',ERROR:'error'};static ErrorTypes={NETWORK_ERROR:'network',MEDIA_ERROR:'media'};
|
||||
static DefaultConfig={manifestLoadPolicy:{default:{}},fragLoadPolicy:{default:{}}};
|
||||
static isSupported(){return supported;}
|
||||
handlers=new Map();destroyed=0;network=0;media=0;stopped=0;
|
||||
constructor(config){this.config=config;this.constructor.instances.push(this);}
|
||||
on(event,handler){this.handlers.set(event,handler);}
|
||||
emit(event,data){this.handlers.get(event)?.(event,data);}
|
||||
loadSource(source){this.source=source;}
|
||||
attachMedia(video){this.video=video;}
|
||||
startLoad(){this.network++;}
|
||||
recoverMediaError(){this.media++;}
|
||||
stopLoad(){this.stopped++;}
|
||||
destroy(){this.destroyed++;}
|
||||
};
|
||||
}
|
||||
function fixture({native=false,navigator=chrome,Hls=hlsClass(),loadHls=async()=>Hls}={}){
|
||||
const video=new Video(native),controller=new AbortController(),messages=[];let errors=0;
|
||||
const media=new MediaPlayback(video,{signal:controller.signal,navigator,loadHls,status:value=>messages.push(value),error:()=>errors++});
|
||||
return {video,controller,Hls,media,messages,get errors(){return errors;}};
|
||||
}
|
||||
|
||||
test('playback addresses preserve signed queries, use the relative proxy once, and reject unsafe paths',()=>{
|
||||
for(const path of ['folder/file.m3u8?token=a%2Fb+1&x=2','/folder/file.m3u8?token=a%2Fb+1&x=2','/h5/m3u8/folder/file.m3u8?token=a%2Fb+1&x=2','h5/m3u8/folder/file.m3u8?token=a%2Fb+1&x=2'])assert.equal(playbackUrl(path),'/h5/m3u8/folder/file.m3u8?token=a%2Fb+1&x=2');
|
||||
assert.equal(playbackUrl(' https://cdn.example/a.m3u8?token=abc#x '),'https://cdn.example/a.m3u8?token=abc#x');
|
||||
for(const value of [null,{},'', 'javascript:alert(1)','blob:abc','//external/stream','../secret','a/%2e%2e/b','a\\b','https://user:password@example.com/a'])assert.equal(playbackUrl(value),'');
|
||||
});
|
||||
|
||||
test('chapter access follows reference precedence, supports legacy free URLs, and never supplies gated main sources',()=>{
|
||||
const url='https://cdn.example/main.mp4';
|
||||
assert.equal(chapterAccess({canWatch:true,needUnlock:true,videoUrl:url}).allowed,true);
|
||||
assert.equal(chapterAccess({canWatch:false,payType:0,videoUrl:url}).allowed,true);
|
||||
for(const chapter of [{needUnlock:true},{payType:1},{canWatch:false,payType:1}]){
|
||||
const access=chapterAccess({...chapter,videoUrl:url});assert.equal(access.allowed,false);assert.equal(access.gate,'coins');assert.equal(access.source,'');assert.equal(initialPlayMode(access,url),'preview');
|
||||
}
|
||||
assert.equal(chapterAccess({needVip:true,videoUrl:url}).gate,'vip');
|
||||
assert.equal(chapterAccess({payType:2,videoUrl:url}).gate,'vip');
|
||||
assert.equal(chapterAccess(null).allowed,false);
|
||||
assert.equal(chapterUnlockCost({costCoins:12,singleCoins:30}),12);assert.equal(chapterUnlockCost({costCoins:-1,singleCoins:30}),30);
|
||||
assert.equal(initialPlayMode(chapterAccess({canWatch:true,videoUrl:url}),url),'main');
|
||||
assert.equal(initialPlayMode(chapterAccess({canWatch:true}),url),'preview');
|
||||
});
|
||||
|
||||
test('native HLS is preferred on Apple browsers while Chromium and shell browsers try MSE first',()=>{
|
||||
const video=new Video(true);
|
||||
for(const browser of [safari,nav('Mozilla iPhone Mobile Safari'),nav('Mozilla Macintosh Safari',5)])assert.equal(preferNativeHls(video,browser),true);
|
||||
for(const browser of [chrome,nav('Android Chrome Safari'),nav('iPhone Quark Safari'),nav('iPhone MicroMessenger Safari')])assert.equal(preferNativeHls(video,browser),false);
|
||||
assert.equal(preferNativeHls(new Video(false),safari),false);
|
||||
});
|
||||
|
||||
test('Chromium uses hls.js even with a native-HLS claim, with reference buffers and modern load policies',async()=>{
|
||||
const f=fixture({native:true});
|
||||
try{
|
||||
await f.media.setSource('/h5/m3u8/stream?token=1');const hls=f.Hls.instances[0];
|
||||
assert.equal(hls.source,'/h5/m3u8/stream?token=1');assert.equal(hls.video,f.video);assert.deepEqual(f.video.sourceWrites,[]);
|
||||
assert.equal(hls.config.maxBufferLength,30);assert.equal(hls.config.maxMaxBufferLength,60);
|
||||
assert.equal(hls.config.manifestLoadPolicy.default.maxLoadTimeMs,10000);assert.equal(hls.config.fragLoadPolicy.default.maxLoadTimeMs,20000);
|
||||
assert.equal(f.video.getAttribute('x5-video-player-type'),'h5');assert.equal(f.video.playsInline,true);
|
||||
assert.equal(f.video.plays,0);await f.media.toggle();assert.equal(f.video.paused,false);await f.media.toggle();assert.equal(f.video.paused,true);
|
||||
}finally{f.media.dispose();}
|
||||
});
|
||||
|
||||
test('Safari and ordinary MP4 playback avoid loading hls.js',async()=>{
|
||||
let imports=0;const loadHls=async()=>{imports++;throw Error('must not import');};
|
||||
for(const [navigator,url] of [[safari,'https://cdn.example/main.m3u8'],[chrome,'https://cdn.example/main.mp4']]){
|
||||
const f=fixture({native:true,navigator,loadHls});
|
||||
try{await f.media.setSource(url);assert.equal(f.video.src,url);assert.equal(f.errors,0);}finally{f.media.dispose();}
|
||||
}
|
||||
assert.equal(imports,0);
|
||||
});
|
||||
|
||||
test('shell browsers without MSE fall back to native HLS; unsupported and failed library loads show retry states',async()=>{
|
||||
for(const native of [true,false]){
|
||||
const f=fixture({native,navigator:nav('iPhone Quark Safari'),Hls:hlsClass(false)});
|
||||
try{await f.media.setSource('/h5/m3u8/main.m3u8');assert.equal(!!f.video.src,native);assert.equal(f.errors,native?0:1);}finally{f.media.dispose();}
|
||||
const failed=fixture({native,loadHls:async()=>{throw Error('offline');}});
|
||||
try{await failed.media.setSource('/h5/m3u8/main.m3u8');assert.equal(!!failed.video.src,native);assert.equal(failed.errors,native?0:1);}finally{failed.media.dispose();}
|
||||
}
|
||||
});
|
||||
|
||||
test('fatal HLS network and media errors recover at most three times before presenting retry',async()=>{
|
||||
const f=fixture();
|
||||
try{
|
||||
await f.media.setSource('/h5/m3u8/main.m3u8');const hls=f.Hls.instances[0];
|
||||
hls.emit('error',{fatal:false,type:'network'});assert.equal(hls.network,0);
|
||||
for(const type of ['network','media','network','network'])hls.emit('error',{fatal:true,type});
|
||||
assert.equal(hls.network,2);assert.equal(hls.media,1);assert.equal(hls.stopped,1);assert.equal(f.errors,1);
|
||||
await f.media.retry();assert.equal(hls.destroyed,1);assert.equal(f.Hls.instances.length,2);assert.equal(f.video.plays,1);
|
||||
}finally{f.media.dispose();}
|
||||
});
|
||||
|
||||
test('source switches destroy the old HLS instance and ignore its late events',async()=>{
|
||||
const f=fixture();
|
||||
try{
|
||||
await f.media.setSource('/h5/m3u8/old.m3u8');const old=f.Hls.instances[0];
|
||||
await f.media.setSource('https://cdn.example/new.mp4');assert.equal(old.destroyed,1);
|
||||
old.emit('error',{fatal:true,type:'media'});assert.equal(old.media,0);assert.equal(f.errors,0);
|
||||
assert.equal(f.video.src,'https://cdn.example/new.mp4');
|
||||
}finally{f.media.dispose();}
|
||||
});
|
||||
|
||||
test('route cancellation during a pending HLS import cannot create a stale player',async()=>{
|
||||
let finish;const Hls=hlsClass();const f=fixture({Hls,loadHls:()=>new Promise(resolve=>finish=resolve)});
|
||||
const pending=f.media.setSource('/h5/m3u8/old.m3u8');
|
||||
f.controller.abort();finish(Hls);await pending;
|
||||
assert.equal(Hls.instances.length,0);assert.equal(f.video.src,'');await f.media.toggle();assert.equal(f.video.plays,0);
|
||||
});
|
||||
|
||||
test('a newer native source wins when an older HLS import completes late, and rejected play remains retryable',async()=>{
|
||||
let finish;const Hls=hlsClass();const f=fixture({Hls,loadHls:()=>new Promise(resolve=>finish=resolve)});
|
||||
try{
|
||||
const pending=f.media.setSource('/h5/m3u8/old.m3u8');await f.media.setSource('https://cdn.example/new.mp4');finish(Hls);await pending;
|
||||
assert.equal(Hls.instances.length,0);assert.equal(f.video.src,'https://cdn.example/new.mp4');
|
||||
f.video.rejectPlay=true;await f.media.toggle();assert.equal(f.video.paused,true);assert.equal(f.messages.at(-1),'请再次点击播放');
|
||||
f.video.rejectPlay=false;await f.media.toggle();assert.equal(f.video.paused,false);
|
||||
}finally{f.media.dispose();}
|
||||
});
|
||||
|
||||
test('codec incompatibility is distinguished from network, quota, and decryption failures',()=>{
|
||||
for(const details of ['manifestIncompatibleCodecsError','bufferAddCodecError','bufferIncompatibleCodecsError'])assert.equal(isCodecFailure({details}),true);
|
||||
for(const reason of ['Unsupported HEVC in M2TS found','H.265 codec not supported','incompatible AVC codec'])assert.equal(isCodecFailure({details:'fragParsingError',reason}),true);
|
||||
assert.equal(isCodecFailure({error:new DOMException('unsupported','NotSupportedError')}),true);
|
||||
for(const details of ['keyLoadError','fragDecryptError','fragLoadError'])assert.equal(isCodecFailure({details,error:Error('network unavailable')}),false);
|
||||
assert.equal(isCodecFailure({details:'bufferAppendError',error:new DOMException('full','QuotaExceededError')}),false);
|
||||
});
|
||||
|
||||
test('HEVC MSE codec errors try native HLS once, retaining signatures and playback position',async()=>{
|
||||
const f=fixture({native:true});const source='/h5/m3u8/hevc.m3u8?id=1&t=2&ticket=a%2Fb+1';
|
||||
try{
|
||||
await f.media.setSource(source);const hls=f.Hls.instances[0];f.video.currentTime=42;
|
||||
hls.emit('error',{fatal:true,type:'media',details:'bufferIncompatibleCodecsError',mimeType:'video/mp4;codecs="hvc1.1.6.L93.B0"'});
|
||||
assert.equal(hls.destroyed,1);assert.equal(f.video.src,source);assert.equal(f.errors,0);
|
||||
f.video.currentTime=0;f.video.dispatchEvent(new Event('loadedmetadata'));assert.equal(f.video.currentTime,42);
|
||||
hls.emit('error',{fatal:true,type:'media',details:'bufferAddCodecError'});assert.equal(f.errors,0);
|
||||
f.video.error={code:4};f.video.dispatchEvent(new Event('error'));await flush();
|
||||
assert.equal(f.errors,1);assert.equal(f.Hls.instances.length,1);assert.match(f.messages.at(-1),/H\.264\/H\.265/);
|
||||
}finally{f.media.dispose();}
|
||||
});
|
||||
|
||||
test('native decode failures can switch to the full HLS engine without showing a premature generic error',async()=>{
|
||||
const f=fixture({native:true,navigator:safari});let genericErrors=0;f.video.addEventListener('error',()=>genericErrors++);
|
||||
try{
|
||||
await f.media.setSource('https://cdn.example/hevc.m3u8?ticket=signed');f.video.currentTime=12.5;
|
||||
f.video.error={code:3};f.video.dispatchEvent(new Event('error'));f.video.dispatchEvent(new Event('error'));await flush();
|
||||
assert.equal(genericErrors,0);assert.equal(f.Hls.instances.length,1);assert.equal(f.video.src,'');assert.equal(f.errors,0);
|
||||
const hls=f.Hls.instances[0];assert.equal(hls.config.startPosition,12.5);assert.equal(hls.source,'https://cdn.example/hevc.m3u8?ticket=signed');
|
||||
hls.emit('error',{fatal:true,type:'media',details:'bufferAddCodecError'});
|
||||
assert.equal(f.errors,1);assert.equal(f.video.sourceWrites.length,1);assert.equal(hls.media,0);
|
||||
}finally{f.media.dispose();}
|
||||
});
|
||||
|
||||
test('nonfatal codec errors and compatible rendition retries are left to hls.js',async()=>{
|
||||
const f=fixture({native:true});
|
||||
try{
|
||||
await f.media.setSource('/h5/m3u8/multi-codec.m3u8');const hls=f.Hls.instances[0];
|
||||
hls.emit('error',{fatal:false,type:'media',details:'bufferAddCodecError'});
|
||||
hls.emit('error',{fatal:true,levelRetry:true,type:'media',details:'bufferAddCodecError'});
|
||||
assert.equal(f.video.src,'');assert.equal(hls.destroyed,0);assert.equal(f.errors,0);
|
||||
assert.equal(hls.config.videoPreference,undefined);
|
||||
}finally{f.media.dispose();}
|
||||
});
|
||||
|
||||
test('unsupported HEVC reports once without repeatedly recovering an incompatible decoder',async()=>{
|
||||
const f=fixture();
|
||||
try{
|
||||
await f.media.setSource('/h5/m3u8/hevc-only.m3u8');const hls=f.Hls.instances[0];
|
||||
for(let index=0;index<3;index++)hls.emit('error',{fatal:true,type:'media',details:'manifestIncompatibleCodecsError'});
|
||||
assert.equal(hls.media,0);assert.equal(hls.network,0);assert.equal(f.errors,1);assert.match(f.messages.at(-1),/不支持此视频编码/);
|
||||
await f.media.toggle();assert.equal(f.video.plays,0);
|
||||
}finally{f.media.dispose();}
|
||||
});
|
||||
|
||||
test('native MP4 codec failures show the decoding limitation without treating MP4 as an HLS playlist',async()=>{
|
||||
let imports=0;const f=fixture({loadHls:async()=>{imports++;return hlsClass();}});
|
||||
try{
|
||||
await f.media.setSource('https://cdn.example/hevc.mp4');
|
||||
f.video.error={code:2};f.video.dispatchEvent(new Event('error'));assert.equal(f.errors,1);assert.match(f.messages.at(-1),/视频加载失败/);
|
||||
await f.media.setSource('https://cdn.example/hevc.mp4');
|
||||
f.video.error={code:4};f.video.dispatchEvent(new Event('error'));
|
||||
assert.equal(imports,0);assert.equal(f.errors,2);assert.match(f.messages.at(-1),/H\.264\/H\.265/);
|
||||
}finally{f.media.dispose();}
|
||||
});
|
||||
|
||||
test('native HLS network failure reports the terminal error once and an aborted load is not counted',async()=>{
|
||||
const f=fixture({native:true,navigator:safari});
|
||||
try{
|
||||
await f.media.setSource('https://cdn.example/main.m3u8');
|
||||
f.video.error={code:1};f.video.dispatchEvent(new Event('error'));assert.equal(f.errors,0);
|
||||
f.video.error={code:2};f.video.dispatchEvent(new Event('error'));f.video.dispatchEvent(new Event('error'));
|
||||
assert.equal(f.errors,1);assert.match(f.messages.at(-1),/视频加载失败/);assert.equal(f.Hls.instances.length,0);
|
||||
}finally{f.media.dispose();}
|
||||
});
|
||||
|
||||
test('switching routes during a native-to-MSE fallback cannot install an outdated decoder',async()=>{
|
||||
let finish;const Hls=hlsClass();const f=fixture({native:true,navigator:safari,Hls,loadHls:()=>new Promise(resolve=>finish=resolve)});
|
||||
await f.media.setSource('/h5/m3u8/old.m3u8');
|
||||
f.video.error={code:4};f.video.dispatchEvent(new Event('error'));f.controller.abort();finish(Hls);await flush();
|
||||
assert.equal(Hls.instances.length,0);assert.equal(f.video.src,'');assert.equal(f.errors,0);
|
||||
});
|
||||
|
||||
function playerFixture({allowed=false,preview=true,unlock=async()=>({}),commentData={list:[],totalCount:0},detail={},chapterList,mobile=false,loadChapter}={}){
|
||||
const original=Object.fromEntries(['document','window','matchMedia'].map(key=>[key,globalThis[key]]));
|
||||
const signal=new AbortController();setMaxListeners(30,signal.signal);
|
||||
const page=new Element('MAIN'),box=new Element(),video=new Video(),overlay=new Element(),status=new Element(),lock=new Element('DIALOG');
|
||||
lock.open=false;lock.showModal=()=>{lock.open=true;};lock.close=()=>{const wasOpen=lock.open;lock.open=false;if(wasOpen)lock.dispatchEvent(new Event('close'));};
|
||||
const controlActions={};const mainButton=new Element('BUTTON'),previewButton=new Element('BUTTON');
|
||||
mainButton.dataset.playMode='main';previewButton.dataset.playMode='preview';
|
||||
const node=(parent,key)=>{if(!parent.nodes.has(key))parent.nodes.set(key,new Element());return parent.nodes.get(key);};
|
||||
page.nodes.set('video',video);page.nodes.set('.play-screen',box);page.nodes.set('.play-source-status',status);
|
||||
box.nodes.set('.play-screen-overlay',overlay);box.nodes.set('[data-media="toggle"]',new Element('BUTTON'));
|
||||
box.nodes.set('[data-play-action="previous"]',new Element('BUTTON'));box.nodes.set('[data-play-action="next"]',new Element('BUTTON'));
|
||||
for(const key of ['.play-episode-pages','.play-episode-grid','.play-comment-list','#play-comments-title','form'])node(page,key);
|
||||
page.nodes.set('.play-comment-form',page.nodes.get('form'));
|
||||
for(const key of ['.unlock-close','.unlock-pay','#episode-lock-balance','.unlock-cost','#episode-lock-error','#episode-lock-description','.unlock-recharge'])node(lock,key);
|
||||
page.lists.set('button[data-play-mode]',preview?[mainButton,previewButton]:[]);page.lists.set('[data-play-mode]',preview?[box,mainButton,previewButton]:[box]);
|
||||
const doc=new EventTarget();doc.getElementById=id=>id==='drama-play-page'?page:lock;doc.createElement=tag=>new Element(tag.toUpperCase());
|
||||
const browser=new EventTarget();browser.navigator=chrome;
|
||||
browser.installPlayerControls=(_box,_episode,actions)=>{Object.assign(controlActions,actions);};
|
||||
globalThis.document=doc;globalThis.window=browser;globalThis.matchMedia=()=>({matches:mobile});
|
||||
const posts=[],navigations=[],notifications=[],getCalls=[];let routes=0,refreshes=0;
|
||||
const chapters=chapterList||[{id:90001,currentEpisode:1},{id:90042,currentEpisode:2},{id:91200,currentEpisode:3}];
|
||||
const chapter={id:90042,playletId:23456,currentEpisode:2,canWatch:allowed,needUnlock:!allowed,payType:1,costCoins:12,balance:90,videoUrl:'https://cdn.example/main.mp4'};
|
||||
const api={user:{recharge:90},get:async(path,query)=>{
|
||||
getCalls.push({path,query});
|
||||
if(path==='playlet/detail')return {id:23456,title:'测试剧集',preview:preview?'https://cdn.example/preview.mp4':'',playletChapterInfo:chapters,...detail};
|
||||
if(path==='playlet/chapter/list')return {list:chapters};
|
||||
if(path==='playlet/chapter/detail')return loadChapter?loadChapter(query):chapter;
|
||||
if(path==='comment/list')return commentData;
|
||||
throw Error(path);
|
||||
},post:async(path,body)=>{posts.push({path,body});return path==='playlet/chapter/unlock'?unlock():{};},setUser(user){api.user=user;},refreshUser:async()=>{refreshes++;}};
|
||||
const app={api,signal:signal.signal,config:{},home:{},notify:value=>notifications.push(value),navigate:path=>navigations.push(path),route:async()=>{routes++;}};
|
||||
return {app,page,video,box,status,lock,posts,navigations,notifications,getCalls,mainButton,previewButton,controlActions,chapter,
|
||||
get routes(){return routes;},get refreshes(){return refreshes;},
|
||||
click(button){const event=new Event('click');Object.defineProperty(event,'target',{value:button});page.dispatchEvent(event);},
|
||||
dispose(){signal.abort();for(const [key,value] of Object.entries(original)){if(value===undefined)delete globalThis[key];else globalThis[key]=value;}}};
|
||||
}
|
||||
|
||||
function engagementControls(f,{like=197,collect=2}={}){
|
||||
const result={};
|
||||
for(const [kind,value] of [['like',like],['collect',collect]]){
|
||||
const button=new Element('BUTTON'),label=new Element('SPAN'),stat=new Element('SPAN');
|
||||
button.dataset.engage=kind;button.setAttribute('aria-pressed','false');label.textContent=kind==='like'?'点赞':'收藏';stat.textContent=value+(kind==='like'?'点赞':'收藏');
|
||||
button.nodes.set('span',label);f.page.lists.set(`[data-engage="${kind}"]`,[button]);f.page.lists.set(`[data-engage-count="${kind}"]`,[stat]);
|
||||
result[kind]={button,label,stat};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
test('likes and collections show pending feedback, reject duplicate clicks and update both counters after empty successes',async()=>{
|
||||
const f=playerFixture({detail:{countLike:197,countCollect:2}}),controls=engagementControls(f),requests=[];let dispose;
|
||||
f.page.nodes.delete('video');
|
||||
f.app.api.post=(path,body)=>{f.posts.push({path,body});return new Promise(resolve=>requests.push(resolve));};
|
||||
try{
|
||||
dispose=await renderPlayer(f.app,23456);await flush();const reads=f.getCalls.length;
|
||||
assert.ok(f.page.innerHTML.includes('data-engage-count="like"'));
|
||||
f.click(controls.like.button);f.click(controls.like.button);
|
||||
assert.equal(f.posts.length,1);assert.equal(controls.like.button.disabled,true);assert.equal(controls.like.button.getAttribute('aria-busy'),'true');assert.equal(controls.like.label.textContent,'请求中');
|
||||
assert.equal(controls.like.stat.textContent,'197点赞','counts change only after the write succeeds');
|
||||
f.click(controls.collect.button);assert.equal(f.posts.length,2);
|
||||
requests[0](null);await flush();assert.equal(controls.like.stat.textContent,'198点赞');assert.equal(controls.like.label.textContent,'已点赞');
|
||||
requests[1]({countCollect:3,isLike:0,countLike:0});await flush();assert.equal(controls.collect.stat.textContent,'3收藏');assert.equal(controls.collect.label.textContent,'已收藏');
|
||||
f.click(controls.like.button);assert.equal(f.posts.at(-1).body.isLike,false,'a concurrent collection response must not undo the like state');requests[2]({});await flush();
|
||||
assert.equal(controls.like.stat.textContent,'197点赞');assert.equal(controls.like.label.textContent,'点赞');assert.equal(controls.like.button.getAttribute('aria-pressed'),'false');
|
||||
assert.equal(controls.like.button.disabled,false);assert.equal(controls.like.button.hasAttribute('aria-busy'),false);
|
||||
assert.equal(f.getCalls.length,reads,'engagement writes do not wait for a detail refetch');
|
||||
}finally{dispose?.();f.dispose();}
|
||||
});
|
||||
|
||||
test('failed engagement writes restore controls and preserve counts; late responses after leaving do not change them',async()=>{
|
||||
const f=playerFixture({detail:{countLike:0,countCollect:0}}),controls=engagementControls(f,{like:0,collect:0});let dispose,resolve,reject;
|
||||
f.page.nodes.delete('video');
|
||||
f.app.api.post=()=>new Promise((yes,no)=>{resolve=yes;reject=no;});
|
||||
try{
|
||||
dispose=await renderPlayer(f.app,23456);await flush();
|
||||
f.click(controls.like.button);reject(new Error('请求失败'));await flush();
|
||||
assert.equal(controls.like.stat.textContent,'0点赞');assert.equal(controls.like.label.textContent,'点赞');assert.equal(controls.like.button.disabled,false);assert.equal(controls.like.button.hasAttribute('aria-busy'),false);assert.equal(f.notifications.at(-1),'请求失败');
|
||||
f.click(controls.like.button);resolve(null);await flush();assert.equal(controls.like.stat.textContent,'1点赞');
|
||||
f.click(controls.like.button);f.dispose();resolve(null);await flush();assert.equal(controls.like.stat.textContent,'1点赞');
|
||||
}finally{dispose?.();f.dispose();}
|
||||
});
|
||||
|
||||
test('play page likes and collections write the drama endpoints',async()=>{
|
||||
const f=playerFixture({allowed:true,detail:{countCollect:200,countLike:300}}),controls=engagementControls(f,{collect:9,like:7});let dispose;
|
||||
f.chapter.countCollect=9;f.chapter.countLike=7;f.chapter.isCollect=1;f.page.nodes.delete('video');
|
||||
f.app.api.post=async(path,body)=>{f.posts.push({path,body});return null;};
|
||||
try{
|
||||
dispose=await renderPlayer(f.app,23456,90042);await flush();
|
||||
assert.ok(f.page.innerHTML.includes('>200收藏</span>'));
|
||||
assert.ok(f.page.innerHTML.includes('data-engage="collect" aria-pressed="false"'));
|
||||
f.click(controls.collect.button);await flush();
|
||||
assert.deepEqual(f.posts.at(-1),{path:'playlet/collect/set',body:{id:23456,isCollect:true}});
|
||||
assert.equal(controls.collect.stat.textContent,'201收藏');assert.equal(controls.collect.label.textContent,'已收藏');assert.equal(f.chapter.countCollect,9);assert.equal(f.chapter.isCollect,1);
|
||||
f.chapter.isLike=1;f.app.api.post=async(path,body)=>{f.posts.push({path,body});return {id:23456,isLike:1,countLike:301};};
|
||||
f.click(controls.like.button);await flush();
|
||||
assert.deepEqual(f.posts.at(-1),{path:'playlet/like/set',body:{id:23456,isLike:true}},'play page likes the drama, not the chapter');
|
||||
assert.equal(controls.like.stat.textContent,'301点赞');assert.equal(controls.like.label.textContent,'已点赞');assert.equal(f.chapter.isLike,1);
|
||||
}finally{dispose?.();f.dispose();}
|
||||
});
|
||||
|
||||
test('play page like follows the drama status and writes playlet/like/set',async()=>{
|
||||
const f=playerFixture({allowed:true,detail:{isLike:1,countLike:12,isCollect:2,countCollect:4}}),controls=engagementControls(f,{like:12,collect:4});let dispose;
|
||||
f.chapter.isLike=2;f.chapter.isCollect=1;f.chapter.countCollect=4;f.page.nodes.delete('video');
|
||||
f.app.api.post=async(path,body)=>{f.posts.push({path,body});return {id:23456,isLike:2,countLike:11};};
|
||||
try{
|
||||
dispose=await renderPlayer(f.app,23456,90042);await flush();
|
||||
assert.ok(f.page.innerHTML.includes('data-engage="like" aria-pressed="true"'));
|
||||
assert.ok(f.page.innerHTML.includes('>已点赞</span>'));
|
||||
assert.ok(f.page.innerHTML.includes('data-engage="collect" aria-pressed="false"'));
|
||||
f.click(controls.like.button);await flush();
|
||||
assert.deepEqual(f.posts.at(-1),{path:'playlet/like/set',body:{id:23456,isLike:false}});
|
||||
assert.equal(controls.like.label.textContent,'点赞');assert.equal(controls.like.stat.textContent,'11点赞');assert.equal(controls.like.button.getAttribute('aria-pressed'),'false');
|
||||
assert.equal(f.chapter.isLike,2);
|
||||
}finally{dispose?.();f.dispose();}
|
||||
});
|
||||
|
||||
test('comment likes update their local count after success without refetching the comment list',async()=>{
|
||||
const f=playerFixture(),button=new Element('BUTTON'),label=new Element('SPAN');let dispose,resolve;
|
||||
f.page.nodes.delete('video');button.dataset.commentLike='8';button.dataset.commentCount='9';button.setAttribute('aria-pressed','false');label.textContent='♡ 9';button.nodes.set('[data-request-label]',label);
|
||||
f.app.api.post=(path,body)=>{f.posts.push({path,body});return new Promise(yes=>resolve=yes);};
|
||||
try{
|
||||
dispose=await renderPlayer(f.app,23456);await flush();const reads=f.getCalls.filter(call=>call.path==='comment/list').length;
|
||||
f.click(button);f.click(button);assert.equal(f.posts.length,1);assert.equal(label.textContent,'请求中');assert.equal(button.disabled,true);
|
||||
resolve(null);await flush();assert.equal(label.textContent,'♥ 10');assert.equal(button.getAttribute('aria-pressed'),'true');assert.equal(button.disabled,false);
|
||||
f.click(button);assert.equal(f.posts.at(-1).body.isLike,false);resolve({countLike:9});await flush();assert.equal(label.textContent,'♡ 9');
|
||||
assert.equal(f.getCalls.filter(call=>call.path==='comment/list').length,reads);
|
||||
}finally{dispose?.();f.dispose();}
|
||||
});
|
||||
|
||||
test('comment submission displays sending feedback, blocks duplicate submits and retains text when the write fails',async()=>{
|
||||
const f=playerFixture(),input=new Element('TEXTAREA'),button=new Element('BUTTON'),context=new Element(),error=new Element();let dispose,resolve,reject;
|
||||
f.page.nodes.delete('video');button.textContent='发表评论';input.value='评论内容';
|
||||
const form=f.page.querySelector('form');form.elements={namedItem:()=>input};form.nodes.set('[type="submit"]',button);f.page.nodes.set('#reply-context',context);f.page.nodes.set('#comment-error',error);
|
||||
const submit=()=>{const event=new Event('submit',{cancelable:true});Object.defineProperty(event,'target',{value:form});form.dispatchEvent(event);};
|
||||
f.app.api.post=(path,body)=>{f.posts.push({path,body});return new Promise((yes,no)=>{resolve=yes;reject=no;});};
|
||||
try{
|
||||
dispose=await renderPlayer(f.app,23456);await flush();
|
||||
submit();submit();assert.equal(f.posts.length,1);assert.equal(button.textContent,'发送中');assert.equal(button.disabled,true);
|
||||
reject(new Error('发送失败'));await flush();assert.equal(input.value,'评论内容');assert.equal(button.textContent,'发表评论');assert.equal(error.textContent,'发送失败');assert.equal(button.disabled,false);assert.deepEqual(f.notifications,[]);
|
||||
assert.deepEqual(f.posts[0],{path:'comment/send',body:{objectId:23456,objectType:10,text:'评论内容',parentsId:0,replyCommentId:0}});
|
||||
submit();resolve({});await flush();assert.equal(input.value,'');assert.equal(button.textContent,'发表评论');assert.equal(button.disabled,false);assert.equal(error.textContent,'');assert.deepEqual(f.notifications,['评论已发表']);
|
||||
}finally{dispose?.();f.dispose();}
|
||||
});
|
||||
|
||||
test('comment pagination uses page links and URL-provided page and sorting',async()=>{
|
||||
const f=playerFixture({allowed:true,commentData:{list:[],totalCount:95}});let dispose;
|
||||
const href=(page,sort)=>`/play/23456/90042/${page>1?`page/${page}/`:''}${sort===1?'?commentSort=1':''}`;
|
||||
try{
|
||||
dispose=await renderPlayer(f.app,23456,90042,false,{page:3,commentSort:1,pageHref:href});await flush();
|
||||
const request=f.getCalls.find(call=>call.path==='comment/list');
|
||||
assert.equal(request.query.objectId,23456);assert.equal(request.query.objectType,10);
|
||||
assert.equal(request.query.pageNum,3);assert.equal(request.query.sortOrder,1);assert.equal(request.query.pageSize,10);
|
||||
const html=f.page.querySelector('.play-comment-list').innerHTML;
|
||||
assert.ok(html.includes('href="/play/23456/90042/page/4/?commentSort=1"'));
|
||||
assert.ok(!html.includes('data-comment-page'));assert.ok(!html.includes('data-page-number'));
|
||||
assert.ok(f.page.innerHTML.includes('data-comment-sort="1" aria-pressed="true"'));
|
||||
const sort=new Element('BUTTON');sort.dataset.commentSort='2';f.click(sort);await flush();
|
||||
assert.equal(f.navigations.at(-1),'/play/23456/90042/');
|
||||
assert.equal(f.getCalls.filter(call=>call.path==='comment/list').length,1);
|
||||
}finally{dispose?.();f.dispose();}
|
||||
});
|
||||
|
||||
test('slug playback links survive client episode rendering, next controls, comments and confirmed unlocking with numeric write IDs',async()=>{
|
||||
const f=playerFixture({allowed:true,detail:{slug:'city-story',tagIds:[9],tagSlugs:['urban'],tagNames:['都市']},commentData:{list:[],totalCount:25}});let dispose;
|
||||
try{
|
||||
dispose=await renderPlayer(f.app,23456,90042);await flush();
|
||||
assert.ok(f.page.innerHTML.includes('href="/detail/city-story/"'));
|
||||
assert.ok(f.page.innerHTML.includes('href="/tag/urban/"'));
|
||||
assert.ok(f.page.querySelector('.play-episode-grid').innerHTML.includes('href="/play/city-story/90042/"'));
|
||||
assert.ok(f.page.querySelector('.play-comment-list').innerHTML.includes('href="/play/city-story/90042/page/2/"'));
|
||||
const next=new Element('BUTTON');next.dataset.playAction='next';f.click(next);await flush();
|
||||
assert.equal(f.navigations.at(-1),'/play/city-story/91200/');
|
||||
}finally{dispose?.();f.dispose();}
|
||||
const paid=playerFixture({detail:{slug:'city-story'},chapterList:lockedList,loadChapter:query=>lockedChapter(query.chapterId)});let stop;
|
||||
paid.page.nodes.delete('video');
|
||||
try{
|
||||
stop=await renderPlayer(paid.app,23456);paid.click(lockedButton(90042));await flush();
|
||||
paid.lock.querySelector('.unlock-pay').dispatchEvent(new Event('click'));await flush();
|
||||
assert.deepEqual(paid.posts[0],{path:'playlet/chapter/unlock',body:{chapterId:90042,playletId:23456}});
|
||||
assert.equal(paid.navigations.at(-1),'/play/city-story/90042/');
|
||||
}finally{stop?.();paid.dispose();}
|
||||
});
|
||||
|
||||
test('locked chapters offer only the free preview; choosing main opens confirmation and never auto-unlocks or reports preview views',async()=>{
|
||||
const f=playerFixture();let dispose;
|
||||
try{
|
||||
dispose=await renderPlayer(f.app,23456,90042);await flush();
|
||||
assert.equal(f.box.dataset.playMode,'preview');assert.equal(f.video.src,'https://cdn.example/preview.mp4');assert.equal(f.video.plays,0);
|
||||
await f.controlActions.toggle();assert.equal(f.video.plays,1);assert.equal(f.posts.length,0);
|
||||
f.mainButton.dispatchEvent(new Event('click'));await flush();
|
||||
assert.equal(f.video.src,'');assert.equal(f.box.dataset.playMode,'main');
|
||||
await f.controlActions.toggle();assert.equal(f.lock.open,true);assert.equal(f.posts.length,0);
|
||||
assert.equal(f.lock.querySelector('.unlock-cost').textContent,'12 金币');
|
||||
}finally{dispose?.();f.dispose();}
|
||||
});
|
||||
|
||||
test('allowed chapters play main despite stale lock flags; only main playback reports, and previous/next use actual IDs',async()=>{
|
||||
const f=playerFixture({allowed:true});let dispose;
|
||||
try{
|
||||
dispose=await renderPlayer(f.app,23456,90042);await flush();
|
||||
assert.equal(f.video.src,'https://cdn.example/main.mp4');await f.controlActions.toggle();await flush();
|
||||
assert.deepEqual(f.posts,[{path:'playlet/chapter/watch/report',body:{playletId:23456,chapterId:90042}}]);
|
||||
f.video.dispatchEvent(new Event('playing'));await flush();assert.equal(f.posts.length,1);
|
||||
f.previewButton.dispatchEvent(new Event('click'));await flush();assert.equal(f.video.src,'https://cdn.example/preview.mp4');assert.equal(f.posts.length,1);
|
||||
const previous=new Element('BUTTON');previous.dataset.playAction='previous';f.click(previous);
|
||||
const next=new Element('BUTTON');next.dataset.playAction='next';f.click(next);await flush();
|
||||
assert.deepEqual(f.navigations,['/play/23456/90001/','/play/23456/91200/']);
|
||||
dispose();assert.equal(f.video.src,'');assert.equal(f.video.paused,true);
|
||||
}finally{dispose?.();f.dispose();}
|
||||
});
|
||||
|
||||
test('unlocking requires an explicit confirmation, prevents double submission, then refreshes account and chapter data',async()=>{
|
||||
let finish;const f=playerFixture({preview:false,unlock:()=>new Promise(resolve=>finish=resolve)});let dispose;
|
||||
try{
|
||||
dispose=await renderPlayer(f.app,23456,90042);await f.controlActions.toggle();assert.equal(f.lock.open,true);assert.equal(f.posts.length,0);
|
||||
const pay=f.lock.querySelector('.unlock-pay');pay.dispatchEvent(new Event('click'));pay.dispatchEvent(new Event('click'));
|
||||
f.lock.close();await f.controlActions.toggle();pay.dispatchEvent(new Event('click'));
|
||||
assert.equal(f.posts.length,1);assert.deepEqual(f.posts[0],{path:'playlet/chapter/unlock',body:{chapterId:90042,playletId:23456}});
|
||||
finish({canWatch:true});await flush();assert.equal(f.lock.open,false);assert.equal(f.refreshes,1);assert.equal(f.routes,1);
|
||||
}finally{dispose?.();f.dispose();}
|
||||
});
|
||||
|
||||
test('actual player controls delegate play and retry to the media engine and allow unlock clicks to reach the page',async()=>{
|
||||
const source=await readFile(new URL('../public/scripts/site-runtime.js',import.meta.url),'utf8');
|
||||
const start=source.indexOf('function installPlayerControls('),end=source.indexOf('/* ---- sticky-search-header-script ---- */',start);
|
||||
const install=vm.runInNewContext(source.slice(start,end)+'\ninstallPlayerControls;', {document:{},emptyStateMarkup:()=>'<div>重试</div>'});
|
||||
const screen=new Element(),video=new Video(),bar=new Element(),overlay=new Element(),status=new Element(),center=new Element('BUTTON');
|
||||
video.volume=1;video.currentTime=0;video.duration=0;
|
||||
screen.nodes.set('video',video);screen.nodes.set('.play-screen-controls',bar);screen.nodes.set('.play-screen-overlay',overlay);screen.nodes.set('.play-source-status',status);overlay.nodes.set('button',center);
|
||||
for(const selector of ['[data-play-action="next"]','.player-seek','.player-volume input','[data-media="toggle"]','[data-media="mute"]','[data-media="fullscreen"]','.player-time'])bar.nodes.set(selector,new Element());
|
||||
let plays=0,retries=0;install(screen,2,{toggle:async()=>{plays++;},retry:async()=>{retries++;}});
|
||||
const click=(button)=>{const event=new Event('click');Object.defineProperty(event,'target',{value:button});let stopped=false;event.stopPropagation=()=>{stopped=true;};screen.dispatchEvent(event);return stopped;};
|
||||
assert.equal(click(center),true);await flush();assert.equal(plays,1);assert.equal(video.plays,0);
|
||||
const retry=new Element('BUTTON');retry.dataset.media='retry';click(retry);await flush();assert.equal(retries,1);assert.equal(video.loads,0);
|
||||
const unlock=new Element('BUTTON');unlock.setAttribute('data-show-unlock','');assert.equal(click(unlock),false);
|
||||
screen.dispatchEvent(new Event('playback:error'));assert.equal(center.hidden,true);assert.ok(overlay.innerHTML.includes('重试'));
|
||||
});
|
||||
|
||||
|
||||
test('detail overview keeps original action groups, actual chapter identifiers and styled comment replies',async()=>{
|
||||
const f=playerFixture({detail:{description:'接口简介',isNew:true,cover:'https://cdn.example/poster.webp'},commentData:{list:[{id:1,userName:'用户 <script>',avatar:'https://cdn.example/a.webp',text:'父评论',children:[{id:2,parentsId:1,replyCommentId:1,replyUserName:'用户 <script>',userAvatar:'https://cdn.example/b.webp',text:'回复'}]}],totalCount:2}});let dispose;
|
||||
f.app.api.user={recharge:90,avatar:'https://cdn.example/me.webp'};
|
||||
f.page.nodes.delete('video');
|
||||
try{
|
||||
dispose=await renderPlayer(f.app,23456);await flush();
|
||||
assert.ok(f.page.innerHTML.includes('href="/play/23456/90001/"'));assert.ok(f.page.innerHTML.includes('series-action-bar'));
|
||||
assert.ok(f.page.innerHTML.includes('接口简介'));assert.ok(f.page.innerHTML.includes('class="play-official"'));
|
||||
assert.ok(!f.getCalls.some(call=>call.path==='playlet/chapter/detail'));
|
||||
assert.ok(f.page.innerHTML.includes('class="play-compose-avatar"'));
|
||||
assert.ok(f.page.innerHTML.includes('data-src="https://cdn.example/me.webp"'));
|
||||
const comments=f.page.querySelector('.play-comment-list').innerHTML;
|
||||
assert.ok(comments.includes('class="play-comment-body"'));assert.ok(comments.includes('class="play-comment-replies"'));
|
||||
assert.ok(comments.includes('play-comment-item play-comment-child'));assert.ok(comments.includes('用户 <script>'));
|
||||
assert.ok(comments.includes('class="play-reply-target">回复 @用户 <script></span>'));
|
||||
assert.ok(comments.includes('class="play-comment-avatar"'));
|
||||
assert.ok(comments.includes('data-src="https://cdn.example/a.webp"'));
|
||||
assert.ok(comments.includes('data-src="https://cdn.example/b.webp"'));
|
||||
assert.ok(comments.includes('data-lazy-eager="1"'));
|
||||
const like=new Element('BUTTON');like.dataset.engage='like';like.nodes.set('span',new Element());
|
||||
f.click(like);await flush();assert.deepEqual(f.posts.at(-1),{path:'playlet/like/set',body:{id:23456,isLike:true}});
|
||||
assert.equal(like.querySelector('span').textContent,'已点赞');
|
||||
}finally{dispose?.();f.dispose();}
|
||||
});
|
||||
|
||||
test('comment composer avatar follows the signed-in account avatar and falls back like the header',()=>{
|
||||
const withUser=playerMarkup({id:1,title:'剧'},[],{user:{userId:7,avatar:'https://cdn.example/account.webp'}});
|
||||
assert.ok(withUser.includes('class="play-compose-avatar"'));
|
||||
assert.ok(withUser.includes('data-src="https://cdn.example/account.webp"'));
|
||||
assert.ok(withUser.includes('src="/icon.svg"'));
|
||||
const guest=playerMarkup({id:1,title:'剧'},[],{user:null});
|
||||
assert.ok(guest.includes('class="play-compose-avatar"'));
|
||||
assert.ok(guest.includes('src="/icon.svg"'));
|
||||
assert.ok(!guest.includes('data-src='));
|
||||
});
|
||||
|
||||
test('episode segments keep their anchor across desktop/mobile layout changes and show original lock and playing markers',async()=>{
|
||||
const chapterList=Array.from({length:130},(_,index)=>({id:90001+index,currentEpisode:index+1,canWatch:index<10,payType:index<10?0:1}));
|
||||
const f=playerFixture({allowed:true,chapterList});let dispose;
|
||||
try{
|
||||
dispose=await renderPlayer(f.app,23456,90042);await flush();
|
||||
const grid=f.page.querySelector('.play-episode-grid'),segments=f.page.querySelector('.play-episode-pages');
|
||||
assert.ok(segments.innerHTML.includes('class="episode-page-track"'));assert.ok(grid.innerHTML.includes('class="episode-playing"'));
|
||||
assert.ok(grid.innerHTML.includes('class="coin-soft-star"'));
|
||||
const tab=new Element('BUTTON');tab.dataset.episodePage='2';f.click(tab);await flush();assert.ok(grid.innerHTML.includes('data-locked-chapter="90091"'));
|
||||
globalThis.matchMedia=()=>({matches:true});window.dispatchEvent(new Event('resize'));
|
||||
assert.ok(grid.innerHTML.includes('data-locked-chapter="90091"'));assert.ok(!grid.innerHTML.includes('data-locked-chapter="90106"'));
|
||||
globalThis.matchMedia=()=>({matches:false});window.dispatchEvent(new Event('resize'));
|
||||
assert.ok(grid.innerHTML.includes('data-locked-chapter="90091"'));assert.ok(grid.innerHTML.includes('data-locked-chapter="90130"'));
|
||||
}finally{dispose?.();f.dispose();}
|
||||
});
|
||||
|
||||
test('episode list gating follows reference permissions independently of video URLs',()=>{
|
||||
for(const chapter of [{payType:0},{payType:1,isUnlocked:true},{canWatch:true,needUnlock:true,needVip:true,payType:1},{isUnlocked:true,needUnlock:true},{needVip:true,isUnlocked:true}])assert.equal(chapterListGate(chapter),'');
|
||||
for(const chapter of [{needUnlock:true},{payType:1}])assert.equal(chapterListGate(chapter),'coins');
|
||||
for(const chapter of [{needVip:true},{payType:2}])assert.equal(chapterListGate(chapter),'vip');
|
||||
assert.equal(chapterAccess({isUnlocked:true,needUnlock:true,payType:1,videoUrl:'https://cdn.example/owned.mp4'}).allowed,true);
|
||||
});
|
||||
|
||||
const lockedList=[{id:90001,currentEpisode:1,canWatch:true},{id:90042,currentEpisode:2,needUnlock:true,payType:1,singleCoins:30},{id:91200,currentEpisode:3,needUnlock:true,payType:1,singleCoins:30}];
|
||||
const lockedChapter=(id=90042,extra={})=>({id,playletId:23456,currentEpisode:id===90042?2:3,needUnlock:true,payType:1,costCoins:12,singleCoins:30,balance:90,...extra});
|
||||
const lockedButton=id=>{const button=new Element('BUTTON');button.dataset.lockedChapter=String(id);return button;};
|
||||
|
||||
test('clicking a coin episode on detail opens the original dialog, fetches live pricing, and waits for confirmation before navigating',async()=>{
|
||||
const f=playerFixture({chapterList:lockedList,loadChapter:query=>lockedChapter(query.chapterId),unlock:async()=>({balance:78})});let dispose;
|
||||
f.page.nodes.delete('video');
|
||||
try{
|
||||
dispose=await renderPlayer(f.app,23456);await flush();
|
||||
const grid=f.page.querySelector('.play-episode-grid').innerHTML;
|
||||
assert.ok(grid.includes('class="play-episode-locked" data-locked-chapter="90042"'));assert.ok(!grid.includes('href="/play/23456/90042/"'));
|
||||
f.click(lockedButton(90042));assert.equal(f.lock.open,true);assert.equal(f.lock.querySelector('.unlock-pay').disabled,true);
|
||||
f.click(lockedButton(90042));await flush();
|
||||
assert.equal(f.getCalls.filter(call=>call.path==='playlet/chapter/detail').length,1);
|
||||
assert.deepEqual(f.getCalls.find(call=>call.path==='playlet/chapter/detail').query,{playletId:23456,chapterId:90042});
|
||||
assert.equal(f.lock.querySelector('.unlock-cost').textContent,'12 金币');assert.equal(f.lock.querySelector('#episode-lock-balance').textContent,'90 金币');
|
||||
assert.match(f.lock.querySelector('#episode-lock-description').textContent,/第 2 集/);assert.equal(f.posts.length,0);assert.deepEqual(f.navigations,[]);
|
||||
f.lock.querySelector('.unlock-pay').dispatchEvent(new Event('click'));await flush();
|
||||
assert.deepEqual(f.posts,[{path:'playlet/chapter/unlock',body:{chapterId:90042,playletId:23456}}]);
|
||||
assert.equal(f.app.api.user.recharge,78);assert.equal(f.refreshes,1);assert.equal(f.lock.open,false);assert.deepEqual(f.navigations,['/play/23456/90042/']);
|
||||
}finally{dispose?.();f.dispose();}
|
||||
});
|
||||
|
||||
test('a late chapter response cannot replace the newer episode selected for payment',async()=>{
|
||||
const pending=new Map();
|
||||
const f=playerFixture({chapterList:lockedList,loadChapter:query=>new Promise(resolve=>pending.set(query.chapterId,resolve))});let dispose;
|
||||
f.page.nodes.delete('video');
|
||||
try{
|
||||
dispose=await renderPlayer(f.app,23456);f.click(lockedButton(90042));f.click(lockedButton(91200));
|
||||
pending.get(91200)(lockedChapter(91200,{costCoins:9}));await flush();
|
||||
pending.get(90042)(lockedChapter(90042,{costCoins:99}));await flush();
|
||||
assert.equal(f.lock.querySelector('.unlock-cost').textContent,'9 金币');assert.match(f.lock.querySelector('#episode-lock-description').textContent,/第 3 集/);
|
||||
f.lock.querySelector('.unlock-pay').dispatchEvent(new Event('click'));await flush();
|
||||
assert.deepEqual(f.posts[0],{path:'playlet/chapter/unlock',body:{chapterId:91200,playletId:23456}});assert.deepEqual(f.navigations,['/play/23456/91200/']);
|
||||
}finally{dispose?.();f.dispose();}
|
||||
});
|
||||
|
||||
test('closing a pending dialog or leaving the route prevents stale requests from reopening or submitting it',async()=>{
|
||||
for(const leave of [false,true]){
|
||||
let finish;const f=playerFixture({chapterList:lockedList,loadChapter:()=>new Promise(resolve=>finish=resolve)});let dispose;
|
||||
f.page.nodes.delete('video');
|
||||
try{
|
||||
dispose=await renderPlayer(f.app,23456);f.click(lockedButton(90042));
|
||||
if(leave)f.dispose();else f.lock.close();
|
||||
finish(lockedChapter());await flush();
|
||||
assert.equal(f.lock.open,false);f.lock.querySelector('.unlock-pay').dispatchEvent(new Event('click'));await flush();
|
||||
assert.equal(f.posts.length,0);assert.deepEqual(f.navigations,[]);
|
||||
}finally{dispose?.();f.dispose();}
|
||||
}
|
||||
});
|
||||
|
||||
test('already permitted and VIP chapters follow fresh access results instead of charging coins',async()=>{
|
||||
for(const [data,path] of [[lockedChapter(90042,{canWatch:true}),'/play/23456/90042/'],[lockedChapter(90042,{needUnlock:false,needVip:true,payType:2}),'/info/deposit/vip/']]){
|
||||
const f=playerFixture({chapterList:lockedList,loadChapter:()=>data});let dispose;f.page.nodes.delete('video');
|
||||
try{
|
||||
dispose=await renderPlayer(f.app,23456);f.click(lockedButton(90042));await flush();
|
||||
assert.equal(f.lock.open,false);assert.equal(f.posts.length,0);assert.deepEqual(f.navigations,[path]);
|
||||
}finally{dispose?.();f.dispose();}
|
||||
}
|
||||
});
|
||||
|
||||
test('a zero chapter balance shows the account coin balance',async()=>{
|
||||
const f=playerFixture({chapterList:lockedList,loadChapter:()=>lockedChapter(90042,{balance:0})});let dispose;f.page.nodes.delete('video');
|
||||
f.app.api.user.recharge=1000;
|
||||
try{
|
||||
dispose=await renderPlayer(f.app,23456);f.click(lockedButton(90042));await flush();
|
||||
assert.equal(f.lock.querySelector('#episode-lock-balance').textContent,'1000 金币');
|
||||
assert.equal(f.lock.querySelector('.unlock-pay').textContent,'支付 12 金币并播放');
|
||||
assert.equal(f.refreshes,1);
|
||||
}finally{dispose?.();f.dispose();}
|
||||
});
|
||||
|
||||
test('insufficient displayed balance offers recharge and never sends an unlock request',async()=>{
|
||||
const f=playerFixture({chapterList:lockedList,loadChapter:()=>lockedChapter(90042,{balance:3})});let dispose;f.page.nodes.delete('video');
|
||||
try{
|
||||
dispose=await renderPlayer(f.app,23456);f.click(lockedButton(90042));await flush();
|
||||
assert.equal(f.lock.querySelector('.unlock-pay').textContent,'金币不足,去充值');
|
||||
f.lock.querySelector('.unlock-pay').dispatchEvent(new Event('click'));await flush();
|
||||
assert.equal(f.posts.length,0);assert.equal(f.lock.open,false);assert.deepEqual(f.navigations,['/info/deposit/']);
|
||||
}finally{dispose?.();f.dispose();}
|
||||
});
|
||||
|
||||
test('mismatched chapter information leaves payment disabled without navigating or charging',async()=>{
|
||||
const f=playerFixture({chapterList:lockedList,loadChapter:()=>lockedChapter(91200)});let dispose;f.page.nodes.delete('video');
|
||||
try{
|
||||
dispose=await renderPlayer(f.app,23456);f.click(lockedButton(90042));await flush();
|
||||
assert.equal(f.lock.querySelector('.unlock-pay').disabled,true);assert.match(f.lock.querySelector('#episode-lock-error').textContent,/不匹配/);
|
||||
f.lock.querySelector('.unlock-pay').dispatchEvent(new Event('click'));await flush();assert.equal(f.posts.length,0);assert.deepEqual(f.navigations,[]);
|
||||
}finally{dispose?.();f.dispose();}
|
||||
});
|
||||
|
||||
test('payment errors remain visible and retryable; balance and auth errors follow reference recovery',async()=>{
|
||||
const {ApiError}=await import(protocolUrl);
|
||||
for(const [error,path] of [[new Error('网络暂不可用'),null],[new ApiError('金币余额不足',400),'/info/deposit/'],[new ApiError('登录已过期',1000),'login']]){
|
||||
let attempts=0,authPrompts=0;
|
||||
const f=playerFixture({chapterList:lockedList,loadChapter:()=>lockedChapter(),unlock:async()=>{attempts++;if(attempts===1)throw error;return {balance:78};}});let dispose;f.page.nodes.delete('video');f.app.openAuth=()=>authPrompts++;
|
||||
try{
|
||||
dispose=await renderPlayer(f.app,23456);f.click(lockedButton(90042));await flush();
|
||||
const pay=f.lock.querySelector('.unlock-pay');pay.dispatchEvent(new Event('click'));await flush();
|
||||
assert.equal(f.refreshes,0);
|
||||
if(path==='login'){assert.equal(authPrompts,1);assert.equal(f.lock.open,false);}
|
||||
else if(path){assert.equal(f.lock.open,false);assert.deepEqual(f.navigations,[path]);}
|
||||
else{
|
||||
assert.equal(f.lock.open,true);assert.equal(f.lock.querySelector('#episode-lock-error').textContent,error.message);assert.equal(f.lock.querySelector('#episode-lock-error').hidden,false);assert.equal(pay.disabled,false);
|
||||
pay.dispatchEvent(new Event('click'));await flush();assert.equal(f.posts.length,2);assert.equal(f.lock.open,false);assert.deepEqual(f.navigations,['/play/23456/90042/']);
|
||||
}
|
||||
}finally{dispose?.();f.dispose();}
|
||||
}
|
||||
});
|
||||
|
||||
test('a pending payment cannot be repeated or retargeted to a different episode',async()=>{
|
||||
let finish;const f=playerFixture({chapterList:lockedList,loadChapter:query=>lockedChapter(query.chapterId),unlock:()=>new Promise(resolve=>finish=resolve)});let dispose;f.page.nodes.delete('video');
|
||||
try{
|
||||
dispose=await renderPlayer(f.app,23456);f.click(lockedButton(90042));await flush();
|
||||
const pay=f.lock.querySelector('.unlock-pay');pay.dispatchEvent(new Event('click'));pay.dispatchEvent(new Event('click'));f.click(lockedButton(91200));await flush();
|
||||
const cancel=new Event('cancel',{cancelable:true});f.lock.dispatchEvent(cancel);
|
||||
assert.equal(cancel.defaultPrevented,true);assert.equal(f.lock.querySelector('.unlock-close').disabled,true);
|
||||
assert.equal(f.posts.length,1);assert.equal(f.getCalls.filter(call=>call.path==='playlet/chapter/detail').length,1);
|
||||
finish({balance:78});await flush();assert.deepEqual(f.navigations,['/play/23456/90042/']);assert.equal(f.lock.open,false);
|
||||
}finally{dispose?.();f.dispose();}
|
||||
});
|
||||
|
||||
|
||||
test('server player data excludes credentials and denied sources while retaining free episodes, comments and artwork',()=>{
|
||||
const locked={id:90042,currentEpisode:2,countLike:27,countCollect:8,countComment:3,ratio:0.5625,seoInfo:{title:'章节独立标题'},canWatch:false,needUnlock:true,payType:1,videoUrl:'https://cdn.example/secret-locked.mp4',token:'chapter-secret'};
|
||||
const free={id:90001,currentEpisode:1,canWatch:true,videoUrl:'https://cdn.example/free.mp4'};
|
||||
const initial=playerInitialData({id:23456,title:'公开剧名',cover:'https://cdn.example/cover.webp',token:'detail-secret',reportSdkConfig:{secret:'sdk-secret'},advitise:{nineGridList:[{id:'a',images:'https://cdn.example/ad.webp',title:'广告',link:'https://example.com',secret:'ad-secret'}]},relatedRecommendData:[{id:7,title:'相关剧集',token:'related-secret'}]},[free,locked],locked,{list:[{id:1,text:'评论正文',token:'comment-secret',children:[{id:2,text:'回复',replyUserName:'甲',replyCommentId:1,parentsId:1,token:'child-secret'}]}],totalCount:12});
|
||||
const json=JSON.stringify(initial);
|
||||
for(const value of ['detail-secret','chapter-secret','sdk-secret','ad-secret','related-secret','comment-secret','child-secret','secret-locked.mp4'])assert.ok(!json.includes(value),value);
|
||||
assert.equal(initial.chapters[0].videoUrl,free.videoUrl);assert.equal(initial.chapter.canWatch,false);assert.equal(initial.chapter.needUnlock,true);
|
||||
assert.equal(initial.chapter.countLike,27);assert.equal(initial.chapter.ratio,0.5625);assert.equal(initial.chapter.seoInfo.title,'章节独立标题');
|
||||
assert.equal(initial.comments.list[0].text,'评论正文');assert.equal(initial.comments.list[0].children[0].text,'回复');assert.equal(initial.comments.list[0].children[0].replyUserName,'甲');assert.equal(initial.detail.advitise.nineGridList[0].images,'https://cdn.example/ad.webp');
|
||||
});
|
||||
|
||||
test('player attaches to server markup without repeating initial reads and still verifies coin pricing on click',async()=>{
|
||||
const f=playerFixture();let dispose;
|
||||
const locked={...f.chapter,videoUrl:undefined};
|
||||
const initial={detail:{id:23456,title:'首屏详情'},chapters:[{id:90001,currentEpisode:1,canWatch:true},locked],chapter:null,comments:{list:[{id:1,text:'首屏评论'}],totalCount:1}};
|
||||
try{
|
||||
f.page.innerHTML='<article>首屏详情</article>';f.page.querySelector('.play-comment-list').innerHTML='<p>首屏评论</p>';
|
||||
dispose=await renderPlayer(f.app,23456,undefined,false,{initial,preserveMarkup:true});await flush();
|
||||
assert.equal(f.getCalls.length,0);assert.equal(f.page.innerHTML,'<article>首屏详情</article>');assert.equal(f.page.querySelector('.play-comment-list').innerHTML,'<p>首屏评论</p>');
|
||||
f.click(lockedButton(90042));await flush();
|
||||
assert.equal(f.getCalls.length,1);assert.equal(f.getCalls[0].path,'playlet/chapter/detail');assert.equal(f.lock.open,true);assert.equal(f.posts.length,0);
|
||||
}finally{dispose?.();f.dispose();}
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFile} from 'node:fs/promises';
|
||||
import ts from 'typescript';
|
||||
import {setMaxListeners} from 'node:events';
|
||||
|
||||
const code=ts.transpileModule(await readFile(new URL('../src/lib/client/promo-marquee.ts',import.meta.url),'utf8'),{compilerOptions:{module:ts.ModuleKind.ESNext,target:ts.ScriptTarget.ES2022}}).outputText;
|
||||
const {bindPromotionMarquee}=await import('data:text/javascript;base64,'+Buffer.from(code).toString('base64'));
|
||||
|
||||
class Element extends EventTarget {
|
||||
attributes=new Map();children=[];tabIndex=0;
|
||||
setAttribute(key,value){this.attributes.set(key,value);}
|
||||
hasAttribute(key){return this.attributes.has(key);}
|
||||
removeAttribute(key){this.attributes.delete(key);}
|
||||
remove(){if(this.parent)this.parent.children=this.parent.children.filter(child=>child!==this);}
|
||||
append(child){this.children.push(child);child.parent=this;}
|
||||
querySelectorAll(selector){return selector==='[id]'?this.children.filter(child=>child.hasAttribute('id')):this.children;}
|
||||
cloneNode(){const copy=new Element();for(const child of this.children){const link=new Element();link.attributes=new Map(child.attributes);copy.append(link);}return copy;}
|
||||
}
|
||||
|
||||
function event(target,type,values={}){const e=new Event(type);for(const [key,value] of Object.entries(values))Object.defineProperty(e,key,{value});target.dispatchEvent(e);}
|
||||
function fixture({width=800,viewport=500,reduced=false}={}){
|
||||
const previous=globalThis.ResizeObserver,frames=new Map(),timers=new Map();let sequence=0,observer;
|
||||
const root=new Element(),group=new Element(),track=new Element(),link=new Element(),doc=new EventTarget(),view=new EventTarget(),motion=new EventTarget();
|
||||
link.setAttribute('id','original-ad');group.append(link);track.append(group);
|
||||
root.clientWidth=viewport;root.scrollLeft=0;root.isConnected=true;
|
||||
root.querySelector=selector=>selector==='.promo-group'?group:track;
|
||||
root.contains=node=>node===link||track.children.some(child=>child.children.includes(node));
|
||||
group.getBoundingClientRect=()=>({width:width+(root.hasAttribute('data-looping')?14:0)});
|
||||
doc.hidden=false;doc.activeElement=null;doc.defaultView=view;root.ownerDocument=doc;
|
||||
motion.matches=reduced;view.matchMedia=()=>motion;
|
||||
view.getComputedStyle=()=>({paddingRight:root.hasAttribute('data-looping')?'14px':'0px'});
|
||||
view.requestAnimationFrame=callback=>{const id=++sequence;frames.set(id,callback);return id;};
|
||||
view.cancelAnimationFrame=id=>frames.delete(id);
|
||||
view.setTimeout=callback=>{const id=++sequence;timers.set(id,callback);return id;};view.clearTimeout=id=>timers.delete(id);
|
||||
globalThis.ResizeObserver=class {constructor(callback){this.callback=callback;this.disconnects=0;observer=this;}observe(){}disconnect(){this.disconnects++;}};
|
||||
const controller=new AbortController();setMaxListeners(30,controller.signal);
|
||||
const dispose=bindPromotionMarquee(root,controller.signal);
|
||||
return {root,group,track,link,doc,view,motion,frames,timers,controller,
|
||||
tick(time){const callbacks=[...frames.values()];frames.clear();for(const callback of callbacks)callback(time);},
|
||||
settle(){const callbacks=[...timers.values()];timers.clear();for(const callback of callbacks)callback();},
|
||||
resize(value){root.clientWidth=value;observer.callback();},
|
||||
get observer(){return observer;},
|
||||
close(){dispose();if(previous===undefined)delete globalThis.ResizeObserver;else globalThis.ResizeObserver=previous;},
|
||||
};
|
||||
}
|
||||
|
||||
test('a fitting row stays still; overflow makes one seamless clickable copy and resizing removes it',()=>{
|
||||
const f=fixture({viewport:900});
|
||||
try{
|
||||
assert.equal(f.track.children.length,1);assert.equal(f.frames.size,0);
|
||||
f.resize(500);assert.equal(f.track.children.length,2);assert.equal(f.frames.size,1);
|
||||
const copy=f.track.children[1];assert.equal(copy.attributes.get('aria-hidden'),'true');assert.equal(copy.children[0].tabIndex,-1);assert.equal(copy.children[0].hasAttribute('id'),false);
|
||||
f.resize(499);assert.equal(f.track.children.length,2);assert.equal(f.frames.size,1);
|
||||
f.tick(0);f.tick(100);assert.ok(Math.abs(f.root.scrollLeft-3.2)<0.001);
|
||||
event(f.root,'wheel');f.root.scrollLeft=813;f.settle();f.tick(200);f.tick(300);
|
||||
assert.ok(Math.abs(f.root.scrollLeft-2.2)<0.001,'scrolling must wrap by exactly the original group including its trailing gap');
|
||||
f.resize(1000);assert.equal(f.track.children.length,1);assert.equal(f.frames.size,0);assert.equal(f.root.hasAttribute('data-looping'),false);
|
||||
}finally{f.close();}
|
||||
});
|
||||
|
||||
test('mouse hover and keyboard focus pause the row and resume from the existing position',()=>{
|
||||
const f=fixture();
|
||||
try{
|
||||
f.tick(0);f.tick(100);const position=f.root.scrollLeft;
|
||||
event(f.root,'pointerenter',{pointerType:'mouse'});assert.equal(f.frames.size,0);
|
||||
event(f.root,'pointerleave',{pointerType:'mouse'});assert.equal(f.frames.size,1);f.tick(10000);assert.equal(f.root.scrollLeft,position);
|
||||
f.doc.activeElement=f.link;event(f.root,'focusin');assert.equal(f.frames.size,0);
|
||||
event(f.root,'focusout',{relatedTarget:null});f.doc.activeElement=null;f.settle();assert.equal(f.frames.size,1);
|
||||
}finally{f.close();}
|
||||
});
|
||||
|
||||
test('touch momentum finishes before auto-scrolling resumes, without changing the manually selected ad',()=>{
|
||||
const f=fixture();
|
||||
try{
|
||||
event(f.root,'pointerdown',{pointerType:'touch'});assert.equal(f.frames.size,0);
|
||||
f.root.scrollLeft=937.5;event(f.view,'pointerup');assert.equal(f.timers.size,1);
|
||||
event(f.root,'scroll');assert.equal(f.timers.size,1);
|
||||
f.settle();f.tick(0);f.tick(100);
|
||||
assert.ok(Math.abs(f.root.scrollLeft-126.7)<0.001);assert.equal(f.frames.size,1);
|
||||
}finally{f.close();}
|
||||
});
|
||||
|
||||
test('reduced motion, hidden pages and route cancellation stop animation and clean up listeners and duplicate links',()=>{
|
||||
const f=fixture({reduced:true});
|
||||
try{
|
||||
assert.equal(f.track.children.length,1);assert.equal(f.frames.size,0);
|
||||
f.motion.matches=false;event(f.motion,'change');assert.equal(f.track.children.length,2);
|
||||
f.doc.hidden=true;event(f.doc,'visibilitychange');assert.equal(f.frames.size,0);
|
||||
f.doc.hidden=false;event(f.doc,'visibilitychange');assert.equal(f.frames.size,1);
|
||||
f.controller.abort();assert.equal(f.frames.size,0);assert.equal(f.track.children.length,1);assert.equal(f.observer.disconnects,1);
|
||||
event(f.root,'pointerdown');event(f.view,'pointerup');assert.equal(f.timers.size,0,'cancelled routes must remove interaction handlers');
|
||||
}finally{f.close();}
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
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,replace=value=>value){
|
||||
const code=ts.transpileModule(await readFile(new URL(path,import.meta.url),'utf8'),{compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.ESNext}}).outputText;
|
||||
return 'data:text/javascript;base64,'+Buffer.from(replace(code)).toString('base64');
|
||||
}
|
||||
const {PublicDataCache,publicCachePolicy}=await import(await moduleURL('../src/lib/api/public-cache.ts'));
|
||||
const protocol=await moduleURL('../src/lib/api/protocol.ts');
|
||||
const {publicSiteData}=await import(await moduleURL('../src/lib/site-shared-data.ts',code=>code.replace("'./api/protocol'",JSON.stringify(protocol))));
|
||||
|
||||
test('public cache coalesces concurrent requests, isolates returned objects and refreshes after expiry',async()=>{
|
||||
let now=0,reads=0,release;const cache=new PublicDataCache(()=>now);
|
||||
const load=()=>{reads++;return new Promise(resolve=>release=resolve);};
|
||||
const a=cache.read('api-a|config',100,load),b=cache.read('api-a|config',100,load);
|
||||
assert.equal(reads,1);release({list:[{name:'original'}]});
|
||||
const [first,second]=await Promise.all([a,b]);first.list[0].name='mutated';assert.equal(second.list[0].name,'original');
|
||||
assert.equal((await cache.read('api-a|config',100,load)).list[0].name,'original');assert.equal(reads,1);
|
||||
now=101;const refreshed=cache.read('api-a|config',100,load);assert.equal(reads,2);release({list:[{name:'updated'}]});assert.equal((await refreshed).list[0].name,'updated');
|
||||
assert.equal((await cache.read('api-b|config',100,async()=>({list:[]}))).list.length,0);
|
||||
});
|
||||
|
||||
test('only successful payloads are cached; configured stale values survive outages without caching failures',async()=>{
|
||||
let now=0,reads=0;const cache=new PublicDataCache(()=>now),options={stale:true,accept:value=>value.code===200};
|
||||
await cache.read('config',10,async()=>({code:200,name:'good'}),options);now=11;
|
||||
const failed=async()=>{reads++;throw new Error('upstream offline');};
|
||||
assert.equal((await cache.read('config',10,failed,options)).name,'good');assert.equal((await cache.read('config',10,failed,options)).name,'good');assert.equal(reads,2);
|
||||
assert.equal((await cache.read('config',10,async()=>({code:500}),options)).name,'good');
|
||||
await assert.rejects(cache.read('navigation',10,failed),/offline/);
|
||||
assert.equal((await cache.read('navigation',10,async()=>({code:200,name:'recovered'}))).name,'recovered');
|
||||
await cache.read('invalid',10,async()=>({code:500}),{accept:value=>value.code===200});
|
||||
assert.equal((await cache.read('invalid',10,async()=>({code:200,name:'valid'}))).name,'valid');
|
||||
});
|
||||
|
||||
test('cache bounds and endpoint policies keep private data and indefinite growth out of the shared cache',async()=>{
|
||||
const cache=new PublicDataCache(Date.now,2);let reads=0;
|
||||
const load=async()=>({count:++reads});
|
||||
for(const key of ['a','b','c'])await cache.read(key,10000,load);
|
||||
assert.equal((await cache.read('a',10000,load)).count,4);
|
||||
await cache.read('a',0,load);assert.equal(reads,5);
|
||||
assert.equal(publicCachePolicy('playlet/home').ttl,30000);assert.equal(publicCachePolicy('config/info').ttl,60000);
|
||||
for(const endpoint of ['auth/getInfo','auth/guest','coin/trans/list','playlet/detail','playlet/chapter/detail','playlet/history/list','recharge/package/list'])assert.equal(publicCachePolicy(endpoint),undefined);
|
||||
assert.equal(publicCachePolicy('config/info','0').ttl,0);assert.equal(publicCachePolicy('playlet/home','999999').ttl,30000);
|
||||
});
|
||||
|
||||
test('shared browser snapshot contains visible configuration and footer fields without SDK or unknown secrets',()=>{
|
||||
const data=publicSiteData({reportSdkConfig:'private-sdk-cipher',token:'private-token',config:{officialAppAddr:'https://app.test/download',telegram:'https://t.me/business',apiKey:'private-api-key'},advitise:{dialogList:[{id:'a',title:'广告',images:'https://ad.test/image',link:'https://ad.test',token:'private-ad-token'}],unknown:[{secret:'private-slot'}]}},{footerInfo:{brand:{title:'黄果短剧',content:'真实页脚',token:'private-brand-token'},contactList:[{name:'服务时间',value:'每天',token:'private-contact-token'}],navList:[{id:1,name:'规则',urlValue:'/ignored/',token:'private-nav-token',translationList:[{id:2,name:'关于我们',urlValue:'/richetext/关于我们',secret:'private-nav-child'}]}]}});
|
||||
const text=JSON.stringify(data);for(const secret of ['private-sdk-cipher','private-token','private-api-key','private-ad-token','private-slot','private-contact-token','private-nav-token','private-nav-child','private-brand-token'])assert.ok(!text.includes(secret));
|
||||
assert.equal(data.trackerConfigured,true);assert.equal(data.config.config.officialAppAddr,'https://app.test/download');assert.equal(data.footer.footerInfo.brand.title,'黄果短剧');assert.equal(data.footer.footerInfo.brand.content,'真实页脚');
|
||||
assert.equal(data.footer.footerInfo.navList[0].translationList[0].name,'关于我们');assert.equal(data.footer.footerInfo.navList[0].translationList[0].urlValue,'/richetext/关于我们');
|
||||
assert.doesNotThrow(()=>publicSiteData({config:null,advitise:null},{footerInfo:null}));
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
import {routingModuleUrl} from './helpers/routing.mjs';
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFile} from 'node:fs/promises';
|
||||
import vm from 'node:vm';
|
||||
import ts from 'typescript';
|
||||
import {seo,clientSeo,headDocument} from './helpers/seo.mjs';
|
||||
|
||||
const routingFile=new URL('../src/lib/routing.ts',import.meta.url);
|
||||
const routingCode=ts.transpileModule(await readFile(routingFile,'utf8'),{compilerOptions:{module:ts.ModuleKind.ESNext,target:ts.ScriptTarget.ES2022}}).outputText;
|
||||
const routing=await import('data:text/javascript;base64,'+Buffer.from(routingCode+'\n//# sourceURL='+routingFile.pathname).toString('base64'));
|
||||
const protocolFile=new URL('../src/lib/api/protocol.ts',import.meta.url);
|
||||
const protocolCode=ts.transpileModule(await readFile(protocolFile,'utf8'),{compilerOptions:{module:ts.ModuleKind.ESNext,target:ts.ScriptTarget.ES2022}}).outputText;
|
||||
const protocolUrl='data:text/javascript;base64,'+Buffer.from(protocolCode).toString('base64');
|
||||
const protocol=await import(protocolUrl);
|
||||
const renderCode=ts.transpileModule(await readFile(new URL('../src/lib/client/render.ts',import.meta.url),'utf8'),{compilerOptions:{module:ts.ModuleKind.ESNext,target:ts.ScriptTarget.ES2022}}).outputText;
|
||||
const renderUrl='data:text/javascript;base64,'+Buffer.from(renderCode.replace("'../routing'",JSON.stringify(routingModuleUrl)).replace('"../api/protocol"',JSON.stringify(protocolUrl))).toString('base64');
|
||||
const searchCode=ts.transpileModule(await readFile(new URL('../src/lib/client/search.ts',import.meta.url),'utf8'),{compilerOptions:{module:ts.ModuleKind.ESNext,target:ts.ScriptTarget.ES2022}}).outputText;
|
||||
const search=await import('data:text/javascript;base64,'+Buffer.from(searchCode.replace("'../routing'",JSON.stringify(routingModuleUrl)).replace("'../api/protocol'",JSON.stringify(protocolUrl)).replace("'./render'",JSON.stringify(renderUrl))).toString('base64'));
|
||||
const infoCode=ts.transpileModule(await readFile(new URL('../src/lib/info-pages.ts',import.meta.url),'utf8'),{compilerOptions:{module:ts.ModuleKind.ESNext,target:ts.ScriptTarget.ES2022}}).outputText;
|
||||
const infoPages=await import('data:text/javascript;base64,'+Buffer.from(infoCode).toString('base64'));
|
||||
const siteNavigationCode=ts.transpileModule(await readFile(new URL('../src/lib/site-navigation.ts',import.meta.url),'utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022}}).outputText;
|
||||
const applicationFile=new URL('../src/lib/client/application.ts',import.meta.url);
|
||||
const applicationCode=ts.transpileModule(await readFile(applicationFile,'utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022}}).outputText;
|
||||
|
||||
const channelCode=ts.transpileModule(await readFile(new URL('../src/lib/client/channel.ts',import.meta.url),'utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022}}).outputText;
|
||||
|
||||
function fixture(load=async()=>({list:[{id:1}],totalCount:72,name:'接口通用标题'}),options={}){
|
||||
const nodes=new Map(),calls=[],cardCalls=[],reloads=[];
|
||||
const location={pathname:'/rank/hot/',search:'',origin:'https://site.test',reload:()=>reloads.push(location.pathname+location.search)};
|
||||
const sidebar={dataset:{page:'rank'},classList:{toggle(name,active){this[name]=active;}},setAttribute(){},removeAttribute(){}};
|
||||
const document={...headDocument(),querySelector(selector){
|
||||
if(selector==='dialog[open]')return null;
|
||||
if(!nodes.has(selector))nodes.set(selector,{innerHTML:'',textContent:'',hidden:false,dataset:{},setAttribute(name,value){this[name]=value;}});
|
||||
return nodes.get(selector);
|
||||
},querySelectorAll:selector=>selector.startsWith('a[data-page]')?[sidebar]:selector==='[data-promotion-row]'?options.promotionRows||[]:[]};
|
||||
class ApiClient {async get(path,query,signal){calls.push({path,...query,signal});return load(path,query,signal);}async revalidateSession(){calls.push({path:'restore-session'});}}
|
||||
const exports={},channelExports={},siteNavigationExports={};
|
||||
const require=specifier=>{
|
||||
if(specifier==='../site-navigation')return siteNavigationExports;
|
||||
if(specifier==='./routing')return routing;
|
||||
if(specifier==='./api/protocol')return protocol;
|
||||
if(specifier==='./channel')return channelExports;
|
||||
if(specifier==='./api')return {ApiClient};
|
||||
if(specifier==='../routing')return routing;
|
||||
if(specifier==='../api/protocol')return protocol;
|
||||
if(specifier==='./search')return search;
|
||||
if(specifier==='../info-pages')return infoPages;
|
||||
if(specifier==='../seo')return seo;
|
||||
if(specifier==='./seo')return {applyPageHead:head=>clientSeo.applyPageHead(head,document)};
|
||||
if(specifier==='./config')return {configureSite:async()=>{}};
|
||||
if(specifier==='./hero-swipe')return {bindHeroSwipe(){}};
|
||||
if(specifier==='./promo-marquee')return {bindPromotionMarquee:root=>{calls.push({path:'bindPromotionMarquee',root});return ()=>calls.push({path:'disposePromotionMarquee',root});}};
|
||||
if(specifier==='./account')return {renderMember:async(...args)=>calls.push({path:'renderMember',args}),renderWallet:async(...args)=>calls.push({path:'renderWallet',args})};
|
||||
if(specifier==='./feedback')return {renderFeedbackPage:(...args)=>calls.push({path:'renderFeedbackPage',args})};
|
||||
if(specifier==='./player')return {renderPlayer:async(_app,id,chapterId,isVideo,options)=>{calls.push({path:'renderPlayer',id,chapterId,isVideo,...options});return ()=>{};}};
|
||||
if(specifier==='./render')return {escape:String,empty:message=>`<p>${message}</p>`,loading:()=>'<p>加载中</p>',activeBanners:()=>[],topicCard:()=>'<a href="/topics/1/">专题</a>',renderHome:()=>'<div data-home-loaded>接口首页</div>',card:(item,video,options)=>{cardCalls.push({item,video,options});return '<a href="/detail/1/">剧集</a>';},pagination:(_,page)=>`<span>第 ${page} 页</span>`};
|
||||
return {};
|
||||
};
|
||||
vm.runInNewContext(siteNavigationCode,{exports:siteNavigationExports,require,URL,URLSearchParams});
|
||||
vm.runInNewContext(channelCode,{exports:channelExports,require,URL,URLSearchParams});
|
||||
vm.runInNewContext(applicationCode,{exports,require,document,location,AbortController,Event,Error,URL,URLSearchParams,clearInterval(){},window:{location,scrollTo(){},dispatchEvent(){}}},{filename:applicationFile.pathname});
|
||||
return {app:new exports.Application({},true,options.seo,options.page,undefined,options.shared),nodes,calls,cardCalls,location,document,sidebar,reloads,channel:channelExports};
|
||||
}
|
||||
|
||||
test('all native pages bind every square ad row and clean up old loops before rebinding',async()=>{
|
||||
for(const pathname of ['/','/shorts/','/detail/7/','/play/7/8/','/member/favorites/','/info/deposit/']){
|
||||
const rows=[{name:'first row'},{name:'second row'}],f=fixture(undefined,{promotionRows:rows});
|
||||
f.location.pathname=pathname;await f.app.route();
|
||||
assert.deepEqual(f.calls.filter(call=>call.path==='bindPromotionMarquee').map(call=>call.root),rows,pathname);
|
||||
f.app.bindPromotions();
|
||||
assert.deepEqual(f.calls.filter(call=>call.path==='disposePromotionMarquee').map(call=>call.root),rows,pathname);
|
||||
assert.equal(f.calls.filter(call=>call.path==='bindPromotionMarquee').length,4,pathname);
|
||||
}
|
||||
});
|
||||
|
||||
test('about uses business Telegram and the feedback group while retaining configured service hours',()=>{
|
||||
const page=infoPages.getInfoPage('about-us',{config:{telegram:'https://t.me/business_team',tgGroup:'https://t.me/feedback_group',gmailAddr:'ignored@example.com',customerServiceHost:'https://ignored.example.com'}},[
|
||||
{name:'商务合作',value:'business@example.com'},{name:'用户反馈',value:'mailto:support@example.com'},
|
||||
{name:'服务时间',value:'周一至周五 09:00–18:00'},{name:'邮箱',value:'ignored@example.com'},
|
||||
]);
|
||||
assert.ok(page.html.includes('商务合作:<a href="https://t.me/business_team"'));
|
||||
assert.ok(page.html.includes('用户反馈:<a href="https://t.me/feedback_group"'));
|
||||
assert.ok(page.html.includes('<p>服务时间:周一至周五 09:00–18:00</p>'));
|
||||
assert.ok(!page.html.includes('mailto:'));assert.ok(!page.html.includes('@example.com'));assert.ok(!page.html.includes('ignored.example.com'));
|
||||
});
|
||||
|
||||
test('Telegram contacts prefer site configuration, fall back to address configuration and hide absent channels',()=>{
|
||||
const html=infoPages.infoContactSection({config:{telegram:'',tgGroup:''},newAddressInfo:{telegram:'@business_team',tgGroup:'https://t.me/feedback_group'}},null);
|
||||
assert.ok(html.includes('商务合作:<a href="https://t.me/business_team"'));assert.ok(html.includes('用户反馈:<a href="https://t.me/feedback_group"'));
|
||||
assert.ok(!html.includes('服务时间'));
|
||||
const preferred=infoPages.infoContactSection({config:{telegram:'https://t.me/primary'},newAddressInfo:{telegram:'https://t.me/fallback'}});
|
||||
assert.ok(preferred.includes('t.me/primary'));assert.ok(!preferred.includes('t.me/fallback'));assert.ok(!preferred.includes('用户反馈'));
|
||||
assert.ok(infoPages.infoContactSection({config:{gmailAddr:'help@example.com',customerServiceHost:'https://ignored.example.com'}}).includes(' hidden'));
|
||||
assert.ok(infoPages.infoContactSection({config:{telegramAdd:'https://t.me/legacy'}}).includes('t.me/legacy'));
|
||||
});
|
||||
|
||||
test('configured contacts escape text and reject executable or malformed addresses',()=>{
|
||||
const html=infoPages.infoContactSection({},[
|
||||
{name:'<img onerror=alert(1)>',value:'<script>alert(1)</script>'},
|
||||
{name:'注入',value:'javascript:alert(1)'},
|
||||
{name:'数据链接',value:'data:text/html,<script>alert(1)</script>'},
|
||||
{name:'正常链接',value:'https://example.com/?a=1&b=2'},null,
|
||||
]);
|
||||
assert.ok(html.includes('<script>'));assert.ok(html.includes('<img onerror=alert(1)>'));
|
||||
assert.ok(!html.includes('<script'));assert.ok(!html.includes('javascript:'));assert.ok(!html.includes('data:text'));
|
||||
assert.ok(html.includes('href="https://example.com/?a=1&b=2" target="_blank" rel="sponsored nofollow noopener noreferrer"'));
|
||||
assert.ok(infoPages.infoContactSection({config:{gmailAddr:'javascript:bad@example.com'}}).includes(' hidden'));
|
||||
});
|
||||
|
||||
test('ranking data and native page links derive from the server route and preserve global positions',()=>{
|
||||
const f=fixture();
|
||||
for(const item of routing.RANK_PAGES){
|
||||
const route=routing.parseSitePath(item.path+'page/2/'),request=f.channel.channelRequest(route);
|
||||
assert.equal(request.endpoint,'playlet/hot/rank/list');assert.equal(request.query.rankType,item.rankType);assert.equal(request.query.pageNum,2);
|
||||
const view=f.channel.channelView(route,{list:[{id:1}],totalCount:72},item.path+'page/2/');
|
||||
assert.equal(view.title,item.title);assert.ok(view.html.includes('href="'+item.path+'" aria-current="page"'));assert.equal(f.cardCalls.at(-1).options.rank,25);
|
||||
}
|
||||
});
|
||||
|
||||
test('fixed information definitions remain static and contain no executable content',()=>{
|
||||
for(const page of Object.values(infoPages.INFO_PAGES)){assert.ok(page.html);assert.ok(!page.html.includes('<script'));assert.ok(!page.html.includes('href="#'));}
|
||||
});
|
||||
|
||||
test('initial server channel and search content is retained without page requests or loading placeholders',async()=>{
|
||||
for(const page of [{path:'/shorts/page/2/?sort=2',panel:'channel',title:'服务端栏目'},{path:'/search/'+encodeURIComponent('都市 爱情')+'/',panel:'search',title:'搜索结果',search:{keyword:'都市 爱情',total:40,page:1,pageSize:25}}]){
|
||||
const f=fixture(()=>assert.fail('page data belongs to SSR'),{page});
|
||||
const address=new URL(page.path,'https://site.test');f.location.pathname=address.pathname;f.location.search=address.search;
|
||||
const selector=page.panel==='channel'?'#channel-content':'#search-results-page';f.document.querySelector(selector).innerHTML='<article>完整首屏正文</article>';
|
||||
await f.app.route();assert.equal(f.nodes.get(selector).innerHTML,'<article>完整首屏正文</article>');assert.equal(f.calls.length,0);
|
||||
await f.app.route();assert.deepEqual(f.reloads,[page.path]);assert.equal(f.nodes.get(selector).innerHTML,'<article>完整首屏正文</article>');
|
||||
}
|
||||
});
|
||||
|
||||
test('server player data binds once with its comment route and never refetches the initial content',async()=>{
|
||||
const player={detail:{id:23456,title:'剧集'},chapters:[],chapter:null,comments:{list:[],totalCount:0}};
|
||||
const path='/play/23456/90001/page/2/',f=fixture(()=>assert.fail('no page fetch'),{page:{path:path+'?commentSort=1',panel:'player',title:'剧集',player}});
|
||||
f.location.pathname=path;f.location.search='?commentSort=1';await f.app.route();
|
||||
assert.equal(f.calls.length,1);assert.equal(f.calls[0].path,'renderPlayer');assert.equal(f.calls[0].initial,player);assert.equal(f.calls[0].preserveMarkup,true);assert.equal(f.calls[0].page,2);assert.equal(f.calls[0].commentSort,1);
|
||||
assert.equal(f.calls[0].pageHref(3,1),'/play/23456/90001/page/3/?commentSort=1');await f.app.route();assert.equal(f.calls.length,1);assert.equal(f.reloads.length,1);
|
||||
});
|
||||
|
||||
test('private and feedback pages only bind interactions to their prepared snapshots',async()=>{
|
||||
for(const [path,info,handler] of [['/member/settings/',{key:'settings',member:{user:{userId:7}}},'renderMember'],['/info/deposit/',{key:'deposit',wallet:{user:{userId:7},packages:{coinList:[]},transactions:{list:[]}}},'renderWallet'],['/info/feedback/',{key:'feedback',feedback:true},'renderFeedbackPage']]){
|
||||
const f=fixture(()=>assert.fail('no page fetch'),{page:{path,panel:'info',title:'完整页面',info}});f.location.pathname=path;await f.app.route();
|
||||
assert.equal(f.calls.length,1);assert.equal(f.calls[0].path,handler);assert.equal(f.calls[0].args.at(-1),true);
|
||||
}
|
||||
});
|
||||
|
||||
test('cold unavailable member and wallet pages client-render after guest bootstrap',async()=>{
|
||||
for(const [path,handler,tab] of [['/member/center/','renderMember','center'],['/member/history/','renderMember','history'],['/info/deposit/','renderWallet',undefined],['/info/deposit/vip/','renderWallet',undefined],['/member/settings/','renderMember','settings']]){
|
||||
const f=fixture(()=>assert.fail('no page fetch'),{page:{path,panel:'info',title:'完整页面',info:{key:path.includes('deposit')?'deposit':tab},session:{user:null,state:'unavailable'}}});
|
||||
f.location.pathname=path;await f.app.route();
|
||||
assert.equal(f.calls.length,1,path);assert.equal(f.calls[0].path,handler,path);
|
||||
if(handler==='renderMember')assert.equal(f.calls[0].args[1],tab);
|
||||
if(path==='/info/deposit/vip/')assert.equal(f.calls[0].args[1],true);
|
||||
if(path==='/info/deposit/')assert.equal(f.calls[0].args[1],false);
|
||||
}
|
||||
const signedOut=fixture(()=>assert.fail('login-required must keep SSR'),{page:{path:'/member/center/',panel:'info',title:'完整页面',info:{key:'center'},session:{user:null,state:'login-required'}}});
|
||||
signedOut.location.pathname='/member/center/';await signedOut.app.route();assert.equal(signedOut.calls.length,0);
|
||||
});
|
||||
|
||||
test('missing or mismatched snapshots leave server error HTML visible without a client retry',async()=>{
|
||||
const f=fixture(()=>assert.fail('errors must not trigger a second page request'),{page:{path:'/shorts/',panel:'channel',title:'旧页'}});
|
||||
f.location.pathname='/topics/7/';f.document.querySelector('#channel-content').innerHTML='<p>服务暂不可用</p>';await f.app.route();assert.equal(f.calls.length,0);assert.equal(f.nodes.get('#channel-content').innerHTML,'<p>服务暂不可用</p>');
|
||||
});
|
||||
|
||||
test('client configuration preserves the server content metadata and canonical',async()=>{
|
||||
const path='/topics/7/',environment=seo.resolveSeoEnvironment({config:{foreverUrlAddr:'https://canonical.drama.test'}}),content={title:'服务端专题',description:'服务端简介',totalCount:72,pageSize:24};
|
||||
const f=fixture(()=>assert.fail('no page fetch'),{page:{path,panel:'channel',title:'服务端专题'},seo:{environment,content,path}});f.location.pathname=path;await f.app.route();
|
||||
await f.app.applyConfig({config:{foreverUrlAddr:'https://other.drama.test'}});
|
||||
assert.equal(f.document.title,'服务端专题 - 禁果短剧');assert.equal(f.document.value('link[rel="canonical"]'),'https://canonical.drama.test/topics/7/');
|
||||
});
|
||||
|
||||
test('shared server configuration initializes downloads and contacts without refetching page configuration or footer',async()=>{
|
||||
const shared={config:{config:{officialAppAddr:'https://app.test/download'}},footer:{footerInfo:{contactList:[{name:'服务时间',value:'每天'}]}},trackerConfigured:false};
|
||||
const f=fixture(()=>assert.fail('public settings already arrived in HTML'),{shared});
|
||||
await f.app.initializeShared();assert.equal(f.app.config.config.officialAppAddr,'https://app.test/download');assert.equal(f.calls.length,0);
|
||||
});
|
||||
|
||||
test('public back/forward documents keep working without reload; interactive private pages revalidate as fresh documents',async()=>{
|
||||
const f=fixture();f.location.pathname='/shorts/';f.app.hidePage(true);assert.equal(f.app.signal.aborted,false);
|
||||
f.app.restorePage();assert.equal(f.reloads.length,0);assert.deepEqual(f.calls.map(item=>item.path),['restore-session']);
|
||||
const privatePage=fixture();privatePage.location.pathname='/info/deposit/';privatePage.app.hidePage(true);assert.equal(privatePage.app.signal.aborted,true);
|
||||
privatePage.app.restorePage();assert.equal(privatePage.reloads.length,1);
|
||||
});
|
||||
@@ -0,0 +1,281 @@
|
||||
import {routingModuleUrl} from './helpers/routing.mjs';
|
||||
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).replace(/(['"])\.\.\/routing\1/g,JSON.stringify(routingModuleUrl))+'\n//# sourceURL='+new URL(path,import.meta.url).pathname).toString('base64');
|
||||
}
|
||||
const protocolUrl=await moduleUrl('../src/lib/api/protocol.ts');
|
||||
const renderUrl=await moduleUrl('../src/lib/client/render.ts',code=>code.replace(/from ['"]\.\.\/api\/protocol['"]/,`from '${protocolUrl}'`));
|
||||
const {ads,promotionRow,renderHome,activeBanners,hero,homeRecommendations,heroFeed,card,coverCornerBadge,topicCard,imageAttributes,blockSection}=await import(renderUrl);
|
||||
const historyUrl=await moduleUrl('../src/lib/client/header-history.ts',code=>code.replace("'../api/protocol'",JSON.stringify(protocolUrl)).replace("'./render'",JSON.stringify(renderUrl)));
|
||||
const {headerHistoryMarkup}=await import(historyUrl);
|
||||
const drama={id:23456,title:'正常剧集 <script>alert(1)</script>',cover:'https://example.com/poster.webp',tagNames:['都市']};
|
||||
const advert={id:'ad-one',title:'有效广告',images:'https://example.com/ad.webp',link:'https://example.com/landing'};
|
||||
|
||||
test('Swagger homepage modules render actual titles, drama cards and theme destinations',()=>{
|
||||
const html=renderHome({modules:[{id:31,title:'新接口短剧栏目',slug:'editors',moduleType:1,list:[drama]},{id:32,title:'新接口题材',moduleType:2,topicList:[{id:7,slug:'urban',name:'都市题材',list:[drama]}]}]});
|
||||
for(const text of ['新接口短剧栏目','新接口题材','href="/config/editors/"','href="/theme/urban/"','href="/detail/23456/"'])assert.ok(html.includes(text),text);
|
||||
assert.equal(homeRecommendations({modules:[{id:31,title:'短剧',moduleType:1,list:[drama]}]})[0].id,drama.id);
|
||||
const emptyModules=renderHome({modules:[],homeConfigData:[{id:1,name:'旧缓存不应复活',type:1,status:true,list:[drama]}]});
|
||||
assert.ok(!emptyModules.includes('旧缓存不应复活'));
|
||||
});
|
||||
|
||||
test('topic cards include escaped metadata for content exposure and click tracking',()=>{
|
||||
const html=topicCard({id:7,name:'都市 <甜宠>',icon:'https://example.com/topic.png'});
|
||||
assert.match(html,/data-track-item data-item-id="7" data-item-type="topic" data-item-name="都市 <甜宠>"/);
|
||||
});
|
||||
|
||||
test('API images use data sources and local placeholders until decrypted, including video posters',()=>{
|
||||
const cases=[card(drama),hero([drama]),topicCard({id:1,icon:drama.cover}),ads([advert])];
|
||||
for(const html of cases){
|
||||
const images=[...html.matchAll(/<img\b[^>]*>/g)];assert.ok(images.length>0);
|
||||
for(const [tag] of images){
|
||||
assert.match(tag,/\ssrc="\/placeholder\.png"/);assert.match(tag,/\sdata-src="https:\/\//);
|
||||
assert.ok(!/\ssrc="https?:/.test(tag));
|
||||
}
|
||||
}
|
||||
assert.match(imageAttributes(drama.cover,undefined,'poster'),/^poster="\/placeholder\.png" data-poster="https:\/\//);
|
||||
assert.equal(imageAttributes('javascript:alert(1)','/icon.svg'),'src="/icon.svg"');
|
||||
assert.ok(imageAttributes('https://example.com/a?one=1&two=2').includes('one=1&two=2'));
|
||||
});
|
||||
|
||||
test('ads tolerates null, missing, and non-array API fields',()=>{
|
||||
for(const value of [null,undefined,{},'',false,0,'[]'])assert.equal(ads(value),'');
|
||||
assert.equal(ads([]),'');
|
||||
});
|
||||
|
||||
test('shared square ad rows keep real ad data and links and omit empty placements',()=>{
|
||||
const html=promotionRow([advert,{...advert,id:'ad-two',title:'第二个广告'}]);
|
||||
assert.ok(html.includes('class="promo-marquee" data-promotion-row'));
|
||||
assert.equal((html.match(/class="promo-tile"/g)||[]).length,2);
|
||||
assert.ok(html.includes('data-ad-id="ad-one"'));assert.ok(html.includes('data-ad-id="ad-two"'));
|
||||
assert.ok(html.includes('href="https://example.com/landing"'));assert.ok(html.includes('data-ad-type="icon"'));
|
||||
assert.ok(html.includes('width="132" height="132"'));assert.ok(html.includes('alt="有效广告"'));
|
||||
for(const value of [null,{},[],[null]])assert.equal(promotionRow(value),'');
|
||||
});
|
||||
|
||||
test('ads ignores malformed entries and keeps valid ads in order with escaped text',()=>{
|
||||
const html=ads([null,undefined,false,42,[],{...advert,images:null},{...advert,images:'javascript:alert(1)'},advert,{...advert,id:'two',title:'第二条 <script>',link:'javascript:alert(1)'}]);
|
||||
assert.equal((html.match(/class="promo-app"/g)||[]).length,2);
|
||||
assert.ok(html.indexOf('有效广告')<html.indexOf('第二条'));
|
||||
assert.ok(html.includes('href="https://example.com/landing"'));
|
||||
assert.ok(html.includes('href="/apps/"'));assert.ok(html.includes('第二条 <script>'));
|
||||
assert.ok(!html.includes('javascript:'));assert.ok(!html.includes('<script>'));
|
||||
});
|
||||
|
||||
test('home renders when nineGridList is explicitly null, preserving valid content',()=>{
|
||||
const html=renderHome({homeBannerData:[drama],homeConfigData:[{id:31,name:'后台栏目',type:1,status:true,list:[drama]}],advitise:{nineGridList:null}});
|
||||
assert.ok(html.includes('后台栏目'));assert.ok(html.includes('href="/detail/23456/"'));
|
||||
assert.ok(html.includes('正常剧集 <script>'));assert.ok(!html.includes('<script>alert(1)</script>'));
|
||||
});
|
||||
|
||||
test('dynamic home sections share a grid without inheriting the prototype editorial rows',()=>{
|
||||
const html=renderHome({homeConfigData:[
|
||||
{id:31,name:'必看好剧',status:true,type:1,list:[drama,{...drama,id:2,totalEpisode:10}]},
|
||||
{id:32,name:'热播榜',status:true,type:1,list:[{...drama,id:3}]},
|
||||
{id:33,name:'剧集',status:true,type:2,videoList:[{...drama,id:4,totalEpisode:8}]},
|
||||
{id:34,name:'题材',status:true,type:3,themeList:[{id:8,name:'都市'}]},
|
||||
]});
|
||||
assert.equal((html.match(/class="topic-section home-block"/g)||[]).length,3);
|
||||
assert.equal((html.match(/class="cards"/g)||[]).length,2);
|
||||
assert.ok(!html.includes('id="romance-rows"'));assert.ok(!html.includes('romance-editorial'));
|
||||
assert.ok(!html.includes('id="library"'));
|
||||
for(const id of [31,32,34])assert.ok(html.includes(`href="/config/${id}/"`));
|
||||
for(const id of [23456,2,3])assert.ok(html.includes(`href="/detail/${id}/"`));
|
||||
assert.ok(!html.includes('href="/video/4/"'));assert.ok(!html.includes('href="/config/33/"'));assert.ok(html.includes('href="/topics/8/"'));
|
||||
assert.equal((html.split('id="home-blocks"')[1].match(/class="drama-episodes"/g)||[]).length,1);
|
||||
assert.ok(html.indexOf('必看好剧')<html.indexOf('热播榜'));
|
||||
});
|
||||
|
||||
test('homepage banner ads follow each second topic section and keep real links',()=>{
|
||||
const banner=id=>({id,title:'横幅 '+id,images:'https://cdn.example/'+id+'.gif',link:'https://example.com/'+id});
|
||||
const block=(id,name)=>({id,name,status:true,type:1,list:[drama]});
|
||||
const html=renderHome({homeConfigData:[block(1,'栏目一'),block(2,'栏目二'),block(3,'栏目三'),block(4,'栏目四'),block(5,'栏目五')],advitise:{detailbannerList:[null,banner('a'),{...banner('b'),images:'javascript:alert(1)'},banner('c'),banner('d'),banner('e'),banner('f')]}});
|
||||
const blocks=html.split('id="home-blocks"')[1].split('popular-searches')[0];
|
||||
assert.equal((blocks.match(/class="wide-ad-row"/g)||[]).length,2);
|
||||
assert.ok(blocks.indexOf('栏目二')<blocks.indexOf('横幅 a')&&blocks.indexOf('横幅 a')<blocks.indexOf('横幅 c')&&blocks.indexOf('横幅 c')<blocks.indexOf('栏目三'));
|
||||
assert.ok(blocks.indexOf('栏目四')<blocks.indexOf('横幅 d')&&blocks.indexOf('横幅 d')<blocks.indexOf('横幅 e')&&blocks.indexOf('横幅 e')<blocks.indexOf('栏目五'));
|
||||
assert.ok(!blocks.includes('横幅 f'));assert.ok(!blocks.includes('横幅 b'));assert.ok(!blocks.includes('javascript:'));
|
||||
assert.ok(blocks.includes('data-ad-type="banner"'));assert.ok(blocks.includes('href="https://example.com/a"'));
|
||||
assert.ok(!renderHome({homeConfigData:[block(1,'栏目一'),block(2,'栏目二')]}).includes('wide-ad-row'));
|
||||
assert.ok(!renderHome({homeConfigData:[block(1,'单独栏目')],advitise:{detailbannerList:[banner('a')]}}).includes('wide-ad-row'));
|
||||
});
|
||||
|
||||
test('empty or malformed homepage lists fall back to empty states instead of throwing',()=>{
|
||||
for(const value of [null,undefined,{},'',false,1]){
|
||||
const html=renderHome({homeBannerData:value,homeConfigData:value,announcements:value,advitise:{nineGridList:value}});
|
||||
assert.ok(html.includes('暂无推荐剧集'));assert.ok(html.includes('暂时没有内容'));
|
||||
}
|
||||
assert.ok(renderHome(null).includes('暂无推荐剧集'));
|
||||
});
|
||||
|
||||
test('homepage category styles follow API values rather than titles, ranks or module positions',()=>{
|
||||
const modules=[
|
||||
{id:1,slug:'first',title:'热门推荐',moduleType:1,rank:99,style:' d ',list:[{...drama,slug:'first-story'}]},
|
||||
{id:2,title:'热门推荐',moduleType:1,rank:1,style:'C',label:'样式C <script>',list:[drama]},
|
||||
{id:3,title:'随意命名',moduleType:1,style:'B',list:[drama]},
|
||||
{id:4,title:'随意命名',moduleType:1,style:'A',list:[drama]},
|
||||
{id:5,title:'随意命名',moduleType:2,style:'E',topicList:[{id:8,slug:'urban',name:'都市'}]},
|
||||
];
|
||||
const html=renderHome({modules});
|
||||
assert.deepEqual([...html.matchAll(/data-home-style="([A-E])"/g)].map(match=>match[1]),['D','C','B','A','E']);
|
||||
assert.ok(html.includes('class="topic-section romance-editorial home-block" data-home-style="A"'));
|
||||
assert.ok(html.includes('id="library" class="home-block" data-home-style="B"'));
|
||||
assert.ok(html.includes('class="topic-section home-block" data-home-style="C"'));
|
||||
assert.ok(html.includes('class="topic-section home-block" data-home-style="D"'));
|
||||
assert.ok(html.includes('class="topic-section topic-recommendations home-block" data-home-style="E"'));
|
||||
assert.ok(html.includes('class="ranked-card drama-data-card"'));assert.ok(html.includes('class="curated-card drama-data-card"'));
|
||||
assert.ok(html.includes('href="/detail/first-story/"'));assert.ok(html.includes('href="/config/first/"'));assert.ok(html.includes('href="/theme/urban/"'));
|
||||
assert.ok(html.includes('class="pick">最新上架</span>'));assert.ok(!html.includes('样式C'));assert.ok(!html.includes('<script>'));
|
||||
});
|
||||
|
||||
test('A–E previews use API limits while retaining backend order and full source lists',()=>{
|
||||
for(const [style,limit] of [['A',3],['B',9],['C',8],['D',7],['E',5]]){
|
||||
const list=Array.from({length:14},(_,index)=>({...drama,id:500-index,slug:'story-'+index,countLike:index}));
|
||||
const topics=list.map(item=>({id:item.id,name:'专题 '+item.id,slug:item.slug}));
|
||||
const block={id:1,name:'任意分类',type:style==='E'?3:1,status:true,style,limit,list,themeList:topics,topicKind:'theme'};
|
||||
const html=blockSection(block);
|
||||
assert.equal((html.match(/data-track-item/g)||[]).length,limit,style);
|
||||
const links=[...html.matchAll(/href="\/(?:detail|theme)\/story-(\d+)\/"/g)].map(match=>Number(match[1]));
|
||||
assert.deepEqual(links,Array.from({length:limit},(_,index)=>index),style);
|
||||
assert.equal(list.length,14);assert.equal(topics.length,14);
|
||||
if(style==='C'){
|
||||
assert.equal((html.match(/class="curated-card drama-data-card"/g)||[]).length,limit);
|
||||
assert.equal((html.match(/class="watch-link"/g)||[]).length,limit);
|
||||
}
|
||||
if(style==='D'){
|
||||
for(let rank=1;rank<=limit;rank++)assert.ok(html.includes(`aria-label="第 ${rank} 名,查看`));
|
||||
assert.ok(html.includes('>01</strong>'));assert.ok(html.includes('>07</strong>'));assert.ok(!html.includes('rank-home-badge'));
|
||||
assert.ok(blockSection({...block,id:2,list:[drama]}).includes('aria-label="第 1 名,查看'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('category limits apply to current and legacy modules, including unknown styles',()=>{
|
||||
const list=Array.from({length:14},(_,index)=>({...drama,id:100+index}));
|
||||
for(const style of ['A','B','C','D','E','F',undefined]){
|
||||
const topic=style==='E';
|
||||
const items=topic?list.map(item=>({id:item.id,name:'题材 '+item.id})):list;
|
||||
for(const limit of [0,1,9,20]){
|
||||
const sources=[{modules:[{id:1,title:'分类',style,limit,moduleType:topic?2:1,list:items,topicList:items}]},
|
||||
{homeConfigData:[{id:1,name:'分类',style,limit,type:topic?3:1,status:true,list:items,themeList:items}]}];
|
||||
for(const home of sources){
|
||||
const html=renderHome(home).split('id="home-blocks"')[1].split('</section>')[0];
|
||||
assert.equal((html.match(/data-track-item/g)||[]).length,Math.min(limit,items.length),String(style)+' '+limit);
|
||||
if(limit===0)assert.ok(html.includes('暂时没有内容'));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('missing or invalid category limits retain all valid returned items without a style cap',()=>{
|
||||
const list=Array.from({length:14},(_,index)=>({...drama,id:100+index}));
|
||||
for(const limit of [undefined,null,-1,1.5,NaN,Infinity,'3','',false,{},Number.MAX_SAFE_INTEGER+1]){
|
||||
const html=blockSection({id:1,name:'分类',type:1,status:true,style:'B',limit,list:[null,false,...list]});
|
||||
assert.equal((html.match(/data-track-item/g)||[]).length,14,String(limit));
|
||||
}
|
||||
const html=blockSection({id:1,name:'分类',type:1,status:true,style:'C',limit:2,list:[null,list[0],false,list[1],list[2]]});
|
||||
assert.equal((html.match(/data-track-item/g)||[]).length,2);
|
||||
assert.ok(html.includes('/detail/100/'));assert.ok(html.includes('/detail/101/'));assert.ok(!html.includes('/detail/102/'));
|
||||
});
|
||||
|
||||
test('missing, reserved, malformed and incompatible category styles preserve the default content layout',()=>{
|
||||
const list=Array.from({length:12},(_,index)=>({...drama,id:index+1}));
|
||||
for(const style of [undefined,'','F','Z','AA',0,{},'E','A" onclick="bad']){
|
||||
const html=blockSection({id:1,name:'热门好剧',type:1,status:true,style,list});
|
||||
assert.ok(!html.includes('data-home-style='));assert.ok(html.includes('class="cards"'));
|
||||
assert.equal((html.match(/class="card drama-data-card"/g)||[]).length,12);
|
||||
assert.ok(!html.includes('onclick='));
|
||||
}
|
||||
const topics=list.map(item=>({id:item.id,name:'专题'}));
|
||||
const html=blockSection({id:2,name:'专题推荐',type:3,status:true,style:'D',themeList:topics});
|
||||
assert.ok(!html.includes('data-home-style='));
|
||||
assert.ok(html.includes('class="topic-section home-block"'));
|
||||
assert.ok(!html.includes('topic-recommendations'));
|
||||
assert.equal((html.match(/class="topic-recommendation-card"/g)||[]).length,12);
|
||||
});
|
||||
|
||||
test('cover corner badges prefer 原创 over 爆剧 over 上新',()=>{
|
||||
assert.equal(coverCornerBadge({isRecommend:true,isFeatured:true,isNew:true}),'<span class="cover-corner-badge cover-corner-badge--original">原创</span>');
|
||||
assert.equal(coverCornerBadge({isFeatured:true,isNew:true}),'<span class="cover-corner-badge cover-corner-badge--hot">爆剧</span>');
|
||||
assert.equal(coverCornerBadge({isNew:true}),'<span class="cover-corner-badge cover-corner-badge--new">上新</span>');
|
||||
assert.equal(coverCornerBadge({}),'');
|
||||
assert.ok(card({...drama,isRecommend:true,isFeatured:true,isNew:true}).includes('>原创</span>'));
|
||||
assert.ok(!card({...drama,isRecommend:true,isFeatured:true,isNew:true}).includes('爆剧'));
|
||||
assert.ok(card({...drama,isFeatured:true,isNew:true}).includes('>爆剧</span>'));
|
||||
assert.ok(card({...drama,isNew:true}).includes('>上新</span>'));
|
||||
});
|
||||
|
||||
test('styled category cards keep escaped metadata, native links, tracking and empty states',()=>{
|
||||
for(const style of ['C','D']){
|
||||
const html=blockSection({id:1,name:'分类',type:1,status:true,style,label:'新剧 <b>',list:[null,{...drama,desc:'简介 <script>',recommendTraceId:'trace-123',totalEpisode:36,isNew:true,tagNames:['都市 <img>']}]});
|
||||
for(const value of ['data-recommend-trace-id="trace-123"','data-src="https://example.com/poster.webp"','loading="lazy"','cover-corner-badge--new','上新','36集','简介 <script>','都市 <img>'])assert.ok(html.includes(value),style+' '+value);
|
||||
assert.ok(!html.includes('<script>'));assert.ok(!html.includes('<b>'));
|
||||
if(style==='C'){assert.ok(html.includes('class="pick">最新上架</span>'));assert.ok(!html.includes('新剧 <b>'));assert.ok(!html.includes('class="pick">新剧'));}
|
||||
}
|
||||
for(const style of ['A','B','C','D','E']){
|
||||
const html=blockSection({id:1,name:'空分类',type:style==='E'?3:1,status:true,style,list:null,themeList:[]});
|
||||
assert.ok(html.includes('role="status"'));assert.ok(html.includes('暂时没有内容'));
|
||||
}
|
||||
});
|
||||
|
||||
test('nested home blocks tolerate invalid rows and non-array child lists',()=>{
|
||||
const html=renderHome({homeBannerData:[null,false,drama],announcements:[null,{title:'有效公告'},4],homeConfigData:[null,7,
|
||||
{id:31,name:'有效栏目',status:true,type:1,list:[null,drama]},
|
||||
{id:32,name:'空视频栏目',status:true,type:2,videoList:{}},
|
||||
{id:33,name:'空题材栏目',status:true,type:3,themeList:'bad'},
|
||||
{id:34,name:'题材栏目',status:true,type:3,themeList:[null,{id:8,name:'悬疑'}]},
|
||||
],advitise:{nineGridList:[null,advert]}});
|
||||
assert.ok(!html.includes('空视频栏目'));
|
||||
for(const content of ['有效栏目','空题材栏目','有效公告','href="/topics/8/"','href="/detail/23456/"','有效广告'])assert.ok(html.includes(content),content);
|
||||
});
|
||||
|
||||
test('banner selection and card metadata tolerate malformed records without losing valid items',()=>{
|
||||
assert.deepEqual(activeBanners({homeBannerData:[null,drama,{...drama,id:2,showOnBanner:true}]}).map(item=>item.id),[2]);
|
||||
assert.ok(hero([null,drama]).includes('href="/detail/23456/"'));
|
||||
assert.ok(hero(null).includes('暂无推荐剧集'));
|
||||
assert.ok(card({...drama,tagNames:'bad',themeNames:['悬疑']}).includes('悬疑'));
|
||||
assert.equal(card(null),'');assert.equal(topicCard(null),'');
|
||||
});
|
||||
|
||||
test('carousel frames keep their own titles below the stable page heading, including fade snapshots',()=>{
|
||||
const items=[drama,{...drama,id:2,title:'下一部短剧'}];
|
||||
for(const selected of [0,1]){
|
||||
const frame=hero(items,selected);
|
||||
assert.equal((frame.match(/<h2\b/g)||[]).length,1);
|
||||
assert.ok(!/<h1\b/.test(frame),'live and outgoing frames must not duplicate the page h1');
|
||||
assert.ok(frame.includes(`href="/detail/${items[selected].id}/"`));
|
||||
assert.ok(frame.includes(`data-feature="${selected}"`));
|
||||
}
|
||||
assert.ok(!/<h1\b/.test(hero(null)));
|
||||
});
|
||||
|
||||
|
||||
test('homepage landscape recommendations use enabled API content, deduplicate IDs and keep real routes and images',()=>{
|
||||
const items=Array.from({length:8},(_,index)=>({...drama,id:index+1,title:'横版推荐 '+index,horizontalCover:'https://example.com/wide-'+index+'.webp',slug:'story-'+index}));
|
||||
const home={homeBannerData:[{...drama,id:9}],homeConfigData:[{id:1,name:'关闭栏目',type:1,status:false,list:[{...drama,id:99}]},{id:2,name:'启用栏目',type:1,status:true,list:[items[0],items[0],null,...items.slice(1)]}]};
|
||||
const recommendations=homeRecommendations(home);
|
||||
assert.deepEqual(recommendations.map(item=>item.id),[1,2,3,4,5,6]);
|
||||
const html=heroFeed(recommendations);
|
||||
assert.equal((html.match(/class="hero-feed-card drama-data-card"/g)||[]).length,6);
|
||||
assert.ok(html.includes('href="/detail/story-0/"'));assert.ok(html.includes('data-src="https://example.com/wide-0.webp"'));
|
||||
assert.ok(!html.includes('关闭栏目'));assert.equal(heroFeed(null),'');
|
||||
const rendered=renderHome(home);
|
||||
assert.ok(rendered.includes('id="hero-feature-carousel"'));assert.ok(rendered.includes('aria-label="精选横版作品"'));
|
||||
});
|
||||
|
||||
|
||||
test('header history shows only valid unique account records, escapes titles and keeps native detail links',()=>{
|
||||
const records=Array.from({length:12},(_,index)=>({...drama,id:index+1,slug:'recent-'+index,totalEpisode:index+3}));
|
||||
const html=headerHistoryMarkup([null,{id:-1},records[0],records[0],...records.slice(1)]);
|
||||
assert.equal((html.match(/class="jg-history-item"/g)||[]).length,9);
|
||||
assert.ok(html.includes('href="/detail/recent-0/"'));assert.ok(html.includes('全 3 集'));
|
||||
assert.ok(html.includes('正常剧集 <script>'));assert.ok(!html.includes('<script>'));
|
||||
assert.ok(html.includes('data-src="https://example.com/poster.webp"'));
|
||||
assert.ok(headerHistoryMarkup(null).includes('暂无观看历史'));
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFile} from 'node:fs/promises';
|
||||
import ts from 'typescript';
|
||||
|
||||
async function moduleUrl(file,transform=code=>code){
|
||||
const source=await readFile(new URL(file,import.meta.url),'utf8');
|
||||
const code=ts.transpileModule(source,{fileName:new URL(file,import.meta.url).pathname,compilerOptions:{module:ts.ModuleKind.ESNext,target:ts.ScriptTarget.ES2022,jsx:ts.JsxEmit.ReactJSX}}).outputText;
|
||||
return 'data:text/javascript;base64,'+Buffer.from(transform(code)).toString('base64');
|
||||
}
|
||||
const protocol=await moduleUrl('../src/lib/api/protocol.ts'),auth=await moduleUrl('../src/lib/client/auth.ts');
|
||||
const apiUrl=await moduleUrl('../src/lib/client/api.ts',code=>code.replace('"../api/protocol"',JSON.stringify(protocol)).replace('"./auth"',JSON.stringify(auth)));
|
||||
const {ApiClient}=await import(apiUrl);
|
||||
const startupUrl='data:text/javascript,export const calls=[];export function startTracker(){calls.push("tracker")}export function startApplication(...args){calls.push(args)}';
|
||||
const startup=await import(startupUrl);
|
||||
const scriptUrl='data:text/javascript,export default function Script(){}';
|
||||
const runtimeUrl=await moduleUrl('../src/components/site/SiteRuntime.tsx',code=>{
|
||||
const imports={'react/jsx-runtime':import.meta.resolve('react/jsx-runtime'),'next/script':scriptUrl,'@/lib/client/application':startupUrl,'@/lib/client/analytics':startupUrl};
|
||||
for(const [path,url] of Object.entries(imports))for(const quote of ["'",'"'])code=code.replace(quote+path+quote,JSON.stringify(url));
|
||||
return code;
|
||||
});
|
||||
const {SiteRuntime}=await import(runtimeUrl);
|
||||
|
||||
async function browser(work){
|
||||
const previous={window:globalThis.window,fetch:globalThis.fetch},logs=[];
|
||||
globalThis.window={console:Object.fromEntries(['log','info','debug','warn','error','group','groupCollapsed','groupEnd','table','trace'].map(method=>[method,(...args)=>logs.push({method,args})]))};
|
||||
try{await work(logs);assert.deepEqual(logs,[]);}
|
||||
finally{globalThis.fetch=previous.fetch;if(previous.window===undefined)delete globalThis.window;else globalThis.window=previous.window;}
|
||||
}
|
||||
|
||||
test('browser requests retain query, credentials, decoded responses and multipart bodies without console output',async()=>{
|
||||
await browser(async()=>{
|
||||
const body={account:'player',password:'unchanged-password'},query={pageNum:2,token:'query-token',empty:''};
|
||||
const payload={code:200,data:JSON.stringify({id:31,moreUrl:'/rank/new/',access_token:'response-token'})};
|
||||
let received;
|
||||
globalThis.fetch=async(url,options)=>{received={url,options};return new Response(JSON.stringify(payload),{status:200});};
|
||||
const result=await new ApiClient().raw('auth/login',query,body);
|
||||
assert.equal(received.url,'/api/app/auth/login?pageNum=2&token=query-token');
|
||||
assert.equal(received.options.credentials,'include');assert.equal(received.options.headers.Accept,'application/json');assert.equal(JSON.parse(received.options.body).password,body.password);
|
||||
assert.equal(result.access_token,'response-token');assert.equal(result.moreUrl,'/rank/new/');
|
||||
const form=new FormData();form.append('image',new Blob(['image bytes'],{type:'image/png'}),'cover.png');
|
||||
await new ApiClient().raw('upload',{},form);assert.equal(received.url,'/api/app/upload');assert.equal(received.options.body,form);assert.equal(received.options.headers['Content-Type'],undefined);
|
||||
});
|
||||
});
|
||||
|
||||
test('API business failures, invalid JSON and network failures remain rejected without console output',async()=>{
|
||||
await browser(async()=>{
|
||||
const api=new ApiClient();
|
||||
globalThis.fetch=async()=>new Response(JSON.stringify({code:5100,msg:'搜索服务暂不可用',data:null}),{status:200});
|
||||
await assert.rejects(api.raw('playlet/search',{keyword:'都市'}),error=>error.code===5100&&error.message==='搜索服务暂不可用');
|
||||
globalThis.fetch=async()=>new Response('invalid json',{status:502});
|
||||
await assert.rejects(api.raw('playlet/home'),SyntaxError);
|
||||
globalThis.fetch=async()=>{throw new TypeError('Network offline');};
|
||||
await assert.rejects(api.raw('playlet/home'),/Network offline/);
|
||||
});
|
||||
});
|
||||
|
||||
test('home, player, navigation and wallet hydration starts normally without logging server data or refetching',async()=>{
|
||||
await browser(async()=>{
|
||||
globalThis.fetch=()=>assert.fail('hydration must use existing server data');
|
||||
const wallet={user:{userId:77,recharge:90,token:'session-secret'},packages:{coinList:[{id:31,name:'金币套餐',price:10000}],vipList:[]},transactions:{list:[{id:12,amount:30}],totalCount:18}};
|
||||
const home={homeConfigData:[{id:31,moreUrl:'/topics/'}]},navigation=[{name:'短剧',url:'/shorts/'}];
|
||||
for(const page of [{path:'/',panel:'home'},{path:'/detail/7/',player:{title:'短剧'}},{path:'/info/deposit/page/2/',panel:'info',info:{key:'deposit',wallet}}]){
|
||||
const start=startup.calls.length;
|
||||
SiteRuntime({home,homeLoaded:true,page,navigation}).props.onReady();
|
||||
assert.equal(startup.calls[start],'tracker');assert.equal(startup.calls[start+1][0],home);assert.equal(startup.calls[start+1][3],page);
|
||||
}
|
||||
assert.equal(wallet.user.token,'session-secret');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFile} from 'node:fs/promises';
|
||||
import ts from 'typescript';
|
||||
|
||||
const source=await readFile(new URL('../src/app/robots.ts',import.meta.url),'utf8');
|
||||
const originUrl='data:text/javascript;base64,'+Buffer.from('export let calls=0;let origin="";export function setOrigin(value){origin=value;calls=0;}export async function getSeoOrigin(){calls++;return origin;}').toString('base64');
|
||||
const originStub=await import(originUrl);
|
||||
const code=ts.transpileModule(source,{compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.ESNext}}).outputText.replace("'@/lib/seo-server'",JSON.stringify(originUrl));
|
||||
const {default:robots}=await import('data:text/javascript;base64,'+Buffer.from(code).toString('base64'));
|
||||
|
||||
async function policy(environment,nodeEnvironment='production',origin='https://permanent.drama.test') {
|
||||
const previous={APP_ENV:process.env.APP_ENV,NODE_ENV:process.env.NODE_ENV};
|
||||
try{
|
||||
if(environment===undefined)delete process.env.APP_ENV;else process.env.APP_ENV=environment;
|
||||
process.env.NODE_ENV=nodeEnvironment;
|
||||
originStub.setOrigin(origin);
|
||||
return await robots();
|
||||
}finally{
|
||||
for(const [key,value] of Object.entries(previous))if(value===undefined)delete process.env[key];else process.env[key]=value;
|
||||
}
|
||||
}
|
||||
|
||||
// These rules contain literal prefixes; use the longest matching prefix, with Allow winning ties.
|
||||
function crawlable(document,path) {
|
||||
const groups=Array.isArray(document.rules)?document.rules:[document.rules];
|
||||
const rules=groups.filter(group=>group.userAgent==='*').flatMap(group=>['allow','disallow'].flatMap(type=>(Array.isArray(group[type])?group[type]:[group[type]]).filter(Boolean).map(prefix=>({prefix,allow:type==='allow'}))));
|
||||
return rules.filter(rule=>path.startsWith(rule.prefix)).sort((a,b)=>b.prefix.length-a.prefix.length||Number(b.allow)-Number(a.allow))[0]?.allow??true;
|
||||
}
|
||||
|
||||
test('production permits public pages, valid pagination and resources needed to render or play them',async()=>{
|
||||
const document=await policy('production');
|
||||
for(const path of ['/','/shorts/','/shorts/page/2/','/rank/new/','/topics/urban/','/tag/urban/','/config/editors-choice/','/detail/city-story/','/play/city-story/90001/','/info/about-us/','/_next/static/chunks/site.js','/_next/static/css/site.css','/scripts/site-runtime.js','/assets/poster.webp','/h5/m3u8/demo.m3u8','/enkey?token=test','/sitemap.xml'])assert.equal(crawlable(document,path),true,path);
|
||||
for(const path of ['/api/app/playlet/home','/api/app/auth/guest','/healthz','/healthz/'])assert.equal(crawlable(document,path),false,path);
|
||||
});
|
||||
|
||||
test('noindex pages and duplicate variants remain crawlable so their head directives are visible',async()=>{
|
||||
const document=await policy('production');
|
||||
for(const path of ['/search/','/search/都市/','/search/都市/page/2/','/member/center/','/member/favorites/','/member/history/','/member/purchased/','/member/settings/','/info/deposit/','/info/deposit/vip/','/info/settings/','/info/user-center/','/info/feedback/','/navigation/','/apps/','/home-road/','/detail/city-story/page/2/','/shorts/?sort=2','/play/city-story/90001/?commentSort=1'])assert.equal(crawlable(document,path),true,path);
|
||||
assert.equal(document.sitemap,'https://permanent.drama.test/sitemap.xml');
|
||||
});
|
||||
|
||||
test('development and test are closed to crawling while the standalone production default stays open',async()=>{
|
||||
for(const environment of ['development','test']){
|
||||
const document=await policy(environment);
|
||||
for(const path of ['/','/shorts/','/detail/city-story/','/_next/static/site.js'])assert.equal(crawlable(document,path),false,environment+' '+path);
|
||||
assert.equal(document.sitemap,undefined);assert.equal(originStub.calls,0);
|
||||
}
|
||||
assert.equal(crawlable(await policy(undefined,'development'),'/'),false);
|
||||
assert.equal(crawlable(await policy(undefined,'production'),'/'),true);
|
||||
});
|
||||
|
||||
test('missing site configuration omits only the Sitemap directive and leaves production crawl rules available',async()=>{
|
||||
const document=await policy('production','production','');
|
||||
assert.equal(document.sitemap,undefined);assert.equal(crawlable(document,'/shorts/'),true);
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
import {routingModuleUrl} from './helpers/routing.mjs';
|
||||
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).replace(/(['"])\.\.\/routing\1/g,JSON.stringify(routingModuleUrl))).toString('base64');
|
||||
}
|
||||
const routingUrl=await moduleUrl('../src/lib/routing.ts');
|
||||
const {parseSitePath,routeFromSegments,navigationSection,internalPagePath,getRankPage,RANK_PAGES,samePageAddress}=await import(routingUrl);
|
||||
const navigationUrl=await moduleUrl('../src/lib/client/navigation.ts',code=>code.replace(/from ['"]\.\.\/routing['"]/,`from '${routingUrl}'`));
|
||||
const {navigateTo,NAVIGATE_EVENT}=await import(navigationUrl);
|
||||
const protocolUrl=await moduleUrl('../src/lib/api/protocol.ts');
|
||||
const renderUrl=await moduleUrl('../src/lib/client/render.ts',code=>code.replace(/from ['"]\.\.\/api\/protocol['"]/,`from '${protocolUrl}'`));
|
||||
const {card,hero,topicCard,renderHome}=await import(renderUrl);
|
||||
|
||||
test('homepage URLs resolve to home independently of the shorts page',()=>{
|
||||
for(const address of ['https://drama.example','https://drama.example/']){
|
||||
const route=parseSitePath(new URL(address).pathname);
|
||||
assert.deepEqual(route,{group:'',key:''});
|
||||
assert.equal(navigationSection(route),'home');
|
||||
}
|
||||
assert.equal(internalPagePath('/','https://drama.example/shorts/'),'/');
|
||||
assert.deepEqual(parseSitePath('/shorts/'),{group:'page',key:'shorts'});
|
||||
});
|
||||
|
||||
test('clean paths select the matching page, IDs, and navigation section',()=>{
|
||||
assert.deepEqual(parseSitePath('/'),{group:'',key:''});
|
||||
assert.deepEqual(parseSitePath('/shorts/'),{group:'page',key:'shorts'});
|
||||
assert.deepEqual(parseSitePath('/topics/31/'),{group:'page',key:'topics',value:'31'});
|
||||
assert.deepEqual(parseSitePath('/play/23456/90001/'),{group:'play',key:'23456',value:'90001'});
|
||||
assert.deepEqual(parseSitePath('/info/deposit/vip/'),{group:'info',key:'deposit',value:'vip'});
|
||||
assert.deepEqual(parseSitePath('/member/history/'),{group:'member',key:'history'});
|
||||
assert.deepEqual(parseSitePath('/member/likes/'),{group:'member',key:'likes'});
|
||||
assert.deepEqual(parseSitePath('/member/likes/page/2/'),{group:'member',key:'likes',page:2});
|
||||
for(const [path,section] of [['/','home'],['/shorts/','shorts'],['/topics/31/','topics'],['/detail/7/','shorts'],['/member/center/','']]){
|
||||
assert.equal(navigationSection(parseSitePath(path)),section,path);
|
||||
}
|
||||
});
|
||||
|
||||
test('search keywords containing Chinese and URL punctuation survive a single decode',()=>{
|
||||
const keyword='都市 / 爱情?#%2F';
|
||||
const route={group:'search',key:keyword};
|
||||
assert.deepEqual(parseSitePath('/search/'+encodeURIComponent(keyword)+'/'),route);
|
||||
assert.deepEqual(routeFromSegments(['search',keyword]),route);
|
||||
assert.equal(parseSitePath('/search/%E0%A4%A/'),null);
|
||||
assert.equal(navigationSection(route),'');
|
||||
assert.deepEqual(parseSitePath('/search/'),{group:'search',key:''});
|
||||
assert.deepEqual(parseSitePath('/search'),{group:'search',key:''});
|
||||
assert.deepEqual(parseSitePath('/search/page/2/','?q='+encodeURIComponent(keyword)),{...route,page:2});
|
||||
});
|
||||
|
||||
test('resource IDs must be positive safe integers before any API reads',()=>{
|
||||
for(const value of ['0','9007199254740992']){
|
||||
for(const path of ['/detail/'+value+'/','/play/23456/'+value+'/','/topics/'+value+'/','/theme/'+value+'/','/tag/'+value+'/','/config/'+value+'/'])assert.equal(parseSitePath(path),null,path);
|
||||
}
|
||||
});
|
||||
|
||||
test('each ranking has a distinct page path and the correct API type',()=>{
|
||||
for(const page of RANK_PAGES){
|
||||
for(const path of [page.path,page.path.slice(0,-1)]){
|
||||
const route=parseSitePath(path);
|
||||
assert.deepEqual(route,{group:'page',key:'rank',value:page.slug});
|
||||
assert.equal(navigationSection(route),'rank');
|
||||
assert.equal(getRankPage(route.value).rankType,page.rankType);
|
||||
assert.equal(internalPagePath(path,'https://drama.example/'),path);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(RANK_PAGES.map(page=>page.rankType),[1,2,3]);
|
||||
assert.equal(getRankPage(parseSitePath('/rank/').value).rankType,1);
|
||||
for(const path of ['/rank/unknown/','/rank/new/extra/','/rank//','/rank/2/'])assert.equal(parseSitePath(path),null,path);
|
||||
});
|
||||
|
||||
test('hash page links are not converted or treated as page destinations',()=>{
|
||||
const base='https://drama.example/';
|
||||
for(const hash of ['#','#page/shorts','#page/config/31','#detail/7','#video/8','#play/7/90001','#search/'+encodeURIComponent('逆袭/甜宠'),'#member/history','#info/deposit/vip']){
|
||||
assert.equal(internalPagePath(hash,base),null,hash);
|
||||
assert.equal(internalPagePath(base+hash,base),null,hash);
|
||||
}
|
||||
assert.deepEqual(parseSitePath(new URL(base+'#page/shorts').pathname),{group:'',key:''});
|
||||
});
|
||||
|
||||
test('only recognized internal page destinations are handled by the application',()=>{
|
||||
const base='https://drama.example/detail/7';
|
||||
assert.equal(internalPagePath('/shorts/',base),'/shorts/');
|
||||
assert.equal(internalPagePath('https://drama.example/member/history/',base),'/member/history/');
|
||||
assert.equal(internalPagePath('/search/'+encodeURIComponent('爱情 / 逆袭')+'/',base),'/search/'+encodeURIComponent('爱情 / 逆袭')+'/');
|
||||
for(const href of ['https://external.example/shorts','javascript:alert(1)','/api/app/playlet/home','/assets/poster.webp','#comments','/unknown','/play/7/','/shorts/extra/','/member/unknown/','/detail/0/']){
|
||||
assert.equal(internalPagePath(href,base),null,href);
|
||||
}
|
||||
});
|
||||
|
||||
test('page navigation requests a complete server document while leaving hashes, external links and browser history to the browser',()=>{
|
||||
const previous=globalThis.window,calls=[];
|
||||
globalThis.window={location:{href:'https://drama.example/shorts/',assign:path=>calls.push(path)},history:{pushState:()=>assert.fail('client page routing must not replace server navigation')}};
|
||||
try{
|
||||
for(const path of ['#comments','/#page/shorts','https://external.example/shorts','/series/'])navigateTo(path);
|
||||
assert.deepEqual(calls,[]);
|
||||
for(const path of ['/play/23456/90001/','/member/history/','/rank/new/','/shorts/page/2/?sort=2'])navigateTo(path);
|
||||
assert.deepEqual(calls,['/play/23456/90001/','/member/history/','/rank/new/','/shorts/page/2/?sort=2']);
|
||||
assert.equal(globalThis.window.location.href,'https://drama.example/shorts/','the old document stays visible until the new response arrives');
|
||||
}finally{globalThis.window=previous;}
|
||||
});
|
||||
|
||||
test('dynamic content generates trailing slashes directly in page links',()=>{
|
||||
const drama={id:23456,title:'测试短剧',showOnBanner:true};
|
||||
const topic={id:31,name:'测试专题'};
|
||||
const cases=[
|
||||
[card(drama),'/detail/23456/'],
|
||||
[hero([drama]),'/detail/23456/'],
|
||||
[topicCard(topic),'/topics/31/'],
|
||||
[topicCard(topic,true),'/topics/31/'],
|
||||
[renderHome({homeConfigData:[{id:31,name:'更多短剧',status:true,type:1,list:[drama]}]}),'/config/31/'],
|
||||
];
|
||||
for(const [html,path] of cases)assert.ok(html.includes(`href="${path}"`),path);
|
||||
assert.equal(internalPagePath('/search/'+encodeURIComponent('都市 / 爱情?#%')+'/?page=2','https://drama.example/'),'/search/'+encodeURIComponent('都市 / 爱情?#%')+'/?page=2');
|
||||
});
|
||||
|
||||
|
||||
test('server bootstrap matches only the same parsed page, pagination and query',()=>{
|
||||
assert.equal(samePageAddress('/shorts/?sort=2&source=test','/shorts?source=test&sort=2'),true);
|
||||
assert.equal(samePageAddress('/search/%E9%83%BD%E5%B8%82/','/search/都市/'),true);
|
||||
assert.equal(samePageAddress('/shorts/page/2/','/shorts/page/3/'),false);
|
||||
assert.equal(samePageAddress('/shorts/?sort=2','/shorts/?sort=1'),false);
|
||||
assert.equal(samePageAddress('/unknown/','/unknown/'),false);
|
||||
});
|
||||
|
||||
|
||||
test('rich text titles are one decoded segment, including the legacy richetext spelling',()=>{
|
||||
const title='关于我们';
|
||||
const route={group:'richtext',key:title};
|
||||
for(const path of [`/richtext/${encodeURIComponent(title)}/`,`/richtext/${title}`,`/richetext/${encodeURIComponent(title)}/`]){
|
||||
assert.deepEqual(parseSitePath(path),route,path);
|
||||
assert.equal(navigationSection(parseSitePath(path)),'');
|
||||
}
|
||||
assert.equal(internalPagePath(`/richtext/${encodeURIComponent(title)}/`,'https://drama.example/'),`/richtext/${encodeURIComponent(title)}/`);
|
||||
assert.equal(parseSitePath(`/richtext/${encodeURIComponent(title)}/page/2/`),null);
|
||||
for(const path of ['/richtext/','/richtext','/richetext/','/richtext/./','/richtext/../','/richtext/a/b/'])assert.equal(parseSitePath(path),null,path);
|
||||
});
|
||||
|
||||
test('removed standalone series and video paths are never recognized or converted to another page',()=>{
|
||||
for(const path of ['/series','/series/','/series/page/2','/series/page/2/','/video/7','/video/7/','/video/7/page/2/']){
|
||||
assert.equal(parseSitePath(path),null,path);assert.equal(internalPagePath(path,'https://drama.example/'),null,path);
|
||||
}
|
||||
assert.deepEqual(parseSitePath('/detail/7/'),{group:'detail',key:'7'});
|
||||
assert.deepEqual(parseSitePath('/play/7/90001/'),{group:'play',key:'7',value:'90001'});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import {routingModuleUrl} from './helpers/routing.mjs';
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFile} from 'node:fs/promises';
|
||||
import ts from 'typescript';
|
||||
|
||||
async function moduleUrl(file,replace=code=>code){
|
||||
const filename=new URL(file,import.meta.url);
|
||||
const {outputText}=ts.transpileModule(await readFile(filename,'utf8'),{compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.ESNext}});
|
||||
return 'data:text/javascript;base64,'+Buffer.from(replace(outputText).replace(/(['"])\.\.\/routing\1/g,JSON.stringify(routingModuleUrl))+'\n//# sourceURL='+filename.pathname).toString('base64');
|
||||
}
|
||||
const protocolUrl=await moduleUrl('../src/lib/api/protocol.ts');
|
||||
const renderUrl=await moduleUrl('../src/lib/client/render.ts',code=>code.replace('"../api/protocol"',JSON.stringify(protocolUrl)));
|
||||
const searchUrl=await moduleUrl('../src/lib/client/search.ts',code=>code.replace("'../api/protocol'",JSON.stringify(protocolUrl)).replace("'./render'",JSON.stringify(renderUrl)));
|
||||
const routingUrl=await moduleUrl('../src/lib/routing.ts');
|
||||
const {loadSearchResults,searchResultsMarkup,searchErrorMarkup,searchSuggestionsMarkup,searchPicksMarkup,SEARCH_PAGE_SIZE}=await import(searchUrl);
|
||||
const {highlightSearchTitle}=await import(renderUrl);
|
||||
const {paginationPath,searchPath,parseSitePath}=await import(routingUrl);
|
||||
const signal=()=>new AbortController().signal;
|
||||
const drama={id:7,title:'SEO 标题',playName:'都市 <短剧>',cover:'https://cdn.example/encrypted.webp',totalEpisode:12};
|
||||
|
||||
test('search uses reference API parameters, authoritative totals and actual playName without local filtering',async()=>{
|
||||
const calls=[];
|
||||
const result=await loadSearchResults({get:async(path,query,requestSignal)=>{calls.push({path,query,requestSignal});return {keyword:'都市剧',list:[null,drama,{id:8,title:'服务端判定的相关作品'}],totalCount:102,pageSize:25};}},' 都市 ',3,signal());
|
||||
assert.equal(SEARCH_PAGE_SIZE,25);assert.equal(calls.length,1);assert.equal(calls[0].path,'playlet/search');assert.deepEqual(calls[0].query,{keyword:'都市',pageNum:3,pageSize:25});
|
||||
assert.equal(result.keyword,'都市剧');assert.equal(result.total,102);assert.equal(result.page,3);assert.equal(result.pageSize,25);
|
||||
assert.equal(result.list[0].title,'都市 <短剧>');assert.equal(result.list.length,2);assert.deepEqual(result.recommended,[]);
|
||||
});
|
||||
|
||||
test('independent search page links preserve encoded keywords and use the response page size',async()=>{
|
||||
const keyword='都市 / 爱情?#%';
|
||||
const result=await loadSearchResults({get:async()=>({list:[drama],totalCount:61,pageSize:10})},keyword,2,signal());
|
||||
const base=searchPath(keyword,2);
|
||||
const html=searchResultsMarkup(result,page=>paginationPath(base,page));
|
||||
for(const page of [1,3,7])assert.ok(html.includes('href="'+searchPath(keyword,page)+'"'));
|
||||
assert.ok(html.includes('name="page" min="1" max="7"'));assert.ok(html.includes('共 <strong>61</strong> 个结果'));assert.ok(html.includes('data-src="https://cdn.example/encrypted.webp"'));
|
||||
assert.ok(!html.includes('SEO 标题'));assert.ok(html.includes('都市 <短剧>'));assert.ok(!html.includes('#search/'));
|
||||
});
|
||||
|
||||
test('empty results request six hottest works and keep them separate from the search count',async()=>{
|
||||
const calls=[];
|
||||
const result=await loadSearchResults({get:async(path,query)=>{
|
||||
calls.push({path,query});
|
||||
if(path==='playlet/search')return {list:null,totalCount:0};
|
||||
return {list:[null,{id:0,title:'无效'},drama,drama,...Array.from({length:7},(_,i)=>({...drama,id:i+10}))]};
|
||||
}},'没有匹配的关键词',1,signal());
|
||||
assert.deepEqual(calls[1],{path:'playlet/operate/list',query:{pageNum:1,pageSize:6,type:2}});
|
||||
assert.equal(result.recommended.length,6);assert.equal(new Set(result.recommended.map(item=>item.id)).size,6);assert.equal(result.total,0);assert.deepEqual(result.list,[]);
|
||||
const html=searchResultsMarkup(result,()=>assert.fail('empty first page has no pagination'));
|
||||
assert.ok(html.includes('共 <strong>0</strong> 个结果'));assert.ok(html.includes('搜索无结果'));assert.ok(html.includes('f2ef01f956fdcd80.png'));
|
||||
assert.ok(html.includes('大家都在看'));assert.ok(html.includes('aria-label="热门推荐"'));assert.ok(html.includes('href="/shorts/"'));
|
||||
});
|
||||
|
||||
test('empty entry skips the search endpoint; unavailable recommendations never replace an empty result with an error',async()=>{
|
||||
const calls=[];
|
||||
const result=await loadSearchResults({get:async(path,query)=>{calls.push({path,query});throw new Error('recommendation unavailable');}},' ',1,signal());
|
||||
assert.deepEqual(calls.map(call=>call.path),['playlet/operate/list']);assert.equal(result.keyword,'');assert.deepEqual(result.recommended,[]);
|
||||
const html=searchResultsMarkup(result,()=>assert.fail('empty entry cannot paginate'));
|
||||
assert.ok(html.includes('搜索你想看的短剧'));assert.ok(html.includes('输入剧名、演员或标签'));assert.ok(!html.includes('大家都在看'));assert.ok(!html.includes('recommendation unavailable'));
|
||||
});
|
||||
|
||||
test('search failures have their own retry state and never masquerade as zero matches',async()=>{
|
||||
const calls=[];
|
||||
await assert.rejects(loadSearchResults({get:async path=>{calls.push(path);throw new Error('搜索服务超时');}},'原名',1,signal()),/搜索服务超时/);
|
||||
assert.deepEqual(calls,['playlet/search']);
|
||||
const html=searchErrorMarkup('<script>','接口失败 <img>');
|
||||
assert.ok(html.includes('搜索暂时不可用'));assert.ok(html.includes('接口失败 <img>'));assert.ok(html.includes('data-api-action="retry"'));assert.ok(!html.includes('共 <strong>0'));
|
||||
});
|
||||
|
||||
test('leaving a search page ignores late search and recommendation responses',async()=>{
|
||||
for(const stage of ['search','recommendation']){
|
||||
const controller=new AbortController(),calls=[];let finish;
|
||||
const pending=loadSearchResults({get:async path=>{
|
||||
calls.push(path);
|
||||
if(stage==='recommendation'&&path==='playlet/search')return {list:[],totalCount:0};
|
||||
return new Promise(resolve=>finish=resolve);
|
||||
}},'旧搜索',1,controller.signal);
|
||||
await new Promise(resolve=>setImmediate(resolve));controller.abort();finish({list:[drama],totalCount:1});
|
||||
await assert.rejects(pending,error=>error.name==='AbortError');
|
||||
assert.deepEqual(calls,stage==='search'?['playlet/search']:['playlet/search','playlet/operate/list']);
|
||||
}
|
||||
});
|
||||
|
||||
test('HTML-style highlighting handles full-width letters, repeated matches, spaces, emoji and escaped markup',()=>{
|
||||
const html=highlightSearchTitle('😀AI 爱 情 / ai爱情 <script>','ai 爱情');
|
||||
assert.equal((html.match(/class="search-keyword"/g)||[]).length,2);assert.ok(html.includes('>AI 爱 情</span>'));assert.ok(html.includes('>ai爱情</span>'));assert.ok(html.includes('😀'));assert.ok(html.includes('<script>'));
|
||||
assert.equal(highlightSearchTitle('<img>',' '),'<img>');assert.equal(highlightSearchTitle('不相关标题','爱情'),'不相关标题');
|
||||
assert.equal(highlightSearchTitle('爱情爱情','爱情'),'<span class="search-keyword">爱情</span><span class="search-keyword">爱情</span>');
|
||||
});
|
||||
|
||||
|
||||
test('empty searches use server everyoneWatching without another recommendation request',async()=>{
|
||||
const calls=[];
|
||||
const result=await loadSearchResults({get:async(path,query)=>{calls.push({path,query});return {list:[],totalCount:0,everyoneWatching:[drama,drama,{...drama,id:8}]};}},'空结果',1,signal());
|
||||
assert.deepEqual(calls.map(call=>call.path),['playlet/search']);assert.deepEqual(result.recommended.map(item=>item.id),[7,8]);assert.equal(result.total,0);
|
||||
});
|
||||
|
||||
|
||||
test('search suggestions open the returned drama while hot keywords open search routes',()=>{
|
||||
const suggestions=searchSuggestionsMarkup([drama],'都市');
|
||||
assert.ok(suggestions.includes('href="/detail/7/"'));assert.ok(suggestions.includes('都市 <短剧>'));assert.ok(!suggestions.includes('SEO 标题'));
|
||||
assert.ok(searchSuggestionsMarkup([{keyword:'都市 / 爱情'}],'').includes('href="'+searchPath('都市 / 爱情')+'"'));
|
||||
for(const value of [null,{},[null]])assert.ok(searchSuggestionsMarkup(value,'词').includes('暂无搜索建议'));
|
||||
});
|
||||
|
||||
test('dropdown recommendations keep compact cards, real detail links and tracking without full-page metadata',()=>{
|
||||
const html=searchPicksMarkup([null,...Array.from({length:4},(_,index)=>({...drama,id:7+index,slug:'story-'+index,isNew:true,desc:'简介 <script>',countLike:99}))]);
|
||||
assert.equal((html.match(/class="search-pick"/g)||[]).length,3);
|
||||
assert.ok(html.includes('href="/detail/story-0/"'));assert.ok(html.includes('data-track-item data-item-id="7"'));
|
||||
assert.ok(html.includes('class="search-pick-cover"'));assert.ok(html.includes('width="160" height="240"'));
|
||||
assert.ok(html.includes('data-src="https://cdn.example/encrypted.webp"'));assert.ok(html.includes('alt="SEO 标题封面"'));
|
||||
assert.ok(html.includes('简介 <script>'));assert.ok(html.includes('cover-corner-badge--new'));assert.ok(html.includes('>上新</span>'));assert.ok(html.includes('12集'));
|
||||
assert.ok(searchPicksMarkup([{...drama,isRecommend:true,isFeatured:true,isNew:true}]).includes('>原创</span>'));
|
||||
assert.ok(searchPicksMarkup([{...drama,isFeatured:true,isNew:true}]).includes('>爆剧</span>'));
|
||||
assert.ok(!html.includes('drama-data-card'));assert.ok(!html.includes('drama-meta'));
|
||||
assert.equal(searchPicksMarkup(null),'');
|
||||
});
|
||||
|
||||
test('search routes encode keywords once in the path and retain exact special characters through pagination',()=>{
|
||||
for(const keyword of ['都市 / 爱情 & 100% #1','字面%2F保持原样','😀+a?b=c']){
|
||||
for(const page of [1,2,7]){
|
||||
const address=searchPath(keyword,page),url=new URL(address,'https://drama.test');
|
||||
assert.equal(url.pathname,'/search/'+encodeURIComponent(keyword)+'/'+(page>1?`page/${page}/`:''));
|
||||
assert.equal(url.search,'');
|
||||
assert.deepEqual(parseSitePath(url.pathname,url.search),{group:'search',key:keyword,...(page>1?{page}:{})});
|
||||
assert.equal(paginationPath(address,3),searchPath(keyword,3));
|
||||
}
|
||||
}
|
||||
assert.equal(searchPath(' '),'/search/');
|
||||
assert.throws(()=>searchPath('',2));
|
||||
assert.throws(()=>searchPath('词',0));
|
||||
assert.equal(paginationPath('/search/'+encodeURIComponent('都市爱情')+'/',2),searchPath('都市爱情',2));
|
||||
assert.equal(paginationPath('/search/?q='+encodeURIComponent('都市爱情')+'&source=menu',2),searchPath('都市爱情',2)+'?source=menu');
|
||||
const suggestions=searchSuggestionsMarkup([{keyword:'都市 & 爱情'}],'');
|
||||
assert.ok(suggestions.includes('href="'+searchPath('都市 & 爱情')+'"'));
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {mkdtemp,readFile,readdir,rm,writeFile} from 'node:fs/promises';
|
||||
import {tmpdir} from 'node:os';
|
||||
import {join} from 'node:path';
|
||||
import ts from 'typescript';
|
||||
import {seo,seoUrl,structuredUrl} from './helpers/seo.mjs';
|
||||
|
||||
const source=await readFile(new URL('../src/lib/seo-snapshot.server.ts',import.meta.url),'utf8');
|
||||
const compiled=ts.transpileModule(source,{compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.ESNext}}).outputText
|
||||
.replace("'./seo'",JSON.stringify(seoUrl)).replace("'./structured-data'",JSON.stringify(structuredUrl));
|
||||
const {SeoSnapshotStore}=await import('data:text/javascript;base64,'+Buffer.from(compiled).toString('base64'));
|
||||
const environment=seo.resolveSeoEnvironment({config:{foreverUrlAddr:'https://drama.test'},seoInfo:{webSiteName:'站点品牌',title:'全站标题'}},[{urlValue:'/shorts/',seoInfo:{title:'栏目标题'}}]);
|
||||
|
||||
async function directory(t){const dir=await mkdtemp(join(tmpdir(),'91-seo-snapshot-'));t.after(()=>rm(dir,{recursive:true,force:true}));return dir;}
|
||||
|
||||
test('public SEO snapshots survive a new store instance without persisting unrelated credentials',async t=>{
|
||||
const dir=await directory(t),store=new SeoSnapshotStore('https://api-a.test',dir);
|
||||
await store.write({...environment,token:'private-session',reportSdkConfig:'private-sdk',categories:[{...environment.categories[0],secret:'private-category'}]});
|
||||
const saved=await new SeoSnapshotStore('https://api-a.test',dir).read();
|
||||
assert.equal(saved.origin,environment.origin);assert.deepEqual(saved.site,environment.site);assert.equal(saved.categories[0].seoInfo.title,'栏目标题');
|
||||
const text=await readFile(join(dir,(await readdir(dir))[0]),'utf8');
|
||||
for(const value of ['private-session','private-sdk','private-category'])assert.ok(!text.includes(value));
|
||||
assert.equal(await new SeoSnapshotStore('https://api-b.test',dir).read(),undefined);
|
||||
});
|
||||
|
||||
test('invalid writes and corrupt files cannot replace the last valid site identity',async t=>{
|
||||
const dir=await directory(t),store=new SeoSnapshotStore('https://api.test',dir);
|
||||
await store.write(environment);await store.write({...environment,origin:'javascript:bad'});
|
||||
assert.equal((await store.read()).origin,environment.origin);
|
||||
const filename=join(dir,(await readdir(dir))[0]);await writeFile(filename,'{invalid-json');
|
||||
assert.equal((await store.read()).origin,environment.origin);
|
||||
assert.equal(await new SeoSnapshotStore('https://api.test',dir).read(),undefined);
|
||||
await writeFile(filename,JSON.stringify({version:1,environment:{...environment,origin:'http://localhost:3000'}}));
|
||||
assert.equal(await new SeoSnapshotStore('https://api.test',dir).read(),undefined);
|
||||
});
|
||||
|
||||
test('parallel snapshots are atomic and a non-writable cache still preserves process-local identity',async t=>{
|
||||
const dir=await directory(t),store=new SeoSnapshotStore('https://api.test',dir);
|
||||
await Promise.all(Array.from({length:8},(_,n)=>store.write({...environment,origin:`https://site-${n}.test`})));
|
||||
const files=await readdir(dir);assert.equal(files.length,1);assert.ok(files[0].endsWith('.json'));
|
||||
const saved=await new SeoSnapshotStore('https://api.test',dir).read();assert.match(saved.origin,/^https:\/\/site-\d\.test$/);
|
||||
const blocked=join(dir,'not-a-directory');await writeFile(blocked,'file');
|
||||
const memory=new SeoSnapshotStore('https://blocked.test',blocked);await memory.write(environment);
|
||||
assert.equal((await memory.read()).origin,environment.origin);
|
||||
});
|
||||
|
||||
test('test and production snapshots remain isolated even when they use the same API',async t=>{
|
||||
const root=await directory(t),cwd=process.cwd(),previous=process.env.APP_DIST_DIR;
|
||||
try{
|
||||
process.chdir(root);
|
||||
process.env.APP_DIST_DIR='.next-test';
|
||||
await new SeoSnapshotStore('https://same-api.test').write(environment);
|
||||
process.env.APP_DIST_DIR='.next';
|
||||
assert.equal(await new SeoSnapshotStore('https://same-api.test').read(),undefined);
|
||||
await new SeoSnapshotStore('https://same-api.test').write({...environment,origin:'https://production-site.test'});
|
||||
process.env.APP_DIST_DIR='.next-test';
|
||||
assert.equal((await new SeoSnapshotStore('https://same-api.test').read()).origin,environment.origin);
|
||||
}finally{
|
||||
process.chdir(cwd);
|
||||
if(previous===undefined)delete process.env.APP_DIST_DIR;else process.env.APP_DIST_DIR=previous;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {seo,clientSeo,headDocument} from './helpers/seo.mjs';
|
||||
|
||||
const config={seoInfo:{title:'全站标题',description:'全站描述',keywords:['全站词'],webSiteName:'91剧场'},config:{foreverUrlAddr:'https://drama.example.net/entry/?channel=1'}};
|
||||
const environment=seo.seoEnvironment(config);
|
||||
const page=(path,content={},search='')=>seo.buildPageHead(path,search,environment,content);
|
||||
|
||||
test('path-based search metadata retains keywords and page numbers without becoming indexable',()=>{
|
||||
const head=page('/search/'+encodeURIComponent('都市 / 爱情')+'/page/2/',{totalCount:60,pageSize:25});
|
||||
assert.ok(head.title.includes('都市 / 爱情 · 搜索结果'));
|
||||
assert.ok(head.title.includes('第2页'));
|
||||
assert.equal(head.robots,'noindex,follow');
|
||||
assert.equal(head.canonical,undefined);assert.equal(head.jsonLd,undefined);
|
||||
});
|
||||
|
||||
test('normalizes legacy/string SEO values and uses the permanent site origin',()=>{
|
||||
assert.deepEqual(seo.normalizeSeo(JSON.stringify({webTitle:' 标题 ',webDescription:' 简介 ',keyworld:'都市,甜宠;都市',webSiteName:'91'})),{title:'标题',description:'简介',keywords:['都市','甜宠'],siteName:'91'});
|
||||
assert.equal(environment.origin,'https://drama.example.net');
|
||||
for(const value of ['',null,'""','{bad',[],12])assert.equal(seo.normalizeSeo(value).title,'');
|
||||
for(const value of ['javascript:alert(1)','https://user:secret@host.test','localhost:3000','https://example.com/path','https://www.xx.xxx/'])assert.equal(seo.siteOrigin(value),'',value);
|
||||
assert.equal(seo.seoEnvironment({config:{foreverUrlAddr:'https://example.com'},newAddressInfo:{foreverUrlAddr:'drama.example.org/entry'}}).origin,'https://drama.example.org');
|
||||
assert.equal(seo.seoEnvironment({}).origin,'');
|
||||
});
|
||||
|
||||
test('home uses global SEO, independent route fields survive, and copied homepage fields fall back to page-specific content',()=>{
|
||||
const home=page('/');assert.equal(home.title,'全站标题 - 91剧场');assert.equal(home.description,'全站描述');assert.equal(home.siteName,'91剧场');
|
||||
const copied=page('/shorts/',{seoInfo:config.seoInfo});assert.equal(copied.title,'短剧 - 91剧场');assert.notEqual(copied.description,'全站描述');
|
||||
const own=page('/topics/7/',{title:'专题名称',seoInfo:{title:'独立专题TDK',description:'独立描述',keywords:'专题词'}});
|
||||
assert.equal(own.title,'独立专题TDK - 91剧场');assert.equal(own.description,'独立描述');assert.deepEqual(own.keywords,['专题词']);
|
||||
const detail=page('/detail/9/',seo.dramaSeoContent({id:9,title:'都市故事',desc:'故事简介',tagNames:['都市'],seoInfo:{title:'剧集独立TDK'}}));
|
||||
assert.equal(detail.title,'剧集独立TDK - 91剧场');assert.equal(detail.description,'故事简介');assert.ok(detail.keywords.includes('都市'));
|
||||
const episode=page('/play/9/90/',seo.dramaSeoContent({id:9,title:'都市故事',desc:'故事简介'},{currentEpisode:3}));
|
||||
assert.equal(episode.title,'都市故事 - 第3集 | 91剧场');assert.ok(episode.description.includes('第3集'));assert.equal(episode.type,'video.episode');
|
||||
});
|
||||
|
||||
test('navigation TDK has priority on category routes and does not replace drama content',()=>{
|
||||
const nav=seo.seoEnvironment(config,[{urlValue:'/playlet',seoInfo:{title:'短剧分类TDK',keyworld:['分类词'],description:'分类描述'}},{urlValue:'/topics/7/',seoInfo:{title:'独立专题分类TDK'}}]);
|
||||
assert.equal(seo.buildPageHead('/shorts/page/2/','',nav,{seoInfo:{title:'列表TDK'},totalCount:72}).title,'短剧分类TDK - 第2页');
|
||||
const detail=seo.buildPageHead('/detail/9/','',nav,{title:'剧名',seoInfo:{title:'剧集TDK',description:'剧集描述'}});
|
||||
assert.equal(detail.title,'剧集TDK - 91剧场');assert.equal(detail.description,'剧集描述');
|
||||
assert.equal(seo.buildPageHead('/topics/7/','',nav).title,'独立专题分类TDK');
|
||||
const withoutOwnTDK=seo.buildPageHead('/detail/9/','',nav,seo.dramaSeoContent({id:9,title:'具体剧名',desc:'具体简介'}));
|
||||
assert.equal(withoutOwnTDK.title,'具体剧名 - 91剧场');assert.equal(withoutOwnTDK.description,'具体简介');
|
||||
const ranking=seo.seoEnvironment(config,[{urlValue:'/rank/',seoInfo:{title:'排行榜配置标题'}}]);
|
||||
assert.equal(seo.buildPageHead('/rank/new/','',ranking).title,'排行榜配置标题 - 新剧榜');
|
||||
assert.equal(seo.buildPageHead('/rank/follow/','',ranking).title,'排行榜配置标题 - 追剧榜');
|
||||
assert.equal(page('/rank/').canonical,page('/rank/hot/').canonical);
|
||||
const home=seo.buildPageHead('/','',seo.seoEnvironment(config,[{urlValue:'https://external.test/',seoInfo:{title:'外站'}}]));assert.equal(home.title,'全站标题 - 91剧场');
|
||||
});
|
||||
|
||||
test('configuration failures and missing permanent origins retain the last valid SEO environment',()=>{
|
||||
const previous=seo.resolveSeoEnvironment(config,[{urlValue:'/shorts/',seoInfo:{title:'短剧分类'}}]);
|
||||
for(const broken of [{},{seoInfo:{webSiteName:'错误品牌'}},{config:{foreverUrlAddr:'javascript:bad'}},{config:{foreverUrlAddr:'http://localhost:3000'}}]){
|
||||
assert.equal(seo.resolveSeoEnvironment(broken,undefined,previous),previous);
|
||||
const head=seo.buildPageHead('/shorts/page/2/','',seo.resolveSeoEnvironment(broken,undefined,previous),{totalCount:72});
|
||||
assert.equal(head.canonical,'https://drama.example.net/shorts/page/2/');assert.equal(head.siteName,'91剧场');
|
||||
assert.equal(head.jsonLd['@graph'].find(item=>item['@type']==='Organization').name,'91剧场');
|
||||
}
|
||||
const partial=seo.resolveSeoEnvironment({config:config.config},undefined,previous);
|
||||
assert.equal(partial.site.siteName,'91剧场');assert.equal(partial.site.title,'全站标题');assert.deepEqual(partial.categories,previous.categories);
|
||||
const updated=seo.resolveSeoEnvironment({config:{foreverUrlAddr:'https://new.drama.test/path/'},seoInfo:{webSiteName:'新站名'}},[],previous);
|
||||
assert.equal(updated.origin,'https://new.drama.test');assert.equal(updated.site.siteName,'新站名');assert.deepEqual(updated.categories,[]);
|
||||
});
|
||||
|
||||
test('cold starts never guess a canonical and the browser preserves the server identity',()=>{
|
||||
const cold=seo.resolveSeoEnvironment({},[]);
|
||||
assert.equal(cold.origin,'');assert.equal(cold.site.siteName,'禁果短剧');
|
||||
const head=seo.buildPageHead('/shorts/','',cold,{totalCount:72});
|
||||
assert.equal(head.canonical,undefined);assert.equal(head.jsonLd,undefined);assert.equal(head.next,undefined);
|
||||
assert.equal(seo.clientSeoEnvironment(environment,{config:{foreverUrlAddr:'https://other.drama.test'},seoInfo:{webSiteName:'其他品牌'}}),environment);
|
||||
assert.equal(seo.clientSeoEnvironment(environment,{}),environment);
|
||||
const recovered=seo.clientSeoEnvironment(cold,config);
|
||||
assert.equal(recovered.origin,environment.origin);assert.equal(recovered.site.siteName,environment.site.siteName);
|
||||
const entity=page('/detail/9/',{title:'作品',seoInfo:{webSiteName:'作品接口的其他品牌'}});
|
||||
assert.equal(entity.siteName,'91剧场');assert.ok(entity.title.endsWith(' - 91剧场'));
|
||||
assert.equal(entity.jsonLd['@graph'].find(item=>item['@type']==='Organization').name,entity.siteName);
|
||||
});
|
||||
|
||||
test('canonical is query-free, every pagination page identifies itself, and first/middle/last page links are bounded',()=>{
|
||||
const first=page('/shorts',{totalCount:72,pageSize:24},'?channel=test#fragment');
|
||||
assert.equal(first.canonical,'https://drama.example.net/shorts/');assert.equal(first.previous,undefined);assert.equal(first.next,'https://drama.example.net/shorts/page/2/?channel=test');
|
||||
const middle=page('/shorts/page/2/',{totalCount:72,pageSize:24});
|
||||
assert.equal(middle.canonical,'https://drama.example.net/shorts/page/2/');assert.equal(middle.previous,'https://drama.example.net/shorts/');assert.equal(middle.next,'https://drama.example.net/shorts/page/3/');
|
||||
assert.match(middle.title,/第2页/);assert.match(middle.description,/第2页/);assert.ok(middle.keywords.includes('第2页'));
|
||||
const last=page('/shorts/page/3/',{totalCount:72,pageSize:24});assert.equal(last.next,undefined);assert.equal(last.previous,'https://drama.example.net/shorts/page/2/');
|
||||
assert.equal(page('/shorts/page/1/',{totalCount:72}).canonical,'https://drama.example.net/shorts/');
|
||||
const overflow=page('/shorts/page/4/',{totalCount:72,pageSize:24});assert.equal(overflow.canonical,undefined);assert.match(overflow.robots,/noindex/);assert.equal(overflow.previous,undefined);
|
||||
for(const totalCount of [0,1,24]){const head=page('/shorts/',{totalCount});assert.equal(head.previous,undefined);assert.equal(head.next,undefined);}
|
||||
const center=page('/member/center/',{totalCount:72});assert.equal(center.previous,undefined);assert.equal(center.next,undefined);
|
||||
for(const totalCount of [-1,NaN,'72']){const head=page('/shorts/',{totalCount});assert.equal(head.next,undefined);}
|
||||
});
|
||||
|
||||
test('private/tools/search/error pages do not inherit public canonical or indexability',()=>{
|
||||
for(const path of ['/search/爱情/','/navigation/','/home-road/','/apps/','/unknown-page/']){const head=page(path);assert.match(head.robots,/noindex,follow/);assert.equal(head.canonical,undefined,path);}
|
||||
for(const path of ['/member/center/','/member/likes/','/member/likes/page/2/','/member/favorites/page/2/','/info/deposit/','/info/feedback/'])assert.equal(page(path).robots,'noindex,nofollow',path);
|
||||
assert.match(page('/shorts/',{},'?sort=2').robots,/noindex/);assert.equal(page('/shorts/',{},'?sort=2').canonical,undefined);
|
||||
for(const content of [{failed:true},{missing:true},{unavailable:true}])assert.equal(page('/detail/9/',content).canonical,undefined);
|
||||
for(const path of ['/info/about-us/','/info/privacy-policy/','/rank/hot/','/rank/new/','/rank/follow/'])assert.ok(page(path).canonical,path);
|
||||
});
|
||||
|
||||
test('random shorts metadata matches the selected feed and remains excluded from indexing',()=>{
|
||||
const head=page('/shorts/page/2/',{totalCount:72},'?sort=4');
|
||||
assert.ok(head.title.includes('随机推荐'));assert.ok(head.description.includes('随机推荐'));assert.ok(head.title.includes('第2页'));
|
||||
assert.equal(head.robots,'noindex,follow');assert.equal(head.canonical,undefined);
|
||||
});
|
||||
|
||||
test('client navigation updates SSR head once and clears old canonical/OG URL/pagination after private or failed routes',()=>{
|
||||
const doc=headDocument();
|
||||
const first=page('/shorts/page/2/',{totalCount:72});clientSeo.applyPageHead(first,doc);
|
||||
const duplicate=doc.createElement('link');duplicate.setAttribute('rel','canonical');doc.head.appendChild(duplicate);
|
||||
const last=page('/rank/new/page/3/',{totalCount:72});clientSeo.applyPageHead(last,doc);
|
||||
assert.equal(doc.head.querySelectorAll('link[rel="canonical"]').length,1);
|
||||
assert.equal(doc.title,last.title);assert.equal(doc.value('meta[property="og:site_name"]'),'91剧场');
|
||||
assert.equal(doc.value('link[rel="canonical"]'),last.canonical);assert.equal(doc.value('meta[property="og:url"]'),last.canonical);
|
||||
assert.equal(doc.value('link[rel="next"]'),undefined);assert.equal(doc.value('link[rel="prev"]'),last.previous);
|
||||
const privatePage=page('/info/deposit/');clientSeo.applyPageHead(privatePage,doc);
|
||||
assert.equal(doc.value('link[rel="canonical"]'),undefined);assert.equal(doc.value('meta[property="og:url"]'),undefined);assert.equal(doc.value('link[rel="prev"]'),undefined);
|
||||
assert.equal(doc.value('meta[name="robots"]'),'noindex,nofollow');assert.equal(doc.value('meta[name="googlebot"]'),'noindex,nofollow');
|
||||
assert.equal(doc.value('meta[name="twitter:title"]'),privatePage.title);
|
||||
});
|
||||
|
||||
|
||||
test('episode metadata uses chapter SEO before drama SEO and keeps the real episode number',()=>{
|
||||
const data=seo.dramaSeoContent({id:9,title:'都市故事',seoInfo:{title:'剧集标题',description:'剧集描述'}},{id:90,currentEpisode:3,seoInfo:{title:'章节标题',description:'章节描述',keyworld:'章节词'}});
|
||||
const head=page('/play/9/90/',data);
|
||||
assert.ok(head.title.includes('章节标题'));assert.equal(head.description,'章节描述 - 第3集 | 91剧场');assert.ok(head.keywords.includes('章节词'));assert.equal(data.episode,3);
|
||||
});
|
||||
|
||||
test('topic aliases and numeric ID variants share a primary canonical without changing catalog pagination',()=>{
|
||||
for(const [path,expected] of [
|
||||
['/theme/007/','/theme/7/'],['/theme/007/page/2/','/theme/7/page/2/'],
|
||||
['/topics/007/page/1/','/topics/7/'],['/tag/009/','/tag/9/'],['/detail/009/','/detail/9/'],
|
||||
]){
|
||||
const head=page(path,{totalCount:72},'?utm_source=test');
|
||||
assert.equal(head.canonical,environment.origin+expected,path);assert.ok(head.robots.startsWith('index,'),path);
|
||||
}
|
||||
const alias=page('/theme/007/page/2/',{totalCount:72});
|
||||
assert.equal(alias.previous,environment.origin+'/theme/7/');assert.equal(alias.next,environment.origin+'/theme/7/page/3/');
|
||||
assert.equal(page('/shorts/page/2/',{totalCount:72}).canonical,environment.origin+'/shorts/page/2/');
|
||||
assert.equal(page('/search/007/').canonical,undefined);
|
||||
});
|
||||
|
||||
test('empty public lists and comment pagination are noindex while preserving successful and error statuses',()=>{
|
||||
for(const path of ['/shorts/','/topics/','/topics/7/','/theme/7/','/tag/9/','/rank/hot/']){
|
||||
const content={totalCount:0},head=page(path,content);
|
||||
assert.equal(head.robots,'noindex,follow',path);assert.equal(head.canonical,undefined);assert.equal(head.jsonLd,undefined);
|
||||
assert.equal(seo.pageResponseStatus(path,content),200);assert.equal(seo.pageResponseStatus(path+'page/2/',content),404);
|
||||
assert.equal(seo.pageResponseStatus(path,{...content,failed:true}),500);
|
||||
assert.equal(seo.pageResponseStatus(path,{...content,missing:true}),404);
|
||||
}
|
||||
const drama=seo.dramaSeoContent({id:9,title:'作品名',desc:'作品简介'});
|
||||
const playable=seo.dramaSeoContent({id:9,title:'作品名'},{id:90,currentEpisode:3,videoUrl:'https://media.example.net/3.mp4'});
|
||||
for(const [path,data] of [['/detail/9/',drama],['/play/9/90/',playable]]){
|
||||
assert.ok(page(path,{...data,totalCount:0,pageSize:10}).robots.startsWith('index,'),path);
|
||||
const content={...data,totalCount:35,pageSize:10},paged=page(path+'page/2/',content);
|
||||
assert.equal(seo.pageResponseStatus(path+'page/2/',content),200);
|
||||
assert.equal(paged.robots,'noindex,follow');
|
||||
for(const key of ['canonical','previous','next','jsonLd'])assert.equal(paged[key],undefined,key);
|
||||
}
|
||||
});
|
||||
|
||||
test('more snapshots need visible collection information and actual items before indexing',()=>{
|
||||
const route={group:'page',key:'config',value:'31'};
|
||||
const data={name:'都市精选',desc:'都市题材作品精选合集',list:[{id:9}],totalCount:1};
|
||||
const complete=seo.listSeoContent(data,route);
|
||||
assert.ok(page('/config/0031/',complete).robots.startsWith('index,'));
|
||||
assert.equal(page('/config/0031/',complete).canonical,environment.origin+'/config/31/');
|
||||
for(const patch of [{name:''},{desc:''},{list:[]},{list:[{id:-1}]},{desc:'<script>bad()</script>'}]){
|
||||
const content=seo.listSeoContent({...data,...patch},route),head=page('/config/31/',content);
|
||||
assert.equal(head.robots,'noindex,follow');assert.equal(head.jsonLd,undefined);assert.equal(seo.pageResponseStatus('/config/31/',content),200);
|
||||
}
|
||||
const topics=seo.listSeoContent({name:'主题精选',desc:'主题合集介绍',type:3,themeList:[{id:7}],totalCount:1},route);
|
||||
assert.deepEqual(topics.items,[{path:'/theme/7/'}]);assert.ok(page('/config/31/',topics).canonical);
|
||||
});
|
||||
|
||||
test('episode indexing requires an actual authorized source and episode identity, not metadata or a shared preview',()=>{
|
||||
const data={id:9,title:'作品名',desc:'作品简介',preview:'https://media.example.net/shared-preview.mp4'};
|
||||
const chapter={id:90,currentEpisode:3,videoUrl:'https://media.example.net/3.mp4'};
|
||||
for(const patch of [{videoUrl:''},{videoUrl:'javascript:bad'},{currentEpisode:0},{currentEpisode:undefined},{canWatch:false,needUnlock:true},{canWatch:false,needVip:true}]){
|
||||
const content=seo.dramaSeoContent(data,{...chapter,...patch,seoInfo:{title:'单集标题',description:'单集描述'}});
|
||||
const head=page('/play/9/90/',content);
|
||||
assert.equal(head.robots,'noindex,follow');assert.equal(head.jsonLd,undefined);assert.equal(seo.pageResponseStatus('/play/9/90/',content),200);
|
||||
if(patch.needUnlock||patch.needVip)assert.equal(content.entity.video.source,undefined);
|
||||
}
|
||||
for(const patch of [{},{payType:1,canWatch:true},{payType:2,canWatch:true}])assert.ok(page('/play/9/90/',seo.dramaSeoContent(data,{...chapter,...patch})).canonical);
|
||||
const doc=headDocument();clientSeo.applyPageHead(page('/play/9/90/',seo.dramaSeoContent(data,chapter)),doc);
|
||||
clientSeo.applyPageHead(page('/play/9/90/',seo.dramaSeoContent(data,{...chapter,needUnlock:true})),doc);
|
||||
assert.equal(doc.value('meta[name="robots"]'),'noindex,follow');assert.equal(doc.value('link[rel="canonical"]'),undefined);
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFile} from 'node:fs/promises';
|
||||
import ts from 'typescript';
|
||||
|
||||
async function moduleUrl(file,replace=code=>code){
|
||||
const code=ts.transpileModule(await readFile(new URL(file,import.meta.url),'utf8'),{compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.ESNext}}).outputText;
|
||||
return 'data:text/javascript;base64,'+Buffer.from(replace(code)).toString('base64');
|
||||
}
|
||||
const routing=await moduleUrl('../src/lib/routing.ts'),protocol=await moduleUrl('../src/lib/api/protocol.ts');
|
||||
const {navigationEntries,navigationIsActive,mobileNavigationEntries}=await import(await moduleUrl('../src/lib/site-navigation.ts',code=>code.replace("'./api/protocol'",JSON.stringify(protocol)).replace("'./routing'",JSON.stringify(routing))));
|
||||
const item=(id,name,urlValue,extra={})=>({id,name,urlValue,categoryType:1,...extra});
|
||||
|
||||
test('API order, labels, icons and nested destinations are preserved while footer and removed video entries stay absent',()=>{
|
||||
const entries=navigationEntries({categoryList:[null,item(20,'精选','/topics/'),item(21,'目录','',{categoryType:2,translationList:[{id:22,name:'第二页',urlValue:'/shorts/page/2/'},null]}),item(23,'更多精彩','/shorts/',{icon:'/assets/custom.webp'}),item(24,'页脚','/info/about-us/',{categoryType:3}),item(25,'剧集','/series/'),item(26,'电影','/video/123/')]});
|
||||
assert.deepEqual(entries.map(entry=>entry.name),['精选','目录','更多精彩']);assert.equal(entries[0].href,'/topics/');assert.equal(entries[1].children[0].href,'/shorts/page/2/');assert.equal(entries[2].icon,'/assets/custom.webp');
|
||||
assert.deepEqual(mobileNavigationEntries(entries).map(entry=>entry.name),['精选','第二页','更多精彩']);
|
||||
for(const categoryList of [null,[],{},'bad',[null]])assert.deepEqual(navigationEntries({categoryList}),[]);
|
||||
});
|
||||
|
||||
test('known API aliases and empty system entries generate native destinations directly without redirects',()=>{
|
||||
const entries=navigationEntries({categoryList:[item(1,'首页',''),item(2,'排行','/hot'),item(3,'回家的路',''),item(4,'福利应用',''),item(5,'名字由后台决定','/shorts?sort=2'),item(6,'未知空地址','')]});
|
||||
assert.deepEqual(entries.map(entry=>entry.href),['/','/rank/hot/','/home-road/','/apps/','/shorts/?sort=2']);
|
||||
});
|
||||
|
||||
test('links reject executable addresses, credentials and removed routes, and keep external URLs external',()=>{
|
||||
const entries=navigationEntries({categoryList:[item(1,'外部','https://example.net/path'),item(2,'本站','https://drama.test/shorts'),...['javascript:alert(1)','data:text/html,x','https://user:pass@example.net/','/\\example.net','/video/7/','/series','/shorts/#bad'].map((url,index)=>item(10+index,'无效',url))]},'https://drama.test');
|
||||
assert.equal(entries.length,2);assert.equal(entries[0].external,true);assert.equal(entries[1].external,false);assert.equal(entries[1].href,'/shorts/');
|
||||
});
|
||||
|
||||
test('active navigation follows configured destinations, independent pages, pagination and query filters',()=>{
|
||||
assert.equal(navigationIsActive('/','/'),true);assert.equal(navigationIsActive('/','/shorts/'),false);
|
||||
assert.equal(navigationIsActive('/shorts/','/shorts/page/3/'),true);
|
||||
assert.equal(navigationIsActive('/shorts/','/detail/7/','top'),false);assert.equal(navigationIsActive('/shorts/','/detail/7/','sidebar'),true);
|
||||
assert.equal(navigationIsActive('/topics/','/topics/7/','top'),false);assert.equal(navigationIsActive('/topics/7/','/topics/7/page/2/'),true);assert.equal(navigationIsActive('/topics/7/','/topics/8/'),false);
|
||||
for(const slug of ['hot','new','follow'])assert.equal(navigationIsActive('/rank/hot/',`/rank/${slug}/page/2/`),true);
|
||||
assert.equal(navigationIsActive('/rank/new/','/rank/follow/'),false);
|
||||
assert.equal(navigationIsActive('/shorts/?sort=2','/shorts/page/2/?sort=2&campaign=test'),true);assert.equal(navigationIsActive('/shorts/?sort=2','/shorts/?sort=1'),false);
|
||||
assert.equal(navigationIsActive('https://example.net/shorts/','/shorts/'),false);assert.equal(navigationIsActive('/series/','/series/'),false);
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFile} from 'node:fs/promises';
|
||||
import ts from 'typescript';
|
||||
import {routingModuleUrl} from './helpers/routing.mjs';
|
||||
import {seo} from './helpers/seo.mjs';
|
||||
|
||||
const routes=await import(routingModuleUrl);
|
||||
async function moduleUrl(path,imports={}){
|
||||
const {outputText}=ts.transpileModule(await readFile(new URL('../src/lib/'+path,import.meta.url),'utf8'),{compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.ESNext}});
|
||||
let code=outputText;
|
||||
for(const [key,url] of Object.entries({'../routing':routingModuleUrl,...imports}))for(const quote of ["'",'"'])code=code.replace(quote+key+quote,JSON.stringify(url));
|
||||
return 'data:text/javascript;base64,'+Buffer.from(code).toString('base64');
|
||||
}
|
||||
const protocol=await moduleUrl('api/protocol.ts');
|
||||
const renderUrl=await moduleUrl('client/render.ts',{'../api/protocol':protocol});
|
||||
const render=await import(renderUrl);
|
||||
const channel=await import(await moduleUrl('client/channel.ts',{'../api/protocol':protocol,'./render':renderUrl}));
|
||||
const search=await import(await moduleUrl('client/search.ts',{'../api/protocol':protocol,'./render':renderUrl}));
|
||||
const drama={id:9,slug:'city-story',title:'都市故事',desc:'作品介绍',cover:'https://media.drama.test/poster.webp',onlineAt:'2026-10-20'};
|
||||
const environment=seo.seoEnvironment({config:{foreverUrlAddr:'https://drama.test'}});
|
||||
|
||||
test('slug-first paths encode one segment once, preserve ID fallback and accept direct paginated requests',()=>{
|
||||
for(const key of ['city-story','city_story-2026','dushiaiqing']){
|
||||
for(const kind of ['detail','topics','theme','tag','config']){
|
||||
const path=routes.contentPath(kind,{id:9,slug:' '+key+' '});
|
||||
assert.equal(path,`/${kind}/${encodeURIComponent(key)}/`);
|
||||
assert.equal(routes.parseSitePath(path)[kind==='detail'?'key':'value'],key);
|
||||
assert.equal(routes.paginationPath(path,2),path+'page/2/');
|
||||
assert.equal(routes.parseSitePath(path+'page/2/').page,2);
|
||||
}
|
||||
assert.deepEqual(routes.parseSitePath(routes.contentPath('play',{id:9,slug:key},90)),{group:'play',key,value:'90'});
|
||||
}
|
||||
for(const slug of [undefined,null,'',' ','..','bad\\path','都市爱情','city-爱情','都市 / 爱情?#%2F','%E9%83%BD%E5%B8%82'])assert.equal(routes.contentPath('detail',{id:9,slug}),'/detail/9/');
|
||||
assert.deepEqual(routes.contentQuery('city-story','playletId','playletSlug'),{playletSlug:'city-story'});
|
||||
assert.deepEqual(routes.contentQuery('009'),{id:9});
|
||||
assert.equal(routes.parseSitePath('/play/city-story/chapter-slug/'),null);
|
||||
});
|
||||
|
||||
test('Chinese upstream slugs use IDs in every generated link and SEO URL without changing API identity',()=>{
|
||||
const item={...drama,slug:'都市爱情'};
|
||||
assert.ok(render.card(item).includes('href="/detail/9/"'));
|
||||
assert.ok(render.hero([item]).includes('href="/detail/9/"'));
|
||||
assert.ok(render.topicCard({id:7,slug:'都市合集',name:'都市合集'}).includes('href="/topics/7/"'));
|
||||
assert.ok(render.blockSection({id:31,slug:'必看好剧',name:'必看好剧',type:1,status:true,list:[item]}).includes('href="/config/31/"'));
|
||||
const chapter={id:90,currentEpisode:3,canWatch:true,videoUrl:'https://media.drama.test/3.mp4'};
|
||||
const head=seo.buildPageHead('/play/9/90/','',environment,seo.dramaSeoContent(item,chapter));
|
||||
assert.equal(head.canonical,'https://drama.test/play/9/90/');
|
||||
assert.ok(!JSON.stringify(head.jsonLd).includes('%E9%83%BD'));
|
||||
assert.equal(routes.matchesContentKey(item,'9'),true);
|
||||
assert.deepEqual(routes.contentQuery('9'),{id:9});
|
||||
// Previously issued Chinese slug links remain readable, but their canonical uses the ID.
|
||||
assert.equal(routes.matchesContentKey(item,'都市爱情'),true);
|
||||
assert.equal(seo.buildPageHead('/detail/都市爱情/','',environment,seo.dramaSeoContent(item)).canonical,'https://drama.test/detail/9/');
|
||||
});
|
||||
|
||||
test('cards, carousel, topics, more lists and suggestions prefer slug while explicit moreUrl remains authoritative',()=>{
|
||||
for(const html of [render.card(drama),render.hero([drama]),search.searchSuggestionsMarkup([drama],'都市')])assert.ok(html.includes('href="/detail/city-story/"'));
|
||||
assert.ok(render.topicCard({id:7,slug:'city-collection',name:'都市合集'}).includes('href="/topics/city-collection/"'));
|
||||
const block={id:31,slug:'editors-choice',name:'编辑精选',type:1,status:true,list:[drama]};
|
||||
assert.ok(render.blockSection(block).includes('href="/config/editors-choice/"'));
|
||||
assert.ok(render.blockSection({...block,moreUrl:'/shorts/'}).includes('href="/shorts/"'));
|
||||
assert.ok(render.card({...drama,slug:''}).includes('href="/detail/9/"'));
|
||||
});
|
||||
|
||||
test('topic slugs query detail directly and retain the response identity',async()=>{
|
||||
const calls=[];
|
||||
const data=await channel.loadChannel({get:async(path,query)=>{calls.push({path,query});return {id:7,slug:'city-collection',name:'都市合集',list:[drama],totalCount:72};}},{group:'page',key:'topics',value:'city-collection',page:2});
|
||||
assert.deepEqual(calls,[{path:'playlet/topic/detail',query:{pageNum:2,pageSize:24,topicSlug:'city-collection',dataType:1,sortType:1}}]);
|
||||
assert.equal(data.id,7);assert.equal(data.slug,'city-collection');
|
||||
const view=channel.channelView({group:'page',key:'topics',value:'7'},data,'/topics/7/?sort=1');
|
||||
assert.ok(view.html.includes('href="/topics/city-collection/page/2/?sort=1"'));
|
||||
const missing=await channel.loadChannel({get:async()=>({id:9,slug:'different',list:[]})},{group:'page',key:'topics',value:'city-collection'});
|
||||
assert.equal(missing.missing,true);
|
||||
await assert.rejects(channel.loadChannel({get:async()=>{throw Error('outage');}},{group:'page',key:'topics',value:'city-collection'}),/outage/);
|
||||
});
|
||||
|
||||
test('tag slug query and paginated home-config response preserve numeric business identity',async()=>{
|
||||
assert.deepEqual(channel.channelRequest({group:'page',key:'tag',value:'urban',page:2}).query,{pageNum:2,pageSize:24,tagSlug:'urban',sortType:1});
|
||||
const data=await channel.loadChannel({get:async()=>({config:{id:31,slug:'editors-choice',title:'编辑精选',desc:'精选介绍',moduleType:1},list:[drama],totalCount:30})},{group:'page',key:'config',value:'editors-choice'});
|
||||
assert.equal(data.id,31);assert.equal(data.slug,'editors-choice');assert.equal(data.totalCount,30);
|
||||
});
|
||||
|
||||
test('canonical, pagination, series/episode schema and homepage ItemList use the same slug paths',()=>{
|
||||
const chapter={id:90,playletId:9,currentEpisode:3,canWatch:true,videoUrl:'https://media.drama.test/3.mp4'};
|
||||
const content=seo.dramaSeoContent(drama,chapter);
|
||||
for(const key of ['9','city-story']){
|
||||
const head=seo.buildPageHead(`/play/${key}/90/`,'',environment,content);
|
||||
assert.equal(head.canonical,'https://drama.test/play/city-story/90/');
|
||||
const graph=head.jsonLd['@graph'];
|
||||
assert.equal(graph.find(node=>node['@type']==='TVSeries').url,'https://drama.test/detail/city-story/');
|
||||
assert.equal(graph.find(node=>node['@type']==='TVEpisode').url,head.canonical);
|
||||
assert.equal(graph.find(node=>node['@type']==='BreadcrumbList').itemListElement[2].item,'https://drama.test/detail/city-story/');
|
||||
}
|
||||
const list=seo.listSeoContent({id:7,slug:'city-collection',list:[drama],totalCount:72},{group:'page',key:'theme',value:'7',page:2});
|
||||
const head=seo.buildPageHead('/theme/7/page/2/','',environment,list);
|
||||
assert.equal(head.canonical,'https://drama.test/theme/city-collection/page/2/');assert.equal(head.previous,'https://drama.test/theme/city-collection/');
|
||||
const home=seo.buildPageHead('/','',environment,seo.homeSeoContent({homeBannerData:[drama]}));
|
||||
assert.equal(home.jsonLd['@graph'].find(node=>node['@type']==='ItemList').itemListElement[0].url,'https://drama.test/detail/city-story/');
|
||||
});
|
||||
@@ -0,0 +1,279 @@
|
||||
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, track clicks, 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,['show','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,['show','click','show']);
|
||||
assert.equal(grid.open,true);assert.equal(promotions.children.length,1);assert.equal(media.dataset.adPlacement,'startup-ad');
|
||||
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,'icon');assert.equal(link.dataset.trackAd,'external');assert.equal(link.dataset.adPlacement,'startup-grid');
|
||||
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.equal(noLink.dataset.adClickable,'0');
|
||||
assert.equal(noLink.dataset.adId,'grid-code');assert.equal(noLink.dataset.adSlotKey,'slot-3');assert.equal(noLink.dataset.adSlotName,'应用推广');
|
||||
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;}}
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFile} from 'node:fs/promises';
|
||||
import ts from 'typescript';
|
||||
import {seo,structuredData,clientSeo,headDocument} from './helpers/seo.mjs';
|
||||
|
||||
const origin='https://drama.example.net';
|
||||
const environment=seo.seoEnvironment({seoInfo:{webSiteName:'91剧场',description:'本站介绍'},config:{foreverUrlAddr:origin,tgGroup:'https://t.me/official',twitterAddr:'https://x.com/official',telegram:'javascript:bad'}});
|
||||
const head=(path,content={},search='')=>seo.buildPageHead(path,search,environment,content);
|
||||
const nodes=(page,type)=>page.jsonLd?.['@graph'].filter(node=>node['@type']===type)||[];
|
||||
const drama={id:9,title:'剧集名称',desc:'剧集简介',cover:'https://images.example.net/poster.webp',onlineAt:'2025-10-18 10:30:00',mediaUpdatedAt:'2025-10-19T08:00:00Z',totalEpisode:12,tagNames:['都市'],anchor:'主演姓名'};
|
||||
const chapter={id:90,playletId:9,currentEpisode:3,canWatch:true,videoUrl:'https://media.example.net/episode.m3u8',mediaTime:125,width:1080,height:1920,mediaSize:30000,countBrowse:25};
|
||||
|
||||
test('home schema uses this site, actual assets, configured official profiles and the visible unique internal item URLs',()=>{
|
||||
const page=head('/',seo.homeSeoContent({homeBannerData:[drama,{...drama,id:10}],homeConfigData:[
|
||||
{id:1,status:true,type:1,list:[drama,{...drama,id:11}]},
|
||||
{id:2,status:false,type:1,list:[{...drama,id:99}]},
|
||||
{id:3,status:true,type:2,videoList:[{...drama,id:12}]},
|
||||
{id:4,status:true,type:3,themeList:[{id:8,name:'题材'}]},
|
||||
]}));
|
||||
const organization=nodes(page,'Organization')[0],website=nodes(page,'WebSite')[0];
|
||||
assert.equal(organization.name,'91剧场');assert.equal(organization.url,origin+'/');
|
||||
assert.equal(organization.logo.url,origin+'/apple-touch-icon.png');assert.equal(organization.logo.width,180);
|
||||
assert.deepEqual(organization.sameAs,['https://x.com/official','https://t.me/official']);
|
||||
assert.equal(website.publisher['@id'],organization['@id']);assert.equal(website.description,'本站介绍');
|
||||
assert.equal(nodes(page,'BreadcrumbList').length,0);
|
||||
const items=nodes(page,'ItemList')[0];assert.equal(items.numberOfItems,4);
|
||||
assert.deepEqual(items.itemListElement.map(item=>item.url),['/detail/9/','/detail/10/','/detail/11/','/topics/8/'].map(path=>origin+path));
|
||||
});
|
||||
|
||||
test('lists, topics and ranks use native URLs and the current page order, not hidden homepage recommendations',()=>{
|
||||
const data={list:[{id:7},{id:8},{id:7},null,{id:-1},{id:'invalid'}],totalCount:72,name:'接口分类名'};
|
||||
const page=head('/shorts/page/2/',seo.listSeoContent(data,{group:'page',key:'shorts',page:2}));
|
||||
assert.equal(nodes(page,'CollectionPage')[0].url,page.canonical);
|
||||
assert.deepEqual(nodes(page,'ItemList')[0].itemListElement,[{'@type':'ListItem',position:25,url:origin+'/detail/7/'},{'@type':'ListItem',position:26,url:origin+'/detail/8/'}]);
|
||||
const crumb=nodes(page,'BreadcrumbList')[0].itemListElement;assert.equal(crumb.at(-1).item,page.canonical);assert.equal(crumb.at(-1).name,'接口分类名');
|
||||
const topics=head('/topics/',seo.listSeoContent({list:[{id:7},{id:8}]},{group:'page',key:'topics'}));
|
||||
assert.equal(nodes(topics,'ItemList')[0].itemListElement[0].url,origin+'/topics/7/');
|
||||
const rank=head('/rank/new/',seo.listSeoContent(data,{group:'page',key:'rank',value:'new'}));
|
||||
assert.equal(nodes(rank,'BreadcrumbList')[0].itemListElement.at(-1).name,'新剧榜');
|
||||
});
|
||||
|
||||
test('current homepage modules use the same theme paths as the visible cards',()=>{
|
||||
const content=seo.homeSeoContent({modules:[{id:1,title:'短剧',moduleType:1,list:[drama]},{id:2,title:'题材',moduleType:2,topicList:[{id:7,slug:'urban',name:'都市'}]}]});
|
||||
assert.deepEqual(content.items,[{path:'/detail/9/'},{path:'/theme/urban/'}]);
|
||||
});
|
||||
|
||||
test('homepage schema follows API limits independently of category styles',()=>{
|
||||
const limits=[3,9,8,7,5,2,0,undefined];
|
||||
const modules=['A','B','C','D','E','F','A','B'].map((style,index)=>({id:index+1,title:'分类',moduleType:style==='E'?2:1,style,limit:limits[index],
|
||||
list:Array.from({length:12},(_,item)=>({id:100*(index+1)+item,slug:style.toLowerCase()+'-'+item})),
|
||||
topicList:Array.from({length:12},(_,item)=>({id:100*(index+1)+item,slug:'topic-'+item})),
|
||||
}));
|
||||
const content=seo.homeSeoContent({modules});
|
||||
const expected=modules.flatMap((module,index)=>Array.from({length:limits[index]??12},(_,item)=>({path:module.style==='E'?`/theme/topic-${item}/`:`/detail/${module.style.toLowerCase()}-${item}/`})));
|
||||
assert.deepEqual(content.items,expected);
|
||||
});
|
||||
|
||||
test('detail describes the series; episode watch pages connect TVEpisode and VideoObject to stable series/media IDs',()=>{
|
||||
const detail=head('/detail/9/',seo.dramaSeoContent(drama));
|
||||
assert.equal(nodes(detail,'VideoObject').length,0);assert.equal(nodes(detail,'TVEpisode').length,0);
|
||||
const series=nodes(detail,'TVSeries')[0];assert.equal(series.name,drama.title);assert.equal(series.numberOfEpisodes,12);assert.deepEqual(series.genre,['都市']);assert.equal(series.actor.name,'主演姓名');
|
||||
const page=head('/play/9/90/',seo.dramaSeoContent(drama,chapter));
|
||||
const episode=nodes(page,'TVEpisode')[0],video=nodes(page,'VideoObject')[0];
|
||||
assert.equal(episode.episodeNumber,3);assert.equal(episode.partOfSeries['@id'],series['@id']);assert.equal(episode.associatedMedia['@id'],video['@id']);
|
||||
assert.equal(video.name,'剧集名称 - 第3集');assert.equal(video.uploadDate,'2025-10-18T10:30:00+08:00');assert.equal(video.duration,'PT125S');assert.equal(video.contentUrl,chapter.videoUrl);
|
||||
assert.equal(video.width,1080);assert.equal(video.height,1920);assert.equal(video.interactionStatistic.userInteractionCount,25);
|
||||
const paged=head('/play/9/90/page/2/',{...seo.dramaSeoContent(drama,chapter),totalCount:35,pageSize:10});
|
||||
assert.equal(paged.robots,'noindex,follow');assert.equal(paged.jsonLd,undefined);
|
||||
});
|
||||
|
||||
test('missing media data and invalid dates do not produce fabricated VideoObject fields or leak locked playback URLs',()=>{
|
||||
for(const patch of [{onlineAt:undefined},{onlineAt:'0001-01-01'},{onlineAt:'not-a-date'},{cover:''},{cover:'javascript:bad'}]){
|
||||
const page=head('/play/9/90/',seo.dramaSeoContent({...drama,...patch},chapter));
|
||||
assert.equal(nodes(page,'VideoObject').length,0);assert.equal(nodes(page,'TVEpisode').length,1);
|
||||
}
|
||||
const locked=head('/play/9/90/',seo.dramaSeoContent(drama,{...chapter,canWatch:false,needUnlock:true}));
|
||||
assert.equal(locked.robots,'noindex,follow');assert.equal(locked.jsonLd,undefined);
|
||||
const earlier=head('/play/9/90/',seo.dramaSeoContent({...drama,mediaUpdatedAt:'2025-10-17 01:00:00'},chapter));
|
||||
assert.equal(nodes(earlier,'VideoObject')[0].dateModified,undefined);
|
||||
const missingName=head('/play/9/90/',seo.dramaSeoContent({...drama,title:''},chapter));assert.equal(nodes(missingName,'VideoObject').length,0);
|
||||
const fallbackDate=head('/play/9/90/',seo.dramaSeoContent({...drama,onlineAt:'0001-01-01',createdAt:'2025-10-01'},chapter));
|
||||
assert.equal(nodes(fallbackDate,'VideoObject')[0].uploadDate,'2025-10-01T00:00:00+08:00');
|
||||
for(const value of ['2025-02-29','2025-13-01','2025-01-01 25:00:00','1970-01-01','0001-01-01'])assert.equal(structuredData.schemaDate(value),'',value);
|
||||
assert.equal(structuredData.schemaDate('2024/02/29'),'2024-02-29T00:00:00+08:00');
|
||||
});
|
||||
|
||||
test('paywalled playable chapters identify the restricted region and normalize canonical media IDs',()=>{
|
||||
const paid=head('/play/009/090/',seo.dramaSeoContent(drama,{...chapter,payType:1}));
|
||||
const page=nodes(paid,'WebPage')[0],video=nodes(paid,'VideoObject')[0],episode=nodes(paid,'TVEpisode')[0];
|
||||
assert.equal(paid.canonical,origin+'/play/9/90/');
|
||||
assert.equal(page.isAccessibleForFree,false);
|
||||
assert.deepEqual(page.hasPart,{'@type':'WebPageElement',isAccessibleForFree:false,cssSelector:'.play-paywalled-content'});
|
||||
assert.equal(video.isAccessibleForFree,false);assert.equal(episode.isAccessibleForFree,false);
|
||||
assert.equal(video['@id'],origin+'/play/9/90/#video');assert.equal(episode.url,paid.canonical);
|
||||
const hashed=head('/play/9/90/',seo.dramaSeoContent(drama,{...chapter,videoUrl:'signed/media.m3u8?token=a%2Fb'}));
|
||||
assert.equal(nodes(hashed,'VideoObject')[0].contentUrl,origin+'/h5/m3u8/signed/media.m3u8?token=a%2Fb');
|
||||
const free=head('/play/9/90/',seo.dramaSeoContent(drama,chapter));
|
||||
assert.equal(nodes(free,'WebPage')[0].hasPart,undefined);
|
||||
});
|
||||
|
||||
test('removed standalone video routes have no canonical or structured data',()=>{
|
||||
const page=head('/video/9/',seo.dramaSeoContent({...drama,video:{url:'https://media.example.net/movie.mp4',videoTime:360}}));
|
||||
assert.equal(page.canonical,undefined);assert.equal(page.jsonLd,undefined);assert.match(page.robots,/noindex/);
|
||||
});
|
||||
|
||||
test('about and FAQ use visible local content, while private/search/tools/failed pages output no graph',async()=>{
|
||||
const code=ts.transpileModule(await readFile(new URL('../src/lib/info-pages.ts',import.meta.url),'utf8'),{compilerOptions:{module:ts.ModuleKind.ESNext,target:ts.ScriptTarget.ES2022}}).outputText;
|
||||
const info=await import('data:text/javascript;base64,'+Buffer.from(code).toString('base64'));
|
||||
const about=head('/info/about-us/',seo.infoSeoContent(info.getInfoPage('about-us')));assert.equal(nodes(about,'AboutPage').length,1);
|
||||
const faqPage=info.getInfoPage('help-center'),content=seo.infoSeoContent(faqPage),page=head('/info/help-center/',content);
|
||||
assert.equal(nodes(page,'FAQPage')[0].mainEntity.length,6);
|
||||
for(const question of nodes(page,'FAQPage')[0].mainEntity){assert.ok(faqPage.html.includes(question.name));assert.ok(question.acceptedAnswer.text);}
|
||||
for(const path of ['/search/爱情/','/navigation/','/home-road/','/apps/','/member/favorites/','/info/deposit/','/unknown/'])assert.equal(head(path,content).jsonLd,undefined,path);
|
||||
assert.equal(head('/shorts/',content,'?sort=2').jsonLd,undefined);assert.equal(head('/detail/9/',{...content,failed:true}).jsonLd,undefined);
|
||||
});
|
||||
|
||||
test('native script serialization stays valid JSON and cannot close its script through API content',()=>{
|
||||
const graph={'@context':'https://schema.org','@graph':[{'@type':'WebPage',name:'</script><script>alert(1)</script>\u2028\u2029'}]};
|
||||
const serialized=structuredData.serializeStructuredData(graph);assert.ok(!serialized.includes('<'));assert.ok(!serialized.includes('\u2028'));assert.deepEqual(JSON.parse(serialized),graph);
|
||||
});
|
||||
|
||||
test('client route changes keep a single script, replace graph contents and remove stale public data on noindex pages',()=>{
|
||||
const doc=headDocument(),selector='script[data-site-json-ld]';
|
||||
clientSeo.applyPageHead(head('/',seo.homeSeoContent({homeBannerData:[drama]})),doc);
|
||||
const duplicate=doc.createElement('script');duplicate.setAttribute('data-site-json-ld','');doc.head.appendChild(duplicate);
|
||||
clientSeo.applyPageHead(head('/detail/9/',seo.dramaSeoContent(drama)),doc);
|
||||
assert.equal(doc.head.querySelectorAll(selector).length,1);
|
||||
const graph=JSON.parse(doc.head.querySelectorAll(selector)[0].textContent);assert.ok(graph['@graph'].some(node=>node['@type']==='TVSeries'));assert.ok(!graph['@graph'].some(node=>node['@type']==='ItemList'));
|
||||
clientSeo.applyPageHead(head('/member/center/'),doc);assert.equal(doc.head.querySelectorAll(selector).length,0);
|
||||
clientSeo.applyPageHead(head('/shorts/',{items:[{path:'/detail/7/'}]}),doc);assert.equal(doc.head.querySelectorAll(selector).length,1);
|
||||
});
|
||||
Reference in New Issue
Block a user