整体UI修改
This commit is contained in:
@@ -1,276 +0,0 @@
|
||||
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 {TRACKER_APP_ID,COMMON_KEYS,SDK_COMMON_KEYS,base64JSON,trackingPage}=await import(trackingUrl);
|
||||
const authUrl='data:text/javascript,export function authMetadata(){return {channel:"paid_1047"}}';
|
||||
const analyticsUrl=await moduleUrl('../src/lib/client/analytics.ts',code=>code.replace("'../tracking'",JSON.stringify(trackingUrl)).replace("'./auth'",JSON.stringify(authUrl)));
|
||||
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_1047',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/'])assert.notEqual(trackingPage(path).key,'internal_other');
|
||||
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);
|
||||
}
|
||||
}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.ok(forwarded['X-User-Agent'].includes('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-1047'}},{...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'),{});
|
||||
});
|
||||
@@ -21,9 +21,9 @@ const cache=await compile('public-cache');
|
||||
|
||||
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:21022');
|
||||
environment.APP_ENV='test';assert.equal(config.getApiBaseUrl(),'http://172.104.181.99:21022');
|
||||
environment.APP_ENV='production';assert.equal(config.getApiBaseUrl(),'http://172.104.56.28:26125');
|
||||
assert.equal(config.getApiBaseUrl(),'https://webhgcgapi.mfcg.me');
|
||||
environment.APP_ENV='test';assert.equal(config.getApiBaseUrl(),'https://webhgcgapi.mfcg.me');
|
||||
environment.APP_ENV='production';assert.equal(config.getApiBaseUrl(),'https://webhgcgapi.mfcg.me');
|
||||
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/ ';
|
||||
|
||||
@@ -24,11 +24,10 @@ test('proxy methods and public endpoints are backed by the reviewed API contract
|
||||
for(const method of ['GET','POST'])for(const path of ['video/detail','video/all/list','playlet/operate/list','playlet/home/config/detail/list','statcenter/sync'])assert.equal(server.allowedRequest(path,method),false,method+' '+path);
|
||||
});
|
||||
|
||||
test('the user-confirmed 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('removed account, recharge, unlock and login endpoints are no longer proxied',()=>{
|
||||
for(const path of ['playlet/like/list','playlet/collect/list','playlet/history/list','playlet/bought/list','user/center/data','coin/trans/list','coin/orders','vip/orders','recharge/package/list'])assert.equal(server.allowedRequest(path,'GET'),false,path);
|
||||
for(const path of ['auth/login','auth/register','auth/resetPassword','auth/profile','auth/logout','coin/buy','vip/pay','playlet/chapter/unlock','upload/image','playlet/history/clear','playlet/history/delete','feedback/add'])assert.equal(server.allowedRequest(path,'POST'),false,path);
|
||||
assert.equal(server.allowedRequest('auth/guest','POST'),true);
|
||||
});
|
||||
|
||||
test('all public list routes send only supported query fields with their native page numbers',()=>{
|
||||
|
||||
+110
-311
@@ -112,7 +112,7 @@ before(async()=>{
|
||||
else if(request.headers.authorization==='http-expired'){response.writeHead(401,{'Content-Type':'application/json'});response.end(JSON.stringify({code:401,data:null}));return;}
|
||||
else if(request.headers.authorization==='registered-user-a-secret'||request.headers.authorization==='registered-user-b-secret'){const first=request.headers.authorization==='registered-user-a-secret';data={userId:first?101:202,nickName:first?'用户甲':'用户乙',recharge:first?111:222,type:1,isRegister:true,token:request.headers.authorization,password:'must-not-leak'};}
|
||||
else data={userId:77,nickName:'接口昵称',token:'must-not-leak',recharge:90};
|
||||
}else if(path==='config/info')data={seoInfo:{webSiteName:'91短剧'},...seoConfig,config:{foreverUrlAddr:'https://default.drama.test',telegram:'https://example.com/contact',tgGroup:'https://example.com/group',twitterAddr:'https://example.com/twitter',officialAppAddr:downloadAddress,...seoConfig.config},navPageList:Object.hasOwn(seoConfig,'navPageList')?seoConfig.navPageList:[{line:2,url:'https://example.com/entry-two'},{line:1,url:'https://example.com/entry-one'},{line:3,url:'javascript:alert(1)'}]};
|
||||
}else if(path==='config/info')data={seoInfo:{webSiteName:'黄果吃瓜'},...seoConfig,config:{foreverUrlAddr:'https://huangguocg.com',telegram:'https://example.com/contact',tgGroup:'https://example.com/group',twitterAddr:'https://example.com/twitter',officialAppAddr:downloadAddress,...seoConfig.config},navPageList:Object.hasOwn(seoConfig,'navPageList')?seoConfig.navPageList:[{line:2,url:'https://example.com/entry-two'},{line:1,url:'https://example.com/entry-one'},{line:3,url:'javascript:alert(1)'}]};
|
||||
else if(path==='navigation/header'){data=headerPayload;if(headerFailure)code=500;}
|
||||
else if(path==='config/footer/info'&&footerPayload)data=footerPayload;
|
||||
else if(path==='translation/category/list')data={categoryList:categories};
|
||||
@@ -156,15 +156,15 @@ before(async()=>{
|
||||
|
||||
after(async()=>{await stopNext();if(mock)await new Promise(resolve=>mock.close(resolve));if(seoSnapshotPath)await rm(seoSnapshotPath,{force:true});});
|
||||
|
||||
test('a cold configuration failure never uses request/forwarded hosts as canonical or publisher URLs',async()=>{
|
||||
test('a cold configuration failure never uses request/forwarded hosts as canonical or publisher URLs, only the site domain',async()=>{
|
||||
try{
|
||||
configFailure='http';
|
||||
const response=await fetch(base+'/info/about-us/',{headers:{'X-Forwarded-Host':'untrusted.entry.test','X-Forwarded-Proto':'https'}});
|
||||
assert.equal(response.status,200);
|
||||
const html=await response.text(),head=html.match(/<head>([\s\S]*?)<\/head>/)[1];
|
||||
assert.ok(!head.includes('rel="canonical"'));assert.ok(!head.includes('property="og:url"'));
|
||||
assert.ok(!head.includes('untrusted.entry.test'));assert.ok(!/<script\b[^>]*data-site-json-ld=/.test(html));
|
||||
assert.match(head,/<title>关于我们 - 91短剧<\/title>/);
|
||||
assert.match(head,/rel="canonical" href="https:\/\/[^"]+\/info\/about-us\/"/);
|
||||
assert.ok(!head.includes('untrusted.entry.test'));
|
||||
assert.match(head,/<title>关于我们 - 黄果吃瓜<\/title>/);
|
||||
}finally{configFailure='';}
|
||||
});
|
||||
|
||||
@@ -179,7 +179,7 @@ test('robots.txt serves crawler text without sessions and advertises the configu
|
||||
assert.match(body,/User-Agent: \*/i);assert.ok(!body.includes('<html'));assert.ok(!body.includes('untrusted.example'));
|
||||
if(testEnvironment==='production'){
|
||||
assert.match(body,/^Allow: \/$/m);assert.match(body,/^Disallow: \/api\/$/m);assert.match(body,/^Disallow: \/healthz$/m);
|
||||
assert.match(body,/^Sitemap: https:\/\/default\.drama\.test\/sitemap\.xml$/m);
|
||||
assert.match(body,/^Sitemap: https:\/\/huangguocg\.com\/sitemap\.xml$/m);
|
||||
assert.ok(!/^Disallow: \/$/m.test(body));
|
||||
}else {assert.match(body,/^Disallow: \/$/m);assert.ok(!body.includes('Sitemap:'));}
|
||||
}
|
||||
@@ -187,17 +187,17 @@ test('robots.txt serves crawler text without sessions and advertises the configu
|
||||
assert.ok(reads.every(call=>call.path==='/api/app/config/info'&&!call.auth),'only public site configuration is needed');
|
||||
});
|
||||
|
||||
test('robots sitemap uses permanent-domain precedence, survives configuration failure and never guesses a host',async()=>{
|
||||
test('robots sitemap always uses the site domain, survives configuration failure and never guesses a host',async()=>{
|
||||
if(testEnvironment!=='production')return;
|
||||
try{
|
||||
seoConfig={config:{foreverUrlAddr:'https://robots.drama.test/nested/?source=1'},newAddressInfo:{foreverUrlAddr:'https://backup.drama.test'}};
|
||||
seoConfig={config:{foreverUrlAddr:'https://huangguocg.com/nested/?source=1'},newAddressInfo:{foreverUrlAddr:'https://backup.drama.test'}};
|
||||
let response=await fetch(base+'/robots.txt');assert.equal(response.status,200);
|
||||
assert.match(await response.text(),/^Sitemap: https:\/\/robots\.drama\.test\/sitemap\.xml$/m);
|
||||
assert.match(await response.text(),/^Sitemap: https:\/\/huangguocg\.com\/sitemap\.xml$/m);
|
||||
configFailure='http';
|
||||
response=await fetch(base+'/robots.txt',{headers:{'X-Forwarded-Host':'fake.test'}});assert.equal(response.status,200);
|
||||
assert.match(await response.text(),/^Sitemap: https:\/\/robots\.drama\.test\/sitemap\.xml$/m);
|
||||
assert.match(await response.text(),/^Sitemap: https:\/\/huangguocg\.com\/sitemap\.xml$/m);
|
||||
configFailure='';seoConfig={config:{foreverUrlAddr:''},newAddressInfo:{foreverUrlAddr:'https://backup.drama.test/base/'}};
|
||||
response=await fetch(base+'/robots.txt');assert.match(await response.text(),/^Sitemap: https:\/\/backup\.drama\.test\/sitemap\.xml$/m);
|
||||
response=await fetch(base+'/robots.txt');assert.match(await response.text(),/^Sitemap: https:\/\/huangguocg\.com\/sitemap\.xml$/m);
|
||||
}finally{configFailure='';seoConfig={};}
|
||||
});
|
||||
|
||||
@@ -218,8 +218,9 @@ test('SSR uses upstream content, escapes HTML, and does not restore homepage fil
|
||||
const response=await fetch(base);const html=await response.text();
|
||||
assert.equal(response.status,200);
|
||||
assert.equal((html.match(/<h1\b/g)||[]).length,1,'only the homepage title may use h1, including hidden templates');
|
||||
assert.match(html,/<h1 id="home-title" class="home-page-title">91短剧 · 百万大神原创短剧<\/h1>/);
|
||||
assert.match(html,/<h2>接口剧名 <script>alert\(1\)<\/script><\/h2>/);
|
||||
assert.match(html,/<h1 id="home-title" class="home-page-title">黄果吃瓜 · 百万大神原创短剧<\/h1>/);
|
||||
assert.match(html,/<h2 class="hg-hero-title"><a href="\/detail\/23456\/">接口剧名 <script>alert\(1\)<\/script><\/a><\/h2>/);
|
||||
assert.match(html,/<ol class="hg-hero-list" aria-label="今日推荐剧集"><li><a href="\/detail\/23456\/" data-feature="0" aria-current="true">/);
|
||||
assert.ok(html.includes('后台配置标题'));
|
||||
assert.ok(html.includes('接口剧名 <script>'));
|
||||
assert.ok(!html.includes('<script>alert(1)</script>'));
|
||||
@@ -268,13 +269,13 @@ test('/navigation renders direct download links without a hover QR panel',async(
|
||||
const before=calls.length;
|
||||
const response=await fetch(base+'/navigation/',{redirect:'manual'});const html=await response.text();
|
||||
assert.equal(response.status,200);assert.equal(response.headers.get('location'),null);
|
||||
assert.ok(html.includes('<title>官方入口 - 91短剧</title>'));
|
||||
assert.ok(html.includes('<title>官方入口 - 黄果吃瓜</title>'));
|
||||
assert.ok(html.includes('<div id="entry-navigation" class="navigation-page">'));
|
||||
assert.ok(!html.includes('<dialog id="entry-navigation"'));
|
||||
assert.ok(html.includes('91短剧 · 线路一'));assert.ok(html.includes('91短剧 · 线路二'));assert.ok(!html.includes('91短剧 · 线路三'));
|
||||
assert.ok(html.includes('href="https://example.com/entry-one"'));assert.ok(html.includes('href="https://example.com/entry-two"'));
|
||||
assert.ok(html.includes('黄果吃瓜 · 线路一'));assert.ok(!html.includes('黄果吃瓜 · 线路二'));
|
||||
assert.ok(html.includes('class="entry-route" href="https://huangguocg.com/"'));assert.ok(!html.includes('href="https://example.com/entry-one"'));assert.ok(!html.includes('href="https://example.com/entry-two"'));
|
||||
assert.ok(!html.includes('href="javascript:'));
|
||||
assert.ok(html.includes('href="https://example.com/contact"'));assert.ok(html.includes('href="https://example.com/group"'));
|
||||
assert.ok(html.includes('官方TG:@jbd125'));assert.ok(html.includes('href="https://t.me/jbd125"'));assert.ok(!html.includes('href="https://example.com/contact"'));assert.ok(!html.includes('href="https://example.com/group"'));
|
||||
assert.ok(html.includes('href="/home-road/"'));assert.ok(html.includes('href="/apps/"'));
|
||||
assert.match(html,/<a class="entry-download" href="https:\/\/example.com\/download" target="_blank" rel="sponsored nofollow noopener noreferrer" data-app-download=""/);
|
||||
assert.match(html,/<a class="entry-channel" href="https:\/\/example.com\/download" target="_blank" rel="sponsored nofollow noopener noreferrer" data-app-download=""/);
|
||||
@@ -286,31 +287,32 @@ test('/navigation renders direct download links without a hover QR panel',async(
|
||||
assert.equal((await fetch(base+'/navigation/',{redirect:'manual',headers:{'Cache-Control':'no-cache'}})).status,200);
|
||||
});
|
||||
|
||||
test('/navigation renders configured identity, routes, permanent entry and channels in its initial HTML',async()=>{
|
||||
test('/navigation uses the fixed name and site domain with configured description and channels in its initial HTML',async()=>{
|
||||
try{
|
||||
seoConfig={seoNavInfo:{dhSiteName:'动态导航站',dhDescription:'后端配置的导航介绍'},config:{telegram:'@configured_contact',tgGroup:'@configured_group',twitterAddr:'https://x.com/configured_91',foreverUrlAddr:'https://permanent.drama.test/entry'},navPageList:[{line:4,url:'https://four.drama.test'},{line:2,url:'https://two.drama.test'}]};
|
||||
const start=calls.length,response=await fetch(base+'/navigation/'),html=await response.text();
|
||||
assert.equal(response.status,200);assert.ok(html.includes('动态导航站官方入口'));assert.ok(html.includes('后端配置的导航介绍'));
|
||||
assert.ok(html.includes('动态导航站 · 线路二'));assert.ok(html.includes('动态导航站 · 线路四'));assert.ok(!html.includes('动态导航站 · 线路一'));
|
||||
assert.ok(html.indexOf('动态导航站 · 线路二')<html.indexOf('动态导航站 · 线路四'));
|
||||
assert.match(html,/<a class="entry-permanent" href="https:\/\/permanent.drama.test\/entry" rel="sponsored nofollow noopener noreferrer"/);
|
||||
for(const url of ['https://t.me/configured_contact','https://t.me/configured_group','https://x.com/configured_91'])assert.ok(html.includes(`href="${url}"`));
|
||||
assert.equal(response.status,200);assert.ok(html.includes('黄果吃瓜官方入口'));assert.ok(!html.includes('动态导航站官方入口'));assert.ok(!html.includes('动态导航站 · '));assert.ok(!html.match(/<title>[^<]*<\/title>/)[0].includes('动态导航站'));assert.ok(html.includes('后端配置的导航介绍'));
|
||||
assert.ok(html.includes('黄果吃瓜 · 线路一'));assert.ok(!html.includes('黄果吃瓜 · 线路二'));for(const host of ['permanent.drama.test','two.drama.test','four.drama.test'])assert.ok(!html.includes('href="https://'+host),host);
|
||||
assert.match(html,/<a class="entry-permanent" href="https:\/\/huangguocg.com\/" rel="sponsored nofollow noopener noreferrer"/);
|
||||
for(const url of ['https://t.me/configured_contact','https://t.me/configured_group','https://x.com/configured_91'])assert.ok(!html.includes(`href="${url}"`),url);
|
||||
assert.ok(html.includes('class="entry-contact" href="https://t.me/jbd125"'));assert.ok(!html.includes('官方推特'));
|
||||
assert.ok(!html.includes('huangguoshortdrama'));assert.ok(!html.includes('huangguodrama'));
|
||||
assert.deepEqual(calls.slice(start).map(call=>call.path),['/api/app/config/info']);
|
||||
}finally{seoConfig={};}
|
||||
});
|
||||
|
||||
test('/navigation omits unconfigured routes and contacts instead of showing homepage links or demo accounts',async()=>{
|
||||
test('/navigation keeps the site entry and official TG but omits unconfigured downloads',async()=>{
|
||||
const saved=downloadAddress;
|
||||
try{
|
||||
downloadAddress='';
|
||||
seoConfig={navPageList:null,config:{foreverUrlAddr:'',telegram:'',telegramAdd:'',tgGroup:'',twitterAddr:''}};
|
||||
const response=await fetch(base+'/navigation/'),html=await response.text();
|
||||
assert.equal(response.status,200);assert.ok(html.includes('暂未配置访问线路'));
|
||||
for(const className of ['entry-route','entry-permanent','entry-contact','entry-download'])assert.ok(!html.includes(`class="${className}"`));
|
||||
assert.equal(response.status,200);assert.ok(!html.includes('暂未配置访问线路'));
|
||||
for(const className of ['entry-route','entry-permanent'])assert.ok(html.includes(`class="${className}" href="https://huangguocg.com/"`),className);
|
||||
assert.ok(html.includes('class="entry-contact" href="https://t.me/jbd125"'));assert.ok(!html.includes('class="entry-download"'));
|
||||
for(const label of ['官方TG群','官方推特'])assert.ok(!html.includes(label));
|
||||
assert.ok(html.includes('href="/home-road/"'));assert.ok(html.includes('href="/apps/"'));
|
||||
assert.ok(html.includes('grid-template-columns:repeat(2, minmax(0, 1fr))'));
|
||||
assert.ok(html.includes('grid-template-columns:repeat(3, minmax(0, 1fr))'));
|
||||
}finally{seoConfig={};downloadAddress=saved;}
|
||||
});
|
||||
|
||||
@@ -328,8 +330,9 @@ test('/navigation omits every download entry when no valid App URL is configured
|
||||
}finally{downloadAddress=saved;}
|
||||
});
|
||||
|
||||
test('all clean page URLs support direct access and refresh',async()=>{
|
||||
const paths=['/shorts/','/topics/','/rank/','/apps/','/home-road/','/topics/3/','/theme/8/','/tag/9/','/config/31/','/detail/23456/','/play/23456/90001/','/search/'+encodeURIComponent('都市 / 爱情?#%')+'/','/member/center/','/member/favorites/','/member/history/','/member/likes/','/member/purchased/','/member/settings/','/info/deposit/','/info/deposit/vip/','/info/user-center/','/info/settings/','/info/about-us/','/info/user-agreement/','/info/privacy-policy/','/info/help-center/','/info/2257/','/info/dmca/','/info/feedback/'];
|
||||
test('all clean page URLs support direct access and refresh, while removed account and recharge pages are 404',async()=>{
|
||||
for(const path of ['/member/center/','/member/history/','/info/deposit/','/info/deposit/vip/','/info/user-center/','/info/settings/'])assert.equal((await fetch(base+path,{redirect:'manual'})).status,404,path);
|
||||
const paths=['/shorts/','/topics/','/rank/','/apps/','/home-road/','/topics/3/','/theme/8/','/tag/9/','/config/31/','/detail/23456/','/play/23456/90001/','/search/'+encodeURIComponent('都市 / 爱情?#%')+'/','/info/about-us/','/info/user-agreement/','/info/privacy-policy/','/info/help-center/','/info/2257/','/info/dmca/'];
|
||||
for(const path of paths){
|
||||
const response=await fetch(new URL(path,base+'/'),{redirect:'manual'});
|
||||
assert.equal(response.status,200,path);
|
||||
@@ -349,7 +352,7 @@ test('all clean page URLs support direct access and refresh',async()=>{
|
||||
|
||||
test('page slash redirects preserve encoded paths and queries without requesting upstream content',async()=>{
|
||||
const keyword=encodeURIComponent('都市 / 爱情?#% example.com');
|
||||
const paths=['/navigation','/search','/search/'+keyword,'/search/'+keyword+'/page/2','/rank/hot','/rank/new','/rank/follow','/info/deposit/vip','/shorts/page/2'];
|
||||
const paths=['/navigation','/search','/search/'+keyword,'/search/'+keyword+'/page/2','/rank/hot','/rank/new','/rank/follow','/shorts/page/2'];
|
||||
const query='?channel=a%2Fb+1&sort=2&source=x%252Fy';
|
||||
const before=calls.length;
|
||||
for(const path of paths){
|
||||
@@ -383,11 +386,11 @@ test('empty and paginated search entries retain native paths and keyword-specifi
|
||||
test('fixed information pages retain local article markup and only load shared head configuration in SSR',async()=>{
|
||||
for(const [key,title,marker] of [
|
||||
['about-us','关于我们','品牌介绍'],['user-agreement','用户协议','关于《使用条款》'],['privacy-policy','隐私政策','八、问题和建议'],
|
||||
['help-center','常见问题','<details open>'],['2257','2257 合规声明','资料留存'],['dmca','DMCA 版权声明','mailto:huangguoaiai@gmail.com'],
|
||||
['help-center','常见问题','<details open>'],['2257','2257 合规声明','资料留存'],['dmca','DMCA 版权声明','href="https://t.me/jbd125"'],
|
||||
]){
|
||||
const count=calls.length,response=await fetch(base+`/info/${key}/`,{redirect:'manual'});
|
||||
assert.equal(response.status,200);assert.equal(response.headers.get('location'),null);
|
||||
const html=await response.text();assert.ok(html.includes(`<title>${title} - 91短剧</title>`));assert.ok(html.includes(marker),key);
|
||||
const html=await response.text();assert.ok(html.includes(`<title>${title} - 黄果吃瓜</title>`));assert.ok(html.includes(marker),key);
|
||||
const main=html.match(/<main\b[^>]*\bid="info-page"[^>]*>/)?.[0];assert.ok(main);assert.ok(main.includes(`data-info="${key}"`));assert.ok(!/\shidden(?:[=\s>])/.test(main));
|
||||
assert.deepEqual(calls.slice(count).map(call=>call.path).sort(),['/api/app/auth/guest','/api/app/config/info','/api/app/config/footer/info','/api/app/navigation/header','/api/app/translation/category/list'].sort(),'fixed page SSR must not fetch homepage or rich text');
|
||||
assert.ok(html.includes('class="home-content" hidden=""'));
|
||||
@@ -412,7 +415,7 @@ test('ranking slash URLs render their titles and bare URLs redirect to the same
|
||||
continue;
|
||||
}
|
||||
assert.equal(response.status,200,path);assert.equal(response.headers.get('location'),null,path);
|
||||
const html=await response.text();assert.ok(html.includes(`<title>${title} - 91短剧</title>`),path);
|
||||
const html=await response.text();assert.ok(html.includes(`<title>${title} - 黄果吃瓜</title>`),path);
|
||||
assert.ok(html.includes('href="/rank/hot/"'));assertPageLinksHaveTrailingSlash(html);
|
||||
}
|
||||
}
|
||||
@@ -420,7 +423,7 @@ test('ranking slash URLs render their titles and bare URLs redirect to the same
|
||||
|
||||
test('SSR emits configured TDK, site_name, canonical and bounded pagination links inside head for browser and crawler user agents',async()=>{
|
||||
try{
|
||||
seoConfig={config:{foreverUrlAddr:'https://canonical.drama.test/entry/?channel=abc'},seoInfo:JSON.stringify({title:'后台首页标题',description:'后台首页描述',keyworld:'首页词,短剧',webSiteName:'91剧场'})};
|
||||
seoConfig={config:{foreverUrlAddr:'https://huangguocg.com/entry/?channel=abc'},seoInfo:JSON.stringify({title:'后台首页标题',description:'后台首页描述',keyworld:'首页词,短剧',webSiteName:'91剧场'})};
|
||||
categories=[{urlValue:'/playlet/',seoInfo:{title:'短剧导航标题',description:'短剧导航描述',keyworld:['导航关键词']}}];
|
||||
listPayload={list:[fixture],totalCount:72,seoInfo:{title:'列表接口标题',description:'列表接口描述',keywords:['接口关键词']}};
|
||||
const headOf=html=>{const match=html.match(/<head>([\s\S]*?)<\/head>/);assert.ok(match);return match[1];};
|
||||
@@ -430,8 +433,8 @@ test('SSR emits configured TDK, site_name, canonical and bounded pagination link
|
||||
assert.match(head,/<meta name="description" content="后台首页描述"/);
|
||||
assert.match(head,/<meta name="keywords" content="首页词,短剧"/);
|
||||
assert.match(head,/<meta property="og:site_name" content="91剧场"/);
|
||||
assert.match(head,/<link rel="canonical" href="https:\/\/canonical.drama.test\/"/);
|
||||
assert.match(head,/<meta property="og:image" content="https:\/\/canonical.drama.test\/og-default.png"/);
|
||||
assert.match(head,/<link rel="canonical" href="https:\/\/huangguocg.com\/"/);
|
||||
assert.match(head,/<meta property="og:image" content="https:\/\/huangguocg.com\/og-default.png"/);
|
||||
assert.match(head,/<meta name="twitter:card" content="summary_large_image"/);
|
||||
assert.equal(calls.slice(start).filter(call=>call.path==='/api/app/playlet/home').length,1,'SSR and metadata share one homepage request');
|
||||
assert.equal(calls.slice(start).filter(call=>call.path==='/api/app/config/info').length,1,'SSR and metadata share one configuration request');
|
||||
@@ -441,13 +444,13 @@ test('SSR emits configured TDK, site_name, canonical and bounded pagination link
|
||||
const response=await fetch(base+path+'?channel=campaign',{redirect:'manual',headers:{'User-Agent':agent}});
|
||||
assert.equal(response.status,200);assert.equal(response.headers.get('location'),null);
|
||||
const head=headOf(await response.text());
|
||||
const canonical=`https://canonical.drama.test/shorts/${page>1?`page/${page}/`:''}`;
|
||||
const canonical=`https://huangguocg.com/shorts/${page>1?`page/${page}/`:''}`;
|
||||
assert.ok(head.includes(`<link rel="canonical" href="${canonical}"`));
|
||||
assert.ok(head.includes(`<meta property="og:url" content="${canonical}"`));
|
||||
assert.ok(head.includes(`<title>短剧导航标题${page>1?' - 第'+page+'页':''}</title>`));
|
||||
assert.ok(head.includes(`<meta name="keywords" content="导航关键词${page>1?',第'+page+'页':''}"`));
|
||||
assert.equal(head.includes('<link rel="prev"'),page>1);assert.equal(head.includes('<link rel="next"'),page<3);
|
||||
if(page===2)assert.ok(head.includes('rel="prev" href="https://canonical.drama.test/shorts/?channel=campaign"'));
|
||||
if(page===2)assert.ok(head.includes('rel="prev" href="https://huangguocg.com/shorts/?channel=campaign"'));
|
||||
assert.equal((head.match(/rel="canonical"/g)||[]).length,1);
|
||||
}
|
||||
}
|
||||
@@ -461,29 +464,29 @@ test('SSR emits configured TDK, site_name, canonical and bounded pagination link
|
||||
}finally{seoConfig={};categories=[];listPayload=undefined;}
|
||||
});
|
||||
|
||||
test('detail SEO reads protected upstream content without exposing credentials and keeps registered sessions intact',async()=>{
|
||||
test('detail SEO reads protected upstream content without exposing credentials and replaces stale accounts with a guest',async()=>{
|
||||
try{
|
||||
requireSeoAuth=true;
|
||||
seoConfig={seoInfo:{webSiteName:'91剧场'},config:{foreverUrlAddr:'https://canonical.drama.test/'}};
|
||||
seoConfig={seoInfo:{webSiteName:'91剧场'},config:{foreverUrlAddr:'https://huangguocg.com/'}};
|
||||
detailSeo={seoInfo:{title:'剧集独立标题',description:'真实剧集描述',keywords:'剧集词'}};
|
||||
const start=calls.length;
|
||||
const response=await fetch(base+'/detail/23456/',{headers:{'X-Real-IP':'203.0.113.8'}});assert.equal(response.status,200);
|
||||
const html=await response.text(),head=html.match(/<head>([\s\S]*?)<\/head>/)[1];
|
||||
assert.ok(head.includes('<title>剧集独立标题 - 91剧场</title>'));assert.ok(head.includes('content="真实剧集描述"'));
|
||||
assert.ok(head.includes('rel="canonical" href="https://canonical.drama.test/detail/23456/"'));
|
||||
assert.ok(head.includes('rel="canonical" href="https://huangguocg.com/detail/23456/"'));
|
||||
assert.equal(calls.slice(start).filter(call=>call.path==='/api/app/auth/guest').length,1);
|
||||
assert.equal(calls.slice(start).find(call=>call.path==='/api/app/auth/guest').ip,'203.0.113.8');
|
||||
assert.ok(!html.includes('test-session-secret'));assert.ok(!html.includes('drama_session'));
|
||||
const registeredStart=calls.length;
|
||||
const registered=await fetch(base+'/detail/23456/',{headers:{Cookie:'drama_session=expired; drama_registered=1'}});
|
||||
assert.equal(registered.status,200);const signedOut=await registered.text();
|
||||
assert.ok(signedOut.includes('请先登录账号'));assert.ok(signedOut.includes('data-auth-mode="login"'));assert.ok(signedOut.includes('noindex'));assert.ok(!signedOut.includes('rel="canonical"'));
|
||||
assert.equal(calls.slice(registeredStart).filter(call=>call.path==='/api/app/auth/guest').length,0,'registered identity is never replaced with guest');
|
||||
assert.equal(registered.status,200);const restored=await registered.text();
|
||||
assert.ok(!restored.includes('请先登录账号'));assert.ok(!restored.includes('data-auth-mode'));assert.ok(restored.includes('rel="canonical" href="https://huangguocg.com/detail/23456/"'));
|
||||
assert.equal(calls.slice(registeredStart).filter(call=>call.path==='/api/app/auth/guest').length,1,'an expired session always becomes a guest');
|
||||
}finally{requireSeoAuth=false;seoConfig={};detailSeo=undefined;}
|
||||
});
|
||||
|
||||
test('permanent canonical and site identity survive config errors, timeouts, missing fields and a server restart',async()=>{
|
||||
const domain='https://stable.drama.test',brand='稳定站点';
|
||||
test('site-domain canonical and site identity survive config errors, timeouts and a server restart',async()=>{
|
||||
const domain='https://huangguocg.com',brand='稳定站点';
|
||||
const identity=async()=>{
|
||||
const response=await fetch(base+'/shorts/page/2/?channel=campaign',{headers:{'X-Forwarded-Host':'alternate.entry.test','X-Forwarded-Proto':'http'}});
|
||||
assert.equal(response.status,200);
|
||||
@@ -503,14 +506,11 @@ test('permanent canonical and site identity survive config errors, timeouts, mis
|
||||
listPayload={list:[fixture],totalCount:72};
|
||||
await identity();
|
||||
for(const failure of ['http','business','malformed','timeout']){configFailure=failure;await identity();}
|
||||
configFailure='';
|
||||
seoConfig={config:{foreverUrlAddr:''},newAddressInfo:{foreverUrlAddr:''},seoInfo:{webSiteName:'不可替换的错误品牌'}};
|
||||
await identity();
|
||||
configFailure='http';await stopNext();await startNext();await identity();
|
||||
configFailure='';
|
||||
seoConfig={config:{foreverUrlAddr:'https://updated.drama.test'},seoInfo:{webSiteName:'更新站点'}};
|
||||
seoConfig={config:{foreverUrlAddr:'https://other.drama.test'},seoInfo:{webSiteName:'更新站点'}};
|
||||
const html=await (await fetch(base+'/shorts/')).text();
|
||||
assert.ok(html.includes('rel="canonical" href="https://updated.drama.test/shorts/"'));
|
||||
assert.ok(html.includes('rel="canonical" href="https://huangguocg.com/shorts/"'));
|
||||
assert.ok(html.includes('property="og:site_name" content="更新站点"'));
|
||||
}finally{configFailure='';seoConfig={};categories=[];listPayload=undefined;}
|
||||
});
|
||||
@@ -523,7 +523,7 @@ test('SSR JSON-LD matches each canonical route, list page, content entity and vi
|
||||
const graph=JSON.parse(scripts[0][1]);assert.equal(graph['@context'],'https://schema.org');return graph['@graph'];
|
||||
};
|
||||
try{
|
||||
const origin='https://schema.drama.test';
|
||||
const origin='https://huangguocg.com';
|
||||
seoConfig={seoInfo:{webSiteName:'91剧场'},config:{foreverUrlAddr:origin}};
|
||||
listPayload={list:[{...fixture,id:7},{...fixture,id:8}],totalCount:72,name:'当前接口栏目'};
|
||||
detailSeo={onlineAt:'2025-10-18 10:30:00',mediaUpdatedAt:'2025-10-19 09:00:00',title:'剧名 </script><script>alert(1)</script>',desc:'真实剧集简介'};
|
||||
@@ -541,7 +541,7 @@ test('SSR JSON-LD matches each canonical route, list page, content entity and vi
|
||||
const items=graph.find(node=>node['@type']==='ItemList').itemListElement;
|
||||
assert.deepEqual(items.map(item=>[item.position,item.url]),[[25,origin+'/detail/7/'],[26,origin+'/detail/8/']]);
|
||||
}
|
||||
if(path==='/info/help-center/')assert.equal(graph.find(node=>node['@type']==='FAQPage').mainEntity.length,6);
|
||||
if(path==='/info/help-center/')assert.equal(graph.find(node=>node['@type']==='FAQPage').mainEntity.length,5);
|
||||
}
|
||||
for(const path of ['/detail/23456/','/play/23456/90001/']){
|
||||
html=await (await fetch(base+path)).text();graph=graphOf(html);
|
||||
@@ -563,10 +563,9 @@ test('paginated routes serve slash URLs directly and redirect bare paths with th
|
||||
try{
|
||||
listPayload=accountListPayload={list:[fixture],totalCount:48};
|
||||
searchPayload={list:[fixture],totalCount:48,pageSize:24};
|
||||
commentPayload=transactionsPayload={list:[],totalCount:18};
|
||||
homePayload={...savedHome,homeConfigData:[{id:31,status:true,name:'后台配置标题',type:1,list:Array.from({length:48},(_,i)=>({...fixture,id:fixture.id+i}))}]};
|
||||
const keyword=encodeURIComponent('都市 / page/7/?#%');
|
||||
const bases=['/shorts/','/topics/','/topics/7/','/theme/8/','/tag/9/','/config/31/','/rank/','/rank/hot/','/rank/new/','/rank/follow/','/search/'+keyword+'/', '/member/favorites/','/member/history/','/member/likes/','/member/purchased/','/info/deposit/','/info/deposit/vip/','/detail/23456/','/play/23456/90001/'];
|
||||
const bases=['/shorts/','/topics/','/topics/7/','/theme/8/','/tag/9/','/config/31/','/rank/','/rank/hot/','/rank/new/','/rank/follow/','/search/'+keyword+'/'];
|
||||
for(const basePath of bases){
|
||||
for(const suffix of ['page/2/','page/2']){
|
||||
const path=basePath+suffix+'?sort=2';
|
||||
@@ -584,7 +583,7 @@ test('paginated routes serve slash URLs directly and redirect bare paths with th
|
||||
for(const path of ['/shorts/page/0/','/shorts/page/01/','/shorts/page/2/page/3/','/apps/page/2/','/home-road/page/2/','/info/about-us/page/2/']){
|
||||
const response=await fetch(base+path,{redirect:'manual'});assert.equal(response.status,404,path);assert.equal(response.headers.get('location'),null,path);
|
||||
}
|
||||
}finally{homePayload=savedHome;listPayload=accountListPayload=searchPayload=commentPayload=transactionsPayload=undefined;}
|
||||
}finally{homePayload=savedHome;listPayload=searchPayload=undefined;}
|
||||
});
|
||||
|
||||
test('guest session token stays in HttpOnly cookie and device header is forwarded',async()=>{
|
||||
@@ -596,7 +595,7 @@ test('guest session token stays in HttpOnly cookie and device header is forwarde
|
||||
assert.ok(calls.findLast(call=>call.path.endsWith('/auth/guest')).device.includes('DevID='));
|
||||
});
|
||||
|
||||
test('browser BFF stays separate from Go app routes and preserves sessions, queries and logout',async()=>{
|
||||
test('browser BFF stays separate from Go app routes, preserves sessions and queries, and rejects removed account routes',async()=>{
|
||||
let start=calls.length;
|
||||
const guest=await fetch(base+'/api/auth/guest',{method:'POST',headers:{Origin:base,'Content-Type':'application/json'},body:JSON.stringify({devID:'bff-device'})});
|
||||
assert.equal(guest.status,200);assert.equal((await guest.json()).data.token,undefined);
|
||||
@@ -610,8 +609,7 @@ test('browser BFF stays separate from Go app routes and preserves sessions, quer
|
||||
assert.equal(calls.at(-1).path,'/api/app/playlet/search');assert.equal(new URLSearchParams(calls.at(-1).search).get('pageNum'),'2');
|
||||
start=calls.length;
|
||||
assert.equal((await fetch(base+'/api/not-allowed')).status,404);
|
||||
const logout=await fetch(base+'/api/auth/logout',{method:'POST',headers:{Origin:base,'Content-Type':'application/json'},body:'{}'});
|
||||
assert.equal(logout.status,200);assert.ok(logout.headers.getSetCookie().some(value=>value.startsWith('drama_session=;')&&value.includes('Max-Age=0')));
|
||||
for(const path of ['auth/logout','auth/login','auth/register','coin/buy','playlet/chapter/unlock'])assert.equal((await fetch(base+'/api/'+path,{method:'POST',headers:{Origin:base,'Content-Type':'application/json',Cookie:cookie},body:'{}'})).status,404,path);
|
||||
assert.equal(calls.length,start);
|
||||
});
|
||||
|
||||
@@ -660,50 +658,6 @@ test('public content ignores expired credentials; double-encoded data is normali
|
||||
assert.equal(payload.data.list[0].id,23456);
|
||||
});
|
||||
|
||||
test('registered session expiry never retries a write as a guest',async()=>{
|
||||
const guestCount=calls.filter(call=>call.path.endsWith('/auth/guest')).length;
|
||||
const response=await fetch(base+'/api/app/playlet/collect/set',{method:'POST',headers:{Cookie:'drama_session=expired; drama_registered=1','Content-Type':'application/json'},body:JSON.stringify({id:23456,isCollect:true})});
|
||||
assert.equal(response.status,401);assert.equal((await response.json()).code,1000);
|
||||
assert.equal(calls.filter(call=>call.path.endsWith('/auth/guest')).length,guestCount);
|
||||
assert.equal(response.headers.get('X-Auth-State'),'login-required');
|
||||
assert.ok(response.headers.getSetCookie().some(value=>value.startsWith('drama_session=;')&&value.includes('Max-Age=0')));
|
||||
});
|
||||
|
||||
test('login sends device/channel and trimmed credentials without attaching the previous identity',async()=>{
|
||||
const response=await fetch(base+'/api/app/auth/login',{method:'POST',headers:{'Content-Type':'application/json','X-Real-IP':'203.0.113.9',Cookie:'drama_session=old-guest; drama_device=device-stable; drama_channel=first'},body:JSON.stringify({account:' player01 ',password:' Secure123 ',devID:'different',channel:'later'})});
|
||||
assert.equal(response.status,200);const data=await response.json();assert.equal(data.data.userId,88);assert.equal(data.data.token,undefined);
|
||||
const call=calls.at(-1);assert.equal(call.auth,undefined);assert.deepEqual(JSON.parse(call.body),{account:'player01',password:' Secure123 ',devID:'device-stable',channel:'first'});
|
||||
assert.ok(call.device.includes('DevID=device-stable;'));
|
||||
assert.equal(call.ip,'203.0.113.9');
|
||||
assert.ok(response.headers.getSetCookie().some(value=>value.startsWith('drama_registered=1;')));
|
||||
});
|
||||
|
||||
test('binding retains the guest Authorization, device, channel and invite and fills the optional email',async()=>{
|
||||
const response=await fetch(base+'/api/app/auth/register',{method:'POST',headers:{'Content-Type':'application/json',Cookie:'drama_session=old-guest; drama_registered=0; drama_device=device-stable; drama_channel=first'},body:JSON.stringify({accountNo:' player01 ',password:'Secure123',agentInviteCode:' INV123 ',channel:'later'})});
|
||||
assert.equal(response.status,200);const data=await response.json();assert.equal(data.data.userId,77);assert.equal(data.data.token,undefined);
|
||||
const call=calls.at(-1);assert.equal(call.auth,'old-guest');
|
||||
assert.deepEqual(JSON.parse(call.body),{accountNo:'player01',password:'Secure123',email:'player01@91dj.local',devID:'device-stable',channel:'first',agentInviteCode:'INV123'});
|
||||
const memberResponse=await fetch(base+'/api/app/auth/register',{method:'POST',headers:{'Content-Type':'application/json',Cookie:'drama_session=registered; drama_registered=1'},body:JSON.stringify({accountNo:'player02',password:'Secure123',email:'me@example.com'})});
|
||||
assert.equal(memberResponse.status,200);assert.equal(calls.at(-1).auth,undefined);assert.equal(JSON.parse(calls.at(-1).body).email,'me@example.com');
|
||||
});
|
||||
|
||||
test('refresh restores an existing account and keeps its token when getInfo omits it',async()=>{
|
||||
const count=calls.length;
|
||||
const response=await fetch(base+'/api/app/auth/guest',{method:'POST',headers:{'Content-Type':'application/json',Cookie:'drama_session=test-registered-secret; drama_registered=1'},body:'{"automatic":true}'});
|
||||
assert.equal(response.status,200);const data=await response.json();assert.equal(data.data.userId,88);assert.equal(data.data.isRegister,true);assert.equal(data.data.token,undefined);
|
||||
assert.deepEqual(calls.slice(count).map(call=>call.path),['/api/app/auth/getInfo']);
|
||||
assert.ok(response.headers.getSetCookie().some(value=>value.startsWith('drama_session=test-registered-secret;')));
|
||||
assert.ok(response.headers.getSetCookie().some(value=>value.startsWith('drama_registered=1;')));
|
||||
});
|
||||
|
||||
test('an expired registered account is cleared during initialization without becoming a guest',async()=>{
|
||||
const count=calls.length;
|
||||
const response=await fetch(base+'/api/app/auth/guest',{method:'POST',headers:{'Content-Type':'application/json',Cookie:'drama_session=expired; drama_registered=1'},body:'{"automatic":true}'});
|
||||
assert.equal(response.status,401);assert.equal(response.headers.get('X-Auth-State'),'login-required');
|
||||
assert.deepEqual(calls.slice(count).map(call=>call.path),['/api/app/auth/getInfo']);
|
||||
assert.ok(response.headers.getSetCookie().some(value=>value.startsWith('drama_session=;')&&value.includes('Max-Age=0')));
|
||||
});
|
||||
|
||||
test('expired guest requests recover once with the same device, including an explicitly rejected write',async()=>{
|
||||
for(const [path,method,body] of [['auth/getInfo','GET',undefined],['playlet/collect/set','POST',JSON.stringify({id:23456,isCollect:true})]]){
|
||||
const count=calls.length;
|
||||
@@ -724,40 +678,14 @@ test('an HTTP 401 follows the same guest recovery path',async()=>{
|
||||
assert.deepEqual(calls.slice(count).map(call=>call.path),['/api/app/auth/getInfo','/api/app/auth/guest','/api/app/auth/getInfo']);
|
||||
});
|
||||
|
||||
test('SDK snapshots reach order APIs and the same drama context survives the guest retry',async()=>{
|
||||
const common={app_id:'1047',uid:'',channel:'organic',sid:'sdk-session',client_ts:Math.floor(Date.now()/1000),device:'PC',device_id:'sdk-device',device_brand:'',device_model:'',device_fingerprint:'raw-sdk-fingerprint',fp_version:'3.0.1',user_agent:'test UA',sdk_version:'1.2.0',app_version:'1.0.0'};
|
||||
const sdk=Buffer.from(JSON.stringify(common)).toString('base64');
|
||||
const requestId='1234567890abcdef1234567890abcdef',context={v:1,request_id:requestId,event_name:'drama_collect',common,drama:{media_id:'',drama_id:'23456',episode:1,video_id:'90001',recommend_trace_id:''}};
|
||||
test('client tracking headers are never forwarded, including across the guest retry',async()=>{
|
||||
const sdk=Buffer.from(JSON.stringify({app_id:'1047',device_id:'sdk-device'})).toString('base64'),requestId='1234567890abcdef1234567890abcdef';
|
||||
const count=calls.length;
|
||||
const response=await fetch(base+'/api/app/playlet/collect/set',{method:'POST',headers:{'Content-Type':'application/json',Cookie:'drama_session=expired; drama_registered=0; drama_device=business-device','X-SDK-Common':sdk,'X-Tracker-Request-Id':requestId,'X-Tracker-Event-Name':'drama_collect','X-DC-Request-Context':Buffer.from(JSON.stringify(context)).toString('base64url')},body:'{"id":23456,"isCollect":true}'});
|
||||
const response=await fetch(base+'/api/app/playlet/collect/set',{method:'POST',headers:{'Content-Type':'application/json',Cookie:'drama_session=expired; drama_registered=0; drama_device=business-device','X-SDK-Common':sdk,'X-Tracker-Request-Id':requestId,'X-Tracker-Event-Name':'drama_collect','X-DC-Request-Context':'e30'},body:'{"playletId":23456,"isCollect":1}'});
|
||||
assert.equal(response.status,200);
|
||||
const requests=calls.slice(count);assert.equal(requests.length,3);
|
||||
for(const call of [requests[0],requests[2]]){
|
||||
assert.equal(call.trackerId,requestId);assert.equal(call.trackerEvent,'drama_collect');assert.deepEqual(JSON.parse(Buffer.from(call.trackerContext,'base64url')),context);assert.equal(call.sdk,sdk);
|
||||
}
|
||||
assert.equal(requests[1].trackerContext,undefined);assert.equal(requests[1].sdk,sdk);assert.equal(JSON.parse(requests[1].body).devID,'business-device');
|
||||
await fetch(base+'/api/app/coin/buy',{method:'POST',headers:{'Content-Type':'application/json',Cookie:'drama_session=test-session-secret','X-SDK-Common':sdk},body:'{"packageId":1,"payType":"alipay"}'});
|
||||
assert.equal(calls.at(-1).sdk,sdk);assert.equal(calls.at(-1).trackerContext,undefined);
|
||||
});
|
||||
|
||||
test('bad credentials and malformed successful login responses never overwrite an existing account cookie',async()=>{
|
||||
for(const [account,status] of [['wrong',200],['missing-token',502]]){
|
||||
const response=await fetch(base+'/api/app/auth/login',{method:'POST',headers:{'Content-Type':'application/json',Cookie:'drama_session=valid-account; drama_registered=1'},body:JSON.stringify({account,password:'Secure123'})});
|
||||
assert.equal(response.status,status);assert.notEqual((await response.json()).code,200);
|
||||
assert.ok(!response.headers.getSetCookie().some(value=>value.startsWith('drama_session=')||value.startsWith('drama_registered=')));
|
||||
}
|
||||
});
|
||||
|
||||
test('manual logout skips the following automatic guest initialization and explicit sign-in can resume',async()=>{
|
||||
const logout=await fetch(base+'/api/app/auth/logout',{method:'POST',body:'{}'});
|
||||
assert.ok(logout.headers.getSetCookie().some(value=>value.startsWith('drama_skip_auto=1;')));
|
||||
const count=calls.length;
|
||||
const skipped=await fetch(base+'/api/app/auth/guest',{method:'POST',headers:{'Content-Type':'application/json',Cookie:'drama_skip_auto=1'},body:'{"automatic":true}'});
|
||||
assert.equal(skipped.status,200);assert.equal((await skipped.json()).data,null);assert.equal(calls.length,count);
|
||||
assert.ok(skipped.headers.getSetCookie().some(value=>value.startsWith('drama_skip_auto=;')&&value.includes('Max-Age=0')));
|
||||
const explicit=await fetch(base+'/api/app/auth/guest',{method:'POST',headers:{'Content-Type':'application/json',Cookie:'drama_skip_auto=1; drama_device=device-stable'},body:'{}'});
|
||||
assert.equal(explicit.status,200);assert.equal((await explicit.json()).data.userId,77);
|
||||
assert.ok(explicit.headers.getSetCookie().some(value=>value.startsWith('drama_skip_auto=;')&&value.includes('Max-Age=0')));
|
||||
for(const call of requests){assert.equal(call.sdk,undefined);assert.equal(call.trackerId,undefined);assert.equal(call.trackerEvent,undefined);assert.equal(call.trackerContext,undefined);}
|
||||
assert.equal(JSON.parse(requests[1].body).devID,'business-device');
|
||||
});
|
||||
|
||||
test('proxy rejects unlisted endpoints and wrong methods before calling upstream',async()=>{
|
||||
@@ -772,15 +700,6 @@ test('chapter permissions and identifiers are preserved from the API',async()=>{
|
||||
const payload=await response.json();assert.equal(payload.data.id,90001);assert.equal(payload.data.canWatch,false);assert.equal(payload.data.singleCoins,13);
|
||||
});
|
||||
|
||||
test('confirmed chapter unlock forwards the exact episode and identity and returns the upstream balance',async()=>{
|
||||
const count=calls.length;
|
||||
const response=await fetch(base+'/api/app/playlet/chapter/unlock',{method:'POST',headers:{'Content-Type':'application/json',Cookie:'drama_session=test-session-secret; drama_device=unlock-test-device'},body:JSON.stringify({playletId:23456,chapterId:90001})});
|
||||
assert.equal(response.status,200);const payload=await response.json();assert.equal(payload.code,200);assert.equal(payload.data.balance,77);
|
||||
assert.equal(calls.length,count+1);const call=calls.at(-1);
|
||||
assert.equal(call.path,'/api/app/playlet/chapter/unlock');assert.equal(call.auth,'test-session-secret');
|
||||
assert.deepEqual(JSON.parse(call.body),{playletId:23456,chapterId:90001});assert.ok(call.device.includes('DevID=unlock-test-device;'));
|
||||
});
|
||||
|
||||
test('media proxy preserves signed URLs and HLS content while isolating application credentials',async()=>{
|
||||
const query='?token=a%2Fb+1&sig=x%252Fy';
|
||||
const response=await fetch(base+'/h5/m3u8/folder/movie.m3u8'+query,{redirect:'manual',headers:{Authorization:'private',Cookie:'drama_session=private','X-Forwarded-For':'203.0.113.10, 10.0.0.1'}});
|
||||
@@ -826,14 +745,6 @@ test('media byte ranges and upstream failure states remain usable by the player'
|
||||
assert.equal((await fetch(base+'/h5/m3u8/missing.m3u8')).status,404);
|
||||
});
|
||||
|
||||
test('logout clears cookies without mutating upstream content',async()=>{
|
||||
const count=calls.length;
|
||||
const response=await fetch(base+'/api/app/auth/logout',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'});
|
||||
assert.equal(response.status,200);assert.ok(response.headers.getSetCookie().some(value=>value.startsWith('drama_session=;')&&value.includes('Max-Age=0')));
|
||||
assert.equal(calls.length,count);
|
||||
});
|
||||
|
||||
|
||||
function currentMain(html,id){
|
||||
const match=html.match(new RegExp('<main\\b[^>]*\\bid="'+id+'"[^>]*>[\\s\\S]*?<\\/main>'));
|
||||
assert.ok(match,'main '+id+' must exist');assert.ok(!/^<main[^>]*\shidden(?:[=\s>])/.test(match[0]),id+' must be visible');
|
||||
@@ -890,39 +801,36 @@ test('standalone lists use H2 card titles while cards within named sections rema
|
||||
assert.ok(section);assert.equal((section.match(/<h2\b/g)||[]).length,1);
|
||||
assert.equal((section.match(/<h3\b/g)||[]).length,1,'homepage cards are below their section heading');
|
||||
}
|
||||
accountListPayload={list:[fixture],totalCount:1};
|
||||
const member=await (await fetch(base+'/member/center/')).text();
|
||||
const shelf=member.match(/<section class="member-library"[^>]*>([\s\S]*?)<\/section>/)?.[1];
|
||||
assert.ok(shelf);assert.match(shelf,/<h2 id="member-library-title"/);assert.match(shelf,/<h3>/);
|
||||
const detail=currentMain(await (await fetch(base+'/detail/23456/')).text(),'drama-play-page');
|
||||
assert.match(detail,/<aside class="play-related"><h2>热门短剧<\/h2>[\s\S]*?<h3>同类推荐短剧<\/h3>/);
|
||||
assert.equal((detail.match(/<h1\b/g)||[]).length,1);
|
||||
assert.match(detail,/<h2 id="hg-recommend-title">推荐视频<\/h2>[\s\S]*?<h3 class="hg-vitem-title"><a href="\/detail\/23458\/" title="《同类推荐短剧》在线观看">同类推荐短剧<\/a><\/h3>/);
|
||||
const search=currentMain(await (await fetch(base+'/search/关键词/')).text(),'search-results-page');
|
||||
assert.match(search,/<h1\b/);assert.match(search,/<h2>/);assert.ok(!/<h3\b/.test(search));
|
||||
}finally{homePayload=savedHome;listPayload=accountListPayload=undefined;}
|
||||
assert.match(search,/<h1\b/);assert.match(search,/<h2 class="hg-card-title">/);assert.ok(!/<h3\b/.test(search));
|
||||
}finally{homePayload=savedHome;listPayload=undefined;}
|
||||
});
|
||||
|
||||
test('detail, playback, video and comment pages contain real initial content without exposing private payloads or denied video sources',async()=>{
|
||||
test('detail and playback pages contain real initial content and recommendations without exposing private payloads, and play every supplied source',async()=>{
|
||||
try{
|
||||
detailSeo={desc:'服务端剧集完整简介',token:'private-detail-token',reportSdkConfig:{secret:'private-sdk-secret'}};
|
||||
chapterListPayload={list:[{id:90001,currentEpisode:1,canWatch:true,videoUrl:'https://cdn.example/free.mp4'},{id:90002,currentEpisode:2,canWatch:false,needUnlock:true,payType:1,singleCoins:13,videoUrl:'https://cdn.example/denied-video-secret.mp4'}]};
|
||||
chapterPayload={...chapterListPayload.list[1],playletId:23456};
|
||||
commentPayload={list:[{id:45,text:'服务端第2页评论',userName:'评论用户',token:'private-comment-token',children:[{id:46,text:'嵌套回复'}]}],totalCount:25};
|
||||
videoPayload={...fixture,id:23457,title:'服务端独立视频',video:{url:'https://cdn.example/public-video.mp4',cover:fixture.cover},desc:'视频真实简介'};
|
||||
for(const path of ['/detail/23456/page/2/?commentSort=1','/play/23456/90002/page/2/']){
|
||||
for(const path of ['/detail/23456/','/play/23456/90002/']){
|
||||
const start=calls.length,response=await fetch(base+path);assert.equal(response.status,200);
|
||||
const html=await response.text(),main=currentMain(html,'drama-play-page');
|
||||
assert.ok(main.includes('服务端第2页评论'));assert.ok(main.includes('嵌套回复'));assert.ok(main.includes('评论 (25)'));
|
||||
assert.ok(main.includes('/page/3/'));assert.ok(!html.includes('后台配置标题'));
|
||||
for(const secret of ['private-detail-token','private-comment-token','private-sdk-secret','denied-video-secret.mp4'])assert.ok(!html.includes(secret),secret);
|
||||
assert.equal(calls.slice(start).filter(call=>call.path==='/api/app/comment/list').length,1,'metadata/body must share comments');
|
||||
assert.ok(main.includes('<h2 id="hg-recommend-title">推荐视频</h2>'));assert.ok(!main.includes('play-comment'));assert.ok(main.includes('剧情简介</h2>'));assert.ok(main.includes('全集列表</h2>'));
|
||||
assert.ok(!html.includes('后台配置标题'));
|
||||
for(const secret of ['private-detail-token','private-sdk-secret'])assert.ok(!html.includes(secret),secret);
|
||||
if(path.startsWith('/play/'))assert.ok(html.includes('denied-video-secret.mp4'),'a supplied source is free to watch');
|
||||
assert.equal(calls.slice(start).filter(call=>call.path==='/api/app/comment/list').length,0,'comments are no longer requested');
|
||||
if(path.startsWith('/video/')){assert.ok(main.includes('服务端独立视频'));assert.ok(main.includes('<video '));}
|
||||
else{
|
||||
assert.ok(main.includes('服务端剧集完整简介'));assert.ok(main.includes('href="/play/23456/90001/"'));assert.ok(main.includes('data-locked-chapter="90002"'));
|
||||
assert.ok(main.includes('服务端剧集完整简介'));assert.ok(main.includes('href="/play/23456/90001/"'));assert.ok(main.includes('href="/play/23456/90002/"'));assert.ok(!main.includes('play-episode-locked'));
|
||||
if(path.startsWith('/detail/'))assert.ok(main.includes('is-overview'));else assert.ok(main.includes('<video '));
|
||||
}
|
||||
assertPageLinksHaveTrailingSlash(main);
|
||||
}
|
||||
}finally{detailSeo=undefined;chapterPayload=undefined;chapterListPayload=undefined;commentPayload=undefined;videoPayload=undefined;}
|
||||
}finally{detailSeo=undefined;chapterPayload=undefined;chapterListPayload=undefined;videoPayload=undefined;}
|
||||
});
|
||||
|
||||
test('search paths decode keyword segments once and include keyword/result cards before JavaScript runs',async()=>{
|
||||
@@ -967,7 +875,7 @@ test('Chinese content slugs generate ID-based links in rendered pages and struct
|
||||
chapterPayload={id:90001,playletId:23456,currentEpisode:1,canWatch:true,videoUrl:'https://media.drama.test/1.mp4'};
|
||||
const detail=await (await fetch(base+'/play/23456/90001/')).text();
|
||||
assert.ok(detail.includes('href="/tag/9/"'));assert.ok(detail.includes('<span>无标识标签</span>'));assert.ok(!detail.includes('href="/tag//"'));
|
||||
assert.ok(detail.includes('rel="canonical" href="https://default.drama.test/play/23456/90001/"'));
|
||||
assert.ok(detail.includes('rel="canonical" href="https://huangguocg.com/play/23456/90001/"'));
|
||||
for(const html of [home,detail]){
|
||||
for(const [,href] of html.matchAll(/<a\b[^>]*href="(\/[^"#?]*)/g))if(!href.startsWith('/search/'))assert.ok(!/[^\x00-\x7f]/.test(decodeURIComponent(href)),href);
|
||||
}
|
||||
@@ -1030,11 +938,9 @@ test('known pagination limits return 404 while existing empty first pages remain
|
||||
const overflow=await fetch(base+path+'page/2/',{redirect:'manual'});assert.equal(overflow.status,404,path);await overflow.text();
|
||||
}
|
||||
listPayload={list:[fixture],totalCount:48};
|
||||
commentPayload=transactionsPayload={list:[],totalCount:20};
|
||||
accountListPayload={list:[],totalCount:48};
|
||||
searchPayload={list:[fixture],totalCount:48,pageSize:24};
|
||||
for(const path of ['/shorts/','/rank/hot/','/rank/new/','/rank/follow/','/topics/','/topics/7/','/theme/8/','/tag/9/',
|
||||
'/detail/23456/','/play/23456/90001/','/search/关键词/','/member/history/','/member/favorites/','/member/purchased/','/info/deposit/','/info/deposit/vip/']){
|
||||
'/search/关键词/']){
|
||||
for(const [page,status] of [[2,200],[3,404]]){
|
||||
const response=await fetch(base+path+'page/'+page+'/',{redirect:'manual'}),html=await response.text();
|
||||
assert.equal(response.status,status,path+' page '+page);assert.equal(response.headers.get('location'),null);
|
||||
@@ -1044,7 +950,7 @@ test('known pagination limits return 404 while existing empty first pages remain
|
||||
const config=await fetch(base+'/config/31/page/2/');assert.equal(config.status,404);await config.text();
|
||||
searchPayload={list:[],totalCount:0};
|
||||
const emptySearch=await fetch(base+'/search/没有匹配项/');assert.equal(emptySearch.status,200);assert.ok((await emptySearch.text()).includes('搜索无结果'));
|
||||
}finally{listPayload=commentPayload=transactionsPayload=accountListPayload=searchPayload=undefined;}
|
||||
}finally{listPayload=searchPayload=undefined;}
|
||||
});
|
||||
|
||||
test('topic aliases and leading-zero IDs serve content directly with a single primary canonical',async()=>{
|
||||
@@ -1053,7 +959,7 @@ test('topic aliases and leading-zero IDs serve content directly with a single pr
|
||||
for(const [path,target] of [['/theme/007/','/topics/7/'],['/theme/007/page/2/','/topics/7/page/2/'],['/topics/007/','/topics/7/'],['/detail/023456/','/detail/23456/']]){
|
||||
const start=calls.length,response=await fetch(base+path+'?utm_source=campaign',{redirect:'manual'}),html=await response.text();
|
||||
assert.equal(response.status,200,path);assert.equal(response.headers.get('location'),null);
|
||||
assert.ok(html.includes(`rel="canonical" href="https://default.drama.test${target}"`),path);
|
||||
assert.ok(html.includes(`rel="canonical" href="https://huangguocg.com${target}"`),path);
|
||||
assert.match(html,/<meta name="robots" content="index,\s*follow/);
|
||||
assert.ok(html.includes(path.startsWith('/detail/')?'play-detail-title':'同一专题'));
|
||||
if(path.startsWith('/theme/'))assert.equal(calls.slice(start).filter(call=>call.path==='/api/app/playlet/topic/detail').length,1);
|
||||
@@ -1061,25 +967,20 @@ test('topic aliases and leading-zero IDs serve content directly with a single pr
|
||||
}finally{listPayload=undefined;}
|
||||
});
|
||||
|
||||
test('comment pages stay usable but noindex, while ordinary catalog pagination remains indexable',async()=>{
|
||||
test('detail and play pages have no comment pagination, while ordinary catalog pagination remains indexable',async()=>{
|
||||
try{
|
||||
commentPayload={list:[{id:321,text:'第二页的真实评论'}],totalCount:25};
|
||||
chapterPayload={id:90001,playletId:23456,currentEpisode:2,canWatch:true,videoUrl:'https://media.drama.test/2.mp4'};
|
||||
for(const path of ['/detail/23456/page/2/','/play/23456/90001/page/2/']){
|
||||
const response=await fetch(base+path,{redirect:'manual',headers:{'User-Agent':'YandexBot'}}),html=await response.text();
|
||||
assert.equal(response.status,200);assert.equal(response.headers.get('location'),null);
|
||||
assert.match(html,/<meta name="robots" content="noindex,\s*follow"/);assert.ok(!html.includes('rel="canonical"'));
|
||||
assert.ok(!/<script\b[^>]*data-site-json-ld=/.test(html));
|
||||
assert.ok(html.includes('第二页的真实评论'));assert.ok(html.includes('href="'+path.replace('/page/2/','/page/3/')+'"'));
|
||||
const response=await fetch(base+path,{redirect:'manual'});assert.equal(response.status,404,path);await response.text();
|
||||
}
|
||||
listPayload={list:[fixture],totalCount:72};
|
||||
const html=await (await fetch(base+'/shorts/page/2/')).text();
|
||||
assert.match(html,/<meta name="robots" content="index,\s*follow/);
|
||||
assert.ok(html.includes('rel="canonical" href="https://default.drama.test/shorts/page/2/"'));
|
||||
}finally{commentPayload=chapterPayload=listPayload=undefined;}
|
||||
assert.ok(html.includes('rel="canonical" href="https://huangguocg.com/shorts/page/2/"'));
|
||||
}finally{chapterPayload=listPayload=undefined;}
|
||||
});
|
||||
|
||||
test('SSR applies more-list content rules and real episode access rules without exposing locked media',async()=>{
|
||||
test('SSR applies more-list content rules and treats every supplied episode source as free',async()=>{
|
||||
const savedHome=homePayload;
|
||||
try{
|
||||
const block={id:31,status:true,name:'都市精选',type:1,list:[fixture]};
|
||||
@@ -1093,17 +994,15 @@ test('SSR applies more-list content rules and real episode access rules without
|
||||
detailSeo={desc:'剧集共用简介',onlineAt:'2025-10-18 10:30:00'};
|
||||
const chapter={id:90001,playletId:23456,currentEpisode:3,payType:1,videoUrl:'https://media.drama.test/paid-episode.m3u8'};
|
||||
for(const allowed of [false,true]){
|
||||
// Payment flags are ignored: a supplied source is always free to watch and indexable.
|
||||
chapterPayload={...chapter,canWatch:allowed,needUnlock:!allowed};
|
||||
const response=await fetch(base+'/play/23456/90001/'),html=await response.text();assert.equal(response.status,200);
|
||||
assert.equal(/<meta name="robots" content="index,/.test(html),allowed);
|
||||
assert.equal(html.includes('rel="canonical"'),allowed);assert.equal(html.includes(chapter.videoUrl),allowed);
|
||||
assert.ok(html.includes('class="play-screen play-paywalled-content"'));
|
||||
if(allowed){
|
||||
const graph=JSON.parse(html.match(/<script\b[^>]*data-site-json-ld=""[^>]*>([\s\S]*?)<\/script>/)[1])['@graph'];
|
||||
const page=graph.find(node=>node['@type']==='WebPage');
|
||||
assert.equal(page.isAccessibleForFree,false);assert.equal(page.hasPart.cssSelector,'.play-paywalled-content');
|
||||
assert.equal(graph.find(node=>node['@type']==='VideoObject').isAccessibleForFree,false);
|
||||
}else assert.ok(!/<script\b[^>]*data-site-json-ld=/.test(html));
|
||||
assert.match(html,/<meta name="robots" content="index,/);
|
||||
assert.ok(html.includes('rel="canonical"'));assert.ok(html.includes(chapter.videoUrl));
|
||||
assert.ok(html.includes('class="play-screen"'));assert.ok(!html.includes('play-paywalled-content'));
|
||||
const graph=JSON.parse(html.match(/<script\b[^>]*data-site-json-ld=""[^>]*>([\s\S]*?)<\/script>/)[1])['@graph'];
|
||||
assert.equal(graph.find(node=>node['@type']==='WebPage').isAccessibleForFree,undefined);
|
||||
assert.equal(graph.find(node=>node['@type']==='VideoObject').isAccessibleForFree,true);
|
||||
}
|
||||
chapterPayload={...chapter,canWatch:true,needUnlock:false,payType:0,videoUrl:''};
|
||||
const html=await (await fetch(base+'/play/23456/90001/')).text();
|
||||
@@ -1156,19 +1055,15 @@ test('HEAD requests report the same missing-resource and temporary-error statuse
|
||||
}finally{responseOverrides={};}
|
||||
});
|
||||
|
||||
test('optional data failures preserve real detail content and do not manufacture pagination 404s',async()=>{
|
||||
test('optional data failures preserve real detail content',async()=>{
|
||||
try{
|
||||
responseOverrides={
|
||||
'comment/list':{status:503},'playlet/chapter/list':{status:503},'playlet/guess-like/list':{status:503},
|
||||
'playlet/chapter/list':{status:503},'playlet/guess-like/list':{status:503},
|
||||
};
|
||||
for(const path of ['/detail/23456/','/play/23456/90001/']){
|
||||
const response=await fetch(base+path);assert.equal(response.status,200,path);
|
||||
const html=await response.text();assert.ok(html.includes('接口剧名'));assert.ok(html.includes('评论暂不可用'));
|
||||
const commentsPage=await fetch(base+path+'page/2/');assert.equal(commentsPage.status,500,'unknown comments count must not become 404');await commentsPage.text();
|
||||
const html=await response.text();assert.ok(html.includes('接口剧名'));assert.ok(html.includes('暂无推荐视频'));
|
||||
}
|
||||
responseOverrides={'coin/trans/list':{status:503}};
|
||||
const wallet=await fetch(base+'/info/deposit/');assert.equal(wallet.status,200);await wallet.text();
|
||||
const page=await fetch(base+'/info/deposit/page/2/');assert.equal(page.status,500);await page.text();
|
||||
}finally{responseOverrides={};}
|
||||
});
|
||||
|
||||
@@ -1240,10 +1135,10 @@ test('navigation failure leaves page content available and never restores a hard
|
||||
});
|
||||
|
||||
|
||||
test('applications, return-home, feedback and contacts arrive as complete HTML without a loading shell',async()=>{
|
||||
test('applications, return-home and contacts arrive as complete HTML without a loading shell',async()=>{
|
||||
try{
|
||||
appsPayload={categoryList:[{id:80,title:'接口应用分类',appList:[{id:81,name:'接口应用',url:'https://apps.example/download',logo:'https://apps.example/logo.webp'}]}]};
|
||||
for(const [path,panel,marker] of [['/apps/','channel-page','接口应用分类'],['/home-road/','channel-page','return-home-directory'],['/info/feedback/','info-page','id="feedback-form"'],['/info/about-us/','info-page','https://example.com/contact']]){
|
||||
for(const [path,panel,marker] of [['/apps/','channel-page','接口应用分类'],['/home-road/','channel-page','return-home-directory'],['/info/about-us/','info-page','官方TG:<a href="https://t.me/jbd125"']]){
|
||||
const start=calls.length,response=await fetch(base+path);assert.equal(response.status,200,path);
|
||||
const html=await response.text(),main=currentMain(html,panel);assert.ok(main.includes(marker),path);assert.ok(!/api-loading|正在加载|加载中/.test(main),path);
|
||||
if(path==='/apps/')assert.equal(calls.slice(start).filter(call=>call.path==='/api/app/applist/list').length,1);
|
||||
@@ -1251,94 +1146,6 @@ test('applications, return-home, feedback and contacts arrive as complete HTML w
|
||||
}finally{appsPayload=undefined;}
|
||||
});
|
||||
|
||||
test('fresh private pages render real guest records with the same persisted identity and no credentials in HTML',async()=>{
|
||||
try{
|
||||
accountListPayload={list:[{...fixture,title:'游客自己的历史',password:'private-list-password'}],totalCount:72};
|
||||
const start=calls.length,response=await fetch(base+'/member/history/page/2/'),html=await response.text(),main=currentMain(html,'info-page');
|
||||
assert.equal(response.status,200);assert.match(response.headers.get('cache-control'),/no-store/);
|
||||
assert.ok(main.includes('游客自己的历史'));assert.ok(main.includes('真实接口用户'));assert.ok(main.includes('href="/member/history/page/3/"'));
|
||||
assert.ok(!/api-loading|正在加载|加载中/.test(main));
|
||||
for(const secret of ['test-session-secret','private-list-password'])assert.ok(!html.includes(secret));
|
||||
assert.ok(response.headers.getSetCookie().some(value=>value.startsWith('drama_session=')&&/HttpOnly/.test(value)));
|
||||
const reads=calls.slice(start);assert.equal(reads.filter(call=>call.path==='/api/app/auth/guest').length,1);assert.equal(reads.filter(call=>call.path==='/api/app/auth/getInfo').length,0);
|
||||
assert.equal(reads.find(call=>call.path==='/api/app/playlet/history/list').auth,'test-session-secret');
|
||||
}finally{accountListPayload=undefined;}
|
||||
});
|
||||
|
||||
test('likes pages render the current account list, native links and pagination before JavaScript without shared caching',async()=>{
|
||||
try{
|
||||
strictContract=true;
|
||||
accountListPayload=(path,request)=>({list:[{...fixture,slug:'liked-story',title:request.headers.authorization==='registered-user-a-secret'?'甲的点赞短剧':'乙的点赞短剧',token:'private-liked-token'}],totalCount:72});
|
||||
for(const [token,title,other] of [['registered-user-a-secret','甲的点赞短剧','乙的点赞短剧'],['registered-user-b-secret','乙的点赞短剧','甲的点赞短剧']]){
|
||||
const start=calls.length;
|
||||
const response=await fetch(base+'/member/likes/page/2/',{headers:{Cookie:'drama_session='+token+'; drama_registered=1'}}),html=await response.text(),main=currentMain(html,'info-page');
|
||||
assert.equal(response.status,200);assert.match(response.headers.get('cache-control'),/private.*no-store/);
|
||||
assert.match(html,/<meta name="robots" content="noindex,\s*nofollow"/);
|
||||
assert.match(main,/<h1\b[^>]*>我的点赞<\/h1>/);assert.ok(main.includes(title));assert.ok(!main.includes(other));
|
||||
assert.ok(main.includes('href="/member/likes/" aria-current="page"'));assert.ok(main.includes('href="/member/likes/page/3/"'));
|
||||
assert.ok(main.includes('href="/detail/liked-story/"'));assert.ok(main.includes('共 72 部短剧'));
|
||||
assert.ok(!/api-loading|正在加载|加载中/.test(main));
|
||||
for(const secret of [token,'private-liked-token'])assert.ok(!html.includes(secret));
|
||||
const reads=calls.slice(start).filter(call=>call.path==='/api/app/playlet/like/list');
|
||||
assert.equal(reads.length,1);assert.equal(reads[0].auth,token);assert.equal(reads[0].cookie,undefined);
|
||||
assert.equal(new URLSearchParams(reads[0].search).get('pageNum'),'2');assert.equal(new URLSearchParams(reads[0].search).get('pageSize'),'24');
|
||||
}
|
||||
const response=await fetch(base+'/api/playlet/like/list?pageNum=3&pageSize=24',{headers:{Cookie:'drama_session=registered-user-a-secret; drama_registered=1'}});
|
||||
assert.equal(response.status,200);assert.match(response.headers.get('cache-control'),/no-store/);
|
||||
assert.equal((await response.json()).code,200);assert.equal(calls.at(-1).auth,'registered-user-a-secret');
|
||||
}finally{accountListPayload=undefined;strictContract=false;}
|
||||
});
|
||||
|
||||
test('likes distinguish empty records, out-of-range pages, backend failures and expired accounts',async()=>{
|
||||
const headers={Cookie:'drama_session=registered-user-a-secret; drama_registered=1'};
|
||||
try{
|
||||
accountListPayload={list:[],totalCount:0};
|
||||
const emptyResponse=await fetch(base+'/member/likes/',{headers}),emptyHtml=await emptyResponse.text();
|
||||
assert.equal(emptyResponse.status,200);assert.ok(emptyHtml.includes('暂无点赞的短剧'));assert.ok(emptyHtml.includes('href="/shorts/">去发现好剧</a>'));
|
||||
assert.equal((await fetch(base+'/member/likes/page/2/',{headers})).status,404);
|
||||
responseOverrides={'playlet/like/list':{status:503,code:503}};
|
||||
const failed=await fetch(base+'/member/likes/',{headers}),failedHtml=await failed.text();
|
||||
assert.equal(failed.status,500);assert.match(failedHtml,/<meta name="robots" content="noindex/);assert.ok(!failedHtml.includes('暂无点赞的短剧'));
|
||||
responseOverrides={};
|
||||
const start=calls.length;
|
||||
const expiredResponse=await fetch(base+'/member/likes/',{headers:{Cookie:'drama_session=expired; drama_registered=1'}}),expiredHtml=await expiredResponse.text();
|
||||
assert.ok(expiredHtml.includes('请先登录账号'));assert.ok(expiredHtml.includes('data-auth-mode="login"'));
|
||||
assert.ok(!calls.slice(start).some(call=>['/api/app/auth/guest','/api/app/playlet/like/list'].includes(call.path)));
|
||||
}finally{accountListPayload=undefined;responseOverrides={};}
|
||||
});
|
||||
|
||||
test('private settings isolate two users and ignore forged SSR identity headers',async()=>{
|
||||
const start=calls.length;
|
||||
for(const [token,name,other] of [['registered-user-a-secret','用户甲','用户乙'],['registered-user-b-secret','用户乙','用户甲']]){
|
||||
const forged=Buffer.from(JSON.stringify({userId:999,nickName:'伪造管理员'})).toString('base64url');
|
||||
const response=await fetch(base+'/member/settings/',{headers:{Cookie:'drama_session='+token+'; drama_registered=1','x-drama-ssr-session':'ready','x-drama-ssr-user':forged}});
|
||||
const html=await response.text(),main=currentMain(html,'info-page');assert.ok(main.includes(name));assert.ok(!html.includes(other));assert.ok(!html.includes('伪造管理员'));
|
||||
for(const secret of [token,'must-not-leak'])assert.ok(!html.includes(secret));assert.ok(!main.includes('api-loading'));
|
||||
}
|
||||
assert.equal(calls.slice(start).filter(call=>call.path==='/api/app/auth/getInfo').length,2);assert.ok(!calls.slice(start).some(call=>call.path==='/api/app/auth/guest'));
|
||||
});
|
||||
|
||||
test('registered expiry and explicit logout render sign-in immediately without guest downgrade',async()=>{
|
||||
for(const cookie of ['drama_session=expired; drama_registered=1','drama_skip_auto=1']){
|
||||
const start=calls.length,response=await fetch(base+'/member/settings/',{headers:{Cookie:cookie}}),html=await response.text(),main=currentMain(html,'info-page');
|
||||
assert.ok(main.includes('请先登录账号'));assert.ok(main.includes('data-auth-mode="login"'));assert.ok(!main.includes('id="profile-form"'));assert.ok(!main.includes('api-loading'));
|
||||
assert.ok(!calls.slice(start).some(call=>call.path==='/api/app/auth/guest'));
|
||||
}
|
||||
});
|
||||
|
||||
test('wallet SSR contains configured packages and transactions without creating an order or leaking extra fields',async()=>{
|
||||
try{
|
||||
packagesPayload={coinList:[{id:91,name:'真实金币套餐',price:1000,baseCoin:100,giftCoin:5,totalCoin:105,packageType:2,payMethods:[{type:'alipay',typeName:'支付宝'}],token:'private-package-token'}],vipList:[]};
|
||||
transactionsPayload={list:[{title:'真实交易明细',changeAmount:25,createdAt:'2026-10-21 10:30:00',token:'private-transaction-token'}],totalCount:18};
|
||||
const start=calls.length,response=await fetch(base+'/info/deposit/'),html=await response.text(),main=currentMain(html,'info-page');
|
||||
assert.ok(main.includes('100金币'));assert.ok(main.includes('真实交易明细'));assert.ok(main.includes('href="/info/deposit/page/2/"'));assert.ok(!/api-loading|正在加载|加载中/.test(main));
|
||||
for(const secret of ['private-package-token','private-transaction-token','test-session-secret'])assert.ok(!html.includes(secret));
|
||||
const reads=calls.slice(start);assert.equal(reads.filter(call=>call.path==='/api/app/recharge/package/list').length,1);assert.equal(reads.filter(call=>call.path==='/api/app/coin/trans/list').length,1);
|
||||
assert.ok(!reads.some(call=>['/api/app/coin/buy','/api/app/vip/pay','/api/app/playlet/chapter/unlock'].includes(call.path)));
|
||||
}finally{packagesPayload=undefined;transactionsPayload=undefined;}
|
||||
});
|
||||
|
||||
|
||||
test('header and complete page content share the server-prepared identity before scripts run',async()=>{
|
||||
const start=calls.length,response=await fetch(base+'/shorts/'),html=await response.text();
|
||||
assert.ok(html.indexOf('id="global-site-header"')<html.indexOf('<main class="shell"'));
|
||||
@@ -1352,34 +1159,32 @@ test('slug details and episodes render directly with numeric dependent reads, sl
|
||||
strictContract=true;
|
||||
detailSeo={slug:'city-story',title:'都市故事',desc:'作品独立介绍',tagNames:['都市'],tagIds:[9],tagSlugs:['urban'],onlineAt:'2026-10-20'};
|
||||
chapterPayload={id:90001,playletId:23456,currentEpisode:3,canWatch:true,videoUrl:'https://media.drama.test/3.mp4'};
|
||||
commentPayload={list:[],totalCount:25};
|
||||
for(const [path,chapter] of [['/detail/city-story/',false],['/play/city-story/90001/',true]]){
|
||||
const start=calls.length,response=await fetch(base+path,{redirect:'manual'}),html=await response.text();
|
||||
assert.equal(response.status,200,path);assert.equal(response.headers.get('location'),null);
|
||||
const main=currentMain(html,'drama-play-page');assert.ok(main.includes('都市故事'));
|
||||
assert.ok(main.includes('href="/play/city-story/90001/"'));
|
||||
assert.ok(main.includes(`href="${path}page/2/"`));
|
||||
assert.ok(html.includes(`rel="canonical" href="https://default.drama.test${path}"`));
|
||||
assert.ok(html.includes(`rel="canonical" href="https://huangguocg.com${path}"`));
|
||||
const reads=calls.slice(start),detail=reads.filter(call=>call.path==='/api/app/playlet/detail');assert.equal(detail.length,1);
|
||||
assert.equal(new URLSearchParams(detail[0].search).get('slug'),'city-story');assert.equal(new URLSearchParams(detail[0].search).has('id'),false);
|
||||
assert.equal(new URLSearchParams(reads.find(call=>call.path==='/api/app/playlet/chapter/list').search).get('playletSlug'),'city-story');
|
||||
assert.equal(new URLSearchParams(reads.find(call=>call.path==='/api/app/playlet/guess-like/list').search).get('playletId'),'23456');
|
||||
assert.equal(new URLSearchParams(reads.find(call=>call.path==='/api/app/comment/list').search).get('objectId'),chapter?'90001':'23456');
|
||||
assert.ok(!reads.some(call=>call.path==='/api/app/comment/list'));
|
||||
if(chapter){assert.ok(main.includes('href="/tag/urban/"'));assert.ok(main.includes('href="/detail/city-story/"'));}
|
||||
const graph=JSON.parse(html.match(/<script\b[^>]*data-site-json-ld=""[^>]*>([\s\S]*?)<\/script>/)[1])['@graph'];
|
||||
assert.equal(graph.find(node=>node['@type']==='TVSeries').url,'https://default.drama.test/detail/city-story/');
|
||||
if(chapter)assert.equal(graph.find(node=>node['@type']==='TVEpisode').url,'https://default.drama.test/play/city-story/90001/');
|
||||
assert.equal(graph.find(node=>node['@type']==='TVSeries').url,'https://huangguocg.com/detail/city-story/');
|
||||
if(chapter)assert.equal(graph.find(node=>node['@type']==='TVEpisode').url,'https://huangguocg.com/play/city-story/90001/');
|
||||
}
|
||||
const legacy=await fetch(base+'/detail/23456/',{redirect:'manual'}),html=await legacy.text();
|
||||
assert.equal(legacy.status,200);assert.equal(legacy.headers.get('location'),null);
|
||||
assert.ok(html.includes('rel="canonical" href="https://default.drama.test/detail/city-story/"'));assert.ok(html.includes('href="/detail/city-story/page/2/"'));
|
||||
assert.ok(html.includes('rel="canonical" href="https://huangguocg.com/detail/city-story/"'));
|
||||
detailSeo={...detailSeo,slug:'都市 / 剧?#%2F'};
|
||||
const path='/play/'+encodeURIComponent(detailSeo.slug)+'/90001/';
|
||||
const special=await fetch(base+path);assert.equal(special.status,200);
|
||||
const specialHtml=await special.text();
|
||||
assert.ok(specialHtml.includes('rel="canonical" href="https://default.drama.test/play/23456/90001/"'));
|
||||
assert.ok(specialHtml.includes('rel="canonical" href="https://huangguocg.com/play/23456/90001/"'));
|
||||
assert.ok(specialHtml.includes('href="/play/23456/90001/"'));
|
||||
}finally{strictContract=false;detailSeo=chapterPayload=commentPayload=undefined;}
|
||||
}finally{strictContract=false;detailSeo=chapterPayload=undefined;}
|
||||
});
|
||||
|
||||
test('home, topics, aliases, tags, more and search generate slug-first destinations and serve refreshed slug URLs',async()=>{
|
||||
@@ -1395,7 +1200,7 @@ test('home, topics, aliases, tags, more and search generate slug-first destinati
|
||||
assert.equal(response.status,200,path);assert.equal(response.headers.get('location'),null);
|
||||
if(path==='/')for(const href of ['/detail/city-story/','/topics/urban-topic/','/config/editors-choice/'])assert.ok(html.includes(`href="${href}"`));
|
||||
if(path.startsWith('/topics/urban')||path.startsWith('/theme/')){
|
||||
assert.ok(html.includes(`rel="canonical" href="https://default.drama.test${path.replace('/theme/','/topics/')}"`));
|
||||
assert.ok(html.includes(`rel="canonical" href="https://huangguocg.com${path.replace('/theme/','/topics/')}"`));
|
||||
const query=new URLSearchParams(calls.slice(start).find(call=>call.path==='/api/app/playlet/topic/detail').search);
|
||||
assert.equal(query.get('id'),'7');assert.equal(query.has('slug'),false);
|
||||
}
|
||||
@@ -1419,12 +1224,12 @@ test('missing slugs stay real 404 responses and topic directory outages stay ser
|
||||
}finally{detailSeo=listPayload=undefined;responseOverrides={};}
|
||||
});
|
||||
|
||||
test('detail, episode, chapter list, comments and recommendations are requested before detail finishes',async()=>{
|
||||
test('detail, episode, chapter list and recommendations are requested before detail finishes',async()=>{
|
||||
let release;blockedDetail={promise:new Promise(resolve=>release=resolve)};
|
||||
const start=calls.length;
|
||||
const pending=fetch(base+'/play/23456/90001/');
|
||||
try{
|
||||
const paths=['playlet/detail','playlet/chapter/detail','playlet/chapter/list','comment/list','playlet/guess-like/list'].map(path=>'/api/app/'+path);
|
||||
const paths=['playlet/detail','playlet/chapter/detail','playlet/chapter/list','playlet/guess-like/list'].map(path=>'/api/app/'+path);
|
||||
for(let attempt=0;attempt<100;attempt++){
|
||||
if(paths.every(path=>calls.slice(start).some(call=>call.path===path)))break;
|
||||
await delay(10);
|
||||
@@ -1434,7 +1239,7 @@ test('detail, episode, chapter list, comments and recommendations are requested
|
||||
const html=await (await pending).text();assert.ok(currentMain(html,'drama-play-page').includes('play-detail-title'));
|
||||
});
|
||||
|
||||
test('public payloads are reused across page requests while two accounts and permissions stay uncached',async()=>{
|
||||
test('public payloads are reused across page requests while permissions stay uncached',async()=>{
|
||||
await stopNext();await startNext({PUBLIC_DATA_CACHE_TTL_MS:'60000'});
|
||||
const previousHeader=headerPayload;
|
||||
try{
|
||||
@@ -1448,12 +1253,6 @@ test('public payloads are reused across page requests while two accounts and per
|
||||
assert.equal(calls.slice(start).filter(call=>call.path==='/api/app/playlet/home').length,1);
|
||||
headerPayload={categoryList:[]};
|
||||
assert.ok(navigationHtml(await (await fetch(base+'/shorts/')).text(),'top').includes('短剧'),'public navigation remains valid within TTL');
|
||||
const privateStart=calls.length;
|
||||
for(const [token,name,other] of [['registered-user-a-secret','用户甲','用户乙'],['registered-user-b-secret','用户乙','用户甲']]){
|
||||
const html=await (await fetch(base+'/member/settings/',{headers:{Cookie:'drama_session='+token+'; drama_registered=1'}})).text();
|
||||
assert.ok(currentMain(html,'info-page').includes(name));assert.ok(!html.includes(other));
|
||||
}
|
||||
assert.equal(calls.slice(privateStart).filter(call=>call.path==='/api/app/auth/getInfo').length,2);
|
||||
const permissionStart=calls.length;
|
||||
await (await fetch(base+'/play/23456/90001/')).text();await (await fetch(base+'/play/23456/90001/')).text();
|
||||
for(const path of ['playlet/detail','playlet/chapter/detail'])assert.equal(calls.slice(permissionStart).filter(call=>call.path==='/api/app/'+path).length,2,path+' must stay realtime');
|
||||
@@ -1463,7 +1262,7 @@ test('public payloads are reused across page requests while two accounts and per
|
||||
}
|
||||
});
|
||||
|
||||
test('current-origin deployments render SSR and proxy account, API and signed media requests through the current site',async()=>{
|
||||
test('current-origin deployments render SSR and proxy guest, API and signed media requests through the current site',async()=>{
|
||||
await stopNext();await startNext({UPSTREAM_API_URL:'current'});
|
||||
// Emulate Nginx: preserve the visitor Host and route only backend paths to Go.
|
||||
const ingress=createServer((request,response)=>{
|
||||
@@ -1485,8 +1284,8 @@ test('current-origin deployments render SSR and proxy account, API and signed me
|
||||
const config=await fetch(origin+'/api/config/info');assert.equal(config.status,200);assert.equal((await config.json()).code,200);
|
||||
const session=await fetch(origin+'/api/auth/guest',{method:'POST',headers:{'Content-Type':'application/json',Origin:origin},body:'{}'});
|
||||
assert.equal(session.status,200);assert.match(session.headers.get('set-cookie'),/drama_session=/);assert.equal((await session.json()).code,200);
|
||||
const privatePage=await fetch(origin+'/member/settings/',{headers:{Cookie:'drama_session=registered-user-a-secret; drama_registered=1'}});
|
||||
assert.equal(privatePage.status,200);assert.ok(currentMain(await privatePage.text(),'info-page').includes('用户甲'));
|
||||
const detailPage=await fetch(origin+'/detail/23456/');
|
||||
assert.equal(detailPage.status,200);assert.ok((await detailPage.text()).includes('接口剧名'));
|
||||
assert.equal(calls.at(-1).cookie,undefined);
|
||||
const key=await fetch(origin+'/enkey?id=12700&ticket=a%2Fb+1');
|
||||
assert.equal(key.status,200);assert.equal((await key.arrayBuffer()).byteLength,16);
|
||||
|
||||
+3
-3
@@ -16,10 +16,10 @@ const {loadAppCategories,renderAppDirectory,scrollToAppCategory}=await import(ap
|
||||
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',()=>{
|
||||
test('benefit applications mark only linked advertisements and carry no tracking attributes',()=>{
|
||||
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"/);
|
||||
assert.equal(html.match(/data-ad /g)?.length,1);
|
||||
assert.doesNotMatch(html,/data-track|data-item-|data-ad-/);
|
||||
});
|
||||
|
||||
test('loads every missing category once, preserves directory order, and omits empty categories',async()=>{
|
||||
|
||||
+21
-114
@@ -11,34 +11,17 @@ async function moduleUrl(file,replace=code=>code){
|
||||
}
|
||||
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 other={userId:8,type:0,isRegister:false};
|
||||
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']]);
|
||||
@@ -66,7 +49,7 @@ test('concurrent private requests share one guest initialization and public data
|
||||
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');
|
||||
const first=api.get('comment/list'),second=api.get('playlet/detail');
|
||||
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);
|
||||
@@ -74,14 +57,26 @@ test('concurrent private requests share one guest initialization and public data
|
||||
});
|
||||
});
|
||||
|
||||
test('history restoration revalidates current cookies, respects logout and cannot overwrite a newer login',async()=>{
|
||||
test('history restoration revalidates current cookies and cannot overwrite a newer session',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);
|
||||
});
|
||||
await withFetch(async()=>response(null),async()=>{await api.revalidateSession();assert.equal(api.user,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);
|
||||
const refresh=api.revalidateSession();await Promise.resolve();api.setUser(other);gate.resolve();await refresh;assert.equal(api.user.userId,other.userId);
|
||||
});
|
||||
});
|
||||
|
||||
test('an expired session is replaced by a fresh guest on the next private request and never prompts for login',async()=>{
|
||||
const api=await client();api.seedSession(guest);const calls=[];let expired=true;
|
||||
await withFetch(async url=>{
|
||||
calls.push(url);
|
||||
if(url.endsWith('/auth/guest'))return response(other);
|
||||
if(expired){expired=false;return response(null,1000);}
|
||||
return response({list:[]});
|
||||
},async()=>{
|
||||
await assert.rejects(api.get('comment/list',{objectId:1}));assert.equal(api.user,null);
|
||||
await api.get('comment/list',{objectId:1});assert.equal(api.user.userId,other.userId);
|
||||
assert.deepEqual(calls.map(url=>url.split('?')[0]),['/api/comment/list','/api/auth/guest','/api/comment/list']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -100,96 +95,8 @@ test('search and suggestions wait for a shared guest session before requesting p
|
||||
});
|
||||
});
|
||||
|
||||
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/auth/guest','/api/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'));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,1);assert.equal(prompts,1);
|
||||
});
|
||||
});
|
||||
|
||||
test('manual logout and the following skipped initialization stay signed out',async()=>{
|
||||
const api=await client();api.setUser(member);const calls=[];
|
||||
await withFetch(async url=>{calls.push(url);return response(null);},async()=>{
|
||||
await api.post('auth/logout',{});assert.equal(api.user,null);
|
||||
assert.equal(await api.initializeSession(),null);assert.deepEqual(calls,['/api/auth/logout']);
|
||||
const refreshed=await client();assert.equal(await refreshed.initializeSession(),null);
|
||||
await assert.rejects(refreshed.ensureSession());assert.deepEqual(calls,['/api/auth/logout','/api/auth/guest']);
|
||||
});
|
||||
});
|
||||
|
||||
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 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)));
|
||||
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;
|
||||
const app={api:{post:async(path,payload)=>{submitted++;assert.equal(path,'auth/register');assert.equal(payload.accountNo,'player01');await gate.promise;}},notify(){},async route(){routed++;}};
|
||||
globalThis.document={getElementById:id=>id==='member-auth-dialog'?dialog: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));
|
||||
assert.equal(routed,1);assert.equal(button.disabled,false);assert.equal(password.value,'');assert.equal(confirmation.value,'');
|
||||
openAuth('switch');assert.ok(dialog.innerHTML.includes('切换账号'));
|
||||
}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()=>{
|
||||
test('a server-prepared session is reused without another guest handshake',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);
|
||||
const ready=await client();ready.seedSession(other);assert.equal((await ready.initializeSession()).userId,other.userId);
|
||||
});
|
||||
});
|
||||
|
||||
+11
-12
@@ -10,31 +10,30 @@ async function moduleUrl(file,replace=code=>code){
|
||||
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 siteUrl=await moduleUrl('../src/lib/site.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 detailUrl=await moduleUrl('../src/lib/client/detail.ts',code=>code.replace('"../api/protocol"',JSON.stringify(protocolUrl)).replace('"./render"',JSON.stringify(renderUrl)).replace("'../site'",JSON.stringify(siteUrl)));
|
||||
const {overviewMarkup,officialLinks,bindSeriesDescription,detailActionIcon}=await import(detailUrl);
|
||||
|
||||
test('overview retains API data, encrypted image loading and real chapter links in the original layout',()=>{
|
||||
test('overview exposes H1, labelled facts, linked genres, synopsis heading and real chapter links',()=>{
|
||||
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('真实简介 <img onerror=alert(1)>'));assert.ok(html.includes('<dt>类型</dt>'));assert.ok(html.includes('<span>古装</span><span>爱情</span>'));assert.ok(html.includes('<dt>集数</dt>'));
|
||||
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('href="/play/23456/90042/"'));assert.ok(html.includes('hg-detail-badge--new'));assert.ok(html.includes('<h1 id="play-detail-title">剧名 <script></h1>'));assert.ok(html.includes('《剧名 <script>》剧情简介</h2>'));assert.ok(html.includes('alt="《剧名 <script>》海报"'));
|
||||
assert.ok(html.includes('<div class="play-detail-actions">actions</div>'));
|
||||
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/'));
|
||||
});
|
||||
|
||||
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('official links show only the site domain and the official TG, ignoring configured mirrors and contacts',()=>{
|
||||
const html=officialLinks();
|
||||
assert.ok(html.includes('href="https://huangguocg.com/"'));assert.ok(html.includes('官方TG:'));assert.ok(html.includes('href="https://t.me/jbd125"'));assert.ok(html.includes('>@jbd125</a>'));
|
||||
for(const text of ['官方客服','Telegram 群','javascript:'])assert.ok(!html.includes(text),text);
|
||||
});
|
||||
|
||||
test('original action SVGs remain accessible and inherit the active colors',()=>{
|
||||
for(const kind of ['like','save','share','feedback']){
|
||||
for(const kind of ['like','save','share']){
|
||||
const html=detailActionIcon(kind);assert.match(html,/<svg class="play-action-icon"/);assert.match(html,/aria-hidden="true"/);assert.ok(html.includes('<path d="'));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -103,7 +103,7 @@ test('project profiles and Next configuration keep the requested API and port wi
|
||||
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://172.104.56.28:26125':'http://172.104.181.99:21022');assert.equal(command.env.API_BASE_URL,command.env.UPSTREAM_API_URL);assert.equal(command.env.PORT,'21021');assert.equal(command.env.NEXT_PUBLIC_TRACKER_APP_ID,'1047');
|
||||
assert.equal(exports.default.distDir,directory);assert.equal(command.env.UPSTREAM_API_URL,'https://webhgcgapi.mfcg.me');assert.equal(command.env.API_BASE_URL,command.env.UPSTREAM_API_URL);assert.equal(command.env.PORT,'21021');assert.equal(command.env.NEXT_PUBLIC_TRACKER_APP_ID,undefined);
|
||||
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'));
|
||||
|
||||
@@ -3,12 +3,13 @@ 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');
|
||||
export const siteUrl=moduleUrl(await compile('src/lib/site.ts'));
|
||||
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)));
|
||||
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 seoUrl=moduleUrl((await compile('src/lib/seo.ts')).replace("'./site'",JSON.stringify(siteUrl)).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))));
|
||||
|
||||
|
||||
@@ -8,25 +8,21 @@ async function moduleUrl(file,replace=code=>code){
|
||||
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))));
|
||||
const siteUrl=await moduleUrl('../src/lib/site.ts');
|
||||
const {navigationPageData}=await import(await moduleUrl('../src/lib/navigation-page.ts',code=>code.replace("'./api/protocol'",JSON.stringify(protocol)).replace("'./site'",JSON.stringify(siteUrl))));
|
||||
|
||||
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('navigation entry and permanent address always use the site domain, never configured mirrors',()=>{
|
||||
for(const config of [{navPageList:[{line:1,url:'https://one.test'},{line:2,url:'https://two.test'}]},{config:{dhNewUrlAddr1:'https://nav-one.test',foreverUrlAddr:'https://permanent.test'}},{navPageList:null}]){
|
||||
const data=navigationPageData(config);
|
||||
assert.deepEqual(data.routes,[{line:1,href:'https://huangguocg.com/',label:'线路一'}]);
|
||||
assert.equal(data.permanent,'https://huangguocg.com/');
|
||||
}
|
||||
const empty=navigationPageData({});assert.equal(empty.telegram,'https://t.me/jbd125');assert.equal(empty.telegramHandle,'@jbd125');
|
||||
});
|
||||
|
||||
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',()=>{
|
||||
test('navigation uses the fixed site name, configured description and only the official TG contact',()=>{
|
||||
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,'站点名称');
|
||||
assert.equal(data.siteName,'黄果吃瓜');assert.equal(data.description,'配置说明');
|
||||
assert.equal(data.telegram,'https://t.me/jbd125');assert.equal(data.telegramHandle,'@jbd125');assert.equal(data.tgGroup,undefined);assert.equal(data.twitter,undefined);
|
||||
assert.equal(navigationPageData({config:{dhSiteName:'配置名称'}}).siteName,'黄果吃瓜');
|
||||
});
|
||||
|
||||
+5
-143
@@ -11,155 +11,17 @@ async function moduleUrl(file,replace=code=>code){
|
||||
}
|
||||
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 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)));
|
||||
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',()=>{
|
||||
test('poster cards keep ranking numerals, descriptive link titles and alt text, 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('<article class="hg-card hg-card--ranked">'));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'));
|
||||
assert.ok(!card(drama,false,{rank:-1}).includes('hg-card-rank'));
|
||||
assert.ok(ranked.includes('title="《测试 <短剧>》全10集 在线观看"'));assert.ok(ranked.includes('alt="《测试 <短剧>》海报"'));assert.ok(ranked.includes('<span class="hg-card-eps">全10集</span>'));
|
||||
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'));
|
||||
}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('<a href="/member/likes/">我的点赞 ›</a>'));
|
||||
assert.ok(html.indexOf('/member/favorites/')<html.indexOf('/member/likes/'));
|
||||
assert.ok(html.indexOf('/member/likes/')<html.indexOf('/member/history/'));
|
||||
assert.ok(!html.includes('<script>'));
|
||||
}
|
||||
});
|
||||
|
||||
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});
|
||||
for(const text of ['member-settings-card','member-avatar-editor','member-avatar-preview','member-nickname-editor','账号与安全','data-auth-mode="register"','data-auth-mode="switch"','data-logout','id="security-form"','name="oldPassword"','name="newPassword"'])assert.ok(html.includes(text),text);
|
||||
assert.ok(html.includes('value="用户 <script>"'));assert.ok(html.includes('简介 <img>'));assert.ok(html.includes('已绑定'));
|
||||
assert.ok(html.includes('id="member-avatar-file" accept="image/png,image/jpeg,image/webp" hidden'));assert.ok(html.includes('data-src="https://cdn.example/avatar.webp"'));
|
||||
});
|
||||
|
||||
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,4);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,5);
|
||||
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('restyled settings forms retain distinct profile/password payloads and prevent repeated saves',async()=>{
|
||||
const oldDocument=globalThis.document,OldFormData=globalThis.FormData;
|
||||
const controller=new AbortController(),root=new Element(),profile=new Element(),security=new Element();
|
||||
const values=new Map([[profile,{nickName:'新昵称',introduction:'新的简介'}],[security,{oldPassword:'Old12345',newPassword:'New12345'}]]);
|
||||
for(const form of [profile,security]){form.nodes.set('[type="submit"]',new Element());form.nodes.set('.api-error',new Element());}
|
||||
root.nodes.set('#profile-form',profile);root.nodes.set('#security-form',security);
|
||||
const calls=[];let finish,refreshes=0,routes=0;
|
||||
const app={signal:controller.signal,home:{},api:{ensureSession:async()=>({userId:8}),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);
|
||||
emit(security,'submit');await flush();assert.deepEqual(calls[1],{path:'auth/profile',body:{oldPassword:'Old12345',newPassword:'New12345'}});assert.equal(refreshes,2);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('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();}
|
||||
assert.ok(search.includes('<h3 class="hg-card-title">'));
|
||||
});
|
||||
|
||||
@@ -15,13 +15,10 @@ 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 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)));
|
||||
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/'];
|
||||
const bases=['/shorts/','/topics/','/topics/7/','/theme/8/','/tag/9/','/config/31/','/rank/','/rank/hot/','/rank/new/','/rank/follow/','/search/'+encodeURIComponent(keyword)+'/'];
|
||||
for(const base of bases){
|
||||
for(const page of [1,2,37]){
|
||||
const path=base+'page/'+page+'/';
|
||||
@@ -36,7 +33,8 @@ test('every paginated page type resolves directly from the page suffix',()=>{
|
||||
|
||||
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);
|
||||
for(const base of ['/','/apps/','/home-road/','/navigation/','/info/about-us/'])assert.equal(parseSitePath(base+'page/2/'),null,base);
|
||||
for(const base of ['/member/favorites/','/member/history/','/info/deposit/','/info/deposit/vip/','/info/settings/','/info/user-center/'])assert.equal(parseSitePath(base),null,base);
|
||||
assert.equal(parseSitePath('/shorts/page/2/page/3/'),null);
|
||||
assert.throws(()=>paginationPath('/shorts/',0));assert.throws(()=>paginationPath('/apps/',2));
|
||||
});
|
||||
@@ -49,7 +47,7 @@ test('page links retain filters and encoded search terms and sorting resets to t
|
||||
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/');
|
||||
for(const path of ['/detail/23456/page/2/','/play/23456/90001/page/2/'])assert.equal(parseSitePath(path),null,'detail and play pages have no comment pagination');
|
||||
});
|
||||
|
||||
test('pagination renders real page links with correct boundaries, current page, and bounded page numbers',()=>{
|
||||
@@ -93,27 +91,3 @@ test('jump forms navigate to independent routes, retain canonical slugs and filt
|
||||
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;}
|
||||
});
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
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 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)));
|
||||
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,popupBlocked=false}={}){
|
||||
const oldDocument=globalThis.document,OldFormData=globalThis.FormData,oldWindow=globalThis.window;
|
||||
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=[],popups=[],redirects=[];let refreshes=0,routes=0;
|
||||
const app={signal:controller.signal,api:{user:{recharge:balance},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.window={open(url,target){
|
||||
if(popupBlocked)return null;
|
||||
const popup={url,target,closed:false,opener:{},document:{title:'',body:{textContent:''}},location:{replace(value){popup.url=value;}},close(){popup.closed=true;}};
|
||||
popups.push(popup);return popup;
|
||||
},location:{assign(url){redirects.push(url);}}};
|
||||
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,popups,redirects,controller,get refreshes(){return refreshes;},get routes(){return routes;},dispose(){controller.abort();globalThis.document=oldDocument;globalThis.FormData=OldFormData;globalThis.window=oldWindow;}};
|
||||
}
|
||||
|
||||
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'}}]);
|
||||
}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'}}]);
|
||||
assert.equal(f.popups.length,1);assert.equal(f.popups[0].url,'about:blank');assert.equal(f.popups[0].target,'_blank');assert.equal(f.popups[0].opener,null);
|
||||
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.equal(f.popups[0].url,'https://pay.example/checkout');assert.equal(f.popups[0].closed,false);assert.deepEqual(f.redirects,[]);
|
||||
assert.match(f.form.innerHTML,/<a class="primary wallet-payment-link" href="https:\/\/pay\.example\/checkout" target="_blank" rel="sponsored nofollow noopener noreferrer">打开支付页面<\/a>/);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);
|
||||
f.controller.abort();assert.equal(f.popups[0].closed,false,'completed payment tabs remain open when leaving the wallet');
|
||||
}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'}}]);
|
||||
assert.match(f.error.textContent,/有效地址/);assert.equal(f.confirm.disabled,false);assert.equal(f.choices[0].disabled,false);assert.equal(f.refreshes,0);
|
||||
assert.equal(f.popups[0].closed,true);assert.equal(f.popups[0].url,'about:blank');assert.deepEqual(f.redirects,[]);
|
||||
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);assert.equal(empty.popups.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);
|
||||
assert.equal(late.popups[0].closed,true);assert.equal(late.popups[0].url,'about:blank');assert.deepEqual(late.redirects,[]);
|
||||
}finally{late.dispose();}
|
||||
});
|
||||
|
||||
test('VIP payment opens the returned URL automatically and keeps a link to reopen the order',async()=>{
|
||||
const f=await fixture({vip:true});
|
||||
try{
|
||||
emit(f.form,'submit');await flush();
|
||||
assert.deepEqual(f.posts,[{path:'vip/pay',body:{packageId:31,payMode:'alipay'}}]);
|
||||
assert.equal(f.popups.length,1);assert.equal(f.popups[0].url,'https://pay.example/checkout');
|
||||
assert.ok(f.form.innerHTML.includes('打开支付页面'));assert.ok(f.form.innerHTML.includes('href="https://pay.example/checkout"'));assert.equal(f.refreshes,0);
|
||||
}finally{f.dispose();}
|
||||
});
|
||||
|
||||
test('a blocked payment popup automatically continues in the current page after order creation',async()=>{
|
||||
let finish;const f=await fixture({popupBlocked:true,post:()=>new Promise(resolve=>finish=resolve)});
|
||||
try{
|
||||
emit(f.form,'submit');assert.deepEqual(f.redirects,[]);
|
||||
finish({payUrl:'https://pay.example/checkout',tradeNo:'order-1'});await flush();
|
||||
assert.deepEqual(f.redirects,['https://pay.example/checkout']);assert.equal(f.posts.length,1);
|
||||
assert.ok(f.form.innerHTML.includes('打开支付页面'));
|
||||
}finally{f.dispose();}
|
||||
});
|
||||
|
||||
test('failed orders close their reserved tab and can retry with a fresh tab',async()=>{
|
||||
let attempts=0;const f=await fixture({post:async()=>{
|
||||
if(!attempts++)throw new Error('下单失败');
|
||||
return {payUrl:'https://pay.example/retry',tradeNo:'order-2'};
|
||||
}});
|
||||
try{
|
||||
emit(f.form,'submit');await flush();
|
||||
assert.equal(f.popups[0].closed,true);assert.equal(f.error.textContent,'下单失败');assert.equal(f.confirm.disabled,false);
|
||||
emit(f.form,'submit');await flush();
|
||||
assert.equal(f.posts.length,2);assert.equal(f.popups.length,2);assert.equal(f.popups[1].url,'https://pay.example/retry');assert.equal(f.popups[1].closed,false);
|
||||
}finally{f.dispose();}
|
||||
});
|
||||
|
||||
test('leaving the wallet closes its pending tab and ignores a late payment URL',async()=>{
|
||||
let finish;const f=await fixture({post:()=>new Promise(resolve=>finish=resolve)});
|
||||
try{
|
||||
emit(f.form,'submit');f.controller.abort();assert.equal(f.popups[0].closed,true);
|
||||
finish({payUrl:'https://pay.example/late',tradeNo:'old-order'});await flush();
|
||||
assert.equal(f.popups[0].url,'about:blank');assert.deepEqual(f.redirects,[]);assert.equal(f.form.innerHTML,'');
|
||||
}finally{f.dispose();}
|
||||
});
|
||||
+47
-234
@@ -5,7 +5,7 @@ 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';
|
||||
import {seoUrl,siteUrl} from './helpers/seo.mjs';
|
||||
|
||||
async function moduleUrl(path,transform=code=>code){
|
||||
const source=await readFile(new URL(path,import.meta.url),'utf8');
|
||||
@@ -14,12 +14,11 @@ async function moduleUrl(path,transform=code=>code){
|
||||
}
|
||||
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 {playbackUrl,chapterAccess,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 detailUrl=await moduleUrl('../src/lib/client/detail.ts',code=>code.replace(/from ['"]\.\.\/api\/protocol['"]/,`from '${protocolUrl}'`).replace(/from ['"]\.\/render['"]/,`from '${renderUrl}'`).replace("'../site'",JSON.stringify(siteUrl)));
|
||||
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}'`)
|
||||
@@ -38,7 +37,6 @@ const playerUrl=await moduleUrl('../src/lib/client/player.ts',code=>code
|
||||
.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);
|
||||
@@ -102,19 +100,15 @@ test('playback addresses preserve signed queries, use the relative proxy once, a
|
||||
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',()=>{
|
||||
test('every chapter with a playable source is free regardless of payment flags; chapters without one fall back to the preview',()=>{
|
||||
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');
|
||||
for(const chapter of [{canWatch:true},{needUnlock:true},{payType:1},{needVip:true},{payType:2},{canWatch:false,needUnlock:true,needVip:true,payType:1}]){
|
||||
const access=chapterAccess({...chapter,videoUrl:url});
|
||||
assert.equal(access.allowed,true);assert.equal(access.gate,'');assert.equal(access.source,url);assert.equal(initialPlayMode(access,url),'main');
|
||||
}
|
||||
for(const chapter of [null,{},{canWatch:true},{videoUrl:''},{videoUrl:'javascript:alert(1)'}]){
|
||||
const access=chapterAccess(chapter);assert.equal(access.allowed,false);assert.equal(access.gate,'unavailable');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',()=>{
|
||||
@@ -274,7 +268,7 @@ test('switching routes during a native-to-MSE fallback cannot install an outdate
|
||||
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}={}){
|
||||
function playerFixture({allowed=false,preview=true,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');
|
||||
@@ -285,8 +279,7 @@ function playerFixture({allowed=false,preview=true,unlock=async()=>({}),commentD
|
||||
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);
|
||||
for(const key of ['.unlock-close','.unlock-pay','#episode-lock-balance','.unlock-cost','#episode-lock-error','#episode-lock-description','.unlock-recharge'])node(lock,key);
|
||||
for(const key of ['.play-episode-pages','.play-episode-grid'])node(page,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;
|
||||
@@ -294,15 +287,15 @@ function playerFixture({allowed=false,preview=true,unlock=async()=>({}),commentD
|
||||
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 chapter={id:90042,playletId:23456,currentEpisode:2,canWatch:allowed,needUnlock:!allowed,payType:1,costCoins:12,balance:90,...(allowed?{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;
|
||||
if(path==='playlet/guess-like/list')return {list:[{id:31,title:'推荐短剧 <b>',totalEpisode:8,desc:'推荐简介',tagNames:['都市']}]};
|
||||
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++;}};
|
||||
},post:async(path,body)=>{posts.push({path,body});return {};},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;},
|
||||
@@ -360,88 +353,40 @@ test('episode engagements update chapter counts and keep the numeric chapter wri
|
||||
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('>9收藏</span>'));
|
||||
assert.ok(f.page.innerHTML.includes('>9收藏</dd>'));
|
||||
f.click(controls.collect.button);await flush();
|
||||
assert.deepEqual(f.posts.at(-1),{path:'playlet/chapter/collect/set',body:{chapterId:90042,isCollect:true}});
|
||||
assert.equal(controls.collect.stat.textContent,'10收藏');assert.equal(f.chapter.countCollect,10);
|
||||
}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);
|
||||
submit();resolve({});await flush();assert.equal(input.value,'');assert.equal(button.textContent,'发表评论');assert.equal(button.disabled,false);assert.equal(error.textContent,'');
|
||||
}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.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;
|
||||
test('slug playback links survive client episode rendering and next controls, and recommendations replace comments',async()=>{
|
||||
const f=playerFixture({allowed:true,detail:{slug:'city-story',tagIds:[9],tagSlugs:['urban'],tagNames:['都市']}});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/"'));
|
||||
assert.ok(f.page.querySelector('.play-episode-grid').innerHTML.includes('title="《测试剧集》第2集"'));
|
||||
assert.ok(f.page.innerHTML.includes('<h2 id="hg-recommend-title">推荐视频</h2>'));assert.ok(f.page.innerHTML.includes('<article class="hg-vitem">'));
|
||||
assert.ok(f.page.innerHTML.includes('推荐短剧 <b>'));assert.ok(f.page.innerHTML.includes('href="/detail/31/"'));
|
||||
assert.ok(!f.page.innerHTML.includes('play-comment'));assert.ok(!f.getCalls.some(call=>call.path==='comment/list'));
|
||||
assert.deepEqual(f.getCalls.find(call=>call.path==='playlet/guess-like/list').query,{playletId:23456,pageNum:1,pageSize:12});
|
||||
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()=>{
|
||||
test('chapters without a source offer the free preview; main shows a not-yet-available notice and never reports',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 金币');
|
||||
assert.equal(f.video.src,'');assert.equal(f.box.dataset.playMode,'main');assert.equal(f.status.textContent,'本集视频暂未开放,请稍后再试');
|
||||
await f.controlActions.toggle();await f.controlActions.retry();assert.equal(f.lock.open,false);assert.equal(f.posts.length,0);assert.equal(f.video.plays,1);
|
||||
assert.ok(!f.box.querySelector('.play-screen-overlay').innerHTML.includes('data-show-unlock'));
|
||||
}finally{dispose?.();f.dispose();}
|
||||
});
|
||||
|
||||
@@ -460,18 +405,7 @@ test('allowed chapters play main despite stale lock flags; only main playback re
|
||||
}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()=>{
|
||||
test('actual player controls delegate play and retry to the media engine',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>'});
|
||||
@@ -483,182 +417,61 @@ test('actual player controls delegate play and retry to the media engine and all
|
||||
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>',text:'父评论',children:[{id:2,parentsId:1,text:'回复'}]}],totalCount:2}});let dispose;
|
||||
test('detail overview keeps action groups, actual chapter identifiers and recommendations without comments',async()=>{
|
||||
const f=playerFixture({detail:{description:'接口简介',isNew:true,cover:'https://cdn.example/poster.webp'}});let dispose;
|
||||
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('href="/play/23456/90001/"'));assert.ok(f.page.innerHTML.includes('class="hg-detail-actions"'));
|
||||
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'));
|
||||
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(f.page.innerHTML.includes('class="hg-recommend"'));assert.ok(!f.page.innerHTML.includes('play-comment'));
|
||||
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('episode segments keep their anchor across desktop/mobile layout changes and show original lock and playing markers',async()=>{
|
||||
test('episode segments keep their anchor across desktop/mobile layout changes, link every episode and show no paywall 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"'));
|
||||
for(const text of ['coin-soft-star','episode-lock-corner','episode-vip-mark','play-episode-locked','付费'])assert.ok(!grid.innerHTML.includes(text),text);
|
||||
const tab=new Element('BUTTON');tab.dataset.episodePage='2';f.click(tab);await flush();assert.ok(grid.innerHTML.includes('href="/play/23456/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"'));
|
||||
assert.ok(grid.innerHTML.includes('href="/play/23456/90091/"'));assert.ok(!grid.innerHTML.includes('href="/play/23456/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"'));
|
||||
assert.ok(grid.innerHTML.includes('href="/play/23456/90091/"'));assert.ok(grid.innerHTML.includes('href="/play/23456/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}])assert.equal(chapterListGate(chapter),'');
|
||||
for(const chapter of [{needUnlock:true},{payType:1},{isUnlocked:true,needUnlock:true}])assert.equal(chapterListGate(chapter),'coins');
|
||||
for(const chapter of [{needVip:true},{payType:2},{needVip:true,isUnlocked:true}])assert.equal(chapterListGate(chapter),'vip');
|
||||
});
|
||||
|
||||
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('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',()=>{
|
||||
test('server player data excludes credentials while keeping every supplied episode source, recommendations 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:'回复'}]}],totalCount:12});
|
||||
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);
|
||||
const json=JSON.stringify(initial);
|
||||
for(const value of ['detail-secret','chapter-secret','sdk-secret','ad-secret','related-secret','comment-secret','secret-locked.mp4'])assert.ok(!json.includes(value),value);
|
||||
for(const value of ['detail-secret','chapter-secret','sdk-secret','ad-secret','related-secret'])assert.ok(!json.includes(value),value);
|
||||
assert.equal(initial.chapter.videoUrl,'https://cdn.example/secret-locked.mp4','payment flags no longer hide a supplied source');
|
||||
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.detail.advitise.nineGridList[0].images,'https://cdn.example/ad.webp');
|
||||
assert.equal(initial.comments,undefined);assert.equal(initial.detail.relatedRecommendData[0].title,'相关剧集');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()=>{
|
||||
test('player attaches to server markup without repeating initial reads',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}};
|
||||
const initial={detail:{id:23456,title:'首屏详情',relatedRecommendData:[{id:31,title:'首屏推荐'}]},chapters:[{id:90001,currentEpisode:1,canWatch:true},locked],chapter:null};
|
||||
try{
|
||||
f.page.innerHTML='<article>首屏详情</article>';f.page.querySelector('.play-comment-list').innerHTML='<p>首屏评论</p>';
|
||||
f.page.innerHTML='<article>首屏详情</article>';
|
||||
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);
|
||||
assert.equal(f.getCalls.length,0);assert.equal(f.page.innerHTML,'<article>首屏详情</article>');
|
||||
assert.equal(f.posts.length,0);
|
||||
}finally{dispose?.();f.dispose();}
|
||||
});
|
||||
|
||||
@@ -45,9 +45,9 @@ test('cache bounds and endpoint policies keep private data and indefinite growth
|
||||
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',()=>{
|
||||
test('shared browser snapshot contains visible configuration and footer fields without 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:{content:'真实页脚'},contactList:[{name:'服务时间',value:'每天',token:'private-contact-token'}]}});
|
||||
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'])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.content,'真实页脚');
|
||||
assert.ok(!('trackerConfigured' in data));assert.equal(data.config.config.officialAppAddr,'https://app.test/download');assert.equal(data.footer.footerInfo.brand.content,'真实页脚');
|
||||
assert.doesNotThrow(()=>publicSiteData({config:null,advitise:null},{footerInfo:null}));
|
||||
});
|
||||
|
||||
+21
-54
@@ -17,7 +17,7 @@ const renderCode=ts.transpileModule(await readFile(new URL('../src/lib/client/re
|
||||
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 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.replace('"./site"',JSON.stringify('data:text/javascript;base64,'+Buffer.from(ts.transpileModule(await readFile(new URL('../src/lib/site.ts',import.meta.url),'utf8'),{compilerOptions:{module:ts.ModuleKind.ESNext,target:ts.ScriptTarget.ES2022}}).outputText).toString('base64')));
|
||||
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);
|
||||
@@ -51,8 +51,6 @@ function fixture(load=async()=>({list:[{id:1}],totalCount:72,name:'接口通用
|
||||
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 {};
|
||||
@@ -64,7 +62,7 @@ function fixture(load=async()=>({list:[{id:1}],totalCount:72,name:'接口通用
|
||||
}
|
||||
|
||||
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/']){
|
||||
for(const pathname of ['/','/shorts/','/detail/7/','/play/7/8/','/info/about-us/']){
|
||||
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);
|
||||
@@ -74,38 +72,13 @@ test('all native pages bind every square ad row and clean up old loops before re
|
||||
}
|
||||
});
|
||||
|
||||
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('about and DMCA show only the official TG contact, ignoring configured channels, footer contacts and email',()=>{
|
||||
const about=infoPages.getInfoPage('about-us').html;
|
||||
assert.ok(about.includes('官方TG:<a href="https://t.me/jbd125"'));assert.ok(about.includes('>@jbd125</a>'));
|
||||
for(const text of ['商务合作','用户反馈','服务时间','mailto:','gmail'])assert.ok(!about.includes(text),text);
|
||||
const dmca=infoPages.getInfoPage('dmca').html;
|
||||
assert.ok(dmca.includes('href="https://t.me/jbd125"'));assert.ok(!dmca.includes('mailto:'));assert.ok(!dmca.includes('gmail'));
|
||||
assert.equal(infoPages.infoContactSection(),infoPages.infoContactSection());
|
||||
});
|
||||
|
||||
test('ranking data and native page links derive from the server route and preserve global positions',()=>{
|
||||
@@ -132,19 +105,13 @@ test('initial server channel and search content is retained without page request
|
||||
}
|
||||
});
|
||||
|
||||
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('server player data binds once and never refetches the initial content',async()=>{
|
||||
const player={detail:{id:23456,title:'剧集'},chapters:[],chapter:null};
|
||||
const path='/play/23456/90001/',f=fixture(()=>assert.fail('no page fetch'),{page:{path,panel:'player',title:'剧集',player}});
|
||||
f.location.pathname=path;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,undefined);assert.equal(f.calls[0].commentSort,undefined);
|
||||
await f.app.route();assert.equal(f.calls.length,1);assert.equal(f.reloads.length,1);
|
||||
});
|
||||
|
||||
test('missing or mismatched snapshots leave server error HTML visible without a client retry',async()=>{
|
||||
@@ -153,21 +120,21 @@ test('missing or mismatched snapshots leave server error HTML visible without a
|
||||
});
|
||||
|
||||
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 path='/topics/7/',environment=seo.resolveSeoEnvironment({config:{foreverUrlAddr:'https://huangguocg.com'}}),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,'服务端专题 - 91短剧');assert.equal(f.document.value('link[rel="canonical"]'),'https://canonical.drama.test/topics/7/');
|
||||
assert.equal(f.document.title,'服务端专题 - 黄果吃瓜');assert.equal(f.document.value('link[rel="canonical"]'),'https://huangguocg.com/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 shared={config:{config:{officialAppAddr:'https://app.test/download'}},footer:{footerInfo:{contactList:[{name:'服务时间',value:'每天'}]}}};
|
||||
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()=>{
|
||||
test('public back/forward documents keep working without reload; interactive player 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);
|
||||
const privatePage=fixture();privatePage.location.pathname='/play/7/8/';privatePage.app.hidePage(true);assert.equal(privatePage.app.signal.aborted,true);
|
||||
privatePage.app.restorePage();assert.equal(privatePage.reloads.length,1);
|
||||
});
|
||||
|
||||
@@ -15,9 +15,9 @@ const {ads,promotionRow,renderHome,activeBanners,hero,card,topicCard,imageAttrib
|
||||
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('topic cards include escaped metadata for content exposure and click tracking',()=>{
|
||||
test('topic cards escape their names and carry no tracking attributes',()=>{
|
||||
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="都市 <甜宠>"/);
|
||||
assert.ok(html.includes('<h3>都市 <甜宠></h3>')||html.includes('都市 <甜宠>'));assert.doesNotMatch(html,/data-track|data-item-/);
|
||||
});
|
||||
|
||||
test('API images use data sources and local placeholders until decrypted, including video posters',()=>{
|
||||
@@ -43,8 +43,8 @@ test('shared square ad rows keep real ad data and links and omit empty placement
|
||||
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.equal((html.match(/data-ad href=/g)||[]).length,2);assert.doesNotMatch(html,/data-track|data-ad-/);
|
||||
assert.ok(html.includes('href="https://example.com/landing"'));
|
||||
assert.ok(html.includes('width="132" height="132"'));assert.ok(html.includes('alt="有效广告"'));
|
||||
for(const value of [null,{},[],[null]])assert.equal(promotionRow(value),'');
|
||||
});
|
||||
@@ -77,7 +77,7 @@ test('dynamic home sections share a grid without inheriting the prototype editor
|
||||
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.match(/class="drama-episodes"/g)||[]).length,1);
|
||||
assert.equal((html.match(/class="hg-card-eps"/g)||[]).length,1);
|
||||
assert.ok(html.indexOf('必看好剧')<html.indexOf('热播榜'));
|
||||
});
|
||||
|
||||
|
||||
@@ -11,11 +11,11 @@ async function moduleUrl(file,transform=code=>code){
|
||||
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 startupUrl='data:text/javascript,export const calls=[];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};
|
||||
const imports={'react/jsx-runtime':import.meta.resolve('react/jsx-runtime'),'next/script':scriptUrl,'@/lib/client/application':startupUrl};
|
||||
for(const [path,url] of Object.entries(imports))for(const quote of ["'",'"'])code=code.replace(quote+path+quote,JSON.stringify(url));
|
||||
return code;
|
||||
});
|
||||
@@ -63,7 +63,7 @@ test('home, player, navigation and wallet hydration starts normally without logg
|
||||
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(startup.calls.length,start+1);assert.equal(startup.calls[start][0],home);assert.equal(startup.calls[start][3],page);
|
||||
}
|
||||
assert.equal(wallet.user.token,'session-secret');
|
||||
});
|
||||
|
||||
@@ -32,11 +32,8 @@ test('clean paths select the matching page, IDs, and navigation section',()=>{
|
||||
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/','']]){
|
||||
for(const path of ['/info/deposit/','/info/deposit/vip/','/info/user-center/','/info/settings/','/info/feedback/','/member/center/','/member/history/','/member/likes/page/2/'])assert.equal(parseSitePath(path),null,path);
|
||||
for(const [path,section] of [['/','home'],['/shorts/','shorts'],['/topics/31/','topics'],['/detail/7/','shorts'],['/info/about-us/','']]){
|
||||
assert.equal(navigationSection(parseSitePath(path)),section,path);
|
||||
}
|
||||
});
|
||||
@@ -86,9 +83,10 @@ test('hash page links are not converted or treated as page destinations',()=>{
|
||||
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('https://drama.example/rank/new/',base),'/rank/new/');
|
||||
assert.equal(internalPagePath('https://drama.example/member/history/',base),null);
|
||||
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/']){
|
||||
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/history/','/detail/0/']){
|
||||
assert.equal(internalPagePath(href,base),null,href);
|
||||
}
|
||||
});
|
||||
@@ -99,8 +97,8 @@ test('page navigation requests a complete server document while leaving hashes,
|
||||
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']);
|
||||
for(const path of ['/play/23456/90001/','/info/about-us/','/rank/new/','/shorts/page/2/?sort=2'])navigateTo(path);
|
||||
assert.deepEqual(calls,['/play/23456/90001/','/info/about-us/','/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;}
|
||||
});
|
||||
|
||||
@@ -103,10 +103,10 @@ test('search suggestions open the returned drama while hot keywords open search
|
||||
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',()=>{
|
||||
test('dropdown recommendations keep compact cards, real detail links 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('href="/detail/story-0/"'));assert.ok(!html.includes('data-track-item'));
|
||||
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('12集'));
|
||||
|
||||
+31
-35
@@ -2,7 +2,7 @@ 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 config={seoInfo:{title:'全站标题',description:'全站描述',keywords:['全站词'],webSiteName:'91剧场'},config:{foreverUrlAddr:'https://huangguocg.com/entry/?channel=1'}};
|
||||
const environment=seo.seoEnvironment(config);
|
||||
const page=(path,content={},search='')=>seo.buildPageHead(path,search,environment,content);
|
||||
|
||||
@@ -14,13 +14,13 @@ test('path-based search metadata retains keywords and page numbers without becom
|
||||
assert.equal(head.canonical,undefined);assert.equal(head.jsonLd,undefined);
|
||||
});
|
||||
|
||||
test('normalizes legacy/string SEO values and uses the permanent site origin',()=>{
|
||||
test('normalizes legacy/string SEO values and always uses the site domain as 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');
|
||||
assert.equal(environment.origin,'https://huangguocg.com');
|
||||
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,'');
|
||||
assert.equal(seo.seoEnvironment({config:{foreverUrlAddr:'https://example.com'},newAddressInfo:{foreverUrlAddr:'drama.example.org/entry'}}).origin,'https://huangguocg.com');
|
||||
assert.equal(seo.seoEnvironment({}).origin,'https://huangguocg.com');
|
||||
});
|
||||
|
||||
test('home uses global SEO, independent route fields survive, and copied homepage fields fall back to page-specific content',()=>{
|
||||
@@ -49,29 +49,29 @@ test('navigation TDK has priority on category routes and does not replace drama
|
||||
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',()=>{
|
||||
test('configuration failures retain the last valid SEO identity while the origin stays the site domain',()=>{
|
||||
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);
|
||||
for(const broken of [{},{config:{foreverUrlAddr:'javascript:bad'}},{config:{foreverUrlAddr:'http://localhost:3000'}}]){
|
||||
const resolved=seo.resolveSeoEnvironment(broken,undefined,previous);
|
||||
assert.equal(resolved.origin,'https://huangguocg.com');assert.deepEqual(resolved.site,previous.site);assert.deepEqual(resolved.categories,previous.categories);
|
||||
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.canonical,'https://huangguocg.com/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,[]);
|
||||
assert.equal(updated.origin,'https://huangguocg.com');assert.equal(updated.site.siteName,'新站名');assert.deepEqual(updated.categories,[]);
|
||||
});
|
||||
|
||||
test('cold starts never guess a canonical and the browser preserves the server identity',()=>{
|
||||
test('cold starts use the default site domain and the browser preserves the server identity',()=>{
|
||||
const cold=seo.resolveSeoEnvironment({},[]);
|
||||
assert.equal(cold.origin,'');assert.equal(cold.site.siteName,'91短剧');
|
||||
assert.equal(cold.origin,'https://huangguocg.com');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(head.canonical,'https://huangguocg.com/shorts/');
|
||||
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);
|
||||
assert.equal(seo.clientSeoEnvironment(cold,config),cold);
|
||||
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);
|
||||
@@ -79,12 +79,12 @@ test('cold starts never guess a canonical and the browser preserves the server i
|
||||
|
||||
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');
|
||||
assert.equal(first.canonical,'https://huangguocg.com/shorts/');assert.equal(first.previous,undefined);assert.equal(first.next,'https://huangguocg.com/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.equal(middle.canonical,'https://huangguocg.com/shorts/page/2/');assert.equal(middle.previous,'https://huangguocg.com/shorts/');assert.equal(middle.next,'https://huangguocg.com/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 last=page('/shorts/page/3/',{totalCount:72,pageSize:24});assert.equal(last.next,undefined);assert.equal(last.previous,'https://huangguocg.com/shorts/page/2/');
|
||||
assert.equal(page('/shorts/page/1/',{totalCount:72}).canonical,'https://huangguocg.com/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);
|
||||
@@ -93,7 +93,7 @@ test('canonical is query-free, every pagination page identifies itself, and firs
|
||||
|
||||
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);
|
||||
for(const path of ['/member/center/','/member/likes/','/info/deposit/','/info/settings/']){const head=page(path);assert.equal(head.robots,'noindex,follow',path);assert.equal(head.canonical,undefined,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);
|
||||
@@ -105,7 +105,7 @@ test('random shorts metadata matches the selected feed and remains excluded from
|
||||
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',()=>{
|
||||
test('client navigation updates SSR head once and clears old canonical/OG URL/pagination after tool or missing 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);
|
||||
@@ -114,10 +114,10 @@ test('client navigation updates SSR head once and clears old canonical/OG URL/pa
|
||||
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);
|
||||
const toolPage=page('/home-road/');clientSeo.applyPageHead(toolPage,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);
|
||||
assert.equal(doc.value('meta[name="robots"]'),'noindex,follow');assert.equal(doc.value('meta[name="googlebot"]'),'noindex,follow');
|
||||
assert.equal(doc.value('meta[name="twitter:title"]'),toolPage.title);
|
||||
});
|
||||
|
||||
|
||||
@@ -141,7 +141,7 @@ test('topic aliases and numeric ID variants share a primary canonical without ch
|
||||
assert.equal(page('/search/007/').canonical,undefined);
|
||||
});
|
||||
|
||||
test('empty public lists and comment pagination are noindex while preserving successful and error statuses',()=>{
|
||||
test('empty public lists are noindex, detail and play pages are indexable and have no paginated variants',()=>{
|
||||
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);
|
||||
@@ -152,11 +152,8 @@ test('empty public lists and comment pagination are noindex while preserving suc
|
||||
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);
|
||||
assert.ok(page(path,data).robots.startsWith('index,'),path);
|
||||
assert.equal(seo.pageResponseStatus(path+'page/2/',data),404,path);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -174,17 +171,16 @@ test('more snapshots need visible collection information and actual items before
|
||||
assert.deepEqual(topics.items,[{path:'/topics/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',()=>{
|
||||
test('episode indexing requires an actual source and episode identity, not metadata or a shared preview, and ignores payment flags',()=>{
|
||||
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}]){
|
||||
for(const patch of [{videoUrl:''},{videoUrl:'javascript:bad'},{currentEpisode:0},{currentEpisode:undefined}]){
|
||||
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);
|
||||
for(const patch of [{},{payType:1,canWatch:true},{payType:2,canWatch:true},{canWatch:false,needUnlock:true},{canWatch:false,needVip: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);
|
||||
clientSeo.applyPageHead(page('/play/9/90/',seo.dramaSeoContent(data,{...chapter,videoUrl:''})),doc);
|
||||
assert.equal(doc.value('meta[name="robots"]'),'noindex,follow');assert.equal(doc.value('link[rel="canonical"]'),undefined);
|
||||
});
|
||||
|
||||
@@ -18,7 +18,7 @@ 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'}});
|
||||
const environment=seo.seoEnvironment({config:{foreverUrlAddr:'https://huangguocg.com'}});
|
||||
|
||||
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']){
|
||||
@@ -26,6 +26,7 @@ test('slug-first paths encode one segment once, preserve ID fallback and accept
|
||||
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);
|
||||
if(kind==='detail'){assert.equal(routes.parseSitePath(path+'page/2/'),null);continue;}
|
||||
assert.equal(routes.paginationPath(path,2),path+'page/2/');
|
||||
assert.equal(routes.parseSitePath(path+'page/2/').page,2);
|
||||
}
|
||||
@@ -45,13 +46,13 @@ test('Chinese upstream slugs use IDs in every generated link and SEO URL without
|
||||
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.equal(head.canonical,'https://huangguocg.com/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/');
|
||||
assert.equal(seo.buildPageHead('/detail/都市爱情/','',environment,seo.dramaSeoContent(item)).canonical,'https://huangguocg.com/detail/9/');
|
||||
});
|
||||
|
||||
test('cards, carousel, topics, more lists and suggestions prefer slug while explicit moreUrl remains authoritative',()=>{
|
||||
@@ -96,15 +97,15 @@ test('canonical, pagination, series/episode schema and homepage ItemList use the
|
||||
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/');
|
||||
assert.equal(head.canonical,'https://huangguocg.com/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']==='TVSeries').url,'https://huangguocg.com/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/');
|
||||
assert.equal(graph.find(node=>node['@type']==='BreadcrumbList').itemListElement[2].item,'https://huangguocg.com/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/topics/city-collection/page/2/');assert.equal(head.previous,'https://drama.test/topics/city-collection/');
|
||||
assert.equal(head.canonical,'https://huangguocg.com/topics/city-collection/page/2/');assert.equal(head.previous,'https://huangguocg.com/topics/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/');
|
||||
assert.equal(home.jsonLd['@graph'].find(node=>node['@type']==='ItemList').itemListElement[0].url,'https://huangguocg.com/detail/city-story/');
|
||||
});
|
||||
|
||||
@@ -218,7 +218,7 @@ test('image loading supports cached images, errors, timeouts and cancellation',a
|
||||
}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()=>{
|
||||
test('dialog controls enforce age confirmation, advance ads on Escape, emit no tracking events, and use the reference exit',async()=>{
|
||||
class Dialog extends FakeElement {
|
||||
open=false;nodes=new Map();
|
||||
showModal(){this.open=true;}
|
||||
@@ -244,14 +244,14 @@ test('dialog controls enforce age confirmation, advance ads on Escape, track cli
|
||||
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);
|
||||
media.click();assert.equal(popup.open,true);
|
||||
const cancelAd=new Event('cancel',{cancelable:true});popup.dispatchEvent(cancelAd);await flush();
|
||||
assert.equal(cancelAd.defaultPrevented,true);assert.equal(media.href,'https://example.com/second');assert.equal(popup.open,true);
|
||||
close.click();assert.equal(popup.open,false);assert.equal(store.getItem('dialogAdDismissed'),'2|first|second');
|
||||
assert.deepEqual(tracks,['show','click','show']);
|
||||
assert.equal(grid.open,true);assert.equal(promotions.children.length,1);assert.equal(media.dataset.adPlacement,'startup-ad');
|
||||
assert.deepEqual(tracks,[]);
|
||||
assert.equal(grid.open,true);assert.equal(promotions.children.length,1);
|
||||
const link=promotions.children[0];assert.equal(link.href,'https://example.com/app');
|
||||
assert.equal(link.rel,'sponsored nofollow noopener noreferrer');assert.equal(link.dataset.adType,'icon');assert.equal(link.dataset.trackAd,'external');assert.equal(link.dataset.adPlacement,'startup-grid');
|
||||
assert.equal(link.rel,'sponsored nofollow noopener noreferrer');assert.deepEqual({...link.dataset},{});
|
||||
assert.equal(link.children[0].alt,'app');assert.equal(link.children[1].textContent,'app');
|
||||
const keyboard=new Event('keydown');keyboard.key='Tab';grid.dispatchEvent(keyboard);assert.equal(grid.classList.contains('startup-keyboard-focus'),true);
|
||||
grid.dispatchEvent(new Event('pointerdown'));assert.equal(grid.classList.contains('startup-keyboard-focus'),false);
|
||||
@@ -268,8 +268,7 @@ test('dialog controls enforce age confirmation, advance ads on Escape, track cli
|
||||
};
|
||||
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,'应用推广');
|
||||
const noLink=promotions.children[0];assert.equal(noLink.hasAttribute('href'),false);assert.deepEqual({...noLink.dataset},{});
|
||||
assert.equal(noLink.children[0].alt,'<script>inert text</script>');assert.equal(noLink.children[1].textContent,'<script>inert text</script>');
|
||||
assert.equal(promotions.children[1].href,'https://example.com/last');
|
||||
const inertClick=new Event('click');Object.defineProperty(inertClick,'target',{value:noLink.children[0]});promotions.dispatchEvent(inertClick);assert.equal(grid.open,true);
|
||||
|
||||
@@ -4,7 +4,7 @@ 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 origin='https://huangguocg.com';
|
||||
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)||[];
|
||||
@@ -21,7 +21,7 @@ test('home schema uses this site, actual assets, configured official profiles an
|
||||
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.deepEqual(organization.sameAs,['https://t.me/jbd125']);
|
||||
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);
|
||||
@@ -53,13 +53,13 @@ test('detail describes the series; episode watch pages connect TVEpisode and Vid
|
||||
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',()=>{
|
||||
test('missing media data and invalid dates do not produce fabricated VideoObject fields, and payment flags never hide a supplied source',()=>{
|
||||
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);
|
||||
assert.ok(locked.robots.startsWith('index,'));assert.equal(nodes(locked,'VideoObject').length,1);
|
||||
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);
|
||||
@@ -69,13 +69,12 @@ test('missing media data and invalid dates do not produce fabricated VideoObject
|
||||
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',()=>{
|
||||
test('all chapters are marked free 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(page.isAccessibleForFree,undefined);assert.equal(page.hasPart,undefined);
|
||||
assert.equal(video.isAccessibleForFree,true);assert.equal(episode.isAccessibleForFree,true);
|
||||
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');
|
||||
@@ -89,11 +88,11 @@ test('removed standalone video routes have no canonical or structured data',()=>
|
||||
});
|
||||
|
||||
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 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.replace('"./site"',JSON.stringify('data:text/javascript;base64,'+Buffer.from(ts.transpileModule(await readFile(new URL('../src/lib/site.ts',import.meta.url),'utf8'),{compilerOptions:{module:ts.ModuleKind.ESNext,target:ts.ScriptTarget.ES2022}}).outputText).toString('base64')));
|
||||
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);
|
||||
assert.equal(nodes(page,'FAQPage')[0].mainEntity.length,5);
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user